smartfile/ts/fs.ts

618 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as plugins from './plugins.js';
import * as interpreter from './interpreter.js';
import { SmartFile } from './classes.smartfile.js';
import * as memory from './memory.js';
import type { StreamFile } from './classes.streamfile.js';
/*===============================================================
============================ Checks =============================
===============================================================*/
/**
*
* @param filePath
* @returns {boolean}
*/
export const fileExistsSync = (filePath): boolean => {
let fileExistsBool: boolean = false;
try {
plugins.fsExtra.readFileSync(filePath);
fileExistsBool = true;
} catch (err) {
fileExistsBool = false;
}
return fileExistsBool;
};
/**
*
* @param filePath
* @returns {any}
*/
export const fileExists = async (filePath): Promise<boolean> => {
const done = plugins.smartpromise.defer<boolean>();
plugins.fs.access(filePath, 4, (err) => {
err ? done.resolve(false) : done.resolve(true);
});
return done.promise;
};
/**
* Checks if given path points to an existing directory
*/
export const isDirectory = (pathArg: string): boolean => {
try {
return plugins.fsExtra.statSync(pathArg).isDirectory();
} catch (err) {
return false;
}
};
/**
* Checks if given path points to an existing directory
*/
export const isDirectorySync = (pathArg: string): boolean => {
try {
return plugins.fsExtra.statSync(pathArg).isDirectory();
} catch (err) {
return false;
}
};
/**
* Checks if a given path points to an existing file
*/
export const isFile = (pathArg): boolean => {
return plugins.fsExtra.statSync(pathArg).isFile();
};
/*===============================================================
============================ FS ACTIONS =========================
===============================================================*/
/**
* copies a file from A to B on the local disk
*/
export const copy = async (fromArg: string, toArg: string): Promise<boolean> => {
const done = plugins.smartpromise.defer<boolean>();
plugins.fsExtra.copy(fromArg, toArg, {}, (err) => {
if (err) {
throw new Error(`Could not copy from ${fromArg} to ${toArg}: ${err}`);
}
done.resolve(true);
});
return done.promise;
};
/**
* copies a file SYNCHRONOUSLY from A to B on the local disk
*/
export const copySync = (fromArg: string, toArg: string): boolean => {
plugins.fsExtra.copySync(fromArg, toArg);
return true;
};
/**
* ensures that a directory is in place
*/
export const ensureDir = async (dirPathArg: string) => {
await plugins.fsExtra.ensureDir(dirPathArg);
};
/**
* ensures that a directory is in place
*/
export const ensureDirSync = (dirPathArg: string) => {
plugins.fsExtra.ensureDirSync(dirPathArg);
};
/**
* ensure an empty directory
* @executes ASYNC
*/
export const ensureEmptyDir = async (dirPathArg: string) => {
await plugins.fsExtra.ensureDir(dirPathArg);
await plugins.fsExtra.emptyDir(dirPathArg);
};
/**
* ensure an empty directory
* @executes SYNC
*/
export const ensureEmptyDirSync = (dirPathArg: string) => {
plugins.fsExtra.ensureDirSync(dirPathArg);
plugins.fsExtra.emptyDirSync(dirPathArg);
};
/**
* ensures that a file is on disk
* @param filePath the filePath to ensureDir
* @param the fileContent to place into a new file in case it doesn't exist yet
* @returns Promise<void>
* @exec ASYNC
*/
export const ensureFile = async (filePathArg, initFileStringArg): Promise<void> => {
ensureFileSync(filePathArg, initFileStringArg);
};
/**
* ensures that a file is on disk
* @param filePath the filePath to ensureDir
* @param the fileContent to place into a new file in case it doesn't exist yet
* @returns Promise<void>
* @exec SYNC
*/
export const ensureFileSync = (filePathArg: string, initFileStringArg: string): void => {
if (fileExistsSync(filePathArg)) {
return null;
} else {
memory.toFsSync(initFileStringArg, filePathArg);
}
};
/**
* removes a file or folder from local disk
*/
export const remove = async (pathArg: string): Promise<void> => {
await plugins.fsExtra.remove(pathArg);
};
/**
* removes a file SYNCHRONOUSLY from local disk
*/
export const removeSync = (pathArg: string): void => {
plugins.fsExtra.removeSync(pathArg);
};
/**
* removes an array of filePaths from disk
*/
export const removeMany = async (filePathArrayArg: string[]) => {
const promiseArray: Array<Promise<void>> = [];
for (const filePath of filePathArrayArg) {
promiseArray.push(remove(filePath));
}
await Promise.all(promiseArray);
};
/**
* like removeFilePathArray but SYNCHRONOUSLY
*/
export const removeManySync = (filePathArrayArg: string[]): void => {
for (const filePath of filePathArrayArg) {
removeSync(filePath);
}
};
/*===============================================================
============================ Write/Read =========================
===============================================================*/
/**
* reads a file content to an object
* good for JSON, YAML, TOML, etc.
* @param filePathArg
* @param fileTypeArg
* @returns {any}
*/
export const toObjectSync = (filePathArg, fileTypeArg?) => {
const fileString = plugins.fsExtra.readFileSync(filePathArg, 'utf8');
let fileType;
fileTypeArg ? (fileType = fileTypeArg) : (fileType = interpreter.filetype(filePathArg));
try {
return interpreter.objectFile(fileString, fileType);
} catch (err) {
err.message = `Failed to read file at ${filePathArg}` + err.message;
throw err;
}
};
/**
* reads a file content to a String
*/
export const toStringSync = (filePath: string): string => {
const encoding = plugins.smartmime.getEncodingForPathSync(filePath);
let fileString: string | Buffer = plugins.fsExtra.readFileSync(filePath, encoding);
if (Buffer.isBuffer(fileString)) {
fileString = fileString.toString('binary');
}
return fileString;
};
export const toBuffer = async (filePath: string): Promise<Buffer> => {
return plugins.fsExtra.readFile(filePath);
};
export const toBufferSync = (filePath: string): Buffer => {
return plugins.fsExtra.readFileSync(filePath);
};
/**
* Creates a Readable Stream from a file path.
* @param filePath The path to the file.
* @returns {fs.ReadStream}
*/
export const toReadStream = (filePath: string): plugins.fs.ReadStream => {
if (!fileExistsSync(filePath)) {
throw new Error(`File does not exist at path: ${filePath}`);
}
return plugins.fsExtra.createReadStream(filePath);
};
export const fileTreeToHash = async (dirPathArg: string, miniMatchFilter: string) => {
const fileTreeObject = await fileTreeToObject(dirPathArg, miniMatchFilter);
let combinedString = '';
for (const smartfile of fileTreeObject) {
combinedString += await smartfile.getHash();
}
const hash = await plugins.smarthash.sha256FromString(combinedString);
return hash;
};
/**
* creates a smartfile array from a directory
* @param dirPathArg the directory to start from
* @param miniMatchFilter a minimatch filter of what files to include
*/
export const fileTreeToObject = async (dirPathArg: string, miniMatchFilter: string) => {
// handle absolute miniMatchFilter
let dirPath: string;
if (plugins.path.isAbsolute(miniMatchFilter)) {
dirPath = '/';
} else {
dirPath = plugins.smartpath.transform.toAbsolute(dirPathArg) as string;
}
const fileTree = await listFileTree(dirPath, miniMatchFilter);
const smartfileArray: SmartFile[] = [];
for (const filePath of fileTree) {
const readPath = ((): string => {
if (!plugins.path.isAbsolute(filePath)) {
return plugins.path.join(dirPath, filePath);
} else {
return filePath;
}
})();
const fileBuffer = plugins.fs.readFileSync(readPath);
// push a read file as Smartfile
smartfileArray.push(
new SmartFile({
contentBuffer: fileBuffer,
base: dirPath,
path: filePath,
})
);
}
return smartfileArray;
};
/**
* lists Folders in a directory on local disk
* @returns Promise with an array that contains the folder names
*/
export const listFolders = async (pathArg: string, regexFilter?: RegExp): Promise<string[]> => {
return listFoldersSync(pathArg, regexFilter);
};
/**
* lists Folders SYNCHRONOUSLY in a directory on local disk
* @returns an array with the folder names as strings
*/
export const listFoldersSync = (pathArg: string, regexFilter?: RegExp): string[] => {
let folderArray = plugins.fsExtra.readdirSync(pathArg).filter((file) => {
return plugins.fsExtra.statSync(plugins.path.join(pathArg, file)).isDirectory();
});
if (regexFilter) {
folderArray = folderArray.filter((fileItem) => {
return regexFilter.test(fileItem);
});
}
return folderArray;
};
/**
* lists Files in a directory on local disk
* @returns Promise
*/
export const listFiles = async (pathArg: string, regexFilter?: RegExp): Promise<string[]> => {
return listFilesSync(pathArg, regexFilter);
};
/**
* lists Files SYNCHRONOUSLY in a directory on local disk
* @returns an array with the folder names as strings
*/
export const listFilesSync = (pathArg: string, regexFilter?: RegExp): string[] => {
let fileArray = plugins.fsExtra.readdirSync(pathArg).filter((file) => {
return plugins.fsExtra.statSync(plugins.path.join(pathArg, file)).isFile();
});
if (regexFilter) {
fileArray = fileArray.filter((fileItem) => {
return regexFilter.test(fileItem);
});
}
return fileArray;
};
/**
* lists all items (folders AND files) in a directory on local disk
* @returns Promise<string[]>
*/
export const listAllItems = async (pathArg: string, regexFilter?: RegExp): Promise<string[]> => {
return listAllItemsSync(pathArg, regexFilter);
};
/**
* lists all items (folders AND files) in a directory on local disk
* @returns an array with the folder names as strings
* @executes SYNC
*/
export const listAllItemsSync = (pathArg: string, regexFilter?: RegExp): string[] => {
let allItmesArray = plugins.fsExtra.readdirSync(pathArg).filter((file) => {
return plugins.fsExtra.statSync(plugins.path.join(pathArg, file)).isFile();
});
if (regexFilter) {
allItmesArray = allItmesArray.filter((fileItem) => {
return regexFilter.test(fileItem);
});
}
return allItmesArray;
};
/**
* lists a file tree using a miniMatch filter
* note: if the miniMatch Filter is an absolute path, the cwdArg will be omitted
* @returns Promise<string[]> string array with the absolute paths of all matching files
*/
export const listFileTree = async (
dirPathArg: string,
miniMatchFilter: string,
absolutePathsBool: boolean = false
): Promise<string[]> => {
// handle absolute miniMatchFilter
let dirPath: string;
if (plugins.path.isAbsolute(miniMatchFilter)) {
dirPath = '/';
} else {
dirPath = dirPathArg;
}
const options = {
cwd: dirPath,
nodir: true,
dot: true,
};
let fileList = await plugins.glob.glob(miniMatchFilter, options);
if (absolutePathsBool) {
fileList = fileList.map((filePath) => {
return plugins.path.resolve(plugins.path.join(dirPath, filePath));
});
}
return fileList;
};
/**
* Watches for file stability before resolving the promise.
* Ensures that the directory/file exists before setting up the watcher.
*
* **New behavior**: If the given path is a directory, this function will:
* 1. Wait for that directory to exist (creating a timeout if needed).
* 2. Watch the directory until at least one file appears.
* 3. Then wait for the first file in the directory to stabilize before resolving.
*
* @param fileOrDirPathArg The path of the file or directory to monitor.
* @param timeoutMs The maximum time to wait for the file to stabilize (in milliseconds). Default is 60 seconds.
* @returns A promise that resolves when the target is stable or rejects on timeout/error.
*/
export const waitForFileToBeReady = async (
fileOrDirPathArg: string,
timeoutMs: number = 60000
): Promise<void> => {
const startTime = Date.now();
/**
* Ensure that a path (file or directory) exists. If it doesn't yet exist,
* wait until it does (or time out).
* @param pathToCheck The file or directory path to check.
*/
const ensurePathExists = async (pathToCheck: string): Promise<void> => {
while (true) {
try {
await plugins.smartpromise.fromCallback((cb) =>
plugins.fs.access(pathToCheck, plugins.fs.constants.F_OK, cb)
);
return;
} catch (err: any) {
if (err.code !== 'ENOENT') {
throw err; // Propagate unexpected errors
}
if (Date.now() - startTime > timeoutMs) {
throw new Error(`Timeout waiting for path to exist: ${pathToCheck}`);
}
await plugins.smartdelay.delayFor(500);
}
}
};
/**
* Checks if a file (not directory) is stable by comparing sizes
* across successive checks.
* @param filePathArg The path of the file to check.
* @returns A promise that resolves once the file stops changing.
*/
const waitForSingleFileToBeStable = async (filePathArg: string): Promise<void> => {
let lastFileSize = -1;
let fileIsStable = false;
// We'll create a helper for repeated stats-checking logic
const checkFileStability = async () => {
try {
const stats = await plugins.smartpromise.fromCallback<plugins.fs.Stats>((cb) =>
plugins.fs.stat(filePathArg, cb)
);
if (stats.isDirectory()) {
// If it unexpectedly turns out to be a directory here, throw
throw new Error(`Expected a file but found a directory: ${filePathArg}`);
}
if (stats.size === lastFileSize) {
fileIsStable = true;
} else {
lastFileSize = stats.size;
fileIsStable = false;
}
} catch (err: any) {
// Ignore only if file not found
if (err.code !== 'ENOENT') {
throw err;
}
}
};
// Ensure file exists first
await ensurePathExists(filePathArg);
// Set up a watcher on the file itself
const fileWatcher = plugins.fs.watch(filePathArg, { persistent: true }, async () => {
if (!fileIsStable) {
await checkFileStability();
}
});
try {
// Poll until stable or timeout
while (!fileIsStable) {
if (Date.now() - startTime > timeoutMs) {
throw new Error(`Timeout waiting for file to stabilize: ${filePathArg}`);
}
await checkFileStability();
if (!fileIsStable) {
await plugins.smartdelay.delayFor(1000);
}
}
} finally {
fileWatcher.close();
}
};
/**
* Main logic: check if we have a directory or file at fileOrDirPathArg.
* If directory, wait for first file in the directory to appear and stabilize.
* If file, do the old single-file wait logic.
*/
const statsForGivenPath = await (async () => {
try {
await ensurePathExists(fileOrDirPathArg);
return await plugins.smartpromise.fromCallback<plugins.fs.Stats>((cb) =>
plugins.fs.stat(fileOrDirPathArg, cb)
);
} catch (err) {
// If there's an error (including timeout), just rethrow
throw err;
}
})();
if (!statsForGivenPath.isDirectory()) {
// It's a file just do the single-file stability wait
await waitForSingleFileToBeStable(fileOrDirPathArg);
return;
}
// Otherwise, it's a directory. Wait for the first file inside to appear and be stable
const dirPath = fileOrDirPathArg;
// Helper to find the first file in the directory if it exists
const getFirstFileInDirectory = async (): Promise<string | null> => {
const entries = await plugins.smartpromise.fromCallback<string[]>((cb) =>
plugins.fs.readdir(dirPath, cb)
);
// We only want actual files, not subdirectories
for (const entry of entries) {
const entryPath = plugins.path.join(dirPath, entry);
const entryStats = await plugins.smartpromise.fromCallback<plugins.fs.Stats>((cb) =>
plugins.fs.stat(entryPath, cb)
);
if (entryStats.isFile()) {
return entryPath;
}
}
return null;
};
// Wait for a file to appear in this directory
let firstFilePath = await getFirstFileInDirectory();
if (!firstFilePath) {
// Set up a watcher on the directory to see if a file appears
const directoryWatcher = plugins.fs.watch(dirPath, { persistent: true });
try {
// We'll poll for the existence of a file in that directory
while (!firstFilePath) {
if (Date.now() - startTime > timeoutMs) {
throw new Error(`Timeout waiting for a file to appear in directory: ${dirPath}`);
}
firstFilePath = await getFirstFileInDirectory();
if (!firstFilePath) {
await plugins.smartdelay.delayFor(1000);
}
}
} finally {
directoryWatcher.close();
}
}
// Now that we have a file path, wait for that file to stabilize
await waitForSingleFileToBeStable(firstFilePath);
};
/**
* writes string or Smartfile to disk.
* @param fileArg
* @param fileNameArg
* @param fileBaseArg
*/
export let toFs = async (
fileContentArg: string | Buffer | SmartFile | StreamFile,
filePathArg: string,
optionsArg: {
respectRelative?: boolean;
} = {}
) => {
const done = plugins.smartpromise.defer();
// check args
if (!fileContentArg || !filePathArg) {
throw new Error('expected valid arguments');
}
// prepare actual write action
let fileContent: string | Buffer;
let fileEncoding: 'utf8' | 'binary' = 'utf8';
let filePath: string = filePathArg;
// handle Smartfile
if (fileContentArg instanceof SmartFile) {
fileContent = fileContentArg.contentBuffer;
// handle options
if (optionsArg.respectRelative) {
filePath = plugins.path.join(filePath, fileContentArg.path);
}
} else if (Buffer.isBuffer(fileContentArg)) {
fileContent = fileContentArg;
fileEncoding = 'binary';
} else if (typeof fileContentArg === 'string') {
fileContent = fileContentArg;
} else {
throw new Error('fileContent is neither string nor Smartfile');
}
await ensureDir(plugins.path.parse(filePath).dir);
plugins.fsExtra.writeFile(filePath, fileContent, { encoding: fileEncoding }, done.resolve);
return await done.promise;
};
export const stat = async (filePathArg: string) => {
return plugins.fsPromises.stat(filePathArg);
};