diff --git a/apps/readest-app/src/__tests__/services/dictionaries/groupBundlesByStem.test.ts b/apps/readest-app/src/__tests__/services/dictionaries/groupBundlesByStem.test.ts new file mode 100644 index 00000000..6e69abff --- /dev/null +++ b/apps/readest-app/src/__tests__/services/dictionaries/groupBundlesByStem.test.ts @@ -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']); + }); +}); diff --git a/apps/readest-app/src/services/dictionaries/dictionaryService.ts b/apps/readest-app/src/services/dictionaries/dictionaryService.ts index 2c7d470e..ad9a3224 100644 --- a/apps/readest-app/src/services/dictionaries/dictionaryService.ts +++ b/apps/readest-app/src/services/dictionaries/dictionaryService.ts @@ -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