feat(share): character export through share link
This commit is contained in:
parent
d87e64ed7d
commit
a97396ef01
3 changed files with 170 additions and 2 deletions
108
src/lib/sharing.test.ts
Normal file
108
src/lib/sharing.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
encodeCharacterURL,
|
||||||
|
decodeCharacterURL,
|
||||||
|
encodeTemplateURL,
|
||||||
|
decodeTemplateURL
|
||||||
|
} from './sharing';
|
||||||
|
import type { Character, Template } from './types';
|
||||||
|
|
||||||
|
const testTemplate: Template = {
|
||||||
|
id: 'preset:standard',
|
||||||
|
name: 'Standard',
|
||||||
|
description: 'The standard record format.',
|
||||||
|
schemaVersion: 1,
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
type: 'public',
|
||||||
|
expanded: true,
|
||||||
|
fields: [
|
||||||
|
{ label: 'Name', type: 'text' },
|
||||||
|
{ label: 'Species', type: 'species' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const testCharacter: Character = {
|
||||||
|
id: 'abc-123',
|
||||||
|
template: testTemplate,
|
||||||
|
data: {
|
||||||
|
name: 'Yury Zakharov',
|
||||||
|
species: 'human',
|
||||||
|
'employment-history': 'Shaft Miner'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('character URL encoding', () => {
|
||||||
|
it('round-trips character data', () => {
|
||||||
|
const encoded = encodeCharacterURL(testCharacter);
|
||||||
|
const decoded = decodeCharacterURL(encoded);
|
||||||
|
expect(decoded.data).toEqual(testCharacter.data);
|
||||||
|
expect(decoded.template.name).toBe('Standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with c1. prefix', () => {
|
||||||
|
const encoded = encodeCharacterURL(testCharacter);
|
||||||
|
expect(encoded.startsWith('c1.')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips id', () => {
|
||||||
|
const encoded = encodeCharacterURL(testCharacter);
|
||||||
|
const decoded = decodeCharacterURL(encoded);
|
||||||
|
expect(decoded).not.toHaveProperty('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes empty values from data', () => {
|
||||||
|
const char: Character = {
|
||||||
|
...testCharacter,
|
||||||
|
data: { name: 'Yury Zakharov', species: '', 'hair-color': '' }
|
||||||
|
};
|
||||||
|
const encoded = encodeCharacterURL(char);
|
||||||
|
const decoded = decodeCharacterURL(encoded);
|
||||||
|
expect(decoded.data).toEqual({ name: 'Yury Zakharov' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('template URL encoding', () => {
|
||||||
|
it('round-trips template structure', () => {
|
||||||
|
const encoded = encodeTemplateURL(testTemplate);
|
||||||
|
const decoded = decodeTemplateURL(encoded);
|
||||||
|
expect(decoded.name).toBe('Standard');
|
||||||
|
expect(decoded.records).toEqual(testTemplate.records);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with t1. prefix', () => {
|
||||||
|
const encoded = encodeTemplateURL(testTemplate);
|
||||||
|
expect(encoded.startsWith('t1.')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips id', () => {
|
||||||
|
const encoded = encodeTemplateURL(testTemplate);
|
||||||
|
const decoded = decodeTemplateURL(encoded);
|
||||||
|
expect(decoded).not.toHaveProperty('id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unicode support', () => {
|
||||||
|
it('round-trips unicode content', () => {
|
||||||
|
const char: Character = {
|
||||||
|
...testCharacter,
|
||||||
|
data: { name: "Ka'Akaix'Lak Zo'ra", species: 'vaurca' }
|
||||||
|
};
|
||||||
|
const encoded = encodeCharacterURL(char);
|
||||||
|
const decoded = decodeCharacterURL(encoded);
|
||||||
|
expect(decoded.data.name).toBe("Ka'Akaix'Lak Zo'ra");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('throws on invalid character input', () => {
|
||||||
|
expect(() => decodeCharacterURL('c1.invaliddata!!!')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on wrong prefix', () => {
|
||||||
|
const encoded = encodeCharacterURL(testCharacter);
|
||||||
|
expect(() => decodeTemplateURL(encoded)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/lib/sharing.ts
Normal file
62
src/lib/sharing.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import pako from 'pako';
|
||||||
|
import type { Character, Template } from './types';
|
||||||
|
|
||||||
|
function toBase64url(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64url(str: string): Uint8Array {
|
||||||
|
const padded = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (Array.isArray(v) && v.length === 0) continue;
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeCharacterURL(char: Character): string {
|
||||||
|
const payload = {
|
||||||
|
template: stripId(char.template),
|
||||||
|
data: pruneEmpty(char.data)
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const compressed = pako.deflate(new TextEncoder().encode(json));
|
||||||
|
return 'c1.' + toBase64url(compressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeCharacterURL(encoded: string): { template: Omit<Template, 'id'>; data: Record<string, unknown> } {
|
||||||
|
if (!encoded.startsWith('c1.')) throw new Error('Invalid character URL prefix');
|
||||||
|
const bytes = fromBase64url(encoded.slice(3));
|
||||||
|
const json = new TextDecoder().decode(pako.inflate(bytes));
|
||||||
|
return JSON.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTemplateURL(template: Template): string {
|
||||||
|
const payload = stripId(template);
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const compressed = pako.deflate(new TextEncoder().encode(json));
|
||||||
|
return 't1.' + toBase64url(compressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTemplateURL(encoded: string): Omit<Template, 'id'> {
|
||||||
|
if (!encoded.startsWith('t1.')) throw new Error('Invalid template URL prefix');
|
||||||
|
const bytes = fromBase64url(encoded.slice(3));
|
||||||
|
const json = new TextDecoder().decode(pako.inflate(bytes));
|
||||||
|
return JSON.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripId(obj: Record<string, any>): Record<string, any> {
|
||||||
|
const { id, ...rest } = obj;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
@ -107,6 +107,4 @@ export interface Character {
|
||||||
id: string;
|
id: string;
|
||||||
template: Template;
|
template: Template;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue