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
+277
View File
@@ -0,0 +1,277 @@
// Explicit complete backup/restore controls; the engine owns preparation tokens.
// SPDX-License-Identifier: GPL-3.0-or-later
import { formatAutomaticBackupAge } from "./backup-automatic.js";
import { downloadBlob } from "./blob-download.js";
export function createBackupSettingsController({
document, send, download, listAutomatic = null, checkReady, setBusy, status, refresh,
trackPreparation = () => {}, cancelPreparation = () => {}, browserName = "Chrome",
}) {
const element = id => document.getElementById(id);
const window = document.defaultView;
let busy = false, prepared = null;
let preparingToken = null;
let exportedUrl = null;
let pageEpoch = 0;
let automaticBackups = [];
let automaticTimer = null;
let automaticLoadEpoch = 0;
function render() {
element("backup-export").disabled = busy;
element("backup-file").disabled = busy;
element("backup-cancel").disabled = busy;
element("backup-restore").disabled = busy || !prepared || !element("backup-confirm").checked;
element("backup-confirm").disabled = busy;
element("backup-preview").hidden = prepared === null;
for (const button of element("automatic-backup-list").querySelectorAll("button")) {
button.disabled = busy;
}
}
async function run(message, operation, requireReady = true) {
if (busy) return;
try {
if (requireReady) checkReady();
busy = true;
setBusy(true);
render();
status(message, "");
await operation();
} catch (error) {
status(error.message || String(error), "error");
} finally {
busy = false;
setBusy(false);
render();
}
}
async function cancelPrepared() {
if (!prepared) return;
const token = prepared.token;
const reply = await send("hd_backup_cancel", { token });
if (!reply.ok) throw new Error(reply.error || "Could not discard the prepared restore.");
trackPreparation(token, false);
prepared = null;
element("backup-confirm").checked = false;
render();
}
async function releaseExport() {
if (!exportedUrl) return;
const reply = await send("hd_backup_release", { blobUrl: exportedUrl });
if (!reply?.ok) throw new Error(reply?.error || "Could not release the temporary backup archive. Try exporting again.");
exportedUrl = null;
}
function renderAutomaticAges() {
for (const row of element("automatic-backup-list").children) {
const backup = automaticBackups.find(candidate => candidate.id === row.dataset.backupId);
if (!backup) continue;
const age = formatAutomaticBackupAge(backup.createdAt);
row.querySelector(".automatic-backup-age").textContent = age;
row.querySelector(".automatic-backup-restore").textContent = `Restore from ${age}`;
}
}
function renderAutomaticBackups() {
const rows = document.createDocumentFragment();
for (const backup of automaticBackups) {
const row = element("automatic-backup-template").content.firstElementChild.cloneNode(true);
row.dataset.backupId = backup.id;
row.querySelector(".automatic-backup-detail").textContent =
`${backup.dictionaries.length} dictionaries · ${backup.customEntryCount} personal entries`;
const created = row.querySelector(".automatic-backup-created");
created.dateTime = backup.createdAt;
created.textContent = new Date(backup.createdAt).toLocaleString();
row.querySelector(".automatic-backup-restore").addEventListener("click", () => {
const age = formatAutomaticBackupAge(backup.createdAt);
void run("Checking the automatic backup and validating its dictionary files…", () =>
prepareRestore("hd_backup_auto_prepare", { id: backup.id }, `Automatic backup from ${age}`));
});
rows.append(row);
}
element("automatic-backup-list").replaceChildren(rows);
renderAutomaticAges();
render();
}
async function loadAutomaticBackups() {
if (!listAutomatic) return;
const epoch = ++automaticLoadEpoch;
try {
const reply = await listAutomatic();
if (epoch !== automaticLoadEpoch) return;
if (!reply?.ok) throw new Error(reply?.error || "Could not read automatic backups.");
automaticBackups = Array.isArray(reply.backups) ? reply.backups : [];
renderAutomaticBackups();
if (reply.corruptCount > 0) {
const count = reply.corruptCount === 1 ? "One automatic backup is" : `${reply.corruptCount} automatic backups are`;
element("automatic-backup-status").textContent =
`${count} damaged. Any valid older backup remains available below.`;
} else if (automaticBackups.length === 0) {
element("automatic-backup-status").textContent =
"No automatic backup has been created yet.";
} else {
element("automatic-backup-status").textContent = "";
}
} catch (error) {
if (epoch !== automaticLoadEpoch) return;
automaticBackups = [];
renderAutomaticBackups();
element("automatic-backup-status").textContent =
error.message || String(error);
}
}
function startAutomaticBackups() {
if (!listAutomatic) return;
void loadAutomaticBackups();
if (automaticTimer === null) {
automaticTimer = window.setInterval(renderAutomaticAges, 60_000);
}
}
function showPrepared(reply, label) {
prepared = reply;
element("backup-confirm").checked = false;
element("backup-file-name").textContent = label;
const created = element("backup-created");
created.dateTime = reply.createdAt;
created.textContent = new Date(reply.createdAt).toLocaleString();
element("backup-count").textContent = `${reply.dictionaries.length} dictionaries · ${reply.customEntryCount} personal entries`;
const list = document.createDocumentFragment();
for (const dictionary of reply.dictionaries) {
const item = document.createElement("li");
item.textContent = `${dictionary.title}${dictionary.enabled ? "" : " (disabled)"}`;
list.append(item);
}
element("backup-dictionaries").replaceChildren(list);
status(reply.warning || "Backup checked. Nothing has been replaced.", reply.warning ? "" : "ready");
render();
element("backup-preview-heading").focus();
}
async function prepareRestore(type, fields, label) {
const epoch = pageEpoch;
await cancelPrepared();
if (epoch !== pageEpoch) return;
const token = window.crypto.randomUUID();
preparingToken = token;
trackPreparation(token, true);
let reply;
try {
reply = await send(type, { ...fields, token });
} catch (error) {
// The engine may have prepared successfully before its reply was lost.
try {
const cancelled = await send("hd_backup_cancel", { token });
if (cancelled.ok) trackPreparation(token, false);
} catch { /* Keep the original failure. */ }
throw error;
} finally {
preparingToken = null;
}
if (!reply.ok) {
trackPreparation(token, false);
throw new Error(reply.error || "This backup could not be prepared.");
}
if (epoch !== pageEpoch) return;
showPrepared(reply, label);
}
element("backup-export").addEventListener("click", () => {
void run("Creating the backup archive…", async () => {
if (!download) {
await releaseExport();
const exported = await send("hd_backup_export");
if (!exported.ok) throw new Error(exported.error || "Could not create the backup.");
exportedUrl = exported.blobUrl;
let blob;
// A failure here leaves exportedUrl set; the next export click retries the release.
const response = await window.fetch(exported.blobUrl);
if (!response.ok) throw new Error("Could not read the backup archive.");
blob = await response.blob();
await releaseExport();
downloadBlob(document, blob, `hachidori-backup-${new Date().toISOString().slice(0, 10)}.zip`);
status("Save requested. Choose where to save the backup in your apps save dialog.", "ready", true);
return;
}
const reply = await download();
if (!reply.ok) throw new Error(reply.error || "Could not create the backup.");
status(reply.warning || `Download started. Check ${browserName}s downloads for progress.`, reply.warning ? "" : "ready", true);
});
});
element("backup-file").addEventListener("change", () => {
const file = element("backup-file").files?.[0];
element("backup-file").value = "";
if (!file) return;
void run("Checking the archive and preparing fresh dictionary files…", async () => {
const blobUrl = window.URL.createObjectURL(file);
try {
await prepareRestore("hd_backup_prepare", { blobUrl }, file.name);
} finally {
window.URL.revokeObjectURL(blobUrl);
}
});
});
element("backup-confirm").addEventListener("change", render);
element("backup-cancel").addEventListener("click", () => run("Discarding the prepared restore…", async () => {
await cancelPrepared();
status("Restore cancelled. Your data has not changed.", "");
element("backup-file").focus();
}, false));
element("backup-restore").addEventListener("click", () => {
if (!prepared || !element("backup-confirm").checked) return;
void run("Restoring dictionaries and settings…", async () => {
const token = prepared.token;
// A restore attempt consumes its token, including an uncertain reply.
prepared = null;
trackPreparation(token, false);
element("backup-confirm").checked = false;
const reply = await send("hd_backup_restore", { token });
if (!reply.ok) throw new Error(reply.error || "The restore could not be confirmed. Check the current library before trying again.");
status(reply.warning || "Restored successfully.", reply.warning ? "" : "ready", true);
try { await refresh(); }
catch { status("Restored successfully. Reopen Settings to refresh this page.", "ready", true); }
await loadAutomaticBackups();
});
});
window.addEventListener("pagehide", () => {
pageEpoch += 1;
automaticLoadEpoch += 1;
if (automaticTimer !== null) {
window.clearInterval(automaticTimer);
automaticTimer = null;
}
const token = preparingToken ?? prepared?.token;
preparingToken = null;
prepared = null;
element("backup-confirm").checked = false;
render();
if (token) status("Restore cancelled. Choose the backup again to prepare it.", "");
if (token) {
// Port delivery is synchronous and its disconnect is a second cleanup
// signal when an older Chrome drops this page's final runtime message.
cancelPreparation(token);
void send("hd_backup_cancel", { token })
.then(reply => { if (reply.ok) trackPreparation(token, false); })
.catch(() => {});
}
if (exportedUrl) {
const blobUrl = exportedUrl;
exportedUrl = null;
void send("hd_backup_release", { blobUrl }).catch(() => {});
}
});
window.addEventListener("pageshow", event => {
if (event.persisted) startAutomaticBackups();
});
render();
startAutomaticBackups();
return { render, refreshAutomaticBackups: loadAutomaticBackups };
}