From 7e9be98709719e14a1503d9b74e8f9c392d43309 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 30 Sep 2026 10:59:08 +0200 Subject: [PATCH] feat(magazine): page overview shows status, article count, page flow and what to do next Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + Manual.md | 27 +++++++++ static/js/magazine.js | 129 ++++++++++++++++++++++++++++++++++++++--- static/style.css | 19 ++++++ tests/magazine.spec.js | 111 +++++++++++++++++++++++++++++++++++ 5 files changed, 280 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd2d23..0ebbe23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Page overview says what to do.** Every page card now states its status in words, how many articles are on the page, where each article continues from / on to ("continues from p.3", "continues on p.6"), and a **To do** list when the system could not settle the page (approve it; fix the quality findings with the tool that fixes each; link orphan text to an article; generate audio; approve the other articles on a shared page). A summary line counts the pages that need you and *Show only pages that need me* hides the rest. The legend is larger (14 px, 18 px swatches). A page coloured "audio" that still has an unapproved article is now labelled "Partly done" with the reason. - **Hands-free listening.** The article player now registers with the browser's Media Session: title, magazine and first-page artwork on the lock screen / headset / car display, with play, pause, previous/next article, ±15 s and seek, plus a position bar. While driving mode is open and audio is playing the screen is kept awake (Screen Wake Lock), and released on pause or exit; a refused lock never affects playback. - **EPUB 3 with Media Overlays.** *EPUB with audio* in the contents toolbar (and `GET /api/magazine/{id}/epub`) packages the approved articles that have audio into a book whose text highlights as the audio plays. MP3 export now records each sentence's real start and end (`audio/.timings.json`, no text), so new audio highlights exactly; older audio is placed by estimate and the book's description says so. Validated with W3C EPUBCheck 5.2.1 (0 errors, 0 warnings) on a real 12-minute two-article issue. - **Magazine reading view shows sidebars.** A sidebar was kept out of narration and therefore never shown at all. The article text endpoint now also returns `asides` and the reflowed view displays each one where it sits, labelled "Sidebar · not read aloud · p. N", as plain text that is neither a sentence nor highlighted. Only for pages the article alone occupies (on a shared page there is no telling which article a sidebar belongs to); not for advertisement pages; a sidebar you ticked into the narration stays narrated. diff --git a/Manual.md b/Manual.md index 601102f..fdea352 100644 --- a/Manual.md +++ b/Manual.md @@ -340,6 +340,33 @@ Open an article and expand **Pronunciation & spelling** under the player. **Mobile / accessibility:** the lists are ordinary buttons with labels ("Replace X with Y"); marks are underlined, not colour-only. **Privacy:** the agent sends only the flagged word and its surrounding sentence to the LLM you configured in Settings; without an LLM nothing leaves the machine. +### The page overview: what every page needs from you + +**Purpose.** The page grid (the *Thumbnails* view of a magazine's contents) shows every page of the issue at once and tells you, +in words, where you still have to act. + +**Steps.** Read the line above the grid: "*N of M pages need you*". Tick **Show only pages that need me** to hide everything the +system has settled. Each card shows, top to bottom: the page, **how many articles are on it**, each article's title with +**"continues from p.X" / "continues on p.Y"** where it flows to another page, a **status** (for example *Text found, not checked yet*, +*Problems found*, *Approved*, *Done: audio is ready*, *Advertisement: not read*), and either a blue **To do** box or "Nothing to do +here". Click the page image to open it in Review. + +**What the to-do lines mean.** *Open the page … and approve it*: detection found text and nobody has confirmed it. *Problems found* +lists what the quality check saw and which tool fixes it (*Find missed text*, *Clean up boxes*, *Spelling*, merge or shrink +overlapping boxes, keep a page in one article only). *Text was found here but it belongs to no article*: link the page to the +article it continues, or start a new one. *Generate the audio*: approved, but no MP3 yet. *N of the M articles here still wait for +approval*: a page shared by several articles is coloured by its most advanced one, so check the others. A small grey note tells you +when a page you approved had problems that the quality check found earlier. + +**Expected result.** When the summary says nothing is left, every page is approved, settled as an advertisement or empty, or has audio. + +**Errors / limits.** The quality-check lines appear only after a quality run (*Auto-fix all pages* or *Quality check*); before that, +pages show the status only. A page with no text and no article is reported as having nothing to do, which is right for a full-page +picture but worth a glance if you expected text. + +**Mobile / accessibility.** Status and to-do are plain text beside the colour, so nothing depends on seeing colours; the legend is 14 px +with large swatches. On a phone use fewer columns (the *Columns* slider) for more readable cards. + ### EPUB with synchronised audio **Purpose.** Take an issue to an e-reader or reading app that highlights the sentence being spoken (EPUB 3 *Media Overlays*). diff --git a/static/js/magazine.js b/static/js/magazine.js index 73d8cd0..027fe23 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -1064,6 +1064,89 @@ function magQualityBadge(q, page) { bits.length + ''; } +// What each page card tells the reader, in words (colour alone is not enough): what state the page is in, +// which articles sit on it and how they flow to neighbouring pages, and what -- if anything -- the reader still has to +// do because the system could not settle it alone. Pure: the card markup only renders what this returns. +const MAG_PAGE_LABELS = { + blank: ['Not looked at yet', 'mdi-help-circle-outline'], + detected: ['Text found, not checked yet', 'mdi-text-search'], + checked: ['Checked: no problems found', 'mdi-check-circle-outline'], + flagged: ['Problems found', 'mdi-alert-circle-outline'], + approved: ['Approved', 'mdi-check-decagram-outline'], + audio: ['Done: audio is ready', 'mdi-volume-high'], + ad: ['Advertisement: not read', 'mdi-bullhorn-outline'], + empty: ['Reviewed: nothing to read', 'mdi-check'], + none: ['No article on this page', 'mdi-file-hidden'], +}; + +// Where an article comes from and goes to around `page`, by its own page list. +function magArticleFlow(article, page) { + const pages = (article?.pages || []).slice().sort((a, b) => a - b); + const i = pages.indexOf(page); + if (i < 0) return { from: null, to: null }; + return { from: i > 0 ? pages[i - 1] : null, to: i < pages.length - 1 ? pages[i + 1] : null }; +} + +function magQualityActions(q) { + const n = (list) => (list || []).length; + const plural = (k, one, many) => k + ' ' + (k === 1 ? one : many); + const out = []; + if (n(q.uncovered)) out.push(plural(n(q.uncovered), 'text area lies', 'text areas lie') + ' outside every box: use "Find missed text"'); + if (n(q.overlaps)) out.push(plural(n(q.overlaps), 'pair of boxes overlaps', 'pairs of boxes overlap') + ': merge, shrink or delete one'); + if (n(q.clipped)) out.push(plural(n(q.clipped), 'box edge cuts', 'box edges cut') + ' into text: use "Clean up boxes"'); + if (n(q.suspect_blocks)) out.push(plural(n(q.suspect_blocks), 'region has', 'regions have') + ' doubtful words: check them with "Spelling"'); + if (n(q.duplicates)) out.push('several articles claim this page: keep it in one of them'); + if (q.error) out.push('the check itself failed: run it again'); + return out; +} + +function magPageGuide({ page, articles, quality, stateName, hasText }) { + const arts = articles || []; + const name = !arts.length && stateName !== 'ad' ? 'none' : stateName; + const [label, icon] = MAG_PAGE_LABELS[name] || MAG_PAGE_LABELS.blank; + const todo = []; + let note = ''; + if (!arts.length) { + if (stateName !== 'ad' && hasText) todo.push('Text was found here but it belongs to no article: open the page and link it to the article it continues, or start a new one'); + } else if (stateName === 'flagged') { + const actions = magQualityActions(quality || {}); + actions.slice(0, 3).forEach((t) => todo.push(t.charAt(0).toUpperCase() + t.slice(1))); + if (actions.length > 3) todo.push('and ' + (actions.length - 3) + ' more: open the page for the full list'); + if (!actions.length) todo.push('Open the page and check what the quality run found'); + todo.push('Then approve the page'); + } else if (stateName === 'detected' || stateName === 'checked') { + todo.push(stateName === 'checked' + ? 'Nothing wrong was found, but nobody has confirmed it: open the page and approve it' + : 'Open the page, check the boxes and approve it'); + } else if (stateName === 'approved' && arts.some((a) => a.status === 'approved' && !a.has_audio)) { + todo.push('Generate the audio (toolbar: "Generate all audio")'); + } + // A page's colour follows its most advanced article ("audio" beats "approved" beats "detected"), so a page can + // look finished while another article on it still waits. Say so whatever the colour is. + const waiting = arts.filter((a) => a.status === 'detected'); + if (arts.length > 1 && waiting.length && stateName !== 'flagged') { + todo.push(waiting.length + ' of the ' + arts.length + ' articles here still wait for approval: open the page and approve ' + (waiting.length === 1 ? 'it' : 'them')); + } + if (arts.some((a) => a.status === 'approved') && quality && quality.needs_review && stateName !== 'flagged') { + const actions = magQualityActions(quality); + if (actions.length) note = 'You approved this page, but the quality check had found: ' + actions.slice(0, 2).join('; '); + } + // Never call a page "Done" while it still asks something of the reader. + const finalLabel = todo.length && name === 'audio' ? 'Partly done: some audio is ready' : label; + return { label: finalLabel, icon, name, todo, needs: todo.length > 0, note }; +} + +function magPageHasText(page) { + const layout = state.layout; + if (!layout) return false; + return (layout.blocks || []).some((b) => b.page === page && MAG_NARRATABLE_TYPES.includes(b.type) && String(b.text || '').trim()); +} + +function magTogglePagesTodo(on) { + state.pagesOnlyTodo = !!on; + renderContents(); +} + function renderPagesView(host) { if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); }); if (state.qualityDocId !== state.doc?.id) magLoadQuality(false); @@ -1080,33 +1163,62 @@ function renderPagesView(host) { ['checked', 'quality check clean'], ['flagged', 'quality check found problems'], ['approved', 'reviewed'], ['ad', 'advertisement (detected, not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'], ]; - let html = '

