diff --git a/CHANGELOG.md b/CHANGELOG.md index 00863af..024b3c7 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.12.5] — 2026-06-27 + +### Added +- **Autosave for audiobook casting**: progress is saved to localStorage after every passage. A page refresh, browser crash, or accidental close no longer loses hours of casting work — reopening "Cast as audiobook" for the same document restores the session automatically with a banner showing how far it was completed and when it was last saved. Manual speaker corrections made in the cast view are also autosaved immediately. The draft is cleared when the script is explicitly saved to Script Rehearsals. + +--- + ## [1.12.4] — 2026-06-27 ### Added diff --git a/static/index.html b/static/index.html index 3de401b..0f9f1dc 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,7 @@ - + @@ -336,7 +336,7 @@ - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 6b42a99..d4bd02b 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -14,6 +14,50 @@ const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call const _audiobook = { running: false, cancel: false }; +// ── Autosave / draft recovery ───────────────────────────────────────────────── +// Saves the accumulated segments to localStorage after every chunk so a page +// refresh or browser crash doesn't lose hours of casting work. + +const _AB_DRAFT_KEY = 'ttsvc_ab_draft'; + +function _abTextId(text) { + // Cheap fingerprint — no need for a full hash; length + first/last chars is enough + // to detect doc changes without storing the source text itself. + const s = (text.slice(0, 300) + text.slice(-300)).replace(/\s+/g, ''); + let h = 5381; + for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; + return h.toString(36) + '_' + text.length; +} + +function _abSaveDraft(segs, roster, text, done, total) { + try { + localStorage.setItem(_AB_DRAFT_KEY, JSON.stringify({ + textId: _abTextId(text), + segments: segs, + roster: roster, + pageMarks: _audiobook.pageMarks || [], + done: done, + total: total, + savedAt: Date.now() + })); + } catch (_) {} // storage full / private mode — silently skip +} + +function _abLoadDraft(text) { + try { + const raw = localStorage.getItem(_AB_DRAFT_KEY); + if (!raw) return null; + const d = JSON.parse(raw); + if (!d || !Array.isArray(d.segments) || !d.segments.length) return null; + if (d.textId !== _abTextId(text)) return null; // different document + return d; + } catch (_) { return null; } +} + +function _abClearDraft() { + try { localStorage.removeItem(_AB_DRAFT_KEY); } catch (_) {} +} + // Opening/closing quote glyphs across book conventions: English "..."/“...”, // German »...«/„...“, French «...», single ‘...’/›...‹, CJK 「...」『...』, em-dash speech. const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「<]|(?:^|\n)\s*[—–]\s/; @@ -642,6 +686,10 @@ STRIKTE FORMAT- UND TEXTREGELN: renderRoster(); toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success'); closeAssignPopup(); + // Persist manual corrections immediately so a refresh doesn't lose them + if (_audiobook.lastText) { + setTimeout(() => _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1), 50); + } } feed.addEventListener('click', e => { @@ -1122,10 +1170,9 @@ function audiobookOpenCastView() { ? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS) : [text]; - // If the user navigates away and back, restore the view instead of clearing their work + // 1. In-memory restore — user navigated away and came back in the same session if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) { const view = audiobookCastView(chunks.length, llm_url, model, false); - // we need to set the roster manually so color generation matches view.addSegments(_audiobook.segments); const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`; @@ -1133,7 +1180,33 @@ function audiobookOpenCastView() { view.update(chunks.length); return; } - + + // 2. localStorage draft restore — survived a page refresh or browser crash + const _draft = _abLoadDraft(text); + if (_draft && _draft.segments && _draft.segments.length > 0) { + _audiobook.segments = _draft.segments; + _audiobook.roster = _draft.roster || []; + _audiobook.lastText = text; + _audiobook.pageMarks = _draft.pageMarks || []; + + const view = audiobookCastView(chunks.length, llm_url, model, false); + view.addSegments(_draft.segments); + + const ageMs = Date.now() - (_draft.savedAt || 0); + const ageMins = Math.round(ageMs / 60000); + const ageStr = ageMins < 1 ? 'just now' : ageMins < 60 ? `${ageMins}m ago` : `${Math.round(ageMins / 60)}h ago`; + const pct = _draft.total > 0 ? Math.round((_draft.done / _draft.total) * 100) : 100; + const wasDone = _draft.done === _draft.total || _draft.done < 0; + view.note(`🔄 Session restored from autosave — ${pct}% complete, saved ${ageStr}.${!wasDone ? ' Casting was interrupted; click "Recast all" to fill in the rest, or "Review & cast" to work with what\'s here.' : ''}`); + + const speakers = new Set(_draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); + const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_draft.segments.length} segments`; + view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); + view.update(_draft.done > 0 ? _draft.done : chunks.length); + return; + } + + // 3. Fresh start audiobookCastView(chunks.length, llm_url, model, true); } @@ -1148,6 +1221,7 @@ async function audiobookCast(overrideUrl, overrideModel) { : [text]; _audiobook.running = true; _audiobook.cancel = false; + _abClearDraft(); // fresh cast — discard any previous draft for this document if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true); const ac = new AbortController(); _audiobook.abort = () => ac.abort(); @@ -1322,6 +1396,7 @@ async function audiobookCast(overrideUrl, overrideModel) { segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); }); segs.forEach(s => allSegments.push(s)); view.addSegments(segs); + _abSaveDraft(allSegments, roster, text, i + 1, chunks.length); } view.update(chunks.length); } finally { @@ -1335,6 +1410,7 @@ async function audiobookCast(overrideUrl, overrideModel) { _audiobook.roster = roster; _audiobook.narratedPassages = narrationOnly; _audiobook.degraded = degraded; + _abSaveDraft(allSegments, roster, text, chunks.length, chunks.length); // mark 100% complete if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info'); @@ -1592,6 +1668,7 @@ async function audiobookSaveAsRehearsal() { if (typeof rehDbAdd === 'function') { try { await rehDbAdd(rec); + _abClearDraft(); // work is committed to Rehearser — draft no longer needed toast('Saved as Script Rehearsal', 'success'); if (typeof renderLibraryList === 'function') renderLibraryList(); } catch (e) {