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.

442 lines (441 loc) • 20.3 kB
import path from 'node:path'; import ArrayPoly from '../polyfill/arrayPoly.js'; import FsPoly from '../polyfill/fsPoly.js'; import Disk from './dats/disk.js'; import TokenReplacementException from './exceptions/tokenReplacementException.js'; import ArchiveEntry from './files/archives/archiveEntry.js'; import ArchiveFile from './files/archives/archiveFile.js'; import FileFactory from './files/fileFactory.js'; import ZeroSizeFile from './files/zeroSizeFile.js'; import GameConsole from './gameConsole.js'; import { FixExtension, GameSubdirMode } from './options.js'; /** * A {@link ParsedPathWithEntryPath} that normalizes formatting across OSes. */ export 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.replaceAll(/[\\/]/g, path.sep); } /** * Format this {@link OutputPath}, similar to {@link path#format}. */ format() { return (path .format(this) // No double slashes / empty subdir name .replaceAll(/\/{2,}/g, path.sep) // unix .replace(/(?<!^\\*)\\{2,}/, path.sep) // windows, preserving network paths // No trailing slashes .replace(/[\\/]+$/, '')); } } /** * A factory of static methods to generate output paths for a {@link ROM} and its related * {@link Game}. */ export default class OutputFactory { /** * 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()) { // If we're not writing anything to the output, then just return the input as the output 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: 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(); // Replace all {token}s in the output path output = FsPoly.makeLegal(OutputFactory.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, ''); }, path.resolve(inputFile.getFilePath())); const mirroredDirPath = path.dirname(mirroredFilePath); output = path.join(output, mirroredDirPath); } const datFilePath = dat.getFilePath(); if (options.getDirDatMirror() && options.getDatPaths().length > 0 && datFilePath !== undefined) { const mirroredFilePath = options .getDatPaths() .map((datPath) => path.resolve(datPath)) .reduce((datFilePath, datPath) => { const datPathRegex = new RegExp(`^${datPath.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\/]?`); return datFilePath.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 FsPoly.makeLegal(output); } static replaceTokensInOutputPath(options, outputPath, dat, inputRomPath, game, outputRomFilename) { let result = outputPath; // NOTE(cemmer): order here is important! They should go most specific to least result = this.replaceGameTokens(result, game); result = this.replaceDatTokens(result, dat); result = this.replaceInputTokens(result, inputRomPath); result = this.replaceOutputTokens(result, options, outputRomFilename); result = this.replaceOutputGameConsoleTokens(result, dat, outputRomFilename); const leftoverTokens = result.match(/\{[a-zA-Z]+\}/g); 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.getRegion(); if (gameRegion) { output = output.replace('{region}', gameRegion); } const gameLanguage = game.getLanguage(); 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.getCategory(); 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.parse(inputRomPath).dir); } static replaceOutputTokens(input, options, outputRomFilename) { if (!outputRomFilename && options.getFixExtension() === FixExtension.NEVER) { // No output ROM filename was provided and we won't know it later from correction, don't // replace any of the output filename tokens return input; } const outputRom = path.parse(outputRomFilename ?? ''); return input .replace('{outputBasename}', outputRom.base) .replace('{outputName}', outputRom.name) .replace('{outputExt}', outputRom.ext.replace(/^\./, '') || '-'); } static replaceOutputGameConsoleTokens(input, dat, outputRomFilename) { if (!outputRomFilename) { return input; } const gameConsole = GameConsole.getForDatName(dat?.getName() ?? '') ?? GameConsole.getForFilename(outputRomFilename); if (!gameConsole) { return input; } let output = input; const adam = gameConsole.getAdam(); if (adam) { output = output.replace('{adam}', adam); } const es = gameConsole.getEmulationStation(); if (es) { output = output.replace('{es}', es); } const pocket = gameConsole.getPocket(); if (pocket) { output = output.replace('{pocket}', pocket); } const mister = gameConsole.getMister(); if (mister) { output = output.replace('{mister}', mister); } const onion = gameConsole.getOnion(); if (onion) { output = output.replace('{onion}', onion); } const batocera = gameConsole.getBatocera(); if (batocera) { output = output.replace('{batocera}', batocera); } const jelos = gameConsole.getJelos(); if (jelos) { output = output.replace('{jelos}', jelos); } const funkeyos = gameConsole.getFunkeyOS(); if (funkeyos) { output = output.replace('{funkeyos}', funkeyos); } const miyoocfw = gameConsole.getMiyooCFW(); if (miyoocfw) { output = output.replace('{miyoocfw}', miyoocfw); } const retrodeck = gameConsole.getRetroDECK(); if (retrodeck) { output = output.replace('{retrodeck}', retrodeck); } const romm = gameConsole.getRomM(); if (romm) { output = output.replace('{romm}', romm); } const twmenu = gameConsole.getTWMenu(); if (twmenu) { output = output.replace('{twmenu}', twmenu); } const minui = gameConsole.getMinUI(); if (minui) { output = output.replace('{minui}', minui); } return output; } static getDirLetterParsed(options, romBasename, romBasenames) { if (!romBasename || !options.getDirLetter()) { return undefined; } // Find the letter for every ROM filename 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) ?? new Set(); existing.add(filename); map.set(letters, existing); return map; }, new Map()); if (options.getDirLetterGroup()) { lettersToFilenames = [...lettersToFilenames.entries()] .sort((a, b) => a[0].localeCompare(b[0])) // Generate a tuple of [letter, Set(filenames)] for every subpath .reduce((arr, [letter, filenames]) => { // ROMs may have been grouped together into a subdirectory. For example, when a game has // multiple ROMs, they get grouped by their game name. Therefore, we have to understand // what the "sub-path" should be within the letter directory: the dirname if the ROM has a // subdir, or just the ROM's basename otherwise. 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; }, new Map()); const tuples = [...subPathsToFilenames.entries()] .sort(([subPathOne], [subPathTwo]) => subPathOne.localeCompare(subPathTwo)) .map(([, subPathFilenames]) => [letter, new Set(subPathFilenames)]); return [...arr, ...tuples]; }, []) // Group letters together to create letter ranges .reduce(ArrayPoly.reduceChunk(options.getDirLetterLimit()), []) .reduce((map, tuples) => { const firstTuple = tuples.at(0); const lastTuple = tuples.at(-1); if (firstTuple === undefined || lastTuple === undefined) { throw new Error('there should be at least one letter tuple (this should never happen!)'); } const letterRange = `${firstTuple[0]}-${lastTuple[0]}`; const newFilenames = new Set(tuples.flatMap(([, filenames]) => [...filenames])); const existingFilenames = map.get(letterRange) ?? new Set(); map.set(letterRange, new Set([...existingFilenames, ...newFilenames])); return map; }, new Map()); } // Split the letter directories, if needed if (options.getDirLetterLimit()) { lettersToFilenames = [...lettersToFilenames.entries()].reduce((lettersMap, [letter, filenames]) => { // ROMs may have been grouped together into a subdirectory. For example, when a game has // multiple ROMs, they get grouped by their game name. Therefore, we have to understand // what the "sub-path" should be within the letter directory: the dirname if the ROM has a // subdir, or just the ROM's basename otherwise. 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; }, new Map()); if (subPathsToFilenames.size <= options.getDirLetterLimit()) { lettersMap.set(letter, new Set(filenames)); return lettersMap; } const subPaths = [...subPathsToFilenames.keys()].sort(); 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; }, new Map()); } const foundEntry = [...lettersToFilenames.entries()].find(([, filenames]) => filenames.has(romBasename)); return foundEntry ? foundEntry[0] : undefined; } /** *********************************** * * 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); } if ((options.getDirGameSubdir() === GameSubdirMode.MULTIPLE && game.getRoms().length > 1 && // Output file is an archive !FileFactory.isExtensionArchive(ext) && !(inputFile instanceof ArchiveFile)) || options.getDirGameSubdir() === GameSubdirMode.ALWAYS || rom instanceof Disk) { 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) { // Determine the output path of the file if (options.shouldZipRom(rom)) { // Should zip, generate the zip name from the game name return `${game.getName()}.zip`; } const romBasename = this.getRomBasename(rom, inputFile); if (!(inputFile instanceof ArchiveEntry || inputFile instanceof ArchiveFile) || options.shouldExtract() || rom instanceof Disk) { // Should extract (if needed), generate the file name from the ROM name return romBasename; } // Should leave archived, generate the archive name from the game name // The regex is to preserve filenames that use 2+ extensions, e.g. "rom.nes.zip" 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]; // If we got a filename with 2+ extensions, but the additional extensions // are actually part of the game's name, then just use the last extension const oldExtSub = oldExt.split('.').slice(0, -1).join('.'); if (oldExtSub.length > 0 && game.getName().endsWith(oldExtSub)) { return game.getName().slice(0, game.getName().lastIndexOf(oldExtSub)) + oldExt; } return game.getName() + oldExt; } static getEntryPath(options, game, rom, inputFile) { const romBasename = this.getRomBasename(rom, inputFile); if (!options.shouldZipRom(rom)) { return romBasename; } // The file structure from HTGD SMDBs ends up in both the Game and ROM names. If we're // zipping, then the Game name will end up in the filename, we don't need it duplicated in // the entry path. const gameNameSanitized = game.getName().replaceAll(/[\\/]/g, path.sep); return romBasename .replaceAll(/[\\/]/g, path.sep) .replace(`${path.dirname(gameNameSanitized)}${path.sep}`, ''); } static getRomBasename(rom, inputFile) { const romNameSanitized = rom.getName().replaceAll(/[\\/]/g, path.sep); const { base, ...parsedRomPath } = path.parse(romNameSanitized); // Alter the output extension of the file const fileHeader = inputFile.getFileHeader(); if (parsedRomPath.ext && fileHeader) { // If the ROM has a header, then we're going to ignore the file extension from the DAT parsedRomPath.ext = fileHeader.getHeaderlessFileExtension(); } return path.format(parsedRomPath); } }