RPGs become local library citizens: identified, enriched, never uploaded

When the board-game search (and truncation heads) runs dry, resolve
falls back to type=rpgitem — the geekdo database is shared, so the same
API, token, cache, and classification machinery apply. Matched rpgitems
flow through review and enrich normally but diff routes them to a
local_only bucket, structurally outside to_add/to_update: their
collections live on RPGGeek, beyond this pipeline's write scope. The
library page gains an All/Board games/RPGs filter and an "RPG · local
only" badge; the catalog tags them too. Fixture generators write blanket
empty rpgitem stubs for every known query (the fallback fires for every
unmatched title) with real synthetic entries for Alice Is Missing.

Data: both Alice rows re-resolved from unmatched to auto rpgitem
matches. First diff since the audit reworks also lands their real-data
consequences: Dungeon! gains its TSR edition update on a versionless
copy the old claim ordering missed, to_add rows carry unioned reshoot
provenance, and the Herbaceous typo row's survivor is now the
correctly-spelled title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 19:01:09 -04:00
co-authored by Claude Fable 5
parent 33cbfffbd6
commit 74fc4fe847
66 changed files with 204 additions and 54 deletions
+10 -1
View File
@@ -62,6 +62,7 @@ class DiffResult:
to_add: list[dict] = field(default_factory=list)
to_update: list[dict] = field(default_factory=list)
already_owned: list[str] = field(default_factory=list) # title_raw
local_only: list[str] = field(default_factory=list) # RPGs etc: never uploaded
second_copies: list[str] = field(default_factory=list) # notes for adds
disagreements: list[str] = field(default_factory=list) # report-only
unseen: list[CollectionItem] = field(default_factory=list)
@@ -137,6 +138,11 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
if status not in RECOGNIZED_MATCH_STATUSES or not row["bgg_id"]:
result.pending.append(row["title_raw"])
continue
if row["type"] == "rpgitem":
# a real, identified game — but it lives on RPGGeek, not in the
# BGG collection: a library citizen only, never queued
result.local_only.append(row["title_raw"])
continue
result.recognized += 1
recognized.append(row)
if by_object.get(int(row["bgg_id"])):
@@ -309,11 +315,14 @@ def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult:
atomic_write_csv(cfg.to_update_path, TO_UPDATE_COLUMNS, result.to_update)
merged_note = f" · {result.merged} merged duplicate(s)" if result.merged else ""
local_note = (
f" · {len(result.local_only)} local-only (RPGs)" if result.local_only else ""
)
typer.echo(
f"\n{result.recognized} recognized · {len(result.already_owned)} already "
f"owned · {len(result.to_add)} to add · {len(result.to_update)} version "
f"update(s) · {len(result.pending)} pending review · "
f"{result.rejected} rejected{merged_note}"
f"{result.rejected} rejected{merged_note}{local_note}"
)
if result.second_copies:
typer.echo("\nSecond copies (verify these on the dry run before upload):")
+22 -2
View File
@@ -207,7 +207,11 @@ def _truncation_heads(title_raw: str) -> list[str]:
def _plausible_candidates(
client: BGGClient, entry: TitleEntry, query: str, head_normalized: str = ""
client: BGGClient,
entry: TitleEntry,
query: str,
head_normalized: str = "",
types: str | None = None,
) -> list[Candidate]:
"""Search BGG and keep plausible candidates, one per id: exact-normalized
or fuzzy>=90 against the FULL title, or — on truncated retries — exact
@@ -218,7 +222,8 @@ def _plausible_candidates(
if not entry.title_normalized:
return []
by_id: dict[int, Candidate] = {}
for result in client.search(query):
results = client.search(query, types) if types else client.search(query)
for result in results:
norm = normalize_title(result.name)
exact = norm == entry.title_normalized
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
@@ -396,6 +401,21 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
cands = _plausible_candidates(client, entry, head, normalize_title(head))
if cands:
break
if not cands:
# Not a board game? RPGs live in the same geekdo database under
# 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"
)
if not cands:
for head in _truncation_heads(entry.title_raw):
cands = _plausible_candidates(
client, entry, head, normalize_title(head), types="rpgitem"
)
if cands:
break
row = _classify(client, entry, cands)
if row.match_status == "auto":
resolve_version(client, entry, row)
+3
View File
@@ -428,6 +428,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
padding: .15rem .5rem; border-radius: 6px;
}
.filterbar { display: flex; gap: .6rem; margin-bottom: 1rem; flex-wrap: wrap; }
.filterbar button[aria-pressed="true"] {
background: var(--accent); border-color: var(--ink); color: #fff;
}
.filterbar input[type=search] {
font: inherit; padding: .35rem .7rem; min-width: 16rem;
border: var(--line); border-radius: var(--radius); background: #fff;
+2 -1
View File
@@ -21,7 +21,8 @@ function render() {
<td class="t">${esc(c.title_raw)}</td>
<td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
${c.version_name ? " · " + esc(c.version_name) : ""}
${c.type === "rpgitem" ? ` <span class="chip open">RPG · local only</span>` : ""}</td>
<td class="meta">${c.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(", ")}</td>
+22 -5
View File
@@ -1,12 +1,16 @@
<h1>Library</h1>
<div class="pagebar"><span id="libcount"></span></div>
<div class="filterbar">
<div class="filterbar" role="group" aria-label="filter by kind">
<input type="search" id="libsearch" placeholder="search your games…" aria-label="search library">
<button type="button" data-kind="" aria-pressed="true">All</button>
<button type="button" data-kind="boardgame" aria-pressed="false">Board games</button>
<button type="button" data-kind="rpgitem" aria-pressed="false">RPGs</button>
</div>
<div id="libbody"></div>
<script>
"use strict";
let GAMES = [];
let KIND = ""; // "" = all; "boardgame" also covers expansions
function gameCard(g) {
const art = g.thumbnail || g.image;
@@ -22,18 +26,25 @@ function gameCard(g) {
? `<img src="${esc(art)}" alt="" loading="lazy">`
: `<div class="noart" aria-hidden="true">${esc((g.name || "?").charAt(0).toUpperCase())}</div>`}
<div class="info">
<div class="gname">${esc(g.name)}${g.year ? ` <span class="meta">(${esc(g.year)})</span>` : ""}</div>
<div class="gname">${esc(g.name)}${g.year ? ` <span class="meta">(${esc(g.year)})</span>` : ""}
${g.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : ""}</div>
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""}
</div>
</article>`;
}
function matchesKind(g) {
if (!KIND) return true;
if (KIND === "boardgame") return g.type !== "rpgitem";
return g.type === "rpgitem";
}
function render() {
const q = document.getElementById("libsearch").value.trim().toLowerCase();
const rows = q
? GAMES.filter(g => (g.name + " " + (g.designers || []).join(" ")).toLowerCase().includes(q))
: GAMES;
let rows = GAMES.filter(matchesKind);
if (q) rows = rows.filter(g =>
(g.name + " " + (g.designers || []).join(" ")).toLowerCase().includes(q));
document.getElementById("libcount").innerHTML =
`<b>${rows.length}</b> of <b>${GAMES.length}</b> game(s)`;
document.getElementById("libbody").innerHTML = rows.length
@@ -56,6 +67,12 @@ async function refresh() {
}
document.getElementById("libsearch").addEventListener("input", render);
document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => {
KIND = b.dataset.kind;
document.querySelectorAll("[data-kind]").forEach(o =>
o.setAttribute("aria-pressed", String(o === b)));
render();
}));
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 10000, () => showBanner(""));
</script>
+1
View File
@@ -367,6 +367,7 @@ def create_app(
"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 "",