377 lines
16 KiB
JavaScript
377 lines
16 KiB
JavaScript
// ── 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 = `<option value="">All productions (${prods.length})</option>` +
|
||
prods.map(b => `<option value="${escHtml(b)}">${escHtml(b)}</option>`).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 = `<div class="cl-empty">
|
||
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
|
||
<p>${_clRecords.length ? 'No characters match your filter.' : 'No characters yet.'}</p>
|
||
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your library.</p>
|
||
</div>`;
|
||
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 `<div class="cl-book-group">
|
||
<div class="cl-book-head"><span class="mdi mdi-book-open-page-variant-outline"></span> ${escHtml(bk)}
|
||
<span class="cl-book-count">${list.length}</span></div>
|
||
<div class="cl-book-cards">${list.map(clCardHtml).join('')}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function clCardHtml(rec) {
|
||
const tags = String(rec.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||
const chips = tags.length
|
||
? `<div class="cl-card-tags">${tags.map(t => `<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>${escHtml(t)}</span>`).join('')}</div>`
|
||
: '';
|
||
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
|
||
<div class="cl-card-tools">
|
||
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
|
||
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
|
||
</div>
|
||
${csCardHtml(rec.sheet)}
|
||
${chips}
|
||
</div>`;
|
||
}
|
||
|
||
// ── 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 `<label class="cl-edit-row"><span>${label}</span>${
|
||
multiline
|
||
? `<textarea data-field="${k}" rows="2">${val}</textarea>`
|
||
: `<input type="text" data-field="${k}" value="${val}">`
|
||
}</label>`;
|
||
}).join('');
|
||
modal.innerHTML = `<div class="audiobook-box cl-edit-box">
|
||
<div class="cs-titlebar">
|
||
<span class="audiobook-title"><span class="mdi mdi-pencil-outline"></span> Edit — ${escHtml(rec.name)}</span>
|
||
<span style="flex:1"></span>
|
||
<button class="btn-secondary btn-sm" id="cl-edit-cancel">Cancel</button>
|
||
<button class="btn-primary btn-sm" id="cl-edit-save"><span class="mdi mdi-content-save-outline"></span> Save</button>
|
||
</div>
|
||
<div class="cl-edit-body">
|
||
<label class="cl-edit-row"><span>Tags / productions (comma-separated — reuse across books & scripts)</span>
|
||
<input type="text" data-rec-field="tags" value="${escHtml(String(rec.tags || rec.book || ''))}" placeholder="e.g. Das Jahr des Greifen, Star-Trek"></label>
|
||
<label class="cl-edit-row"><span>Moral alignment (0 evil – 100 good)</span>
|
||
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
|
||
<label class="cl-edit-row"><span>Arc direction</span>
|
||
<select data-field="arc_direction">${
|
||
['stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex']
|
||
.map(o => `<option value="${o}"${(s.arc_direction || 'neutral') === o ? ' selected' : ''}>${o}</option>`).join('')
|
||
}</select></label>
|
||
${rows}
|
||
</div>
|
||
</div>`;
|
||
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;
|