mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
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:
+115
@@ -0,0 +1,115 @@
|
||||
// Complete persisted-state contract shared by the engine and storage owner.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import "./reader-options.js";
|
||||
import "./dictionary-group-state.js";
|
||||
import {
|
||||
assertCustomSourceState, customDictionarySemanticRevision,
|
||||
normaliseCustomDictionaryDocument, parseCustomDictionary,
|
||||
} from "./custom-dictionary.js";
|
||||
import { assertDictionaryUpdateSchedule, assertRecommendedDictionary, normaliseUpdateSettings, recommendedDictionarySource } from "./managed-dictionary-source.js";
|
||||
import { sameJsonValue } from "./json-value.js";
|
||||
import { assertLookupStatsDescriptor } from "./lookup-stats.js";
|
||||
|
||||
export function backupRevisions(snapshot) {
|
||||
return Object.fromEntries(["state", "options", "document", "updates", "lookupStats"].map(key => {
|
||||
const revision = snapshot[key]?.revision;
|
||||
return [key, Number.isSafeInteger(revision) && revision >= 0 ? revision : 0];
|
||||
}));
|
||||
}
|
||||
|
||||
export function restoredBackupSnapshot(current, archived, dictionaries) {
|
||||
return Object.fromEntries(Object.entries(backupRevisions(current)).map(([key, revision]) => [key, {
|
||||
...(key === "options"
|
||||
? globalThis.HDReaderOptions.projectStoredOptions(archived[key])
|
||||
: archived[key]),
|
||||
...(key === "state" ? { dictionaries } : {}),
|
||||
...(key === "lookupStats" ? { generation: crypto.randomUUID() } : {}),
|
||||
revision: revision + 1,
|
||||
}]));
|
||||
}
|
||||
|
||||
function assertDictionaryList(dictionaries) {
|
||||
const ids = new Set(), titles = new Set();
|
||||
for (const entry of dictionaries) {
|
||||
assertDictionaryUpdateSchedule(entry);
|
||||
if (typeof entry?.id !== "string" || entry.id === "" || ids.has(entry.id)
|
||||
|| typeof entry.title !== "string" || entry.title === "" || titles.has(entry.title)
|
||||
|| /[\\/]/u.test(entry.title) || entry.title.includes("\0") || [".", ".."].includes(entry.title)
|
||||
|| typeof entry.revision !== "string"
|
||||
|| typeof entry.enabled !== "boolean" || typeof entry.favorite !== "boolean"
|
||||
|| (entry.displayName !== null && typeof entry.displayName !== "string")
|
||||
|| ["termCount", "frequencyCount", "pitchCount", "kanjiCount", "mediaCount"].some(key =>
|
||||
!Number.isSafeInteger(entry[key]) || entry[key] < 0)) {
|
||||
throw new Error("The backup contains invalid or duplicate dictionary packages.");
|
||||
}
|
||||
const recommended = recommendedDictionarySource(entry.sourceId);
|
||||
if (recommended) {
|
||||
assertRecommendedDictionary(recommended, entry);
|
||||
if (entry.downloadUrl !== recommended.downloadUrl) {
|
||||
throw new Error("The backup dictionary does not match its recommended source.");
|
||||
}
|
||||
}
|
||||
ids.add(entry.id);
|
||||
titles.add(entry.title);
|
||||
}
|
||||
}
|
||||
|
||||
function assertGroups(groups, dictionaries) {
|
||||
const { normaliseDictionaryGroups, groupNameKey } = globalThis.HDDictionaryGroups;
|
||||
if (!Array.isArray(groups) || !sameJsonValue(groups, normaliseDictionaryGroups(groups, dictionaries))) {
|
||||
throw new Error("The backup contains invalid dictionary groups.");
|
||||
}
|
||||
const ids = new Set(), names = new Set(["all"]);
|
||||
for (const group of groups) {
|
||||
const key = groupNameKey(group.name);
|
||||
if (ids.has(group.id) || names.has(key)) throw new Error("The backup contains duplicate dictionary groups.");
|
||||
ids.add(group.id);
|
||||
names.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
function validBackupReaderOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) return false;
|
||||
const allowed = new Set([...Object.keys(globalThis.HDReaderOptions.DEFAULT_OPTIONS), "modifier"]);
|
||||
if (Object.keys(options).some(key => !allowed.has(key))) return false;
|
||||
let projected;
|
||||
try {
|
||||
projected = globalThis.HDReaderOptions.validateOptionsPatch(options);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// A legacy backup may have only `customLinks`, and its singleton Anki
|
||||
// object has no `templates`. If the richer fields are present, however, they
|
||||
// must already be canonical instead of relying on migration to resolve two
|
||||
// conflicting representations.
|
||||
if (Object.hasOwn(options, "customButtons") && Object.hasOwn(options, "customLinks")
|
||||
&& !sameJsonValue(options.customLinks, projected.customLinks)) return false;
|
||||
if (options.anki && Object.hasOwn(options.anki, "templates")
|
||||
&& !sameJsonValue(options.anki, projected.anki)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function assertBackupSnapshot(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)
|
||||
|| ["state", "options", "document", "updates"].some(key =>
|
||||
!Number.isSafeInteger(snapshot[key]?.revision) || snapshot[key].revision < 0)
|
||||
|| snapshot.state?.schemaVersion !== 1 || !Array.isArray(snapshot.state.dictionaries)) {
|
||||
throw new Error("The backup contains invalid dictionary state.");
|
||||
}
|
||||
assertDictionaryList(snapshot.state.dictionaries);
|
||||
assertLookupStatsDescriptor(snapshot.lookupStats);
|
||||
assertGroups(snapshot.state.groups, snapshot.state.dictionaries);
|
||||
const document = normaliseCustomDictionaryDocument(snapshot.document);
|
||||
const entries = parseCustomDictionary(document.text).entries;
|
||||
const semanticRevision = await customDictionarySemanticRevision(entries);
|
||||
if (document.semanticRevision !== semanticRevision) throw new Error("The backup custom source has invalid semantics.");
|
||||
assertCustomSourceState(snapshot.state.dictionaries, semanticRevision, entries.length);
|
||||
const { revision, ...options } = snapshot.options ?? {};
|
||||
if (!Number.isSafeInteger(revision) || revision < 0
|
||||
|| !validBackupReaderOptions(options)) {
|
||||
throw new Error("The backup contains invalid reader settings.");
|
||||
}
|
||||
if (!sameJsonValue(snapshot.updates, normaliseUpdateSettings(snapshot.updates))) {
|
||||
throw new Error("The backup contains invalid update settings.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user