76efc459bb
- Lean file-backed accounts (server/src/accounts.ts): scrypt-hashed passwords, hashed bearer tokens, per-user backup blob; persisted under DATA_DIR. /api routes (register/login/logout, PUT/GET /save) with per-IP throttle + 48MB body limit. - Client cloud lib + Settings "Cloud sync": sign up / sign in, back up this device, restore from cloud (whole-backup push/pull, last-write-wins). Local-first default; the assistant key (localStorage) is never part of the synced Dexie backup. - Pathbuilder 2e import: the existing character import now detects a Pathbuilder build JSON and converts it to a PF2e character (abilities/HP/saves/skills). - Dockerfile creates a node-owned /data; compose mounts a named ttrpg-data volume. - (Spectator links = existing /play?room=CODE; PDF export = existing Print.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
import { characterSchema, type Character } from '@/lib/schemas';
|
|
import { newId } from '@/lib/ids';
|
|
import { downloadJson, safeFilename } from './file';
|
|
import { isPathbuilder, pathbuilderToCharacterFields } from './pathbuilder';
|
|
|
|
const FORMAT = 'ttrpg-manager:character';
|
|
const VERSION = 1;
|
|
|
|
interface CharacterFile {
|
|
format: typeof FORMAT;
|
|
version: number;
|
|
character: Character;
|
|
}
|
|
|
|
/** Download a character as a portable JSON file. */
|
|
export function exportCharacter(c: Character): void {
|
|
const payload: CharacterFile = { format: FORMAT, version: VERSION, character: c };
|
|
downloadJson(`${safeFilename(c.name)}.character.json`, payload);
|
|
}
|
|
|
|
export class CharacterImportError extends Error {}
|
|
|
|
/**
|
|
* Parse imported JSON into a valid Character bound to the given campaign. Accepts
|
|
* both our wrapped export format and a bare character object. Always assigns a
|
|
* fresh id + timestamps so importing never collides with or overwrites existing
|
|
* data.
|
|
*/
|
|
export function parseCharacterImport(text: string, campaignId: string): Character {
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(text);
|
|
} catch {
|
|
throw new CharacterImportError('That file is not valid JSON.');
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
// Pathbuilder 2e export → convert to our PF2e character shape.
|
|
if (isPathbuilder(raw)) {
|
|
const result = characterSchema.safeParse({
|
|
...pathbuilderToCharacterFields(raw.build),
|
|
id: newId(), campaignId, createdAt: now, updatedAt: now,
|
|
});
|
|
if (!result.success) throw new CharacterImportError(`Pathbuilder import failed: ${result.error.issues[0]?.message ?? 'unknown error'}`);
|
|
return result.data;
|
|
}
|
|
|
|
const candidate =
|
|
raw && typeof raw === 'object' && 'character' in raw
|
|
? (raw as { character: unknown }).character
|
|
: raw;
|
|
|
|
if (!candidate || typeof candidate !== 'object') {
|
|
throw new CharacterImportError('No character data found in that file.');
|
|
}
|
|
const result = characterSchema.safeParse({
|
|
...(candidate as Record<string, unknown>),
|
|
id: newId(),
|
|
campaignId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
if (!result.success) {
|
|
throw new CharacterImportError(
|
|
`That file isn't a valid character: ${result.error.issues[0]?.message ?? 'unknown error'}`,
|
|
);
|
|
}
|
|
return result.data;
|
|
}
|