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
+215
View File
@@ -0,0 +1,215 @@
import { RECOMMENDED_DICTIONARIES } from "./recommended-dictionaries.js";
/*
* Canonical update-source, download trust, and schedule rules shared by every
* runtime context that enforces managed dictionary updates.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
const RECOMMENDED_BY_ID = new Map(
RECOMMENDED_DICTIONARIES.map((entry) => [entry.sourceId, entry]),
);
export const MANAGED_UPDATE_SCHEDULE_MINUTES = Object.freeze({
off: null,
hourly: 60,
daily: 24 * 60,
weekly: 7 * 24 * 60,
monthly: 30 * 24 * 60,
});
export function managedUpdateSchedule(value) {
return typeof value === "string"
&& Object.hasOwn(MANAGED_UPDATE_SCHEDULE_MINUTES, value)
? value
: null;
}
export function normaliseUpdateSettings(value) {
return {
revision: Number.isSafeInteger(value?.revision) && value.revision >= 0 ? value.revision : 0,
schedule: managedUpdateSchedule(value?.schedule) ?? "off",
lastCheckedAt: typeof value?.lastCheckedAt === "string" ? value.lastCheckedAt : null,
};
}
export function effectiveDictionarySchedule(dictionary, globalSchedule) {
return managedUpdateSchedule(dictionary.updateScheduleOverride) ?? globalSchedule;
}
export function assertDictionaryUpdateSchedule(dictionary) {
if (dictionary.updateScheduleOverride != null && managedUpdateSchedule(dictionary.updateScheduleOverride) === null) {
throw new Error("The dictionary update schedule is invalid.");
}
}
export function nextDictionaryUpdateCheck(dictionary, globalSchedule, now) {
if (managedDictionarySource(dictionary) === null) return null;
const interval = MANAGED_UPDATE_SCHEDULE_MINUTES[effectiveDictionarySchedule(dictionary, globalSchedule)];
if (interval === null) return null;
const checkedAt = Date.parse(dictionary.lastUpdateCheck?.checkedAt);
return Number.isNaN(checkedAt) ? now : checkedAt + interval * 60_000;
}
export function nextManagedUpdateCheck(dictionaries, globalSchedule, now) {
let next = null;
for (const dictionary of dictionaries) {
const due = nextDictionaryUpdateCheck(dictionary, globalSchedule, now);
if (due !== null && (next === null || due < next)) next = due;
}
return next === null ? null : Math.max(now, next);
}
export const MANAGED_DICTIONARY_CHANGED =
"the managed dictionary changed while its update was being prepared";
export function httpsUrl(value) {
try {
const url = new URL(value);
return url.protocol === "https:" && url.username === "" && url.password === ""
? url.href
: null;
} catch {
return null;
}
}
export function recommendedDictionarySource(sourceId) {
return RECOMMENDED_BY_ID.get(sourceId) ?? null;
}
// A recommendation counts as installed through its validated catalogue identity
// or its exact update index, never through a display name.
export function installedRecommendedDictionary(entry, dictionaries) {
return dictionaries.find((dictionary) =>
dictionary.sourceId === entry.sourceId
|| (entry.indexUrl !== null && dictionary.indexUrl === entry.indexUrl)) ?? null;
}
export function recommendedDictionaryInstalled(entry, dictionaries) {
return installedRecommendedDictionary(entry, dictionaries) !== null;
}
export function assertRecommendedDictionary(source, dictionary) {
if (!new RegExp(source.titlePattern, "u").test(dictionary.title)) {
throw new Error(`${source.name} archive did not match its expected title`);
}
if (dictionary.indexUrl !== source.indexUrl) {
throw new Error(`${source.name} archive did not match its expected update source`);
}
if (typeof dictionary.revision !== "string" || dictionary.revision === "") {
throw new Error(`${source.name} archive did not declare a revision`);
}
const countKey = source.requiredCapability === "freq" ? "frequencyCount" : "termCount";
const hasCapability = dictionary[countKey] > 0;
if (!hasCapability) {
throw new Error(`${source.name} archive did not contain its expected capability`);
}
}
export function managedDictionarySource(dictionary) {
const recommended = recommendedDictionarySource(dictionary?.sourceId);
if (recommended !== null) {
if (recommended.indexUrl === null) return null;
return {
kind: "recommended",
sourceId: recommended.sourceId,
indexUrl: recommended.indexUrl,
downloadUrl: recommended.downloadUrl,
};
}
const indexUrl = httpsUrl(dictionary?.indexUrl);
const downloadUrl = httpsUrl(dictionary?.downloadUrl);
if (dictionary?.isUpdatable !== true || indexUrl === null || downloadUrl === null) {
return null;
}
return {
kind: "generic",
sourceId: typeof dictionary?.sourceId === "string" && dictionary.sourceId !== ""
? dictionary.sourceId
: null,
indexUrl,
downloadUrl,
};
}
export function managedDictionaryFingerprint(dictionary) {
const source = managedDictionarySource(dictionary);
return source !== null
&& typeof dictionary?.id === "string"
&& dictionary.id !== ""
&& typeof dictionary?.path === "string"
&& dictionary.path !== ""
&& typeof dictionary?.revision === "string"
? {
id: dictionary.id,
path: dictionary.path,
revision: dictionary.revision,
source,
}
: null;
}
export function managedDictionaryMatches(dictionary, fingerprint) {
const current = managedDictionaryFingerprint(dictionary);
return current !== null
&& current.id === fingerprint?.id
&& current.path === fingerprint?.path
&& current.revision === fingerprint?.revision
&& current.source.kind === fingerprint?.source?.kind
&& current.source.sourceId === fingerprint?.source?.sourceId
&& current.source.indexUrl === fingerprint?.source?.indexUrl
&& current.source.downloadUrl === fingerprint?.source?.downloadUrl;
}
function recommendedAssetUrlMatches(source, value, declaredUrl, assetName) {
let finalUrl;
try {
finalUrl = new URL(value);
} catch {
return false;
}
if (finalUrl.protocol !== "https:" || finalUrl.username !== "" || finalUrl.password !== "") {
return false;
}
if (finalUrl.href === new URL(declaredUrl).href) {
return true;
}
if (source.githubRepository === null) {
return false;
}
if (finalUrl.hostname === "github.com") {
const prefix = `/${source.githubRepository}/releases/download/`;
const rest = finalUrl.pathname.startsWith(prefix) ? finalUrl.pathname.slice(prefix.length) : "";
return rest.includes("/")
&& decodeURIComponent(rest.slice(rest.lastIndexOf("/") + 1)) === assetName;
}
if (finalUrl.hostname !== "release-assets.githubusercontent.com") {
return false;
}
const assetPrefix = `/github-production-release-asset/${source.githubRepositoryId}/`;
if (!finalUrl.pathname.startsWith(assetPrefix)) {
return false;
}
const disposition = finalUrl.searchParams.get("response-content-disposition")
?? finalUrl.searchParams.get("rscd")
?? "";
const match = /(?:^|;)\s*filename="?([^";]+)"?/iu.exec(disposition);
return match?.[1] === assetName;
}
export function recommendedDownloadUrlMatches(source, value) {
return recommendedAssetUrlMatches(
source,
value,
source.downloadUrl,
source.archiveName,
);
}
export function recommendedIndexUrlMatches(source, value) {
const index = new URL(source.indexUrl);
const assetName = decodeURIComponent(index.pathname.slice(index.pathname.lastIndexOf("/") + 1));
return recommendedAssetUrlMatches(source, value, index.href, assetName);
}