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
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import { extensionApi as chrome } from "./browser-api.js";
import "./reader-options.js";
import { HOST_BROWSER, HOST_CAPABILITIES } from "./overlay-mode.js";
const { normaliseOptions } = globalThis.HDReaderOptions;
const elements = Object.fromEntries([...document.querySelectorAll("[id]")].map(node => [node.id, node]));
let options = null;
let linkedAddress = null;
let revision = -1;
let pending = false;
let requestCounter = 0;
function adoptOptions(stored) {
const nextRevision = Number.isInteger(stored?.revision) && stored.revision >= 0 ? stored.revision : 0;
if (nextRevision < revision) return;
revision = nextRevision;
options = normaliseOptions(stored);
render();
}
function render() {
elements["lookup-toggle"].disabled = pending || !options;
elements["record-screen"].hidden = HOST_BROWSER === "firefox";
elements["record-screen"].disabled = pending || !options || !HOST_CAPABILITIES.mediaCapture;
if (!options) return;
elements["lookup-toggle"].setAttribute("aria-checked", String(options.hoverEnabled));
elements["lookup-state"].textContent = options.hoverEnabled ? "On" : "Off";
const activeHint = options.lookupMode !== "hover"
? `Hold ${options.activationKey} to scan` : "Hover over Japanese text to scan";
elements["activation-hint"].textContent = options.hoverEnabled ? activeHint : "Lookups paused";
}
function showError(error) {
elements["toolbar-error"].textContent = error instanceof Error ? error.message : String(error);
elements["toolbar-error"].hidden = false;
}
async function send(type, fields = {}, target = "hachidori-capture") {
const reply = await chrome.runtime.sendMessage({ target, type, requestId: `toolbar-${++requestCounter}`, ...fields });
if (reply?.options) adoptOptions(reply.options);
if (!reply?.ok) throw new Error(reply?.error || "Hachidori did not reply. Please try again.");
return reply;
}
async function writeOptions(patch) {
await send("hd_options_write", { baseRevision: revision, options: patch }, "hoshidicts-worker");
}
async function run(action) {
if (pending) return;
pending = true;
elements["toolbar-error"].hidden = true;
render();
try { await action(); }
catch (error) { showError(error); }
finally { pending = false; render(); }
}
function renderCapture(recording) {
if (!HOST_CAPABILITIES.mediaCapture) {
elements["record-screen"].classList.toggle("is-recording", false);
elements["record-screen"].title = HOST_BROWSER === "firefox"
? "Context capture is unavailable in Firefox"
: "Context capture is unavailable in this overlay";
elements["record-label"].textContent = "Context capture unavailable";
return;
}
elements["record-screen"].classList.toggle("is-recording", recording);
elements["record-screen"].title = recording ? "Open capture controls"
: "Capture a screen, window, or tab as context for your Japanese cards";
elements["record-label"].textContent = recording ? "Recording context" : "Record context for Anki";
}
// A linked install shows where its lookups go and whether that host answers.
function adoptSharing(stored) {
linkedAddress = typeof stored?.client?.address === "string" ? stored.client.address : null;
elements["toolbar-sharing"].hidden = linkedAddress === null;
if (linkedAddress === null) elements["toolbar-sharing"].textContent = "";
}
async function refreshSharing() {
if (linkedAddress === null) return;
try {
const status = await send("hd_sharing_status", {}, "hachidori-sharing");
elements["toolbar-sharing"].textContent = status.sharing?.client?.connected
? "Linked to another Hachidori" : "Linked Hachidori not reachable";
} catch (error) { showError(error); }
}
async function refreshCapture() {
if (!HOST_CAPABILITIES.mediaCapture) {
renderCapture(false);
return;
}
if (!options?.mediaCapture.enabled) {
renderCapture(false);
return;
}
try {
const status = await send("hd_capture_status");
renderCapture(status.state === "recording");
} catch (error) { showError(error); }
}
elements["lookup-toggle"].addEventListener("click", () => {
void run(() => writeOptions({ hoverEnabled: !options.hoverEnabled }));
});
elements["record-screen"].addEventListener("click", () => {
if (!HOST_CAPABILITIES.mediaCapture) return;
void run(async () => {
// The shortcut enables the existing capture controls. The recorder still
// requires Start capture and an explicit source choice in Chrome's picker.
if (!options.mediaCapture.enabled) {
await writeOptions({ mediaCapture: { ...options.mediaCapture, enabled: true } });
}
await send("hd_capture_open");
window.close();
});
});
elements["open-settings"].addEventListener("click", () => {
void run(async () => { await chrome.runtime.openOptionsPage(); window.close(); });
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
if (changes.options) {
adoptOptions(changes.options.newValue);
void refreshCapture();
}
if (changes.sharing) {
adoptSharing(changes.sharing.newValue);
void refreshSharing();
}
});
try {
const stored = await chrome.storage.local.get(["options", "sharing"]);
adoptOptions(stored.options);
adoptSharing(stored.sharing);
await Promise.all([refreshCapture(), refreshSharing()]);
} catch (error) { showError(error); }
const capturePoll = setInterval(() => { void refreshCapture(); void refreshSharing(); }, 1000);
window.addEventListener("pagehide", () => clearInterval(capturePoll), { once: true });