feat(magazine): select many boxes and act once, undo instead of confirm, fold labels into new pictures
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

Bulk delete/type for a click- or rectangle-selected set; every destructive
region change returns an undo token (one level, refused once something newer
happened); text inside a region that becomes a picture is folded into it.
TODO: model-based layout detection (PP-DocLayout, LayoutReader) with licence
notes. v1.39.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-21 16:33:01 +02:00
parent 5009f388e7
commit f155839300
12 changed files with 495 additions and 35 deletions

View File

@ -7,6 +7,19 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
## [1.39.0] — 2026-09-21
### Added
- **Magazine review: select many boxes and act once.** *Select boxes* lets you click boxes or drag a rectangle over them, then change all their types or delete them in one step.
- **Undo instead of "are you sure?".** Deleting, changing a type, editing text, merging and splitting happen at once and offer **Undo** (the latest change, while nothing newer has happened).
- **Text inside a new picture folds into it.** When a region is drawn as, or changed into, a picture, the short text inside it (a chart's legend and axis labels) is no longer read aloud — no more deleting a dozen labels one by one.
### Changed
- Deleting a single region no longer asks for confirmation; it can be undone instead.
### Fixed
- Opening a region's text editor right after another change could have it wiped by the redraw still in flight.
## [1.38.1] — 2026-09-21
### Fixed

View File

@ -177,8 +177,18 @@ turned into a podcast discussion between two or three voices you pick.
at the line end is closed up again). Everything works from the keyboard
too: ↑/↓ move between regions, Space ticks or unticks, Enter edits the
text, R reads again, M merges with the next region and Delete deletes
(after confirming); letters typed in the text editor are never taken as
shortcuts. Use
(with Undo); letters typed in the text editor are never taken as
shortcuts. To fix **many boxes at once**, press **"Select boxes"**, then
click boxes on the page or drag a rectangle over them (every box at least
half inside is selected; X selects the focused row from the keyboard). A
bar shows how many are selected: change their type in one go, or delete
them. Deleting, changing a type, merging and splitting no longer ask
"are you sure?" — they happen at once and an **Undo** button appears
above the list (it undoes the latest change, and only while nothing else
has changed since). When a region becomes a picture — drawn as one or
changed into one — the short text inside it (a chart's legend and axis
labels) automatically becomes *Text in picture*, so there is nothing left
to delete by hand. Use
**"Two-page spread"** to see this page next to its facing page (e.g. 22
and 23), each with its own OCR-results list directly beneath it — useful
for lining up a figure or a jump that crosses the gutter. Only one page

11
TODO.md
View File

