Add a game by hand: the fourth curation store

BGG wants base game and expansion as separate collection entries, but
a box that stores its expansion's bits shows one spine to the camera —
the hidden half was unreachable. "add a game" on the Titles page
records an entry in data/title_additions.json (committed, like every
curation store), joined into every rebuild BEFORE edits and dedupe: so
corrections apply to it, a later photo sighting of the same game
merges instead of duplicating (photo provenance wins), and re-adding
an existing title is a no-op. Photo-less lines show an "added by hand"
chip where their photo links would be; from resolve onward they are
ordinary titles.

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-05 19:13:49 -04:00
co-authored by Claude Fable 5
parent 7b6ea90934
commit 90e75daf98
9 changed files with 216 additions and 16 deletions
+6
View File
@@ -74,6 +74,12 @@ class Config:
def games_path(self) -> Path:
return self.data_dir / "games.json"
@property
def title_additions_path(self) -> Path:
# games the human added without a photo (expansions stored inside
# base boxes, unshelved games) — joined into every rebuild
return self.data_dir / "title_additions.json"
@property
def title_removals_path(self) -> Path:
# titles the human removed from the catalog (not a game, misread) —
+30 -1
View File
@@ -395,6 +395,24 @@ def _scoped_records(path: Path) -> list[dict]:
return records
def load_title_additions(path: Path) -> list[dict]:
"""Entries the human added without a photo — an expansion stored inside
a base box, a game away from the shelves. Shaped like raw reads and fed
into every rebuild BEFORE edits and dedupe, so corrections apply and a
later photo sighting of the same game merges instead of duplicating."""
return _load_store(path)
def record_title_addition(path: Path, entry: dict) -> None:
existing = load_title_additions(path)
norm = normalize_title(entry["title_raw"])
if any(normalize_title(e["title_raw"]) == norm for e in existing):
return # already on file; edit the existing line instead
atomic_write_text(
path, json.dumps([*existing, entry], indent=2, ensure_ascii=False) + "\n"
)
def load_title_splits(path: Path) -> list[dict]:
"""The human's split-into-copies decisions — durable: they must survive
extract rebuilds and resolve --force."""
@@ -490,6 +508,7 @@ def rebuild_artifacts(
splits: list[dict] | None = None,
edits: list[dict] | None = None,
removals: list[dict] | None = None,
additions: list[dict] | None = None,
) -> tuple[list[dict], dict[str, list[dict]]]:
"""Regenerate titles.json and unidentified.json from the per-photo raw
cache. A raw file is either an object with titles/unidentified or a bare
@@ -511,6 +530,7 @@ def rebuild_artifacts(
photo = raw_file.name.removesuffix(".json")
if data.get("unidentified"):
unidentified[photo] = data["unidentified"]
entries = entries + [dict(e) for e in additions or []]
entries = apply_title_removals(
apply_title_edits(entries, edits or []), removals or []
)
@@ -534,6 +554,7 @@ def replay_titles(cfg: Config) -> None:
splits = load_title_splits(cfg.title_splits_path)
edits = load_title_edits(cfg.title_edits_path)
removals = load_title_removals(cfg.title_removals_path)
additions = load_title_additions(cfg.title_additions_path)
raw_dir = cfg.extract_raw_dir
raw_photos = (
{f.name.removesuffix(".json") for f in raw_dir.glob("*.json")}
@@ -549,7 +570,13 @@ def replay_titles(cfg: Config) -> None:
# them would silently truncate the committed catalog
if raw_photos and raw_photos >= known_photos:
rebuild_artifacts(
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits, removals
raw_dir,
cfg.titles_path,
cfg.unidentified_path,
splits,
edits,
removals,
additions,
)
return
if not cfg.titles_path.exists():
@@ -561,6 +588,7 @@ def replay_titles(cfg: Config) -> None:
exploded.extend({**entry, "source_photos": [p]} for p in photos)
else:
exploded.append(dict(entry))
exploded.extend(dict(e) for e in additions)
deduped = dedupe_entries(
apply_title_removals(apply_title_edits(exploded, edits), removals), splits
)
@@ -655,6 +683,7 @@ def run_extract(
load_title_splits(cfg.title_splits_path),
load_title_edits(cfg.title_edits_path),
load_title_removals(cfg.title_removals_path),
load_title_additions(cfg.title_additions_path),
)
typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
if failed:
+1
View File
@@ -39,6 +39,7 @@
<p><b>edit</b> — fix a misread title or add cues you already know (publisher, edition, year, language). A corrected misspelling automatically merges with a correctly-read sighting of the same game from another photo. If the line already had a BGG match, saving re-queues it so resolve searches again with the corrected data.</p>
<p><b>split into copies</b> — one line, several physical boxes? Splitting makes each photo its own copy, and each copy picks its own edition afterward. Appears on any line whose title was seen in more than one photo. Splitting one game never affects a same-named different edition.</p>
<p><b>pick edition</b> — a matched game with no legible edition cues stays version-less by design (never guess) — but you know which printing your box is. This fetches the game's complete version list into a Review ballot; pick yours there.</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>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. The line and its matches are discarded and stay gone. 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.</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>
+47 -3
View File
@@ -4,7 +4,21 @@
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter titles">
<button id="shakyfilter" aria-pressed="false" hidden
title="reads the model wasn't sure of — confirm or fix each one">shaky reads</button>
<button id="addtitle" aria-expanded="false"
title="a game no photo shows — an expansion inside a base box, a game away from the shelves">add a game</button>
</div>
<form id="addform" class="editform card" hidden>
<label>Title <input name="title" required></label>
<label>Publisher <input name="publisher"></label>
<label>Edition <input name="edition"></label>
<label>Year <input name="year" inputmode="numeric" size="6"></label>
<label>Language <input name="language"></label>
<span class="editactions">
<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>
</form>
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
<script>
"use strict";
@@ -60,9 +74,11 @@ function render() {
title="the model wasn't sure of this read — press ✓ if it's right, or edit it">shaky read</span>` : ""}</td>
<td>${statusChip(c)}</td>
<td class="meta">${metaLine(c)}</td>
<td class="meta">${c.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(", ")}</td>
<td class="meta">${c.photos.length
? c.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(", ")
: `<span class="chip merged">added by hand</span>`}</td>
<td class="actions">${c.can_split
? `<button class="split" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}"
@@ -176,6 +192,34 @@ document.getElementById("catbody").addEventListener("submit", async e => {
if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); }
});
const addForm = document.getElementById("addform");
const addBtn = document.getElementById("addtitle");
addBtn.addEventListener("click", () => {
addForm.hidden = !addForm.hidden;
addBtn.setAttribute("aria-expanded", String(!addForm.hidden));
if (!addForm.hidden) addForm.elements.title.focus();
});
document.getElementById("addcancel").addEventListener("click", () => {
addForm.hidden = true;
addBtn.setAttribute("aria-expanded", "false");
addForm.reset();
});
addForm.addEventListener("submit", async e => {
e.preventDefault();
const v = name => addForm.elements[name].value;
const res = await apiPost("/api/add-title", {
title: v("title"), publisher: v("publisher"), edition: v("edition"),
year: v("year"), language: v("language"),
});
if (res) {
addForm.reset();
addForm.hidden = true;
addBtn.setAttribute("aria-expanded", "false");
GATE.reset();
refresh().catch(() => {});
}
});
document.getElementById("catsearch").addEventListener("input", render);
document.getElementById("shakyfilter").addEventListener("click", () => {
SHAKY_ONLY = !SHAKY_ONLY;
+37
View File
@@ -44,6 +44,7 @@ from bggpipe.config import DEFAULT_REVIEW_PORT, Config
from bggpipe.extract import (
is_split,
load_title_splits,
record_title_addition,
record_title_edit,
record_title_removal,
record_title_split,
@@ -174,6 +175,17 @@ class EditBody(BaseModel):
confirm: bool = False
class AddBody(BaseModel):
"""A game no photo shows: an expansion stored inside a base box, a
game away from the shelves. Cues optional, like any read."""
title: str
publisher: str = ""
edition: str = ""
year: str = ""
language: str = ""
class RemoveBody(BaseModel):
title_raw: str
source_photos: str = ""
@@ -1059,6 +1071,31 @@ def create_app(
replay_titles(cfg)
return state()
@app.post("/api/add-title")
def api_add_title(body: AddBody) -> dict:
with lock:
revision["n"] += 1
_refuse_if_rewriting()
freshen()
title = body.title.strip()
if not title:
raise HTTPException(400, "the title cannot be empty")
if body.year.strip() and not body.year.strip().isdigit():
raise HTTPException(400, "year must be a number")
entry = {
"title_raw": title,
"confidence": "high", # the human is holding the box
"publisher_hint": body.publisher.strip(),
"edition_hint": body.edition.strip(),
"year_hint": int(body.year) if body.year.strip() else None,
"language_hint": body.language.strip(),
"art_notes": "",
"source_photos": [],
}
record_title_addition(cfg.title_additions_path, entry)
replay_titles(cfg)
return state()
@app.post("/api/remove-title")
def api_remove_title(body: RemoveBody) -> dict:
with lock: