fix: magazine state persistence, misfiled in book library, confidence badge layout, docker-socket-proxy crash loop

Magazine "forgets" the open document (real bug, confirmed live):
- A full page reload or the browser's own Back button tears down all
  in-memory JS state; magInit() always defaulted to the empty library
  view with no way back to what was open. Persists the last-opened
  magazine id in localStorage and auto-reopens it on load, falling back
  to the library only if that document no longer exists.
- The actual root cause of the reported duplicate: readerRenderLibrary()
  (Studio/Reader "Source") and libraryRenderBooks() (Library > Books)
  both listed EVERY document from the shared reader_library store with
  no kind filter, so a magazine (kind: "magazine") was already showing
  up there disguised as a plain book. The magazine never actually
  vanished -- it was misfiled in a different list, which is what led to
  the user re-uploading it and getting a real duplicate under a
  different kind. Both listings now exclude kind: "magazine".

Confidence badge layout (Studio Characters review):
- The badge (radiobox is already positioned after the speaker name in
  DOM order) rendered on its own line far below the name because both
  .ab-cv-spk and .ab-cv-txt were `display: block`, forcing a line break
  regardless of markup order. Wrapped the name + badge + check button in
  a shared flex row (.ab-cv-spk-row) so the badge now sits directly
  beside the name/after "NARRATOR" as requested, without needing to
  touch the two existing incremental-patch code paths (they insert
  relative to the same elements, now just nested one level deeper).

docker-socket-proxy crash loop (found while restarting for the magazine
Python changes to take effect -- unrelated to this session's earlier
work, landed in an earlier commit during the interruption):
- read_only: true left the entrypoint with nowhere to write its own
  haproxy.cfg ("Read-only file system"), crash-looping the container and
  leaving the app without Docker access. Removed read_only for this one
  service -- cap_drop: ALL, no-new-privileges, and the :ro socket mount
  already provide the real hardening.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-07 09:39:20 +02:00
parent 9942f920bc
commit e81fa64448
7 changed files with 96 additions and 16 deletions

View File

@ -5,7 +5,13 @@ services:
image: tecnativa/docker-socket-proxy:0.3.0 image: tecnativa/docker-socket-proxy:0.3.0
container_name: tts-voice-creator-docker-proxy container_name: tts-voice-creator-docker-proxy
restart: unless-stopped 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: cap_drop:
- ALL - ALL
security_opt: security_opt:

View File

