diff --git a/__test__/main/chrome.js b/__test__/main/chrome.js index ef84ac38..e76a4d8b 100644 --- a/__test__/main/chrome.js +++ b/__test__/main/chrome.js @@ -20,6 +20,12 @@ class Storage { async set(items) { Object.assign(this.data, items); } + + async remove(keys) { + for (const key of keys) { + delete this.data[key]; + } + } } // Mimics chrome.events.Event. Tests can fire registered listeners via dispatch(). diff --git a/__test__/main/core/generator.test.js b/__test__/main/core/generator.test.js index 78abe5ab..9058dfcb 100644 --- a/__test__/main/core/generator.test.js +++ b/__test__/main/core/generator.test.js @@ -25,7 +25,7 @@ test("Generator should return empty HTML when no words are provided", () => { 🔊
- + テスト `, @@ -39,7 +39,7 @@ test("Generator should return empty HTML when no words are provided", () => { 🔊
- + テスト ■TEST `, diff --git a/__test__/options/logic/dict.test.ts b/__test__/options/logic/dict.test.ts new file mode 100644 index 00000000..533dd97e --- /dev/null +++ b/__test__/options/logic/dict.test.ts @@ -0,0 +1,123 @@ +/** + * Mouse Dictionary (https://github.com/wtetsu/mouse-dictionary/) + * Copyright 2018-present wtetsu + * Licensed under MIT + */ + +import { beforeEach, expect, test, vi } from "vitest"; +import Chrome from "../../main/chrome"; + +// dict.ts reads pack data via fetch(chrome.runtime.getURL(...)). +// Serve fake pack files from memory; unknown paths answer like a missing +// file (not ok), which exercises the registry fallback. +const PACK_FILES: Record = { + "/data/packs.json": [ + { id: "en-ja", metaFile: "/data/dict.json" }, + { id: "en-fr", metaFile: "/data/dict-en-fr.json" }, + { id: "ja-fr", metaFile: "/data/dict-ja-fr.json" }, + ], + "/data/dict.json": { files: ["/data/dict0.json"] }, + // "constructor" mirrors real ejdict-hand data (data/dict/a.json5 has it): + // a bare existing[head] lookup falls through to Object.prototype.constructor. + "/data/dict0.json": { cat: "キャット", dog: "イヌ", constructor: "構築者" }, + "/data/dict-en-fr.json": { files: ["/data/dict-en-fr0.json"] }, + "/data/dict-en-fr0.json": { cat: "chat", water: "eau", constructor: "constructeur" }, + "/data/dict-ja-fr.json": { files: ["/data/dict-ja-fr0.json"] }, + "/data/dict-ja-fr0.json": { 猫: "chat", 水: "eau" }, +}; + +const fetchMock = (url: string) => { + // the test mock builds URLs as `chrome-extension://test/${path}`; with the + // leading slash in our paths that yields a double slash — normalize it + const path = String(url).replace("chrome-extension://test", "").replace(/^\/\//, "/"); + const data = PACK_FILES[path]; + if (data === undefined) { + return { ok: false } as Response; + } + return { ok: true, json: async () => data } as Response; +}; + +beforeEach(() => { + global.chrome = new Chrome() as any; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => fetchMock(url)), + ); +}); + +test("getPacks reads the generated manifest", async () => { + const dict = await import("../../../src/options/logic/dict"); + const packs = await dict.getPacks(); + expect(packs.map((p) => p.id)).toEqual(["en-ja", "en-fr", "ja-fr"]); +}); + +test("getPacks falls back to the single-dictionary layout without a manifest", async () => { + const saved = PACK_FILES["/data/packs.json"]; + delete PACK_FILES["/data/packs.json"]; + const dict = await import("../../../src/options/logic/dict"); + const packs = await dict.getPacks(); + expect(packs).toEqual([{ id: "en-ja", metaFile: "/data/dict.json" }]); + // registration still works with the fallback registry + const count = await dict.registerDefaultDict(() => {}); + expect(count).toBe(3); + PACK_FILES["/data/packs.json"] = saved; +}); + +test("getPacks falls back when fetching the manifest throws", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }), + ); + const dict = await import("../../../src/options/logic/dict"); + expect(await dict.getPacks()).toEqual([{ id: "en-ja", metaFile: "/data/dict.json" }]); + // restore the file-based mock for subsequent tests + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => fetchMock(url)), + ); +}); + +test("registerPacks merges descriptions when two packs share a headword", async () => { + const dict = await import("../../../src/options/logic/dict"); + await dict.registerPacks(["en-ja", "en-fr"], () => {}); + + const stored = await global.chrome.storage.local.get(["cat", "water", "dog", "constructor"]); + expect(stored.cat).toBe("キャット / chat"); + expect(stored.water).toBe("eau"); + expect(stored.dog).toBe("イヌ"); + // "constructor" is a real English headword in both packs; the lookup must + // not fall through to Object.prototype.constructor (regression: crash on + // first-run registration when storage was empty). + expect(stored.constructor).toBe("構築者 / constructeur"); +}); + +test("registerPacks ignores unknown pack ids", async () => { + const dict = await import("../../../src/options/logic/dict"); + const count = await dict.registerPacks(["en-ja", "no-such-pack"], () => {}); + expect(count).toBe(3); +}); + +test("syncInstalledPacks registers new packs and removes unselected ones", async () => { + const dict = await import("../../../src/options/logic/dict"); + + await dict.syncInstalledPacks(["en-ja", "ja-fr"], () => {}); + let stored = await global.chrome.storage.local.get(["cat", "猫"]); + expect(stored.cat).toBe("キャット"); + expect(stored["猫"]).toBe("chat"); + + // Drop ja-fr: its keys are removed, en-ja keys are re-registered. + await dict.syncInstalledPacks(["en-ja"], () => {}); + stored = await global.chrome.storage.local.get(["cat", "猫"]); + expect(stored["猫"]).toBeUndefined(); + expect(stored.cat).toBe("キャット"); +}); + +test("syncInstalledPacks survives a stale id whose pack no longer exists", async () => { + const dict = await import("../../../src/options/logic/dict"); + await dict.syncInstalledPacks(["en-ja"], () => {}); + await global.chrome.storage.local.set({ "***** dict_packs *****": ["ghost"] }); + const res = await dict.syncInstalledPacks(["en-ja"], () => {}); + expect(res.registered).toBe(3); +}); diff --git a/__test__/options/logic/packs.test.ts b/__test__/options/logic/packs.test.ts new file mode 100644 index 00000000..3de78765 --- /dev/null +++ b/__test__/options/logic/packs.test.ts @@ -0,0 +1,89 @@ +/** + * Mouse Dictionary (https://github.com/wtetsu/mouse-dictionary/) + * Copyright 2018-present wtetsu + * Licensed under MIT + */ + +import { describe, expect, test } from "vitest"; +import { + DEFAULT_PACK_IDS, + FALLBACK_PACKS, + mergeDescriptions, + packIdsFromSettings, +} from "../../../src/options/logic/packs"; + +const REGISTRY = [ + { id: "en-ja", metaFile: "/data/dict.json" }, + { id: "en-fr", metaFile: "/data/dict-en-fr.json" }, + { id: "de-fr", metaFile: "/data/dict-de-fr.json" }, +]; + +describe("mergeDescriptions", () => { + test("returns incoming when there is no existing value", () => { + expect(mergeDescriptions(undefined, "value")).toBe("value"); + }); + + test("returns existing when incoming is empty", () => { + expect(mergeDescriptions("existing", "")).toBe("existing"); + }); + + test("returns existing when identical", () => { + expect(mergeDescriptions("same", "same")).toBe("same"); + }); + + test("joins different descriptions with separator", () => { + expect(mergeDescriptions("AAA", "BBB")).toBe("AAA / BBB"); + }); + + test("skips incoming when it is already contained in existing", () => { + expect(mergeDescriptions("AAA / BBB", "BBB")).toBe("AAA / BBB"); + }); + + test("prefers incoming when it contains existing entirely", () => { + expect(mergeDescriptions("BBB", "AAA / BBB")).toBe("AAA / BBB"); + }); + + test("treats non-string values (e.g. Object.prototype leak) as absent", () => { + expect(mergeDescriptions((() => {}) as unknown as string, "real")).toBe("real"); + expect(mergeDescriptions("real", undefined)).toBe("real"); + }); +}); + +describe("packIdsFromSettings", () => { + test("undefined selection falls back to defaults present in the registry", () => { + expect(packIdsFromSettings(REGISTRY, undefined)).toEqual(DEFAULT_PACK_IDS); + }); + + test("empty selection falls back to defaults", () => { + expect(packIdsFromSettings(REGISTRY, [])).toEqual(DEFAULT_PACK_IDS); + }); + + test("unknown ids are ignored, falling back to defaults", () => { + expect(packIdsFromSettings(REGISTRY, ["no-such-pack"])).toEqual(DEFAULT_PACK_IDS); + }); + + test("valid ids pass through in order", () => { + expect(packIdsFromSettings(REGISTRY, ["de-fr", "en-ja"])).toEqual(["de-fr", "en-ja"]); + }); + + test("invalid ids are filtered out, valid ones kept", () => { + expect(packIdsFromSettings(REGISTRY, ["en-fr", "bogus"])).toEqual(["en-fr"]); + }); + + test("defaults missing from the registry fall back to the first available pack", () => { + const registry = [{ id: "xx-yy", metaFile: "/data/dict-xx-yy.json" }]; + expect(packIdsFromSettings(registry, undefined)).toEqual(["xx-yy"]); + }); +}); + +describe("FALLBACK_PACKS", () => { + test("describes the historical single-dictionary layout", () => { + expect(FALLBACK_PACKS).toEqual([{ id: "en-ja", metaFile: "/data/dict.json" }]); + }); + + test("default pack ids exist in the fallback registry", () => { + for (const id of DEFAULT_PACK_IDS) { + expect(FALLBACK_PACKS.some((p) => p.id === id)).toBe(true); + } + }); +}); diff --git a/data/manifest/manifest-chrome.json b/data/manifest/manifest-chrome.json index 213016dc..81f91dfc 100644 --- a/data/manifest/manifest-chrome.json +++ b/data/manifest/manifest-chrome.json @@ -35,7 +35,7 @@ }, "web_accessible_resources": [ { - "resources": ["data/rule.json", "data/dict*.json"], + "resources": ["data/rule.json", "data/packs.json", "data/dict*.json"], "matches": [""] } ] diff --git a/src/main/lib/storage.ts b/src/main/lib/storage.ts index 214533ca..5647f756 100644 --- a/src/main/lib/storage.ts +++ b/src/main/lib/storage.ts @@ -17,6 +17,7 @@ const sync = { const local = { get: (keys?: Keys): Promise => ext().storage.local.get(keys), set: (items: Items): Promise => ext().storage.local.set(items), + remove: (keys: string[]): Promise => ext().storage.local.remove(keys), async pick(key: string): Promise { const data = await local.get([key]); return data?.[key] as T | undefined; diff --git a/src/main/settings.ts b/src/main/settings.ts index b2f3e153..413bbc3c 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -12,6 +12,7 @@ const defaultSettings: MouseDictionarySettings = { parseWordsLimit: 8, lookupWithCapitalized: false, initialPosition: "right", + dictionaryPacks: ["en-ja"], skipPdfConfirmation: false, pdfUrl: "", backgroundColor: "#ffffff", @@ -83,7 +84,7 @@ const defaultSettings: MouseDictionarySettings = { 🔊
- + {{{desc}}} {{/isShort}} @@ -91,7 +92,7 @@ const defaultSettings: MouseDictionarySettings = { {{head}} - + {{shortDesc}} {{/isShort}} diff --git a/src/main/types.ts b/src/main/types.ts index a42883af..af5caae5 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -16,6 +16,7 @@ export type MouseDictionaryBasicSettings = { width: number; height: number; skipPdfConfirmation: boolean; + dictionaryPacks: string[]; }; export type MouseDictionaryAdvancedSettings = { diff --git a/src/options/component/organism/DictionaryPacks.tsx b/src/options/component/organism/DictionaryPacks.tsx new file mode 100644 index 00000000..2cfe7e4b --- /dev/null +++ b/src/options/component/organism/DictionaryPacks.tsx @@ -0,0 +1,61 @@ +/** + * Mouse Dictionary (https://github.com/wtetsu/mouse-dictionary/) + * Copyright 2018-present wtetsu + * Licensed under MIT + */ + +import { useEffect, useState } from "react"; +import { res } from "../../logic"; +import type { DictionaryPack } from "../../types"; +import { Button } from "../atom/Button"; + +type Props = { + busy: boolean; + packs: DictionaryPack[]; + selectedPackIds: string[]; + onSync: (packIds: string[]) => void; +}; + +export const DictionaryPacks: React.FC = (props) => { + const [selected, setSelected] = useState>(new Set(props.selectedPackIds)); + + useEffect(() => { + setSelected(new Set(props.selectedPackIds)); + }, [props.selectedPackIds]); + + const toggle = (id: string) => { + const next = new Set(selected); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + setSelected(next); + }; + + const dirty = selected.size !== props.selectedPackIds.length || props.selectedPackIds.some((id) => !selected.has(id)); + + return ( +
+
+ {props.packs.map((pack) => ( + + ))} +
+
+ ); +}; diff --git a/src/options/component/organism/index.ts b/src/options/component/organism/index.ts index 3e5e5114..a62156cd 100644 --- a/src/options/component/organism/index.ts +++ b/src/options/component/organism/index.ts @@ -6,6 +6,7 @@ export * from "./AdvancedSettings"; export * from "./BasicSettings"; +export * from "./DictionaryPacks"; export * from "./LoadDictionary"; export * from "./OperationPanel"; export * from "./ReplaceRuleEditor"; diff --git a/src/options/logic/dict.ts b/src/options/logic/dict.ts index 81a9c0e1..625ed66a 100644 --- a/src/options/logic/dict.ts +++ b/src/options/logic/dict.ts @@ -5,9 +5,12 @@ */ import { env, storage } from "../extern"; -import type { DictionaryFileEncoding, DictionaryFileFormat } from "../types"; +import type { DictionaryFileEncoding, DictionaryFileFormat, DictionaryPack } from "../types"; import { EijiroParser, JsonDictParser, SimpleDictParser } from "./dictparser"; import { LineReader } from "./linereader"; +import { DEFAULT_PACK_IDS, loadPackRegistry, mergeDescriptions, packIdsFromSettings } from "./packs"; + +const KEY_INSTALLED_PACKS = "***** dict_packs *****"; type ProgressCallback = (wordCount: number, progress: string) => void; @@ -111,17 +114,84 @@ const createDictParser = (format: DictionaryFileFormat) => { }; export const registerDefaultDict = async (fnProgress: ProgressCallback): Promise => { - const dict = (await loadJsonFile("/data/dict.json")) as DictionaryInformation; - fnProgress(0, "0"); + return await registerPacks(DEFAULT_PACK_IDS, fnProgress); +}; + +// Pack registry from the generated manifest; small static file, fetched on +// the few user-triggered dictionary operations (no caching needed). +export const getPacks = async (): Promise => { + return await loadPackRegistry(); +}; + +// Register one dictionary pack (shards listed in its metadata file). +export const registerPack = async (pack: DictionaryPack, fnProgress: ProgressCallback): Promise => { + const dict = (await loadJsonFile(pack.metaFile)) as DictionaryInformation; let wordCount = 0; for (let i = 0; i < dict.files.length; i++) { wordCount += await registerDict(dict.files[i]); - const progress = `${i + 1}/${dict.files.length}`; - fnProgress(wordCount, progress); + fnProgress(wordCount, `${i + 1}/${dict.files.length}`); + } + return wordCount; +}; + +// Register multiple packs, merging descriptions when headwords collide. +// Unknown ids are ignored (the id may belong to a pack not bundled here). +export const registerPacks = async (packIds: string[], fnProgress: ProgressCallback): Promise => { + const registry = await getPacks(); + const packs = registry.filter((p) => packIds.includes(p.id)); + let wordCount = 0; + for (const pack of packs) { + wordCount += await registerPack(pack, fnProgress); } return wordCount; }; +// Synchronize the storage with the desired pack set: drop keys that no +// selected pack provides, then (re-)register every selected pack so values +// are rebuilt cleanly from pack data. +export const syncInstalledPacks = async ( + desiredIds: string[], + fnProgress: ProgressCallback, +): Promise<{ removed: number; registered: number }> => { + const registry = await getPacks(); + const desired = packIdsFromSettings(registry, desiredIds); + const installed = await getInstalledPacks(); + + let removed = 0; + const stale = installed.filter((id) => !desired.includes(id)); + for (const id of stale) { + // resolve stale ids against the current registry first; a pack removed + // from the manifest keeps its metaFile convention so we can still drop it + const pack = registry.find((p) => p.id === id) ?? { id, metaFile: `/data/dict-${id}.json` }; + try { + removed += await unregisterPack(pack); + } catch { + // metadata for an uninstalled pack is gone; keys stay until overwrite + } + } + + const registered = await registerPacks(desired, fnProgress); + await storage.local.set({ [KEY_INSTALLED_PACKS]: desired }); + return { removed, registered }; +}; + +const unregisterPack = async (pack: DictionaryPack): Promise => { + const dict = (await loadJsonFile(pack.metaFile)) as DictionaryInformation; + let count = 0; + for (const file of dict.files) { + const data = await loadJsonFile(file); + const keys = Object.keys(data); + await storage.local.remove(keys); + count += keys.length; + } + return count; +}; + +export const getInstalledPacks = async (): Promise => { + const packs = await storage.local.pick(KEY_INSTALLED_PACKS); + return Array.isArray(packs) ? packs : []; +}; + const loadJsonFile = async (fname: string): Promise> => { const url = chrome.runtime.getURL(fname); const response = await fetch(url); @@ -131,6 +201,15 @@ const loadJsonFile = async (fname: string): Promise> => { const registerDict = async (fname: string): Promise => { const dictData = await loadJsonFile(fname); const wordCount = Object.keys(dictData).length; - await storage.local.set(dictData); + // Merge with values already in storage (another pack may own the same headword). + const merged: Record = {}; + const existing = await storage.local.get(Object.keys(dictData)); + for (const [head, desc] of Object.entries(dictData)) { + // Own-property check only: keys like "constructor" hit Object.prototype + // through the chain and would leak built-in functions as "existing" values. + const prev = Object.hasOwn(existing, head) ? existing[head] : undefined; + merged[head] = mergeDescriptions(prev, desc); + } + await storage.local.set(merged); return wordCount; }; diff --git a/src/options/logic/packs.ts b/src/options/logic/packs.ts new file mode 100644 index 00000000..f75e5eb9 --- /dev/null +++ b/src/options/logic/packs.ts @@ -0,0 +1,70 @@ +/** + * Mouse Dictionary (https://github.com/wtetsu/mouse-dictionary/) + * Copyright 2018-present wtetsu + * Licensed under MIT + */ + +// Registry of bundled dictionary packs (language pairs). The registry is a +// generated manifest (/data/packs.json, written by tools/make_dict.ts) that +// lists one entry per pack: { id, metaFile, label? }. Each metaFile points to +// a metadata file listing that pack's lookup shards. Keeping the registry as +// data means adding a language pair never requires changing this module. + +import type { DictionaryPack } from "../types"; + +const PACKS_MANIFEST_FILE = "/data/packs.json"; + +// The historical single-dictionary layout. Used when /data/packs.json is +// missing or unreadable, which covers data bundles generated before this +// feature and stock builds: existing users always have a working default. +export const FALLBACK_PACKS: DictionaryPack[] = [{ id: "en-ja", metaFile: "/data/dict.json" }]; + +export const DEFAULT_PACK_IDS = ["en-ja"]; + +export const loadPackRegistry = async (): Promise => { + try { + const response = await fetch(chrome.runtime.getURL(PACKS_MANIFEST_FILE)); + if (!response.ok) { + return FALLBACK_PACKS; + } + const manifest = await response.json(); + const packs = (Array.isArray(manifest) ? manifest : []) + .filter((p: DictionaryPack) => typeof p?.id === "string" && typeof p?.metaFile === "string") + .map((p: DictionaryPack) => ({ + id: p.id, + metaFile: p.metaFile, + label: typeof p.label === "string" ? p.label : undefined, + })); + return packs.length > 0 ? packs : FALLBACK_PACKS; + } catch { + return FALLBACK_PACKS; + } +}; + +export const packIdsFromSettings = (available: DictionaryPack[], packs: string[] | undefined): string[] => { + const valid = (packs ?? DEFAULT_PACK_IDS).filter((id) => available.some((p) => p.id === id)); + if (valid.length > 0) { + return valid; + } + // fall back to the defaults that actually exist in this build + const fallback = DEFAULT_PACK_IDS.filter((id) => available.some((p) => p.id === id)); + return fallback.length > 0 ? fallback : available.map((p) => p.id).slice(0, 1); +}; + +// Join two descriptions of the same headword coming from different packs. +// Values that are not strings (e.g. prototype members leaked by key lookups +// such as "constructor") are treated as absent. +export const mergeDescriptions = (existing: unknown, incoming: unknown): string => { + const a = typeof existing === "string" ? existing : ""; + const b = typeof incoming === "string" ? incoming : ""; + if (!a) { + return b; + } + if (!b || a === b || a.includes(b)) { + return a; + } + if (b.includes(a)) { + return b; + } + return `${a} / ${b}`; +}; diff --git a/src/options/page/Main.tsx b/src/options/page/Main.tsx index cd7fcbe5..555e195d 100644 --- a/src/options/page/Main.tsx +++ b/src/options/page/Main.tsx @@ -10,6 +10,7 @@ import { Button, DataUsage, EditableSpan, ExternalLink, Launch, Overlay, Panel, import { AdvancedSettings, BasicSettings, + DictionaryPacks, LoadDictionary, OperationPanel, Tips, @@ -17,16 +18,18 @@ import { } from "../component/organism"; import { config, defaultSettings, env } from "../extern"; import { data, dict, message, res } from "../logic"; +import { DEFAULT_PACK_IDS } from "../logic/packs"; import { usePreview } from "../logic/preview"; import type { TextResourceKeys } from "../resource"; -import type { DictionaryFile, MouseDictionarySettings } from "../types"; +import type { DictionaryFile, DictionaryPack, MouseDictionarySettings } from "../types"; type MainState = { dictDataUsage?: number; busy: boolean; progress: string; settings: MouseDictionarySettings; + availablePacks: DictionaryPack[]; previewText: string; panelLevel: 0 | 1 | 2 | 3; lang: string; @@ -63,13 +66,14 @@ const initialState: MainState = { busy: false, progress: "", settings: {} as MouseDictionarySettings, + availablePacks: [], previewText: "rained cats and dogs", panelLevel: 0, lang: "", initialized: false, }; -type UpdateState = (state: Partial) => void; +type UpdateState = (state: Partial, settingsPatch?: Partial) => void; export const Main: React.FC = () => { const [state, dispatch] = useReducer(reducer, { @@ -86,6 +90,7 @@ export const Main: React.FC = () => { updateState({ settings }); const isLoaded = await config.isDataReady(); + updateState({ availablePacks: await dict.getPacks() }); if (!isLoaded) { const ok = await confirmAndLoadInitialDict("confirmLoadInitialDict", updateState); updateState({ initialized: ok, dictDataUsage: -1 }); @@ -204,6 +209,12 @@ export const Main: React.FC = () => { disabled={state.busy} onClick={() => confirmAndLoadInitialDict("confirmReloadInitialDict", updateState)} /> + applyDictionaryPacks(packIds, state.settings, updateState)} + />
@@ -255,6 +266,26 @@ const saveSettings = async (rawSettings: MouseDictionarySettings): Promise } }; +const applyDictionaryPacks = async ( + packIds: string[], + settings: MouseDictionarySettings, + updateState: UpdateState, +): Promise => { + try { + updateState({ busy: true, panelLevel: 0 }, { dictionaryPacks: packIds }); + await saveSettings({ ...settings, dictionaryPacks: packIds }); + const { registered } = await dict.syncInstalledPacks(packIds, (count, progress) => { + updateState({ progress: res.get("progressRegister", { count: count.toLocaleString(), progress }) }); + }); + await config.setDataReady(true); + await message.success(res.get("finishSyncPacks", { count: registered.toLocaleString() })); + } catch (e) { + showError(e); + } finally { + updateState({ busy: false, progress: "", dictDataUsage: -1 }); + } +}; + const confirmAndLoadInitialDict = async (messageId: TextResourceKeys, updateState: UpdateState): Promise => { const willLoad = await message.confirm(res.get(messageId)); if (!willLoad) { diff --git a/src/options/resource/en.ts b/src/options/resource/en.ts index 0f92eedf..d4abefbc 100644 --- a/src/options/resource/en.ts +++ b/src/options/resource/en.ts @@ -22,6 +22,8 @@ const EnglishTextResource: TextResource = { dictDataUsage: "{{{size}}} kilobytes used", confirmLoadInitialDict: "There is no dictionary data yet.\nDo you want to register free dictionary data?", confirmReloadInitialDict: "Are you sure you want to reload the default dictionary data?", + applyDictionaryPacks: "Apply dictionary packs", + finishSyncPacks: "Dictionary packs updated ({{{count}}} words)", fileMayNotBeShiftJis: "The selected file may not be Shift_JIS-encoded.", formatEijiroText: "EIJIRO text data", diff --git a/src/options/resource/ja.ts b/src/options/resource/ja.ts index 67005bed..fb547f57 100644 --- a/src/options/resource/ja.ts +++ b/src/options/resource/ja.ts @@ -17,6 +17,8 @@ const JapaneseTextResource: TextResource = { dictDataUsage: "{{{size}}}KB使用中", confirmLoadInitialDict: "まだ辞書データが無いようです。\nフリー辞書のデータを自動登録します。", confirmReloadInitialDict: "デフォルト辞書データを再登録します", + applyDictionaryPacks: "辞書パックを適用", + finishSyncPacks: "辞書パックを更新しました({{{count}}}語)", fileMayNotBeShiftJis: "このファイルはShift_JISではないかもしれません。", formatEijiroText: "英辞郎テキストデータ(■見出し)", diff --git a/src/options/resource/types.ts b/src/options/resource/types.ts index b8822d29..542e2bc8 100644 --- a/src/options/resource/types.ts +++ b/src/options/resource/types.ts @@ -14,6 +14,8 @@ export type TextResource = { dictDataUsage: string; confirmLoadInitialDict: string; confirmReloadInitialDict: string; + applyDictionaryPacks: string; + finishSyncPacks: string; fileMayNotBeShiftJis: string; formatEijiroText: string; formatTsv: string; diff --git a/src/options/types.ts b/src/options/types.ts index 97a84a5e..87f7dc5f 100644 --- a/src/options/types.ts +++ b/src/options/types.ts @@ -45,6 +45,14 @@ export type DictionaryFile = { format: DictionaryFileFormat; }; +// A bundled dictionary pack (one language pair), listed in the generated +// /data/packs.json manifest; `metaFile` holds its shard list. +export type DictionaryPack = { + id: string; + metaFile: string; + label?: string; +}; + export type ExternalLinks = { windowManipulation: string; downloadDictData: string; diff --git a/tools/make_dict.ts b/tools/make_dict.ts index 6af10f10..56aca37c 100644 --- a/tools/make_dict.ts +++ b/tools/make_dict.ts @@ -91,13 +91,31 @@ const uniteJsonFiles = (fileGlobList: string[]) => { return resultData; }; +// Bundled dictionary packs. The generated /data/packs.json manifest is the +// runtime source of truth for the options-page pack selector, so adding a +// language pair here (from: shards glob, to: metadata name) is all it takes. +type Pack = { id: string; from: string[]; to: string; split: number; label?: string }; + +const DICTIONARY_PACKS: Pack[] = [{ id: "en-ja", from: ["data/dict/[a-z].json5"], to: "data/dict", split: 10 }]; + +const writePacksManifest = (packs: Pack[], outputDirPath: string) => { + const manifest = packs.map((pack) => ({ + id: pack.id, + metaFile: `/${pack.to}.json`, + ...(pack.label ? { label: pack.label } : {}), + })); + const outputPath = path.join(outputDirPath, "data/packs.json"); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(manifest), "utf-8"); + console.info(`✅ Generated: ${outputPath}`); +}; + +export { DICTIONARY_PACKS, main, writePacksManifest }; + if (import.meta.main) { - main( - { - from: ["data/dict/[a-z].json5"], - to: "data/dict", - split: 10, - }, - "static/gen", - ); + const outputDirPath = "static/gen"; + for (const pack of DICTIONARY_PACKS) { + main(pack, outputDirPath); + } + writePacksManifest(DICTIONARY_PACKS, outputDirPath); }