tts-voice-creator-clone-and.../static/js/magazine.js
mARTin-B78 5009f388e7 fix(magazine): approve failed with unknown block ids after deleting or converting a region
The review selection now follows deletes, merges, splits and text-to-image
changes; approve sends only existing ids and no longer overwrites the image
list with a stale one; deleting a region removes it from articles. v1.38.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:22:20 +02:00

2765 lines
123 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
reviewSpread: false, // two-page spread view in review, see magReviewToggleSpread
audioCache: new Map(), // voice+text -> Promise<Blob>, 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) => ({ '&': '&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.
// What a region can be changed into from the review list. Values match the server's REGION_TYPES.
const MAG_REGION_TYPES = [
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'],
['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'],
['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'],
['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['image', 'Image'],
];
// 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 <label>: a click inside it would otherwise toggle the checkbox.
function magRegionControls(region, number) {
const current = magRegionTypeValue(region);
const known = MAG_REGION_TYPES.some(([v]) => v === current);
const options = (known ? [] : [[current, current]]).concat(MAG_REGION_TYPES)
.map(([v, label]) => '<option value="' + v + '"' + (v === current ? ' selected' : '') + '>' + esc(label) + '</option>').join('');
return '<select class="mag-block-type-select" aria-label="Type of region ' + number + '" ' +
'onclick="event.stopPropagation()" onchange="magReviewChangeType(\'' + esc(region.id) + '\', this.value, this)">' +
options + '</select>' +
'<button type="button" class="btn-sm mag-block-delete" aria-label="Delete region ' + number + '" title="Delete this region" ' +
'onclick="event.preventDefault(); event.stopPropagation(); magReviewDeleteRegion(\'' + esc(region.id) + '\')">' +
'<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span></button>';
}
async function magReviewChangeType(regionId, type, select) {
const toImage = type === 'image';
const fromImage = !!(state.layout?.images || []).some((img) => img.id === regionId);
if (select) select.disabled = true;
say(fromImage && !toImage ? 'Reading the region as text…' : 'Changing region type…', 'info');
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(regionId), {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type }),
});
if (r && r.id && r.id !== regionId) magRemapSelection(regionId, r.id, toImage);
} catch (e) {
if (select) select.disabled = false;
return say('Could not change the region type: ' + e.message, 'err');
}
say('Region type changed', 'ok');
state.layout = null;
await ensureLayout();
renderReview();
}
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;
}
// Builds one page's worth of review UI -- the page image with its numbered
// boxes, and the matching OCR-results list -- so the same markup can be used
// for the single active page or, in spread mode, for either half of a
// two-page spread. Only `interactive` (always exactly one page at a time,
// the one the toolbar's draw/edit/OCR-again actions apply to) gets the fixed
// 'mag-review-page-wrap' id that setupMagBoxEdit/setupMagDrawing target and
// the draw-rectangle placeholder; the other page in a spread is shown for
// context with working checkboxes and click-to-focus, but its box outlines
// are not draggable.
function magReviewPageColumn(page, wrapId, interactive) {
const a = state.article;
const included = _magReviewChecked || new Set(a.blocks || []);
const ownImages = new Set(a.images || []);
const pageMeta = page != null ? (state.layout?.pages || []).find((p) => p.page === page) : null;
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);
if (interactive) state.reviewRegionOrder = regions.map((r) => r.id);
let pageHtml = '';
if (page != null) {
pageHtml = '<div class="mag-review-page-wrap' + (interactive ? '' : ' is-context-page') + '" id="' + wrapId + '">' +
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
(interactive ? '<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);
pageHtml += '<div class="mag-review-box' + (on ? '' : ' is-excluded') + (owner ? ' has-owner' : '') + (r.data.type === 'ad' ? ' is-advertisement' : '') +
'" data-block="' + esc(r.id) + '" data-kind="' + (isImage ? 'image' : 'text') +
'" 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>' : '') +
'<button type="button" class="mag-review-box-delete" tabindex="-1" ' +
'aria-label="Delete this region" title="Delete this region" ' +
'onclick="event.stopPropagation(); magReviewDeleteRegion(\'' + esc(r.id) + '\')">' +
'<span class="mdi mdi-close" aria-hidden="true"></span></button>' +
MAG_RESIZE_HANDLES +
'</div>';
});
pageHtml += '</div>';
} else {
pageHtml = '<p class="muted">No page to show.</p>';
}
let listHtml = '<div class="mag-blocks"' + (interactive
? ' onkeydown="magListKey(event)" onfocusin="magListFocus(event)" aria-describedby="mag-list-keys"' : '') + '>' +
(interactive ? '<p id="mag-list-keys" class="muted mag-list-keys">Keys: ↑/↓ move between regions · Space tick · Enter edit text · R read again · M merge with the next region · Delete delete</p>' : '');
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);
listHtml += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
magOrderNumber(r.id, i + 1, regions.length, interactive) +
'<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 + '" aria-hidden="true"></span>' + magRegionControls(r, i + 1) + '</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>' +
magDescribeMarkup(r.id, r.data.description) +
'</div>';
return;
}
const b = r.data;
const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type);
const on = included.has(b.id);
listHtml += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
magOrderNumber(b.id, i + 1, regions.length, interactive) +
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '" aria-hidden="true"></span>' + magRegionControls(r, i + 1) + '</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>' +
(interactive ? magRowActions(b.id, i + 1, i + 1 < regions.length) : '') + '</label>';
});
if (!regions.length) listHtml += '<p class="muted">Nothing detected on this page.</p>';
listHtml += '</div>';
return { pageHtml, listHtml };
}
// The natural print-spread partner of a page (odd/even pairing, e.g. 22-23),
// falling back to the other neighbour when the natural one is out of range
// (covers, single-page inserts) so a spread is still offered near either end
// of the issue.
function magSpreadPartner(page, pageCount) {
if (page == null || !pageCount) return null;
const natural = (page % 2 === 0) ? page + 1 : page - 1;
if (natural >= 1 && natural <= pageCount) return natural;
const alt = (page % 2 === 0) ? page - 1 : page + 1;
return (alt >= 1 && alt <= pageCount && alt !== page) ? alt : null;
}
function magReviewToggleSpread() {
state.reviewSpread = !state.reviewSpread;
renderReview();
}
// Switches which half of a two-page spread the toolbar's draw/edit/OCR-again
// actions apply to, without leaving spread mode.
function magReviewSetActivePage(page) {
if (page === state.reviewPage) return;
state.reviewPage = page;
renderReview();
}
function magReviewStepPage(dir) {
const pages = state.article?.pages || [];
const next = pages[pages.indexOf(state.reviewPage) + dir];
if (next == null) return;
state.reviewPage = next;
renderReview();
}
function renderReview() {
const host = $('mag-review');
const a = state.article;
if (!host || !a) return;
// A half-built click order belongs to the page it was started on.
if (state.orderMode && state.orderPage !== state.reviewPage) { state.orderMode = false; state.orderSeq = []; }
const conf = confidenceLabel(a.confidence);
const page = state.reviewPage;
const pageCount = Number(state.layout?.page_count || 0);
const pageIdx = (a?.pages || []).indexOf(page);
const secondaryPage = state.reviewSpread ? magSpreadPartner(page, pageCount) : null;
const spreadPages = secondaryPage != null ? [page, secondaryPage].sort((x, y) => x - y) : null;
const issuePages = pageCount || '—';
const nextIssuePage = page != null && page < pageCount ? 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" ' + (pageIdx > 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" ' + (pageIdx >= 0 && pageIdx < (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-pagenav-group" role="group" aria-label="Spread view">' +
'<button type="button" class="btn-sm" id="mag-spread-toggle" aria-pressed="' + String(!!state.reviewSpread) + '" ' +
'title="Show this page next to its facing page, each with its own OCR results below" ' +
'onclick="magReviewToggleSpread()"><span class="mdi mdi-book-open-page-variant" aria-hidden="true"></span> ' +
(state.reviewSpread ? 'Single page' : 'Two-page spread') + '</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" id="mag-edit-toggle" aria-pressed="false" ' +
'title="Drag a region to move or resize it, or delete one that was detected wrongly" ' +
'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' +
'<button type="button" class="btn-sm" id="mag-order-toggle" aria-pressed="' + String(!!state.orderMode) + '" ' +
'title="Click the boxes on the page in the order they should be read" onclick="magOrderToggle()">' +
'<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> Order by clicking</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-reading-order-btn" onclick="magFixReadingOrder()" ' +
'title="Re-read every page column by column, left to right. Text and approvals stay as they are; pages you ordered by hand are kept." >' +
'<span class="mdi mdi-sort-numeric-ascending" aria-hidden="true"></span> Fix reading order</button>' +
'<button type="button" class="btn-sm" id="mag-cleanup-btn" onclick="magCleanUpBoxes()" ' +
'title="Stop reading text inside pictures, OCR noise and duplicated boxes, and split author lines that run into the text. Nothing is deleted; a backup is kept.">' +
'<span class="mdi mdi-broom" aria-hidden="true"></span> Clean up boxes</button>' +
'<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>' +
'<div id="mag-order-bar" class="mag-order-bar" role="status" aria-live="polite">' + magOrderBarHtml() + '</div>';
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>';
}
if (spreadPages) {
// 2x2 spread: both pages' images on top, each page's own OCR-results
// list directly underneath it -- only the toolbar-active page (state.
// reviewPage) is draggable/drawable; the other is shown for context,
// with "Edit this page" swapping which one is active.
const cols = spreadPages.map((p) => ({
page: p,
interactive: p === page,
...magReviewPageColumn(p, p === page ? 'mag-review-page-wrap' : 'mag-review-page-wrap-alt', p === page),
}));
html += '<div class="mag-review-spread">';
cols.forEach((c) => {
html += '<div class="mag-review-spread-page' + (c.interactive ? ' is-active-page' : '') + '">' +
'<div class="mag-review-spread-pagehead"><strong>Page ' + c.page + '</strong>' +
(c.interactive ? ' <span class="mag-pill ok">Editing</span>'
: ' <button type="button" class="btn-sm" onclick="magReviewSetActivePage(' + c.page + ')">Edit this page</button>') +
'</div>' + c.pageHtml + '</div>';
});
cols.forEach((c) => {
html += '<div class="mag-review-spread-list"><h4 class="mag-page-head">OCR results &mdash; page ' + c.page + '</h4>' + c.listHtml + '</div>';
});
html += '</div>';
} else {
const col = magReviewPageColumn(page, 'mag-review-page-wrap', true);
html += '<div class="mag-review-split">' +
'<div class="mag-review-page">' + col.pageHtml + '</div>' +
col.listHtml +
'</div>';
}
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;
if (state.orderMode) {
$('mag-review-page-wrap')?.classList.add('is-ordering');
magOrderRefreshBar();
}
setupMagDrawing();
setupMagBoxEdit();
}
// 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.';
}
}
// ── Figure descriptions ──────────────────────────────────────────────────
// Purely additive: generating or editing a description never changes
// whether the image itself is shown or included (see magReviewToggleImage
// above) -- it only supplies the text "Read figure descriptions" narrates
// if the reader turns that on. Never spoken by itself.
function magDescribeMarkup(id, description) {
const safeId = esc(id);
const has = !!(description || '').trim();
return '<div class="mag-describe">' +
'<button type="button" class="btn-sm" onclick="magDescribeImage(\'' + safeId + '\')" id="mag-describe-btn-' + safeId + '">' +
'<span class="mdi mdi-text-to-speech" aria-hidden="true"></span> ' + (has ? 'Redo description' : 'Describe figure') +
'</button>' +
'<span class="muted" id="mag-describe-status-' + safeId + '" role="status"></span>' +
'<label class="mag-describe-label" for="mag-describe-text-' + safeId + '">Spoken description ' +
'<span class="muted">(only read aloud if "Read figure descriptions" is on)</span></label>' +
'<textarea class="mag-describe-text" id="mag-describe-text-' + safeId + '" rows="2" ' +
'placeholder="No description yet." onchange="magSaveImageDescription(\'' + safeId + '\', this)">' +
esc(description || '') + '</textarea>' +
'</div>';
}
async function magDescribeImage(id) {
const btn = $('mag-describe-btn-' + id);
const statusEl = $('mag-describe-status-' + id);
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Describing…';
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/images/' + id + '/describe', { method: 'POST' });
jobId = r.job_id;
} catch (e) {
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
return say('Could not start: ' + e.message, 'err');
}
let result = null, failed = false;
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) { failed = true; say('Description failed: ' + job.error, 'err'); }
else { result = job.result; say('Figure described', 'ok'); }
break;
}
await new Promise((r) => setTimeout(r, 2000));
}
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="mdi mdi-text-to-speech" aria-hidden="true"></span> Redo description'; }
if (statusEl) statusEl.textContent = '';
if (failed || !result) return;
const textarea = $('mag-describe-text-' + id);
if (textarea) textarea.value = result.description || '';
const layoutImg = (state.layout?.images || []).find((i) => i.id === id);
if (layoutImg) layoutImg.description = result.description || '';
}
async function magSaveImageDescription(id, textarea) {
const statusEl = $('mag-describe-status-' + id);
if (statusEl) statusEl.textContent = 'Saving…';
try {
await api('/api/magazine/' + state.doc.id + '/images/' + id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: textarea.value }),
});
const layoutImg = (state.layout?.images || []).find((i) => i.id === id);
if (layoutImg) layoutImg.description = textarea.value;
if (statusEl) statusEl.textContent = 'Saved';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
} catch (e) {
if (statusEl) statusEl.textContent = '';
say('Could not save description: ' + e.message, 'err');
}
}
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');
}
// ── Reading order by hand ─────────────────────────────────────────────────
// The number in front of each row is its reading position. Typing a new number moves the region
// there; "Order by clicking" lets the reader click the boxes on the page in reading order. Either
// way the page is then locked against automatic reordering until "Automatic order" is pressed.
function magOrderNumber(id, number, total, editable) {
if (!editable) return '<span class="mag-block-num">' + number + '</span>';
return '<input type="number" class="mag-block-num mag-block-order" min="1" max="' + total + '" value="' + number + '" ' +
'aria-label="Reading position of region ' + number + ' (1 to ' + total + ')" ' +
'onclick="event.stopPropagation()" onkeydown="if (event.key === \'Enter\') { event.preventDefault(); this.blur(); }" ' +
'onchange="magOrderMove(\'' + esc(id) + '\', this.value)">';
}
function magReviewPageLocked() {
const meta = (state.layout?.pages || []).find((p) => p.page === state.reviewPage);
return !!meta?.order_locked;
}
function magOrderBarHtml() {
if (state.orderMode) {
const done = (state.orderSeq || []).length, total = (state.reviewRegionOrder || []).length;
return '<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' +
'<button type="button" class="btn-sm btn-primary" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>Save order</button> ' +
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button> ' +
'<button type="button" class="btn-sm" onclick="magOrderToggle()">Cancel</button>';
}
if (magReviewPageLocked()) {
return '<span class="mdi mdi-lock-outline" aria-hidden="true"></span> Reading order on this page was set by hand and is kept. ' +
'<button type="button" class="btn-sm" onclick="magOrderReset()">Automatic order</button>';
}
return '';
}
function magOrderRefreshBar() {
const bar = $('mag-order-bar');
if (bar) bar.innerHTML = magOrderBarHtml();
document.querySelectorAll('#mag-review-page-wrap .mag-review-box').forEach((box) => {
const at = (state.orderSeq || []).indexOf(box.dataset.block);
box.classList.toggle('is-ordered', state.orderMode && at >= 0);
const num = box.querySelector('.mag-review-box-num');
if (num && state.orderMode) num.textContent = at >= 0 ? String(at + 1) : '·';
});
}
function magOrderToggle() {
state.orderMode = !state.orderMode;
state.orderSeq = [];
state.orderPage = state.reviewPage;
if (state.orderMode && state.drawMode) magReviewToggleDraw(state.drawKind);
if (state.orderMode && state.editMode) magReviewToggleEdit();
const btn = $('mag-order-toggle');
if (btn) { btn.classList.toggle('active', state.orderMode); btn.setAttribute('aria-pressed', String(state.orderMode)); }
const wrap = $('mag-review-page-wrap');
if (wrap) wrap.classList.toggle('is-ordering', state.orderMode);
if (!state.orderMode) return renderReview(); // restore the real numbers
magOrderRefreshBar();
}
function magOrderPick(id) {
const seq = state.orderSeq || (state.orderSeq = []);
if (!seq.includes(id)) seq.push(id);
magOrderRefreshBar();
}
function magOrderUndo() {
(state.orderSeq || []).pop();
magOrderRefreshBar();
}
async function magOrderPut(order) {
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order }),
});
state.layout = null;
await ensureLayout();
}
async function magOrderSave() {
const seq = (state.orderSeq || []).slice();
if (!seq.length) return;
try {
await magOrderPut(seq);
} catch (e) {
return say('Could not save the reading order: ' + e.message, 'err');
}
state.orderMode = false;
state.orderSeq = [];
say('Reading order saved for this page', 'ok');
renderReview();
}
async function magOrderMove(id, value) {
const order = (state.reviewRegionOrder || []).filter((x) => x !== id);
const to = Math.min(Math.max(1, parseInt(value, 10) || 1), order.length + 1) - 1;
order.splice(to, 0, id);
try {
await magOrderPut(order);
} catch (e) {
say('Could not change the reading order: ' + e.message, 'err');
return renderReview();
}
say('Moved to position ' + (to + 1), 'ok');
renderReview();
}
async function magOrderReset() {
try {
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', { method: 'DELETE' });
} catch (e) {
return say('Could not reset the reading order: ' + e.message, 'err');
}
state.layout = null;
await ensureLayout();
say('Automatic reading order restored for this page', 'ok');
renderReview();
}
function magReviewFocusBlock(blockId) {
if (state.orderMode) return magOrderPick(blockId);
const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]');
if (state.drawMode || state.editMode) 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;
if (state.drawMode && state.editMode) magReviewToggleEdit();
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();
}
// ── Edit an already-detected region ──────────────────────────────────────
// Correcting a box the layout engine already found -- drag its body to
// move it, its corner handle to resize it, or its × to delete it entirely.
// Mutually exclusive with draw mode (adding a region that's missing) for
// the same reason those drawing tools already dim every existing box while
// active: dragging and click-to-toggle-inclusion would otherwise fight over
// the same mousedown.
function magReviewToggleEdit() {
state.editMode = !state.editMode;
if (state.editMode && state.drawMode) magReviewToggleDraw(state.drawKind);
const wrap = $('mag-review-page-wrap');
if (wrap) wrap.classList.toggle('is-editing', state.editMode);
const btn = $('mag-edit-toggle');
if (btn) { btn.classList.toggle('active', state.editMode); btn.setAttribute('aria-pressed', String(state.editMode)); }
const hint = $('mag-draw-hint');
if (hint) hint.textContent = state.editMode
? ' Drag a region to move it, its corner to resize it, or press × to delete it.' : '';
magReviewCancelEditPanel();
}
// A handle per edge/corner so any one boundary (e.g. the line between two
// stacked paragraph blocks) can be dragged on its own, instead of only the
// bottom-right corner moving width and height together.
const MAG_RESIZE_HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'].map((dir) =>
'<span class="mag-review-box-resize rz-' + dir + '" data-dir="' + dir + '" aria-hidden="true"></span>').join('');
let _magEditState = null; // { blockId, mode: 'move'|'resize', dir, box, startXPct, startYPct, origLeft, origTop, origW, origH }
function setupMagBoxEdit() {
const wrap = $('mag-review-page-wrap');
if (!wrap) return;
if (state.editMode) wrap.classList.add('is-editing');
// wrap is rebuilt fresh by every renderReview(), so per-wrap listeners
// never accumulate -- only the one-time window-level listener below needs
// the install guard, matching setupMagDrawing's own pattern.
wrap.addEventListener('mousedown', (e) => {
if (!state.editMode || e.button !== 0) return;
if (e.target.closest('.mag-edit-panel')) return;
const resizeHandle = e.target.closest('.mag-review-box-resize');
const box = e.target.closest('.mag-review-box');
if (!box || box.id === 'mag-draw-rect') return;
magReviewCancelEditPanel();
const rect = wrap.getBoundingClientRect();
_magEditState = {
blockId: box.dataset.block,
mode: resizeHandle ? 'resize' : 'move',
dir: resizeHandle ? resizeHandle.dataset.dir : null,
box,
startXPct: (e.clientX - rect.left) / rect.width * 100,
startYPct: (e.clientY - rect.top) / rect.height * 100,
origLeft: parseFloat(box.style.left) || 0,
origTop: parseFloat(box.style.top) || 0,
origW: parseFloat(box.style.width) || 0,
origH: parseFloat(box.style.height) || 0,
};
e.preventDefault();
});
const MAG_RESIZE_MIN_PCT = 1;
wrap.addEventListener('mousemove', (e) => {
if (!_magEditState) 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 dx = xPct - _magEditState.startXPct;
const dy = yPct - _magEditState.startYPct;
const { box, origLeft, origTop, origW, origH, dir } = _magEditState;
if (_magEditState.mode === 'move') {
const left = Math.max(0, Math.min(100 - origW, origLeft + dx));
const top = Math.max(0, Math.min(100 - origH, origTop + dy));
box.style.left = left + '%'; box.style.top = top + '%';
} else {
// Each handle only moves the edges named in its direction, so e.g. the
// "n" (top) handle can pull the boundary between two stacked regions
// up or down without disturbing either one's width or opposite edge.
let left = origLeft, top = origTop, w = origW, h = origH;
if (dir.includes('w')) {
const clampedDx = Math.max(-origLeft, Math.min(origW - MAG_RESIZE_MIN_PCT, dx));
left = origLeft + clampedDx; w = origW - clampedDx;
} else if (dir.includes('e')) {
w = Math.max(MAG_RESIZE_MIN_PCT, Math.min(100 - origLeft, origW + dx));
}
if (dir.includes('n')) {
const clampedDy = Math.max(-origTop, Math.min(origH - MAG_RESIZE_MIN_PCT, dy));
top = origTop + clampedDy; h = origH - clampedDy;
} else if (dir.includes('s')) {
h = Math.max(MAG_RESIZE_MIN_PCT, Math.min(100 - origTop, origH + dy));
}
box.style.left = left + '%'; box.style.top = top + '%';
box.style.width = w + '%'; box.style.height = h + '%';
}
});
if (!_magEditMouseupInstalled) {
_magEditMouseupInstalled = true;
window.addEventListener('mouseup', () => {
if (!_magEditState) return;
const { box, origLeft, origTop, origW, origH } = _magEditState;
const moved = Math.abs((parseFloat(box.style.left) || 0) - origLeft) > .1 ||
Math.abs((parseFloat(box.style.top) || 0) - origTop) > .1 ||
Math.abs((parseFloat(box.style.width) || 0) - origW) > .1 ||
Math.abs((parseFloat(box.style.height) || 0) - origH) > .1;
const state_ = _magEditState;
_magEditState = null;
if (!moved) return; // a click, not a drag -- nothing to confirm
magReviewOpenEditPanel(state_.blockId, box, { origLeft, origTop, origW, origH });
});
}
}
let _magEditMouseupInstalled = false;
function magReviewCancelEditPanel(revert = false) {
const panel = document.querySelector('.mag-edit-panel');
if (panel) {
if (revert && panel.dataset.origLeft !== undefined) {
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(panel.dataset.blockId) + '"]');
if (box) {
box.style.left = panel.dataset.origLeft + '%'; box.style.top = panel.dataset.origTop + '%';
box.style.width = panel.dataset.origW + '%'; box.style.height = panel.dataset.origH + '%';
}
}
panel.remove();
}
}
function magReviewOpenEditPanel(blockId, box, orig) {
const wrap = $('mag-review-page-wrap');
if (!wrap) return;
const kind = box.dataset.kind;
const panel = document.createElement('div');
panel.className = 'mag-draw-panel mag-edit-panel';
panel.dataset.blockId = blockId;
panel.dataset.origLeft = orig.origLeft; panel.dataset.origTop = orig.origTop;
panel.dataset.origW = orig.origW; panel.dataset.origH = orig.origH;
panel.innerHTML =
'<div class="mag-draw-panel-row"><strong>Save this region’s new position?</strong></div>' +
(kind === 'text'
? '<p class="muted">Save keeps the existing text. Save &amp; re-OCR reads the corrected area again and replaces it.</p>'
: '<p class="muted">The saved figure image will be re-cropped to the new box.</p>') +
'<div class="mag-draw-panel-row">' +
'<button type="button" class="btn-sm" onclick="magReviewCancelEditPanel(true)">Cancel</button>' +
(kind === 'text' ? '<button type="button" class="btn-sm" onclick="magReviewSubmitEdit(false)">Save</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitEdit(true)"><span class="mdi mdi-text-recognition"></span> Save &amp; re-OCR</button>'
: '<button type="button" class="btn-sm primary" onclick="magReviewSubmitEdit(false)">Save</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(80, top + height + 1) + '%';
panel.style.left = Math.min(55, left) + '%';
}
async function magReviewSubmitEdit(reocr) {
const panel = document.querySelector('.mag-edit-panel');
if (!panel) return;
const blockId = panel.dataset.blockId;
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(blockId) + '"]');
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
if (!box || !pageMeta) { magReviewCancelEditPanel(); return; }
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,
];
panel.remove();
say(reocr ? 'Saving and re-reading region…' : 'Saving region…', '');
try {
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(blockId), {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bbox, reocr }),
});
} catch (e) {
say('Could not save region: ' + e.message, 'err');
magReviewCancelEditPanel(true);
return;
}
say('Region saved', 'ok');
state.layout = null;
await ensureLayout();
renderReview();
}
async function magCleanUpBoxes() {
const ok = await confirmDialog('Clean up the boxes of this whole issue: text inside pictures, OCR noise (boxes of one or two characters) ' +
'and boxes that repeat a bigger box\'s text stop being read aloud, and author lines that run into the first sentence are split. ' +
'Nothing is deleted — every change shows in the review list and can be undone with the type picker — and a backup of the layout is kept.',
{ title: 'Clean up boxes?', okLabel: 'Clean up' });
if (!ok) return;
const btn = document.getElementById('mag-cleanup-btn');
if (btn) btn.disabled = true;
let r;
try {
r = await api('/api/magazine/' + state.doc.id + '/boxes/clean-up', { method: 'POST' });
} catch (e) {
if (btn) btn.disabled = false;
return say('Could not clean up the boxes: ' + e.message, 'err');
}
const c = r.counts || {};
const parts = [[c.in_picture, 'text in pictures'], [c.ocr_noise, 'OCR noise'], [c.duplicate, 'duplicates'], [c.byline_split, 'author lines split']]
.filter(([n]) => n).map(([n, what]) => n + ' ' + what);
say(parts.length ? 'Cleaned up: ' + parts.join(', ') + '. Backup: ' + r.backup : 'Nothing to clean up.', 'ok');
state.layout = null;
await ensureLayout();
renderReview();
}
async function magFixReadingOrder() {
const ok = await confirmDialog('Re-read the reading order of every page in this issue, column by column. ' +
'Text, boxes and approvals stay as they are, but the order in which approved articles are read aloud can change. ' +
'Pages you ordered by hand are kept.', { title: 'Fix reading order?', okLabel: 'Fix reading order' });
if (!ok) return;
const btn = document.getElementById('mag-reading-order-btn');
if (btn) btn.disabled = true;
let r;
try {
r = await api('/api/magazine/' + state.doc.id + '/reading-order', { method: 'POST' });
} catch (e) {
if (btn) btn.disabled = false;
return say('Could not fix the reading order: ' + e.message, 'err');
}
const n = (r.pages_changed || []).length;
say(n ? 'Reading order fixed on ' + n + ' page' + (n === 1 ? '' : 's') +
(r.pages_locked ? ' (' + r.pages_locked + ' hand-ordered page' + (r.pages_locked === 1 ? '' : 's') + ' kept)' : '') + '.'
: 'The reading order was already right on every page.', 'ok');
state.layout = null;
await ensureLayout();
renderReview();
}
// ── Quick corrections from the review list ────────────────────────────────
// Edit a region's text in place (and split it at the cursor), read it again, merge it with the next
// region, and move between regions with the keyboard -- without leaving the page.
function magRowActions(id, number, hasNext) {
const btn = (action, icon, label) => '<button type="button" class="btn-sm mag-row-action" ' +
'aria-label="' + esc(label) + ' (region ' + number + ')" title="' + esc(label) + '" ' +
'onclick="event.preventDefault(); event.stopPropagation(); ' + action + '(\'' + esc(id) + '\')">' +
'<span class="mdi ' + icon + '" aria-hidden="true"></span></button>';
return '<span class="mag-row-actions">' +
btn('magRowEdit', 'mdi-pencil-outline', 'Edit text') +
btn('magRowReocr', 'mdi-text-recognition', 'Read again') +
(hasNext ? btn('magRowMerge', 'mdi-table-merge-cells', 'Merge with the next region') : '') +
'</span>';
}
function magRegionUrl(id) {
return '/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(id);
}
async function magReloadReview(message) {
// Keep a keyboard user's place: the row focused when the redraw happens (not when the change
// started -- the user may have moved on meanwhile) gets focus back.
state.layout = null;
await ensureLayout();
const had = document.activeElement?.closest?.('[data-block-row]')?.dataset.blockRow;
renderReview();
if (had) document.querySelector('#mag-review [data-block-row="' + CSS.escape(had) + '"] input[type="checkbox"]')?.focus();
if (message) say(message, 'ok');
}
function magRowEdit(id) {
const block = (state.layout?.blocks || []).find((b) => b.id === id);
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(id) + '"]');
const span = row?.querySelector('.mag-block-text');
if (!block || !span || row.querySelector('.mag-row-editor')) return;
span.hidden = true;
const editor = document.createElement('span');
editor.className = 'mag-row-editor';
editor.innerHTML = '<textarea aria-label="Text of this region" rows="5"></textarea>' +
'<span class="mag-row-editor-actions">' +
'<button type="button" class="btn-sm btn-primary" data-act="save">Save text</button>' +
'<button type="button" class="btn-sm" data-act="split" title="Split this region into two where the cursor is">Split here</button>' +
'<button type="button" class="btn-sm" data-act="cancel">Cancel</button></span>';
span.after(editor);
const area = editor.querySelector('textarea');
area.value = block.text;
['click', 'keydown', 'keyup'].forEach((ev) => editor.addEventListener(ev, (e) => e.stopPropagation()));
area.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); magReloadReview(); } });
editor.addEventListener('click', async (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return;
e.preventDefault();
try {
if (act === 'save') {
await api(magRegionUrl(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: area.value }) });
return magReloadReview('Text saved');
}
if (act === 'split') {
const r = await api(magRegionUrl(id) + '/split', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ at: area.selectionStart }) });
if (r?.new_id) magSelectionAddAlongside(id, r.new_id);
return magReloadReview('Region split in two');
}
magReloadReview();
} catch (err) {
say('Could not change the region: ' + err.message, 'err');
}
});
area.focus();
}
async function magRowReocr(id) {
say('Reading the region again…', 'info');
try {
await api(magRegionUrl(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reocr: true }) });
} catch (e) {
return say('Could not read the region again: ' + e.message, 'err');
}
magReloadReview('Region read again');
}
async function magRowMerge(id) {
try {
const r = await api(magRegionUrl(id) + '/merge-next', { method: 'POST' });
if (r?.removed) magRemapSelection(r.removed, null, false);
} catch (e) {
return say('Could not merge: ' + e.message, 'err');
}
magReloadReview('Merged with the next region');
}
function magListFocus(event) {
const row = event.target.closest('[data-block-row]');
if (row) magReviewHighlightBox(row.dataset.blockRow);
}
function magListKey(event) {
const target = event.target;
if (target.closest('textarea, select, .mag-row-editor') || (target.tagName === 'INPUT' && target.type === 'number')) return;
const row = target.closest('[data-block-row]');
if (!row) return;
const rows = [...document.querySelectorAll('#mag-review .mag-blocks [data-block-row]')];
const at = rows.indexOf(row);
const focusRow = (r) => (r?.querySelector('input[type="checkbox"]') || r)?.focus();
const id = row.dataset.blockRow;
const isText = row.tagName === 'LABEL';
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (key === 'ArrowDown' || key === 'j') { event.preventDefault(); focusRow(rows[at + 1]); }
else if (key === 'ArrowUp' || key === 'k') { event.preventDefault(); focusRow(rows[at - 1]); }
else if (key === 'Enter' && isText) { event.preventDefault(); magRowEdit(id); }
else if (key === 'r' && isText) { event.preventDefault(); magRowReocr(id); }
else if (key === 'm' && isText) { event.preventDefault(); magRowMerge(id); }
else if (key === 'Delete') { event.preventDefault(); magReviewDeleteRegion(id); }
}
async function magReviewDeleteRegion(blockId) {
const ok = await confirmDialog('Delete this region? This removes it from the page entirely, not just from this article.',
{ title: 'Delete region?', okLabel: 'Delete', danger: true });
if (!ok) return;
try {
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(blockId), {
method: 'DELETE',
});
magRemapSelection(blockId, null, false);
} catch (e) {
return say('Could not delete region: ' + e.message, 'err');
}
say('Region deleted', 'ok');
state.layout = null;
await ensureLayout();
renderReview();
}
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);
if (e.key === 'Escape' && state.editMode) magReviewToggleEdit();
});
// $('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();
}
// Keep the review's unsaved selection in step with a region change made on the server. Without this,
// Approve sent ids that no longer existed ("Unknown block ids") after a delete, merge or type change,
// and sent a stale image list that dropped a text box just turned into an image.
function magRemapSelection(oldId, newId, newIsImage) {
const a = state.article;
if (!a) return;
if (!_magReviewChecked) _magReviewChecked = new Set(a.blocks || []);
const wasOn = _magReviewChecked.has(oldId) || (a.images || []).includes(oldId);
_magReviewChecked.delete(oldId);
a.blocks = (a.blocks || []).filter((id) => id !== oldId);
a.images = (a.images || []).filter((id) => id !== oldId);
if (!newId || !wasOn) return;
if (newIsImage) a.images = a.images.concat([newId]);
else { _magReviewChecked.add(newId); a.blocks = a.blocks.concat([newId]); }
}
// A region split in two: the new part is included wherever the original was.
function magSelectionAddAlongside(existingId, newId) {
const a = state.article;
if (!a) return;
if (!_magReviewChecked) _magReviewChecked = new Set(a.blocks || []);
if (_magReviewChecked.has(existingId)) { _magReviewChecked.add(newId); a.blocks = (a.blocks || []).concat([newId]); }
}
function selectedBlockIds() {
const exists = new Set((state.layout?.blocks || []).map((b) => b.id));
const ids = _magReviewChecked ? Array.from(_magReviewChecked)
: Array.from(document.querySelectorAll('#mag-review input[data-block]:checked')).map((el) => el.dataset.block);
return state.layout ? ids.filter((id) => exists.has(id)) : ids;
}
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 || []).filter((id) => !state.layout || (state.layout.images || []).some((img) => img.id === id)),
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;
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) =>
'<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);
}
// ── 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
? '<span class="mdi mdi-close"></span> Cancel'
: '<span class="mdi mdi-waveform"></span> 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].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.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.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 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('');
// The app-wide searchable picker (voice-picker.js) hides the <select> but
// keeps it as the source of truth, so every existing `.value` read below
// keeps working. It must be told about programmatic changes -- a bare
// `.value =` does not refresh its trigger label.
const setVoice = (id, value) => {
const el = $(id);
if (!el) return;
if (root.VoicePicker && el.dataset.vpUpgraded) root.VoicePicker.setValue(id, value);
else el.value = value;
};
const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice' };
Object.keys(voiceLabels).forEach((id) => {
const el = $(id);
if (!el) return;
const keep = el.value;
el.innerHTML = options;
if (root.VoicePicker) {
root.VoicePicker.upgrade(id);
// The picker's trigger is a div, so it needs its own accessible name
// (the <label for> points at the hidden <select>).
const trigger = document.querySelector('.vp-root[data-vp-for="' + id + '"] .vp-trigger');
if (trigger) {
trigger.setAttribute('role', 'button');
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-label', voiceLabels[id]);
}
}
if (keep) setVoice(id, keep);
});
const def = state.doc && state.doc.default_voice;
if (def && $('mag-voice') && !$('mag-voice').value) setVoice('mag-voice', 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.magReviewStepPage = magReviewStepPage;
root.magReviewToggleSpread = magReviewToggleSpread;
root.magReviewSetActivePage = magReviewSetActivePage;
root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox;
root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReocrPage = magReocrPage;
root.magRefineFigures = magRefineFigures;
root.magReviewToggleImage = magReviewToggleImage;
root.magDescribeImage = magDescribeImage;
root.magSaveImageDescription = magSaveImageDescription;
root.magReviewToggleDraw = magReviewToggleDraw;
root.magReviewCancelDrawPanel = magReviewCancelDrawPanel;
root.magReviewSubmitDraw = magReviewSubmitDraw;
root.magReviewToggleEdit = magReviewToggleEdit;
root.magReviewCancelEditPanel = magReviewCancelEditPanel;
root.magReviewSubmitEdit = magReviewSubmitEdit;
root.magReviewDeleteRegion = magReviewDeleteRegion;
root.magReviewChangeType = magReviewChangeType;
root.magFixReadingOrder = magFixReadingOrder;
root.magCleanUpBoxes = magCleanUpBoxes;
root.magRowEdit = magRowEdit;
root.magRowReocr = magRowReocr;
root.magRowMerge = magRowMerge;
root.magListKey = magListKey;
root.magListFocus = magListFocus;
root.magOrderToggle = magOrderToggle;
root.magOrderSave = magOrderSave;
root.magOrderUndo = magOrderUndo;
root.magOrderMove = magOrderMove;
root.magOrderReset = magOrderReset;
root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove;
root.magSetStatus = magSetStatus;
root.magRead = magRead;
root.magSeek = magSeek;
root.magPlay = magPlay;
root.magPrepareAudio = magPrepareAudio;
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);