"use client"; /** * Full-data backup: one JSON file with decks, folders, homework, progress and * safe settings — **never the API key**. Import validates with zod, shows a * preview of what will be added or replaced, and offers merge or replace. * * The same payload shape is what encrypted sync uploads (minus settings), so * the two features share this file's validation. */ import { z } from "zod"; import { applyTheme } from "./theme"; import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store"; import { replaceFolders, folderStore } from "./folders"; import { homeworkStore, replaceHomework } from "./homework"; import { ensureLearningHydrated, learningStore, replaceLearning } from "./history"; import { setLocale, t } from "./i18n"; import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema, chatConversationSchema } from "./schemas"; import { saveSettings, settingsStore } from "./settings"; import { deckStore, replaceDecks } from "./storage"; export const BACKUP_VERSION = 1; const settingsSchema = z.object({ haptics: z.boolean().optional(), locale: z.enum(["en", "cs"]).optional(), theme: z.enum(["light", "dark"]).optional(), dailyGoal: z.number().int().min(1).max(500).optional(), }); export const backupDataSchema = z.object({ decks: z.array(deckSchema).max(2000), folders: z.array(folderSchema).max(500), homework: z.array(homeworkRecordSchema).max(2000), /** Answer log and SRS schedule (IndexedDB). Optional for old backups. */ history: z.array(answerRecordSchema).max(20_000).optional(), srs: z.array(srsRecordSchema).max(50_000).optional(), /** Chat conversations (IndexedDB). Optional for old backups. */ chats: z.array(chatConversationSchema).max(100).optional(), }); export const backupSchema = z.object({ app: z.literal("freezmo"), kind: z.literal("backup"), version: z.literal(1), exportedAt: z.string(), data: backupDataSchema.extend({ settings: settingsSchema.optional() }), }); export type BackupFile = z.infer; /* ------------------------------------------------------------------ */ /* Building and downloading */ /* ------------------------------------------------------------------ */ export async function buildBackup(): Promise { await ensureLearningHydrated(); await ensureChatsHydrated(); return { app: "freezmo", kind: "backup", version: BACKUP_VERSION, exportedAt: new Date().toISOString(), data: { decks: deckStore.getSnapshot(), folders: folderStore.getSnapshot(), homework: homeworkStore.getSnapshot(), history: learningStore.getAnswersSnapshot(), srs: learningStore.getSrsSnapshot(), chats: chatHistoryStore.getSnapshot(), settings: { haptics: settingsStore.getSnapshot().haptics, dailyGoal: settingsStore.getSnapshot().dailyGoal, locale: (window.localStorage.getItem("freezmo.locale.v1") as "en" | "cs" | null) ?? undefined, theme: (window.localStorage.getItem("freezmo.theme") as "light" | "dark" | null) ?? undefined, }, }, }; } export async function downloadBackup(): Promise { const backup = await buildBackup(); const blob = new Blob([JSON.stringify(backup, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `freezmo-backup-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); markBackedUp(backup.data.decks.length); return backup; } /* ------------------------------------------------------------------ */ /* Parsing, previewing, applying */ /* ------------------------------------------------------------------ */ export function parseBackup(raw: string): { backup: BackupFile } | { error: string } { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { error: t("import.notJson") }; } const result = backupSchema.safeParse(parsed); if (!result.success) return { error: t("import.notBackup") }; return { backup: result.data }; } export type ImportCounts = { added: number; replaced: number }; function countMerge(current: T[], incoming: T[]): ImportCounts { const ids = new Set(current.map((item) => item.id)); let added = 0; let replaced = 0; for (const item of incoming) { if (ids.has(item.id)) replaced += 1; else added += 1; } return { added, replaced }; } function mergeById(current: T[], incoming: T[]): T[] { const positions = new Map(current.map((item, index) => [item.id, index])); const merged = [...current]; for (const item of incoming) { const at = positions.get(item.id); if (at === undefined) { positions.set(item.id, merged.length); merged.push(item); } else { merged[at] = item; } } return merged; } export type ImportPreview = { decks: ImportCounts; folders: ImportCounts; homework: ImportCounts; chats: ImportCounts; }; export function previewImport(backup: BackupFile): ImportPreview { return { decks: countMerge(deckStore.getSnapshot(), backup.data.decks), folders: countMerge(folderStore.getSnapshot(), backup.data.folders), homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework), chats: countMerge(chatHistoryStore.getSnapshot(), backup.data.chats ?? []), }; } /** Applies a backup; the API key is never touched (it is not in the file). */ export async function applyImport( backup: BackupFile, mode: "merge" | "replace", ): Promise<{ decks: number; homework: number }> { const data = backup.data; const decks = mode === "replace" ? data.decks : mergeById(deckStore.getSnapshot(), data.decks); const folders = mode === "replace" ? data.folders : mergeById(folderStore.getSnapshot(), data.folders); const homework = mode === "replace" ? data.homework : mergeById(homeworkStore.getSnapshot(), data.homework); // Folders first so imported decks can point at them. Importing is a real // local change, so it also marks sync dirty (via the stores' write()). replaceFolders(folders); replaceDecks(decks); replaceHomework(homework); // Answer log + schedule: merge by id (answers) and by key (schedules). await ensureLearningHydrated(); const incomingAnswers = data.history ?? []; const incomingSrs = data.srs ?? []; if (incomingAnswers.length > 0 || incomingSrs.length > 0) { const currentAnswers = learningStore.getAnswersSnapshot(); const currentSrs = learningStore.getSrsSnapshot(); const answers = mode === "replace" ? incomingAnswers : mergeById(currentAnswers, incomingAnswers); const srsByKey = new Map(currentSrs.map((entry) => [entry.key, entry])); for (const entry of incomingSrs) { const current = srsByKey.get(entry.key); if (!current || current.lastAt < entry.lastAt) srsByKey.set(entry.key, entry); } await replaceLearning(answers, [...srsByKey.values()]); } // Chat conversations: merge by id (newest wins) or replace wholesale. await ensureChatsHydrated(); const incomingChats = data.chats ?? []; if (incomingChats.length > 0) { replaceChats(mode === "replace" ? incomingChats : mergeById(chatHistoryStore.getSnapshot(), incomingChats)); } const settings = data.settings; if (settings) { if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics }); if (typeof settings.dailyGoal === "number") saveSettings({ dailyGoal: settings.dailyGoal }); if (settings.locale) setLocale(settings.locale); if (settings.theme) applyTheme(settings.theme); } return { decks: decks.length, homework: homework.length }; } /* ------------------------------------------------------------------ */ /* Backup nudge */ /* ------------------------------------------------------------------ */ const BACKUP_STATE_KEY = "freezmo.backup.v1"; const NUDGE_DAYS = 14; const NUDGE_DECKS = 5; type BackupState = { lastBackupAt: number | null; decksAtLastBackup: number; snoozedUntil: number | null; }; const EMPTY_STATE: BackupState = { lastBackupAt: null, decksAtLastBackup: 0, snoozedUntil: null }; function readBackupState(): BackupState { try { const raw = window.localStorage.getItem(BACKUP_STATE_KEY); if (!raw) return EMPTY_STATE; const parsed = JSON.parse(raw) as Partial; return { lastBackupAt: typeof parsed.lastBackupAt === "number" ? parsed.lastBackupAt : null, decksAtLastBackup: typeof parsed.decksAtLastBackup === "number" ? parsed.decksAtLastBackup : 0, snoozedUntil: typeof parsed.snoozedUntil === "number" ? parsed.snoozedUntil : null, }; } catch { return EMPTY_STATE; } } function writeBackupState(next: BackupState): void { try { window.localStorage.setItem(BACKUP_STATE_KEY, JSON.stringify(next)); } catch { /* private mode */ } } export function markBackedUp(deckCount: number): void { writeBackupState({ lastBackupAt: Date.now(), decksAtLastBackup: deckCount, snoozedUntil: null }); } export function snoozeBackupNudge(): void { const state = readBackupState(); writeBackupState({ ...state, snoozedUntil: Date.now() + NUDGE_DAYS * 86_400_000 }); } export function backupNudgeDue(deckCount: number): boolean { if (deckCount < 2) return false; const state = readBackupState(); if (state.snoozedUntil && state.snoozedUntil > Date.now()) return false; if (state.lastBackupAt === null) return deckCount >= NUDGE_DECKS; if (Date.now() - state.lastBackupAt > NUDGE_DAYS * 86_400_000) return true; return deckCount >= state.decksAtLastBackup + NUDGE_DECKS; } export function lastBackupAt(): number | null { return readBackupState().lastBackupAt; } /* ------------------------------------------------------------------ */ /* Storage persistence */ /* ------------------------------------------------------------------ */ export type PersistenceState = "persistent" | "best-effort" | "unsupported"; export async function getStoragePersistence(): Promise { try { if (!navigator.storage?.persisted) return "unsupported"; return (await navigator.storage.persisted()) ? "persistent" : "best-effort"; } catch { return "unsupported"; } } export async function requestStoragePersistence(): Promise { try { if (!navigator.storage?.persist) return false; return await navigator.storage.persist(); } catch { return false; } }