diff --git a/data/matches.csv b/data/matches.csv index 8c5c3fe..ce08012 100644 --- a/data/matches.csv +++ b/data/matches.csv @@ -17,7 +17,7 @@ Cards Against Humanity,50381,Cards Against Humanity,2009,boardgame,auto,,,versio Joking Hazard,193621,Joking Hazard,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 193621, ""name"": ""Joking Hazard"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4502.jpeg,, SKIP-BO,1269,Skip-Bo,1967,boardgame,auto,,,version_unknown,"[{""bgg_id"": 1269, ""name"": ""Skip-Bo"", ""year"": 1967, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],IMG_4501.jpeg;IMG_4542.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,, -Wiz-War,589,Wiz-War,1983,boardgame,auto,,,version_ambiguous,"[{""bgg_id"": 589, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 188937, ""name"": ""First edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360421, ""name"": ""Second edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360420, ""name"": ""Third edition"", ""year"": 1986, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 29214, ""name"": ""Fourth edition"", ""year"": 1987, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 26114, ""name"": ""Fifth edition"", ""year"": 1991, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 27352, ""name"": ""Chessex sixth edition"", ""year"": 1993, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 28612, ""name"": ""Chessex classic edition 1997"", ""year"": 1997, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}]",IMG_4502.jpeg,, +Wiz-War,589,Wiz-War,1983,boardgame,auto,27352,Chessex sixth edition,version_approved,"[{""bgg_id"": 589, ""name"": ""Wiz-War"", ""year"": 1983, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 188937, ""name"": ""First edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360421, ""name"": ""Second edition"", ""year"": 1985, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 360420, ""name"": ""Third edition"", ""year"": 1986, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 29214, ""name"": ""Fourth edition"", ""year"": 1987, ""publishers"": [""Jolly Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 26114, ""name"": ""Fifth edition"", ""year"": 1991, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 27352, ""name"": ""Chessex sixth edition"", ""year"": 1993, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 28612, ""name"": ""Chessex classic edition 1997"", ""year"": 1997, ""publishers"": [""Chessex""], ""languages"": [""English""], ""score"": 1}]",IMG_4502.jpeg,, SCRAWL,202982,Scrawl,2016,boardgame,auto,,,version_unknown,"[{""bgg_id"": 202982, ""name"": ""Scrawl"", ""year"": 2016, ""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/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index 26dd71a..4536c3a 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -324,6 +324,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .catalog a:hover { color: var(--accent-ink); } .catalog td.actions { text-align: right; } .catalog td.actions button { margin: .1rem 0 .1rem .3rem; white-space: nowrap; } +.catalog td.actions .ballotlink { + display: inline-block; margin: .1rem 0 .1rem .3rem; white-space: nowrap; + font-size: .78rem; color: var(--accent-ink); + text-decoration: underline dotted; text-underline-offset: 3px; +} .catalog td.actions button { font-size: .78rem; padding: .2rem .6rem; box-shadow: none; } .catalog td.actions button:hover { background: var(--board); } .catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; } diff --git a/src/bggpipe/templates/pages/titles.html b/src/bggpipe/templates/pages/titles.html index f717546..2ab5461 100644 --- a/src/bggpipe/templates/pages/titles.html +++ b/src/bggpipe/templates/pages/titles.html @@ -90,8 +90,8 @@ function render() { split into copies` : ""} ${c.version_status === "version_ambiguous" - ? `ballot in Review` + ? `ballot in Review →` : ""} ${c.bgg_id && ["auto", "approved"].includes(c.status) && ["version_unknown", "version_error", ""].includes(c.version_status || "") diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 89ed442..3eb95c3 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -534,7 +534,6 @@ def create_app( rows_by_title: dict[str, list[dict]] = {} for r in session.rows: rows_by_title.setdefault(r["title_raw"], []).append(r) - title_seen: Counter[str] = Counter() catalog = [] def catalog_line(entry, row) -> dict: @@ -579,25 +578,43 @@ def create_app( "shaky": bool(entry and not row and entry.confidence != "high"), } - for entry in session.titles: + # photo-overlap pairing first, positional fallback — the same rule + # as run_resolve. Positional-only broke the moment an edited copy's + # row was recreated at the END of matches.csv: same-title lines + # displayed each other's rows (buttons and ballots swapped owners) + claimed: set[int] = set() + + def row_for(entry) -> dict | None: same_title = rows_by_title.get(entry.title_raw, []) - ix = title_seen[entry.title_raw] - title_seen[entry.title_raw] += 1 - # positional pairing: the ix-th entry of a title reports the - # ix-th row of that title (run_resolve pairs photo-overlap - # first, but split entries and split rows both keep sorted - # photo order, so the ordinals line up) - row = same_title[ix] if ix < len(same_title) else None + entry_photos = set(entry.source_photos) + + def photos_of(r: dict) -> set[str]: + return {p for p in r["source_photos"].split(";") if p} + + for match in ( + lambda r: photos_of(r) == entry_photos, + lambda r: bool(photos_of(r) & entry_photos), + lambda r: True, + ): + for r in same_title: + if id(r) not in claimed and match(r): + claimed.add(id(r)) + return r + return None + + for entry in session.titles: + row = row_for(entry) # a split row's photo set is narrower than its entry's — show # the row's own photos for split copies catalog.append( catalog_line(None if row and row.get("dedupe_veto") else entry, row) ) - # 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)) + # rows no entry claimed — split copies — are real physical games + # and get their own catalog lines + for rows_for_title in rows_by_title.values(): + for row in rows_for_title: + if id(row) not in claimed: + catalog.append(catalog_line(None, row)) unresolved_count = sum( 1 for e in session.titles if e.title_raw not in resolved_titles ) diff --git a/tests/test_webreview.py b/tests/test_webreview.py index da67d36..27aa0f5 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -1202,3 +1202,44 @@ def test_version_cards_carry_their_photos(tmp_path): (card,) = web.get("/api/state").json()["versions"] assert card["title_raw"] == "Dungeon!" assert card["photos"] == ["shelf.jpg"] # same-title copies stay tellable + + +def test_same_title_lines_pair_rows_by_photos_not_csv_order(tmp_path): + cfg = make_cfg(tmp_path) + titles = json.loads(cfg.titles_path.read_text()) + titles += [ + {"title_raw": "WIZ-WAR", "confidence": "high", "source_photos": ["a.jpg"]}, + {"title_raw": "WIZ-WAR", "confidence": "high", "source_photos": ["b.jpg"]}, + ] + cfg.titles_path.write_text(json.dumps(titles)) + rows = read_matches(cfg.matches_path) + # csv order REVERSED vs entry order: b's row first (an edit re-queue + # recreates a row at the file's end) + rows.append( + _row( + title_raw="WIZ-WAR", + match_status="auto", + bgg_id="589", + source_photos="b.jpg", + version_status="version_ambiguous", + version_candidates_json=VERSION_CANDIDATES, + ) + ) + rows.append( + _row( + title_raw="WIZ-WAR", + match_status="auto", + bgg_id="589", + source_photos="a.jpg", + version_status="version_unknown", + ) + ) + write_matches(cfg.matches_path, rows) + web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path))) + lines = { + tuple(c["photos"]): c + for c in web.get("/api/state").json()["catalog"] + if c["title_raw"] == "WIZ-WAR" + } + assert lines[("a.jpg",)]["version_status"] == "version_unknown" + assert lines[("b.jpg",)]["version_status"] == "version_ambiguous"