Files
ttrpg_manager/src/lib/io/backup.ts
T
NilsBriggen f2e4259c84 UX safety: confirm destructive deletes, scope dice clear, back up sessionLog, add 404
- New themed useConfirm() hook (no native dialogs). World deletes (notes, quests,
  homebrew, maps) now confirm before removing.
- Dice 'Clear history' is disabled with no active campaign (it used to wipe EVERY
  campaign's rolls) and now confirms; only ever clears the active campaign.
- sessionLog is included in backup/restore and the campaign cascade-delete — it was
  silently dropped on backup and orphaned on campaign delete.
- Unknown URLs render a themed 'Page not found' with a link home instead of a blank shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:37:52 +02:00

77 lines
2.9 KiB
TypeScript

import type { ZodTypeAny } from 'zod';
import { db } from '@/lib/db/db';
import {
campaignSchema, characterSchema, encounterSchema, diceRollSchema,
noteSchema, npcSchema, questSchema, calendarSchema, battleMapSchema, homebrewSchema,
sessionLogEntrySchema,
} from '@/lib/schemas';
import { downloadJson } from './file';
const TABLES = [
'campaigns', 'characters', 'encounters', 'diceRolls',
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew', 'sessionLog',
] as const;
/** Schema per table, so restored rows are validated + backfilled with defaults
* (a backup from an older app version is missing newer fields, e.g. `feats`). */
const SCHEMA: Record<(typeof TABLES)[number], ZodTypeAny> = {
campaigns: campaignSchema, characters: characterSchema, encounters: encounterSchema,
diceRolls: diceRollSchema, notes: noteSchema, npcs: npcSchema, quests: questSchema,
calendars: calendarSchema, maps: battleMapSchema, homebrew: homebrewSchema,
sessionLog: sessionLogEntrySchema,
};
const FORMAT = 'ttrpg-manager:backup';
export class BackupError extends Error {}
/** Snapshot every table into a portable object. */
export async function buildBackup(): Promise<{ format: string; version: number; data: Record<string, unknown[]> }> {
const data: Record<string, unknown[]> = {};
for (const t of TABLES) data[t] = await db.table(t).toArray();
return { format: FORMAT, version: 1, data };
}
/** Download a full backup of all campaigns and data. */
export async function exportBackup(): Promise<void> {
downloadJson('ttrpg-manager-backup.json', await buildBackup());
}
/**
* Restore a backup, REPLACING all current data. Runs in one transaction so a
* failure rolls back cleanly.
*/
export async function restoreBackup(text: string): Promise<void> {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new BackupError('That file is not valid JSON.');
}
const data = (parsed as { data?: Record<string, unknown[]> })?.data;
if (!data || typeof data !== 'object') throw new BackupError('That file is not a TTRPG Manager backup.');
await db.transaction('rw', TABLES.map((t) => db.table(t)), async () => {
for (const t of TABLES) {
await db.table(t).clear();
const rows = data[t];
if (!Array.isArray(rows) || !rows.length) continue;
// Validate + backfill defaults so a backup from an older app version (or an
// edited/foreign file) can't land rows missing newer fields and crash the UI.
const valid: unknown[] = [];
for (const row of rows) {
const r = SCHEMA[t].safeParse(row);
if (r.success) valid.push(r.data);
}
if (valid.length) await db.table(t).bulkAdd(valid);
}
});
}
/** Wipe all data (danger zone). */
export async function clearAllData(): Promise<void> {
await db.transaction('rw', TABLES.map((t) => db.table(t)), async () => {
for (const t of TABLES) await db.table(t).clear();
});
}