Add JSON import/export for full encounter state
Export and import encounter, undo/redo history, and player characters as a downloadable .json file. Export/import actions are in the action bar overflow menu. Import validates using existing rehydration functions and shows a confirmation dialog when replacing a non-empty encounter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
apps/web/src/persistence/export-import.ts
Normal file
108
apps/web/src/persistence/export-import.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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[],
|
||||
): ExportBundle {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
encounter,
|
||||
undoStack: undoRedoState.undoStack,
|
||||
redoStack: undoRedoState.redoStack,
|
||||
playerCharacters: [...playerCharacters],
|
||||
};
|
||||
}
|
||||
|
||||
export function triggerDownload(bundle: ExportBundle): void {
|
||||
const json = JSON.stringify(bundle, null, 2);
|
||||
const blob = new Blob([json], { 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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user