// 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 | pages — how the article list itself is shown contentsSelectMode: false, // batch-select articles for bulk approve/reject contentsSelected: new Set(), pagesCols: 8, // "All pages" grid column count, user-adjustable 1-12 playlistCurrent: null, // article id of the MP3 playlist's current track playlistSentences: [], // transcript aligned approximately to the exported MP3 playlistImages: [], // figures/tables/charts shown alongside that transcript playlistLoadToken: 0, // prevents a slow text fetch replacing a newer track playlistLastSaved: 0, playlistHighlightIndex: -1, playlistSwitching: false, pane: 'reflow', // reflow | pdf playing: false, cancel: false, idx: 0, // current sentence audio: null, voices: [], podcast: null, spell: null, // last spelling findings shown (page or article scope) spellByBlock: new Map(), spellOpen: false, acronyms: null, // the open article's glossary state 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: 'Intro / Standfirst', byline: 'Author', body: 'Paragraph', quote: 'Quote', caption: 'Caption', pullquote: 'Pullquote', 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 || ''; magMarkLive(ing.status === 'running' ? data.job : null, true); 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) + '%' }; } // ── Page coverage ──────────────────────────────────────────────────────── // "Approve" only ever advanced to the next ARTICLE, so a page that never // got grouped into any article -- an ad, a missed piece, a page the // detection pass just skipped -- was never surfaced by that workflow at // all. Confirmed live: 73 of 204 pages (36%) in one real issue belonged to // no article whatsoever. A banner listing every such gap used to live here // too, but the "All pages" view's own per-card "no article" tag already // says the same thing in context, so it was just a second, redundant // warning -- removed. // The next page after `afterPage` that still needs a human decision -- no // article covers it at all, or one does but is still sitting at its // detected/unreviewed status. A page touched by several articles only // counts as resolved once none of them are still pending. function magNextUnresolvedPage(afterPage) { const pageCount = state.doc?.pageCount || 0; const byPage = new Map(); state.articles.forEach((a) => (a.pages || []).forEach((p) => { if (!byPage.has(p)) byPage.set(p, []); byPage.get(p).push(a); })); for (let p = afterPage + 1; p <= pageCount; p++) { const arts = byPage.get(p) || []; if (!arts.length || arts.some((a) => a.status === 'detected')) return p; } return null; } // Opens Review at a specific page even when no article covers it, by // anchoring on whichever approved-or-not article is nearest that page (the // review UI needs *an* article as context, but its own page navigation is // not restricted to that article's pages) and then jumping. async function magReviewPage(pageNumber) { // Reuse whatever already covers this page if something does (idempotent // if called twice for the same page); otherwise give the page its own // article record rather than borrowing an unrelated one as review // context -- confirmed live: Approve and "Not an article" both act on // whatever article is open, so borrowing one meant either action would // have silently changed a completely different, already-fine article. let article = state.articles.find((a) => (a.pages || []).includes(pageNumber)); if (!article) { try { const r = await api('/api/magazine/' + state.doc.id + '/articles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page: pageNumber, title: 'Page ' + pageNumber }), }); article = r.article; state.articles.push(article); } catch (e) { return say('Could not open page ' + pageNumber + ': ' + e.message, 'err'); } } await magReview(article.id); magReviewGoMagazinePage(pageNumber); } function renderContents() { const host = $('mag-contents'); if (!host) return; const title = $('mag-doc-title'); if (title && state.doc && document.activeElement !== title) title.textContent = state.doc.title || 'Magazine'; const issueInput = $('mag-doc-issue'); if (issueInput && state.doc && document.activeElement !== issueInput) issueInput.value = 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 toggleBtn = $('mag-contents-view-toggle'); if (toggleBtn) { const nextLabel = { list: 'Thumbnails', pages: 'List' }[state.contentsView]; const nextIcon = { list: 'mdi-view-grid-outline', pages: 'mdi-view-list-outline' }[state.contentsView]; toggleBtn.innerHTML = ' ' + nextLabel; toggleBtn.setAttribute('aria-pressed', String(state.contentsView !== 'list')); toggleBtn.title = 'Currently: ' + { list: 'article list', pages: 'page thumbnails' }[state.contentsView] + '. Click to switch to ' + nextLabel.toLowerCase() + '.'; } const colsControl = $('mag-cols-control'); if (colsControl) colsControl.hidden = state.contentsView !== 'pages'; if (state.contentsView === 'pages') { const range = $('mag-cols-range'), valueEl = $('mag-cols-value'); if (range) range.value = state.pagesCols; if (valueEl) valueEl.textContent = String(state.pagesCols); renderPagesView(host); renderContentsBulkBar(); magUpdateAudioZipLink(); return; } 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) + '

'; }); host.innerHTML = html; renderContentsBulkBar(); magUpdateAudioZipLink(); } const MAG_CONTENTS_VIEW_CYCLE = ['list', 'pages']; const MAG_PAGES_COLS_KEY = 'ttsvc_mag_pages_cols'; // Every page, 1..pageCount, regardless of whether an article claims it -- // the article grid/list above can only ever show what detection already // grouped, which is exactly what left 36% of one real issue invisible (see // magUncoveredPageRanges). This is the complete picture: a page can belong // to zero articles (never reviewed), one, or several (a page shared by two // short pieces), and this shows all three cases rather than picking one. // One of five states per page, cheap to compute from data already loaded // (no extra layout/OCR fetch just to colour an overview grid): // blank -- no article touches this page at all yet // detected -- an article does, but it's still sitting at its // just-detected status -- OCR ran, a human hasn't looked // approved -- reviewed and approved, no audio rendered yet // audio -- reviewed, approved, and has a generated MP3 // empty -- reviewed and rejected ("Not an article") on every article // that touches it -- a blank page or an ad, not a gap // A page touched by several articles takes the most-attention-needed state // among them (audio beats approved beats detected beats empty). // The last whole-issue quality run splits the two unreviewed states further: // checked -- quality run found nothing left to fix (blue) // flagged -- quality run found uncovered text / overlaps / clipping / suspect words (purple) // flagged wins over every review state except a fully rejected page; clean only replaces 'unreviewed'. // Mirrors page_is_advertisement() in core/magazine.py: flagged, or detection found advertisement // regions and nothing narratable on the page. const MAG_NARRATABLE_TYPES = ['title', 'heading', 'deck', 'byline', 'body', 'quote']; function magIsAdPage(page) { const layout = state.layout; if (!layout) return false; if ((layout.pages || []).find((m) => m.page === page)?.advertisement) return true; const onPage = (layout.blocks || []).filter((b) => b.page === page); return onPage.some((b) => b.type === 'ad') && !onPage.some((b) => MAG_NARRATABLE_TYPES.includes(b.type)); } function magPageState(arts, q, page) { // A page the quality run flagged stays purple whatever its review state (a reviewed or // audio page can still hide clipped or uncovered text) -- except one whose every article was // rejected, which is a settled "nothing to narrate". const flagged = !!(q && q.needs_review); if (page != null && magIsAdPage(page)) return 'ad'; // approved as advertisement: settled, green const rejectedOnly = arts.length > 0 && arts.every((a) => a.status === 'rejected'); if (flagged && !rejectedOnly) return 'flagged'; const unreviewed = q ? 'checked' : null; if (!arts.length) return unreviewed || 'blank'; if (arts.some((a) => a.has_audio)) return 'audio'; if (arts.some((a) => a.status === 'detected')) return unreviewed || 'detected'; if (arts.some((a) => a.status === 'approved')) return 'approved'; return 'empty'; } // Quality-run rows by page, loaded once per document (and refreshed after a run). async function magLoadQuality(force) { if (!state.doc) return; if (!force && state.qualityDocId === state.doc.id) return; const docId = state.doc.id; state.qualityDocId = docId; state.quality = new Map(); try { const report = await api('/api/magazine/' + docId + '/quality/report'); if (state.doc && state.doc.id === docId) { state.quality = new Map((report.pages || []).map((row) => [row.page, row])); } } catch (_) { /* no quality run yet */ } if (state.doc && state.doc.id === docId && state.contentsView === 'pages' && state.view !== 'review') renderContents(); } // Live marks while any batch step runs (ingest/OCR, quality run, figure fixing, audio export): // each server job reports {page | pages, step} in its result. With `sequential`, pages before // the working page get a tick. Applied straight to the existing cards -- no re-render per poll. // Pass nothing to clear the marks. function magMarkLive(job, sequential) { const r = job && job.result; const working = new Set(r && Array.isArray(r.pages) ? r.pages : (r && r.page != null ? [r.page] : [])); const first = working.size ? Math.min(...working) : null; const step = r && r.step; document.querySelectorAll('.mag-pages-grid .mag-card-wrap').forEach((wrap) => { const n = Number(wrap.dataset.page); const card = wrap.querySelector('.mag-card'); const isWorking = working.has(n); const isDone = !!sequential && first != null && n < first; card.classList.toggle('is-page-working', isWorking); card.classList.toggle('is-page-live-done', isDone); let tag = card.querySelector('.mag-live-tag'); if (isWorking || isDone) { if (!tag) { tag = document.createElement('span'); tag.className = 'mag-live-tag'; card.prepend(tag); } tag.textContent = isWorking ? (step || 'working…') : '✓ done'; } else if (tag) tag.remove(); }); } function magQualityProblems(q) { const bits = []; if (q.uncovered.length) bits.push(q.uncovered.length + ' text area' + (q.uncovered.length === 1 ? '' : 's') + ' outside any box'); if (q.overlaps.length) bits.push(q.overlaps.length + ' overlapping box pair' + (q.overlaps.length === 1 ? '' : 's')); if (q.clipped.length) bits.push(q.clipped.length + ' box edge' + (q.clipped.length === 1 ? '' : 's') + ' cutting into text'); if (q.suspect_blocks.length) bits.push(q.suspect_blocks.length + ' region' + (q.suspect_blocks.length === 1 ? '' : 's') + ' with suspect OCR words'); if ((q.duplicates || []).length) bits.push((q.duplicates.length) + ' articles claim this page (read more than once)'); if (q.error) bits.push('the check itself failed'); return bits; } // Overlay on the thumbnail itself: a warning mark with the problem count, hover/focus text // listing what was found, and a click that opens that page in review. function magQualityBadge(q, page) { if (!q || !q.needs_review) return ''; const bits = magQualityProblems(q); const text = 'Page ' + page + ': ' + bits.join('; '); return ''; } function renderPagesView(host) { if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); }); if (state.qualityDocId !== state.doc?.id) magLoadQuality(false); const quality = state.quality || new Map(); const pageCount = state.doc?.pageCount || 0; if (!pageCount) { host.innerHTML = '

Page count unknown yet.

'; return; } const byPage = new Map(); state.articles.forEach((a) => (a.pages || []).forEach((p) => { if (!byPage.has(p)) byPage.set(p, []); byPage.get(p).push(a); })); const legend = [ ['blank', 'not reviewed yet'], ['detected', 'has content, not quality-checked'], ['checked', 'quality check clean'], ['flagged', 'quality check found problems'], ['approved', 'reviewed'], ['ad', 'advertisement (detected, not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'], ]; let html = '

' + legend.map(([cls, label]) => '' + label + '').join('') + '

'; html += '
'; for (let p = 1; p <= pageCount; p++) { const arts = byPage.get(p) || []; html += '
' + 'Page ' + p + '' + 'Page ' + p + '' + magQualityBadge(quality.get(p), p) + (arts.length ? '
' + arts.map((a) => '
' + '' + esc(a.title || 'Untitled') + '' + '' + '' + (a.has_audio ? '' : '') + '' + '
').join('') + '
' : 'no article' + '
') + '
'; } html += '
'; host.innerHTML = html; } function magSetPagesCols(n) { state.pagesCols = Math.max(1, Math.min(12, parseInt(n, 10) || 8)); try { localStorage.setItem(MAG_PAGES_COLS_KEY, String(state.pagesCols)); } catch (_) {} const valueEl = $('mag-cols-value'); if (valueEl) valueEl.textContent = String(state.pagesCols); const grid = document.querySelector('.mag-pages-grid'); if (grid) grid.style.setProperty('--cols', state.pagesCols); } // Editing the magazine's own title/issue was previously only possible at // upload time -- "Issue" is optional there, so the common case left it // permanently blank with no way to add it later. It matters beyond just // display now: the exported MP3 filename's year/month come from parsing // this field (see _parse_issue_year_month in routes/magazine.py). async function magCommitDocField(el, field) { const value = (field === 'title' ? el.textContent : el.value).replace(/\s+/g, ' ').trim(); const previous = state.doc[field]; if (value === (previous || '')) return; if (field === 'title' && !value) { el.textContent = previous || 'Magazine'; return; } state.doc[field] = value; try { await api('/api/magazine/' + state.doc.id, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: value }), }); say(field === 'title' ? 'Title updated' : 'Issue updated', 'ok'); } catch (e) { state.doc[field] = previous; if (field === 'title') el.textContent = previous || 'Magazine'; else el.value = previous || ''; say('Could not save ' + field + ': ' + e.message, 'err'); } } function magToggleContentsView() { const i = MAG_CONTENTS_VIEW_CYCLE.indexOf(state.contentsView); state.contentsView = MAG_CONTENTS_VIEW_CYCLE[(i + 1) % MAG_CONTENTS_VIEW_CYCLE.length]; 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(force = false) { const voice = ($('mag-audio-voice')?.value || '').trim(); const interviewVoice = ($('mag-audio-interview-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 missingArticles = state.articles.filter((a) => a.status === 'approved' && (force || !a.has_audio)); if (!missingArticles.length) return say('Every approved article already has audio', ''); const missing = missingArticles.map((a) => a.id); const pageCount = state.doc.pageCount || 0; const btn = $('mag-audio-export-btn'); const rebuildBtn = $('mag-audio-rebuild-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 (rebuildBtn) rebuildBtn.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, interview_voice: interviewVoice, article_ids: missing }), }); jobId = r.job_id; } catch (e) { if (btn) btn.disabled = false; if (rebuildBtn) rebuildBtn.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; if (rebuildBtn) rebuildBtn.disabled = false; return say('Could not check audio export: ' + e.message, 'err'); } if (status) status.textContent = job.detail || 'Working…'; magMarkLive(job.done ? null : job, false); // 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. "Article 6 of 44" means little // on its own; "page 20 of 204" is what actually says how far through // the magazine this run has gotten, so N is mapped back to that // article's own starting page wherever possible. const m = /(\d+)\/(\d+)/.exec(job.detail || ''); if (m && progressBar && progressCount) { const page = missingArticles[parseInt(m[1], 10) - 1]?.pages?.[0]; if (page && pageCount) { progressBar.max = pageCount; progressBar.value = page; progressCount.textContent = 'p. ' + page + ' / ' + pageCount; } else { 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 (rebuildBtn) rebuildBtn.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 magReviewAtPage(articleId, page) { await magReview(articleId); if (state.article?.id !== articleId) return; magReviewGoMagazinePage(Number(page)); } 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(); } async function magReviewGoMagazinePage(page) { const lastPage = Number(state.layout?.page_count || 0); if (!Number.isInteger(page) || page < 1 || page > lastPage) return; const currentCoversPage = (state.article?.pages || []).includes(page); if (!currentCoversPage) { const owner = state.articles.find((article) => (article.pages || []).includes(page)); if (owner) return magReviewAtPage(owner.id, page); return magReviewPage(page); } 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', 'quote']); const MAG_REGION_TYPES = [ ['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'], ['byline', 'Author'], ['image', 'Image'], ['caption', 'Caption'], ['in_picture', 'Text in Picture'], ['table', 'Table'], ['quote', 'Quote'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'], ['ad', 'Advertisement'], ['page_number', 'Page Number'], ['source', 'Footer'], ['duplicate', 'Duplicate'], ['ocr_noise', 'OCR Noise'], ]; // 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', 'Intro / Standfirst'], ['byline', 'Author'], ['body', 'Paragraph'], ['quote', 'Quote'], ['caption', 'Caption'], ['pullquote', 'Pullquote'], ['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', quote: 'mdi-format-quote-open', 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