// 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, audit: null, // live audit of the reviewed page ({page, data}) flow: null, // how the open article's text continues between regions flowOn: true, // show the flow arrows/chips in review 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 ' + ' ' + '' + ''; if (total) { html += ''; } if (audit.advertisement) html += '

This page is an advertisement: nothing on it is read aloud.

'; panel.innerHTML = html; } async function magAutofixPage(mode) { const page = state.reviewPage; if (page == null) return; const btn = $('mag-autofix-btn'); const status = () => $('mag-issues-status'); if (btn) btn.disabled = true; if (status()) status().textContent = 'Starting…'; let jobId; try { jobId = (await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/autofix', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }), })).job_id; } catch (e) { if (btn) btn.disabled = false; return say('Could not start the fix: ' + e.message, 'err'); } let job; for (;;) { try { job = await api('/api/jobs/' + jobId); } catch (_) { job = null; break; } if (job.done) break; if (status() && job.detail) status().textContent = job.detail; await new Promise((r) => setTimeout(r, 1500)); } if (btn) btn.disabled = false; if (!job || job.error) return say('Fixing the page failed: ' + (job && job.error || 'lost the job'), 'err'); say(job.detail || 'Page fixed', 'ok'); if (job.result && job.result.undo) state.undo = { token: job.result.undo, label: 'Auto-fixed page ' + page }; state.layout = null; await ensureLayout(); const articleId = state.article.id; await magRefreshArticles(); state.article = state.articles.find((a) => a.id === articleId) || state.article; _magReviewChecked = null; renderReview(); } async function magLinkPage(where, pageOverride) { const input = $('mag-link-page'); const page = Number(pageOverride || (input && input.value)); if (!page) return say('Type the page number to link first', 'err'); try { const r = await api('/api/magazine/' + state.doc.id + '/articles/' + state.article.id + '/link-page', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page, where }), }); say('Linked ' + r.added.length + ' region' + (r.added.length === 1 ? '' : 's') + ' from page ' + page + ' — review them on that page', 'ok'); state.undo = { token: r.undo, label: 'Linked page ' + page }; } catch (e) { return say(e.message, 'err'); } const articleId = state.article.id; await magRefreshArticles(); state.article = state.articles.find((a) => a.id === articleId) || state.article; _magReviewChecked = null; state.reviewPage = page; state.layout = null; await ensureLayout(); renderReview(); } async function magSpellOpenAndFix() { if (!state.spellOpen) magSpellToggle(); await magSpellAgent(); } // ── Spelling and pronunciation checks ──────────────────────────────────────── // Spelling: dictionary-unknown words are MARKED (review list) and listed with // suggestions; nothing is rewritten unless the reviewer picks a suggestion or the // language-model agent's answer passes the server's guards. Pronunciation: the // per-article acronym glossary that decides how the narrator says "NAS" or "ML". let _magSpellSeq = 0; function magSpellMarked(blockId, text) { const issues = (state.spellByBlock && state.spellByBlock.get(blockId)) || []; let out = ''; let pos = 0; issues.filter((i) => i.end <= text.length).sort((x, y) => x.start - y.start).forEach((i) => { if (i.start < pos) return; const hint = 'Possible misspelling' + (i.suggestions.length ? ' — try: ' + i.suggestions.join(', ') : ''); out += esc(text.slice(pos, i.start)) + '' + esc(text.slice(i.start, i.end)) + ''; pos = i.end; }); return out + esc(text.slice(pos)); } function magSpellPanelHtml(issues, scopeLabel) { const agent = '' + ''; if (!issues.length) { return '

No unknown words on ' + esc(scopeLabel) + '. ' + agent + '

'; } return '

' + issues.length + ' word' + (issues.length === 1 ? '' : 's') + ' on ' + esc(scopeLabel) + ' that no German or English dictionary knows. Names and technical terms are normal — choose Keep for those. ' + agent + '

