// ── Character library ───────────────────────────────────────────────────────── // // Persistent, book-scoped character sheets. Running "Character sheets" in Read // Aloud or the Rehearser upserts each extracted character into this library, // keyed by (book + name). Cards reuse the renderers from character-sheets.js // (csCardHtml, csAlignmentBar, csDeepAnalysis), which loads before this module. const CL_DB_NAME = 'character-library'; const CL_STORE = 'characters'; // 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'], ]; // ── IndexedDB ───────────────────────────────────────────────────────────────── function clDbOpen() { return new Promise((resolve, reject) => { const req = indexedDB.open(CL_DB_NAME, 1); req.onupgradeneeded = e => { const db = e.target.result; if (!db.objectStoreNames.contains(CL_STORE)) { const store = db.createObjectStore(CL_STORE, { keyPath: 'id' }); store.createIndex('book', 'book', { unique: false }); } }; req.onsuccess = e => resolve(e.target.result); req.onerror = e => reject(e.target.error); }); } async function clDbOp(mode, fn) { const db = await clDbOpen(); return new Promise((resolve, reject) => { const tx = db.transaction(CL_STORE, mode); const req = fn(tx.objectStore(CL_STORE)); req.onsuccess = e => resolve(e.target.result); req.onerror = e => reject(e.target.error); }); } async function clGetAll() { const db = await clDbOpen(); return new Promise((resolve, reject) => { const req = db.transaction(CL_STORE, 'readonly').objectStore(CL_STORE).getAll(); req.onsuccess = e => resolve(e.target.result || []); req.onerror = e => reject(e.target.error); }); } async function clGet(id) { return clDbOp('readonly', s => s.get(id)); } async function clPut(rec) { return clDbOp('readwrite', s => s.put(rec)); } async function clDelete(id) { return clDbOp('readwrite', s => s.delete(id)); } function clKey(book, name) { return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase(); } // Merge an incoming sheet into an existing one without blanking solid facts — // same rule as csMerge in character-sheets.js (longer value wins; sources append; // alignment score averages). function clMergeSheet(existing, incoming) { const e = { ...existing }; 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 ((incoming[f] || '').length > (e[f] || '').length) e[f] = incoming[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; } // 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) : { ...sheet, name }; // Tags (comma-separated, like voices) — the productions this character belongs // to. Seeded with its origin book; accumulates without ever blanking so a // character can be reused across several books/scripts. 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, created: prev?.created || now, updated: now, }; await clPut(rec); return rec; } // 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(', '); } // 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 (used by the analysis side-effect). async function clUpsertMany(book, sheets) { let n = 0; for (const s of (sheets || [])) { if (await clUpsert(book, s)) n++; } return n; } // ── Rendering ───────────────────────────────────────────────────────────────── let _clRecords = []; async function clRender() { const grid = document.getElementById('cl-grid'); if (!grid) return; // Bind filter/search once the section fragment exists (it loads after this JS). 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 = []; } // Populate the production filter with the union of origin books and all tags // (preserve current selection if still present). 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(); } // Distinct production names across origin books and character tags. 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; } // Group by book. 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 }; // Record-level fields (tags) live outside the sheet. const tagsEl = modal.querySelector('[data-rec-field="tags"]'); const newTags = tagsEl ? clMergeTags(tagsEl.value, rec.book) : (rec.tags || rec.book); // If the name changed, the key changes too — delete old, write new. 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-level delegation — the #cl-grid is (re)built on every render and the // section fragment loads after this script, so we can't bind to it directly. 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;