diff --git a/client/backup.js b/client/backup.js new file mode 100644 index 0000000..32e22d5 --- /dev/null +++ b/client/backup.js @@ -0,0 +1,902 @@ +/** + * Client-side backup & restore for PearDock preferences and local state. + * + * Packages settings, peers, templates, table columns, notifications, and + * related keys into a versioned JSON archive. Maintains a local backup + * history with retention (max count / max age) and auto-rotation. + * + * Storage: localStorage key peardock.backups.v1 + * Export format: peardock-backup (JSON, version 1) + */ + +import { + loadPeers, + savePeers, + getLastActivePeerId, + setLastActivePeerId, + LOCALSTORAGE_KEY, + ACTIVE_PEER_KEY, +} from './peerCache.js' + +export const BACKUP_FORMAT = 'peardock-backup' +export const BACKUP_FORMAT_VERSION = 1 +export const BACKUP_STORE_KEY = 'peardock.backups.v1' +export const SETTINGS_KEY = 'peardock.settings.v1' +export const TEMPLATES_KEY = 'peardock_templates' +export const NOTIFICATIONS_KEY = 'peardock_notifications' +export const PEER_ENVS_KEY = 'peardock.peerEnvs.v1' +export const TABLE_COLS_PREFIX = 'peardock.tableCols.' +export const FIRST_CONNECT_KEY = 'peardock.firstConnect.dismissed' +export const SIDEBAR_KEY = 'peardock.sidebar.collapsed' + +/** @typedef {'settings'|'peers'|'templates'|'notifications'|'tableColumns'|'peerEnvs'|'misc'} BackupSection */ + +/** @type {Record} */ +export const DEFAULT_INCLUDE = Object.freeze({ + settings: true, + peers: true, + templates: true, + notifications: false, + tableColumns: true, + peerEnvs: true, + misc: true, +}) + +/** @type {BackupPolicy} */ +export const DEFAULT_POLICY = Object.freeze({ + maxBackups: 10, + maxAgeDays: 90, + autoEnabled: false, + autoIntervalHours: 24, + include: { ...DEFAULT_INCLUDE }, + lastAutoAt: null, +}) + +/** + * @typedef {object} BackupPolicy + * @property {number} maxBackups — 0 = unlimited (still soft-capped at 50) + * @property {number} maxAgeDays — 0 = no age pruning + * @property {boolean} autoEnabled + * @property {number} autoIntervalHours + * @property {Record} include + * @property {string|null} lastAutoAt + */ + +/** + * @typedef {object} BackupRecord + * @property {string} id + * @property {string} createdAt + * @property {string} [label] + * @property {'manual'|'auto'|'import'} source + * @property {number} sizeBytes + * @property {string[]} sections + * @property {boolean} [pinned] + * @property {object} package — full peardock-backup payload + */ + +/** + * @typedef {object} BackupStore + * @property {number} version + * @property {BackupRecord[]} backups + * @property {BackupPolicy} policy + */ + +const SOFT_MAX_BACKUPS = 50 +const MAX_LABEL_LEN = 120 + +/** @type {ReturnType|null} */ +let autoTimer = null + +/** + * @returns {string} + */ +function nowIso() { + return new Date().toISOString() +} + +/** + * @returns {string} + */ +function newId() { + try { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + } catch { + // fall through + } + return `b_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}` +} + +/** + * @param {unknown} raw + * @returns {BackupPolicy} + */ +export function normalizePolicy(raw) { + const p = raw && typeof raw === 'object' ? raw : {} + const includeIn = p.include && typeof p.include === 'object' ? p.include : {} + /** @type {Record} */ + const include = { ...DEFAULT_INCLUDE } + for (const key of Object.keys(DEFAULT_INCLUDE)) { + if (typeof includeIn[key] === 'boolean') include[key] = includeIn[key] + } + let maxBackups = Number(p.maxBackups) + if (!Number.isFinite(maxBackups) || maxBackups < 0) maxBackups = DEFAULT_POLICY.maxBackups + maxBackups = Math.min(SOFT_MAX_BACKUPS, Math.floor(maxBackups)) + + let maxAgeDays = Number(p.maxAgeDays) + if (!Number.isFinite(maxAgeDays) || maxAgeDays < 0) maxAgeDays = DEFAULT_POLICY.maxAgeDays + maxAgeDays = Math.min(3650, Math.floor(maxAgeDays)) + + let autoIntervalHours = Number(p.autoIntervalHours) + if (!Number.isFinite(autoIntervalHours) || autoIntervalHours < 1) { + autoIntervalHours = DEFAULT_POLICY.autoIntervalHours + } + autoIntervalHours = Math.min(24 * 30, Math.floor(autoIntervalHours)) + + return { + maxBackups, + maxAgeDays, + autoEnabled: Boolean(p.autoEnabled), + autoIntervalHours, + include, + lastAutoAt: + typeof p.lastAutoAt === 'string' && p.lastAutoAt ? p.lastAutoAt : null, + } +} + +/** + * @returns {BackupStore} + */ +export function loadBackupStore() { + try { + if (typeof localStorage === 'undefined') { + return { version: 1, backups: [], policy: { ...DEFAULT_POLICY, include: { ...DEFAULT_INCLUDE } } } + } + const raw = JSON.parse(localStorage.getItem(BACKUP_STORE_KEY) || '{}') + const backups = Array.isArray(raw.backups) + ? raw.backups + .filter((b) => b && typeof b === 'object' && b.package && b.id) + .map((b) => ({ + id: String(b.id), + createdAt: String(b.createdAt || ''), + label: b.label != null ? String(b.label).slice(0, MAX_LABEL_LEN) : '', + source: b.source === 'auto' || b.source === 'import' ? b.source : 'manual', + sizeBytes: Number(b.sizeBytes) || 0, + sections: Array.isArray(b.sections) ? b.sections.map(String) : [], + pinned: Boolean(b.pinned), + package: b.package, + })) + : [] + return { + version: 1, + backups, + policy: normalizePolicy(raw.policy), + } + } catch { + return { + version: 1, + backups: [], + policy: { ...DEFAULT_POLICY, include: { ...DEFAULT_INCLUDE } }, + } + } +} + +/** + * @param {BackupStore} store + */ +export function saveBackupStore(store) { + if (typeof localStorage === 'undefined') return + const payload = { + version: 1, + updatedAt: nowIso(), + policy: normalizePolicy(store.policy), + backups: Array.isArray(store.backups) ? store.backups : [], + } + localStorage.setItem(BACKUP_STORE_KEY, JSON.stringify(payload)) +} + +/** + * @param {Partial} partial + * @returns {BackupPolicy} + */ +export function saveBackupPolicy(partial) { + const store = loadBackupStore() + store.policy = normalizePolicy({ ...store.policy, ...partial }) + if (partial?.include) { + store.policy.include = normalizePolicy({ + ...store.policy, + include: { ...store.policy.include, ...partial.include }, + }).include + } + store.backups = applyRetention(store.backups, store.policy) + saveBackupStore(store) + return store.policy +} + +/** + * Collect peardock.tableCols.* keys from localStorage. + * @returns {Record} + */ +export function collectTableColumns() { + /** @type {Record} */ + const out = {} + try { + if (typeof localStorage === 'undefined') return out + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (!key || !key.startsWith(TABLE_COLS_PREFIX)) continue + try { + out[key.slice(TABLE_COLS_PREFIX.length)] = JSON.parse(localStorage.getItem(key) || 'null') + } catch { + out[key.slice(TABLE_COLS_PREFIX.length)] = localStorage.getItem(key) + } + } + } catch { + // ignore + } + return out +} + +/** + * @param {Record} map + */ +function restoreTableColumns(map) { + if (!map || typeof map !== 'object' || typeof localStorage === 'undefined') return + for (const [suffix, value] of Object.entries(map)) { + if (!suffix || suffix.includes('..') || suffix.includes('/')) continue + try { + localStorage.setItem( + TABLE_COLS_PREFIX + suffix, + typeof value === 'string' ? value : JSON.stringify(value) + ) + } catch { + // ignore per-key failures + } + } +} + +/** + * Build a portable backup package from current client state. + * @param {{ include?: Partial>, label?: string, appVersion?: string }} [opts] + * @returns {object} + */ +export function buildBackupPackage(opts = {}) { + const include = normalizePolicy({ include: { ...DEFAULT_INCLUDE, ...opts.include } }).include + /** @type {Record} */ + const sections = {} + /** @type {string[]} */ + const included = [] + + if (include.settings) { + try { + sections.settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') + } catch { + sections.settings = {} + } + included.push('settings') + } + + if (include.peers) { + try { + sections.peers = loadPeers() || {} + } catch { + try { + sections.peers = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) || '{}') + } catch { + sections.peers = {} + } + } + try { + sections.activePeerId = getLastActivePeerId() || localStorage.getItem(ACTIVE_PEER_KEY) || null + } catch { + sections.activePeerId = null + } + included.push('peers') + } + + if (include.templates) { + try { + sections.templates = JSON.parse(localStorage.getItem(TEMPLATES_KEY) || '{}') + } catch { + sections.templates = {} + } + included.push('templates') + } + + if (include.notifications) { + try { + sections.notifications = JSON.parse(localStorage.getItem(NOTIFICATIONS_KEY) || '[]') + } catch { + sections.notifications = [] + } + included.push('notifications') + } + + if (include.tableColumns) { + sections.tableColumns = collectTableColumns() + included.push('tableColumns') + } + + if (include.peerEnvs) { + try { + sections.peerEnvs = JSON.parse(localStorage.getItem(PEER_ENVS_KEY) || '{}') + } catch { + sections.peerEnvs = {} + } + included.push('peerEnvs') + } + + if (include.misc) { + sections.misc = { + firstConnectDismissed: + typeof localStorage !== 'undefined' + ? localStorage.getItem(FIRST_CONNECT_KEY) === '1' + : false, + sidebarCollapsed: + typeof localStorage !== 'undefined' ? localStorage.getItem(SIDEBAR_KEY) : null, + } + included.push('misc') + } + + const createdAt = nowIso() + const pkg = { + format: BACKUP_FORMAT, + version: BACKUP_FORMAT_VERSION, + createdAt, + appVersion: opts.appVersion || guessAppVersion(), + label: opts.label ? String(opts.label).slice(0, MAX_LABEL_LEN) : '', + sections, + meta: { + include: { ...include }, + sectionKeys: included, + note: 'May include peer public keys and admin seeds. Store securely.', + }, + } + return pkg +} + +/** + * @returns {string} + */ +function guessAppVersion() { + try { + const el = typeof document !== 'undefined' ? document.getElementById('settings-app-version') : null + if (el?.textContent?.trim()) return el.textContent.trim() + } catch { + // ignore + } + return '2.0.1' +} + +/** + * Validate a parsed backup package. + * @param {unknown} raw + * @returns {{ ok: true, package: object } | { ok: false, error: string }} + */ +export function validateBackupPackage(raw) { + if (!raw || typeof raw !== 'object') { + return { ok: false, error: 'Backup is not a JSON object' } + } + const pkg = /** @type {Record} */ (raw) + if (pkg.format !== BACKUP_FORMAT) { + return { ok: false, error: `Unknown backup format (expected ${BACKUP_FORMAT})` } + } + const ver = Number(pkg.version) + if (!Number.isFinite(ver) || ver < 1 || ver > BACKUP_FORMAT_VERSION) { + return { ok: false, error: `Unsupported backup version: ${pkg.version}` } + } + if (!pkg.sections || typeof pkg.sections !== 'object') { + return { ok: false, error: 'Backup missing sections payload' } + } + return { ok: true, package: pkg } +} + +/** + * Serialize package for download / size accounting. + * @param {object} pkg + * @returns {string} + */ +export function serializePackage(pkg) { + return JSON.stringify(pkg, null, 2) +} + +/** + * Suggested download filename for a package. + * @param {object} [pkg] + * @returns {string} + */ +export function backupFilename(pkg) { + const ts = (pkg?.createdAt || nowIso()).replace(/[:.]/g, '-').replace(/Z$/, 'Z') + const label = pkg?.label + ? `-${String(pkg.label) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40)}` + : '' + return `peardock-backup${label}-${ts}.json` +} + +/** + * Trigger a browser download of the package JSON. + * @param {object} pkg + * @param {string} [filename] + * @returns {{ filename: string, sizeBytes: number }} + */ +export function downloadBackupPackage(pkg, filename) { + const text = serializePackage(pkg) + const name = filename || backupFilename(pkg) + if (typeof document !== 'undefined' && typeof Blob !== 'undefined') { + const blob = new Blob([text], { type: 'application/json;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name + a.rel = 'noopener' + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 2_000) + } + return { filename: name, sizeBytes: text.length } +} + +/** + * Apply retention: drop expired unpinned, then cap count (pinned always kept). + * @param {BackupRecord[]} backups + * @param {BackupPolicy} policy + * @returns {BackupRecord[]} + */ +export function applyRetention(backups, policy) { + const p = normalizePolicy(policy) + let list = Array.isArray(backups) ? [...backups] : [] + + // Age prune (unpinned only) + if (p.maxAgeDays > 0) { + const cutoff = Date.now() - p.maxAgeDays * 86_400_000 + list = list.filter((b) => { + if (b.pinned) return true + const t = Date.parse(b.createdAt) + if (!Number.isFinite(t)) return true + return t >= cutoff + }) + } + + // Newest first + list.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0)) + + // Count prune: keep all pinned + newest unpinned up to maxBackups total soft rules + const max = p.maxBackups > 0 ? p.maxBackups : SOFT_MAX_BACKUPS + const pinned = list.filter((b) => b.pinned) + const unpinned = list.filter((b) => !b.pinned) + // Prefer: pinned always; fill remaining slots with newest unpinned + // If pinned alone exceeds max, still keep all pinned (operator chose them) + const room = Math.max(0, max - pinned.length) + list = [...pinned, ...unpinned.slice(0, room)] + list.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0)) + return list +} + +/** + * Create a backup, store in history, apply rotation, optionally download. + * @param {{ + * include?: Partial>, + * label?: string, + * source?: 'manual'|'auto'|'import', + * download?: boolean, + * appVersion?: string, + * pin?: boolean, + * }} [opts] + * @returns {{ record: BackupRecord, store: BackupStore, downloaded?: { filename: string, sizeBytes: number } }} + */ +export function createBackup(opts = {}) { + const store = loadBackupStore() + const include = opts.include || store.policy.include + const pkg = buildBackupPackage({ + include, + label: opts.label, + appVersion: opts.appVersion, + }) + const text = serializePackage(pkg) + /** @type {BackupRecord} */ + const record = { + id: newId(), + createdAt: pkg.createdAt, + label: pkg.label || '', + source: opts.source === 'auto' || opts.source === 'import' ? opts.source : 'manual', + sizeBytes: text.length, + sections: pkg.meta?.sectionKeys || Object.keys(pkg.sections || {}), + pinned: Boolean(opts.pin), + package: pkg, + } + + store.backups = applyRetention([record, ...store.backups], store.policy) + if (opts.source === 'auto') { + store.policy = { ...store.policy, lastAutoAt: record.createdAt } + } + saveBackupStore(store) + + /** @type {{ filename: string, sizeBytes: number }|undefined} */ + let downloaded + if (opts.download) { + downloaded = downloadBackupPackage(pkg) + } + + return { record, store, downloaded } +} + +/** + * Import an external package into local history (does not apply restore). + * @param {unknown} raw + * @param {{ label?: string, pin?: boolean }} [opts] + * @returns {{ ok: true, record: BackupRecord, store: BackupStore } | { ok: false, error: string }} + */ +export function importBackupToHistory(raw, opts = {}) { + const v = validateBackupPackage(raw) + if (!v.ok) return v + const pkg = { ...v.package } + if (opts.label) pkg.label = String(opts.label).slice(0, MAX_LABEL_LEN) + const text = serializePackage(pkg) + const store = loadBackupStore() + const record = { + id: newId(), + createdAt: String(pkg.createdAt || nowIso()), + label: pkg.label || opts.label || 'Imported', + source: /** @type {const} */ ('import'), + sizeBytes: text.length, + sections: Array.isArray(pkg.meta?.sectionKeys) + ? pkg.meta.sectionKeys + : Object.keys(pkg.sections || {}), + pinned: Boolean(opts.pin), + package: pkg, + } + store.backups = applyRetention([record, ...store.backups], store.policy) + saveBackupStore(store) + return { ok: true, record, store } +} + +/** + * @param {string} id + * @returns {BackupRecord|null} + */ +export function getBackupById(id) { + if (!id) return null + return loadBackupStore().backups.find((b) => b.id === id) || null +} + +/** + * @param {string} id + * @returns {boolean} + */ +export function deleteBackup(id) { + const store = loadBackupStore() + const next = store.backups.filter((b) => b.id !== id) + if (next.length === store.backups.length) return false + store.backups = next + saveBackupStore(store) + return true +} + +/** + * @param {string} id + * @param {boolean} pinned + * @returns {BackupRecord|null} + */ +export function setBackupPinned(id, pinned) { + const store = loadBackupStore() + const rec = store.backups.find((b) => b.id === id) + if (!rec) return null + rec.pinned = Boolean(pinned) + store.backups = applyRetention(store.backups, store.policy) + saveBackupStore(store) + return rec +} + +/** + * Restore client state from a package. Merge or replace per opts. + * @param {unknown} raw + * @param {{ + * sections?: Partial>, + * mode?: 'merge'|'replace', + * }} [opts] + * @returns {{ ok: true, restored: string[], warnings: string[] } | { ok: false, error: string }} + */ +export function restoreFromPackage(raw, opts = {}) { + const v = validateBackupPackage(raw) + if (!v.ok) return v + const pkg = v.package + const sections = pkg.sections || {} + const mode = opts.mode === 'merge' ? 'merge' : 'replace' + /** @type {Record} */ + const want = { ...DEFAULT_INCLUDE } + if (opts.sections) { + for (const k of Object.keys(DEFAULT_INCLUDE)) { + if (typeof opts.sections[k] === 'boolean') want[k] = opts.sections[k] + } + } else if (pkg.meta?.include && typeof pkg.meta.include === 'object') { + for (const k of Object.keys(DEFAULT_INCLUDE)) { + if (typeof pkg.meta.include[k] === 'boolean') want[k] = pkg.meta.include[k] + } + } + + /** @type {string[]} */ + const restored = [] + /** @type {string[]} */ + const warnings = [] + + try { + if (want.settings && sections.settings && typeof sections.settings === 'object') { + if (mode === 'merge') { + let cur = {} + try { + cur = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') + } catch { + cur = {} + } + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...cur, ...sections.settings })) + } else { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(sections.settings)) + } + restored.push('settings') + } + + if (want.peers && sections.peers && typeof sections.peers === 'object') { + let peersMap = sections.peers + // Accept array form + if (Array.isArray(peersMap)) { + /** @type {Record} */ + const map = {} + for (const p of peersMap) { + if (!p || typeof p !== 'object') continue + const id = p.id || String(p.publicKeyHex || '').slice(0, 12) + if (id) map[id] = p + } + peersMap = map + } + if (mode === 'merge') { + try { + const existing = loadPeers() || {} + peersMap = { ...existing, ...peersMap } + } catch { + // use package only + } + } + const activePeerId = + sections.activePeerId != null ? String(sections.activePeerId) : undefined + const result = savePeers(peersMap, { + ...(activePeerId !== undefined ? { activePeerId } : {}), + }) + if (!result.ok) { + warnings.push('Peers written to localStorage mirror only (file write failed)') + } + if (activePeerId) { + try { + setLastActivePeerId(activePeerId) + } catch { + try { + localStorage.setItem(ACTIVE_PEER_KEY, activePeerId) + } catch { + // ignore + } + } + } + restored.push('peers') + } + + if (want.templates && sections.templates != null) { + localStorage.setItem( + TEMPLATES_KEY, + JSON.stringify( + mode === 'merge' + ? { + ...(() => { + try { + return JSON.parse(localStorage.getItem(TEMPLATES_KEY) || '{}') + } catch { + return {} + } + })(), + ...(typeof sections.templates === 'object' ? sections.templates : {}), + } + : sections.templates + ) + ) + restored.push('templates') + } + + if (want.notifications && sections.notifications != null) { + localStorage.setItem( + NOTIFICATIONS_KEY, + JSON.stringify( + Array.isArray(sections.notifications) ? sections.notifications : [] + ) + ) + restored.push('notifications') + } + + if (want.tableColumns && sections.tableColumns && typeof sections.tableColumns === 'object') { + restoreTableColumns(sections.tableColumns) + restored.push('tableColumns') + } + + if (want.peerEnvs && sections.peerEnvs && typeof sections.peerEnvs === 'object') { + if (mode === 'merge') { + let cur = {} + try { + cur = JSON.parse(localStorage.getItem(PEER_ENVS_KEY) || '{}') + } catch { + cur = {} + } + localStorage.setItem(PEER_ENVS_KEY, JSON.stringify({ ...cur, ...sections.peerEnvs })) + } else { + localStorage.setItem(PEER_ENVS_KEY, JSON.stringify(sections.peerEnvs)) + } + restored.push('peerEnvs') + } + + if (want.misc && sections.misc && typeof sections.misc === 'object') { + const misc = sections.misc + if (misc.firstConnectDismissed) { + localStorage.setItem(FIRST_CONNECT_KEY, '1') + } else if (mode === 'replace') { + localStorage.removeItem(FIRST_CONNECT_KEY) + } + if (misc.sidebarCollapsed === '1' || misc.sidebarCollapsed === 1 || misc.sidebarCollapsed === true) { + localStorage.setItem(SIDEBAR_KEY, '1') + } else if (mode === 'replace' && (misc.sidebarCollapsed === '0' || misc.sidebarCollapsed === false || misc.sidebarCollapsed === null)) { + localStorage.setItem(SIDEBAR_KEY, '0') + } + restored.push('misc') + } + } catch (err) { + return { ok: false, error: err?.message || String(err) } + } + + if (restored.length === 0) { + return { ok: false, error: 'No matching sections to restore from this backup' } + } + return { ok: true, restored, warnings } +} + +/** + * Parse file text into a package. + * @param {string} text + * @returns {{ ok: true, package: object } | { ok: false, error: string }} + */ +export function parseBackupText(text) { + if (text == null || !String(text).trim()) { + return { ok: false, error: 'Empty file' } + } + try { + const raw = JSON.parse(String(text)) + return validateBackupPackage(raw) + } catch (err) { + return { ok: false, error: `Invalid JSON: ${err?.message || err}` } + } +} + +/** + * Human-readable size. + * @param {number} n + * @returns {string} + */ +export function formatBytes(n) { + const v = Number(n) || 0 + if (v < 1024) return `${v} B` + if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB` + return `${(v / (1024 * 1024)).toFixed(2)} MB` +} + +/** + * Summarize store for UI. + * @returns {{ count: number, totalBytes: number, policy: BackupPolicy, backups: BackupRecord[], nextAutoDue: string|null }} + */ +export function getBackupSummary() { + const store = loadBackupStore() + const totalBytes = store.backups.reduce((s, b) => s + (b.sizeBytes || 0), 0) + let nextAutoDue = null + if (store.policy.autoEnabled) { + const last = store.policy.lastAutoAt ? Date.parse(store.policy.lastAutoAt) : 0 + const intervalMs = store.policy.autoIntervalHours * 3_600_000 + const next = (Number.isFinite(last) && last > 0 ? last : 0) + intervalMs + nextAutoDue = new Date(Math.max(Date.now(), next)).toISOString() + // If never run, due now + if (!store.policy.lastAutoAt) nextAutoDue = nowIso() + } + return { + count: store.backups.length, + totalBytes, + policy: store.policy, + backups: store.backups, + nextAutoDue, + } +} + +/** + * Run auto-backup if policy says it's due. + * @param {{ force?: boolean, appVersion?: string }} [opts] + * @returns {{ ran: boolean, record?: BackupRecord, reason?: string }} + */ +export function maybeRunAutoBackup(opts = {}) { + const store = loadBackupStore() + const policy = store.policy + if (!policy.autoEnabled && !opts.force) { + return { ran: false, reason: 'auto-disabled' } + } + if (!opts.force && policy.lastAutoAt) { + const last = Date.parse(policy.lastAutoAt) + const due = last + policy.autoIntervalHours * 3_600_000 + if (Number.isFinite(last) && Date.now() < due) { + return { ran: false, reason: 'not-due' } + } + } + const { record } = createBackup({ + source: 'auto', + include: policy.include, + label: 'Auto backup', + appVersion: opts.appVersion, + download: false, + }) + return { ran: true, record } +} + +/** + * Start / restart the auto-backup interval timer (client session). + * @param {{ onRun?: (result: ReturnType) => void, appVersion?: string }} [opts] + */ +export function startAutoBackupScheduler(opts = {}) { + stopAutoBackupScheduler() + const tick = () => { + try { + const result = maybeRunAutoBackup({ appVersion: opts.appVersion }) + if (result.ran) opts.onRun?.(result) + } catch (err) { + console.warn('[backup] auto tick failed', err?.message || err) + } + } + // Check every 15 minutes; interval policy decides whether to run + autoTimer = setInterval(tick, 15 * 60 * 1000) + if (typeof autoTimer.unref === 'function') autoTimer.unref() + // Immediate check shortly after start + setTimeout(tick, 8_000) +} + +export function stopAutoBackupScheduler() { + if (autoTimer) { + clearInterval(autoTimer) + autoTimer = null + } +} + +/** + * Wipe all local history (not current settings). + * @returns {number} removed count + */ +export function clearBackupHistory() { + const store = loadBackupStore() + const n = store.backups.length + store.backups = [] + saveBackupStore(store) + return n +} + +export default { + BACKUP_FORMAT, + BACKUP_FORMAT_VERSION, + DEFAULT_INCLUDE, + DEFAULT_POLICY, + loadBackupStore, + saveBackupPolicy, + buildBackupPackage, + validateBackupPackage, + createBackup, + downloadBackupPackage, + restoreFromPackage, + parseBackupText, + applyRetention, + getBackupSummary, + maybeRunAutoBackup, + startAutoBackupScheduler, + stopAutoBackupScheduler, +} diff --git a/index.html b/index.html index ac6ff1f..45760f2 100644 --- a/index.html +++ b/index.html @@ -2643,7 +2643,7 @@ services:
+ + +