The per-word <span> wrapping behind "click any word to assign" created ~100k DOM nodes at book scale and froze the tab on every feed redraw; replaced with native caretRangeFromPoint word detection plus a single reused hover overlay — same UX, zero extra DOM. Export button gained a 2s re-entry guard (queued clicks during a freeze fired as a download burst) and now delivers one zip: the cast script in Markdown plus a sheet per character. Character detail view gains a Generation Prompts section — four fold-out copy boxes (Voice Design, Character Image, SillyTavern card, Concept Art sheet) filled by one LLM call over the full profile via the new /api/character-generate-prompts endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
489 lines
22 KiB
JavaScript
489 lines
22 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 / 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;
|
||
|
||
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));
|
||
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 = `<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?.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 = `<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 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;
|