feat: add magazine reader frontend (P1)
Studio -> Magazines: library, contents tree grouped by section with
confidence/pages/listening-time/audio status, the Stage D review
screen (tick/untick layout blocks, correct title/author, approve or
reject), a reflowed article view with click-to-seek sentence
highlighting, a linked original-page view showing the source bounding
box, and a podcast panel (2-3 host pickers, script generation,
turn-by-turn playback).
Wired into the existing lazy-module system (module-manifest.json,
loader.js, nav.js) rather than a separate app. Reuses /api/tts-preview
for both narration and podcast turns -- no new audio pipeline.
Fixes found building this:
- The pane toggle used role="tab" without the full ARIA tabs pattern
(aria-selected, roving tabindex, arrow-key nav) -- fails WCAG 2.2 AA
and hid the buttons from role-based tooling. Replaced with a button
group + aria-pressed, matching the plain-click interaction it
actually has.
- Print layout cues ("(continued on page 8)") were being sent to TTS
verbatim -- narration would have spoken the parenthetical aloud.
Stripped from the narration copy only; layout.json keeps the
original for the review screen.
- has_podcast now comes from the articles listing so opening an
article without a generated script skips a doomed GET instead of a
routine 404 on every open.
Verified against the real API contract with Playwright (library,
review pre-selection, approval, sentence highlighting + PDF-page
linking, podcast approval gate, zero console errors across the flow)
and manually end-to-end against a real ingested fixture, screenshots
included. Full suite: 94 Python tests, 33/34 desktop Playwright tests
(one unrelated pre-existing flaky DOM-count assertion).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
736aeaaeac
commit
71a398b82f
10
CHANGELOG.md
10
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,
|
||||
|
||||
@ -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.
|
||||
|
||||
20
TODO.md
20
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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"]))
|
||||
|
||||
@ -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": []},
|
||||
|
||||
@ -182,6 +182,10 @@
|
||||
<div class="nav-tree-item" onclick="navTo('s-reader');if(typeof showReaderView==='function')showReaderView('main')"><span class="mdi mdi-file-document-outline"></span> Reader</div>
|
||||
<div class="nav-tree-item" id="nav-reader-cast-item" onclick="navTo('s-reader');if(typeof audiobookOpenCastView==='function')audiobookOpenCastView();else if(typeof showReaderView==='function')showReaderView('cast')"><span class="mdi mdi-drama-masks"></span> Casting</div>
|
||||
</div>
|
||||
<div class="nav-tree-head nav-subhead" data-nav-section="s-magazine" id="nav-magazine-head" onclick="navTo('s-magazine')">
|
||||
<span class="nav-icon"><span class="mdi mdi-newspaper-variant-outline"></span></span>
|
||||
<span class="nav-label">Magazines</span>
|
||||
</div>
|
||||
<div class="nav-tree-head nav-subhead" data-nav-section="s-rehearser" id="nav-rehearser-head" onclick="navTo('s-rehearser')">
|
||||
<span class="nav-icon"><span class="mdi mdi-theater"></span></span>
|
||||
<span class="nav-label">Script Rehearsal</span>
|
||||
@ -330,6 +334,7 @@
|
||||
<section class="page-section" id="s-caststudio" style="display:none"></section>
|
||||
<section class="page-section" id="s-conversation" style="display:none"></section>
|
||||
<section class="page-section" id="s-library" style="display:none"></section>
|
||||
<section class="page-section" id="s-magazine" style="display:none"></section>
|
||||
</main>
|
||||
|
||||
</div><!-- /app-shell -->
|
||||
|
||||
748
static/js/magazine.js
Normal file
748
static/js/magazine.js
Normal file
@ -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 =
|
||||
'<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>';
|
||||
return '<button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
|
||||
'<span class="mag-card-title">' + esc(d.title || 'Untitled') + '</span>' +
|
||||
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' +
|
||||
badge + '</button>';
|
||||
}).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'
|
||||
? '<span class="mdi mdi-progress-clock"></span> ' + esc(detail || 'Analysing…')
|
||||
: ing.status === 'failed'
|
||||
? '<span class="mdi mdi-alert"></span> Ingestion failed: ' + esc(ing.error || '')
|
||||
: '<span class="mdi mdi-check"></span> ' + (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 = '<p class="muted">No articles yet. Run ingestion to detect them.</p>';
|
||||
return;
|
||||
}
|
||||
const bySection = new Map();
|
||||
state.articles.forEach((a) => {
|
||||
const key = a.section || 'Articles';
|
||||
if (!bySection.has(key)) bySection.set(key, []);
|
||||
bySection.get(key).push(a);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
bySection.forEach((arts, section) => {
|
||||
html += '<div class="mag-section"><h4>' + esc(section) + '</h4><ul class="mag-toc">';
|
||||
arts.forEach((a) => {
|
||||
const conf = confidenceLabel(a.confidence);
|
||||
const done = a.status === 'approved';
|
||||
const progress = (state.doc.progress || {})[a.id];
|
||||
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' +
|
||||
'<div class="mag-toc-main">' +
|
||||
'<span class="mag-toc-title">' + esc(a.title || 'Untitled') + '</span>' +
|
||||
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="mag-toc-meta">' +
|
||||
'<span>' + fmtPages(a.pages) + '</span>' +
|
||||
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
|
||||
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
|
||||
Math.round(a.confidence * 100) + '%</span>' +
|
||||
'<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
|
||||
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="mag-toc-actions">' +
|
||||
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
|
||||
(done
|
||||
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
|
||||
(progress ? 'Continue' : 'Listen') + '</button>'
|
||||
: '') +
|
||||
'</div></li>';
|
||||
});
|
||||
html += '</ul></div>';
|
||||
});
|
||||
host.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Review (Stage D) ─────────────────────────────────────────────────────────
|
||||
|
||||
async function magReview(articleId) {
|
||||
const article = state.articles.find((a) => a.id === articleId);
|
||||
if (!article) return;
|
||||
state.article = article;
|
||||
await ensureLayout();
|
||||
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 =
|
||||
'<div class="mag-review-head">' +
|
||||
'<label>Title<input id="mag-rev-title" type="text" value="' + esc(a.title) + '"></label>' +
|
||||
'<label>Author<input id="mag-rev-author" type="text" value="' + esc(a.author) + '"></label>' +
|
||||
'<div class="mag-pill ' + conf.cls + '">' + esc(conf.text) + '</div>' +
|
||||
'</div>' +
|
||||
'<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.</p>';
|
||||
|
||||
let currentPage = null;
|
||||
html += '<div class="mag-blocks">';
|
||||
blocksForPages(span).forEach((b) => {
|
||||
if (b.page !== currentPage) {
|
||||
currentPage = b.page;
|
||||
html += '<h5 class="mag-page-head">Page ' + b.page +
|
||||
' <button type="button" class="btn-sm" onclick="magPeekPage(' + b.page + ')">' +
|
||||
'Show original</button></h5>';
|
||||
}
|
||||
const narratable = ['title', 'deck', 'byline', 'body'].includes(b.type);
|
||||
const on = included.has(b.id);
|
||||
html += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '">' +
|
||||
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '">' +
|
||||
'<span class="mag-block-type">' + esc(b.type) + '</span>' +
|
||||
'<span class="mag-block-text">' + esc(b.text.slice(0, 220)) +
|
||||
(b.text.length > 220 ? '…' : '') + '</span></label>';
|
||||
});
|
||||
html += '</div>' +
|
||||
'<div class="mag-review-foot">' +
|
||||
'<button type="button" class="btn" onclick="magShow(\'contents\')">Back to contents</button>' +
|
||||
'<button type="button" class="btn" onclick="magSetStatus(\'rejected\')">Not an article</button>' +
|
||||
'<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' +
|
||||
'</div>';
|
||||
host.innerHTML = html;
|
||||
}
|
||||
|
||||
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) + '</' + 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);
|
||||
});
|
||||
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
|
||||
? '<span class="mdi mdi-pause"></span> Pause'
|
||||
: '<span class="mdi mdi-play"></span> 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 = '<option value="">— pick a voice —</option>' +
|
||||
state.voices.map((v) => {
|
||||
const id = v.id || v.name || v;
|
||||
return '<option value="' + esc(id) + '">' + esc(v.name || id) + '</option>';
|
||||
}).join('');
|
||||
['mag-voice', 'mag-host-a', 'mag-host-b', 'mag-host-c'].forEach((id) => {
|
||||
const el = $(id);
|
||||
if (el) {
|
||||
const keep = el.value;
|
||||
el.innerHTML = options;
|
||||
if (keep) el.value = keep;
|
||||
}
|
||||
});
|
||||
const def = state.doc && state.doc.default_voice;
|
||||
if (def && $('mag-voice') && !$('mag-voice').value) $('mag-voice').value = def;
|
||||
}
|
||||
|
||||
async function loadPodcastIfAny() {
|
||||
state.podcast = null;
|
||||
renderPodcast();
|
||||
// has_podcast comes from the articles listing so an article that never had
|
||||
// a script generated skips the request entirely, rather than a 404 on
|
||||
// every open.
|
||||
if (!state.article || !state.article.has_podcast) return;
|
||||
try {
|
||||
state.podcast = await api('/api/magazine/' + state.doc.id + '/articles/' +
|
||||
state.article.id + '/podcast');
|
||||
} catch (_) { /* generated flag was stale — treat as none */ }
|
||||
renderPodcast();
|
||||
}
|
||||
|
||||
async function magGeneratePodcast() {
|
||||
const a = state.article;
|
||||
if (!a) return;
|
||||
if (a.status !== 'approved') {
|
||||
return say('Approve the article first — unreviewed boundaries may include an advert', 'err');
|
||||
}
|
||||
const hosts = ['mag-host-a', 'mag-host-b', 'mag-host-c']
|
||||
.map((id) => ($(id)?.value || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((voice, i) => ({ voice, name: ($('mag-host-name-' + 'abc'[i])?.value || '').trim() }));
|
||||
if (hosts.length < 2) return say('Pick at least two host voices', 'err');
|
||||
|
||||
const btn = $('mag-podcast-gen');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Writing script…'; }
|
||||
try {
|
||||
state.podcast = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/podcast', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hosts }),
|
||||
});
|
||||
a.has_podcast = true;
|
||||
say('Podcast script ready', 'ok');
|
||||
renderPodcast();
|
||||
} catch (e) {
|
||||
say('Could not generate: ' + e.message, 'err');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Generate script'; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderPodcast() {
|
||||
const host = $('mag-podcast-script');
|
||||
if (!host) return;
|
||||
if (!state.podcast) {
|
||||
host.innerHTML = '<p class="muted">No podcast script yet. Pick two or three voices and ' +
|
||||
'generate one — the hosts discuss this article for someone who has not read it.</p>';
|
||||
const play = $('mag-podcast-play');
|
||||
if (play) play.hidden = true;
|
||||
return;
|
||||
}
|
||||
const names = {};
|
||||
(state.podcast.hosts || []).forEach((h) => { names[h.label] = h.name || ('Host ' + h.label); });
|
||||
host.innerHTML = '<ol class="mag-turns">' + (state.podcast.turns || []).map((t, i) =>
|
||||
'<li data-turn="' + i + '"><b>' + esc(names[t.host] || t.host) + '</b> ' +
|
||||
esc(t.text) + '</li>').join('') + '</ol>';
|
||||
const play = $('mag-podcast-play');
|
||||
if (play) play.hidden = false;
|
||||
}
|
||||
|
||||
async function magPlayPodcast() {
|
||||
if (state.playing) return magPause();
|
||||
if (!state.podcast) return;
|
||||
const voiceOf = {};
|
||||
(state.podcast.hosts || []).forEach((h) => { voiceOf[h.label] = h.voice; });
|
||||
state.playing = true;
|
||||
state.cancel = false;
|
||||
const btn = $('mag-podcast-play');
|
||||
if (btn) btn.textContent = 'Pause';
|
||||
try {
|
||||
const turns = state.podcast.turns || [];
|
||||
for (let i = 0; i < turns.length && !state.cancel; i++) {
|
||||
document.querySelectorAll('#mag-podcast-script [data-turn]').forEach((el) => {
|
||||
el.classList.toggle('is-current', Number(el.dataset.turn) === i);
|
||||
});
|
||||
const voice = voiceOf[turns[i].host];
|
||||
if (!voice) continue;
|
||||
let blob;
|
||||
try { blob = await fetchTtsPreviewBlob(voice, turns[i].text, 'mp3'); }
|
||||
catch (e) { say('Synthesis failed: ' + e.message, 'err'); break; }
|
||||
if (state.cancel) break;
|
||||
await playBlob(blob);
|
||||
}
|
||||
} finally {
|
||||
state.playing = false;
|
||||
state.cancel = false;
|
||||
if (btn) btn.textContent = 'Play podcast';
|
||||
}
|
||||
}
|
||||
|
||||
// ── View switching ───────────────────────────────────────────────────────────
|
||||
|
||||
function magShow(view) {
|
||||
state.view = view;
|
||||
['library', 'contents', 'review', 'read'].forEach((v) => {
|
||||
const el = $('mag-view-' + v);
|
||||
if (el) el.hidden = v !== view;
|
||||
});
|
||||
if (view !== 'read') magPause();
|
||||
}
|
||||
|
||||
function magBackToContents() {
|
||||
magPause();
|
||||
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);
|
||||
@ -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() {
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ''; }
|
||||
|
||||
119
static/sections/s-magazine.html
Normal file
119
static/sections/s-magazine.html
Normal file
@ -0,0 +1,119 @@
|
||||
<div class="section-head">
|
||||
<span class="section-icon"><span class="mdi mdi-newspaper-variant-outline"></span></span>
|
||||
<div class="section-title">
|
||||
<h2>Magazines</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Library ──────────────────────────────────────────────────────── -->
|
||||
<div id="mag-view-library">
|
||||
<div class="card mag-new">
|
||||
<h3>Add a magazine or paper</h3>
|
||||
<div class="mag-new-grid">
|
||||
<label for="mag-new-title">Title
|
||||
<input id="mag-new-title" type="text" placeholder="Scientific American" autocomplete="off">
|
||||
</label>
|
||||
<label for="mag-new-issue">Issue
|
||||
<input id="mag-new-issue" type="text" placeholder="September 2026" autocomplete="off">
|
||||
</label>
|
||||
<label for="mag-new-file">PDF
|
||||
<input id="mag-new-file" type="file" accept="application/pdf">
|
||||
</label>
|
||||
<button type="button" class="btn primary" onclick="magCreate()">
|
||||
<span class="mdi mdi-plus"></span> Add magazine
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted">Scanned issues work too — pages without selectable text are read with OCR.</p>
|
||||
</div>
|
||||
|
||||
<h3>Your magazines</h3>
|
||||
<div id="mag-library-list" class="mag-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Contents ─────────────────────────────────────────────────────── -->
|
||||
<div id="mag-view-contents" hidden>
|
||||
<div class="mag-bar">
|
||||
<button type="button" class="btn-sm" onclick="magShow('library')">
|
||||
<span class="mdi mdi-arrow-left"></span> All magazines
|
||||
</button>
|
||||
<h3 id="mag-doc-title">Magazine</h3>
|
||||
<div class="mag-bar-actions">
|
||||
<button type="button" class="btn-sm" onclick="magIngest()">
|
||||
<span class="mdi mdi-refresh"></span> Re-run detection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mag-ingest-status" class="mag-status" hidden></div>
|
||||
<div id="mag-contents"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Review (Stage D) ─────────────────────────────────────────────── -->
|
||||
<div id="mag-view-review" hidden>
|
||||
<div class="mag-bar">
|
||||
<button type="button" class="btn-sm" onclick="magBackToContents()">
|
||||
<span class="mdi mdi-arrow-left"></span> Contents
|
||||
</button>
|
||||
<h3>Review article boundaries</h3>
|
||||
</div>
|
||||
<div id="mag-review"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Reading ──────────────────────────────────────────────────────── -->
|
||||
<div id="mag-view-read" hidden>
|
||||
<div class="mag-bar">
|
||||
<button type="button" class="btn-sm" onclick="magBackToContents()">
|
||||
<span class="mdi mdi-arrow-left"></span> Contents
|
||||
</button>
|
||||
<div class="mag-read-id">
|
||||
<h3 id="mag-read-title">Article</h3>
|
||||
<span id="mag-read-by" class="muted"></span>
|
||||
<span id="mag-read-pages" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mag-controls">
|
||||
<label for="mag-voice" class="mag-voice-pick">Narrator
|
||||
<select id="mag-voice"></select>
|
||||
</label>
|
||||
<button type="button" class="btn primary" id="mag-play" onclick="magPlay()"
|
||||
aria-label="Play narration">
|
||||
<span class="mdi mdi-play"></span> Listen
|
||||
</button>
|
||||
<span id="mag-read-progress" class="muted" aria-live="polite"></span>
|
||||
<div class="mag-pane-tabs" role="group" aria-label="Reading view">
|
||||
<button type="button" class="btn-sm active" data-mag-pane="reflow"
|
||||
aria-pressed="true" onclick="magSetPane('reflow')">Article</button>
|
||||
<button type="button" class="btn-sm" data-mag-pane="pdf"
|
||||
aria-pressed="false" onclick="magSetPane('pdf')">Original page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mag-pane-reflow" class="mag-pane">
|
||||
<div id="mag-article" class="mag-article"></div>
|
||||
</div>
|
||||
|
||||
<div id="mag-pane-pdf" class="mag-pane" hidden>
|
||||
<div class="mag-page-wrap">
|
||||
<img id="mag-page-img" alt="">
|
||||
<div id="mag-page-box" class="mag-page-box" hidden></div>
|
||||
</div>
|
||||
<p id="mag-page-label" class="muted"></p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast mode -->
|
||||
<details class="card mag-podcast">
|
||||
<summary><span class="mdi mdi-podcast"></span> Podcast mode — discuss this article</summary>
|
||||
<p class="muted">Two or three hosts discuss the article for someone who has not read it. The script is written by the configured LLM from the article text; it will not invent facts, but it is a generated discussion, not the author’s words.</p>
|
||||
<div class="mag-hosts">
|
||||
<label for="mag-host-a">Host A<select id="mag-host-a"></select></label>
|
||||
<label for="mag-host-b">Host B<select id="mag-host-b"></select></label>
|
||||
<label for="mag-host-c">Host C <span class="muted">(optional)</span><select id="mag-host-c"></select></label>
|
||||
</div>
|
||||
<div class="mag-podcast-actions">
|
||||
<button type="button" class="btn" id="mag-podcast-gen" onclick="magGeneratePodcast()">Generate script</button>
|
||||
<button type="button" class="btn primary" id="mag-podcast-play" onclick="magPlayPodcast()" hidden>Play podcast</button>
|
||||
</div>
|
||||
<div id="mag-podcast-script"></div>
|
||||
</details>
|
||||
</div>
|
||||
100
static/style.css
100
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; }
|
||||
}
|
||||
|
||||
178
tests/magazine.spec.js
Normal file
178
tests/magazine.spec.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user