// ── 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 / also known as'], ['first_name', 'First name'], ['last_name', 'Last name'], ['full_name', 'Full name'], ['title', 'Title / role'], ['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'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'], ['silly_tavern_prompt', 'SillyTavern Character Prompt'], ['concept_art_prompt', 'Concept Art Prompt'], ['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) { if (rec?.color) { rec.sheet = rec.sheet || {}; rec.sheet.color = clNormalizeColor(rec.color, rec.name); } 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(); } function clHslToHex(h, s, l) { s /= 100; l /= 100; const k = n => (n + h / 30) % 12; const a = s * Math.min(l, 1 - l); const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return '#' + [f(0), f(8), f(4)].map(x => Math.round(255 * x).toString(16).padStart(2, '0')).join(''); } function clNameHue(name) { return Math.abs((name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0)); } function clNormalizeColor(color, name) { const c = String(color || '').trim(); if (/^#[0-9a-f]{6}$/i.test(c)) return c; if (/^#[0-9a-f]{3}$/i.test(c)) return '#' + c.slice(1).split('').map(ch => ch + ch).join(''); return clHslToHex(clNameHue(name), 58, 43); } const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title']; const CL_ALIAS_MAX_TOKENS = 12; const CL_ALIAS_MAX_CHARS = 500; // Bare articles/pronouns can end up as "aliases" when a descriptive alias like // "Die, die den Vampir verließ" gets comma-split — a lone "Die" token then // matches (and underlines) every article in the whole book. const CL_ALIAS_STOPWORDS = new Set(['die','der','das','den','dem','des','ein','eine','einer','er','sie','es','ich','du','wir','ihr','the','a','an','he','she','it','they','who']); function clSplitIdentityTokens(v, opts = {}) { const raw = _clStr(v); const parts = raw .split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i) .map(x => x.trim()) .filter(Boolean) .filter(x => x.length <= 80 && !/^needs?:/i.test(x) && !/^complete$/i.test(x)) .filter(x => !CL_ALIAS_STOPWORDS.has(x.toLowerCase())); if (opts.aliases && (raw.length > CL_ALIAS_MAX_CHARS || parts.length > CL_ALIAS_MAX_TOKENS)) return []; return parts.slice(0, opts.aliases ? CL_ALIAS_MAX_TOKENS : undefined); } function clIdentityNames(recOrSheet) { const s = recOrSheet?.sheet || recOrSheet || {}; const out = new Set(); const add = (v, opts = {}) => clSplitIdentityTokens(v, opts).forEach(x => out.add(x.toLowerCase())); add(recOrSheet?.name || s.name); CL_IDENTITY_FIELDS.filter(k => k !== 'name').forEach(k => add(s[k], { aliases: k === 'aliases' })); return out; } function clMergeAliases(existing, incoming) { const names = new Map(); const add = (v) => clSplitIdentityTokens(v, { aliases: true }).forEach(x => names.set(x.toLowerCase(), x)); add(existing.aliases); add(incoming.aliases); if (incoming.name && incoming.name !== existing.name) add(incoming.name); return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', '); } function clSameIdentity(rec, book, sheet) { if (String(rec?.book || '').trim().toLowerCase() !== String(book || '').trim().toLowerCase()) return false; const a = clIdentityNames(rec); const b = clIdentityNames(sheet); for (const n of b) if (a.has(n)) return true; return false; } // 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'); const oldAliases = e.aliases; scalars.forEach(f => { if ((inc[f] || '').length > (e[f] || '').length) e[f] = inc[f]; }); e.aliases = clMergeAliases({ ...e, aliases: oldAliases }, incoming); 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 < 12 && !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 all = await clGetAll().catch(() => []); const aliasPrev = all.find(r => clSameIdentity(r, bk, sheet)); const id = aliasPrev?.id || clKey(bk, name); const now = new Date(); const prev = aliasPrev || await clGet(id).catch(() => null); const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name }; const canonicalName = prev?.name || name; merged.name = canonicalName; const tags = clMergeTags(prev?.tags, sheet.tags, bk); const color = clNormalizeColor(prev?.color || prev?.sheet?.color || sheet.color, canonicalName); merged.color = color; const rec = { id, book: bk, name: canonicalName, tags, sheet: merged, color, 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?.first_name || '').toLowerCase().includes(q) || (r.sheet?.last_name || '').toLowerCase().includes(q) || (r.sheet?.full_name || '').toLowerCase().includes(q) || (r.sheet?.title || '').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.