Release v1.51.0
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-22 23:23:46 +02:00
parent 5b0fc1cb65
commit c453295ab0
5 changed files with 98 additions and 18 deletions

View File

@ -9,6 +9,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.51.0] — 2026-09-22
### Fixed
- **Magazine: reviewing a page-coverage gap borrowed an unrelated article as context.** Approve or "Not an article" on that page would have silently changed the status of whatever nearby article was borrowed, not said anything about the page actually being looked at. A gap page now gets its own dedicated article record (`POST /api/magazine/{doc_id}/articles`) instead.
- **Magazine: "Approve" (now "Approve & Next") jumps to the next unresolved page, not the next article.** Advancing by article order alone was exactly what left page-coverage gaps invisible to this workflow in the first place; it now walks forward page by page and stops at whichever needs a decision next — an unreviewed article, or a page nothing covers yet — reusing the same one-click flow as the coverage-gap banner either way.
---
## [1.50.0] — 2026-09-22
### Added
@ -4393,7 +4401,8 @@ Initial feature-complete release.
---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.50.0...HEAD
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.51.0...HEAD
[1.51.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.50.0...v1.51.0
[1.50.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.49.0...v1.50.0
[1.49.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.48.2...v1.49.0
[1.48.2]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.48.1...v1.48.2

View File

@ -1 +1 @@
1.50.0
1.51.0

View File

@ -456,6 +456,36 @@ async def magazine_layout(doc_id: str, page: int | None = None):
return layout
def _next_article_id(articles: list[dict]) -> str:
nums = [int(m.group(1)) for a in articles
if (m := re.fullmatch(r"article-(\d+)", a.get("id") or ""))]
return f"article-{(max(nums) + 1) if nums else 1:03d}"
@router.post("/api/magazine/{doc_id}/articles")
async def magazine_create_article(doc_id: str, request: Request):
"""A new, otherwise-empty article -- for a page no detected article
covers. Reviewing that page previously had to borrow a nearby unrelated
article as context, so Approve or "Not an article" would have silently
changed THAT article's status instead of saying anything about the page
actually being looked at. This gives the page its own record instead.
"""
d, _ = _require_magazine(doc_id)
body = await request.json() if await request.body() else {}
page = body.get("page")
if page is not None and not isinstance(page, int):
raise HTTPException(400, "page must be an integer")
articles = mag.load_articles(d)
article = mag.Article(
id=_next_article_id(articles),
title=str(body.get("title") or "").strip(),
pages=[page] if page else [],
).to_json()
articles.append(article)
mag.save_articles(d, articles)
return {"article": article}
@router.get("/api/magazine/{doc_id}/articles")
async def magazine_articles(doc_id: str):
d, meta = _require_magazine(doc_id)

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.50.0">
<meta name="app-version" content="1.51.0">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.50.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.50.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.50.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.50.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.50.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.50.0">
<link rel="stylesheet" href="/static/style.css?v=1.51.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.51.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.51.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.51.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.51.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.51.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.50.0"></script>
<script src="/static/loader.js?v=1.51.0"></script>
</body>
</html>

View File

@ -643,6 +643,24 @@ function magUncoveredPageRanges() {
return gaps;
}
// The next page after `afterPage` that still needs a human decision -- no
// article covers it at all, or one does but is still sitting at its
// detected/unreviewed status. A page touched by several articles only
// counts as resolved once none of them are still pending.
function magNextUnresolvedPage(afterPage) {
const pageCount = state.doc?.pageCount || 0;
const byPage = new Map();
state.articles.forEach((a) => (a.pages || []).forEach((p) => {
if (!byPage.has(p)) byPage.set(p, []);
byPage.get(p).push(a);
}));
for (let p = afterPage + 1; p <= pageCount; p++) {
const arts = byPage.get(p) || [];
if (!arts.length || arts.some((a) => a.status === 'detected')) return p;
}
return null;
}
function renderCoverageBanner() {
const el = $('mag-coverage-banner');
if (!el) return;
@ -665,10 +683,24 @@ function renderCoverageBanner() {
// review UI needs *an* article as context, but its own page navigation is
// not restricted to that article's pages) and then jumping.
async function magReviewPage(pageNumber) {
if (!state.articles.length) return;
const anchor = state.articles.slice().sort((a, b) =>
Math.abs((a.pages?.[0] ?? 0) - pageNumber) - Math.abs((b.pages?.[0] ?? 0) - pageNumber))[0];
await magReview(anchor.id);
// Reuse whatever already covers this page if something does (idempotent
// if called twice for the same page); otherwise give the page its own
// article record rather than borrowing an unrelated one as review
// context -- confirmed live: Approve and "Not an article" both act on
// whatever article is open, so borrowing one meant either action would
// have silently changed a completely different, already-fine article.
let article = state.articles.find((a) => (a.pages || []).includes(pageNumber));
if (!article) {
try {
const r = await api('/api/magazine/' + state.doc.id + '/articles', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page: pageNumber, title: 'Page ' + pageNumber }),
});
article = r.article;
state.articles.push(article);
} catch (e) { return say('Could not open page ' + pageNumber + ': ' + e.message, 'err'); }
}
await magReview(article.id);
magReviewGoMagazinePage(pageNumber);
}
@ -1433,7 +1465,9 @@ function renderReview() {
html += '<div class="mag-review-foot">' +
'<button type="button" class="btn" onclick="magShow(\'contents\')">Back to contents</button>' +
'<button type="button" class="btn" onclick="magSetStatus(\'rejected\')">Not an article</button>' +
'<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' +
'<button type="button" class="btn primary" onclick="magApprove()" ' +
'title="Approve this article, then jump straight to the next page that still needs a decision -- an unreviewed article, or a page nothing covers yet">' +
'Approve &amp; Next</button>' +
'</div>';
host.innerHTML = html;
if (state.orderMode) {
@ -2709,10 +2743,17 @@ async function magApprove() {
// the list meant re-finding and re-opening whichever article came next
// after every single approval. Go straight to it instead, and only fall
// back to the list once nothing is left to review.
const idx = state.articles.findIndex((x) => x.id === a.id);
const nextId = idx >= 0 ? state.articles[idx + 1]?.id : null;
// Next PAGE needing attention, not next article -- an article can span
// many pages or share one with others, and jumping by article order
// alone is exactly what left pages uncovered by any article invisible
// to this whole workflow (see magUncoveredPageRanges). Walking forward
// page by page and reusing magReviewPage's "create a stub if nothing
// covers it" logic means a gap gets exactly the same one-click
// attention as an already-detected-but-unreviewed article.
const lastPage = Math.max(0, ...(a.pages || []));
await magRefreshArticles();
if (nextId && state.articles.some((x) => x.id === nextId)) magReview(nextId);
const nextPage = magNextUnresolvedPage(lastPage);
if (nextPage) magReviewPage(nextPage);
else magShow('contents');
} catch (e) {
say('Could not approve: ' + e.message, 'err');