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
+677
View File
@@ -0,0 +1,677 @@
// SPDX-License-Identifier: GPL-3.0-or-later
(function () {
"use strict";
const text = (node, value) => { if (node.textContent !== value) node.textContent = value; };
const FEEDBACK_PRIORITY = { info: 0, success: 1, warning: 2, error: 3 };
function syncFeedbackSurface(feedback) {
const visible = [...feedback.querySelectorAll(".gsm-hoshidicts-anki-control")]
.filter(control => !control.hidden);
feedback.hidden = visible.length === 0;
if (visible.length === 0) {
delete feedback.dataset.kind;
return;
}
feedback.dataset.kind = visible.reduce((kind, control) => {
const next = control.dataset.kind || "info";
return FEEDBACK_PRIORITY[next] > FEEDBACK_PRIORITY[kind] ? next : kind;
}, "info");
}
function syncFeedback(record) {
if (!record.control) return;
record.control.hidden = record.hidden || (record.badge.hidden && record.output.textContent === "");
syncFeedbackSurface(record.feedback);
}
function setStatus(record, value, kind = "info") {
text(record.output, value);
record.control.dataset.kind = kind;
syncFeedback(record);
}
function setMiningButtonState(record, state, message = "") {
const button = record.add;
button.dataset.state = state;
const actionTitle = message || {
checking: "Checking Anki card status",
ready: "Mine to Anki",
"add-duplicate": "Add duplicate to Anki",
overwrite: "Overwrite note in Anki",
"view-existing": "View existing notes in Anki",
mining: "Adding note",
success: "Note added",
error: "Could not add note",
duplicate: "Note already exists",
unavailable: "Anki mining is unavailable",
}[state] || "Mine to Anki";
const title = record.custom && !message ? `${record.label}: ${actionTitle}` : actionTitle;
button.title = title;
button.setAttribute("aria-label", title);
button.setAttribute("aria-busy", String(state === "checking" || state === "mining"));
button.dataset.action = views(record) ? "view" : "add";
if (record.custom) {
let label = button.querySelector(".gsm-hoshidicts-text-action-label");
if (!label) {
label = button.ownerDocument.createElement("span");
label.className = "gsm-hoshidicts-text-action-label";
button.replaceChildren(label);
}
text(label, record.label);
return;
}
const iconName = {
ready: "add",
"add-duplicate": "document-add",
overwrite: "document-edit",
"view-existing": "book-search",
checking: "arrow-clockwise",
mining: "arrow-sync",
success: "book-search",
error: "error-circle",
unavailable: "subtract",
}[state] || "subtract";
const icon = button.ownerDocument.createElement("span");
icon.className = "gsm-hoshidicts-mine-icon hd-icon";
icon.setAttribute("aria-hidden", "true");
icon.dataset.icon = iconName;
button.replaceChildren(icon);
}
// The one Anki button opens Anki instead of adding once there is a note to
// show: the note it wrote, the write it could not confirm, or the duplicates
// that block adding.
function views(record) {
return record.terminal || (record.decision?.state === "duplicate" && record.decision.canAdd === false);
}
function disabled(record) {
if (!record.add) return;
record.add.disabled = record.busy
|| (!record.terminal && (record.needsCheck || (!record.decision?.canAdd && !views(record))));
}
function payload(record) {
return {
...record.group.getRequest(record.result),
configKey: record.configKey,
templateId: record.templateId,
};
}
function decisionState(value) {
if (value.action === "overwrite" && value.canAdd) return "overwrite";
if (value.state === "duplicate") return value.canAdd ? "add-duplicate" : "view-existing";
if (value.state === "invalid" || value.state === "error") return "error";
return "ready";
}
function showControls(record, value) {
record.hidden = !value;
if (record.add) record.add.hidden = !value;
syncFeedback(record);
}
function removeControls(record) {
if (record?.add && !record.custom) record.add.remove();
if (record?.add && record.custom) {
record.add.removeEventListener("mousedown", record.onMouseDown);
record.add.removeEventListener("click", record.onClick);
record.add.disabled = true;
delete record.add.dataset.state;
delete record.add.dataset.action;
record.add.removeAttribute("aria-busy");
}
record?.control?.remove();
if (record?.feedback) syncFeedbackSurface(record.feedback);
}
function reusableRecord(record, group, spec) {
return record?.group === group && record.result === spec.item.result
&& record.custom === spec.custom && record.templateId === spec.templateId;
}
function createRecord(group, spec) {
const { item, binding, custom, templateId, label, add } = spec;
return {
...item,
binding,
group,
custom,
templateId,
label,
...(add ? { add } : {}),
configKey: null,
busy: false,
terminal: false,
decision: null,
viewChecked: false,
needsCheck: true,
captureJobId: null,
};
}
function updateRecord(record, spec) {
Object.assign(record, {
actions: spec.item.actions,
feedback: spec.item.feedback,
result: spec.item.result,
label: spec.label,
});
if (spec.custom) setMiningButtonState(record, record.add.dataset.state || "checking");
}
function captureBadge(record, state = "") {
if (!record.badge) return;
const capture = record.decision?.capture;
record.badge.hidden = !capture;
if (!capture) {
text(record.badge, "");
syncFeedback(record);
return;
}
const labels = [capture.sourceLabel, capture.partial ? "Partial" : "", state].filter(Boolean);
text(record.badge, labels.join(" · "));
syncFeedback(record);
}
function decision(record, value) {
record.decision = value;
if (!record.terminal && !record.busy) {
const state = decisionState(value);
setMiningButtonState(record, state, state === "view-existing" ? "" : value.error || "");
setStatus(record, value.error || "", value.error ? "error" : "info");
}
captureBadge(record);
disabled(record);
}
function uncertain(record, error) {
record.terminal = true;
setMiningButtonState(record, "error", "Check Anki before trying again");
setStatus(record, error, "error");
}
function readyCaptureRequest(record, request, requirements, assets) {
captureBadge(record);
const unavailable = [...(request.captureUnavailable ?? [])];
if (requirements.includeAnimation && !assets?.animation) unavailable.push("animation");
if (requirements.includeAudio && !assets?.audio) unavailable.push("audio");
return { ...request, captureJobId: record.captureJobId, captureUnavailable: unavailable };
}
function captureProgress(record, status) {
if (status.state === "finishing") {
captureBadge(record, "Finishing clip");
setStatus(record, "Finishing clip…");
} else {
captureBadge(record);
const progress = status.total > 0 ? ` ${status.progress}/${status.total}` : "";
setStatus(record, `Encoding captured media${progress}`);
}
}
function restartChecks(records) {
for (const record of records) {
if (record.terminal) continue;
record.viewChecked = false;
record.needsCheck = true;
record.decision = null;
if (record.add) setMiningButtonState(record, "checking");
}
}
function cachedViewRequest(record) {
return { request: {
term: {
expression: record.result.term.expression,
reading: record.result.term.reading,
},
templateId: record.templateId,
} };
}
function cachedView(value) {
return value?.cached === true && value.state === "duplicate"
&& value.canAdd === false && Array.isArray(value.noteIds) && value.noteIds.length > 0;
}
function checkedConfigKey(configKey, result) {
if (typeof result?.configKey !== "string") return { configKey, changed: false };
if (configKey !== null && result.configKey !== configKey) return { configKey, changed: true };
return { configKey: result.configKey, changed: false };
}
function createAnkiController({
send,
capture = send,
onChange,
// The page's own overlays are hidden for a viewport screenshot and restored
// afterwards; without a host to hide, the screenshot is just taken.
conceal = during => during(),
wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
}) {
const owners = new Map(), bound = new WeakMap();
let enabled = false, settingsKey = "", checks = Promise.resolve();
let primaryTemplateId = "default";
let templateIds = new Set(["default"]);
let customButtonTemplates = new Map();
const live = group => enabled && owners.get(group.owner) === group && !group.popup.hidden && group.isCurrent();
const boundHere = record => bound.get(record.binding) === record && record.binding.isConnected;
const current = record => live(record.group) && boundHere(record);
const needsCheck = record => boundHere(record) && record.needsCheck && !record.busy && !record.terminal;
function available(records, value, error = "") {
for (const record of records) {
const show = value || record.custom || record.terminal || cachedView(record.decision);
if (show && record.actions.isConnected) controls(record);
if (record.control) showControls(record, show);
if (!value && !show) record.needsCheck = false;
if (!value && record.custom) {
record.needsCheck = false;
setMiningButtonState(record, "unavailable", error || "This Anki Template is unavailable.");
setStatus(record, error || "This Anki Template is unavailable.", "error");
}
}
}
async function requestCachedView(record) {
record.viewChecked = true;
try {
return await send("hd_anki_view", cachedViewRequest(record));
} catch {
return null;
}
}
function applyCachedView(group, record, result) {
if (!cachedView(result)) return;
record.needsCheck = false;
controls(record);
showControls(record, true);
decision(record, result);
onChange(group.owner);
}
async function checkCachedViews(group, records, owns) {
const configKeys = new Map();
for (const record of records) {
if (!owns()) return { configKeys, changed: [] };
if (!needsCheck(record) || record.viewChecked) continue;
const result = await requestCachedView(record);
if (result === null || !owns() || !boundHere(record)) continue;
const configKey = configKeys.get(record.templateId) ?? null;
const checked = checkedConfigKey(configKey, result);
if (checked.changed) {
return {
configKeys,
changed: records.filter(candidate => candidate.templateId === record.templateId),
};
}
if (checked.configKey !== null) configKeys.set(record.templateId, checked.configKey);
record.configKey = checked.configKey;
applyCachedView(group, record, result);
}
return { configKeys, changed: [] };
}
async function checkRecords(records, owns) {
for (const record of records) {
if (!owns()) return;
if (!needsCheck(record)) continue;
record.needsCheck = false;
try {
const result = await send("hd_anki_preflight", { request: payload(record) });
if (owns() && boundHere(record)) decision(record, result);
} catch (error) {
if (owns() && boundHere(record)) decision(record, { state: "error", canAdd: false, error: error.message });
}
}
}
function recordsByTemplate(records) {
const byTemplate = new Map();
for (const record of records) {
if (!byTemplate.has(record.templateId)) byTemplate.set(record.templateId, []);
byTemplate.get(record.templateId).push(record);
}
return byTemplate;
}
async function checkTemplateRecords(group, templateId, records, configKeys, owns) {
const status = await send("hd_anki_status", { templateId });
if (!owns()) return;
const cachedKey = configKeys.get(templateId) ?? null;
if (status.available && cachedKey !== null && status.configKey !== cachedKey) {
restartChecks(records);
return;
}
for (const record of records) record.configKey = status.configKey;
available(records, status.available, status.error);
if (!status.available) return;
onChange(group.owner);
await checkRecords(records, owns);
}
async function checkGroup(group) {
const epoch = group.epoch;
const owns = () => live(group) && epoch === group.epoch;
try {
if (!owns()) return;
const missing = group.records.filter(record => needsCheck(record) && !templateIds.has(record.templateId));
available(missing, false, "The selected Anki Template is no longer available.");
const configured = group.records.filter(record => templateIds.has(record.templateId));
const cached = await checkCachedViews(group, configured, owns);
if (!owns()) return;
if (cached.changed.length > 0) {
restartChecks(cached.changed);
return;
}
if (!group.records.some(needsCheck)) return;
const byTemplate = recordsByTemplate(group.records.filter(needsCheck));
for (const [templateId, records] of byTemplate) {
await checkTemplateRecords(group, templateId, records, cached.configKeys, owns);
if (!owns()) return;
}
} catch (error) {
if (owns()) available(group.records.filter(needsCheck), false, error.message);
} finally {
group.queued = false;
if (live(group)) {
group.records.forEach(disabled);
onChange(group.owner);
refresh(group);
}
}
}
function refresh(group, all = false) {
if (all) for (const record of group.records) {
record.viewChecked = record.terminal;
record.needsCheck = !record.terminal;
}
group.records.forEach(record => {
if (needsCheck(record)) {
// Readiness belongs to this result, not to the whole popup's queue.
record.decision = null;
if (enabled) {
controls(record);
showControls(record, true);
setMiningButtonState(record, "checking");
}
}
disabled(record);
});
if (!live(group) || group.queued || !group.records.some(needsCheck)) return;
group.queued = true;
const operation = () => checkGroup(group);
checks = checks.then(operation, operation);
}
function refreshAll() {
for (const group of owners.values()) refresh(group, true);
}
async function discardScreenshot(record) {
if (!record.screenshot) return;
const { token } = record.screenshot;
record.screenshot = null;
try { await send("hd_anki_screenshot_discard", { request: { token } }); } catch { /* A restarted worker holds nothing. */ }
}
async function cancelCapture(record) {
if (!record.captureJobId) return;
try { await capture("hd_capture_cancel", { jobId: record.captureJobId }); } catch { /* Stop/expiry already cleaned it up. */ }
record.captureJobId = null;
}
async function handleSubmissionFailure(record, error, writeSent, owns) {
if (!writeSent) {
// Nothing was sent, so the picture this submission took is nobody's.
await discardScreenshot(record);
} else if (!error.responseReceived) {
uncertain(record, `The write could not be confirmed. Check Anki before trying again. ${error.message}`);
return;
} else {
// A worker reply confirms that no Anki mutation was sent. Release the
// request-owned export even after its popup owner has retired.
await cancelCapture(record);
await discardScreenshot(record);
}
if (!owns()) return;
setMiningButtonState(record, decisionState(record.decision));
setStatus(record, `Could not add: ${error.message}`, "error");
}
// One viewport screenshot for this submission, taken with Hachidori's own
// overlays hidden. A capture or upload that fails is a warning carried with
// the note's outcome: the field renders empty and the note still goes in.
async function prepareScreenshot(record, request, owns) {
record.screenshotWarning = "";
record.screenshot = null;
if (record.decision?.screenshot !== true) return request;
if (owns()) setStatus(record, "Taking the screenshot…");
try {
const taken = await conceal(() => send("hd_anki_screenshot", { templateId: record.templateId }));
if (typeof taken?.filename !== "string" || !taken.filename) throw new Error("no screenshot was taken");
record.screenshot = { token: taken.token, filename: taken.filename };
return { ...request, screenshot: record.screenshot };
} catch (error) {
record.screenshotWarning = `Screenshot: ${error.message}`;
return { ...request, captureUnavailable: [...(request.captureUnavailable ?? []), "screenshot"] };
}
}
function submitted(record, result) {
if (result.state === "uncertain") { uncertain(record, result.error); return true; }
if (result.state !== "added" && result.state !== "updated") return false;
// A configuration epoch can change while Anki commits. The submitted
// record still owns its outcome; never turn a known write into a retry.
record.terminal = true;
record.noteIds = [result.noteId];
const label = result.state === "added" ? "Added" : "Updated";
const warnings = [record.screenshotWarning, ...(result.warnings ?? [])].filter(Boolean);
setMiningButtonState(record, "success", `Find ${label.toLowerCase()} note in Anki`);
setStatus(record, `${label} note ${result.noteId}. ${warnings.join(" ")}`.trim(),
warnings.length > 0 ? "warning" : "success");
refreshAll(); // Best-effort checks cannot turn a confirmed write into a retry.
return true;
}
async function prepareCapture(record, request, owns) {
const selected = record.decision?.capture;
if (!selected) return request;
if (!request.capturePin?.token) throw new Error("The capture pin expired. Look up the text again.");
if (!record.captureJobId) {
const started = await capture("hd_capture_export", {
token: request.capturePin.token,
requirements: selected.requirements,
});
record.captureJobId = started.jobId;
}
for (;;) {
const status = await capture("hd_capture_job_status", { jobId: record.captureJobId });
if (status.state === "ready") {
return readyCaptureRequest(record, request, selected.requirements, status.assets);
}
if (status.state === "error") {
const message = status.error || "Captured media could not be encoded.";
await cancelCapture(record);
throw new Error(message);
}
if (owns()) captureProgress(record, status);
await wait(100);
}
}
async function submit(record, fromPointer) {
if (!current(record) || record.add.disabled || record.busy || record.terminal) return;
const group = record.group, epoch = group.epoch;
const owns = () => current(record) && record.group === group && group.epoch === epoch;
const baseRequest = (fromPointer && record.pointerRequest) || payload(record);
const request = record.decision?.clientSpeech
? { ...baseRequest, clientSpeech: record.decision.clientSpeech }
: baseRequest;
record.pointerRequest = null;
record.busy = true;
setMiningButtonState(record, "mining");
disabled(record);
setStatus(record, "Saving to Anki…");
let writeSent = false;
try {
const prepared = await prepareCapture(record, await prepareScreenshot(record, request, owns), owns);
if (owns()) setStatus(record, "Saving to Anki…");
writeSent = true;
const result = await send("hd_anki_submit", { request: prepared });
// These replies confirm that no note was written. Release the export
// even if its popup retired while Anki was checking the submission.
if (["duplicate", "invalid"].includes(result.state)) await cancelCapture(record);
if (!submitted(record, result) && owns()) { decision(record, { ...result, canAdd: false }); refreshAll(); }
} catch (error) {
await handleSubmissionFailure(record, error, writeSent, owns);
} finally {
record.busy = false;
if (current(record)) { disabled(record); onChange(record.group.owner); refresh(record.group); }
}
}
// An unconfirmed write has no note ID, so Anki searches for the expression.
async function browse(record) {
if (!current(record) || record.add.disabled) return;
record.add.disabled = true;
const noteIds = record.terminal ? record.noteIds : record.decision?.noteIds;
try {
const result = await send("hd_anki_browse", { request: {
noteIds: Array.isArray(noteIds) ? noteIds : [],
expression: record.result.term.expression,
configKey: record.configKey,
templateId: record.templateId,
} });
if (current(record) && Array.isArray(result?.noteIds)) {
if (record.terminal) record.noteIds = result.noteIds;
else if (record.decision) record.decision = { ...record.decision, noteIds: result.noteIds };
}
if (current(record) && result?.opened === false) {
record.terminal = false;
record.noteIds = [];
record.decision = null;
record.viewChecked = false;
record.needsCheck = true;
setMiningButtonState(record, "checking");
setStatus(record, "");
refresh(record.group);
}
}
catch (error) { if (current(record)) setStatus(record, `Could not open Anki: ${error.message}`, "error"); }
finally {
if (current(record)) { disabled(record); onChange(record.group.owner); }
}
}
function controls(record) {
if (record.control) return;
const document = record.actions.ownerDocument;
const feedback = record.feedback || document.createElement("div");
if (!record.feedback) {
feedback.className = "gsm-hoshidicts-mining-feedback";
feedback.setAttribute("role", "status");
feedback.setAttribute("aria-live", "polite");
feedback.hidden = true;
record.actions.after(feedback);
}
const control = document.createElement("div");
control.className = "gsm-hoshidicts-anki-control";
const add = record.add || document.createElement("button");
if (!record.custom) {
add.type = "button";
add.className = "gsm-hoshidicts-mine-button";
}
const badge = document.createElement("span");
badge.className = "gsm-hoshidicts-capture-badge";
badge.hidden = true;
const output = document.createElement("output");
output.className = "gsm-hoshidicts-anki-status";
output.setAttribute("aria-live", "polite");
control.append(badge, output);
control.hidden = true;
feedback.append(control);
if (!record.custom) {
const leadingAction = record.actions.firstElementChild;
if (leadingAction?.matches(".gsm-hoshidicts-popup-close, .gsm-hoshidicts-kanji-back")) {
leadingAction.after(add);
} else {
record.actions.prepend(add);
}
}
Object.assign(record, { feedback, control, add, badge, output, hidden: false });
setMiningButtonState(record, "checking");
record.onMouseDown = event => {
if (event.button === 0 && current(record) && !views(record)) record.pointerRequest = payload(record);
};
record.onClick = event => {
if (views(record)) void browse(record);
else void submit(record, event.detail > 0);
};
add.addEventListener("mousedown", record.onMouseDown);
add.addEventListener("click", record.onClick);
disabled(record);
}
function recordSpecs(group) {
const specs = [];
for (const item of group.items) {
specs.push({
item,
binding: item.actions,
custom: false,
templateId: primaryTemplateId,
label: "Anki",
});
for (const add of item.actions.querySelectorAll(".gsm-hoshidicts-custom-anki-button")) {
const descriptor = customButtonTemplates.get(add.dataset.customButtonId);
if (!descriptor) continue;
specs.push({
item,
binding: add,
add,
custom: true,
templateId: descriptor.templateId,
label: descriptor.label,
});
}
}
return specs;
}
function recordForSpec(group, spec) {
let record = bound.get(spec.binding);
if (reusableRecord(record, group, spec)) {
updateRecord(record, spec);
return record;
}
if (record) {
removeControls(record);
bound.delete(spec.binding);
}
record = createRecord(group, spec);
bound.set(spec.binding, record);
return record;
}
function reconcile(group) {
const next = recordSpecs(group).map(spec => recordForSpec(group, spec));
const retained = new Set(next);
const retainedBindings = new Set(next.map(record => record.binding));
for (const record of group.records) {
if (retained.has(record) || retainedBindings.has(record.binding)) continue;
removeControls(record);
if (bound.get(record.binding) === record) bound.delete(record.binding);
}
group.records = next;
}
function bind(items, context) {
let group = owners.get(context.owner);
if (group && group.request !== context.request) { retire(context.owner); group = null; }
if (!group) {
group = { ...context, items, records: [], epoch: 0, queued: false };
owners.set(context.owner, group);
}
else Object.assign(group, context);
group.items = items;
reconcile(group);
group.records.forEach(disabled);
refresh(group);
}
function retire(owner) {
for (const [key, group] of owners) {
if (owner !== undefined && owner !== key) continue;
owners.delete(key);
for (const record of group.records) if (record.control) showControls(record, false);
}
}
return { bind, retire,
refresh(owner) { const group = owners.get(owner); if (group) refresh(group, true); },
update(options, ready = true) {
const anki = globalThis.HDReaderOptions.normaliseAnki(options.anki);
const customButtons = globalThis.HDReaderOptions.normaliseCustomButtons(options.customButtons);
const key = JSON.stringify([ready, anki, customButtons, options.audioSources, options.mediaCapture]);
if (key === settingsKey) return;
settingsKey = key;
primaryTemplateId = anki.templates[0].id;
templateIds = new Set(anki.templates.map(template => template.id));
customButtonTemplates = new Map(customButtons.filter(button => button.type === "anki")
.map(button => [button.id, { templateId: button.templateId, label: button.label }]));
enabled = ready && (anki.templates.some(template => Boolean(template.model))
|| customButtonTemplates.size > 0);
for (const group of owners.values()) {
group.epoch++;
reconcile(group);
for (const record of group.records) if (record.control) showControls(record, false);
refresh(group, true);
}
},
};
}
// Mining screenshots address this exact document before and after capture.
// This script is present in every browser; the capture content script is not.
globalThis.chrome?.runtime?.onMessage?.addListener((message, sender, sendResponse) => {
if (message?.target === "hachidori-anki-content" && message.type === "hd_anki_document") sendResponse({ present: true });
});
globalThis.HDAnki = { createAnkiController };
}());