diff --git a/docker-compose.yml b/docker-compose.yml index a450ffa..7fdb4d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,13 @@ services: image: tecnativa/docker-socket-proxy:0.3.0 container_name: tts-voice-creator-docker-proxy restart: unless-stopped - read_only: true + # NOT read_only: the entrypoint sed's its own haproxy.cfg.template into + # haproxy.cfg at startup, in the same directory the template ships in -- + # read_only crash-looped it ("Read-only file system"), and a tmpfs mount + # over that directory only trades one failure for another by hiding the + # template it needs to read (confirmed live: "No such file or + # directory"). The real hardening here is cap_drop: ALL, + # no-new-privileges, and the Docker socket itself being mounted :ro. cap_drop: - ALL security_opt: diff --git a/static/css/audiobook.css b/static/css/audiobook.css index 8f21fb3..e3a1a7b 100644 --- a/static/css/audiobook.css +++ b/static/css/audiobook.css @@ -653,15 +653,28 @@ background: rgba(37,99,235,.06); border: 1px solid var(--accent); border-radius: 4px; padding: 4px 6px; outline: none; } +/* .ab-cv-spk-row wraps the speaker name together with its confidence badge + and check button (see _abRowFromSegment) so the badge sits directly beside + the name instead of dropping to its own line below it — before this, both + .ab-cv-spk and .ab-cv-txt were `display: block`, which forced a line break + between the name and the badge that followed it in the DOM regardless of + how close together the markup already put them. */ +.ab-cv-spk-row { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 2px; +} .ab-cv-spk { - display: block; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; - margin-bottom: 2px; +} +.ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-spk-row { + padding-left: 120px; } .ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-spk { - padding-left: 120px; cursor: pointer; border-radius: 4px; transition: background 0.15s; @@ -685,9 +698,7 @@ /*max-width: 500px;*/ } .ab-cv-row.is-narr .ab-cv-spk { - display: block; font-weight: 600; - padding-left: 0; font-size: 11px; } .ab-cv-row.is-processing { diff --git a/static/js/audiobook-cast.js b/static/js/audiobook-cast.js index 574af32..ce2df6d 100644 --- a/static/js/audiobook-cast.js +++ b/static/js/audiobook-cast.js @@ -1867,8 +1867,7 @@ STRIKTE FORMAT- UND TEXTREGELN: - ${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''} - ${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)} + ${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)} ${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; if (typeof s.conf === 'number') row.dataset.conf = String(s.conf); diff --git a/static/js/library.js b/static/js/library.js index 2edc2e7..6d2d34d 100644 --- a/static/js/library.js +++ b/static/js/library.js @@ -66,6 +66,9 @@ async function libraryRenderBooks() { list.innerHTML = _libSkeleton(4); let all = []; try { const r = await fetch(LIB_READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; } + // Magazines share this store (kind: "magazine") but live in their own + // library at Studio -> Magazines -- see the matching fix in reader.js. + all = all.filter(d => d.kind !== 'magazine'); if (!all.length) { list.innerHTML = '

No books yet.

' + '

Open Read Aloud, import a PDF or text, and save it to your library.

