tts-voice-creator-clone-and.../static/js/characters-library.js
mARTin-B78 52994df70b feat: unified Library + character tags + shared cast (v1.10.0)
Restructure the sidebar into Voices / Speak / Setup groups and fuse the
Read Aloud and Script Rehearser features into one workspace:

- New combined Library section (Books · Theater Plays · Characters/Cast)
  with cross-links (Rehearse a book, Read Aloud a play).
- Character tags like voice tags: one record, many productions, reusable
  across books/scripts; seeded with origin book, editable in the editor.
- Shared cast resolution: opening a production fills empty cast slots from
  the character roster (matched by book OR tag); voice choices written back
  on save. Productions joined by normalized title (prodKey).
- Tags nav group for voices (distinct tags + counts, Cloned/Designed/Fav).
- Fold standalone Characters into Library; Read Aloud now a single entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:12:22 +02:00

335 lines
15 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. 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 = `<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();
}
// 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 = `<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;
}
// 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 `<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 };
// 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;