From 31b13a4e162a88dbdb8f5d07a6518d1fc862b34f Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 9 Sep 2026 17:36:27 +0200 Subject: [PATCH] feat: improve magazine review navigation --- CHANGELOG.md | 18 +++++++ VERSION | 2 +- core/magazine.py | 34 +++++++++++++ routes/magazine.py | 21 ++++++++ static/js/magazine.js | 113 ++++++++++++++++++++++++++++++++++++------ static/style.css | 10 +++- 6 files changed, 182 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e835e..cc8317c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.26.6] — 2026-09-09 + +### Added +- **Safer Magazine Deletion.** Each magazine card now offers a confirmed delete action that removes its source PDF, + OCR/layout data, articles and generated assets together. +- **Rectangle-to-Metadata Extraction.** In article review, **Pick from page** beside Title and Author OCRs a drawn + rectangle directly into that metadata field without adding it to narratable article content. +- **Whole-Issue Page Navigation.** Review now has a separate **Magazine p. N / total** navigator, allowing every + PDF page to be inspected across article boundaries while keeping the article-only navigator available. + +### Changed +- **Review Selection Controls Are Clearer.** **Draw image** is now **Draw graphic**, and **Clear all fields** resets + all selected text and graphics on the current page for clean manual article-boundary review. +- **Headline Detection Is Not Pretended to Be a Percentage.** The fixed 0.55 fallback now displays as + **headline detected** rather than a misleading repeated `55%` confidence badge. + +--- + ## [1.26.5] — 2026-09-09 ### Fixed diff --git a/VERSION b/VERSION index 8fe00a5..f250c8f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.26.5 +1.26.6 diff --git a/core/magazine.py b/core/magazine.py index 38ee4f5..0ab9123 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -32,6 +32,7 @@ import math import re import statistics import subprocess +import tempfile import unicodedata import requests @@ -1860,6 +1861,39 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int, return layout +def extract_region_text(doc_dir: Path, layout: dict[str, Any], page_no: int, + bbox: Sequence[float], vision_ocr_url: str | None = None, + vision_ocr_model: str | None = None, + vision_ocr_fallback_url: str | None = None, + vision_ocr_fallback_model: str | None = None, + timeout: float = 150.0) -> str: + """OCR a user-selected rectangle without changing the saved layout. + + Used for metadata fields such as article title/author: unlike a manual + region, the selection is a one-off extraction and must not become prose + that is later narrated. + """ + page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None) + if not page_meta or len(bbox) != 4 or bbox[2] <= 0 or bbox[3] <= 0: + raise ValueError("Invalid page selection") + with tempfile.NamedTemporaryFile(prefix=f".extract-{page_no}-", suffix=".png", + dir=doc_dir / "pages", delete=False) as tmp: + crop_path = Path(tmp.name) + try: + _crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path) + regions = None + if vision_ocr_url and vision_ocr_model: + regions = vision_ocr_page_chain(crop_path, [ + (vision_ocr_url, vision_ocr_model), + (vision_ocr_fallback_url, vision_ocr_fallback_model), + ], timeout=timeout) + if regions: + return " ".join(r.get("text", "") for r in regions if r.get("text")).strip() + return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip() + finally: + crop_path.unlink(missing_ok=True) + + def save_layout(doc_dir: Path, layout: dict[str, Any]) -> None: (doc_dir / "layout.json").write_text( json.dumps(layout, ensure_ascii=False), "utf-8") diff --git a/routes/magazine.py b/routes/magazine.py index 7b0bf6b..ca91f71 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -686,6 +686,27 @@ async def magazine_add_region(doc_id: str, page: int, request: Request): return {"job_id": job_id} +@router.post("/api/magazine/{doc_id}/pages/{page}/extract") +async def magazine_extract_region(doc_id: str, page: int, request: Request): + """OCR a selected page rectangle for a metadata field without saving it.""" + d, _ = _require_magazine(doc_id) + body = await request.json() + bbox = body.get("bbox") + if page < 1 or not (isinstance(bbox, list) and len(bbox) == 4 + and all(isinstance(v, (int, float)) for v in bbox)): + raise HTTPException(400, "bbox must be [x, y, width, height]") + layout = mag.load_layout(d) + settings = _load_settings() + text = await asyncio.to_thread( + mag.extract_region_text, d, layout, page, [float(v) for v in bbox], + str(settings.get("vision_ocr_url") or "").strip() or None, + str(settings.get("vision_ocr_model") or "").strip() or None, + str(settings.get("vision_ocr_fallback_url") or "").strip() or None, + str(settings.get("vision_ocr_fallback_model") or "").strip() or None, + ) + return {"text": text} + + @router.get("/api/magazine/{doc_id}/images/{name}") async def magazine_image(doc_id: str, name: str): d, _ = _require_magazine(doc_id) diff --git a/static/js/magazine.js b/static/js/magazine.js index 3ffb8f1..c423dcb 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -145,12 +145,33 @@ function renderLibrary() { const thumbnail = 'First page of ' + esc(d.title || 'magazine') +
       ''; - return ''; + '' + esc(d.issue || '') + '' + badge + '' + + ''; }).join(''); } +async function magDelete(docId) { + const doc = state.docs.find((item) => item.id === docId); + const title = doc?.title || 'this magazine'; + if (!window.confirm(`Delete “${title}”? This permanently removes its PDF, OCR, articles, and generated files.`)) return; + try { + await api('/api/reader/docs/' + encodeURIComponent(docId), { method: 'DELETE' }); + if (state.doc?.id === docId) { + magForgetLastDoc(); + state.doc = null; + state.article = null; + } + state.docs = state.docs.filter((item) => item.id !== docId); + renderLibrary(); + say(`Deleted “${title}”`, 'ok'); + } catch (e) { + say('Could not delete magazine: ' + e.message, 'err'); + } +} + // ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ───────── const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.'; @@ -388,9 +409,16 @@ async function magRefreshArticles() { // ── 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' }; + // 0.55 is the backend's explicit heuristic marker: no reliable contents + // entry was available, so an article was started from a page headline. It + // is not a calibrated probability and a column of identical “55%” badges + // falsely suggests a measured OCR score. + if (Math.abs(Number(c) - 0.55) < 0.001) { + return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' }; + } + if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' }; + if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' }; + return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' }; } function renderContents() { @@ -428,7 +456,7 @@ function renderContents() { '' + fmtPages(a.pages) + '' + '' + fmtDuration(a.estimated_seconds) + '' + '' + - Math.round(a.confidence * 100) + '%' + + esc(conf.badge) + '' + '' + (done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '' + '' + @@ -494,6 +522,13 @@ function magReviewGoPage(page) { renderReview(); } +function magReviewGoMagazinePage(page) { + const lastPage = Number(state.layout?.page_count || 0); + if (!Number.isInteger(page) || page < 1 || page > lastPage) return; + state.reviewPage = page; + renderReview(); +} + // One glyph per region kind, shown centred in its box -- the fastest way to // tell "a paragraph of text" from "a photo" from "furniture that will not be // read" without reading the label. No table detection exists yet (P2), so a @@ -550,11 +585,14 @@ function renderReview() { const spanIdx = state.reviewSpan.indexOf(page); const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null; const nextPage = spanIdx >= 0 && spanIdx < state.reviewSpan.length - 1 ? state.reviewSpan[spanIdx + 1] : null; + const issuePages = Number(state.layout?.page_count || 0); + const previousIssuePage = page > 1 ? page - 1 : null; + const nextIssuePage = page < issuePages ? page + 1 : null; let html = '
