From 3fdeb5822d65f4dcd54f316878ed57cacae30a3e Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 16 Apr 2026 16:14:46 -0600 Subject: [PATCH] feat(chess): add JSON export/import with validation (P3.13) --- packages/chess/src/app/App.tsx | 44 +++++++---- packages/chess/src/persist/io.ts | 63 ++++++++++++++++ packages/chess/src/ui/ImportExport.tsx | 100 +++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 packages/chess/src/persist/io.ts create mode 100644 packages/chess/src/ui/ImportExport.tsx diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx index d9a2dc7..862417f 100644 --- a/packages/chess/src/app/App.tsx +++ b/packages/chess/src/app/App.tsx @@ -2,9 +2,10 @@ import { Routes, Route, useNavigate } from 'react-router-dom' import { GameView } from '../ui/GameView' import { RulesView } from '../ui/RulesView' import { SavePanel } from '../ui/SavePanel' +import { ImportExport } from '../ui/ImportExport' import { useChessEngine } from '../hooks/useChessEngine' import { ChessEngine } from '../engine' -import type { AttrKey, FactValue } from '@paratype/rete' +import type { AttrKey, FactValue, EntityId } from '@paratype/rete' export function App() { const chessState = useChessEngine() @@ -23,22 +24,33 @@ export function App() { function SaveWrapper({ chessState }: { chessState: ReturnType }) { const navigate = useNavigate() + + const handleLoad = (loadedFacts: Array<{ id: number; attr: string; value: unknown }>) => { + const newEngine = new ChessEngine() + const oldFacts = newEngine.session.allFacts(); + for (const f of oldFacts) { + newEngine.session.retract(f.id, f.attr as AttrKey); + } + for (const fact of loadedFacts) { + newEngine.session.insert(fact.id as EntityId, fact.attr as AttrKey, fact.value as FactValue) + } + chessState.loadEngine(newEngine) + navigate('/game') + } + return ( - { - const newEngine = new ChessEngine() - const oldFacts = newEngine.session.allFacts(); - for (const f of oldFacts) { - newEngine.session.retract(f.id, f.attr as AttrKey); - } - for (const fact of loadedFacts) { - newEngine.session.insert(fact.id, fact.attr as AttrKey, fact.value as FactValue) - } - chessState.loadEngine(newEngine) - navigate('/game') - }} - /> +
+ +
+ +
+
) } diff --git a/packages/chess/src/persist/io.ts b/packages/chess/src/persist/io.ts new file mode 100644 index 0000000..9cdc2de --- /dev/null +++ b/packages/chess/src/persist/io.ts @@ -0,0 +1,63 @@ +export interface GameExport { + version: 1; + exportedAt: number; + facts: Array<{ id: number; attr: string; value: unknown }>; +} + +export class ImportError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImportError'; + } +} + +export function exportGame(facts: Array<{ id: number; attr: string; value: unknown }>): string { + const payload: GameExport = { + version: 1, + exportedAt: Date.now(), + facts + }; + return JSON.stringify(payload, null, 2); +} + +export function importGame(json: string): Array<{ id: number; attr: string; value: unknown }> { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new ImportError('Invalid JSON format'); + } + + if (!parsed || typeof parsed !== 'object') { + throw new ImportError('Invalid export format: not an object'); + } + + const exportData = parsed as Record; + + if (exportData.version !== 1) { + throw new ImportError(`Unsupported export version: ${exportData.version}`); + } + + if (!Array.isArray(exportData.facts)) { + throw new ImportError('Invalid export format: missing facts array'); + } + + for (let i = 0; i < exportData.facts.length; i++) { + const fact = exportData.facts[i]; + if (!fact || typeof fact !== 'object') { + throw new ImportError(`Invalid fact at index ${i}: not an object`); + } + + const { id, attr } = fact as Record; + + if (typeof id !== 'number') { + throw new ImportError(`Invalid fact at index ${i}: id must be a number`); + } + + if (typeof attr !== 'string') { + throw new ImportError(`Invalid fact at index ${i}: attr must be a string`); + } + } + + return exportData.facts as Array<{ id: number; attr: string; value: unknown }>; +} diff --git a/packages/chess/src/ui/ImportExport.tsx b/packages/chess/src/ui/ImportExport.tsx new file mode 100644 index 0000000..28d9287 --- /dev/null +++ b/packages/chess/src/ui/ImportExport.tsx @@ -0,0 +1,100 @@ +import { useState, useRef } from 'react'; +import { exportGame, importGame } from '../persist/io'; + +interface ImportExportProps { + currentFacts?: Array<{ id: number; attr: string; value: unknown }>; + onLoad: (facts: Array<{ id: number; attr: string; value: unknown }>) => void; +} + +export function ImportExport({ currentFacts, onLoad }: ImportExportProps) { + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const handleExport = () => { + if (!currentFacts) return; + + try { + const json = exportGame(currentFacts); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `chess-game-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error('Export failed:', err); + } + }; + + const handleImportClick = () => { + setError(null); + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const text = event.target?.result as string; + const facts = importGame(text); + onLoad(facts); + // Reset file input so the same file can be selected again + if (fileInputRef.current) fileInputRef.current.value = ''; + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown import error'); + } + }; + reader.onerror = () => { + setError('Failed to read file'); + }; + reader.readAsText(file); + }; + + return ( +
+

Export / Import

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + + + + +
+
+
+ ); +}