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
131 lines
4.6 KiB
JavaScript
131 lines
4.6 KiB
JavaScript
// 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);
|
|
}
|