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:
parent
9942f920bc
commit
e81fa64448
@ -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:
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button>
|
||||
</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>
|
||||
${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)}
|
||||
<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>
|
||||
|
||||
<span class="ab-cv-txt">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</span>`;
|
||||
if (typeof s.conf === 'number') row.dataset.conf = String(s.conf);
|
||||
|
||||
@ -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 = '<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>';
|
||||
|
||||
@ -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'
|
||||
? '<span class="mdi mdi-progress-clock"></span> ' + esc(detail || 'Analysing…')
|
||||
: ing.status === 'failed'
|
||||
? '<span class="mdi mdi-alert"></span> Ingestion failed: ' + esc(ing.error || '')
|
||||
: '<span class="mdi mdi-check"></span> ' + (ing.articles || 0) + ' articles detected';
|
||||
if (ing.status === 'running') {
|
||||
bar.className = 'mag-status is-running';
|
||||
const p = magParsePageProgress(detail);
|
||||
bar.innerHTML =
|
||||
'<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 === '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');
|
||||
}
|
||||
|
||||
|
||||
@ -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 = '<div style="padding: 20px; text-align: center; color: var(--subtext);">No saved books yet.</div>'; return; }
|
||||
list.innerHTML = all.map(rec => {
|
||||
const total = rec.sentenceCount || 0;
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
<!-- ── Contents ─────────────────────────────────────────────────────── -->
|
||||
<div id="mag-view-contents" hidden>
|
||||
<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
|
||||
</button>
|
||||
<h3 id="mag-doc-title">Magazine</h3>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user