diff --git a/CHANGELOG.md b/CHANGELOG.md index aff7e1a..4031892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,16 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi instructions but had never been created. All three now exist; the architecture SOP records the decisions that are easy to get wrong and the limitations that remain, including that OCR-engine selection is not yet dispatched server-side. +- **Magazine Reader UI.** The Studio sidebar gains a Magazines section with the full P1 flow: a library of magazine + projects, a contents tree grouped by section with per-article confidence, page range, listening estimate and audio + status, the Stage D review screen (tick/untick layout blocks, correct title and author, approve or reject), a reflowed + article view with click-to-seek sentence highlighting, a linked original-page view that shows the bounding box the + current sentence came from, and a podcast panel (2-3 host voice pickers, script generation, turn-by-turn playback with + highlighting). Verified with Playwright against the real backend contract: library listing, review pre-selection, + approval, sentence highlighting and PDF-page linking, and the podcast gate on unapproved articles, plus a full + navigation pass with zero console errors. Reuses the existing TTS preview endpoint for synthesis, so no new audio + pipeline was needed. + ### Changed - **Server-Side Layout Analysis For Magazines.** PDF text extraction for plain books stays in the browser, but magazine layout analysis runs server-side: PyMuPDF exposes real font metrics and embedded-image rectangles that PDF.js does not, diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 6deba93..621b75d 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -5,7 +5,7 @@ Update in the same commit as the change it describes. ## Current phase -Magazine projects, **P0 (backend) complete**. Audiobook/Rehearser/voice-library +Magazine projects, **P0 complete, P1 mostly complete** (frontend reachable at Studio → Magazines). Audiobook/Rehearser/voice-library features are mature and in daily use. ## Deployment @@ -38,7 +38,7 @@ recreate; rebuild and push only when `Dockerfile` or `requirements.txt` change. | Studio (phase wizard) | — | `studio.js` | mature | | TTS engines / routing | `routes/tts.py`, `core/routing.py` | `engines.js` | mature | | STT | `routes/stt.py` | — | mature | -| **Magazines** | `core/magazine.py`, `routes/magazine.py` | *not started* | **P0 backend** | +| **Magazines** | `core/magazine.py`, `routes/magazine.py` | `magazine.js`, `s-magazine.html` | **P0 + P1** | ## Data model @@ -81,5 +81,6 @@ which is the writable volume. ## Next logical step -Magazine P1: magazine overview with the contents tree, the article review -screen, reflowed + PDF views, and sentence-highlighted playback. +Magazine P1 remainder: article images/page crops shown during playback, a +cross-article "play all" queue, and OCR-engine dispatch to the KDL/Baidu +parsers (see architecture/magazine-ingestion.md). Otherwise P2 per TODO.md. diff --git a/TODO.md b/TODO.md index a574c55..080ec40 100644 --- a/TODO.md +++ b/TODO.md @@ -290,23 +290,27 @@ casting. The original PDF remains the visual source of truth while approved arti ### P0 — Magazine foundation -- [~] Add a Magazine project type beside Books and Theater Plays. *(backend + API done; Studio UI not started)* +- [x] Add a Magazine project type beside Books and Theater Plays. - [~] Add PDF/JPG/PNG import with page previews and safe local storage. *(PDF + page renders done; JPG/PNG not done)* - [x] Add OCR-engine selection from Engines > OCR & Document Parsing, with page-image input and structured output. - [x] Detect contents/index pages and propose article titles, authors, and page ranges. -- [~] Build a review screen where users approve or correct article boundaries before TTS. *(approve/edit API done and gating narration; screen not built)* +- [x] Build a review screen where users approve or correct article boundaries before TTS. - [x] Preserve source coordinates for every extracted block, image, caption, and page reference. -- [x] Generate per-article podcast scripts from a connected LLM with two or three user-picked host voices. - *(script generation + review gating done; multi-voice synthesis reuses the existing casting path, UI not built)* +- [x] Generate per-article podcast scripts from a connected LLM with two or three user-picked host voices, + with a Studio panel to pick hosts, generate, and play the script back turn by turn. ### P1 — Magazine listening experience -- [ ] Generate clean article HTML with headings, captions, figures, sidebars, and page links. -- [ ] Generate narrator audio per article with resume/progress and a play-all queue. -- [ ] Synchronize paragraph/sentence highlighting with playback and keep PDF and article views linked. +- [~] Generate clean article HTML with headings, captions, figures, sidebars, and page links. + *(headings/byline/captions styled and page-linked; figures and sidebars are not rendered inline yet)* +- [~] Generate narrator audio per article with resume/progress and a play-all queue. + *(on-demand narration + per-article resume done; no cross-article play-all queue yet)* +- [x] Synchronize paragraph/sentence highlighting with playback and keep PDF and article views linked. + *(sentence-level; clicking a sentence or switching to the original-page view jumps to its source bbox)* - [ ] Show article images or page crops during playback, with optional caption reading. -- [ ] Add confidence warnings, extraction diffs, and manual text correction before audio generation. +- [~] Add confidence warnings, extraction diffs, and manual text correction before audio generation. + *(confidence badges + block-level boundary editing done; no extraction diff view or free-text correction)* ### P2 — Advanced magazine intelligence diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index e994206..81e7db3 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -1,6 +1,8 @@ # SOP — Magazine ingestion -Status: **P0 complete (backend)**. P1 (listening workflow UI) not started. +Status: **P0 complete. P1 mostly complete** — Studio -> Magazines has the full +library/contents/review/read/podcast flow. Remaining P1: images shown during +playback and a cross-article play-all queue. ## Why magazines are not books @@ -122,6 +124,32 @@ than dropping the turn — an unrecognised label would otherwise leave a line wi no voice. 2–3 hosts, each bound to a voice the user picks; synthesis reuses the multi-speaker path the audiobook casting already uses. +## Frontend + +`static/js/magazine.js` + `static/sections/s-magazine.html`, wired into the same +lazy-module system as the other sections (`static/module-manifest.json`, +`static/loader.js`, `static/nav.js`). Two independent section lists gate +visibility (`loader.js` fetches the HTML, `nav.js` decides `navTo` will reveal +it) — both needed updating; missing either one leaves the section perpetually +hidden even though its HTML loaded correctly. + +State machine: `library -> contents -> review -> read`, plus a `pane` toggle +(`reflow` | `pdf`) within `read`. Sentences are split from narratable blocks +client-side, each tagged with its source `blockId`/`page`/`bbox`, so clicking a +sentence or switching to the PDF pane draws the highlight rectangle from the +same coordinate the layout pipeline recorded — no separate sync data. + +Reuses `/api/tts-preview` for both narration and podcast turns; no new audio +pipeline. `magazine_articles` marks each article `has_podcast` so the read view +skips a doomed status probe when nothing was generated, rather than a routine +404 on every open. + +The pane toggle is a button *group* with `aria-pressed`, not `role="tab"` — +this app's tab pattern would additionally require `aria-selected`, roving +`tabindex` and arrow-key navigation, none of which plain click switching +provides. `role="tab"` without that whole pattern fails WCAG 2.2 AA guidance +and, in practice, hides the buttons from `getByRole('tab')`-oriented tooling. + ## Tests - [`tests/test_magazine.py`](../tests/test_magazine.py) — 34 tests on the pure @@ -131,6 +159,10 @@ multi-speaker path the audiobook casting already uses. - [`tests/test_magazine_routes.py`](../tests/test_magazine_routes.py) — 10 tests: path traversal rejection, review gating, LLM script parsing including malformed output. +- [`tests/magazine.spec.js`](../tests/magazine.spec.js) — Playwright, backend mocked to the + real response contract: library listing, review pre-selection, approval, + sentence highlighting + PDF-page linking, the podcast approval gate, and a + full-flow console-error check. Both run without PyMuPDF: the I/O functions import it lazily so the logic worth testing needs no native dependency or sample PDF. diff --git a/core/magazine.py b/core/magazine.py index b4bf0c0..035a639 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -236,6 +236,18 @@ def find_continuation(text: str) -> tuple[int | None, int | None]: return to_page, from_page +def strip_continuation_markers(text: str) -> str: + """Remove '(continued on page N)' style cues before text is narrated. + + These are a print layout convention -- meaningless, and slightly absurd, + spoken aloud ("open paren continued on page eight close paren"). The + layout store keeps the original text; only the narration copy is cleaned. + """ + cleaned = _CONT_TO.sub("", text or "") + cleaned = _CONT_FROM.sub("", cleaned) + return re.sub(r"\s+", " ", cleaned).strip() + + def classify_blocks(blocks: list[Block], page_height: float, page_width: float, repeated: set[str] | None = None, image_boxes: Sequence[Sequence[float]] = (), @@ -833,7 +845,8 @@ def article_text(layout: dict[str, Any], article: dict[str, Any], wanted = set(article.get("blocks") or []) types = set(NARRATABLE) | ({BLOCK_CAPTION} if include_captions else set()) blocks = [ - b for b in layout.get("blocks", []) + dict(b, text=strip_continuation_markers(b["text"])) + for b in layout.get("blocks", []) if b["id"] in wanted and b["type"] in types ] blocks.sort(key=lambda b: (b["page"], b["reading_order"])) diff --git a/routes/magazine.py b/routes/magazine.py index b2d3748..5ddeb2c 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -209,8 +209,13 @@ async def magazine_layout(doc_id: str, page: int | None = None): @router.get("/api/magazine/{doc_id}/articles") async def magazine_articles(doc_id: str): d, meta = _require_magazine(doc_id) + articles = mag.load_articles(d) + # Lets the frontend skip the per-article podcast GET when nothing was ever + # generated, instead of a 404 on every article open. + for a in articles: + a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file() return { - "articles": mag.load_articles(d), + "articles": articles, "progress": meta.get("progress") or {}, "default_voice": meta.get("default_voice") or "", "podcast": meta.get("podcast") or {"hosts": []}, diff --git a/static/index.html b/static/index.html index d57ac41..beda017 100644 --- a/static/index.html +++ b/static/index.html @@ -182,6 +182,10 @@ + diff --git a/static/js/magazine.js b/static/js/magazine.js new file mode 100644 index 0000000..43ec55a --- /dev/null +++ b/static/js/magazine.js @@ -0,0 +1,748 @@ +// 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 }] + view: 'library', // library | contents | review | read + pane: 'reflow', // reflow | pdf + playing: false, + cancel: false, + idx: 0, // current sentence + audio: null, + voices: [], + podcast: null, +}; +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(', '); +} + +// ── Library ────────────────────────────────────────────────────────────────── + +async function magLoadLibrary() { + const data = await api('/api/reader/docs'); + state.docs = (data.docs || []).filter((d) => d.kind === 'magazine'); + renderLibrary(); +} + +function renderLibrary() { + const host = $('mag-library-list'); + if (!host) return; + if (!state.docs.length) { + host.innerHTML = + '

