mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- 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
476 lines
22 KiB
JavaScript
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 };
|
|
}
|