fix(magazine): adding a region crashed after a byline split; undo could roll back later changes

original_text is now a Block field, so paths that rebuild blocks keep it
instead of crashing. Undo snapshots are sealed with a digest of the saved
state and refused once anything else changed; actions without their own undo
point clear the Undo button. v1.41.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-22 00:50:33 +02:00
parent 2d5d5da777
commit 703052d1d2
9 changed files with 118 additions and 11 deletions

View File

@ -7,6 +7,12 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
## [1.41.1] — 2026-09-22
### Fixed
- **Magazine: "Could not add region: … unexpected keyword argument 'original_text'".** After *Clean up boxes* split an author line, drawing a region (and re-OCR of a page) crashed on the extra field kept with that author line. The field is now part of the region model and survives every path.
- **Undo could roll back more than the change it belonged to.** An Undo offered for a delete stayed valid while other changes that record no undo point of their own (drawing a region, fixing the reading order, clean-up, approving) happened afterwards — pressing it would have reverted those too. Undo now refuses as soon as anything else has changed, and those actions clear the Undo button.
## [1.41.0] — 2026-09-21 ## [1.41.0] — 2026-09-21
### Changed ### Changed

View File

@ -1 +1 @@
1.41.0 1.41.1

View File

@ -347,7 +347,10 @@ membership like the single-region routes. Every destructive route (delete, type
split, bulk) writes `undo.json` = {token, label, layout, articles} *after* validating and *before* saving 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 (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 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), change. One level. After the change is saved the snapshot is sealed with a digest of layout.json +
articles.json (`_seal_undo`); undo also requires the current state to match that digest, so a later change that
records no undo point (drawing, fix reading order, clean-up, approve) makes the old Undo invalid instead of being
rolled back with it. 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, 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?"). reversible actions get Undo, not "are you sure?").

View File

@ -116,9 +116,16 @@ class Block:
region_label: str = "" region_label: str = ""
continues_from_previous_page: bool = False continues_from_previous_page: bool = False
source: str = "native" # "native" | "ocr" | "vision_ocr" | "vision_label" source: str = "native" # "native" | "ocr" | "vision_ocr" | "vision_label"
# The author line's text before an author line that ran into the first sentence was split
# (clean_up_regions). Part of the model so every path that rebuilds blocks keeps it -- and does
# not crash on it (drawing a region did, "unexpected keyword argument 'original_text'").
original_text: str = ""
def to_json(self) -> dict[str, Any]: def to_json(self) -> dict[str, Any]:
return asdict(self) data = asdict(self)
if not data["original_text"]:
del data["original_text"]
return data
@dataclass @dataclass

View File

@ -19,6 +19,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import html import html
import hashlib
import json import json
import logging import logging
import re import re
@ -756,13 +757,36 @@ def _add_split_paragraphs_to_articles(articles: list[dict], report: dict) -> boo
def _snapshot_for_undo(d: Path, label: str) -> str: 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. """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.""" One level: the next snapshot replaces this one. Returns the token the client undoes with.
The caller seals it with _seal_undo once the change is saved."""
token = uuid.uuid4().hex token = uuid.uuid4().hex
snapshot = {"token": token, "label": label, "layout": mag.load_layout(d), "articles": mag.load_articles(d)} 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") (d / "undo.json").write_text(json.dumps(snapshot, ensure_ascii=False), "utf-8")
return token return token
def _state_digest(d: Path) -> str:
"""A fingerprint of the saved layout and articles."""
h = hashlib.sha256()
for name in ("layout.json", "articles.json"):
path = d / name
h.update(path.read_bytes() if path.is_file() else b"-")
return h.hexdigest()
def _seal_undo(d: Path | None, token: str | None) -> None:
"""Record what the state looks like right after the undoable change. Undo is refused once the
state differs from that -- any later change (drawing a region, fixing the reading order,
approving) would otherwise be rolled back silently with it."""
path = d / "undo.json" if d is not None and token else None
if path is None or not path.is_file():
return
snapshot = json.loads(path.read_text("utf-8"))
if snapshot.get("token") == token:
snapshot["after"] = _state_digest(d)
path.write_text(json.dumps(snapshot, ensure_ascii=False), "utf-8")
@router.post("/api/magazine/{doc_id}/undo") @router.post("/api/magazine/{doc_id}/undo")
async def magazine_undo(doc_id: str, request: Request): async def magazine_undo(doc_id: str, request: Request):
"""Undo the last region change (delete, type change, merge, split, bulk). Body: {token}. """Undo the last region change (delete, type change, merge, split, bulk). Body: {token}.
@ -774,7 +798,7 @@ async def magazine_undo(doc_id: str, request: Request):
if not isinstance(token, str) or not path.is_file(): if not isinstance(token, str) or not path.is_file():
raise HTTPException(409, "Nothing to undo") raise HTTPException(409, "Nothing to undo")
snapshot = json.loads(path.read_text("utf-8")) snapshot = json.loads(path.read_text("utf-8"))
if snapshot.get("token") != token: if snapshot.get("token") != token or snapshot.get("after") != _state_digest(d):
raise HTTPException(409, "Something else has changed since -- this can no longer be undone") raise HTTPException(409, "Something else has changed since -- this can no longer be undone")
mag.save_layout(d, snapshot["layout"]) mag.save_layout(d, snapshot["layout"])
mag.save_articles(d, snapshot["articles"]) mag.save_articles(d, snapshot["articles"])
@ -840,6 +864,7 @@ async def magazine_bulk_regions(doc_id: str, page: int, request: Request):
changed = True changed = True
if changed: if changed:
mag.save_articles(d, articles) mag.save_articles(d, articles)
_seal_undo(d, token)
return {"ok": True, "undo": token, "remap": remap, "count": len(ids)} return {"ok": True, "undo": token, "remap": remap, "count": len(ids)}
@ -866,6 +891,7 @@ async def magazine_merge_region(doc_id: str, page: int, block_id: str):
changed = True changed = True
if changed: if changed:
mag.save_articles(d, articles) mag.save_articles(d, articles)
_seal_undo(d, token)
return {"ok": True, "id": block_id, "removed": removed, "undo": token} return {"ok": True, "id": block_id, "removed": removed, "undo": token}
@ -890,6 +916,7 @@ async def magazine_split_region(doc_id: str, page: int, block_id: str, request:
articles = mag.load_articles(d) articles = mag.load_articles(d)
if _add_split_paragraphs_to_articles(articles, {"byline_split": [{"id": block_id, "new_id": new_id}]}): if _add_split_paragraphs_to_articles(articles, {"byline_split": [{"id": block_id, "new_id": new_id}]}):
mag.save_articles(d, articles) mag.save_articles(d, articles)
_seal_undo(d, token)
return {"ok": True, "id": block_id, "new_id": new_id, "undo": token} return {"ok": True, "id": block_id, "new_id": new_id, "undo": token}
@ -1095,6 +1122,7 @@ async def magazine_update_region(doc_id: str, page: int, block_id: str, request:
mag.save_layout(d, layout) mag.save_layout(d, layout)
if region_id != block_id: if region_id != block_id:
_move_article_membership(d, block_id, region_id, to_image=new_type == "image") _move_article_membership(d, block_id, region_id, to_image=new_type == "image")
_seal_undo(d, token)
return {"ok": True, "text": text, "id": region_id, "type": new_type, "undo": token} return {"ok": True, "text": text, "id": region_id, "type": new_type, "undo": token}
@ -1142,6 +1170,7 @@ async def magazine_delete_region(doc_id: str, page: int, block_id: str):
changed = True changed = True
if changed: if changed:
mag.save_articles(d, articles) mag.save_articles(d, articles)
_seal_undo(d, token)
return {"ok": True, "undo": token} return {"ok": True, "undo": token}

View File

@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.41.0"> <link rel="stylesheet" href="/static/style.css?v=1.41.1">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.41.0"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.41.1">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.41.0"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.41.1">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.41.0"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.41.1">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.41.0"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.41.1">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.41.0"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.41.1">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -1217,6 +1217,7 @@ async function magOrderPut(order) {
} }
async function magOrderSave() { async function magOrderSave() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const seq = (state.orderSeq || []).slice(); const seq = (state.orderSeq || []).slice();
if (!seq.length) return; if (!seq.length) return;
try { try {
@ -1231,6 +1232,7 @@ async function magOrderSave() {
} }
async function magOrderMove(id, value) { async function magOrderMove(id, value) {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const order = (state.reviewRegionOrder || []).filter((x) => x !== id); const order = (state.reviewRegionOrder || []).filter((x) => x !== id);
const to = Math.min(Math.max(1, parseInt(value, 10) || 1), order.length + 1) - 1; const to = Math.min(Math.max(1, parseInt(value, 10) || 1), order.length + 1) - 1;
order.splice(to, 0, id); order.splice(to, 0, id);
@ -1245,6 +1247,7 @@ async function magOrderMove(id, value) {
} }
async function magOrderReset() { async function magOrderReset() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
try { try {
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', { method: 'DELETE' }); await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/reading-order', { method: 'DELETE' });
} catch (e) { } catch (e) {
@ -1596,6 +1599,7 @@ async function magReviewSubmitEdit(reocr) {
} }
async function magCleanUpBoxes() { async function magCleanUpBoxes() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const ok = await confirmDialog('Clean up the boxes of this whole issue: text inside pictures, OCR noise (boxes of one or two characters) ' + const ok = await confirmDialog('Clean up the boxes of this whole issue: text inside pictures, OCR noise (boxes of one or two characters) ' +
'and boxes that repeat a bigger box\'s text stop being read aloud, and author lines that run into the first sentence are split. ' + 'and boxes that repeat a bigger box\'s text stop being read aloud, and author lines that run into the first sentence are split. ' +
'Nothing is deleted — every change shows in the review list and can be undone with the type picker — and a backup of the layout is kept.', 'Nothing is deleted — every change shows in the review list and can be undone with the type picker — and a backup of the layout is kept.',
@ -1620,6 +1624,7 @@ async function magCleanUpBoxes() {
} }
async function magFixReadingOrder() { async function magFixReadingOrder() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const ok = await confirmDialog('Re-read the reading order of every page in this issue, column by column. ' + const ok = await confirmDialog('Re-read the reading order of every page in this issue, column by column. ' +
'Text, boxes and approvals stay as they are, but the order in which approved articles are read aloud can change. ' + 'Text, boxes and approvals stay as they are, but the order in which approved articles are read aloud can change. ' +
'Pages you ordered by hand are kept.', { title: 'Fix reading order?', okLabel: 'Fix reading order' }); 'Pages you ordered by hand are kept.', { title: 'Fix reading order?', okLabel: 'Fix reading order' });
@ -1911,6 +1916,7 @@ function magReviewOpenDrawPanel(box) {
} }
async function magReviewSubmitDraw() { async function magReviewSubmitDraw() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const box = $('mag-draw-rect'); const box = $('mag-draw-rect');
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage); const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
if (!box || !pageMeta) return; if (!box || !pageMeta) return;
@ -2020,6 +2026,7 @@ function selectedBlockIds() {
} }
async function magReocrPage() { async function magReocrPage() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const page = state.reviewPage; const page = state.reviewPage;
if (page == null) return; if (page == null) return;
const btn = $('mag-reocr-btn'); const btn = $('mag-reocr-btn');
@ -2102,6 +2109,7 @@ async function magRefineFigures() {
} }
async function magApprove() { async function magApprove() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const a = state.article; const a = state.article;
if (!a) return; if (!a) return;
const blocks = selectedBlockIds(); const blocks = selectedBlockIds();

View File

@ -629,3 +629,15 @@ test('clicking boxes in select mode builds a selection whose type changes at onc
await page.locator('#mag-bulk-type').selectOption('ocr_noise'); await page.locator('#mag-bulk-type').selectOption('ocr_noise');
await expect.poll(() => sent).toEqual({ ids: ['b1', 'b2'], action: 'type', type: 'ocr_noise' }); await expect.poll(() => sent).toEqual({ ids: ['b1', 'b2'], action: 'type', type: 'ocr_noise' });
}); });
test('an action without its own undo point clears the Undo button', async ({ page }) => {
await mockBackend(page);
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => route.fulfill({ json: { ok: true, undo: 'tok-9' } }));
await page.route(`**/api/magazine/${DOC_ID}/reading-order`, route => route.fulfill({ json: { ok: true, pages_changed: [], pages_locked: 0 } }));
await openReview(page);
await page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ }).click();
await expect(page.locator('#mag-undo-bar')).toContainText('Region deleted.');
await page.getByRole('button', { name: 'Fix reading order', exact: true }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Fix reading order', exact: true }).click();
await expect(page.locator('#mag-undo-bar')).toBeEmpty();
});

View File

@ -1123,3 +1123,45 @@ class BulkUndoFoldTests(unittest.TestCase):
self.assertEqual(err.exception.status_code, 400) self.assertEqual(err.exception.status_code, 400)
self.assertEqual(len(mag.load_layout(self.dir)["blocks"]), 4) self.assertEqual(len(mag.load_layout(self.dir)["blocks"]), 4)
self.assertFalse((self.dir / "undo.json").exists()) self.assertFalse((self.dir / "undo.json").exists())
class UndoSafetyAndExtraFieldTests(BulkUndoFoldTests):
"""Undo never rolls back a later change; a split author line's extra field survives every path."""
def test_undo_is_refused_once_anything_else_changed(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"], "action": "delete"})
# A change that records no undo point of its own, e.g. drawing a region or fixing the order.
layout = mag.load_layout(self.dir)
layout["blocks"][0]["text"] = "edited later"
mag.save_layout(self.dir, layout)
with self.assertRaises(HTTPException) as err:
self.run_route(magazine_undo, body={"token": out["undo"]})
self.assertEqual(err.exception.status_code, 409)
self.assertEqual(mag.load_layout(self.dir)["blocks"][0]["text"], "edited later")
def test_an_unsealed_old_snapshot_is_refused(self):
import json
from routes.magazine import magazine_undo
from core import magazine as mag
(self.dir / "undo.json").write_text(json.dumps({"token": "t", "label": "x", "layout": mag.load_layout(self.dir),
"articles": mag.load_articles(self.dir)}), "utf-8")
with self.assertRaises(HTTPException):
self.run_route(magazine_undo, body={"token": "t"})
def test_drawing_a_region_on_a_layout_with_a_split_author_line_works(self):
""""Could not add region: Block.__init__() got an unexpected keyword argument 'original_text'"."""
from core.magazine import add_manual_region, clean_up_regions
self.layout["blocks"].append({"id": "by", "page": 1, "bbox": [50, 20, 300, 30], "type": "byline",
"text": "Von Jo Bager KI gilt als Antwort", "reading_order": 9})
cleaned, report = clean_up_regions(self.layout)
self.assertEqual(len(report["byline_split"]), 1)
after = add_manual_region(self.dir, cleaned, 1, [400, 700, 100, 40], "text", "New text")
byline = next(b for b in after["blocks"] if b["id"] == "by")
self.assertIn("KI gilt als Antwort", byline["original_text"])
self.assertTrue(any(b["text"] == "New text" for b in after["blocks"]))
def test_blocks_without_the_field_do_not_gain_it(self):
from core.magazine import Block
self.assertNotIn("original_text", Block(id="x", page=1, bbox=[0, 0, 1, 1], type="body", text="t").to_json())