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
JavaScript
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