Add View Characters + recast-all/selected once a book is already cast (v1.13.1)

"Cast Characters" always blindly regenerated the whole cast from
scratch, even for a book already fully cast - no way to just look at
what's there or touch up a handful of characters without redoing
everyone. Once clGetAllByTagOrBook finds existing characters for this
book, the button becomes a split control: View Characters (jump to the
Library overview) plus a dropdown for Recast all or Recast selected...
(checkbox picker that re-scans the book but only saves updates for the
characters checked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-06 01:03:30 +02:00
parent 04531b25f8
commit 204bc3a6c6
6 changed files with 145 additions and 4 deletions

View File

@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.13.1] — 2026-07-06
### Added
- **"View Characters" + recast options once a book's already cast** — Read Aloud's Casting view no longer offers a single blind "Cast Characters" button once this book already has saved characters. It becomes a split button: **View Characters** jumps straight to the Library's character overview, and the dropdown caret offers **Recast all** (previous behavior) or **Recast selected…**, which opens a checkbox picker of the existing cast and only refreshes the ones you check — everyone else's saved sheet is left untouched. (Extraction still re-scans the whole book either way, since any page could mention any character; "selected" just controls what gets saved afterward, not what gets read.)
---
## [1.13.0] — 2026-07-06 ## [1.13.0] — 2026-07-06
### Changed ### Changed

View File

@ -1 +1 @@
1.13.0 1.13.1

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.13.0"> <meta name="app-version" content="1.13.1">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.13.0"> <link rel="stylesheet" href="/static/style.css?v=1.13.1">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.13.0"></script> <script src="/static/loader.js?v=1.13.1"></script>
</body> </body>
</html> </html>

View File

@ -1012,6 +1012,68 @@ function highlightText(text, names) {
return html; return html;
} }
// Small dropdown anchored to the "Recast options" caret next to "View
// Characters", once a book's already been cast before — offers the existing
// full recast plus a targeted "recast just these characters" flow.
function _abOpenRecastCharsMenu(anchorEl, bookTitle, existingChars) {
document.querySelectorAll('.ab-recast-menu').forEach(el => el.remove());
const menu = document.createElement('div');
menu.className = 'ab-recast-menu';
menu.innerHTML =
'<button type="button" data-action="all"><span class="mdi mdi-refresh"></span> Recast all</button>' +
'<button type="button" data-action="selected"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> Recast selected…</button>';
document.body.appendChild(menu);
const rect = anchorEl.getBoundingClientRect();
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.left = Math.max(10, rect.right - 190) + 'px';
const close = () => { menu.remove(); document.removeEventListener('mousedown', onDoc); };
const onDoc = (e) => { if (!menu.contains(e.target) && e.target !== anchorEl) close(); };
setTimeout(() => document.addEventListener('mousedown', onDoc), 0);
menu.querySelector('[data-action="all"]').addEventListener('click', () => {
close();
if (typeof window.csForReader === 'function') window.csForReader();
});
menu.querySelector('[data-action="selected"]').addEventListener('click', () => {
close();
_abOpenRecastSelectPopup(bookTitle, existingChars);
});
}
// Checkbox picker for "recast selected" — re-scans the whole book (extraction
// is passage-by-passage, so any page could mention any character) but only
// saves updated sheets for the characters checked here.
function _abOpenRecastSelectPopup(bookTitle, existingChars) {
const ov = document.createElement('div');
ov.className = 'audiobook-overlay';
ov.innerHTML = '<div class="audiobook-box">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Recast selected characters</div>'
+ '<div class="audiobook-msg">Re-scans the whole book, but only refreshes the characters you pick below — everyone else\'s sheet is left as-is.</div>'
+ '<div class="ab-recast-select-list">'
+ existingChars.map(c => '<label><input type="checkbox" class="ab-recast-cb" value="' + escHtml(c.name) + '"> ' + escHtml(c.name) + '</label>').join('')
+ '</div>'
+ '<div class="audiobook-actions">'
+ '<button class="btn-secondary btn-sm" id="ab-recast-select-all">Select all</button>'
+ '<span style="flex:1"></span>'
+ '<button class="btn-secondary btn-sm" id="ab-recast-cancel">Cancel</button>'
+ '<button class="btn-primary btn-sm" id="ab-recast-confirm">Recast selected</button>'
+ '</div>'
+ '</div>';
document.body.appendChild(ov);
ov.querySelector('#ab-recast-cancel').addEventListener('click', () => ov.remove());
ov.querySelector('#ab-recast-select-all').addEventListener('click', (e) => {
const boxes = ov.querySelectorAll('.ab-recast-cb');
const allChecked = [...boxes].every(b => b.checked);
boxes.forEach(b => { b.checked = !allChecked; });
e.currentTarget.textContent = allChecked ? 'Select all' : 'Select none';
});
ov.querySelector('#ab-recast-confirm').addEventListener('click', async () => {
const names = [...ov.querySelectorAll('.ab-recast-cb:checked')].map(b => b.value);
if (!names.length) { toast('Select at least one character', 'error'); return; }
ov.remove();
if (typeof window.csForReaderSelective === 'function') await window.csForReaderSelective(names);
});
}
// Live casting view: a scrolling feed of attributed lines + a character roster // Live casting view: a scrolling feed of attributed lines + a character roster
// that fills up as speakers are discovered. Far clearer than a bare bar. // that fills up as speakers are discovered. Far clearer than a bare bar.
function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) { function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
@ -3105,6 +3167,33 @@ ABSOLUTE REGELN:
else if (typeof csForReader === 'function') csForReader(); else if (typeof csForReader === 'function') csForReader();
else toast('Character sheets not loaded yet', 'error'); else toast('Character sheets not loaded yet', 'error');
}); });
// If this book's already been cast before, "Cast Characters" blindly
// re-running the full generation over again isn't the useful default
// anymore — offer to jump straight to the existing overview, or recast
// just a hand-picked subset, instead of only "do it all again".
(async () => {
const bookTitle = window.readerState?.title || '';
if (!bookTitle || typeof clGetAllByTagOrBook !== 'function') return;
let existing = [];
try { existing = await clGetAllByTagOrBook(bookTitle); } catch (_) {}
if (!existing.length) return;
const castBtn = foot.querySelector('#ab-cv-cast-chars');
if (!castBtn) return;
const wrap = document.createElement('span');
wrap.className = 'ab-cv-castchars-group';
wrap.innerHTML =
'<button class="btn-secondary btn-sm ab-cv-castchars-view" title="Open this book\'s characters in the Library"><span class="mdi mdi-account-box-multiple-outline"></span> View Characters</button>' +
'<button class="btn-secondary btn-sm ab-cv-castchars-menu" title="Recast options"><span class="mdi mdi-chevron-down"></span></button>';
castBtn.replaceWith(wrap);
wrap.querySelector('.ab-cv-castchars-view').addEventListener('click', () => {
if (typeof navTo === 'function') navTo('s-library');
if (typeof navLibraryView === 'function') navLibraryView('characters');
});
wrap.querySelector('.ab-cv-castchars-menu').addEventListener('click', (e) => {
e.stopPropagation();
_abOpenRecastCharsMenu(e.currentTarget, bookTitle, existing);
});
})();
panel.classList.add('ab-castpanel-done'); panel.classList.add('ab-castpanel-done');
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
}, },

View File

@ -778,6 +778,32 @@ async function csForReader() {
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success'); toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
} }
// Refresh sheet data for a hand-picked subset of an already-cast book's
// characters, leaving everyone else's saved sheet untouched. Extraction is
// still passage-by-passage over the whole book (any page might mention any
// character), so this takes as long as a full recast — it just discards the
// results for characters the user didn't pick, rather than skipping work.
async function csForReaderSelective(selectedNames) {
const wanted = new Set((selectedNames || []).map(n => String(n).trim().toLowerCase()));
if (!wanted.size) { toast('No characters selected', 'error'); return; }
const text = csReaderText();
const book = readerState.title || 'Untitled book';
const knownRoster = csKnownReaderRoster();
const sheets = await csGenerate(text, null, knownRoster);
if (!sheets) return;
const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase()));
if (!filtered.length) { toast('None of the selected characters turned up in this pass — try again or pick different ones', 'error'); return; }
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
if (ab?.roster) {
const counts = new Map();
ab.roster.forEach(function (info, name) { counts.set(String(name).trim().toLowerCase(), info.count || 0); });
csAttachLineCounts(filtered, counts);
}
await csSaveToLibrary(book, filtered);
csGoToLibrary();
toast(filtered.length + ' character' + (filtered.length !== 1 ? 's' : '') + ' recast — Library → Characters / Cast', 'success');
}
async function csForRehearser() { async function csForRehearser() {
const title = $('reh-script-title')?.value.trim() || 'Character sheets'; const title = $('reh-script-title')?.value.trim() || 'Character sheets';
const book = $('reh-script-title')?.value.trim() || 'Untitled script'; const book = $('reh-script-title')?.value.trim() || 'Untitled script';
@ -802,6 +828,7 @@ async function csForRehearser() {
} }
window.csForReader = csForReader; window.csForReader = csForReader;
window.csForReaderSelective = csForReaderSelective;
window.csForRehearser = csForRehearser; window.csForRehearser = csForRehearser;
$('reader-charsheets-btn')?.addEventListener('click', csForReader); $('reader-charsheets-btn')?.addEventListener('click', csForReader);
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser); $('reh-charsheets-btn')?.addEventListener('click', csForRehearser);

View File

@ -5616,6 +5616,24 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.audiobook-msg { color: var(--subtext); font-size: 13px; margin: 10px 0 12px; } .audiobook-msg { color: var(--subtext); font-size: 13px; margin: 10px 0 12px; }
.audiobook-box .reader-synth-track { width: 100%; } .audiobook-box .reader-synth-track { width: 100%; }
.audiobook-actions { margin-top: 14px; text-align: right; display: flex; gap: 8px; justify-content: flex-end; } .audiobook-actions { margin-top: 14px; text-align: right; display: flex; gap: 8px; justify-content: flex-end; }
/* "View Characters ▾" split button + its recast dropdown, shown once a book
already has cast characters instead of a single blind "Cast Characters". */
.ab-cv-castchars-group { display: inline-flex; margin-right: 8px; }
.ab-cv-castchars-group .ab-cv-castchars-view { border-radius: 6px 0 0 6px; }
.ab-cv-castchars-group .ab-cv-castchars-menu { border-radius: 0 6px 6px 0; border-left: 0; padding: 0 8px; }
.ab-recast-menu {
position: fixed; z-index: 2001; background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,.3); padding: 4px; min-width: 180px;
display: flex; flex-direction: column; gap: 1px;
}
.ab-recast-menu button {
display: flex; align-items: center; gap: 8px; padding: 7px 10px; font-size: 12.5px;
border: none; background: none; color: var(--text); border-radius: 5px; cursor: pointer; text-align: left;
}
.ab-recast-menu button:hover { background: var(--panel); }
.ab-recast-select-list { max-height: 280px; overflow-y: auto; margin: 10px 0; text-align: left; border: 1px solid var(--border); border-radius: 6px; padding: 4px; }
.ab-recast-select-list label { display: flex; align-items: center; gap: 8px; padding: 5px 6px; cursor: pointer; font-size: 13px; border-radius: 4px; }
.ab-recast-select-list label:hover { background: var(--panel); }
.audiobook-count { font-weight: 500; font-size: 12px; color: var(--subtext); margin-left: auto; } .audiobook-count { font-weight: 500; font-size: 12px; color: var(--subtext); margin-left: auto; }
.audiobook-preview-box { width: min(760px, 94vw); } .audiobook-preview-box { width: min(760px, 94vw); }
.audiobook-preview-box .audiobook-title { flex-wrap: wrap; } .audiobook-preview-box .audiobook-title { flex-wrap: wrap; }