@ -653,15 +653,28 @@
background: rgba(37,99,235,.06); border: 1px solid var(--accent); border-radius: 4px; background: rgba(37,99,235,.06); border: 1px solid var(--accent); border-radius: 4px;
padding: 4px 6px; outline: none; 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 { .ab-cv-spk {
display: block;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; 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 { .ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-spk {
padding-left: 120px;
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
transition: background 0.15s; transition: background 0.15s;
@ -685,9 +698,7 @@
/*max-width: 500px;*/ /*max-width: 500px;*/
} }
.ab-cv-row.is-narr .ab-cv-spk { .ab-cv-row.is-narr .ab-cv-spk {
display: block;
font-weight: 600; font-weight: 600;
padding-left: 0;
font-size: 11px; font-size: 11px;
} }
.ab-cv-row.is-processing { .ab-cv-row.is-processing {

View File

@ -1867,8 +1867,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
<button class="ab-cv-row-tool ab-cv-merge-prev" type="button" title="Merge with previous segment"><span class="mdi mdi-arrow-collapse-up"></span></button> <button class="ab-cv-row-tool ab-cv-merge-prev" type="button" title="Merge with previous segment"><span class="mdi mdi-arrow-collapse-up"></span></button>
<button class="ab-cv-row-tool ab-cv-merge-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button> <button class="ab-cv-row-tool ab-cv-merge-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button>
</span> </span>
<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span> <span class="ab-cv-spk-row"><span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span>${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)}</span>
${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)}
<span class="ab-cv-txt">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</span>`; <span class="ab-cv-txt">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</span>`;
if (typeof s.conf === 'number') row.dataset.conf = String(s.conf); if (typeof s.conf === 'number') row.dataset.conf = String(s.conf);

View File

@ -66,6 +66,9 @@ async function libraryRenderBooks() {
list.innerHTML = _libSkeleton(4); list.innerHTML = _libSkeleton(4);
let all = []; let all = [];
try { const r = await fetch(LIB_READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { 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) { if (!all.length) {
list.innerHTML = '<div class="lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No books yet.</p>' list.innerHTML = '<div class="lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No books yet.</p>'
+ '<p class="lib-empty-hint">Open <b>Read Aloud</b>, import a PDF or text, and save it to your library.</p></div>'; + '<p class="lib-empty-hint">Open <b>Read Aloud</b>, import a PDF or text, and save it to your library.</p></div>';

View File

@ -193,18 +193,32 @@ async function magCreate() {
// ── Ingestion ──────────────────────────────────────────────────────────────── // ── 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) { async function magOpen(docId) {
try { try {
state.doc = await api('/api/reader/docs/' + docId); state.doc = await api('/api/reader/docs/' + docId);
state.doc.id = docId; state.doc.id = docId;
await magRefreshArticles(); await magRefreshArticles();
magShow('contents'); magShow('contents');
try { localStorage.setItem(MAG_LAST_DOC_KEY, docId); } catch (_) {}
if ((state.doc.ingest || {}).status === 'running') pollIngest(); if ((state.doc.ingest || {}).status === 'running') pollIngest();
} catch (e) { } catch (e) {
say('Could not open magazine: ' + e.message, 'err'); 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() { async function magIngest() {
if (!state.doc) return; if (!state.doc) return;
try { 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() { async function pollIngest() {
const bar = $('mag-ingest-status'); const bar = $('mag-ingest-status');
if (bar) bar.hidden = false; if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; }
for (;;) { for (;;) {
let data; let data;
try { try {
@ -230,11 +253,27 @@ async function pollIngest() {
const ing = data.ingest || {}; const ing = data.ingest || {};
const detail = (data.job || {}).detail || ''; const detail = (data.job || {}).detail || '';
if (bar) { if (bar) {
bar.innerHTML = ing.status === 'running' if (ing.status === 'running') {
? '<span class="mdi mdi-progress-clock"></span> ' + esc(detail || 'Analysing…') bar.className = 'mag-status is-running';
: ing.status === 'failed' const p = magParsePageProgress(detail);
? '<span class="mdi mdi-alert"></span> Ingestion failed: ' + esc(ing.error || '') bar.innerHTML =
: '<span class="mdi mdi-check"></span> ' + (ing.articles || 0) + ' articles detected'; '<div class="mag-ingest-row">' +
'<span class="mdi mdi-progress-clock mag-ingest-icon"></span>' +
'<div class="mag-ingest-text">' +
'<strong>' + esc(detail || 'Analysing…') + '</strong>' +
(p ? '<div class="mag-ingest-track"><div class="mag-ingest-fill" style="width:' + p.pct + '%"></div></div>' : '') +
'</div>' +
(p ? '<span class="mag-ingest-pct">' + p.pct + '%</span>' : '') +
'</div>';
} else if (ing.status === 'failed') {
bar.className = 'mag-status is-failed';
bar.innerHTML = '<div class="mag-ingest-row"><span class="mdi mdi-alert mag-ingest-icon"></span>' +
'<span>Ingestion failed: ' + esc(ing.error || '') + '</span></div>';
} else {
bar.className = 'mag-status is-done';
bar.innerHTML = '<div class="mag-ingest-row"><span class="mdi mdi-check-circle mag-ingest-icon"></span>' +
'<span>' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected</span></div>';
}
} }
if (ing.status !== 'running') { if (ing.status !== 'running') {
if (ing.status === 'done') await magRefreshArticles(); if (ing.status === 'done') await magRefreshArticles();
@ -969,11 +1008,27 @@ function syncDrivingPlayButton() {
// ── Boot ───────────────────────────────────────────────────────────────────── // ── Boot ─────────────────────────────────────────────────────────────────────
function magInit() { async function magInit() {
if (!$('mag-library-list')) return; // section not mounted if (!$('mag-library-list')) return; // section not mounted
magLoadLibrary(); magLoadLibrary();
magLoadVoices(); magLoadVoices();
setupMagDropzone(); 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'); magShow('library');
} }

View File

@ -1717,6 +1717,12 @@ async function readerRenderLibrary() {
if (!card || !list) return; if (!card || !list) return;
let all = []; let all = [];
try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { 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 = '<div style="padding: 20px; text-align: center; color: var(--subtext);">No saved books yet.</div>'; return; } if (!all.length) { list.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--subtext);">No saved books yet.</div>'; return; }
list.innerHTML = all.map(rec => { list.innerHTML = all.map(rec => {
const total = rec.sentenceCount || 0; const total = rec.sentenceCount || 0;

View File

@ -41,7 +41,7 @@
<!-- ── Contents ─────────────────────────────────────────────────────── --> <!-- ── Contents ─────────────────────────────────────────────────────── -->
<div id="mag-view-contents" hidden> <div id="mag-view-contents" hidden>
<div class="mag-bar"> <div class="mag-bar">
<button type="button" class="btn-sm" onclick="magShow('library')"> <button type="button" class="btn-sm" onclick="magForgetLastDoc();magShow('library')">
<span class="mdi mdi-arrow-left"></span> All magazines <span class="mdi mdi-arrow-left"></span> All magazines
</button> </button>
<h3 id="mag-doc-title">Magazine</h3> <h3 id="mag-doc-title">Magazine</h3>