Files
initiative/apps/web/src/persistence/export-import.ts
Lukas 209df13c32 Add export method dialog, extract shared Dialog primitive
Add export dialog with download/clipboard options and optional
undo/redo history inclusion (default off). Extract shared Dialog
component to ui/dialog.tsx, consolidating open/close lifecycle,
backdrop click, and escape key handling from all 6 dialog components.
Update spec to reflect export method dialog and optional history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:57:31 +01:00

113 lines
2.8 KiB
TypeScript

import type {
Encounter,
ExportBundle,
PlayerCharacter,
UndoRedoState,
} from "@initiative/domain";
import { rehydrateEncounter } from "./encounter-storage.js";
import { rehydrateCharacter } from "./player-character-storage.js";
function rehydrateStack(raw: unknown[]): Encounter[] {
const result: Encounter[] = [];
for (const entry of raw) {
const rehydrated = rehydrateEncounter(entry);
if (rehydrated !== null) {
result.push(rehydrated);
}
}
return result;
}
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
const result: PlayerCharacter[] = [];
for (const entry of raw) {
const rehydrated = rehydrateCharacter(entry);
if (rehydrated !== null) {
result.push(rehydrated);
}
}
return result;
}
export function validateImportBundle(data: unknown): ExportBundle | string {
if (typeof data !== "object" || data === null || Array.isArray(data)) {
return "Invalid file format";
}
const obj = data as Record<string, unknown>;
if (typeof obj.version !== "number" || obj.version !== 1) {
return "Invalid file format";
}
if (typeof obj.exportedAt !== "string") {
return "Invalid file format";
}
if (!Array.isArray(obj.undoStack) || !Array.isArray(obj.redoStack)) {
return "Invalid file format";
}
if (!Array.isArray(obj.playerCharacters)) {
return "Invalid file format";
}
const encounter = rehydrateEncounter(obj.encounter);
if (encounter === null) {
return "Invalid encounter data";
}
return {
version: 1,
exportedAt: obj.exportedAt,
encounter,
undoStack: rehydrateStack(obj.undoStack),
redoStack: rehydrateStack(obj.redoStack),
playerCharacters: rehydrateCharacters(obj.playerCharacters),
};
}
export function assembleExportBundle(
encounter: Encounter,
undoRedoState: UndoRedoState,
playerCharacters: readonly PlayerCharacter[],
includeHistory = true,
): ExportBundle {
return {
version: 1,
exportedAt: new Date().toISOString(),
encounter,
undoStack: includeHistory ? undoRedoState.undoStack : [],
redoStack: includeHistory ? undoRedoState.redoStack : [],
playerCharacters: [...playerCharacters],
};
}
export function bundleToJson(bundle: ExportBundle): string {
return JSON.stringify(bundle, null, 2);
}
export function triggerDownload(bundle: ExportBundle): void {
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const date = new Date().toISOString().slice(0, 10);
const filename = `initiative-export-${date}.json`;
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export async function readImportFile(
file: File,
): Promise<ExportBundle | string> {
try {
const text = await file.text();
const parsed: unknown = JSON.parse(text);
return validateImportBundle(parsed);
} catch {
return "Invalid file format";
}
}