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:
co-authored by
Claude Fable 5
parent
7b6ea90934
commit
90e75daf98
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user