romtool
Version:
Collection of tools for managing your PSX rom collection
429 lines (373 loc) • 12.5 kB
JavaScript
const c = require("chalk");
const Promise = require("bluebird");
const ora = require("ora");
const { prompt } = require("inquirer");
const commandExists = require("command-exists");
const { resolve, basename, dirname, join } = require("path");
const {
ensureDir,
move,
remove,
rename,
readFile,
outputFile
} = require("fs-extra");
const Zip = require("node-7z");
const { regex } = require("../find/findMultiTrack");
const { findCues, findSourceFiles } = require("../../utils/cues");
const { calcFolderSize, calcFileSize } = require("../../utils/filesystem");
const {
logFactory,
getFolderCount,
findFilesMatching,
flatten,
escapeRegExp,
matrixArray,
clock,
askConfirm,
getCLIName
} = require("../../utils/misc");
const mergeBin = require("./binMerge");
const ORIGINAL_FOLDER = `${getCLIName()}-original`;
const REGEX_MERGED_FILES = /.*\.merged\.(cue|bin)/i;
const REGEX_MOVED_FOLDER = new RegExp(escapeRegExp(ORIGINAL_FOLDER), "i");
const ACTION_ARCHIVE = "ARCHIVE";
const ACTION_CLEAN = "CLEAN";
const ACTION_MOVE = "MOVE";
let log;
let spinner;
let spinStop;
function spinnerFactory(theSpinner) {
return (text, symbol = c`{blue ℹ}`) =>
theSpinner.stopAndPersist({ text, symbol }) &&
theSpinner.start("please wait...");
}
function spinnerMessage(text, startMsg = "please wait...", symbol = "ℹ") {
spinner.stopAndPersist({ text, symbol });
spinner.start(startMsg);
}
let isVerbose = false;
async function handler({
path = "./",
recursive,
confirm,
clean,
exclude,
archive,
verbose,
workers = 3,
archiveWorkers = 1
}) {
isVerbose = verbose;
log = logFactory(verbose);
log.info("multi-track bin merger");
const startingPath = resolve(path);
const folderCount = await getFolderCount(startingPath);
const excludeReg = exclude
? new RegExp(escapeRegExp(exclude), "i")
: undefined;
log.info(`searching -> ${startingPath}`);
log.verbose(`checking ${folderCount} folders`);
log.verbose(
`using the search pattern -> ${regex}, excluding -> ${excludeReg || "none"}`
);
log.verbose(
`options: archive -> ${archive}, confirm -> ${confirm}, clean -> ${clean}`
);
recursive &&
log.verbose("recursive mode is enabled, it's turtles all the way down!");
try {
await commandExists("python");
log.verbose(c`i found {magenta python}!`);
} catch (error) {
log.info(
c`{red uh-oh}... {magenta python} was not found, install it and try again`
);
return process.exit(1);
}
let archivePath = path;
if (archive) {
try {
await commandExists("7z");
if (typeof archive === "string") {
archivePath = resolve(archive);
}
log.verbose(`archiving file to ${archivePath}`);
log.verbose("7zip executable exists on the path! :)");
if (archiveWorkers > 3) {
log.info(
c`{yellow WARNING:} running more than 3 7zip processes might melt your computer`
);
if (confirm && !await askConfirm("do it anyway?")) {
log.info(c`either remove the '--archiveWorkers' flag, or lower it`);
return process.exit(0);
}
}
} catch (err) {
log.info(
c`{red ERROR} archive mode is enabled but 7zip's '7z' command is not available!`
);
return process.exit(1);
}
}
if (clean) {
log.info(
c`{yellow WARNING:} Clean is enabled original files will be deleted!`
);
log.info(
c`Meaning the original files will be {red deleted}, this cannot be {red undone}!`
);
}
if (workers < 1) {
log.info(
"invalid number of workers, there needs to be at least 1, defaulting to 1"
);
}
log.info("finding all of the multi-track files");
spinner = ora("Please wait...").start();
spinStop = spinnerFactory(spinner);
const depthLimit = recursive ? 3 : undefined;
const cuesToUse = await findCues({
spinner,
startingPath,
depthLimit,
log,
confirm,
isVerbose,
spinnerMessage,
folderRegex: regex,
excludeFolderRegex: new RegExp(
`${REGEX_MERGED_FILES.source}|${REGEX_MOVED_FOLDER.source}`
),
findFilesExcludeRegex: REGEX_MOVED_FOLDER
});
spinStop(`alrighty, lets merge these ${cuesToUse.length} cues!`, ":)");
// for each file
const workerCount = workers < 1 ? 1 : workers;
const numWorkers =
workerCount > cuesToUse.length ? cuesToUse.length : workerCount;
spinStop(`merging [${cuesToUse.length}] cues [${numWorkers}] at a time`);
spinner.start(`please wait...`);
const mergeResults = await mergeCueFiles(cuesToUse, numWorkers);
const mergedCount = mergeResults.filter(x => x.success).length;
if (mergedCount === cuesToUse.length) {
spinner.succeed(c`successfully merged {green ${mergedCount}} cue files`);
} else if (mergedCount === 0) {
spinner.fail(c`{red none} of the cues we're able to be merged...`);
log.info(
"re-run the script using the '--verbose' flag to see why it failed"
);
process.exit(1);
} else if (mergedCount < cuesToUse.length) {
spinner.info(
c`only {yellow ${mergedCount} / ${cuesToUse.length}} files were merged`
);
}
const findSourceFileOptions = {
spinner,
folders: cuesToUse,
excludeReg: REGEX_MERGED_FILES,
onModifyFolder: item => dirname(item),
searchReg: cue =>
new RegExp(escapeRegExp(basename(cue, ".cue")) + /.*\.bin/.source, "i")
};
let actionToDo;
if (confirm && !archive && !clean) {
const choices = [
{ value: ACTION_MOVE, name: "Move them into a subfolder" },
{ value: ACTION_ARCHIVE, name: "Use 7zip to archive them" },
{
value: ACTION_CLEAN,
name: "Permanently delete them - CANNOT BE UNDONE"
}
];
const { action } = await prompt({
type: "list",
name: "action",
message: "What would you like to do with the original files?",
choices
});
actionToDo = action;
}
if (archive || actionToDo === ACTION_ARCHIVE) {
log.info(c`{magenta archiving} the original files`);
spinner.start("please wait...");
// Gather all the source files
const sourceFiles = await findSourceFiles(findSourceFileOptions);
const archiveResults = await archiveSourceFiles(
sourceFiles,
archivePath,
archiveWorkers
);
spinnerMessage(
c`archived {green ${archiveResults.filter(x => x.success).length}} games`
);
} else if (clean || actionToDo === ACTION_CLEAN) {
log.info(c`{red deleting} the original files`);
log.info(c`this action {red CANNOT} be undone`);
log.info(c`use the '{cyan --archive} flag to 7zip the originals`);
if (confirm) {
if (!await askConfirm("continue?")) return process.exit(0);
}
const sourceFiles = await findSourceFiles(findSourceFileOptions);
await deleteSourceFiles(sourceFiles);
} else {
// Move the originals to an './original' folder
spinnerMessage(c`moving {magenta original} files`);
const sourceFiles = await findSourceFiles(findSourceFileOptions);
await moveSourceFiles(sourceFiles);
}
// Rename the merged files back to their original filename
// TODO - this is broken.... finding files from every dir instead of ones just being used
spinnerMessage(c`cleaning up the {magenta filenames}`);
const merged = await findFilesMatching({
path: startingPath,
spinner,
regex: REGEX_MERGED_FILES
});
await Promise.map(merged, async file => {
spinner.text = `removing '.merged' from ${basename(file)}`;
if (/\.cue/i.test(file)) {
const contents = await readFile(file, "utf8");
await outputFile(file, contents.replace(".merged", ""));
}
return rename(file, file.replace(".merged", ""));
});
spinner.succeed(c`have a {magenta nice} day! :)`);
return true;
}
async function mergeCueFiles(cues, workers) {
const cueChunks = matrixArray(cues, workers);
const timer = clock();
spinner.start(`please wait...`);
const results = await Promise.all(
cueChunks.map(chunk =>
Promise.mapSeries(chunk, async cue => {
const name = basename(cue);
try {
const start = clock();
spinnerMessage(c`starting {green ${name}}...`);
isVerbose && spinnerMessage(c`path -> ${cue}`);
await mergeBin(cue);
spinnerMessage(c`finished {cyan ${name}} in ${clock(start) / 1000}s`);
return { cue, success: true };
} catch (error) {
spinnerMessage(
c`❌ failed to merge {red ${name}}... -> ERROR: \n${error}`
);
return { cue, success: false };
}
})
)
);
spinnerMessage(c`merging took {cyan ${clock(timer) / 1000}s}`);
return flatten(results);
}
function mergeDuplicateFolders(files) {
const merged = files.reduce(
(prev, curr) => ({
...prev,
[curr.folder]: [...(prev[curr.folder] || []), ...curr.files]
}),
{}
);
return Object.keys(merged).map(folder => ({ folder, files: merged[folder] }));
}
// files => [{ folder: basename of file, files: [bin1, bin2, ...] }]
async function archiveSourceFiles(files, destPath, workers) {
const merged = mergeDuplicateFolders(files);
const chunks = matrixArray(merged, workers);
const start = clock();
spinner.start(`please wait...`);
const results = await Promise.all(
chunks.map(chunk =>
Promise.mapSeries(chunk, async sourceSet => {
const folderName = basename(sourceSet.folder);
try {
const newPath = join(sourceSet.folder, folderName);
log.verbose(`new path => ${newPath}`);
await ensureDir(newPath);
spinner.text = `created folder ${folderName}`;
await Promise.all(
sourceSet.files.map(async file => {
await move(file, join(newPath, basename(file)));
spinner.text = `copied ${file}`;
})
);
const time = clock();
const zipPath = `${newPath}.7z`;
const oldSize = await calcFolderSize(newPath);
spinnerMessage(
c`zipping {magenta ${folderName}} -> {cyan ${oldSize}}`
);
await new Zip().add(zipPath, newPath);
spinner.text = `deleting ${newPath}`;
await remove(newPath);
const finalDestination = join(destPath, basename(zipPath));
spinner.text = `moving to ${finalDestination}`;
await move(zipPath, finalDestination);
const zipSize = await calcFileSize(finalDestination);
spinnerMessage(
c`finished {green ${finalDestination}} in ${clock(time) /
1000}s -> {cyan ${zipSize}}`
);
return { zip: finalDestination, name: folderName, success: true };
} catch (err) {
spinnerMessage(
c`{red ERROR} unable to archive ${folderName}\nERROR -> ${err}`
);
return { name: folderName, success: false };
}
})
)
);
spinnerMessage(c`archiving took {cyan ${clock(start) / 1000}}s`);
return flatten(results);
}
async function modifySourceFiles(files, action = () => {}) {
const toModify = flatten(
mergeDuplicateFolders(files).map(file => file.files)
);
return Promise.all(
toModify.map(async file => {
try {
await action(file);
} catch (error) {
spinnerMessage(c`failed to modify {red ${file}}`);
spinnerMessage(error);
return false;
}
})
);
}
async function moveSourceFiles(files) {
const start = clock();
const results = await modifySourceFiles(files, async file => {
const destination = join(dirname(file), ORIGINAL_FOLDER, basename(file));
const moveText = c`-> {cyan ${destination}}`;
spinner.text = moveText;
isVerbose && spinnerMessage(moveText);
await move(file, destination);
return true;
});
spinnerMessage(
c`moving {cyan ${results.length}} files took {cyan ${clock(start) / 1000}}s`
);
return results;
}
async function deleteSourceFiles(files) {
const start = clock();
const results = await modifySourceFiles(files, async file => {
const deleteText = c`-> {red ${basename(file)}}`;
spinner.text = deleteText;
isVerbose && spinnerMessage(deleteText);
await remove(file);
return true;
});
spinnerMessage(
c`deleting {cyan ${results.length}} files took {cyan ${clock(start) /
1000}}s`
);
return results;
}
module.exports = handler;