' + ''; } async function magSpellLoadPage() { const page = state.reviewPage; if (page == null || !state.doc) return; const seq = ++_magSpellSeq; let data; try { data = await api('/api/magazine/' + state.doc.id + '/spelling?page=' + page); } catch (_) { return; } if (seq !== _magSpellSeq || state.view !== 'review') return; state.spell = { issues: data.issues || [], scope: 'page ' + page, page }; state.spellByBlock = new Map(); state.spell.issues.forEach((i) => { if (!state.spellByBlock.has(i.block_id)) state.spellByBlock.set(i.block_id, []); state.spellByBlock.get(i.block_id).push(i); }); const blocks = new Map((state.layout?.blocks || []).map((b) => [b.id, b])); document.querySelectorAll('#mag-review [data-text-of]').forEach((el) => { const b = blocks.get(el.dataset.textOf); if (b) el.innerHTML = magSpellMarked(b.id, b.text); }); const count = $('mag-spell-count'); if (count) count.textContent = state.spell.issues.length ? String(state.spell.issues.length) : ''; const panel = $('mag-spell-panel'); if (panel) panel.innerHTML = magSpellPanelHtml(state.spell.issues, 'this page'); } function magSpellToggle() { state.spellOpen = !state.spellOpen; const panel = $('mag-spell-panel'); if (panel) panel.hidden = !state.spellOpen; $('mag-spell-btn')?.setAttribute('aria-expanded', String(!!state.spellOpen)); } async function magSpellAfterChange() { state.layout = null; await ensureLayout(); if (state.view === 'review') return renderReview(); await magReloadArticleText(); return magChecksLoad(); } async function magSpellFix(n, k) { const issue = state.spell?.issues[n]; if (!issue || !issue.suggestions[k]) return; try { const r = await api('/api/magazine/' + state.doc.id + '/spelling/fix', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ block_id: issue.block_id, start: issue.start, end: issue.end, token: issue.raw || issue.token, replacement: issue.suggestions[k] }), }); if (state.view === 'review') magOfferUndo(r.undo, 'Corrected ' + issue.token); say('Corrected ' + issue.token + ' → ' + issue.suggestions[k], 'ok'); } catch (e) { return say('Could not correct: ' + e.message, 'err'); } await magSpellAfterChange(); } async function magSpellKeep(n) { const issue = state.spell?.issues[n]; if (!issue) return; try { await api('/api/magazine/' + state.doc.id + '/spelling/dictionary', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ word: issue.token }), }); } catch (e) { return say('Could not add to the dictionary: ' + e.message, 'err'); } say(issue.token + ' added to this issue\'s dictionary', 'ok'); if (state.view === 'review') return magSpellLoadPage(); return magChecksLoad(); } async function magSpellAgent() { const inRead = state.view === 'read'; const scope = inRead ? { article: state.article?.id } : { page: state.reviewPage }; const btn = $('mag-spell-agent-btn'); const status = () => $('mag-spell-status'); if (btn) btn.disabled = true; let jobId; try { jobId = (await api('/api/magazine/' + state.doc.id + '/spelling/agent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scope), })).job_id; } catch (e) { if (btn) btn.disabled = false; return say('Could not start the spelling check: ' + e.message, 'err'); } let failed = false; for (;;) { let job; try { job = await api('/api/jobs/' + jobId); } catch (_) { break; } if (job.done) { if (job.error) { failed = true; say('Spelling check failed: ' + job.error, 'err'); } else say(job.detail || 'Spelling checked', 'ok'); break; } if (status() && job.detail) status().textContent = job.detail; await new Promise((r) => setTimeout(r, 1500)); } if (btn) btn.disabled = false; if (!failed) await magSpellAfterChange(); } // Reading view: the article's own acronym glossary and spelling findings. async function magReloadArticleText() { const a = state.article; if (!a) return; const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' + ($('mag-read-captions')?.checked ? 1 : 0) + '&descriptions=' + ($('mag-read-descriptions')?.checked ? 1 : 0)); state.sentences = splitSentences(data.blocks || []); state.idx = Math.min(state.idx, Math.max(0, state.sentences.length - 1)); // Anything already synthesised was spoken from the old text. state.audioCache = new Map(); state.prepGen += 1; state.preparing = false; syncPrepButton(); renderArticle(); } async function magChecksLoad() { const body = $('mag-checks-body'); const a = state.article; if (!body || !a || !$('mag-checks')?.open) return; let acr, spell; try { [acr, spell] = await Promise.all([ api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/acronyms'), api('/api/magazine/' + state.doc.id + '/spelling?article=' + encodeURIComponent(a.id)), ]); } catch (e) { body.innerHTML = '

