tts-voice-creator-clone-and.../static/js/magazine.js
mARTin-B78 f88fefcee4 feat(magazine): driving mode shows three sentence lines and syncs per frame
Previous/current/next sentence in a fixed-height box with a fade, tap to jump. The
highlight is a binary search over sentence starts on audio.currentTime every animation
frame, with forced resync on seeking, seeked, ratechange, play, pause and visibility; the
marked word uses the sentence's own span. Playwright covers seeks, 1.5x/2x, rapid seeks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 14:57:39 +02:00

6254 lines
301 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
asides: [], // sidebars shown beside the text (not narrated), see article_asides
view: 'library', // library | contents | review | read
contentsView: 'list', // list | pages — how the article list itself is shown
contentsSelectMode: false, // batch-select articles for bulk approve/reject
contentsSelected: new Set(),
pagesCols: 8, // "All pages" grid column count, user-adjustable 1-12
playlistCurrent: null, // article id of the MP3 playlist's current track
playlistSentences: [], // transcript aligned approximately to the exported MP3
playlistExact: null, // exact [{start,end}] per narrated sentence from /timings, or null (estimate)
playlistImages: [], // figures/tables/charts shown alongside that transcript
playlistLoadToken: 0, // prevents a slow text fetch replacing a newer track
playlistLastSaved: 0,
playlistHighlightIndex: -1,
playlistSwitching: false,
pane: 'reflow', // reflow | pdf
playing: false,
cancel: false,
idx: 0, // current sentence
audio: null,
voices: [],
podcast: null,
spell: null, // last spelling findings shown (page or article scope)
spellByBlock: new Map(),
spellOpen: false,
audit: null, // live audit of the reviewed page ({page, data})
flow: null, // how the open article's text continues between regions
flowOn: true, // show the flow arrows/chips in review
history: null, // {can_undo, can_redo, undo_label, redo_label} from the server
historyBusy: false,
acronyms: null, // the open article's glossary state
queue: [], // article ids for "play all" (empty = not queued)
queueIndex: -1,
driving: false, // large-control, minimal-interaction view
karaoke: true, // word-level highlighting (approximate, see estimateWordTimings)
drawMode: false, // manually drawing a region OCR/layout missed, in review
reviewSpan: [],
reviewPage: null,
reviewSpread: false, // two-page spread view in review, see magReviewToggleSpread
audioCache: new Map(), // voice+text -> Promise<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';
}
// The contents list doubles as the playlist: one shared <audio> for the inline play buttons,
// separate from the reading view's own playback so neither disturbs the other.
const tocPlayer = { audio: null, id: null };
function magTocSyncButtons() {
document.querySelectorAll('[data-toc-play]').forEach((btn) => {
const on = tocPlayer.id === btn.dataset.tocPlay && tocPlayer.audio && !tocPlayer.audio.paused;
const title = btn.dataset.title || 'article';
btn.setAttribute('aria-pressed', String(!!on));
btn.setAttribute('aria-label', (on ? 'Pause ' : 'Play ') + title);
btn.innerHTML = '<span class="mdi ' + (on ? 'mdi-pause' : 'mdi-play') + '" aria-hidden="true"></span>';
});
}
function magTocPlay(articleId) {
if (tocPlayer.audio && tocPlayer.id === articleId) {
if (tocPlayer.audio.paused) tocPlayer.audio.play().catch(() => {}); else tocPlayer.audio.pause();
return;
}
if (tocPlayer.audio) tocPlayer.audio.pause();
if (typeof state.audio?.pause === 'function') state.audio.pause();
const audio = new Audio('/api/magazine/' + encodeURIComponent(state.doc.id) + '/articles/' + encodeURIComponent(articleId) + '/audio');
tocPlayer.audio = audio;
tocPlayer.id = articleId;
audio.addEventListener('play', magTocSyncButtons);
audio.addEventListener('pause', magTocSyncButtons);
audio.addEventListener('ended', magTocSyncButtons);
audio.addEventListener('error', () => { say('Could not play this article\'s MP3', 'err'); tocPlayer.audio = null; tocPlayer.id = null; magTocSyncButtons(); });
audio.play().catch(() => {});
}
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 });
});
}
// The URL of a page's picture. It carries the picture's version (its modification time, stamped on each page by the
// layout endpoint), so when a page is turned or re-rendered the URL changes and every browser fetches it again instead
// of trusting a copy it cached under the old one.
function magPageSrc(page, docId) {
const id = docId || state.doc?.id;
const meta = (state.layout?.pages || []).find((p) => p.page === Number(page));
return '/api/magazine/' + id + '/pages/' + page + (meta && meta.image_v ? '?v=' + meta.image_v : '');
}
// ── Library ──────────────────────────────────────────────────────────────────
async function magLoadLibrary() {
const data = await api('/api/reader/docs');
state.docs = (data.docs || []).filter((d) => d.kind === 'magazine');
renderLibrary();
magFindLoadSources();
magCatLoad();
}
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 and phone photos work too — pages without selectable text are read with OCR. Several images are read in filename order.';
function magFormatFilenameAsTitle(filename) {
if (!filename) return '';
const base = String(filename).replace(/\.[^.]+$/, '');
return base
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// What was chosen or dropped: exactly one PDF, or several page images (PNG/JPEG) read in filename order.
// Returns { kind, files, label: {name, size} } or { error }. The server re-checks content by its bytes.
function magClassifyFiles(list) {
const files = Array.from(list || []).filter(Boolean);
if (!files.length) return { error: 'Choose or drop a PDF or page images first' };
const isPdf = (f) => f.type === 'application/pdf' || /\.pdf$/i.test(f.name);
const isImg = (f) => /^image\/(png|jpeg)$/.test(f.type) || /\.(png|jpe?g)$/i.test(f.name);
if (files.every(isPdf)) {
if (files.length > 1) return { error: 'Choose one PDF, or several page images — not several PDFs' };
return { kind: 'pdf', files, label: files[0] };
}
if (files.every(isImg)) {
files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
const size = files.reduce((n, f) => n + f.size, 0);
return { kind: 'images', files, label: { name: files.length === 1 ? files[0].name : files.length + ' page images', size } };
}
return { error: 'Only a PDF, or PNG/JPEG page images, can be imported' };
}
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 = '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 or page images';
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 dropped = Array.from(e.dataTransfer?.files || []);
if (!dropped.length && e.dataTransfer?.items?.length) {
dropped = Array.from(e.dataTransfer.items).map((i) => i.getAsFile()).filter(Boolean);
}
const picked = magClassifyFiles(dropped);
if (picked.error) return say(picked.error, 'err');
magShowDroppedFile(picked.label);
magCreate(picked.files);
});
input.addEventListener('change', () => {
if (!input.files?.length) return;
const picked = magClassifyFiles(input.files);
if (picked.error) return say(picked.error, 'err');
magShowDroppedFile(picked.label);
magCreate(picked.files);
});
}
async function magCreate(dropped = null) {
const picked = magClassifyFiles(dropped ? (Array.isArray(dropped) ? dropped : [dropped]) : $('mag-new-file')?.files);
if (picked.error) return say(picked.error, 'err');
const file = picked.label;
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 }),
});
if (picked.kind === 'pdf') {
await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: picked.files[0] });
} else {
const form = new FormData();
picked.files.forEach((f) => form.append('files', f, f.name));
await api('/api/magazine/' + id + '/source-images', { method: 'POST', body: form });
}
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');
}
}
// ── Find an issue online ─────────────────────────────────────────────────
async function magFindLoadSources() {
const sel = $('mag-find-source');
if (!sel || sel.dataset.loaded) return;
try {
const { sources } = await api('/api/magazine-sources');
sel.innerHTML = sources.map((s) => '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>').join('');
sel.dataset.loaded = '1';
} catch (e) {
const st = $('mag-find-status');
if (st) st.textContent = 'Could not load the sources: ' + e.message;
}
}
// ── Your OPDS catalogues ─────────────────────────────────────────────────
async function magCatLoad() {
const list = $('mag-cat-list');
if (!list) return;
try {
const { catalogues } = await api('/api/magazine-sources/catalogues');
state.catalogues = catalogues || [];
} catch (e) {
const st = $('mag-cat-status');
if (st) st.textContent = 'Could not load your catalogues: ' + e.message;
return;
}
list.innerHTML = state.catalogues.length ? state.catalogues.map((c, i) =>
'<li><span><strong>' + esc(c.name) + '</strong> <span class="muted">' + esc(c.url) + '</span>' +
(c.username ? ' <span class="muted">· login ' + esc(c.username) + (c.password_set ? '' : ' — <em>password variable ' + esc(c.password_env) + ' is not set on the server</em>') + '</span>' : '') +
'</span><button type="button" class="btn-sm" aria-label="Remove ' + esc(c.name) + '" onclick="magCatRemove(' + i + ')">Remove</button></li>').join('')
: '<li class="muted">None yet. The Internet Archive is always available.</li>';
}
async function magCatSave(next, doneText) {
const st = $('mag-cat-status');
try {
await api('/api/magazine-sources/catalogues', { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ catalogues: next.map((c) => ({ name: c.name, url: c.url, username: c.username, password_env: c.password_env })) }) });
} catch (e) {
if (st) st.textContent = 'Not saved: ' + e.message;
return false;
}
if (st) st.textContent = doneText;
await magCatLoad();
const sel = $('mag-find-source');
if (sel) { delete sel.dataset.loaded; await magFindLoadSources(); }
return true;
}
async function magCatAdd(ev) {
if (ev) ev.preventDefault();
const entry = { name: $('mag-cat-name').value.trim(), url: $('mag-cat-url').value.trim(),
username: $('mag-cat-user').value.trim(), password_env: $('mag-cat-env').value.trim() };
if (await magCatSave([...(state.catalogues || []), entry], 'Catalogue added.')) $('mag-cat-form').reset();
}
async function magCatRemove(i) {
const next = (state.catalogues || []).filter((_, k) => k !== i);
await magCatSave(next, 'Catalogue removed.');
}
async function magFindSearch(ev) {
if (ev) ev.preventDefault();
const q = ($('mag-find-query')?.value || '').trim();
const source = $('mag-find-source')?.value;
const status = $('mag-find-status'), list = $('mag-find-results');
if (!q) { status.textContent = 'Enter something to search for.'; return; }
status.textContent = 'Searching…';
list.innerHTML = '';
try {
const data = await api('/api/magazine-sources/' + encodeURIComponent(source) + '/search?q=' + encodeURIComponent(q));
magFindRender(source, data.items || []);
} catch (e) {
status.textContent = 'Search failed: ' + e.message;
}
}
function magFindRender(source, items) {
const status = $('mag-find-status'), list = $('mag-find-results');
state.findItems = items;
status.textContent = items.length ? items.length + ' result' + (items.length === 1 ? '' : 's') + '.' : 'Nothing found.';
const isUrl = (u) => /^https?:\/\//.test(u || '');
list.innerHTML = items.map((it, i) =>
'<li class="mag-find-item">' +
'<div class="mag-find-main"><strong>' + esc(it.title) + '</strong>' +
'<span class="muted">' + [it.author, it.date].filter(Boolean).map(esc).join(' · ') + '</span>' +
(it.summary ? '<span class="mag-find-summary">' + esc(it.summary) + '</span>' : '') +
'<span class="muted">' + (it.license
? 'Licence: ' + (isUrl(it.license) ? '<a href="' + esc(it.license) + '" target="_blank" rel="noopener noreferrer">' + esc(it.license) + '</a>' : esc(it.license))
: 'Licence not stated by the source') +
(isUrl(it.page_url) ? ' · <a href="' + esc(it.page_url) + '" target="_blank" rel="noopener noreferrer">Open source page</a>' : '') +
'</span></div>' +
'<button type="button" class="btn-secondary btn-sm" aria-label="Import ' + esc(it.title) + '" ' +
'onclick="magFindImport(\'' + esc(source) + '\', ' + i + ', this)"><span class="mdi mdi-download" aria-hidden="true"></span> Import</button>' +
'</li>').join('');
}
async function magFindImport(source, index, btn) {
const it = (state.findItems || [])[index];
if (!it) return;
const status = $('mag-find-status');
btn.disabled = true;
try {
status.textContent = 'Starting download of “' + it.title + '”…';
const { id, job_id } = await api('/api/magazine-sources/import', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, item_id: it.id, title: it.title }),
});
for (;;) {
await new Promise((r) => setTimeout(r, 1500));
const job = await api('/api/jobs/' + encodeURIComponent(job_id));
if (job.error) throw new Error(job.error);
if (job.done) break;
status.textContent = job.detail || 'Downloading…';
}
status.textContent = 'Downloaded — starting ingestion.';
await magOpen(id);
await magIngest();
} catch (e) {
status.textContent = 'Import failed: ' + e.message;
say('Import failed: ' + e.message, 'err');
} finally {
btn.disabled = false;
}
}
// ── Ingestion ────────────────────────────────────────────────────────────────
// Remembers which magazine was open so a full page reload or the browser's
// own Back button (which, unlike in-app navigation, tears down all JS state)
// lands back on it instead of the empty library — confirmed live as the
// cause of a user re-uploading the same PDF, creating a duplicate under a
// completely different kind ("book", via Studio/Reader's own import) because
// the magazine they had been working on appeared to have vanished.
const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc';
const MAG_CONTENTS_VIEW_KEY = 'ttsvc_mag_contents_view';
async function magOpen(docId) {
try {
state.doc = await api('/api/reader/docs/' + docId);
state.doc.id = docId;
await magRefreshArticles();
magShow('contents');
try { localStorage.setItem(MAG_LAST_DOC_KEY, docId); } catch (_) {}
if ((state.doc.ingest || {}).status === 'running') pollIngest();
} catch (e) {
say('Could not open magazine: ' + e.message, 'err');
try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {}
}
}
function magForgetLastDoc() {
try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {}
}
async function magIngest() {
if (!state.doc) return;
try {
await api('/api/magazine/' + state.doc.id + '/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
pollIngest();
} catch (e) {
say('Ingestion could not start: ' + e.message, 'err');
}
}
// "OCR again" for the whole issue: forces every page through the vision
// engine regardless of its current quality, then re-detects articles --
// which is why this asks for confirmation. A single page's "OCR again" in
// review does not touch article grouping; re-reading the whole document
// does, since detection runs again over the freshly re-read text.
async function magReocrMagazine() {
if (!state.doc) return;
const hasReviewed = state.articles.some((a) => a.status !== 'detected');
if (hasReviewed && !confirm(
'Re-reading the whole magazine runs article detection again. ' +
'Any articles you already approved or rejected will need reviewing again. Continue?'
)) return;
try {
await api('/api/magazine/' + state.doc.id + '/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force_vision_ocr: true }),
});
pollIngest();
} catch (e) {
say('Could not start OCR: ' + e.message, 'err');
}
}
// "Analysing page 28 of 204" -> a real percentage, not just spinner text.
function magParsePageProgress(detail) {
const m = /page (\d+) of (\d+)/i.exec(detail || '');
if (!m) return null;
const page = parseInt(m[1], 10), total = parseInt(m[2], 10);
if (!total) return null;
return { page, total, pct: Math.min(100, Math.round((page / total) * 100)) };
}
// ── Live boxes on the page being read ─────────────────────────────────────
// The regions of the page currently being read (and, in the labelling pass, being re-typed) are
// drawn over the page image as they arrive, coloured by type and numbered in reading order.
const MAG_PREVIEW_TYPES = {
title: 'Headline', heading: 'Subheading', deck: 'Intro / Standfirst', byline: 'Author', body: 'Paragraph',
quote: 'Quote', caption: 'Caption', pullquote: 'Pullquote', sidebar: 'Sidenote', ad: 'Advertisement',
furniture: 'Running head / footer', page_number: 'Page number', image: 'Picture',
figure: 'Picture', chart: 'Chart', table: 'Table', code: 'Code',
};
let _magPreviewSeen = { page: null, keys: new Set() };
// Fit the box layer to the part of the frame the page image actually covers ("contain" scaling
// leaves bars beside or below it -- the page is top-aligned), so a box drawn at 50% sits at 50% of
// the page, not of the frame.
function magFitPreviewLayer() {
const img = $('mag-ingest-preview-img'), layer = $('mag-ingest-preview-boxes');
if (!img || !layer || !img.naturalWidth || !img.clientWidth) return;
const scale = Math.min(img.clientWidth / img.naturalWidth, img.clientHeight / img.naturalHeight);
const w = img.naturalWidth * scale, h = img.naturalHeight * scale;
layer.style.left = (img.offsetLeft + (img.clientWidth - w) / 2) + 'px';
layer.style.top = img.offsetTop + 'px'; // the page is drawn at the top of its frame (object-position: top)
layer.style.width = w + 'px';
layer.style.height = h + 'px';
}
function magRenderPreviewBoxes(page, boxes, stage) {
const layer = $('mag-ingest-preview-boxes'), img = $('mag-ingest-preview-img');
if (!layer) return;
const stageEl = $('mag-ingest-preview-stage');
if (stageEl) stageEl.textContent = page ? '· ' + page + (stage === 'labelling' ? ' — labelling regions' : ' — reading') : '';
// A page-sized embedded picture is the page scan itself, not a picture on the page (the server
// drops it too; this also covers a preview written by an older server).
boxes = (boxes || []).filter((b) => !(b.type === 'image' && b.w * b.h >= 8500));
if (_magPreviewSeen.page !== page) _magPreviewSeen = { page, keys: new Set() };
const key = (b) => [b.x, b.y, b.w, b.h].join(',');
layer.innerHTML = boxes.map((b) => {
const isNew = !_magPreviewSeen.keys.has(key(b));
const type = MAG_PREVIEW_TYPES[b.type] ? b.type : 'body';
return '<div class="mag-preview-box mpt-' + type + (isNew ? ' is-new' : '') + '" style="left:' + b.x + '%;top:' + b.y +
'%;width:' + b.w + '%;height:' + b.h + '%" title="' + esc(MAG_PREVIEW_TYPES[type]) + '">' +
(b.n ? '<span class="mag-preview-box-num">' + b.n + '</span>' : '') + '</div>';
}).join('');
boxes.forEach((b) => _magPreviewSeen.keys.add(key(b)));
const legend = $('mag-ingest-preview-legend');
if (legend) {
// One entry per label: a picture region and an embedded picture are both "Picture".
const byLabel = new Map();
boxes.forEach((b) => { const t = MAG_PREVIEW_TYPES[b.type] ? b.type : 'body'; if (!byLabel.has(MAG_PREVIEW_TYPES[t])) byLabel.set(MAG_PREVIEW_TYPES[t], t); });
legend.innerHTML = [...byLabel].map(([label, t]) => '<span class="mag-preview-legend-item"><span class="mag-preview-swatch mpt-' + t + '"></span>' +
esc(label) + '</span>').join('') +
// The first pass only reads the text; pictures, charts and tables are recognised in the second.
(stage === 'reading' ? '<span class="mag-preview-legend-hint">Pictures, charts and tables are marked in the second pass</span>' : '') +
(boxes.length ? '<span class="mag-preview-legend-count">' + boxes.length + ' regions</span>' : '');
}
if (img && !img.dataset.fitBound) {
img.dataset.fitBound = '1';
img.addEventListener('load', magFitPreviewLayer);
if (typeof ResizeObserver === 'function') new ResizeObserver(magFitPreviewLayer).observe(img);
}
magFitPreviewLayer();
}
async function pollIngest() {
const bar = $('mag-ingest-status');
const preview = $('mag-ingest-preview');
if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; }
let lastPreviewPage = null;
for (;;) {
let data;
try {
data = await api('/api/magazine/' + state.doc.id + '/ingest/status');
} catch (_) { break; }
const ing = data.ingest || {};
const detail = (data.job || {}).detail || '';
magMarkLive(ing.status === 'running' ? data.job : null, true);
if (bar) {
if (ing.status === 'running') {
bar.className = 'mag-status is-running';
const p = magParsePageProgress(detail);
bar.innerHTML =
'<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 + '?t=' + Date.now();
}
magRenderPreviewBoxes(data.preview.page, data.preview.boxes || [], data.preview.stage);
const textEl = $('mag-ingest-preview-text');
// Server-built HTML (see _render_ingest_preview_html in
// routes/magazine.py) -- every text node in it is already escaped,
// so this is the one place that inner-HTMLs a live server value.
if (textEl) textEl.innerHTML = data.preview.text || '';
} else if (ing.status !== 'running') {
preview.hidden = true;
}
}
if (ing.status !== 'running') {
if (ing.status === 'done') {
// Ingestion now runs the same automatic Auto-fix pass "Auto-fix all pages" runs by
// hand before reporting done (see _ingest_sync) -- show its summary here too, exactly
// as magQualityRun does on its own completion, so the reviewer sees what was fixed
// and what still needs a decision without an extra click.
await magRefreshArticles();
state.layout = null;
try { magRenderQualityReport(await api('/api/magazine/' + state.doc.id + '/quality/report')); } catch (_) { /* no report yet */ }
await magLoadQuality(true);
}
break;
}
await new Promise((r) => setTimeout(r, 2000));
}
}
async function magRefreshArticles() {
if (!state.doc) return;
try {
const data = await api('/api/magazine/' + state.doc.id + '/articles');
state.articles = data.articles || [];
state.doc.progress = data.progress || {};
state.doc.podcast = data.podcast || { hosts: [] };
state.doc.default_voice = data.default_voice || '';
} catch (_) {
state.articles = [];
}
renderContents();
}
// ── Contents tree ────────────────────────────────────────────────────────────
function confidenceLabel(c) {
// 0.55 is the backend's explicit heuristic marker: no reliable contents
// entry was available, so an article was started from a page headline. It
// is not a calibrated probability and a column of identical “55%” badges
// falsely suggests a measured OCR score.
if (Math.abs(Number(c) - 0.55) < 0.001) {
return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' };
}
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' };
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' };
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' };
}
// ── Page coverage ────────────────────────────────────────────────────────
// "Approve" only ever advanced to the next ARTICLE, so a page that never
// got grouped into any article -- an ad, a missed piece, a page the
// detection pass just skipped -- was never surfaced by that workflow at
// all. Confirmed live: 73 of 204 pages (36%) in one real issue belonged to
// no article whatsoever. A banner listing every such gap used to live here
// too, but the "All pages" view's own per-card "no article" tag already
// says the same thing in context, so it was just a second, redundant
// warning -- removed.
// The next page after `afterPage` that still needs a human decision -- no
// article covers it at all, or one does but is still sitting at its
// detected/unreviewed status. A page touched by several articles only
// counts as resolved once none of them are still pending.
function magNextUnresolvedPage(afterPage) {
const pageCount = state.doc?.pageCount || 0;
const byPage = new Map();
state.articles.forEach((a) => (a.pages || []).forEach((p) => {
if (!byPage.has(p)) byPage.set(p, []);
byPage.get(p).push(a);
}));
for (let p = afterPage + 1; p <= pageCount; p++) {
const arts = byPage.get(p) || [];
if (!arts.length || arts.some((a) => a.status === 'detected')) return p;
}
return null;
}
// Opens Review at a specific page even when no article covers it, by
// anchoring on whichever approved-or-not article is nearest that page (the
// review UI needs *an* article as context, but its own page navigation is
// not restricted to that article's pages) and then jumping.
async function magReviewPage(pageNumber) {
// Reuse whatever already covers this page if something does (idempotent
// if called twice for the same page); otherwise give the page its own
// article record rather than borrowing an unrelated one as review
// context -- confirmed live: Approve and "Not an article" both act on
// whatever article is open, so borrowing one meant either action would
// have silently changed a completely different, already-fine article.
let article = state.articles.find((a) => (a.pages || []).includes(pageNumber));
if (!article) {
try {
const r = await api('/api/magazine/' + state.doc.id + '/articles', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page: pageNumber, title: 'Page ' + pageNumber }),
});
article = r.article;
state.articles.push(article);
} catch (e) { return say('Could not open page ' + pageNumber + ': ' + e.message, 'err'); }
}
await magReview(article.id);
magReviewGoMagazinePage(pageNumber);
}
function renderContents() {
const host = $('mag-contents');
if (!host) return;
const title = $('mag-doc-title');
if (title && state.doc && document.activeElement !== title) title.textContent = state.doc.title || 'Magazine';
const issueInput = $('mag-doc-issue');
if (issueInput && state.doc && document.activeElement !== issueInput) issueInput.value = state.doc.issue || '';
if (!state.articles.length) {
host.innerHTML = '<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);
});
const toggleBtn = $('mag-contents-view-toggle');
if (toggleBtn) {
const nextLabel = { list: 'Thumbnails', pages: 'List' }[state.contentsView];
const nextIcon = { list: 'mdi-view-grid-outline', pages: 'mdi-view-list-outline' }[state.contentsView];
toggleBtn.innerHTML = '<span class="mdi ' + nextIcon + '"></span> ' + nextLabel;
toggleBtn.setAttribute('aria-pressed', String(state.contentsView !== 'list'));
toggleBtn.title = 'Currently: ' + { list: 'article list', pages: 'page thumbnails' }[state.contentsView] +
'. Click to switch to ' + nextLabel.toLowerCase() + '.';
}
const colsControl = $('mag-cols-control');
if (colsControl) colsControl.hidden = state.contentsView !== 'pages';
if (state.contentsView === 'pages') {
const range = $('mag-cols-range'), valueEl = $('mag-cols-value');
if (range) range.value = state.pagesCols;
if (valueEl) valueEl.textContent = String(state.pagesCols);
renderPagesView(host);
renderContentsBulkBar();
magUpdateAudioZipLink();
return;
}
// Marked with an icon and words, never colour alone.
const synthHtml = (a) => a.has_audio
? '<span class="mag-synth is-done" title="MP3 rendered"><span class="mdi mdi-waveform" aria-hidden="true"></span> Synthesised' +
(a.audio_seconds ? ' · ' + magFmtClock(a.audio_seconds) : '') + '</span>'
: a.status === 'approved'
? '<span class="mag-synth" title="Approved, no MP3 yet"><span class="mdi mdi-microphone-off" aria-hidden="true"></span> Not synthesised</span>'
: '';
const actionsHtml = (a, done, progress) =>
(a.has_audio
? '<button type="button" class="btn-sm mag-toc-play" data-toc-play="' + esc(a.id) + '" data-title="' + esc(a.title || 'article') +
'" aria-pressed="false" aria-label="Play ' + esc(a.title || 'article') + '" onclick="magTocPlay(\'' + a.id + '\')">' +
'<span class="mdi mdi-play" aria-hidden="true"></span></button>'
: '') +
'<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>'
: '') +
(a.has_audio
? '<a class="btn-sm" href="/api/magazine/' + esc(state.doc.id) + '/articles/' + a.id + '/audio" download ' +
'title="Download the exported MP3 (with title, author, cover and lyrics tags)"><span class="mdi mdi-download"></span> MP3</a>'
: '');
const selectHtml = (a) => !state.contentsSelectMode ? '' :
'<label class="mag-select-check" onclick="event.stopPropagation()">' +
'<input type="checkbox" ' + (state.contentsSelected.has(a.id) ? 'checked' : '') +
' onchange="magContentsToggleArticle(\'' + a.id + '\')" aria-label="Select ' + esc(a.title || 'this article') + '"></label>';
// Detection routinely mangles a title through OCR ("Sbimmb das? c'b
// prüFb") -- fixing it should not require opening Review. contenteditable
// rather than swapping in an <input> keeps the card's own font/wrapping
// instead of a generic text box.
const titleHtml = (a, cls) =>
'<span class="' + cls + '" contenteditable="true" spellcheck="false" ' +
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
'onclick="event.stopPropagation()" title="Click to edit the title">' +
esc(a.title || 'Untitled') + '</span>';
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];
// A piece nobody has approved or rejected yet is "not reviewed": blue, so the ones that
// still need a person stand out from the approved (green edge) at a glance.
const unreviewed = !done && a.status !== 'rejected';
const statusBadge = '<span class="mag-pill ' + (done ? 'ok' : unreviewed ? 'unreviewed' : '') + '">' +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'not reviewed') + '</span>';
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + (unreviewed ? ' is-unreviewed' : '') + '">' +
selectHtml(a) +
'<div class="mag-toc-main">' +
titleHtml(a, 'mag-toc-title') +
(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>' +
statusBadge + synthHtml(a) +
'</div>' +
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div></li>';
});
html += '</ul></div>';
});
host.innerHTML = html;
renderContentsBulkBar();
magUpdateAudioZipLink();
magTocSyncButtons();
}
const MAG_CONTENTS_VIEW_CYCLE = ['list', 'pages'];
const MAG_PAGES_COLS_KEY = 'ttsvc_mag_pages_cols';
// Every page, 1..pageCount, regardless of whether an article claims it --
// the article grid/list above can only ever show what detection already
// grouped, which is exactly what left 36% of one real issue invisible (see
// magUncoveredPageRanges). This is the complete picture: a page can belong
// to zero articles (never reviewed), one, or several (a page shared by two
// short pieces), and this shows all three cases rather than picking one.
// One of five states per page, cheap to compute from data already loaded
// (no extra layout/OCR fetch just to colour an overview grid):
// blank -- no article touches this page at all yet
// detected -- an article does, but it's still sitting at its
// just-detected status -- OCR ran, a human hasn't looked
// approved -- reviewed and approved, no audio rendered yet
// audio -- reviewed, approved, and has a generated MP3
// empty -- reviewed and rejected ("Not an article") on every article
// that touches it -- a blank page or an ad, not a gap
// A page touched by several articles takes the most-attention-needed state
// among them (audio beats approved beats detected beats empty).
// The last whole-issue quality run splits the two unreviewed states further:
// checked -- quality run found nothing left to fix (blue)
// flagged -- quality run found uncovered text / overlaps / clipping / suspect words (purple)
// flagged shows only while nothing on the page is approved (an approval wins; old audio does not count); clean only replaces 'unreviewed'.
// Mirrors page_is_advertisement() in core/magazine.py: flagged, or detection found advertisement
// regions and nothing narratable on the page.
const MAG_NARRATABLE_TYPES = ['title', 'heading', 'deck', 'byline', 'body', 'quote'];
function magIsAdPage(page) {
const layout = state.layout;
if (!layout) return false;
if ((layout.pages || []).find((m) => m.page === page)?.advertisement) return true;
const onPage = (layout.blocks || []).filter((b) => b.page === page);
return onPage.some((b) => b.type === 'ad') && !onPage.some((b) => MAG_NARRATABLE_TYPES.includes(b.type));
}
function magPageState(arts, q, page) {
// A page the quality run flagged is purple until somebody has looked at it: once one of its
// articles is approved (or has audio) the reviewer's decision wins and the page shows its review
// colour; the warning badge on the card still lists what the check found. A page whose every
// article was rejected is a settled "nothing to narrate".
const flagged = !!(q && q.needs_review);
if (page != null && magIsAdPage(page)) return 'ad'; // approved as advertisement: settled, green
const rejectedOnly = arts.length > 0 && arts.every((a) => a.status === 'rejected');
// Audio does not count as a review: an article nobody approved can still carry an MP3 from before
// its pages were re-linked.
const reviewed = arts.some((a) => a.status === 'approved');
if (flagged && !rejectedOnly && !reviewed) return 'flagged';
const unreviewed = q ? 'checked' : null;
if (!arts.length) return unreviewed || 'blank';
if (arts.some((a) => a.has_audio && a.status === 'approved')) return 'audio';
if (arts.some((a) => a.status === 'detected')) return unreviewed || 'detected';
if (arts.some((a) => a.status === 'approved')) return 'approved';
return 'empty';
}
// Quality-run rows by page, loaded once per document (and refreshed after a run).
async function magLoadQuality(force) {
if (!state.doc) return;
if (!force && state.qualityDocId === state.doc.id) return;
const docId = state.doc.id;
state.qualityDocId = docId;
state.quality = new Map();
try {
const report = await api('/api/magazine/' + docId + '/quality/report');
if (state.doc && state.doc.id === docId) {
state.quality = new Map((report.pages || []).map((row) => [row.page, row]));
}
} catch (_) { /* no quality run yet */ }
if (state.doc && state.doc.id === docId && state.contentsView === 'pages' && state.view !== 'review') renderContents();
}
// Live marks while any batch step runs (ingest/OCR, quality run, figure fixing, audio export):
// each server job reports {page | pages, step} in its result. With `sequential`, pages before
// the working page get a tick. Applied straight to the existing cards -- no re-render per poll.
// Pass nothing to clear the marks.
function magMarkLive(job, sequential) {
const r = job && job.result;
const working = new Set(r && Array.isArray(r.pages) ? r.pages : (r && r.page != null ? [r.page] : []));
const first = working.size ? Math.min(...working) : null;
const step = r && r.step;
document.querySelectorAll('.mag-pages-grid .mag-card-wrap').forEach((wrap) => {
const n = Number(wrap.dataset.page);
const card = wrap.querySelector('.mag-card');
const isWorking = working.has(n);
const isDone = !!sequential && first != null && n < first;
card.classList.toggle('is-page-working', isWorking);
card.classList.toggle('is-page-live-done', isDone);
let tag = card.querySelector('.mag-live-tag');
if (isWorking || isDone) {
if (!tag) { tag = document.createElement('span'); tag.className = 'mag-live-tag'; card.prepend(tag); }
tag.textContent = isWorking ? (step || 'working…') : '✓ done';
} else if (tag) tag.remove();
});
}
function magQualityProblems(q) {
const bits = [];
if (q.uncovered.length) bits.push(q.uncovered.length + ' text area' + (q.uncovered.length === 1 ? '' : 's') + ' outside any box');
if (q.overlaps.length) bits.push(q.overlaps.length + ' overlapping box pair' + (q.overlaps.length === 1 ? '' : 's'));
if (q.clipped.length) bits.push(q.clipped.length + ' box edge' + (q.clipped.length === 1 ? '' : 's') + ' cutting into text');
if (q.suspect_blocks.length) bits.push(q.suspect_blocks.length + ' region' + (q.suspect_blocks.length === 1 ? '' : 's') + ' with suspect OCR words');
if ((q.duplicates || []).length) bits.push((q.duplicates.length) + ' articles claim this page (read more than once)');
if (q.error) bits.push('the check itself failed');
return bits;
}
// Overlay on the thumbnail itself: a warning mark with the problem count, hover/focus text
// listing what was found, and a click that opens that page in review.
function magQualityBadge(q, page) {
if (!q || !q.needs_review) return '';
const bits = magQualityProblems(q);
const text = 'Page ' + page + ': ' + bits.join('; ');
return '<button type="button" class="mag-thumb-flag" title="' + esc(text) + '" aria-label="' + esc(text + '. Open in review') +
'" onclick="magReviewPage(' + Number(page) + ')"><span class="mdi mdi-alert-circle-outline" aria-hidden="true"></span> ' +
bits.length + '</button>';
}
// What each page card tells the reader, in words (colour alone is not enough): what state the page is in,
// which articles sit on it and how they flow to neighbouring pages, and what -- if anything -- the reader still has to
// do because the system could not settle it alone. Pure: the card markup only renders what this returns.
const MAG_PAGE_LABELS = {
blank: ['Not looked at yet', 'mdi-help-circle-outline'],
detected: ['Text found, not checked yet', 'mdi-text-search'],
checked: ['Checked: no problems found', 'mdi-check-circle-outline'],
flagged: ['Problems found', 'mdi-alert-circle-outline'],
approved: ['Approved', 'mdi-check-decagram-outline'],
audio: ['Done: audio is ready', 'mdi-volume-high'],
ad: ['Advertisement: not read', 'mdi-bullhorn-outline'],
empty: ['Reviewed: nothing to read', 'mdi-check'],
none: ['No article on this page', 'mdi-file-hidden'],
};
// Where an article comes from and goes to around `page`, by its own page list.
function magArticleFlow(article, page) {
const pages = (article?.pages || []).slice().sort((a, b) => a - b);
const i = pages.indexOf(page);
if (i < 0) return { from: null, to: null };
return { from: i > 0 ? pages[i - 1] : null, to: i < pages.length - 1 ? pages[i + 1] : null };
}
function magQualityActions(q) {
const n = (list) => (list || []).length;
const plural = (k, one, many) => k + ' ' + (k === 1 ? one : many);
const out = [];
if (n(q.uncovered)) out.push(plural(n(q.uncovered), 'text area lies', 'text areas lie') + ' outside every box: use "Find missed text"');
if (n(q.overlaps)) out.push(plural(n(q.overlaps), 'pair of boxes overlaps', 'pairs of boxes overlap') + ': merge, shrink or delete one');
if (n(q.clipped)) out.push(plural(n(q.clipped), 'box edge cuts', 'box edges cut') + ' into text: use "Clean up boxes"');
if (n(q.suspect_blocks)) out.push(plural(n(q.suspect_blocks), 'region has', 'regions have') + ' doubtful words: check them with "Spelling"');
if (n(q.duplicates)) out.push('several articles claim this page: keep it in one of them');
if (q.error) out.push('the check itself failed: run it again');
return out;
}
function magPageGuide({ page, articles, quality, stateName, hasText }) {
const arts = articles || [];
const name = !arts.length && stateName !== 'ad' ? 'none' : stateName;
const [label, icon] = MAG_PAGE_LABELS[name] || MAG_PAGE_LABELS.blank;
const todo = [];
let note = '';
if (!arts.length) {
if (stateName !== 'ad' && hasText) todo.push('Text was found here but it belongs to no article: open the page and link it to the article it continues, or start a new one');
} else if (stateName === 'flagged') {
const actions = magQualityActions(quality || {});
actions.slice(0, 3).forEach((t) => todo.push(t.charAt(0).toUpperCase() + t.slice(1)));
if (actions.length > 3) todo.push('and ' + (actions.length - 3) + ' more: open the page for the full list');
if (!actions.length) todo.push('Open the page and check what the quality run found');
todo.push('Then approve the page');
} else if (stateName === 'detected' || stateName === 'checked') {
todo.push(stateName === 'checked'
? 'Nothing wrong was found, but nobody has confirmed it: open the page and approve it'
: 'Open the page, check the boxes and approve it');
} else if (stateName === 'approved' && arts.some((a) => a.status === 'approved' && !a.has_audio)) {
todo.push('Generate the audio (toolbar: "Generate all audio")');
}
// A page's colour follows its most advanced article ("audio" beats "approved" beats "detected"), so a page can
// look finished while another article on it still waits. Say so whatever the colour is.
const waiting = arts.filter((a) => a.status === 'detected');
if (arts.length > 1 && waiting.length && stateName !== 'flagged') {
todo.push(waiting.length + ' of the ' + arts.length + ' articles here still wait for approval: open the page and approve ' + (waiting.length === 1 ? 'it' : 'them'));
}
if (arts.some((a) => a.status === 'approved') && quality && quality.needs_review && stateName !== 'flagged') {
const actions = magQualityActions(quality);
if (actions.length) note = 'You approved this page, but the quality check had found: ' + actions.slice(0, 2).join('; ');
}
// Never call a page "Done" while it still asks something of the reader.
const finalLabel = todo.length && name === 'audio' ? 'Partly done: some audio is ready' : label;
return { label: finalLabel, icon, name, todo, needs: todo.length > 0, note };
}
function magPageHasText(page) {
const layout = state.layout;
if (!layout) return false;
return (layout.blocks || []).some((b) => b.page === page && MAG_NARRATABLE_TYPES.includes(b.type) && String(b.text || '').trim());
}
function magTogglePagesTodo(on) {
state.pagesOnlyTodo = !!on;
renderContents();
}
// Filter the page grid by status: each legend entry is a toggle, several can be on at once (a page matches if its status
// is any of the chosen ones), and it combines with "Show only pages that need me". The choice survives re-renders.
function magTogglePagesStatus(status) {
const set = state.pagesStatus || (state.pagesStatus = new Set());
if (set.has(status)) set.delete(status); else set.add(status);
renderContents();
}
function magClearPagesFilters() {
state.pagesStatus = new Set();
state.pagesOnlyTodo = false;
renderContents();
}
// Which pages the grid shows, given the current filters. Pure, so it can be tested without a DOM.
function magPageVisible(statusName, needs, filters) {
const f = filters || {};
if (f.onlyTodo && !needs) return false;
const chosen = f.status instanceof Set ? f.status : new Set(f.status || []);
return chosen.size === 0 || chosen.has(statusName);
}
function renderPagesView(host) {
if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
const quality = state.quality || new Map();
const pageCount = state.doc?.pageCount || 0;
if (!pageCount) { host.innerHTML = '<p class="muted">Page count unknown yet.</p>'; return; }
const byPage = new Map();
state.articles.forEach((a) => (a.pages || []).forEach((p) => {
if (!byPage.has(p)) byPage.set(p, []);
byPage.get(p).push(a);
}));
const legend = [
['blank', 'not reviewed yet'], ['detected', 'has content, not quality-checked'],
['checked', 'quality check clean'], ['flagged', 'quality check found problems'],
['approved', 'reviewed'], ['ad', 'advertisement (detected, not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'],
];
// The guide for every page first, so the summary can say how many pages still need the reader.
const guides = new Map();
for (let p = 1; p <= pageCount; p++) {
const arts = byPage.get(p) || [];
const st = magPageState(arts, quality.get(p), p);
guides.set(p, { st, arts, guide: magPageGuide({ page: p, articles: arts, quality: quality.get(p), stateName: st, hasText: magPageHasText(p) }) });
}
const needing = [...guides.values()].filter((g) => g.guide.needs).length;
const counts = new Map();
guides.forEach((g) => counts.set(g.st, (counts.get(g.st) || 0) + 1));
const chosen = state.pagesStatus instanceof Set ? state.pagesStatus : new Set();
const filters = { onlyTodo: state.pagesOnlyTodo, status: chosen };
const shown = [...guides.values()].filter((g) => magPageVisible(g.st, g.guide.needs, filters)).length;
let html = '<div class="mag-pages-help">' +
'<p class="mag-pages-summary" role="status"><strong>' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + '</strong> ' +
(shown !== pageCount ? '<span class="mag-pages-shown"> Showing ' + shown + ' of ' + pageCount + ' pages.</span> ' : '') +
'Each card says what state the page is in, how many articles are on it, and what to do next.</p>' +
'<label class="mag-pages-filter"><input type="checkbox" ' + (state.pagesOnlyTodo ? 'checked' : '') + ' onchange="magTogglePagesTodo(this.checked)"> Show only pages that need me</label>' +
'<div class="mag-pages-legend" role="group" aria-label="Filter the pages by status">' +
legend.map(([cls, label]) => {
const n = counts.get(cls) || 0;
const on = chosen.has(cls);
return '<button type="button" class="mag-pages-legend-item mag-legend-filter' + (on ? ' is-on' : '') + '" aria-pressed="' + on + '" data-status="' + cls + '"' +
(!n && !on ? ' disabled' : '') + ' onclick="magTogglePagesStatus(\'' + cls + '\')" title="' + (on ? 'Stop showing only' : 'Show only') + ' pages that are: ' + esc(label) + '">' +
'<span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + esc(label) + ' <span class="mag-legend-count">' + n + '</span></button>';
}).join('') +
(chosen.size || state.pagesOnlyTodo ? '<button type="button" class="btn-sm mag-legend-clear" onclick="magClearPagesFilters()">Show all pages</button>' : '') +
'</div></div>';
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
for (let p = 1; p <= pageCount; p++) {
const { st, arts, guide } = guides.get(p);
if (!magPageVisible(st, guide.needs, filters)) continue;
// The thumbnail is the big, easy target: it opens the page in review (in the context of the
// first article on it). A separate Review button per article only appears when the page
// really holds several, to say which one is meant.
const reviewJs = arts.length ? 'magReviewAtPage(\'' + arts[0].id + '\', ' + p + ')' : 'magReviewPage(' + p + ')';
const flowChips = (a) => {
const f = magArticleFlow(a, p);
return (f.from ? '<span class="mag-flow" title="This article started on page ' + f.from + '"><span class="mdi mdi-arrow-left-bottom" aria-hidden="true"></span> continues from p.' + f.from + '</span>' : '') +
(f.to ? '<span class="mag-flow" title="This article goes on on page ' + f.to + '"><span class="mdi mdi-arrow-right-bottom" aria-hidden="true"></span> continues on p.' + f.to + '</span>' : '');
};
html += '<div class="mag-card-wrap" data-page="' + p + '"><div class="mag-card is-page-' + st + (guide.needs ? ' needs-you' : '') + '">' +
'<button type="button" class="mag-card-thumb-btn" title="Review page ' + p + '" aria-label="Review page ' + p + '" onclick="' + reviewJs + '">' +
'<img class="mag-card-thumb" loading="lazy" alt="Page ' + p + '" src="' + esc(magPageSrc(p)) + '"></button>' +
'<span class="mag-card-title">Page ' + p + '</span>' +
'<span class="mag-card-count">' + (arts.length ? arts.length + (arts.length === 1 ? ' article' : ' articles') : 'no article') + '</span>' +
magQualityBadge(quality.get(p), p) +
(arts.length
? '<div class="mag-page-articles">' + arts.map((a) =>
'<div class="mag-page-article">' +
'<span class="mag-page-article-title">' + esc(a.title || 'Untitled') + '</span>' +
(flowChips(a) ? '<span class="mag-flows">' + flowChips(a) + '</span>' : '') +
'<span class="mag-page-article-actions">' +
(arts.length > 1 ? '<button type="button" class="btn-sm" aria-label="Review ' + esc(a.title || 'article') + '" onclick="magReviewAtPage(\'' + a.id + '\', ' + p + ')">Review</button>' : '') +
(a.has_audio ? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')" ' +
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi mdi-play" aria-hidden="true"></span> Play</button>' : '') +
'</span>' +
'</div>').join('') + '</div>'
: '') +
'<div class="mag-page-guide">' +
'<span class="mag-page-status"><span class="mdi ' + guide.icon + '" aria-hidden="true"></span> ' + esc(guide.label) + '</span>' +
(guide.needs
? '<div class="mag-page-todo"><strong>To do:</strong><ul>' + guide.todo.map((t) => '<li>' + esc(t) + '</li>').join('') + '</ul></div>'
: '<span class="mag-page-done"><span class="mdi mdi-check" aria-hidden="true"></span> Nothing to do here</span>') +
(guide.note ? '<span class="mag-page-note">' + esc(guide.note) + '</span>' : '') +
'</div>' +
'</div></div>';
}
if (!shown) html += '<p class="muted">' + (chosen.size ? 'No page has that status' + (state.pagesOnlyTodo ? ' and needs you' : '') + '.' : 'No page needs you.') + '</p>';
html += '</div>';
host.innerHTML = html;
}
function magSetPagesCols(n) {
state.pagesCols = Math.max(1, Math.min(12, parseInt(n, 10) || 8));
try { localStorage.setItem(MAG_PAGES_COLS_KEY, String(state.pagesCols)); } catch (_) {}
const valueEl = $('mag-cols-value');
if (valueEl) valueEl.textContent = String(state.pagesCols);
const grid = document.querySelector('.mag-pages-grid');
if (grid) grid.style.setProperty('--cols', state.pagesCols);
}
// Editing the magazine's own title/issue was previously only possible at
// upload time -- "Issue" is optional there, so the common case left it
// permanently blank with no way to add it later. It matters beyond just
// display now: the exported MP3 filename's year/month come from parsing
// this field (see _parse_issue_year_month in routes/magazine.py).
async function magCommitDocField(el, field) {
const value = (field === 'title' ? el.textContent : el.value).replace(/\s+/g, ' ').trim();
const previous = state.doc[field];
if (value === (previous || '')) return;
if (field === 'title' && !value) { el.textContent = previous || 'Magazine'; return; }
state.doc[field] = value;
try {
await api('/api/magazine/' + state.doc.id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value }),
});
say(field === 'title' ? 'Title updated' : 'Issue updated', 'ok');
} catch (e) {
state.doc[field] = previous;
if (field === 'title') el.textContent = previous || 'Magazine'; else el.value = previous || '';
say('Could not save ' + field + ': ' + e.message, 'err');
}
}
function magToggleContentsView() {
const i = MAG_CONTENTS_VIEW_CYCLE.indexOf(state.contentsView);
state.contentsView = MAG_CONTENTS_VIEW_CYCLE[(i + 1) % MAG_CONTENTS_VIEW_CYCLE.length];
try { localStorage.setItem(MAG_CONTENTS_VIEW_KEY, state.contentsView); } catch (_) {}
renderContents();
}
// ── Batch approve/reject from the article list ──────────────────────────────
// Reviewing every single article one at a time does not scale to a 200-page
// issue with 50+ detected pieces. This accepts each selected article exactly
// as auto-detected (no block-list edit, unlike a normal Approve from inside
// review) -- a deliberate trade-off: bulk action is for the pieces that
// already look right, not a replacement for reviewing a messy one.
function magContentsToggleSelect() {
state.contentsSelectMode = !state.contentsSelectMode;
if (!state.contentsSelectMode) state.contentsSelected.clear();
const btn = $('mag-contents-select-toggle');
if (btn) btn.setAttribute('aria-pressed', String(state.contentsSelectMode));
renderContents();
}
async function magContentsCommitTitle(el, id) {
const a = state.articles.find((x) => x.id === id);
if (!a) return;
const value = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; }
const previous = a.title;
a.title = value;
try {
await api('/api/magazine/' + state.doc.id + '/articles/' + id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: value }),
});
} catch (e) {
a.title = previous;
el.textContent = previous || 'Untitled';
say('Could not save the title: ' + e.message, 'err');
}
}
function magContentsToggleArticle(id) {
if (state.contentsSelected.has(id)) state.contentsSelected.delete(id);
else state.contentsSelected.add(id);
renderContents();
}
function magContentsSelectAllVisible() {
state.articles.forEach((a) => state.contentsSelected.add(a.id));
renderContents();
}
function magContentsClearSelection() {
state.contentsSelected.clear();
renderContents();
}
async function magContentsBulkStatus(status) {
const ids = Array.from(state.contentsSelected);
if (!ids.length) return;
const bar = $('mag-contents-bulkbar');
let done = 0;
for (const id of ids) {
if (bar) bar.querySelector('.mag-bulkbar-progress')?.replaceChildren(
document.createTextNode(`${status === 'approved' ? 'Approving' : 'Rejecting'} ${done + 1} of ${ids.length}…`));
try {
await api('/api/magazine/' + state.doc.id + '/articles/' + id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
} catch (e) { say('Could not update ' + id + ': ' + e.message, 'err'); }
done += 1;
}
state.contentsSelected.clear();
say(`${done} article${done === 1 ? '' : 's'} ${status}`, 'ok');
await magRefreshArticles();
renderContents();
}
function renderContentsBulkBar() {
const bar = $('mag-contents-bulkbar');
if (!bar) return;
const n = state.contentsSelected.size;
if (!state.contentsSelectMode) { bar.hidden = true; bar.innerHTML = ''; return; }
bar.hidden = false;
bar.innerHTML =
'<span class="mag-bulkbar-count">' + n + ' selected</span>' +
'<span class="mag-bulkbar-progress muted"></span>' +
'<div class="mag-bulkbar-actions">' +
'<button type="button" class="btn-sm" onclick="magContentsSelectAllVisible()">Select all</button>' +
'<button type="button" class="btn-sm" onclick="magContentsClearSelection()" ' + (n ? '' : 'disabled') + '>Clear</button>' +
'<button type="button" class="btn-sm" onclick="magContentsBulkStatus(\'rejected\')" ' + (n ? '' : 'disabled') + '>Reject selected</button>' +
'<button type="button" class="btn-sm primary" onclick="magContentsBulkStatus(\'approved\')" ' + (n ? '' : 'disabled') + '>Approve selected</button>' +
'</div>';
}
// ── Standalone MP3 export (title/author/cover/lyrics tags) ─────────────────
// For listening away from this app entirely -- a phone's own music/podcast
// player, a car head unit -- not the in-browser sentence-by-sentence player.
async function magGenerateAllAudio(force = false) {
const voice = ($('mag-audio-voice')?.value || '').trim();
const interviewVoice = ($('mag-audio-interview-voice')?.value || '').trim();
if (!voice) return say('Pick a narrator voice for the export first', 'err');
// Skip whatever already has audio -- resuming after an aborted or
// partial run (confirmed live: a magazine this size can genuinely take
// a while, and killing the job partway through, deliberately or via a
// container restart, is a real scenario, not an edge case) should not
// mean re-synthesising articles that are already done.
const missingArticles = state.articles.filter((a) => a.status === 'approved' && (force || !a.has_audio));
if (!missingArticles.length) return say('Every approved article already has audio', '');
const missing = missingArticles.map((a) => a.id);
const pageCount = state.doc.pageCount || 0;
const btn = $('mag-audio-export-btn');
const rebuildBtn = $('mag-audio-rebuild-btn');
const status = $('mag-audio-export-status');
const progressWrap = $('mag-audio-export-progress-wrap');
const progressBar = $('mag-audio-export-progress');
const progressCount = $('mag-audio-export-count');
if (btn) btn.disabled = true;
if (rebuildBtn) rebuildBtn.disabled = true;
if (progressWrap) progressWrap.hidden = false;
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/audio/batch', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice, interview_voice: interviewVoice, article_ids: missing }),
});
jobId = r.job_id;
} catch (e) {
if (btn) btn.disabled = false;
if (rebuildBtn) rebuildBtn.disabled = false;
if (progressWrap) progressWrap.hidden = true;
return say('Could not start audio export: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); }
catch (e) {
if (btn) btn.disabled = false;
if (rebuildBtn) rebuildBtn.disabled = false;
return say('Could not check audio export: ' + e.message, 'err');
}
if (status) status.textContent = job.detail || 'Working…';
magMarkLive(job.done ? null : job, false);
// The job's own detail text always leads with "N/Total" -- either
// "Article N/Total…" between articles, or "(N/Total) Title: sentence
// X/Y" while one is actively rendering. "Article 6 of 44" means little
// on its own; "page 20 of 204" is what actually says how far through
// the magazine this run has gotten, so N is mapped back to that
// article's own starting page wherever possible.
const m = /(\d+)\/(\d+)/.exec(job.detail || '');
if (m && progressBar && progressCount) {
const page = missingArticles[parseInt(m[1], 10) - 1]?.pages?.[0];
if (page && pageCount) {
progressBar.max = pageCount; progressBar.value = page;
progressCount.textContent = 'p. ' + page + ' / ' + pageCount;
} else {
progressBar.max = m[2]; progressBar.value = m[1];
progressCount.textContent = m[1] + ' / ' + m[2];
}
}
if (job.done) {
say(job.error ? 'Audio export failed: ' + job.error : (job.detail || 'Audio export done'), job.error ? 'err' : 'ok');
break;
}
await new Promise((r) => setTimeout(r, 1500));
}
if (btn) btn.disabled = false;
if (rebuildBtn) rebuildBtn.disabled = false;
if (status) status.textContent = '';
if (progressWrap) progressWrap.hidden = true;
await magRefreshArticles();
renderContents();
if (!$('mag-playlist')?.hidden) renderPlaylistTracks();
}
function magUpdateAudioZipLink() {
const link = $('mag-audio-zip-link');
if (!link) return;
const any = state.articles.some((a) => a.has_audio);
link.hidden = !any;
if (any) link.href = '/api/magazine/' + state.doc.id + '/audio.zip';
// The EPUB carries only approved articles that have audio.
const epub = $('mag-epub-link');
if (epub) {
const ready = state.articles.some((a) => a.has_audio && a.status === 'approved');
epub.hidden = !ready;
if (ready) epub.href = '/api/magazine/' + state.doc.id + '/epub';
}
}
// ── Review (Stage D) ─────────────────────────────────────────────────────────
async function magReview(articleId) {
const article = state.articles.find((a) => a.id === articleId);
if (!article) return;
state.article = article;
await ensureLayout();
// The whole review is one page at a time -- showing every page of a
// 10-page feature stacked at once was unreadable and made the boxes tiny.
state.reviewSpan = magReviewSpan(article);
state.reviewPage = article.pages[0] || state.reviewSpan[0] || null;
_magReviewChecked = null;
magShow('review');
renderReview();
}
async function magReviewAtPage(articleId, page) {
await magReview(articleId);
if (state.article?.id !== articleId) return;
magReviewGoMagazinePage(Number(page));
}
async function ensureLayout() {
if (state.layout) return;
try {
state.layout = await api('/api/magazine/' + state.doc.id + '/layout');
} catch (e) {
say('Could not load layout: ' + e.message, 'err');
state.layout = { blocks: [], pages: [], page_count: 0 };
}
}
function blocksForPages(pages) {
const want = new Set(pages || []);
return (state.layout.blocks || [])
.filter((b) => want.has(b.page))
.sort((a, b) => a.page - b.page || a.reading_order - b.reading_order);
}
// Offer neighbouring pages too, so a boundary that started a page late (or a
// jump tail that should have been included) can be corrected without
// leaving the screen.
function magReviewSpan(article) {
if (!article.pages.length) return [];
return Array.from(new Set(article.pages.concat(
[article.pages[0] - 1, article.pages[article.pages.length - 1] + 1])))
.filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y);
}
function magReviewGoPage(page) {
if (!state.reviewSpan.includes(page)) return;
state.reviewPage = page;
renderReview();
}
async function magReviewGoMagazinePage(page) {
const lastPage = Number(state.layout?.page_count || 0);
if (!Number.isInteger(page) || page < 1 || page > lastPage) return;
const currentCoversPage = (state.article?.pages || []).includes(page);
if (!currentCoversPage) {
const owner = state.articles.find((article) => (article.pages || []).includes(page));
if (owner) return magReviewAtPage(owner.id, page);
return magReviewPage(page);
}
state.reviewPage = page;
renderReview();
}
// One glyph per region kind, shown centred in its box -- the fastest way to
// tell "a paragraph of text" from "a photo" from "furniture that will not be
// read" without reading the label. No table detection exists yet (P2), so a
// table currently reads as its own text region like any other paragraph.
// What a region can be changed into from the review list. Values match the server's REGION_TYPES.
// Mirrors core/magazine.py's NARRATABLE -- the kinds that are actually read
// aloud. A freshly drawn region of one of these is ticked in automatically
// (see magReviewSubmitDraw); everything else (page number, source, ad,
// table, duplicate, OCR noise, caption, pull quote, sidenote) is furniture
// or reference material excluded from narration on purpose and stays unticked.
const MAG_NARRATABLE_KINDS = new Set(['title', 'heading', 'deck', 'byline', 'body', 'quote']);
const MAG_REGION_TYPES = [
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'],
['byline', 'Author'], ['image', 'Image'], ['caption', 'Caption'], ['in_picture', 'Text in Picture'],
['table', 'Table'], ['quote', 'Quote'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'],
['ad', 'Advertisement'], ['page_number', 'Page Number'], ['source', 'Footer'],
['duplicate', 'Duplicate'], ['ocr_noise', 'OCR Noise'],
];
// A compact subset of MAG_REGION_TYPES for the review page's colour legend
// -- the full 16-entry list would be noisy here, and several of those kinds
// (source/in_picture/ocr_noise/duplicate) already render as plain grey
// "furniture" on the page itself, so listing them separately would promise
// a colour distinction the boxes don't actually make.
const MAG_LEGEND_TYPES = [
['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'], ['byline', 'Author'],
['body', 'Paragraph'], ['quote', 'Quote'], ['caption', 'Caption'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'],
['ad', 'Advertisement'], ['furniture', 'Page number / footer'],
['image', 'Image'], ['chart', 'Chart'], ['table', 'Table'], ['code', 'Code'],
];
function magReviewLegendHtml() {
return '<p class="mag-review-legend">' +
MAG_LEGEND_TYPES.map(([key, label]) =>
'<span class="mag-preview-legend-item"><span class="mag-preview-swatch mpt-' + key + '"></span>' + label + '</span>'
).join('') + '</p>';
}
const MAG_REGION_TYPE_ICONS = {
body: 'mdi-text-box-outline', title: 'mdi-format-title', heading: 'mdi-format-header-2',
deck: 'mdi-text-short', byline: 'mdi-account-edit-outline', caption: 'mdi-image-text',
quote: 'mdi-format-quote-open',
pullquote: 'mdi-format-quote-close', sidebar: 'mdi-note-text-outline', page_number: 'mdi-numeric',
source: 'mdi-book-open-page-variant-outline', ad: 'mdi-advertisements-outline',
in_picture: 'mdi-text-recognition', ocr_noise: 'mdi-alert-circle-outline',
duplicate: 'mdi-content-duplicate', table: 'mdi-table', image: 'mdi-image-outline',
};
// Furniture kinds that the picker shows under their own name (everything else is a page number).
const MAG_FURNITURE_KINDS = ['source', 'in_picture', 'ocr_noise', 'duplicate'];
// The picker value for a region as it is now.
function magRegionTypeValue(region) {
if (region.kind === 'image') return 'image';
const b = region.data;
if (b.type === 'furniture') return MAG_FURNITURE_KINDS.includes(b.region_label) ? b.region_label : 'page_number';
return MAG_REGION_TYPES.some(([v]) => v === b.type) ? b.type : 'body';
}
// A type picker and a delete button for one row of the review list. Both stop the click from
// reaching the row, which is a <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 ? MAG_REGION_TYPES : [[current, current]].concat(MAG_REGION_TYPES));
const label = (options.find(([value]) => value === current) || [current, current])[1];
return '<div class="mag-type-picker mag-region-type-picker" data-region-id="' + esc(region.id) + '" ' +
'data-current="' + esc(current) + '" onclick="event.preventDefault();event.stopPropagation()">' +
'<input type="text" class="mag-type-picker-input mag-block-type-search" value="' + esc(label) + '" ' +
'aria-label="Type of region ' + number + '" autocomplete="off" role="combobox" aria-autocomplete="list" ' +
'aria-expanded="false" onfocus="magRegionPickerFocus(this)" oninput="magRegionPickerInput(this)" ' +
'onkeydown="magRegionPickerKeydown(event,this)" onblur="magRegionPickerBlur(this)">' +
'<input type="hidden" class="mag-block-type-select" value="' + esc(current) + '">' +
'<ul class="mag-type-picker-list" role="listbox" hidden></ul>' +
'</div>' +
'<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>';
}
function _magRegionPickerLabel(value) {
return (MAG_REGION_TYPES.find(([candidate]) => candidate === value) || [value, value])[1];
}
function _magRenderRegionPicker(input) {
const picker = input.closest('.mag-region-type-picker');
const list = picker && picker.querySelector('.mag-type-picker-list');
if (!picker || !list) return;
const query = input.value.trim().toLowerCase();
const matches = query
? MAG_REGION_TYPES.filter(([, label]) => label.toLowerCase().includes(query))
: MAG_REGION_TYPES;
list.innerHTML = matches.length ? matches.map(([value, label]) =>
'<li role="option" data-value="' + value + '" class="mag-type-picker-option' +
(value === picker.dataset.current ? ' is-selected' : '') + '" ' +
'onmousedown="magRegionPickerChoose(this,event)">' +
'<span class="mdi ' + (MAG_REGION_TYPE_ICONS[value] || 'mdi-text-box-outline') + '" aria-hidden="true"></span> ' +
esc(label) + '</li>').join('')
: '<li class="mag-type-picker-empty muted">No matching region type</li>';
list.hidden = false;
input.setAttribute('aria-expanded', 'true');
}
// The selected label is useful while scanning rows, but it is not a search
// query. Clear it as soon as the field receives focus so typing can start
// immediately without first deleting “Paragraph”.
function magRegionPickerFocus(input) {
input.value = '';
_magRenderRegionPicker(input);
}
function magRegionPickerInput(input) { _magRenderRegionPicker(input); }
function magRegionPickerBlur(input) {
setTimeout(() => {
const picker = input.closest('.mag-region-type-picker');
if (!picker) return;
const list = picker.querySelector('.mag-type-picker-list');
if (list) list.hidden = true;
input.value = _magRegionPickerLabel(picker.dataset.current);
input.setAttribute('aria-expanded', 'false');
}, 0);
}
function magRegionPickerKeydown(event, input) {
const picker = input.closest('.mag-region-type-picker');
const list = picker && picker.querySelector('.mag-type-picker-list');
if (event.key === 'Enter' && list) {
const first = list.querySelector('.mag-type-picker-option');
if (first) { event.preventDefault(); magRegionPickerChoose(first, event); }
} else if (event.key === 'Escape') {
if (list) list.hidden = true;
input.value = _magRegionPickerLabel(picker.dataset.current);
input.setAttribute('aria-expanded', 'false');
input.blur();
}
}
function magRegionPickerChoose(option, event) {
event.preventDefault();
event.stopPropagation();
const picker = option.closest('.mag-region-type-picker');
const input = picker.querySelector('.mag-block-type-search');
const list = picker.querySelector('.mag-type-picker-list');
const value = option.dataset.value;
input.value = _magRegionPickerLabel(value);
list.hidden = true;
input.setAttribute('aria-expanded', 'false');
if (value !== picker.dataset.current) magReviewChangeType(picker.dataset.regionId, value, input);
}
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);
magOfferUndo(r?.undo, 'Region type changed.');
} 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 === 'quote') return 'mdi-format-quote-open';
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="' + esc(magPageSrc(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 typeKey = isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type;
const icon = magBlockIcon(typeKey);
pageHtml += '<div class="mag-review-box mpt-' + typeKey + (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>';
}
const selectedCount = regions.filter((r) => r.kind === 'image'
? ownImages.has(r.id) : included.has(r.id)).length;
const allSelected = regions.length > 0 && selectedCount === regions.length;
let listHtml = '<div class="mag-blocks"' + (interactive
? ' onkeydown="magListKey(event)" onfocusin="magListFocus(event)" aria-describedby="mag-list-keys"' : '') + '>' +
'<div class="mag-blocks-head">' +
'<span>Region</span>' +
'<label class="mag-blocks-check-all" title="Include or exclude every detected region on this page">' +
'<input type="checkbox" data-mag-check-all data-selected="' + selectedCount + '" data-total="' + regions.length + '" ' +
(allSelected ? 'checked ' : '') + (regions.length ? '' : 'disabled ') +
'onchange="magReviewToggleAllPage(this, ' + page + ')">' +
'<span>Select all</span>' +
'</label>' +
'<span>Type</span><span>Content</span>' +
'</div>' +
(interactive ? '<p id="mag-list-keys" class="muted mag-list-keys">Keys: ↑/↓ move between regions · Space tick · X select · Enter edit text · R read again · M merge with the next region · Delete delete (Undo appears above)</p>' : '') +
(interactive ? magReviewLegendHtml() : '');
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');
// Resizing an image region re-crops the same filename in place (see
// update_region in core/magazine.py) -- an identical URL never
// re-fetches in the browser, so the old crop kept showing after a
// resize was saved. bbox changes exactly when the crop does, so it
// makes a cheap, correct cache-busting key.
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename) +
'?v=' + encodeURIComponent((r.data.bbox || []).join(','));
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 = MAG_NARRATABLE_KINDS.has(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' + (interactive ? ' is-editable' : '') + '"' +
// The whole row is a <label> wrapping the tick checkbox -- a plain
// click anywhere in it toggles that checkbox by default, so a click
// meant for editing must stop that before it fires. Bypasses the
// pencil-icon click magRowActions already offers; this is just a
// faster path to the exact same editor, not a replacement for it.
(interactive ? ' onclick="event.preventDefault();event.stopPropagation();magRowEdit(\'' + b.id + '\')" title="Click to edit"' : '') + '>' +
(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> ' : '') +
'<span data-text-of="' + esc(b.id) + '">' + magSpellMarked(b.id, b.text) + '</span>' +
(b.ocr_text ? magEditedChip(b.id, interactive) : '') + '</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 renderReview() {
const host = $('mag-review');
const a = state.article;
if (!host || !a) return;
magClearFloatingPanels();
// 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 = []; }
if (state.selectPage !== state.reviewPage) { state.selected = new Set(); state.selectPage = state.reviewPage; }
const conf = confidenceLabel(a.confidence);
const page = state.reviewPage;
const pageCount = Number(state.layout?.page_count || 0);
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">' +
// Back to the contents lives in the sticky bar, so it stays in reach while scrolling a long page.
'<button type="button" class="btn-sm" id="mag-review-contents-btn" onclick="magBackToContents()" title="Back to the contents">' +
'<span class="mdi mdi-arrow-left" aria-hidden="true"></span> Contents</button>' +
// One page stepper for the whole magazine, not a separate one bounded
// to just this article's own pages — the two almost always agreed and
// having both doubled the toolbar's width for no real benefit.
'<div class="mag-review-pagenav-group" role="group" aria-label="Magazine page">' +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + prevIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next 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" aria-pressed="false">' +
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw region' +
'<span class="mdi mdi-toggle-switch-off-outline mag-draw-toggle-switch" id="mag-draw-toggle-switch" aria-hidden="true"></span></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-select-toggle" aria-pressed="' + String(!!state.selectMode) + '" ' +
'title="Select several boxes -- click them or drag a rectangle over them -- then change their type or delete them at once" onclick="magSelectToggle()">' +
'<span class="mdi mdi-selection-drag" aria-hidden="true"></span> Select 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>' +
'<button type="button" class="btn-sm" onclick="magReviewRemoveAllBoxes()" title="Delete every detected box on this page, so you can draw the layout from scratch">' +
'<span class="mdi mdi-eraser" aria-hidden="true"></span> Remove all boxes</button></div>' +
// Undo / Redo: magPlaceUndoRedo() moves this group to the start of the bar's second row.
'<div class="mag-review-pagenav-group" id="mag-undo-group" role="group" aria-label="Undo and redo">' +
'<button type="button" class="btn-sm" id="mag-undo-btn" onclick="magHistoryStep(\'undo\')" aria-keyshortcuts="Control+Z" ' +
(state.history?.can_undo ? '' : 'disabled ') + 'title="' + esc(state.history?.can_undo ? 'Undo: ' + state.history.undo_label + ' (Ctrl+Z)' : 'Nothing to undo') + '">' +
'<span class="mdi mdi-undo" aria-hidden="true"></span> Undo</button>' +
'<button type="button" class="btn-sm" id="mag-redo-btn" onclick="magHistoryStep(\'redo\')" aria-keyshortcuts="Control+Shift+Z Control+Y" ' +
(state.history?.can_redo ? '' : 'disabled ') + 'title="' + esc(state.history?.can_redo ? 'Redo: ' + state.history.redo_label + ' (Ctrl+Shift+Z)' : 'Nothing to redo') + '">' +
'<span class="mdi mdi-redo" aria-hidden="true"></span> Redo</button></div>' +
'<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 figure boundaries on this page only">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries (this page)</button>' +
'<span class="mag-turn-group" role="group" aria-label="Turn this page">' +
'<button type="button" class="btn-sm" id="mag-turn-left" onclick="magRotatePage(-90)" ' +
'title="The page was scanned sideways: turn it 90° counter-clockwise and read it again">' +
'<span class="mdi mdi-rotate-left" aria-hidden="true"></span> Turn left</button>' +
'<button type="button" class="btn-sm" id="mag-turn-right" onclick="magRotatePage(90)" ' +
'title="The page was scanned sideways: turn it 90° clockwise and read it again">' +
'<span class="mdi mdi-rotate-right" aria-hidden="true"></span> Turn right</button>' +
'<button type="button" class="btn-sm" id="mag-turn-flip" onclick="magRotatePage(180)" ' +
'title="The page is upside down: turn it 180° and read it again">' +
'<span class="mdi mdi-flip-vertical" aria-hidden="true"></span> Turn 180°</button>' +
magTurnStateHtml() +
'</span>' +
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
'<button type="button" class="btn-sm" id="mag-find-missing-btn" onclick="magRunPageQuality(\'missing\')" ' +
'title="Use an independent OCR pass to find text regions that are absent, without replacing existing boxes">' +
'<span class="mdi mdi-text-box-search-outline"></span> Find missed text</button>' +
'<button type="button" class="btn-sm" id="mag-verify-text-btn" onclick="magRunPageQuality(\'recognition\')" ' +
'title="Flag suspicious words by language, then re-read their page crops before changing anything">' +
'<span class="mdi mdi-spellcheck"></span> Verify OCR text</button>' +
'<button type="button" class="btn-sm" id="mag-spell-btn" aria-expanded="' + String(!!state.spellOpen) + '" aria-controls="mag-spell-panel" ' +
'title="Show the words on this page that no German or English dictionary knows, marked in the list below" onclick="magSpellToggle()">' +
'<span class="mdi mdi-spellcheck" aria-hidden="true"></span> Spelling <span id="mag-spell-count" class="mag-pill"></span></button>' +
'<span id="mag-reocr-status" class="muted" role="status"></span>' +
'</div>' +
'<div id="mag-spell-panel" class="mag-spell-panel" ' + (state.spellOpen ? '' : 'hidden') + '></div>' +
'<div id="mag-issues-panel" class="mag-issues-panel" role="region" aria-label="What is wrong on this page" aria-live="polite"></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' + (state.orderMode ? ' is-floating' : '') + '" role="region" aria-label="Order by clicking" aria-live="polite">' + magOrderBarHtml() + '</div>' +
'<div id="mag-select-bar" class="mag-order-bar" role="status" aria-live="polite">' + magSelectBarHtml() + '</div>' +
'<div id="mag-undo-bar" class="mag-order-bar" role="status" aria-live="polite">' + magUndoBarHtml() + '</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()" ' +
'title="Approve this article, then open the immediately following physical page">' +
'Approve &amp; Next</button>' +
'</div>';
host.innerHTML = html;
host.querySelectorAll('[data-mag-check-all]').forEach((input) => {
input.indeterminate = Number(input.dataset.selected) > 0 &&
Number(input.dataset.selected) < Number(input.dataset.total);
});
if (state.orderMode) {
$('mag-review-page-wrap')?.classList.add('is-ordering');
magOrderRefreshBar();
}
if (state.selectMode) magSelectRefresh();
setupMagDrawing();
setupMagBoxEdit();
magReviewAutofillTitleAuthor();
magSpellLoadPage();
magAuditLoad();
magFlowLoad();
magHistoryRefresh();
magPlaceUndoRedo();
}
// A Headline or Author region belonging to this article is exactly what its
// own Title/Author field should say -- confirmed live, they matched
// perfectly for a headline detected at ingestion, but nothing kept them in
// sync afterward: retyping a region to Author, or drawing a new Headline
// region by hand, left the fields blank until "Pick from page" was used
// again by hand. Only fills an EMPTY field, so it never overwrites a
// value the reader already typed or picked deliberately.
function magReviewAutofillTitleAuthor() {
const article = state.article;
if (!article) return;
const titleInput = $('mag-rev-title');
const authorInput = $('mag-rev-author');
if ((!titleInput || titleInput.value.trim()) && (!authorInput || authorInput.value.trim())) return;
const ownIds = new Set(article.blocks || []);
const ownBlocks = (state.layout.blocks || []).filter((b) => ownIds.has(b.id));
if (titleInput && !titleInput.value.trim()) {
const block = ownBlocks.find((b) => b.type === 'title' && (b.text || '').trim());
if (block) { titleInput.value = block.text.trim(); article.title = titleInput.value; }
}
if (authorInput && !authorInput.value.trim()) {
const block = ownBlocks.find((b) => b.type === 'byline' && (b.text || '').trim());
// Same "Von "/"by " credit-prefix strip the initial ingestion pass uses
// (core/magazine.py's looks_like_byline) — approximated here since this
// runs client-side without a round trip.
const name = block ? block.text.replace(/^(von|by)\s+/i, '').trim() : '';
if (name) { authorInput.value = name; article.author = name; }
}
}
// 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');
}
// The header checkbox controls the complete region column for one page. It
// covers figures as well as text, matching the individual checkboxes below
// it; a partially selected page is rendered with the native indeterminate
// state so the header never implies that everything is included when it is
// not.
function magReviewToggleAllPage(input, page) {
if (!state.article || !state.layout) return;
if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
const blockIds = (state.layout.blocks || []).filter((b) => b.page === page).map((b) => b.id);
const imageIds = (state.layout.images || []).filter((img) => img.page === page).map((img) => img.id);
if (input.checked) blockIds.forEach((id) => _magReviewChecked.add(id));
else blockIds.forEach((id) => _magReviewChecked.delete(id));
const images = new Set(state.article.images || []);
imageIds.forEach((id) => input.checked ? images.add(id) : images.delete(id));
state.article.images = Array.from(images);
renderReview();
}
// "Clear all fields" above only deselects a page's regions from narration --
// the boxes themselves, right or wrong, stay. When the detected layout on a
// page is too tangled to fix by editing individual boxes, this deletes every
// region on the page outright so "Draw region" can rebuild it from nothing.
// Goes through the same bulk endpoint as multi-select delete, so it gets the
// same one-shot Undo.
async function magReviewRemoveAllBoxes() {
if (state.reviewPage == null) return;
const page = state.reviewPage;
const ids = [
...(state.layout.blocks || []).filter((b) => b.page === page).map((b) => b.id),
...(state.layout.images || []).filter((im) => im.page === page).map((im) => im.id),
];
if (!ids.length) return say('No boxes on this page to remove', '');
const ok = await confirmDialog(
`Delete all ${ids.length} box${ids.length === 1 ? '' : 'es'} on page ${page}? ` +
'This can be undone with the Undo button that appears after.',
{ title: 'Remove all boxes?', okLabel: 'Remove all' });
if (!ok) return;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/regions/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, action: 'delete' }),
});
magOfferUndo(r?.undo, `Removed ${ids.length} box${ids.length === 1 ? '' : 'es'}.`);
} catch (e) {
return say('Could not remove boxes: ' + e.message, 'err');
}
state.layout = null;
await ensureLayout();
renderReview();
say('All boxes on this page removed', '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) {
// A floating panel (see .mag-order-bar.is-floating): pinned to the viewport so the button that finishes
// the task can never scroll out of sight, yet parked in a corner clear of the page image being clicked.
const done = (state.orderSeq || []).length, total = (state.reviewRegionOrder || []).length;
const all = total > 0 && done >= total;
return '<div class="mag-order-head"><span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
'<strong>Order by clicking</strong></div>' +
'<p class="mag-order-hint">' + (all
? 'Every box is numbered. Press <strong>Save order</strong> to keep it.'
: 'Click the boxes on the page in the order they are read. Boxes you skip keep their current order.') + '</p>' +
'<div class="mag-order-progress"><progress max="' + Math.max(total, 1) + '" value="' + done + '" aria-label="Boxes numbered"></progress>' +
'<span>' + done + ' of ' + total + ' set</span></div>' +
'<div class="mag-order-actions">' +
'<button type="button" class="btn-primary mag-order-save' + (all ? ' is-ready' : '') + '" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>' +
'<span class="mdi mdi-content-save-outline" aria-hidden="true"></span> Save order</button>' +
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button>' +
'<button type="button" class="btn-sm" onclick="magOrderToggle()" title="Discard this numbering (Esc)">Cancel</button>' +
'</div>';
}
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(); bar.classList.toggle('is-floating', !!state.orderMode); }
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();
document.removeEventListener('keydown', magOrderKey);
if (state.orderMode) document.addEventListener('keydown', magOrderKey);
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();
}
// Esc discards the numbering, like Cancel -- unless the key is meant for a field (a position box, a select).
function magOrderKey(e) {
if (e.key !== 'Escape' || !state.orderMode) return;
if (e.target && e.target.closest && e.target.closest('input, textarea, select')) return;
e.preventDefault();
magOrderToggle();
}
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() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
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) {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
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() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
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();
}
// ── Select several boxes, act once; undo instead of confirming ─────────────
function magSelectBarHtml() {
if (!state.selectMode) return '';
const n = (state.selected || new Set()).size;
const options = MAG_REGION_TYPES.map(([v, label]) => '<option value="' + v + '">' + esc(label) + '</option>').join('');
return '<span class="mdi mdi-selection-drag" aria-hidden="true"></span> ' +
(n ? n + ' selected.' : 'Click boxes on the page, or drag a rectangle over them, to select them.') + ' ' +
'<select id="mag-bulk-type" aria-label="Change the type of the selected boxes"' + (n ? '' : ' disabled') + '>' +
'<option value="">Change type to…</option>' + options + '</select> ' +
'<button type="button" class="btn-sm" onclick="magBulk(\'delete\')"' + (n ? '' : ' disabled') + '>' +
'<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete ' + (n || '') + '</button> ' +
'<button type="button" class="btn-sm" onclick="magSelectNone()"' + (n ? '' : ' disabled') + '>Select none</button> ' +
'<button type="button" class="btn-sm" onclick="magSelectToggle()">Done</button>';
}
function magSelectRefresh() {
const bar = $('mag-select-bar');
if (bar) {
bar.innerHTML = magSelectBarHtml();
$('mag-bulk-type')?.addEventListener('change', (e) => { if (e.target.value) magBulk('type', e.target.value); });
}
const sel = state.selected || new Set();
$('mag-review-page-wrap')?.classList.toggle('is-selecting', !!state.selectMode);
document.querySelectorAll('#mag-review [data-block], #mag-review [data-block-row]').forEach((el) => {
const id = el.dataset.block || el.dataset.blockRow;
if (el.matches('.mag-review-box, [data-block-row]')) el.classList.toggle('is-selected', !!state.selectMode && sel.has(id));
});
}
function magSelectToggle() {
state.selectMode = !state.selectMode;
state.selected = new Set();
state.selectPage = state.reviewPage;
if (state.selectMode && state.drawMode) magReviewToggleDraw(state.drawKind);
if (state.selectMode && state.editMode) magReviewToggleEdit();
if (state.selectMode && state.orderMode) magOrderToggle();
const btn = $('mag-select-toggle');
if (btn) { btn.classList.toggle('active', state.selectMode); btn.setAttribute('aria-pressed', String(state.selectMode)); }
magSelectRefresh();
}
function magSelectToggleOne(id) {
const sel = state.selected || (state.selected = new Set());
if (sel.has(id)) sel.delete(id); else sel.add(id);
magSelectRefresh();
}
function magSelectNone() {
state.selected = new Set();
magSelectRefresh();
}
// Boxes at least half inside the dragged rectangle (all values in % of the page).
function magSelectInRect(left, top, width, height) {
const sel = state.selected || (state.selected = new Set());
document.querySelectorAll('#mag-review-page-wrap .mag-review-box[data-block]').forEach((box) => {
const bl = parseFloat(box.style.left), bt = parseFloat(box.style.top);
const bw = parseFloat(box.style.width), bh = parseFloat(box.style.height);
const ix = Math.max(0, Math.min(bl + bw, left + width) - Math.max(bl, left));
const iy = Math.max(0, Math.min(bt + bh, top + height) - Math.max(bt, top));
if (bw > 0 && bh > 0 && ix * iy >= 0.5 * bw * bh) sel.add(box.dataset.block);
});
magSelectRefresh();
}
async function magBulk(action, type) {
const ids = Array.from(state.selected || []);
if (!ids.length) return;
let r;
try {
r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action === 'type' ? { ids, action, type } : { ids, action }),
});
} catch (e) {
return say('Could not change the selected boxes: ' + e.message, 'err');
}
Object.entries(r.remap || {}).forEach(([oldId, newId]) => magRemapSelection(oldId, newId, type === 'image'));
state.selected = new Set();
magOfferUndo(r.undo, (action === 'delete' ? 'Deleted ' : 'Changed ') + r.count + ' box' + (r.count === 1 ? '' : 'es') + '.');
await magReloadReview();
}
// Undo / Redo belong at the left end of the bar's SECOND row, wherever the rows happen to break at
// this window width, so the group is placed by measuring: take it out, find the first control that
// sits below the first row, put the group in front of it (or at the end if the bar is one row).
function magPlaceUndoRedo() {
const bar = document.querySelector('#mag-review .mag-review-pagenav');
const group = $('mag-undo-group');
if (!bar || !group) return;
const place = () => {
group.remove();
bar.querySelectorAll('.mag-pagenav-break').forEach((el) => el.remove());
const items = Array.from(bar.children).filter((el) => !el.classList.contains('mag-review-pagenav-spacer'));
const firstTop = items.length ? items[0].offsetTop : 0;
const secondRowStart = items.find((el) => el.offsetTop > firstTop + 8);
if (!secondRowStart) { bar.appendChild(group); return; } // one row only: keep it at the end
// A forced line break in front of the group, so it opens the second row instead of sliding
// back into free space at the end of the first.
const lineBreak = document.createElement('span');
lineBreak.className = 'mag-pagenav-break';
lineBreak.setAttribute('aria-hidden', 'true');
bar.insertBefore(lineBreak, secondRowStart);
bar.insertBefore(group, secondRowStart);
};
place();
if (!bar._magUndoObserver && typeof ResizeObserver === 'function') {
let width = bar.clientWidth;
bar._magUndoObserver = new ResizeObserver(() => {
if (bar.clientWidth !== width) { width = bar.clientWidth; place(); }
});
bar._magUndoObserver.observe(bar);
}
}
// ── Undo / redo (server keeps the last 20 recorded changes) ───────────────────
function magHistoryPaint() {
const h = state.history || {};
const undo = $('mag-undo-btn'), redo = $('mag-redo-btn');
if (undo) {
undo.disabled = !h.can_undo;
undo.title = h.can_undo ? 'Undo: ' + h.undo_label + ' (Ctrl+Z)' : 'Nothing to undo';
}
if (redo) {
redo.disabled = !h.can_redo;
redo.title = h.can_redo ? 'Redo: ' + h.redo_label + ' (Ctrl+Shift+Z)' : 'Nothing to redo';
}
}
async function magHistoryRefresh() {
if (!state.doc) return;
try { state.history = await api('/api/magazine/' + state.doc.id + '/history'); } catch (_) { state.history = null; }
magHistoryPaint();
}
async function magHistoryStep(kind) {
if (state.historyBusy || !state.doc) return;
if (kind === 'undo' ? !state.history?.can_undo : !state.history?.can_redo) return;
state.historyBusy = true;
try {
const r = await api('/api/magazine/' + state.doc.id + '/' + kind, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
state.history = r;
state.undo = null; // the old "Undo" bar named a change that is now gone
const articleId = state.article?.id;
await magRefreshArticles();
state.article = (state.articles || []).find((a) => a.id === articleId) || state.article;
_magReviewChecked = null;
await magReloadReviewNow((kind === 'undo' ? 'Undone: ' : 'Redone: ') + r.label);
} catch (e) {
say('Could not ' + kind + ': ' + e.message, 'err');
await magHistoryRefresh();
} finally {
state.historyBusy = false;
}
}
document.addEventListener('keydown', (event) => {
if (state.view !== 'review' || !(event.ctrlKey || event.metaKey) || event.altKey) return;
const target = event.target;
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return; // native undo of typing
const key = String(event.key).toLowerCase();
if (key === 'z' && !event.shiftKey) { event.preventDefault(); magHistoryStep('undo'); }
else if ((key === 'z' && event.shiftKey) || key === 'y') { event.preventDefault(); magHistoryStep('redo'); }
});
function magUndoBarHtml() {
if (!state.undo) return '';
return '<span class="mdi mdi-undo-variant" aria-hidden="true"></span> ' + esc(state.undo.label) + ' ' +
'<button type="button" class="btn-sm" onclick="magUndo()">Undo</button>';
}
function magOfferUndo(token, label) {
state.undo = token ? { token, label } : null;
const bar = $('mag-undo-bar');
if (bar) bar.innerHTML = magUndoBarHtml();
}
async function magUndo() {
const undo = state.undo;
if (!undo) return;
try {
await api('/api/magazine/' + state.doc.id + '/undo', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: undo.token }),
});
} catch (e) {
state.undo = null;
magOfferUndo(null);
return say('Could not undo: ' + e.message, 'err');
}
state.undo = null;
// The article itself was restored too: take it (and its ticks) from the server again.
const articleId = state.article?.id;
await magRefreshArticles();
state.article = (state.articles || []).find((a) => a.id === articleId) || state.article;
_magReviewChecked = null;
await magReloadReview('Undone');
}
function magReviewFocusBlock(blockId) {
if (state.orderMode) return magOrderPick(blockId);
if (state.selectMode) return magSelectToggleOne(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.
// Applies state.drawMode/drawKind to the DOM -- pulled out of
// magReviewToggleDraw so magReviewSubmitDraw can re-assert the same "still
// drawing" visuals after renderReview() rebuilds the toolbar from scratch
// (which has no memory of drawMode, since that class was only ever applied
// imperatively, never baked into the template).
function _magSyncDrawUi() {
const hint = $('mag-draw-hint');
const field = state.drawKind === 'title' ? 'title' : state.drawKind === 'author' ? 'author' : '';
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 region, then say what it is. 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); btn.setAttribute('aria-pressed', String(state.drawMode)); }
// A background-colour change alone was easy to miss once several regions
// in a row had been added -- confirmed live. An explicit on/off switch
// icon is a second, unambiguous cue for the same state.
const switchIcon = $('mag-draw-toggle-switch');
if (switchIcon) {
switchIcon.classList.toggle('mdi-toggle-switch', state.drawMode);
switchIcon.classList.toggle('mdi-toggle-switch-off-outline', !state.drawMode);
}
}
function magReviewToggleDraw(kind = 'text') {
state.drawMode = state.drawMode && state.drawKind === kind ? false : true;
state.drawKind = kind;
if (state.drawMode && state.editMode) magReviewToggleEdit();
_magSyncDrawUi();
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>';
magMountFloatingPanel(panel, box);
}
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() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
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, author lines that run into the first sentence are split, and text boxes are fitted to the ink of their text (boxes you drew or resized yourself are left alone). ' +
'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'], [c.snapped, 'boxes fitted to their text'], [c.pullquote_added, 'inset pull quotes found'], [c.wrap_split, 'paragraphs cut around a pull quote']]
.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() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
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);
}
function magReloadReview(message) {
// Other actions (opening the text editor) wait for a redraw in flight, or it would wipe them.
const run = magReloadReviewNow(message).finally(() => { if (state.reloadPending === run) state.reloadPending = null; });
state.reloadPending = run;
return run;
}
async function magReloadReviewNow(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');
}
// Word-level difference between the recognised text and the reader's edit. Whitespace-separated words,
// longest-common-subsequence; returns escaped HTML with <del> (only in the original) and <ins> (only in
// the edit). Bounded: two very long texts are shown whole rather than freezing the page.
const MAG_DIFF_MAX_WORDS = 1500;
function magWordDiff(before, after) {
const a = String(before || '').split(/\s+/).filter(Boolean);
const b = String(after || '').split(/\s+/).filter(Boolean);
if (a.length > MAG_DIFF_MAX_WORDS || b.length > MAG_DIFF_MAX_WORDS) {
return '<del>' + esc(a.join(' ')) + '</del> <ins>' + esc(b.join(' ')) + '</ins>';
}
const n = a.length, m = b.length, w = m + 1;
const t = new Uint16Array((n + 1) * w);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
t[i * w + j] = a[i] === b[j] ? t[(i + 1) * w + j + 1] + 1 : Math.max(t[(i + 1) * w + j], t[i * w + j + 1]);
}
}
const out = [];
let i = 0, j = 0;
const push = (kind, word) => {
const last = out[out.length - 1];
if (last && last.kind === kind) last.words.push(word); else out.push({ kind, words: [word] });
};
while (i < n && j < m) {
if (a[i] === b[j]) { push('same', a[i]); i++; j++; }
else if (t[(i + 1) * w + j] >= t[i * w + j + 1]) { push('del', a[i]); i++; }
else { push('ins', b[j]); j++; }
}
while (i < n) push('del', a[i++]);
while (j < m) push('ins', b[j++]);
return out.map((g) => {
const text = esc(g.words.join(' '));
return g.kind === 'del' ? '<del>' + text + '</del>' : g.kind === 'ins' ? '<ins>' + text + '</ins>' : text;
}).join(' ');
}
// Marks a region the reader edited by hand, with a way to see the change and to put the recognised text back.
// Inside a <label> row, so every click must stop the tick box from toggling.
function magEditedChip(id, interactive) {
const stop = 'event.preventDefault();event.stopPropagation();';
return ' <span class="mag-edited"><span class="mdi mdi-pencil-outline" aria-hidden="true"></span> Edited' +
(interactive
? ' <button type="button" class="btn-sm" aria-expanded="false" onclick="' + stop + 'magRowDiff(\'' + esc(id) + '\', this)">Show changes</button>' +
' <button type="button" class="btn-sm" onclick="' + stop + 'magRowRestore(\'' + esc(id) + '\')">Restore recognised text</button>'
: '') + '</span>';
}
function magRowDiff(id, btn) {
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(id) + '"]');
const block = (state.layout?.blocks || []).find((b) => b.id === id);
if (!row || !block?.ocr_text) return;
let box = row.querySelector('.mag-diff');
if (box) { box.remove(); btn?.setAttribute('aria-expanded', 'false'); if (btn) btn.textContent = 'Show changes'; return; }
box = document.createElement('div');
box.className = 'mag-diff';
box.setAttribute('role', 'group');
box.setAttribute('aria-label', 'Changes from the recognised text: removed words struck through and marked minus, added words underlined and marked plus');
box.innerHTML = magWordDiff(block.ocr_text, block.text);
row.querySelector('.mag-block-text').after(box);
btn?.setAttribute('aria-expanded', 'true');
if (btn) btn.textContent = 'Hide changes';
}
async function magRowRestore(id) {
const block = (state.layout?.blocks || []).find((b) => b.id === id);
if (!block?.ocr_text) return;
try {
await api(magRegionUrl(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: block.ocr_text }) });
} catch (err) {
return say('Could not restore the recognised text: ' + err.message, 'err');
}
magReloadReview('Recognised text restored');
}
// ── Correct a flagged word where it stands ───────────────────────────────────
// The word becomes a small input set in the paragraph's own type (nothing around it reflows or restyles), with
// the dictionary's suggestions as one-click chips. Enter applies, Esc puts the word back. The fix goes through
// the same checked route as the spelling list (offsets re-verified server-side, one Undo).
function magWordEdit(blockId, start, end, ev) {
if (ev) { ev.preventDefault(); ev.stopPropagation(); }
const issues = state.spell?.issues || [];
const n = issues.findIndex((i) => i.block_id === blockId && i.start === start && i.end === end);
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(blockId) + '"]');
const mark = row?.querySelector('mark[data-spell-start="' + Number(start) + '"]');
if (n < 0 || !mark || mark.querySelector('input')) return;
const issue = issues[n];
const original = issue.raw || issue.token;
const shown = mark.textContent;
mark.classList.add('is-editing');
mark.removeAttribute('role');
mark.innerHTML = '<input type="text" class="mag-word-input" maxlength="120" autocomplete="off" spellcheck="false" ' +
'aria-label="Correct the word ' + esc(issue.token) + '" value="' + esc(original) + '" style="width:' + Math.max(3, original.length + 1) + 'ch">' +
'<span class="mag-word-pop" role="group" aria-label="Corrections for ' + esc(issue.token) + '">' +
(issue.suggestions || []).slice(0, 5).map((sug, k) => '<button type="button" class="btn-sm" data-sug="' + k + '">' + esc(sug) + '</button>').join('') +
'<button type="button" class="btn-sm" data-keep title="This word is fine (a name or technical term): stop flagging it in this issue">Keep</button>' +
'<span class="mag-word-hint muted">Enter applies · Esc cancels</span></span>';
const input = mark.querySelector('input');
const restore = () => { mark.classList.remove('is-editing'); mark.setAttribute('role', 'button'); mark.textContent = shown; };
const apply = (value) => {
const v = String(value).replace(/\s+/g, ' ').trim();
if (!v) return say('Type the corrected word first', 'err');
if (v === original) return restore();
magSpellApply(n, v);
};
['click', 'keyup'].forEach((t) => mark.addEventListener(t, (e) => { if (mark.classList.contains('is-editing')) e.stopPropagation(); }));
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); apply(input.value); }
else if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
input.addEventListener('input', () => { input.style.width = Math.max(3, input.value.length + 1) + 'ch'; });
mark.querySelector('.mag-word-pop').addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
e.preventDefault(); e.stopPropagation();
if (btn.dataset.keep !== undefined) return magSpellKeep(n);
apply(issue.suggestions[Number(btn.dataset.sug)]);
});
input.addEventListener('blur', () => setTimeout(() => {
if (mark.isConnected && mark.classList.contains('is-editing') && !mark.contains(document.activeElement)) restore();
}, 150));
input.focus();
input.select();
}
// The flagged words of a block as a regex over its *current* text, so underlines survive typing: a word stops
// being marked the moment it no longer matches, with no stale offsets.
function magMarkTokens(text, tokens) {
const words = [...new Set((tokens || []).filter(Boolean))].sort((a, b) => b.length - a.length);
if (!words.length) return esc(text);
const re = new RegExp('(?<![\\p{L}\\p{N}])(?:' + words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')(?![\\p{L}\\p{N}])', 'gu');
let out = '', pos = 0, m;
while ((m = re.exec(text))) {
out += esc(text.slice(pos, m.index)) + '<mark class="mag-spell">' + esc(m[0]) + '</mark>';
pos = m.index + m[0].length;
}
return out + esc(text.slice(pos));
}
async function magRowEdit(id) {
if (state.reloadPending) await state.reloadPending;
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 = '<span class="mag-hl-wrap"><span class="mag-hl-back" aria-hidden="true"></span>' +
'<textarea class="mag-hl-area" aria-label="Text of this region" rows="1" spellcheck="false"></textarea></span>' +
'<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;
// Set in the paragraph's own type, growing with its text, with the flagged words still underlined while you type.
const back = editor.querySelector('.mag-hl-back');
const tokens = ((state.spellByBlock && state.spellByBlock.get(id)) || []).map((i) => i.raw || i.token);
const sync = () => {
back.innerHTML = magMarkTokens(area.value, tokens) + (area.value.endsWith('\n') ? '\n ' : '');
area.style.height = 'auto';
area.style.height = area.scrollHeight + 'px';
};
area.addEventListener('input', sync);
sync();
['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);
magOfferUndo(r?.undo, 'Region split in two.');
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);
magOfferUndo(r?.undo, 'Merged with the next region.');
} 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); }
else if (key === 'x') { event.preventDefault(); if (!state.selectMode) magSelectToggle(); magSelectToggleOne(id); }
}
// Deleting is immediate and offers Undo -- a confirmation dialog for every box made clearing a page of
// chart labels a click-marathon.
async function magReviewDeleteRegion(blockId) {
let r;
try {
r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(blockId), {
method: 'DELETE',
});
magRemapSelection(blockId, null, false);
state.selected?.delete(blockId);
} catch (e) {
return say('Could not delete region: ' + e.message, 'err');
}
magOfferUndo(r?.undo, 'Region deleted.');
await magReloadReview();
}
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 || state.selectMode) || 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
if (state.selectMode) { box.hidden = true; return magSelectInRect(parseFloat(box.style.left), parseFloat(box.style.top), w, h); }
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 : '';
// One generic draw tool now covers every region kind (previously six
// separate "Draw X" buttons pre-selected the kind before drawing) — the
// kind is picked here, after the rectangle is already on the page, since
// that's the order a person actually works in: see the shape, then say
// what it is.
const drawKindOptions = MAG_REGION_TYPES;
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>'
:
'<div class="mag-draw-panel-row">' +
'<div class="mag-type-picker" id="mag-draw-kind-picker">' +
'<label for="mag-draw-kind-search">Region type</label>' +
'<input type="text" id="mag-draw-kind-search" class="mag-type-picker-input" autocomplete="off" ' +
'placeholder="Search region type…" aria-expanded="false" role="combobox" aria-autocomplete="list">' +
'<input type="hidden" id="mag-draw-kind" value="body">' +
'<ul class="mag-type-picker-list" id="mag-draw-kind-list" role="listbox" hidden></ul>' +
'</div>' +
'</div>' +
'<label id="mag-draw-exclude" hidden><input id="mag-draw-exclude-check" type="checkbox" checked> Exclude enclosed text from narration</label>' +
'<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 region</button>' +
'</div>';
magMountFloatingPanel(panel, box);
if ($('mag-draw-kind-picker')) _magSetupDrawKindPicker(drawKindOptions);
}
// Panels mounted on <body> outlive the review list that opened them, so whatever redraws or leaves the review clears them.
function magClearFloatingPanels() {
document.querySelectorAll('.mag-draw-panel').forEach((n) => n.remove());
}
// The panels that belong to a rectangle on the page (draw, edit) are mounted on <body>, not inside the page frame.
// The frame sits in a sticky container, and a sticky element is its own stacking context: a panel inside it is ranked
// only against the frame's other content, so the sticky Back/Approve bar and the status bar painted over it and cut off
// its buttons however high its z-index was. On <body> the panel is ranked against the page, above those bars.
// It is placed in the window, follows the rectangle while the page scrolls or resizes, and goes away with its panel.
function magMountFloatingPanel(panel, box) {
document.body.appendChild(panel);
magPlaceDrawPanel(panel, box);
const follow = () => {
if (!panel.isConnected) {
window.removeEventListener('scroll', follow, true);
window.removeEventListener('resize', follow);
return;
}
magPlaceDrawPanel(panel, box);
};
window.addEventListener('scroll', follow, true);
window.addEventListener('resize', follow);
}
// Below the drawn rectangle when it fits, above it when not, and always fully inside the window
// (a rectangle drawn near the bottom of the page used to push the panel past the edge, cutting off
// the Add region button).
function magPlaceDrawPanel(panel, box) {
const margin = 8;
const r = box.getBoundingClientRect();
const width = Math.min(panel.offsetWidth, window.innerWidth - 2 * margin);
const height = Math.min(panel.offsetHeight, window.innerHeight - 2 * margin);
let top = r.bottom + 6;
if (top + height > window.innerHeight - margin) {
const above = r.top - height - 6;
top = above >= margin ? above : Math.max(margin, window.innerHeight - height - margin);
}
panel.style.top = Math.round(top) + 'px';
panel.style.left = Math.round(Math.min(Math.max(margin, r.left), window.innerWidth - width - margin)) + 'px';
}
// A 16-option <select> for something picked constantly deserved faster
// access than scrolling a native dropdown -- type to filter, arrow keys or
// click to choose, icon per type so the list scans visually instead of by
// re-reading every label. #mag-draw-kind stays a plain hidden input so
// magReviewSubmitDraw's existing $('mag-draw-kind').value read needed no
// changes.
function _magDrawKindOnPick(value) {
const isImage = value === 'image';
const ta = $('mag-draw-text');
if (ta) ta.hidden = isImage;
const exclude = $('mag-draw-exclude');
if (exclude) exclude.hidden = !isImage;
}
function _magSetupDrawKindPicker(options) {
const search = $('mag-draw-kind-search');
const hidden = $('mag-draw-kind');
const list = $('mag-draw-kind-list');
if (!search || !hidden || !list) return;
const renderList = (query) => {
const q = (query || '').trim().toLowerCase();
const matches = q ? options.filter(([, label]) => label.toLowerCase().includes(q)) : options;
list.innerHTML = matches.length
? matches.map(([v, label]) => '<li role="option" data-value="' + v + '" ' +
'class="mag-type-picker-option' + (v === hidden.value ? ' is-selected' : '') + '">' +
'<span class="mdi ' + (MAG_REGION_TYPE_ICONS[v] || 'mdi-text-box-outline') + '" aria-hidden="true"></span> ' +
esc(label) + '</li>').join('')
: '<li class="mag-type-picker-empty muted">No matching region type</li>';
};
const pick = (value, label) => {
hidden.value = value;
search.value = label;
list.hidden = true;
search.setAttribute('aria-expanded', 'false');
_magDrawKindOnPick(value);
};
// Opens below the field, or above it when the window has more room there (a rectangle drawn at the foot of the page).
const place = () => {
const r = search.getBoundingClientRect();
const below = window.innerHeight - r.bottom - 12, above = r.top - 12;
const up = below < 200 && above > below;
list.classList.toggle('opens-up', up);
list.style.maxHeight = Math.max(110, Math.min(260, up ? above : below)) + 'px';
};
const open = () => {
renderList(search.value === (options.find(([v]) => v === hidden.value) || [])[1] ? '' : search.value);
list.hidden = false;
search.setAttribute('aria-expanded', 'true');
place();
};
// Start on the default (Paragraph) with its label showing, matching the
// hidden input's own default value set in the HTML above.
pick(hidden.value, (options.find(([v]) => v === hidden.value) || [])[1] || '');
search.addEventListener('focus', () => { search.value = ''; open(); });
search.addEventListener('input', () => { list.hidden = false; search.setAttribute('aria-expanded', 'true'); renderList(search.value); place(); });
search.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { list.hidden = true; search.setAttribute('aria-expanded', 'false'); search.blur(); }
else if (e.key === 'Enter') {
e.preventDefault();
const first = list.querySelector('.mag-type-picker-option');
if (first) { const v = first.dataset.value; pick(v, options.find(([ov]) => ov === v)[1]); }
} else if (e.key === 'ArrowDown' && list.hidden) { open(); }
});
list.addEventListener('mousedown', (e) => {
// mousedown (not click) fires before the input's blur, so the list is
// still in the DOM to read from when the handler below reacts to blur.
const li = e.target.closest('.mag-type-picker-option');
if (!li) return;
e.preventDefault();
pick(li.dataset.value, options.find(([v]) => v === li.dataset.value)[1]);
});
search.addEventListener('blur', () => {
setTimeout(() => { list.hidden = true; search.setAttribute('aria-expanded', 'false'); }, 0);
});
}
async function magReviewSubmitDraw() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
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' : ($('mag-draw-kind')?.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();
if (field) {
state.drawMode = false;
_magSyncDrawUi();
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' },
// Every text-like kind gets read automatically if left blank — except
// an advertisement, which is a graphic, not something worth OCRing.
body: JSON.stringify({ bbox, kind, text, ocr: kind !== 'image' && kind !== 'ad' && !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 (MAG_NARRATABLE_KINDS.has(kind)) {
// Drawing a region at all means the reader judged it part of the
// article -- it should read as included, not force a second click per
// box to tick what was just drawn. Furniture-like kinds (page number,
// source, advertisement, table, duplicate, OCR noise) stay unchecked,
// since those are excluded from narration on purpose.
added.forEach(r => _magReviewChecked.add(r.id));
}
renderReview();
// Draw region is a toggle, not a one-shot: stay armed for the next box
// instead of forcing another click on the toolbar button every time.
// renderReview() just rebuilt the toolbar from scratch with no memory of
// drawMode, so the "still drawing" visuals need reapplying here.
if (state.drawMode) _magSyncDrawUi();
}
// 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() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
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();
}
// While a turn is being read, the picture and the text on screen are still the OLD ones (the page is replaced as a whole
// when the read finishes, 30 s to a few minutes). Say so plainly, with the time spent, and dim what is about to be replaced,
// so nobody mistakes the stale, sideways picture and its gibberish text for the result.
function magTurnBusyNote(on, page, detail, startedAt) {
const review = $('mag-review');
const split = review?.querySelector('.mag-review-split');
let note = $('mag-turn-busy');
if (!on) { note?.remove(); split?.classList.remove('is-stale'); return; }
if (!split) return;
if (!note) {
note = document.createElement('div');
note.id = 'mag-turn-busy';
note.className = 'mag-turn-busy';
note.setAttribute('role', 'status');
split.before(note);
}
const secs = Math.round((Date.now() - startedAt) / 1000);
note.innerHTML = '<span class="mdi mdi-loading mdi-spin" aria-hidden="true"></span> <strong>Turning page ' + Number(page) +
' and reading it again</strong> — ' + esc(detail || 'working') + ' (' + secs + ' s). The picture and the text below are from <em>before</em> ' +
'the turn and are replaced when this finishes; it usually takes 30 to 60 seconds.';
split.classList.add('is-stale');
}
// How far this page has been turned from the PDF's own orientation, in words, with a way back. Turns add up (a right
// turn and a half turn is three quarters), so pressing the buttons more than once is easy to lose track of.
function magTurnNow(page) {
const meta = (state.layout?.pages || []).find((p) => p.page === Number(page));
return ((Number(meta?.rotation) || 0) % 360 + 360) % 360;
}
function magTurnStateHtml() {
const now = magTurnNow(state.reviewPage);
if (!now) return '';
return '<span class="mag-turn-state" id="mag-turn-state" role="status">Turned ' + now + '° clockwise from the scan</span>' +
'<button type="button" class="btn-sm" id="mag-turn-undo" onclick="magRotatePage(' + (360 - now) + ')" ' +
'title="Turn the page back to the orientation the PDF gives it, and read it again">' +
'<span class="mdi mdi-backup-restore" aria-hidden="true"></span> Undo turns</button>';
}
// A page scanned sideways or upside down: turn the page itself, then read it again. The boxes and text
// produced from the wrong orientation are replaced, so this asks first; approvals carry over where the new
// regions line up with the old ones. Turning is not covered by Undo (it rewrites the page image), so the
// way back is the opposite turn.
const MAG_TURN_WORDS = { 90: 'clockwise', [-90]: 'counter-clockwise', 270: 'counter-clockwise', 180: 'half a turn' };
async function magRotatePage(degrees) {
const page = state.reviewPage;
if (page == null) return;
const what = degrees === 180 ? 'Turn page ' + page + ' upside down (180°)' : 'Turn page ' + page + ' 90° ' + MAG_TURN_WORDS[degrees];
const ok = await confirmDialog(
'The page image is turned and the page is read again, so the boxes and text now on it are replaced by new ones. ' +
'Any corrections you made to this page by hand are lost, and articles that used it are set back to "not approved". ' +
'This cannot be undone with Undo; turn it back with the opposite button if it was wrong.',
{ title: what + '?', okLabel: 'Turn and read again' });
if (!ok) return;
magOfferUndo(null); // an older Undo would put old-orientation boxes on the turned page
const buttons = ['mag-turn-left', 'mag-turn-right', 'mag-turn-flip', 'mag-turn-undo', 'mag-reocr-btn'].map($).filter(Boolean);
const statusEl = $('mag-reocr-status');
const startedAt = Date.now();
const busy = (on, text) => {
buttons.forEach((b) => { b.disabled = on; });
if (statusEl) statusEl.textContent = text || '';
magTurnBusyNote(on, page, text, startedAt);
};
busy(true, 'Turning page ' + page + '…');
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/rotate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ degrees }),
});
jobId = r.job_id;
} catch (e) {
busy(false);
return say('Could not turn the page: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) { busy(false); return say('The page was left as it was: ' + job.error, 'err'); }
say('Page ' + page + ' turned and read again', 'ok');
break;
}
busy(true, job.detail || 'Turning page ' + page + '…');
await new Promise((r) => setTimeout(r, 2000));
}
busy(false);
state.layout = null;
await ensureLayout();
const articleId = state.article.id;
await magRefreshArticles();
state.article = state.articles.find((a) => a.id === articleId) || state.article;
state.quality = null; state.qualityDocId = null; // the old findings described the sideways page
_magReviewChecked = null;
renderReview();
}
async function magRunPageQuality(mode) {
magOfferUndo(null);
const page = state.reviewPage;
if (page == null) return;
const isMissing = mode === 'missing';
const btn = $(isMissing ? 'mag-find-missing-btn' : 'mag-verify-text-btn');
const statusEl = $('mag-reocr-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = isMissing
? 'Checking page ' + page + ' for missed text…'
: 'Verifying suspicious words on page ' + page + '…';
let jobId;
try {
const result = await api('/api/magazine/' + state.doc.id + '/pages/' + page +
'/quality/' + mode, { method: 'POST' });
jobId = result.job_id;
} catch (e) {
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
return say('Could not start quality 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('Quality check failed: ' + job.error, 'err'); }
else say(job.detail || 'Page quality checked', 'ok');
break;
}
if (statusEl && job.detail) statusEl.textContent = job.detail;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
if (failed) return;
state.layout = null;
await ensureLayout();
_magReviewChecked = null;
renderReview();
}
// Re-derives figure boxes from the page renders. Review uses the active page;
// the contents toolbar exposes the intentionally separate whole-issue batch.
// No OCR engine is involved: this only corrects where figures are.
async function magRunFigureRefine(page) {
const wholeIssue = page == null;
const btn = $(wholeIssue ? 'mag-refine-all-figures-btn' : 'mag-refine-figures-btn');
const statusEl = $('mag-reocr-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = wholeIssue
? 'Checking every page…' : 'Checking figure boundaries on page ' + page + '…';
let jobId;
try {
const endpoint = wholeIssue ? '/figures/refine' : '/pages/' + page + '/figures/refine';
const r = await api('/api/magazine/' + state.doc.id + endpoint, { 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;
}
magMarkLive(job, wholeIssue);
if (statusEl && job.detail) statusEl.textContent = job.detail;
await new Promise((r) => setTimeout(r, 2000));
}
magMarkLive(null);
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
if (failed) return;
state.layout = null;
await ensureLayout();
_magReviewChecked = null;
if (state.view === 'review') renderReview();
else renderContents();
}
// Whole-issue completeness run (server: _quality_run_sync). Adds text no box covers,
// re-reads suspicious OCR, then lists the pages whose render still has uncovered ink or
// suspicious words so the reviewer can jump straight to them.
function magRenderQualityReport(report) {
const box = $('mag-quality-report');
if (!box) return;
const sm = report.summary || {};
const pages = report.pages || [];
const boxes = pages.filter((p) => p.needs_review);
const words = pages.filter((p) => !p.needs_review && p.words_to_check);
const did = [
[sm.pages_linked, 'text blocks linked into articles'], [sm.boxes_fixed, 'boxes fixed'],
[sm.regions_added, 'missed regions added'], [sm.regions_corrected, 'words corrected'],
[sm.spelling_fixed, 'misspellings fixed']].filter(([n]) => n).map(([n, w]) => n + ' ' + w);
const tip = (p) => {
const bits = [];
if (p.uncovered.length) bits.push(p.uncovered.length + ' text area' + (p.uncovered.length === 1 ? '' : 's') + ' outside any box');
if (p.overlaps.length) bits.push(p.overlaps.length + ' overlapping box pair' + (p.overlaps.length === 1 ? '' : 's'));
if (p.clipped.length) bits.push(p.clipped.length + ' box edge' + (p.clipped.length === 1 ? '' : 's') + ' cutting text');
if ((p.duplicates || []).length) bits.push('duplicate articles');
if (p.error) bits.push('check failed');
if (p.words_to_check) bits.push('words to check');
return bits.join(', ');
};
const pill = (p) => '<button type="button" class="btn-sm mag-page-pill" onclick="magReviewPage(' + Number(p.page) + ')" title="' + esc(tip(p)) + '">' + Number(p.page) + '</button>';
box.hidden = false;
box.innerHTML =
'<div><strong>' + (did.length ? 'Fixed automatically: ' + esc(did.join(', ')) + '.' : 'Nothing needed fixing automatically.') + '</strong></div>' +
(boxes.length
? '<div class="mag-report-group"><span>' + boxes.length + ' page' + (boxes.length === 1 ? '' : 's') + ' need a decision (boxes):</span> ' +
'<button type="button" class="btn-sm primary" onclick="magReviewPage(' + Number(boxes[0].page) + ')">Start with page ' + Number(boxes[0].page) + '</button> ' +
boxes.map(pill).join(' ') + '</div>'
: '<div class="mag-report-group">✓ No page needs a decision about its boxes.</div>') +
(words.length
? '<details class="mag-report-group"><summary>' + words.length + ' more page' + (words.length === 1 ? '' : 's') +
' have only words to check (mostly names and terms) — optional</summary>' + words.map(pill).join(' ') + '</details>'
: '');
}
async function magQualityRun() {
if (!state.doc) return;
const ok = await confirmDialog(
'Fix every page automatically (link continuation pages into their articles, fit boxes, find inset pull quotes, add missed text, re-read suspicious words, fix provable misspellings) and list only what still needs your decision? This can take a while on a long issue; a backup of the layout is kept. ' +
'Added regions start unticked, so nothing new is read aloud until you approve it.',
{ title: 'Auto-fix and check the whole magazine?', okLabel: 'Run' });
if (!ok) return;
const btn = $('mag-quality-run-btn');
const box = $('mag-quality-report');
const label = btn && btn.querySelector('.mag-qrun-label');
const showProgress = (text, pct) => {
if (box) {
box.hidden = false;
box.innerHTML = '<div>' + esc(text) + '</div>' +
(pct == null ? '<progress aria-label="Quality check progress"></progress>'
: '<progress max="100" value="' + pct + '" aria-label="Quality check progress"></progress>');
}
if (label) label.textContent = pct == null ? 'Checking…' : 'Checking… ' + pct + '%';
};
if (btn) btn.disabled = true;
showProgress('Starting quality check…', null);
let jobId;
try {
jobId = (await api('/api/magazine/' + state.doc.id + '/quality/run', { method: 'POST' })).job_id;
} catch (e) {
if (btn) btn.disabled = false;
if (label) label.textContent = 'Auto-fix all pages';
if (box) box.hidden = true;
return say('Could not start the quality 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('Quality check failed: ' + job.error, 'err'); }
else say(job.detail || 'Quality check finished', 'ok');
break;
}
const prog = magParsePageProgress(job.detail);
showProgress(job.detail || 'Working…', prog ? prog.pct : null);
magMarkLive(job, true);
await new Promise((r) => setTimeout(r, 1500));
}
if (btn) btn.disabled = false;
if (label) label.textContent = 'Auto-fix all pages';
magMarkLive(null);
if (failed) { if (box) box.textContent = 'Quality check failed — see the message above.'; return; }
state.layout = null;
await ensureLayout();
if (state.view !== 'review') renderContents();
try { magRenderQualityReport(await api('/api/magazine/' + state.doc.id + '/quality/report')); } catch (_) { /* no report */ }
await magLoadQuality(true);
}
async function magRefineFigures() {
if (!state.reviewPage) return;
return magRunFigureRefine(state.reviewPage);
}
async function magRefineAllFigures() {
const ok = await confirmDialog(
'Check and re-crop figure boundaries on every page in this magazine?',
{ title: 'Fix all figure boundaries?', okLabel: 'Fix all pages' });
if (!ok) return;
return magRunFigureRefine(null);
}
async function magApprove() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
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');
// Review is a physical page-by-page workflow. An article may span many
// pages, so advancing from its last claimed page skips pages the user has
// not inspected yet. Always advance from the page currently on screen;
// magReviewPage creates a page-scoped placeholder if nothing covers it.
const nextPage = Number(state.reviewPage || 0) + 1;
await magRefreshArticles();
if (nextPage <= Number(state.doc?.pageCount || state.layout?.page_count || 0)) {
await magReviewPage(nextPage);
}
else 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');
}
}
// ── Text flow: where the article carries on ───────────────────────────────────
// Server side (core.magazine.article_flow) uses the same fusion as narration, so the arrows show
// exactly what is read as one paragraph: below (same column), column (top of another column) or
// page (another page).
const MAG_FLOW_LABEL = { below: 'continues below', column: 'continues in the next column', page: 'continues on page' };
async function magFlowLoad() {
const a = state.article;
if (!a || !state.doc) return;
try {
state.flow = { article: a.id, ...(await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/flow')) };
} catch (_) { state.flow = null; }
magFlowPaint();
}
function magFlowToggle(on) {
state.flowOn = !!on;
magFlowPaint();
}
function magFlowPaint() {
const wrap = $('mag-review-page-wrap');
document.querySelectorAll('#mag-review .mag-flow-chip, #mag-review .mag-flow-svg').forEach((el) => el.remove());
if (!wrap || !state.flow || !state.flowOn || state.view !== 'review') return;
const page = state.reviewPage;
const boxOf = (id) => wrap.querySelector('.mag-review-box[data-block="' + CSS.escape(id) + '"]');
const chip = (id, where, text, cls) => {
const el = boxOf(id);
if (!el) return;
const c = document.createElement('span');
c.className = 'mag-flow-chip is-' + where + ' ' + cls;
c.textContent = text;
el.appendChild(c);
};
if (state.flow.first) chip(state.flow.first, 'top', 'article starts', 'is-edge');
if (state.flow.last) {
// "Article ends" only when it says so (a signed end such as "(hag)"). Otherwise the next page
// may well carry it on: offer to link it with one click.
const nextPage = (state.flow.last_page || 0) + 1;
const lastBox = boxOf(state.flow.last);
if (state.flow.ended || nextPage > Number(state.layout?.page_count || 0)) {
chip(state.flow.last, 'bottom', 'article ends', 'is-edge');
} else if (lastBox) {
const open = document.createElement('button');
open.type = 'button';
open.className = 'mag-flow-chip is-bottom is-open';
open.textContent = 'no end marker — link page ' + nextPage + ' ›';
open.title = 'This article has no signed end, so it may continue on page ' + nextPage + '. Click to add the text of that page that carries it on.';
open.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); magLinkPage('after', nextPage); });
lastBox.appendChild(open);
}
}
const arrows = [];
(state.flow.links || []).forEach((l) => {
if (l.relation === 'page') {
if (l.from_page === page) chip(l.from, 'bottom', 'continues on page ' + l.to_page + ' ›', 'is-page');
if (l.to_page === page) chip(l.to, 'top', '‹ from page ' + l.from_page, 'is-page');
} else if (l.from_page === page && (l.relation === 'column' || l.sentence)) {
// A jump to another column always gets an arrow; a step to the box below only when a
// sentence runs across it (a plain new paragraph below is obvious and would be clutter).
arrows.push(l);
}
});
if (!arrows.length) return;
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('class', 'mag-flow-svg');
svg.setAttribute('aria-hidden', 'true');
const W = wrap.clientWidth, H = wrap.clientHeight;
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.innerHTML = '<defs><marker id="mag-flow-head" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">' +
'<path d="M0,0 L10,5 L0,10 z" fill="#16a34a"/></marker></defs>';
const wr = wrap.getBoundingClientRect();
arrows.forEach((l) => {
const a = boxOf(l.from), b = boxOf(l.to);
if (!a || !b) return;
const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
const ax = ra.left - wr.left + ra.width / 2, ay = ra.bottom - wr.top - 6;
const bx = rb.left - wr.left + rb.width / 2, by = rb.top - wr.top + 6;
const path = document.createElementNS(svgNS, 'path');
const bend = l.relation === 'column' ? Math.max(30, Math.abs(by - ay) * 0.35) : 0;
path.setAttribute('d', l.relation === 'column'
? 'M' + ax + ',' + ay + ' C' + ax + ',' + (ay + bend) + ' ' + bx + ',' + (by - bend) + ' ' + bx + ',' + by
: 'M' + ax + ',' + ay + ' L' + bx + ',' + by);
path.setAttribute('class', 'mag-flow-path' + (l.sentence ? '' : ' is-paragraph'));
path.setAttribute('marker-end', 'url(#mag-flow-head)');
svg.appendChild(path);
const label = document.createElementNS(svgNS, 'text');
label.setAttribute('class', 'mag-flow-label');
label.setAttribute('x', (ax + bx) / 2 + 6);
label.setAttribute('y', (ay + by) / 2);
label.textContent = l.relation === 'column' ? (l.sentence ? 'sentence continues in next column' : 'next column') : 'sentence continues';
svg.appendChild(label);
});
wrap.appendChild(svg);
if (!wrap._magFlowObserver && typeof ResizeObserver === 'function') {
wrap._magFlowObserver = new ResizeObserver(() => { wrap.classList.toggle('has-room', wrap.clientWidth >= 640); if (state.flowOn) magFlowPaint(); });
wrap._magFlowObserver.observe(wrap);
}
}
// ── What is wrong on this page: markers on the boxes + one-click fixes ──────────
// The audit is measured live from the page render (GET .../pages/N/audit), not read from the last
// whole-issue run, so it always matches what is on screen. Each kind of problem has its own
// marker on the page and its own plain-language row with the button that fixes it.
let _magAuditSeq = 0;
const MAG_ISSUE_KINDS = [
{ key: 'uncovered', cls: 'is-uncovered', label: 'Text outside any box',
what: (n) => n + ' area' + (n === 1 ? '' : 's') + ' of printed text ' + (n === 1 ? 'is' : 'are') + ' not inside any box, so ' + (n === 1 ? 'it' : 'they') + ' would never be read (red dashed frame).',
fix: 'Find missed text', run: "magRunPageQuality('missing')" },
{ key: 'clipped', cls: 'is-issue-clipped', label: 'Edge cuts text',
what: (n) => n + ' box edge' + (n === 1 ? '' : 's') + ' cut' + (n === 1 ? 's' : '') + ' through the text, so letters or words at the border can be lost (orange edge).',
fix: 'Fit boxes to the text', run: "magAutofixPage('boxes')" },
{ key: 'overlaps', cls: 'is-issue-overlap', label: 'Overlaps another box',
what: (n) => n + ' pair' + (n === 1 ? '' : 's') + ' of boxes cover the same text, so it may be read twice (purple dashed outline).',
fix: null, hint: 'Delete or merge one of the two (Select boxes, then Delete), or run Clean up boxes.' },
{ key: 'spelling', cls: null, label: 'Unknown words',
what: (n) => n + ' word' + (n === 1 ? '' : 's') + ' no German or English dictionary knows (red wavy underline in the list).',
fix: 'Fix with language model', run: 'magSpellOpenAndFix()' },
];
function magIssueCount(audit, key) {
const v = audit[key];
return Array.isArray(v) ? v.length : (Number(v) || 0);
}
async function magAuditLoad() {
const page = state.reviewPage;
if (page == null || !state.doc) return;
const seq = ++_magAuditSeq;
const panel = $('mag-issues-panel');
if (panel && !(state.audit && state.audit.page === page)) panel.innerHTML = '<p class="muted">Checking this page…</p>';
let data;
try { data = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/audit'); } catch (_) {
if (panel) panel.innerHTML = '';
return;
}
if (seq !== _magAuditSeq || state.view !== 'review') return;
state.audit = { page, data };
magAuditPaint();
}
function magAuditPaint() {
const wrap = $('mag-review-page-wrap');
const audit = state.audit && state.audit.page === state.reviewPage ? state.audit.data : null;
document.querySelectorAll('#mag-review .mag-issue-box').forEach((el) => el.remove());
document.querySelectorAll('#mag-review .mag-review-box').forEach((el) => {
el.classList.remove('is-issue-clipped', 'is-issue-overlap',
'side-left', 'side-right', 'side-top', 'side-bottom');
el.removeAttribute('data-issue');
});
const panel = $('mag-issues-panel');
// Labels inside boxes only fit on a large page image; a small one keeps just the outlines (and
// the label on hover), and the panel explains the colours.
if (wrap) wrap.classList.toggle('has-room', wrap.clientWidth >= 640);
if (!audit || !panel) return;
const boxOf = (id) => document.querySelector('#mag-review-page-wrap .mag-review-box[data-block="' + CSS.escape(id) + '"]');
const tag = (el, cls, text) => {
if (!el) return;
el.classList.add(cls);
el.setAttribute('data-issue', [el.getAttribute('data-issue'), text].filter(Boolean).join(' · '));
};
(audit.clipped || []).forEach((c) => {
const el = boxOf(c.id);
tag(el, 'is-issue-clipped', 'edge cuts text');
if (el) el.classList.add('side-' + c.side);
});
(audit.overlaps || []).forEach(([a, b]) => { tag(boxOf(a), 'is-issue-overlap', 'overlaps another box'); tag(boxOf(b), 'is-issue-overlap', 'overlaps another box'); });
const meta = (state.layout?.pages || []).find((p) => p.page === state.reviewPage);
if (wrap && meta) {
(audit.uncovered || []).forEach((b) => {
const el = document.createElement('div');
el.className = 'mag-issue-box is-uncovered';
el.setAttribute('data-issue', 'text outside any box');
el.style.cssText = 'left:' + (b[0] / meta.width * 100).toFixed(2) + '%;top:' + (b[1] / meta.height * 100).toFixed(2) +
'%;width:' + (b[2] / meta.width * 100).toFixed(2) + '%;height:' + (b[3] / meta.height * 100).toFixed(2) + '%';
wrap.appendChild(el);
});
}
const found = MAG_ISSUE_KINDS.filter((k) => magIssueCount(audit, k.key) > 0);
const total = found.reduce((n, k) => n + magIssueCount(audit, k.key), 0);
let html = '<div class="mag-issues-head"><strong>' + (total ? 'What is wrong on this page' : '✓ Nothing wrong found on this page') + '</strong>' +
'<button type="button" class="btn-sm primary" id="mag-autofix-btn" onclick="magAutofixPage(\'all\')" ' +
'title="Fits boxes to their text, adds inset pull quotes, cuts paragraphs that wrap around them, finds missed text, re-reads suspicious words and (with a language model) fixes provable misspellings. One Undo brings the page back.">' +
'<span class="mdi mdi-auto-fix" aria-hidden="true"></span> Fix this page automatically</button>' +
'<span id="mag-issues-status" class="muted" role="status"></span>' +
'<span class="mag-link-page" title="Connect this article to a page it was not linked to: the text that belongs is picked for you and added ticked">' +
'<label>Link to page <input type="number" id="mag-link-page" min="1" max="' + Number(state.layout?.page_count || 9999) + '" style="width:5em"></label> ' +
'<button type="button" class="btn-sm" onclick="magLinkPage(\'after\')">Article continues there</button> ' +
'<button type="button" class="btn-sm" onclick="magLinkPage(\'before\')">Article starts there</button></span>' +
'<label class="mag-flow-toggle" title="Green arrows show where the article\'s text carries on: below, in the next column or on another page">' +
'<input type="checkbox" id="mag-flow-toggle" ' + (state.flowOn ? 'checked' : '') + ' onchange="magFlowToggle(this.checked)"> Show text flow</label></div>';
if (total) {
html += '<ul class="mag-issues-list">' + found.map((k) => {
const n = magIssueCount(audit, k.key);
return '<li><span class="mag-issue-swatch ' + (k.cls || 'is-spell') + '" aria-hidden="true"></span>' +
'<span class="mag-issues-text"><strong>' + esc(k.label) + '</strong> — ' + esc(k.what(n)) +
(k.fix ? '' : ' <span class="muted">' + esc(k.hint) + '</span>') + '</span>' +
(k.fix ? '<button type="button" class="btn-sm" onclick="' + k.run + '">' + esc(k.fix) + '</button>' : '') + '</li>';
}).join('') + '</ul>';
}
if (audit.advertisement) html += '<p class="muted">This page is an advertisement: nothing on it is read aloud.</p>';
panel.innerHTML = html;
}
async function magAutofixPage(mode) {
const page = state.reviewPage;
if (page == null) return;
const btn = $('mag-autofix-btn');
const status = () => $('mag-issues-status');
if (btn) btn.disabled = true;
if (status()) status().textContent = 'Starting…';
let jobId;
try {
jobId = (await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/autofix', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }),
})).job_id;
} catch (e) {
if (btn) btn.disabled = false;
return say('Could not start the fix: ' + e.message, 'err');
}
let job;
for (;;) {
try { job = await api('/api/jobs/' + jobId); } catch (_) { job = null; break; }
if (job.done) break;
if (status() && job.detail) status().textContent = job.detail;
await new Promise((r) => setTimeout(r, 1500));
}
if (btn) btn.disabled = false;
if (!job || job.error) return say('Fixing the page failed: ' + (job && job.error || 'lost the job'), 'err');
say(job.detail || 'Page fixed', 'ok');
if (job.result && job.result.undo) state.undo = { token: job.result.undo, label: 'Auto-fixed page ' + page };
state.layout = null;
await ensureLayout();
const articleId = state.article.id;
await magRefreshArticles();
state.article = state.articles.find((a) => a.id === articleId) || state.article;
_magReviewChecked = null;
renderReview();
}
async function magLinkPage(where, pageOverride) {
const input = $('mag-link-page');
const page = Number(pageOverride || (input && input.value));
if (!page) return say('Type the page number to link first', 'err');
try {
const r = await api('/api/magazine/' + state.doc.id + '/articles/' + state.article.id + '/link-page', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page, where }),
});
say('Linked ' + r.added.length + ' region' + (r.added.length === 1 ? '' : 's') + ' from page ' + page + ' — review them on that page', 'ok');
state.undo = { token: r.undo, label: 'Linked page ' + page };
} catch (e) {
return say(e.message, 'err');
}
const articleId = state.article.id;
await magRefreshArticles();
state.article = state.articles.find((a) => a.id === articleId) || state.article;
_magReviewChecked = null;
state.reviewPage = page;
state.layout = null;
await ensureLayout();
renderReview();
}
async function magSpellOpenAndFix() {
if (!state.spellOpen) magSpellToggle();
await magSpellAgent();
}
// ── Spelling and pronunciation checks ────────────────────────────────────────
// Spelling: dictionary-unknown words are MARKED (review list) and listed with
// suggestions; nothing is rewritten unless the reviewer picks a suggestion or the
// language-model agent's answer passes the server's guards. Pronunciation: the
// per-article acronym glossary that decides how the narrator says "NAS" or "ML".
let _magSpellSeq = 0;
function magSpellMarked(blockId, text) {
const issues = (state.spellByBlock && state.spellByBlock.get(blockId)) || [];
let out = '';
let pos = 0;
issues.filter((i) => i.end <= text.length).sort((x, y) => x.start - y.start).forEach((i) => {
if (i.start < pos) return;
const hint = 'Possible misspelling' + (i.suggestions.length ? ' — try: ' + i.suggestions.join(', ') : '');
// In a review row a flagged word is a control: click it to correct just that word, in place.
const live = state.view === 'review' && i.block_id === blockId;
out += esc(text.slice(pos, i.start)) + '<mark class="mag-spell' + (live ? ' mag-spell-edit' : '') + '" title="' + esc(hint + (live ? ' — click to correct' : '')) + '"' +
(live ? ' role="button" tabindex="0" data-spell-start="' + Number(i.start) + '" data-spell-end="' + Number(i.end) + '"' +
' onclick="magWordEdit(\'' + esc(blockId) + '\', ' + Number(i.start) + ', ' + Number(i.end) + ', event)"' +
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){magWordEdit(\'' + esc(blockId) + '\', ' + Number(i.start) + ', ' + Number(i.end) + ', event);}"' : '') + '>' +
esc(text.slice(i.start, i.end)) + '</mark>';
pos = i.end;
});
return out + esc(text.slice(pos));
}
function magSpellPanelHtml(issues, scopeLabel) {
const agent = '<button type="button" class="btn-sm" id="mag-spell-agent-btn" onclick="magSpellAgent()" ' +
'title="The language model fixes only clear OCR errors, and only with a real word close to the printed one. Names and technical terms are kept, everything else stays marked here.">' +
'<span class="mdi mdi-robot-outline" aria-hidden="true"></span> Fix with language model</button>' +
'<span id="mag-spell-status" class="muted" role="status" aria-live="polite"></span>';
if (!issues.length) {
return '<p class="muted">No unknown words on ' + esc(scopeLabel) + '. ' + agent + '</p>';
}
return '<p class="muted">' + issues.length + ' word' + (issues.length === 1 ? '' : 's') + ' on ' + esc(scopeLabel) +
' that no German or English dictionary knows. Names and technical terms are normal &mdash; choose <em>Keep</em> for those. ' + agent + '</p>' +
'<ul class="mag-spell-list">' + issues.map((i, n) => {
const at = i.context.indexOf(i.token);
const word = '<mark class="mag-spell mag-spell-edit" role="button" tabindex="0" title="Click to correct this word yourself" ' +
'onclick="magSpellEdit(' + n + ')" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();magSpellEdit(' + n + ');}">' + esc(i.token) + '</mark>';
const ctx = at < 0 ? esc(i.context) : esc(i.context.slice(0, at)) + word + esc(i.context.slice(at + i.token.length));
return '<li data-spell="' + n + '"><span class="mag-spell-ctx">' + (i.page ? 'p.' + Number(i.page) + ' · ' : '') + '…' + ctx + '…</span> ' +
i.suggestions.map((sug, k) => '<button type="button" class="btn-sm" onclick="magSpellFix(' + n + ',' + k + ')" ' +
'aria-label="Replace ' + esc(i.token) + ' with ' + esc(sug) + '">' + esc(sug) + '</button>').join(' ') +
' <button type="button" class="btn-sm" onclick="magSpellEdit(' + n + ')" title="Type your own correction">Edit…</button>' +
' <button type="button" class="btn-sm" onclick="magSpellKeep(' + n + ')" ' +
'title="Add ' + esc(i.token) + ' to this issue\'s dictionary">Keep</button></li>';
}).join('') + '</ul>';
}
async function magSpellLoadPage() {
const page = state.reviewPage;
if (page == null || !state.doc) return;
const seq = ++_magSpellSeq;
let data;
try { data = await api('/api/magazine/' + state.doc.id + '/spelling?page=' + page); } catch (_) { return; }
if (seq !== _magSpellSeq || state.view !== 'review') return;
state.spell = { issues: data.issues || [], scope: 'page ' + page, page };
state.spellByBlock = new Map();
state.spell.issues.forEach((i) => {
if (!state.spellByBlock.has(i.block_id)) state.spellByBlock.set(i.block_id, []);
state.spellByBlock.get(i.block_id).push(i);
});
const blocks = new Map((state.layout?.blocks || []).map((b) => [b.id, b]));
document.querySelectorAll('#mag-review [data-text-of]').forEach((el) => {
const b = blocks.get(el.dataset.textOf);
if (b) el.innerHTML = magSpellMarked(b.id, b.text);
});
const count = $('mag-spell-count');
if (count) count.textContent = state.spell.issues.length ? String(state.spell.issues.length) : '';
const panel = $('mag-spell-panel');
if (panel) panel.innerHTML = magSpellPanelHtml(state.spell.issues, 'this page');
}
function magSpellToggle() {
state.spellOpen = !state.spellOpen;
const panel = $('mag-spell-panel');
if (panel) panel.hidden = !state.spellOpen;
$('mag-spell-btn')?.setAttribute('aria-expanded', String(!!state.spellOpen));
}
async function magSpellAfterChange() {
state.layout = null;
await ensureLayout();
if (state.view === 'review') return renderReview();
await magReloadArticleText();
return magChecksLoad();
}
async function magSpellFix(n, k) {
const issue = state.spell?.issues[n];
if (issue && issue.suggestions[k]) await magSpellApply(n, issue.suggestions[k]);
}
// Correct one finding with the given text (a suggestion, or whatever the reviewer typed).
async function magSpellApply(n, replacement) {
const issue = state.spell?.issues[n];
if (!issue) return;
try {
const r = await api('/api/magazine/' + state.doc.id + '/spelling/fix', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ block_id: issue.block_id, start: issue.start, end: issue.end,
token: issue.raw || issue.token, replacement }),
});
if (state.view === 'review') magOfferUndo(r.undo, 'Corrected ' + issue.token);
say('Corrected ' + issue.token + ' → ' + replacement, 'ok');
} catch (e) {
return say('Could not correct: ' + e.message, 'err');
}
await magSpellAfterChange();
}
// Click the flagged word (or Edit…): the row turns into a small editor prefilled with the word.
// Enter applies, Escape cancels.
function magSpellEdit(n) {
const issue = state.spell?.issues[n];
const row = document.querySelector('.mag-spell-list li[data-spell="' + n + '"]');
if (!issue || !row || row.querySelector('.mag-spell-editor')) return;
const start = issue.raw || issue.token;
const editor = document.createElement('span');
editor.className = 'mag-spell-editor';
editor.innerHTML = '<input type="text" class="mag-spell-input" maxlength="120" autocomplete="off" spellcheck="false" ' +
'aria-label="Correct ' + esc(issue.token) + '" value="' + esc(start) + '" size="' + Math.max(12, start.length + 4) + '"> ' +
'<button type="button" class="btn-sm primary" data-apply>Apply</button> <button type="button" class="btn-sm" data-cancel>Cancel</button>';
row.innerHTML = '';
row.appendChild(editor);
const input = editor.querySelector('input');
const submit = () => {
const value = input.value.replace(/\s+/g, ' ').trim();
if (!value) return say('Type the corrected text first', 'err');
if (value === start) return say('That is what is already there', '');
magSpellApply(n, value);
};
const cancel = () => { if (state.view === 'review') magSpellLoadPage(); else magChecksLoad(); };
editor.querySelector('[data-apply]').addEventListener('click', submit);
editor.querySelector('[data-cancel]').addEventListener('click', cancel);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); submit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
input.focus();
input.select();
}
async function magSpellKeep(n) {
const issue = state.spell?.issues[n];
if (!issue) return;
try {
await api('/api/magazine/' + state.doc.id + '/spelling/dictionary', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ word: issue.token }),
});
} catch (e) {
return say('Could not add to the dictionary: ' + e.message, 'err');
}
say(issue.token + ' added to this issue\'s dictionary', 'ok');
if (state.view === 'review') return magSpellLoadPage();
return magChecksLoad();
}
async function magSpellAgent() {
const inRead = state.view === 'read';
const scope = inRead ? { article: state.article?.id } : { page: state.reviewPage };
const btn = $('mag-spell-agent-btn');
const status = () => $('mag-spell-status');
if (btn) btn.disabled = true;
let jobId;
try {
jobId = (await api('/api/magazine/' + state.doc.id + '/spelling/agent', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scope),
})).job_id;
} catch (e) {
if (btn) btn.disabled = false;
return say('Could not start the spelling check: ' + e.message, 'err');
}
let failed = false;
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) { failed = true; say('Spelling check failed: ' + job.error, 'err'); }
else say(job.detail || 'Spelling checked', 'ok');
break;
}
if (status() && job.detail) status().textContent = job.detail;
await new Promise((r) => setTimeout(r, 1500));
}
if (btn) btn.disabled = false;
if (!failed) await magSpellAfterChange();
}
// Reading view: the article's own acronym glossary and spelling findings.
async function magReloadArticleText() {
const a = state.article;
if (!a) return;
const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' +
($('mag-read-captions')?.checked ? 1 : 0) + '&descriptions=' + ($('mag-read-descriptions')?.checked ? 1 : 0));
state.sentences = splitSentences(data.blocks || []);
state.idx = Math.min(state.idx, Math.max(0, state.sentences.length - 1));
// Anything already synthesised was spoken from the old text.
state.audioCache = new Map();
state.prepGen += 1;
state.preparing = false;
syncPrepButton();
renderArticle();
}
async function magChecksLoad() {
const body = $('mag-checks-body');
const a = state.article;
if (!body || !a || !$('mag-checks')?.open) return;
let acr, spell;
try {
[acr, spell] = await Promise.all([
api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/acronyms'),
api('/api/magazine/' + state.doc.id + '/spelling?article=' + encodeURIComponent(a.id)),
]);
} catch (e) {
body.innerHTML = '<p class="muted">Could not load the checks: ' + esc(e.message) + '</p>';
return;
}
state.acronyms = acr;
state.spell = { issues: spell.issues || [], scope: 'this article' };
const row = (acronym, spoken, context) => '<tr><th scope="row">' + esc(acronym) + '</th><td>' +
'<input type="text" maxlength="80" data-acr="' + esc(acronym) + '" value="' + esc(spoken) + '" ' +
'placeholder="say it as printed" aria-label="Spoken form of ' + esc(acronym) + '" onchange="magAcrSave()"></td>' +
'<td class="muted">' + esc(context || '') + '</td></tr>';
const rows = Object.entries(acr.glossary || {}).map(([k, v]) => row(k, v, 'in this article\'s glossary')).join('') +
(acr.unknown || []).map((u) => row(u.acronym, '', u.context)).join('');
body.innerHTML =
'<h4>Acronyms <span class="muted">(' + (acr.language === 'de' ? 'German' : 'English') + ' article)</span></h4>' +
'<p class="muted">Common acronyms are spoken from a built-in list in the article&rsquo;s language. Others are listed here with the sentence they appear in; ' +
'type how the narrator should say one (empty = as printed), or let the language model decide from the context.</p>' +
'<button type="button" class="btn-sm" id="mag-acr-resolve-btn" onclick="magAcrResolve()"' + ((acr.unknown || []).length ? '' : ' disabled') + '>' +
'<span class="mdi mdi-robot-outline" aria-hidden="true"></span> Let the language model decide</button>' +
(rows ? '<table class="mag-acr-table"><thead><tr><th>Acronym</th><th>Spoken as</th><th>Context</th></tr></thead><tbody>' + rows + '</tbody></table>'
: '<p class="muted">No unfamiliar acronyms in this article.</p>') +
'<h4>Spelling</h4>' + magSpellPanelHtml(state.spell.issues, 'this article');
}
function magChecksToggled(el) {
if (el.open) magChecksLoad();
}
async function magAcrSave() {
const glossary = {};
document.querySelectorAll('#mag-checks-body [data-acr]').forEach((input) => {
if (input.value.trim()) glossary[input.dataset.acr] = input.value.trim();
});
try {
await api('/api/magazine/' + state.doc.id + '/articles/' + state.article.id + '/acronyms', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ glossary }),
});
state.article.glossary = glossary;
await magReloadArticleText();
} catch (e) {
return say('Could not save the glossary: ' + e.message, 'err');
}
magChecksLoad();
}
async function magAcrResolve() {
const btn = $('mag-acr-resolve-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Asking the language model…'; }
try {
const r = await api('/api/magazine/' + state.doc.id + '/articles/' + state.article.id + '/acronyms/resolve', { method: 'POST' });
state.article.glossary = r.glossary;
say(r.resolved ? 'Decided ' + r.resolved + ' acronym' + (r.resolved === 1 ? '' : 's') + ' from context' : 'Nothing could be decided', r.resolved ? 'ok' : '');
await magReloadArticleText();
} catch (e) {
say('Could not look up the acronyms: ' + e.message, 'err');
}
magChecksLoad();
}
// ── Reading: sentences, both views ───────────────────────────────────────────
// Sentence splitting keeps each piece tied to its source block, so highlighting
// in the reflowed view can drive the page shown in the PDF view.
function splitSentences(blocks) {
const out = [];
blocks.forEach((b) => {
const parts = String(b.text || '')
.split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/)
.map((s) => s.trim()).filter(Boolean);
const spokenParts = String(b.spoken_text || b.text || '')
.split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/)
.map((s) => s.trim()).filter(Boolean);
// A paragraph fused across a column/page break carries `parts`: the source
// regions with their offsets in the fused text. Each sentence takes the
// region it starts in, so highlighting and page jumps still follow the print.
let cursor = 0;
(parts.length ? parts : [b.text]).forEach((text, partIndex) => {
if (text && text.trim()) {
let origin = b;
if (Array.isArray(b.parts) && b.parts.length > 1) {
const at = String(b.text || '').indexOf(text.trim(), cursor);
if (at >= 0) cursor = at + text.trim().length;
const src = b.parts.find((p) => at >= p.start && at < p.end) || b.parts[0];
origin = { id: src.id, page: src.page, bbox: src.bbox };
}
// Where a fused paragraph crosses from one region to the next, mark the word after which
// the print continues (below / in another column / on another page).
let seams = null;
if (Array.isArray(b.parts) && b.parts.length > 1) {
const start = String(b.text || '').indexOf(text.trim(), Math.max(0, cursor - text.trim().length));
b.parts.slice(1).forEach((part, k) => {
const prev = b.parts[k];
if (start >= 0 && part.start > start && part.start < start + text.trim().length) {
const before = text.trim().slice(0, part.start - start).split(/\s+/).filter(Boolean).length;
const relation = prev.page !== part.page ? 'page'
: (Math.abs((prev.bbox[0] + prev.bbox[2] / 2) - (part.bbox[0] + part.bbox[2] / 2)) <= 0.5 * Math.min(prev.bbox[2], part.bbox[2])
&& part.bbox[1] >= prev.bbox[1]) ? 'below' : 'column';
(seams = seams || []).push({ afterWord: before - 1, relation, page: part.page });
}
});
}
out.push({
seams,
text: text.trim(), blockId: origin.id, page: origin.page, bbox: origin.bbox, type: b.type,
spokenText: spokenParts[partIndex] || text.trim(),
// Used to pick "the image reached so far" during playback (see
// currentImageIndex) -- the backend already computes this per
// block relative to the whole page, not just this article.
readingOrder: b.reading_order || 0,
words: text.trim().split(/\s+/).filter(Boolean),
});
}
});
});
return out;
}
// The current article's figures, in reading order, resolved from the full
// layout (fetched once) filtered to this article's own image ids. Kept
// separate from state.sentences because an image has no sentence of its
// own -- it is matched to "the most recent sentence at or before it".
function articleImages(article) {
const ids = new Set(article.images || []);
return (state.layout.images || [])
.filter((img) => ids.has(img.id))
.sort((a, b) => (a.reading_order || 0) - (b.reading_order || 0));
}
// The image to show once sentence `i` has been reached: the last image
// whose position in the page's reading order is at or before that
// sentence's own block. Falls back to the first image before any text has
// caught up to it, so a lead photo is not left blank at the very start.
function currentImageIndex(i) {
if (!state.images.length) return -1;
const order = (state.sentences[i] || {}).readingOrder || 0;
let found = -1;
for (let k = 0; k < state.images.length; k++) {
if ((state.images[k].reading_order || 0) <= order) found = k;
else break;
}
return found === -1 ? 0 : found;
}
function renderCurrentImage(i) {
const host = $('mag-read-image');
if (!host) return;
const idx = currentImageIndex(i);
if (idx < 0) { host.hidden = true; return; }
const img = state.images[idx];
if (state._shownImageId === img.id) { host.hidden = false; return; }
state._shownImageId = img.id;
host.hidden = false;
host.innerHTML = '<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;
// A generated MP3 already exists: play that directly through the
// playlist player instead of picking a narrator and synthesising sentence
// by sentence again -- confirmed live, "Listen" on an article that had
// already been exported still went through the live reader from scratch.
if (a.has_audio) {
magTogglePlaylist(true);
magPlaylistPlay(articleId, opts);
return;
}
const playlistAudio = $('mag-playlist-audio');
if (playlistAudio) playlistAudio.pause();
state.playlistCurrent = null;
state.article = a;
state.audioCache = new Map();
state.prepGen += 1;
state.preparing = false;
state.prepCancel = false;
syncPrepButton();
await ensureLayout();
try {
const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' +
($('mag-read-captions')?.checked ? 1 : 0) + '&descriptions=' +
($('mag-read-descriptions')?.checked ? 1 : 0));
state.sentences = splitSentences(data.blocks || []);
state.asides = Array.isArray(data.asides) ? data.asides : [];
} 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);
// Sidebars are shown where they sit in the article but are not sentences: never narrated, never highlighted.
const asideHtml = (a2) => '<aside class="mag-aside" aria-label="Sidebar, not read aloud">' +
'<span class="mag-aside-label"><span class="mdi mdi-page-layout-sidebar-right" aria-hidden="true"></span> Sidebar · not read aloud · p. ' +
Number(a2.page) + '</span><span class="mag-aside-text">' + esc(a2.text) + '</span></aside>';
const asidesAfter = new Map();
let asidesFirst = '';
(state.asides || []).forEach((a2) => {
let at = a2.after ? state.sentences.map((x) => x.blockId).lastIndexOf(a2.after) : -1;
if (at < 0) {
// Not after a narrated region on its page: after the last sentence from an earlier page, else first.
for (let k = state.sentences.length - 1; k >= 0; k--) if (state.sentences[k].page < a2.page) { at = k; break; }
}
if (at < 0) asidesFirst += asideHtml(a2);
else asidesAfter.set(at, (asidesAfter.get(at) || '') + asideHtml(a2));
});
host.innerHTML = asidesFirst + state.sentences.map((s, i) => {
const tag = s.type === 'title' ? 'h2' : s.type === 'deck' ? 'p class="mag-deck"'
: s.type === 'byline' ? 'p class="mag-byline"' : s.type === 'caption'
? 'p class="mag-caption"' : s.type === 'figure_description'
? 'p class="mag-figure-description"' : 'span';
const open = tag.split(' ')[0];
// Each word gets its own span so playback can highlight it individually
// (see estimateWordTimings) -- sentence-level highlighting alone doesn't
// need this, but word-level does and this markup serves both.
const wordsHtml = s.words.map((w, j) => {
const seam = (s.seams || []).find((m) => m.afterWord === j);
const marker = seam
? '<span class="mag-seam is-' + seam.relation + '" role="note" title="' + esc(seam.relation === 'page' ? 'Continues on page ' + seam.page
: seam.relation === 'column' ? 'Continues in the next column' : 'Continues in the next box below') + '">' +
(seam.relation === 'page' ? 'p.' + Number(seam.page) + ' ›' : seam.relation === 'column' ? 'col ›' : '↓') + '</span>'
: '';
return '<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>' + marker;
}).join(' ');
return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' +
'onclick="magSeek(' + i + ')">' + wordsHtml + '</' + open + '> ' + (asidesAfter.get(i) || '');
}).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 = magPageSrc(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].spokenText || state.sentences[i].text); }
catch (e) { failure = { i, message: e.message }; return; }
done += 1;
if (gen === state.prepGen) syncPrepButton(done, total);
}
};
await Promise.all(Array.from({ length: Math.min(MAG_PREP_CONCURRENCY, total) }, worker));
// An article change while this ran already reset the flags -- touching them
// now would clobber a newer run.
if (gen !== state.prepGen) return;
const cancelled = state.prepCancel;
state.preparing = false;
state.prepCancel = false;
syncPrepButton();
if (failure) say('Synthesis failed at sentence ' + (failure.i + 1) + ': ' + failure.message, 'err');
else if (cancelled) say('Audio preparation cancelled', '');
else say('Audio ready — ' + total + ' sentences', 'ok');
}
// ── Playback ─────────────────────────────────────────────────────────────────
async function magPlay() {
if (state.playing) return magPause();
const voice = magNarratorVoice();
if (!voice) return say('Pick a narrator voice first', 'err');
state.playing = true;
state.cancel = false;
syncPlayButton();
let finishedArticle = false;
try {
while (state.idx < state.sentences.length && !state.cancel) {
highlight(state.idx);
const sentence = state.sentences[state.idx];
let blob;
try {
const pending = magSynth(voice, sentence.spokenText || sentence.text);
// Without a full "Prepare audio" run, still stay a few sentences ahead
// so the wait is only ever for the first one.
if (!state.preparing) {
for (let k = 1; k <= MAG_LOOKAHEAD; k++) {
const upcoming = state.sentences[state.idx + k];
if (upcoming) magSynth(voice, upcoming.spokenText || upcoming.text).catch(() => {});
}
}
blob = await pending;
} catch (e) {
say('Synthesis failed: ' + e.message, 'err');
break;
}
if (state.cancel) break;
await playBlob(blob, sentence);
if (state.cancel) break;
state.idx += 1;
saveProgress();
}
finishedArticle = state.idx >= state.sentences.length && !state.cancel;
} finally {
state.playing = false;
state.cancel = false;
syncPlayButton();
}
if (finishedArticle) {
state.idx = state.sentences.length - 1;
if (state.queue.length && state.queueIndex < state.queue.length - 1) {
// Play-all: move straight to the next approved article rather than
// stopping -- the whole point of a queue is not touching the screen
// between articles.
state.queueIndex += 1;
await magRead(state.queue[state.queueIndex], { fromStart: true });
magPlay();
} else {
state.queue = [];
state.queueIndex = -1;
say('Article finished', 'ok');
}
}
}
function playBlob(blob, sentence) {
return new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
state.audio = audio;
let timings = null;
const onTimeUpdate = () => {
if (!state.karaoke || !sentence || !sentence.words.length) return;
if (!timings) {
if (!audio.duration || !isFinite(audio.duration)) return;
timings = estimateWordTimings(sentence.words, audio.duration);
}
const t = audio.currentTime;
const wi = timings.findIndex((w) => t >= w.start && t < w.end);
if (wi !== -1) highlightWord(state.idx, wi);
};
if (state.karaoke) audio.addEventListener('timeupdate', onTimeUpdate);
const done = () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
URL.revokeObjectURL(url);
state.audio = null;
resolve();
};
audio.onended = done;
audio.onerror = done;
audio.play().catch(done);
});
}
// ── Play all approved articles in sequence ──────────────────────────────
async function magPlayAll() {
const approved = state.articles.filter((a) => a.status === 'approved');
if (!approved.length) return say('Approve at least one article first', 'err');
// Every approved article already has a generated MP3: use those directly
// (ready instantly, tagged, works exactly like the standalone export)
// instead of synthesising sentence by sentence live. A queue with even
// one ungenerated article keeps the original live-reading behaviour,
// since a listener partway through a magazine mid-drive should not hit a
// gap where one piece silently has no audio.
if (approved.every((a) => a.has_audio)) {
magTogglePlaylist(true);
magPlaylistPlay(_magPlaylistOrder()[0]?.id);
return;
}
const ids = approved.map((a) => a.id);
state.queue = ids;
state.queueIndex = 0;
await magRead(ids[0], { fromStart: true });
magPlay();
}
// ── MP3 Playlist (generated audio, reading order) ───────────────────────────
// A separate, self-contained playback path from the live sentence-by-
// sentence reader above: this just points a plain <audio> element at the
// already-rendered, already-tagged files from the MP3 export feature, one
// after another. No synthesis wait, no karaoke word-timing to estimate --
// exactly what "driving mode" wants, when the audio already exists.
function _magPlaylistOrder() {
return state.articles
.filter((a) => a.has_audio)
.slice()
.sort((a, b) => (a.pages?.[0] ?? 0) - (b.pages?.[0] ?? 0));
}
function magTogglePlaylist(forceOpen) {
const panel = $('mag-playlist');
const btn = $('mag-playlist-toggle');
if (!panel) return;
const open = forceOpen === true ? true : forceOpen === false ? false : panel.hidden;
panel.hidden = !open;
if (btn) btn.setAttribute('aria-pressed', String(open));
if (open) renderPlaylistTracks();
else { const audio = $('mag-playlist-audio'); if (audio) audio.pause(); }
}
function renderPlaylistTracks() {
const list = $('mag-playlist-tracks');
if (!list) return;
const order = _magPlaylistOrder();
if (!order.length) {
list.innerHTML = '<li class="muted">No generated MP3s yet — use "Generate all audio" above.</li>';
return;
}
list.innerHTML = order.map((a, i) => {
const active = a.id === state.playlistCurrent;
const progress = (state.doc.progress || {})[a.id] || {};
const completed = !!progress.completed;
const started = !completed && Number(progress.seconds) > 0;
const status = completed ? 'complete' : started ? 'in-progress' : 'unplayed';
const statusLabel = completed ? 'Played' : started ? 'Partly played' : 'Not played';
const duration = a.audio_seconds || progress.duration || a.estimated_seconds;
return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' +
'<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' +
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi ' +
(active ? 'mdi-volume-high' : completed ? 'mdi-replay' : 'mdi-play') + '" aria-hidden="true"></span></button>' +
'<span class="mag-playlist-track-page">' + esc(fmtPages(a.pages)) + '</span>' +
'<span class="mag-playlist-track-main">' +
'<span class="mag-playlist-track-title">' + esc(a.title || 'Untitled') + '</span>' +
(a.author ? '<span class="mag-playlist-track-sub muted">by ' + esc(a.author) + '</span>' : '') +
'</span>' +
'<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' +
'<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' +
(completed ? 'mdi-check-circle' : started ? 'mdi-progress-clock' : 'mdi-circle-outline') +
'" aria-hidden="true"></span><span class="sr-only">' + statusLabel + '</span></span>' +
'</li>';
}).join('');
}
function playlistDuration(a) {
const audio = $('mag-playlist-audio');
if (audio && Number.isFinite(audio.duration) && audio.duration > 0) return audio.duration;
const progress = (state.doc?.progress || {})[a?.id] || {};
return Number(a?.audio_seconds || progress.duration || a?.estimated_seconds || 0);
}
function magFmtClock(seconds) {
const total = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s).padStart(2, '0');
}
function savePlaylistProgress(completed, immediate) {
const a = state.articles.find((x) => x.id === state.playlistCurrent);
const audio = $('mag-playlist-audio');
if (!a || !audio || !state.doc) return;
const now = Date.now();
if (!immediate && now - state.playlistLastSaved < 5000) return;
state.playlistLastSaved = now;
const seconds = completed ? (Number.isFinite(audio.duration) ? audio.duration : audio.currentTime) : audio.currentTime;
const duration = Number.isFinite(audio.duration) ? audio.duration : playlistDuration(a);
const entry = Object.assign({}, (state.doc.progress || {})[a.id], {
seconds: Math.max(0, seconds || 0), duration: Math.max(0, duration || 0), completed: !!completed,
});
state.doc.progress = state.doc.progress || {};
state.doc.progress[a.id] = entry;
fetch('/api/magazine/' + state.doc.id + '/progress', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, keepalive: !!immediate,
body: JSON.stringify({ article: a.id, seconds: entry.seconds, duration: entry.duration, completed: entry.completed }),
}).catch(() => {});
renderPlaylistTracks();
}
// One {start, end, speech, eff, skip} per displayed sentence, in seconds on the MP3's own clock.
// The text is fetched with captions (shown), but the MP3 is built without them, so a caption sentence has no
// slot (skip, zero width, sitting where the narration reaches it). Narrated sentences use the exact times the
// renderer recorded (state.playlistExact, one entry per narrated sentence, from /timings) when there are any,
// else a length-based estimate over `duration`. `speech` is when the words are first audible (each clip has a
// silent lead-in); `eff` is when the sentence lights up: just before its speech, never before its own start and
// never earlier than the sentence before it. The highlight only ever reads audio.currentTime against this
// (a binary search over `eff`), so seeking by slider, skip buttons, click, lock screen or resume cannot desync it.
const MAG_HIGHLIGHT_LEAD = 0.15; // seconds a sentence lights up before its first word is heard
function playlistTimeline(duration) {
const sentences = state.playlistSentences;
const exact = state.playlistExact;
const key = state.playlistCurrent + '|' + sentences.length + '|' + (exact ? exact.length : 0) + '|' + Math.round(duration * 100);
if (state.playlistTimelineKey === key && state.playlistTimelineCache) return state.playlistTimelineCache;
const narrated = sentences.filter((s) => s.type !== 'caption');
let result;
if (exact && exact.length === narrated.length && exact.length) {
let n = 0, cursor = 0;
result = sentences.map((s) => {
if (s.type === 'caption') return { start: cursor, end: cursor, speech: cursor, skip: true };
const t = exact[n++];
cursor = t.end;
const speech = Number.isFinite(t.speech) ? Math.min(t.end, Math.max(t.start, t.speech)) : t.start;
return { start: t.start, end: t.end, speech, skip: false };
});
} else {
const weights = sentences.map((s) => s.type === 'caption' ? 0 : Math.max(6, s.text.replace(/\s+/g, ' ').length));
const total = weights.reduce((sum, n) => sum + n, 0) || 1;
let cursor = 0;
result = weights.map((weight) => {
const start = cursor / total * duration;
cursor += weight;
return { start, end: cursor / total * duration, speech: start, skip: weight === 0 };
});
}
const slots = [];
const pos = {};
let prevEff = 0;
result.forEach((t, i) => {
if (t.skip) { t.eff = prevEff; return; }
t.eff = slots.length ? Math.max(prevEff, Math.min(t.end, Math.max(t.start, t.speech - MAG_HIGHLIGHT_LEAD))) : 0;
prevEff = t.eff;
pos[i] = slots.length;
slots.push(i);
});
result.slots = slots; // indices of the narrated sentences, in order
result.pos = pos; // sentence index -> position in slots
state.playlistTimelineKey = key;
state.playlistTimelineCache = result;
return result;
}
// The sentence the audio is in: the last narrated one whose lead-in has begun (binary search). Before the first
// that is the first; in a silent gap it stays on the sentence just spoken; a caption never takes the highlight.
function playlistIndexAt(timeline, time) {
const slots = timeline.slots || [];
if (!slots.length) return 0;
let lo = 0, hi = slots.length - 1, found = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (timeline[slots[mid]].eff <= time) { found = mid; lo = mid + 1; } else hi = mid - 1;
}
return slots[found];
}
// Fraction (0..1) of the sentence's own spoken part [speech, end] that has played.
function playlistLocal(span, time) {
if (!span) return 0;
return Math.max(0, Math.min(0.999, (time - span.speech) / Math.max(0.1, span.end - span.speech)));
}
// Which word is being said, by the share of the sentence's characters spoken so far.
function playlistWordAt(words, local) {
if (!words || !words.length) return -1;
const lens = words.map((w) => String(w).length + 1);
const total = lens.reduce((a, b) => a + b, 0);
let acc = 0;
for (let i = 0; i < lens.length; i++) { acc += lens[i]; if (local * total < acc) return i; }
return words.length - 1;
}
function magPlaylistSeekSentence(index) {
const audio = $('mag-playlist-audio');
if (!audio || !state.playlistSentences[index]) return;
const timeline = playlistTimeline(playlistDuration(state.article));
const span = timeline[index];
if (span) audio.currentTime = span.skip ? span.start : Math.min(span.end, span.eff + 0.005);
audio.play().catch(() => {});
magPlaylistSync();
}
function renderPlaylistCompanion() {
const companion = $('mag-playlist-companion');
const transcript = $('mag-playlist-transcript');
if (!companion || !transcript) return;
companion.hidden = !state.playlistSentences.length && !state.playlistImages.length;
state.playlistHighlightIndex = -1;
transcript.innerHTML = state.playlistSentences.map((s, i) => {
const tag = ['title', 'heading'].includes(s.type) ? 'h3' : 'p';
const words = s.words.map((w, j) => '<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>').join(' ');
return '<' + tag + ' class="mag-playlist-line" data-playlist-sentence="' + i + '" ' +
'onclick="magPlaylistSeekSentence(' + i + ')">' + words + '</' + tag + '>';
}).join('');
magPlaylistSync();
}
function renderPlaylistVisual(sentenceIndex) {
const host = $('mag-playlist-visual');
if (!host) return;
if (!state.playlistImages.length) { host.hidden = true; return; }
const order = state.playlistSentences[sentenceIndex]?.readingOrder || 0;
let imageIndex = 0;
state.playlistImages.forEach((img, i) => { if ((img.reading_order || 0) <= order) imageIndex = i; });
const img = state.playlistImages[imageIndex];
if (host.dataset.imageId === img.id) { host.hidden = false; return; }
host.dataset.imageId = img.id;
const label = img.type === 'table' ? 'Table' : img.type === 'chart' ? 'Graph' : 'Image';
host.innerHTML = '<img src="/api/magazine/' + state.doc.id + '/images/' + encodeURIComponent(img.filename) +
'" alt="' + label + ' from page ' + img.page + '">' +
'<figcaption><strong>' + label + ' · page ' + img.page + '</strong>' +
(img.description ? '<span>' + esc(img.description) + '</span>' : '') + '</figcaption>';
host.hidden = false;
}
function magPlaylistSync() {
const audio = $('mag-playlist-audio');
const a = state.articles.find((x) => x.id === state.playlistCurrent);
if (!audio || !a) return;
const time = audio.currentTime;
const duration = playlistDuration(a);
const timeline = playlistTimeline(duration);
const index = playlistIndexAt(timeline, time);
const span = timeline[index];
const local = playlistLocal(span, time);
const wordIdx = playlistWordAt(state.playlistSentences[index]?.words, local);
const follow = $('mag-playlist-karaoke')?.checked !== false;
document.querySelectorAll('#mag-playlist-transcript [data-playlist-sentence]').forEach((el) => {
el.classList.toggle('is-current', follow && Number(el.dataset.playlistSentence) === index);
el.querySelectorAll('.mag-w').forEach((w) => w.classList.remove('is-current'));
});
const line = document.querySelector('#mag-playlist-transcript [data-playlist-sentence="' + index + '"]');
if (follow && line && span) {
const words = line.querySelectorAll('.mag-w');
if (words[wordIdx]) words[wordIdx].classList.add('is-current');
if (state.playlistHighlightIndex !== index) {
if (!prefersReducedMotion()) line.scrollIntoView({ block: 'center', behavior: 'smooth' });
else line.scrollIntoView({ block: 'center' });
state.playlistHighlightIndex = index;
}
}
renderPlaylistVisual(index);
const pct = duration ? Math.min(100, time / duration * 100) : 0;
state.syncIndex = index; state.syncWord = wordIdx; state.syncTime = time;
if (state.driving) magDrivingSync(audio, duration, pct, index, local, timeline);
}
// Per-frame check while playing: the full sync (DOM work) only runs when the sentence or word changed, or
// 4 times a second for the clock and slider.
function magPlaylistFrame() {
const audio = $('mag-playlist-audio');
const a = state.articles.find((x) => x.id === state.playlistCurrent);
if (!audio || !a) return;
const time = audio.currentTime;
const timeline = playlistTimeline(playlistDuration(a));
const index = playlistIndexAt(timeline, time);
const wordIdx = playlistWordAt(state.playlistSentences[index]?.words, playlistLocal(timeline[index], time));
if (index !== state.syncIndex || wordIdx !== state.syncWord || Math.abs(time - (state.syncTime || 0)) >= 0.25) magPlaylistSync();
}
// One loop reads audio.currentTime (media time: playbackRate and seeks never skew it) every animation frame
// while audio plays. Events below force a resync for everything the loop cannot see (paused seeks, hidden tabs).
function magSyncLoopStart() {
if (state.syncRaf) return;
const tick = () => {
state.syncRaf = 0;
const audio = $('mag-playlist-audio');
if (!audio || audio.paused || audio.ended) return;
magPlaylistFrame();
state.syncRaf = requestAnimationFrame(tick);
};
state.syncRaf = requestAnimationFrame(tick);
}
async function loadPlaylistCompanion(a) {
const token = ++state.playlistLoadToken;
state.playlistSentences = [];
state.playlistImages = [];
state.playlistExact = null;
state.playlistTimelineKey = null;
renderPlaylistCompanion();
try {
await ensureLayout();
const base = '/api/magazine/' + state.doc.id + '/articles/' + a.id;
// Timings are optional: older audio has none and the player then estimates.
const [data, timings] = await Promise.all([
api(base + '/text?captions=1&descriptions=0'),
api(base + '/timings').catch(() => null),
]);
if (token !== state.playlistLoadToken || state.playlistCurrent !== a.id) return;
state.playlistSentences = splitSentences(data.blocks || []);
state.playlistExact = timings && timings.available && Array.isArray(timings.sentences)
? timings.sentences.map((u) => ({ start: Number(u.start), end: Number(u.end), speech: u.speech == null ? NaN : Number(u.speech) }))
.filter((u) => Number.isFinite(u.start) && Number.isFinite(u.end))
: null;
state.playlistTimelineKey = null;
state.playlistImages = articleImages(a);
renderPlaylistCompanion();
} catch (_) {
if (token === state.playlistLoadToken) renderPlaylistCompanion();
}
}
function magPlaylistPlay(articleId, options) {
if (!articleId) return;
const a = state.articles.find((x) => x.id === articleId);
if (!a) return;
if (!options?.skipCurrentSave && state.playlistCurrent && state.playlistCurrent !== articleId) {
savePlaylistProgress(false, true);
}
state.playlistCurrent = articleId;
state.article = a;
state.playlistLastSaved = 0;
const audio = $('mag-playlist-audio');
if (audio) {
state.playlistSwitching = true;
audio.dataset.article = articleId;
// The element ships with preload="none" (nothing is fetched until a track is chosen). Choosing one must
// load it: with "none" the browser never fires loadedmetadata, and the code below waits for exactly that.
audio.preload = 'auto';
audio.src = '/api/magazine/' + state.doc.id + '/articles/' + articleId + '/audio';
queueMicrotask(() => { state.playlistSwitching = false; });
const saved = (state.doc.progress || {})[articleId] || {};
const resumeAt = options?.fromStart || saved.completed ? 0 : Number(saved.seconds || 0);
const resume = () => {
if (resumeAt > 0 && resumeAt < audio.duration - 1) audio.currentTime = resumeAt;
// A browser refuses to start audio without a tap on this page (autoplay policy); a caller that
// opened the player from a link passes onPlayBlocked to show a Play prompt instead of failing silently.
audio.play().catch((e) => { if (options?.onPlayBlocked) options.onPlayBlocked(e); });
};
if (audio.readyState >= 1) resume();
else audio.addEventListener('loadedmetadata', resume, { once: true });
}
const titleEl = $('mag-playlist-title');
const subEl = $('mag-playlist-sub');
if (titleEl) titleEl.textContent = a.title || 'Untitled';
if (subEl) subEl.textContent = (a.author ? 'by ' + a.author + ' · ' : '') + fmtPages(a.pages);
const cover = $('mag-playlist-cover');
if (cover) {
const firstPage = (a.pages || [])[0];
if (firstPage) { cover.src = magPageSrc(firstPage); cover.hidden = false; }
else cover.hidden = true;
}
renderPlaylistTracks();
loadPlaylistCompanion(a);
if (state.driving) { renderDriving(); magDrivingApplyToggles(); magApplyPlaybackSpeed(); }
}
function _magPlaylistNext(eventOrCompleted) {
const completed = eventOrCompleted !== false;
savePlaylistProgress(completed, true);
const order = _magPlaylistOrder();
const i = order.findIndex((a) => a.id === state.playlistCurrent);
if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true });
else syncDrivingPlayButton();
}
function magPlaylistPrevious() {
const order = _magPlaylistOrder();
const i = order.findIndex((a) => a.id === state.playlistCurrent);
if (i > 0) magPlaylistPlay(order[i - 1].id);
}
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', 'mag-audio-voice': 'Narrator for exported MP3s', 'mag-audio-interview-voice': 'Interview voice 2 (optional)' };
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);
if (def && $('mag-audio-voice') && !$('mag-audio-voice').value) setVoice('mag-audio-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) {
const changed = state.view !== view;
state.view = view;
if (view !== 'review') magClearFloatingPanels();
['library', 'contents', 'review', 'read'].forEach((v) => {
const el = $('mag-view-' + v);
if (el) el.hidden = v !== view;
});
// A new view starts at its top: the scroll offset of the page grid (which grows with the filter legend) must not
// carry into review, where it would leave the sticky page bar covering the top of the page picture.
if (changed && view === 'review') { const main = $('main-content'); if (main) main.scrollTop = 0; }
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();
magDrivingWake();
}
function magOpenDriving() {
const order = _magPlaylistOrder();
if (!order.length) return say('Generate the magazine audio first', 'err');
magTogglePlaylist(true);
if (!state.playlistCurrent) {
const next = order.find((a) => !((state.doc.progress || {})[a.id] || {}).completed) || order[0];
magPlaylistPlay(next.id);
}
if (!state.driving) magToggleDriving();
}
// ── Share: a deep link that opens an article (or the whole magazine) in driving mode ───────────────
// The link is only an address, #s-magazine?doc=<id>&article=<id>&mode=drive, on this app's own origin. It
// carries no credentials and grants nothing: whoever opens it reaches the app the same way as anyone
// else (the app's network / reverse-proxy access rules apply, and every API call is checked as usual).
const MAG_DOC_ID_RE = /^[A-Za-z0-9]{1,40}$/;
const MAG_ARTICLE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
// Strict parse of the URL fragment. Anything that is not exactly this shape is ignored (returns null); the ids
// are only ever matched against the loaded data and never written into markup.
function magParseDeepLink(hash) {
const m = /^#?\/?s-magazine\?([^#]*)$/.exec(String(hash || '').trim());
if (!m) return null;
let params;
try { params = new URLSearchParams(m[1]); } catch (_) { return null; }
const doc = params.get('doc') || '';
const article = params.get('article') || '';
if (!MAG_DOC_ID_RE.test(doc) || params.get('mode') !== 'drive') return null;
if (article && !MAG_ARTICLE_ID_RE.test(article)) return null;
return { doc, article };
}
function magDeepLinkUrl(docId, articleId) {
const params = new URLSearchParams({ doc: docId });
if (articleId) params.set('article', articleId);
params.set('mode', 'drive');
return location.origin + location.pathname + '#s-magazine?' + params.toString();
}
function magShareFallback(url) {
const sheet = $('mag-share-sheet'), input = $('mag-share-link');
if (!sheet || !input) return;
input.value = url;
sheet.hidden = false;
input.focus();
input.select();
}
function magShareClose() {
const sheet = $('mag-share-sheet');
if (sheet) sheet.hidden = true;
}
async function magCopyText(text) {
try {
if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; }
} catch (_) { /* fall through to the legacy path */ }
// Plain-http LAN addresses are not secure contexts, where the Clipboard API does not exist.
const box = document.createElement('textarea');
box.value = text;
box.setAttribute('readonly', '');
box.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(box);
box.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
box.remove();
return ok;
}
// Share one article (articleId) or, without it, the magazine (opens at the first unfinished article).
async function magShare(articleId) {
if (!state.doc) return;
let article = null;
if (articleId) {
article = state.articles.find((a) => a.id === articleId);
if (!article || !MAG_ARTICLE_ID_RE.test(articleId)) return;
if (!article.has_audio) return say('Generate this article\'s audio before sharing it', 'err');
}
const url = magDeepLinkUrl(state.doc.id, article ? article.id : '');
const title = article ? (article.title || 'Article') : (state.doc.title || 'Magazine');
const text = (article ? 'Listen to "' + title + '"' : 'Listen to ' + title) + (state.doc.title && article ? ' · ' + state.doc.title : '');
if (navigator.share) {
try { await navigator.share({ title, text, url }); return; }
catch (e) { if (e && e.name === 'AbortError') return; } // dismissed the sheet; any other failure: copy instead
}
if (await magCopyText(url)) say('Link copied. Anyone who can reach this app can open it in driving mode.', 'success');
else { say('Copy the link below', 'info'); magShareFallback(url); }
}
function magShareCurrent() { return magShare(state.playlistCurrent || ''); }
// The Play prompt shown when the browser blocked autoplay for a deep-linked article.
function magDrivingStartPrompt(show) {
const btn = $('mag-driving-start');
if (!btn) return;
btn.hidden = !show;
if (show) btn.focus();
}
function magDrivingStart() {
const audio = $('mag-playlist-audio');
if (audio) audio.play().then(() => magDrivingStartPrompt(false)).catch(() => {});
}
// Opens the link's magazine, then driving mode on its article. Returns true when driving mode is open.
async function magOpenDeepLink(link) {
if (!link) return false;
await magOpen(link.doc);
if (!state.doc || state.doc.id !== link.doc) return false; // not found or not allowed: magOpen already said so
let article = link.article ? state.articles.find((a) => a.id === link.article) : null;
if (link.article && !article) { say('That article is not in this magazine any more', 'err'); return false; }
if (article && !article.has_audio) { say('That article has no audio yet', 'err'); return false; }
const order = _magPlaylistOrder();
if (!article) article = order.find((a) => !((state.doc.progress || {})[a.id] || {}).completed) || order[0];
if (!article) { say('This magazine has no generated audio yet', 'err'); return false; }
magTogglePlaylist(true);
magPlaylistPlay(article.id, { onPlayBlocked: () => magDrivingStartPrompt(true) });
if (!state.driving) magToggleDriving();
return true;
}
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 && !state.playlistCurrent;
if (state.playlistCurrent) {
const order = _magPlaylistOrder();
const i = order.findIndex((item) => item.id === state.playlistCurrent);
const progress = $('mag-queue-progress-2');
if (progress) progress.textContent = 'Article ' + (i + 1) + ' / ' + order.length;
}
}
magPlaylistSync();
}
function syncDrivingPlayButton() {
const btn = $('mag-driving-play');
if (!btn) return;
const playlistAudio = $('mag-playlist-audio');
const isPlaying = state.playlistCurrent && playlistAudio ? !playlistAudio.paused : state.playing;
btn.innerHTML = isPlaying
? '<span class="mdi mdi-pause"></span>'
: '<span class="mdi mdi-play"></span>';
btn.setAttribute('aria-label', isPlaying ? 'Pause' : 'Play');
}
function magDrivingTogglePlayback() {
const audio = $('mag-playlist-audio');
if (state.playlistCurrent && audio) {
if (audio.paused) audio.play().catch(() => {}); else audio.pause();
return;
}
magPlay();
}
// ── Driving mode extras: +/-15 s, speed, text and image toggles, seek ────────
const MAG_DRIVING_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2];
const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs';
function magDrivingPrefs() {
if (!state.drivingPrefs) {
let saved = {};
try { saved = JSON.parse(localStorage.getItem(MAG_DRIVING_PREFS_KEY) || '{}') || {}; } catch (_) {}
state.drivingPrefs = {
speed: MAG_DRIVING_SPEEDS.includes(saved.speed) ? saved.speed : 1,
text: saved.text !== false, images: saved.images !== false,
};
}
return state.drivingPrefs;
}
function magDrivingSavePrefs() {
try { localStorage.setItem(MAG_DRIVING_PREFS_KEY, JSON.stringify(magDrivingPrefs())); } catch (_) {}
}
// Speed lives on the audio element; a new article's src resets playbackRate to the element's
// default, so both are set and re-applied whenever a track loads.
function magApplyPlaybackSpeed() {
const audio = $('mag-playlist-audio');
const speed = magDrivingPrefs().speed;
if (audio) { audio.defaultPlaybackRate = speed; audio.playbackRate = speed; }
const btn = $('mag-driving-speed');
if (btn) {
btn.textContent = speed + '×';
btn.setAttribute('aria-label', 'Playback speed ' + speed + '×. Press to change');
}
}
function magDrivingCycleSpeed() {
const prefs = magDrivingPrefs();
prefs.speed = MAG_DRIVING_SPEEDS[(MAG_DRIVING_SPEEDS.indexOf(prefs.speed) + 1) % MAG_DRIVING_SPEEDS.length];
magDrivingSavePrefs();
magApplyPlaybackSpeed();
}
function magDrivingSkip(seconds) {
const audio = $('mag-playlist-audio');
if (!audio || !state.playlistCurrent) return;
const end = Number.isFinite(audio.duration) ? audio.duration : Infinity;
audio.currentTime = Math.max(0, Math.min(end, audio.currentTime + seconds));
}
function magDrivingSeek(value) {
const audio = $('mag-playlist-audio');
if (!audio || !Number.isFinite(audio.duration)) return;
audio.currentTime = audio.duration * (Number(value) / 1000);
}
function magDrivingToggleText() {
const prefs = magDrivingPrefs();
prefs.text = !prefs.text;
magDrivingSavePrefs();
magDrivingApplyToggles();
magPlaylistSync();
}
function magDrivingToggleImages() {
const prefs = magDrivingPrefs();
prefs.images = !prefs.images;
magDrivingSavePrefs();
magDrivingApplyToggles();
magPlaylistSync();
}
function magDrivingApplyToggles() {
const prefs = magDrivingPrefs();
const text = $('mag-driving-text-toggle'), images = $('mag-driving-image-toggle');
if (text) text.setAttribute('aria-pressed', String(prefs.text));
if (images) images.setAttribute('aria-pressed', String(prefs.images));
const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual');
if (lyrics && !prefs.text) lyrics.hidden = true;
if (visual && !prefs.images) visual.hidden = true;
}
// Called from magPlaylistSync whenever the sentence, word or clock changes while the overlay is open.
// The text shows three sentences only: the one before, the current one (largest, with the spoken word marked)
// and the one after. All lines are built once per article; only visibility and classes move, inside a
// fixed-height box, so nothing around it jumps.
function magDrivingSync(audio, duration, pct, sentenceIndex, local, timeline) {
const prefs = magDrivingPrefs();
const seek = $('mag-driving-seek');
if (seek && document.activeElement !== seek) seek.value = String(Math.round(pct * 10));
const time = $('mag-driving-time');
if (time) time.textContent = magFmtClock(audio.currentTime) + ' / ' + magFmtClock(duration);
const sentences = state.playlistSentences || [];
const lyrics = $('mag-driving-lyrics');
if (lyrics) {
lyrics.hidden = !prefs.text || !sentences.length;
if (!lyrics.hidden) {
const key = state.playlistCurrent + ':' + sentences.length;
if (lyrics.dataset.key !== key) {
lyrics.dataset.key = key;
lyrics.dataset.window = '';
lyrics.innerHTML = sentences.map((sent, i) =>
'<p class="mag-driving-line" data-i="' + i + '" hidden>' +
(sent.words || [sent.text]).map((w) => '<span class="mag-dw">' + esc(w) + '</span>').join(' ') + '</p>').join('');
if (!lyrics.dataset.wired) {
lyrics.dataset.wired = '1';
const jump = (el) => { const line = el.closest('.mag-driving-line.is-prev, .mag-driving-line.is-next'); if (line) magPlaylistSeekSentence(Number(line.dataset.i)); };
lyrics.addEventListener('click', (e) => jump(e.target));
lyrics.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); jump(e.target); } });
}
}
const slots = (timeline && timeline.slots) || [];
const p = timeline && timeline.pos ? timeline.pos[sentenceIndex] : undefined;
const prev = p > 0 ? slots[p - 1] : -1, next = p >= 0 && p < slots.length - 1 ? slots[p + 1] : -1;
const windowKey = prev + ',' + sentenceIndex + ',' + next;
if (lyrics.dataset.window !== windowKey) {
(lyrics.dataset.window || '').split(',').forEach((i) => {
const el = lyrics.querySelector('.mag-driving-line[data-i="' + i + '"]');
if (el) { el.hidden = true; el.classList.remove('is-prev', 'is-current', 'is-next'); el.removeAttribute('tabindex'); el.removeAttribute('role'); el.removeAttribute('aria-label'); }
});
lyrics.dataset.window = windowKey;
[[prev, 'is-prev', 'Jump to the previous sentence'], [sentenceIndex, 'is-current', ''], [next, 'is-next', 'Jump to the next sentence']].forEach(([i, cls, label]) => {
const el = i >= 0 ? lyrics.querySelector('.mag-driving-line[data-i="' + i + '"]') : null;
if (!el) return;
el.hidden = false;
el.classList.add(cls);
if (label) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', label + ': ' + (sentences[i].text || '')); }
});
// A very long sentence can outgrow the box: keep its start in view (the box itself scrolls, not the page).
const cur = lyrics.querySelector('.mag-driving-line.is-current');
if (cur && lyrics.scrollHeight > lyrics.clientHeight) lyrics.scrollTo({ top: cur.offsetTop - 8, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}
const cur = lyrics.querySelector('.mag-driving-line.is-current');
lyrics.querySelectorAll('.mag-dw.is-current').forEach((w) => w.classList.remove('is-current'));
const words = cur ? cur.querySelectorAll('.mag-dw') : [];
const wi = playlistWordAt(sentences[sentenceIndex]?.words, local || 0);
if (words[wi]) words[wi].classList.add('is-current');
}
}
const visual = $('mag-driving-visual');
const source = $('mag-playlist-visual');
if (visual) {
const show = prefs.images && source && !source.hidden && source.dataset.imageId;
visual.hidden = !show;
if (show && visual.dataset.imageId !== source.dataset.imageId) {
visual.dataset.imageId = source.dataset.imageId;
visual.innerHTML = source.innerHTML;
}
}
}
function magDrivingNext() {
if (state.playlistCurrent) return _magPlaylistNext(false);
magQueueNext();
}
function magDrivingPrevious() {
if (state.playlistCurrent) return magPlaylistPrevious();
if (state.queue.length && state.queueIndex > 0) {
state.queueIndex -= 2;
magQueueNext();
}
}
// ── Lock screen, headset and car controls; screen stays awake while driving ───
// Hands-free means the phone in a pocket or a dashboard mount must still be controllable: the Media Session
// API puts title, artwork, position and play/pause/next/previous/±15 s on the lock screen, in a notification,
// on a Bluetooth headset or a car's display. Everything is optional: a browser without it simply plays.
const MAG_SEEK_STEP = 15;
function magMediaSessionSync() {
if (!('mediaSession' in navigator)) return;
const audio = $('mag-playlist-audio');
const a = state.articles.find((x) => x.id === state.playlistCurrent);
if (!audio || !a) return;
try {
const first = (a.pages || [])[0];
navigator.mediaSession.metadata = new MediaMetadata({
title: a.title || 'Untitled',
artist: a.author || state.doc?.title || '',
album: [state.doc?.title, state.doc?.issue].filter(Boolean).join(' — '),
artwork: first ? [{ src: magPageSrc(first), type: 'image/png' }] : [],
});
navigator.mediaSession.playbackState = audio.paused ? 'paused' : 'playing';
} catch (_) { /* metadata is presentation only */ }
}
function magMediaSessionPosition() {
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
const audio = $('mag-playlist-audio');
const now = Date.now();
if (!audio || now - (state._msPosAt || 0) < 1000) return; // once a second is plenty for a lock screen
state._msPosAt = now;
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
try {
navigator.mediaSession.setPositionState({
duration: audio.duration, playbackRate: audio.playbackRate || 1, position: Math.min(audio.currentTime, audio.duration),
});
} catch (_) { /* inconsistent state during a track change: skip this tick */ }
}
function magWireMediaSession() {
if (!('mediaSession' in navigator) || state._msWired) return;
state._msWired = true;
const audio = () => $('mag-playlist-audio');
const handlers = {
play: () => audio()?.play().catch(() => {}),
pause: () => audio()?.pause(),
stop: () => audio()?.pause(),
previoustrack: () => magDrivingPrevious(),
nexttrack: () => magDrivingNext(),
seekbackward: (d) => magDrivingSkip(-(d?.seekOffset || MAG_SEEK_STEP)),
seekforward: (d) => magDrivingSkip(d?.seekOffset || MAG_SEEK_STEP),
seekto: (d) => { const el = audio(); if (el && Number.isFinite(d?.seekTime)) el.currentTime = d.seekTime; },
};
Object.entries(handlers).forEach(([action, fn]) => {
try { navigator.mediaSession.setActionHandler(action, fn); } catch (_) { /* action not supported here */ }
});
}
// Keep the screen on while driving mode is open AND audio is playing; let it sleep otherwise (battery).
async function magDrivingWake() {
if (!('wakeLock' in navigator)) return;
const audio = $('mag-playlist-audio');
const want = !!(state.driving && audio && !audio.paused);
try {
if (want && !state.wakeLock) {
state.wakeLock = await navigator.wakeLock.request('screen');
state.wakeLock.addEventListener?.('release', () => { state.wakeLock = null; });
} else if (!want && state.wakeLock) {
const lock = state.wakeLock;
state.wakeLock = null;
await lock.release();
}
} catch (_) { state.wakeLock = null; /* refused (battery saver, hidden tab): playback is unaffected */ }
}
function wirePlaylistAudio() {
const audio = $('mag-playlist-audio');
if (!audio || audio.dataset.wired) return;
audio.dataset.wired = '1';
audio.addEventListener('ended', _magPlaylistNext);
audio.addEventListener('playing', () => magDrivingStartPrompt(false));
audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); magMediaSessionPosition(); });
// The loop covers smooth playback; these cover every way the position can change without it noticing.
['seeking', 'seeked', 'ratechange', 'durationchange', 'pause', 'ended'].forEach((ev) => audio.addEventListener(ev, magPlaylistSync));
['play', 'playing', 'seeked'].forEach((ev) => audio.addEventListener(ev, () => { magPlaylistSync(); magSyncLoopStart(); }));
audio.addEventListener('loadedmetadata', () => {
const a = state.articles.find((x) => x.id === state.playlistCurrent);
if (a && Number.isFinite(audio.duration)) a.audio_seconds = audio.duration;
renderPlaylistTracks();
magPlaylistSync();
});
audio.addEventListener('loadedmetadata', magApplyPlaybackSpeed);
audio.addEventListener('play', () => { syncDrivingPlayButton(); magWireMediaSession(); magMediaSessionSync(); magDrivingWake(); });
audio.addEventListener('pause', () => {
if (!state.playlistSwitching) savePlaylistProgress(false, true);
syncDrivingPlayButton();
magMediaSessionSync();
magDrivingWake();
});
audio.addEventListener('loadedmetadata', magMediaSessionSync);
// The browser drops a screen wake lock when the tab is hidden; take it again on return.
document.addEventListener('visibilitychange', () => { magPlaylistSync(); if (document.visibilityState === 'visible') { magDrivingWake(); magSyncLoopStart(); } });
}
// ── Boot ─────────────────────────────────────────────────────────────────────
async function magOnShow() {
setupMagDropzone();
setupMagIngestPreviewResize();
wirePlaylistAudio();
if ($('mag-library-list')) {
magLoadLibrary();
magLoadVoices();
}
}
async function magInit() {
setupMagDropzone();
setupMagIngestPreviewResize();
wirePlaylistAudio();
try {
const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY);
if (MAG_CONTENTS_VIEW_CYCLE.includes(savedView)) state.contentsView = savedView;
const savedCols = parseInt(localStorage.getItem(MAG_PAGES_COLS_KEY), 10);
if (savedCols >= 1 && savedCols <= 12) state.pagesCols = savedCols;
} catch (_) {}
if (!$('mag-library-list')) return; // section not mounted
magLoadLibrary();
magLoadVoices();
// A shared link (#s-magazine?doc=..&article=..&mode=drive) wins over "reopen the last magazine".
const link = magParseDeepLink(window._ttsvcDeepLink || location.hash);
window._ttsvcDeepLink = '';
if (link && await magOpenDeepLink(link)) return;
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.magToggleContentsView = magToggleContentsView;
root.magContentsToggleSelect = magContentsToggleSelect;
root.magContentsCommitTitle = magContentsCommitTitle;
root.magGenerateAllAudio = magGenerateAllAudio;
root.magTogglePlaylist = magTogglePlaylist;
root.magReviewPage = magReviewPage;
root.magCommitDocField = magCommitDocField;
root.magSetPagesCols = magSetPagesCols;
root.magPlaylistPlay = magPlaylistPlay;
root.magPlaylistSeekSentence = magPlaylistSeekSentence;
root.magPlaylistSync = magPlaylistSync;
root.magContentsToggleArticle = magContentsToggleArticle;
root.magContentsSelectAllVisible = magContentsSelectAllVisible;
root.magContentsClearSelection = magContentsClearSelection;
root.magContentsBulkStatus = magContentsBulkStatus;
root.magReviewGoPage = magReviewGoPage;
root.magReviewGoMagazinePage = magReviewGoMagazinePage;
root.magReviewAtPage = magReviewAtPage;
root.magReviewToggleSpread = magReviewToggleSpread;
root.magReviewSetActivePage = magReviewSetActivePage;
root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox;
root.magReviewToggleAllPage = magReviewToggleAllPage;
root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
root.magReocrPage = magReocrPage;
root.magMountFloatingPanel = magMountFloatingPanel;
root.magReviewOpenEditPanel = magReviewOpenEditPanel;
root.magRotatePage = magRotatePage;
root.magRefineFigures = magRefineFigures;
root.magRefineAllFigures = magRefineAllFigures;
root.magQualityRun = magQualityRun;
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.magRegionPickerFocus = magRegionPickerFocus;
root.magRegionPickerInput = magRegionPickerInput;
root.magRegionPickerBlur = magRegionPickerBlur;
root.magRegionPickerKeydown = magRegionPickerKeydown;
root.magRegionPickerChoose = magRegionPickerChoose;
root.magRunPageQuality = magRunPageQuality;
root.magFixReadingOrder = magFixReadingOrder;
root.magCleanUpBoxes = magCleanUpBoxes;
root.magRowEdit = magRowEdit;
root.magWordEdit = magWordEdit;
root.magMarkTokens = magMarkTokens;
root.magRowDiff = magRowDiff;
root.magRowRestore = magRowRestore;
root.magWordDiff = magWordDiff;
root.magSelectToggle = magSelectToggle;
root.magSelectNone = magSelectNone;
root.magBulk = magBulk;
root.magUndo = magUndo;
root.magRenderPreviewBoxes = magRenderPreviewBoxes;
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.magPageGuide = magPageGuide;
root.magArticleFlow = magArticleFlow;
root.magTogglePagesTodo = magTogglePagesTodo;
root.magTogglePagesStatus = magTogglePagesStatus;
root.magClearPagesFilters = magClearPagesFilters;
root.magPageVisible = magPageVisible;
root.magFindSearch = magFindSearch;
root.magCatAdd = magCatAdd;
root.magCatRemove = magCatRemove;
root.magFindImport = magFindImport;
root.magTocPlay = magTocPlay;
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.magOpenDriving = magOpenDriving;
root.magShare = magShare;
root.magShareCurrent = magShareCurrent;
root.magShareClose = magShareClose;
root.magDrivingStart = magDrivingStart;
root.magOpenDeepLink = magOpenDeepLink;
root.magParseDeepLink = magParseDeepLink;
root.magDrivingTogglePlayback = magDrivingTogglePlayback;
root.magDrivingNext = magDrivingNext;
root.magDrivingSkip = magDrivingSkip;
root.magDrivingSeek = magDrivingSeek;
root.magDrivingCycleSpeed = magDrivingCycleSpeed;
root.magDrivingToggleText = magDrivingToggleText;
root.magDrivingToggleImages = magDrivingToggleImages;
root.magDrivingPrevious = magDrivingPrevious;
root.magHistoryStep = magHistoryStep;
root.magFlowToggle = magFlowToggle;
root.magLinkPage = magLinkPage;
root.magAutofixPage = magAutofixPage;
root.magSpellOpenAndFix = magSpellOpenAndFix;
root.magSpellToggle = magSpellToggle;
root.magSpellFix = magSpellFix;
root.magSpellEdit = magSpellEdit;
root.magSpellKeep = magSpellKeep;
root.magSpellAgent = magSpellAgent;
root.magChecksToggled = magChecksToggled;
root.magAcrSave = magAcrSave;
root.magAcrResolve = magAcrResolve;
root.magInit = magInit;
MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', magInit);
} else {
magInit();
}
})(window);