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:
Huang Xin
2026-05-30 16:30:54 +08:00
committed by GitHub
parent 78794499a2
commit 6605ae8242
2 changed files with 128 additions and 1 deletions
@@ -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