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
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:
parent
5009f388e7
commit
f155839300
13
CHANGELOG.md
13
CHANGELOG.md
@ -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
|
||||
|
||||
14
Manual.md
14
Manual.md
@ -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
11
TODO.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) ── -->
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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' });
|
||||
});
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user