// ── Character library ───────────────────────────────────────────────────────── // // Persistent, book-scoped character sheets stored in SQLite via /api/characters. // All function signatures are unchanged from the IndexedDB version so callers // (audiobook.js, character-sheets.js, etc.) need no edits. // Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics. const CL_EDIT_FIELDS = [ ['name', 'Name'], ['aliases', 'Aliases'], ['archetype', 'Archetype'], ['physical', 'Physical'], ['clothing', 'Clothing & Appearance'], ['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'], ['skills', 'Trained Skills'], ['capabilities', 'Capabilities'], ['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'], ['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'], ['voice_pattern', 'Voice & Speech'], ['secret', 'Dark Secret / Fatal Flaw'], ['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'], ]; // ── Server API ──────────────────────────────────────────────────────────────── async function clGetAll() { const r = await fetch('/api/characters'); if (!r.ok) throw new Error('clGetAll failed: ' + r.status); const d = await r.json(); return d.characters || []; } async function clGet(id) { const r = await fetch('/api/characters/' + encodeURIComponent(id)); if (r.status === 404) return undefined; if (!r.ok) throw new Error('clGet failed: ' + r.status); return r.json(); } async function clPut(rec) { const r = await fetch('/api/characters/' + encodeURIComponent(rec.id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rec), }); if (!r.ok) throw new Error('clPut failed: ' + r.status); return r.json(); } async function clDelete(id) { const r = await fetch('/api/characters/' + encodeURIComponent(id), { method: 'DELETE' }); if (!r.ok) throw new Error('clDelete failed: ' + r.status); } function clKey(book, name) { return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase(); } // Coerce a sheet field value to string, matching _csStr in character-sheets.js. function _clStr(v) { if (v == null) return ''; if (typeof v === 'string') return v; if (Array.isArray(v)) return v.filter(Boolean).join(', '); return JSON.stringify(v); } // Sanitize all scalar string fields on a raw sheet object before storing. function _clSanitize(sheet) { const out = { ...sheet }; const fields = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS : CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name'); fields.forEach(f => { if (out[f] != null) out[f] = _clStr(out[f]); }); return out; } // Merge an incoming sheet into an existing one without blanking solid facts. function clMergeSheet(existing, incoming) { const e = { ...existing }; const inc = _clSanitize(incoming); const scalars = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS : CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name'); scalars.forEach(f => { if ((inc[f] || '').length > (e[f] || '').length) e[f] = inc[f]; }); if (incoming.tier === 'main') e.tier = 'main'; if (incoming.moral_alignment_score != null) { e.moral_alignment_score = e.moral_alignment_score != null ? Math.round((e.moral_alignment_score + incoming.moral_alignment_score) / 2) : incoming.moral_alignment_score; } if (incoming.arc_direction && incoming.arc_direction !== 'neutral') e.arc_direction = incoming.arc_direction; e.inventory = [...(existing.inventory || [])]; (incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); }); e.sources = [...(existing.sources || [])]; (incoming.sources || []).forEach(src => { if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); }); return e; } // Union a character's tag strings into one clean comma-separated list. function clMergeTags(...parts) { const set = new Set(); parts.forEach(p => String(p || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t))); return [...set].join(', '); } // Upsert one sheet into the library under a book. Returns the stored record. async function clUpsert(book, sheet) { const name = (sheet.name || '').trim(); if (!name) return null; const bk = (book || '').trim() || 'Unsorted'; const id = clKey(bk, name); const now = new Date(); const prev = await clGet(id).catch(() => null); const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name }; const tags = clMergeTags(prev?.tags, sheet.tags, bk); const rec = { id, book: bk, name, tags, sheet: merged, analysis: prev?.analysis || null, voice: prev?.voice || sheet.voice || null, image: prev?.image || sheet.image || null, created: prev?.created || now, updated: now, }; await clPut(rec); return rec; } // Save (or clear) a profile image for a character by its record id. async function clSetImage(id, dataUrl) { const rec = await clGet(id).catch(() => null); if (!rec) return; rec.image = dataUrl || null; rec.updated = new Date(); await clPut(rec); return rec; } window.clSetImage = clSetImage; // All characters belonging to a production, by origin book OR by tag membership. async function clGetAllByTagOrBook(title) { const key = String(title || '').trim().toLowerCase(); if (!key) return []; const all = await clGetAll().catch(() => []); return all.filter(r => { if (String(r.book || '').trim().toLowerCase() === key) return true; return String(r.tags || '').split(',').some(t => t.trim().toLowerCase() === key); }); } // Bulk upsert a list of sheets for one book. async function clUpsertMany(book, sheets) { let n = 0; for (const s of (sheets || [])) { if (await clUpsert(book, s)) n++; } return n; } // ── One-time IndexedDB → server migration ──────────────────────────────────── // Runs once after a successful clGetAll() that returns an empty server. Reads // the old IndexedDB store (if present) and POSTs all records to /api/characters/migrate. (async function _clMigrateIfNeeded() { try { const serverRecs = await clGetAll(); if (serverRecs.length > 0) return; // already migrated const idbRecs = await _clIdbGetAll().catch(() => []); if (!idbRecs.length) return; const r = await fetch('/api/characters/migrate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(idbRecs), }); if (r.ok) { const d = await r.json(); console.log(`[characters-library] migrated ${d.imported} records from IndexedDB → SQLite`); } } catch (e) { console.warn('[characters-library] migration skipped:', e); } })(); function _clIdbGetAll() { return new Promise((resolve, reject) => { const req = indexedDB.open('character-library', 1); req.onerror = () => resolve([]); req.onsuccess = e => { const db = e.target.result; if (!db.objectStoreNames.contains('characters')) { db.close(); resolve([]); return; } const tx = db.transaction('characters', 'readonly'); const all = tx.objectStore('characters').getAll(); all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); }; all.onerror = () => { db.close(); resolve([]); }; }; }); } // ── Rendering ───────────────────────────────────────────────────────────────── let _clRecords = []; async function clRender() { const grid = document.getElementById('cl-grid'); if (!grid) return; const filterEl = document.getElementById('cl-book-filter'); const searchEl = document.getElementById('cl-search'); if (filterEl && !filterEl.dataset.bound) { filterEl.dataset.bound = '1'; filterEl.addEventListener('change', clApplyFilter); } if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); } try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; } const filter = document.getElementById('cl-book-filter'); const prods = clAllProductions(); if (filter) { const cur = filter.value; filter.innerHTML = `` + prods.map(b => ``).join(''); if (cur && prods.includes(cur)) filter.value = cur; } clApplyFilter(); } function clAllProductions() { const set = new Set(); _clRecords.forEach(r => { if (r.book) set.add(r.book); String(r.tags || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t)); }); return [...set].sort((a, b) => a.localeCompare(b)); } function clApplyFilter() { const grid = document.getElementById('cl-grid'); if (!grid) return; const book = document.getElementById('cl-book-filter')?.value || ''; const q = (document.getElementById('cl-search')?.value || '').trim().toLowerCase(); let recs = _clRecords.slice(); if (book) { const bk = book.toLowerCase(); recs = recs.filter(r => (r.book || '').toLowerCase() === bk || String(r.tags || '').split(',').some(t => t.trim().toLowerCase() === bk)); } if (q) recs = recs.filter(r => (r.name || '').toLowerCase().includes(q) || (r.sheet?.aliases || '').toLowerCase().includes(q) || (r.sheet?.archetype || '').toLowerCase().includes(q) || (r.tags || '').toLowerCase().includes(q) || (r.book || '').toLowerCase().includes(q)); if (!recs.length) { grid.innerHTML = `
${_clRecords.length ? 'No characters match your filter.' : 'No characters yet.'}
Run Character sheets from Read Aloud or the Script Rehearser to populate your library.