// ── 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 }; const rec = { id, book: bk, name, sheet: merged, analysis: prev?.analysis || null, voice: prev?.voice || null, created: prev?.created || now, updated: now, }; await clPut(rec); return rec; } // 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 book filter (preserve current selection if still present). const filter = document.getElementById('cl-book-filter'); const books = [...new Set(_clRecords.map(r => r.book))].sort((a, b) => a.localeCompare(b)); if (filter) { const cur = filter.value; filter.innerHTML = `` + books.map(b => ``).join(''); if (cur && books.includes(cur)) filter.value = cur; } clApplyFilter(); } 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) recs = recs.filter(r => r.book === book); if (q) recs = recs.filter(r => (r.name || '').toLowerCase().includes(q) || (r.sheet?.aliases || '').toLowerCase().includes(q) || (r.sheet?.archetype || '').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.