feat(dictionary): import companion MDD files that share the MDX prefix (#4363)
A single MDX commonly ships its resources across several MDD files sharing the MDX's filename prefix (e.g. `Name.mdd` for images, `Name-02.mdd` for scripts, `Name-03.mdd` for audio). The importer only grouped the exact-stem `Name.mdd`, so the other MDDs were dropped as orphans and their resources (notably audio) could never be loaded. `groupBundlesByStem` now attaches every `.mdd` whose stem starts with an `.mdx` stem at a separator boundary to that MDX bundle (longest prefix wins on overlap); the boundary check prevents false merges like `dict.mdx` claiming `dictionary-words.mdd`. The runtime provider, contentId, and replica sync (binary upload + manifest apply) already treat `files.mdd` as a list, so multi-MDD bundles sync across devices with no further changes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* groupBundlesByStem — MDict companion-MDD attachment.
|
||||
*
|
||||
* A single MDX often ships its resources across several MDD files named with a
|
||||
* shared prefix (`Name.mdd`, `Name-01.mdd`, `Name.1.mdd`, …) — images in one,
|
||||
* scripts in another, audio in a third. The importer must attach every MDD
|
||||
* whose stem starts with the MDX stem (at a separator boundary) to that MDX
|
||||
* bundle; `.css` is pooled by name-independent rule (unchanged).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { groupBundlesByStem } from '@/services/dictionaries/dictionaryService';
|
||||
import type { SelectedFile } from '@/hooks/useFileSelector';
|
||||
|
||||
const sf = (name: string): SelectedFile => ({ path: `/books/${name}` });
|
||||
|
||||
const mdictBundle = (files: string[]) => {
|
||||
const { bundles, orphans } = groupBundlesByStem(files.map(sf));
|
||||
const mdict = bundles.find((b) => b.kind === 'mdict');
|
||||
return {
|
||||
mdx: mdict && mdict.kind === 'mdict' ? mdict.mdx.name : null,
|
||||
mdds: mdict && mdict.kind === 'mdict' ? mdict.mdd.map((m) => m.name).sort() : [],
|
||||
css: mdict && mdict.kind === 'mdict' ? mdict.css.map((c) => c.name).sort() : [],
|
||||
bundleCount: bundles.filter((b) => b.kind === 'mdict').length,
|
||||
orphans: orphans.map((o) => o.name).sort(),
|
||||
};
|
||||
};
|
||||
|
||||
describe('groupBundlesByStem — MDict companion MDDs', () => {
|
||||
it('attaches dash-numbered companion MDDs (vocabulary.com layout)', () => {
|
||||
const r = mdictBundle([
|
||||
'Vocabulary.com Dictionary.mdx',
|
||||
'Vocabulary.com Dictionary.mdd',
|
||||
'Vocabulary.com Dictionary-01.mdd',
|
||||
'Vocabulary.com Dictionary-02.mdd',
|
||||
'Vocabulary.com Dictionary-03.mdd',
|
||||
]);
|
||||
expect(r.mdx).toBe('Vocabulary.com Dictionary.mdx');
|
||||
expect(r.mdds).toEqual([
|
||||
'Vocabulary.com Dictionary-01.mdd',
|
||||
'Vocabulary.com Dictionary-02.mdd',
|
||||
'Vocabulary.com Dictionary-03.mdd',
|
||||
'Vocabulary.com Dictionary.mdd',
|
||||
]);
|
||||
expect(r.orphans).toEqual([]);
|
||||
});
|
||||
|
||||
it('attaches dot-numbered companion MDDs (.1/.2 convention)', () => {
|
||||
const r = mdictBundle(['Base.mdx', 'Base.mdd', 'Base.1.mdd', 'Base.2.mdd']);
|
||||
expect(r.mdds).toEqual(['Base.1.mdd', 'Base.2.mdd', 'Base.mdd']);
|
||||
expect(r.orphans).toEqual([]);
|
||||
});
|
||||
|
||||
it('still attaches the single exact-stem MDD (existing behavior)', () => {
|
||||
const r = mdictBundle(['Base.mdx', 'Base.mdd']);
|
||||
expect(r.mdds).toEqual(['Base.mdd']);
|
||||
expect(r.orphans).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not merge an unrelated MDD that only shares a word prefix', () => {
|
||||
// `dict` is a prefix of `dictionary-words` as a string, but the next char
|
||||
// ('i') is not a separator, so it must NOT attach.
|
||||
const r = mdictBundle(['dict.mdx', 'dict.mdd', 'dictionary-words.mdd']);
|
||||
expect(r.mdds).toEqual(['dict.mdd']);
|
||||
expect(r.orphans).toEqual(['dictionary-words.mdd']);
|
||||
});
|
||||
|
||||
it('attaches a companion MDD to the longest matching MDX prefix', () => {
|
||||
const { bundles } = groupBundlesByStem(
|
||||
['dict.mdx', 'dictionary.mdx', 'dictionary-01.mdd'].map(sf),
|
||||
);
|
||||
const dictionary = bundles.find((b) => b.kind === 'mdict' && b.mdx.name === 'dictionary.mdx');
|
||||
const dict = bundles.find((b) => b.kind === 'mdict' && b.mdx.name === 'dict.mdx');
|
||||
expect(
|
||||
dictionary && dictionary.kind === 'mdict' ? dictionary.mdd.map((m) => m.name) : null,
|
||||
).toEqual(['dictionary-01.mdd']);
|
||||
expect(dict && dict.kind === 'mdict' ? dict.mdd.map((m) => m.name) : null).toEqual([]);
|
||||
});
|
||||
|
||||
it('pools .css of any name onto the MDict bundle (unchanged)', () => {
|
||||
const r = mdictBundle(['Base.mdx', 'Base-01.mdd', 'arbitrary-name.css']);
|
||||
expect(r.mdds).toEqual(['Base-01.mdd']);
|
||||
expect(r.css).toEqual(['arbitrary-name.css']);
|
||||
});
|
||||
|
||||
it('orphans companion MDDs when no MDX is present', () => {
|
||||
const r = mdictBundle(['Base-01.mdd', 'Base-02.mdd']);
|
||||
expect(r.bundleCount).toBe(0);
|
||||
expect(r.orphans).toEqual(['Base-01.mdd', 'Base-02.mdd']);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,21 @@ function classify(source: SelectedFile): SourceFile {
|
||||
return { name, stem, ext, isDictZip, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `mddStem` is a numbered companion of the MDict bundle keyed by
|
||||
* `bundleStem` — i.e. `bundleStem` followed by a separator (`Name-01`,
|
||||
* `Name.1`, `Name 2`, …). A non-alphanumeric boundary is required so a
|
||||
* different word can't match (`dict` must not claim `dictionary-words`). The
|
||||
* exact-stem `Name.mdd` is already attached by the stem pass, so an exact
|
||||
* match returns false here.
|
||||
*/
|
||||
function isCompanionMddStem(bundleStem: string, mddStem: string): boolean {
|
||||
if (mddStem === bundleStem) return false;
|
||||
if (!mddStem.startsWith(bundleStem)) return false;
|
||||
const boundary = mddStem.charAt(bundleStem.length);
|
||||
return boundary !== '' && !/[a-z0-9]/i.test(boundary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a flat list of selected files into StarDict and MDict bundles by
|
||||
* stem. Files that don't belong to any complete bundle land in `orphans`.
|
||||
@@ -120,7 +135,9 @@ function classify(source: SelectedFile): SourceFile {
|
||||
* Rules:
|
||||
* - StarDict bundle = exactly one `.ifo` + one `.idx` + one `.dict` or
|
||||
* `.dict.dz` (sharing a stem). `.syn` is optional.
|
||||
* - MDict bundle = one `.mdx` + zero or more `.mdd` (sharing a stem).
|
||||
* - MDict bundle = one `.mdx` + zero or more `.mdd` whose stem starts with the
|
||||
* `.mdx` stem at a separator boundary (`Name.mdd`, `Name-01.mdd`,
|
||||
* `Name.1.mdd`); each `.mdd` attaches to the longest matching `.mdx`.
|
||||
* - DICT (dictd) bundle = one `.index` + one `.dict` or `.dict.dz`
|
||||
* (sharing a stem). Note: `.idx` (StarDict) and `.index` (DICT) differ
|
||||
* only by spelling — the StarDict branch wins when both are present.
|
||||
@@ -166,6 +183,26 @@ export function groupBundlesByStem(files: SelectedFile[]): GroupResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Attach numbered companion MDDs. A single MDX often splits its resources
|
||||
// across several MDD files sharing its filename prefix (e.g. images in one,
|
||||
// scripts in another, audio in a third); the stem pass above only catches the
|
||||
// exact-stem `Name.mdd`. Rescue the rest from `orphans`, attaching each to the
|
||||
// MDict bundle whose stem is the longest matching prefix.
|
||||
const mdictForMdd = bundles
|
||||
.filter((b): b is MDictGroup => b.kind === 'mdict')
|
||||
.sort((a, b) => b.stem.length - a.stem.length);
|
||||
if (mdictForMdd.length > 0) {
|
||||
const stillOrphaned: SourceFile[] = [];
|
||||
for (const f of orphans) {
|
||||
const target =
|
||||
f.ext === 'mdd' ? mdictForMdd.find((b) => isCompanionMddStem(b.stem, f.stem)) : undefined;
|
||||
if (target) target.mdd.push(f);
|
||||
else stillOrphaned.push(f);
|
||||
}
|
||||
orphans.length = 0;
|
||||
orphans.push(...stillOrphaned);
|
||||
}
|
||||
|
||||
// Distribute all loose `.css` files across the MDict bundles in this
|
||||
// import. With one dictionary at a time (the common case) every selected
|
||||
// `.css` ends up applied; with multiple, each gets the full set — benign
|
||||
|
||||
Reference in New Issue
Block a user