// Magazine reader — contents tree, article review, dual reading views, // sentence-highlighted playback and podcast mode. // // The canonical object is the ARTICLE, not the page (see // architecture/magazine-ingestion.md). Nothing is narrated until a human has // approved the detected boundaries, so the review screen is on the critical // path rather than an optional extra. // // Two views stay linked: the reflowed article (comfortable reading, sentence // highlighting) and the original PDF page (the visual source of truth). Every // block carries page + bbox, so either view can jump to the other. (function (root) { 'use strict'; const MAG = root.TTVC = root.TTVC || {}; const state = { docs: [], doc: null, // { id, title, issue, ingest, ... } articles: [], layout: null, // { page_count, pages[], blocks[] } article: null, // the article being read or reviewed sentences: [], // [{ text, blockId, page, bbox }] images: [], // current article's figures, in reading order view: 'library', // library | contents | review | read contentsView: 'list', // list | grid — how the article list itself is shown contentsSelectMode: false, // batch-select articles for bulk approve/reject contentsSelected: new Set(), playlistCurrent: null, // article id of the MP3 playlist's current track pane: 'reflow', // reflow | pdf playing: false, cancel: false, idx: 0, // current sentence audio: null, voices: [], podcast: null, queue: [], // article ids for "play all" (empty = not queued) queueIndex: -1, driving: false, // large-control, minimal-interaction view karaoke: true, // word-level highlighting (approximate, see estimateWordTimings) drawMode: false, // manually drawing a region OCR/layout missed, in review reviewSpan: [], reviewPage: null, reviewSpread: false, // two-page spread view in review, see magReviewToggleSpread audioCache: new Map(), // voice+text -> Promise, see magSynth preparing: false, // "Prepare audio" is synthesising the whole article prepCancel: false, prepGen: 0, // bumped on article change so a stale prepare run stops touching state }; MAG.magazine = state; const $ = (id) => document.getElementById(id); const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); function say(msg, kind) { if (typeof toast === 'function') toast(msg, kind || ''); } async function api(path, opts) { const r = await fetch(path, opts); if (!r.ok) { let detail = r.statusText; try { detail = (await r.json()).detail || detail; } catch (_) {} throw new Error(detail); } const type = r.headers.get('content-type') || ''; return type.includes('json') ? r.json() : r; } function fmtDuration(seconds) { const s = Math.max(0, Math.round(seconds || 0)); if (s < 60) return s + ' sec'; const m = Math.round(s / 60); return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min'; } function fmtPages(pages) { if (!pages || !pages.length) return '—'; if (pages.length === 1) return 'p. ' + pages[0]; // Non-contiguous page lists are the norm once a feature jumps, and showing // "18–88" for pages [18, 88] would misdescribe it as a 70-page article. const runs = []; let start = pages[0], prev = pages[0]; for (let i = 1; i <= pages.length; i++) { if (pages[i] === prev + 1) { prev = pages[i]; continue; } runs.push(start === prev ? String(start) : start + '–' + prev); start = prev = pages[i]; } return 'pp. ' + runs.join(', '); } // The live OCR view is intentionally split: a portrait source page needs more // horizontal room than a dense recognised-text pane on some screens, while on // others the text is the thing being inspected. Keep a per-browser choice so // the user does not have to re-adjust it for every magazine/page. function setupMagIngestPreviewResize() { const preview = $('mag-ingest-preview'); const grip = $('mag-ingest-preview-resize'); if (!preview || !grip || preview.dataset.resizeWired) return; preview.dataset.resizeWired = '1'; const storageKey = 'ttsvc_mag_ingest_page_pane_pct'; const apply = (value) => { const pct = Math.max(30, Math.min(70, Number(value) || 50)); preview.style.gridTemplateColumns = `${pct}% 8px calc(${100 - pct}% - 8px)`; return pct; }; try { const saved = localStorage.getItem(storageKey); if (saved != null) apply(saved); } catch (_) {} grip.addEventListener('pointerdown', (event) => { if (window.matchMedia('(max-width: 860px)').matches) return; event.preventDefault(); preview.classList.add('is-resizing'); const move = (e) => { const rect = preview.getBoundingClientRect(); apply(((e.clientX - rect.left) / rect.width) * 100); }; const stop = (e) => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); preview.classList.remove('is-resizing'); const rect = preview.getBoundingClientRect(); const pct = apply(((e.clientX - rect.left) / rect.width) * 100); try { localStorage.setItem(storageKey, String(pct)); } catch (_) {} }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', stop, { once: true }); }); } // ── Library ────────────────────────────────────────────────────────────────── async function magLoadLibrary() { const data = await api('/api/reader/docs'); state.docs = (data.docs || []).filter((d) => d.kind === 'magazine'); renderLibrary(); } function renderLibrary() { const host = $('mag-library-list'); if (!host) return; if (!state.docs.length) { host.innerHTML = '

