Files
SubMiner/vendor/hachidori/extension/anki-mining.js
T
sudacode d9fdc7ef6d 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
2026-09-22 00:21:19 -07:00

476 lines
22 KiB
JavaScript

// SPDX-License-Identifier: GPL-3.0-or-later
import { ankiAvailability, isUndispatchedAnkiTransportError } from "./anki.js";
import { ankiCaptureRequirements, resolveAnkiTemplates } from "./anki-templates.js";
import { ankiDigest } from "./anki-digest.js";
import { ankiSetupFamily } from "./anki-setup.js";
import { inspectAnkiNoteIds } from "./anki-index.js";
import { ankiBrowseQuery, ankiNoteIdsQuery, ankiNoteOptions, canonicalAnkiFields, checkAnkiDuplicate, findAnkiDuplicateNotes,
isAnkiDuplicateError, overwriteAnkiFields, validateAnkiNote } from "./anki-duplicates.js";
const CONFIG_CHANGED = "Anki configuration changed. Refresh this result before adding a note.";
const AUTOMATIC_CAPTURE_FIELDS = {
kiku: { picture: "Picture", audio: "SentenceAudio" },
lapis: { picture: "Picture", audio: "SentenceAudio" },
senren: { picture: "picture", audio: "sentenceAudio" },
};
function requestConfiguration(current, request) {
const fields = AUTOMATIC_CAPTURE_FIELDS[ankiSetupFamily(current.config.model)];
if (!fields) return current;
const templates = Object.fromEntries(Object.entries(current.resolved.templates)
.map(([field, template]) => [field, { ...template }]));
const routed = { ...current, resolved: { ...current.resolved, templates } };
const capture = current.config.mediaCapture;
if (!request.capturePin || capture?.enabled !== true) return routed;
if (capture.includeAnimation === true && templates[fields.picture]) {
templates[fields.picture].value = templates[fields.picture].value
.replaceAll("{screenshot}", "{capture-animation}");
}
if (capture.includeCapturedAudio === true && templates[fields.audio]?.value.trim() === "") {
templates[fields.audio].value = "{capture-audio}";
}
return routed;
}
export async function readAnkiNoteFields(invoke, noteId) {
const infos = await invoke("notesInfo", { notes: [noteId] });
const info = Array.isArray(infos) ? infos.find(value => value.noteId === noteId) : null;
if (!info?.fields || typeof info.fields !== "object" || Array.isArray(info.fields)) throw new Error("Anki did not return the saved note fields.");
return Object.fromEntries(Object.entries(info.fields).map(([field, value]) => [field, value?.value]));
}
export async function verifyAnkiFields(invoke, noteId, expected) {
const fields = await readAnkiNoteFields(invoke, noteId);
const missing = [], changed = [];
for (const [field, value] of Object.entries(expected)) {
if (typeof fields[field] !== "string") missing.push(field);
else if (fields[field].normalize("NFC") !== value.normalize("NFC")) changed.push(field);
}
if (missing.length === 0 && changed.length === 0) return;
const list = names => names.map(name => `“${name}”`).join(", ");
const parts = [];
if (missing.length) parts.push(`${missing.length === 1 ? "field" : "fields"} ${list(missing)} ${missing.length === 1 ? "is" : "are"} missing from note ${noteId}`);
if (changed.length) parts.push(`${changed.length === 1 ? "field" : "fields"} ${list(changed)} ${changed.length === 1 ? "was" : "were"} saved with different content`);
throw new Error(`Anki's saved note differs from the submitted values: ${parts.join("; ")}. Inspect note ${noteId} in Anki.`);
}
async function addableDecision(prepared) {
const check = await validateAnkiNote(prepared.invoke, prepared.note);
return { state: check.addable ? "addable" : "invalid", canAdd: check.addable, error: check.error };
}
async function unindexedDecision(prepared) {
const { invoke, note, config, firstField } = prepared;
const checked = await checkAnkiDuplicate(invoke, note, config);
if (!checked.duplicate) {
return { state: checked.addable ? "addable" : "invalid", canAdd: checked.addable, error: checked.error };
}
// A non-direct destination field cannot be keyed by the word index. Keep
// Anki's exact first-field identity as a compatibility path, restricted to
// the configured destination type so unrelated custom models never block.
const matches = await findAnkiDuplicateNotes(invoke, note, firstField, config);
const noteIds = matches.map(match => match.noteId);
if (config.duplicateBehavior === "overwrite") {
const target = matches.find(match => match.fields !== null) ?? null;
return { state: "duplicate", canAdd: target !== null, action: "overwrite", target, noteIds, mature: false,
error: target ? null : "A duplicate exists, but no matching configured note type is inside the selected scope." };
}
if (config.duplicateBehavior === "new") {
const addable = await addableDecision(prepared);
if (!addable.canAdd) return addable;
}
return {
state: "duplicate",
canAdd: config.duplicateBehavior === "new",
error: null,
noteIds,
mature: false,
};
}
async function decision(prepared, request, duplicateIndex) {
const { invoke, config } = prepared;
const expression = request.term?.expression ?? request.expression;
const source = await duplicateIndex.source(config);
if (source === null) return unindexedDecision(prepared);
let duplicate = await duplicateIndex.lookup(config, expression, invoke);
if (!duplicate.noteIds.length) return addableDecision(prepared);
if (config.duplicateBehavior === "overwrite") {
let inspected = await inspectAnkiNoteIds(invoke, source, expression, duplicate.noteIds);
if (inspected.stale) {
duplicate = await duplicateIndex.repair(config, expression, invoke);
if (!duplicate.noteIds.length) return addableDecision(prepared);
inspected = await inspectAnkiNoteIds(invoke, source, expression, duplicate.noteIds);
}
const target = inspected.target;
return { state: "duplicate", canAdd: target !== null, action: "overwrite", target, noteIds: duplicate.noteIds,
mature: duplicate.mature,
error: target ? null : "A duplicate exists, but no matching configured note type is inside the selected scope." };
}
if (config.duplicateBehavior === "new") {
const addable = await addableDecision(prepared);
if (!addable.canAdd) return addable;
}
return { state: "duplicate", canAdd: config.duplicateBehavior === "new", error: null,
noteIds: duplicate.noteIds, mature: duplicate.mature };
}
function omitUnchangedFields(fields, existing) {
if (existing) for (const [field, value] of Object.entries(fields)) {
if (value === existing[field]) delete fields[field];
}
return fields;
}
function fieldsForDecision(prepared, checked) {
const target = checked.target;
if (!target) {
return {
fields: prepared.note.fields,
target: null,
templates: prepared.resolved.templates,
};
}
const canonical = canonicalAnkiFields(prepared.note.fields, prepared.resolved.templates, target.fields);
// Only the initial write omits unchanged values. Pronunciation enrichment
// compares its complete desired value with the text-only write it replaces.
const fields = omitUnchangedFields(overwriteAnkiFields(canonical.fields, target.fields, canonical.templates), target.fields);
return {
fields,
target,
templates: Object.fromEntries(Object.entries(canonical.templates).filter(([field]) => Object.hasOwn(fields, field))),
};
}
function captureForApplication(request, templates) {
const requirements = ankiCaptureRequirements(templates);
const unavailable = new Set(Array.isArray(request.captureUnavailable) ? request.captureUnavailable : []);
requirements.includeAnimation &&= !unavailable.has("animation");
requirements.includeAudio &&= !unavailable.has("audio");
if (!requirements.includeAnimation && !requirements.includeAudio) return null;
const pin = request.capturePin;
return {
requirements,
sourceLabel: pin?.sourceLabel,
partial: pin?.partial === true,
readyAtMs: pin?.readyAtMs,
};
}
// Names the looked-up word in an error, so a reader mining several results
// can tell which one Anki refused.
function describeRequestTerm(request) {
const expression = request?.term?.expression ?? request?.expression;
return typeof expression === "string" && expression.trim() ? `“${expression}”` : "this result";
}
async function writeAnkiNote(invoke, note, target, fields, duplicateNoteIds) {
let noteId;
if (target) {
const reply = await invoke("updateNoteFields", { note: { id: target.noteId, fields }, subminerEnrich: true }, 10_000);
if (reply !== null) throw new Error("Anki returned an invalid field-update acknowledgement.");
noteId = target.noteId;
} else {
try {
noteId = await invoke("addNote", { note, subminerDuplicateNoteIds: duplicateNoteIds }, 10_000);
} catch (error) {
throw addNoteContext(error, note);
}
}
if (!Number.isSafeInteger(noteId) || noteId <= 0) {
throw new Error(`Anki did not return a valid note ID for the ${target ? "updated" : "added"} note in deck “${note.deckName}”. Check the note in Anki.`);
}
return noteId;
}
// AnkiConnect's "empty" refusal names neither the note nor the field; Anki
// strips HTML before judging, so a first field this side considered filled
// can still be refused. A duplicate refusal is described by the caller.
function addNoteContext(error, note) {
const message = error?.message ?? String(error);
const [firstField] = Object.keys(note.fields ?? {});
const firstValue = firstField === undefined ? "" : String(note.fields[firstField] ?? "");
const context = `deck “${note.deckName}”, note type “${note.modelName}”`;
if (!/cannot create note because it is empty/iu.test(message)) return error;
const detail = `Anki refused the note for ${context} because its first field “${firstField}” is empty`
+ `${firstValue.trim() ? " once Anki stripped its formatting" : ""}.`;
const raw = (/\(AnkiConnect: (.+)\)$/u.exec(message)?.[1] ?? message).replace(/^AnkiConnect: /u, "");
return new Error(`${detail} (AnkiConnect: ${raw})`, { cause: error });
}
export function createAnkiMiningService({
gateway,
readConfig,
buildFields,
beforeWrite,
beforeMutation = async () => {},
afterConfirmed = async () => {},
afterRejected = async () => {},
preflightExtra = async () => ({}),
validateCapture = async () => {},
enrich,
duplicateIndex,
now = Date.now,
}) {
const cached = new Map();
let mutations = Promise.resolve();
const invokeFor = config => (action, params, timeoutMs) => gateway.invoke(action, params, config.apiKey, timeoutMs, config.url);
async function identity(templateId) {
const config = await readConfig(templateId);
if (!config) throw new Error("The selected Anki Template is no longer available.");
const configJson = JSON.stringify(config);
const configKey = await ankiDigest(new TextEncoder().encode(JSON.stringify({
templateId: templateId ?? null,
config,
})));
return { config, configJson, configKey, templateId: templateId ?? null };
}
async function configuration(templateId, fresh = false) {
const current = await identity(templateId);
const { config, configJson } = current;
const cacheKey = templateId ?? "";
const previous = cached.get(cacheKey);
if (!fresh && previous?.key === configJson && now() < previous.expires) return previous.promise;
const promise = (async () => {
// Correlate reader requests without returning the saved API key/source
// credentials in a serialized configuration string to each content script.
const { configKey } = current;
if (!config.model) return { config, configKey, configJson, errors: ["Choose an Anki note type in Settings."] };
const discovery = await gateway.discover(config);
const resolved = resolveAnkiTemplates(config, discovery.fields);
return { config, configKey, configJson, discovery, resolved, errors: ankiAvailability(config, discovery, resolved) };
})();
// GSM's two-second status cache, sharing concurrent callers as well. Only
// read-only preparation may use it; each submission refreshes discovery.
cached.set(cacheKey, { key: configJson, expires: now() + 2000, promise });
return promise;
}
async function status(templateId) {
const current = await configuration(templateId);
return { available: current.errors.length === 0, configKey: current.configKey, error: current.errors.join("\n") };
}
async function view(request) {
const current = await identity(request?.templateId);
const expression = request?.term?.expression ?? request?.expression;
const unknown = {
state: "unknown",
canAdd: false,
noteIds: [],
configKey: current.configKey,
cached: false,
};
if (current.config.duplicateBehavior !== "prevent") return unknown;
const duplicate = await duplicateIndex.peek(current.config, expression);
if (!duplicate.noteIds.length) return unknown;
return {
state: "duplicate",
canAdd: false,
noteIds: duplicate.noteIds,
mature: duplicate.mature,
configKey: current.configKey,
cached: true,
};
}
async function prepare(request, fresh) {
const configured = await configuration(request?.templateId, fresh);
const current = requestConfiguration(configured, request);
if (request.configKey !== current.configKey) throw new Error(CONFIG_CHANGED);
if (current.errors.length) throw new Error(current.errors.join("\n"));
const resources = await buildFields(request, current, { preflight: !fresh });
const { fields } = resources;
const firstField = current.discovery.fields[0];
if (!fields[firstField]?.trim()) {
const template = current.resolved.templates[firstField]?.value ?? "";
throw new Error(`The first field of note type “${current.config.model}”, “${firstField}”, is empty for this result`
+ `${template ? `: its template ${template} produced nothing for ${describeRequestTerm(request)}` : ""}. Anki requires it.`);
}
const note = { deckName: current.config.deck, modelName: current.config.model, fields,
options: ankiNoteOptions(current.config), tags: [...new Set(current.config.tags)] };
return { ...current, note, resources, firstField, invoke: invokeFor(current.config) };
}
async function preflight(request) {
const prepared = await prepare(request, false);
if (prepared.resources.deferDuplicateCheck === true) {
const capture = captureForApplication(request, prepared.resolved.templates);
if (capture) await validateCapture({ request, prepared, capture });
const extra = await preflightExtra({ request, prepared, applied: null, deferred: true });
return {
state: "addable",
canAdd: true,
error: null,
deferred: true,
capture,
screenshot: prepared.config.captureScreenshot === true
&& ankiCaptureRequirements(prepared.resolved.templates).includeScreenshot,
...(extra ?? {}),
};
}
const result = await decision(prepared, request, duplicateIndex);
const applied = result.canAdd ? fieldsForDecision(prepared, result) : null;
const capture = applied ? captureForApplication(request, applied.templates) : null;
if (capture) await validateCapture({ request, prepared, capture });
const extra = await preflightExtra({ request, prepared, applied, deferred: false });
return {
state: result.state,
canAdd: result.canAdd,
error: result.error,
action: result.action,
noteIds: result.noteIds,
capture,
// A mapped {screenshot} that the user has left switched on: the reader
// takes the viewport picture itself, when it submits. The whole
// request-specific mapping decides, not the subset this preflight would
// apply, because the authoritative decision is made again inside the
// write and may then apply a field this one would have kept.
screenshot: prepared.config.captureScreenshot === true
&& ankiCaptureRequirements(prepared.resolved.templates).includeScreenshot,
...(extra ?? {}),
};
}
async function write(request) {
const prepared = await prepare(request, true);
const checked = await decision(prepared, request, duplicateIndex);
if (!checked.canAdd) return { state: checked.state, error: checked.error,
action: checked.action, noteIds: checked.noteIds };
const { config, configJson, firstField, note, invoke } = prepared;
const { fields, target, templates } = fieldsForDecision(prepared, checked);
const capture = captureForApplication(request, templates);
if (capture) await validateCapture({ request, prepared, capture });
if (JSON.stringify(await readConfig(request?.templateId)) !== configJson) throw new Error(CONFIG_CHANGED);
const writeResources = await beforeWrite({
request,
...prepared,
target,
appliedFields: fields,
capture,
});
// Failed media can restore a field's original value after preparation.
// Leave it untouched instead of overwriting an intervening Anki edit.
omitUnchangedFields(fields, target?.fields);
// A definitive no-write releases whatever only this note would have used.
// An uncertain write keeps it: the note may exist in Anki after all.
const releaseRejected = () => afterRejected({ request, ...prepared, writeResources })
.catch(() => undefined);
if (JSON.stringify(await readConfig(request?.templateId)) !== configJson) {
await releaseRejected();
throw new Error(CONFIG_CHANGED);
}
// Uploads and configuration reads can outlive Stop. Validate the remaining
// write ownership last, with no unrelated await before sending the mutation.
try {
await beforeMutation({ request, capture, writeResources });
} catch (error) {
await releaseRejected();
throw error;
}
let noteId;
try {
noteId = await writeAnkiNote(invoke, note, target, fields, checked.noteIds ?? []);
} catch (error) {
if (isAnkiDuplicateError(error.message)) {
await releaseRejected();
let noteIds = [];
try {
const expression = request.term?.expression ?? request.expression;
noteIds = await duplicateIndex.source(config) === null
? (await findAnkiDuplicateNotes(invoke, note, firstField, config)).map(match => match.noteId)
: (await duplicateIndex.repair(config, expression, invoke)).noteIds;
} catch { /* The duplicate result is definitive even if browse discovery fails. */ }
const firstValue = String(fields[firstField] ?? note.fields[firstField] ?? "").trim();
return { state: "duplicate", noteIds,
error: `Anki already has a note in deck “${note.deckName}” (note type “${note.modelName}”) whose first field “${firstField}” is `
+ `${firstValue ? `“${firstValue}”` : "empty"}.` };
}
if (isUndispatchedAnkiTransportError(error)) {
// A failed endpoint generation rejected this queued mutation before it
// entered fetch. Its note cannot exist, so release request-owned media
// and return a definitive retryable failure instead of uncertainty.
await releaseRejected();
throw error;
}
// A lost acknowledgement may follow a completed write. Neither this
// worker nor the reader retries it automatically, including append modes.
return { state: "uncertain", error: `The write could not be confirmed. Check Anki before trying again. ${error.message}` };
}
const warnings = [...(Array.isArray(writeResources?.warnings) ? writeResources.warnings : [])];
try {
await duplicateIndex.recordWrite(config, request.term?.expression ?? request.expression, noteId,
{ mature: checked.mature === true });
} catch (error) {
warnings.push(`Duplicate index: ${error.message}`);
}
let verified = false;
try {
await verifyAnkiFields(invoke, noteId, fields);
verified = true;
} catch (error) {
warnings.push(error.message);
}
try {
await afterConfirmed({
request,
...prepared,
noteId,
existingFields: target?.fields,
appliedFields: fields,
capture,
writeResources,
verified,
});
} catch (error) {
warnings.push(`Captured media cleanup: ${error.message}`);
}
if (verified) {
try {
warnings.push(...await enrich({ request, ...prepared, noteId, existingFields: target?.fields, appliedFields: fields }));
} catch (error) {
warnings.push(error.message);
}
}
cached.delete(request?.templateId ?? "");
return { state: target ? "updated" : "added", noteId, warnings };
}
function submit(request) {
const operation = mutations.then(() => write(request));
mutations = operation.catch(() => {});
return operation;
}
async function browse(request) {
const value = typeof request === "string" ? { expression: request } : request;
const config = await readConfig(value?.templateId);
if (!config) throw new Error("The selected Anki Template is no longer available.");
if (typeof value?.configKey === "string") {
const configKey = await ankiDigest(new TextEncoder().encode(JSON.stringify({
templateId: value?.templateId ?? null,
config,
})));
if (value.configKey !== configKey) throw new Error(CONFIG_CHANGED);
}
const invoke = invokeFor(config);
const supplied = Array.isArray(value?.noteIds) && value.noteIds.length;
let noteIds = supplied ? [...value.noteIds] : [];
let repaired = false;
if (supplied && typeof value?.expression === "string" && value.expression
&& await duplicateIndex.source(config) !== null) {
const refreshed = await duplicateIndex.repair(config, value.expression, invoke);
noteIds = refreshed.noteIds;
if (!noteIds.length) return { opened: false, noteIds: [], repaired: true };
repaired = true;
}
const query = noteIds.length ? ankiNoteIdsQuery(noteIds) : ankiBrowseQuery(value?.expression ?? "");
await invoke("guiBrowse", { query }, 30_000);
return { opened: true, noteIds, repaired };
}
return { status, view, preflight, submit, browse };
}