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:
+130
@@ -0,0 +1,130 @@
|
||||
// Local daily automatic snapshots using the complete manual-backup payload.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import { assertLookupStatsRows } from "./lookup-stats.js";
|
||||
import { assertBackupSnapshot } from "./backup-state.js";
|
||||
|
||||
export const AUTOMATIC_BACKUPS_KEY = "automaticBackups";
|
||||
export const AUTOMATIC_BACKUP_ALARM = "hachidori-automatic-backup";
|
||||
export const AUTOMATIC_BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
export const AUTOMATIC_BACKUP_SCHEMA_VERSION = 1;
|
||||
|
||||
export function emptyAutomaticBackupStore() {
|
||||
return { schemaVersion: AUTOMATIC_BACKUP_SCHEMA_VERSION, backups: [] };
|
||||
}
|
||||
|
||||
export function automaticBackupStore(value) {
|
||||
if (value === undefined) return emptyAutomaticBackupStore();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)
|
||||
|| value.schemaVersion !== AUTOMATIC_BACKUP_SCHEMA_VERSION
|
||||
|| !Array.isArray(value.backups)) {
|
||||
throw new Error("The automatic backup index has an unsupported schema.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function automaticBackupTime(record) {
|
||||
const timestamp = typeof record?.createdAt === "string" ? Date.parse(record.createdAt) : Number.NaN;
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
export function newestAutomaticBackupTime(value) {
|
||||
const store = automaticBackupStore(value);
|
||||
let newest = null;
|
||||
for (const record of store.backups) {
|
||||
const timestamp = automaticBackupTime(record);
|
||||
if (timestamp !== null && (newest === null || timestamp > newest)) newest = timestamp;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
export function automaticBackupDue(value, now = Date.now()) {
|
||||
const newest = newestAutomaticBackupTime(value);
|
||||
return newest === null || now - newest >= AUTOMATIC_BACKUP_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function nextAutomaticBackupTime(value, now = Date.now()) {
|
||||
const newest = newestAutomaticBackupTime(value);
|
||||
return newest === null ? now : newest + AUTOMATIC_BACKUP_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export async function assertAutomaticBackupRecord(record) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)
|
||||
|| typeof record.id !== "string" || record.id === ""
|
||||
|| automaticBackupTime(record) === null
|
||||
|| !Array.isArray(record.lookupStatsRows)) {
|
||||
throw new Error("The automatic backup record is invalid.");
|
||||
}
|
||||
await assertBackupSnapshot(record.snapshot);
|
||||
assertLookupStatsRows(record.snapshot.lookupStats, record.lookupStatsRows);
|
||||
return record;
|
||||
}
|
||||
|
||||
function newestFirst(left, right) {
|
||||
return automaticBackupTime(right) - automaticBackupTime(left)
|
||||
|| String(right.id).localeCompare(String(left.id));
|
||||
}
|
||||
|
||||
export async function validAutomaticBackups(value) {
|
||||
const store = automaticBackupStore(value);
|
||||
const valid = [];
|
||||
let corruptCount = 0;
|
||||
for (const record of store.backups) {
|
||||
try {
|
||||
await assertAutomaticBackupRecord(record);
|
||||
valid.push(record);
|
||||
} catch {
|
||||
corruptCount += 1;
|
||||
}
|
||||
}
|
||||
valid.sort(newestFirst);
|
||||
const ids = new Set();
|
||||
const backups = [];
|
||||
for (const record of valid) {
|
||||
if (ids.has(record.id)) {
|
||||
corruptCount += 1;
|
||||
continue;
|
||||
}
|
||||
ids.add(record.id);
|
||||
backups.push(record);
|
||||
}
|
||||
return { backups, corruptCount };
|
||||
}
|
||||
|
||||
// `limit` is the user's `automaticBackupDays` option: one snapshot per day, so
|
||||
// the retained count is the number of days kept. A lowered limit prunes when
|
||||
// the next snapshot is written, not when the option changes.
|
||||
export async function replaceAutomaticBackup(value, record, limit) {
|
||||
await assertAutomaticBackupRecord(record);
|
||||
const { backups } = await validAutomaticBackups(value);
|
||||
const retained = backups.filter(candidate => candidate.id !== record.id);
|
||||
retained.push(record);
|
||||
retained.sort(newestFirst);
|
||||
return {
|
||||
schemaVersion: AUTOMATIC_BACKUP_SCHEMA_VERSION,
|
||||
backups: retained.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
export function automaticBackupJsonBytes(value) {
|
||||
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
||||
}
|
||||
|
||||
export function formatAutomaticBackupAge(createdAt, now = Date.now(), locale = undefined) {
|
||||
const timestamp = Date.parse(createdAt);
|
||||
if (!Number.isFinite(timestamp)) return "unknown time";
|
||||
const delta = timestamp - now;
|
||||
const absolute = Math.abs(delta);
|
||||
if (absolute < 60_000) return delta > 0 ? "in less than a minute" : "less than a minute ago";
|
||||
let unit = "day";
|
||||
let unitMilliseconds = 24 * 60 * 60_000;
|
||||
if (absolute < 60 * 60_000) {
|
||||
unit = "minute";
|
||||
unitMilliseconds = 60_000;
|
||||
} else if (absolute < 24 * 60 * 60_000) {
|
||||
unit = "hour";
|
||||
unitMilliseconds = 60 * 60_000;
|
||||
}
|
||||
const magnitude = Math.max(1, Math.floor(absolute / unitMilliseconds));
|
||||
return new Intl.RelativeTimeFormat(locale, { numeric: "always" })
|
||||
.format(delta < 0 ? -magnitude : magnitude, unit);
|
||||
}
|
||||
Reference in New Issue
Block a user