' + - '' + - '' + + '' + + '' + '
' + esc(conf.text) + '
' + '
' + '

Untick anything that is not part of this article — a sidebar, an advert, ' + @@ -566,14 +604,21 @@ function renderReview() { '

' + '' + - 'Page ' + (page ?? '—') + '' + - '
' + + '
' + + '' + + 'Magazine ' + (page ?? '—') + ' / ' + issuePages + '' + + '
' + '
' + '' + '
' + + ' Draw graphic' + + '' + '' + '' + @@ -693,6 +738,18 @@ function magReviewToggleImage(id, checked) { } } +function magReviewClearCurrentPage() { + if (state.reviewPage == null) return; + if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []); + const page = state.reviewPage; + (state.layout.blocks || []).filter((block) => block.page === page) + .forEach((block) => _magReviewChecked.delete(block.id)); + const imageIds = new Set((state.layout.images || []).filter((image) => image.page === page).map((image) => image.id)); + state.article.images = (state.article.images || []).filter((id) => !imageIds.has(id)); + renderReview(); + say('All fields on this page cleared — select only the article content to keep.', 'ok'); +} + function magReviewFocusBlock(blockId) { const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]'); if (state.drawMode) return; @@ -718,7 +775,10 @@ function magReviewToggleDraw(kind = 'text') { state.drawMode = state.drawMode && state.drawKind === kind ? false : true; state.drawKind = kind; const hint = $('mag-draw-hint'); - if (hint) hint.textContent = state.drawMode ? ' Drag around the whole ' + (kind === 'image' ? 'image or chart' : 'text region') + '. Press Escape to cancel.' : ''; + const field = kind === 'title' ? 'title' : kind === 'author' ? 'author' : ''; + if (hint) hint.textContent = state.drawMode + ? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.` + : ' Drag around the whole ' + (kind === 'image' ? 'image or chart' : 'text region') + '. Press Escape to cancel.') : ''; const wrap = $('mag-review-page-wrap'); if (wrap) wrap.classList.toggle('is-drawing', state.drawMode); const btn = $('mag-draw-toggle'); @@ -801,7 +861,13 @@ function magReviewOpenDrawPanel(box) { box.hidden = false; const panel = document.createElement('div'); panel.className = 'mag-draw-panel'; - panel.innerHTML = + const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : ''; + panel.innerHTML = field + ? '
Extract ' + field + '
' + + '

OCR the selected rectangle and use its recognised text for the article ' + field + '.

' + + '
' + + '
' + : '
' + '' + '' + @@ -830,7 +896,8 @@ async function magReviewSubmitDraw() { const box = $('mag-draw-rect'); const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage); if (!box || !pageMeta) return; - const kind = document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text'; + const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : ''; + const kind = field ? 'text' : (document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text'); const text = ($('mag-draw-text')?.value || '').trim(); const bbox = [ parseFloat(box.style.left) / 100 * pageMeta.width, @@ -847,6 +914,22 @@ async function magReviewSubmitDraw() { if (!_magReviewChecked) _magReviewChecked = new Set(article.blocks || []); magReviewCancelDrawPanel(); state.drawMode = false; + if (field) { + magReviewCancelDrawPanel(); + say(`Reading selected ${field}…`, ''); + try { + const result = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/extract', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bbox }), + }); + const value = String(result.text || '').replace(/\s+/g, ' ').trim(); + if (!value) return say('No text found in that selection', 'err'); + article[field] = value; + const input = $(field === 'title' ? 'mag-rev-title' : 'mag-rev-author'); + if (input) input.value = value; + say(`${field === 'title' ? 'Title' : 'Author'} filled from selected text`, 'ok'); + } catch (e) { say(`Could not read selected ${field}: ` + e.message, 'err'); } + return; + } say('Adding region…', ''); let jobId; try { @@ -1547,10 +1630,12 @@ async function magInit() { } root.magOpen = magOpen; +root.magDelete = magDelete; root.magCreate = magCreate; root.magIngest = magIngest; root.magReview = magReview; root.magReviewGoPage = magReviewGoPage; +root.magReviewGoMagazinePage = magReviewGoMagazinePage; root.magReviewFocusBlock = magReviewFocusBlock; root.magReviewHighlightBox = magReviewHighlightBox; root.magReviewToggleBox = magReviewToggleBox; diff --git a/static/style.css b/static/style.css index e871a1a..4a9616a 100644 --- a/static/style.css +++ b/static/style.css @@ -4777,11 +4777,16 @@ input[list="lang-codes"] { .mag-new-actions { display: flex; justify-content: flex-end; margin-top: 14px; } .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; +.mag-card-wrap { position: relative; min-width: 0; } +.mag-card { display: flex; width: 100%; min-height: 100%; 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-delete { position: absolute; top: 7px; right: 7px; width: 32px; height: 32px; display: grid; place-items: center; + border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--surface) 88%, transparent); + color: var(--subtext); cursor: pointer; opacity: .82; } +.mag-card-delete:hover, .mag-card-delete:focus-visible { color: var(--error); border-color: var(--error); background: var(--surface); opacity: 1; } .mag-card-thumb { display: block; width: 100%; height: 190px; object-fit: contain; object-position: center; background: var(--panel); border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px); } .mag-card-title { font-weight: 600; } @@ -4856,6 +4861,9 @@ input[list="lang-codes"] { .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-field-control { display: flex; gap: 6px; align-items: center; } +.mag-review-field-control input { flex: 1 1 auto; min-width: 0; } +.mag-review-field-control .btn-sm { white-space: nowrap; } .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); }