src/types/options.ts
import 'reflect-metadata';
import os from 'node:os';
import path from 'node:path';
import async, { AsyncResultCallback } from 'async';
import { Expose, instanceToPlain, plainToInstance } from 'class-transformer';
import fg from 'fast-glob';
import { isNotJunk } from 'junk';
import micromatch from 'micromatch';
import moment from 'moment';
import LogLevel from '../console/logLevel.js';
import Defaults from '../globals/defaults.js';
import Temp from '../globals/temp.js';
import ArrayPoly from '../polyfill/arrayPoly.js';
import fsPoly, { FsWalkCallback } from '../polyfill/fsPoly.js';
import URLPoly from '../polyfill/urlPoly.js';
import Disk from './dats/disk.js';
import ROM from './dats/rom.js';
import ExpectedError from './expectedError.js';
import File from './files/file.js';
import { ChecksumBitmask } from './files/fileChecksums.js';
export enum InputChecksumArchivesMode {
// Never calculate the checksum of archive files
NEVER = 1,
// Calculate the checksum of archive files if DATs reference archives
AUTO = 2,
// Always calculate the checksum of archive files
ALWAYS = 3,
}
export enum MergeMode {
// Clones contain all parent ROMs, all games contain BIOS & device ROMs
FULLNONMERGED = 1,
// Clones contain all parent ROMs, BIOS & device ROMsets are separate
NONMERGED,
// Clones exclude all parent ROMs, BIOS & device ROMsets are separate
SPLIT,
// Clones are merged into parent, BIOS & device ROMsets are separate
MERGED,
}
export enum GameSubdirMode {
// Never add the Game name as a subdirectory
NEVER = 1,
// Add the Game name as a subdirectory if it has multiple output files
MULTIPLE,
// Always add the Game name as a subdirectory
ALWAYS,
}
export enum FixExtension {
NEVER = 1,
AUTO = 2,
ALWAYS = 3,
}
export enum PreferRevision {
OLDER = 1,
NEWER = 2,
}
export interface OptionsProps {
readonly commands?: string[];
readonly input?: string[];
readonly inputExclude?: string[];
readonly inputChecksumQuick?: boolean;
readonly inputChecksumMin?: string;
readonly inputChecksumMax?: string;
readonly inputChecksumArchives?: string;
readonly dat?: string[];
readonly datExclude?: string[];
readonly datNameRegex?: string;
readonly datNameRegexExclude?: string;
readonly datDescriptionRegex?: string;
readonly datDescriptionRegexExclude?: string;
readonly datCombine?: boolean;
readonly datIgnoreParentClone?: boolean;
readonly patch?: string[];
readonly patchExclude?: string[];
readonly output?: string;
readonly dirMirror?: boolean;
readonly dirDatName?: boolean;
readonly dirDatDescription?: boolean;
readonly dirLetter?: boolean;
readonly dirLetterCount?: number;
readonly dirLetterLimit?: number;
readonly dirLetterGroup?: boolean;
readonly dirGameSubdir?: string;
readonly fixExtension?: string;
readonly overwrite?: boolean;
readonly overwriteInvalid?: boolean;
readonly cleanExclude?: string[];
readonly cleanBackup?: string;
readonly cleanDryRun?: boolean;
readonly zipExclude?: string;
readonly zipDatName?: boolean;
readonly symlink?: boolean;
readonly symlinkRelative?: boolean;
readonly header?: string;
readonly removeHeaders?: string[];
readonly mergeRoms?: string;
readonly excludeDisks?: boolean;
readonly allowExcessSets?: boolean;
readonly allowIncompleteSets?: boolean;
readonly filterRegex?: string;
readonly filterRegexExclude?: string;
readonly filterLanguage?: string[];
readonly filterRegion?: string[];
readonly noBios?: boolean;
readonly onlyBios?: boolean;
readonly noDevice?: boolean;
readonly onlyDevice?: boolean;
readonly noUnlicensed?: boolean;
readonly onlyUnlicensed?: boolean;
readonly onlyRetail?: boolean;
readonly noDebug?: boolean;
readonly onlyDebug?: boolean;
readonly noDemo?: boolean;
readonly onlyDemo?: boolean;
readonly noBeta?: boolean;
readonly onlyBeta?: boolean;
readonly noSample?: boolean;
readonly onlySample?: boolean;
readonly noPrototype?: boolean;
readonly onlyPrototype?: boolean;
readonly noProgram?: boolean;
readonly onlyProgram?: boolean;
readonly noAftermarket?: boolean;
readonly onlyAftermarket?: boolean;
readonly noHomebrew?: boolean;
readonly onlyHomebrew?: boolean;
readonly noUnverified?: boolean;
readonly onlyUnverified?: boolean;
readonly noBad?: boolean;
readonly onlyBad?: boolean;
readonly single?: boolean;
readonly preferGameRegex?: string;
readonly preferRomRegex?: string;
readonly preferVerified?: boolean;
readonly preferGood?: boolean;
readonly preferLanguage?: string[];
readonly preferRegion?: string[];
readonly preferRevision?: string;
readonly preferRetail?: boolean;
readonly preferParent?: boolean;
readonly reportOutput?: string;
readonly datThreads?: number;
readonly readerThreads?: number;
readonly writerThreads?: number;
readonly writeRetry?: number;
readonly tempDir?: string;
readonly disableCache?: boolean;
readonly cachePath?: string;
readonly verbose?: number;
readonly help?: boolean;
}
/**
* A collection of all options for a single invocation of the application.
*/
export default class Options implements OptionsProps {
@Expose({ name: '_' })
readonly commands: string[];
readonly input: string[];
readonly inputExclude: string[];
readonly inputChecksumQuick: boolean;
readonly inputChecksumMin?: string;
readonly inputChecksumMax?: string;
readonly inputChecksumArchives?: string;
readonly dat: string[];
readonly datExclude: string[];
readonly datNameRegex: string;
readonly datNameRegexExclude: string;
readonly datDescriptionRegex: string;
readonly datDescriptionRegexExclude: string;
readonly datCombine: boolean;
readonly datIgnoreParentClone: boolean;
readonly patch: string[];
readonly patchExclude: string[];
readonly output: string;
readonly dirMirror: boolean;
readonly dirDatName: boolean;
readonly dirDatDescription: boolean;
readonly dirLetter: boolean;
readonly dirLetterCount: number;
readonly dirLetterLimit: number;
readonly dirLetterGroup: boolean;
readonly dirGameSubdir?: string;
readonly fixExtension?: string;
readonly overwrite: boolean;
readonly overwriteInvalid: boolean;
readonly cleanExclude: string[];
readonly cleanBackup?: string;
readonly cleanDryRun: boolean;
readonly zipExclude: string;
readonly zipDatName: boolean;
readonly symlink: boolean;
readonly symlinkRelative: boolean;
readonly header: string;
readonly removeHeaders?: string[];
readonly mergeRoms?: string;
readonly excludeDisks: boolean;
readonly allowExcessSets: boolean;
readonly allowIncompleteSets: boolean;
readonly filterRegex: string;
readonly filterRegexExclude: string;
readonly filterLanguage: string[];
readonly filterRegion: string[];
readonly noBios: boolean;
readonly onlyBios: boolean;
readonly noDevice: boolean;
readonly onlyDevice: boolean;
readonly noUnlicensed: boolean;
readonly onlyUnlicensed: boolean;
readonly onlyRetail: boolean;
readonly noDebug: boolean;
readonly onlyDebug: boolean;
readonly noDemo: boolean;
readonly onlyDemo: boolean;
readonly noBeta: boolean;
readonly onlyBeta: boolean;
readonly noSample: boolean;
readonly onlySample: boolean;
readonly noPrototype: boolean;
readonly onlyPrototype: boolean;
readonly noProgram: boolean;
readonly onlyProgram: boolean;
readonly noAftermarket: boolean;
readonly onlyAftermarket: boolean;
readonly noHomebrew: boolean;
readonly onlyHomebrew: boolean;
readonly noUnverified: boolean;
readonly onlyUnverified: boolean;
readonly noBad: boolean;
readonly onlyBad: boolean;
readonly single: boolean;
readonly preferGameRegex: string;
readonly preferRomRegex: string;
readonly preferVerified: boolean;
readonly preferGood: boolean;
readonly preferLanguage: string[];
readonly preferRegion: string[];
readonly preferRevision?: string;
readonly preferRetail: boolean;
readonly preferParent: boolean;
readonly reportOutput: string;
readonly datThreads: number;
readonly readerThreads: number;
readonly writerThreads: number;
readonly writeRetry: number;
readonly tempDir: string;
readonly disableCache: boolean;
readonly cachePath?: string;
readonly verbose: number;
readonly help: boolean;
constructor(options?: OptionsProps) {
this.commands = options?.commands ?? [];
this.input = options?.input ?? [];
this.inputExclude = options?.inputExclude ?? [];
this.inputChecksumQuick = options?.inputChecksumQuick ?? false;
this.inputChecksumMin = options?.inputChecksumMin;
this.inputChecksumMax = options?.inputChecksumMax;
this.inputChecksumArchives = options?.inputChecksumArchives;
this.dat = options?.dat ?? [];
this.datExclude = options?.datExclude ?? [];
this.datNameRegex = options?.datNameRegex ?? '';
this.datNameRegexExclude = options?.datNameRegexExclude ?? '';
this.datDescriptionRegex = options?.datDescriptionRegex ?? '';
this.datDescriptionRegexExclude = options?.datDescriptionRegexExclude ?? '';
this.datCombine = options?.datCombine ?? false;
this.datIgnoreParentClone = options?.datIgnoreParentClone ?? false;
this.patch = options?.patch ?? [];
this.patchExclude = options?.patchExclude ?? [];
this.output = options?.output ?? '';
this.dirMirror = options?.dirMirror ?? false;
this.dirDatName = options?.dirDatName ?? false;
this.dirDatDescription = options?.dirDatDescription ?? false;
this.dirLetter = options?.dirLetter ?? false;
this.dirLetterCount = options?.dirLetterCount ?? 0;
this.dirLetterLimit = options?.dirLetterLimit ?? 0;
this.dirLetterGroup = options?.dirLetterGroup ?? false;
this.dirGameSubdir = options?.dirGameSubdir;
this.fixExtension = options?.fixExtension;
this.overwrite = options?.overwrite ?? false;
this.overwriteInvalid = options?.overwriteInvalid ?? false;
this.cleanExclude = options?.cleanExclude ?? [];
this.cleanBackup = options?.cleanBackup;
this.cleanDryRun = options?.cleanDryRun ?? false;
this.zipExclude = options?.zipExclude ?? '';
this.zipDatName = options?.zipDatName ?? false;
this.symlink = options?.symlink ?? false;
this.symlinkRelative = options?.symlinkRelative ?? false;
this.header = options?.header ?? '';
this.removeHeaders = options?.removeHeaders;
this.mergeRoms = options?.mergeRoms;
this.excludeDisks = options?.excludeDisks ?? false;
this.allowExcessSets = options?.allowExcessSets ?? false;
this.allowIncompleteSets = options?.allowIncompleteSets ?? false;
this.filterRegex = options?.filterRegex ?? '';
this.filterRegexExclude = options?.filterRegexExclude ?? '';
this.filterLanguage = options?.filterLanguage ?? [];
this.filterRegion = options?.filterRegion ?? [];
this.noBios = options?.noBios ?? false;
this.onlyBios = options?.onlyBios ?? false;
this.noDevice = options?.noDevice ?? false;
this.onlyDevice = options?.onlyDevice ?? false;
this.noUnlicensed = options?.noUnlicensed ?? false;
this.onlyUnlicensed = options?.onlyUnlicensed ?? false;
this.onlyRetail = options?.onlyRetail ?? false;
this.noDebug = options?.noDebug ?? false;
this.onlyDebug = options?.onlyDebug ?? false;
this.noDemo = options?.noDemo ?? false;
this.onlyDemo = options?.onlyDemo ?? false;
this.noBeta = options?.noBeta ?? false;
this.onlyBeta = options?.onlyBeta ?? false;
this.noSample = options?.noSample ?? false;
this.onlySample = options?.onlySample ?? false;
this.noPrototype = options?.noPrototype ?? false;
this.onlyPrototype = options?.onlyPrototype ?? false;
this.noProgram = options?.noProgram ?? false;
this.onlyProgram = options?.onlyProgram ?? false;
this.noAftermarket = options?.noAftermarket ?? false;
this.onlyAftermarket = options?.onlyAftermarket ?? false;
this.noHomebrew = options?.noHomebrew ?? false;
this.onlyHomebrew = options?.onlyHomebrew ?? false;
this.noUnverified = options?.noUnverified ?? false;
this.onlyUnverified = options?.onlyUnverified ?? false;
this.noBad = options?.noBad ?? false;
this.onlyBad = options?.onlyBad ?? false;
this.single = options?.single ?? false;
this.preferGameRegex = options?.preferGameRegex ?? '';
this.preferRomRegex = options?.preferRomRegex ?? '';
this.preferVerified = options?.preferVerified ?? false;
this.preferGood = options?.preferGood ?? false;
this.preferLanguage = options?.preferLanguage ?? [];
this.preferRegion = options?.preferRegion ?? [];
this.preferRevision = options?.preferRevision;
this.preferRetail = options?.preferRetail ?? false;
this.preferParent = options?.preferParent ?? false;
this.reportOutput = options?.reportOutput ?? '';
this.datThreads = Math.max(options?.datThreads ?? 0, 1);
this.readerThreads = Math.max(options?.readerThreads ?? 0, 1);
this.writerThreads = Math.max(options?.writerThreads ?? 0, 1);
this.writeRetry = Math.max(options?.writeRetry ?? 0, 0);
this.tempDir = options?.tempDir ?? Temp.getTempDir();
this.disableCache = options?.disableCache ?? false;
this.cachePath = options?.cachePath;
this.verbose = options?.verbose ?? 0;
this.help = options?.help ?? false;
}
/**
* Construct a {@link Options} from a generic object, such as one from `yargs`.
*/
static fromObject(obj: object): Options {
return plainToInstance(Options, obj, {
enableImplicitConversion: true,
});
}
/**
* Return a JSON representation of all options.
*/
toString(): string {
return JSON.stringify(instanceToPlain(this));
}
// Helpers
private static getRegex(pattern: string): RegExp[] | undefined {
if (!pattern.trim()) {
return undefined;
}
return pattern
.split(/\r?\n/)
.filter((line) => line.length)
.map((line) => {
const flagsMatch = line.match(/^\/(.+)\/([a-z]*)$/);
if (flagsMatch !== null) {
return new RegExp(flagsMatch[1], flagsMatch[2]);
}
return new RegExp(line);
});
}
// Commands
getCommands(): Set<string> {
return new Set(this.commands.map((c) => c.toLowerCase()));
}
/**
* Was any writing command provided?
*/
shouldWrite(): boolean {
return this.writeString() !== undefined;
}
/**
* The writing command that was specified.
*/
writeString(): string | undefined {
return ['copy', 'move', 'link'].find((command) => this.getCommands().has(command));
}
/**
* Was the `copy` command provided?
*/
shouldCopy(): boolean {
return this.getCommands().has('copy');
}
/**
* Was the `move` command provided?
*/
shouldMove(): boolean {
return this.getCommands().has('move');
}
/**
* Was the `link` command provided?
*/
shouldLink(): boolean {
return this.getCommands().has('link');
}
/**
* Was the `extract` command provided?
*/
shouldExtract(): boolean {
return this.getCommands().has('extract');
}
/**
* Should a given ROM be extracted?
*/
shouldExtractRom(rom: ROM): boolean {
if (rom instanceof Disk) {
return false;
}
return this.shouldExtract();
}
/**
* Was the `zip` command provided?
*/
shouldZip(): boolean {
return this.getCommands().has('zip');
}
/**
* Should a given output file path be zipped?
*/
shouldZipRom(rom: ROM): boolean {
if (rom instanceof Disk) {
return false;
}
return (
this.shouldZip() &&
(!this.getZipExclude() ||
!micromatch.isMatch(rom.getName().replace(/^.[\\/]/, ''), this.getZipExclude()))
);
}
/**
* Was the 'dir2dat' command provided?
*/
shouldDir2Dat(): boolean {
return this.getCommands().has('dir2dat');
}
/**
* Was the 'fixdat' command provided?
*/
shouldFixdat(): boolean {
return this.getCommands().has('fixdat');
}
/**
* Was the `test` command provided?
*/
shouldTest(): boolean {
return this.getCommands().has('test');
}
/**
* Was the `clean` command provided?
*/
shouldClean(): boolean {
return this.getCommands().has('clean');
}
/**
* Was the `report` command provided?
*/
shouldReport(): boolean {
return this.getCommands().has('report');
}
// Options
getInputPaths(): string[] {
return this.input;
}
private async scanInputFiles(walkCallback?: FsWalkCallback): Promise<string[]> {
return Options.scanPaths(this.input, walkCallback, this.shouldWrite() || !this.shouldReport());
}
private async scanInputExcludeFiles(): Promise<string[]> {
return Options.scanPaths(this.inputExclude, undefined, false);
}
/**
* Scan for input files, and input files to exclude, and return the difference.
*/
async scanInputFilesWithoutExclusions(walkCallback?: FsWalkCallback): Promise<string[]> {
const inputFiles = await this.scanInputFiles(walkCallback);
const inputExcludeFiles = new Set(await this.scanInputExcludeFiles());
return inputFiles.filter((inputPath) => !inputExcludeFiles.has(inputPath));
}
private static async scanPaths(
globPatterns: string[],
walkCallback?: FsWalkCallback,
requireFiles = true,
): Promise<string[]> {
// Limit to scanning one glob pattern at a time to keep memory in check
const uniqueGlobPatterns = globPatterns.reduce(ArrayPoly.reduceUnique(), []);
let globbedPaths: string[] = [];
for (const uniqueGlobPattern of uniqueGlobPatterns) {
const paths = await this.globPath(uniqueGlobPattern, walkCallback ?? ((): void => {}));
// NOTE(cemmer): if `paths` is really large, `globbedPaths.push(...paths)` can hit a stack
// size limit
globbedPaths = [...globbedPaths, ...paths];
}
// Filter to non-directories
const isNonDirectory = await async.mapLimit(
globbedPaths,
Defaults.MAX_FS_THREADS,
async (file, callback: AsyncResultCallback<boolean, Error>) => {
if (!(await fsPoly.exists(file)) && URLPoly.canParse(file)) {
callback(undefined, true);
return;
}
try {
callback(undefined, !(await fsPoly.isDirectory(file)));
} catch {
// Assume errors mean the path doesn't exist
callback(undefined, false);
}
},
);
const globbedFiles = globbedPaths
.filter((inputPath, idx) => isNonDirectory[idx])
.filter((inputPath) => isNotJunk(path.basename(inputPath)));
if (requireFiles && globbedFiles.length === 0) {
throw new ExpectedError(
`no files found in director${globPatterns.length !== 1 ? 'ies' : 'y'}: ${globPatterns.map((p) => `'${p}'`).join(', ')}`,
);
}
// Remove duplicates
return globbedFiles.reduce(ArrayPoly.reduceUnique(), []);
}
private static async globPath(
inputPath: string,
walkCallback: FsWalkCallback,
): Promise<string[]> {
// Windows will report that \\.\nul doesn't exist, catch it explicitly
if (inputPath === os.devNull || inputPath.startsWith(os.devNull + path.sep)) {
return [];
}
// Glob the contents of directories
if (await fsPoly.isDirectory(inputPath)) {
return (await fsPoly.walk(inputPath, walkCallback)).map((filePath) =>
path.normalize(filePath),
);
}
// If the file exists, don't process it as a glob pattern
if (await fsPoly.exists(inputPath)) {
walkCallback(1);
return [inputPath];
}
// fg only uses forward-slash path separators
const inputPathNormalized = inputPath.replace(/\\/g, '/');
// Try to handle globs a little more intelligently (see the JSDoc below)
const inputPathEscaped = await this.sanitizeGlobPattern(inputPathNormalized);
if (!inputPathEscaped) {
// fast-glob will throw with empty-ish inputs
return [];
}
// Otherwise, process it as a glob pattern
const paths = (await fg(inputPathEscaped, { onlyFiles: true })).map((filePath) =>
path.normalize(filePath),
);
if (paths.length === 0) {
if (URLPoly.canParse(inputPath)) {
// Allow URLs, let the scanner modules deal with them
walkCallback(1);
return [inputPath];
}
return [];
}
walkCallback(paths.length);
return paths;
}
/**
* Trying to use globs with directory names that resemble glob patterns (e.g. dirs that include
* parentheticals) is problematic. Most of the time globs are at the tail end of the path, so try
* to figure out what leading part of the pattern is just a path, and escape it appropriately,
* and then tack on the glob at the end.
* Example problematic paths:
* ./TOSEC - DAT Pack - Complete (3983) (TOSEC-v2023-07-10)/TOSEC-ISO/Sega*
*/
private static async sanitizeGlobPattern(globPattern: string): Promise<string> {
const pathsSplit = globPattern.split(/[\\/]/);
for (let i = 0; i < pathsSplit.length; i += 1) {
const subPath = pathsSplit.slice(0, i + 1).join('/');
if (subPath !== '' && !(await fsPoly.exists(subPath))) {
const dirname = pathsSplit.slice(0, i).join('/');
if (dirname === '') {
// fg won't let you escape empty strings
return pathsSplit.slice(i).join('/');
}
return `${fg.escapePath(dirname)}/${pathsSplit.slice(i).join('/')}`;
}
}
return globPattern;
}
getInputChecksumQuick(): boolean {
return this.inputChecksumQuick;
}
getInputChecksumMin(): ChecksumBitmask | undefined {
const checksumBitmask = Object.keys(ChecksumBitmask).find(
(bitmask) => bitmask.toUpperCase() === this.inputChecksumMin?.toUpperCase(),
);
if (!checksumBitmask) {
return undefined;
}
return ChecksumBitmask[checksumBitmask as keyof typeof ChecksumBitmask];
}
getInputChecksumMax(): ChecksumBitmask | undefined {
const checksumBitmask = Object.keys(ChecksumBitmask).find(
(bitmask) => bitmask.toUpperCase() === this.inputChecksumMax?.toUpperCase(),
);
if (!checksumBitmask) {
return undefined;
}
return ChecksumBitmask[checksumBitmask as keyof typeof ChecksumBitmask];
}
getInputChecksumArchives(): InputChecksumArchivesMode | undefined {
const checksumMode = Object.keys(InputChecksumArchivesMode).find(
(mode) => mode.toLowerCase() === this.inputChecksumArchives?.toLowerCase(),
);
if (!checksumMode) {
return undefined;
}
return InputChecksumArchivesMode[checksumMode as keyof typeof InputChecksumArchivesMode];
}
/**
* Were any DAT paths provided?
*/
usingDats(): boolean {
return this.dat.length > 0;
}
private async scanDatFiles(walkCallback?: FsWalkCallback): Promise<string[]> {
return Options.scanPaths(this.dat, walkCallback);
}
private async scanDatExcludeFiles(): Promise<string[]> {
return Options.scanPaths(this.datExclude, undefined, false);
}
/**
* Scan for DAT files, and DAT files to exclude, and return the difference.
*/
async scanDatFilesWithoutExclusions(walkCallback?: FsWalkCallback): Promise<string[]> {
const datFiles = await this.scanDatFiles(walkCallback);
const datExcludeFiles = new Set(await this.scanDatExcludeFiles());
return datFiles.filter((inputPath) => !datExcludeFiles.has(inputPath));
}
getDatNameRegex(): RegExp[] | undefined {
return Options.getRegex(this.datNameRegex);
}
getDatNameRegexExclude(): RegExp[] | undefined {
return Options.getRegex(this.datNameRegexExclude);
}
getDatDescriptionRegex(): RegExp[] | undefined {
return Options.getRegex(this.datDescriptionRegex);
}
getDatDescriptionRegexExclude(): RegExp[] | undefined {
return Options.getRegex(this.datDescriptionRegexExclude);
}
getDatCombine(): boolean {
return this.datCombine;
}
getDatIgnoreParentClone(): boolean {
return this.datIgnoreParentClone;
}
getPatchFileCount(): number {
return this.patch.length;
}
/**
* Scan for patch files, and patch files to exclude, and return the difference.
*/
async scanPatchFilesWithoutExclusions(walkCallback?: FsWalkCallback): Promise<string[]> {
const patchFiles = await this.scanPatchFiles(walkCallback);
const patchExcludeFiles = new Set(await this.scanPatchExcludeFiles());
return patchFiles.filter((patchPath) => !patchExcludeFiles.has(patchPath));
}
private async scanPatchFiles(walkCallback?: FsWalkCallback): Promise<string[]> {
return Options.scanPaths(this.patch, walkCallback);
}
private async scanPatchExcludeFiles(): Promise<string[]> {
return Options.scanPaths(this.patchExclude, undefined, false);
}
getOutput(): string {
return this.shouldWrite() ? this.output : this.getTempDir();
}
/**
* Get the "root" sub-path of the output dir, the sub-path up until the first replaceable token.
*/
getOutputDirRoot(): string {
const outputSplit = path.normalize(this.getOutput()).split(/[\\/]/);
for (let i = 0; i < outputSplit.length; i += 1) {
if (outputSplit[i].match(/\{[a-zA-Z]+\}/) !== null) {
return path.normalize(outputSplit.slice(0, i).join(path.sep));
}
}
return outputSplit.join(path.sep);
}
getDirMirror(): boolean {
return this.dirMirror;
}
getDirDatName(): boolean {
return this.dirDatName;
}
getDirDatDescription(): boolean {
return this.dirDatDescription;
}
getDirLetter(): boolean {
return this.dirLetter;
}
getDirLetterCount(): number {
return this.dirLetterCount;
}
getDirLetterLimit(): number {
return this.dirLetterLimit;
}
getDirLetterGroup(): boolean {
return this.dirLetterGroup;
}
getDirGameSubdir(): GameSubdirMode | undefined {
const subdirMode = Object.keys(GameSubdirMode).find(
(mode) => mode.toLowerCase() === this.dirGameSubdir?.toLowerCase(),
);
if (!subdirMode) {
return undefined;
}
return GameSubdirMode[subdirMode as keyof typeof GameSubdirMode];
}
getFixExtension(): FixExtension | undefined {
const fixExtensionMode = Object.keys(FixExtension).find(
(mode) => mode.toLowerCase() === this.fixExtension?.toLowerCase(),
);
if (!fixExtensionMode) {
return undefined;
}
return FixExtension[fixExtensionMode as keyof typeof FixExtension];
}
getOverwrite(): boolean {
return this.overwrite;
}
getOverwriteInvalid(): boolean {
return this.overwriteInvalid;
}
private async scanCleanExcludeFiles(): Promise<string[]> {
return Options.scanPaths(this.cleanExclude, undefined, false);
}
/**
* Scan for output files, and output files to exclude from cleaning, and return the difference.
*/
async scanOutputFilesWithoutCleanExclusions(
outputDirs: string[],
writtenFiles: File[],
walkCallback?: FsWalkCallback,
): Promise<string[]> {
// Written files that shouldn't be cleaned
const writtenFilesNormalized = new Set(
writtenFiles.map((file) => path.normalize(file.getFilePath())),
);
// Files excluded from cleaning
const cleanExcludedFilesNormalized = new Set(
(await this.scanCleanExcludeFiles()).map((filePath) => path.normalize(filePath)),
);
return (await Options.scanPaths(outputDirs, walkCallback, false))
.map((filePath) => path.normalize(filePath))
.filter((filePath) => !writtenFilesNormalized.has(filePath))
.filter((filePath) => !cleanExcludedFilesNormalized.has(filePath))
.sort();
}
getCleanBackup(): string | undefined {
return this.cleanBackup;
}
getCleanDryRun(): boolean {
return this.cleanDryRun;
}
private getZipExclude(): string {
return this.zipExclude;
}
getZipDatName(): boolean {
return this.zipDatName;
}
getSymlink(): boolean {
return this.symlink;
}
getSymlinkRelative(): boolean {
return this.symlinkRelative;
}
private getHeader(): string {
return this.header;
}
/**
* Should a file have its contents read to detect any {@link Header}?
*/
shouldReadFileForHeader(filePath: string): boolean {
return (
this.getHeader().length > 0 &&
micromatch.isMatch(filePath.replace(/^.[\\/]/, ''), this.getHeader())
);
}
/**
* Can the {@link Header} be removed for a {@link extension} during writing?
*/
canRemoveHeader(extension: string): boolean {
if (this.removeHeaders === undefined) {
// Option wasn't provided, we shouldn't remove headers
return false;
}
if (this.removeHeaders.length === 1 && this.removeHeaders[0] === '') {
// Option was provided without any extensions, we should remove headers from every file
return true;
}
// Option was provided with extensions, we should remove headers on name match
return this.removeHeaders.some(
(removeHeader) => removeHeader.toLowerCase() === extension.toLowerCase(),
);
}
getMergeRoms(): MergeMode | undefined {
const mergeMode = Object.keys(MergeMode).find(
(mode) => mode.toLowerCase() === this.mergeRoms?.toLowerCase(),
);
if (!mergeMode) {
return undefined;
}
return MergeMode[mergeMode as keyof typeof MergeMode];
}
getExcludeDisks(): boolean {
return this.excludeDisks;
}
getAllowExcessSets(): boolean {
return this.allowExcessSets;
}
getAllowIncompleteSets(): boolean {
return this.allowIncompleteSets;
}
getFilterRegex(): RegExp[] | undefined {
return Options.getRegex(this.filterRegex);
}
getFilterRegexExclude(): RegExp[] | undefined {
return Options.getRegex(this.filterRegexExclude);
}
getFilterLanguage(): Set<string> {
if (this.filterLanguage.length > 0) {
return new Set(Options.filterUniqueUpper(this.filterLanguage));
}
return new Set();
}
getFilterRegion(): Set<string> {
if (this.filterRegion.length > 0) {
return new Set(Options.filterUniqueUpper(this.filterRegion));
}
return new Set();
}
getNoBios(): boolean {
return this.noBios;
}
getOnlyBios(): boolean {
return this.onlyBios;
}
getNoDevice(): boolean {
return this.noDevice;
}
getOnlyDevice(): boolean {
return this.onlyDevice;
}
getNoUnlicensed(): boolean {
return this.noUnlicensed;
}
getOnlyUnlicensed(): boolean {
return this.onlyUnlicensed;
}
getOnlyRetail(): boolean {
return this.onlyRetail;
}
getNoDebug(): boolean {
return this.noDebug;
}
getOnlyDebug(): boolean {
return this.onlyDebug;
}
getNoDemo(): boolean {
return this.noDemo;
}
getOnlyDemo(): boolean {
return this.onlyDemo;
}
getNoBeta(): boolean {
return this.noBeta;
}
getOnlyBeta(): boolean {
return this.onlyBeta;
}
getNoSample(): boolean {
return this.noSample;
}
getOnlySample(): boolean {
return this.onlySample;
}
getNoPrototype(): boolean {
return this.noPrototype;
}
getOnlyPrototype(): boolean {
return this.onlyPrototype;
}
getNoProgram(): boolean {
return this.noProgram;
}
getOnlyProgram(): boolean {
return this.onlyProgram;
}
getNoAftermarket(): boolean {
return this.noAftermarket;
}
getOnlyAftermarket(): boolean {
return this.onlyAftermarket;
}
getNoHomebrew(): boolean {
return this.noHomebrew;
}
getOnlyHomebrew(): boolean {
return this.onlyHomebrew;
}
getNoUnverified(): boolean {
return this.noUnverified;
}
getOnlyUnverified(): boolean {
return this.onlyUnverified;
}
getNoBad(): boolean {
return this.noBad;
}
getOnlyBad(): boolean {
return this.onlyBad;
}
getSingle(): boolean {
return this.single;
}
getPreferGameRegex(): RegExp[] | undefined {
return Options.getRegex(this.preferGameRegex);
}
getPreferRomRegex(): RegExp[] | undefined {
return Options.getRegex(this.preferRomRegex);
}
getPreferVerified(): boolean {
return this.preferVerified;
}
getPreferGood(): boolean {
return this.preferGood;
}
getPreferLanguages(): string[] {
return Options.filterUniqueUpper(this.preferLanguage);
}
getPreferRegions(): string[] {
return Options.filterUniqueUpper(this.preferRegion);
}
getPreferRevision(): PreferRevision | undefined {
const preferRevision = Object.keys(PreferRevision).find(
(mode) => mode.toLowerCase() === this.preferRevision?.toLowerCase(),
);
if (!preferRevision) {
return undefined;
}
return PreferRevision[preferRevision as keyof typeof PreferRevision];
}
getPreferRetail(): boolean {
return this.preferRetail;
}
getPreferParent(): boolean {
return this.preferParent;
}
getReportOutput(): string {
let { reportOutput } = this;
// Replace date & time tokens
const symbolMatches = reportOutput.match(/%([a-zA-Z])(\1|o)*/g);
if (symbolMatches) {
symbolMatches.reduce(ArrayPoly.reduceUnique(), []).forEach((match) => {
const val = moment().format(match.replace(/^%/, ''));
reportOutput = reportOutput.replace(match, val);
});
}
return fsPoly.makeLegal(path.resolve(reportOutput));
}
getDatThreads(): number {
return this.datThreads;
}
getReaderThreads(): number {
return this.readerThreads;
}
getWriterThreads(): number {
return this.writerThreads;
}
getWriteRetry(): number {
return this.writeRetry;
}
getTempDir(): string {
return this.tempDir;
}
getDisableCache(): boolean {
return this.disableCache;
}
getCachePath(): string | undefined {
return this.cachePath;
}
getLogLevel(): LogLevel {
if (this.verbose === 1) {
return LogLevel.INFO;
}
if (this.verbose === 2) {
return LogLevel.DEBUG;
}
if (this.verbose >= 3) {
return LogLevel.TRACE;
}
return LogLevel.WARN;
}
getHelp(): boolean {
return this.help;
}
private static filterUniqueUpper(array: string[]): string[] {
return array.map((value) => value.toUpperCase()).reduce(ArrayPoly.reduceUnique(), []);
}
}