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(),
});