UNPKG

igir

Version:

🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.

463 lines (462 loc) • 19.5 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __decorateClass = (decorators, target, key, kind) => { var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target; for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result; if (kind && result) __defProp(target, key, result); return result; }; import fs from "node:fs"; import path from "node:path"; import { Memoize } from "typescript-memoize"; import TokenReplacementException from "../../../exceptions/tokenReplacementException.js"; import FileFactory from "../../../factories/fileFactory.js"; import ConsoleTokens from "../../../models/consoleTokens.js"; import Disk from "../../../models/dats/disk.js"; import MergedDiscGame from "../../../models/dats/mergedDiscGame.js"; import ArchiveEntry from "../../../models/files/archives/archiveEntry.js"; import ArchiveFile from "../../../models/files/archives/archiveFile.js"; import ZeroSizeFile from "../../../models/files/zeroSizeFile.js"; import { FixExtension, GameSubdirMode } from "../../../models/options.js"; import FsUtil from "../../../utils/fsUtil.js"; import outputTokensData from "./consoleTokens.json" with { type: "json" }; class OutputPath { base; dir; ext; /** * NOTE(cemmer): this class differs from {@link ParsedPath} crucially in that the "name" here * may contain {@link path.sep} in it on purpose. That's fine, because {@link path.format} handles * it gracefully. */ name; root; entryPath; constructor(parsedPath) { this.base = parsedPath.base.replaceAll(/[\\/]/g, path.sep); this.dir = parsedPath.dir.replaceAll(/[\\/]/g, path.sep); this.ext = parsedPath.ext.replaceAll(/[\\/]/g, path.sep); this.name = parsedPath.name.replaceAll(/[\\/]/g, path.sep); this.root = parsedPath.root.replaceAll(/[\\/]/g, path.sep); this.entryPath = parsedPath.entryPath; } /** * Format this {@link OutputPath}, similar to {@link path#format}. */ format() { return path.format(this).replaceAll(/\/{2,}/g, path.sep).replace(/(?<!^\\*)\\{2,}/, path.sep).replace(/[\\/]+$/, ""); } } class OutputFactory { static LEFTOVER_TOKEN_REGEX = /\{[a-zA-Z]+\}/g; /** * Get the full output path for a ROM file. * @param options the {@link Options} instance for this run of igir. * @param dat the {@link DAT} that the ROM/{@link Game} is from. * @param game the {@link Game} that this file matches to. * @param rom a {@link ROM} from the {@link Game}. * @param inputFile a {@link File} that matches the {@link ROM}. * @param romBasenames the intended output basenames for every ROM from this {@link DAT}. */ static getPath(options, dat, game, rom, inputFile, romBasenames) { if (!options.shouldWrite() && !options.shouldDir2Dat() && !options.shouldFixdat()) { return new OutputPath({ ...path.parse(inputFile.getFilePath()), root: "", base: "", entryPath: inputFile instanceof ArchiveEntry ? inputFile.getEntryPath() : "" }); } const name = this.getName(options, game, rom, inputFile); const ext = this.getExt(options, game, rom, inputFile); const basename = name + ext; return new OutputPath({ root: "", dir: path.resolve(this.getDir(options, dat, game, inputFile, basename, romBasenames)), base: "", name, ext, entryPath: this.getEntryPath(options, game, rom, inputFile) }); } /** ************************** * * File directory * * * ************************* */ static getDir(options, dat, game, inputFile, romBasename, romBasenames) { let output = options.getOutput(); output = FsUtil.makeLegal( this.replaceTokensInOutputPath( options, output, dat, inputFile?.getFilePath(), game, romBasename ) ); if (options.getDirMirror() && options.getInputPaths().length > 0 && !(inputFile instanceof ZeroSizeFile) && inputFile?.getFilePath()) { const mirroredFilePath = options.getInputPaths().map((inputPath) => path.resolve(inputPath)).reduce((inputFilePath, inputPath) => { const inputPathRegex = new RegExp( `^${inputPath.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\/]?` ); return inputFilePath.replace(inputPathRegex, ""); }, inputFile.getFilePath()); const mirroredDirPath = path.dirname(mirroredFilePath); output = path.join(output, mirroredDirPath); } const datFilePath = dat.getFilePath(); if (options.getDirDatMirror() && options.getDatPaths().length > 0 && datFilePath !== void 0) { const mirroredFilePath = options.getDatPaths().map((datPath) => path.resolve(datPath)).reduce((datFilePath2, datPath) => { const datPathRegex = new RegExp( `^${datPath.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\/]?` ); return datFilePath2.replace(datPathRegex, ""); }, path.resolve(datFilePath)); const mirroredDirPath = path.dirname(mirroredFilePath); output = path.join(output, mirroredDirPath); } if (options.getDirDatName() && dat.getName()) { output = path.join(output, dat.getName()); } const datDescription = dat.getDescription(); if (options.getDirDatDescription() && datDescription) { output = path.join(output, datDescription); } const dirLetter = this.getDirLetterParsed(options, romBasename, romBasenames); if (dirLetter) { output = path.join(output, dirLetter); } return FsUtil.makeLegal(output); } static replaceTokensInOutputPath(options, outputPath, dat, inputRomPath, game, outputRomFilename) { let result = outputPath; result = this.replaceGameTokens(result, game); result = this.replaceDatTokens(result, dat); result = this.replaceInputTokens(result, inputRomPath); result = this.replaceOutputTokens(result, options, outputRomFilename); result = this.replaceConsoleTokens(result, options, dat, outputRomFilename); const leftoverTokens = result.match(this.LEFTOVER_TOKEN_REGEX); if (leftoverTokens !== null && leftoverTokens.length > 0) { throw new TokenReplacementException( `failed to replace output token${leftoverTokens.length === 1 ? "" : "s"}: ${leftoverTokens.join(", ")}` ); } return result; } static replaceGameTokens(input, game) { if (!game) { return input; } let output = input; const gameRegion = game.getRegions().at(0); if (gameRegion) { output = output.replace("{region}", gameRegion); } const gameLanguage = game.getLanguages().at(0); if (gameLanguage) { output = output.replace("{language}", gameLanguage); } output = output.replace("{type}", game.getGameType()); const gameGenre = game.getGenre(); if (gameGenre) { output = output.replace("{genre}", gameGenre); } const gameCategory = game.getCategories().at(0); if (gameCategory) { output = output.replace("{category}", gameCategory); } return output; } static replaceDatTokens(input, dat) { let output = input; output = output.replace("{datName}", dat.getName().replaceAll(/[\\/]/g, "_")); const description = dat.getDescription(); if (description) { output = output.replace("{datDescription}", description.replaceAll(/[\\/]/g, "_")); } return output; } static replaceInputTokens(input, inputRomPath) { if (!inputRomPath) { return input; } return input.replace( "{inputDirname}", path.relative(process.cwd(), path.parse(inputRomPath).dir) ); } static replaceOutputTokens(input, options, outputRomFilename) { if (!outputRomFilename && options.getFixExtension() === FixExtension.NEVER) { return input; } const outputRom = path.parse(outputRomFilename ?? ""); return input.replace("{outputBasename}", outputRom.base).replace("{outputName}", outputRom.name).replace("{outputExt}", outputRom.ext.replace(/^\./, "") || "-"); } static TOKEN_ALIASES = { rocknix: "jelos" }; static loadTokensFile(filePath) { const data = filePath ? JSON.parse(fs.readFileSync(filePath, "utf8")) : outputTokensData; return data.consoles.map(({ datNameRegex, extensions, tokens }) => { const lastSlash = datNameRegex.lastIndexOf("/"); const pattern = datNameRegex.slice(1, lastSlash); const flags = datNameRegex.slice(lastSlash + 1); const tokensMap = new Map( Object.entries(tokens).filter((entry) => entry[1] !== void 0) ); for (const [primary, alias] of Object.entries(this.TOKEN_ALIASES)) { const primaryValue = tokensMap.get(primary); if (primaryValue !== void 0 && !tokensMap.has(alias)) { tokensMap.set(alias, primaryValue); } } return new ConsoleTokens(new RegExp(pattern, flags || void 0), extensions, tokensMap); }); } static getConsoleTokensForFilename(outputTokensFile, filePath) { const fileExtension = path.extname(filePath).toLowerCase(); return outputTokensFile.findLast( (outputTokens) => outputTokens.getExtensions().includes(fileExtension) ); } static getConsoleTokensForDatName(outputTokensFile, datName) { return outputTokensFile.findLast((outputTokens) => outputTokens.getDatRegex().test(datName)); } static replaceConsoleTokens(input, options, dat, outputRomFilename) { if (!outputRomFilename) { return input; } const outputTokensFile = this.loadTokensFile(options.getOutputConsoleTokens()); const outputTokens = this.getConsoleTokensForDatName(outputTokensFile, dat?.getName() ?? "") ?? this.getConsoleTokensForFilename(outputTokensFile, outputRomFilename); if (!outputTokens) { return input; } let output = input; for (const [key, value] of outputTokens.getTokens()) { output = output.replace(`{${key}}`, value); } return output; } /** * Greedily pack tuples into groups that respect {@link limit}, grouping by whole letters so * that a single letter is never split across two groups. This guarantees that adjacent letter * ranges never share a boundary letter and therefore never overlap. A letter whose own count * exceeds the limit is placed alone in its group; splitting it is left to the numeric-suffix * logic in {@link getDirLetterParsed}. */ static groupTuplesByLetter(tuples, limit) { if (limit <= 0) { return tuples.length > 0 ? [tuples] : []; } const letterRuns = []; for (const tuple of tuples) { const lastRun = letterRuns.at(-1); if (lastRun?.[0][0] === tuple[0]) { lastRun.push(tuple); } else { letterRuns.push([tuple]); } } const groups = []; let current = []; for (const run of letterRuns) { if (current.length > 0 && current.length + run.length > limit) { groups.push(current); current = []; } for (const tuple of run) { current.push(tuple); } } if (current.length > 0) { groups.push(current); } return groups; } static getDirLetterParsed(options, romBasename, romBasenames) { if (!romBasename || !options.getDirLetter()) { return void 0; } let lettersToFilenames = (romBasenames ?? [romBasename]).reduce((map, filename) => { const filenameParsed = path.parse(filename); let letters = (filenameParsed.dir || filenameParsed.name).slice(0, Math.max(0, options.getDirLetterCount())).padEnd(options.getDirLetterCount(), "A").toUpperCase().replaceAll(/[^A-Z0-9]/g, "#"); if (!options.getDirLetterGroup()) { letters = letters.replaceAll(/[^A-Z]/g, "#"); } const existing = map.get(letters) ?? /* @__PURE__ */ new Set(); existing.add(filename); map.set(letters, existing); return map; }, /* @__PURE__ */ new Map()); if (options.getDirLetterGroup()) { const flatTuples = [...lettersToFilenames].toSorted((a, b) => a[0].localeCompare(b[0])).reduce((arr, [letter, filenames]) => { const subPathsToFilenames = [...filenames].reduce((subPathMap, filename) => { const subPath = filename.replace(/[\\/].+$/, ""); if (subPathMap.has(subPath)) { subPathMap.get(subPath)?.push(filename); } else { subPathMap.set(subPath, [filename]); } return subPathMap; }, /* @__PURE__ */ new Map()); const tuples = [...subPathsToFilenames].toSorted(([subPathOne], [subPathTwo]) => subPathOne.localeCompare(subPathTwo)).map( ([, subPathFilenames]) => [letter, new Set(subPathFilenames)] ); return [...arr, ...tuples]; }, []); lettersToFilenames = this.groupTuplesByLetter(flatTuples, options.getDirLetterLimit()).reduce( (map, tuples) => { const firstTuple = tuples.at(0); const lastTuple = tuples.at(-1); if (firstTuple === void 0 || lastTuple === void 0) { throw new Error( "there should be at least one letter tuple (this should never happen!)" ); } const letterRange = firstTuple[0] === lastTuple[0] ? firstTuple[0] : `${firstTuple[0]}-${lastTuple[0]}`; const existingFilenames = map.get(letterRange) ?? /* @__PURE__ */ new Set(); for (const [, filenames] of tuples) { for (const filename of filenames) { existingFilenames.add(filename); } } map.set(letterRange, existingFilenames); return map; }, /* @__PURE__ */ new Map() ); } if (options.getDirLetterLimit()) { lettersToFilenames = [...lettersToFilenames].reduce((lettersMap, [letter, filenames]) => { const subPathsToFilenames = [...filenames].reduce((subPathMap, filename) => { const subPath = filename.replace(/[\\/].+$/, ""); if (subPathMap.has(subPath)) { subPathMap.get(subPath)?.push(filename); } else { subPathMap.set(subPath, [filename]); } return subPathMap; }, /* @__PURE__ */ new Map()); if (subPathsToFilenames.size <= options.getDirLetterLimit()) { lettersMap.set(letter, new Set(filenames)); return lettersMap; } const subPaths = [...subPathsToFilenames.keys()].toSorted((a, b) => a.localeCompare(b)); const chunkSize = options.getDirLetterLimit(); for (let i = 0; i < subPaths.length; i += chunkSize) { const chunk = subPaths.slice(i, i + chunkSize).flatMap((subPath) => subPathsToFilenames.get(subPath) ?? []); const newLetter = `${letter}${i / chunkSize + 1}`; lettersMap.set(newLetter, new Set(chunk)); } return lettersMap; }, /* @__PURE__ */ new Map()); } const foundEntry = [...lettersToFilenames].find(([, filenames]) => filenames.has(romBasename)); return foundEntry ? foundEntry[0] : void 0; } /** *********************************** * * File name and extension * * * ********************************* */ static getName(options, game, rom, inputFile) { const { dir, name, ext } = path.parse( this.getOutputFileBasename(options, game, rom, inputFile) ); let output = name; if (dir.trim() !== "") { output = path.join(dir, output); } const hasMultipleFiles = game.getRoms().length + (options.getExcludeDisks() ? 0 : game.getDisks().length) > 1; if (rom instanceof Disk) { if ( // When the ROMs are zipped, MAME expects Disks to be in a subdirectory named after the // Game. This makes the directory tree slightly more human-readable, and it also helps // deconflict any duplicate Disk names. options.shouldZip() || // When the ROMs aren't zipped, MAME expects all files to be in the same subdirectory named // after the Game. options.getDirGameSubdir() === GameSubdirMode.ALWAYS || options.getDirGameSubdir() === GameSubdirMode.MULTIPLE && hasMultipleFiles ) { output = path.join(game.getName(), output); } return output; } if (options.getDirGameSubdir() === GameSubdirMode.MULTIPLE && hasMultipleFiles && // Ignore Games/ROMs coming from SMDB-like DATs that already have a complete directory // structure in the ROM name !(/[\\/]/.test(game.getName()) && /[\\/]/.test(rom.getName())) && // Output file is not an archive !FileFactory.isExtensionArchive(ext) && !(inputFile instanceof ArchiveFile) || options.getDirGameSubdir() === GameSubdirMode.ALWAYS || // Discs merged together are always grouped into a subdirectory named after the game game instanceof MergedDiscGame) { output = path.join(game.getName(), output); } return output; } static getExt(options, game, rom, inputFile) { const { ext } = path.parse(this.getOutputFileBasename(options, game, rom, inputFile)); return ext; } static getOutputFileBasename(options, game, rom, inputFile) { if (options.shouldZipRom(rom)) { return `${game.getName()}.zip`; } const romBasename = this.getRomBasename(rom, inputFile); if (!(inputFile instanceof ArchiveEntry || inputFile instanceof ArchiveFile) || options.shouldExtract() || rom instanceof Disk) { return romBasename; } const oldExtMatch = /[^.]+((\.[a-zA-Z0-9]+)+)$/.exec(inputFile.getFilePath()); const oldExt = oldExtMatch === null ? ( // The input file has no extension, get the canonical extension from the {@link Archive} inputFile.getArchive().getExtension() ) : ( // Respect the input file's extension oldExtMatch[1] ); const gameName = game instanceof MergedDiscGame ? ( // The Game is the result of 2+ discs merged together, and we're not extracting this file, // so we generate a basename based on the original game/disc name (with track numbers // stripped). OutputFactory#getName() will group multiple discs together. game.getSubGames().find( (subGame) => subGame.getRoms().some((subRom) => subRom.getName() === rom.getName()) )?.getName() ?? game.getName() ) : game.getName(); const oldExtSub = oldExt.split(".").slice(0, -1).join("."); if (oldExtSub.length > 0 && gameName.endsWith(oldExtSub)) { return gameName.slice(0, gameName.lastIndexOf(oldExtSub)) + oldExt; } return gameName + oldExt; } static getEntryPath(options, game, rom, inputFile) { const romBasename = this.getRomBasename(rom, inputFile); if (!options.shouldZipRom(rom)) { return romBasename; } const gameNameSanitized = game.getName().replaceAll("\\", "/"); return romBasename.replace(`${path.posix.dirname(gameNameSanitized)}/`, ""); } static getRomBasename(rom, inputFile) { if (rom instanceof Disk) { return `${rom.getName().replace(/\.chd$/i, "")}.chd`; } const { base, ...parsedRomPath } = path.posix.parse(rom.getName()); const fileHeader = inputFile.getFileHeader(); if (parsedRomPath.ext && fileHeader) { parsedRomPath.ext = fileHeader.getHeaderlessFileExtension(); } return path.posix.format(parsedRomPath); } } __decorateClass([ Memoize() ], OutputFactory, "loadTokensFile", 1); export { OutputPath, OutputFactory as default }; //# sourceMappingURL=outputFactory.js.map