'; diff --git a/static/js/magazine.js b/static/js/magazine.js index f54d087..0f73fdb 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -193,18 +193,32 @@ async function magCreate() { // ── Ingestion ──────────────────────────────────────────────────────────────── +// Remembers which magazine was open so a full page reload or the browser's +// own Back button (which, unlike in-app navigation, tears down all JS state) +// lands back on it instead of the empty library — confirmed live as the +// cause of a user re-uploading the same PDF, creating a duplicate under a +// completely different kind ("book", via Studio/Reader's own import) because +// the magazine they had been working on appeared to have vanished. +const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc'; + async function magOpen(docId) { try { state.doc = await api('/api/reader/docs/' + docId); state.doc.id = docId; await magRefreshArticles(); magShow('contents'); + try { localStorage.setItem(MAG_LAST_DOC_KEY, docId); } catch (_) {} if ((state.doc.ingest || {}).status === 'running') pollIngest(); } catch (e) { say('Could not open magazine: ' + e.message, 'err'); + try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {} } } +function magForgetLastDoc() { + try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {} +} + async function magIngest() { if (!state.doc) return; try { @@ -219,9 +233,18 @@ async function magIngest() { } } +// "Analysing page 28 of 204" -> a real percentage, not just spinner text. +function magParsePageProgress(detail) { + const m = /page (\d+) of (\d+)/i.exec(detail || ''); + if (!m) return null; + const page = parseInt(m[1], 10), total = parseInt(m[2], 10); + if (!total) return null; + return { page, total, pct: Math.min(100, Math.round((page / total) * 100)) }; +} + async function pollIngest() { const bar = $('mag-ingest-status'); - if (bar) bar.hidden = false; + if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; } for (;;) { let data; try { @@ -230,11 +253,27 @@ async function pollIngest() { const ing = data.ingest || {}; const detail = (data.job || {}).detail || ''; if (bar) { - bar.innerHTML = ing.status === 'running' - ? ' ' + esc(detail || 'Analysing…') - : ing.status === 'failed' - ? ' Ingestion failed: ' + esc(ing.error || '') - : ' ' + (ing.articles || 0) + ' articles detected'; + if (ing.status === 'running') { + bar.className = 'mag-status is-running'; + const p = magParsePageProgress(detail); + bar.innerHTML = + '
' + + '' + + '
' + + '' + esc(detail || 'Analysing…') + '' + + (p ? '
' : '') + + '
' + + (p ? '' + p.pct + '%' : '') + + '
'; + } else if (ing.status === 'failed') { + bar.className = 'mag-status is-failed'; + bar.innerHTML = '
' + + 'Ingestion failed: ' + esc(ing.error || '') + '
'; + } else { + bar.className = 'mag-status is-done'; + bar.innerHTML = '
' + + '' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected
'; + } } if (ing.status !== 'running') { if (ing.status === 'done') await magRefreshArticles(); @@ -969,11 +1008,27 @@ function syncDrivingPlayButton() { // ── Boot ───────────────────────────────────────────────────────────────────── -function magInit() { +async function magInit() { if (!$('mag-library-list')) return; // section not mounted magLoadLibrary(); magLoadVoices(); setupMagDropzone(); + let lastId = null; + try { lastId = localStorage.getItem(MAG_LAST_DOC_KEY); } catch (_) {} + if (lastId) { + // Confirmed live: without this, a full reload or the browser's own Back + // button (as opposed to in-app navigation) always dropped back to the + // empty library, making an in-progress magazine look like it had been + // lost. If it genuinely no longer exists (deleted elsewhere), fall back + // to the library rather than getting stuck on a permanent error toast. + try { + await api('/api/reader/docs/' + lastId); + await magOpen(lastId); + return; + } catch (_) { + magForgetLastDoc(); + } + } magShow('library'); } diff --git a/static/js/reader.js b/static/js/reader.js index 6273946..d8ed0c7 100644 --- a/static/js/reader.js +++ b/static/js/reader.js @@ -1717,6 +1717,12 @@ async function readerRenderLibrary() { if (!card || !list) return; let all = []; try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; } + // Magazines share this store (kind: "magazine") but are a different + // feature with their own library at Studio -> Magazines -- listing them + // here too let one masquerade as a plain book, which is how a user's + // in-progress magazine ended up re-uploaded as a duplicate (confirmed + // live: it never actually vanished, it was just misfiled here). + all = all.filter(d => d.kind !== 'magazine'); if (!all.length) { list.innerHTML = '
No saved books yet.
'; return; } list.innerHTML = all.map(rec => { const total = rec.sentenceCount || 0; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index d7d14c9..4d586dc 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -41,7 +41,7 @@