Could not load the checks: ' + esc(e.message) + '

'; return; } state.acronyms = acr; state.spell = { issues: spell.issues || [], scope: 'this article' }; const row = (acronym, spoken, context) => '' + esc(acronym) + '' + '' + '' + esc(context || '') + ''; const rows = Object.entries(acr.glossary || {}).map(([k, v]) => row(k, v, 'in this article\'s glossary')).join('') + (acr.unknown || []).map((u) => row(u.acronym, '', u.context)).join(''); body.innerHTML = '

Acronyms (' + (acr.language === 'de' ? 'German' : 'English') + ' article)

' + '

Common acronyms are spoken from a built-in list in the article’s language. Others are listed here with the sentence they appear in; ' + 'type how the narrator should say one (empty = as printed), or let the language model decide from the context.

' + '' + (rows ? '' + rows + '
AcronymSpoken asContext
' : '

No unfamiliar acronyms in this article.

') + '

Spelling

' + magSpellPanelHtml(state.spell.issues, 'this article'); } function magChecksToggled(el) { if (el.open) magChecksLoad(); } async function magAcrSave() { const glossary = {}; document.querySelectorAll('#mag-checks-body [data-acr]').forEach((input) => { if (input.value.trim()) glossary[input.dataset.acr] = input.value.trim(); }); try { await api('/api/magazine/' + state.doc.id + '/articles/' + state.article.id + '/acronyms', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ glossary }), }); state.article.glossary = glossary; await magReloadArticleText(); } catch (e) { return say('Could not save the glossary: ' + e.message, 'err'); } magChecksLoad(); } async function magAcrResolve() { const btn = $('mag-acr-resolve-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Asking the language model…'; } try { const r = await api('/api/magazine/' + state.doc.id + '/articles/' + state.article.id + '/acronyms/resolve', { method: 'POST' }); state.article.glossary = r.glossary; say(r.resolved ? 'Decided ' + r.resolved + ' acronym' + (r.resolved === 1 ? '' : 's') + ' from context' : 'Nothing could be decided', r.resolved ? 'ok' : ''); await magReloadArticleText(); } catch (e) { say('Could not look up the acronyms: ' + e.message, 'err'); } magChecksLoad(); } // ── Reading: sentences, both views ─────────────────────────────────────────── // Sentence splitting keeps each piece tied to its source block, so highlighting // in the reflowed view can drive the page shown in the PDF view. function splitSentences(blocks) { const out = []; blocks.forEach((b) => { const parts = String(b.text || '') .split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/) .map((s) => s.trim()).filter(Boolean); const spokenParts = String(b.spoken_text || b.text || '') .split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/) .map((s) => s.trim()).filter(Boolean); // A paragraph fused across a column/page break carries `parts`: the source // regions with their offsets in the fused text. Each sentence takes the // region it starts in, so highlighting and page jumps still follow the print. let cursor = 0; (parts.length ? parts : [b.text]).forEach((text, partIndex) => { if (text && text.trim()) { let origin = b; if (Array.isArray(b.parts) && b.parts.length > 1) { const at = String(b.text || '').indexOf(text.trim(), cursor); if (at >= 0) cursor = at + text.trim().length; const src = b.parts.find((p) => at >= p.start && at < p.end) || b.parts[0]; origin = { id: src.id, page: src.page, bbox: src.bbox }; } // Where a fused paragraph crosses from one region to the next, mark the word after which // the print continues (below / in another column / on another page). let seams = null; if (Array.isArray(b.parts) && b.parts.length > 1) { const start = String(b.text || '').indexOf(text.trim(), Math.max(0, cursor - text.trim().length)); b.parts.slice(1).forEach((part, k) => { const prev = b.parts[k]; if (start >= 0 && part.start > start && part.start < start + text.trim().length) { const before = text.trim().slice(0, part.start - start).split(/\s+/).filter(Boolean).length; const relation = prev.page !== part.page ? 'page' : (Math.abs((prev.bbox[0] + prev.bbox[2] / 2) - (part.bbox[0] + part.bbox[2] / 2)) <= 0.5 * Math.min(prev.bbox[2], part.bbox[2]) && part.bbox[1] >= prev.bbox[1]) ? 'below' : 'column'; (seams = seams || []).push({ afterWord: before - 1, relation, page: part.page }); } }); } out.push({ seams, text: text.trim(), blockId: origin.id, page: origin.page, bbox: origin.bbox, type: b.type, spokenText: spokenParts[partIndex] || text.trim(), // Used to pick "the image reached so far" during playback (see // currentImageIndex) -- the backend already computes this per // block relative to the whole page, not just this article. readingOrder: b.reading_order || 0, words: text.trim().split(/\s+/).filter(Boolean), }); } }); }); return out; } // The current article's figures, in reading order, resolved from the full // layout (fetched once) filtered to this article's own image ids. Kept // separate from state.sentences because an image has no sentence of its // own -- it is matched to "the most recent sentence at or before it". function articleImages(article) { const ids = new Set(article.images || []); return (state.layout.images || []) .filter((img) => ids.has(img.id)) .sort((a, b) => (a.reading_order || 0) - (b.reading_order || 0)); } // The image to show once sentence `i` has been reached: the last image // whose position in the page's reading order is at or before that // sentence's own block. Falls back to the first image before any text has // caught up to it, so a lead photo is not left blank at the very start. function currentImageIndex(i) { if (!state.images.length) return -1; const order = (state.sentences[i] || {}).readingOrder || 0; let found = -1; for (let k = 0; k < state.images.length; k++) { if ((state.images[k].reading_order || 0) <= order) found = k; else break; } return found === -1 ? 0 : found; } function renderCurrentImage(i) { const host = $('mag-read-image'); if (!host) return; const idx = currentImageIndex(i); if (idx < 0) { host.hidden = true; return; } const img = state.images[idx]; if (state._shownImageId === img.id) { host.hidden = false; return; } state._shownImageId = img.id; host.hidden = false; host.innerHTML = 'Figure from page ' + img.page + ''; } async function magRead(articleId, opts) { const a = state.articles.find((x) => x.id === articleId); if (!a) return; // A generated MP3 already exists: play that directly through the // playlist player instead of picking a narrator and synthesising sentence // by sentence again -- confirmed live, "Listen" on an article that had // already been exported still went through the live reader from scratch. if (a.has_audio) { magTogglePlaylist(true); magPlaylistPlay(articleId, opts); return; } const playlistAudio = $('mag-playlist-audio'); if (playlistAudio) playlistAudio.pause(); state.playlistCurrent = null; state.article = a; state.audioCache = new Map(); state.prepGen += 1; state.preparing = false; state.prepCancel = false; syncPrepButton(); await ensureLayout(); try { const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' + ($('mag-read-captions')?.checked ? 1 : 0) + '&descriptions=' + ($('mag-read-descriptions')?.checked ? 1 : 0)); state.sentences = splitSentences(data.blocks || []); } catch (e) { return say('Could not load article text: ' + e.message, 'err'); } state.images = articleImages(a); state._shownImageId = null; const forceStart = opts && opts.fromStart; const saved = !forceStart && (state.doc.progress || {})[a.id]; state.idx = saved && saved.sentence < state.sentences.length ? saved.sentence : 0; magShow('read'); renderArticle(); loadPodcastIfAny(); } function renderArticle() { const host = $('mag-article'); const a = state.article; if (!host || !a) return; $('mag-read-title').textContent = a.title || 'Untitled'; $('mag-read-by').textContent = a.author ? 'By ' + a.author : ''; $('mag-read-pages').textContent = fmtPages(a.pages); host.innerHTML = state.sentences.map((s, i) => { const tag = s.type === 'title' ? 'h2' : s.type === 'deck' ? 'p class="mag-deck"' : s.type === 'byline' ? 'p class="mag-byline"' : s.type === 'caption' ? 'p class="mag-caption"' : s.type === 'figure_description' ? 'p class="mag-figure-description"' : 'span'; const open = tag.split(' ')[0]; // Each word gets its own span so playback can highlight it individually // (see estimateWordTimings) -- sentence-level highlighting alone doesn't // need this, but word-level does and this markup serves both. const wordsHtml = s.words.map((w, j) => { const seam = (s.seams || []).find((m) => m.afterWord === j); const marker = seam ? '' + (seam.relation === 'page' ? 'p.' + Number(seam.page) + ' ›' : seam.relation === 'column' ? 'col ›' : '↓') + '' : ''; return '' + esc(w) + '' + marker; }).join(' '); return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' + 'onclick="magSeek(' + i + ')">' + wordsHtml + ' '; }).join(''); highlight(state.idx); } function highlight(i) { document.querySelectorAll('#mag-article [data-i]').forEach((el) => { el.classList.toggle('is-current', Number(el.dataset.i) === i); }); clearWordHighlight(); const el = document.querySelector('#mag-article [data-i="' + i + '"]'); if (el && !prefersReducedMotion()) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); else if (el) el.scrollIntoView({ block: 'center' }); const s = state.sentences[i]; if (s && state.pane === 'pdf') showPage(s.page, s.bbox); renderCurrentImage(i); const counter = $('mag-read-progress'); if (counter) counter.textContent = (i + 1) + ' / ' + state.sentences.length; const qText = state.queue.length ? 'Article ' + (state.queueIndex + 1) + ' / ' + state.queue.length : ''; ['mag-queue-progress', 'mag-queue-progress-2'].forEach((id) => { const el = $(id); if (el) el.textContent = qText; }); } function clearWordHighlight() { document.querySelectorAll('#mag-article .mag-w.is-current').forEach((el) => el.classList.remove('is-current')); } // ── Word-level highlighting (approximate) ──────────────────────────────── // No forced-alignment or TTS timestamp source exists in this pipeline, so // per-word timing is ESTIMATED, not measured: each word's share of the // sentence's real (measured) audio duration is proportional to its own // character length, with a small fixed share added per word so short words // are not given an unrealistically instant duration. This is a common // karaoke approximation, not true alignment -- it can drift on sentences // with heavy punctuation-driven pauses. function estimateWordTimings(words, totalSeconds) { if (!words.length || !totalSeconds) return []; const FIXED = 0.6; // arbitrary units, shifts weight toward evening out short words const weights = words.map((w) => Math.max(1, w.replace(/[.,!?;:"'()«»„"]/g, '').length) + FIXED); const total = weights.reduce((a, b) => a + b, 0); let t = 0; return weights.map((w) => { const dur = totalSeconds * (w / total); const start = t; t += dur; return { start, end: t }; }); } function highlightWord(sentenceIdx, wordIdx) { const sentenceEl = document.querySelector('#mag-article [data-i="' + sentenceIdx + '"]'); if (!sentenceEl) return; sentenceEl.querySelectorAll('.mag-w').forEach((el) => { el.classList.toggle('is-current', Number(el.dataset.w) === wordIdx); }); } function prefersReducedMotion() { try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; } } function magSeek(i) { state.idx = Math.max(0, Math.min(state.sentences.length - 1, i)); highlight(state.idx); saveProgress(); } function magSetPane(pane) { state.pane = pane; const reflow = $('mag-pane-reflow'), pdf = $('mag-pane-pdf'); if (reflow) reflow.hidden = pane !== 'reflow'; if (pdf) pdf.hidden = pane !== 'pdf'; document.querySelectorAll('[data-mag-pane]').forEach((b) => { const on = b.dataset.magPane === pane; b.classList.toggle('active', on); b.setAttribute('aria-pressed', on ? 'true' : 'false'); }); if (pane === 'pdf') { const s = state.sentences[state.idx]; if (s) showPage(s.page, s.bbox); } } function showPage(page, bbox) { const img = $('mag-page-img'); const box = $('mag-page-box'); if (!img) return; const src = '/api/magazine/' + state.doc.id + '/pages/' + page; if (img.getAttribute('src') !== src) img.setAttribute('src', src); img.setAttribute('alt', 'Page ' + page + ' of ' + (state.doc.title || 'the magazine')); const label = $('mag-page-label'); if (label) label.textContent = 'Page ' + page; if (!box || !bbox) return; // The layout stores PDF user-space units; the rendered PNG is a different // size, so scale by the page dimensions rather than assuming they match. const meta = (state.layout.pages || []).find((p) => p.page === page); if (!meta) { box.hidden = true; return; } box.hidden = false; box.style.left = (bbox[0] / meta.width * 100) + '%'; box.style.top = (bbox[1] / meta.height * 100) + '%'; box.style.width = (bbox[2] / meta.width * 100) + '%'; box.style.height = (bbox[3] / meta.height * 100) + '%'; } function magPeekPage(page) { magShow('read'); magSetPane('pdf'); showPage(page, null); } // ── Audio preparation ──────────────────────────────────────────────────────── // Synthesised sentences are cached as promises keyed by voice + text, so a // prefetch and playback asking for the same sentence share one request, and a // changed voice or edited text can never be served stale audio. Held in memory // only: closing the tab discards it. const MAG_PREP_CONCURRENCY = 2; const MAG_LOOKAHEAD = 3; function magNarratorVoice() { return ($('mag-voice')?.value || state.doc.default_voice || '').trim(); } function magSynth(voice, text) { const key = voice + '' + text; let p = state.audioCache.get(key); if (!p) { p = fetchTtsPreviewBlob(voice, text, 'mp3'); const cache = state.audioCache; cache.set(key, p); // A failed request must not be cached, or a retry would replay the failure. p.catch(() => { if (cache.get(key) === p) cache.delete(key); }); } return p; } function syncPrepButton(done, total) { const btn = $('mag-prepare'); if (btn) { btn.innerHTML = state.preparing ? ' Cancel' : ' Prepare audio'; } const status = $('mag-prep-status'); if (status) status.textContent = state.preparing && total ? 'Preparing audio ' + done + ' / ' + total : ''; } async function magPrepareAudio() { if (state.preparing) { state.prepCancel = true; return; } const voice = magNarratorVoice(); if (!voice) return say('Pick a narrator voice first', 'err'); const total = state.sentences.length; if (!total) return; const gen = state.prepGen; state.preparing = true; state.prepCancel = false; syncPrepButton(0, total); // Start at the current position so Listen is ready soonest, then wrap round // to the part before it. const order = Array.from({ length: total }, (_, k) => (state.idx + k) % total); let next = 0, done = 0, failure = null; const worker = async () => { while (!failure && !state.prepCancel && gen === state.prepGen && next < order.length) { const i = order[next++]; try { await magSynth(voice, state.sentences[i].spokenText || state.sentences[i].text); } catch (e) { failure = { i, message: e.message }; return; } done += 1; if (gen === state.prepGen) syncPrepButton(done, total); } }; await Promise.all(Array.from({ length: Math.min(MAG_PREP_CONCURRENCY, total) }, worker)); // An article change while this ran already reset the flags -- touching them // now would clobber a newer run. if (gen !== state.prepGen) return; const cancelled = state.prepCancel; state.preparing = false; state.prepCancel = false; syncPrepButton(); if (failure) say('Synthesis failed at sentence ' + (failure.i + 1) + ': ' + failure.message, 'err'); else if (cancelled) say('Audio preparation cancelled', ''); else say('Audio ready — ' + total + ' sentences', 'ok'); } // ── Playback ───────────────────────────────────────────────────────────────── async function magPlay() { if (state.playing) return magPause(); const voice = magNarratorVoice(); if (!voice) return say('Pick a narrator voice first', 'err'); state.playing = true; state.cancel = false; syncPlayButton(); let finishedArticle = false; try { while (state.idx < state.sentences.length && !state.cancel) { highlight(state.idx); const sentence = state.sentences[state.idx]; let blob; try { const pending = magSynth(voice, sentence.spokenText || sentence.text); // Without a full "Prepare audio" run, still stay a few sentences ahead // so the wait is only ever for the first one. if (!state.preparing) { for (let k = 1; k <= MAG_LOOKAHEAD; k++) { const upcoming = state.sentences[state.idx + k]; if (upcoming) magSynth(voice, upcoming.spokenText || upcoming.text).catch(() => {}); } } blob = await pending; } catch (e) { say('Synthesis failed: ' + e.message, 'err'); break; } if (state.cancel) break; await playBlob(blob, sentence); if (state.cancel) break; state.idx += 1; saveProgress(); } finishedArticle = state.idx >= state.sentences.length && !state.cancel; } finally { state.playing = false; state.cancel = false; syncPlayButton(); } if (finishedArticle) { state.idx = state.sentences.length - 1; if (state.queue.length && state.queueIndex < state.queue.length - 1) { // Play-all: move straight to the next approved article rather than // stopping -- the whole point of a queue is not touching the screen // between articles. state.queueIndex += 1; await magRead(state.queue[state.queueIndex], { fromStart: true }); magPlay(); } else { state.queue = []; state.queueIndex = -1; say('Article finished', 'ok'); } } } function playBlob(blob, sentence) { return new Promise((resolve) => { const url = URL.createObjectURL(blob); const audio = new Audio(url); state.audio = audio; let timings = null; const onTimeUpdate = () => { if (!state.karaoke || !sentence || !sentence.words.length) return; if (!timings) { if (!audio.duration || !isFinite(audio.duration)) return; timings = estimateWordTimings(sentence.words, audio.duration); } const t = audio.currentTime; const wi = timings.findIndex((w) => t >= w.start && t < w.end); if (wi !== -1) highlightWord(state.idx, wi); }; if (state.karaoke) audio.addEventListener('timeupdate', onTimeUpdate); const done = () => { audio.removeEventListener('timeupdate', onTimeUpdate); URL.revokeObjectURL(url); state.audio = null; resolve(); }; audio.onended = done; audio.onerror = done; audio.play().catch(done); }); } // ── Play all approved articles in sequence ────────────────────────────── async function magPlayAll() { const approved = state.articles.filter((a) => a.status === 'approved'); if (!approved.length) return say('Approve at least one article first', 'err'); // Every approved article already has a generated MP3: use those directly // (ready instantly, tagged, works exactly like the standalone export) // instead of synthesising sentence by sentence live. A queue with even // one ungenerated article keeps the original live-reading behaviour, // since a listener partway through a magazine mid-drive should not hit a // gap where one piece silently has no audio. if (approved.every((a) => a.has_audio)) { magTogglePlaylist(true); magPlaylistPlay(_magPlaylistOrder()[0]?.id); return; } const ids = approved.map((a) => a.id); state.queue = ids; state.queueIndex = 0; await magRead(ids[0], { fromStart: true }); magPlay(); } // ── MP3 Playlist (generated audio, reading order) ─────────────────────────── // A separate, self-contained playback path from the live sentence-by- // sentence reader above: this just points a plain