The saved casting prompt was the 2nd-quality verification prompt, so first-pass attribution ran with the wrong job description and none of the deduction rules — and the client-side prompt migration had no anchor to upgrade. The server now appends the rules to any prompt lacking them, and the saved prompt was reset to the default (backed up to config/audiobook_prompt.backup.txt). Also: single-pass combined name regex (a shorter alias could match inside a longer name's data-name attribute and leak raw style="..." into the feed), stopword filter so a comma-split alias like "Die, die den Vampir verließ" can't underline every article, heading-like narration renders bold/centered, and A-/A+ font controls in the casting toolbar sharing the Rehearser Stage scale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
494 lines
22 KiB
JavaScript
494 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;
|
||
// 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;
|
||
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;
|