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:
co-authored by
Claude Fable 5
parent
33cbfffbd6
commit
74fc4fe847
+10
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
Reference in New Issue
Block a user