mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
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:
Vendored
+285
@@ -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 AnkiConnect’s 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;
|
||||
}
|
||||
Reference in New Issue
Block a user