tts-voice-creator-clone-and.../static/js/characters-library.js
2026-06-30 13:53:08 +02:00

377 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 &amp; 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;