feat(export): file export and import
This commit is contained in:
parent
e4d87d13e2
commit
f52a6c5b68
7 changed files with 335 additions and 37 deletions
|
|
@ -1,17 +1,24 @@
|
|||
<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 { roster } from '$lib/state.svelte';
|
||||
import { presets } from '$lib/presets';
|
||||
import { encodeCharacterURL } from '$lib/sharing';
|
||||
import { slugify } from '$lib/utils/slugify';
|
||||
import CharacterSwitcher from './CharacterSwitcher.svelte';
|
||||
import TemplatePicker from './TemplatePicker.svelte';
|
||||
import ShareMenu from './ShareMenu.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
let shared = $state(false);
|
||||
let { onImport }: { onImport?: (json: string) => void } = $props();
|
||||
|
||||
let confirmDelete = $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() {
|
||||
if (presets.length === 1) {
|
||||
|
|
@ -19,16 +26,21 @@
|
|||
} else {
|
||||
showPicker = true;
|
||||
}
|
||||
openDropdown = null;
|
||||
}
|
||||
|
||||
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);
|
||||
shared = true;
|
||||
setTimeout(() => { shared = false; }, 2000);
|
||||
function triggerImport() {
|
||||
fileInput.click();
|
||||
openDropdown = null;
|
||||
}
|
||||
|
||||
async function handleFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
onImport?.(text);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function displayName(): string {
|
||||
|
|
@ -54,22 +66,40 @@
|
|||
<CharacterSwitcher />
|
||||
{/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">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<span class="relative">
|
||||
<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">
|
||||
<Trash2 size={14} />
|
||||
</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">
|
||||
{#if shared}
|
||||
<Check size={14} /> <span class="text-sm hidden sm:inline">Copied share link</span>
|
||||
{:else}
|
||||
<Share2 size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
<ShareMenu open={openDropdown === 'share'} onToggle={() => toggleDropdown('share')} />
|
||||
{/if}
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
|
|
@ -88,6 +118,14 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
onchange={handleFile}
|
||||
/>
|
||||
|
||||
{#if showPicker}
|
||||
<TemplatePicker onClose={() => { showPicker = false; }} />
|
||||
{/if}
|
||||
|
|
@ -106,3 +144,5 @@
|
|||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<svelte:window onclick={() => { openDropdown = null; }} />
|
||||
|
|
|
|||
|
|
@ -6,7 +6,15 @@
|
|||
import { slugify } from '$lib/utils/slugify';
|
||||
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 type = $state<'character' | 'template' | null>(null);
|
||||
|
|
@ -16,18 +24,24 @@
|
|||
let activeTab = $state('');
|
||||
|
||||
$effect(() => {
|
||||
try {
|
||||
if (encoded.startsWith('c1.')) {
|
||||
type = 'character';
|
||||
charData = decodeCharacterURL(encoded);
|
||||
} else if (encoded.startsWith('t1.')) {
|
||||
type = 'template';
|
||||
tmplData = decodeTemplateURL(encoded);
|
||||
} else {
|
||||
error = 'Unrecognized share link format.';
|
||||
if (fileData) {
|
||||
type = 'character';
|
||||
charData = fileData;
|
||||
error = '';
|
||||
} else if (encoded) {
|
||||
try {
|
||||
if (encoded.startsWith('c1.')) {
|
||||
type = 'character';
|
||||
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.';
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
66
src/lib/components/ShareMenu.svelte
Normal file
66
src/lib/components/ShareMenu.svelte
Normal 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
110
src/lib/file.test.ts
Normal 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
50
src/lib/file.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ function fromBase64url(str: string): Uint8Array {
|
|||
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> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v === '' || v === undefined || v === null) continue;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue