From 204bc3a6c6a8bcb7368c795f6cd16b928f82e3d4 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 6 Jul 2026 01:03:30 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 7 +++ VERSION | 2 +- static/index.html | 6 +-- static/js/audiobook.js | 89 +++++++++++++++++++++++++++++++++++ static/js/character-sheets.js | 27 +++++++++++ static/style.css | 18 +++++++ 6 files changed, 145 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c172e3c..0c5b4b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Changed diff --git a/VERSION b/VERSION index feaae22..b50dd27 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.0 +1.13.1 diff --git a/static/index.html b/static/index.html index ab1f69a..90c2afd 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 7cc41a6..04775f2 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -1012,6 +1012,68 @@ function highlightText(text, names) { 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 = + '' + + ''; + 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 = '
' + + '
Recast selected characters
' + + '
Re-scans the whole book, but only refreshes the characters you pick below — everyone else\'s sheet is left as-is.
' + + '
' + + existingChars.map(c => '').join('') + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
'; + 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 // that fills up as speakers are discovered. Far clearer than a bare bar. function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) { @@ -3105,6 +3167,33 @@ ABSOLUTE REGELN: else if (typeof csForReader === 'function') csForReader(); 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 = + '' + + ''; + 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'); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); }, diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index 518e6ef..02c9c50 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -778,6 +778,32 @@ async function csForReader() { 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() { const title = $('reh-script-title')?.value.trim() || 'Character sheets'; const book = $('reh-script-title')?.value.trim() || 'Untitled script'; @@ -802,6 +828,7 @@ async function csForRehearser() { } window.csForReader = csForReader; +window.csForReaderSelective = csForReaderSelective; window.csForRehearser = csForRehearser; $('reader-charsheets-btn')?.addEventListener('click', csForReader); $('reh-charsheets-btn')?.addEventListener('click', csForRehearser); diff --git a/static/style.css b/static/style.css index 0984d22..478893e 100644 --- a/static/style.css +++ b/static/style.css @@ -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-box .reader-synth-track { width: 100%; } .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-preview-box { width: min(760px, 94vw); } .audiobook-preview-box .audiobook-title { flex-wrap: wrap; }