feat(export): file export and import

This commit is contained in:
Lewis Wynne 2026-03-23 23:00:34 +00:00
parent e4d87d13e2
commit f52a6c5b68
7 changed files with 335 additions and 37 deletions

View file

@ -1,17 +1,24 @@
<script lang="ts"> <script lang="ts">
import { Sun, Moon, Share2, Check, Trash2, Plus } from 'lucide-svelte'; import { Sun, Moon, Trash2, Plus, Upload } from 'lucide-svelte';
import { theme } from '$lib/theme.svelte'; import { theme } from '$lib/theme.svelte';
import { roster } from '$lib/state.svelte'; import { roster } from '$lib/state.svelte';
import { presets } from '$lib/presets'; import { presets } from '$lib/presets';
import { encodeCharacterURL } from '$lib/sharing';
import { slugify } from '$lib/utils/slugify'; import { slugify } from '$lib/utils/slugify';
import CharacterSwitcher from './CharacterSwitcher.svelte'; import CharacterSwitcher from './CharacterSwitcher.svelte';
import TemplatePicker from './TemplatePicker.svelte'; import TemplatePicker from './TemplatePicker.svelte';
import ShareMenu from './ShareMenu.svelte';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
let shared = $state(false); let { onImport }: { onImport?: (json: string) => void } = $props();
let confirmDelete = $state(false); let confirmDelete = $state(false);
let showPicker = $state(false); let showPicker = $state(false);
let openDropdown = $state<'add' | 'share' | null>(null);
let fileInput: HTMLInputElement;
function toggleDropdown(which: 'add' | 'share') {
openDropdown = openDropdown === which ? null : which;
}
function createCharacter() { function createCharacter() {
if (presets.length === 1) { if (presets.length === 1) {
@ -19,16 +26,21 @@
} else { } else {
showPicker = true; showPicker = true;
} }
openDropdown = null;
} }
async function share() { function triggerImport() {
const char = roster.active; fileInput.click();
if (!char) return; openDropdown = null;
const encoded = encodeCharacterURL(char); }
const url = `${window.location.origin}${window.location.pathname}#${encoded}`;
await navigator.clipboard.writeText(url); async function handleFile(e: Event) {
shared = true; const input = e.target as HTMLInputElement;
setTimeout(() => { shared = false; }, 2000); const file = input.files?.[0];
if (!file) return;
const text = await file.text();
onImport?.(text);
input.value = '';
} }
function displayName(): string { function displayName(): string {
@ -54,22 +66,40 @@
<CharacterSwitcher /> <CharacterSwitcher />
{/if} {/if}
<button onclick={createCharacter} class="flex items-center justify-center w-[30px] h-[30px] rounded border hover:opacity-80" style="border-color: var(--border);" title="New character"> <span class="relative">
<Plus size={14} /> <button
</button> onclick={(e) => { e.stopPropagation(); toggleDropdown('add'); }}
class="flex items-center justify-center w-[30px] h-[30px] rounded border hover:opacity-80"
style="border-color: var(--border);"
title="Add character"
>
<Plus size={14} />
</button>
{#if roster.characters.length > 0} {#if openDropdown === 'add'}
<nav class="absolute left-0 z-10 mt-1 w-48 rounded border shadow-lg" style="background: var(--bg-card); border-color: var(--border);">
<button
onclick={createCharacter}
class="flex items-center gap-2 w-full text-left px-3 py-2 text-sm hover:opacity-80"
>
<Plus size={14} /> New character
</button>
<button
onclick={triggerImport}
class="flex items-center gap-2 w-full text-left px-3 py-2 text-sm hover:opacity-80"
>
<Upload size={14} /> Import from file
</button>
</nav>
{/if}
</span>
{#if roster.active}
<button onclick={() => { confirmDelete = true; }} class="flex items-center justify-center w-[30px] h-[30px] rounded border hover:opacity-80" style="border-color: var(--border);" title="Delete character"> <button onclick={() => { confirmDelete = true; }} class="flex items-center justify-center w-[30px] h-[30px] rounded border hover:opacity-80" style="border-color: var(--border);" title="Delete character">
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
<button onclick={share} class="flex items-center justify-center h-[30px] rounded border hover:opacity-80 {shared ? 'gap-1 px-2' : 'w-[30px]'}" style="border-color: var(--border);" title="Share character"> <ShareMenu open={openDropdown === 'share'} onToggle={() => toggleDropdown('share')} />
{#if shared}
<Check size={14} /> <span class="text-sm hidden sm:inline">Copied share link</span>
{:else}
<Share2 size={14} />
{/if}
</button>
{/if} {/if}
<div class="ml-auto flex items-center gap-2"> <div class="ml-auto flex items-center gap-2">
@ -88,6 +118,14 @@
</div> </div>
</header> </header>
<input
bind:this={fileInput}
type="file"
accept=".json"
class="hidden"
onchange={handleFile}
/>
{#if showPicker} {#if showPicker}
<TemplatePicker onClose={() => { showPicker = false; }} /> <TemplatePicker onClose={() => { showPicker = false; }} />
{/if} {/if}
@ -106,3 +144,5 @@
</div> </div>
</Modal> </Modal>
{/if} {/if}
<svelte:window onclick={() => { openDropdown = null; }} />

View file

@ -6,7 +6,15 @@
import { slugify } from '$lib/utils/slugify'; import { slugify } from '$lib/utils/slugify';
import OutputTab from './OutputTab.svelte'; import OutputTab from './OutputTab.svelte';
let { encoded, onClose }: { encoded: string; onClose: () => void } = $props(); let {
encoded = '',
fileData = null,
onClose
}: {
encoded?: string;
fileData?: { template: any; data: Record<string, unknown> } | null;
onClose: () => void;
} = $props();
let error = $state(''); let error = $state('');
let type = $state<'character' | 'template' | null>(null); let type = $state<'character' | 'template' | null>(null);
@ -16,18 +24,24 @@
let activeTab = $state(''); let activeTab = $state('');
$effect(() => { $effect(() => {
try { if (fileData) {
if (encoded.startsWith('c1.')) { type = 'character';
type = 'character'; charData = fileData;
charData = decodeCharacterURL(encoded); error = '';
} else if (encoded.startsWith('t1.')) { } else if (encoded) {
type = 'template'; try {
tmplData = decodeTemplateURL(encoded); if (encoded.startsWith('c1.')) {
} else { type = 'character';
error = 'Unrecognized share link format.'; charData = decodeCharacterURL(encoded);
} else if (encoded.startsWith('t1.')) {
type = 'template';
tmplData = decodeTemplateURL(encoded);
} else {
error = 'Unrecognized share link format.';
}
} catch {
error = 'Failed to decode share link.';
} }
} catch {
error = 'Failed to decode share link.';
} }
}); });

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { Share2, Download, Check } from 'lucide-svelte';
import { roster } from '$lib/state.svelte';
import { encodeCharacterURL } from '$lib/sharing';
import { exportCharacter, characterFileName } from '$lib/file';
let { open, onToggle }: { open: boolean; onToggle: () => void } = $props();
let copied = $state(false);
async function share() {
const char = roster.active;
if (!char) return;
const encoded = encodeCharacterURL(char);
const url = `${window.location.origin}${window.location.pathname}#${encoded}`;
await navigator.clipboard.writeText(url);
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
function exportFile() {
const char = roster.active;
if (!char) return;
const json = exportCharacter(char);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = characterFileName(char);
a.click();
URL.revokeObjectURL(url);
onToggle();
}
</script>
<span class="relative">
<button
onclick={(e) => { e.stopPropagation(); onToggle(); }}
class="flex items-center justify-center w-[30px] h-[30px] rounded border hover:opacity-80"
style="border-color: var(--border);"
title="Share & export"
>
<Share2 size={14} />
</button>
{#if open}
<nav class="absolute left-0 z-10 mt-1 w-48 rounded border shadow-lg" style="background: var(--bg-card); border-color: var(--border);">
<button
onclick={share}
class="flex items-center gap-2 w-full text-left px-3 py-2 text-sm hover:opacity-80"
>
{#if copied}
<Check size={14} /> Copied!
{:else}
<Share2 size={14} /> Copy share link
{/if}
</button>
<button
onclick={exportFile}
class="flex items-center gap-2 w-full text-left px-3 py-2 text-sm hover:opacity-80"
>
<Download size={14} /> Export to file
</button>
</nav>
{/if}
</span>

110
src/lib/file.test.ts Normal file
View file

@ -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();
});
});

50
src/lib/file.ts Normal file
View file

@ -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<Template, 'id'>;
data: Record<string, unknown>;
}
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<Template, 'id'>; data: Record<string, unknown> } {
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';
}

View file

@ -16,7 +16,7 @@ function fromBase64url(str: string): Uint8Array {
return bytes; return bytes;
} }
function pruneEmpty(data: Record<string, unknown>): Record<string, unknown> { export function pruneEmpty(data: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) { for (const [k, v] of Object.entries(data)) {
if (v === '' || v === undefined || v === null) continue; if (v === '' || v === undefined || v === null) continue;

View file

@ -6,9 +6,11 @@
import ImportModal from '$lib/components/ImportModal.svelte'; import ImportModal from '$lib/components/ImportModal.svelte';
import { roster } from '$lib/state.svelte'; import { roster } from '$lib/state.svelte';
import { presets } from '$lib/presets'; import { presets } from '$lib/presets';
import { parseCharacterFile } from '$lib/file';
import TemplatePicker from '$lib/components/TemplatePicker.svelte'; import TemplatePicker from '$lib/components/TemplatePicker.svelte';
let importData = $state<string | null>(null); let importData = $state<string | null>(null);
let fileImportData = $state<{ template: any; data: Record<string, unknown> } | null>(null);
let mobileView = $state<'edit' | 'preview' | 'split'>('split'); let mobileView = $state<'edit' | 'preview' | 'split'>('split');
let showPicker = $state(false); let showPicker = $state(false);
@ -24,13 +26,29 @@
history.replaceState(null, '', window.location.pathname); 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; const modes = ['edit', 'preview', 'split'] as const;
</script> </script>
<div class="h-dvh flex flex-col overflow-hidden"> <div class="h-dvh flex flex-col overflow-hidden">
<Header /> <Header onImport={handleFileImport} />
{#if importData} {#if fileImportData}
<div class="flex-1 overflow-y-auto">
<ImportModal fileData={fileImportData} onClose={closeFileImport} />
</div>
{:else if importData}
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<ImportModal encoded={importData} onClose={closeImport} /> <ImportModal encoded={importData} onClose={closeImport} />
</div> </div>