mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- 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
216 lines
7.3 KiB
JavaScript
216 lines
7.3 KiB
JavaScript
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);
|
|
}
|