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
+132
View File
@@ -0,0 +1,132 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import { decodedBase64Length } from "./anki-client-media.js";
const GENERATED_MEDIA_FILENAME = /^hachidori_[0-9a-f]{64}\.[a-z0-9]+$/u;
function validatePayload(data, kind) {
const bytes = decodedBase64Length(data);
if (bytes === null || bytes < 1) throw new Error(`The ${kind} payload is not valid base64.`);
return bytes;
}
async function exactMediaExists(invoke, filename) {
const names = await invoke("getMediaFilesNames", { pattern: filename }, 10_000);
if (!Array.isArray(names) || names.some(name => typeof name !== "string")) {
throw new Error("Anki returned an invalid media inventory.");
}
return names.includes(filename);
}
function referenced(fields, filename) {
return Object.values(fields).some(value => typeof value === "string" && value.includes(filename));
}
function requiredDictionaryMedia(resources, fields) {
if (!Array.isArray(resources.media)) throw new Error("The planned dictionary media is invalid.");
const required = new Map();
for (const item of resources.media) {
if (!item || typeof item !== "object" || typeof item.filename !== "string"
|| typeof item.dictionary !== "string" || typeof item.path !== "string") {
throw new Error("The planned dictionary media entry is invalid.");
}
if (!referenced(fields, item.filename)) continue;
const previous = required.get(item.filename);
if (previous && (previous.dictionary !== item.dictionary || previous.path !== item.path)) {
throw new Error(`Two dictionary resources planned the same Anki filename: ${item.filename}`);
}
required.set(item.filename, item);
}
return [...required.values()];
}
async function ensure({
invoke,
filename,
kind,
data,
load,
validate,
}) {
if (!GENERATED_MEDIA_FILENAME.test(filename)) {
throw new Error("The generated Anki media filename is invalid.");
}
await validate?.();
const exists = await exactMediaExists(invoke, filename);
await validate?.();
if (exists) {
return { filename, status: "existing", bytes: 0 };
}
const loaded = data === undefined ? await load() : { data };
const payload = typeof loaded === "string" ? loaded : loaded?.data;
const bytes = validatePayload(payload, kind);
await validate?.();
let stored;
try {
stored = await invoke("storeMediaFile", { filename, data: payload, deleteExisting: false }, 30_000);
} catch (error) {
// A timed-out acknowledgement may follow a completed write. Confirm the
// exact deterministic name before deciding that the note cannot proceed.
if (await exactMediaExists(invoke, filename).catch(() => false)) {
return { filename, status: "confirmed-after-error", bytes };
}
throw error;
}
if (stored !== filename) {
const exists = await exactMediaExists(invoke, filename);
if (!exists) throw new Error(`Anki stored ${kind} under a different filename.`);
return { filename, status: "existing-race", bytes };
}
if (!await exactMediaExists(invoke, filename)) {
throw new Error(`Anki acknowledged ${kind} without confirming the requested media filename.`);
}
return { filename, status: "stored", bytes };
}
export function createAnkiMediaStore() {
async function prepare({
request,
invoke,
appliedFields,
resources,
media,
validate,
}) {
const files = [];
try {
for (const item of requiredDictionaryMedia(resources, appliedFields)) {
files.push(await ensure({
invoke,
filename: item.filename,
kind: "dictionary image",
validate,
load: () => media(item, request.generation),
}));
}
if (resources.audioPrepared && resources.audio && referenced(appliedFields, resources.audio.filename)) {
files.push(await ensure({
invoke,
filename: resources.audio.filename,
data: resources.audio.data,
kind: "pronunciation",
validate,
}));
}
} catch (error) {
const retained = files.length;
if (retained > 0) {
throw new Error(`${error.message} ${retained} confirmed media ${retained === 1 ? "file was" : "files were"} retained for a safe retry.`, { cause: error });
}
throw error;
}
resources.confirmedMedia = files.map(file => file.filename);
return {
files,
required: files.length,
existing: files.filter(file => file.status !== "stored").length,
stored: files.filter(file => file.status === "stored").length,
uploadedBytes: files.reduce((sum, file) => sum + (file.status === "stored" ? file.bytes : 0), 0),
};
}
return { ensure, prepare };
}