tts-voice-creator-clone-and.../static/js/magazine.js
mARTin-B78 96b0b9dd71
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
fix(magazine): finish and harden the live ingest preview
Resumes an in-progress, uncommitted change from a previous session: the
live "page image + recognised text" preview shown during ingestion was
reworked to render formatted HTML (styled headings, a figure
placeholder) instead of a plain-text dump, but was left with a
duplication bug, dead code, inline styles, and no tests.

- Fixed duplication: a figure block's own transcribed text (usually its
  on-screen UI copy) was rendered as an ordinary paragraph in addition
  to the new figure placeholder box. Figure/chart/table/code blocks are
  now represented only by the placeholder.
- Furniture/sidebar/pullquote/ad text is shown muted instead of being
  dropped with no replacement, and is never promoted to a heading
  regardless of font size.
- Factored the formatting logic out of the progress closure into a
  standalone, unit-tested _render_ingest_preview_html().
- Replaced dead getattr(bbox, "y0", ...) calls (bbox is always a plain
  [x, y, w, h] list) with direct indexing, moved statistics/logging
  imports to module level, switched manual character replacement to
  html.escape(), and bounded the preview by an accumulated line budget
  instead of a blind slice that could truncate mid-tag.
- Replaced inline style="..." with CSS classes matching the panel's
  existing dark console treatment.
- The frontend now always uses innerHTML (every text node is escaped
  server-side), dropping the startsWith('<') content-type sniffing.
- Marked the preview panel aria-hidden -- decorative, and would
  otherwise churn noisily for screen reader users on top of the
  adjacent progress bar.

Verified against the real c't KI Wissen 2026 layout: a figure's raw
OCR text no longer appears twice, a real headline renders as <h3>, a
footer/pull-quote render muted, and all text is correctly escaped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:24:16 +02:00

