tts-voice-creator-clone-and.../static/js/characters-library.js
mARTin-B78 ea50267c30 Add unified Studio casting workflow and fix voice/casting pipeline bugs
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 02:03:56 +02:00

528 lines
25 KiB
JavaScript
Raw Permalink 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 / also known as'], ['first_name', 'First name'], ['last_name', 'Last name'], ['title', 'Title / role'],
['age_estimate', 'Estimated age'], ['race_species', 'Race / species'], ['languages', 'Languages'],
['nationality_background', 'Nationality / background'], ['social_class', 'Social class'],
['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'],
['communication_style', 'Communication style'], ['reputation', 'Reputation'], ['religious_beliefs', 'Religious beliefs'], ['notes', 'Notes'],
['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);
const saved = await r.json();
// Lets an already-open Stage/rehearsal for this same book pick up a voice
// change immediately instead of silently keeping the old one — see
// _rehSyncCastVoiceFromLibrary's own comment for the full story.
if (typeof window._rehSyncCastVoiceFromLibrary === 'function') window._rehSyncCastVoiceFromLibrary(saved);
return saved;
}
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);
}
// Deliberately excludes 'title'/'profession' etc. — mirrors CS_IDENTITY_FIELDS
// in character-sheets.js (see the comment there): those are purely
// descriptive text that unrelated characters routinely share verbatim (job
// titles, epithets like "the Executioner" reused for a different character
// later in the book), so treating a shared title as proof of shared identity
// silently merged two different characters' persisted library records into
// one, corrupting both. Keep this list in sync with CS_IDENTITY_FIELDS.
const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name'];
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;
if (incoming.gender && !e.gender) e.gender = incoming.gender;
if (incoming.line_count != null) e.line_count = incoming.line_count;
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.
// `knownId` bypasses the alias-based identity guess below entirely — pass it
// whenever the caller already holds a concrete, previously-loaded record
// (e.g. saving a voice/image pick from that record's own card/row) rather
// than a freshly-extracted sheet with no stable home yet. Without it, a
// character whose OWN alias list happens to also name a different character
// in the same book (confirmed live: an LLM-extracted "aliases" field for one
// character literally included another character's real name — an
// extraction slip, not a genuine same-person case) silently redirects the
// write to that OTHER character's record instead, since clSameIdentity only
// needs one alias token to overlap. Alias matching is still exactly right
// for its original purpose — deduping a freshly-extracted sheet against
// whatever's already stored — just not for updating a record the caller can
// already point at directly by id.
async function clUpsert(book, sheet, knownId) {
const name = (sheet.name || '').trim();
if (!name) return null;
const bk = (book || '').trim() || 'Unsorted';
const aliasPrev = knownId
? await clGet(knownId).catch(() => null)
: (await clGetAll().catch(() => [])).find(r => clSameIdentity(r, bk, sheet));
const id = aliasPrev?.id || knownId || 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,
// An explicit new value from the caller (e.g. picking a different voice
// in the picker) must win over whatever was already stored — this used
// to be `prev.voice || sheet.voice`, so once a character had ANY voice,
// every later reassignment silently no-op'd: the picker showed a
// "success" toast and updated the in-memory rec, but the persisted
// record kept the OLD voice forever, with the wrong voice then used for
// every audio-generation pass. Character-sheet regeneration passes never
// set voice/image at all, so falling back to prev here is still correct
// for that path.
voice: sheet.voice || prev?.voice || null,
image: sheet.image || prev?.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?.first_name || '').toLowerCase().includes(q) ||
(r.sheet?.last_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 = `<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) {
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>
${typeof csOverviewCardHtml === 'function' ? csOverviewCardHtml(rec) : csCardHtml(rec.sheet)}
</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 cl-edit-color"><span>Character color</span>
<input type="color" data-rec-field="color" value="${escHtml(clNormalizeColor(rec.color || s.color, rec.name))}"></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 colorEl = modal.querySelector('[data-rec-field="color"]');
const color = clNormalizeColor(colorEl?.value || rec.color || s.color, newName);
const newId = clKey(rec.book, newName);
newSheet.color = color;
rec.sheet = newSheet; rec.name = newName; rec.tags = newTags; rec.color = color; 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, '');
}
const img = e.target.closest?.('.cl-card-wrap .cs-img-btn');
if (img) {
e.stopPropagation();
const rec = _clRecords.find(r => r.id === img.closest('.cl-card-wrap')?.dataset.id);
if (rec && typeof csBuildImagePrompt === 'function') {
navigator.clipboard?.writeText(csBuildImagePrompt(rec.sheet))
.then(() => toast('Image prompt copied', 'success'), () => toast('Copy failed', 'error'));
}
return;
}
const voice = e.target.closest?.('.cl-card-wrap .cs-voice-btn');
if (voice) {
e.stopPropagation();
const rec = _clRecords.find(r => r.id === voice.closest('.cl-card-wrap')?.dataset.id);
if (rec && typeof csBuildVoicePrompt === 'function') {
navigator.clipboard?.writeText(csBuildVoicePrompt(rec.sheet))
.then(() => toast('Voice prompt copied', 'success'), () => toast('Copy failed', 'error'));
}
return;
}
const src = e.target.closest?.('.cl-card-wrap .cs-source-link, .cl-card-wrap .cs-source-mark');
if (src) {
e.stopPropagation();
const pg = parseInt(src.dataset.page, 10);
if (pg && typeof window.readerJumpToPage === 'function') window.readerJumpToPage(pg);
else if (pg) toast(`Source: page ${pg}`, 'info');
}
});
window.clRender = clRender;
window.clUpsertMany = clUpsertMany;
window.clUpsert = clUpsert;
window.clGetAll = clGetAll;
window.clPut = clPut;
window.clGetAllByTagOrBook = clGetAllByTagOrBook;