feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+251
View File
@@ -0,0 +1,251 @@
// First-run setup state shared by the service worker, the startup page and Settings.
// SPDX-License-Identifier: GPL-3.0-or-later
import { RECOMMENDED_DICTIONARIES } from "./recommended-dictionaries.js";
export const SETUP_STATE_KEY = "setupState";
// Installation-local bookkeeping, also available when an embedded host skips setup.
export const RECOMMENDED_SELECTIONS_KEY = "recommendedDictionarySelections";
export const SETUP_STATE_SCHEMA_VERSION = 1;
export const STARTUP_PAGE = "startup.html";
// A new installation waits for informed Start setup before its automatic work.
// Existing records keep their stage, including runs already in progress.
export const SETUP_STAGES = Object.freeze(["welcome", "dictionaries", "anki", "practice", "complete"]);
export const SETUP_OUTCOME_STATUSES = Object.freeze(["installed", "already-installed", "failed"]);
export const SETUP_ANKI_STATUSES = Object.freeze(["configured", "already-configured", "unavailable", "needs-attention"]);
// Initial preferences for a new installation. They are written once into the
// stored options, so an extension update never changes an existing user's
// reader defaults or overrides a later edit.
export const FIRST_INSTALL_OPTIONS = Object.freeze({
popupTheme: "auto",
showCompactDefinitionSummary: true,
compactDefinitionSummaryCount: 2,
});
// Initial preferences an overlay host seeds on top of the first-install ones.
// A viewport screenshot of a see-through overlay window has no game in it.
export const OVERLAY_MODE_OPTIONS = Object.freeze({
lookupMode: "hover",
sourceHighlightEnabled: false,
anki: Object.freeze({ captureScreenshot: false }),
});
// These describe the local reading surface, even while its library is shared.
export const OVERLAY_LOCAL_OPTION_KEYS = Object.freeze([
"anki", "audioSources", "customButtons",
"hoverEnabled", "onlyScanJapaneseText", "lookupMode", "activationKey", "popupHideDelayMs",
"sourceHighlightEnabled", "popupWidthPx", "popupHeightPx", "popupScalePercent", "popupColumns", "popupToolbarPosition", "popupNestingMaxDepth",
]);
// What mining may use in this host, whatever the stored options say. The
// returned projection never changes the saved cross-browser configuration.
export function capabilityAnkiOptions(options, {
screenshot = true,
browserSpeech = true,
mediaCapture = true,
} = {}) {
return {
...options,
anki: {
...options.anki,
captureScreenshot: screenshot && options.anki.captureScreenshot,
templates: screenshot
? options.anki.templates
: options.anki.templates.map(template => ({ ...template, captureScreenshot: false })),
},
audioSources: browserSpeech
? options.audioSources
: options.audioSources.filter(source => !source.type.startsWith("text-to-speech")),
mediaCapture: { ...options.mediaCapture, enabled: mediaCapture && options.mediaCapture.enabled },
};
}
// Electron has no chrome.tabs.captureVisibleTab, and no capture host can record
// browser text-to-speech, so only downloadable pronunciations reach Anki.
export function overlayAnkiOptions(options) {
return capabilityAnkiOptions(options, { screenshot: false, browserSpeech: false, mediaCapture: false });
}
// How each first-install option's value is built from a committed title.
const FIRST_INSTALL_SELECTORS = Object.freeze({
compactDefinitionSummaryDictionary: (title) => title,
kanjiClickDictionary: (title) => ({ title, kind: "term" }),
});
// Dictionary-dependent initial preferences, applied once from the committed
// catalogue entry's exact title while the option is still Automatic. Which
// entry sets which option is declared by the catalogue.
export const FIRST_INSTALL_SELECTIONS = Object.freeze(Object.fromEntries(
RECOMMENDED_DICTIONARIES.filter((entry) => entry.firstInstallOption !== null).map((entry) => {
const select = FIRST_INSTALL_SELECTORS[entry.firstInstallOption];
if (select === undefined) throw new Error(`no first-install selector for ${entry.firstInstallOption}`);
return [entry.sourceId, Object.freeze({ option: entry.firstInstallOption, select })];
}),
));
function emptySetupDictionaries() {
return { outcomes: {}, totalSeconds: null, continued: false, selectionsApplied: [], recordedRuns: [] };
}
export function initialSetupState(startedAt) {
return {
schemaVersion: SETUP_STATE_SCHEMA_VERSION,
revision: 1,
startedAt,
stage: SETUP_STAGES[0],
completedAt: null,
dictionaries: emptySetupDictionaries(),
anki: null,
};
}
// The Anki stage settles once: configured automatically, already configured
// by the user, ordinarily absent, or needing attention for a specific reason.
// A configured outcome names the model and deck and carries no reason text; the
// other two carry a reason and no names, so neither can be rendered empty.
export function normaliseSetupAnki(value) {
if (value === undefined || value === null) return null;
if (!value || typeof value !== "object" || Array.isArray(value)
|| !SETUP_ANKI_STATUSES.includes(value.status)
|| (value.detail !== null && typeof value.detail !== "string")
|| (value.model !== null && typeof value.model !== "string")
|| (value.deck !== null && typeof value.deck !== "string")) {
throw new Error("the setup Anki outcome is malformed");
}
const configured = value.status === "configured" || value.status === "already-configured";
if (configured ? (value.model === null || value.deck === null) : (typeof value.detail !== "string" || value.detail === "")) {
throw new Error("the setup Anki outcome is malformed");
}
return {
status: value.status,
detail: configured ? null : value.detail,
model: configured ? value.model : null,
deck: configured ? value.deck : null,
};
}
function validSeconds(value) {
return value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0);
}
export function normaliseSetupOutcome(value) {
if (!value || typeof value !== "object" || Array.isArray(value)
|| !SETUP_OUTCOME_STATUSES.includes(value.status)
|| !validSeconds(value.seconds ?? null)
|| (value.error !== undefined && value.error !== null && typeof value.error !== "string")) {
throw new Error("the setup dictionary outcome is malformed");
}
return {
status: value.status,
seconds: value.status === "already-installed" ? null : value.seconds ?? null,
error: value.status === "failed" ? value.error ?? "" : null,
};
}
function normaliseSetupDictionaries(value) {
if (value === undefined) return emptySetupDictionaries();
if (!value || typeof value !== "object" || Array.isArray(value)
|| !value.outcomes || typeof value.outcomes !== "object" || Array.isArray(value.outcomes)
|| !validSeconds(value.totalSeconds)
|| typeof value.continued !== "boolean"
|| !Array.isArray(value.selectionsApplied)
|| !value.selectionsApplied.every((sourceId) => Object.hasOwn(FIRST_INSTALL_SELECTIONS, sourceId))
|| !Array.isArray(value.recordedRuns)
|| !value.recordedRuns.every((runId) => typeof runId === "string" && runId !== "")) {
throw new Error("the setup state is malformed");
}
return {
outcomes: Object.fromEntries(Object.entries(value.outcomes).map(([sourceId, outcome]) =>
[sourceId, normaliseSetupOutcome(outcome)])),
totalSeconds: value.totalSeconds,
continued: value.continued,
selectionsApplied: [...new Set(value.selectionsApplied)],
recordedRuns: [...new Set(value.recordedRuns)],
};
}
/**
* @returns {null | {schemaVersion: 1, revision: number, startedAt: string, stage: string, completedAt: string | null,
* dictionaries: {outcomes: object, totalSeconds: number | null, continued: boolean, selectionsApplied: string[]}}}
*/
export function normaliseSetupState(value) {
if (value === undefined || value === null) return null;
if (value?.schemaVersion !== SETUP_STATE_SCHEMA_VERSION) {
throw new Error(`unsupported setup state schema ${String(value?.schemaVersion)}`);
}
if (!Number.isSafeInteger(value.revision) || value.revision < 1
|| typeof value.startedAt !== "string"
|| !SETUP_STAGES.includes(value.stage)
|| (value.completedAt !== null && typeof value.completedAt !== "string")) {
throw new Error("the setup state is malformed");
}
return {
schemaVersion: SETUP_STATE_SCHEMA_VERSION,
revision: value.revision,
startedAt: value.startedAt,
stage: value.stage,
completedAt: value.completedAt,
dictionaries: normaliseSetupDictionaries(value.dictionaries),
anki: normaliseSetupAnki(value.anki),
};
}
export function recordSetupAnki(current, outcome) {
const anki = normaliseSetupAnki(outcome);
if (anki === null) throw new Error("the setup Anki outcome is malformed");
return { ...current, revision: current.revision + 1, anki };
}
export function setupIncomplete(state) {
return state !== null && state.stage !== "complete";
}
// Setup only moves forward: a stale or unexpected write can neither reopen a
// finished setup nor return to an earlier stage. Leaving the dictionary stage
// with `continued` records that the user accepted an incomplete dictionary set.
export function advanceSetupState(current, stage, now, { continued = false } = {}) {
if (!SETUP_STAGES.includes(stage)) throw new Error("the setup stage is invalid");
if (SETUP_STAGES.indexOf(stage) <= SETUP_STAGES.indexOf(current.stage)) {
throw new Error("the setup stage cannot move backwards");
}
return {
...current,
revision: current.revision + 1,
stage,
completedAt: stage === "complete" ? now : null,
dictionaries: continued && current.stage === "dictionaries"
? { ...current.dictionaries, continued: true }
: current.dictionaries,
};
}
// The installer reports one outcome per dictionary and the summed installation
// duration for each run. Retries accumulate into the stage total; a superseded
// outcome is replaced.
// A record is idempotent: the installer resends it until the reply arrives, so
// a run whose duration already landed is not counted again.
export function recordSetupDictionaries(current, { runId, outcomes = {}, runSeconds = null, selectionsApplied = [] }) {
if (typeof runId !== "string" || runId === "") throw new Error("the setup record names no run");
if (!validSeconds(runSeconds)) throw new Error("the setup run installation duration is invalid");
const recorded = Object.fromEntries(Object.entries(outcomes).map(([sourceId, outcome]) =>
[sourceId, normaliseSetupOutcome(outcome)]));
if (!selectionsApplied.every((sourceId) => Object.hasOwn(FIRST_INSTALL_SELECTIONS, sourceId))) {
throw new Error("the setup selection is unknown");
}
// Settings records the same outcomes for initial selections without onboarding.
if (current === null) return null;
const dictionaries = current.dictionaries;
const countRun = runSeconds !== null && !dictionaries.recordedRuns.includes(runId);
return {
...current,
revision: current.revision + 1,
dictionaries: {
...dictionaries,
outcomes: { ...dictionaries.outcomes, ...recorded },
totalSeconds: countRun ? (dictionaries.totalSeconds ?? 0) + runSeconds : dictionaries.totalSeconds,
selectionsApplied: [...new Set([...dictionaries.selectionsApplied, ...selectionsApplied])],
recordedRuns: countRun ? [...dictionaries.recordedRuns, runId] : dictionaries.recordedRuns,
},
};
}