From 24491f3357fa3086e0ff5f9ef166af505a97ffcd Mon Sep 17 00:00:00 2001 From: Christian Emmer <10749361+emmercm@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:15:17 -0800 Subject: [PATCH] Feature: dir-letter-group option (#900) --- src/modules/argumentsParser.ts | 6 + src/polyfill/arrayPoly.ts | 28 +++++ src/types/options.ts | 8 ++ src/types/outputFactory.ts | 52 ++++++-- test/modules/argumentsParser.test.ts | 11 ++ test/modules/candidatePostProcessor.test.ts | 129 +++++++++++++++++++- 6 files changed, 225 insertions(+), 9 deletions(-) diff --git a/src/modules/argumentsParser.ts b/src/modules/argumentsParser.ts index 15206b786..8bd0f40f5 100644 --- a/src/modules/argumentsParser.ts +++ b/src/modules/argumentsParser.ts @@ -321,6 +321,12 @@ export default class ArgumentsParser { requiresArg: true, implies: 'dir-letter', }) + .option('dir-letter-group', { + group: groupRomOutput, + description: 'Group letter subdirectories into ranges, combining multiple letters together (requires --dir-letter-limit)', + type: 'boolean', + implies: 'dir-letter-limit', + }) .option('dir-game-subdir', { group: groupRomOutput, description: 'Append the name of the game as an output subdirectory depending on its ROMs', diff --git a/src/polyfill/arrayPoly.ts b/src/polyfill/arrayPoly.ts index 779fd6579..c5f538aae 100644 --- a/src/polyfill/arrayPoly.ts +++ b/src/polyfill/arrayPoly.ts @@ -37,6 +37,34 @@ export default class ArrayPoly { }; } + /** + * Reduce elements in an array to chunks of size {@link limit}. + * + * + * [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].reduce(ArrayPoly.reduceChunk(3), []); + * + */ + public static reduceChunk( + limit: number, + ): (previous: T[][], current: T, idx: number, array: T[]) => T[][] { + return (previous: T[][], current: T, idx: number, array: T[]): T[][] => { + if (idx === 0) { + if (limit <= 0) { + return [array]; + } + + const chunks = [] as T[][]; + for (let i = 0; i < array.length; i += limit) { + const chunk = array.slice(i, i + limit); + chunks.push(chunk); + } + return chunks; + } + + return previous; + }; + } + /** * Reduce elements in an array to only unique values. Usage: * diff --git a/src/types/options.ts b/src/types/options.ts index 5aae07cee..0e48ed9d4 100644 --- a/src/types/options.ts +++ b/src/types/options.ts @@ -68,6 +68,7 @@ export interface OptionsProps { readonly dirLetter?: boolean, readonly dirLetterCount?: number, readonly dirLetterLimit?: number, + readonly dirLetterGroup?: boolean, readonly dirGameSubdir?: string, readonly overwrite?: boolean, readonly overwriteInvalid?: boolean, @@ -186,6 +187,8 @@ export default class Options implements OptionsProps { readonly dirLetterLimit: number; + readonly dirLetterGroup: boolean; + readonly dirGameSubdir?: string; readonly overwrite: boolean; @@ -338,6 +341,7 @@ export default class Options implements OptionsProps { 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.overwrite = options?.overwrite ?? false; this.overwriteInvalid = options?.overwriteInvalid ?? false; @@ -768,6 +772,10 @@ export default class Options implements OptionsProps { return this.dirLetterLimit; } + getDirLetterGroup(): boolean { + return this.dirLetterGroup; + } + getDirGameSubdir(): GameSubdirMode | undefined { const subdirMode = Object.keys(GameSubdirMode) .find((mode) => mode.toLowerCase() === this.dirGameSubdir?.toLowerCase()); diff --git a/src/types/outputFactory.ts b/src/types/outputFactory.ts index 59e1f3706..eb29728cb 100644 --- a/src/types/outputFactory.ts +++ b/src/types/outputFactory.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line max-classes-per-file import path, { ParsedPath } from 'node:path'; +import ArrayPoly from '../polyfill/arrayPoly.js'; import fsPoly from '../polyfill/fsPoly.js'; import DAT from './dats/dat.js'; import Game from './dats/game.js'; @@ -334,8 +335,9 @@ export default class OutputFactory { .padEnd(options.getDirLetterCount(), 'A') .toUpperCase() .replace(/[^A-Z0-9]/g, '#'); - // TODO(cemmer): only do this when not --dir-letter-group - letters = letters.replace(/[^A-Z]/g, '#'); + if (!options.getDirLetterGroup()) { + letters = letters.replace(/[^A-Z]/g, '#'); + } const existing = map.get(letters) ?? new Set(); existing.add(filename); @@ -343,15 +345,46 @@ export default class OutputFactory { 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(/[\\/].+$/, ''); + subPathMap.set(subPath, [...subPathMap.get(subPath) ?? [], filename]); + return subPathMap; + }, new Map()); + const tuples = [...subPathsToFilenames.entries()] + .sort(([subPathOne], [subPathTwo]) => subPathOne.localeCompare(subPathTwo)) + .map(([, subPathFilenames]) => [ + letter, + new Set(subPathFilenames), + ] satisfies [string, Set]); + return [...arr, ...tuples]; + }, [] as [string, Set][]) + // Group letters together to create letter ranges + .reduce(ArrayPoly.reduceChunk(options.getDirLetterLimit()), []) + .reduce((map, tuples) => { + const firstTuple = tuples.at(0) as [string, Set]; + const lastTuple = tuples.at(-1) as [string, Set]; + 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]) => { - if (filenames.size <= options.getDirLetterLimit()) { - lettersMap.set(letter, new Set(filenames)); - return lettersMap; - } - // 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 @@ -363,6 +396,11 @@ export default class OutputFactory { 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) { diff --git a/test/modules/argumentsParser.test.ts b/test/modules/argumentsParser.test.ts index f92bd28f1..e32655459 100644 --- a/test/modules/argumentsParser.test.ts +++ b/test/modules/argumentsParser.test.ts @@ -113,6 +113,7 @@ describe('options', () => { expect(options.getDirLetter()).toEqual(false); expect(options.getDirLetterCount()).toEqual(1); expect(options.getDirLetterLimit()).toEqual(0); + expect(options.getDirLetterGroup()).toEqual(false); expect(options.getDirGameSubdir()).toEqual(GameSubdirMode.MULTIPLE); expect(options.getOverwrite()).toEqual(false); expect(options.getOverwriteInvalid()).toEqual(false); @@ -368,6 +369,16 @@ describe('options', () => { expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-letter', '--dir-letter-limit', '5', '--dir-letter-limit', '10']).getDirLetterLimit()).toEqual(10); }); + it('should parse "dir-letter-group"', () => { + expect(() => argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-letter-group'])).toThrow(/dependent|implication/i); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-letter', '--dir-letter-limit', '1', '--dir-letter-group']).getDirLetterGroup()).toEqual(true); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-letter', '--dir-letter-limit', '1', '--dir-letter-group', 'true']).getDirLetterGroup()).toEqual(true); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-letter', '--dir-letter-limit', '1', '--dir-letter-group', 'false']).getDirLetterGroup()).toEqual(false); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-letter', '--dir-letter-limit', '1', '--dir-letter-group', '--dir-letter-group']).getDirLetterGroup()).toEqual(true); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-letter', '--dir-letter-limit', '1', '--dir-letter-group', 'false', '--dir-letter-group', 'true']).getDirLetterGroup()).toEqual(true); + expect(argumentsParser.parse([...dummyCommandAndRequiredArgs, '--dir-letter', '--dir-letter-limit', '1', '--dir-letter-group', 'true', '--dir-letter-group', 'false']).getDirLetterGroup()).toEqual(false); + }); + it('should parse "dir-game-subdir"', () => { expect(argumentsParser.parse(dummyCommandAndRequiredArgs).getDirGameSubdir()) .toEqual(GameSubdirMode.MULTIPLE); diff --git a/test/modules/candidatePostProcessor.test.ts b/test/modules/candidatePostProcessor.test.ts index 8e6c196db..337c3de72 100644 --- a/test/modules/candidatePostProcessor.test.ts +++ b/test/modules/candidatePostProcessor.test.ts @@ -164,13 +164,138 @@ describe('dirLetterLimit', () => { path.join('Output', 'D2', 'disk1_Confident.rom'), path.join('Output', 'D3', 'disk1_Cool.rom'), ]], - ])('it should split the letter dirs: %s', async (limit, expectedFilePaths) => { + ])('it should split the letter dirs based on limit: %s', async (dirLetterLimit, expectedFilePaths) => { const options = new Options({ commands: ['copy'], output: 'Output', dirLetter: true, dirLetterCount: 1, - dirLetterLimit: limit, + dirLetterLimit, + dirGameSubdir: GameSubdirMode[GameSubdirMode.MULTIPLE].toLowerCase(), + }); + + const parentsToCandidates = await runCandidatePostProcessor(options); + + const outputFilePaths = [...parentsToCandidates.values()] + .flat() + .flatMap((releaseCandidate) => releaseCandidate.getRomsWithFiles()) + .map((romWithFiles) => romWithFiles.getOutputFile().getFilePath()) + .sort(); + expect(outputFilePaths).toEqual(expectedFilePaths); + }); +}); + +describe('dirLetterGroup', () => { + test.each([ + [1, undefined, [ + // This isn't realistic, but we should have a test case for it + path.join('Output', 'A-D', 'Admirable.rom'), + path.join('Output', 'A-D', 'Adorable.rom'), + path.join('Output', 'A-D', 'Adventurous.rom'), + path.join('Output', 'A-D', 'Amazing.rom'), + path.join('Output', 'A-D', 'Awesome.rom'), + path.join('Output', 'A-D', 'Best.rom'), + path.join('Output', 'A-D', 'Brilliant.rom'), + path.join('Output', 'A-D', 'Dainty', 'Dainty (Track 01).bin'), + path.join('Output', 'A-D', 'Dainty', 'Dainty.cue'), + path.join('Output', 'A-D', 'Daring', 'Daring (Track 01).bin'), + path.join('Output', 'A-D', 'Daring', 'Daring.cue'), + path.join('Output', 'A-D', 'Dazzling', 'Dazzling (Track 01).bin'), + path.join('Output', 'A-D', 'Dazzling', 'Dazzling.cue'), + path.join('Output', 'A-D', 'Dedicated', 'Dedicated (Track 01).bin'), + path.join('Output', 'A-D', 'Dedicated', 'Dedicated.cue'), + path.join('Output', 'A-D', 'disk1_Cheerful.rom'), + path.join('Output', 'A-D', 'disk1_Confident.rom'), + path.join('Output', 'A-D', 'disk1_Cool.rom'), + ]], + [1, 2, [ + path.join('Output', 'A-A1', 'Admirable.rom'), + path.join('Output', 'A-A1', 'Adorable.rom'), + path.join('Output', 'A-A2', 'Adventurous.rom'), + path.join('Output', 'A-A2', 'Amazing.rom'), + path.join('Output', 'A-B', 'Awesome.rom'), + path.join('Output', 'A-B', 'Best.rom'), + path.join('Output', 'B-D', 'Brilliant.rom'), + path.join('Output', 'B-D', 'Dainty', 'Dainty (Track 01).bin'), + path.join('Output', 'B-D', 'Dainty', 'Dainty.cue'), + path.join('Output', 'D-D1', 'Daring', 'Daring (Track 01).bin'), + path.join('Output', 'D-D1', 'Daring', 'Daring.cue'), + path.join('Output', 'D-D1', 'Dazzling', 'Dazzling (Track 01).bin'), + path.join('Output', 'D-D1', 'Dazzling', 'Dazzling.cue'), + path.join('Output', 'D-D2', 'Dedicated', 'Dedicated (Track 01).bin'), + path.join('Output', 'D-D2', 'Dedicated', 'Dedicated.cue'), + path.join('Output', 'D-D2', 'disk1_Cheerful.rom'), + path.join('Output', 'D-D3', 'disk1_Confident.rom'), + path.join('Output', 'D-D3', 'disk1_Cool.rom'), + ]], + [1, 3, [ + path.join('Output', 'A-A', 'Admirable.rom'), + path.join('Output', 'A-A', 'Adorable.rom'), + path.join('Output', 'A-A', 'Adventurous.rom'), + path.join('Output', 'A-B', 'Amazing.rom'), + path.join('Output', 'A-B', 'Awesome.rom'), + path.join('Output', 'A-B', 'Best.rom'), + path.join('Output', 'B-D', 'Brilliant.rom'), + path.join('Output', 'B-D', 'Dainty', 'Dainty (Track 01).bin'), + path.join('Output', 'B-D', 'Dainty', 'Dainty.cue'), + path.join('Output', 'B-D', 'Daring', 'Daring (Track 01).bin'), + path.join('Output', 'B-D', 'Daring', 'Daring.cue'), + path.join('Output', 'D-D1', 'Dazzling', 'Dazzling (Track 01).bin'), + path.join('Output', 'D-D1', 'Dazzling', 'Dazzling.cue'), + path.join('Output', 'D-D1', 'Dedicated', 'Dedicated (Track 01).bin'), + path.join('Output', 'D-D1', 'Dedicated', 'Dedicated.cue'), + path.join('Output', 'D-D1', 'disk1_Cheerful.rom'), + path.join('Output', 'D-D2', 'disk1_Confident.rom'), + path.join('Output', 'D-D2', 'disk1_Cool.rom'), + ]], + [2, 3, [ + path.join('Output', 'AD-AD', 'Admirable.rom'), + path.join('Output', 'AD-AD', 'Adorable.rom'), + path.join('Output', 'AD-AD', 'Adventurous.rom'), + path.join('Output', 'AM-BE', 'Amazing.rom'), + path.join('Output', 'AM-BE', 'Awesome.rom'), + path.join('Output', 'AM-BE', 'Best.rom'), + path.join('Output', 'BR-DA', 'Brilliant.rom'), + path.join('Output', 'BR-DA', 'Dainty', 'Dainty (Track 01).bin'), + path.join('Output', 'BR-DA', 'Dainty', 'Dainty.cue'), + path.join('Output', 'BR-DA', 'Daring', 'Daring (Track 01).bin'), + path.join('Output', 'BR-DA', 'Daring', 'Daring.cue'), + path.join('Output', 'DA-DI', 'Dazzling', 'Dazzling (Track 01).bin'), + path.join('Output', 'DA-DI', 'Dazzling', 'Dazzling.cue'), + path.join('Output', 'DA-DI', 'Dedicated', 'Dedicated (Track 01).bin'), + path.join('Output', 'DA-DI', 'Dedicated', 'Dedicated.cue'), + path.join('Output', 'DA-DI', 'disk1_Cheerful.rom'), + path.join('Output', 'DI-DI', 'disk1_Confident.rom'), + path.join('Output', 'DI-DI', 'disk1_Cool.rom'), + ]], + [3, 4, [ + path.join('Output', 'ADM-AMA', 'Admirable.rom'), + path.join('Output', 'ADM-AMA', 'Adorable.rom'), + path.join('Output', 'ADM-AMA', 'Adventurous.rom'), + path.join('Output', 'ADM-AMA', 'Amazing.rom'), + path.join('Output', 'AWE-DAI', 'Awesome.rom'), + path.join('Output', 'AWE-DAI', 'Best.rom'), + path.join('Output', 'AWE-DAI', 'Brilliant.rom'), + path.join('Output', 'AWE-DAI', 'Dainty', 'Dainty (Track 01).bin'), + path.join('Output', 'AWE-DAI', 'Dainty', 'Dainty.cue'), + path.join('Output', 'DAR-DIS', 'Daring', 'Daring (Track 01).bin'), + path.join('Output', 'DAR-DIS', 'Daring', 'Daring.cue'), + path.join('Output', 'DAR-DIS', 'Dazzling', 'Dazzling (Track 01).bin'), + path.join('Output', 'DAR-DIS', 'Dazzling', 'Dazzling.cue'), + path.join('Output', 'DAR-DIS', 'Dedicated', 'Dedicated (Track 01).bin'), + path.join('Output', 'DAR-DIS', 'Dedicated', 'Dedicated.cue'), + path.join('Output', 'DAR-DIS', 'disk1_Cheerful.rom'), + path.join('Output', 'DIS-DIS', 'disk1_Confident.rom'), + path.join('Output', 'DIS-DIS', 'disk1_Cool.rom'), + ]], + ])('it should group based on count & limit: %s, %s', async (dirLetterCount, dirLetterLimit, expectedFilePaths) => { + const options = new Options({ + commands: ['copy'], + output: 'Output', + dirLetter: true, + dirLetterCount, + dirLetterLimit, + dirLetterGroup: true, dirGameSubdir: GameSubdirMode[GameSubdirMode.MULTIPLE].toLowerCase(), });