No magazines yet. Add one to get started — ' + 'upload a PDF and the articles are detected for you.

'; return; } host.innerHTML = state.docs.map((d) => { const ing = d.ingest || {}; const badge = ing.status === 'done' ? '' + (ing.articles || 0) + ' articles' : ing.status === 'running' ? 'ingesting…' : ing.status === 'failed' ? 'failed' : 'not ingested'; const thumbnail = 'First page of ' + esc(d.title || 'magazine') +
      ''; return '
' + '
'; }).join(''); } async function magDelete(docId) { const doc = state.docs.find((item) => item.id === docId); const title = doc?.title || 'this magazine'; if (!window.confirm(`Delete “${title}”? This permanently removes its PDF, OCR, articles, and generated files.`)) return; try { await api('/api/reader/docs/' + encodeURIComponent(docId), { method: 'DELETE' }); if (state.doc?.id === docId) { magForgetLastDoc(); state.doc = null; state.article = null; } state.docs = state.docs.filter((item) => item.id !== docId); renderLibrary(); say(`Deleted “${title}”`, 'ok'); } catch (e) { say('Could not delete magazine: ' + e.message, 'err'); } } // ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ───────── const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.'; function magFormatFilenameAsTitle(filename) { if (!filename) return ''; const base = String(filename).replace(/\.[^.]+$/, ''); return base .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function magShowDroppedFile(file) { const noteEl = $('mag-dropzone-note'); const titleEl = $('mag-dropzone-title'); const iconEl = $('mag-dropzone-icon'); const titleInput = $('mag-new-title'); const dropzone = $('mag-dropzone'); if (file) { const sizeMb = (file.size / (1024 * 1024)).toFixed(1); if (dropzone) dropzone.classList.add('is-selected'); if (noteEl) noteEl.textContent = `Selected: ${file.name} (${sizeMb} MB)`; if (titleEl) titleEl.textContent = 'PDF file selected — uploading...'; if (iconEl) iconEl.className = 'mdi mdi-file-check-outline reader-dropzone-icon'; // Auto-fill title from filename if title input is empty if (titleInput && !titleInput.value.trim()) { titleInput.value = magFormatFilenameAsTitle(file.name); } } else { if (dropzone) dropzone.classList.remove('is-selected', 'is-uploading'); if (noteEl) noteEl.textContent = MAG_DROPZONE_DEFAULT_NOTE; if (titleEl) titleEl.textContent = 'Drop a PDF document'; if (iconEl) iconEl.className = 'mdi mdi-cloud-upload-outline reader-dropzone-icon'; } } function installGlobalMagDropGuard() { if (window._magDropGuardInstalled) return; window._magDropGuardInstalled = true; const guard = (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) { e.preventDefault(); if (e.type === 'dragover' && e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; } }; window.addEventListener('dragenter', guard, true); window.addEventListener('dragover', guard, true); window.addEventListener('drop', guard, true); } installGlobalMagDropGuard(); function setupMagDropzone() { installGlobalMagDropGuard(); const zone = $('mag-dropzone'); const input = $('mag-new-file'); if (!zone || !input) return; if (zone.dataset.magDropzoneWired) return; zone.dataset.magDropzoneWired = '1'; // Clicking anywhere in the dropzone triggers the hidden file picker zone.addEventListener('click', (e) => { if (!e.target.closest('input')) { input.click(); } }); let dragCounter = 0; zone.addEventListener('dragenter', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter++; if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; zone.classList.add('dragover'); }); zone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; zone.classList.add('dragover'); }); zone.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter = Math.max(0, dragCounter - 1); if (dragCounter === 0) { zone.classList.remove('dragover'); } }); zone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter = 0; zone.classList.remove('dragover'); let file = e.dataTransfer?.files?.[0]; if (!file && e.dataTransfer?.items?.[0]) { file = e.dataTransfer.items[0].getAsFile(); } if (!file) return; if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) { return say('Only PDF files are supported', 'err'); } magShowDroppedFile(file); magCreate(file); }); input.addEventListener('change', () => { const file = input.files?.[0]; if (file) { magShowDroppedFile(file); magCreate(file); } }); } async function magCreate(droppedFile = null) { const file = droppedFile || $('mag-new-file')?.files?.[0]; if (!file) return say('Choose or drop a PDF file first', 'err'); let title = ($('mag-new-title')?.value || '').trim(); if (!title) { title = magFormatFilenameAsTitle(file.name) || 'Untitled Magazine'; if ($('mag-new-title')) $('mag-new-title').value = title; } const issue = ($('mag-new-issue')?.value || '').trim(); const addBtn = $('mag-add-btn'); const browseBtn = $('mag-browse-btn'); const dropzone = $('mag-dropzone'); const titleInp = $('mag-new-title'); const issueInp = $('mag-new-issue'); const progress = $('mag-dropzone-progress'); const pickBtn = $('mag-dropzone-pick-btn'); const titleEl = $('mag-dropzone-title'); const noteEl = $('mag-dropzone-note'); const iconEl = $('mag-dropzone-icon'); const origBtnText = addBtn ? addBtn.innerHTML : ''; const setUploadingState = (uploading) => { const sizeMb = file ? (file.size / (1024 * 1024)).toFixed(1) : 0; if (uploading) { if (dropzone) { dropzone.classList.add('is-uploading'); dropzone.style.pointerEvents = 'none'; } if (progress) progress.hidden = false; if (pickBtn) pickBtn.hidden = true; if (browseBtn) browseBtn.hidden = true; if (iconEl) iconEl.className = 'mdi mdi-loading mdi-spin reader-dropzone-icon'; if (titleEl) titleEl.textContent = 'Uploading & Ingesting PDF...'; if (noteEl) noteEl.textContent = `Processing "${file.name}" (${sizeMb} MB). Transferring source file and extracting layout...`; if (addBtn) { addBtn.disabled = true; addBtn.innerHTML = ' Uploading PDF...'; } if (titleInp) titleInp.disabled = true; if (issueInp) issueInp.disabled = true; } else { if (dropzone) { dropzone.classList.remove('is-uploading'); dropzone.style.pointerEvents = 'auto'; } if (progress) progress.hidden = true; if (pickBtn) pickBtn.hidden = false; if (browseBtn) browseBtn.hidden = false; if (addBtn) { addBtn.disabled = false; addBtn.innerHTML = origBtnText; } if (titleInp) titleInp.disabled = false; if (issueInp) issueInp.disabled = false; } }; try { setUploadingState(true); say(`Uploading ${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)...`, 'ok'); const { id } = await api('/api/magazine/docs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, issue }), }); await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: file }); say('Magazine uploaded — starting ingestion', 'ok'); if ($('mag-new-title')) $('mag-new-title').value = ''; if ($('mag-new-issue')) $('mag-new-issue').value = ''; if ($('mag-new-file')) $('mag-new-file').value = ''; magShowDroppedFile(null); setUploadingState(false); await magOpen(id); await magIngest(); } catch (e) { setUploadingState(false); say('Could not add magazine: ' + e.message, 'err'); } } // ── 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'; const MAG_CONTENTS_VIEW_KEY = 'ttsvc_mag_contents_view'; 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 { await api('/api/magazine/' + state.doc.id + '/ingest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); pollIngest(); } catch (e) { say('Ingestion could not start: ' + e.message, 'err'); } } // "OCR again" for the whole issue: forces every page through the vision // engine regardless of its current quality, then re-detects articles -- // which is why this asks for confirmation. A single page's "OCR again" in // review does not touch article grouping; re-reading the whole document // does, since detection runs again over the freshly re-read text. async function magReocrMagazine() { if (!state.doc) return; const hasReviewed = state.articles.some((a) => a.status !== 'detected'); if (hasReviewed && !confirm( 'Re-reading the whole magazine runs article detection again. ' + 'Any articles you already approved or rejected will need reviewing again. Continue?' )) return; try { await api('/api/magazine/' + state.doc.id + '/ingest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force_vision_ocr: true }), }); pollIngest(); } catch (e) { say('Could not start OCR: ' + e.message, 'err'); } } // "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)) }; } // ── Live boxes on the page being read ───────────────────────────────────── // The regions of the page currently being read (and, in the labelling pass, being re-typed) are // drawn over the page image as they arrive, coloured by type and numbered in reading order. const MAG_PREVIEW_TYPES = { title: 'Headline', heading: 'Subheading', deck: 'Standfirst', byline: 'Author', body: 'Paragraph', caption: 'Caption', pullquote: 'Pull quote', sidebar: 'Sidenote', ad: 'Advertisement', furniture: 'Running head / footer', page_number: 'Page number', image: 'Picture', figure: 'Picture', chart: 'Chart', table: 'Table', code: 'Code', }; let _magPreviewSeen = { page: null, keys: new Set() }; // Fit the box layer to the part of the frame the page image actually covers ("contain" scaling // leaves bars beside or below it -- the page is top-aligned), so a box drawn at 50% sits at 50% of // the page, not of the frame. function magFitPreviewLayer() { const img = $('mag-ingest-preview-img'), layer = $('mag-ingest-preview-boxes'); if (!img || !layer || !img.naturalWidth || !img.clientWidth) return; const scale = Math.min(img.clientWidth / img.naturalWidth, img.clientHeight / img.naturalHeight); const w = img.naturalWidth * scale, h = img.naturalHeight * scale; layer.style.left = (img.offsetLeft + (img.clientWidth - w) / 2) + 'px'; layer.style.top = img.offsetTop + 'px'; // the page is drawn at the top of its frame (object-position: top) layer.style.width = w + 'px'; layer.style.height = h + 'px'; } function magRenderPreviewBoxes(page, boxes, stage) { const layer = $('mag-ingest-preview-boxes'), img = $('mag-ingest-preview-img'); if (!layer) return; const stageEl = $('mag-ingest-preview-stage'); if (stageEl) stageEl.textContent = page ? '· ' + page + (stage === 'labelling' ? ' — labelling regions' : ' — reading') : ''; // A page-sized embedded picture is the page scan itself, not a picture on the page (the server // drops it too; this also covers a preview written by an older server). boxes = (boxes || []).filter((b) => !(b.type === 'image' && b.w * b.h >= 8500)); if (_magPreviewSeen.page !== page) _magPreviewSeen = { page, keys: new Set() }; const key = (b) => [b.x, b.y, b.w, b.h].join(','); layer.innerHTML = boxes.map((b) => { const isNew = !_magPreviewSeen.keys.has(key(b)); const type = MAG_PREVIEW_TYPES[b.type] ? b.type : 'body'; return '
' + (b.n ? '' + b.n + '' : '') + '
'; }).join(''); boxes.forEach((b) => _magPreviewSeen.keys.add(key(b))); const legend = $('mag-ingest-preview-legend'); if (legend) { // One entry per label: a picture region and an embedded picture are both "Picture". const byLabel = new Map(); boxes.forEach((b) => { const t = MAG_PREVIEW_TYPES[b.type] ? b.type : 'body'; if (!byLabel.has(MAG_PREVIEW_TYPES[t])) byLabel.set(MAG_PREVIEW_TYPES[t], t); }); legend.innerHTML = [...byLabel].map(([label, t]) => '' + esc(label) + '').join('') + // The first pass only reads the text; pictures, charts and tables are recognised in the second. (stage === 'reading' ? 'Pictures, charts and tables are marked in the second pass' : '') + (boxes.length ? '' + boxes.length + ' regions' : ''); } if (img && !img.dataset.fitBound) { img.dataset.fitBound = '1'; img.addEventListener('load', magFitPreviewLayer); if (typeof ResizeObserver === 'function') new ResizeObserver(magFitPreviewLayer).observe(img); } magFitPreviewLayer(); } async function pollIngest() { const bar = $('mag-ingest-status'); const preview = $('mag-ingest-preview'); if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; } let lastPreviewPage = null; for (;;) { let data; try { data = await api('/api/magazine/' + state.doc.id + '/ingest/status'); } catch (_) { break; } const ing = data.ingest || {}; const detail = (data.job || {}).detail || ''; if (bar) { 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
'; } } // Live "source page / recognised text" preview, side by side -- same // idea as ocr-baidu-unlimited-ocr's own demo UI. Only swaps the image // when the page actually changed, so it doesn't flicker on every poll. if (preview) { if (ing.status === 'running' && data.preview && data.preview.page) { preview.hidden = false; if (data.preview.page !== lastPreviewPage) { lastPreviewPage = data.preview.page; const img = $('mag-ingest-preview-img'); if (img) img.src = '/api/magazine/' + state.doc.id + '/pages/' + data.preview.page; } magRenderPreviewBoxes(data.preview.page, data.preview.boxes || [], data.preview.stage); const textEl = $('mag-ingest-preview-text'); // Server-built HTML (see _render_ingest_preview_html in // routes/magazine.py) -- every text node in it is already escaped, // so this is the one place that inner-HTMLs a live server value. if (textEl) textEl.innerHTML = data.preview.text || ''; } else if (ing.status !== 'running') { preview.hidden = true; } } if (ing.status !== 'running') { if (ing.status === 'done') await magRefreshArticles(); break; } await new Promise((r) => setTimeout(r, 2000)); } } async function magRefreshArticles() { if (!state.doc) return; try { const data = await api('/api/magazine/' + state.doc.id + '/articles'); state.articles = data.articles || []; state.doc.progress = data.progress || {}; state.doc.podcast = data.podcast || { hosts: [] }; state.doc.default_voice = data.default_voice || ''; } catch (_) { state.articles = []; } renderContents(); } // ── Contents tree ──────────────────────────────────────────────────────────── function confidenceLabel(c) { // 0.55 is the backend's explicit heuristic marker: no reliable contents // entry was available, so an article was started from a page headline. It // is not a calibrated probability and a column of identical “55%” badges // falsely suggests a measured OCR score. if (Math.abs(Number(c) - 0.55) < 0.001) { return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' }; } if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' }; if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' }; return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' }; } function renderContents() { const host = $('mag-contents'); if (!host) return; const title = $('mag-doc-title'); if (title && state.doc) { title.textContent = (state.doc.title || 'Magazine') + (state.doc.issue ? ' — ' + state.doc.issue : ''); } if (!state.articles.length) { host.innerHTML = '

No articles yet. Run ingestion to detect them.

'; return; } const bySection = new Map(); state.articles.forEach((a) => { const key = a.section || 'Articles'; if (!bySection.has(key)) bySection.set(key, []); bySection.get(key).push(a); }); const grid = state.contentsView === 'grid'; const toggleBtn = $('mag-contents-view-toggle'); if (toggleBtn) { toggleBtn.innerHTML = grid ? ' List' : ' Thumbnails'; toggleBtn.setAttribute('aria-pressed', String(grid)); } const actionsHtml = (a, done, progress) => '' + (done ? '' : '') + (a.has_audio ? ' MP3' : ''); const selectHtml = (a) => !state.contentsSelectMode ? '' : ''; // Detection routinely mangles a title through OCR ("Sbimmb das? c'b // prüFb") -- fixing it should not require opening Review. contenteditable // rather than swapping in an keeps the card's own font/wrapping // instead of a generic text box. const titleHtml = (a, cls) => '' + esc(a.title || 'Untitled') + ''; let html = ''; bySection.forEach((arts, section) => { html += '

' + esc(section) + '

' + (grid ? '
' : '
    '); arts.forEach((a) => { const conf = confidenceLabel(a.confidence); const done = a.status === 'approved'; const progress = (state.doc.progress || {})[a.id]; const statusBadge = '' + (done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + ''; if (grid) { const firstPage = (a.pages || [])[0]; html += '
    ' + selectHtml(a) + (firstPage ? 'Page ' + firstPage + '' : '
    ') + titleHtml(a, 'mag-card-title') + (a.author ? 'by ' + esc(a.author) + '' : '') + '' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '' + '
    ' + esc(conf.badge) + '' + statusBadge + '
    ' + '
    ' + actionsHtml(a, done, progress) + '
    ' + '
    '; } else { html += '
  • ' + selectHtml(a) + '
    ' + titleHtml(a, 'mag-toc-title') + (a.author ? 'by ' + esc(a.author) + '' : '') + '
    ' + '
    ' + '' + fmtPages(a.pages) + '' + '' + fmtDuration(a.estimated_seconds) + '' + '' + esc(conf.badge) + '' + statusBadge + '
    ' + '
    ' + actionsHtml(a, done, progress) + '
  • '; } }); html += grid ? '
' : ''; }); host.innerHTML = html; renderContentsBulkBar(); magUpdateAudioZipLink(); } function magToggleContentsView() { state.contentsView = state.contentsView === 'grid' ? 'list' : 'grid'; try { localStorage.setItem(MAG_CONTENTS_VIEW_KEY, state.contentsView); } catch (_) {} renderContents(); } // ── Batch approve/reject from the article list ────────────────────────────── // Reviewing every single article one at a time does not scale to a 200-page // issue with 50+ detected pieces. This accepts each selected article exactly // as auto-detected (no block-list edit, unlike a normal Approve from inside // review) -- a deliberate trade-off: bulk action is for the pieces that // already look right, not a replacement for reviewing a messy one. function magContentsToggleSelect() { state.contentsSelectMode = !state.contentsSelectMode; if (!state.contentsSelectMode) state.contentsSelected.clear(); const btn = $('mag-contents-select-toggle'); if (btn) btn.setAttribute('aria-pressed', String(state.contentsSelectMode)); renderContents(); } async function magContentsCommitTitle(el, id) { const a = state.articles.find((x) => x.id === id); if (!a) return; const value = (el.textContent || '').replace(/\s+/g, ' ').trim(); if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; } const previous = a.title; a.title = value; try { await api('/api/magazine/' + state.doc.id + '/articles/' + id, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: value }), }); } catch (e) { a.title = previous; el.textContent = previous || 'Untitled'; say('Could not save the title: ' + e.message, 'err'); } } function magContentsToggleArticle(id) { if (state.contentsSelected.has(id)) state.contentsSelected.delete(id); else state.contentsSelected.add(id); renderContents(); } function magContentsSelectAllVisible() { state.articles.forEach((a) => state.contentsSelected.add(a.id)); renderContents(); } function magContentsClearSelection() { state.contentsSelected.clear(); renderContents(); } async function magContentsBulkStatus(status) { const ids = Array.from(state.contentsSelected); if (!ids.length) return; const bar = $('mag-contents-bulkbar'); let done = 0; for (const id of ids) { if (bar) bar.querySelector('.mag-bulkbar-progress')?.replaceChildren( document.createTextNode(`${status === 'approved' ? 'Approving' : 'Rejecting'} ${done + 1} of ${ids.length}…`)); try { await api('/api/magazine/' + state.doc.id + '/articles/' + id, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }), }); } catch (e) { say('Could not update ' + id + ': ' + e.message, 'err'); } done += 1; } state.contentsSelected.clear(); say(`${done} article${done === 1 ? '' : 's'} ${status}`, 'ok'); await magRefreshArticles(); renderContents(); } function renderContentsBulkBar() { const bar = $('mag-contents-bulkbar'); if (!bar) return; const n = state.contentsSelected.size; if (!state.contentsSelectMode) { bar.hidden = true; bar.innerHTML = ''; return; } bar.hidden = false; bar.innerHTML = '' + n + ' selected' + '' + '
' + '' + '' + '' + '' + '
'; } // ── Standalone MP3 export (title/author/cover/lyrics tags) ───────────────── // For listening away from this app entirely -- a phone's own music/podcast // player, a car head unit -- not the in-browser sentence-by-sentence player. async function magGenerateAllAudio() { const voice = ($('mag-audio-voice')?.value || '').trim(); if (!voice) return say('Pick a narrator voice for the export first', 'err'); // Skip whatever already has audio -- resuming after an aborted or // partial run (confirmed live: a magazine this size can genuinely take // a while, and killing the job partway through, deliberately or via a // container restart, is a real scenario, not an edge case) should not // mean re-synthesising articles that are already done. const missing = state.articles.filter((a) => a.status === 'approved' && !a.has_audio).map((a) => a.id); if (!missing.length) return say('Every approved article already has audio', ''); const btn = $('mag-audio-export-btn'); const status = $('mag-audio-export-status'); const progressWrap = $('mag-audio-export-progress-wrap'); const progressBar = $('mag-audio-export-progress'); const progressCount = $('mag-audio-export-count'); if (btn) btn.disabled = true; if (progressWrap) progressWrap.hidden = false; let jobId; try { const r = await api('/api/magazine/' + state.doc.id + '/audio/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice, article_ids: missing }), }); jobId = r.job_id; } catch (e) { if (btn) btn.disabled = false; if (progressWrap) progressWrap.hidden = true; return say('Could not start audio export: ' + e.message, 'err'); } for (;;) { let job; try { job = await api('/api/jobs/' + jobId); } catch (e) { if (btn) btn.disabled = false; return say('Could not check audio export: ' + e.message, 'err'); } if (status) status.textContent = job.detail || 'Working…'; // The job's own detail text always leads with "N/Total" -- either // "Article N/Total…" between articles, or "(N/Total) Title: sentence // X/Y" while one is actively rendering -- so one simple match covers // both without the backend needing a separate numeric progress field. const m = /(\d+)\/(\d+)/.exec(job.detail || ''); if (m && progressBar && progressCount) { progressBar.max = m[2]; progressBar.value = m[1]; progressCount.textContent = m[1] + ' / ' + m[2]; } if (job.done) { say(job.error ? 'Audio export failed: ' + job.error : (job.detail || 'Audio export done'), job.error ? 'err' : 'ok'); break; } await new Promise((r) => setTimeout(r, 1500)); } if (btn) btn.disabled = false; if (status) status.textContent = ''; if (progressWrap) progressWrap.hidden = true; await magRefreshArticles(); renderContents(); if (!$('mag-playlist')?.hidden) renderPlaylistTracks(); } function magUpdateAudioZipLink() { const link = $('mag-audio-zip-link'); if (!link) return; const any = state.articles.some((a) => a.has_audio); link.hidden = !any; if (any) link.href = '/api/magazine/' + state.doc.id + '/audio.zip'; } // ── Review (Stage D) ───────────────────────────────────────────────────────── async function magReview(articleId) { const article = state.articles.find((a) => a.id === articleId); if (!article) return; state.article = article; await ensureLayout(); // The whole review is one page at a time -- showing every page of a // 10-page feature stacked at once was unreadable and made the boxes tiny. state.reviewSpan = magReviewSpan(article); state.reviewPage = article.pages[0] || state.reviewSpan[0] || null; _magReviewChecked = null; magShow('review'); renderReview(); } async function ensureLayout() { if (state.layout) return; try { state.layout = await api('/api/magazine/' + state.doc.id + '/layout'); } catch (e) { say('Could not load layout: ' + e.message, 'err'); state.layout = { blocks: [], pages: [], page_count: 0 }; } } function blocksForPages(pages) { const want = new Set(pages || []); return (state.layout.blocks || []) .filter((b) => want.has(b.page)) .sort((a, b) => a.page - b.page || a.reading_order - b.reading_order); } // Offer neighbouring pages too, so a boundary that started a page late (or a // jump tail that should have been included) can be corrected without // leaving the screen. function magReviewSpan(article) { if (!article.pages.length) return []; return Array.from(new Set(article.pages.concat( [article.pages[0] - 1, article.pages[article.pages.length - 1] + 1]))) .filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y); } function magReviewGoPage(page) { if (!state.reviewSpan.includes(page)) return; state.reviewPage = page; renderReview(); } function magReviewGoMagazinePage(page) { const lastPage = Number(state.layout?.page_count || 0); if (!Number.isInteger(page) || page < 1 || page > lastPage) return; state.reviewPage = page; renderReview(); } // One glyph per region kind, shown centred in its box -- the fastest way to // tell "a paragraph of text" from "a photo" from "furniture that will not be // read" without reading the label. No table detection exists yet (P2), so a // table currently reads as its own text region like any other paragraph. // What a region can be changed into from the review list. Values match the server's REGION_TYPES. // Mirrors core/magazine.py's NARRATABLE -- the kinds that are actually read // aloud. A freshly drawn region of one of these is ticked in automatically // (see magReviewSubmitDraw); everything else (page number, source, ad, // table, duplicate, OCR noise, caption, pull quote, sidenote) is furniture // or reference material excluded from narration on purpose and stays unticked. const MAG_NARRATABLE_KINDS = new Set(['title', 'heading', 'deck', 'byline', 'body']); const MAG_REGION_TYPES = [ ['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], ['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'], ['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['table', 'Table'], ['image', 'Image'], ]; // A compact subset of MAG_REGION_TYPES for the review page's colour legend // -- the full 16-entry list would be noisy here, and several of those kinds // (source/in_picture/ocr_noise/duplicate) already render as plain grey // "furniture" on the page itself, so listing them separately would promise // a colour distinction the boxes don't actually make. const MAG_LEGEND_TYPES = [ ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['byline', 'Author'], ['body', 'Paragraph'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], ['ad', 'Advertisement'], ['furniture', 'Page number / footer'], ['image', 'Image'], ['chart', 'Chart'], ['table', 'Table'], ['code', 'Code'], ]; function magReviewLegendHtml() { return '

' + MAG_LEGEND_TYPES.map(([key, label]) => '' + label + '' ).join('') + '

'; } const MAG_REGION_TYPE_ICONS = { body: 'mdi-text-box-outline', title: 'mdi-format-title', heading: 'mdi-format-header-2', deck: 'mdi-text-short', byline: 'mdi-account-edit-outline', caption: 'mdi-image-text', pullquote: 'mdi-format-quote-close', sidebar: 'mdi-note-text-outline', page_number: 'mdi-numeric', source: 'mdi-book-open-page-variant-outline', ad: 'mdi-advertisements-outline', in_picture: 'mdi-text-recognition', ocr_noise: 'mdi-alert-circle-outline', duplicate: 'mdi-content-duplicate', table: 'mdi-table', image: 'mdi-image-outline', }; // Furniture kinds that the picker shows under their own name (everything else is a page number). const MAG_FURNITURE_KINDS = ['source', 'in_picture', 'ocr_noise', 'duplicate']; // The picker value for a region as it is now. function magRegionTypeValue(region) { if (region.kind === 'image') return 'image'; const b = region.data; if (b.type === 'furniture') return MAG_FURNITURE_KINDS.includes(b.region_label) ? b.region_label : 'page_number'; return MAG_REGION_TYPES.some(([v]) => v === b.type) ? b.type : 'body'; } // A type picker and a delete button for one row of the review list. Both stop the click from // reaching the row, which is a