- Conversations in a versioned IndexedDB store (freezmo-chat v1, zod validated with the same schema backup/sync use, capped at 100 x 300). Settled turns persist (question always; answer when produced, partial stops included); ?c=<id> keeps reloads on the conversation via history.replaceState (no router round-trip mid-chat). - History UI: desktop column, phone sheet (spring, backdrop, Escape), auto titles from the first question, inline rename, delete with an Undo toast, diacritic-folding search over titles and message text. Scope and model are remembered per conversation; history is readable offline. - Assistant actions: Turn into flashcards (New deck with the answer as notes, landing on the options step - deck seed gained step) and Save to notes (appends to the single scoped deck, trimmed at 40k). - Chats travel in the backup export (preview counts them) and in the encrypted sync payload; still never the key. - Motion from lib/motion.ts: message fade-up, sheet springs for the model/context/history sheets, 150ms composer height transition, all skipped under reduced motion. - Tests: chat suite 63 -> 89 checks (history, reload, rename/search/ delete/undo, actions, export, two-device sync round-trip, offline, desktop panel). Full set: 12/12 suites, 513 checks. SW -> freezmo-v33. Also: tests/model-compare.mjs benchmark (diagnostic) for deciding the default text model on numbers; defaults unchanged.
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
"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 "@/components/ThemeToggle";
|
|
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<typeof backupSchema>;
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Building and downloading */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export async function buildBackup(): Promise<BackupFile> {
|
|
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<BackupFile> {
|
|
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<T extends { id: string }>(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<T extends { id: string }>(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<BackupState>;
|
|
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<PersistenceState> {
|
|
try {
|
|
if (!navigator.storage?.persisted) return "unsupported";
|
|
return (await navigator.storage.persisted()) ? "persistent" : "best-effort";
|
|
} catch {
|
|
return "unsupported";
|
|
}
|
|
}
|
|
|
|
export async function requestStoragePersistence(): Promise<boolean> {
|
|
try {
|
|
if (!navigator.storage?.persist) return false;
|
|
return await navigator.storage.persist();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|