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
+84
View File
@@ -0,0 +1,84 @@
/*
* Reads only Yomitan index.json for an interactive import decision. The engine
* still imports and validates the complete archive before anything is published.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { BlobReader, Writer, ZipReader } from "./vendor/zip.js";
import { dictionaryArchiveIdentity } from "./dictionary-import.js";
export const MAX_PREFLIGHT_INDEX_BYTES = 1024 * 1024;
export class BoundedIndexWriter extends Writer {
constructor(limit) {
super();
this.limit = limit;
this.length = 0;
this.parts = [];
}
writeUint8Array(bytes) {
if (this.length + bytes.byteLength > this.limit) {
throw new Error(`The selected archive's index.json exceeds ${this.limit} bytes.`);
}
this.parts.push(bytes.slice());
this.length += bytes.byteLength;
}
getData() {
const data = new Uint8Array(this.length);
let offset = 0;
for (const part of this.parts) {
data.set(part, offset);
offset += part.byteLength;
}
return new TextDecoder().decode(data);
}
}
function assertRegularIndex(entry) {
const kind = entry.unixMode === undefined ? 0 : entry.unixMode & 0o170000;
if (entry.directory || entry.symlink || (kind !== 0 && kind !== 0o100000)) {
throw new Error("The selected archive's index.json is not a regular file.");
}
if (entry.encrypted) {
throw new Error("The selected archive's index.json is encrypted.");
}
}
export async function readDictionaryArchiveIdentity(blob) {
const reader = new ZipReader(new BlobReader(blob), {
useWebWorkers: false,
checkAmbiguity: true,
});
try {
const entries = await reader.getEntries();
const indexes = entries.filter(entry => entry.filename === "index.json");
if (indexes.length !== 1) {
throw new Error(indexes.length === 0
? "The selected archive has no index.json."
: "The selected archive has more than one index.json.");
}
const entry = indexes[0];
assertRegularIndex(entry);
if (entry.uncompressedSize > MAX_PREFLIGHT_INDEX_BYTES) {
throw new Error(`The selected archive's index.json exceeds ${MAX_PREFLIGHT_INDEX_BYTES} bytes.`);
}
const data = await entry.getData(new BoundedIndexWriter(MAX_PREFLIGHT_INDEX_BYTES), {
useWebWorkers: false,
checkSignature: true,
checkOverlappingEntry: true,
checkAmbiguity: true,
});
let index;
try {
index = JSON.parse(data);
} catch (error) {
throw new Error(`The selected archive's index.json is malformed: ${error.message || String(error)}`);
}
return dictionaryArchiveIdentity(index);
} finally {
await reader.close();
}
}