feat(magazine): show page labels, display titles and merge suggestions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
42bb556db9
commit
03ebd9ba35
@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- **Magazine UI shows page labels, display titles and merge suggestions.** The article index (list, playlist, driving-mode Index sheet and driving header) shows `display_title` (falls back to `title`) with the `page_label` ("Seite 33") in smaller muted text, plus small kind badges (advert, photo, contents, imprint, continuation) and an "Audio is out of date: re-render" badge when `audio_stale` is set. Review shows a suggestion box: the proposed merge target with confidence and a collapsible plain-language "Why do we think so?" list, **Merge into previous article** (`POST .../merge-into-previous {into}`; a 409 "proposal changed" refreshes the suggestion; undoable) and **Use suggested title** (`POST .../accept-title`; undoable). Controls are labelled, keyboard operable and 44 px high. Tests: `tests/magazine.spec.js` ("untitled entries", driving index).
|
||||||
- **Untitled magazine entries ("Page 33") are explained, merged on request and given readable titles** (`core/magazine_untitled.py`, `scripts/magazine_untitled.py`). Root cause: the review screen creates a `Page N` stand-in for any page no article covers, and *Approve* without a toggled box saved only the visible page's regions, which cut multi-page features down to one page. On *c't KI Wissen 2026* this left 59 stand-ins, most of them continuation pages. A continuation detector scores each untitled entry against the article before it (lower-case/mid-sentence start, hyphenated break, the narration fusion rule, open ending, adjacent page, column width; a signed-off previous article or the page's own headline counts against). An optional local-LLM check runs only for ambiguous scores. Each proposal comes with a confidence and the evidence. Records whose title repeats the headline of the article they continue are proposed as well. Titles: a deterministic fallback (*Anzeige*, *Bildseite*, *Inhalt*, *Impressum*, *Editorial*, *Textfragment*, the page headline, or the first sentence cut to 8 words), plus optional validated and cached LLM titles in the magazine's language (`magazine_llm_titles`, `--no-llm`). Nothing changes silently. `GET /articles` gains derived `display_title`, `title_source`, `suggested_title`, `page_label`, `untitled_kind`, `merge_suggestion` and `audio_stale`, and `GET /untitled` plus `quality/report → untitled_articles` list everything. Changes happen only through the undoable `POST /articles/{id}/merge-into-previous` and `POST /articles/{id}/accept-title` (never over a real title), `POST /titles/suggest` (suggestion cache only), and the CLI's `--apply` (backup first). A merge keeps reading order, marks the article `audio_stale` (a fresh render clears it) and deletes no audio. See architecture/magazine-ingestion.md, "Untitled entries".
|
- **Untitled magazine entries ("Page 33") are explained, merged on request and given readable titles** (`core/magazine_untitled.py`, `scripts/magazine_untitled.py`). Root cause: the review screen creates a `Page N` stand-in for any page no article covers, and *Approve* without a toggled box saved only the visible page's regions, which cut multi-page features down to one page. On *c't KI Wissen 2026* this left 59 stand-ins, most of them continuation pages. A continuation detector scores each untitled entry against the article before it (lower-case/mid-sentence start, hyphenated break, the narration fusion rule, open ending, adjacent page, column width; a signed-off previous article or the page's own headline counts against). An optional local-LLM check runs only for ambiguous scores. Each proposal comes with a confidence and the evidence. Records whose title repeats the headline of the article they continue are proposed as well. Titles: a deterministic fallback (*Anzeige*, *Bildseite*, *Inhalt*, *Impressum*, *Editorial*, *Textfragment*, the page headline, or the first sentence cut to 8 words), plus optional validated and cached LLM titles in the magazine's language (`magazine_llm_titles`, `--no-llm`). Nothing changes silently. `GET /articles` gains derived `display_title`, `title_source`, `suggested_title`, `page_label`, `untitled_kind`, `merge_suggestion` and `audio_stale`, and `GET /untitled` plus `quality/report → untitled_articles` list everything. Changes happen only through the undoable `POST /articles/{id}/merge-into-previous` and `POST /articles/{id}/accept-title` (never over a real title), `POST /titles/suggest` (suggestion cache only), and the CLI's `--apply` (backup first). A merge keeps reading order, marks the article `audio_stale` (a fresh render clears it) and deletes no audio. See architecture/magazine-ingestion.md, "Untitled entries".
|
||||||
- **Distinct, deterministic face profile per character** (`core/face_profile.py`). The cast of one book looked like one family because most sheets name no facial feature and the image model then draws its default face. Each character now gets a compact `face:` clause (face shape, jaw, nose, eyes, brows, mouth, cheekbones, skin, hair colour and style, facial hair, age signs, a mark) in the Subject of every character prompt: traits the sheet states (German or English) are kept, the rest comes from species/age/gender/culture-filtered option pools, chosen so that the characters of a book are spread apart (greedy max-distance in creation order; adding characters never changes an existing profile; relatives named in `relationships` share a few traits). Owner override: a `visual_face` text on the sheet. On the real books 02/03 the mean pairwise face-descriptor distance goes from 0.09/0.04 to 0.91 (44/68 characters had no face trait at all). See `architecture/character-visual-identity.md`.
|
- **Distinct, deterministic face profile per character** (`core/face_profile.py`). The cast of one book looked like one family because most sheets name no facial feature and the image model then draws its default face. Each character now gets a compact `face:` clause (face shape, jaw, nose, eyes, brows, mouth, cheekbones, skin, hair colour and style, facial hair, age signs, a mark) in the Subject of every character prompt: traits the sheet states (German or English) are kept, the rest comes from species/age/gender/culture-filtered option pools, chosen so that the characters of a book are spread apart (greedy max-distance in creation order; adding characters never changes an existing profile; relatives named in `relationships` share a few traits). Owner override: a `visual_face` text on the sheet. On the real books 02/03 the mean pairwise face-descriptor distance goes from 0.09/0.04 to 0.91 (44/68 characters had no face trait at all). See `architecture/character-visual-identity.md`.
|
||||||
- **Dry run for faces:** `POST /api/character-face-preview {book}` lists every character's face profile (what came from the sheet, what was chosen) with a similarity matrix and before/after score; `POST /api/character-prompt-preview` also returns `face_clause`. Nothing is generated or saved.
|
- **Dry run for faces:** `POST /api/character-face-preview {book}` lists every character's face profile (what came from the sheet, what was chosen) with a similarity matrix and before/after score; `POST /api/character-prompt-preview` also returns `face_clause`. Nothing is generated or saved.
|
||||||
|
|||||||
@ -353,6 +353,12 @@ turned into a podcast discussion between two or three voices you pick.
|
|||||||
switch which one the drawing and editing tools apply to. Confirm the
|
switch which one the drawing and editing tools apply to. Confirm the
|
||||||
boundaries, correct the title, then **Approve**.
|
boundaries, correct the title, then **Approve**.
|
||||||
**Approve** on one page of a multi-page article keeps the blocks of all its pages: only boxes you untick or add change what the article contains.
|
**Approve** on one page of a multi-page article keeps the blocks of all its pages: only boxes you untick or add change what the article contains.
|
||||||
|
|
||||||
|
**Untitled entries ("Page 33") in review.** An entry without a real title shows its page label (for example "Seite 33") and a suggestion box:
|
||||||
|
- *Merge into previous article* appears when the page looks like the continuation of the article before it. Open **Why do we think so?** to read the reasons in plain words and the confidence. Press the button to merge. Use **Undo** in the bar above the page to take it back. If the suggestion changed in the meantime nothing is merged and the suggestion is refreshed.
|
||||||
|
- *Use suggested title* writes the proposed title into the entry (never over a title you set). Undo is offered.
|
||||||
|
- After a merge the article's audio is marked *Audio is out of date: re-render*; render it again from the contents list. No audio file is deleted.
|
||||||
|
Page labels and these badges also appear in the contents list, the playlist and the driving-mode index. Mobile: buttons are full width and 44 px high; keyboard: Tab to the buttons and the details summary, Enter or Space to use them. Privacy: nothing leaves the device beyond the local server.
|
||||||
Optionally, click **"Describe"** under a figure, chart, table or code block
|
Optionally, click **"Describe"** under a figure, chart, table or code block
|
||||||
to have the configured vision engine generate a one- or two-sentence
|
to have the configured vision engine generate a one- or two-sentence
|
||||||
description for listeners who cannot see the image — e.g. what a chart
|
description for listeners who cannot see the image — e.g. what a chart
|
||||||
|
|||||||
@ -895,6 +895,23 @@ async function magReviewPage(pageNumber) {
|
|||||||
magReviewGoMagazinePage(pageNumber);
|
magReviewGoMagazinePage(pageNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An entry without a real title ("Page 33") shows the server's derived title; the label says where it is.
|
||||||
|
function magDisplayTitle(a) { return (a && (a.display_title || a.title)) || 'Untitled'; }
|
||||||
|
function magPageLabelHtml(a, cls) {
|
||||||
|
return a && a.page_label ? '<span class="' + cls + '">' + esc(a.page_label) + '</span>' : '';
|
||||||
|
}
|
||||||
|
const MAG_KIND_LABELS = { advert: 'Advert', photo: 'Photo page', content: 'Contents', imprint: 'Imprint', continuation: 'Continuation' };
|
||||||
|
function magKindBadgeHtml(a) {
|
||||||
|
const k = MAG_KIND_LABELS[a && a.untitled_kind];
|
||||||
|
return k ? '<span class="mag-pill mag-kind-badge" title="Detected kind of this untitled entry">' + esc(k) + '</span>' : '';
|
||||||
|
}
|
||||||
|
function magStaleHtml(a) {
|
||||||
|
return a && a.audio_stale
|
||||||
|
? '<span class="mag-pill warn mag-stale-badge" title="Pages were merged into this article after its audio was made">' +
|
||||||
|
'<span class="mdi mdi-alert-outline" aria-hidden="true"></span> Audio is out of date: re-render</span>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
function renderContents() {
|
function renderContents() {
|
||||||
const host = $('mag-contents');
|
const host = $('mag-contents');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
@ -969,7 +986,7 @@ function renderContents() {
|
|||||||
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
|
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
|
||||||
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
|
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
|
||||||
'onclick="event.stopPropagation()" title="Click to edit the title">' +
|
'onclick="event.stopPropagation()" title="Click to edit the title">' +
|
||||||
esc(a.title || 'Untitled') + '</span>';
|
esc(magDisplayTitle(a)) + '</span>' + magPageLabelHtml(a, 'mag-page-label');
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
bySection.forEach((arts, section) => {
|
bySection.forEach((arts, section) => {
|
||||||
@ -988,6 +1005,7 @@ function renderContents() {
|
|||||||
'<div class="mag-toc-main">' +
|
'<div class="mag-toc-main">' +
|
||||||
titleHtml(a, 'mag-toc-title') +
|
titleHtml(a, 'mag-toc-title') +
|
||||||
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
|
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
|
||||||
|
magKindBadgeHtml(a) + magStaleHtml(a) +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mag-toc-meta">' +
|
'<div class="mag-toc-meta">' +
|
||||||
'<span>' + fmtPages(a.pages) + '</span>' +
|
'<span>' + fmtPages(a.pages) + '</span>' +
|
||||||
@ -1375,7 +1393,7 @@ async function magContentsCommitTitle(el, id) {
|
|||||||
const a = state.articles.find((x) => x.id === id);
|
const a = state.articles.find((x) => x.id === id);
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
const value = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
const value = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; }
|
if (!value || value === (a.title || '') || value === magDisplayTitle(a)) { el.textContent = magDisplayTitle(a); return; }
|
||||||
const previous = a.title;
|
const previous = a.title;
|
||||||
a.title = value;
|
a.title = value;
|
||||||
try {
|
try {
|
||||||
@ -1385,7 +1403,7 @@ async function magContentsCommitTitle(el, id) {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
a.title = previous;
|
a.title = previous;
|
||||||
el.textContent = previous || 'Untitled';
|
el.textContent = magDisplayTitle(a);
|
||||||
say('Could not save the title: ' + e.message, 'err');
|
say('Could not save the title: ' + e.message, 'err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2066,6 +2084,7 @@ function renderReview() {
|
|||||||
'<div id="mag-spell-panel" class="mag-spell-panel" ' + (state.spellOpen ? '' : 'hidden') + '></div>' +
|
'<div id="mag-spell-panel" class="mag-spell-panel" ' + (state.spellOpen ? '' : 'hidden') + '></div>' +
|
||||||
'<div id="mag-issues-panel" class="mag-issues-panel" role="region" aria-label="What is wrong on this page" aria-live="polite"></div>';
|
'<div id="mag-issues-panel" class="mag-issues-panel" role="region" aria-label="What is wrong on this page" aria-live="polite"></div>';
|
||||||
|
|
||||||
|
html += magReviewSuggestHtml(a);
|
||||||
html += '<div class="mag-review-fields">';
|
html += '<div class="mag-review-fields">';
|
||||||
html += '<div class="mag-review-field-row">' +
|
html += '<div class="mag-review-field-row">' +
|
||||||
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
|
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
|
||||||
@ -3956,6 +3975,81 @@ async function magRefineAllFigures() {
|
|||||||
return magRunFigureRefine(null);
|
return magRunFigureRefine(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function magReviewSuggestHtml(a) {
|
||||||
|
const m = a.merge_suggestion;
|
||||||
|
const placeholderTitle = a.title_source === 'suggested' && a.suggested_title;
|
||||||
|
if (!m && !placeholderTitle && !a.audio_stale && !a.untitled_kind) return '';
|
||||||
|
let h = '<section class="mag-suggest" aria-label="Suggestions for this entry">';
|
||||||
|
h += '<p class="mag-suggest-head">' + esc(a.page_label || '') + (a.untitled_kind ? ' ' + magKindBadgeHtml(a) : '') + '</p>';
|
||||||
|
if (a.audio_stale) h += '<p class="mag-suggest-stale" role="status">' + magStaleHtml(a) + '</p>';
|
||||||
|
if (m) {
|
||||||
|
const lvl = m.level === 'high' ? 'high' : 'medium';
|
||||||
|
const pct = Math.round(Number(m.confidence || 0) * 100);
|
||||||
|
h += '<p>This looks like the continuation of <strong>' + esc(m.into_title || 'the article before it') + '</strong> (' +
|
||||||
|
lvl + ' confidence, ' + pct + '%).</p>' +
|
||||||
|
'<details class="mag-suggest-why"><summary>Why do we think so?</summary><ul>' +
|
||||||
|
(m.evidence || []).map((e) => '<li>' + esc(e) + '</li>').join('') + '</ul></details>' +
|
||||||
|
'<button type="button" class="btn-sm mag-suggest-btn" id="mag-merge-prev-btn" onclick="magMergeIntoPrevious()">' +
|
||||||
|
'<span class="mdi mdi-call-merge" aria-hidden="true"></span> Merge into previous article</button> ';
|
||||||
|
}
|
||||||
|
if (placeholderTitle) {
|
||||||
|
h += '<button type="button" class="btn-sm mag-suggest-btn" id="mag-accept-title-btn" onclick="magAcceptSuggestedTitle()">' +
|
||||||
|
'<span class="mdi mdi-check" aria-hidden="true"></span> Use suggested title</button> ' +
|
||||||
|
'<span class="muted">“' + esc(a.suggested_title) + '”</span>';
|
||||||
|
}
|
||||||
|
return h + '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function magReselectArticle(id) {
|
||||||
|
await magRefreshArticles();
|
||||||
|
const next = (state.articles || []).find((x) => x.id === id);
|
||||||
|
if (next) { state.article = next; _magReviewChecked = null; }
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function magMergeIntoPrevious() {
|
||||||
|
const a = state.article;
|
||||||
|
if (!a || !a.merge_suggestion) return;
|
||||||
|
const btn = $('mag-merge-prev-btn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/merge-into-previous', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ into: a.merge_suggestion.into }),
|
||||||
|
});
|
||||||
|
await magRefreshArticles();
|
||||||
|
const target = (state.articles || []).find((x) => x.id === (r.article && r.article.id)) || (state.articles || [])[0];
|
||||||
|
if (target) await magReview(target.id); else magShow('contents');
|
||||||
|
magOfferUndo(r.undo, 'Merged ' + (a.page_label || 'the page') + ' into ' + magDisplayTitle(target || {}));
|
||||||
|
say('Merged. Audio of the target is out of date: re-render it.', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
if (/proposal changed|No merge is proposed/i.test(String(e.message))) {
|
||||||
|
await magReselectArticle(a.id);
|
||||||
|
renderReview();
|
||||||
|
return say('The proposal changed, so nothing was merged. The suggestion is refreshed.', 'err');
|
||||||
|
}
|
||||||
|
say('Could not merge: ' + e.message, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function magAcceptSuggestedTitle() {
|
||||||
|
const a = state.article;
|
||||||
|
if (!a) return;
|
||||||
|
try {
|
||||||
|
const r = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/accept-title', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
||||||
|
});
|
||||||
|
await magReselectArticle(a.id);
|
||||||
|
renderReview();
|
||||||
|
magOfferUndo(r.undo, 'Title set to "' + ((r.article && r.article.title) || '') + '"');
|
||||||
|
} catch (e) {
|
||||||
|
await magReselectArticle(a.id);
|
||||||
|
renderReview();
|
||||||
|
say('Could not use the suggested title: ' + e.message, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function magApprove() {
|
async function magApprove() {
|
||||||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||||||
const a = state.article;
|
const a = state.article;
|
||||||
@ -5082,12 +5176,12 @@ function renderPlaylistTracks() {
|
|||||||
const duration = a.audio_seconds || progress.duration || a.estimated_seconds;
|
const duration = a.audio_seconds || progress.duration || a.estimated_seconds;
|
||||||
return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' +
|
return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' +
|
||||||
'<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' +
|
'<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' +
|
||||||
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi ' +
|
'aria-label="Play ' + esc(magDisplayTitle(a)) + '"><span class="mdi ' +
|
||||||
(active ? 'mdi-volume-high' : completed ? 'mdi-replay' : 'mdi-play') + '" aria-hidden="true"></span></button>' +
|
(active ? 'mdi-volume-high' : completed ? 'mdi-replay' : 'mdi-play') + '" aria-hidden="true"></span></button>' +
|
||||||
'<span class="mag-playlist-track-page">' + esc(fmtPages(a.pages)) + '</span>' +
|
'<span class="mag-playlist-track-page">' + esc(fmtPages(a.pages)) + '</span>' +
|
||||||
'<span class="mag-playlist-track-main">' +
|
'<span class="mag-playlist-track-main">' +
|
||||||
'<span class="mag-playlist-track-title">' + esc(a.title || 'Untitled') + '</span>' +
|
'<span class="mag-playlist-track-title">' + esc(magDisplayTitle(a)) + '</span>' +
|
||||||
(a.author ? '<span class="mag-playlist-track-sub muted">by ' + esc(a.author) + '</span>' : '') +
|
((a.page_label || a.author) ? '<span class="mag-playlist-track-sub muted">' + esc([a.page_label, a.author ? 'by ' + a.author : ''].filter(Boolean).join(' · ')) + '</span>' : '') +
|
||||||
'</span>' +
|
'</span>' +
|
||||||
'<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' +
|
'<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' +
|
||||||
'<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' +
|
'<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' +
|
||||||
@ -5430,7 +5524,7 @@ function magPlaylistPlay(articleId, options) {
|
|||||||
}
|
}
|
||||||
const titleEl = $('mag-playlist-title');
|
const titleEl = $('mag-playlist-title');
|
||||||
const subEl = $('mag-playlist-sub');
|
const subEl = $('mag-playlist-sub');
|
||||||
if (titleEl) titleEl.textContent = a.title || 'Untitled';
|
if (titleEl) titleEl.textContent = magDisplayTitle(a);
|
||||||
if (subEl) subEl.textContent = (a.author ? 'by ' + a.author + ' · ' : '') + fmtPages(a.pages);
|
if (subEl) subEl.textContent = (a.author ? 'by ' + a.author + ' · ' : '') + fmtPages(a.pages);
|
||||||
const cover = $('mag-playlist-cover');
|
const cover = $('mag-playlist-cover');
|
||||||
if (cover) {
|
if (cover) {
|
||||||
@ -5870,9 +5964,9 @@ function renderDriving() {
|
|||||||
const a = state.article;
|
const a = state.article;
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
const titleEl = $('mag-driving-title');
|
const titleEl = $('mag-driving-title');
|
||||||
if (titleEl) titleEl.textContent = a.title || 'Untitled';
|
if (titleEl) titleEl.textContent = magDisplayTitle(a);
|
||||||
const byEl = $('mag-driving-by');
|
const byEl = $('mag-driving-by');
|
||||||
if (byEl) byEl.textContent = a.author ? 'By ' + a.author : '';
|
if (byEl) byEl.textContent = [a.page_label, a.author ? 'By ' + a.author : ''].filter(Boolean).join(' · ');
|
||||||
syncDrivingPlayButton();
|
syncDrivingPlayButton();
|
||||||
const qEl = $('mag-driving-queue');
|
const qEl = $('mag-driving-queue');
|
||||||
if (qEl) {
|
if (qEl) {
|
||||||
@ -6464,8 +6558,8 @@ function magDrivingIndexRender() {
|
|||||||
'<button type="button" class="ix-play" data-id="' + id + '"' + dis + ' aria-label="' + esc(magIndexPlayLabel(ls, a)) + '">' +
|
'<button type="button" class="ix-play" data-id="' + id + '"' + dis + ' aria-label="' + esc(magIndexPlayLabel(ls, a)) + '">' +
|
||||||
'<span class="mdi ' + magIndexPlayIcon(ls) + '" aria-hidden="true"></span></button>' +
|
'<span class="mdi ' + magIndexPlayIcon(ls) + '" aria-hidden="true"></span></button>' +
|
||||||
'<button type="button" class="ix-row" data-id="' + id + '"' + dis + '>' +
|
'<button type="button" class="ix-row" data-id="' + id + '"' + dis + '>' +
|
||||||
'<span class="mdi-num">' + (n + 1) + '</span><span class="ix-main"><span class="ix-title">' + esc(a.title || 'Untitled') + '</span>' +
|
'<span class="mdi-num">' + (n + 1) + '</span><span class="ix-main"><span class="ix-title">' + esc(magDisplayTitle(a)) + '</span>' +
|
||||||
(a.author ? '<span class="ix-author">by ' + esc(a.author) + '</span>' : '') + '</span>' +
|
((a.page_label || a.author) ? '<span class="ix-author ix-page">' + esc([a.page_label, a.author ? 'by ' + a.author : ''].filter(Boolean).join(' · ')) + '</span>' : '') + '</span>' +
|
||||||
'<span class="ix-dur">' + esc(dur) + '</span>' +
|
'<span class="ix-dur">' + esc(dur) + '</span>' +
|
||||||
'<span class="ix-status" role="img" aria-label="' + ls.label + '"><span class="mdi ' + ls.statusIcon + '" aria-hidden="true"></span></span></button></li>';
|
'<span class="ix-status" role="img" aria-label="' + ls.label + '"><span class="mdi ' + ls.statusIcon + '" aria-hidden="true"></span></span></button></li>';
|
||||||
}).join('');
|
}).join('');
|
||||||
@ -6473,7 +6567,7 @@ function magDrivingIndexRender() {
|
|||||||
}
|
}
|
||||||
function magIndexPlayIcon(ls) { return ls.completed ? 'mdi-replay' : ls.started ? 'mdi-restore' : 'mdi-play'; }
|
function magIndexPlayIcon(ls) { return ls.completed ? 'mdi-replay' : ls.started ? 'mdi-restore' : 'mdi-play'; }
|
||||||
function magIndexPlayLabel(ls, a) {
|
function magIndexPlayLabel(ls, a) {
|
||||||
return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + (a.title || 'article');
|
return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + magDisplayTitle(a);
|
||||||
}
|
}
|
||||||
// Live update of the three states (class, icons, accessible text) without re-rendering the 100+ rows.
|
// Live update of the three states (class, icons, accessible text) without re-rendering the 100+ rows.
|
||||||
function magDrivingIndexRefresh() {
|
function magDrivingIndexRefresh() {
|
||||||
@ -6734,6 +6828,8 @@ root.magContentsCommitTitle = magContentsCommitTitle;
|
|||||||
root.magGenerateAllAudio = magGenerateAllAudio;
|
root.magGenerateAllAudio = magGenerateAllAudio;
|
||||||
root.magTogglePlaylist = magTogglePlaylist;
|
root.magTogglePlaylist = magTogglePlaylist;
|
||||||
root.magReviewPage = magReviewPage;
|
root.magReviewPage = magReviewPage;
|
||||||
|
root.magMergeIntoPrevious = magMergeIntoPrevious;
|
||||||
|
root.magAcceptSuggestedTitle = magAcceptSuggestedTitle;
|
||||||
root.magCommitDocField = magCommitDocField;
|
root.magCommitDocField = magCommitDocField;
|
||||||
root.magSetPagesCols = magSetPagesCols;
|
root.magSetPagesCols = magSetPagesCols;
|
||||||
root.magPlaylistPlay = magPlaylistPlay;
|
root.magPlaylistPlay = magPlaylistPlay;
|
||||||
|
|||||||
@ -5972,3 +5972,20 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night
|
|||||||
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button:disabled { opacity: 1; color: var(--dim); }
|
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button:disabled { opacity: 1; color: var(--dim); }
|
||||||
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button { background: var(--surface); }
|
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button { background: var(--surface); }
|
||||||
.mag-driving[data-dtheme="dark"] .mag-driving-seek input[type="range"] { accent-color: var(--accent); }
|
.mag-driving[data-dtheme="dark"] .mag-driving-seek input[type="range"] { accent-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Untitled entries: page labels, kind/stale badges and the merge/title suggestion in review */
|
||||||
|
.mag-page-label { display: block; font-size: 12px; font-weight: 400; color: var(--subtext); }
|
||||||
|
.mag-kind-badge, .mag-stale-badge { margin-left: 8px; font-size: 12px; }
|
||||||
|
.mag-stale-badge { white-space: normal; }
|
||||||
|
.mag-driving-index-list .ix-page { font-variant-numeric: tabular-nums; }
|
||||||
|
.mag-suggest { border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius, 8px);
|
||||||
|
padding: 10px 12px; margin: 0 0 12px; background: var(--surface); color: var(--text); max-width: 100%; overflow-wrap: anywhere; }
|
||||||
|
.mag-suggest p { margin: 0 0 8px; }
|
||||||
|
.mag-suggest-head { font-size: 13px; color: var(--subtext); }
|
||||||
|
.mag-suggest-why { margin: 0 0 8px; }
|
||||||
|
.mag-suggest-why summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; }
|
||||||
|
.mag-suggest-why ul { margin: 0 0 4px; padding-left: 20px; }
|
||||||
|
.mag-suggest .mag-suggest-btn { min-height: 44px; min-width: 44px; margin: 0 8px 8px 0; }
|
||||||
|
.mag-suggest summary:focus-visible, .mag-suggest .mag-suggest-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||||
|
@media (max-width: 480px) { .mag-suggest .mag-suggest-btn { width: 100%; justify-content: center; } }
|
||||||
|
.mag-stale-badge.mag-pill { color: var(--text); background: rgba(217,119,6,.16); border: 1px solid var(--yellow); }
|
||||||
|
|||||||
@ -3365,6 +3365,29 @@ test.describe('Driving mode layout and views', () => {
|
|||||||
: THREE.map((a) => ({ ...base, ...a, ...(extra.authors && a.id in extra.authors ? { author: extra.authors[a.id] } : {}) }));
|
: THREE.map((a) => ({ ...base, ...a, ...(extra.authors && a.id in extra.authors ? { author: extra.authors[a.id] } : {}) }));
|
||||||
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: extra.progress || {}, default_voice: '', podcast: { hosts: [] } } }));
|
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: extra.progress || {}, default_voice: '', podcast: { hosts: [] } } }));
|
||||||
}
|
}
|
||||||
|
test('index: shows the derived title with its page label and keeps 44px hit areas and 1:1 circles', async ({ page }) => {
|
||||||
|
await setupIndex(page);
|
||||||
|
const base = { ...ARTICLE_APPROVED, status: 'approved', has_audio: true, audio_seconds: 30 };
|
||||||
|
const arts = [
|
||||||
|
{ ...base, id: 'article-001', title: 'The Future of Batteries', display_title: 'The Future of Batteries', page_label: 'Page 1', pages: [1] },
|
||||||
|
{ ...base, id: 'article-002', title: 'Page 33', display_title: 'Anzeige', title_source: 'suggested', page_label: 'Seite 33', pages: [33] },
|
||||||
|
];
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||||||
|
await page.setViewportSize({ width: 320, height: 568 });
|
||||||
|
await open(page);
|
||||||
|
await page.locator('#mag-driving-indexbtn').click();
|
||||||
|
const row = page.locator('#mag-driving-index-list .ix-item').nth(1);
|
||||||
|
await expect(row.locator('.ix-title')).toHaveText('Anzeige');
|
||||||
|
await expect(row.locator('.ix-page')).toContainText('Seite 33');
|
||||||
|
const g = await page.evaluate(() => ({
|
||||||
|
plays: [...document.querySelectorAll('#mag-driving-index-list .ix-play')].map((e) => { const b = e.getBoundingClientRect(); return [b.width, b.height]; }),
|
||||||
|
rows: [...document.querySelectorAll('#mag-driving-index-list .ix-row')].map((e) => e.getBoundingClientRect().height),
|
||||||
|
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
for (const [w, h] of g.plays) { expect(w).toBeGreaterThanOrEqual(44); expect(Math.abs(w - h)).toBeLessThan(1); }
|
||||||
|
for (const h of g.rows) expect(h).toBeGreaterThanOrEqual(44);
|
||||||
|
expect(g.overflow).toBe(false);
|
||||||
|
});
|
||||||
test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => {
|
test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => {
|
||||||
await setupIndex(page);
|
await setupIndex(page);
|
||||||
await page.setViewportSize({ width: 375, height: 667 });
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
@ -4119,3 +4142,82 @@ test.describe('Magazine: approving a multi-page article page by page', () => {
|
|||||||
expect(posts).toBe(0);
|
expect(posts).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test.describe('Magazine: untitled entries (labels, merge suggestion, titles, stale audio)', () => {
|
||||||
|
const mk = (id, pg, y) => ({ id, page: pg, bbox: [50, y, 200, 40], type: 'body', text: 'Text ' + id + '. More words here.', reading_order: 0, confidence: 0.9, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' });
|
||||||
|
const BLOCKS = [mk('t1', 3, 60), mk('c1', 4, 60)];
|
||||||
|
const REAL = { id: 'article-001', title: 'Real Story', display_title: 'Real Story', title_source: 'title', page_label: 'Seite 3', author: '', blocks: ['t1'], images: [], status: 'approved', confidence: 0.9, section: '', pages: [3], has_audio: true, audio_seconds: 5, audio_stale: false, untitled_kind: null, merge_suggestion: null };
|
||||||
|
const STAND = { id: 'article-002', title: 'Page 4', display_title: 'Weiter im Text', title_source: 'suggested', suggested_title: 'Weiter im Text', page_label: 'Seite 4', author: '', blocks: ['c1'], images: [], status: 'detected', confidence: 0.5, section: '', pages: [4], has_audio: false, audio_stale: false, untitled_kind: 'continuation',
|
||||||
|
merge_suggestion: { into: 'article-001', into_title: 'Real Story', level: 'high', confidence: 0.91, evidence: ['starts in lower case', 'previous article ends mid-sentence'] } };
|
||||||
|
|
||||||
|
async function setup(page, { articles = [REAL, STAND], onMerge, onAccept } = {}) {
|
||||||
|
await mockBackend(page);
|
||||||
|
let list = articles.map((a) => ({ ...a }));
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/layout`, r => r.fulfill({ json: { ...LAYOUT, page_count: 4, pages: [3, 4].map((p) => ({ page: p, width: 595, height: 842 })), blocks: BLOCKS } }));
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/articles`, r => r.fulfill({ json: { articles: list, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*`, r => r.fulfill({ json: list[0] }));
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*/merge-into-previous`, r => onMerge ? onMerge(r, (n) => { list = n; }) : r.fulfill({ json: { ok: true, undo: 'tok1', article: { ...REAL, audio_stale: true } } }));
|
||||||
|
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*/accept-title`, r => onAccept ? onAccept(r, (n) => { list = n; }) : r.fulfill({ json: { ok: true, undo: 'tok2', article: { ...STAND, title: 'Weiter im Text' } } }));
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||||
|
await navTo(page, 's-magazine');
|
||||||
|
await page.locator('#mag-library-list .mag-card').first().click();
|
||||||
|
}
|
||||||
|
const reviewStandIn = async (page) => {
|
||||||
|
await page.locator('.mag-toc-row').filter({ hasText: 'Weiter im Text' }).getByRole('button', { name: 'Review', exact: true }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
test('the article index shows display titles with page labels and the stale hint', async ({ page }) => {
|
||||||
|
await setup(page, { articles: [{ ...REAL, audio_stale: true }, STAND] });
|
||||||
|
const rows = page.locator('.mag-toc-row');
|
||||||
|
await expect(rows.nth(1).locator('.mag-toc-title')).toHaveText('Weiter im Text');
|
||||||
|
await expect(rows.nth(1).locator('.mag-page-label')).toHaveText('Seite 4');
|
||||||
|
await expect(rows.nth(1).locator('.mag-kind-badge')).toBeVisible();
|
||||||
|
await expect(rows.nth(0).locator('.mag-stale-badge')).toContainText('Audio is out of date: re-render');
|
||||||
|
await expect(rows.nth(1).locator('.mag-stale-badge')).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||||
|
// the play button of a row with audio keeps its hit area
|
||||||
|
const b = await rows.nth(0).locator('.mag-toc-play').boundingBox();
|
||||||
|
expect(b.width).toBeGreaterThanOrEqual(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('review explains the merge suggestion and posts the proposed target', async ({ page }) => {
|
||||||
|
let posted = null;
|
||||||
|
await setup(page, { onMerge: (r) => { posted = r.request().postDataJSON(); return r.fulfill({ json: { ok: true, undo: 'tok1', article: { ...REAL, audio_stale: true } } }); } });
|
||||||
|
await reviewStandIn(page);
|
||||||
|
const box = page.locator('.mag-suggest');
|
||||||
|
await expect(box).toContainText('Real Story');
|
||||||
|
await expect(box).toContainText('high confidence');
|
||||||
|
await box.locator('summary').click();
|
||||||
|
await expect(box.locator('li')).toHaveCount(2);
|
||||||
|
const btn = page.getByRole('button', { name: 'Merge into previous article' });
|
||||||
|
expect((await btn.boundingBox()).height).toBeGreaterThanOrEqual(44);
|
||||||
|
await btn.click();
|
||||||
|
await expect.poll(() => posted).toEqual({ into: 'article-001' });
|
||||||
|
await expect(page.locator('#mag-undo-bar')).toContainText('Undo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 409 on merge refreshes the suggestion and merges nothing', async ({ page }) => {
|
||||||
|
await setup(page, { onMerge: (r) => r.fulfill({ status: 409, json: { detail: 'The proposal changed: it now merges into article-009' } }) });
|
||||||
|
await reviewStandIn(page);
|
||||||
|
await page.getByRole('button', { name: 'Merge into previous article' }).click();
|
||||||
|
await expect(page.locator('#toast')).toContainText('proposal changed');
|
||||||
|
await expect(page.getByRole('button', { name: 'Merge into previous article' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Use suggested title posts accept-title and offers undo', async ({ page }) => {
|
||||||
|
let hit = 0;
|
||||||
|
await setup(page, { onAccept: (r) => { hit++; return r.fulfill({ json: { ok: true, undo: 'tok2', article: { ...STAND, title: 'Weiter im Text' } } }); } });
|
||||||
|
await reviewStandIn(page);
|
||||||
|
await page.getByRole('button', { name: 'Use suggested title' }).click();
|
||||||
|
await expect.poll(() => hit).toBe(1);
|
||||||
|
await expect(page.locator('#mag-undo-bar')).toContainText('Title set to');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('review shows the out-of-date audio hint', async ({ page }) => {
|
||||||
|
await setup(page, { articles: [{ ...REAL, audio_stale: true, merge_suggestion: null }, STAND] });
|
||||||
|
await page.locator('.mag-toc-row').filter({ hasText: 'Real Story' }).getByRole('button', { name: 'Review', exact: true }).click();
|
||||||
|
await expect(page.locator('.mag-suggest-stale')).toContainText('Audio is out of date: re-render');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user