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:
co-authored by
Claude Fable 5
parent
102507b040
commit
dbc1899759
+4685
-3831
File diff suppressed because it is too large
Load Diff
+65
-65
File diff suppressed because one or more lines are too long
@@ -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,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,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,
|
||||
|
||||
|
@@ -41,14 +41,24 @@ def run_dims_report(cfg: Config) -> dict:
|
||||
raise typer.Exit(code=1)
|
||||
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]]] = {}
|
||||
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"}
|
||||
by_source.setdefault(dims["source"], []).append(
|
||||
(entry.get("name") or "?", dims)
|
||||
)
|
||||
|
||||
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 (
|
||||
("version", "from the exact owned version"),
|
||||
("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()},
|
||||
"misfits": [name for name, _ in misfits],
|
||||
"unknown": sorted(unknown),
|
||||
"contained": sorted(contained),
|
||||
}
|
||||
|
||||
@@ -271,6 +271,19 @@ def run_enrich(
|
||||
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
|
||||
# (read-only; upload is untouched) fills entry["dims"] for shelf math
|
||||
dims_filled, dims_blocked = _dims_pass(games, client, refresh=refresh)
|
||||
|
||||
@@ -57,6 +57,9 @@ MATCH_COLUMNS = [
|
||||
"source_photos",
|
||||
"merged_into",
|
||||
"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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -498,6 +498,15 @@ a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
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 {
|
||||
background: var(--board); border: 2px dashed var(--board-edge);
|
||||
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 —
|
||||
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
|
||||
? `<h2>Seen on your shelves</h2>
|
||||
<div class="card prose"><p>${g.photos.map(p =>
|
||||
@@ -173,6 +189,8 @@ function render(g) {
|
||||
</div>
|
||||
${local ? localForm(g) : ""}
|
||||
${version}
|
||||
${boxed}
|
||||
${contains}
|
||||
${photos}
|
||||
${g.description && !local ? `<h2>About</h2>
|
||||
<div class="card prose"><p class="gdesc">${esc(g.description)}</p></div>` : ""}`;
|
||||
|
||||
@@ -17,13 +17,30 @@
|
||||
<button type="submit" class="primary">add</button>
|
||||
<button type="button" id="addcancel">cancel</button>
|
||||
</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>
|
||||
<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>
|
||||
<script>
|
||||
"use strict";
|
||||
let CATALOG = [];
|
||||
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(";"); }
|
||||
|
||||
@@ -258,6 +275,73 @@ document.getElementById("addcancel").addEventListener("click", () => {
|
||||
addBtn.setAttribute("aria-expanded", "false");
|
||||
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 => {
|
||||
e.preventDefault();
|
||||
const v = name => addForm.elements[name].value;
|
||||
|
||||
+142
-2
@@ -60,7 +60,7 @@ from bggpipe.models import (
|
||||
RECOGNIZED_MATCH_STATUSES,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -211,6 +211,11 @@ class ResearchBody(BaseModel):
|
||||
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):
|
||||
title_raw: str
|
||||
source_photos: str = ""
|
||||
@@ -952,9 +957,32 @@ def create_app(
|
||||
|
||||
@app.get("/api/library/{key:path}")
|
||||
def api_library_game(key: str) -> dict:
|
||||
game = library_entries().get(key)
|
||||
entries = library_entries()
|
||||
game = entries.get(key)
|
||||
if game is None:
|
||||
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:"):
|
||||
# overlay the hand-written store LIVE: the edit form re-renders
|
||||
# from this payload, and pre-enrich values here would resubmit
|
||||
@@ -1344,6 +1372,118 @@ def create_app(
|
||||
replay_titles(cfg)
|
||||
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")
|
||||
def api_add_title(body: AddBody) -> dict:
|
||||
with lock:
|
||||
|
||||
@@ -103,6 +103,7 @@ def make_cfg(tmp_path) -> Config:
|
||||
[
|
||||
{
|
||||
"title_raw": "Citadels",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Fantasy Flight",
|
||||
"edition_hint": "",
|
||||
"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")
|
||||
web2, _ = make_client(tmp_path / "tokenless")
|
||||
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"}]
|
||||
|
||||
Reference in New Issue
Block a user