find-remove
Version:
recursively finds files and/or directories by filter options from a start directory onwards and deletes these according to plenty of options you can configure. useful if you want to clean up stuff within a directory in your node.js app.
304 lines (248 loc) • 7.7 kB
text/typescript
import fs from "fs";
import path from "path";
import { rimrafSync } from "rimraf";
let now: number | undefined;
let testRun: boolean | undefined;
interface Options {
test?: boolean;
limit?: number;
totalRemoved?: number;
maxLevel?: number;
dir?: string | string[];
regex?: boolean;
prefix?: string;
ignore?: string | string[];
extensions?: string | string[];
files?: string | string[];
age?: { seconds?: number };
}
function isOlder(path: string, ageSeconds: number) {
if (!now) return false;
const stats = fs.statSync(path);
const mtime = stats.mtime.getTime();
const expirationTime = mtime + ageSeconds * 1000;
return now > expirationTime;
}
function getLimit(options: Options = {}) {
if (options.limit !== undefined) {
return options.limit;
}
return -1;
}
function getTotalRemoved(options: Options = {}) {
if (options.totalRemoved !== undefined) {
return options.totalRemoved;
}
return -2;
}
function isOverTheLimit(options: Options = {}) {
return getTotalRemoved(options) >= getLimit(options);
}
function getMaxLevel(options: Options = {}) {
if (options.maxLevel !== undefined) {
return options.maxLevel;
}
return -1;
}
function getAgeSeconds(options: Options = {}) {
if (!options.age) {
return null;
}
return options.age.seconds ?? null;
}
function doDeleteDirectory(
currentDir: string,
currentLevel: number,
options: Options = {},
) {
let doDelete = false;
const dir = options.dir;
if (dir) {
const ageSeconds = getAgeSeconds(options);
const basename = path.basename(currentDir);
if (Array.isArray(dir)) {
doDelete = dir.indexOf("*") !== -1 || dir.indexOf(basename) !== -1;
} else if (
(options.regex && basename.match(new RegExp(dir))) ||
basename === dir ||
dir === "*"
) {
doDelete = true;
}
if (doDelete && options.limit !== undefined) {
doDelete = !isOverTheLimit(options);
}
if (doDelete && options.maxLevel !== undefined && currentLevel > 0) {
doDelete = currentLevel <= getMaxLevel(options);
}
if (ageSeconds && doDelete) {
doDelete = isOlder(currentDir, ageSeconds);
}
}
return doDelete;
}
function doDeleteFile(currentFile: string, options: Options = {}) {
// by default it deletes nothing
let doDelete = false;
const extensions = options.extensions ? options.extensions : null;
const files = options.files ? options.files : null;
const prefix = options.prefix ? options.prefix : null;
const ignore = options.ignore ?? null;
// return the last portion of a path, the filename aka basename
const basename = path.basename(currentFile);
if (files) {
if (Array.isArray(files)) {
doDelete = files.indexOf("*.*") !== -1 || files.indexOf(basename) !== -1;
} else {
if ((options.regex && basename.match(new RegExp(files))) || files === "*.*") {
doDelete = true;
} else {
doDelete = basename === files;
}
}
}
if (!doDelete && extensions) {
const currentExt = path.extname(currentFile);
if (Array.isArray(extensions)) {
doDelete = extensions.indexOf(currentExt) !== -1;
} else {
doDelete = currentExt === extensions;
}
}
if (!doDelete && prefix) {
doDelete = basename.indexOf(prefix) === 0;
}
if (doDelete && options.limit !== undefined) {
doDelete = !isOverTheLimit(options);
}
if (doDelete && ignore) {
if (Array.isArray(ignore)) {
doDelete = !(ignore.indexOf(basename) !== -1);
} else {
doDelete = !(basename === ignore);
}
}
if (doDelete) {
const ageSeconds = getAgeSeconds(options);
if (ageSeconds) {
doDelete = isOlder(currentFile, ageSeconds);
}
}
return doDelete;
}
function hasStats(dir: string) {
try {
fs.lstatSync(dir);
return true;
} catch (err) {
return false;
}
}
/**
* FindRemoveSync(currentDir, options) takes any start directory and searches files from there for removal.
* the selection of files for removal depends on the given options. when no options are given, or only the maxLevel
* parameter is given, then everything is removed as if there were no filters.
*
* Beware: everything happens synchronously.
*
*
* @param {string} currentDir any directory to operate within. it will seek files and/or directories recursively from there.
* beware that it deletes the given currentDir when no options or only the maxLevel parameter are given.
* @param options json object with optional properties like extensions, files, ignore, maxLevel and age.seconds.
* @return {Object} json object of files and/or directories that were found and successfully removed.
* @api public
*/
const findRemoveSync = function (
currentDir: string,
options: Options = {},
currentLevel?: number,
) {
let removed: Record<string, boolean> = {};
if (isOverTheLimit(options)) {
// Return early in that case
return removed;
}
let deleteDirectory = false;
const dirExists = fs.existsSync(currentDir);
const dirHasStats = hasStats(currentDir);
if (dirExists && !dirHasStats) {
// Must be a broken symlink. Flag it for deletion. See:
// https://github.com/binarykitchen/find-remove/issues/42
deleteDirectory = true;
} else if (dirExists) {
const maxLevel = getMaxLevel(options);
if (options.limit !== undefined) {
options.totalRemoved =
options.totalRemoved !== undefined ? getTotalRemoved(options) : 0;
}
if (currentLevel === undefined) {
currentLevel = 0;
} else {
currentLevel++;
}
if (currentLevel < 1) {
now = new Date().getTime();
testRun = options.test;
} else {
// check directories before deleting files inside.
// this to maintain the original creation time,
// because linux modifies creation date of folders when files within have been deleted.
deleteDirectory = doDeleteDirectory(currentDir, currentLevel, options);
}
if (maxLevel === -1 || currentLevel < maxLevel) {
const filesInDir = fs.readdirSync(currentDir);
filesInDir.forEach(function (file) {
const currentFile = path.join(currentDir, file);
let skip = false;
let stat;
try {
// Add extra checks for invalid symlinks using lstatSync
stat = fs.lstatSync(currentFile);
} catch (exc) {
// ignore
skip = true;
}
if (skip) {
// ignore, do nothing
} else if (stat?.isDirectory()) {
// the recursive call
const result = findRemoveSync(currentFile, options, currentLevel);
// merge results
removed = { ...removed, ...result };
if (options.totalRemoved !== undefined) {
options.totalRemoved += Object.keys(result).length;
}
} else if (doDeleteFile(currentFile, options)) {
let unlinked;
if (!testRun) {
try {
fs.unlinkSync(currentFile);
unlinked = true;
} catch (exc) {
// ignore
}
} else {
unlinked = true;
}
if (unlinked) {
removed[currentFile] = true;
if (options.totalRemoved !== undefined) {
options.totalRemoved++;
}
}
}
});
}
}
if (deleteDirectory) {
if (!testRun) {
rimrafSync(currentDir);
}
if (options.totalRemoved === undefined) {
// for limit of files - we do not want to count the directories
removed[currentDir] = true;
}
}
return removed;
};
export default findRemoveSync;