1886 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
pane: 'reflow', // reflow | pdf
playing: false,
cancel: false,
idx: 0, // current sentence
audio: null,
voices: [],
podcast: null,
queue: [], // article ids for "play all" (empty = not queued)
queueIndex: -1,
driving: false, // large-control, minimal-interaction view
karaoke: true, // word-level highlighting (approximate, see estimateWordTimings)
drawMode: false, // manually drawing a region OCR/layout missed, in review
reviewSpan: [],
reviewPage: null,
};
MAG.magazine = state;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =
'<p class="muted" style="padding:18px 0">No magazines yet. Add one to get started — ' +
'upload a PDF and the articles are detected for you.</p>';
return;
}
host.innerHTML = state.docs.map((d) => {
const ing = d.ingest || {};
const badge = ing.status === 'done'
? '<span class="mag-pill ok">' + (ing.articles || 0) + ' articles</span>'
: ing.status === 'running' ? '<span class="mag-pill warn">ingesting…</span>'
: ing.status === 'failed' ? '<span class="mag-pill bad">failed</span>'
: '<span class="mag-pill">not ingested</span>';
const thumbnail = '<img class="mag-card-thumb" src="/api/magazine/' + encodeURIComponent(d.id) +
'/pages/1" alt="First page of ' + esc(d.title || 'magazine') +
'" loading="lazy" onerror="this.hidden=true">';
return '<div class="mag-card-wrap"><button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
thumbnail + '<span class="mag-card-title">' + esc(d.title || 'Untitled') + '</span>' +
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' + badge + '</button>' +
'<div class="mag-card-actions"><button type="button" class="mag-card-delete" onclick="magDelete(\'' + d.id + '\')" ' +
'title="Delete this magazine" aria-label="Delete ' + esc(d.title || 'magazine') + '"><span class="mdi mdi-delete-outline"></span> Delete</button></div></div>';
}).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 = '<span class="mdi mdi-loading mdi-spin"></span> 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';
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)) };
}
async function pollIngest() {
const bar = $('mag-ingest-status');
const preview = $('mag-ingest-preview');
if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; }
let lastPreviewPage = null;
for (;;) {
let data;
try {
data = await api('/api/magazine/' + state.doc.id + '/ingest/status');
} catch (_) { break; }
const ing = data.ingest || {};
const detail = (data.job || {}).detail || '';
if (bar) {
if (ing.status === 'running') {
bar.className = 'mag-status is-running';
const p = magParsePageProgress(detail);
bar.innerHTML =
'<div class="mag-ingest-row">' +
'<span class="mdi mdi-progress-clock mag-ingest-icon"></span>' +
'<div class="mag-ingest-text">' +
'<strong>' + esc(detail || 'Analysing…') + '</strong>' +
(p ? '<div class="mag-ingest-track"><div class="mag-ingest-fill" style="width:' + p.pct + '%"></div></div>' : '') +
'</div>' +
(p ? '<span class="mag-ingest-pct">' + p.pct + '%</span>' : '') +
'</div>';
} else if (ing.status === 'failed') {
bar.className = 'mag-status is-failed';
bar.innerHTML = '<div class="mag-ingest-row"><span class="mdi mdi-alert mag-ingest-icon"></span>' +
'<span>Ingestion failed: ' + esc(ing.error || '') + '</span></div>';
} else {
bar.className = 'mag-status is-done';
bar.innerHTML = '<div class="mag-ingest-row"><span class="mdi mdi-check-circle mag-ingest-icon"></span>' +
'<span>' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected</span></div>';
}
}
// 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;
}
const textEl = $('mag-ingest-preview-text');
// Server-built HTML (see _render_ingest_preview_html in
// routes/magazine.py) -- every text node in it is already escaped,
// so this is the one place that inner-HTMLs a live server value.
if (textEl) textEl.innerHTML = data.preview.text || '';
} else if (ing.status !== 'running') {
preview.hidden = true;
}
}
if (ing.status !== 'running') {
if (ing.status === 'done') await magRefreshArticles();
break;
}
await new Promise((r) => setTimeout(r, 2000));
}
}
async function magRefreshArticles() {
if (!state.doc) return;
try {
const data = await api('/api/magazine/' + state.doc.id + '/articles');
state.articles = data.articles || [];
state.doc.progress = data.progress || {};
state.doc.podcast = data.podcast || { hosts: [] };
state.doc.default_voice = data.default_voice || '';
} catch (_) {
state.articles = [];
}
renderContents();
}
// ── Contents tree ────────────────────────────────────────────────────────────
function confidenceLabel(c) {
// 0.55 is the backend's explicit heuristic marker: no reliable contents
// entry was available, so an article was started from a page headline. It
// is not a calibrated probability and a column of identical “55%” badges
// falsely suggests a measured OCR score.
if (Math.abs(Number(c) - 0.55) < 0.001) {
return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' };
}
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' };
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' };
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' };
}
function renderContents() {
const host = $('mag-contents');
if (!host) return;
const title = $('mag-doc-title');
if (title && state.doc) {
title.textContent = (state.doc.title || 'Magazine') +
(state.doc.issue ? ' — ' + state.doc.issue : '');
}
if (!state.articles.length) {
host.innerHTML = '<p class="muted">No articles yet. Run ingestion to detect them.</p>';
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);
});
let html = '';
bySection.forEach((arts, section) => {
html += '<div class="mag-section"><h4>' + esc(section) + '</h4><ul class="mag-toc">';
arts.forEach((a) => {
const conf = confidenceLabel(a.confidence);
const done = a.status === 'approved';
const progress = (state.doc.progress || {})[a.id];
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' +
'<div class="mag-toc-main">' +
'<span class="mag-toc-title">' + esc(a.title || 'Untitled') + '</span>' +
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
'</div>' +
'<div class="mag-toc-meta">' +
'<span>' + fmtPages(a.pages) + '</span>' +
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
esc(conf.badge) + '</span>' +
'<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>' +
'</div>' +
'<div class="mag-toc-actions">' +
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
(done
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
(progress ? 'Continue' : 'Listen') + '</button>'
: '') +
'</div></li>';
});
html += '</ul></div>';
});
host.innerHTML = html;
}
// ── Review (Stage D) ─────────────────────────────────────────────────────────
async function magReview(articleId) {
const article = state.articles.find((a) => a.id === articleId);
if (!article) return;
state.article = article;
await ensureLayout();
// The whole review is one page at a time -- showing every page of a
// 10-page feature stacked at once was unreadable and made the boxes tiny.
state.reviewSpan = magReviewSpan(article);
state.reviewPage = article.pages[0] || state.reviewSpan[0] || null;
_magReviewChecked = null;
magShow('review');
renderReview();
}
async function ensureLayout() {
if (state.layout) return;
try {
state.layout = await api('/api/magazine/' + state.doc.id + '/layout');
} catch (e) {
say('Could not load layout: ' + e.message, 'err');
state.layout = { blocks: [], pages: [], page_count: 0 };
}
}
function blocksForPages(pages) {
const want = new Set(pages || []);
return (state.layout.blocks || [])
.filter((b) => want.has(b.page))
.sort((a, b) => a.page - b.page || a.reading_order - b.reading_order);
}
// Offer neighbouring pages too, so a boundary that started a page late (or a
// jump tail that should have been included) can be corrected without
// leaving the screen.
function magReviewSpan(article) {
if (!article.pages.length) return [];
return Array.from(new Set(article.pages.concat(
[article.pages[0] - 1, article.pages[article.pages.length - 1] + 1])))
.filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y);
}
function magReviewGoPage(page) {
if (!state.reviewSpan.includes(page)) return;
state.reviewPage = page;
renderReview();
}
function magReviewGoMagazinePage(page) {
const lastPage = Number(state.layout?.page_count || 0);
if (!Number.isInteger(page) || page < 1 || page > lastPage) return;
state.reviewPage = page;
renderReview();
}
// One glyph per region kind, shown centred in its box -- the fastest way to
// tell "a paragraph of text" from "a photo" from "furniture that will not be
// read" without reading the label. No table detection exists yet (P2), so a
// table currently reads as its own text region like any other paragraph.
function magBlockIcon(type) {
if (type === 'image' || type === 'figure') return 'mdi-image-outline';
if (type === 'pullquote') return 'mdi-format-quote-close';
if (type === 'chart') return 'mdi-chart-bar';
if (type === 'table') return 'mdi-table';
if (type === 'code') return 'mdi-code-tags';
if (type === 'page_number') return 'mdi-numeric';
if (type === 'furniture' || type === 'ad') return 'mdi-close-box-outline';
if (type === 'caption') return 'mdi-image-text';
return 'mdi-text-box-outline';
}
// A page a feature spans can share space with a neighbouring piece (the
// tail of the previous article, the start of the next). Blocks belonging to
// the article being reviewed need no extra label -- their checkbox already
// says so -- but a block claimed by a DIFFERENT already-detected article is
// otherwise easy to mistake for stray, unclaimed text. Returns null for the
// current article or for a block no article has claimed.
function magBlockOwner(blockId) {
const current = state.article;
for (let i = 0; i < state.articles.length; i++) {
const art = state.articles[i];
if (art.id === current.id) continue;
if ((art.blocks || []).includes(blockId) || (art.images || []).includes(blockId)) {
return { index: i + 1, title: art.title };
}
}
return null;
}
function renderReview() {
const host = $('mag-review');
const a = state.article;
if (!host || !a) return;
const included = _magReviewChecked || new Set(a.blocks || []);
const conf = confidenceLabel(a.confidence);
const page = state.reviewPage;
const pageMeta = page != null ? (state.layout?.pages || []).find((p) => p.page === page) : null;
const ownImages = new Set(a.images || []);
const pageBlocks = page == null ? [] : blocksForPages([page]);
const pageImages = page == null ? [] : (state.layout?.images || [])
.filter((img) => img.page === page).sort((x, y) => (x.reading_order || 0) - (y.reading_order || 0));
// One merged, reading-order-sorted list so numbering on both sides matches
// regardless of whether an item is text or a figure.
const visualBlockIds = new Set(pageImages.map(i => i.source_block).filter(Boolean));
const regions = pageBlocks.filter(b => !visualBlockIds.has(b.id)).map((b) => ({ kind: 'block', id: b.id, ro: b.reading_order ?? 0, data: b }))
.concat(pageImages.map((img) => ({ kind: 'image', id: img.id, ro: img.reading_order ?? 0, data: img })))
.sort((x, y) => x.ro - y.ro);
const spanIdx = (state.reviewSpan || []).indexOf(page);
const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null;
const nextPage = spanIdx >= 0 && spanIdx < (state.reviewSpan || []).length - 1 ? state.reviewSpan[spanIdx + 1] : null;
const issuePages = (state.layout || {}).page_count || '—';
const nextIssuePage = page != null && page < (state.layout || {}).page_count ? page + 1 : null;
const prevIssuePage = page != null && page > 1 ? page - 1 : null;
let html = '';
html += '<div class="mag-review-pagenav">' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Review pages">' +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (state.reviewPageIdx > 0 ? '' : 'disabled') +
' onclick="magReviewStepPage(-1)"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (state.reviewPageIdx < (a?.pages || []).length - 1 ? '' : 'disabled') +
' onclick="magReviewStepPage(1)"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Whole magazine pages">' +
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (prevIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + prevIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next magazine page" ' + (nextIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-draw-controls" role="group" aria-label="Draw regions">' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle">' +
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw text</button>' +
'<button type="button" class="btn-sm" id="mag-draw-image-toggle" onclick="magReviewToggleDraw(\'image\')">' +
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw image</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'heading\')">' +
'<span class="mdi mdi-format-header-1" aria-hidden="true"></span> Headline</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'page_number\')">' +
'<span class="mdi mdi-pound" aria-hidden="true"></span> Page number</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'source\')">' +
'<span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> Source / footer</button>' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'ad\')">' +
'<span class="mdi mdi-advertisements-outline" aria-hidden="true"></span> Advertisement</button>' +
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button></div>' +
'<span class="mag-review-pagenav-spacer"></span>' +
'<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' +
'title="Re-check every figure boundary in this issue against the scanned pages">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries</button>' +
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
'<span id="mag-reocr-status" class="muted" role="status"></span>' +
'</div>';
html += '<div class="mag-review-fields">';
html += '<div class="mag-review-field-row">' +
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'</div>' +
'<span id="mag-draw-hint" class="muted"></span>';
html += '</div>';
html += '<span class="mag-review-conf ' + conf.cls + '">' + esc(conf.text) + '</span>';
html += '<p class="muted">Untick anything that is not part of this article &mdash; a sidebar, an advert, or a neighbouring piece. Only ticked text is read aloud. Click a numbered region on the page, or its matching entry on the right, to line the two up. A region labelled with another article belongs to a different piece sharing this page. Draw a missing text or image region below.</p>';
if (state.layout.semantic_review === 'partial_failure') {
html += '<p class="muted" role="status">Some regions could not be grouped automatically. Check paragraph boundaries before approving.</p>';
}
if (state.layout.advertisement_review === 'partial_failure') {
html += '<p class="muted" role="status">Advertisement detection could not finish. Check this page for adverts before approving.</p>';
} else if (state.layout.advertisement_review === 'not_configured') {
html += '<p class="muted">Set a language model in Settings to recognise advertisements automatically.</p>';
}
html += '<div class="mag-review-split">';
html += '<div class="mag-review-page">';
if (page != null) {
html += '<div class="mag-review-page-wrap" id="mag-review-page-wrap">' +
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
'<div id="mag-draw-rect" class="mag-review-box mag-draw-rect" hidden></div>';
regions.forEach((r, i) => {
if (!pageMeta) return;
const bbox = r.data.bbox;
const left = (bbox[0] / pageMeta.width * 100).toFixed(2);
const top = (bbox[1] / pageMeta.height * 100).toFixed(2);
const w = (bbox[2] / pageMeta.width * 100).toFixed(2);
const h = (bbox[3] / pageMeta.height * 100).toFixed(2);
const isImage = r.kind === 'image';
const on = isImage ? ownImages.has(r.id) : included.has(r.id);
const owner = magBlockOwner(r.id);
const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
html += '<div class="mag-review-box' + (on ? '' : ' is-excluded') + (owner ? ' has-owner' : '') + (r.data.type === 'ad' ? ' is-advertisement' : '') +
'" data-block="' + esc(r.id) + '" style="left:' + left + '%;top:' + top + '%;width:' + w + '%;height:' + h +
'%" onclick="magReviewFocusBlock(\'' + esc(r.id) + '\')" title="' + esc(isImage ? 'figure' : r.data.type) + '">' +
'<span class="mag-review-box-num">' + (i + 1) + '</span>' +
'<span class="mag-review-box-icon mdi ' + icon + '"></span>' +
(owner ? '<span class="mag-review-box-owner">Art. ' + owner.index + '</span>' : '') +
'</div>';
});
html += '</div>';
} else {
html += '<p class="muted">No page to show.</p>';
}
html += '</div>';
html += '<div class="mag-blocks">';
regions.forEach((r, i) => {
const isImage = r.kind === 'image';
const owner = magBlockOwner(r.id);
const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
if (isImage) {
const on = ownImages.has(r.id);
const imgFilename = r.data.filename || (r.id + '.png');
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename);
html += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(r.data.type || 'image') + '</span>' +
'<div class="mag-block-thumb-wrap">' +
'<img class="mag-block-thumb" src="' + imgUrl + '" alt="' + esc(r.data.type || 'figure') + '" onerror="this.style.display=\'none\'">' +
'<span class="mag-block-text">' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title)
: on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '</span>' +
'</div>' +
'</div>';
return;
}
const b = r.data;
const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type);
const on = included.has(b.id);
html += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(b.region_label === 'page_number' ? 'page number' : b.type === 'body' ? 'paragraph' : b.type === 'title' || b.type === 'heading' ? 'headline' : b.type === 'byline' ? 'author' : b.type === 'sidebar' ? 'sidenote' : b.type === 'ad' ? 'advertisement' : b.type) + '</span>' +
'<span class="mag-block-text">' +
(owner ? '<span class="mag-block-owner">Article ' + owner.index + ': ' + esc(owner.title) + '</span> ' : '') +
(b.continues_from_previous_page ? '<span class="mag-block-owner">Continued from previous page</span> ' : '') +
esc(b.text.slice(0, 220)) + (b.text.length > 220 ? '…' : '') + '</span></label>';
});
if (!regions.length) html += '<p class="muted">Nothing detected on this page.</p>';
html += '</div>';
html += '</div>'; // /mag-review-split
html += '<div class="mag-review-foot">' +
'<button type="button" class="btn" onclick="magShow(\'contents\')">Back to contents</button>' +
'<button type="button" class="btn" onclick="magSetStatus(\'rejected\')">Not an article</button>' +
'<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' +
'</div>';
host.innerHTML = html;
setupMagDrawing();
}
// Tracks every ticked block across ALL reviewed pages of this article, not
// just the one currently on screen -- switching pages must not forget a
// change made on a previous one.
let _magReviewChecked = null;
function magReviewToggleBox(input) {
if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
if (input.checked) _magReviewChecked.add(input.dataset.block);
else _magReviewChecked.delete(input.dataset.block);
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(input.dataset.block) + '"]');
if (box) box.classList.toggle('is-excluded', !input.checked);
}
function magReviewToggleImage(id, checked) {
const selected = new Set(state.article.images || []);
if (checked) selected.add(id); else selected.delete(id);
state.article.images = Array.from(selected);
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(id) + '"]');
if (box) box.classList.toggle('is-excluded', !checked);
const row = document.querySelector('[data-block-row="' + CSS.escape(id) + '"]');
if (row) {
row.classList.toggle('is-aux', !checked);
row.querySelector('.mag-block-text').textContent = checked ? 'Shown during playback for this article.' : 'Not included in playback for this article.';
}
}
function magReviewClearCurrentPage() {
if (state.reviewPage == null) return;
if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
const page = state.reviewPage;
(state.layout.blocks || []).filter((block) => block.page === page)
.forEach((block) => _magReviewChecked.delete(block.id));
const imageIds = new Set((state.layout.images || []).filter((image) => image.page === page).map((image) => image.id));
state.article.images = (state.article.images || []).filter((id) => !imageIds.has(id));
renderReview();
say('All fields on this page cleared — select only the article content to keep.', 'ok');
}
function magReviewFocusBlock(blockId) {
const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]');
if (state.drawMode) return;
if (cb) { cb.checked = !cb.checked; magReviewToggleBox(cb); }
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(blockId) + '"]');
if (row) row.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}
function magReviewHighlightBox(blockId) {
document.querySelectorAll('.mag-review-box.is-linked').forEach((el) => el.classList.remove('is-linked'));
if (!blockId) return;
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(blockId) + '"]');
if (box) box.classList.add('is-linked');
}
// ── Draw a region by hand ────────────────────────────────────────────────
// Escape hatch for when the OCR/layout pipeline missed a region entirely --
// not for correcting one it already found (tick/untick or "OCR again"
// cover that). Percent-of-image coordinates while dragging, converted to
// PDF-point page coordinates (matching every other bbox in the layout) only
// once the user commits the region.
function magReviewToggleDraw(kind = 'text') {
state.drawMode = state.drawMode && state.drawKind === kind ? false : true;
state.drawKind = kind;
const hint = $('mag-draw-hint');
const field = kind === 'title' ? 'title' : kind === 'author' ? 'author' : '';
const drawLabels = { text: 'text region', image: 'image, chart, or graphic', heading: 'headline', page_number: 'page number', source: 'source or footer', ad: 'advertisement' };
if (hint) hint.textContent = state.drawMode
? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.`
: ' Drag around the whole ' + (drawLabels[kind] || 'text region') + '. Press Escape to cancel.') : '';
const wrap = $('mag-review-page-wrap');
if (wrap) wrap.classList.toggle('is-drawing', state.drawMode);
const btn = $('mag-draw-toggle');
if (btn) { btn.classList.toggle('active', state.drawMode && kind === 'text'); btn.setAttribute('aria-pressed', String(state.drawMode && kind === 'text')); }
const imageBtn = $('mag-draw-image-toggle');
if (imageBtn) { imageBtn.classList.toggle('active', state.drawMode && kind === 'image'); imageBtn.setAttribute('aria-pressed', String(state.drawMode && kind === 'image')); }
magReviewCancelDrawPanel();
}
let _magDrawState = null; // { startXPct, startYPct } while dragging
let _magDrawMouseupInstalled = false;
function setupMagDrawing() {
const wrap = $('mag-review-page-wrap');
if (!wrap) return;
if (state.drawMode) wrap.classList.add('is-drawing');
// wrap itself is rebuilt fresh by every renderReview() (host.innerHTML
// replaces the whole subtree), so listeners on it never accumulate --
// only the window-level one below needs the one-time guard, since window
// is never replaced.
wrap.addEventListener('mousedown', (e) => {
if (!state.drawMode || e.button !== 0 || e.target.closest('.mag-draw-panel')) return;
magReviewCancelDrawPanel();
const rect = wrap.getBoundingClientRect();
_magDrawState = {
startXPct: (e.clientX - rect.left) / rect.width * 100,
startYPct: (e.clientY - rect.top) / rect.height * 100,
};
e.preventDefault();
});
wrap.addEventListener('mousemove', (e) => {
if (!_magDrawState) return;
const rect = wrap.getBoundingClientRect();
const xPct = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
const yPct = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100));
const box = $('mag-draw-rect');
if (!box) return;
const left = Math.max(0, Math.min(_magDrawState.startXPct, xPct));
const top = Math.max(0, Math.min(_magDrawState.startYPct, yPct));
const w = Math.abs(xPct - _magDrawState.startXPct);
const h = Math.abs(yPct - _magDrawState.startYPct);
box.hidden = false;
box.style.left = left + '%'; box.style.top = top + '%';
box.style.width = w + '%'; box.style.height = h + '%';
});
if (!_magDrawMouseupInstalled) {
_magDrawMouseupInstalled = true;
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && state.drawMode) magReviewToggleDraw(state.drawKind);
});
// $('mag-draw-rect') is looked up fresh on every mouseup, so this one
// listener keeps working correctly across every renderReview() rebuild
// without needing to be re-attached.
window.addEventListener('mouseup', (e) => {
if (!_magDrawState) return;
const wrap = $('mag-review-page-wrap');
const box = $('mag-draw-rect');
if (wrap && box) {
const rect = wrap.getBoundingClientRect();
const xPct = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
const yPct = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100));
const left = Math.max(0, Math.min(_magDrawState.startXPct, xPct));
const top = Math.max(0, Math.min(_magDrawState.startYPct, yPct));
const w = Math.abs(xPct - _magDrawState.startXPct);
const h = Math.abs(yPct - _magDrawState.startYPct);
box.hidden = false;
box.style.left = left + '%'; box.style.top = top + '%';
box.style.width = w + '%'; box.style.height = h + '%';
}
_magDrawState = null;
if (!box || box.hidden) return;
const w = parseFloat(box.style.width) || 0, h = parseFloat(box.style.height) || 0;
if (w < 1 || h < 1) { box.hidden = true; return; } // too small to be intentional
magReviewOpenDrawPanel(box);
});
}
}
function magReviewCancelDrawPanel() {
_magDrawState = null;
const box = $('mag-draw-rect');
if (box) box.hidden = true;
const panel = document.querySelector('.mag-draw-panel');
if (panel) panel.remove();
}
function magReviewOpenDrawPanel(box) {
const wrap = $('mag-review-page-wrap');
if (!wrap) return;
box.hidden = false;
const panel = document.createElement('div');
panel.className = 'mag-draw-panel';
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
const semanticKinds = { heading: 'headline', page_number: 'page number', source: 'source / footer', ad: 'advertisement' };
const semanticLabel = semanticKinds[state.drawKind] || '';
panel.innerHTML = field
? '<div class="mag-draw-panel-row"><strong>Extract ' + field + '</strong></div>' +
'<p class="muted">OCR the selected rectangle and use its recognised text for the article ' + field + '.</p>' +
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-text-recognition"></span> Use recognised text</button></div>'
: semanticLabel
? '<div class="mag-draw-panel-row"><strong>Add ' + semanticLabel + '</strong></div>' +
'<p class="muted">This region is classified as ' + semanticLabel + (state.drawKind === 'heading' ? ' and can be included in narration.' : ' and is kept out of narration.') + '</p>' +
'<textarea id="mag-draw-text" rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-plus" aria-hidden="true"></span> Add ' + semanticLabel + '</button></div>'
:
'<div class="mag-draw-panel-row">' +
'<label><input type="radio" name="mag-draw-kind" value="text" ' + (state.drawKind !== 'image' ? 'checked' : '') + '> Text</label>' +
'<label><input type="radio" name="mag-draw-kind" value="image" ' + (state.drawKind === 'image' ? 'checked' : '') + '> Image</label>' +
'</div>' +
'<label id="mag-draw-exclude"' + (state.drawKind === 'image' ? '' : ' hidden') + '><input id="mag-draw-exclude-check" type="checkbox" checked> Exclude enclosed text from narration</label>' +
'<textarea id="mag-draw-text"' + (state.drawKind === 'image' ? ' hidden' : '') + ' rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
'<div class="mag-draw-panel-row">' +
'<button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' +
'<span class="mdi mdi-plus" aria-hidden="true"></span> Add region</button>' +
'</div>';
wrap.appendChild(panel);
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
panel.style.top = Math.min(85, top + height + 1) + '%';
panel.style.left = Math.min(60, left) + '%';
wrap.querySelectorAll('input[name="mag-draw-kind"]').forEach((r) => {
r.addEventListener('change', () => {
const ta = $('mag-draw-text');
if (ta) ta.hidden = r.value === 'image' && r.checked;
$('mag-draw-exclude').hidden = r.value !== 'image';
});
});
}
async function magReviewSubmitDraw() {
const box = $('mag-draw-rect');
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
if (!box || !pageMeta) return;
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
const kind = field ? 'text' : (['heading', 'page_number', 'source', 'ad'].includes(state.drawKind)
? state.drawKind : (document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text'));
const text = ($('mag-draw-text')?.value || '').trim();
const bbox = [
parseFloat(box.style.left) / 100 * pageMeta.width,
parseFloat(box.style.top) / 100 * pageMeta.height,
parseFloat(box.style.width) / 100 * pageMeta.width,
parseFloat(box.style.height) / 100 * pageMeta.height,
];
const excludeText = kind === 'image' && $('mag-draw-exclude-check')?.checked;
const article = state.article;
const page = state.reviewPage;
const oldIds = new Set((kind === 'image' ? state.layout.images || [] : state.layout.blocks || []).map(r => r.id));
article.title = $('mag-rev-title').value;
article.author = $('mag-rev-author').value;
if (!_magReviewChecked) _magReviewChecked = new Set(article.blocks || []);
magReviewCancelDrawPanel();
state.drawMode = false;
if (field) {
magReviewCancelDrawPanel();
say(`Reading selected ${field}…`, '');
try {
const result = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/extract', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bbox }),
});
const value = String(result.text || '').replace(/\s+/g, ' ').trim();
if (!value) return say('No text found in that selection', 'err');
article[field] = value;
const input = $(field === 'title' ? 'mag-rev-title' : 'mag-rev-author');
if (input) input.value = value;
say(`${field === 'title' ? 'Title' : 'Author'} filled from selected text`, 'ok');
} catch (e) { say(`Could not read selected ${field}: ` + e.message, 'err'); }
return;
}
say('Adding region…', '');
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bbox, kind, text, ocr: kind !== 'image' && !text }),
});
jobId = r.job_id;
} catch (e) {
return say('Could not add region: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (e) { return say('Could not check region: ' + e.message, 'err'); }
if (job.done) {
if (job.error) return say('Could not add region: ' + job.error, 'err');
else say('Region added', 'ok');
break;
}
await new Promise((r) => setTimeout(r, 1500));
}
state.layout = null;
await ensureLayout();
if (state.article !== article) return;
const added = (kind === 'image' ? state.layout.images || [] : state.layout.blocks || [])
.filter(r => r.page === page && !oldIds.has(r.id));
if (kind === 'image') {
article.images = [...new Set((article.images || []).concat(added.map(r => r.id)))];
if (excludeText) {
for (const b of state.layout.blocks || []) {
const [x, y, w, h] = b.bbox;
if (b.page === page && x >= bbox[0] && y >= bbox[1] &&
x + w <= bbox[0] + bbox[2] && y + h <= bbox[1] + bbox[3]) _magReviewChecked.delete(b.id);
}
}
} else if (kind === 'text' || kind === 'heading') added.forEach(r => _magReviewChecked.add(r.id));
renderReview();
}
function selectedBlockIds() {
if (_magReviewChecked) return Array.from(_magReviewChecked);
return Array.from(document.querySelectorAll('#mag-review input[data-block]:checked'))
.map((el) => el.dataset.block);
}
async function magReocrPage() {
const page = state.reviewPage;
if (page == null) return;
const btn = $('mag-reocr-btn');
const statusEl = $('mag-reocr-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Reading page ' + page + '…';
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/reocr', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
jobId = r.job_id;
} catch (e) {
if (statusEl) statusEl.textContent = '';
if (btn) btn.disabled = false;
return say('Could not start OCR: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) {
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
return say('OCR failed: ' + job.error, 'err');
}
else say('Page ' + page + ' re-read', 'ok');
break;
}
await new Promise((r) => setTimeout(r, 2000));
}
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
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();
}
// Re-derives every figure box in the issue from the page renders. Separate
// from "OCR again" because no OCR engine is involved: it only corrects where
// the figures are, never what the page says, so it is safe to run on an issue
// that is half reviewed already.
async function magRefineFigures() {
const btn = $('mag-refine-figures-btn');
const statusEl = $('mag-reocr-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Checking figure boundaries…';
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/figures/refine', { method: 'POST' });
jobId = r.job_id;
} catch (e) {
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
return say('Could not start the 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('Figure check failed: ' + job.error, 'err'); }
else say(job.detail || 'Figure boundaries checked', 'ok');
break;
}
if (statusEl && job.detail) statusEl.textContent = job.detail;
await new Promise((r) => setTimeout(r, 2000));
}
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
if (failed) return;
state.layout = null;
await ensureLayout();
_magReviewChecked = null;
renderReview();
}
async function magApprove() {
const a = state.article;
if (!a) return;
const blocks = selectedBlockIds();
if (!blocks.length) return say('Tick at least one block before approving', 'err');
try {
const updated = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
blocks,
images: a.images || [],
status: 'approved',
title: ($('mag-rev-title')?.value || a.title).trim(),
author: ($('mag-rev-author')?.value || a.author).trim(),
}),
});
Object.assign(a, updated);
say('Approved — ready to listen', 'ok');
await magRefreshArticles();
magShow('contents');
} catch (e) {
say('Could not approve: ' + e.message, 'err');
}
}
async function magSetStatus(status) {
const a = state.article;
if (!a) return;
try {
const updated = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
Object.assign(a, updated);
await magRefreshArticles();
magShow('contents');
} catch (e) {
say('Could not update: ' + e.message, 'err');
}
}
// ── 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);
(parts.length ? parts : [b.text]).forEach((text) => {
if (text && text.trim()) {
out.push({
text: text.trim(), blockId: b.id, page: b.page, bbox: b.bbox, type: b.type,
// 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 = '<img src="/api/magazine/' + state.doc.id + '/images/' + encodeURIComponent(img.filename) +
'" alt="Figure from page ' + img.page + '" loading="lazy">';
}
async function magRead(articleId, opts) {
const a = state.articles.find((x) => x.id === articleId);
if (!a) return;
state.article = a;
await ensureLayout();
try {
const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' +
($('mag-read-captions')?.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"' : '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) =>
'<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>').join(' ');
return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' +
'onclick="magSeek(' + i + ')">' + wordsHtml + '</' + open + '> ';
}).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);
}
// ── Playback ─────────────────────────────────────────────────────────────────
async function magPlay() {
if (state.playing) return magPause();
const voice = ($('mag-voice')?.value || state.doc.default_voice || '').trim();
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 {
blob = await fetchTtsPreviewBlob(voice, sentence.text, 'mp3');
} 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 ids = state.articles.filter((a) => a.status === 'approved').map((a) => a.id);
if (!ids.length) return say('Approve at least one article first', 'err');
state.queue = ids;
state.queueIndex = 0;
await magRead(ids[0], { fromStart: true });
magPlay();
}
function magStopQueue() {
state.queue = [];
state.queueIndex = -1;
['mag-queue-progress', 'mag-queue-progress-2'].forEach((id) => {
const el = $(id);
if (el) el.textContent = '';
});
}
async function magQueueNext() {
if (!state.queue.length || state.queueIndex >= state.queue.length - 1) return;
const wasPlaying = state.playing;
if (wasPlaying) magPause();
state.queueIndex += 1;
await magRead(state.queue[state.queueIndex], { fromStart: true });
if (wasPlaying) magPlay();
}
function magPause() {
state.cancel = true;
if (state.audio) { try { state.audio.pause(); } catch (_) {} }
state.playing = false;
syncPlayButton();
}
function syncPlayButton() {
const btn = $('mag-play');
if (btn) {
btn.innerHTML = state.playing
? '<span class="mdi mdi-pause"></span> Pause'
: '<span class="mdi mdi-play"></span> Listen';
btn.setAttribute('aria-label', state.playing ? 'Pause narration' : 'Play narration');
}
if (state.driving) syncDrivingPlayButton();
}
let _saveTimer = null;
function saveProgress() {
if (!state.doc || !state.article) return;
clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
fetch('/api/magazine/' + state.doc.id + '/progress', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ article: state.article.id, sentence: state.idx }),
}).catch(() => {});
}, 800);
}
// ── Podcast mode ─────────────────────────────────────────────────────────────
async function magLoadVoices() {
try {
const data = await api('/api/voices');
state.voices = data.voices || data || [];
} catch (_) { state.voices = []; }
const options = '<option value="">— pick a voice —</option>' +
state.voices.map((v) => {
const id = v.id || v.name || v;
return '<option value="' + esc(id) + '">' + esc(v.name || id) + '</option>';
}).join('');
['mag-voice', 'mag-host-a', 'mag-host-b', 'mag-host-c'].forEach((id) => {
const el = $(id);
if (el) {
const keep = el.value;
el.innerHTML = options;
if (keep) el.value = keep;
}
});
const def = state.doc && state.doc.default_voice;
if (def && $('mag-voice') && !$('mag-voice').value) $('mag-voice').value = def;
}
async function loadPodcastIfAny() {
state.podcast = null;
renderPodcast();
// has_podcast comes from the articles listing so an article that never had
// a script generated skips the request entirely, rather than a 404 on
// every open.
if (!state.article || !state.article.has_podcast) return;
try {
state.podcast = await api('/api/magazine/' + state.doc.id + '/articles/' +
state.article.id + '/podcast');
} catch (_) { /* generated flag was stale — treat as none */ }
renderPodcast();
}
async function magGeneratePodcast() {
const a = state.article;
if (!a) return;
if (a.status !== 'approved') {
return say('Approve the article first — unreviewed boundaries may include an advert', 'err');
}
const hosts = ['mag-host-a', 'mag-host-b', 'mag-host-c']
.map((id) => ($(id)?.value || '').trim())
.filter(Boolean)
.map((voice, i) => ({ voice, name: ($('mag-host-name-' + 'abc'[i])?.value || '').trim() }));
if (hosts.length < 2) return say('Pick at least two host voices', 'err');
const btn = $('mag-podcast-gen');
if (btn) { btn.disabled = true; btn.textContent = 'Writing script…'; }
try {
state.podcast = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/podcast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hosts }),
});
a.has_podcast = true;
say('Podcast script ready', 'ok');
renderPodcast();
} catch (e) {
say('Could not generate: ' + e.message, 'err');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Generate script'; }
}
}
function renderPodcast() {
const host = $('mag-podcast-script');
if (!host) return;
if (!state.podcast) {
host.innerHTML = '<p class="muted">No podcast script yet. Pick two or three voices and ' +
'generate one — the hosts discuss this article for someone who has not read it.</p>';
const play = $('mag-podcast-play');
if (play) play.hidden = true;
return;
}
const names = {};
(state.podcast.hosts || []).forEach((h) => { names[h.label] = h.name || ('Host ' + h.label); });
host.innerHTML = '<ol class="mag-turns">' + (state.podcast.turns || []).map((t, i) =>
'<li data-turn="' + i + '"><b>' + esc(names[t.host] || t.host) + '</b> ' +
esc(t.text) + '</li>').join('') + '</ol>';
const play = $('mag-podcast-play');
if (play) play.hidden = false;
}
async function magPlayPodcast() {
if (state.playing) return magPause();
if (!state.podcast) return;
const voiceOf = {};
(state.podcast.hosts || []).forEach((h) => { voiceOf[h.label] = h.voice; });
state.playing = true;
state.cancel = false;
const btn = $('mag-podcast-play');
if (btn) btn.textContent = 'Pause';
try {
const turns = state.podcast.turns || [];
for (let i = 0; i < turns.length && !state.cancel; i++) {
document.querySelectorAll('#mag-podcast-script [data-turn]').forEach((el) => {
el.classList.toggle('is-current', Number(el.dataset.turn) === i);
});
const voice = voiceOf[turns[i].host];
if (!voice) continue;
let blob;
try { blob = await fetchTtsPreviewBlob(voice, turns[i].text, 'mp3'); }
catch (e) { say('Synthesis failed: ' + e.message, 'err'); break; }
if (state.cancel) break;
await playBlob(blob);
}
} finally {
state.playing = false;
state.cancel = false;
if (btn) btn.textContent = 'Play podcast';
}
}
// ── View switching ───────────────────────────────────────────────────────────
function magShow(view) {
state.view = view;
['library', 'contents', 'review', 'read'].forEach((v) => {
const el = $('mag-view-' + v);
if (el) el.hidden = v !== view;
});
if (view !== 'read') magPause();
}
function magBackToContents() {
magPause();
magStopQueue();
state.driving = false;
magShow('contents');
magRefreshArticles();
}
async function magBackToLibrary() {
// This button was previously labeled "All magazines" but only switched the
// current view; after a reload magInit could immediately reopen the saved
// document. Clear that remembered document and refresh the actual library.
magPause();
magStopQueue();
state.driving = false;
magForgetLastDoc();
state.doc = null;
state.article = null;
magShow('library');
try { await magLoadLibrary(); }
catch (e) { say('Could not refresh magazines: ' + e.message, 'err'); }
}
// ── Driving mode ──────────────────────────────────────────────────────────
// Large, minimal-interaction controls for listening without looking at the
// screen (see Manual.md's planned driving-mode behaviour). Reuses whatever
// is already playing/queued rather than starting its own playback path --
// entering driving mode does not interrupt an article already in progress.
function magToggleDriving() {
state.driving = !state.driving;
const overlay = $('mag-driving');
if (overlay) overlay.hidden = !state.driving;
if (state.driving) renderDriving();
}
function renderDriving() {
const a = state.article;
if (!a) return;
const titleEl = $('mag-driving-title');
if (titleEl) titleEl.textContent = a.title || 'Untitled';
const byEl = $('mag-driving-by');
if (byEl) byEl.textContent = a.author ? 'By ' + a.author : '';
syncDrivingPlayButton();
const qEl = $('mag-driving-queue');
if (qEl) qEl.hidden = !state.queue.length;
}
function syncDrivingPlayButton() {
const btn = $('mag-driving-play');
if (!btn) return;
btn.innerHTML = state.playing
? '<span class="mdi mdi-pause"></span>'
: '<span class="mdi mdi-play"></span>';
btn.setAttribute('aria-label', state.playing ? 'Pause' : 'Play');
}
// ── Boot ─────────────────────────────────────────────────────────────────────
async function magOnShow() {
setupMagDropzone();
setupMagIngestPreviewResize();
if ($('mag-library-list')) {
magLoadLibrary();
magLoadVoices();
}
}
async function magInit() {
setupMagDropzone();
setupMagIngestPreviewResize();
if (!$('mag-library-list')) return; // section not mounted
magLoadLibrary();
magLoadVoices();
let lastId = null;
try { lastId = localStorage.getItem(MAG_LAST_DOC_KEY); } catch (_) {}
if (lastId) {
// Confirmed live: without this, a full reload or the browser's own Back
// button (as opposed to in-app navigation) always dropped back to the
// empty library, making an in-progress magazine look like it had been
// lost. If it genuinely no longer exists (deleted elsewhere), fall back
// to the library rather than getting stuck on a permanent error toast.
try {
await api('/api/reader/docs/' + lastId);
await magOpen(lastId);
return;
} catch (_) {
magForgetLastDoc();
}
}
magShow('library');
}
root.magOnShow = magOnShow;
root.magOpen = magOpen;
root.magDelete = magDelete;
root.magCreate = magCreate;
root.magIngest = magIngest;
root.magReview = magReview;
root.magReviewGoPage = magReviewGoPage;
root.magReviewGoMagazinePage = magReviewGoMagazinePage;
root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox;
root.magReocrPage = magReocrPage;
root.magRefineFigures = magRefineFigures;
root.magReviewToggleImage = magReviewToggleImage;
root.magReviewToggleDraw = magReviewToggleDraw;
root.magReviewCancelDrawPanel = magReviewCancelDrawPanel;
root.magReviewSubmitDraw = magReviewSubmitDraw;
root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove;
root.magSetStatus = magSetStatus;
root.magRead = magRead;
root.magSeek = magSeek;
root.magPlay = magPlay;
root.magPause = magPause;
root.magSetPane = magSetPane;
root.magPeekPage = magPeekPage;
root.magShow = magShow;
root.magBackToContents = magBackToContents;
root.magBackToLibrary = magBackToLibrary;
root.magGeneratePodcast = magGeneratePodcast;
root.magPlayPodcast = magPlayPodcast;
root.magPlayAll = magPlayAll;
root.magQueueNext = magQueueNext;
root.magStopQueue = magStopQueue;
root.magToggleDriving = magToggleDriving;
root.magInit = magInit;
MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', magInit);
} else {
magInit();
}
})(window);