diff --git a/src/lib/sharing.test.ts b/src/lib/sharing.test.ts new file mode 100644 index 0000000..0715253 --- /dev/null +++ b/src/lib/sharing.test.ts @@ -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(); + }); +}); diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts new file mode 100644 index 0000000..0726f10 --- /dev/null +++ b/src/lib/sharing.ts @@ -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): Record { + const out: Record = {}; + 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; data: Record } { + 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 { + 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): Record { + const { id, ...rest } = obj; + return rest; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index b42891c..b0852cd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -107,6 +107,4 @@ export interface Character { id: string; template: Template; data: Record; - createdAt: string; - updatedAt: string; }