Remove-from-catalog: a third durable curation store
Titles that aren't games (misread box art, out-of-scope items) can now be removed outright: a danger button in the catalog's edit panel posts /api/remove-title, which drops the line's matches rows (veto'd ones too — removal is the human explicitly discarding the line), records the decision photo-scoped in data/title_removals.json, and replays titles.json. Every rebuild filters removed sightings after edits and before dedupe, so re-extraction cannot resurrect them; undo by deleting the record from the store. The three stores now share one scoped-record parser and recorder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
@@ -47,11 +47,11 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel
|
|||||||
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
|
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
|
||||||
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
|
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
|
||||||
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
|
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
|
||||||
- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe) and `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place).
|
- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), and `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
|
||||||
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
|
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
|
||||||
- Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift.
|
- Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift.
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub** — `gh` CLI does not work here.
|
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub** — `gh` CLI does not work here.
|
||||||
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, Playwright storage state, or `.env`.
|
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, Playwright storage state, or `.env`.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"match": "Hangermuger 4 800",
|
||||||
|
"title_raw": "Huggermugger"
|
||||||
|
}
|
||||||
|
]
|
||||||
+2
-2
@@ -783,7 +783,7 @@
|
|||||||
"title_normalized": "dixit"
|
"title_normalized": "dixit"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title_raw": "Hangermuger 4 800",
|
"title_raw": "Huggermugger",
|
||||||
"confidence": "low",
|
"confidence": "low",
|
||||||
"publisher_hint": "",
|
"publisher_hint": "",
|
||||||
"edition_hint": "",
|
"edition_hint": "",
|
||||||
@@ -793,7 +793,7 @@
|
|||||||
"source_photos": [
|
"source_photos": [
|
||||||
"IMG_4514.jpeg"
|
"IMG_4514.jpeg"
|
||||||
],
|
],
|
||||||
"title_normalized": "hangermuger 4 800"
|
"title_normalized": "huggermugger"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title_raw": "The Ain't It Cool Trivia Game",
|
"title_raw": "The Ain't It Cool Trivia Game",
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ class Config:
|
|||||||
def games_path(self) -> Path:
|
def games_path(self) -> Path:
|
||||||
return self.data_dir / "games.json"
|
return self.data_dir / "games.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title_removals_path(self) -> Path:
|
||||||
|
# titles the human removed from the catalog (not a game, misread) —
|
||||||
|
# filtered out of every titles.json rebuild
|
||||||
|
return self.data_dir / "title_removals.json"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title_edits_path(self) -> Path:
|
def title_edits_path(self) -> Path:
|
||||||
# human corrections to extracted reads (misspellings, known cues) —
|
# human corrections to extracted reads (misspellings, known cues) —
|
||||||
|
|||||||
+57
-11
@@ -313,11 +313,10 @@ def apply_title_edits(entries: list[dict], edits: list[dict]) -> list[dict]:
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def load_title_splits(path: Path) -> list[dict]:
|
def _scoped_records(path: Path) -> list[dict]:
|
||||||
"""The human's split-into-copies decisions — durable: they must survive
|
"""Parse a store of photo-scoped title decisions. Each stored record is
|
||||||
extract rebuilds and resolve --force. Each stored record is
|
{"title": ..., "photos": [...]} scoping the decision to the sightings
|
||||||
{"title": ..., "photos": [...]} scoping the split to the sightings that
|
that were on the line (a bare string is legacy: unscoped). Returns
|
||||||
were on the split line (a bare string is legacy: unscoped). Returns
|
|
||||||
[{"norm": <normalized title>, "photos": set | None}]."""
|
[{"norm": <normalized title>, "photos": set | None}]."""
|
||||||
records = []
|
records = []
|
||||||
for item in _load_store(path):
|
for item in _load_store(path):
|
||||||
@@ -334,6 +333,20 @@ def load_title_splits(path: Path) -> list[dict]:
|
|||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def load_title_splits(path: Path) -> list[dict]:
|
||||||
|
"""The human's split-into-copies decisions — durable: they must survive
|
||||||
|
extract rebuilds and resolve --force."""
|
||||||
|
return _scoped_records(path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_title_removals(path: Path) -> list[dict]:
|
||||||
|
"""Titles the human removed from the catalog (not a game, a misread of
|
||||||
|
box art, out of scope) — their sightings are filtered out of every
|
||||||
|
rebuild, so re-extraction cannot resurrect them. Undo by deleting the
|
||||||
|
record from the store file."""
|
||||||
|
return _scoped_records(path)
|
||||||
|
|
||||||
|
|
||||||
def is_split(norm: str, photos, splits: list[dict]) -> bool:
|
def is_split(norm: str, photos, splits: list[dict]) -> bool:
|
||||||
"""Does a split decision cover this (normalized title, photo set)?
|
"""Does a split decision cover this (normalized title, photo set)?
|
||||||
Photo-scoped records only bind sightings from the photos that were on
|
Photo-scoped records only bind sightings from the photos that were on
|
||||||
@@ -345,9 +358,9 @@ def is_split(norm: str, photos, splits: list[dict]) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def record_title_split(path: Path, title: str, photos: list[str] | None = None) -> None:
|
def _record_scoped(path: Path, title: str, photos: list[str] | None) -> None:
|
||||||
if is_split(normalize_title(title), photos or [], load_title_splits(path)):
|
if is_split(normalize_title(title), photos or [], _scoped_records(path)):
|
||||||
return
|
return # an existing record already covers this line
|
||||||
existing = _load_store(path)
|
existing = _load_store(path)
|
||||||
record = {"title": title, "photos": sorted(photos) if photos else None}
|
record = {"title": title, "photos": sorted(photos) if photos else None}
|
||||||
atomic_write_text(
|
atomic_write_text(
|
||||||
@@ -355,6 +368,16 @@ def record_title_split(path: Path, title: str, photos: list[str] | None = None)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_title_split(path: Path, title: str, photos: list[str] | None = None) -> None:
|
||||||
|
_record_scoped(path, title, photos)
|
||||||
|
|
||||||
|
|
||||||
|
def record_title_removal(
|
||||||
|
path: Path, title: str, photos: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
_record_scoped(path, title, photos)
|
||||||
|
|
||||||
|
|
||||||
def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> list[dict]:
|
def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> list[dict]:
|
||||||
"""Collapse same-normalized-title sightings unless their cues conflict —
|
"""Collapse same-normalized-title sightings unless their cues conflict —
|
||||||
or unless the human declared them split (several physical copies):
|
or unless the human declared them split (several physical copies):
|
||||||
@@ -383,12 +406,28 @@ def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> lis
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_title_removals(entries: list[dict], removals: list[dict]) -> list[dict]:
|
||||||
|
"""Filter out sightings the human removed. Runs AFTER edits (records
|
||||||
|
key on the title as displayed when removal was clicked) and before
|
||||||
|
dedupe (so a removed sighting can't be absorbed into a survivor)."""
|
||||||
|
if not removals:
|
||||||
|
return entries
|
||||||
|
return [
|
||||||
|
e
|
||||||
|
for e in entries
|
||||||
|
if not is_split(
|
||||||
|
normalize_title(e["title_raw"]), e.get("source_photos"), removals
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def rebuild_artifacts(
|
def rebuild_artifacts(
|
||||||
raw_dir: Path,
|
raw_dir: Path,
|
||||||
titles_path: Path,
|
titles_path: Path,
|
||||||
unidentified_path: Path,
|
unidentified_path: Path,
|
||||||
splits: list[dict] | None = None,
|
splits: list[dict] | None = None,
|
||||||
edits: list[dict] | None = None,
|
edits: list[dict] | None = None,
|
||||||
|
removals: list[dict] | None = None,
|
||||||
) -> tuple[list[dict], dict[str, list[dict]]]:
|
) -> tuple[list[dict], dict[str, list[dict]]]:
|
||||||
"""Regenerate titles.json and unidentified.json from the per-photo raw
|
"""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
|
cache. A raw file is either an object with titles/unidentified or a bare
|
||||||
@@ -410,7 +449,10 @@ def rebuild_artifacts(
|
|||||||
photo = raw_file.name.removesuffix(".json")
|
photo = raw_file.name.removesuffix(".json")
|
||||||
if data.get("unidentified"):
|
if data.get("unidentified"):
|
||||||
unidentified[photo] = data["unidentified"]
|
unidentified[photo] = data["unidentified"]
|
||||||
deduped = dedupe_entries(apply_title_edits(entries, edits or []), splits)
|
entries = apply_title_removals(
|
||||||
|
apply_title_edits(entries, edits or []), removals or []
|
||||||
|
)
|
||||||
|
deduped = dedupe_entries(entries, splits)
|
||||||
titles_path.parent.mkdir(parents=True, exist_ok=True)
|
titles_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
atomic_write_text(
|
atomic_write_text(
|
||||||
titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
|
titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
|
||||||
@@ -429,6 +471,7 @@ def replay_titles(cfg: Config) -> None:
|
|||||||
photo-level splits and targeted edits can take hold."""
|
photo-level splits and targeted edits can take hold."""
|
||||||
splits = load_title_splits(cfg.title_splits_path)
|
splits = load_title_splits(cfg.title_splits_path)
|
||||||
edits = load_title_edits(cfg.title_edits_path)
|
edits = load_title_edits(cfg.title_edits_path)
|
||||||
|
removals = load_title_removals(cfg.title_removals_path)
|
||||||
raw_dir = cfg.extract_raw_dir
|
raw_dir = cfg.extract_raw_dir
|
||||||
raw_photos = (
|
raw_photos = (
|
||||||
{f.name.removesuffix(".json") for f in raw_dir.glob("*.json")}
|
{f.name.removesuffix(".json") for f in raw_dir.glob("*.json")}
|
||||||
@@ -444,7 +487,7 @@ def replay_titles(cfg: Config) -> None:
|
|||||||
# them would silently truncate the committed catalog
|
# them would silently truncate the committed catalog
|
||||||
if raw_photos and raw_photos >= known_photos:
|
if raw_photos and raw_photos >= known_photos:
|
||||||
rebuild_artifacts(
|
rebuild_artifacts(
|
||||||
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits
|
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits, removals
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not cfg.titles_path.exists():
|
if not cfg.titles_path.exists():
|
||||||
@@ -456,7 +499,9 @@ def replay_titles(cfg: Config) -> None:
|
|||||||
exploded.extend({**entry, "source_photos": [p]} for p in photos)
|
exploded.extend({**entry, "source_photos": [p]} for p in photos)
|
||||||
else:
|
else:
|
||||||
exploded.append(dict(entry))
|
exploded.append(dict(entry))
|
||||||
deduped = dedupe_entries(apply_title_edits(exploded, edits), splits)
|
deduped = dedupe_entries(
|
||||||
|
apply_title_removals(apply_title_edits(exploded, edits), removals), splits
|
||||||
|
)
|
||||||
atomic_write_text(
|
atomic_write_text(
|
||||||
cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
|
cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
|
||||||
)
|
)
|
||||||
@@ -547,6 +592,7 @@ def run_extract(
|
|||||||
cfg.unidentified_path,
|
cfg.unidentified_path,
|
||||||
load_title_splits(cfg.title_splits_path),
|
load_title_splits(cfg.title_splits_path),
|
||||||
load_title_edits(cfg.title_edits_path),
|
load_title_edits(cfg.title_edits_path),
|
||||||
|
load_title_removals(cfg.title_removals_path),
|
||||||
)
|
)
|
||||||
typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
|
typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
|
||||||
if failed:
|
if failed:
|
||||||
|
|||||||
@@ -270,13 +270,16 @@ class ReviewSession:
|
|||||||
title_raw: str,
|
title_raw: str,
|
||||||
photos: list[str] | None = None,
|
photos: list[str] | None = None,
|
||||||
new_title: str | None = None,
|
new_title: str | None = None,
|
||||||
|
even_vetoed: bool = False,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""An edit invalidated these rows — the BGG match was made against
|
"""An edit invalidated these rows — the BGG match was made against
|
||||||
the uncorrected read. Remove them so resolve re-queries with the
|
the uncorrected read. Remove them so resolve re-queries with the
|
||||||
fix; `photos` narrows the cull to one copy of a split title.
|
fix; `photos` narrows the cull to one copy of a split title.
|
||||||
Rows carrying a human veto (dedupe_veto) are never dropped — the
|
Rows carrying a human veto (dedupe_veto) are never dropped — the
|
||||||
match itself was human-vetted, and dropping would erase the veto;
|
match itself was human-vetted, and dropping would erase the veto;
|
||||||
a rename updates their title in place so they follow the entry."""
|
a rename updates their title in place so they follow the entry.
|
||||||
|
`even_vetoed` is for title REMOVAL, where the human is explicitly
|
||||||
|
discarding the line, vetted or not."""
|
||||||
self.reload_if_changed()
|
self.reload_if_changed()
|
||||||
norm = normalize_title(title_raw)
|
norm = normalize_title(title_raw)
|
||||||
keep: list[dict] = []
|
keep: list[dict] = []
|
||||||
@@ -288,7 +291,7 @@ class ReviewSession:
|
|||||||
)
|
)
|
||||||
if not targeted:
|
if not targeted:
|
||||||
keep.append(row)
|
keep.append(row)
|
||||||
elif row.get("dedupe_veto"):
|
elif row.get("dedupe_veto") and not even_vetoed:
|
||||||
if new_title and row["title_raw"] != new_title:
|
if new_title and row["title_raw"] != new_title:
|
||||||
row["title_raw"] = new_title
|
row["title_raw"] = new_title
|
||||||
renamed += 1
|
renamed += 1
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function editorRow(c) {
|
|||||||
<span class="editactions">
|
<span class="editactions">
|
||||||
<button type="submit" class="primary">save</button>
|
<button type="submit" class="primary">save</button>
|
||||||
<button type="button" class="canceledit">cancel</button>
|
<button type="button" class="canceledit">cancel</button>
|
||||||
|
<button type="button" class="removetitle danger">remove from catalog</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="edithint">saving re-queues this title for resolve with the corrected data</span>
|
<span class="edithint">saving re-queues this title for resolve with the corrected data</span>
|
||||||
</form>
|
</form>
|
||||||
@@ -82,6 +83,19 @@ async function refresh() {
|
|||||||
document.getElementById("catbody").addEventListener("click", async e => {
|
document.getElementById("catbody").addEventListener("click", async e => {
|
||||||
const cancel = e.target.closest("button.canceledit");
|
const cancel = e.target.closest("button.canceledit");
|
||||||
if (cancel) { EDITING = null; LAST = null; render(); refresh().catch(() => {}); return; }
|
if (cancel) { EDITING = null; LAST = null; render(); refresh().catch(() => {}); return; }
|
||||||
|
const rm = e.target.closest("button.removetitle");
|
||||||
|
if (rm) {
|
||||||
|
const f = rm.closest("form.editform");
|
||||||
|
if (!confirm(`Remove "${f.dataset.title}" from the catalog? ` +
|
||||||
|
`Re-running extract won't bring it back — the removal is saved ` +
|
||||||
|
`in data/title_removals.json (delete its record there to undo).`)) return;
|
||||||
|
const res = await apiPost("/api/remove-title", {
|
||||||
|
title_raw: f.dataset.title,
|
||||||
|
source_photos: f.dataset.photos,
|
||||||
|
});
|
||||||
|
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const edit = e.target.closest("button.edit");
|
const edit = e.target.closest("button.edit");
|
||||||
if (edit) {
|
if (edit) {
|
||||||
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
|
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from bggpipe.extract import (
|
|||||||
is_split,
|
is_split,
|
||||||
load_title_splits,
|
load_title_splits,
|
||||||
record_title_edit,
|
record_title_edit,
|
||||||
|
record_title_removal,
|
||||||
record_title_split,
|
record_title_split,
|
||||||
replay_titles,
|
replay_titles,
|
||||||
)
|
)
|
||||||
@@ -171,6 +172,11 @@ class EditBody(BaseModel):
|
|||||||
language: str | None = None
|
language: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveBody(BaseModel):
|
||||||
|
title_raw: str
|
||||||
|
source_photos: str = ""
|
||||||
|
|
||||||
|
|
||||||
class RunBody(BaseModel):
|
class RunBody(BaseModel):
|
||||||
dry_run: bool = True # upload only; the safe direction is the default
|
dry_run: bool = True # upload only; the safe direction is the default
|
||||||
limit: int | None = None
|
limit: int | None = None
|
||||||
@@ -874,6 +880,38 @@ def create_app(
|
|||||||
replay_titles(cfg)
|
replay_titles(cfg)
|
||||||
return state()
|
return state()
|
||||||
|
|
||||||
|
@app.post("/api/remove-title")
|
||||||
|
def api_remove_title(body: RemoveBody) -> dict:
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
_refuse_if_rewriting()
|
||||||
|
freshen()
|
||||||
|
photos = [p for p in body.source_photos.split(";") if p]
|
||||||
|
entry = _find_entry(body.title_raw, body.source_photos)
|
||||||
|
norm = normalize_title(body.title_raw)
|
||||||
|
has_rows = any(
|
||||||
|
normalize_title(r["title_raw"]) == norm
|
||||||
|
and (
|
||||||
|
not photos
|
||||||
|
or {p for p in r["source_photos"].split(";") if p} & set(photos)
|
||||||
|
)
|
||||||
|
for r in session.rows
|
||||||
|
)
|
||||||
|
# surplus rows without an entry (a split copy whose entry is
|
||||||
|
# gone) are removable too — rows alone are enough to act on
|
||||||
|
if entry is None and not has_rows:
|
||||||
|
raise HTTPException(
|
||||||
|
404, "title not found — titles.json changed underneath?"
|
||||||
|
)
|
||||||
|
# removal is the human explicitly discarding the line: veto'd
|
||||||
|
# rows go too, then the durable record, then the replay
|
||||||
|
session.drop_rows(body.title_raw, photos or None, even_vetoed=True)
|
||||||
|
record_title_removal(
|
||||||
|
cfg.title_removals_path, body.title_raw, photos or None
|
||||||
|
)
|
||||||
|
replay_titles(cfg)
|
||||||
|
return state()
|
||||||
|
|
||||||
@app.post("/api/veto-merge")
|
@app.post("/api/veto-merge")
|
||||||
def api_veto_merge(body: VetoBody) -> dict:
|
def api_veto_merge(body: VetoBody) -> dict:
|
||||||
with lock:
|
with lock:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from bggpipe.extract import (
|
|||||||
replay_titles,
|
replay_titles,
|
||||||
run_extract,
|
run_extract,
|
||||||
)
|
)
|
||||||
|
from bggpipe.normalize import normalize_title
|
||||||
|
|
||||||
|
|
||||||
def _write_image(path, size=(400, 300), fmt="JPEG", color=(200, 30, 30)):
|
def _write_image(path, size=(400, 300), fmt="JPEG", color=(200, 30, 30)):
|
||||||
@@ -234,6 +235,22 @@ def test_edits_chain_and_target_photos():
|
|||||||
assert entries[1] == _entry("Wiz-War", "b.jpg") # untargeted copy untouched
|
assert entries[1] == _entry("Wiz-War", "b.jpg") # untargeted copy untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_removals_filter_rebuilds_photo_scoped():
|
||||||
|
from bggpipe.extract import apply_title_removals
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
_entry("Not A Game", "a.jpg"),
|
||||||
|
_entry("Not A Game", "b.jpg"),
|
||||||
|
_entry("Catan", "a.jpg"),
|
||||||
|
]
|
||||||
|
removals = [{"norm": normalize_title("Not A Game"), "photos": {"a.jpg"}}]
|
||||||
|
kept = apply_title_removals(entries, removals)
|
||||||
|
assert [(e["title_raw"], e["source_photos"]) for e in kept] == [
|
||||||
|
("Not A Game", ["b.jpg"]), # other sighting untouched
|
||||||
|
("Catan", ["a.jpg"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_stores_roundtrip_and_replay_from_raw(tmp_path):
|
def test_stores_roundtrip_and_replay_from_raw(tmp_path):
|
||||||
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
||||||
raw = cfg.extract_raw_dir
|
raw = cfg.extract_raw_dir
|
||||||
|
|||||||
@@ -726,3 +726,43 @@ def test_single_photo_rowless_entry_is_not_splittable(tmp_path):
|
|||||||
if c["title_raw"] == "Fresh Off The Shelf"
|
if c["title_raw"] == "Fresh Off The Shelf"
|
||||||
)
|
)
|
||||||
assert line["can_split"] is False
|
assert line["can_split"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_title_is_durable_and_drops_vetoed_rows(tmp_path):
|
||||||
|
cfg = make_cfg(tmp_path)
|
||||||
|
rows = read_matches(cfg.matches_path)
|
||||||
|
rows.append(
|
||||||
|
_row(
|
||||||
|
title_raw="Citadels",
|
||||||
|
match_status="approved",
|
||||||
|
bgg_id="478",
|
||||||
|
source_photos="shelf.jpg",
|
||||||
|
dedupe_veto="1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
write_matches(cfg.matches_path, rows)
|
||||||
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
||||||
|
res = web.post(
|
||||||
|
"/api/remove-title",
|
||||||
|
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "Citadels" not in [c["title_raw"] for c in res.json()["catalog"]]
|
||||||
|
# removal is explicit: the veto'd row goes too (unlike edits)
|
||||||
|
assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
|
||||||
|
(record,) = json.loads(cfg.title_removals_path.read_text())
|
||||||
|
assert record == {"title": "Citadels", "photos": ["shelf.jpg"]}
|
||||||
|
# durable: a fresh replay keeps it gone; the sibling title is untouched
|
||||||
|
from bggpipe.extract import replay_titles
|
||||||
|
|
||||||
|
replay_titles(cfg)
|
||||||
|
titles = {e["title_raw"] for e in json.loads(cfg.titles_path.read_text())}
|
||||||
|
assert titles == {"Fresh Off The Shelf"}
|
||||||
|
# unknown titles still 404
|
||||||
|
assert (
|
||||||
|
web.post(
|
||||||
|
"/api/remove-title",
|
||||||
|
json={"title_raw": "No Such Game", "source_photos": "x.jpg"},
|
||||||
|
).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user