wakitsu
Version:
Hobby project for managing anime watch list on Kitsu through CLI
290 lines • 11.4 kB
JavaScript
import { readdir, unlink } from 'fs/promises';
import { parseFansubFilename, pathJoin, toReadableBytes, } from '../../utils.js';
import { CLI, CLIFlag } from '../cli.js';
import { stat } from 'fs/promises';
import { fitStringEnd } from '../../utils.js';
import { Printer } from '../../printer/printer.js';
export class Directory extends CLIFlag {
name = ['d', 'dir'];
type = 'multiArg';
helpAliases = [
...this.name,
'dir info',
'watch folder',
'watch directory',
'folder info',
'directory info',
'clean',
'clean files',
'delete files',
'clean watched dir',
'clean watch dir',
'clean watched directory',
'delete watched files',
'delete watch files',
];
shortHelpDisplay = 'Analyzes the "watched" folder and displays breakdown.';
getHelpLogs() {
return [
['h1', ['Directory']],
[
'p',
'This flag allows you to manipulate or view special information about, the ' +
';x;watched ;bk;directory. The ;x;watched ;bk;directory contains all your ' +
'recently watched anime files and should be cleaned out regularly.',
],
null,
];
}
getSyntaxHelpLogs() {
return [
['h2', ['Usage']],
['s', ['d', 'dir'], '<info|clean> ;bm;<all|old>'],
null,
['h2', ['Details']],
[
'd',
[
'info ;bm;all',
'Displays verbose detailed information about the ;m;watch ;x;folder.',
],
1,
],
null,
[
'd',
['clean ;bm;all', 'Deletes ;bm;all ;x;files in the ;bw;watched ;x;directory.'],
],
null,
[
'd',
[
'clean ;bm;old',
'Deletes all ;bm;old ;x;files in the ;bw;watched ;x;directory, leaving ' +
'only the ;bw;latest files ;bk;of each anime.',
],
],
null,
['h2', ['Examples']],
['e', ['d', 'info ;bm;all']],
['e', ['d', 'clean ;bm;old']],
['e', ['d', 'clean ;bm;all']],
['e', ['dir', 'info ;bm;all']],
['e', ['dir', 'clean ;bm;old']],
['e', ['dir', 'clean ;bm;all']],
];
}
async exec() {
const hasValidFlags = CLI.validateSingleArg({
args: ['info', 'clean'],
argHasArgs: true,
flag: this,
});
if (hasValidFlags) {
const [arg, modArg] = CLI.nonFlagArgs;
if (arg == 'clean') {
if (modArg != 'all' && modArg != 'old') {
Printer.printError(`The syntax for the ;bw;clean ;y;command is as follows:`, 'Invalid Clean Argument');
return this.printSyntax();
}
const hasConsented = await Printer.promptYesNo('This operation cannot be undone, are you sure');
if (!hasConsented) {
return Printer.printInfo('Cancelled by user input', 'Operation Aborted');
}
}
if (arg == 'info' && modArg == 'all') {
const watchPath = pathJoin(process.cwd(), 'watched');
const dirEntries = await readdir(watchPath, {
withFileTypes: true,
});
if (dirEntries.length == 0) {
return Printer.printInfo('Your ;x;watch ;g;directory is empty; no meaningful info available.', 'Empty Directory', 3);
}
displayFolderInfo(await serializeFileStats(dirEntries));
}
if (arg == 'clean' && modArg == 'old') {
return cleanOldFiles();
}
if (arg == 'clean' && modArg == 'all') {
return cleanAllFiles();
}
}
}
}
async function cleanOldFiles() {
const stopLoader = Printer.printLoader('Deleting Old Files');
const [deletedFileCount, freedBytes] = await deleteOldFiles();
stopLoader();
if (deletedFileCount == 0) {
return Printer.printWarning('No old files to clean out', 'Operation Aborted', 3);
}
Printer.printInfo([
`Removed ;bg;${deletedFileCount} ;g;Old Files`,
`Freed ;bg;${toReadableBytes(freedBytes)} ;g;of space`,
], 'Success', 3);
}
async function cleanAllFiles() {
const stopLoader = Printer.printLoader('Deleting ALL Files');
const [deletedFileCount, freedBytes] = await deleteAllFiles();
stopLoader();
if (deletedFileCount == 0) {
return Printer.printWarning('Watch directory is already empty', 'Operation Aborted', 3);
}
Printer.printInfo([
`Removed ;bg;${deletedFileCount} ;g;Files`,
`Freed ;bg;${toReadableBytes(freedBytes)} ;g;of space`,
], 'Operation Successful', 3);
}
function displayFolderInfo(fileStats) {
const { size, fileCount, avgFileSize, lastWatchedFile, lastWatchedFileDate, lastWatchedFileSize, oldestFile, oldestFileDate, oldestFileSize, largestFile, largestFileSize, smallestFile, smallestFileSize, } = fileStats;
const indent = 9;
Printer.print([
null,
['h2', ['Directory Details']],
[
'p',
`;c;${fitStringEnd('Location', 15)} ;x;: ;g;${pathJoin(process.cwd(), 'watched')}`,
],
['p', `;c;${fitStringEnd('Size', 15)} ;x;: ;y;${size}`],
['p', `;c;${fitStringEnd('File Count', 15)} ;x;: ;y;${fileCount}`],
['p', `;c;${fitStringEnd('Avg. File Size', 15)} ;x;: ;y;${avgFileSize}`],
null,
['h2', ['File Details']],
['p', `;c;${fitStringEnd('Newest', 8)} ;bk;: ;x;${lastWatchedFile}`],
['p', `: ;y;${lastWatchedFileDate.toLocaleString()}`, indent],
['p', `: ;y;${lastWatchedFileSize}`, indent],
null,
['p', `;c;${fitStringEnd('Oldest', 8)} ;bk;: ;x;${oldestFile}`],
['p', `: ;y;${oldestFileDate.toLocaleString()}`, indent],
['p', `: ;y;${oldestFileSize}`, indent],
null,
['p', `;c;${fitStringEnd('Largest', 8)} ;bk;: ;x;${largestFile}`],
['p', `: ;y;${largestFileSize}`, indent],
null,
['p', `;c;${fitStringEnd('Smallest', 8)} ;bk;: ;x;${smallestFile}`],
['p', `: ;y;${smallestFileSize}`, indent],
null,
]);
}
async function serializeFileStats(dirEntries) {
const fileStats = await Promise.all(loadFileStats(dirEntries));
const fileCount = fileStats.filter((f) => f[0] > 0).length;
const totalFileBytes = fileStats.reduce(toSumOfBytes, 0);
const lastWatchedFileStat = fileStats.reduce(toLatestFileStat);
const largestFileStat = fileStats.reduce(toLargestFileStat);
const smallestFileStat = fileStats.reduce(toSmallestFileStat);
const oldestFileStat = fileStats.reduce(toOldestFileStat);
const avgFileSize = toReadableBytes(totalFileBytes / fileCount);
return {
size: toReadableBytes(totalFileBytes),
lastWatchedFile: lastWatchedFileStat[3].title,
lastWatchedFileSize: toReadableBytes(lastWatchedFileStat[0]),
lastWatchedFileDate: new Date(lastWatchedFileStat[1]),
oldestFile: oldestFileStat[3].title,
oldestFileSize: toReadableBytes(oldestFileStat[0]),
oldestFileDate: new Date(oldestFileStat[1]),
fileCount,
avgFileSize,
largestFileSize: toReadableBytes(largestFileStat[0]),
largestFile: largestFileStat[3].title,
smallestFileSize: toReadableBytes(smallestFileStat[0]),
smallestFile: smallestFileStat[3].title,
};
}
function loadFileStats(dirEntries) {
const filePromises = [];
for (const dent of dirEntries) {
if (dent.isFile()) {
filePromises.push(getFileStats(dent));
}
}
return filePromises;
}
async function getFileStats(dirEnt) {
const stats = await stat(pathJoin(process.cwd(), 'watched', dirEnt.name));
const [error, data] = parseFansubFilename(dirEnt.name);
if (error) {
throw Error(error.parseError);
}
return [stats.size, stats.mtimeMs, dirEnt.name, data];
}
function toLatestFileStat(lastStat, currentStat) {
return currentStat[1] > lastStat[1] ? currentStat : lastStat;
}
function toLargestFileStat(lastStat, currentStat) {
return currentStat[0] > lastStat[0] ? currentStat : lastStat;
}
function toSmallestFileStat(lastStat, currentStat) {
return currentStat[0] < lastStat[0] ? currentStat : lastStat;
}
function toOldestFileStat(lastStat, currentStat) {
return currentStat[1] < lastStat[1] ? currentStat : lastStat;
}
function toSumOfBytes(bytes, stat) {
return bytes + stat[0];
}
async function deleteOldFiles() {
const watchDir = pathJoin(process.cwd(), 'watched');
const statPromises = [];
const fileList = await readdir(watchDir, { withFileTypes: true });
for (const file of fileList) {
statPromises.push(getOldFileStats(file));
}
const stats = await Promise.all(statPromises);
const latestFansubFiles = getLatestFansubFiles(stats);
const filesToDelete = stats.filter((s) => !latestFansubFiles.includes(s[0]));
if (!filesToDelete.length)
return [0, 0];
const deletedFileCount = (await Promise.all(filesToDelete.map((file) => unlink(pathJoin(watchDir, file[0]))))).length;
return [deletedFileCount, filesToDelete.reduce((pv, cv) => (pv += cv[2]), 0)];
}
async function deleteAllFiles() {
const fileList = await readdir(pathJoin(process.cwd(), 'watched'), {
withFileTypes: true,
});
if (!fileList.length) {
return [0, 0];
}
const statPromises = [];
for (const file of fileList) {
statPromises.push(getOldFileStats(file));
}
const stats = await Promise.all(statPromises);
const pendingDeletion = [];
for (const [fileName] of stats) {
pendingDeletion.push(unlink(pathJoin(process.cwd(), 'watched', fileName)));
}
await Promise.all(pendingDeletion);
return [pendingDeletion.length, stats.reduce((pv, cv) => (pv += cv[2]), 0)];
}
function getLatestFansubFiles(stats) {
const latestFiles = [];
while (stats.length) {
const similarFiles = stats
.filter(hasSimilarFiles(stats[0][0]))
.sort((a, b) => (a[1] > b[1] ? -1 : 1));
latestFiles.push(similarFiles[0][0]);
stats = stats.filter(notSimilarFiles(similarFiles[0][0]));
}
return latestFiles;
}
function hasSimilarFiles(v1, isEqual = true) {
return (v2) => {
const [file1Error, file1Data] = parseFansubFilename(v1);
const [file2Error, file2Data] = parseFansubFilename(v2[0]);
if (file1Error || file2Error) {
throw Error('Failed to parse files properly');
}
const isSimilar = `${file1Data.fansub} ${file1Data.title}` ==
`${file2Data.fansub} ${file2Data.title}`;
return isEqual ? isSimilar : !isSimilar;
};
}
function notSimilarFiles(v1) {
return hasSimilarFiles(v1, false);
}
async function getOldFileStats(dirEnt) {
const stats = await stat(pathJoin(process.cwd(), 'watched', dirEnt.name));
return [dirEnt.name, stats.ctimeMs, stats.size];
}
//# sourceMappingURL=flag-dir.js.map