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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user