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">
|
<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; }} />
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue