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
+285
View File
@@ -0,0 +1,285 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import { ankiTemplateMarkerNames, resolveAnkiTemplates } from "./anki-templates.js";
import "./reader-options.js";
export class AnkiTransportError extends Error {
constructor(message, { dispatched }) {
super(message);
Object.defineProperty(this, "name", { value: "AnkiTransportError", configurable: true });
Object.defineProperty(this, "dispatched", { value: dispatched === true, enumerable: false });
}
}
export function isUndispatchedAnkiTransportError(error) {
return error instanceof AnkiTransportError && error.dispatched === false;
}
// Every AnkiConnect API-v6 reply, including each sub-action reply inside a
// `multi` batch, is exactly `{ result, error }` with a string or null error.
const isEnvelope = payload => payload !== null && typeof payload === "object" && !Array.isArray(payload)
&& Object.keys(payload).length === 2 && Object.hasOwn(payload, "result") && Object.hasOwn(payload, "error")
&& (payload.error === null || typeof payload.error === "string");
const invalidResponse = () => new Error("AnkiConnect returned an invalid response. Check the add-on and retry.");
// AnkiConnect's own error strings name the cause but not what to do about it.
// Each translation keeps the original text so it can still be searched for.
const ANKI_CONNECT_EXPLANATIONS = [
[/api key/iu, () => "AnkiConnect requires a valid API key. Enter the key from its add-on configuration."],
[/collection is not available/iu,
() => "Anki has no open collection. Open your profile in Anki, then retry."],
[/^deck was not found: (.+)$/iu,
([, deck]) => `Anki has no deck named “${deck}”. Choose an available deck in Anki Settings.`],
[/^model was not found: (.+)$/iu,
([, model]) => `Anki has no note type named “${model}”. Choose an available note type in Anki Settings.`],
[/^cannot create note because it is empty$/iu,
() => "Anki refused the note because its first field is empty. Map the first field to content this result has."],
[/^cannot create note because it is a duplicate$/iu,
() => "Anki refused the note because a note with the same first field already exists."],
[/^note was not found: (.+)$/iu,
([, id]) => `Anki no longer has note ${id}. It was deleted or moved to another collection; refresh and retry.`],
[/unsupported action|unknown action/iu,
() => "The installed AnkiConnect add-on is too old for this request. Update AnkiConnect in Anki."],
];
// Turns a raw AnkiConnect error string into the message shown to the reader.
export function describeAnkiConnectError(error) {
for (const [pattern, explain] of ANKI_CONNECT_EXPLANATIONS) {
const match = pattern.exec(error);
if (match === null) continue;
const explanation = explain(match);
return pattern === ANKI_CONNECT_EXPLANATIONS[0][0] ? explanation : `${explanation} (AnkiConnect: ${error})`;
}
return `AnkiConnect: ${error}`;
}
function unwrap(reply) {
if (reply.error !== null) throw new Error(describeAnkiConnectError(reply.error));
return reply.result;
}
// Unwraps the sub-action replies of one `invoke("multi", …)` result, throwing
// the first sub-action failure the way a direct request would.
export function ankiMultiResults(replies) {
return replies.map(unwrap);
}
function names(action, reply) {
const result = unwrap(reply);
if (!Array.isArray(result) || result.some(name => typeof name !== "string" || name.trim() === "")) {
throw new Error(`AnkiConnect returned an invalid ${action} list.`);
}
// Exact names remain authoritative; model field order determines Anki's
// required first field. Never sort the returned list or truncate it.
return [...new Set(result)];
}
// GSM PR #549's API-v6 discovery, adapted to the MV3 worker. AnkiConnect
// handles requests through Anki's UI loop, so each endpoint gets a small,
// bounded set of transport lanes. Four lanes let a replacement Settings check
// pass one delayed stale reply without allowing an unbounded server-side queue.
// The private worker's feature handlers still select actions and bind every
// conversation to its configured endpoint and API key.
export function createAnkiGateway({ fetch = globalThis.fetch, timeoutMs = 10_000,
readSubminerProxyUrl = async () => (await globalThis.chrome?.storage?.local?.get("subminerAnkiProxyUrl"))?.subminerAnkiProxyUrl,
} = {}) {
const maximumActive = 4;
const queues = new Map();
async function isSubminerEndpoint(endpoint) {
const url = globalThis.HDReaderOptions.normaliseAnkiConnectUrl(endpoint);
if (url === null) return false;
try { return url === await readSubminerProxyUrl(); }
catch { return false; }
}
async function dispatch({ url, body, requestTimeoutMs }, queue) {
const controller = new AbortController();
queue.controllers.add(controller);
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
const unavailable = () => new AnkiTransportError(
controller.signal.aborted ? "AnkiConnect timed out. Check its URL in Settings, open Anki and retry."
: "Open Anki with the AnkiConnect add-on installed, check its URL in Settings, then retry.",
{ dispatched: true },
);
const interrupted = () => controller.signal.reason instanceof AnkiTransportError
? controller.signal.reason : unavailable();
try {
let response;
try {
response = await fetch(url, { method: "POST", credentials: "omit", redirect: "error",
headers: { "Content-Type": "application/json" }, signal: controller.signal,
body });
} catch {
throw interrupted();
}
if (!response.ok) throw new Error(response.status === 403
? "AnkiConnect denied permission. Allow this extension in AnkiConnects webCorsOriginList, then retry."
: `AnkiConnect returned HTTP ${response.status}.`);
const payload = await response.json().catch(() => null);
if (controller.signal.aborted) throw interrupted();
if (!isEnvelope(payload)) throw invalidResponse();
return unwrap(payload);
} finally {
clearTimeout(timer);
queue.controllers.delete(controller);
}
}
function failQueue(url, queue, error) {
if (queue.failure !== null) return;
queue.failure = error;
if (queues.get(url) === queue) queues.delete(url);
for (const pending of queue.pending.splice(0)) {
pending.reject(new AnkiTransportError(error.message, { dispatched: false }));
}
// Every active entry has entered fetch, so aborting one cannot prove that
// its Anki mutation did not run. Mark active siblings dispatched and keep
// their outcomes conservative while ending a failed endpoint generation
// within one transport deadline.
for (const controller of queue.controllers) {
controller.abort(new AnkiTransportError(error.message, { dispatched: true }));
}
}
async function runEntry(url, queue, entry) {
try {
entry.resolve(await dispatch(entry.request, queue));
} catch (error) {
entry.reject(error);
if (error instanceof AnkiTransportError) failQueue(url, queue, error);
} finally {
queue.active -= 1;
pump(url, queue);
}
}
function pump(url, queue) {
if (queue.failure === null) {
while (queue.active < maximumActive && queue.pending.length > 0) {
const entry = queue.pending.shift();
queue.active += 1;
void runEntry(url, queue, entry);
}
}
if (queue.active === 0 && queue.pending.length === 0 && queues.get(url) === queue) {
queues.delete(url);
}
}
function enqueue(request) {
let queue = queues.get(request.url);
if (!queue) {
queue = { pending: [], active: 0, controllers: new Set(), failure: null };
queues.set(request.url, queue);
}
return new Promise((resolve, reject) => {
queue.pending.push({ request, resolve, reject });
pump(request.url, queue);
});
}
// AnkiConnect's socket is polled on a timer, so every request costs one poll
// interval regardless of content and parallel requests serialise. A `multi`
// batch pays that once. AnkiConnect runs each sub-action through its ordinary
// handler, which checks the API key and picks the reply shape per sub-action,
// so every sub-action is bound to this conversation's key and API v6 here and
// the reply is an array of `{ result, error }` envelopes in request order.
async function invoke(action, params, apiKey, requestTimeoutMs = timeoutMs,
endpoint = globalThis.HDReaderOptions.DEFAULT_OPTIONS.anki.url) {
const url = globalThis.HDReaderOptions.normaliseAnkiConnectUrl(endpoint);
if (url === null) throw new Error("Enter a valid HTTP or HTTPS AnkiConnect URL in Settings, without a username or password.");
const privateParams = value => value && (Object.hasOwn(value, "subminerEnrich")
|| Object.hasOwn(value, "subminerDuplicateNoteIds"));
const containsPrivateParams = privateParams(params)
|| (action === "multi" && params.actions.some(entry => privateParams(entry.params)));
if (containsPrivateParams) {
if (!await isSubminerEndpoint(url)) {
const strip = value => {
if (!privateParams(value)) return value;
const { subminerEnrich, subminerDuplicateNoteIds, ...publicParams } = value;
return publicParams;
};
params = action === "multi"
? { ...strip(params), actions: params.actions.map(entry => ({ ...entry, params: strip(entry.params) })) }
: strip(params);
}
}
const key = apiKey ? { key: apiKey } : {};
// Sub-actions are rebuilt from their action and params so nothing else a
// caller passes reaches the wire.
const actions = action === "multi"
? params.actions.map(entry => ({ action: entry.action, params: entry.params, version: 6, ...key })) : null;
const body = JSON.stringify({ action, version: 6, params: actions ? { actions } : params, ...key });
const result = await enqueue({ url, body, requestTimeoutMs });
if (actions && (!Array.isArray(result) || result.length !== actions.length || !result.every(isEnvelope))) {
throw invalidResponse();
}
return result;
}
// One round trip: decks, note types and, speculatively, the configured note
// type's fields. The field reply is ignored when that note type is absent.
async function discover({ model, apiKey = "", url }) {
const errors = [];
let connected = false;
let replies;
try {
replies = await invoke("multi", { actions: [
{ action: "deckNames", params: {} },
{ action: "modelNames", params: {} },
{ action: "modelFieldNames", params: { modelName: model } },
] }, apiKey, undefined, url);
} catch (error) {
return { connected, model, decks: [], models: [], fields: [], errors: [error.message] };
}
function read(action, reply) {
try {
const result = names(action, reply);
connected = true;
return result;
} catch (error) {
if (!errors.includes(error.message)) errors.push(error.message);
return [];
}
}
const decks = read("deckNames", replies[0]);
const models = read("modelNames", replies[1]);
const fields = models.includes(model) ? read("modelFieldNames", replies[2]) : [];
return { connected, model, decks, models, fields, errors };
}
return { discover, invoke, isSubminerEndpoint };
}
// Shared by Settings and authoritative mining readiness checks. Validation
// reports missing choices instead of changing a saved or in-progress mapping.
export function ankiAvailability(config, discovery, resolvedTemplates) {
if (!discovery) return ["Refresh Anki to check this configuration."];
if (!discovery.connected) return discovery.errors;
const errors = [...discovery.errors];
if (!discovery.decks.includes(config.deck)) {
errors.push(config.deck
? `Anki has no deck named “${config.deck}”. Choose an available deck.`
: "Choose an available deck.");
}
if (!discovery.models.includes(config.model)) {
errors.push(config.model
? `Anki has no note type named “${config.model}”. Choose an available note type.`
: "Choose an available note type.");
}
if (config.model !== discovery.model) {
return [...errors, `Refresh fields for the selected note type, “${config.model}”.`];
}
const resolved = resolvedTemplates ?? resolveAnkiTemplates(config, discovery.fields);
errors.push(...resolved.errors);
if (discovery.fields.length > 0 && !resolved.templates[discovery.fields[0]].value.trim()) {
errors.push(`Map the first field, “${discovery.fields[0]}”, of note type “${config.model}” before adding notes. Anki requires it.`);
}
if (discovery.fields.length > 0) {
const markers = ankiTemplateMarkerNames(resolved.templates[discovery.fields[0]].value);
if (markers.includes("capture-animation") || markers.includes("capture-audio") || markers.includes("screenshot")) {
errors.push(`Captured media cannot be mapped to the first field, “${discovery.fields[0]}”.`);
}
}
if (config.model && discovery.fields.length === 0 && errors.length === 0) errors.push("The selected note type has no fields.");
return errors;
}