diff --git a/data/matches.csv b/data/matches.csv index 62ee227..603d1dc 100644 --- a/data/matches.csv +++ b/data/matches.csv @@ -19,7 +19,9 @@ Cards Against Humanity,50381,Cards Against Humanity,2009,boardgame,auto,,,versio Joking Hazard,193621,Joking Hazard,2016,boardgame,auto,19362101,First edition,version_auto,"[{""bgg_id"": 193621, ""name"": ""Joking Hazard"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 19362101, ""name"": ""First edition"", ""year"": 2016, ""publishers"": [""Cyanide & Happiness""], ""languages"": [""English""], ""score"": 3}]",IMG_4501.jpeg;IMG_4502.jpeg,, EXPLODING KITTENS,172225,Exploding Kittens,2015,boardgame,auto,,,version_unknown,"[{""bgg_id"": 172225, ""name"": ""Exploding Kittens"", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4505.jpeg,, Hebarceos,195314,Herbaceous,2017,boardgame,merged,,,version_unknown,[],[],IMG_4502.jpeg,Herbaceous, -Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4502.jpeg;IMG_4504.jpeg;IMG_4528.jpeg,, +Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4502.jpeg,,1 +Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4504.jpeg,,1 +Wiz-War,104710,Wiz-War,2012,boardgame,approved,,,version_unknown,"[{""bgg_id"": 1218, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 4000, ""rank"": 2500}, {""bgg_id"": 104710, ""name"": ""Wiz-War"", ""year"": 2012, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 8000, ""rank"": 1200}]",[],IMG_4528.jpeg,,1 SCRAWL,218866,Scrawl,2017,boardgame,auto,,,version_unknown,"[{""bgg_id"": 218866, ""name"": ""Scrawl"", ""year"": 2017, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,, FLAT TOP,2529,Flat Top,1977,boardgame,auto,,,version_unknown,"[{""bgg_id"": 2529, ""name"": ""Flat Top"", ""year"": 1977, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,, Diplomacy,483,Diplomacy,1959,boardgame,auto,,,version_unknown,"[{""bgg_id"": 483, ""name"": ""Diplomacy"", ""year"": 1959, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4502.jpeg,, diff --git a/data/to_add.csv b/data/to_add.csv index b680301..6b36998 100644 --- a/data/to_add.csv +++ b/data/to_add.csv @@ -5,5 +5,7 @@ bgg_id,bgg_name,year,type,version_id,version_name,title_raw,source_photos,second 163412,Patchwork,2014,boardgame,,,PATCH WORK,IMG_4501.jpeg, 181254,Utter Nonsense,2015,boardgame,,,Utter Nonsense!,IMG_4501.jpeg;IMG_4507.jpeg;IMG_4556.jpeg, 50381,Cards Against Humanity,2009,boardgame,,,Cards Against Humanity,IMG_4501.jpeg, +104710,Wiz-War,2012,boardgame,,,Wiz-War,IMG_4504.jpeg,1 +104710,Wiz-War,2012,boardgame,,,Wiz-War,IMG_4528.jpeg,1 218866,Scrawl,2017,boardgame,,,SCRAWL,IMG_4502.jpeg, 195314,Herbaceous,2017,boardgame,,,Herbaceous,IMG_4502.jpeg;IMG_4507.jpeg, diff --git a/src/bggpipe/resolve.py b/src/bggpipe/resolve.py index 76e86ab..62b8754 100644 --- a/src/bggpipe/resolve.py +++ b/src/bggpipe/resolve.py @@ -603,7 +603,11 @@ 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: + 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 photos_updated = True skipped += 1 diff --git a/src/bggpipe/review.py b/src/bggpipe/review.py index d780cd2..61ded93 100644 --- a/src/bggpipe/review.py +++ b/src/bggpipe/review.py @@ -222,6 +222,42 @@ class ReviewSession: def merged_rows(self) -> list[dict]: return [r for r in self.rows if r["match_status"] == "merged"] + def split_row(self, row: dict) -> list[dict]: + """The human says one multi-photo row is actually N physical copies + (one per photo). Replace it with per-photo rows, each veto-flagged + so no future dedupe re-merges them, each with its own edition slot + (different copies are usually different editions).""" + photos = [p for p in row["source_photos"].split(";") if p] + if len(photos) < 2: + raise ValueError("only a multi-photo row can be split into copies") + # same concurrent-rewrite discipline as every decision: re-adopt the + # (possibly orphaned) row into the current list before mutating + self.reload_if_changed() + if not self._adopt(row): + self._warn( + f"{row['title_raw']!r} disappeared from matches.csv while " + "you split — nothing changed" + ) + return [] + has_candidates = (row.get("version_candidates_json") or "[]") != "[]" + ix = next(i for i, r in enumerate(self.rows) if r is row) + copies = [] + for photo in photos: + copy = dict(row) + copy["source_photos"] = photo + copy["dedupe_veto"] = "1" + copy["merged_into"] = "" + # each physical copy picks its OWN edition in the version pass + copy["version_id"] = "" + copy["version_name"] = "" + copy["version_status"] = ( + "version_ambiguous" if has_candidates else "version_unknown" + ) + copies.append(copy) + self.rows[ix : ix + 1] = copies + self._save(copies[0]) + return copies + 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.""" diff --git a/src/bggpipe/templates/pages/catalog.html b/src/bggpipe/templates/pages/catalog.html index ceca5c9..2f5ffcc 100644 --- a/src/bggpipe/templates/pages/catalog.html +++ b/src/bggpipe/templates/pages/catalog.html @@ -18,7 +18,8 @@ function render() { document.getElementById("catbody").innerHTML = rows.length ? `
` + rows.map(c => ` - + + `).join("") + `
${esc(c.title_raw)}${esc(c.title_raw)} + ${c.split_copy ? `copy` : ""} ${statusChip(c)} ${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""} ${c.version_name ? " · " + esc(c.version_name) : ""} @@ -26,6 +27,12 @@ function render() { ${c.photos.map(p => `${esc(p)}` ).join(", ")}${c.can_split + ? `` + : ""}
` : `

${CATALOG.length ? "No titles match that filter." @@ -42,6 +49,19 @@ async function refresh() { render(); } +document.getElementById("catbody").addEventListener("click", async e => { + const b = e.target.closest("button.split"); + if (!b) return; + const n = b.dataset.photos.split(";").length; + if (!confirm(`Split "${b.dataset.title}" into ${n} separate copies (one per photo)? ` + + `Each picks its own edition afterward.`)) return; + const res = await apiPost("/api/split", { + title_raw: b.dataset.title, + source_photos: b.dataset.photos, + row_ix: Number(b.dataset.rowix), + }); + if (res) refresh().catch(() => {}); +}); document.getElementById("catsearch").addEventListener("input", render); refresh().catch(err => errorBanner(err.message || err)); pollLoop(refresh, 5000, () => showBanner("")); diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 66f5193..7a13ac7 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -142,6 +142,12 @@ class VetoBody(BaseModel): row_ix: int | None = None +class SplitBody(BaseModel): + title_raw: str + source_photos: str + row_ix: int | None = None + + class RunBody(BaseModel): dry_run: bool = True # upload only; the safe direction is the default limit: int | None = None @@ -354,6 +360,32 @@ def create_app( rows_by_title.setdefault(r["title_raw"], []).append(r) title_seen: Counter[str] = Counter() catalog = [] + + def catalog_line(entry, row) -> dict: + return { + "title_raw": entry.title_raw if entry else row["title_raw"], + "confidence": entry.confidence if entry else "", + "photos": ( + list(entry.source_photos) + if entry + else [p for p in row["source_photos"].split(";") if p] + ), + "status": row["match_status"] if row else "awaiting_resolve", + "type": row["type"] if row else "", + "bgg_id": row["bgg_id"] if row else "", + "bgg_name": row["bgg_name"] if row else "", + "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, + "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 + ), + "split_copy": bool(row and row.get("dedupe_veto")), + } + for entry in session.titles: same_title = rows_by_title.get(entry.title_raw, []) ix = title_seen[entry.title_raw] @@ -361,19 +393,18 @@ def create_app( # positional pairing, same rule as run_resolve: the ix-th entry # of a title reports the ix-th row of that title row = same_title[ix] if ix < len(same_title) else None + # a split row's photo set is narrower than its entry's — show + # the row's own photos for split copies catalog.append( - { - "title_raw": entry.title_raw, - "confidence": entry.confidence, - "photos": list(entry.source_photos), - "status": row["match_status"] if row else "awaiting_resolve", - "type": row["type"] if row else "", - "bgg_id": row["bgg_id"] if row else "", - "bgg_name": row["bgg_name"] if row else "", - "version_name": row["version_name"] if row else "", - "merged_into": row.get("merged_into", "") if row else "", - } + catalog_line(None if row and row.get("dedupe_veto") else entry, row) + if row + else catalog_line(entry, None) ) + # surplus rows beyond the entry count — split copies — are real + # physical games and get their own catalog lines + for title, rows_for_title in rows_by_title.items(): + for row in rows_for_title[title_seen[title] :]: + catalog.append(catalog_line(None, row)) unresolved_count = sum( 1 for e in session.titles if e.title_raw not in resolved_titles ) @@ -664,6 +695,18 @@ def create_app( raise HTTPException(400, f"unknown action {body.action!r}") return state() + @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 + return state() + @app.post("/api/veto-merge") def api_veto_merge(body: VetoBody) -> dict: with lock: diff --git a/tests/test_diff.py b/tests/test_diff.py index acc3c1b..8ab0aef 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -405,3 +405,17 @@ def test_rpgitem_rows_are_local_only_never_queued(): assert result.local_only == ["ALICE IS MISSING"] assert [r["bgg_id"] for r in result.to_add] == ["13"] # RPG never queued assert result.recognized == 1 # RPGs aren't counted against the queue + + +def test_split_copies_become_extra_collection_entries(): + # three vetoed per-photo copies, one owned: one claims the copy, the + # other two are queued as new entries + base = _match("Wiz-War", "104710") + copies = [ + {**base, "source_photos": p, "dedupe_veto": "1"} + for p in ("a.jpg", "b.jpg", "c.jpg") + ] + result = compute_diff(copies, [_item(104710, 900)]) + assert result.already_owned == ["Wiz-War"] + assert len(result.to_add) == 2 + assert all(r["second_copy"] == "1" for r in result.to_add) diff --git a/tests/test_review.py b/tests/test_review.py index d9319cb..9e802c7 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -469,3 +469,70 @@ def test_corrupt_dismiss_file_is_quarantined_not_fatal(tmp_path): store = DismissStore(path) assert store.keys == set() assert (tmp_path / "dismissed.json.corrupt").exists() + + +def test_split_row_makes_per_photo_vetoed_copies(tmp_path): + from bggpipe.resolve import read_matches + + cfg = _setup( + tmp_path, + [ + _row( + title_raw="Wiz-War", + match_status="approved", + bgg_id="104710", + bgg_name="Wiz-War", + source_photos="a.jpg;b.jpg;c.jpg", + version_candidates_json='[{"version_id": 1, "name": "8th"}]', + ) + ], + ) + session = ReviewSession( + cfg, + console=quiet_console(), + input_fn=scripted(), + client=unauthorized_client(tmp_path), + ) + copies = session.split_row(session.rows[0]) + saved = read_matches(cfg.matches_path) + assert [r["source_photos"] for r in saved] == ["a.jpg", "b.jpg", "c.jpg"] + assert all(r["dedupe_veto"] == "1" for r in saved) + assert all(r["bgg_id"] == "104710" for r in saved) # match survives + # each copy picks its own edition: candidates kept, status reopened + assert all(r["version_status"] == "version_ambiguous" for r in saved) + assert all(r["version_id"] == "" for r in saved) + assert len(copies) == 3 + + +def test_split_copies_survive_resolve_rerun(tmp_path): + import json as _json + + from bggpipe.resolve import read_matches, run_resolve + + cfg = _setup( + tmp_path, + [ + _row( + title_raw="Wiz-War", + match_status="approved", + bgg_id="104710", + source_photos="a.jpg;b.jpg;c.jpg", + ) + ], + ) + (cfg.data_dir / "titles.json").write_text( + _json.dumps( + [{"title_raw": "Wiz-War", "source_photos": ["a.jpg", "b.jpg", "c.jpg"]}] + ) + ) + session = ReviewSession( + cfg, + console=quiet_console(), + input_fn=scripted(), + client=unauthorized_client(tmp_path), + ) + session.split_row(session.rows[0]) + run_resolve(cfg, client=fixture_client()) + saved = read_matches(cfg.matches_path) + assert len(saved) == 3 # not re-merged, not re-resolved + assert [r["source_photos"] for r in saved] == ["a.jpg", "b.jpg", "c.jpg"]