@ -64,6 +64,17 @@ not part of this order.
### P1 — next, highest user impact
- [ ] **L — Magazine: model-based layout detection and reading order.** Evaluate replacing the hand-written
region and order rules with dedicated models, as in the approach from
[Building agentic document understanding with OCR and LLMs](https://damimartinez.github.io/building-agentic-document-understanding-with-ocr-and-llms/):
**PaddleOCR PP-DocLayout** (regions: text, title, figure, chart, table, footnote) and **LayoutReader**
(a LayoutLMv3 fine-tune that orders regions from text + boxes). Before building: licences (PaddleOCR
Apache-2.0; LayoutLMv3 weights CC BY-NC-SA 4.0 -- non-commercial only), GPU/RAM on the DGX (already near
its limit), and a measurement on the same 204-page issue with the existing continuation score (current
heuristics: 89 confirmed continuations; any model must beat that). A tagged PDF, when a publisher ships one,
already carries the reading order in its structure tree -- read that first when present
([reading-order best practice](https://www.globalvision.co/artwork-best-practices/reading-order)).
- [ ] **M — Magazine podcasts via Open Notebook (multi-speaker).** [Open Notebook](https://github.com/lfnovo/open-notebook)
has an advanced multi-speaker podcast generator ("Generate professional podcasts") that could make
the magazine podcast mode much better. It already runs on the LAN at `192.168.178.19` (Docker: API

View File

@ -1 +1 @@
1.38.1
1.39.0

View File

@ -339,6 +339,25 @@ after the first and joins the same articles. Keyboard: ↑/↓ (j/k), Space, Ent
ignored while typing in the editor, a select or a position box; focus returns to the same row after the
list is redrawn.
## Many boxes at once, and undo instead of confirmation
`POST .../pages/{page}/regions/bulk {ids, action: delete|type, type?}` applies one action to a selection
(≤ 500 ids, all on the page; validated before anything changes) with one undo point, and moves article
membership like the single-region routes. Every destructive route (delete, type change, text edit, merge,
split, bulk) writes `undo.json` = {token, label, layout, articles} *after* validating and *before* saving
(the files on disk still hold the old state), and returns the token; `POST .../undo {token}` restores both
files only if that token is still the latest (409 otherwise), so an old Undo can never roll back a newer
change. One level. UI: *Select boxes* mode (click or drag a rectangle; a box ≥ 50% inside is selected),
a bulk bar, and an Undo bar replacing the per-delete confirmation dialog (per NN/g-style guidance: frequent,
reversible actions get Undo, not "are you sure?").
`fold_text_into_picture`: when a region becomes a picture (drawn, or changed into one), text ≤ 80 chars and
≥ 90% inside it becomes `in_picture` -- the same limits as the clean-up. A real chart page had 13 legend and
axis-label boxes that had to be deleted one by one after the chart was marked as an image.
Redraw race: the review list redraws asynchronously after a change; actions that build UI in the list (the
text editor) wait for a redraw in flight, and focus goes to the row focused when the redraw happens.
## Region types from the review list
Every region in the review list has a type picker and a delete button. `PATCH .../regions/{id}` takes

View File

@ -2589,6 +2589,7 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int,
images.append({"id": name, "page": page_no, "bbox": list(bbox),
"filename": f"{name}.png", "reading_order": next_ro})
layout["images"] = images
layout, _ = fold_text_into_picture(layout, page_no, bbox)
return layout
manual_type, region_label = {
@ -2887,6 +2888,28 @@ def clean_up_regions(layout: dict[str, Any], pages: Iterable[int] | None = None)
return layout, report
def fold_text_into_picture(layout: dict[str, Any], page_no: int, bbox: Sequence[float]) -> tuple[dict[str, Any], list[str]]:
"""Short text lying inside a picture becomes part of it (not read aloud). Returns the ids changed.
Used when a region becomes a picture -- drawn as one, or changed into one. A chart's legend and
axis labels had to be deleted by hand one by one after the chart was marked as an image (a real
page carried 13 of them). Same limits as clean_up_regions: at most 80 characters, at least 90%
inside, so real running text caught under a too-large frame is left alone.
"""
changed: list[str] = []
blocks = []
for b in layout.get("blocks", []):
text = " ".join((b.get("text") or "").split())
if (b["page"] == page_no and b["type"] not in _VISUAL_TYPES and b["type"] != BLOCK_FURNITURE
and text and len(text) <= 80 and _area_inside(b["bbox"], bbox) >= 0.9):
b = dict(b, type=BLOCK_FURNITURE, region_label="in_picture", confidence=0.9)
changed.append(b["id"])
blocks.append(b)
layout = dict(layout)
layout["blocks"] = blocks
return layout, changed
# What a region can be changed into from the review screen: text kinds map to a block type (and,
# for furniture, which kind of furniture); "image" turns the region into a figure crop.
REGION_TYPES: dict[str, tuple[str, str]] = {
@ -2939,7 +2962,8 @@ def change_region_type(doc_dir: Path, layout: dict[str, Any], page_no: int, regi
before = {im["id"] for im in layout.get("images", [])}
layout = add_manual_region(doc_dir, layout, page_no, block["bbox"], "image")
new_id = next(im["id"] for im in layout["images"] if im["id"] not in before)
return _reorder_page(delete_region(layout, region_id), page_no), new_id
layout, _ = fold_text_into_picture(delete_region(layout, region_id), page_no, block["bbox"])
return _reorder_page(layout, page_no), new_id
block_type, region_label = REGION_TYPES[new_type]
if block is not None:

View File

@ -754,6 +754,95 @@ def _add_split_paragraphs_to_articles(articles: list[dict], report: dict) -> boo
return changed
def _snapshot_for_undo(d: Path, label: str) -> str:
"""Keep the layout and articles as they are now, so the change about to happen can be undone.
One level: the next snapshot replaces this one. Returns the token the client undoes with."""
token = uuid.uuid4().hex
snapshot = {"token": token, "label": label, "layout": mag.load_layout(d), "articles": mag.load_articles(d)}
(d / "undo.json").write_text(json.dumps(snapshot, ensure_ascii=False), "utf-8")
return token
@router.post("/api/magazine/{doc_id}/undo")
async def magazine_undo(doc_id: str, request: Request):
"""Undo the last region change (delete, type change, merge, split, bulk). Body: {token}.
Only the latest change can be undone, so a stale token never rolls back a newer one."""
d, _ = _require_magazine(doc_id)
body = await request.json()
token = body.get("token") if isinstance(body, dict) else None
path = d / "undo.json"
if not isinstance(token, str) or not path.is_file():
raise HTTPException(409, "Nothing to undo")
snapshot = json.loads(path.read_text("utf-8"))
if snapshot.get("token") != token:
raise HTTPException(409, "Something else has changed since -- this can no longer be undone")
mag.save_layout(d, snapshot["layout"])
mag.save_articles(d, snapshot["articles"])
path.unlink(missing_ok=True)
return {"ok": True, "label": snapshot.get("label", "")}
@router.post("/api/magazine/{doc_id}/pages/{page}/regions/bulk")
async def magazine_bulk_regions(doc_id: str, page: int, request: Request):
"""Delete or re-type several regions at once. Body: {ids: [...], action: "delete"|"type",
type?: one of REGION_TYPES}. One undo point for the whole batch."""
d, _ = _require_magazine(doc_id)
layout = mag.load_layout(d)
if not layout:
raise HTTPException(404, "No layout yet -- run ingestion first")
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Body must be an object")
ids, action, new_type = body.get("ids"), body.get("action"), body.get("type")
if not isinstance(ids, list) or not ids or len(ids) > 500 or not all(isinstance(i, str) for i in ids):
raise HTTPException(400, "ids must be a non-empty list of region ids")
if action not in ("delete", "type"):
raise HTTPException(400, "action must be delete or type")
if action == "type" and new_type not in mag.REGION_TYPES:
raise HTTPException(400, f"type must be one of {', '.join(mag.REGION_TYPES)}")
on_page = {b["id"] for b in layout.get("blocks", []) if b["page"] == page} | \
{i["id"] for i in layout.get("images", []) if i["page"] == page}
missing = [i for i in ids if i not in on_page]
if missing:
raise HTTPException(400, f"Not on page {page}: {missing[:5]}")
ids = list(dict.fromkeys(ids))
token = _snapshot_for_undo(d, f"{'Deleted' if action == 'delete' else 'Changed'} {len(ids)} region{'s' if len(ids) != 1 else ''}")
remap: dict[str, str | None] = {}
if action == "delete":
for rid in ids:
layout = mag.delete_region(layout, rid)
remap[rid] = None
else:
settings = _load_settings()
ocr = [str(settings.get(k) or "").strip() or None for k in
("vision_ocr_url", "vision_ocr_model", "vision_ocr_fallback_url", "vision_ocr_fallback_model")]
for rid in ids:
if not any(b["id"] == rid for b in layout.get("blocks", [])) and \
not any(i["id"] == rid for i in layout.get("images", [])):
continue # already folded into a picture made earlier in this batch
try:
layout, new_id = await asyncio.to_thread(mag.change_region_type, d, layout, page, rid, new_type, *ocr)
except ValueError as exc:
raise HTTPException(400, str(exc))
if new_id != rid:
remap[rid] = new_id
mag.save_layout(d, layout)
articles = mag.load_articles(d)
changed = False
for article in articles:
for rid, new_id in remap.items():
for key in ("blocks", "images"):
if rid in (article.get(key) or []):
article[key] = [x for x in article[key] if x != rid]
if new_id:
target = "images" if new_type == "image" else "blocks"
article[target] = list(article.get(target) or []) + [new_id]
changed = True
if changed:
mag.save_articles(d, articles)
return {"ok": True, "undo": token, "remap": remap, "count": len(ids)}
@router.post("/api/magazine/{doc_id}/pages/{page}/regions/{block_id}/merge-next")
async def magazine_merge_region(doc_id: str, page: int, block_id: str):
"""Join a text region with the one read after it (a paragraph the OCR cut in two)."""
@ -765,6 +854,7 @@ async def magazine_merge_region(doc_id: str, page: int, block_id: str):
layout, removed = mag.merge_with_next(layout, page, block_id)
except ValueError as exc:
raise HTTPException(400, str(exc))
token = _snapshot_for_undo(d, "Merged two regions") if d is not None else None
mag.save_layout(d, layout)
articles = mag.load_articles(d)
changed = False
@ -776,7 +866,7 @@ async def magazine_merge_region(doc_id: str, page: int, block_id: str):
changed = True
if changed:
mag.save_articles(d, articles)
return {"ok": True, "id": block_id, "removed": removed}
return {"ok": True, "id": block_id, "removed": removed, "undo": token}
@router.post("/api/magazine/{doc_id}/pages/{page}/regions/{block_id}/split")
@ -795,11 +885,12 @@ async def magazine_split_region(doc_id: str, page: int, block_id: str, request:
layout, new_id = mag.split_region(layout, page, block_id, at)
except ValueError as exc:
raise HTTPException(400, str(exc))
token = _snapshot_for_undo(d, "Split a region") if d is not None else None
mag.save_layout(d, layout)
articles = mag.load_articles(d)
if _add_split_paragraphs_to_articles(articles, {"byline_split": [{"id": block_id, "new_id": new_id}]}):
mag.save_articles(d, articles)
return {"ok": True, "id": block_id, "new_id": new_id}
return {"ok": True, "id": block_id, "new_id": new_id, "undo": token}
@router.post("/api/magazine/{doc_id}/boxes/clean-up")
@ -999,10 +1090,12 @@ async def magazine_update_region(doc_id: str, page: int, block_id: str, request:
)
except ValueError as exc:
raise HTTPException(400, str(exc))
# Taken once the change is known to be valid, from the files on disk (still the old state).
token = _snapshot_for_undo(d, "Changed a region") if (new_type is not None or new_text is not None) and d is not None else None
mag.save_layout(d, layout)
if region_id != block_id:
_move_article_membership(d, block_id, region_id, to_image=new_type == "image")
return {"ok": True, "text": text, "id": region_id, "type": new_type}
return {"ok": True, "text": text, "id": region_id, "type": new_type, "undo": token}
def _move_article_membership(d: Path, old_id: str, new_id: str, to_image: bool) -> None:
@ -1035,6 +1128,7 @@ async def magazine_delete_region(doc_id: str, page: int, block_id: str):
if not any(b["id"] == block_id for b in layout.get("blocks", [])) and \
not any(im["id"] == block_id for im in layout.get("images", [])):
raise HTTPException(404, "Region not found")
token = _snapshot_for_undo(d, "Deleted a region") if d is not None else None
layout = mag.delete_region(layout, block_id)
mag.save_layout(d, layout)
# Also drop it from articles: an article still listing a deleted id made Approve fail with
@ -1048,7 +1142,7 @@ async def magazine_delete_region(doc_id: str, page: int, block_id: str):
changed = True
if changed:
mag.save_articles(d, articles)
return {"ok": True}
return {"ok": True, "undo": token}
@router.post("/api/magazine/{doc_id}/pages/{page}/extract")

View File

@ -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.38.1">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.38.1">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.38.1">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.38.1">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.38.1">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.38.1">
<link rel="stylesheet" href="/static/style.css?v=1.39.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.39.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.39.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.39.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.39.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.39.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -705,6 +705,7 @@ async function magReviewChangeType(regionId, type, select) {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type }),
});
if (r && r.id && r.id !== regionId) magRemapSelection(regionId, r.id, toImage);
magOfferUndo(r?.undo, 'Region type changed.');
} catch (e) {
if (select) select.disabled = false;
return say('Could not change the region type: ' + e.message, 'err');
@ -808,7 +809,7 @@ function magReviewPageColumn(page, wrapId, interactive) {
let listHtml = '<div class="mag-blocks"' + (interactive
? ' onkeydown="magListKey(event)" onfocusin="magListFocus(event)" aria-describedby="mag-list-keys"' : '') + '>' +
(interactive ? '<p id="mag-list-keys" class="muted mag-list-keys">Keys: ↑/↓ move between regions · Space tick · Enter edit text · R read again · M merge with the next region · Delete delete</p>' : '');
(interactive ? '<p id="mag-list-keys" class="muted mag-list-keys">Keys: ↑/↓ move between regions · Space tick · X select · Enter edit text · R read again · M merge with the next region · Delete delete (Undo appears above)</p>' : '');
regions.forEach((r, i) => {
const isImage = r.kind === 'image';
const owner = magBlockOwner(r.id);
@ -890,6 +891,7 @@ function renderReview() {
if (!host || !a) return;
// A half-built click order belongs to the page it was started on.
if (state.orderMode && state.orderPage !== state.reviewPage) { state.orderMode = false; state.orderSeq = []; }
if (state.selectPage !== state.reviewPage) { state.selected = new Set(); state.selectPage = state.reviewPage; }
const conf = confidenceLabel(a.confidence);
const page = state.reviewPage;
const pageCount = Number(state.layout?.page_count || 0);
@ -935,6 +937,9 @@ function renderReview() {
'<button type="button" class="btn-sm" id="mag-edit-toggle" aria-pressed="false" ' +
'title="Drag a region to move or resize it, or delete one that was detected wrongly" ' +
'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' +
'<button type="button" class="btn-sm" id="mag-select-toggle" aria-pressed="' + String(!!state.selectMode) + '" ' +
'title="Select several boxes -- click them or drag a rectangle over them -- then change their type or delete them at once" onclick="magSelectToggle()">' +
'<span class="mdi mdi-selection-drag" aria-hidden="true"></span> Select boxes</button>' +
'<button type="button" class="btn-sm" id="mag-order-toggle" aria-pressed="' + String(!!state.orderMode) + '" ' +
'title="Click the boxes on the page in the order they should be read" onclick="magOrderToggle()">' +
'<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> Order by clicking</button>' +
@ -960,7 +965,9 @@ function renderReview() {
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'</div>' +
'<span id="mag-draw-hint" class="muted"></span>' +
'<div id="mag-order-bar" class="mag-order-bar" role="status" aria-live="polite">' + magOrderBarHtml() + '</div>';
'<div id="mag-order-bar" class="mag-order-bar" role="status" aria-live="polite">' + magOrderBarHtml() + '</div>' +
'<div id="mag-select-bar" class="mag-order-bar" role="status" aria-live="polite">' + magSelectBarHtml() + '</div>' +
'<div id="mag-undo-bar" class="mag-order-bar" role="status" aria-live="polite">' + magUndoBarHtml() + '</div>';
html += '</div>';
html += '<span class="mag-review-conf ' + conf.cls + '">' + esc(conf.text) + '</span>';
@ -1015,6 +1022,7 @@ function renderReview() {
$('mag-review-page-wrap')?.classList.add('is-ordering');
magOrderRefreshBar();
}
if (state.selectMode) magSelectRefresh();
setupMagDrawing();
setupMagBoxEdit();
}
@ -1248,8 +1256,126 @@ async function magOrderReset() {
renderReview();
}
// ── Select several boxes, act once; undo instead of confirming ─────────────
function magSelectBarHtml() {
if (!state.selectMode) return '';
const n = (state.selected || new Set()).size;
const options = MAG_REGION_TYPES.map(([v, label]) => '<option value="' + v + '">' + esc(label) + '</option>').join('');
return '<span class="mdi mdi-selection-drag" aria-hidden="true"></span> ' +
(n ? n + ' selected.' : 'Click boxes on the page, or drag a rectangle over them, to select them.') + ' ' +
'<select id="mag-bulk-type" aria-label="Change the type of the selected boxes"' + (n ? '' : ' disabled') + '>' +
'<option value="">Change type to…</option>' + options + '</select> ' +
'<button type="button" class="btn-sm" onclick="magBulk(\'delete\')"' + (n ? '' : ' disabled') + '>' +
'<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete ' + (n || '') + '</button> ' +
'<button type="button" class="btn-sm" onclick="magSelectNone()"' + (n ? '' : ' disabled') + '>Select none</button> ' +
'<button type="button" class="btn-sm" onclick="magSelectToggle()">Done</button>';
}
function magSelectRefresh() {
const bar = $('mag-select-bar');
if (bar) {
bar.innerHTML = magSelectBarHtml();
$('mag-bulk-type')?.addEventListener('change', (e) => { if (e.target.value) magBulk('type', e.target.value); });
}
const sel = state.selected || new Set();
$('mag-review-page-wrap')?.classList.toggle('is-selecting', !!state.selectMode);
document.querySelectorAll('#mag-review [data-block], #mag-review [data-block-row]').forEach((el) => {
const id = el.dataset.block || el.dataset.blockRow;
if (el.matches('.mag-review-box, [data-block-row]')) el.classList.toggle('is-selected', !!state.selectMode && sel.has(id));
});
}
function magSelectToggle() {
state.selectMode = !state.selectMode;
state.selected = new Set();
state.selectPage = state.reviewPage;
if (state.selectMode && state.drawMode) magReviewToggleDraw(state.drawKind);
if (state.selectMode && state.editMode) magReviewToggleEdit();
if (state.selectMode && state.orderMode) magOrderToggle();
const btn = $('mag-select-toggle');
if (btn) { btn.classList.toggle('active', state.selectMode); btn.setAttribute('aria-pressed', String(state.selectMode)); }
magSelectRefresh();
}
function magSelectToggleOne(id) {
const sel = state.selected || (state.selected = new Set());
if (sel.has(id)) sel.delete(id); else sel.add(id);
magSelectRefresh();
}
function magSelectNone() {
state.selected = new Set();
magSelectRefresh();
}
// Boxes at least half inside the dragged rectangle (all values in % of the page).
function magSelectInRect(left, top, width, height) {
const sel = state.selected || (state.selected = new Set());
document.querySelectorAll('#mag-review-page-wrap .mag-review-box[data-block]').forEach((box) => {
const bl = parseFloat(box.style.left), bt = parseFloat(box.style.top);
const bw = parseFloat(box.style.width), bh = parseFloat(box.style.height);
const ix = Math.max(0, Math.min(bl + bw, left + width) - Math.max(bl, left));
const iy = Math.max(0, Math.min(bt + bh, top + height) - Math.max(bt, top));
if (bw > 0 && bh > 0 && ix * iy >= 0.5 * bw * bh) sel.add(box.dataset.block);
});
magSelectRefresh();
}
async function magBulk(action, type) {
const ids = Array.from(state.selected || []);
if (!ids.length) return;
let r;
try {
r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action === 'type' ? { ids, action, type } : { ids, action }),
});
} catch (e) {
return say('Could not change the selected boxes: ' + e.message, 'err');
}
Object.entries(r.remap || {}).forEach(([oldId, newId]) => magRemapSelection(oldId, newId, type === 'image'));
state.selected = new Set();
magOfferUndo(r.undo, (action === 'delete' ? 'Deleted ' : 'Changed ') + r.count + ' box' + (r.count === 1 ? '' : 'es') + '.');
await magReloadReview();
}
function magUndoBarHtml() {
if (!state.undo) return '';
return '<span class="mdi mdi-undo-variant" aria-hidden="true"></span> ' + esc(state.undo.label) + ' ' +
'<button type="button" class="btn-sm" onclick="magUndo()">Undo</button>';
}
function magOfferUndo(token, label) {
state.undo = token ? { token, label } : null;
const bar = $('mag-undo-bar');
if (bar) bar.innerHTML = magUndoBarHtml();
}
async function magUndo() {
const undo = state.undo;
if (!undo) return;
try {
await api('/api/magazine/' + state.doc.id + '/undo', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: undo.token }),
});
} catch (e) {
state.undo = null;
magOfferUndo(null);
return say('Could not undo: ' + e.message, 'err');
}
state.undo = null;
// The article itself was restored too: take it (and its ticks) from the server again.
const articleId = state.article?.id;
await magRefreshArticles();
state.article = (state.articles || []).find((a) => a.id === articleId) || state.article;
_magReviewChecked = null;
await magReloadReview('Undone');
}
function magReviewFocusBlock(blockId) {
if (state.orderMode) return magOrderPick(blockId);
if (state.selectMode) return magSelectToggleOne(blockId);
const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]');
if (state.drawMode || state.editMode) return;
if (cb) { cb.checked = !cb.checked; magReviewToggleBox(cb); }
@ -1536,7 +1662,14 @@ function magRegionUrl(id) {
return '/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(id);
}
async function magReloadReview(message) {
function magReloadReview(message) {
// Other actions (opening the text editor) wait for a redraw in flight, or it would wipe them.
const run = magReloadReviewNow(message).finally(() => { if (state.reloadPending === run) state.reloadPending = null; });
state.reloadPending = run;
return run;
}
async function magReloadReviewNow(message) {
// Keep a keyboard user's place: the row focused when the redraw happens (not when the change
// started -- the user may have moved on meanwhile) gets focus back.
state.layout = null;
@ -1547,7 +1680,8 @@ async function magReloadReview(message) {
if (message) say(message, 'ok');
}
function magRowEdit(id) {
async function magRowEdit(id) {
if (state.reloadPending) await state.reloadPending;
const block = (state.layout?.blocks || []).find((b) => b.id === id);
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(id) + '"]');
const span = row?.querySelector('.mag-block-text');
@ -1577,6 +1711,7 @@ function magRowEdit(id) {
if (act === 'split') {
const r = await api(magRegionUrl(id) + '/split', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ at: area.selectionStart }) });
if (r?.new_id) magSelectionAddAlongside(id, r.new_id);
magOfferUndo(r?.undo, 'Region split in two.');
return magReloadReview('Region split in two');
}
magReloadReview();
@ -1601,6 +1736,7 @@ async function magRowMerge(id) {
try {
const r = await api(magRegionUrl(id) + '/merge-next', { method: 'POST' });
if (r?.removed) magRemapSelection(r.removed, null, false);
magOfferUndo(r?.undo, 'Merged with the next region.');
} catch (e) {
return say('Could not merge: ' + e.message, 'err');
}
@ -1629,24 +1765,24 @@ function magListKey(event) {
else if (key === 'r' && isText) { event.preventDefault(); magRowReocr(id); }
else if (key === 'm' && isText) { event.preventDefault(); magRowMerge(id); }
else if (key === 'Delete') { event.preventDefault(); magReviewDeleteRegion(id); }
else if (key === 'x') { event.preventDefault(); if (!state.selectMode) magSelectToggle(); magSelectToggleOne(id); }
}
// Deleting is immediate and offers Undo -- a confirmation dialog for every box made clearing a page of
// chart labels a click-marathon.
async function magReviewDeleteRegion(blockId) {
const ok = await confirmDialog('Delete this region? This removes it from the page entirely, not just from this article.',
{ title: 'Delete region?', okLabel: 'Delete', danger: true });
if (!ok) return;
let r;
try {
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(blockId), {
r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(blockId), {
method: 'DELETE',
});
magRemapSelection(blockId, null, false);
state.selected?.delete(blockId);
} catch (e) {
return say('Could not delete region: ' + e.message, 'err');
}
say('Region deleted', 'ok');
state.layout = null;
await ensureLayout();
renderReview();
magOfferUndo(r?.undo, 'Region deleted.');
await magReloadReview();
}
let _magDrawState = null; // { startXPct, startYPct } while dragging
@ -1662,7 +1798,7 @@ function setupMagDrawing() {
// only the window-level one below needs the one-time guard, since window
// is never replaced.
wrap.addEventListener('mousedown', (e) => {
if (!state.drawMode || e.button !== 0 || e.target.closest('.mag-draw-panel')) return;
if (!(state.drawMode || state.selectMode) || e.button !== 0 || e.target.closest('.mag-draw-panel')) return;
magReviewCancelDrawPanel();
const rect = wrap.getBoundingClientRect();
_magDrawState = {
@ -1715,6 +1851,7 @@ function setupMagDrawing() {
if (!box || box.hidden) return;
const w = parseFloat(box.style.width) || 0, h = parseFloat(box.style.height) || 0;
if (w < 1 || h < 1) { box.hidden = true; return; } // too small to be intentional
if (state.selectMode) { box.hidden = true; return magSelectInRect(parseFloat(box.style.left), parseFloat(box.style.top), w, h); }
magReviewOpenDrawPanel(box);
});
}
@ -2724,6 +2861,10 @@ root.magReviewChangeType = magReviewChangeType;
root.magFixReadingOrder = magFixReadingOrder;
root.magCleanUpBoxes = magCleanUpBoxes;
root.magRowEdit = magRowEdit;
root.magSelectToggle = magSelectToggle;
root.magSelectNone = magSelectNone;
root.magBulk = magBulk;
root.magUndo = magUndo;
root.magRowReocr = magRowReocr;
root.magRowMerge = magRowMerge;
root.magListKey = magListKey;

View File

@ -5182,3 +5182,10 @@ input[list="lang-codes"] {
border-radius: var(--radius); background: var(--surface); color: var(--text); resize: vertical; }
.mag-row-editor-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.mag-blocks [data-block-row]:focus-within { outline: 2px solid var(--accent, #2563eb); outline-offset: 1px; }
/* Select several boxes (magazine review) */
.mag-review-page-wrap.is-selecting { cursor: crosshair; }
.mag-review-page-wrap.is-selecting .mag-review-box { cursor: pointer; }
.mag-review-box.is-selected { outline: 3px solid var(--warning, #d97706); outline-offset: -1px;
background: color-mix(in srgb, var(--warning, #d97706) 18%, transparent); }
.mag-blocks [data-block-row].is-selected { border-color: var(--warning, #d97706); box-shadow: inset 3px 0 0 var(--warning, #d97706); }

View File

@ -360,25 +360,30 @@ test('every type the server knows is offered, image included', async ({ page })
'page_number', 'source', 'ad', 'in_picture', 'ocr_noise', 'duplicate', 'image']);
});
test('each review row has its own delete button that asks first', async ({ page }) => {
test('each review row has its own delete button, which deletes at once and offers undo', async ({ page }) => {
await mockBackend(page);
let deleted = false;
let deleted = false, undone = false;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => {
expect(route.request().method()).toBe('DELETE');
deleted = true;
return route.fulfill({ json: { ok: true, undo: 'tok-1' } });
});
await page.route(`**/api/magazine/${DOC_ID}/undo`, route => {
expect(route.request().postDataJSON()).toEqual({ token: 'tok-1' });
undone = true;
return route.fulfill({ json: { ok: true } });
});
await openReview(page);
const button = page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ });
// The button sits in its own column and does not cover the paragraph text.
const [btn, text] = [await button.boundingBox(), await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox()];
expect(btn.x + btn.width).toBeLessThanOrEqual(text.x);
await button.click();
const dialog = page.getByRole('alertdialog', { name: 'Delete region?' });
await expect(dialog).toContainText('Delete this region?');
expect(deleted).toBe(false);
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect.poll(() => deleted).toBe(true);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.locator('#mag-undo-bar')).toContainText('Region deleted.');
await page.locator('#mag-undo-bar').getByRole('button', { name: 'Undo', exact: true }).click();
await expect.poll(() => undone).toBe(true);
await expect(page.locator('#mag-undo-bar')).toBeEmpty();
});
test('fix reading order asks first, then reports how many pages changed', async ({ page }) => {
@ -575,9 +580,52 @@ test('approving after deleting a ticked box does not send its id', async ({ page
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => { deleted = true; return route.fulfill({ json: { ok: true } }); });
await openReview(page);
await page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }).click();
await expect(page.locator('[data-block-row="b3"]')).toHaveCount(0);
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
await page.getByRole('button', { name: 'Approve', exact: true }).click();
expect((await request).postDataJSON().blocks).not.toContain('b3');
});
test('select boxes by dragging a rectangle, then delete them in one go with undo', async ({ page }) => {
await mockBackend(page);
let sent = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/bulk`, route => {
sent = route.request().postDataJSON();
return route.fulfill({ json: { ok: true, undo: 'tok-2', remap: { b2: null, b3: null }, count: 2 } });
});
await openReview(page);
await page.getByRole('button', { name: 'Select boxes', exact: true }).click();
await expect(page.locator('#mag-select-bar')).toContainText('drag a rectangle');
await page.locator('#mag-review-page-wrap').evaluate(el => { el.style.width = '400px'; el.scrollIntoView(); });
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
// b2 [50,120,200,14] and b3 [50,150,200,300] on a 595x842 page; b1 (the headline at y 60) stays out.
const at = (x, y) => [wrap.x + wrap.width * x / 595, wrap.y + wrap.height * y / 842];
await page.mouse.move(...at(40, 112));
await page.mouse.down();
await page.mouse.move(...at(270, 470), { steps: 6 });
await page.mouse.up();
await expect(page.locator('#mag-select-bar')).toContainText('2 selected.');
await expect(page.locator('#mag-review-page-wrap [data-block="b1"]')).not.toHaveClass(/is-selected/);
await page.locator('#mag-select-bar').getByRole('button', { name: /^Delete/ }).click();
await expect.poll(() => sent).toEqual({ ids: expect.arrayContaining(['b2', 'b3']), action: 'delete' });
await expect(page.locator('#mag-undo-bar')).toContainText('Deleted 2 boxes.');
});
test('clicking boxes in select mode builds a selection whose type changes at once', async ({ page }) => {
await mockBackend(page);
let sent = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/bulk`, route => {
sent = route.request().postDataJSON();
return route.fulfill({ json: { ok: true, undo: 'tok-3', remap: {}, count: 2 } });
});
await openReview(page);
const b1Checked = await page.locator('#mag-review input[data-block="b1"]').isChecked();
await page.getByRole('button', { name: 'Select boxes', exact: true }).click();
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
await expect(page.locator('#mag-select-bar')).toContainText('2 selected.');
// Selecting is not ticking: the include checkboxes stay as they were.
await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked({ checked: b1Checked });
await page.locator('#mag-bulk-type').selectOption('ocr_noise');
await expect.poll(() => sent).toEqual({ ids: ['b1', 'b2'], action: 'type', type: 'ocr_noise' });
});

View File

@ -1030,3 +1030,96 @@ class QuickCorrectionTests(unittest.TestCase):
request = AsyncMock(); request.json.return_value = body
with self.assertRaises(HTTPException):
asyncio.run(magazine_split_region('a' * 32, 1, 'c', request))
class BulkUndoFoldTests(unittest.TestCase):
"""Folding labels into a new picture, bulk changes, and one-level undo."""
def setUp(self):
from pathlib import Path
from PIL import Image
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-bulk-"))
(self.dir / "pages").mkdir()
Image.new("RGB", (600, 800), "white").save(self.dir / "pages" / "0001.png")
self.layout = {"pages": [{"page": 1, "width": 600.0, "height": 800.0}], "images": [], "blocks": [
{"id": "chart", "page": 1, "bbox": [50, 300, 400, 300], "type": "body", "text": "Chart OCR text", "reading_order": 0},
{"id": "lg1", "page": 1, "bbox": [60, 320, 80, 12], "type": "caption", "text": "Einsteiger (22-25 Jahre)", "reading_order": 1},
{"id": "lg2", "page": 1, "bbox": [160, 320, 80, 12], "type": "caption", "text": "Senior (50 Jahre und älter)", "reading_order": 2},
{"id": "body", "page": 1, "bbox": [50, 50, 400, 200], "type": "body", "text": "Running text above the chart.", "reading_order": 3},
]}
self.articles = [{"id": "a1", "blocks": ["chart", "lg1", "lg2", "body"], "images": []}]
from core import magazine as mag
mag.save_layout(self.dir, self.layout)
mag.save_articles(self.dir, self.articles)
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def run_route(self, fn, *args, body=None):
import asyncio
from unittest.mock import AsyncMock, patch
with patch('routes.magazine._require_magazine', return_value=(self.dir, {})), \
patch('routes.magazine._load_settings', return_value={}), \
patch('routes.magazine.asyncio.to_thread', new=_run_sync):
request = AsyncMock(); request.json.return_value = body or {}
return asyncio.run(fn('a' * 32, *args, request) if body is not None else fn('a' * 32, *args))
def test_labels_inside_a_region_turned_into_a_picture_fold_into_it(self):
from core.magazine import change_region_type
layout, _ = change_region_type(self.dir, self.layout, 1, "chart", "image")
types = {b["id"]: (b["type"], b.get("region_label")) for b in layout["blocks"]}
self.assertEqual(types["lg1"], ("furniture", "in_picture"))
self.assertEqual(types["lg2"], ("furniture", "in_picture"))
self.assertEqual(types["body"][0], "body") # outside the picture: untouched
def test_a_drawn_picture_folds_the_labels_inside_it_too(self):
from core.magazine import add_manual_region
layout = add_manual_region(self.dir, self.layout, 1, [50, 300, 400, 300], "image")
self.assertEqual(next(b for b in layout["blocks"] if b["id"] == "lg1")["region_label"], "in_picture")
def test_bulk_delete_removes_regions_and_article_entries_and_can_be_undone(self):
from core import magazine as mag
from routes.magazine import magazine_bulk_regions, magazine_undo
out = self.run_route(magazine_bulk_regions, 1, body={"ids": ["lg1", "lg2"], "action": "delete"})
self.assertEqual(out["count"], 2)
self.assertEqual({b["id"] for b in mag.load_layout(self.dir)["blocks"]}, {"chart", "body"})
self.assertEqual(mag.load_articles(self.dir)[0]["blocks"], ["chart", "body"])
self.run_route(magazine_undo, body={"token": out["undo"]})
self.assertEqual(len(mag.load_layout(self.dir)["blocks"]), 4)
self.assertEqual(mag.load_articles(self.dir)[0]["blocks"], ["chart", "lg1", "lg2", "body"])
def test_bulk_type_change_applies_to_every_selected_region(self):
from core import magazine as mag
from routes.magazine import magazine_bulk_regions
self.run_route(magazine_bulk_regions, 1, body={"ids": ["lg1", "lg2"], "action": "type", "type": "ocr_noise"})
labels = {b["id"]: b.get("region_label") for b in mag.load_layout(self.dir)["blocks"]}
self.assertEqual((labels["lg1"], labels["lg2"]), ("ocr_noise", "ocr_noise"))
def test_bulk_turning_text_into_a_picture_moves_it_to_the_article_images(self):
from core import magazine as mag
from routes.magazine import magazine_bulk_regions
out = self.run_route(magazine_bulk_regions, 1, body={"ids": ["chart"], "action": "type", "type": "image"})
new_id = out["remap"]["chart"]
article = mag.load_articles(self.dir)[0]
self.assertNotIn("chart", article["blocks"])
self.assertEqual(article["images"], [new_id])
def test_only_the_latest_change_can_be_undone(self):
from routes.magazine import magazine_bulk_regions, magazine_undo
first = self.run_route(magazine_bulk_regions, 1, body={"ids": ["lg1"], "action": "delete"})
self.run_route(magazine_bulk_regions, 1, body={"ids": ["lg2"], "action": "delete"})
with self.assertRaises(HTTPException) as err:
self.run_route(magazine_undo, body={"token": first["undo"]})
self.assertEqual(err.exception.status_code, 409)
def test_bulk_rejects_bad_requests_before_changing_anything(self):
from core import magazine as mag
from routes.magazine import magazine_bulk_regions
for body in ({"ids": [], "action": "delete"}, {"ids": ["lg1"], "action": "nuke"},
{"ids": ["lg1"], "action": "type", "type": "nonsense"}, {"ids": ["elsewhere"], "action": "delete"}):
with self.subTest(body=body):
with self.assertRaises(HTTPException) as err:
self.run_route(magazine_bulk_regions, 1, body=body)
self.assertEqual(err.exception.status_code, 400)
self.assertEqual(len(mag.load_layout(self.dir)["blocks"]), 4)
self.assertFalse((self.dir / "undo.json").exists())