Durable curation: persisted splits + pre-resolve title edits, catalog A→Z
Wiz-War had no split button: can_split required a matches row, but fresh extractions leave multi-photo titles rowless until resolve runs. Splits are now a title-level decision persisted in data/title_splits.json, honored by extract's dedupe and resolve's dedupe on every rebuild, with the button on any multi-photo line — resolved or not. Same mechanism carries human corrections: data/title_edits.json stores fixed misreads and known cues (publisher/edition/year/language), applied before dedupe on every titles.json rebuild, editable from a new inline form on every catalog line. An edit drops the title's stale matches rows so resolve re-queries with the corrected data. The catalog page now sorts alphabetically (case-insensitive; split copies stay adjacent) instead of extraction order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
24a7bcb9e0
commit
a7f0cfee05
@@ -66,6 +66,18 @@ class Config:
|
||||
def games_path(self) -> Path:
|
||||
return self.data_dir / "games.json"
|
||||
|
||||
@property
|
||||
def title_edits_path(self) -> Path:
|
||||
# human corrections to extracted reads (misspellings, known cues) —
|
||||
# replayed on every titles.json rebuild
|
||||
return self.data_dir / "title_edits.json"
|
||||
|
||||
@property
|
||||
def title_splits_path(self) -> Path:
|
||||
# titles the human declared to be MULTIPLE physical copies: extract
|
||||
# and resolve dedupe must never cross-photo-merge them again
|
||||
return self.data_dir / "title_splits.json"
|
||||
|
||||
@property
|
||||
def dismissed_path(self) -> Path:
|
||||
return self.data_dir / "unidentified_dismissed.json"
|
||||
|
||||
+124
-5
@@ -240,11 +240,93 @@ def _merge(a: dict, b: dict) -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
def dedupe_entries(entries: list[dict]) -> list[dict]:
|
||||
"""Collapse same-normalized-title sightings unless their cues conflict."""
|
||||
# the fields a human correction may override on an extracted entry
|
||||
EDIT_FIELDS = (
|
||||
"title_raw",
|
||||
"publisher_hint",
|
||||
"edition_hint",
|
||||
"year_hint",
|
||||
"language_hint",
|
||||
"art_notes",
|
||||
)
|
||||
|
||||
|
||||
def load_title_edits(path: Path) -> list[dict]:
|
||||
"""Human corrections to raw reads (fixed misspellings, cues the owner
|
||||
knows offhand). Each record: {"match": <title as displayed when the fix
|
||||
was made>, "photos": [...] to target one copy (optional), <EDIT_FIELDS
|
||||
to override>}. Applied on every rebuild, before dedupe."""
|
||||
if not path.exists():
|
||||
return []
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def record_title_edit(path: Path, record: dict) -> None:
|
||||
existing = load_title_edits(path)
|
||||
atomic_write_text(
|
||||
path, json.dumps([*existing, record], indent=2, ensure_ascii=False) + "\n"
|
||||
)
|
||||
|
||||
|
||||
def apply_title_edits(entries: list[dict], edits: list[dict]) -> list[dict]:
|
||||
"""Apply stored corrections in order. Matching is by the title an entry
|
||||
CURRENTLY carries, so a later edit made against an earlier edit's result
|
||||
chains naturally; each record applies at most once per entry."""
|
||||
if not edits:
|
||||
return entries
|
||||
for entry in entries:
|
||||
applied: set[int] = set()
|
||||
while True:
|
||||
norm = normalize_title(entry["title_raw"])
|
||||
photos = set(entry.get("source_photos") or [])
|
||||
hit = next(
|
||||
(
|
||||
i
|
||||
for i, e in enumerate(edits)
|
||||
if i not in applied
|
||||
and normalize_title(e["match"]) == norm
|
||||
and (not e.get("photos") or set(e["photos"]) & photos)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if hit is None:
|
||||
break
|
||||
applied.add(hit)
|
||||
for key in EDIT_FIELDS:
|
||||
if key in edits[hit]:
|
||||
entry[key] = edits[hit][key]
|
||||
return entries
|
||||
|
||||
|
||||
def load_title_splits(path: Path) -> set[str]:
|
||||
"""Normalized titles the human split into per-photo copies — a durable
|
||||
review decision that must survive extract rebuilds and resolve --force."""
|
||||
if not path.exists():
|
||||
return set()
|
||||
return {normalize_title(t) for t in json.loads(path.read_text())}
|
||||
|
||||
|
||||
def record_title_split(path: Path, title: str) -> None:
|
||||
existing = json.loads(path.read_text()) if path.exists() else []
|
||||
if normalize_title(title) in {normalize_title(t) for t in existing}:
|
||||
return
|
||||
atomic_write_text(
|
||||
path, json.dumps([*existing, title], indent=2, ensure_ascii=False) + "\n"
|
||||
)
|
||||
|
||||
|
||||
def dedupe_entries(
|
||||
entries: list[dict], split_titles: set[str] = frozenset()
|
||||
) -> list[dict]:
|
||||
"""Collapse same-normalized-title sightings unless their cues conflict —
|
||||
or unless the human declared the title split (several physical copies):
|
||||
those stay one entry per photo."""
|
||||
result: list[dict] = []
|
||||
for entry in entries:
|
||||
entry = {**entry, "title_normalized": normalize_title(entry["title_raw"])}
|
||||
if entry["title_normalized"] in split_titles:
|
||||
result.append(entry)
|
||||
continue
|
||||
for existing in result:
|
||||
if existing["title_normalized"] == entry[
|
||||
"title_normalized"
|
||||
@@ -257,7 +339,11 @@ def dedupe_entries(entries: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def rebuild_artifacts(
|
||||
raw_dir: Path, titles_path: Path, unidentified_path: Path
|
||||
raw_dir: Path,
|
||||
titles_path: Path,
|
||||
unidentified_path: Path,
|
||||
split_titles: set[str] = frozenset(),
|
||||
edits: list[dict] | None = None,
|
||||
) -> tuple[list[dict], dict[str, list[dict]]]:
|
||||
"""Regenerate titles.json and unidentified.json from the per-photo raw
|
||||
cache. A raw file is either an object with titles/unidentified or a bare
|
||||
@@ -279,7 +365,7 @@ def rebuild_artifacts(
|
||||
photo = raw_file.name.removesuffix(".json")
|
||||
if data.get("unidentified"):
|
||||
unidentified[photo] = data["unidentified"]
|
||||
deduped = dedupe_entries(entries)
|
||||
deduped = dedupe_entries(apply_title_edits(entries, edits or []), split_titles)
|
||||
titles_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_text(
|
||||
titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
|
||||
@@ -290,6 +376,35 @@ def rebuild_artifacts(
|
||||
return deduped, unidentified
|
||||
|
||||
|
||||
def replay_titles(cfg: Config) -> None:
|
||||
"""Re-derive titles.json after a stored split or edit changed the rules.
|
||||
Prefers the raw caches (per-photo cue fidelity); without them (trimmed
|
||||
or hand-built data) it replays the current titles.json entries through
|
||||
the same edit + dedupe path, exploding multi-photo entries first so
|
||||
photo-level splits and targeted edits can take hold."""
|
||||
splits = load_title_splits(cfg.title_splits_path)
|
||||
edits = load_title_edits(cfg.title_edits_path)
|
||||
raw_dir = cfg.extract_raw_dir
|
||||
if raw_dir.is_dir() and any(raw_dir.glob("*.json")):
|
||||
rebuild_artifacts(
|
||||
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits
|
||||
)
|
||||
return
|
||||
if not cfg.titles_path.exists():
|
||||
return
|
||||
exploded: list[dict] = []
|
||||
for entry in json.loads(cfg.titles_path.read_text()):
|
||||
photos = entry.get("source_photos") or []
|
||||
if len(photos) > 1:
|
||||
exploded.extend({**entry, "source_photos": [p]} for p in photos)
|
||||
else:
|
||||
exploded.append(dict(entry))
|
||||
deduped = dedupe_entries(apply_title_edits(exploded, edits), splits)
|
||||
atomic_write_text(
|
||||
cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
|
||||
)
|
||||
|
||||
|
||||
def run_extract(
|
||||
cfg: Config,
|
||||
*,
|
||||
@@ -370,7 +485,11 @@ def run_extract(
|
||||
typer.echo(f" {photo.name}: {len(result['titles'])} title(s){note}")
|
||||
|
||||
deduped, unidentified = rebuild_artifacts(
|
||||
raw_dir, cfg.titles_path, cfg.unidentified_path
|
||||
raw_dir,
|
||||
cfg.titles_path,
|
||||
cfg.unidentified_path,
|
||||
load_title_splits(cfg.title_splits_path),
|
||||
load_title_edits(cfg.title_edits_path),
|
||||
)
|
||||
typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
|
||||
if failed:
|
||||
|
||||
+11
-9
@@ -22,7 +22,7 @@ from rapidfuzz import fuzz
|
||||
|
||||
from bggpipe.bgg_client import BGGAuthError, BGGClient, BGGQueueTimeout, client_for
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.extract import cues_conflict
|
||||
from bggpipe.extract import cues_conflict, load_title_splits
|
||||
from bggpipe.fsio import atomic_write_csv
|
||||
from bggpipe.models import (
|
||||
RECOGNIZED_MATCH_STATUSES,
|
||||
@@ -406,9 +406,7 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
|
||||
# type=rpgitem (same API, same token). A hit becomes a LOCAL
|
||||
# library citizen: identified and enriched, never uploaded (diff
|
||||
# routes rpgitem rows to local_only).
|
||||
cands = _plausible_candidates(
|
||||
client, entry, entry.title_raw, types="rpgitem"
|
||||
)
|
||||
cands = _plausible_candidates(client, entry, entry.title_raw, types="rpgitem")
|
||||
if not cands:
|
||||
for head in _truncation_heads(entry.title_raw):
|
||||
cands = _plausible_candidates(
|
||||
@@ -430,7 +428,11 @@ class MergeEvent:
|
||||
bgg_id: str
|
||||
|
||||
|
||||
def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEvent]:
|
||||
def dedupe_matches(
|
||||
rows: list[dict],
|
||||
titles: list[TitleEntry],
|
||||
split_titles: set[str] = frozenset(),
|
||||
) -> list[MergeEvent]:
|
||||
"""Post-resolve dedupe: rows resolving to the same (bgg_id, version_id —
|
||||
or both version-unknown) are the same physical game seen twice (a typo
|
||||
read, a partial spine) UNLESS their extraction cues conflict, which
|
||||
@@ -464,6 +466,8 @@ def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEven
|
||||
# re-running resolve must never overturn that (spec: re-runs
|
||||
# lose no work, least of all review decisions)
|
||||
continue
|
||||
if normalize_title(row["title_raw"]) in split_titles:
|
||||
continue # human-split title: per-photo rows stay separate
|
||||
key = (
|
||||
row["bgg_id"],
|
||||
row["version_id"] if is_confident_version(row) else "",
|
||||
@@ -603,9 +607,7 @@ def run_resolve(
|
||||
row_dict = paired_by_id.get(id(entry))
|
||||
if row_dict is not None:
|
||||
photos = ";".join(entry.source_photos)
|
||||
if row_dict["source_photos"] != photos and not row_dict.get(
|
||||
"dedupe_veto"
|
||||
):
|
||||
if row_dict["source_photos"] != photos and not row_dict.get("dedupe_veto"):
|
||||
# provenance follows the entry — except on split/vetoed rows,
|
||||
# whose per-copy photo sets are human-authored
|
||||
row_dict["source_photos"] = photos
|
||||
@@ -639,7 +641,7 @@ def run_resolve(
|
||||
typer.echo(f" {entry.title_raw!r} -> {row.match_status}: {detail}{version}")
|
||||
|
||||
all_rows = existing_rows + [row.to_csv() for row in new_rows]
|
||||
merges = dedupe_matches(all_rows, entries)
|
||||
merges = dedupe_matches(all_rows, entries, load_title_splits(cfg.title_splits_path))
|
||||
if new_rows or photos_updated or merges:
|
||||
write_matches(cfg.matches_path, all_rows) # atomic full rewrite
|
||||
if merges:
|
||||
|
||||
@@ -258,6 +258,27 @@ class ReviewSession:
|
||||
self._save(copies[0])
|
||||
return copies
|
||||
|
||||
def drop_rows(self, title_raw: str, photos: list[str] | None = None) -> int:
|
||||
"""An edit invalidated these rows — the BGG match was made against
|
||||
the uncorrected read. Remove them so resolve re-queries with the
|
||||
fix; `photos` narrows the cull to one copy of a split title."""
|
||||
self.reload_if_changed()
|
||||
|
||||
def stale(row: dict) -> bool:
|
||||
if row["title_raw"] != title_raw:
|
||||
return False
|
||||
if photos is None:
|
||||
return True
|
||||
row_photos = {p for p in row["source_photos"].split(";") if p}
|
||||
return bool(row_photos & set(photos))
|
||||
|
||||
keep = [r for r in self.rows if not stale(r)]
|
||||
dropped = len(self.rows) - len(keep)
|
||||
if dropped:
|
||||
self.rows = keep
|
||||
self._save()
|
||||
return dropped
|
||||
|
||||
def veto_merge(self, row: dict) -> None:
|
||||
"""The human says these are NOT the same physical game: restore the
|
||||
row as a distinct, human-confirmed match."""
|
||||
|
||||
@@ -310,6 +310,21 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.catalog a:hover { color: var(--accent-ink); }
|
||||
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
|
||||
.editform { display: flex; gap: .7rem; align-items: end; flex-wrap: wrap; }
|
||||
.editform label {
|
||||
display: flex; flex-direction: column; gap: .15rem;
|
||||
font-size: .72rem; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.editform input {
|
||||
font: inherit; font-size: .85rem; color: var(--ink);
|
||||
border: 1px solid var(--board-edge); border-radius: var(--radius);
|
||||
padding: .25rem .45rem; background: var(--board);
|
||||
}
|
||||
.editform input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.editactions { display: flex; gap: .5rem; }
|
||||
.edithint { font-size: .78rem; color: var(--ink-soft); align-self: center; }
|
||||
.chip {
|
||||
font-size: .68rem; text-transform: uppercase; letter-spacing: .06em;
|
||||
border-radius: 999px; padding: .1rem .55rem; white-space: nowrap;
|
||||
|
||||
@@ -7,12 +7,37 @@
|
||||
<script>
|
||||
"use strict";
|
||||
let CATALOG = [];
|
||||
let EDITING = null; // lineKey of the row whose editor is open
|
||||
|
||||
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
|
||||
|
||||
function editorRow(c) {
|
||||
const cue = c.cues || {};
|
||||
return `
|
||||
<tr class="editrow"><td colspan="5">
|
||||
<form class="editform" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}">
|
||||
<label>Title <input name="title" value="${esc(c.title_raw)}" required></label>
|
||||
<label>Publisher <input name="publisher" value="${esc(cue.publisher || "")}"></label>
|
||||
<label>Edition <input name="edition" value="${esc(cue.edition || "")}"></label>
|
||||
<label>Year <input name="year" value="${esc(cue.year ?? "")}" inputmode="numeric" size="6"></label>
|
||||
<label>Language <input name="language" value="${esc(cue.language || "")}"></label>
|
||||
<span class="editactions">
|
||||
<button type="submit" class="primary">save</button>
|
||||
<button type="button" class="canceledit">cancel</button>
|
||||
</span>
|
||||
<span class="edithint">saving re-queues this title for resolve with the corrected data</span>
|
||||
</form>
|
||||
</td></tr>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const q = document.getElementById("catsearch").value.trim().toLowerCase();
|
||||
const sorted = [...CATALOG].sort((a, b) =>
|
||||
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" }));
|
||||
const rows = q
|
||||
? CATALOG.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
|
||||
: CATALOG;
|
||||
? sorted.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
|
||||
: sorted;
|
||||
document.getElementById("catcount").innerHTML =
|
||||
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
|
||||
document.getElementById("catbody").innerHTML = rows.length
|
||||
@@ -27,13 +52,17 @@ function render() {
|
||||
<td class="meta">${c.photos.map(p =>
|
||||
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
|
||||
).join(", ")}</td>
|
||||
<td>${c.can_split
|
||||
<td class="rowactions">${c.can_split
|
||||
? `<button class="split" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix}"
|
||||
data-photos="${esc(c.photos.join(";"))}"
|
||||
data-rowix="${c.row_ix ?? ""}"
|
||||
title="one line, several boxes? make each photo its own copy">
|
||||
split into copies</button>`
|
||||
: ""}</td>
|
||||
</tr>`).join("") + `</table></div>`
|
||||
: ""}
|
||||
<button class="edit" data-key="${esc(lineKey(c))}"
|
||||
title="fix a misread title or add cues you already know">edit</button>
|
||||
</td>
|
||||
</tr>` + (EDITING === lineKey(c) ? editorRow(c) : "")).join("") + `</table></div>`
|
||||
: `<p class="empty">${CATALOG.length
|
||||
? "No titles match that filter."
|
||||
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
|
||||
@@ -42,6 +71,7 @@ function render() {
|
||||
let LAST = null;
|
||||
async function refresh() {
|
||||
const state = await fetchJSON("/api/state");
|
||||
if (EDITING) return; // never repaint under an open editor
|
||||
const payload = JSON.stringify(state.catalog);
|
||||
if (payload === LAST) return;
|
||||
LAST = payload;
|
||||
@@ -50,6 +80,14 @@ async function refresh() {
|
||||
}
|
||||
|
||||
document.getElementById("catbody").addEventListener("click", async e => {
|
||||
const cancel = e.target.closest("button.canceledit");
|
||||
if (cancel) { EDITING = null; LAST = null; render(); refresh().catch(() => {}); return; }
|
||||
const edit = e.target.closest("button.edit");
|
||||
if (edit) {
|
||||
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const b = e.target.closest("button.split");
|
||||
if (!b) return;
|
||||
const n = b.dataset.photos.split(";").length;
|
||||
@@ -58,10 +96,29 @@ document.getElementById("catbody").addEventListener("click", async e => {
|
||||
const res = await apiPost("/api/split", {
|
||||
title_raw: b.dataset.title,
|
||||
source_photos: b.dataset.photos,
|
||||
row_ix: Number(b.dataset.rowix),
|
||||
row_ix: b.dataset.rowix === "" ? null : Number(b.dataset.rowix),
|
||||
});
|
||||
if (res) refresh().catch(() => {});
|
||||
});
|
||||
|
||||
document.getElementById("catbody").addEventListener("submit", async e => {
|
||||
const f = e.target.closest("form.editform");
|
||||
if (!f) return;
|
||||
e.preventDefault();
|
||||
const orig = CATALOG.find(c => lineKey(c) === EDITING) || {};
|
||||
const cue = orig.cues || {};
|
||||
const v = name => f.elements[name].value;
|
||||
const body = { title_raw: f.dataset.title, source_photos: f.dataset.photos };
|
||||
if (v("title").trim() !== f.dataset.title) body.title_new = v("title").trim();
|
||||
if (v("publisher") !== (cue.publisher || "")) body.publisher = v("publisher");
|
||||
if (v("edition") !== (cue.edition || "")) body.edition = v("edition");
|
||||
if (v("year") !== String(cue.year ?? "")) body.year = v("year");
|
||||
if (v("language") !== (cue.language || "")) body.language = v("language");
|
||||
if (Object.keys(body).length <= 2) { EDITING = null; render(); return; }
|
||||
const res = await apiPost("/api/edit-title", body);
|
||||
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
|
||||
});
|
||||
|
||||
document.getElementById("catsearch").addEventListener("input", render);
|
||||
refresh().catch(err => errorBanner(err.message || err));
|
||||
pollLoop(refresh, 5000, () => showBanner(""));
|
||||
|
||||
+118
-6
@@ -38,6 +38,11 @@ from rich.console import Console
|
||||
|
||||
from bggpipe.bgg_client import BGGClient, cached_paths
|
||||
from bggpipe.config import DEFAULT_REVIEW_PORT, Config
|
||||
from bggpipe.extract import (
|
||||
record_title_edit,
|
||||
record_title_split,
|
||||
replay_titles,
|
||||
)
|
||||
from bggpipe.fsio import atomic_write_bytes, atomic_write_text
|
||||
from bggpipe.jobs import JobRunner
|
||||
from bggpipe.models import (
|
||||
@@ -148,6 +153,19 @@ class SplitBody(BaseModel):
|
||||
row_ix: int | None = None
|
||||
|
||||
|
||||
class EditBody(BaseModel):
|
||||
"""A human correction to an extracted read. None = leave that field
|
||||
alone; empty string = clear it."""
|
||||
|
||||
title_raw: str
|
||||
source_photos: str = ""
|
||||
title_new: str | None = None
|
||||
publisher: str | None = None
|
||||
edition: str | None = None
|
||||
year: str | None = None
|
||||
language: str | None = None
|
||||
|
||||
|
||||
class RunBody(BaseModel):
|
||||
dry_run: bool = True # upload only; the safe direction is the default
|
||||
limit: int | None = None
|
||||
@@ -377,12 +395,21 @@ def create_app(
|
||||
"version_name": row["version_name"] if row else "",
|
||||
"merged_into": row.get("merged_into", "") if row else "",
|
||||
"row_ix": _ix_of(session.rows, row) if row else None,
|
||||
"cues": {
|
||||
"publisher": entry.publisher_hint if entry else "",
|
||||
"edition": entry.edition_hint if entry else "",
|
||||
"year": entry.year_hint if entry else None,
|
||||
"language": entry.language_hint if entry else "",
|
||||
},
|
||||
"can_split": bool(
|
||||
row
|
||||
and row["match_status"] in RECOGNIZED_MATCH_STATUSES
|
||||
and not row.get("dedupe_veto")
|
||||
and len(row["source_photos"].split(";")) > 1
|
||||
),
|
||||
)
|
||||
# no matches row yet (title still awaiting resolve): the
|
||||
# split is a titles.json decision, no BGG data at stake
|
||||
or bool(not row and entry and len(entry.source_photos) > 1),
|
||||
"split_copy": bool(row and row.get("dedupe_veto")),
|
||||
}
|
||||
|
||||
@@ -695,16 +722,101 @@ def create_app(
|
||||
raise HTTPException(400, f"unknown action {body.action!r}")
|
||||
return state()
|
||||
|
||||
def _find_entry(title_raw: str, source_photos: str):
|
||||
photos = [p for p in source_photos.split(";") if p]
|
||||
return next(
|
||||
(
|
||||
e
|
||||
for e in session.titles
|
||||
if e.title_raw == title_raw
|
||||
and (not photos or list(e.source_photos) == photos)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@app.post("/api/split")
|
||||
def api_split(body: SplitBody) -> dict:
|
||||
with lock:
|
||||
revision["n"] += 1
|
||||
_refuse_if_rewriting()
|
||||
row = find_row(body.title_raw, body.source_photos, body.row_ix)
|
||||
try:
|
||||
session.split_row(row)
|
||||
except ValueError as err:
|
||||
raise HTTPException(400, str(err)) from err
|
||||
freshen()
|
||||
row = session.find_row(body.title_raw, body.source_photos, body.row_ix)
|
||||
if row is not None:
|
||||
try:
|
||||
session.split_row(row)
|
||||
except ValueError as err:
|
||||
raise HTTPException(400, str(err)) from err
|
||||
else:
|
||||
# no matches row yet — the title is still awaiting resolve;
|
||||
# splitting is purely a titles.json (extraction) decision
|
||||
entry = _find_entry(body.title_raw, body.source_photos)
|
||||
if entry is None:
|
||||
raise HTTPException(
|
||||
404, "title not found — titles.json changed underneath?"
|
||||
)
|
||||
if len(entry.source_photos) < 2:
|
||||
raise HTTPException(
|
||||
400, "only a multi-photo title can be split into copies"
|
||||
)
|
||||
# persist the decision so every future extract rebuild and
|
||||
# resolve dedupe keeps the copies apart, then re-derive
|
||||
# titles.json so each copy carries its own photo's cues
|
||||
record_title_split(cfg.title_splits_path, body.title_raw)
|
||||
replay_titles(cfg)
|
||||
return state()
|
||||
|
||||
@app.post("/api/edit-title")
|
||||
def api_edit_title(body: EditBody) -> dict:
|
||||
with lock:
|
||||
revision["n"] += 1
|
||||
_refuse_if_rewriting()
|
||||
freshen()
|
||||
entry = _find_entry(body.title_raw, body.source_photos)
|
||||
if entry is None:
|
||||
raise HTTPException(
|
||||
404, "title not found — titles.json changed underneath?"
|
||||
)
|
||||
record: dict = {"match": body.title_raw}
|
||||
photos = [p for p in body.source_photos.split(";") if p]
|
||||
same_title = [e for e in session.titles if e.title_raw == body.title_raw]
|
||||
if photos and len(same_title) > 1:
|
||||
# several copies/editions share this title: the fix targets
|
||||
# only the copy the human was looking at
|
||||
record["photos"] = photos
|
||||
if body.title_new is not None:
|
||||
corrected = body.title_new.strip()
|
||||
if not corrected:
|
||||
raise HTTPException(400, "corrected title cannot be empty")
|
||||
if corrected != body.title_raw:
|
||||
record["title_raw"] = corrected
|
||||
for key, value in (
|
||||
("publisher_hint", body.publisher),
|
||||
("edition_hint", body.edition),
|
||||
("language_hint", body.language),
|
||||
):
|
||||
if value is not None:
|
||||
record[key] = value.strip()
|
||||
if body.year is not None:
|
||||
year = body.year.strip()
|
||||
if year and not year.isdigit():
|
||||
raise HTTPException(400, "year must be a number")
|
||||
record["year_hint"] = int(year) if year else None
|
||||
if not any(
|
||||
key in record
|
||||
for key in (
|
||||
"title_raw",
|
||||
"publisher_hint",
|
||||
"edition_hint",
|
||||
"language_hint",
|
||||
"year_hint",
|
||||
)
|
||||
):
|
||||
raise HTTPException(400, "nothing to change")
|
||||
record_title_edit(cfg.title_edits_path, record)
|
||||
replay_titles(cfg)
|
||||
# rows matched against the uncorrected read are stale: drop
|
||||
# them so the next resolve re-queries with the fix
|
||||
session.drop_rows(body.title_raw, photos if "photos" in record else None)
|
||||
return state()
|
||||
|
||||
@app.post("/api/veto-merge")
|
||||
|
||||
Reference in New Issue
Block a user