Skip to content

Commit

Permalink
Feature: dir-letter-group option (#900)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmercm authored Feb 1, 2024
1 parent 7bc5540 commit 24491f3
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 9 deletions.
6 changes: 6 additions & 0 deletions src/modules/argumentsParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
28 changes: 28 additions & 0 deletions src/polyfill/arrayPoly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ export default class ArrayPoly {
};
}

/**
* Reduce elements in an array to chunks of size {@link limit}.
*
* <code>
* [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].reduce(ArrayPoly.reduceChunk(3), []);
* </code>
*/
public static reduceChunk<T>(
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:
*
Expand Down
8 changes: 8 additions & 0 deletions src/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -186,6 +187,8 @@ export default class Options implements OptionsProps {

readonly dirLetterLimit: number;

readonly dirLetterGroup: boolean;

readonly dirGameSubdir?: string;

readonly overwrite: boolean;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
52 changes: 45 additions & 7 deletions src/types/outputFactory.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -334,24 +335,56 @@ 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);
map.set(letters, existing);
return map;
}, new Map<string, Set<string>>());

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<string, string[]>());
const tuples = [...subPathsToFilenames.entries()]
.sort(([subPathOne], [subPathTwo]) => subPathOne.localeCompare(subPathTwo))
.map(([, subPathFilenames]) => [
letter,
new Set(subPathFilenames),
] satisfies [string, Set<string>]);
return [...arr, ...tuples];
}, [] as [string, Set<string>][])
// Group letters together to create letter ranges
.reduce(ArrayPoly.reduceChunk(options.getDirLetterLimit()), [])
.reduce((map, tuples) => {
const firstTuple = tuples.at(0) as [string, Set<string>];
const lastTuple = tuples.at(-1) as [string, Set<string>];
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<string, Set<string>>());
}

// 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
Expand All @@ -363,6 +396,11 @@ export default class OutputFactory {
return subPathMap;
}, new Map<string, string[]>());

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) {
Expand Down
11 changes: 11 additions & 0 deletions test/modules/argumentsParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
129 changes: 127 additions & 2 deletions test/modules/candidatePostProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down

0 comments on commit 24491f3

Please sign in to comment.