diff --git a/CLAUDE.md b/CLAUDE.md
index 0b68809..c64c130 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,7 +47,7 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
-- **Human curation is durable**: `data/title_splits.json` (titles split into per-photo copies) and `data/title_edits.json` (corrected reads/cues) are replayed on every titles.json rebuild, and both extract's and resolve's dedupe honor them forever. Row-level split decisions also persist via the `dedupe_veto` column.
+- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe) and `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place).
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
- Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift.
diff --git a/data/title_splits.json b/data/title_splits.json
new file mode 100644
index 0000000..3e5a77a
--- /dev/null
+++ b/data/title_splits.json
@@ -0,0 +1,3 @@
+[
+ "Wiz-War"
+]
diff --git a/data/titles.json b/data/titles.json
index 46a47d4..aadd394 100644
--- a/data/titles.json
+++ b/data/titles.json
@@ -260,16 +260,14 @@
},
{
"title_raw": "Wiz-War",
- "confidence": "high",
- "publisher_hint": "Fantasy Flight Games",
- "edition_hint": "9th Edition",
+ "confidence": "medium",
+ "publisher_hint": "",
+ "edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "black spine with purple/blue text, partial visible 'Wiz-Wa...' with subtitle 'and board game of ...ly battle and treasures!'",
"source_photos": [
- "IMG_4502.jpeg",
- "IMG_4504.jpeg",
- "IMG_4528.jpeg"
+ "IMG_4502.jpeg"
],
"title_normalized": "wiz war"
},
@@ -351,6 +349,19 @@
],
"title_normalized": "dungeon"
},
+ {
+ "title_raw": "WIZ-WAR",
+ "confidence": "high",
+ "publisher_hint": "Fantasy Flight Games",
+ "edition_hint": "",
+ "year_hint": null,
+ "language_hint": "English",
+ "art_notes": "Dark spine with wizard/warrior artwork, sepia tones",
+ "source_photos": [
+ "IMG_4504.jpeg"
+ ],
+ "title_normalized": "wiz war"
+ },
{
"title_raw": "TICKET TO RIDE",
"confidence": "high",
@@ -1280,6 +1291,19 @@
],
"title_normalized": "red dragon inn smorgasbox"
},
+ {
+ "title_raw": "WIZ-WAR",
+ "confidence": "high",
+ "publisher_hint": "",
+ "edition_hint": "9th Edition",
+ "year_hint": null,
+ "language_hint": "English",
+ "art_notes": "Dark purple/maroon box with pink outlined logo text, illustration of a turbaned wizard character casting fire spell in bottom right corner, tagline 'KILL THEM WITH FIRE!'",
+ "source_photos": [
+ "IMG_4528.jpeg"
+ ],
+ "title_normalized": "wiz war"
+ },
{
"title_raw": "SLUGFEST GAMES",
"confidence": "low",
diff --git a/src/bggpipe/extract.py b/src/bggpipe/extract.py
index 9f53770..8c078f6 100644
--- a/src/bggpipe/extract.py
+++ b/src/bggpipe/extract.py
@@ -251,18 +251,33 @@ EDIT_FIELDS = (
)
+def _load_store(path: Path) -> list:
+ """Curation stores hold irreplaceable human decisions and are committed
+ (merge conflicts are a realistic corruption vector) — so a broken file
+ must stop the pipeline loudly, never quietly reset it."""
+ if not path.exists():
+ return []
+ try:
+ return json.loads(path.read_text())
+ except json.JSONDecodeError as err:
+ raise ValueError(
+ f"{path} is corrupt ({err}) — fix or delete it; it holds human "
+ "review decisions, so check git history before deleting"
+ ) from err
+
+
def load_title_edits(path: Path) -> list[dict]:
"""Human corrections to raw reads (fixed misspellings, cues the owner
knows offhand). Each record: {"match":
, "photos": [...] to target one copy (optional), }. Applied on every rebuild, before dedupe."""
- if not path.exists():
- return []
- return json.loads(path.read_text())
+ return _load_store(path)
def record_title_edit(path: Path, record: dict) -> None:
existing = load_title_edits(path)
+ if existing and existing[-1] == record:
+ return # a retried request must not double-record
atomic_write_text(
path, json.dumps([*existing, record], indent=2, ensure_ascii=False) + "\n"
)
@@ -298,39 +313,69 @@ def apply_title_edits(entries: list[dict], edits: list[dict]) -> list[dict]:
return entries
-def load_title_splits(path: Path) -> set[str]:
- """Normalized titles the human split into per-photo copies — a durable
- review decision that must survive extract rebuilds and resolve --force."""
- if not path.exists():
- return set()
- return {normalize_title(t) for t in json.loads(path.read_text())}
+def load_title_splits(path: Path) -> list[dict]:
+ """The human's split-into-copies decisions — durable: they must survive
+ extract rebuilds and resolve --force. Each stored record is
+ {"title": ..., "photos": [...]} scoping the split to the sightings that
+ were on the split line (a bare string is legacy: unscoped). Returns
+ [{"norm": , "photos": set | None}]."""
+ records = []
+ for item in _load_store(path):
+ if isinstance(item, str):
+ records.append({"norm": normalize_title(item), "photos": None})
+ else:
+ photos = item.get("photos")
+ records.append(
+ {
+ "norm": normalize_title(item["title"]),
+ "photos": set(photos) if photos else None,
+ }
+ )
+ return records
-def record_title_split(path: Path, title: str) -> None:
- existing = json.loads(path.read_text()) if path.exists() else []
- if normalize_title(title) in {normalize_title(t) for t in existing}:
- return
- atomic_write_text(
- path, json.dumps([*existing, title], indent=2, ensure_ascii=False) + "\n"
+def is_split(norm: str, photos, splits: list[dict]) -> bool:
+ """Does a split decision cover this (normalized title, photo set)?
+ Photo-scoped records only bind sightings from the photos that were on
+ the split line — a same-named different edition keeps deduping."""
+ photo_set = set(photos or [])
+ return any(
+ r["norm"] == norm and (r["photos"] is None or r["photos"] & photo_set)
+ for r in splits
)
-def dedupe_entries(
- entries: list[dict], split_titles: set[str] = frozenset()
-) -> list[dict]:
+def record_title_split(path: Path, title: str, photos: list[str] | None = None) -> None:
+ if is_split(normalize_title(title), photos or [], load_title_splits(path)):
+ return
+ existing = _load_store(path)
+ record = {"title": title, "photos": sorted(photos) if photos else None}
+ atomic_write_text(
+ path, json.dumps([*existing, record], indent=2, ensure_ascii=False) + "\n"
+ )
+
+
+def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> list[dict]:
"""Collapse same-normalized-title sightings unless their cues conflict —
- or unless the human declared the title split (several physical copies):
- those stay one entry per photo."""
+ or unless the human declared them split (several physical copies):
+ split sightings stay one entry per photo and never absorb new ones."""
+ splits = splits or []
+
+ def covered(e: dict) -> bool:
+ return is_split(e["title_normalized"], e.get("source_photos"), splits)
+
result: list[dict] = []
for entry in entries:
entry = {**entry, "title_normalized": normalize_title(entry["title_raw"])}
- if entry["title_normalized"] in split_titles:
+ if covered(entry):
result.append(entry)
continue
for existing in result:
- if existing["title_normalized"] == entry[
- "title_normalized"
- ] and not cues_conflict(existing, entry):
+ if (
+ existing["title_normalized"] == entry["title_normalized"]
+ and not covered(existing)
+ and not cues_conflict(existing, entry)
+ ):
existing.update(_merge(existing, entry))
break
else:
@@ -342,7 +387,7 @@ def rebuild_artifacts(
raw_dir: Path,
titles_path: Path,
unidentified_path: Path,
- split_titles: set[str] = frozenset(),
+ splits: list[dict] | None = None,
edits: list[dict] | None = None,
) -> tuple[list[dict], dict[str, list[dict]]]:
"""Regenerate titles.json and unidentified.json from the per-photo raw
@@ -365,7 +410,7 @@ def rebuild_artifacts(
photo = raw_file.name.removesuffix(".json")
if data.get("unidentified"):
unidentified[photo] = data["unidentified"]
- deduped = dedupe_entries(apply_title_edits(entries, edits or []), split_titles)
+ deduped = dedupe_entries(apply_title_edits(entries, edits or []), splits)
titles_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text(
titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
@@ -385,7 +430,19 @@ def replay_titles(cfg: Config) -> None:
splits = load_title_splits(cfg.title_splits_path)
edits = load_title_edits(cfg.title_edits_path)
raw_dir = cfg.extract_raw_dir
- if raw_dir.is_dir() and any(raw_dir.glob("*.json")):
+ raw_photos = (
+ {f.name.removesuffix(".json") for f in raw_dir.glob("*.json")}
+ if raw_dir.is_dir()
+ else set()
+ )
+ known_photos: set[str] = set()
+ if cfg.titles_path.exists():
+ for entry in json.loads(cfg.titles_path.read_text()):
+ known_photos.update(entry.get("source_photos") or [])
+ # raw caches are gitignored: a fresh clone (or a killed/partial extract)
+ # can have FEWER raw files than titles.json has photos — rebuilding from
+ # 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
)
diff --git a/src/bggpipe/resolve.py b/src/bggpipe/resolve.py
index 487c818..fb536c1 100644
--- a/src/bggpipe/resolve.py
+++ b/src/bggpipe/resolve.py
@@ -22,7 +22,7 @@ from rapidfuzz import fuzz
from bggpipe.bgg_client import BGGAuthError, BGGClient, BGGQueueTimeout, client_for
from bggpipe.config import Config
-from bggpipe.extract import cues_conflict, load_title_splits
+from bggpipe.extract import cues_conflict, is_split, load_title_splits
from bggpipe.fsio import atomic_write_csv
from bggpipe.models import (
RECOGNIZED_MATCH_STATUSES,
@@ -431,7 +431,7 @@ class MergeEvent:
def dedupe_matches(
rows: list[dict],
titles: list[TitleEntry],
- split_titles: set[str] = frozenset(),
+ splits: list[dict] | None = None,
) -> list[MergeEvent]:
"""Post-resolve dedupe: rows resolving to the same (bgg_id, version_id —
or both version-unknown) are the same physical game seen twice (a typo
@@ -466,8 +466,12 @@ def dedupe_matches(
# re-running resolve must never overturn that (spec: re-runs
# lose no work, least of all review decisions)
continue
- if normalize_title(row["title_raw"]) in split_titles:
- continue # human-split title: per-photo rows stay separate
+ if is_split(
+ normalize_title(row["title_raw"]),
+ row["source_photos"].split(";"),
+ splits or [],
+ ):
+ continue # human-split copies: per-photo rows stay separate
key = (
row["bgg_id"],
row["version_id"] if is_confident_version(row) else "",
diff --git a/src/bggpipe/review.py b/src/bggpipe/review.py
index 8eb70e9..30a7599 100644
--- a/src/bggpipe/review.py
+++ b/src/bggpipe/review.py
@@ -28,6 +28,7 @@ from bggpipe.models import (
UNDECIDED_MATCH_STATUSES,
BGGResponseError,
)
+from bggpipe.normalize import normalize_title
from bggpipe.resolve import (
MatchRow,
TitleEntry,
@@ -158,6 +159,12 @@ class ReviewSession:
"you decided — decision NOT saved"
)
return
+ self._write_rows()
+
+ def _write_rows(self) -> None:
+ """Atomic write of self.rows exactly as they stand — no reload, so
+ a caller that just derived self.rows from a fresh reload can't have
+ its result silently replaced before the write."""
try:
own_mtime = write_matches(self.cfg.matches_path, self.rows)
except OSError:
@@ -258,25 +265,39 @@ class ReviewSession:
self._save(copies[0])
return copies
- def drop_rows(self, title_raw: str, photos: list[str] | None = None) -> int:
+ def drop_rows(
+ self,
+ title_raw: str,
+ photos: list[str] | None = None,
+ new_title: str | None = None,
+ ) -> int:
"""An edit invalidated these rows — the BGG match was made against
the uncorrected read. Remove them so resolve re-queries with the
- fix; `photos` narrows the cull to one copy of a split title."""
+ fix; `photos` narrows the cull to one copy of a split title.
+ Rows carrying a human veto (dedupe_veto) are never dropped — the
+ match itself was human-vetted, and dropping would erase the veto;
+ a rename updates their title in place so they follow the entry."""
self.reload_if_changed()
-
- def stale(row: dict) -> bool:
- if row["title_raw"] != title_raw:
- return False
- if photos is None:
- return True
- row_photos = {p for p in row["source_photos"].split(";") if p}
- return bool(row_photos & set(photos))
-
- keep = [r for r in self.rows if not stale(r)]
- dropped = len(self.rows) - len(keep)
- if dropped:
+ norm = normalize_title(title_raw)
+ keep: list[dict] = []
+ dropped = renamed = 0
+ for row in self.rows:
+ targeted = normalize_title(row["title_raw"]) == norm and (
+ photos is None
+ or bool({p for p in row["source_photos"].split(";") if p} & set(photos))
+ )
+ if not targeted:
+ keep.append(row)
+ elif row.get("dedupe_veto"):
+ if new_title and row["title_raw"] != new_title:
+ row["title_raw"] = new_title
+ renamed += 1
+ keep.append(row)
+ else:
+ dropped += 1
+ if dropped or renamed:
self.rows = keep
- self._save()
+ self._write_rows()
return dropped
def veto_merge(self, row: dict) -> None:
diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css
index b114f5b..5e934b6 100644
--- a/src/bggpipe/static/app.css
+++ b/src/bggpipe/static/app.css
@@ -310,6 +310,13 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
text-underline-offset: 2px;
}
.catalog a:hover { color: var(--accent-ink); }
+.catalog td.actions { text-align: right; white-space: nowrap; }
+.catalog td.actions button {
+ font: inherit; font-size: .78rem; border: var(--line); background: #fff;
+ border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer;
+ box-shadow: none;
+}
+.catalog td.actions button:hover { background: var(--board); }
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
.editform { display: flex; gap: .7rem; align-items: end; flex-wrap: wrap; }
.editform label {
@@ -319,10 +326,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
}
.editform input {
font: inherit; font-size: .85rem; color: var(--ink);
- border: 1px solid var(--board-edge); border-radius: var(--radius);
- padding: .25rem .45rem; background: var(--board);
+ border: 2px solid var(--board-edge); border-radius: var(--radius);
+ padding: .25rem .45rem; background: #fff;
}
-.editform input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.editactions { display: flex; gap: .5rem; }
.edithint { font-size: .78rem; color: var(--ink-soft); align-self: center; }
.chip {
diff --git a/src/bggpipe/templates/pages/catalog.html b/src/bggpipe/templates/pages/catalog.html
index 51a9c0f..67413bf 100644
--- a/src/bggpipe/templates/pages/catalog.html
+++ b/src/bggpipe/templates/pages/catalog.html
@@ -52,7 +52,7 @@ function render() {