From f52a6c5b682b311f9fbf27506f67e8e802c1cc18 Mon Sep 17 00:00:00 2001 From: lew Date: Mon, 23 Mar 2026 23:00:34 +0000 Subject: [PATCH] feat(export): file export and import --- src/lib/components/Header.svelte | 84 ++++++++++++++------ src/lib/components/ImportModal.svelte | 38 ++++++--- src/lib/components/ShareMenu.svelte | 66 ++++++++++++++++ src/lib/file.test.ts | 110 ++++++++++++++++++++++++++ src/lib/file.ts | 50 ++++++++++++ src/lib/sharing.ts | 2 +- src/routes/+page.svelte | 22 +++++- 7 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 src/lib/components/ShareMenu.svelte create mode 100644 src/lib/file.test.ts create mode 100644 src/lib/file.ts diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index a35b0d2..ad32172 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,17 +1,24 @@ + + + + + {#if open} + + {/if} + diff --git a/src/lib/file.test.ts b/src/lib/file.test.ts new file mode 100644 index 0000000..ab924ff --- /dev/null +++ b/src/lib/file.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { exportCharacter, parseCharacterFile } from './file'; +import { presets } from './presets'; +import type { Character } from './types'; + +const standardPreset = presets.find((p) => p.id === 'preset:standard')!; + +const testCharacter: Character = { + id: 'abc-123', + template: standardPreset, + data: { + name: 'Yury Zakharov', + species: 'human', + 'employment-history': 'Shaft Miner' + } +}; + +describe('exportCharacter', () => { + it('returns valid JSON with version, templateId, template, and data', () => { + const json = exportCharacter(testCharacter); + const parsed = JSON.parse(json); + expect(parsed.version).toBe(1); + expect(parsed.templateId).toBe('preset:standard'); + expect(parsed.template).toBeDefined(); + expect(parsed.template.name).toBe('Standard'); + expect(parsed.data).toEqual({ + name: 'Yury Zakharov', + species: 'human', + 'employment-history': 'Shaft Miner' + }); + }); + + it('strips template id from embedded template', () => { + const json = exportCharacter(testCharacter); + const parsed = JSON.parse(json); + expect(parsed.template).not.toHaveProperty('id'); + }); + + it('prunes empty values from data', () => { + const char: Character = { + ...testCharacter, + data: { name: 'Yury Zakharov', species: '', 'hair-color': '' } + }; + const json = exportCharacter(char); + const parsed = JSON.parse(json); + expect(parsed.data).toEqual({ name: 'Yury Zakharov' }); + }); + + it('omits templateId for non-preset templates', () => { + const char: Character = { + ...testCharacter, + template: { + id: 'custom:test', + name: 'Custom', + description: 'Test', + schemaVersion: 1, + records: [] + } + }; + const json = exportCharacter(char); + const parsed = JSON.parse(json); + expect(parsed.templateId).toBeUndefined(); + expect(parsed.template.name).toBe('Custom'); + }); +}); + +describe('parseCharacterFile', () => { + it('resolves preset template by templateId', () => { + const json = exportCharacter(testCharacter); + const result = parseCharacterFile(json); + expect(result.template).toHaveProperty('id', 'preset:standard'); + expect(result.data.name).toBe('Yury Zakharov'); + }); + + it('falls back to embedded template for unknown preset', () => { + const payload = { + version: 1, + templateId: 'preset:nonexistent', + template: { name: 'Fallback', description: '', schemaVersion: 1, records: [] }, + data: { name: 'Test' } + }; + const result = parseCharacterFile(JSON.stringify(payload)); + expect(result.template.name).toBe('Fallback'); + expect(result.template).not.toHaveProperty('id'); + }); + + it('uses embedded template when no templateId', () => { + const payload = { + version: 1, + template: { name: 'Custom', description: '', schemaVersion: 1, records: [] }, + data: { name: 'Test' } + }; + const result = parseCharacterFile(JSON.stringify(payload)); + expect(result.template.name).toBe('Custom'); + }); + + it('throws on invalid JSON', () => { + expect(() => parseCharacterFile('not json')).toThrow(); + }); + + it('throws on missing data field', () => { + const payload = { version: 1, template: { name: 'X', description: '', schemaVersion: 1, records: [] } }; + expect(() => parseCharacterFile(JSON.stringify(payload))).toThrow(); + }); + + it('throws on missing template and templateId', () => { + const payload = { version: 1, data: { name: 'Test' } }; + expect(() => parseCharacterFile(JSON.stringify(payload))).toThrow(); + }); +}); diff --git a/src/lib/file.ts b/src/lib/file.ts new file mode 100644 index 0000000..3bd5e8e --- /dev/null +++ b/src/lib/file.ts @@ -0,0 +1,50 @@ +import type { Character, Template } from './types'; +import { pruneEmpty } from './sharing'; +import { presets } from './presets'; + +interface CharacterFilePayload { + version: number; + templateId?: string; + template: Omit; + data: Record; +} + +export function exportCharacter(char: Character): string { + const isPreset = char.template.id.startsWith('preset:'); + const { id, ...templateWithoutId } = char.template; + const payload: CharacterFilePayload = { + version: 1, + template: templateWithoutId, + data: pruneEmpty(char.data) + }; + if (isPreset) { + payload.templateId = char.template.id; + } + return JSON.stringify(payload, null, 2); +} + +export function parseCharacterFile(json: string): { template: Template | Omit; data: Record } { + const payload = JSON.parse(json); + if (!payload.data || typeof payload.data !== 'object') { + throw new Error('Invalid character file: missing data'); + } + if (!payload.template && !payload.templateId) { + throw new Error('Invalid character file: missing template'); + } + if (payload.templateId) { + const preset = presets.find((p) => p.id === payload.templateId); + if (preset) { + return { template: preset, data: payload.data }; + } + } + if (payload.template) { + return { template: payload.template, data: payload.data }; + } + throw new Error('Invalid character file: could not resolve template'); +} + +export function characterFileName(char: Character): string { + const name = char.data.name as string | undefined; + if (!name || !name.trim()) return 'character.json'; + return name.trim().replace(/[^a-zA-Z0-9'-]/g, '-').replace(/-+/g, '-') + '.json'; +} diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts index 160f75c..ca6345a 100644 --- a/src/lib/sharing.ts +++ b/src/lib/sharing.ts @@ -16,7 +16,7 @@ function fromBase64url(str: string): Uint8Array { return bytes; } -function pruneEmpty(data: Record): Record { +export function pruneEmpty(data: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(data)) { if (v === '' || v === undefined || v === null) continue; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8ae07f9..0c8aaec 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,9 +6,11 @@ import ImportModal from '$lib/components/ImportModal.svelte'; import { roster } from '$lib/state.svelte'; import { presets } from '$lib/presets'; + import { parseCharacterFile } from '$lib/file'; import TemplatePicker from '$lib/components/TemplatePicker.svelte'; let importData = $state(null); + let fileImportData = $state<{ template: any; data: Record } | null>(null); let mobileView = $state<'edit' | 'preview' | 'split'>('split'); let showPicker = $state(false); @@ -24,13 +26,29 @@ history.replaceState(null, '', window.location.pathname); } + function handleFileImport(json: string) { + try { + fileImportData = parseCharacterFile(json); + } catch { + // TODO: show error to user + } + } + + function closeFileImport() { + fileImportData = null; + } + const modes = ['edit', 'preview', 'split'] as const;
-
+
- {#if importData} + {#if fileImportData} +
+ +
+ {:else if importData}