Containment becomes editable: the where-it-lives card grows controls

Eric's question exposed the gap: stored_in was settable only at
pick-time, with no path for a game already in the system. The Library
detail page's "Where it lives" card now renders for every entry — "in
its own box on a shelf" with an it-lives-inside-another-box control,
or the current container with a change button — saving through a new
/api/stored-in endpoint that updates the match rows (the durable
record) AND the library entry in place, so the page and the shelf math
reflect it immediately, no enrich run needed. Guards refuse
self-containment and direct cycles; local games are addressable
through their key's normalized title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqdW79g
This commit is contained in:
Eric Wagoner
2026-08-09 12:55:36 -04:00
co-authored by Claude Fable 5
parent f825a91ea4
commit fb611e0c87
5 changed files with 1484 additions and 137 deletions
+1352 -126
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -42,7 +42,7 @@
<p><b>wrong match</b> (inside the edit panel) — an auto-match landed on the wrong game (same-name impostors happen). This clears the match, re-searches immediately, and returns the title to Review as a fresh ballot of candidates (including same-named sibling editions); manual-id entry is there too for games BGG's search can't find. Note BGG sometimes splits one game's lineage across entries — Wiz-War's early editions and its FFG remake are separate games — so a copy whose edition isn't on the ballot may belong to the sibling entry.</p>
<p><b>add a game</b> (top of the Titles page) — a game no photo shows: an expansion stored inside a base box, a game away from the shelves. It joins the list like any read (BGG wants base game and expansion as separate collection entries, so boxes that hold both need this for the hidden half), and if a later photo shows it, the sighting merges instead of duplicating.</p>
<p><b>change edition</b> (on any matched line whose printing is set) — reopens the full edition list in Review; the current pick stays until you choose, so backing out loses nothing. Note the pipeline never edits a version already set on your BGG collection entry (strictly additive): if diff reports a "version disagreement", either fix the pipeline's pick here, or fix the entry by hand on BGG — the pipeline won't overwrite it.</p>
<p><b>search BGG and tick them off</b> (inside the add panel) — for a box holding many games at once, like a compilation of two dozen expansions: search BGG once, tick every one that's in your box, and optionally name the container they all live inside. Each tick lands <i>already matched</i> — you picked it off BGG's own list, so resolve has nothing to re-derive; a pick whose name matches an undecided photo line decides that line instead of duplicating it. Contained games show "where it lives" in the Library, the container lists what's in it, and the shelf-space report knows they take no shelf of their own.</p>
<p><b>search BGG and tick them off</b> (inside the add panel) — for a box holding many games at once, like a compilation of two dozen expansions: search BGG once, tick every one that's in your box, and optionally name the container they all live inside. Each tick lands <i>already matched</i> — you picked it off BGG's own list, so resolve has nothing to re-derive; a pick whose name matches an undecided photo line decides that line instead of duplicating it. Contained games show "where it lives" in the Library, the container lists what's in it, and the shelf-space report knows they take no shelf of their own. Containment is editable after the fact: every game's Library detail page has a "where it lives" card — change it, clear it, or declare it there anytime.</p>
<p><b>remove</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title, or a <i>duplicate read</i> — the same physical copy read differently from two photos, leaving two lines for one box (remove the worse read; the survivor keeps its own photos). This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know. And the mirror case — ONE line that's really several physical copies — wants <b>split</b>, not remove.</p>
<p>Undo: each decision is one record in <code>data/title_edits.json</code>, <code>data/title_splits.json</code>, or <code>data/title_removals.json</code> — delete the record and the next rebuild restores the old state.</p>
</div>
+45 -9
View File
@@ -62,6 +62,33 @@ function localForm(g) {
</div>`;
}
function wireStored(g) {
const btn = document.getElementById("editstored");
if (!btn) return;
btn.addEventListener("click", async () => {
const form = document.getElementById("storedform");
form.hidden = false;
btn.disabled = true;
const all = await fetchJSON("/api/library");
const pick = document.getElementById("storedpick");
pick.innerHTML = `<option value="">its own box</option>` + all
.filter(o => o.bgg_id && o.bgg_id !== g.bgg_id)
.sort((a, b) => (a.name || "").localeCompare(b.name || ""))
.map(o => `<option value="${esc(String(o.bgg_id))}"
${String(o.bgg_id) === String(g.stored_in || "") ? "selected" : ""}>
${esc(o.name)}</option>`).join("");
});
document.getElementById("storedsave")?.addEventListener("click", async () => {
const res = await apiPost("/api/stored-in", {
key: KEY, container: document.getElementById("storedpick").value,
});
if (res) {
showToast("saved — the shelf math knows");
refresh();
}
});
}
function wireLocal(g) {
const form = document.getElementById("localform");
if (form) form.addEventListener("submit", async e => {
@@ -159,16 +186,24 @@ 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>`
const boxed = `<h2>Where it lives</h2>
<div class="card prose">
${g.stored_in_game
? `<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>.
<button class="linkish" id="editstored">change</button></p>`
: 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>`
: "";
? `<p class="meta">Stored inside BGG game ${esc(g.stored_in)}
(not in the library yet — run <b>enrich</b>).
<button class="linkish" id="editstored">change</button></p>`
: `<p class="meta">In its own box on a shelf.
<button class="linkish" id="editstored">it lives inside another box…</button></p>`}
<p id="storedform" hidden>
<select id="storedpick"><option value="">its own box</option></select>
<button class="primary" id="storedsave">save</button>
</p>
</div>`;
const contains = (g.contains || []).length
? `<h2>In this box</h2>
<div class="card prose"><p>${g.contains.map(c =>
@@ -195,6 +230,7 @@ function render(g) {
${g.description && !local ? `<h2>About</h2>
<div class="card prose"><p class="gdesc">${esc(g.description)}</p></div>` : ""}`;
wireLocal(g);
wireStored(g);
}
async function refresh() {
+60
View File
@@ -211,6 +211,11 @@ class ResearchBody(BaseModel):
types: str | None = None # e.g. "rpgitem" to search RPGGeek
class StoredInBody(BaseModel):
key: str # games.json key of the game being housed
container: str = "" # container's bgg_id; "" = its own box again
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
@@ -955,6 +960,61 @@ def create_app(
raise HTTPException(404, "no such image")
return FileResponse(target)
@app.post("/api/stored-in")
def api_stored_in(body: StoredInBody) -> dict:
"""Declare (or clear) which box an already-cataloged game lives
inside. Updates the match rows (the durable record) and the
library entry in place, so the page reflects it immediately."""
with lock:
revision["n"] += 1
_refuse_if_rewriting()
freshen()
games = read_games()
entry = games.get(body.key)
if entry is None:
raise HTTPException(404, "no such game in the library")
container = body.container.strip()
if container:
if str(entry.get("bgg_id")) == container:
raise HTTPException(400, "a box cannot live inside itself")
holder = next(
(e for e in games.values() if str(e.get("bgg_id")) == container),
None,
)
if holder is None:
raise HTTPException(400, "the container must be a cataloged game")
if str(holder.get("stored_in") or "") == str(entry.get("bgg_id")):
raise HTTPException(400, "that box already lives inside THIS one")
rows = read_matches(cfg.matches_path)
bgg_id = str(entry.get("bgg_id") or "")
version_id = str((entry.get("version") or {}).get("version_id") or "")
hit = False
for row in rows:
if bgg_id and row["bgg_id"] == bgg_id:
if version_id and row.get("version_id") != version_id:
continue
row["stored_in"] = container
hit = True
elif not bgg_id and body.key.startswith("local:"):
norm = body.key.split(":", 2)[1]
if (
row["match_status"] == "local"
and normalize_title(row["title_raw"]) == norm
):
row["stored_in"] = container
hit = True
if not hit:
raise HTTPException(
409, "no match row backs this entry — re-run enrich first"
)
write_matches(cfg.matches_path, rows)
entry["stored_in"] = container or None
atomic_write_text(
cfg.games_path,
json.dumps(games, indent=2, ensure_ascii=False) + "\n",
)
return {"stored_in": container or None}
@app.get("/api/library/{key:path}")
def api_library_game(key: str) -> dict:
entries = library_entries()
+25
View File
@@ -1540,3 +1540,28 @@ def test_library_detail_shows_containment_both_ways(tmp_path):
assert inside["stored_in_game"] == {"key": "173634", "name": "The Trove"}
box = web.get("/api/library/173634").json()
assert box["contains"] == [{"key": "999", "name": "Witchdoctor"}]
# containment is editable after the fact: clear it, then set it back
assert web.post("/api/stored-in", json={"key": "999", "container": ""}).json() == {
"stored_in": None
}
rows2 = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
assert rows2["999"]["stored_in"] == ""
assert "stored_in_game" not in web.get("/api/library/999").json()
assert web.post(
"/api/stored-in", json={"key": "999", "container": "173634"}
).json() == {"stored_in": "173634"}
assert read_matches(cfg.matches_path)[-1]["stored_in"] == "173634"
# guards: self-containment and direct cycles refuse
assert (
web.post("/api/stored-in", json={"key": "999", "container": "999"}).status_code
== 400
)
assert (
web.post(
"/api/stored-in", json={"key": "173634", "container": "999"}
).status_code
== 400
)