5035 lines
236 KiB
JavaScript
5035 lines
236 KiB
JavaScript
// Magazine reader — contents tree, article review, dual reading views,
|
||
// sentence-highlighted playback and podcast mode.
|
||
//
|
||
// The canonical object is the ARTICLE, not the page (see
|
||
// architecture/magazine-ingestion.md). Nothing is narrated until a human has
|
||
// approved the detected boundaries, so the review screen is on the critical
|
||
// path rather than an optional extra.
|
||
//
|
||
// Two views stay linked: the reflowed article (comfortable reading, sentence
|
||
// highlighting) and the original PDF page (the visual source of truth). Every
|
||
// block carries page + bbox, so either view can jump to the other.
|
||
(function (root) {
|
||
'use strict';
|
||
|
||
const MAG = root.TTVC = root.TTVC || {};
|
||
const state = {
|
||
docs: [],
|
||
doc: null, // { id, title, issue, ingest, ... }
|
||
articles: [],
|
||
layout: null, // { page_count, pages[], blocks[] }
|
||
article: null, // the article being read or reviewed
|
||
sentences: [], // [{ text, blockId, page, bbox }]
|
||
images: [], // current article's figures, in reading order
|
||
view: 'library', // library | contents | review | read
|
||
contentsView: 'list', // list | pages — how the article list itself is shown
|
||
contentsSelectMode: false, // batch-select articles for bulk approve/reject
|
||
contentsSelected: new Set(),
|
||
pagesCols: 8, // "All pages" grid column count, user-adjustable 1-12
|
||
playlistCurrent: null, // article id of the MP3 playlist's current track
|
||
playlistSentences: [], // transcript aligned approximately to the exported MP3
|
||
playlistImages: [], // figures/tables/charts shown alongside that transcript
|
||
playlistLoadToken: 0, // prevents a slow text fetch replacing a newer track
|
||
playlistLastSaved: 0,
|
||
playlistHighlightIndex: -1,
|
||
playlistSwitching: false,
|
||
pane: 'reflow', // reflow | pdf
|
||
playing: false,
|
||
cancel: false,
|
||
idx: 0, // current sentence
|
||
audio: null,
|
||
voices: [],
|
||
podcast: null,
|
||
spell: null, // last spelling findings shown (page or article scope)
|
||
spellByBlock: new Map(),
|
||
spellOpen: false,
|
||
audit: null, // live audit of the reviewed page ({page, data})
|
||
flow: null, // how the open article's text continues between regions
|
||
flowOn: true, // show the flow arrows/chips in review
|
||
acronyms: null, // the open article's glossary state
|
||
queue: [], // article ids for "play all" (empty = not queued)
|
||
queueIndex: -1,
|
||
driving: false, // large-control, minimal-interaction view
|
||
karaoke: true, // word-level highlighting (approximate, see estimateWordTimings)
|
||
drawMode: false, // manually drawing a region OCR/layout missed, in review
|
||
reviewSpan: [],
|
||
reviewPage: null,
|
||
reviewSpread: false, // two-page spread view in review, see magReviewToggleSpread
|
||
audioCache: new Map(), // voice+text -> Promise<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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
|
||
function say(msg, kind) { if (typeof toast === 'function') toast(msg, kind || ''); }
|
||
|
||
async function api(path, opts) {
|
||
const r = await fetch(path, opts);
|
||
if (!r.ok) {
|
||
let detail = r.statusText;
|
||
try { detail = (await r.json()).detail || detail; } catch (_) {}
|
||
throw new Error(detail);
|
||
}
|
||
const type = r.headers.get('content-type') || '';
|
||
return type.includes('json') ? r.json() : r;
|
||
}
|
||
|
||
function fmtDuration(seconds) {
|
||
const s = Math.max(0, Math.round(seconds || 0));
|
||
if (s < 60) return s + ' sec';
|
||
const m = Math.round(s / 60);
|
||
return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min';
|
||
}
|
||
|
||
function fmtPages(pages) {
|
||
if (!pages || !pages.length) return '—';
|
||
if (pages.length === 1) return 'p. ' + pages[0];
|
||
// Non-contiguous page lists are the norm once a feature jumps, and showing
|
||
// "18–88" for pages [18, 88] would misdescribe it as a 70-page article.
|
||
const runs = [];
|
||
let start = pages[0], prev = pages[0];
|
||
for (let i = 1; i <= pages.length; i++) {
|
||
if (pages[i] === prev + 1) { prev = pages[i]; continue; }
|
||
runs.push(start === prev ? String(start) : start + '–' + prev);
|
||
start = prev = pages[i];
|
||
}
|
||
return 'pp. ' + runs.join(', ');
|
||
}
|
||
|
||
// The live OCR view is intentionally split: a portrait source page needs more
|
||
// horizontal room than a dense recognised-text pane on some screens, while on
|
||
// others the text is the thing being inspected. Keep a per-browser choice so
|
||
// the user does not have to re-adjust it for every magazine/page.
|
||
function setupMagIngestPreviewResize() {
|
||
const preview = $('mag-ingest-preview');
|
||
const grip = $('mag-ingest-preview-resize');
|
||
if (!preview || !grip || preview.dataset.resizeWired) return;
|
||
preview.dataset.resizeWired = '1';
|
||
const storageKey = 'ttsvc_mag_ingest_page_pane_pct';
|
||
const apply = (value) => {
|
||
const pct = Math.max(30, Math.min(70, Number(value) || 50));
|
||
preview.style.gridTemplateColumns = `${pct}% 8px calc(${100 - pct}% - 8px)`;
|
||
return pct;
|
||
};
|
||
try {
|
||
const saved = localStorage.getItem(storageKey);
|
||
if (saved != null) apply(saved);
|
||
} catch (_) {}
|
||
grip.addEventListener('pointerdown', (event) => {
|
||
if (window.matchMedia('(max-width: 860px)').matches) return;
|
||
event.preventDefault();
|
||
preview.classList.add('is-resizing');
|
||
const move = (e) => {
|
||
const rect = preview.getBoundingClientRect();
|
||
apply(((e.clientX - rect.left) / rect.width) * 100);
|
||
};
|
||
const stop = (e) => {
|
||
window.removeEventListener('pointermove', move);
|
||
window.removeEventListener('pointerup', stop);
|
||
preview.classList.remove('is-resizing');
|
||
const rect = preview.getBoundingClientRect();
|
||
const pct = apply(((e.clientX - rect.left) / rect.width) * 100);
|
||
try { localStorage.setItem(storageKey, String(pct)); } catch (_) {}
|
||
};
|
||
window.addEventListener('pointermove', move);
|
||
window.addEventListener('pointerup', stop, { once: true });
|
||
});
|
||
}
|
||
|
||
// ── Library ──────────────────────────────────────────────────────────────────
|
||
|
||
async function magLoadLibrary() {
|
||
const data = await api('/api/reader/docs');
|
||
state.docs = (data.docs || []).filter((d) => d.kind === 'magazine');
|
||
renderLibrary();
|
||
}
|
||
|
||
function renderLibrary() {
|
||
const host = $('mag-library-list');
|
||
if (!host) return;
|
||
if (!state.docs.length) {
|
||
host.innerHTML =
|
||
'<p class="muted" style="padding:18px 0">No magazines yet. Add one to get started — ' +
|
||
'upload a PDF and the articles are detected for you.</p>';
|
||
return;
|
||
}
|
||
host.innerHTML = state.docs.map((d) => {
|
||
const ing = d.ingest || {};
|
||
const badge = ing.status === 'done'
|
||
? '<span class="mag-pill ok">' + (ing.articles || 0) + ' articles</span>'
|
||
: ing.status === 'running' ? '<span class="mag-pill warn">ingesting…</span>'
|
||
: ing.status === 'failed' ? '<span class="mag-pill bad">failed</span>'
|
||
: '<span class="mag-pill">not ingested</span>';
|
||
const thumbnail = '<img class="mag-card-thumb" src="/api/magazine/' + encodeURIComponent(d.id) +
|
||
'/pages/1" alt="First page of ' + esc(d.title || 'magazine') +
|
||
'" loading="lazy" onerror="this.hidden=true">';
|
||
return '<div class="mag-card-wrap"><button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
|
||
thumbnail + '<span class="mag-card-title">' + esc(d.title || 'Untitled') + '</span>' +
|
||
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' + badge + '</button>' +
|
||
'<div class="mag-card-actions"><button type="button" class="mag-card-delete" onclick="magDelete(\'' + d.id + '\')" ' +
|
||
'title="Delete this magazine" aria-label="Delete ' + esc(d.title || 'magazine') + '"><span class="mdi mdi-delete-outline"></span> Delete</button></div></div>';
|
||
}).join('');
|
||
}
|
||
|
||
async function magDelete(docId) {
|
||
const doc = state.docs.find((item) => item.id === docId);
|
||
const title = doc?.title || 'this magazine';
|
||
if (!window.confirm(`Delete “${title}”? This permanently removes its PDF, OCR, articles, and generated files.`)) return;
|
||
try {
|
||
await api('/api/reader/docs/' + encodeURIComponent(docId), { method: 'DELETE' });
|
||
if (state.doc?.id === docId) {
|
||
magForgetLastDoc();
|
||
state.doc = null;
|
||
state.article = null;
|
||
}
|
||
state.docs = state.docs.filter((item) => item.id !== docId);
|
||
renderLibrary();
|
||
say(`Deleted “${title}”`, 'ok');
|
||
} catch (e) {
|
||
say('Could not delete magazine: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
// ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ─────────
|
||
const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.';
|
||
|
||
function magFormatFilenameAsTitle(filename) {
|
||
if (!filename) return '';
|
||
const base = String(filename).replace(/\.[^.]+$/, '');
|
||
return base
|
||
.replace(/[_-]+/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function magShowDroppedFile(file) {
|
||
const noteEl = $('mag-dropzone-note');
|
||
const titleEl = $('mag-dropzone-title');
|
||
const iconEl = $('mag-dropzone-icon');
|
||
const titleInput = $('mag-new-title');
|
||
const dropzone = $('mag-dropzone');
|
||
|
||
if (file) {
|
||
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
|
||
if (dropzone) dropzone.classList.add('is-selected');
|
||
if (noteEl) noteEl.textContent = `Selected: ${file.name} (${sizeMb} MB)`;
|
||
if (titleEl) titleEl.textContent = 'PDF file selected — uploading...';
|
||
if (iconEl) iconEl.className = 'mdi mdi-file-check-outline reader-dropzone-icon';
|
||
|
||
// Auto-fill title from filename if title input is empty
|
||
if (titleInput && !titleInput.value.trim()) {
|
||
titleInput.value = magFormatFilenameAsTitle(file.name);
|
||
}
|
||
} else {
|
||
if (dropzone) dropzone.classList.remove('is-selected', 'is-uploading');
|
||
if (noteEl) noteEl.textContent = MAG_DROPZONE_DEFAULT_NOTE;
|
||
if (titleEl) titleEl.textContent = 'Drop a PDF document';
|
||
if (iconEl) iconEl.className = 'mdi mdi-cloud-upload-outline reader-dropzone-icon';
|
||
}
|
||
}
|
||
|
||
function installGlobalMagDropGuard() {
|
||
if (window._magDropGuardInstalled) return;
|
||
window._magDropGuardInstalled = true;
|
||
const guard = (e) => {
|
||
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) {
|
||
e.preventDefault();
|
||
if (e.type === 'dragover' && e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
};
|
||
window.addEventListener('dragenter', guard, true);
|
||
window.addEventListener('dragover', guard, true);
|
||
window.addEventListener('drop', guard, true);
|
||
}
|
||
installGlobalMagDropGuard();
|
||
|
||
function setupMagDropzone() {
|
||
installGlobalMagDropGuard();
|
||
const zone = $('mag-dropzone');
|
||
const input = $('mag-new-file');
|
||
if (!zone || !input) return;
|
||
|
||
if (zone.dataset.magDropzoneWired) return;
|
||
zone.dataset.magDropzoneWired = '1';
|
||
|
||
// Clicking anywhere in the dropzone triggers the hidden file picker
|
||
zone.addEventListener('click', (e) => {
|
||
if (!e.target.closest('input')) {
|
||
input.click();
|
||
}
|
||
});
|
||
|
||
let dragCounter = 0;
|
||
|
||
zone.addEventListener('dragenter', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragCounter++;
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||
zone.classList.add('dragover');
|
||
});
|
||
|
||
zone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||
zone.classList.add('dragover');
|
||
});
|
||
|
||
zone.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragCounter = Math.max(0, dragCounter - 1);
|
||
if (dragCounter === 0) {
|
||
zone.classList.remove('dragover');
|
||
}
|
||
});
|
||
|
||
zone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dragCounter = 0;
|
||
zone.classList.remove('dragover');
|
||
|
||
let file = e.dataTransfer?.files?.[0];
|
||
if (!file && e.dataTransfer?.items?.[0]) {
|
||
file = e.dataTransfer.items[0].getAsFile();
|
||
}
|
||
if (!file) return;
|
||
if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {
|
||
return say('Only PDF files are supported', 'err');
|
||
}
|
||
magShowDroppedFile(file);
|
||
magCreate(file);
|
||
});
|
||
|
||
input.addEventListener('change', () => {
|
||
const file = input.files?.[0];
|
||
if (file) {
|
||
magShowDroppedFile(file);
|
||
magCreate(file);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function magCreate(droppedFile = null) {
|
||
const file = droppedFile || $('mag-new-file')?.files?.[0];
|
||
if (!file) return say('Choose or drop a PDF file first', 'err');
|
||
|
||
let title = ($('mag-new-title')?.value || '').trim();
|
||
if (!title) {
|
||
title = magFormatFilenameAsTitle(file.name) || 'Untitled Magazine';
|
||
if ($('mag-new-title')) $('mag-new-title').value = title;
|
||
}
|
||
const issue = ($('mag-new-issue')?.value || '').trim();
|
||
|
||
const addBtn = $('mag-add-btn');
|
||
const browseBtn = $('mag-browse-btn');
|
||
const dropzone = $('mag-dropzone');
|
||
const titleInp = $('mag-new-title');
|
||
const issueInp = $('mag-new-issue');
|
||
const progress = $('mag-dropzone-progress');
|
||
const pickBtn = $('mag-dropzone-pick-btn');
|
||
const titleEl = $('mag-dropzone-title');
|
||
const noteEl = $('mag-dropzone-note');
|
||
const iconEl = $('mag-dropzone-icon');
|
||
const origBtnText = addBtn ? addBtn.innerHTML : '';
|
||
|
||
const setUploadingState = (uploading) => {
|
||
const sizeMb = file ? (file.size / (1024 * 1024)).toFixed(1) : 0;
|
||
if (uploading) {
|
||
if (dropzone) {
|
||
dropzone.classList.add('is-uploading');
|
||
dropzone.style.pointerEvents = 'none';
|
||
}
|
||
if (progress) progress.hidden = false;
|
||
if (pickBtn) pickBtn.hidden = true;
|
||
if (browseBtn) browseBtn.hidden = true;
|
||
if (iconEl) iconEl.className = 'mdi mdi-loading mdi-spin reader-dropzone-icon';
|
||
if (titleEl) titleEl.textContent = 'Uploading & Ingesting PDF...';
|
||
if (noteEl) noteEl.textContent = `Processing "${file.name}" (${sizeMb} MB). Transferring source file and extracting layout...`;
|
||
if (addBtn) {
|
||
addBtn.disabled = true;
|
||
addBtn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Uploading PDF...';
|
||
}
|
||
if (titleInp) titleInp.disabled = true;
|
||
if (issueInp) issueInp.disabled = true;
|
||
} else {
|
||
if (dropzone) {
|
||
dropzone.classList.remove('is-uploading');
|
||
dropzone.style.pointerEvents = 'auto';
|
||
}
|
||
if (progress) progress.hidden = true;
|
||
if (pickBtn) pickBtn.hidden = false;
|
||
if (browseBtn) browseBtn.hidden = false;
|
||
if (addBtn) {
|
||
addBtn.disabled = false;
|
||
addBtn.innerHTML = origBtnText;
|
||
}
|
||
if (titleInp) titleInp.disabled = false;
|
||
if (issueInp) issueInp.disabled = false;
|
||
}
|
||
};
|
||
|
||
try {
|
||
setUploadingState(true);
|
||
say(`Uploading ${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)...`, 'ok');
|
||
const { id } = await api('/api/magazine/docs', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title, issue }),
|
||
});
|
||
await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: file });
|
||
say('Magazine uploaded — starting ingestion', 'ok');
|
||
|
||
if ($('mag-new-title')) $('mag-new-title').value = '';
|
||
if ($('mag-new-issue')) $('mag-new-issue').value = '';
|
||
if ($('mag-new-file')) $('mag-new-file').value = '';
|
||
magShowDroppedFile(null);
|
||
setUploadingState(false);
|
||
|
||
await magOpen(id);
|
||
await magIngest();
|
||
} catch (e) {
|
||
setUploadingState(false);
|
||
say('Could not add magazine: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
// ── Ingestion ────────────────────────────────────────────────────────────────
|
||
|
||
// Remembers which magazine was open so a full page reload or the browser's
|
||
// own Back button (which, unlike in-app navigation, tears down all JS state)
|
||
// lands back on it instead of the empty library — confirmed live as the
|
||
// cause of a user re-uploading the same PDF, creating a duplicate under a
|
||
// completely different kind ("book", via Studio/Reader's own import) because
|
||
// the magazine they had been working on appeared to have vanished.
|
||
const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc';
|
||
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;
|
||
}
|
||
magRenderPreviewBoxes(data.preview.page, data.preview.boxes || [], data.preview.stage);
|
||
const textEl = $('mag-ingest-preview-text');
|
||
// Server-built HTML (see _render_ingest_preview_html in
|
||
// routes/magazine.py) -- every text node in it is already escaped,
|
||
// so this is the one place that inner-HTMLs a live server value.
|
||
if (textEl) textEl.innerHTML = data.preview.text || '';
|
||
} else if (ing.status !== 'running') {
|
||
preview.hidden = true;
|
||
}
|
||
}
|
||
if (ing.status !== 'running') {
|
||
if (ing.status === 'done') await magRefreshArticles();
|
||
break;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 2000));
|
||
}
|
||
}
|
||
|
||
async function magRefreshArticles() {
|
||
if (!state.doc) return;
|
||
try {
|
||
const data = await api('/api/magazine/' + state.doc.id + '/articles');
|
||
state.articles = data.articles || [];
|
||
state.doc.progress = data.progress || {};
|
||
state.doc.podcast = data.podcast || { hosts: [] };
|
||
state.doc.default_voice = data.default_voice || '';
|
||
} catch (_) {
|
||
state.articles = [];
|
||
}
|
||
renderContents();
|
||
}
|
||
|
||
// ── Contents tree ────────────────────────────────────────────────────────────
|
||
|
||
function confidenceLabel(c) {
|
||
// 0.55 is the backend's explicit heuristic marker: no reliable contents
|
||
// entry was available, so an article was started from a page headline. It
|
||
// is not a calibrated probability and a column of identical “55%” badges
|
||
// falsely suggests a measured OCR score.
|
||
if (Math.abs(Number(c) - 0.55) < 0.001) {
|
||
return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' };
|
||
}
|
||
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' };
|
||
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' };
|
||
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' };
|
||
}
|
||
|
||
// ── Page coverage ────────────────────────────────────────────────────────
|
||
// "Approve" only ever advanced to the next ARTICLE, so a page that never
|
||
// got grouped into any article -- an ad, a missed piece, a page the
|
||
// detection pass just skipped -- was never surfaced by that workflow at
|
||
// all. Confirmed live: 73 of 204 pages (36%) in one real issue belonged to
|
||
// no article whatsoever. A banner listing every such gap used to live here
|
||
// too, but the "All pages" view's own per-card "no article" tag already
|
||
// says the same thing in context, so it was just a second, redundant
|
||
// warning -- removed.
|
||
|
||
// The next page after `afterPage` that still needs a human decision -- no
|
||
// article covers it at all, or one does but is still sitting at its
|
||
// detected/unreviewed status. A page touched by several articles only
|
||
// counts as resolved once none of them are still pending.
|
||
function magNextUnresolvedPage(afterPage) {
|
||
const pageCount = state.doc?.pageCount || 0;
|
||
const byPage = new Map();
|
||
state.articles.forEach((a) => (a.pages || []).forEach((p) => {
|
||
if (!byPage.has(p)) byPage.set(p, []);
|
||
byPage.get(p).push(a);
|
||
}));
|
||
for (let p = afterPage + 1; p <= pageCount; p++) {
|
||
const arts = byPage.get(p) || [];
|
||
if (!arts.length || arts.some((a) => a.status === 'detected')) return p;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Opens Review at a specific page even when no article covers it, by
|
||
// anchoring on whichever approved-or-not article is nearest that page (the
|
||
// review UI needs *an* article as context, but its own page navigation is
|
||
// not restricted to that article's pages) and then jumping.
|
||
async function magReviewPage(pageNumber) {
|
||
// Reuse whatever already covers this page if something does (idempotent
|
||
// if called twice for the same page); otherwise give the page its own
|
||
// article record rather than borrowing an unrelated one as review
|
||
// context -- confirmed live: Approve and "Not an article" both act on
|
||
// whatever article is open, so borrowing one meant either action would
|
||
// have silently changed a completely different, already-fine article.
|
||
let article = state.articles.find((a) => (a.pages || []).includes(pageNumber));
|
||
if (!article) {
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/articles', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ page: pageNumber, title: 'Page ' + pageNumber }),
|
||
});
|
||
article = r.article;
|
||
state.articles.push(article);
|
||
} catch (e) { return say('Could not open page ' + pageNumber + ': ' + e.message, 'err'); }
|
||
}
|
||
await magReview(article.id);
|
||
magReviewGoMagazinePage(pageNumber);
|
||
}
|
||
|
||
function renderContents() {
|
||
const host = $('mag-contents');
|
||
if (!host) return;
|
||
const title = $('mag-doc-title');
|
||
if (title && state.doc && document.activeElement !== title) title.textContent = state.doc.title || 'Magazine';
|
||
const issueInput = $('mag-doc-issue');
|
||
if (issueInput && state.doc && document.activeElement !== issueInput) issueInput.value = state.doc.issue || '';
|
||
if (!state.articles.length) {
|
||
host.innerHTML = '<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;
|
||
}
|
||
|
||
const actionsHtml = (a, done, progress) =>
|
||
'<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 +
|
||
'</div>' +
|
||
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div></li>';
|
||
});
|
||
html += '</ul></div>';
|
||
});
|
||
host.innerHTML = html;
|
||
renderContentsBulkBar();
|
||
magUpdateAudioZipLink();
|
||
}
|
||
|
||
const MAG_CONTENTS_VIEW_CYCLE = ['list', 'pages'];
|
||
const MAG_PAGES_COLS_KEY = 'ttsvc_mag_pages_cols';
|
||
|
||
// Every page, 1..pageCount, regardless of whether an article claims it --
|
||
// the article grid/list above can only ever show what detection already
|
||
// grouped, which is exactly what left 36% of one real issue invisible (see
|
||
// magUncoveredPageRanges). This is the complete picture: a page can belong
|
||
// to zero articles (never reviewed), one, or several (a page shared by two
|
||
// short pieces), and this shows all three cases rather than picking one.
|
||
// One of five states per page, cheap to compute from data already loaded
|
||
// (no extra layout/OCR fetch just to colour an overview grid):
|
||
// blank -- no article touches this page at all yet
|
||
// detected -- an article does, but it's still sitting at its
|
||
// just-detected status -- OCR ran, a human hasn't looked
|
||
// approved -- reviewed and approved, no audio rendered yet
|
||
// audio -- reviewed, approved, and has a generated MP3
|
||
// empty -- reviewed and rejected ("Not an article") on every article
|
||
// that touches it -- a blank page or an ad, not a gap
|
||
// A page touched by several articles takes the most-attention-needed state
|
||
// among them (audio beats approved beats detected beats empty).
|
||
// The last whole-issue quality run splits the two unreviewed states further:
|
||
// checked -- quality run found nothing left to fix (blue)
|
||
// flagged -- quality run found uncovered text / overlaps / clipping / suspect words (purple)
|
||
// flagged 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>';
|
||
}
|
||
|
||
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'],
|
||
];
|
||
let html = '<p class="mag-pages-legend">' +
|
||
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
|
||
'</p>';
|
||
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
|
||
for (let p = 1; p <= pageCount; p++) {
|
||
const arts = byPage.get(p) || [];
|
||
// 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 + ')';
|
||
html += '<div class="mag-card-wrap" data-page="' + p + '"><div class="mag-card is-page-' + magPageState(arts, quality.get(p), p) + '">' +
|
||
'<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="/api/magazine/' + esc(state.doc.id) + '/pages/' + p + '"></button>' +
|
||
'<span class="mag-card-title">Page ' + p + '</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>' +
|
||
'<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>'
|
||
: '<span class="mag-pill">no article</span>') +
|
||
'</div></div>';
|
||
}
|
||
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';
|
||
}
|
||
|
||
// ── 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="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
|
||
(interactive ? '<div id="mag-draw-rect" class="mag-review-box mag-draw-rect" hidden></div>' : '');
|
||
regions.forEach((r, i) => {
|
||
if (!pageMeta) return;
|
||
const bbox = r.data.bbox;
|
||
const left = (bbox[0] / pageMeta.width * 100).toFixed(2);
|
||
const top = (bbox[1] / pageMeta.height * 100).toFixed(2);
|
||
const w = (bbox[2] / pageMeta.width * 100).toFixed(2);
|
||
const h = (bbox[3] / pageMeta.height * 100).toFixed(2);
|
||
const isImage = r.kind === 'image';
|
||
const on = isImage ? ownImages.has(r.id) : included.has(r.id);
|
||
const owner = magBlockOwner(r.id);
|
||
const 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></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;
|
||
// 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>' +
|
||
'<span class="mag-review-pagenav-spacer"></span>' +
|
||
'<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' +
|
||
'title="Re-read every page column by column, left to right. Text and approvals stay as they are; pages you ordered by hand are kept." >' +
|
||
'<span class="mdi mdi-sort-numeric-ascending" aria-hidden="true"></span> Fix reading order</button>' +
|
||
'<button type="button" class="btn-sm" id="mag-cleanup-btn" onclick="magCleanUpBoxes()" ' +
|
||
'title="Stop reading text inside pictures, OCR noise and duplicated boxes, and split author lines that run into the text. Nothing is deleted; a backup is kept.">' +
|
||
'<span class="mdi mdi-broom" aria-hidden="true"></span> Clean up boxes</button>' +
|
||
'<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' +
|
||
'title="Re-check figure boundaries on this page only">' +
|
||
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries (this page)</button>' +
|
||
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
|
||
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
|
||
'<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" role="status" 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 — 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 — 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 & 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();
|
||
}
|
||
|
||
// 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) {
|
||
const done = (state.orderSeq || []).length, total = (state.reviewRegionOrder || []).length;
|
||
return '<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
|
||
'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' +
|
||
'<button type="button" class="btn-sm btn-primary" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>Save order</button> ' +
|
||
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button> ' +
|
||
'<button type="button" class="btn-sm" onclick="magOrderToggle()">Cancel</button>';
|
||
}
|
||
if (magReviewPageLocked()) {
|
||
return '<span class="mdi mdi-lock-outline" aria-hidden="true"></span> Reading order on this page was set by hand and is kept. ' +
|
||
'<button type="button" class="btn-sm" onclick="magOrderReset()">Automatic order</button>';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function magOrderRefreshBar() {
|
||
const bar = $('mag-order-bar');
|
||
if (bar) bar.innerHTML = magOrderBarHtml();
|
||
document.querySelectorAll('#mag-review-page-wrap .mag-review-box').forEach((box) => {
|
||
const at = (state.orderSeq || []).indexOf(box.dataset.block);
|
||
box.classList.toggle('is-ordered', state.orderMode && at >= 0);
|
||
const num = box.querySelector('.mag-review-box-num');
|
||
if (num && state.orderMode) num.textContent = at >= 0 ? String(at + 1) : '·';
|
||
});
|
||
}
|
||
|
||
function magOrderToggle() {
|
||
state.orderMode = !state.orderMode;
|
||
state.orderSeq = [];
|
||
state.orderPage = state.reviewPage;
|
||
if (state.orderMode && state.drawMode) magReviewToggleDraw(state.drawKind);
|
||
if (state.orderMode && state.editMode) magReviewToggleEdit();
|
||
const btn = $('mag-order-toggle');
|
||
if (btn) { btn.classList.toggle('active', state.orderMode); btn.setAttribute('aria-pressed', String(state.orderMode)); }
|
||
const wrap = $('mag-review-page-wrap');
|
||
if (wrap) wrap.classList.toggle('is-ordering', state.orderMode);
|
||
if (!state.orderMode) return renderReview(); // restore the real numbers
|
||
magOrderRefreshBar();
|
||
}
|
||
|
||
function magOrderPick(id) {
|
||
const seq = state.orderSeq || (state.orderSeq = []);
|
||
if (!seq.includes(id)) seq.push(id);
|
||
magOrderRefreshBar();
|
||
}
|
||
|
||
function magOrderUndo() {
|
||
(state.orderSeq || []).pop();
|
||
magOrderRefreshBar();
|
||
}
|
||
|
||
async function magOrderPut(order) {
|
||
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order }),
|
||
});
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
}
|
||
|
||
async function magOrderSave() {
|
||
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();
|
||
}
|
||
|
||
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 & 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 & re-OCR</button>'
|
||
: '<button type="button" class="btn-sm primary" onclick="magReviewSubmitEdit(false)">Save</button>') +
|
||
'</div>';
|
||
wrap.appendChild(panel);
|
||
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
|
||
panel.style.top = Math.min(80, top + height + 1) + '%';
|
||
panel.style.left = Math.min(55, left) + '%';
|
||
}
|
||
|
||
async function magReviewSubmitEdit(reocr) {
|
||
const panel = document.querySelector('.mag-edit-panel');
|
||
if (!panel) return;
|
||
const blockId = panel.dataset.blockId;
|
||
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(blockId) + '"]');
|
||
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
|
||
if (!box || !pageMeta) { magReviewCancelEditPanel(); return; }
|
||
const bbox = [
|
||
parseFloat(box.style.left) / 100 * pageMeta.width,
|
||
parseFloat(box.style.top) / 100 * pageMeta.height,
|
||
parseFloat(box.style.width) / 100 * pageMeta.width,
|
||
parseFloat(box.style.height) / 100 * pageMeta.height,
|
||
];
|
||
panel.remove();
|
||
say(reocr ? 'Saving and re-reading region…' : 'Saving region…', '');
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(blockId), {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ bbox, reocr }),
|
||
});
|
||
} catch (e) {
|
||
say('Could not save region: ' + e.message, 'err');
|
||
magReviewCancelEditPanel(true);
|
||
return;
|
||
}
|
||
say('Region saved', 'ok');
|
||
state.layout = null;
|
||
await ensureLayout();
|
||
renderReview();
|
||
}
|
||
|
||
async function magCleanUpBoxes() {
|
||
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');
|
||
}
|
||
|
||
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 = '<textarea aria-label="Text of this region" rows="5"></textarea>' +
|
||
'<span class="mag-row-editor-actions">' +
|
||
'<button type="button" class="btn-sm btn-primary" data-act="save">Save text</button>' +
|
||
'<button type="button" class="btn-sm" data-act="split" title="Split this region into two where the cursor is">Split here</button>' +
|
||
'<button type="button" class="btn-sm" data-act="cancel">Cancel</button></span>';
|
||
span.after(editor);
|
||
const area = editor.querySelector('textarea');
|
||
area.value = block.text;
|
||
['click', 'keydown', 'keyup'].forEach((ev) => editor.addEventListener(ev, (e) => e.stopPropagation()));
|
||
area.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); magReloadReview(); } });
|
||
editor.addEventListener('click', async (e) => {
|
||
const act = e.target.closest('[data-act]')?.dataset.act;
|
||
if (!act) return;
|
||
e.preventDefault();
|
||
try {
|
||
if (act === 'save') {
|
||
await api(magRegionUrl(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: area.value }) });
|
||
return magReloadReview('Text saved');
|
||
}
|
||
if (act === 'split') {
|
||
const r = await api(magRegionUrl(id) + '/split', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ at: area.selectionStart }) });
|
||
if (r?.new_id) magSelectionAddAlongside(id, r.new_id);
|
||
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>';
|
||
wrap.appendChild(panel);
|
||
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
|
||
panel.style.top = Math.min(85, top + height + 1) + '%';
|
||
panel.style.left = Math.min(60, left) + '%';
|
||
if ($('mag-draw-kind-picker')) _magSetupDrawKindPicker(drawKindOptions);
|
||
}
|
||
|
||
// 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);
|
||
};
|
||
|
||
const open = () => {
|
||
renderList(search.value === (options.find(([v]) => v === hidden.value) || [])[1] ? '' : search.value);
|
||
list.hidden = false;
|
||
search.setAttribute('aria-expanded', 'true');
|
||
};
|
||
|
||
// 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); });
|
||
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();
|
||
}
|
||
|
||
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(', ') : '');
|
||
out += esc(text.slice(pos, i.start)) + '<mark class="mag-spell" title="' + esc(hint) + '">' +
|
||
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 — 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 ctx = at < 0 ? esc(i.context) : esc(i.context.slice(0, at)) + '<mark class="mag-spell">' +
|
||
esc(i.token) + '</mark>' + esc(i.context.slice(at + i.token.length));
|
||
return '<li><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="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]) return;
|
||
try {
|
||
const r = await api('/api/magazine/' + state.doc.id + '/spelling/fix', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ block_id: issue.block_id, start: issue.start, end: issue.end,
|
||
token: issue.raw || issue.token, replacement: issue.suggestions[k] }),
|
||
});
|
||
if (state.view === 'review') magOfferUndo(r.undo, 'Corrected ' + issue.token);
|
||
say('Corrected ' + issue.token + ' → ' + issue.suggestions[k], 'ok');
|
||
} catch (e) {
|
||
return say('Could not correct: ' + e.message, 'err');
|
||
}
|
||
await magSpellAfterChange();
|
||
}
|
||
|
||
async function magSpellKeep(n) {
|
||
const issue = state.spell?.issues[n];
|
||
if (!issue) return;
|
||
try {
|
||
await api('/api/magazine/' + state.doc.id + '/spelling/dictionary', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ word: issue.token }),
|
||
});
|
||
} catch (e) {
|
||
return say('Could not add to the dictionary: ' + e.message, 'err');
|
||
}
|
||
say(issue.token + ' added to this issue\'s dictionary', 'ok');
|
||
if (state.view === 'review') return magSpellLoadPage();
|
||
return magChecksLoad();
|
||
}
|
||
|
||
async function magSpellAgent() {
|
||
const inRead = state.view === 'read';
|
||
const scope = inRead ? { article: state.article?.id } : { page: state.reviewPage };
|
||
const btn = $('mag-spell-agent-btn');
|
||
const status = () => $('mag-spell-status');
|
||
if (btn) btn.disabled = true;
|
||
let jobId;
|
||
try {
|
||
jobId = (await api('/api/magazine/' + state.doc.id + '/spelling/agent', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scope),
|
||
})).job_id;
|
||
} catch (e) {
|
||
if (btn) btn.disabled = false;
|
||
return say('Could not start the spelling check: ' + e.message, 'err');
|
||
}
|
||
let failed = false;
|
||
for (;;) {
|
||
let job;
|
||
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
|
||
if (job.done) {
|
||
if (job.error) { failed = true; say('Spelling check failed: ' + job.error, 'err'); }
|
||
else say(job.detail || 'Spelling checked', 'ok');
|
||
break;
|
||
}
|
||
if (status() && job.detail) status().textContent = job.detail;
|
||
await new Promise((r) => setTimeout(r, 1500));
|
||
}
|
||
if (btn) btn.disabled = false;
|
||
if (!failed) await magSpellAfterChange();
|
||
}
|
||
|
||
// Reading view: the article's own acronym glossary and spelling findings.
|
||
async function magReloadArticleText() {
|
||
const a = state.article;
|
||
if (!a) return;
|
||
const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' +
|
||
($('mag-read-captions')?.checked ? 1 : 0) + '&descriptions=' + ($('mag-read-descriptions')?.checked ? 1 : 0));
|
||
state.sentences = splitSentences(data.blocks || []);
|
||
state.idx = Math.min(state.idx, Math.max(0, state.sentences.length - 1));
|
||
// Anything already synthesised was spoken from the old text.
|
||
state.audioCache = new Map();
|
||
state.prepGen += 1;
|
||
state.preparing = false;
|
||
syncPrepButton();
|
||
renderArticle();
|
||
}
|
||
|
||
async function magChecksLoad() {
|
||
const body = $('mag-checks-body');
|
||
const a = state.article;
|
||
if (!body || !a || !$('mag-checks')?.open) return;
|
||
let acr, spell;
|
||
try {
|
||
[acr, spell] = await Promise.all([
|
||
api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/acronyms'),
|
||
api('/api/magazine/' + state.doc.id + '/spelling?article=' + encodeURIComponent(a.id)),
|
||
]);
|
||
} catch (e) {
|
||
body.innerHTML = '<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’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 || []);
|
||
} catch (e) {
|
||
return say('Could not load article text: ' + e.message, 'err');
|
||
}
|
||
state.images = articleImages(a);
|
||
state._shownImageId = null;
|
||
const forceStart = opts && opts.fromStart;
|
||
const saved = !forceStart && (state.doc.progress || {})[a.id];
|
||
state.idx = saved && saved.sentence < state.sentences.length ? saved.sentence : 0;
|
||
magShow('read');
|
||
renderArticle();
|
||
loadPodcastIfAny();
|
||
}
|
||
|
||
function renderArticle() {
|
||
const host = $('mag-article');
|
||
const a = state.article;
|
||
if (!host || !a) return;
|
||
$('mag-read-title').textContent = a.title || 'Untitled';
|
||
$('mag-read-by').textContent = a.author ? 'By ' + a.author : '';
|
||
$('mag-read-pages').textContent = fmtPages(a.pages);
|
||
|
||
host.innerHTML = state.sentences.map((s, i) => {
|
||
const tag = s.type === 'title' ? 'h2' : s.type === 'deck' ? 'p class="mag-deck"'
|
||
: s.type === 'byline' ? 'p class="mag-byline"' : s.type === 'caption'
|
||
? 'p class="mag-caption"' : s.type === 'figure_description'
|
||
? 'p class="mag-figure-description"' : 'span';
|
||
const open = tag.split(' ')[0];
|
||
// Each word gets its own span so playback can highlight it individually
|
||
// (see estimateWordTimings) -- sentence-level highlighting alone doesn't
|
||
// need this, but word-level does and this markup serves both.
|
||
const wordsHtml = s.words.map((w, j) => {
|
||
const seam = (s.seams || []).find((m) => m.afterWord === j);
|
||
const marker = seam
|
||
? '<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 + '> ';
|
||
}).join('');
|
||
highlight(state.idx);
|
||
}
|
||
|
||
function highlight(i) {
|
||
document.querySelectorAll('#mag-article [data-i]').forEach((el) => {
|
||
el.classList.toggle('is-current', Number(el.dataset.i) === i);
|
||
});
|
||
clearWordHighlight();
|
||
const el = document.querySelector('#mag-article [data-i="' + i + '"]');
|
||
if (el && !prefersReducedMotion()) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
else if (el) el.scrollIntoView({ block: 'center' });
|
||
const s = state.sentences[i];
|
||
if (s && state.pane === 'pdf') showPage(s.page, s.bbox);
|
||
renderCurrentImage(i);
|
||
const counter = $('mag-read-progress');
|
||
if (counter) counter.textContent = (i + 1) + ' / ' + state.sentences.length;
|
||
const qText = state.queue.length ? 'Article ' + (state.queueIndex + 1) + ' / ' + state.queue.length : '';
|
||
['mag-queue-progress', 'mag-queue-progress-2'].forEach((id) => {
|
||
const el = $(id);
|
||
if (el) el.textContent = qText;
|
||
});
|
||
}
|
||
|
||
function clearWordHighlight() {
|
||
document.querySelectorAll('#mag-article .mag-w.is-current').forEach((el) => el.classList.remove('is-current'));
|
||
}
|
||
|
||
// ── Word-level highlighting (approximate) ────────────────────────────────
|
||
// No forced-alignment or TTS timestamp source exists in this pipeline, so
|
||
// per-word timing is ESTIMATED, not measured: each word's share of the
|
||
// sentence's real (measured) audio duration is proportional to its own
|
||
// character length, with a small fixed share added per word so short words
|
||
// are not given an unrealistically instant duration. This is a common
|
||
// karaoke approximation, not true alignment -- it can drift on sentences
|
||
// with heavy punctuation-driven pauses.
|
||
function estimateWordTimings(words, totalSeconds) {
|
||
if (!words.length || !totalSeconds) return [];
|
||
const FIXED = 0.6; // arbitrary units, shifts weight toward evening out short words
|
||
const weights = words.map((w) => Math.max(1, w.replace(/[.,!?;:"'()«»„"]/g, '').length) + FIXED);
|
||
const total = weights.reduce((a, b) => a + b, 0);
|
||
let t = 0;
|
||
return weights.map((w) => {
|
||
const dur = totalSeconds * (w / total);
|
||
const start = t;
|
||
t += dur;
|
||
return { start, end: t };
|
||
});
|
||
}
|
||
|
||
function highlightWord(sentenceIdx, wordIdx) {
|
||
const sentenceEl = document.querySelector('#mag-article [data-i="' + sentenceIdx + '"]');
|
||
if (!sentenceEl) return;
|
||
sentenceEl.querySelectorAll('.mag-w').forEach((el) => {
|
||
el.classList.toggle('is-current', Number(el.dataset.w) === wordIdx);
|
||
});
|
||
}
|
||
|
||
function prefersReducedMotion() {
|
||
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
|
||
catch (_) { return false; }
|
||
}
|
||
|
||
function magSeek(i) {
|
||
state.idx = Math.max(0, Math.min(state.sentences.length - 1, i));
|
||
highlight(state.idx);
|
||
saveProgress();
|
||
}
|
||
|
||
function magSetPane(pane) {
|
||
state.pane = pane;
|
||
const reflow = $('mag-pane-reflow'), pdf = $('mag-pane-pdf');
|
||
if (reflow) reflow.hidden = pane !== 'reflow';
|
||
if (pdf) pdf.hidden = pane !== 'pdf';
|
||
document.querySelectorAll('[data-mag-pane]').forEach((b) => {
|
||
const on = b.dataset.magPane === pane;
|
||
b.classList.toggle('active', on);
|
||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
});
|
||
if (pane === 'pdf') {
|
||
const s = state.sentences[state.idx];
|
||
if (s) showPage(s.page, s.bbox);
|
||
}
|
||
}
|
||
|
||
function showPage(page, bbox) {
|
||
const img = $('mag-page-img');
|
||
const box = $('mag-page-box');
|
||
if (!img) return;
|
||
const src = '/api/magazine/' + state.doc.id + '/pages/' + page;
|
||
if (img.getAttribute('src') !== src) img.setAttribute('src', src);
|
||
img.setAttribute('alt', 'Page ' + page + ' of ' + (state.doc.title || 'the magazine'));
|
||
const label = $('mag-page-label');
|
||
if (label) label.textContent = 'Page ' + page;
|
||
if (!box || !bbox) return;
|
||
// The layout stores PDF user-space units; the rendered PNG is a different
|
||
// size, so scale by the page dimensions rather than assuming they match.
|
||
const meta = (state.layout.pages || []).find((p) => p.page === page);
|
||
if (!meta) { box.hidden = true; return; }
|
||
box.hidden = false;
|
||
box.style.left = (bbox[0] / meta.width * 100) + '%';
|
||
box.style.top = (bbox[1] / meta.height * 100) + '%';
|
||
box.style.width = (bbox[2] / meta.width * 100) + '%';
|
||
box.style.height = (bbox[3] / meta.height * 100) + '%';
|
||
}
|
||
|
||
function magPeekPage(page) {
|
||
magShow('read');
|
||
magSetPane('pdf');
|
||
showPage(page, null);
|
||
}
|
||
|
||
// ── Audio preparation ────────────────────────────────────────────────────────
|
||
// Synthesised sentences are cached as promises keyed by voice + text, so a
|
||
// prefetch and playback asking for the same sentence share one request, and a
|
||
// changed voice or edited text can never be served stale audio. Held in memory
|
||
// only: closing the tab discards it.
|
||
const MAG_PREP_CONCURRENCY = 2;
|
||
const MAG_LOOKAHEAD = 3;
|
||
|
||
function magNarratorVoice() {
|
||
return ($('mag-voice')?.value || state.doc.default_voice || '').trim();
|
||
}
|
||
|
||
function magSynth(voice, text) {
|
||
const key = voice + ' |