Catalog pairs same-title rows by photos, not csv position

The Wiz-War lines were displaying each other's rows: an edit re-queue
recreates its row at the END of matches.csv, and the catalog's
positional per-title pairing then crossed the wires — the 4504 line
wore 4528's open ballot while 4528's line offered 4504's pick-edition
button (whose click re-targeted by photos and safely hit the other
row, deepening the confusion). Pairing now matches run_resolve's rule:
exact photo set, then overlap, then positional fallback, with
unclaimed rows appended as their own lines. Plus a regression test
with deliberately reversed csv order, and the open-ballot marker
restyled as a quiet dotted link instead of a mis-wrapped button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 21:32:46 -04:00
co-authored by Claude Fable 5
parent c94e8bc695
commit 3e61a03686
5 changed files with 80 additions and 17 deletions
+5
View File
@@ -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; }
+2 -2
View File
@@ -90,8 +90,8 @@ function render() {
split into copies</button>`
: ""}
${c.version_status === "version_ambiguous"
? `<a class="linkbtn ballot" href="/review#editions"
title="this copy's edition ballot is open">ballot in Review</a>`
? `<a class="ballotlink" href="/review#editions"
title="this copy's edition ballot is open">ballot in Review</a>`
: ""}
${c.bgg_id && ["auto", "approved"].includes(c.status)
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
+31 -14
View File
@@ -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
)