This commit is contained in:
parent
5b0fc1cb65
commit
c453295ab0
11
CHANGELOG.md
11
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 & 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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user