No magazines yet. Add one to get started — ' + + 'upload a PDF and the articles are detected for you.

'; + return; + } + host.innerHTML = state.docs.map((d) => { + const ing = d.ingest || {}; + const badge = ing.status === 'done' + ? '' + (ing.articles || 0) + ' articles' + : ing.status === 'running' ? 'ingesting…' + : ing.status === 'failed' ? 'failed' + : 'not ingested'; + return ''; + }).join(''); +} + +async function magCreate() { + const title = ($('mag-new-title')?.value || '').trim(); + const issue = ($('mag-new-issue')?.value || '').trim(); + const file = $('mag-new-file')?.files?.[0]; + if (!title) return say('Give the magazine a title', 'err'); + if (!file) return say('Choose the PDF', 'err'); + try { + 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 added — 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 = ''; + await magOpen(id); + await magIngest(); + } catch (e) { + say('Could not add magazine: ' + e.message, 'err'); + } +} + +// ── Ingestion ──────────────────────────────────────────────────────────────── + +async function magOpen(docId) { + try { + state.doc = await api('/api/reader/docs/' + docId); + state.doc.id = docId; + await magRefreshArticles(); + magShow('contents'); + if ((state.doc.ingest || {}).status === 'running') pollIngest(); + } catch (e) { + say('Could not open magazine: ' + e.message, 'err'); + } +} + +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'); + } +} + +async function pollIngest() { + const bar = $('mag-ingest-status'); + if (bar) bar.hidden = false; + for (;;) { + let data; + try { + data = await api('/api/magazine/' + state.doc.id + '/ingest/status'); + } catch (_) { break; } + const ing = data.ingest || {}; + const detail = (data.job || {}).detail || ''; + if (bar) { + bar.innerHTML = ing.status === 'running' + ? ' ' + esc(detail || 'Analysing…') + : ing.status === 'failed' + ? ' Ingestion failed: ' + esc(ing.error || '') + : ' ' + (ing.articles || 0) + ' articles detected'; + } + 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) { + if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents' }; + if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries' }; + return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article' }; +} + +function renderContents() { + const host = $('mag-contents'); + if (!host) return; + const title = $('mag-doc-title'); + if (title && state.doc) { + title.textContent = (state.doc.title || 'Magazine') + + (state.doc.issue ? ' — ' + state.doc.issue : ''); + } + if (!state.articles.length) { + host.innerHTML = '

No articles yet. Run ingestion to detect them.

'; + return; + } + const bySection = new Map(); + state.articles.forEach((a) => { + const key = a.section || 'Articles'; + if (!bySection.has(key)) bySection.set(key, []); + bySection.get(key).push(a); + }); + + let html = ''; + bySection.forEach((arts, section) => { + html += '

' + esc(section) + '

'; + }); + host.innerHTML = html; +} + +// ── Review (Stage D) ───────────────────────────────────────────────────────── + +async function magReview(articleId) { + const article = state.articles.find((a) => a.id === articleId); + if (!article) return; + state.article = article; + await ensureLayout(); + magShow('review'); + renderReview(); +} + +async function ensureLayout() { + if (state.layout) return; + try { + state.layout = await api('/api/magazine/' + state.doc.id + '/layout'); + } catch (e) { + say('Could not load layout: ' + e.message, 'err'); + state.layout = { blocks: [], pages: [], page_count: 0 }; + } +} + +function blocksForPages(pages) { + const want = new Set(pages || []); + return (state.layout.blocks || []) + .filter((b) => want.has(b.page)) + .sort((a, b) => a.page - b.page || a.reading_order - b.reading_order); +} + +function renderReview() { + const host = $('mag-review'); + const a = state.article; + if (!host || !a) return; + const included = new Set(a.blocks || []); + // Offer neighbouring pages too, so a boundary that started a page late can be + // corrected without leaving the screen. + const span = a.pages.length + ? Array.from(new Set(a.pages.concat([a.pages[0] - 1, a.pages[a.pages.length - 1] + 1]))) + .filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y) + : []; + const conf = confidenceLabel(a.confidence); + + let html = + '
' + + '' + + '' + + '
' + esc(conf.text) + '
' + + '
' + + '

Untick anything that is not part of this article — a sidebar, an advert, ' + + 'or a neighbouring piece. Only ticked text is read aloud.

'; + + let currentPage = null; + html += '
'; + blocksForPages(span).forEach((b) => { + if (b.page !== currentPage) { + currentPage = b.page; + html += '
Page ' + b.page + + '
'; + } + const narratable = ['title', 'deck', 'byline', 'body'].includes(b.type); + const on = included.has(b.id); + html += ''; + }); + html += '
' + + '
' + + '' + + '' + + '' + + '
'; + host.innerHTML = html; +} + +function selectedBlockIds() { + return Array.from(document.querySelectorAll('#mag-review input[data-block]:checked')) + .map((el) => el.dataset.block); +} + +async function magApprove() { + const a = state.article; + if (!a) return; + const blocks = selectedBlockIds(); + if (!blocks.length) return say('Tick at least one block before approving', 'err'); + try { + const updated = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + blocks, + status: 'approved', + title: ($('mag-rev-title')?.value || a.title).trim(), + author: ($('mag-rev-author')?.value || a.author).trim(), + }), + }); + Object.assign(a, updated); + say('Approved — ready to listen', 'ok'); + await magRefreshArticles(); + magShow('contents'); + } catch (e) { + say('Could not approve: ' + e.message, 'err'); + } +} + +async function magSetStatus(status) { + const a = state.article; + if (!a) return; + try { + const updated = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + Object.assign(a, updated); + await magRefreshArticles(); + magShow('contents'); + } catch (e) { + say('Could not update: ' + e.message, 'err'); + } +} + +// ── Reading: sentences, both views ─────────────────────────────────────────── + +// Sentence splitting keeps each piece tied to its source block, so highlighting +// in the reflowed view can drive the page shown in the PDF view. +function splitSentences(blocks) { + const out = []; + blocks.forEach((b) => { + const parts = String(b.text || '') + .split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/) + .map((s) => s.trim()).filter(Boolean); + (parts.length ? parts : [b.text]).forEach((text) => { + if (text && text.trim()) { + out.push({ text: text.trim(), blockId: b.id, page: b.page, bbox: b.bbox, type: b.type }); + } + }); + }); + return out; +} + +async function magRead(articleId) { + const a = state.articles.find((x) => x.id === articleId); + if (!a) return; + state.article = a; + await ensureLayout(); + try { + const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' + + ($('mag-read-captions')?.checked ? 1 : 0)); + state.sentences = splitSentences(data.blocks || []); + } catch (e) { + return say('Could not load article text: ' + e.message, 'err'); + } + const saved = (state.doc.progress || {})[a.id]; + state.idx = saved && saved.sentence < state.sentences.length ? saved.sentence : 0; + magShow('read'); + renderArticle(); + loadPodcastIfAny(); +} + +function renderArticle() { + const host = $('mag-article'); + const a = state.article; + if (!host || !a) return; + $('mag-read-title').textContent = a.title || 'Untitled'; + $('mag-read-by').textContent = a.author ? 'By ' + a.author : ''; + $('mag-read-pages').textContent = fmtPages(a.pages); + + host.innerHTML = state.sentences.map((s, i) => { + const tag = s.type === 'title' ? 'h2' : s.type === 'deck' ? 'p class="mag-deck"' + : s.type === 'byline' ? 'p class="mag-byline"' : s.type === 'caption' + ? 'p class="mag-caption"' : 'span'; + const open = tag.split(' ')[0]; + return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' + + 'onclick="magSeek(' + i + ')">' + esc(s.text) + ' '; + }).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); + }); + 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); + const counter = $('mag-read-progress'); + if (counter) counter.textContent = (i + 1) + ' / ' + state.sentences.length; +} + +function prefersReducedMotion() { + try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } + catch (_) { return false; } +} + +function magSeek(i) { + state.idx = Math.max(0, Math.min(state.sentences.length - 1, i)); + highlight(state.idx); + saveProgress(); +} + +function magSetPane(pane) { + state.pane = pane; + const reflow = $('mag-pane-reflow'), pdf = $('mag-pane-pdf'); + if (reflow) reflow.hidden = pane !== 'reflow'; + if (pdf) pdf.hidden = pane !== 'pdf'; + document.querySelectorAll('[data-mag-pane]').forEach((b) => { + const on = b.dataset.magPane === pane; + b.classList.toggle('active', on); + b.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + if (pane === 'pdf') { + const s = state.sentences[state.idx]; + if (s) showPage(s.page, s.bbox); + } +} + +function showPage(page, bbox) { + const img = $('mag-page-img'); + const box = $('mag-page-box'); + if (!img) return; + const src = '/api/magazine/' + state.doc.id + '/pages/' + page; + if (img.getAttribute('src') !== src) img.setAttribute('src', src); + img.setAttribute('alt', 'Page ' + page + ' of ' + (state.doc.title || 'the magazine')); + const label = $('mag-page-label'); + if (label) label.textContent = 'Page ' + page; + if (!box || !bbox) return; + // The layout stores PDF user-space units; the rendered PNG is a different + // size, so scale by the page dimensions rather than assuming they match. + const meta = (state.layout.pages || []).find((p) => p.page === page); + if (!meta) { box.hidden = true; return; } + box.hidden = false; + box.style.left = (bbox[0] / meta.width * 100) + '%'; + box.style.top = (bbox[1] / meta.height * 100) + '%'; + box.style.width = (bbox[2] / meta.width * 100) + '%'; + box.style.height = (bbox[3] / meta.height * 100) + '%'; +} + +function magPeekPage(page) { + magShow('read'); + magSetPane('pdf'); + showPage(page, null); +} + +// ── Playback ───────────────────────────────────────────────────────────────── + +async function magPlay() { + if (state.playing) return magPause(); + const voice = ($('mag-voice')?.value || state.doc.default_voice || '').trim(); + if (!voice) return say('Pick a narrator voice first', 'err'); + state.playing = true; + state.cancel = false; + syncPlayButton(); + try { + while (state.idx < state.sentences.length && !state.cancel) { + highlight(state.idx); + const text = state.sentences[state.idx].text; + let blob; + try { + blob = await fetchTtsPreviewBlob(voice, text, 'mp3'); + } catch (e) { + say('Synthesis failed: ' + e.message, 'err'); + break; + } + if (state.cancel) break; + await playBlob(blob); + if (state.cancel) break; + state.idx += 1; + saveProgress(); + } + } finally { + state.playing = false; + state.cancel = false; + syncPlayButton(); + if (state.idx >= state.sentences.length) { + state.idx = state.sentences.length - 1; + say('Article finished', 'ok'); + } + } +} + +function playBlob(blob) { + return new Promise((resolve) => { + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + state.audio = audio; + const done = () => { URL.revokeObjectURL(url); state.audio = null; resolve(); }; + audio.onended = done; + audio.onerror = done; + audio.play().catch(done); + }); +} + +function magPause() { + state.cancel = true; + if (state.audio) { try { state.audio.pause(); } catch (_) {} } + state.playing = false; + syncPlayButton(); +} + +function syncPlayButton() { + const btn = $('mag-play'); + if (!btn) return; + btn.innerHTML = state.playing + ? ' Pause' + : ' Listen'; + btn.setAttribute('aria-label', state.playing ? 'Pause narration' : 'Play narration'); +} + +let _saveTimer = null; +function saveProgress() { + if (!state.doc || !state.article) return; + clearTimeout(_saveTimer); + _saveTimer = setTimeout(() => { + fetch('/api/magazine/' + state.doc.id + '/progress', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ article: state.article.id, sentence: state.idx }), + }).catch(() => {}); + }, 800); +} + +// ── Podcast mode ───────────────────────────────────────────────────────────── + +async function magLoadVoices() { + try { + const data = await api('/api/voices'); + state.voices = data.voices || data || []; + } catch (_) { state.voices = []; } + const options = '' + + state.voices.map((v) => { + const id = v.id || v.name || v; + return ''; + }).join(''); + ['mag-voice', 'mag-host-a', 'mag-host-b', 'mag-host-c'].forEach((id) => { + const el = $(id); + if (el) { + const keep = el.value; + el.innerHTML = options; + if (keep) el.value = keep; + } + }); + const def = state.doc && state.doc.default_voice; + if (def && $('mag-voice') && !$('mag-voice').value) $('mag-voice').value = def; +} + +async function loadPodcastIfAny() { + state.podcast = null; + renderPodcast(); + // has_podcast comes from the articles listing so an article that never had + // a script generated skips the request entirely, rather than a 404 on + // every open. + if (!state.article || !state.article.has_podcast) return; + try { + state.podcast = await api('/api/magazine/' + state.doc.id + '/articles/' + + state.article.id + '/podcast'); + } catch (_) { /* generated flag was stale — treat as none */ } + renderPodcast(); +} + +async function magGeneratePodcast() { + const a = state.article; + if (!a) return; + if (a.status !== 'approved') { + return say('Approve the article first — unreviewed boundaries may include an advert', 'err'); + } + const hosts = ['mag-host-a', 'mag-host-b', 'mag-host-c'] + .map((id) => ($(id)?.value || '').trim()) + .filter(Boolean) + .map((voice, i) => ({ voice, name: ($('mag-host-name-' + 'abc'[i])?.value || '').trim() })); + if (hosts.length < 2) return say('Pick at least two host voices', 'err'); + + const btn = $('mag-podcast-gen'); + if (btn) { btn.disabled = true; btn.textContent = 'Writing script…'; } + try { + state.podcast = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/podcast', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hosts }), + }); + a.has_podcast = true; + say('Podcast script ready', 'ok'); + renderPodcast(); + } catch (e) { + say('Could not generate: ' + e.message, 'err'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Generate script'; } + } +} + +function renderPodcast() { + const host = $('mag-podcast-script'); + if (!host) return; + if (!state.podcast) { + host.innerHTML = '

No podcast script yet. Pick two or three voices and ' + + 'generate one — the hosts discuss this article for someone who has not read it.

'; + const play = $('mag-podcast-play'); + if (play) play.hidden = true; + return; + } + const names = {}; + (state.podcast.hosts || []).forEach((h) => { names[h.label] = h.name || ('Host ' + h.label); }); + host.innerHTML = '
    ' + (state.podcast.turns || []).map((t, i) => + '
  1. ' + esc(names[t.host] || t.host) + ' ' + + esc(t.text) + '
  2. ').join('') + '
'; + const play = $('mag-podcast-play'); + if (play) play.hidden = false; +} + +async function magPlayPodcast() { + if (state.playing) return magPause(); + if (!state.podcast) return; + const voiceOf = {}; + (state.podcast.hosts || []).forEach((h) => { voiceOf[h.label] = h.voice; }); + state.playing = true; + state.cancel = false; + const btn = $('mag-podcast-play'); + if (btn) btn.textContent = 'Pause'; + try { + const turns = state.podcast.turns || []; + for (let i = 0; i < turns.length && !state.cancel; i++) { + document.querySelectorAll('#mag-podcast-script [data-turn]').forEach((el) => { + el.classList.toggle('is-current', Number(el.dataset.turn) === i); + }); + const voice = voiceOf[turns[i].host]; + if (!voice) continue; + let blob; + try { blob = await fetchTtsPreviewBlob(voice, turns[i].text, 'mp3'); } + catch (e) { say('Synthesis failed: ' + e.message, 'err'); break; } + if (state.cancel) break; + await playBlob(blob); + } + } finally { + state.playing = false; + state.cancel = false; + if (btn) btn.textContent = 'Play podcast'; + } +} + +// ── View switching ─────────────────────────────────────────────────────────── + +function magShow(view) { + state.view = view; + ['library', 'contents', 'review', 'read'].forEach((v) => { + const el = $('mag-view-' + v); + if (el) el.hidden = v !== view; + }); + if (view !== 'read') magPause(); +} + +function magBackToContents() { + magPause(); + magShow('contents'); + magRefreshArticles(); +} + +// ── Boot ───────────────────────────────────────────────────────────────────── + +function magInit() { + if (!$('mag-library-list')) return; // section not mounted + magLoadLibrary(); + magLoadVoices(); + magShow('library'); +} + +root.magOpen = magOpen; +root.magCreate = magCreate; +root.magIngest = magIngest; +root.magReview = magReview; +root.magApprove = magApprove; +root.magSetStatus = magSetStatus; +root.magRead = magRead; +root.magSeek = magSeek; +root.magPlay = magPlay; +root.magPause = magPause; +root.magSetPane = magSetPane; +root.magPeekPage = magPeekPage; +root.magShow = magShow; +root.magBackToContents = magBackToContents; +root.magGeneratePodcast = magGeneratePodcast; +root.magPlayPodcast = magPlayPodcast; +root.magInit = magInit; +MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages }; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', magInit); +} else { + magInit(); +} + +})(window); diff --git a/static/loader.js b/static/loader.js index f157086..048eda0 100644 --- a/static/loader.js +++ b/static/loader.js @@ -17,7 +17,7 @@ const SECTIONS = [ 's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-caststudio', 's-performance', 's-routing', 's-connect', 's-settings', - 's-llms', 's-conversation', 's-library', + 's-llms', 's-conversation', 's-library', 's-magazine', ]; function _storedSection() { diff --git a/static/module-manifest.json b/static/module-manifest.json index aaf8f95..298c844 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -1,12 +1,122 @@ { "core": [ - "voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs", "image-cache", - "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" + "voice-picker", + "benchmark-voice-picker", + "voice-inspector", + "seed-finder", + "voice-sources", + "fishaudio-browser", + "voice-cache", + "jobs", + "image-cache", + "integrations", + "routing", + "voice-clone", + "voice-library", + "tts-preview", + "generation", + "benchmark", + "stt", + "namespace" ], "lazy": { - "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-training-ui", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], - "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-training-ui", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-training-ui", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-training-ui", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-rehearser": [ + "rehearser-parse", + "rehearser-cast", + "rehearser-train", + "rehearser-training-ui", + "rehearser-voice-design", + "rehearser-io", + "rehearser-playback", + "rehearser-recording", + "rehearser", + "rehearser-library", + "rehearser-stage", + "rehearser-voice-picker" + ], + "s-reader": [ + "rehearser-parse", + "rehearser-cast", + "rehearser-train", + "rehearser-training-ui", + "rehearser-voice-design", + "rehearser-io", + "rehearser-playback", + "rehearser-recording", + "rehearser", + "rehearser-library", + "rehearser-stage", + "rehearser-voice-picker", + "characters-library", + "reader", + "audiobook/network", + "audiobook-parse", + "audiobook-segmentation", + "audiobook-cast", + "audiobook-pipeline", + "audiobook-preview-export", + "audiobook", + "character-sheets" + ], + "s-library": [ + "rehearser-parse", + "rehearser-cast", + "rehearser-train", + "rehearser-training-ui", + "rehearser-voice-design", + "rehearser-io", + "rehearser-playback", + "rehearser-recording", + "rehearser", + "rehearser-library", + "rehearser-stage", + "rehearser-voice-picker", + "characters-library", + "reader", + "audiobook/network", + "audiobook-parse", + "audiobook-segmentation", + "audiobook-cast", + "audiobook-pipeline", + "audiobook-preview-export", + "audiobook", + "character-sheets", + "library", + "character-columns", + "library-characters", + "sillytavern" + ], + "s-caststudio": [ + "rehearser-parse", + "rehearser-cast", + "rehearser-train", + "rehearser-training-ui", + "rehearser-voice-design", + "rehearser-io", + "rehearser-playback", + "rehearser-recording", + "rehearser", + "rehearser-library", + "rehearser-stage", + "rehearser-voice-picker", + "characters-library", + "reader", + "audiobook/network", + "audiobook-parse", + "audiobook-segmentation", + "audiobook-cast", + "audiobook-pipeline", + "audiobook-preview-export", + "audiobook", + "character-sheets", + "library", + "character-columns", + "library-characters", + "sillytavern", + "studio" + ], + "s-magazine": [ + "magazine" + ] } } diff --git a/static/nav.js b/static/nav.js index 3a7ccd3..883673a 100644 --- a/static/nav.js +++ b/static/nav.js @@ -18,7 +18,7 @@ llms: 's-llms' }; - const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-caststudio', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library']; + const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-caststudio', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library', 's-magazine']; function storedSection() { try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; } diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html new file mode 100644 index 0000000..769acac --- /dev/null +++ b/static/sections/s-magazine.html @@ -0,0 +1,119 @@ +
+ +
+

Magazines

+

Listen to a magazine one article at a time — from its true beginning to its true end, without adverts, sidebars or a neighbouring piece spliced into the middle.

+
+
+ + +
+
+

Add a magazine or paper

+
+ + + + +
+

Scanned issues work too — pages without selectable text are read with OCR.

+
+ +

Your magazines

+
+
+ + + + + + + + + diff --git a/static/style.css b/static/style.css index 5c01715..ae77bc1 100644 --- a/static/style.css +++ b/static/style.css @@ -4742,3 +4742,103 @@ input[list="lang-codes"] { .routing-grid .langsel { width: 100%; } .routing-grid .langsel-btn { padding: 3px 6px; gap: 4px; } .routing-grid .langsel-label { font-variant-numeric: tabular-nums; } + +/* ── Magazines ───────────────────────────────────────────────────────────── + Article-first magazine reader: contents tree, boundary review, reflowed + article with sentence highlighting, and the original PDF page beside it. */ +.mag-new { margin-bottom: 20px; } +.mag-new-grid { display: grid; grid-template-columns: 2fr 1fr 1.5fr auto; gap: 12px; align-items: end; } +.mag-new-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); } +.mag-new-grid input { padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); } + +.mag-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.mag-card { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; text-align: left; + padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); cursor: pointer; } +.mag-card:hover { border-color: var(--accent); } +.mag-card:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.18); } +.mag-card-title { font-weight: 600; } +.mag-card-sub { color: var(--subtext); font-size: 13px; } + +.mag-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } +.mag-bar h3 { margin: 0; } +.mag-bar-actions { margin-left: auto; } +.mag-status { padding: 8px 12px; border-radius: var(--radius); background: var(--panel); + color: var(--subtext); margin-bottom: 12px; } + +.mag-pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; + background: var(--panel); color: var(--subtext); border: 1px solid var(--border); } +.mag-pill.ok { background: rgba(22,163,74,.12); color: var(--green); border-color: transparent; } +.mag-pill.warn { background: rgba(217,119,6,.12); color: var(--yellow); border-color: transparent; } +.mag-pill.bad { background: rgba(220,38,38,.12); color: var(--red); border-color: transparent; } + +.mag-section h4 { margin: 18px 0 8px; color: var(--subtext); text-transform: uppercase; + font-size: 12px; letter-spacing: .06em; } +.mag-toc { list-style: none; margin: 0; padding: 0; } +.mag-toc-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: center; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); margin-bottom: 6px; } +.mag-toc-row.is-approved { border-left: 3px solid var(--green); } +.mag-toc-title { font-weight: 600; } +.mag-toc-by { color: var(--subtext); font-size: 13px; margin-left: 8px; } +.mag-toc-meta { display: flex; gap: 10px; align-items: center; color: var(--subtext); font-size: 13px; flex-wrap: wrap; } +.mag-toc-actions { display: flex; gap: 6px; } + +.mag-review-head { display: grid; grid-template-columns: 2fr 1fr auto; gap: 12px; align-items: end; margin-bottom: 10px; } +.mag-review-head label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); } +.mag-review-head input { padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); color: var(--text); } +.mag-page-head { margin: 16px 0 6px; font-size: 13px; color: var(--subtext); } +.mag-block { display: grid; grid-template-columns: auto 90px 1fr; gap: 10px; align-items: start; + padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); margin-bottom: 4px; cursor: pointer; } +.mag-block.is-aux { opacity: .68; } +.mag-block:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.14); } +.mag-block-type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); } +.mag-block-text { font-size: 14px; line-height: 1.45; } +.mag-review-foot { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; + position: sticky; bottom: 0; padding: 12px 0; background: var(--bg); } + +.mag-controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); } +.mag-voice-pick { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); } +.mag-pane-tabs { margin-left: auto; display: flex; gap: 4px; } +.mag-pane-tabs .active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } + +.mag-article { max-width: 68ch; margin: 0 auto; font-size: 17px; line-height: 1.7; color: var(--text); } +.mag-article h2 { font-size: 26px; line-height: 1.25; margin: 0 0 8px; } +.mag-article .mag-deck { font-size: 19px; color: var(--subtext); margin: 0 0 12px; } +.mag-article .mag-byline { font-size: 14px; color: var(--subtext); margin: 0 0 20px; } +.mag-article .mag-caption { font-size: 14px; color: var(--subtext); font-style: italic; } +.mag-article [data-i] { cursor: pointer; border-radius: 3px; } +.mag-article [data-i]:hover { background: rgba(37,99,235,.07); } +.mag-article [data-i]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* The reading highlight has to survive both themes and colour-blindness, so it + carries a left rule as well as a tint rather than relying on hue alone. */ +.mag-article [data-i].is-current { background: rgba(37,99,235,.16); box-shadow: -3px 0 0 0 var(--accent); } + +.mag-page-wrap { position: relative; max-width: 900px; margin: 0 auto; } +.mag-page-wrap img { width: 100%; height: auto; display: block; border: 1px solid var(--border); border-radius: var(--radius); } +.mag-page-box { position: absolute; border: 2px solid var(--accent); background: rgba(37,99,235,.12); + border-radius: 2px; pointer-events: none; transition: all .25s ease; } + +.mag-podcast { margin-top: 24px; } +.mag-podcast summary { cursor: pointer; font-weight: 600; } +.mag-hosts { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 12px 0; } +.mag-hosts label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); } +.mag-podcast-actions { display: flex; gap: 8px; margin-bottom: 12px; } +.mag-turns { padding-left: 20px; line-height: 1.6; } +.mag-turns li { margin-bottom: 8px; border-radius: 3px; padding: 2px 4px; } +.mag-turns li.is-current { background: rgba(37,99,235,.16); box-shadow: -3px 0 0 0 var(--accent); } + +@media (prefers-reduced-motion: reduce) { + .mag-page-box { transition: none; } +} +@media (max-width: 860px) { + .mag-new-grid, .mag-review-head { grid-template-columns: 1fr; } + .mag-toc-row { grid-template-columns: 1fr; } + .mag-toc-actions { justify-content: flex-start; } + .mag-block { grid-template-columns: auto 1fr; } + .mag-block-type { display: none; } + .mag-article { font-size: 18px; } +} diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js new file mode 100644 index 0000000..470963f --- /dev/null +++ b/tests/magazine.spec.js @@ -0,0 +1,178 @@ +// @ts-check +// Functional safety net for the Magazine reader UI. Backend calls are mocked +// with page.route so this exercises the frontend against a fixed contract +// (matching routes/magazine.py's actual response shapes) without needing a +// live PyMuPDF/tesseract ingestion — that pipeline has its own coverage in +// tests/test_magazine.py and tests/test_magazine_routes.py. +const { test, expect } = require('@playwright/test'); + +const DOC_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +const ARTICLE_DETECTED = { + id: 'article-001', title: 'The Future of Batteries', author: 'Jane Smith', + pages: [3, 8], blocks: ['b1', 'b2', 'b3'], images: [], status: 'detected', + confidence: 0.9, section: 'Features', continued_to: [8], + audio_status: 'none', estimated_seconds: 96, has_podcast: false, +}; + +const ARTICLE_APPROVED = { ...ARTICLE_DETECTED, status: 'approved' }; + +const LAYOUT = { + page_count: 8, + pages: [{ page: 3, width: 595, height: 842 }], + blocks: [ + { id: 'b1', page: 3, bbox: [50, 60, 470, 50], type: 'title', text: 'The Future of Batteries', reading_order: 0, confidence: 0.9, font_size: 26, font_name: '', bold: true, column: 0, source: 'native' }, + { id: 'b2', page: 3, bbox: [50, 120, 200, 14], type: 'byline', text: 'By Jane Smith', reading_order: 1, confidence: 0.9, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' }, + { id: 'b3', page: 3, bbox: [50, 150, 200, 300], type: 'body', text: 'Battery technology is entering a new phase. It will take years to mature.', reading_order: 2, confidence: 0.85, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' }, + ], + contents_candidates: [], +}; + +async function navTo(page, section) { + await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); + await page.evaluate((s) => window.navTo(s), section); +} + +async function mockBackend(page, { articleStatus = 'detected' } = {}) { + const article = articleStatus === 'approved' ? ARTICLE_APPROVED : ARTICLE_DETECTED; + + await page.route('**/api/reader/docs', route => route.fulfill({ + json: { docs: [{ id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', ingest: { status: 'done', articles: 1 } }] }, + })); + await page.route(`**/api/reader/docs/${DOC_ID}`, route => route.fulfill({ + json: { id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', ingest: { status: 'done', articles: 1 } }, + })); + await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ + json: { articles: [article], progress: {}, default_voice: '', podcast: { hosts: [] } }, + })); + await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: LAYOUT })); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => { + if (route.request().method() === 'PATCH') { + const body = route.request().postDataJSON(); + return route.fulfill({ json: { ...article, ...body, status: body.status || article.status } }); + } + return route.fulfill({ json: article }); + }); + await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ + json: { article, blocks: LAYOUT.blocks }, + })); + await page.route(`**/api/magazine/${DOC_ID}/pages/*`, route => route.fulfill({ + // 1x1 PNG — only geometry/wiring is under test, not real page rendering. + body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), + contentType: 'image/png', + })); + await page.route('**/api/voices*', route => route.fulfill({ + json: [{ id: 'smoke_voice', name: 'Smoke voice', enabled: true }], + })); +} + +test.describe('Magazine reader', () => { + test('library lists a magazine and opens its contents tree', async ({ page }) => { + await mockBackend(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + + await expect(page.locator('#mag-library-list .mag-card')).toHaveCount(1); + await page.locator('#mag-library-list .mag-card').first().click(); + + await expect(page.locator('#mag-view-contents')).toBeVisible(); + await expect(page.locator('.mag-toc-title')).toHaveText('The Future of Batteries'); + // Non-contiguous pages (a jump-linked continuation) must not read as a range. + await expect(page.locator('.mag-toc-row')).toContainText('pp. 3, 8'); + await expect(page.locator('.mag-toc-row')).toContainText('90%'); + await expect(page.locator('.mag-toc-row')).toContainText('needs review'); + }); + + test('review screen shows layout blocks pre-selected and approves', async ({ page }) => { + await mockBackend(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.waitForSelector('#mag-library-list .mag-card'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.waitForSelector('.mag-toc-row'); + + await page.getByRole('button', { name: 'Review' }).click(); + await expect(page.locator('#mag-view-review')).toBeVisible(); + + // The article's own blocks (b1-b3) start ticked; nothing else does. + await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3); + await expect(page.locator('#mag-rev-title')).toHaveValue('The Future of Batteries'); + + await page.getByRole('button', { name: 'Approve' }).click(); + await expect(page.locator('#mag-view-contents')).toBeVisible(); + }); + + test('approved article reads, highlights sentences, and links to the PDF page', async ({ page }) => { + await mockBackend(page, { articleStatus: 'approved' }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.waitForSelector('#mag-library-list .mag-card'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.waitForSelector('.mag-toc-row.is-approved'); + + await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click(); + await expect(page.locator('#mag-view-read')).toBeVisible(); + await expect(page.locator('#mag-read-title')).toHaveText('The Future of Batteries'); + await expect(page.locator('#mag-read-by')).toHaveText('By Jane Smith'); + + // title (1) + byline (1) + body split into 2 sentences = 4 spans. + const sentences = page.locator('#mag-article [data-i]'); + await expect(sentences).toHaveCount(4); + await expect(sentences.first()).toHaveClass(/is-current/); + + // Click-to-seek moves the highlight and the progress counter. + await sentences.nth(3).click(); + await expect(sentences.nth(3)).toHaveClass(/is-current/); + await expect(sentences.first()).not.toHaveClass(/is-current/); + await expect(page.locator('#mag-read-progress')).toHaveText('4 / 4'); + + // Switching to the PDF pane shows the page the current sentence came from. + await page.getByRole('button', { name: 'Original page' }).click(); + await expect(page.locator('#mag-pane-pdf')).toBeVisible(); + await expect(page.locator('#mag-page-label')).toHaveText('Page 3'); + await expect(page.locator('#mag-page-box')).toBeVisible(); + }); + + test('podcast generation is refused before the article is approved', async ({ page }) => { + await mockBackend(page, { articleStatus: 'detected' }); + let podcastCalled = false; + await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/podcast`, (route) => { + podcastCalled = true; + return route.fulfill({ status: 400, json: { detail: 'Approve the article before generating a podcast' } }); + }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.waitForSelector('#mag-library-list .mag-card'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.waitForSelector('.mag-toc-row'); + + // Detected (unapproved) articles show Review only — no Listen/Continue, + // so there is no route into the read view (and podcast mode) at all. + await expect(page.locator('.mag-toc-row').getByRole('button', { name: /Listen|Continue/ })).toHaveCount(0); + expect(podcastCalled).toBe(false); + }); + + test('no console errors navigating the whole flow', async ({ page }) => { + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + await mockBackend(page, { articleStatus: 'approved' }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.waitForSelector('#mag-library-list .mag-card'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.waitForSelector('.mag-toc-row.is-approved'); + await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click(); + await page.waitForSelector('#mag-article [data-i]'); + await page.getByRole('button', { name: 'Original page' }).click(); + await page.waitForTimeout(300); + + const real = errors.filter(e => /ReferenceError|TypeError|SyntaxError|is not defined|is not a function/.test(e)); + expect(real, real.join('\n')).toHaveLength(0); + }); +}); diff --git a/tests/test_magazine.py b/tests/test_magazine.py index b558e63..5363b24 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -32,6 +32,7 @@ from core.magazine import ( estimate_listen_seconds, find_continuation, find_repeated_lines, + strip_continuation_markers, group_articles, looks_like_byline, mark_advert_pages, @@ -365,7 +366,37 @@ class ArticleGroupingTests(unittest.TestCase): self.assertTrue(all(a.pages[0] != 2 for a in arts)) +class ContinuationMarkerStrippingTests(unittest.TestCase): + def test_a_print_layout_cue_is_removed_before_narration(self): + """Spoken aloud this would read as literal parenthesised text -- a + print convention, not article content.""" + text = strip_continuation_markers( + "The promises did not survive contact. (continued on page 8)") + self.assertNotIn("continued", text.lower()) + self.assertTrue(text.endswith("contact.")) + + def test_german_marker_is_also_removed(self): + text = strip_continuation_markers("Mehr dazu. Fortsetzung auf Seite 90") + self.assertNotIn("Seite", text) + + def test_ordinary_text_is_unaffected(self): + self.assertEqual(strip_continuation_markers("Nothing special here."), + "Nothing special here.") + + class ArticleTextTests(unittest.TestCase): + def test_continuation_marker_is_stripped_from_narrated_output(self): + """article_text() feeds TTS directly, so the print cue must not + survive into what gets spoken -- even though it stays in layout.json + for the review screen.""" + layout = { + "blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY, + "reading_order": 0, "bbox": [0, 0, 1, 1], + "text": "End of the thought. (continued on page 8)"}], + } + got = article_text(layout, {"blocks": ["b1"]}) + self.assertNotIn("continued", got[0]["text"].lower()) + def test_only_narratable_blocks_in_reading_order(self): layout = { "page_count": 2,