// ── 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.

`; return; } const byBook = new Map(); for (const r of recs) { if (!byBook.has(r.book)) byBook.set(r.book, []); byBook.get(r.book).push(r); } const groups = [...byBook.keys()].sort((a, b) => a.localeCompare(b)); grid.innerHTML = groups.map(bk => { const list = byBook.get(bk).sort((a, b) => { const t = (a.sheet?.tier === 'main' ? 0 : 1) - (b.sheet?.tier === 'main' ? 0 : 1); return t || (a.name || '').localeCompare(b.name || ''); }); return `
${escHtml(bk)} ${list.length}
${list.map(clCardHtml).join('')}
`; }).join(''); } function clCardHtml(rec) { const tags = String(rec.tags || '').split(',').map(t => t.trim()).filter(Boolean); const chips = tags.length ? `
${tags.map(t => `${escHtml(t)}`).join('')}
` : ''; return `
${csCardHtml(rec.sheet)} ${chips}
`; } // ── Edit modal ────────────────────────────────────────────────────────────── function clEdit(id) { const rec = _clRecords.find(r => r.id === id); if (!rec) return; const s = rec.sheet || {}; const modal = document.createElement('div'); modal.className = 'audiobook-overlay'; const rows = CL_EDIT_FIELDS.map(([k, label]) => { const multiline = !['name', 'aliases', 'archetype'].includes(k); const val = escHtml(String(s[k] || '')); return ``; }).join(''); modal.innerHTML = `
Edit — ${escHtml(rec.name)}
${rows}
`; document.body.appendChild(modal); const close = () => modal.remove(); modal.querySelector('#cl-edit-cancel').addEventListener('click', close); modal.addEventListener('click', e => { if (e.target === modal) close(); }); modal.querySelector('#cl-edit-save').addEventListener('click', async () => { const patch = {}; modal.querySelectorAll('[data-field]').forEach(el => { patch[el.dataset.field] = el.value; }); const newName = (patch.name || '').trim() || rec.name; let mas = parseInt(patch.moral_alignment_score, 10); mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas)); const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: mas }; const tagsEl = modal.querySelector('[data-rec-field="tags"]'); const newTags = tagsEl ? clMergeTags(tagsEl.value, rec.book) : (rec.tags || rec.book); const newId = clKey(rec.book, newName); rec.sheet = newSheet; rec.name = newName; rec.tags = newTags; rec.updated = new Date(); try { if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; } await clPut(rec); toast('Character saved', 'success'); close(); clRender(); } catch (e) { toast('Save failed: ' + (e.message || e), 'error'); } }); } async function clDeleteRec(id) { const rec = _clRecords.find(r => r.id === id); if (!rec) return; if (!confirm(`Delete "${rec.name}" from ${rec.book}?`)) return; try { await clDelete(id); toast('Deleted', 'success'); clRender(); } catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); } } // ── Wiring ────────────────────────────────────────────────────────────────── document.addEventListener('click', e => { const edit = e.target.closest?.('.cl-edit'); if (edit) { e.stopPropagation(); clEdit(edit.dataset.id); return; } const del = e.target.closest?.('.cl-delete'); if (del) { e.stopPropagation(); clDeleteRec(del.dataset.id); return; } const deep = e.target.closest?.('.cl-card-wrap .cs-deep-btn'); if (deep) { e.stopPropagation(); const rec = _clRecords.find(r => r.id === deep.closest('.cl-card-wrap')?.dataset.id); if (rec && typeof csDeepAnalysis === 'function') csDeepAnalysis(rec.sheet, ''); } }); window.clRender = clRender; window.clUpsertMany = clUpsertMany; window.clUpsert = clUpsert; window.clGetAll = clGetAll; window.clPut = clPut; window.clGetAllByTagOrBook = clGetAllByTagOrBook;