' + - legend.map(([cls, label]) => '' + label + '').join('') + - '

'; - html += '
'; + // The guide for every page first, so the summary can say how many pages still need the reader. + const guides = new Map(); for (let p = 1; p <= pageCount; p++) { const arts = byPage.get(p) || []; + const st = magPageState(arts, quality.get(p), p); + guides.set(p, { st, arts, guide: magPageGuide({ page: p, articles: arts, quality: quality.get(p), stateName: st, hasText: magPageHasText(p) }) }); + } + const needing = [...guides.values()].filter((g) => g.guide.needs).length; + let html = '
' + + '

' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + ' ' + + 'Each card says what state the page is in, how many articles are on it, and what to do next.

' + + '' + + '

' + + legend.map(([cls, label]) => '' + label + '').join('') + + '

'; + html += '
'; + for (let p = 1; p <= pageCount; p++) { + const { st, arts, guide } = guides.get(p); + if (state.pagesOnlyTodo && !guide.needs) continue; // The thumbnail is the big, easy target: it opens the page in review (in the context of the // first article on it). A separate Review button per article only appears when the page // really holds several, to say which one is meant. const reviewJs = arts.length ? 'magReviewAtPage(\'' + arts[0].id + '\', ' + p + ')' : 'magReviewPage(' + p + ')'; - html += '
' + + const flowChips = (a) => { + const f = magArticleFlow(a, p); + return (f.from ? ' continues from p.' + f.from + '' : '') + + (f.to ? ' continues on p.' + f.to + '' : ''); + }; + html += '
' + '' + - 'Page ' + p + '' + magQualityBadge(quality.get(p), p) + + 'Page ' + p + '' + + '' + (arts.length ? arts.length + (arts.length === 1 ? ' article' : ' articles') : 'no article') + '' + + magQualityBadge(quality.get(p), p) + (arts.length ? '
' + arts.map((a) => '
' + '' + esc(a.title || 'Untitled') + '' + + (flowChips(a) ? '' + flowChips(a) + '' : '') + '' + (arts.length > 1 ? '' : '') + (a.has_audio ? '' : '') + '' + '
').join('') + '
' - : 'no article') + + : '') + + '
' + + ' ' + esc(guide.label) + '' + + (guide.needs + ? '
To do:
    ' + guide.todo.map((t) => '
  • ' + esc(t) + '
  • ').join('') + '
' + : ' Nothing to do here') + + (guide.note ? '' + esc(guide.note) + '' : '') + + '
' + '
'; } + if (state.pagesOnlyTodo && !needing) html += '

No page needs you.

'; html += '
'; host.innerHTML = html; } @@ -5513,6 +5625,9 @@ root.magReocrMagazine = magReocrMagazine; root.magApprove = magApprove; root.magSetStatus = magSetStatus; root.magRead = magRead; +root.magPageGuide = magPageGuide; +root.magArticleFlow = magArticleFlow; +root.magTogglePagesTodo = magTogglePagesTodo; root.magFindSearch = magFindSearch; root.magFindImport = magFindImport; root.magTocPlay = magTocPlay; diff --git a/static/style.css b/static/style.css index 118543d..ba3066d 100644 --- a/static/style.css +++ b/static/style.css @@ -5598,3 +5598,22 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; } .mag-order-bar.is-floating { animation: mag-order-in .18s ease-out; } @keyframes mag-order-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } } } + +/* Page grid: bigger legend, and a status / articles / next-step block on every card */ +.mag-pages-help { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); } +.mag-pages-summary { margin: 0 0 8px; font-size: 15px; } +.mag-pages-filter { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; margin-bottom: 10px; cursor: pointer; } +.mag-pages-filter input { width: 18px; height: 18px; accent-color: var(--accent); } +.mag-pages-help .mag-pages-legend { font-size: 14px; gap: 8px 20px; margin: 0; color: var(--text); } +.mag-pages-help .mag-pages-legend-swatch { width: 18px; height: 18px; border-radius: 4px; } +.mag-card-count { display: block; font-size: 13px; font-weight: 600; color: var(--text); } +.mag-flows { display: flex; flex-direction: column; gap: 2px; } +.mag-flow { font-size: 12px; color: var(--subtext); } +.mag-page-guide { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 13px; line-height: 1.4; } +.mag-page-status { font-weight: 700; display: inline-flex; gap: 6px; align-items: flex-start; } +.mag-page-todo { padding: 6px 8px; border-radius: 6px; background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border-left: 4px solid var(--accent); } +.mag-page-todo ul { margin: 4px 0 0; padding-left: 18px; } +.mag-page-todo li { margin: 2px 0; } +.mag-page-done { color: var(--subtext); } +.mag-page-note { color: var(--subtext); font-size: 12px; } +.mag-card.needs-you { box-shadow: 0 0 0 2px var(--accent); } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index 1d01e9e..8cdd1b4 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -1695,3 +1695,114 @@ test.describe('Hands-free listening', () => { await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true); }); }); + +test.describe('Page cards say what to do', () => { + const guide = (page, args) => page.evaluate((a) => window.magPageGuide(a), args); + + async function openGrid(page, articles, quality) { + await mockBackend(page, { articleStatus: 'approved' }); + await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ + json: { articles, progress: {}, default_voice: '', podcast: { hosts: [] } } })); + if (quality) await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { pages: quality, summary: {} } })); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached' }); + await navTo(page, 's-magazine'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.locator('#mag-contents-view-toggle').click(); + await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible(); + } + + const art = (id, title, pages, status = 'detected', extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra }); + + test('the guide names the state and the next step for every kind of page', async ({ page }) => { + await mockBackend(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached' }); + await navTo(page, 's-magazine'); + const a = (status, has_audio = false) => ({ id: 'x', title: 'T', pages: [3], status, has_audio }); + const flagged = await guide(page, { page: 3, articles: [a('detected')], quality: { needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [['a', 'b'], ['c', 'd']], clipped: [], suspect_blocks: ['s'], duplicates: [] }, stateName: 'flagged', hasText: true }); + expect(flagged.needs).toBe(true); + expect(flagged.label).toBe('Problems found'); + expect(flagged.todo[0]).toMatch(/^1 text area lies outside every box: use "Find missed text"/); + expect(flagged.todo).toContain('2 pairs of boxes overlap: merge, shrink or delete one'); + expect(flagged.todo.at(-1)).toBe('Then approve the page'); + const detected = await guide(page, { page: 3, articles: [a('detected')], quality: null, stateName: 'detected', hasText: true }); + expect(detected.todo[0]).toMatch(/approve it/); + const done = await guide(page, { page: 3, articles: [a('approved', true)], quality: null, stateName: 'audio', hasText: true }); + expect([done.needs, done.label]).toEqual([false, 'Done: audio is ready']); + const noAudio = await guide(page, { page: 3, articles: [a('approved')], quality: null, stateName: 'approved', hasText: true }); + expect(noAudio.todo[0]).toMatch(/Generate the audio/); + const ad = await guide(page, { page: 3, articles: [], quality: null, stateName: 'ad', hasText: false }); + expect([ad.needs, ad.label]).toEqual([false, 'Advertisement: not read']); + const orphan = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: true }); + expect(orphan.needs).toBe(true); + expect(orphan.todo[0]).toMatch(/belongs to no article/); + const empty = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: false }); + expect([empty.needs, empty.label]).toEqual([false, 'No article on this page']); + const shared = await guide(page, { page: 3, articles: [a('detected'), a('approved')], quality: null, stateName: 'detected', hasText: true }); + expect(shared.todo.join(' ')).toMatch(/1 of the 2 articles here still wait/); + const hidden = await guide(page, { page: 3, articles: [a('detected'), a('approved', true)], quality: null, stateName: 'audio', hasText: true }); + expect([hidden.label, hidden.needs]).toEqual(['Partly done: some audio is ready', true]); // colour says audio, one article still waits + expect(hidden.todo.join(' ')).toMatch(/1 of the 2 articles here still wait for approval/); + const noted = await guide(page, { page: 3, articles: [a('approved', true)], quality: { needs_review: true, uncovered: [], overlaps: [['a', 'b']], clipped: [], suspect_blocks: [], duplicates: [] }, stateName: 'audio', hasText: true }); + expect(noted.needs).toBe(false); + expect(noted.note).toMatch(/You approved this page, but the quality check had found/); + }); + + test('how an article flows between pages is computed from its page list', async ({ page }) => { + await mockBackend(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached' }); + await navTo(page, 's-magazine'); + const r = await page.evaluate(() => ({ + first: window.magArticleFlow({ pages: [10, 11, 25] }, 10), mid: window.magArticleFlow({ pages: [10, 11, 25] }, 11), + last: window.magArticleFlow({ pages: [25, 10, 11] }, 25), single: window.magArticleFlow({ pages: [4] }, 4), absent: window.magArticleFlow({ pages: [4] }, 9) })); + expect(r.first).toEqual({ from: null, to: 11 }); + expect(r.mid).toEqual({ from: 10, to: 25 }); + expect(r.last).toEqual({ from: 11, to: null }); + expect(r.single).toEqual({ from: null, to: null }); + expect(r.absent).toEqual({ from: null, to: null }); + }); + + test('every card shows its status, article count, page flow and to-do in words', async ({ page }) => { + await openGrid(page, [ + art('a1', 'Long feature', [3, 4], 'detected'), + art('a2', 'Short piece', [4], 'approved', { has_audio: true }), + ], [{ page: 3, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }]); + const p3 = page.locator('.mag-card-wrap[data-page="3"]'); + await expect(p3.locator('.mag-card-count')).toHaveText('1 article'); + await expect(p3.locator('.mag-page-status')).toContainText('Problems found'); + await expect(p3.locator('.mag-flow')).toHaveText(/continues on p\.4/); + await expect(p3.locator('.mag-page-todo')).toContainText('Find missed text'); + const p4 = page.locator('.mag-card-wrap[data-page="4"]'); + await expect(p4.locator('.mag-card-count')).toHaveText('2 articles'); + await expect(p4.locator('.mag-flow').first()).toHaveText(/continues from p\.3/); + await expect(p4.locator('.mag-page-todo')).toContainText('1 of the 2 articles here still wait for approval'); + const p6 = page.locator('.mag-card-wrap[data-page="6"]'); + await expect(p6.locator('.mag-card-count')).toHaveText('no article'); + await expect(p6.locator('.mag-page-status')).toContainText('No article on this page'); + }); + + test('the summary counts pages that need you and the filter hides the rest', async ({ page }) => { + await openGrid(page, [art('a1', 'Done piece', [3], 'approved', { has_audio: true }), art('a2', 'Open piece', [4], 'detected')], null); + const summary = page.locator('.mag-pages-summary'); + await expect(summary).toContainText(/pages need you/); + const total = await page.locator('.mag-pages-grid .mag-card-wrap').count(); + await page.getByLabel('Show only pages that need me').check(); + const shown = await page.locator('.mag-pages-grid .mag-card-wrap').count(); + expect(shown).toBeLessThan(total); + await expect(page.locator('.mag-card-wrap[data-page="4"]')).toBeVisible(); + await expect(page.locator('.mag-card-wrap[data-page="3"]')).toHaveCount(0); + await page.getByLabel('Show only pages that need me').uncheck(); + await expect(page.locator('.mag-pages-grid .mag-card-wrap')).toHaveCount(total); + }); + + test('the legend is large enough to read', async ({ page }) => { + await openGrid(page, [art('a1', 'X', [3])], null); + const sizes = await page.evaluate(() => ({ + font: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-item')).fontSize), + swatch: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-swatch')).width) })); + expect(sizes.font).toBeGreaterThanOrEqual(14); + expect(sizes.swatch).toBeGreaterThanOrEqual(18); + }); +});