A box of expansions in one pass: search, tick, done — with containment

Eric's Character Trove problem, both halves. The Titles add panel gains
"search BGG and tick them off": one API search (rate-limited, cached)
returns the whole family as a checklist, already-cataloged ids greyed
out, and every ticked result lands as an APPROVED match row plus a
title addition — the human picked it off BGG's own list, so resolve
has nothing left to derive. A pick whose name matches an undecided
photo line decides THAT line (photos kept) instead of duplicating it;
BGG's true name twins (two games both called "Citadels") skip with an
honest message rather than fusing.

And the half Eric spotted mid-build: containment is real data, not a
convention. A stored_in column on the match row (the container's
bgg_id — human curation, riding the same durable CSV as dedupe_veto)
is set by the pick panel's "they all live inside" selector, flows
through enrich onto games.json, and surfaces both directions in the
Library — "where it lives" on the content, "in this box" on the
container. The dims report excludes contained games from the Kallax
unknowns and counts them separately: a game with no box of its own
has no shelf space to plan.

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-09 12:05:43 -04:00
co-authored by Claude Fable 5
parent 102507b040
commit dbc1899759
11 changed files with 5192 additions and 3899 deletions
+4685 -3831
View File
File diff suppressed because it is too large Load Diff
+65 -65
View File
File diff suppressed because one or more lines are too long
+64
View File
@@ -115,3 +115,67 @@ update,40692,53430517,Small World,294178,,updated,2026-08-06T03:20:45+00:00,
update,172503,53429718,Mage Wars Academy,265107,,updated,2026-08-06T03:20:49+00:00, update,172503,53429718,Mage Wars Academy,265107,,updated,2026-08-06T03:20:49+00:00,
update,104710,53429642,Wiz-War (Eighth Edition),117685,,updated,2026-08-06T03:20:53+00:00, update,104710,53429642,Wiz-War (Eighth Edition),117685,,updated,2026-08-06T03:20:53+00:00,
update,1339,53430097,Dungeon!,34390,,updated,2026-08-06T04:01:15+00:00, update,1339,53430097,Dungeon!,34390,,updated,2026-08-06T04:01:15+00:00,
update,165986,53429696,Royals,277137,,updated,2026-08-09T15:42:48+00:00,
update,40529,53429871,Cosmic Encounter,27692,,updated,2026-08-09T15:42:56+00:00,
update,157969,53430297,Sheriff of Nottingham,237864,,updated,2026-08-09T15:43:07+00:00,
update,207830,53429613,5-Minute Dungeon,344075,,updated,2026-08-09T15:43:13+00:00,
update,194655,53430612,Santorini,668826,,updated,2026-08-09T15:43:19+00:00,
update,161928,148198650,Utter Nonsense,245124,,updated,2026-08-09T15:43:25+00:00,
update,50381,148198655,Cards Against Humanity,134539,,updated,2026-08-09T15:43:32+00:00,
update,193621,53430387,Joking Hazard,300682,,updated,2026-08-09T15:43:37+00:00,
update,1269,148198659,Skip-Bo,29705,,updated,2026-08-09T15:43:44+00:00,
update,172225,53430108,Exploding Kittens,296012,,updated,2026-08-09T15:43:50+00:00,
update,202982,148198665,Scrawl,316090,,updated,2026-08-09T15:43:58+00:00,
update,2529,53429839,Flat Top,98655,,updated,2026-08-09T15:44:04+00:00,
update,483,49111776,Diplomacy,48522,,updated,2026-08-09T15:44:11+00:00,
update,26,53429831,Age of Renaissance,28607,,updated,2026-08-09T15:44:21+00:00,
update,97903,53430468,Rampage,231971,,updated,2026-08-09T15:44:27+00:00,
update,160477,53430375,Onitama,269392,,updated,2026-08-09T15:44:34+00:00,
update,195314,148198667,Herbaceous,525268,,updated,2026-08-09T15:44:39+00:00,
update,320202,148198669,Dragon Land,528008,,updated,2026-08-09T15:44:43+00:00,
update,2452,148198672,Jenga,288640,,updated,2026-08-09T15:44:50+00:00,
update,208428,53429689,"No Thank You, Evil!",325455,,updated,2026-08-09T15:44:58+00:00,
update,42063,148198675,Code Master,191736,,updated,2026-08-09T15:45:09+00:00,
update,57215,53430150,Googly Eyes,295423,,updated,2026-08-09T15:45:14+00:00,
update,340420,148198677,Throw Throw Avocado,567183,,updated,2026-08-09T15:45:19+00:00,
update,139443,53430424,Superfight,202519,,updated,2026-08-09T15:45:24+00:00,
update,1260,148198681,Rook,156689,,updated,2026-08-09T15:45:29+00:00,
update,2197,53430503,Kensington,42495,,updated,2026-08-09T15:45:35+00:00,
update,116,148198685,Guillotine,28201,,updated,2026-08-09T15:45:42+00:00,
update,124548,53430526,The Doom That Came to Atlantic City,165214,,updated,2026-08-09T15:45:48+00:00,
update,169124,148198688,Flick 'em Up!,451577,,updated,2026-08-09T15:45:54+00:00,
update,2387,54518133,Huggermugger,91428,,updated,2026-08-09T15:45:59+00:00,
update,20866,148198690,The Rocky Horror Trivia Game,40025,,updated,2026-08-09T15:46:07+00:00,
update,196105,53430662,Speechless,304900,,updated,2026-08-09T15:46:13+00:00,
update,156994,53430578,Skylanders Portal Master,236280,,updated,2026-08-09T15:46:18+00:00,
update,269564,148198703,Captain Marvel: Secret Skrulls,439948,,updated,2026-08-09T15:46:24+00:00,
update,18755,148198705,Pie Face!,284056,,updated,2026-08-09T15:46:30+00:00,
update,166109,53429921,Meteor,253058,,updated,2026-08-09T15:46:36+00:00,
update,125675,53429970,Doctor Who: The Card Game,167657,,updated,2026-08-09T15:46:41+00:00,
update,128554,53429733,Völuspá,207529,,updated,2026-08-09T15:46:46+00:00,
update,822,53429625,Carcassonne,187468,,updated,2026-08-09T15:46:53+00:00,
update,201248,148198712,Evolution: The Beginning,313478,,updated,2026-08-09T15:46:59+00:00,
update,362205,148198714,Sleeping Gods: Primeval Peril,612448,,updated,2026-08-09T15:47:05+00:00,
update,220199,53430477,Snappy Dressers,369805,,updated,2026-08-09T15:47:09+00:00,
update,453845,148198716,Murder at the Manor,772660,,updated,2026-08-09T15:47:15+00:00,
update,329230,148198718,Bluffaneer,547518,,updated,2026-08-09T15:47:23+00:00,
update,358320,148198720,Sleeping Gods: Distant Skies,602661,,updated,2026-08-09T15:47:29+00:00,
update,389113,148198726,Rivals,662372,,updated,2026-08-09T15:47:34+00:00,
update,324937,148198729,Wiz-War (9th Edition),537230,,updated,2026-08-09T15:47:43+00:00,
update,295770,148198731,Frosthaven,484007,,updated,2026-08-09T15:47:49+00:00,
update,174430,148198734,Gloomhaven,520054,,updated,2026-08-09T15:47:55+00:00,
update,442312,148198737,Deadly Doodles,752105,,updated,2026-08-09T15:48:03+00:00,
update,332398,148198739,Everdell: The Complete Collection,553061,,updated,2026-08-09T15:48:12+00:00,
update,334065,148198740,Verdant,759453,,updated,2026-08-09T15:48:19+00:00,
update,283155,148198745,Calico,462049,,updated,2026-08-09T15:48:27+00:00,
update,291457,148198746,Gloomhaven: Jaws of the Lion,476060,,updated,2026-08-09T15:48:36+00:00,
update,173057,53430364,Go Fish Yourself,266044,,updated,2026-08-09T15:48:43+00:00,
update,150925,148198748,Hold Your Breath!,225659,,updated,2026-08-09T15:48:49+00:00,
update,2980,148198751,The Simpsons Trivia Game,138013,,updated,2026-08-09T15:48:54+00:00,
update,1197,148198753,Blink,78624,,updated,2026-08-09T15:49:03+00:00,
update,352819,148198874,The Office Trivia Game,591147,,updated,2026-08-09T15:49:08+00:00,
update,163412,148198877,Patchwork,495998,,updated,2026-08-09T15:49:20+00:00,
update,291847,148198878,Mantis Falls,476820,,updated,2026-08-09T15:49:29+00:00,
update,2058,148198880,Civilization: West Extension Map,28041,,updated,2026-08-09T15:49:34+00:00,
update,452833,148198886,Artistry: Delightful Doorways Mini Expansion,770724,,updated,2026-08-09T15:49:39+00:00,
update,104590,53430550,Castle Panic: The Wizard's Tower,117256,,updated,2026-08-09T15:49:45+00:00,
1 action bgg_id collid name version_id second_copy status timestamp error
115 update 172503 53429718 Mage Wars Academy 265107 updated 2026-08-06T03:20:49+00:00
116 update 104710 53429642 Wiz-War (Eighth Edition) 117685 updated 2026-08-06T03:20:53+00:00
117 update 1339 53430097 Dungeon! 34390 updated 2026-08-06T04:01:15+00:00
118 update 165986 53429696 Royals 277137 updated 2026-08-09T15:42:48+00:00
119 update 40529 53429871 Cosmic Encounter 27692 updated 2026-08-09T15:42:56+00:00
120 update 157969 53430297 Sheriff of Nottingham 237864 updated 2026-08-09T15:43:07+00:00
121 update 207830 53429613 5-Minute Dungeon 344075 updated 2026-08-09T15:43:13+00:00
122 update 194655 53430612 Santorini 668826 updated 2026-08-09T15:43:19+00:00
123 update 161928 148198650 Utter Nonsense 245124 updated 2026-08-09T15:43:25+00:00
124 update 50381 148198655 Cards Against Humanity 134539 updated 2026-08-09T15:43:32+00:00
125 update 193621 53430387 Joking Hazard 300682 updated 2026-08-09T15:43:37+00:00
126 update 1269 148198659 Skip-Bo 29705 updated 2026-08-09T15:43:44+00:00
127 update 172225 53430108 Exploding Kittens 296012 updated 2026-08-09T15:43:50+00:00
128 update 202982 148198665 Scrawl 316090 updated 2026-08-09T15:43:58+00:00
129 update 2529 53429839 Flat Top 98655 updated 2026-08-09T15:44:04+00:00
130 update 483 49111776 Diplomacy 48522 updated 2026-08-09T15:44:11+00:00
131 update 26 53429831 Age of Renaissance 28607 updated 2026-08-09T15:44:21+00:00
132 update 97903 53430468 Rampage 231971 updated 2026-08-09T15:44:27+00:00
133 update 160477 53430375 Onitama 269392 updated 2026-08-09T15:44:34+00:00
134 update 195314 148198667 Herbaceous 525268 updated 2026-08-09T15:44:39+00:00
135 update 320202 148198669 Dragon Land 528008 updated 2026-08-09T15:44:43+00:00
136 update 2452 148198672 Jenga 288640 updated 2026-08-09T15:44:50+00:00
137 update 208428 53429689 No Thank You, Evil! 325455 updated 2026-08-09T15:44:58+00:00
138 update 42063 148198675 Code Master 191736 updated 2026-08-09T15:45:09+00:00
139 update 57215 53430150 Googly Eyes 295423 updated 2026-08-09T15:45:14+00:00
140 update 340420 148198677 Throw Throw Avocado 567183 updated 2026-08-09T15:45:19+00:00
141 update 139443 53430424 Superfight 202519 updated 2026-08-09T15:45:24+00:00
142 update 1260 148198681 Rook 156689 updated 2026-08-09T15:45:29+00:00
143 update 2197 53430503 Kensington 42495 updated 2026-08-09T15:45:35+00:00
144 update 116 148198685 Guillotine 28201 updated 2026-08-09T15:45:42+00:00
145 update 124548 53430526 The Doom That Came to Atlantic City 165214 updated 2026-08-09T15:45:48+00:00
146 update 169124 148198688 Flick 'em Up! 451577 updated 2026-08-09T15:45:54+00:00
147 update 2387 54518133 Huggermugger 91428 updated 2026-08-09T15:45:59+00:00
148 update 20866 148198690 The Rocky Horror Trivia Game 40025 updated 2026-08-09T15:46:07+00:00
149 update 196105 53430662 Speechless 304900 updated 2026-08-09T15:46:13+00:00
150 update 156994 53430578 Skylanders Portal Master 236280 updated 2026-08-09T15:46:18+00:00
151 update 269564 148198703 Captain Marvel: Secret Skrulls 439948 updated 2026-08-09T15:46:24+00:00
152 update 18755 148198705 Pie Face! 284056 updated 2026-08-09T15:46:30+00:00
153 update 166109 53429921 Meteor 253058 updated 2026-08-09T15:46:36+00:00
154 update 125675 53429970 Doctor Who: The Card Game 167657 updated 2026-08-09T15:46:41+00:00
155 update 128554 53429733 Völuspá 207529 updated 2026-08-09T15:46:46+00:00
156 update 822 53429625 Carcassonne 187468 updated 2026-08-09T15:46:53+00:00
157 update 201248 148198712 Evolution: The Beginning 313478 updated 2026-08-09T15:46:59+00:00
158 update 362205 148198714 Sleeping Gods: Primeval Peril 612448 updated 2026-08-09T15:47:05+00:00
159 update 220199 53430477 Snappy Dressers 369805 updated 2026-08-09T15:47:09+00:00
160 update 453845 148198716 Murder at the Manor 772660 updated 2026-08-09T15:47:15+00:00
161 update 329230 148198718 Bluffaneer 547518 updated 2026-08-09T15:47:23+00:00
162 update 358320 148198720 Sleeping Gods: Distant Skies 602661 updated 2026-08-09T15:47:29+00:00
163 update 389113 148198726 Rivals 662372 updated 2026-08-09T15:47:34+00:00
164 update 324937 148198729 Wiz-War (9th Edition) 537230 updated 2026-08-09T15:47:43+00:00
165 update 295770 148198731 Frosthaven 484007 updated 2026-08-09T15:47:49+00:00
166 update 174430 148198734 Gloomhaven 520054 updated 2026-08-09T15:47:55+00:00
167 update 442312 148198737 Deadly Doodles 752105 updated 2026-08-09T15:48:03+00:00
168 update 332398 148198739 Everdell: The Complete Collection 553061 updated 2026-08-09T15:48:12+00:00
169 update 334065 148198740 Verdant 759453 updated 2026-08-09T15:48:19+00:00
170 update 283155 148198745 Calico 462049 updated 2026-08-09T15:48:27+00:00
171 update 291457 148198746 Gloomhaven: Jaws of the Lion 476060 updated 2026-08-09T15:48:36+00:00
172 update 173057 53430364 Go Fish Yourself 266044 updated 2026-08-09T15:48:43+00:00
173 update 150925 148198748 Hold Your Breath! 225659 updated 2026-08-09T15:48:49+00:00
174 update 2980 148198751 The Simpsons Trivia Game 138013 updated 2026-08-09T15:48:54+00:00
175 update 1197 148198753 Blink 78624 updated 2026-08-09T15:49:03+00:00
176 update 352819 148198874 The Office Trivia Game 591147 updated 2026-08-09T15:49:08+00:00
177 update 163412 148198877 Patchwork 495998 updated 2026-08-09T15:49:20+00:00
178 update 291847 148198878 Mantis Falls 476820 updated 2026-08-09T15:49:29+00:00
179 update 2058 148198880 Civilization: West Extension Map 28041 updated 2026-08-09T15:49:34+00:00
180 update 452833 148198886 Artistry: Delightful Doorways Mini Expansion 770724 updated 2026-08-09T15:49:39+00:00
181 update 104590 53430550 Castle Panic: The Wizard's Tower 117256 updated 2026-08-09T15:49:45+00:00
+11
View File
@@ -41,14 +41,24 @@ def run_dims_report(cfg: Config) -> dict:
raise typer.Exit(code=1) raise typer.Exit(code=1)
games: dict[str, dict] = json.loads(cfg.games_path.read_text()) games: dict[str, dict] = json.loads(cfg.games_path.read_text())
contained = [
entry.get("name") or "?" for entry in games.values() if entry.get("stored_in")
]
by_source: dict[str, list[tuple[str, dict]]] = {} by_source: dict[str, list[tuple[str, dict]]] = {}
for entry in games.values(): for entry in games.values():
if entry.get("stored_in"):
continue # lives inside another box: no shelf space of its own
dims = entry.get("dims") or dict.fromkeys(_AXES, None) | {"source": "absent"} dims = entry.get("dims") or dict.fromkeys(_AXES, None) | {"source": "absent"}
by_source.setdefault(dims["source"], []).append( by_source.setdefault(dims["source"], []).append(
(entry.get("name") or "?", dims) (entry.get("name") or "?", dims)
) )
typer.echo(f"Box dimensions across {len(games)} game(s):") typer.echo(f"Box dimensions across {len(games)} game(s):")
if contained:
typer.echo(
f" {len(contained):>4} stored inside another box — no shelf "
"space of their own"
)
for source, label in ( for source, label in (
("version", "from the exact owned version"), ("version", "from the exact owned version"),
("unanimous", "every listed version agrees"), ("unanimous", "every listed version agrees"),
@@ -109,4 +119,5 @@ def run_dims_report(cfg: Config) -> dict:
"by_source": {s: len(v) for s, v in by_source.items()}, "by_source": {s: len(v) for s, v in by_source.items()},
"misfits": [name for name, _ in misfits], "misfits": [name for name, _ in misfits],
"unknown": sorted(unknown), "unknown": sorted(unknown),
"contained": sorted(contained),
} }
+13
View File
@@ -271,6 +271,19 @@ def run_enrich(
f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}" f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}"
) )
# containment is row-level curation: carry it onto entries every run
# so the library and the shelf math both know what shares a box
stored_by_key = {}
for row in rows:
if is_recognized(row) and row.get("stored_in"):
version = _version_info(row)
key = (
f"{row['bgg_id']}:{version['version_id']}" if version else row["bgg_id"]
)
stored_by_key[key] = row["stored_in"]
for key, entry in games.items():
entry["stored_in"] = stored_by_key.get(key)
# box dimensions live on VERSIONS, not games: a second cached pass # box dimensions live on VERSIONS, not games: a second cached pass
# (read-only; upload is untouched) fills entry["dims"] for shelf math # (read-only; upload is untouched) fills entry["dims"] for shelf math
dims_filled, dims_blocked = _dims_pass(games, client, refresh=refresh) dims_filled, dims_blocked = _dims_pass(games, client, refresh=refresh)
+3
View File
@@ -57,6 +57,9 @@ MATCH_COLUMNS = [
"source_photos", "source_photos",
"merged_into", "merged_into",
"dedupe_veto", "dedupe_veto",
# human curation: the bgg_id of the box this game physically lives
# inside (a compilation like a Character Trove) — "" = its own box
"stored_in",
] ]
+9
View File
@@ -498,6 +498,15 @@ a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
padding: .35rem .45rem; background: #fff; resize: vertical; padding: .35rem .45rem; background: #fff; resize: vertical;
} }
.linkish {
background: none; border: none; padding: 0; font: inherit;
color: var(--accent); text-decoration: underline; cursor: pointer;
}
.pickrow { display: flex; gap: .5rem; align-items: center; padding: .25rem 0; }
.pickrow .meta { font-size: .8rem; }
#pickresults { max-height: 22rem; overflow-y: auto; margin: .5rem 0; }
#pickfooter { display: flex; gap: .7rem; align-items: center; flex-wrap: wrap; }
.empty { .empty {
background: var(--board); border: 2px dashed var(--board-edge); background: var(--board); border: 2px dashed var(--board-edge);
border-radius: var(--radius-lg); padding: 2rem; text-align: center; border-radius: var(--radius-lg); padding: 2rem; text-align: center;
@@ -159,6 +159,22 @@ function render(g) {
<div class="card prose"><p class="meta">No specific edition recorded — <div class="card prose"><p class="meta">No specific edition recorded —
set one from the <a href="/titles">Titles</a> page if you know it.</p></div>`; set one from the <a href="/titles">Titles</a> page if you know it.</p></div>`;
const boxed = g.stored_in_game
? `<h2>Where it lives</h2>
<div class="card prose"><p>This one has no box of its own — it lives
inside <a href="/library/game/${encodeURIComponent(g.stored_in_game.key)}">
${esc(g.stored_in_game.name)}</a>.</p></div>`
: g.stored_in
? `<h2>Where it lives</h2>
<div class="card prose"><p class="meta">Stored inside BGG game
${esc(g.stored_in)} (not in the library yet — run <b>enrich</b>).</p></div>`
: "";
const contains = (g.contains || []).length
? `<h2>In this box</h2>
<div class="card prose"><p>${g.contains.map(c =>
`<a href="/library/game/${encodeURIComponent(c.key)}">${esc(c.name)}</a>`
).join(" · ")}</p></div>`
: "";
const photos = (g.photos || []).length const photos = (g.photos || []).length
? `<h2>Seen on your shelves</h2> ? `<h2>Seen on your shelves</h2>
<div class="card prose"><p>${g.photos.map(p => <div class="card prose"><p>${g.photos.map(p =>
@@ -173,6 +189,8 @@ function render(g) {
</div> </div>
${local ? localForm(g) : ""} ${local ? localForm(g) : ""}
${version} ${version}
${boxed}
${contains}
${photos} ${photos}
${g.description && !local ? `<h2>About</h2> ${g.description && !local ? `<h2>About</h2>
<div class="card prose"><p class="gdesc">${esc(g.description)}</p></div>` : ""}`; <div class="card prose"><p class="gdesc">${esc(g.description)}</p></div>` : ""}`;
+85 -1
View File
@@ -17,13 +17,30 @@
<button type="submit" class="primary">add</button> <button type="submit" class="primary">add</button>
<button type="button" id="addcancel">cancel</button> <button type="button" id="addcancel">cancel</button>
</span> </span>
<span class="edithint">joins the titles list like a photo read — resolve matches it next run</span> <span class="edithint">joins the titles list like a photo read — resolve matches it
next run. A whole box of expansions? <button type="button" id="topicks"
class="linkish">search BGG and tick them off</button></span>
</form> </form>
<div id="pickpanel" class="editform card" hidden>
<label>Search BGG <input id="pickq" placeholder="red dragon inn"></label>
<span class="editactions"><button type="button" id="dopicksearch" class="primary">search</button>
<button type="button" id="pickcancel">cancel</button></span>
<div id="pickresults"></div>
<div id="pickfooter" hidden>
<label>They all live inside
<select id="pickcontainer"><option value="">their own boxes</option></select>
</label>
<button type="button" id="addpicks" class="primary">add checked</button>
<span class="edithint">each checked result is added already-matched (you just
picked it off BGG's list) — no resolve pass needed</span>
</div>
</div>
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div> <div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
<script> <script>
"use strict"; "use strict";
let CATALOG = []; let CATALOG = [];
let EDITING = null; // lineKey of the row whose editor is open let EDITING = null; // lineKey of the row whose editor is open
let PICKS = []; // last BGG search results shown in the pick panel
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); } function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
@@ -258,6 +275,73 @@ document.getElementById("addcancel").addEventListener("click", () => {
addBtn.setAttribute("aria-expanded", "false"); addBtn.setAttribute("aria-expanded", "false");
addForm.reset(); addForm.reset();
}); });
// -- the pick panel: one BGG search, tick off a whole box ---------------
const pickPanel = document.getElementById("pickpanel");
document.getElementById("topicks").addEventListener("click", () => {
addForm.hidden = true;
pickPanel.hidden = false;
document.getElementById("pickq").focus();
});
document.getElementById("pickcancel").addEventListener("click", () => {
pickPanel.hidden = true;
document.getElementById("pickresults").innerHTML = "";
document.getElementById("pickfooter").hidden = true;
});
async function pickSearch() {
const q = document.getElementById("pickq").value.trim();
if (!q) return;
const box = document.getElementById("pickresults");
box.innerHTML = `<p class="meta">searching BGG…</p>`;
let data;
try {
data = await fetchJSON(`/api/bgg-search?q=${encodeURIComponent(q)}`);
} catch (err) {
box.innerHTML = `<p class="meta">search failed: ${esc(err.message || err)}</p>`;
return;
}
PICKS = data.results;
box.innerHTML = PICKS.length
? PICKS.map((r, i) => `
<label class="pickrow">
<input type="checkbox" data-pick-ix="${i}" ${r.already ? "disabled" : ""}>
${esc(r.name)} <span class="meta">${esc(r.year ?? "—")} · ${esc(r.type)}</span>
${r.already ? `<span class="chip ok">already cataloged</span>` : ""}
</label>`).join("")
: `<p class="meta">BGG found nothing for that.</p>`;
document.getElementById("pickfooter").hidden = !PICKS.length;
// container choices: anything already matched, the searched family first
const owned = CATALOG.filter(c => c.bgg_id && ["auto", "approved"].includes(c.status));
const ql = q.toLowerCase();
owned.sort((a, b) =>
(b.bgg_name || "").toLowerCase().includes(ql) -
(a.bgg_name || "").toLowerCase().includes(ql));
document.getElementById("pickcontainer").innerHTML =
`<option value="">their own boxes</option>` + owned.map(c =>
`<option value="${esc(c.bgg_id)}">${esc(c.bgg_name)}</option>`).join("");
}
document.getElementById("dopicksearch").addEventListener("click", pickSearch);
document.getElementById("pickq").addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); pickSearch(); }
});
document.getElementById("addpicks").addEventListener("click", async () => {
const picks = Array.from(
document.querySelectorAll("#pickresults input:checked")
).map(cb => PICKS[Number(cb.dataset.pickIx)]);
if (!picks.length) return;
const res = await apiPost("/api/add-picked", {
picks, stored_in: document.getElementById("pickcontainer").value,
});
if (res) {
showToast(`added <b>${res.added}</b> game(s)` +
(res.skipped.length ? `${res.skipped.length} skipped (already on file)` : ""));
pickPanel.hidden = true;
document.getElementById("pickresults").innerHTML = "";
document.getElementById("pickfooter").hidden = true;
GATE.reset();
refresh().catch(() => {});
}
});
addForm.addEventListener("submit", async e => { addForm.addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const v = name => addForm.elements[name].value; const v = name => addForm.elements[name].value;
+142 -2
View File
@@ -60,7 +60,7 @@ from bggpipe.models import (
RECOGNIZED_MATCH_STATUSES, RECOGNIZED_MATCH_STATUSES,
) )
from bggpipe.normalize import normalize_title from bggpipe.normalize import normalize_title
from bggpipe.resolve import TitleEntry from bggpipe.resolve import MATCH_COLUMNS, TitleEntry, read_matches, write_matches
from bggpipe.review import _BGG_ERRORS, ReviewSession from bggpipe.review import _BGG_ERRORS, ReviewSession
@@ -211,6 +211,11 @@ class ResearchBody(BaseModel):
types: str | None = None # e.g. "rpgitem" to search RPGGeek types: str | None = None # e.g. "rpgitem" to search RPGGeek
class PickBody(BaseModel):
picks: list[dict] # {bgg_id, name, year, type} straight from search
stored_in: str = "" # container's bgg_id: all picks live in ONE box
class RemoveBody(BaseModel): class RemoveBody(BaseModel):
title_raw: str title_raw: str
source_photos: str = "" source_photos: str = ""
@@ -952,9 +957,32 @@ def create_app(
@app.get("/api/library/{key:path}") @app.get("/api/library/{key:path}")
def api_library_game(key: str) -> dict: def api_library_game(key: str) -> dict:
game = library_entries().get(key) entries = library_entries()
game = entries.get(key)
if game is None: if game is None:
raise HTTPException(404, "no such game in the library") raise HTTPException(404, "no such game in the library")
# containment, both directions: the box this lives in, and what
# lives in this box
if game.get("stored_in"):
container = next(
(
{"key": k, "name": e.get("name")}
for k, e in entries.items()
if str(e.get("bgg_id")) == str(game["stored_in"])
),
None,
)
game = {**game, "stored_in_game": container}
contents = sorted(
(
{"key": k, "name": e.get("name")}
for k, e in entries.items()
if e.get("stored_in") and str(e["stored_in"]) == str(game.get("bgg_id"))
),
key=lambda c: (c["name"] or "").casefold(),
)
if contents:
game = {**game, "contains": contents}
if key.startswith("local:"): if key.startswith("local:"):
# overlay the hand-written store LIVE: the edit form re-renders # overlay the hand-written store LIVE: the edit form re-renders
# from this payload, and pre-enrich values here would resubmit # from this payload, and pre-enrich values here would resubmit
@@ -1344,6 +1372,118 @@ def create_app(
replay_titles(cfg) replay_titles(cfg)
return state() return state()
@app.get("/api/bgg-search")
def api_bgg_search(q: str) -> dict:
"""Raw BGG search results as a pick-list — for cataloging a box
of expansions in one pass. Marks ids the catalog already has so
the human isn't offered what's already shelved."""
query = q.strip()
if not query:
raise HTTPException(400, "a search needs some text")
with lock:
freshen()
have = {row["bgg_id"] for row in session.rows if row["bgg_id"]}
try:
results = session.client.search(query)
except _BGG_ERRORS as err:
raise HTTPException(502, f"search failed: {err}") from err
seen: set[int] = set()
out = []
for r in results:
if r.bgg_id in seen:
continue # one pick-list line per game, whatever name matched
seen.add(r.bgg_id)
out.append(
{
"bgg_id": r.bgg_id,
"name": r.name,
"year": r.year,
"type": r.type,
"already": str(r.bgg_id) in have,
}
)
out.sort(key=lambda r: (r["name"].casefold(), r["year"] or 0))
return {"results": out}
@app.post("/api/add-picked")
def api_add_picked(body: PickBody) -> dict:
"""Each ticked search result becomes a hand-added title AND an
approved match in one step — the human picked it off BGG's own
list, so there is nothing left for resolve to re-derive."""
with lock:
revision["n"] += 1
_refuse_if_rewriting()
freshen()
added, skipped = 0, []
rows = read_matches(cfg.matches_path)
have_ids = {row["bgg_id"] for row in rows if row["bgg_id"]}
by_norm: dict[str, dict] = {}
for row in rows:
by_norm.setdefault(normalize_title(row["title_raw"]), row)
for pick in body.picks:
name = str(pick.get("name") or "").strip()
bgg_id = pick.get("bgg_id")
if not name or not bgg_id:
raise HTTPException(400, "each pick needs a name and bgg_id")
if str(bgg_id) in have_ids:
skipped.append(f"{name} — already in the catalog")
continue
existing = by_norm.get(normalize_title(name))
if existing is not None:
# merged rows stay merged — only live open lines adopt
if existing["match_status"] in ("ambiguous", "unmatched"):
# the pick IS the box already photographed: decide
# that line rather than duplicating it
existing.update(
{
"match_status": "approved",
"bgg_id": str(bgg_id),
"bgg_name": name,
"year": str(pick.get("year") or ""),
"type": str(pick.get("type") or "boardgame"),
"stored_in": body.stored_in.strip(),
}
)
have_ids.add(str(bgg_id))
added += 1
else:
skipped.append(f"{name} — a same-named line already exists")
continue
entry = {
"title_raw": name,
"confidence": "high", # the human is holding the box
"publisher_hint": "",
"edition_hint": "",
"year_hint": pick.get("year"),
"language_hint": "",
"art_notes": "",
"source_photos": [],
}
rescind_title_removal(cfg.title_removals_path, name)
if not record_title_addition(cfg.title_additions_path, entry):
skipped.append(f"{name} — a same-named line already exists")
continue
rows.append(
{
**dict.fromkeys(MATCH_COLUMNS, ""),
"title_raw": name,
"bgg_id": str(bgg_id),
"bgg_name": name,
"year": str(pick.get("year") or ""),
"type": str(pick.get("type") or "boardgame"),
"match_status": "approved",
"version_status": "version_unknown",
"candidates_json": "[]",
"version_candidates_json": "[]",
"stored_in": body.stored_in.strip(),
}
)
have_ids.add(str(bgg_id))
added += 1
write_matches(cfg.matches_path, rows)
replay_titles(cfg)
return {**state(), "added": added, "skipped": skipped}
@app.post("/api/add-title") @app.post("/api/add-title")
def api_add_title(body: AddBody) -> dict: def api_add_title(body: AddBody) -> dict:
with lock: with lock:
+97
View File
@@ -103,6 +103,7 @@ def make_cfg(tmp_path) -> Config:
[ [
{ {
"title_raw": "Citadels", "title_raw": "Citadels",
"confidence": "high",
"publisher_hint": "Fantasy Flight", "publisher_hint": "Fantasy Flight",
"edition_hint": "", "edition_hint": "",
"source_photos": ["shelf.jpg"], "source_photos": ["shelf.jpg"],
@@ -1443,3 +1444,99 @@ def test_review_summary_reports_token_presence_not_value(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_API_TOKEN") monkeypatch.delenv("BGG_API_TOKEN")
web2, _ = make_client(tmp_path / "tokenless") web2, _ = make_client(tmp_path / "tokenless")
assert web2.get("/api/state").json()["summary"]["token_present"] is False assert web2.get("/api/state").json()["summary"]["token_present"] is False
def test_bgg_search_and_add_picked_flow(tmp_path):
"""A whole box of expansions in one pass: search once, tick, and each
pick lands already-matched with its container recorded."""
cfg = make_cfg(tmp_path)
web = TestClient(create_app(cfg, client=fixture_search_client()))
found = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
assert len(found) >= 2
ids = {r["bgg_id"] for r in found}
assert 478 in ids
assert all(r["already"] is False for r in found)
picks = [r for r in found if r["bgg_id"] in (478, 13291, 205398)]
res = web.post(
"/api/add-picked", json={"picks": picks, "stored_in": "173634"}
).json()
# 478 and The Dark City land; 205398 is BGG's OTHER game named
# "Citadels" — a true name twin, skipped honestly rather than fused
assert res["added"] == 2
assert res["skipped"] == ["Citadels — a same-named line already exists"]
rows = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
# "Citadels" already existed as an undecided photo line: the pick
# DECIDED that line (photos kept) instead of duplicating it
assert rows["478"]["match_status"] == "approved"
assert rows["478"]["source_photos"] == "shelf.jpg"
assert rows["478"]["stored_in"] == "173634"
# the expansion had no line: appended photo-less, already matched
assert rows["13291"]["match_status"] == "approved"
assert rows["13291"]["source_photos"] == ""
# re-searching now flags them; re-adding skips instead of duplicating
found2 = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
assert next(r for r in found2 if r["bgg_id"] == 478)["already"] is True
res2 = web.post("/api/add-picked", json={"picks": picks}).json()
assert res2["added"] == 0 and len(res2["skipped"]) == 3
# blank query is a 400, and BGG-down is a 502, not a crash
assert web.get("/api/bgg-search", params={"q": " "}).status_code == 400
down = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
assert down.get("/api/bgg-search", params={"q": "x"}).status_code == 502
def fixture_search_client() -> BGGClient:
from pathlib import Path as _P
fixtures = _P(__file__).parent / "fixtures" / "bgg_cache"
return BGGClient(
cache_dir=fixtures,
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
)
def test_library_detail_shows_containment_both_ways(tmp_path):
cfg = make_cfg(tmp_path)
rows = read_matches(cfg.matches_path)
rows.append(
_row(
title_raw="The Trove",
match_status="approved",
bgg_id="173634",
source_photos="shelf.jpg",
)
)
rows.append(
_row(
title_raw="Witchdoctor",
match_status="approved",
bgg_id="999",
source_photos="",
stored_in="173634",
)
)
write_matches(cfg.matches_path, rows)
cfg.games_path.write_text(
json.dumps(
{
"173634": {"bgg_id": 173634, "name": "The Trove", "type": "boardgame"},
"999": {
"bgg_id": 999,
"name": "Witchdoctor",
"type": "boardgameexpansion",
"stored_in": "173634",
},
}
)
)
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
inside = web.get("/api/library/999").json()
assert inside["stored_in_game"] == {"key": "173634", "name": "The Trove"}
box = web.get("/api/library/173634").json()
assert box["contains"] == [{"key": "999", "name": "Witchdoctor"}]