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
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import { ankiTemplateMarkerNames } from "./anki-templates.js";
import { canonicalAnkiFields, overwriteAnkiFields } from "./anki-duplicates.js";
import { readAnkiNoteFields, verifyAnkiFields } from "./anki-mining.js";
function pronunciationFields(incoming, current, appliedFields, existingFields, warnings) {
const fields = {};
for (const [field, value] of Object.entries(incoming)) {
const baseline = appliedFields[field] ?? existingFields?.[field] ?? "";
if (typeof current[field] !== "string" || current[field].normalize("NFC") !== baseline.normalize("NFC")) {
warnings.push(`Field “${field}” changed in Anki; its pronunciation update was skipped.`);
} else if (current[field] !== value) fields[field] = value;
}
return fields;
}
export async function enrichAnkiNote(context, { audio, render, store }) {
const { request, invoke, noteId, appliedFields, existingFields, resolved, resources } = context;
if (resources.pronunciationWarning) return [resources.pronunciationWarning];
const warnings = [];
const confirmed = new Set(Array.isArray(resources.confirmedMedia) ? resources.confirmedMedia : []);
async function ensure(file, kind) {
if (confirmed.has(file.filename)) return;
await store(file, kind);
confirmed.add(file.filename);
}
// Dictionary media and any first-field pronunciation have already been
// confirmed before the note mutation. Only deferred pronunciation remains.
const canonical = existingFields ? canonicalAnkiFields({}, resolved.templates, existingFields).templates : resolved.templates;
const templates = Object.fromEntries(Object.entries(canonical).filter(([field, template]) => {
if (!ankiTemplateMarkerNames(template.value).includes("audio")) return false;
return !existingFields || (template.overwriteMode !== "skip"
&& !(template.overwriteMode === "coalesce" && existingFields[field]));
}));
// No enabled audio source means no pronunciation, like a screenshot turned off.
if (!Object.keys(templates).length || (!resources.audioPrepared && !context.config.audioSources.length)) return warnings;
try {
const file = resources.audioPrepared ? resources.audio : await audio(request, context.config);
await ensure(file, "pronunciation");
const rendered = await render(request, templates, `[sound:${file.filename}]`, resources);
const incoming = existingFields ? overwriteAnkiFields(rendered.fields, existingFields, templates, { includeAudio: true }) : rendered.fields;
// Audio work may have taken seconds. Re-read just before updating; never
// clobber an external edit or append to our already-applied text a second
// time. AnkiConnect has no CAS, so the final inter-call race remains.
const current = await readAnkiNoteFields(invoke, noteId);
const fields = pronunciationFields(incoming, current, appliedFields, existingFields, warnings);
if (Object.keys(fields).length) {
const result = await invoke("updateNoteFields", { note: { id: noteId, fields } }, 10_000);
if (result !== null) throw new Error("Anki returned an invalid pronunciation-update acknowledgement. Inspect the saved note.");
await verifyAnkiFields(invoke, noteId, fields);
}
} catch (error) { warnings.push(`Pronunciation: ${error.message}`); }
return warnings;
}