Compare commits

79 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 1f1d50e649 Contact grows two social channels: Mastodon and Bluesky
Eric's call — the README's questions-and-bugs section and the package
urls now list @eric@toots.kestrelsnest.social and the
kestrelsnest.social Bluesky handle alongside the email.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 13:29:17 -04:00
Eric WagonerandClaude Fable 5 0b5c1c727f Pre-announcement sweep: a contact channel, a fresh Titles shot
Three of four reviewer nits. The real gap: the Gitea instance takes no
public registrations, so announcement readers had no way to report
anything — the README now offers the author's email and says plainly
why there's no issue tracker yet. The Titles screenshot still showed
the day-three catalog ("123 extracted titles") — replaced with the
clean run's capture (150 reads, shaky-read badges, the add-a-game
panel) and honest alt text. And the Beta classifier gets a comment
recording that it's deliberate alongside the README's "battle-tested":
tested for one person on one platform is exactly what Beta means.

The fourth item (release notes on the v1.0.0 tag) is a Gitea web-UI
task — notes drafted separately for pasting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 13:25:39 -04:00
Eric WagonerandClaude Fable 5 4e09f11052 The gallery learns from the clean run, and the docs link the proof
Eric's clean-room run produced screenshots of states the gallery
never had: Review showing LIVE ballots (Agricola vs Revised Edition,
with per-candidate ranks, owner counts and view-on-BGG links, and the
search BGG/RPGGeek buttons), the editions pass in full flight (five
English Catan printings to choose from — the version-selector shot
Eric asked for days ago), the Queue with real pending adds and a
pending version update, a dry run's would-add output (now
illustrating the guide's Uploading-safely section), and extract
streaming per-photo counts (now illustrating the guide's web-app
section). The old Review and Queue images showed stub-era done/empty
states with captions about waiting on tokens — retired.

Also linked, at Eric's suggestion: the pipeline's actual output is
public at boardgamegeek.com/collection/user/ewagoner — the README
status line and tour intro now point at it, because "here's the
collection it built" beats any amount of claimed battle-testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 12:57:27 -04:00
Eric WagonerandClaude Fable 5 e7f04f9e6d 1.0.0 — the version the pipeline earned
Battle-tested against a live collection, clean-room verified, docs
split for humans, audited seven rounds. Eric's call: "1.0.0 for sure!"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 12:54:40 -04:00
Eric WagonerandClaude Fable 5 c3549ddae2 Package metadata fills in: author, urls, keywords, classifiers
Ahead of sharing: authors (Eric, eric@ericwagoner.com), readme,
Homepage/Repository/Documentation urls, keywords, and trove
classifiers (beta, end-user, board games; macOS/Linux listed —
Windows stays unlisted until someone actually tests it). Version was
already handled: dynamic from __init__.py, currently 0.1.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 12:53:19 -04:00
Eric WagonerandClaude Fable 5 22c663d620 The spec moves to docs/ and says what it is
Floating in the root under its planning-era name, the spec read like
leftover scaffolding. It isn't — it's the maintained design contract
(what must stay true, and why) that CLAUDE.md designates as
read-before-changing-semantics. Now docs/spec.md, with a header
saying exactly that and pointing usage questions at the guide; both
pointers updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 12:50:41 -04:00
Eric WagonerandClaude Fable 5 abb64aa40c Docs answer "what will this cost me" with a real number
Eric's clean run put a price on the pipeline's only paid step:
65 shelf photos, 136 games, under a dollar on the default model. The
README's vision requirement and the guide's config section both now
say so, plus the shape of the cost — one-time, since per-photo reads
are cached and re-runs are free — and that the BGG side costs nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 10:28:27 -04:00
Eric WagonerandClaude Fable 5 251fc8fde8 The review done-card stops assuming why titles are unresolved
Clean-run finding #2 from Eric: with a token loaded and resolve simply
not yet run, the card still said unmatched titles were "waiting on the
API token" — stub-era copy hard-coding the only reason unresolved
titles existed back then. The summary payload now carries
token_present (a boolean, never the value — pinned by test), and the
card gives the advice that fits: token loaded → "run resolve";
no token → the waiting message, now with a link to BGG's application
page since that reader hasn't registered yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 10:21:33 -04:00
Eric WagonerandClaude Fable 5 3566a62f2d "remove — not a game" admits its second job
Eric's observation: the button also removes duplicate reads — the
same physical box read differently from two photos, leaving two lines
for one copy — and its label denied that use existed. Now "remove
this line", with the hover title and the confirm dialog naming both
legitimate reasons and pointing the opposite case (one line, several
real boxes) at split. Help's remove entry documents the duplicate-
read use and which line to remove (the worse read; the survivor
keeps its own photos).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 10:11:23 -04:00
Eric WagonerandClaude Fable 5 1d9baff989 Requirements answer the question every BGG tool gets asked
Eric has seen the criticism land on other BGG apps: why does this
thing want my password? The README now answers it where the
requirement appears: BGG has no write API, so uploading means signing
into the real website in a visible browser on the user's own machine
— that login is the password's entire job. And the reassurance that
matters: no server, no telemetry, no analytics, nothing collected;
credentials go to boardgamegeek.com and nowhere else, the only other
contact is the user's own chosen vision provider (photos only, and a
local Ollama keeps even those home). The guide's credentials section
links back and notes the saved browser session stays local too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 10:06:43 -04:00
Eric WagonerandClaude Fable 5 6041150c8d The README stops being four documents wearing one trench coat
Eric's read on the first-visitor experience: 190 lines of pitch,
manual, gallery, and contributor doc is intimidating when the visitor
only needs the first 40. Split three ways:

README.md is now the front door — what it is, why it exists (told in
first person now, since it IS a personal itch scratched), how the six
stages work, requirements, quick start, one hero screenshot, and the
development/citizenship/license notes. Sixty percent shorter.

docs/tour.md carries the full gallery: all seven pages, the game
detail view, and the phone set, captions intact.

docs/guide.md is the complete user's guide: credentials and config,
the stages and their flags, phone pairing, photo technique, curation
stores, RPG handling, upload safety (including the collection-export
lag), the no-token-yet path, and the keep-data-out-of-git rationale.

Every relative link and README→guide anchor machine-verified to
resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 10:03:48 -04:00
Eric WagonerandClaude Fable 5 1df784e253 The CLI loads .env itself — init's promise finally holds
First finding of Eric's clean-room run, and the exact kind the
rehearsal exists for: init writes credentials to .env, but nothing
ever loaded it — the dev repo's committed .envrc + direnv did it
invisibly, and a fresh directory has neither. The web banner then
advised "run bggpipe init or load .env", circular counsel for someone
who just ran init.

A typer callback now loads ./.env before every command, using the
same parsing rules as the wizard that writes it (export prefixes,
quoted values, quoted-empty = unset). Real environment variables
always outrank the file, so direnv setups and explicit overrides keep
working unchanged. Verified in a scrubbed-environment clean room: the
credentials banner is gone with nothing but .env present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 09:50:49 -04:00
Eric WagonerandClaude Fable 5 2542560d57 Every matched name links out to its BGG (or RPGGeek) page
The one outbound link in the whole app was the detail-page crumb, and
it sent RPG items to a /boardgame/ URL — the wrong home for things
that live on RPGGeek. A shared bggUrl(id, type) helper now routes by
type, and the link grew into the places where opening the real page is
the point: every matched name on the Titles catalog (verifying a match
IS opening its page), each Review ballot candidate (a "view ↗" that
stops propagation so checking a candidate doesn't vote for it), and
the version ballot's title links to BGG's own /versions list.

Verified against live data: RPGs route to rpggeek.com, board games to
boardgamegeek.com, 127 outbound links on the Titles page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 01:15:43 -04:00
Eric WagonerandClaude Fable 5 e9168044f3 Declare the MIT license in package metadata
The LICENSE file has been in the repo since day one; pyproject.toml
just never said so, leaving installs without license metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 01:01:06 -04:00
Eric WagonerandClaude Fable 5 52fb52ea3b Quick start finishes the move off git clone; Windows stops being banned
Eric caught two leftovers. The Quick start still opened with git clone
— the exact path the last commit built two warning layers against —
and now leads with uv tool install into a directory of your own, with
the stage commands dropping their uv run prefix to match (the clone
recipe moves to Development, its one remaining legitimate home, with
a pointer to run real pipelines elsewhere).

And the requirements line claimed macOS or Linux. Nothing in the code
is platform-specific — pure Python, pathlib throughout, and uv,
FastAPI, and Playwright all support Windows — so the honest statement
is: Windows works but is untested, with one real caveat named in
place: the owner-only chmod on .env and browser session state is
POSIX-only, so Windows users keep those files in an account-protected
directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 00:54:10 -04:00
Eric WagonerandClaude Fable 5 53ebb15840 De-slop pass: dead plumbing out, comments stop narrating history
Eric asked whether AI residue had crept in since the last credibility
pass. It had, in two forms — both descendants of the audit's
"row-edit flow is collid-exact now" fix, which corrected upload.py but
not everyone who believed the old constraint:

The deferral scaffolding survived its own removal: build_queue still
returned a permanently-empty deferred list, run_upload still unpacked
it and carried a dead echo branch repeating the DISPROVEN claim ("the
row-edit flow can't target a collid"). And diff.py held a vestigial
guard on the same false premise, refusing to upgrade a versionless
copy whenever a sibling carried a version and telling the human to do
it by hand — the collid-exact editor makes that upgrade safe, so the
guard cost a real capability and its test now pins the opposite.

A comment audit (13 findings, verified line-by-line) cleaned the rest:
the webreview docstring counted six pages of seven and claimed
"localhost only" (--lan exists); the nav comment still said "catalog"
for the page renamed Titles; extract's docstring credited "Claude
vision" though any OpenAI-compatible endpoint dispatches; and five
comments narrated retired bugs ("the original bug", "why the old
guess quit after page one", "made the count go negative") — each
rewritten as the present-tense constraint or trimmed, per the
standing rule: comments state what the code can't show, never how it
got that way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 00:50:38 -04:00
Eric WagonerandClaude Fable 5 dec2bfc7b6 Defuse the clone-and-run trap: tracked pipeline data warns loudly
Eric spotted it: the README told people to clone this repo, rm the
committed data, and run — which writes THEIR pipeline artifacts at
git-TRACKED paths. The next `git pull` (this repo commits data every
session) refuses to merge, and the internet's standard remedies for
that error — reset --hard, checkout ., stash, clean -fdx — destroy
their review decisions, hand-written games, upload log, and photos.

Two layers. The README's "Bring your own shelves" now leads with
`uv tool install git+…` and running in a directory of your own: data
lands untracked by construction and a bug fix is `uv tool upgrade`,
which cannot touch it. And because nobody re-reads a README, Config
gains tracked_data_warning(): if artifacts under data_dir are
git-tracked, `bggpipe init` and the web dashboard both warn in plain
words. The owner's exemption is data/.own_repo — a GITIGNORED marker,
so the author's checkout is silent while a fresh clone of the same
repo still gets the warning (a committed marker or config key would
have shipped the exemption to exactly the people who need warning).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 00:43:35 -04:00
Eric WagonerandClaude Fable 5 77d330748d README tour gains the game detail page
Eric flagged the detail pages as worth showing. Britannia's page joins
BGG's stats with the pipeline's own knowledge — the owned edition and
the shelf photo provenance — which is the pitch in one image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 00:31:18 -04:00
Eric WagonerandClaude Fable 5 e6d45011cc Audit round 7, web + stages cluster: 13 more verified findings fixed
The web layer's serialization story had three gaps: /api/run started a
stage without the lock, so a decision mid-save could pass the rewrite
guard and still be clobbered by the stage's full rewrite (now the start
itself serializes); /api/photos accepted a replacement photo while
extract was running, permanently pairing the new bytes with the old
photo's reads (now refuses like every other mutation); and /api/queue
read session rows lock-free and stale (now freshens under the lock).
The localhost Host allowlist applied only to writes — a DNS-rebound
page could read pipeline state and shelf photos with plain GETs; it
now covers all methods (foreign-Origin reads still pass: without CORS
headers a cross-origin page can't read the response anyway).

Data-loss finds: the off-BGG edit form re-rendered from games.json,
which only sees hand data after enrich — so a second save resubmitted
pre-save blanks and cleared the first (the detail endpoint now overlays
local_games.json live). The local key embeds the photo list, so a new
sighting orphaned hand-written facts silently; enrich now migrates them
when the title still matches exactly one line, and warns instead of
ever dropping. research() left the previous game's version verdicts on
the row, riding a stale version_id onto the next pick; it clears all
four fields as reopen does. find_row now prefers the version-open
sibling on duplicate keys, mirroring _adopt. Re-adding a removed
hand-added title silently no-opped behind a 200 — it now rescinds the
removal (an explicit undo), and a true duplicate add answers 409.

Smaller: parse_search's dedupe collapsed same-id rows under DIFFERENT
names, discarding the alternate-name row whose exact match downstream
scoring needed (now collapses same-name only; research merges its
ballot per game preferring exact evidence); rpgitems rank in their own
family so their rank parsed null; the pipeline badge counted
review-retired queue rows as pending; the catalog pairing cascade ran
per-entry so a tier-3 claim could steal a sibling's exact row (now
tier-by-tier across all entries, as resolve does); library cards
render a lone player bound without "undefined" and the seats filter
tolerates it; added_no_version reads "done · no version" instead of a
bare green done.

Every finding verified against the code before fixing; each fix
carries a regression test. 337 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 00:30:13 -04:00
Eric WagonerandClaude Fable 5 32b6aae841 Audit round 7, upload cluster: evidence over inference at every exit
Five blind reviewers swept the real-data-era surface; this lands the
upload findings, all verified against the code and the documented site
behavior before fixing.

The two HIGHs shared a root: logging outcomes the browser never proved.
add_game waited for an "Add To" button that an owned game's page does
not have — so a second-copy add could never succeed, and worse, an add
that LANDED but missed the log became an unretryable failure loop
(every retry: 30s timeout, logged failed, nothing ever settles).
add_game now polls for either button state: "In Collections" without
second_copy returns the previously-dead already_present status (the
landed-but-unlogged case heals itself on retry); with second_copy it
refuses loudly (that flow is unverified — add by hand). A save whose
dialog is slow to hide reloads the page and asks for ownership evidence
instead of guessing "failed". update_entry no longer trusts the editor
merely closing: the cell must settle on text matching the CHOSEN
version, else the AJAX save failed server-side and "updated" would
mark a job done forever that never touched the site.

Per-copy bookkeeping: stale_jobs endorsed per game, so rejecting one
of two queued editions let the rejected copy upload on the survivor's
endorsement — it now counts endorsements per (bgg_id, version) and
retires the game with "re-run diff" when a copy loses its backing.
annotate_queue stamped every row sharing a job key with the same log
status, so one success marked both vetoed duplicates done; completions
are now claimed one row per done log line.

Smaller findings: the version-drift note queued a doomed re-add after
warning about it (now skips — the entry exists on BGG; re-adding only
duplicates); the one-update-per-game deferral rested on a claim the
collid-exact editor disproves (removed — same-game updates run
together); the 3-identical-failures abort compared exception class
only, so three unrelated problems aborted a healthy run (now compares
whole messages).

Also from the test seat: run_upload's stale filtering finally executes
against a real matches.csv in tests; rejected credentials pin that no
anonymous storage state is saved; update_entry's three guarded exits
each have a test; _scrub's newline flattening is pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 00:21:52 -04:00
Eric WagonerandClaude Fable 5 22fa17b5ee Docs catch up with reality: the stub era is over and upload is proven
The README still warned "not yet battle-tested / browser flows await
their first real run" — they ran, against a live account: 62 adds and
36 version updates landed, every flow verified. Status blurb, stage
table, spec's update-mode paragraph (now describing the verified
collection-cell route instead of asking for manual verification), and
the CLAUDE.md token/fixture notes all now describe the present.

Screenshots 01 and 06 retaken: the Pipeline card shows a completed
run's real numbers instead of stub-lock banners, and the Library shows
136 games as art cards instead of the empty state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 00:10:57 -04:00
Eric WagonerandClaude Fable 5 9663f29702 Review can search a chosen database: BGG or RPGGeek, by hand
The automatic cascade only reaches RPGGeek when BGG's board-game search
comes up empty — so every D&D box, which BGG does list as board games,
can never find its RPGGeek entry no matter how many times it is
reopened. Eric hit exactly that and settled for keeping them local.

Match cards now carry an editable query with two buttons, "search BGG"
and "search RPGGeek", which replace the row's ballot with whatever the
chosen database returns (owned counts and ranks attached when
available; if that stats call fails the results still stand and the
degradation is reported). The TUI's (f) re-search falls back to
RPGGeek automatically when the board-game search is empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:58:58 -04:00
Eric WagonerandClaude Fable 5 50a710ce99 "Local" stops being a one-way door, and the head search stops skipping
Answering Eric's question — no, a local game never looks itself up again
— by making it possible. A local row's Titles line gains "look it up",
reopening it through the SAME cascade resolve uses (board games,
truncation heads, then RPGGeek) rather than the partial re-implementation
reopen_match had; that cascade is now one shared find_candidates()
instead of two drifting copies. Review's manual (f) re-search falls back
to RPGGeek too.

That exposed a real matcher gap: truncation heads jumped from
"drop the last word" straight to "first two words", so a printed title
that buries the real name in the middle was unreachable — "ALICE IS
MISSING A SILENT ROLE PLAYING GAME" never tried "ALICE IS MISSING".
Heads now shrink from the right, longest first (bounded at six, since
each is a rate-limited request); only exact normalized matches count for
heads, so shorter heads cannot match loosely.

Both of Eric's Alice Is Missing rows now find their RPGGeek entries
(311654, and 380459 for Silent Falls) and await his picks in review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:51:54 -04:00
Eric WagonerandClaude Fable 5 7e95ed607d RPGs pull real RPGGeek data; off-BGG games get facts and a cover photo
Two gaps at the edges of the library, both closed.

RPGGeek items live in the same database but use their own link types —
rpgdesigner, rpgpublisher, rpggenre, rpgcategory, rpgmechanic — so a
board-game-only parser found none of them and both RPG entries showed
just a year and a description. parse_things_full now reads both
vocabularies (plus rpgproducer/rpgseries): .dungeon gains John Battle
and Project Nerves, Parsely gains Jared A. Sorensen and its genres.

An off-BGG game has no API to enrich it and no publisher art to fetch,
so its detail page now hosts the only source it will ever have: a form
for title, year, players, playing time, publishers, designers and
notes, plus a cover photo upload. Both persist in data/local_games.json
and data/local_art/ (committed, like every other curation store) and
enrich merges them over the photo reads, so a rebuild can't erase them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:45:09 -04:00
Eric WagonerandClaude Fable 5 f5e949bd62 The library becomes browsable: detail pages, sorting, real filters
136 games with nothing to do but look at them. Now:

Every card links to /library/game/<key> — a detail page with the box
art, players (with best-at counts), playing time, weight, rank, rating,
ages, owner count, designers/artists/publishers, categories and
mechanics as chips, the description, YOUR edition (name, year,
publishers, languages), and the shelf photos the game was read from,
linking back to those photo pages. That provenance is the join only
this pipeline can make: games.json knows the game, matches.csv knows
which of your photos it came from.

The list gains sorting (name, year, BGG rank, weight, playing time —
with nulls always last, since an unranked game is not rank zero), a
"plays with N" filter that keeps games whose player range covers the
table, an Off-BGG kind filter, and a search that now covers designers,
mechanics, categories and edition names rather than titles alone.

/api/library drops the description field (a megabyte of dead weight
across 136 games); the detail endpoint serves the whole entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:38:35 -04:00
Eric WagonerandClaude Fable 5 2e1693c0be Enrich's summary stops reporting a negative count
"136 entries (136 added/refreshed this run; -11 already present or
waiting)" — local library entries were counted in the same tally as API
fetches, but they have no API target, so the remainder went negative
once eleven off-BGG games existed. The two populations are now counted
and named separately, and zero-valued clauses are omitted:
"136 entries (0 fetched from BGG; 11 local-only; 125 already present or
waiting)."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:32:55 -04:00
Eric WagonerandClaude Fable 5 b5e13be335 Count pending work: BGG's export lags, so raw queue rows lie
The card read "1 version updates" while upload said "skipping 1 already
done". Both were right. Recon on the live site shows the update DID
apply — the version cell reads "English first edition Year: 2012" and
its radio is checked — but BGG's XML collection export still reports
that collid with no version, even on a forced refresh. diff reads the
API, so it re-queued finished work; the log correctly refused it.

Nothing to fix in the flow: the pipeline card now counts PENDING jobs
(queue rows minus what the log completed) for both to_add and
to_update, reports outstanding failures rather than every failure ever
logged, and when everything queued is already applied it says so and
names the cause. Documented under "BGG's collection export lags the
site" so the next person doesn't chase it as a bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:29:25 -04:00
Eric WagonerandClaude Fable 5 e44c7e1b92 The Queue page reports what upload already did
All 36 version updates landed and all 36 still read as outstanding:
to_add.csv and to_update.csv are diff-time snapshots that never shrink,
and the page showed them without consulting the upload log. Rows now
carry their last attempt's outcome — pending / done / failed, plus
"retired" for jobs a later review decision withdrew — and each section
heads with a tally instead of a raw row count. A note explains that
finished rows persist until the next diff rebuilds the queue, and that
the log is the permanent record.

On Eric's data: to_update now reads 36 done, 0 pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:23:50 -04:00
Eric WagonerandClaude Fable 5 0af9ae86c5 Updates go through the collection cell: collid- and version_id-exact
The last unverified flow failed on its guess — the collection row has no
link named "own"; the edit affordances are icon anchors with no text. The
site offers something far better, now verified: the row's VERSION CELL
carries its own collid in an onclick, and the inline editor it opens is a
radio list whose values ARE version ids.

So an update addresses the copy by collid and the edition by version id —
no name matching, no pagination, no dialog, and structurally incapable of
creating a duplicate entry (it sets one field on one collid). Clicking
the radio fires CE_SaveData itself; there is no Save button, and the save
has landed when the cell stops reading "Editing". A version id the editor
doesn't offer aborts with the entry untouched.

The class docstring's UNVERIFIED list is now empty but for the
second-copy add, which --verify already reports as a copy-count
shortfall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:15:39 -04:00
Eric WagonerandClaude Fable 5 74ecd5059b The retry badge stops offering jobs the human retired
Marking the D&D blue box "local" removed its job from the queue — but
the badge still counted its old failure and the checkbox still offered
to retry it, because the count read only upload_log.csv. A failure is
retryable only if the job is still queued AND still endorsed by
matches.csv; on Eric's data that's the difference between 2 and 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:06:40 -04:00
Eric WagonerandClaude Fable 5 99c1fbf02d Review decisions outrank the queue; stop waiting on OUR name for a heading
Two bugs behind one failure. Dungeons & Dragons timed out waiting for a
dialog heading matching our stored name — but BGG 140509's primary name
is "Dragones Y Mazmorras"; we matched it through an ALTERNATE name, so
that heading never appears. The add flow now waits for the Own checkbox
(the form itself) instead: /boardgame/<id>/ already establishes which
game the page is.

And the job should not have run at all. to_add.csv is a snapshot from
the last diff, so any review decision taken afterwards — local,
rejected, wrong-match — was invisible to upload. run_upload now
cross-checks every queued job against the CURRENT matches.csv and skips
those it no longer endorses, naming each and pointing at diff. When
matches.csv is absent or empty it condemns nothing: absence is not a
verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:02:59 -04:00
Eric WagonerandClaude Fable 5 6e72ef22d1 The picker's paging state outlives a reopen — navigate, don't assume
Sleeping Gods and Gloomhaven "vanished on the second pass" because the
second pass began wherever the first ended: closing and reopening the
version sub-view does NOT reset it to page 1 (Angular keeps the scope),
so the rescan started mid-list and never revisited the earlier pages
holding the row. Verified against the live picker.

The second pass now clicks the visible numbered "1" anchor first — and
so does the initial scan, since paging state can outlive anything. The
reopen is gone entirely. Docs record both this and the has_text
whitespace trap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 22:53:05 -04:00
Eric WagonerandClaude Fable 5 6f2f2dac53 Row clicks survive whitespace; the failure badge stops inflating
Sleeping Gods failed where three siblings passed: the second-pass row
click matched captured text with Playwright's has_text regex, which
tests raw textContent — tabs and newlines included — against a capture
that was whitespace-normalized. Rows whose markup happened to be tidy
matched; that one didn't. The picker now re-finds the row by NORMALIZED
text and clicks it by index, which also survives the list re-rendering
in a different order between openings.

And the UI's failure count only ever grew: upload_log.csv is an
append-only audit trail, so a retry that succeeds leaves its old
'failed' line in place. outstanding_failures() counts the LAST status
per job key — the same rule _plan_jobs already uses to decide what to
skip — so a landed retry clears the badge. On Eric's log: 6 'failed'
rows, 1 job actually outstanding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 22:49:02 -04:00
Eric WagonerandClaude Fable 5 e49d1234d6 --retry-failed reaches the UI
The CLI could retry failed upload jobs; the web app couldn't, so a run
that hit a bug (twice today) left work only a terminal could reclaim.
/api/pipeline now reports the failed count, and the upload card grows a
"retry N failed" checkbox — shown only when there are failures — that
rides along with both Dry run and the real Upload. Help explains why
failures are skipped by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 22:44:23 -04:00
Eric WagonerandClaude Fable 5 9283a3e980 Paging clicks only visible controls: First/Prev are mobile-only
Munchkin Big Box and Tang Garden hung 30s each on a "First Page" anchor
that exists but is invisible: BGG renders every paging control twice,
and the First/Prev pair lives only in the mobile set
(<li class="visible-xs-inline">). A desktop viewport can never click it.

Paging now selects the first VISIBLE match, and returning to page 1
closes and reopens the sub-view (which always opens on page 1) instead
of reaching for a control that isn't there. A test proves no hidden
control is ever clicked — the fake picker raises if one is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 22:41:42 -04:00
Eric WagonerandClaude Fable 5 ba55863cef Version picker: real pagination, and matching that declines to guess
Five games uploaded; two landed version-less. Neither was the picker's
fault: paging is an AngularJS <ul class="pagination"> of anchors, not
buttons named "next", so the old guess found no control and quit after
page one — and BGG's API version names carry printing qualifiers the
picker omits ("English edition 2018-2" vs "(English edition) (2018)").

_select_version now scans the WHOLE list (verified selectors: rows are
<li>s with a thumbnail; a[title="Next Page"] advances; the parent <li>
disables at the end), collects every candidate, then decides: one exact
match wins; failing that, one match after stripping a trailing year
qualifier wins and says so; several matches are refused outright rather
than guessed, and the reason reaches upload_log.csv. Both call sites
carry the reason through.

docs/bgg-upload-flow.md records what the live site actually does —
including that every login-gate selector the doc called "verified" was
wrong, while the "unverified" dialog structure was right.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 22:36:48 -04:00
Eric WagonerandClaude Fable 5 6ae2667c58 First real upload lands: three browser-flow bugs, found by doing it
A Gentle Rain is on BGG (collid 148198034, version 701315 — the
English Bloom edition matched from a shelf photo), verified by
re-fetching the collection. The failures on the way, all in the
login gate the docs called "verified" and none in the version-picker
code they called "unverified":

1. BGG's Sign In is an <a class="btn"> with NO href, so it has no
   implicit link role: get_by_role("link", name="Sign In") matched
   zero elements in EVERY state, and "no Sign In link" was read as
   "already signed in". Every run browsed anonymously.
2. The header hydrates after domcontentloaded, so for a moment
   neither control exists — a check resting on one absence guesses.
   _signed_out() now polls until the page proves one state or the
   other (Sign In vs Sign Out) and raises after 30s rather than
   assume; login is verified by the transition, and the session file
   saves only after that proof.
3. get_by_label("Own") also matched "Prev. Owned" — strict-mode
   violation; the Own checkbox is now matched exactly.

Also: Playwright's multi-line call logs no longer break upload_log.csv
into ragged rows (errors flatten to one line). Three unit tests cover
the hydration window, positive detection, and the undeterminable case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 22:22:11 -04:00
Eric WagonerandClaude Fable 5 481fa63488 "Wrong game" re-searches on the spot; resolve explains what it won't do
Eric pressed wrong-game on both rows, ran resolve, and resolve
truthfully found "nothing to match" — unmatched is a human-owned state
it refuses to touch, but the button's name promised a re-match and the
pipeline was the natural place to seek one. reopen_match now clears
the match AND re-searches immediately (merged depunct search, sibling
editions included, stats attached), returning the row as an ambiguous
ballot on the very card the button lives on; if BGG is unreachable it
reopens bare with a visible warning, and re-search/manual-id remain.
run_resolve's summary now says out loud that unmatched rows wait for
the human — it never overrides a decision, including "this is wrong."

Data: the two stranded WIZ-WAR rows re-searched into ballots — both
cards now offer all three lineages, awaiting Eric's picks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 21:53:22 -04:00
Eric WagonerandClaude Fable 5 1dd72d2688 The matcher stops trusting what the user can't see
Eric's question cut to the bone: "How would a user know? It matched
wiz-war and that IS the game." The auto looked unanimous because the
matcher discarded the evidence of doubt before anyone saw it — and
worse, BGG's search hides evidence of its own: results truncate
unordered in the several-hundreds (the game named "Dungeon!" appears
in NEITHER the "Dungeon!" nor the "Dungeon" search), and punctuation
can bury matches.

Three matcher changes: every title is searched raw AND depuncted,
merged by id; a name that becomes exact once its trailing
parenthetical is stripped ("Wiz-War (Eighth Edition)") is a sibling
edition — BGG files new editions as separate games — and enters the
candidate set at exact grade, so same-named lineages land in review as
a visible choice; and a LONE candidate must now earn trust (stats
fetched, sibling-grade never autos alone, true exacts must clear the
dominance ownership floor) — closing the fast path both impostors
(.dungeon at 31 owners, then Dungeon (ICP)) walked through.

Recorded outcomes: WIZ-WAR → ambiguous with all three lineages on the
ballot; Dungeon! → ambiguous (its true match is beyond BGG's search
horizon — that's what manual id is for); every legitimate auto in the
fixture set held. And the answer to Eric's second question is now
structural: re-match never re-decides — it demotes to unmatched and
the HUMAN picks from re-search or manual id; the machine only chooses
on first resolve, and it now chooses more humbly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 21:47:49 -04:00
Eric WagonerandClaude Fable 5 8db69685c4 Version cards get the wrong-game escape hatch; scroll stops yanking
Eric stared at game 589's complete printing list hunting for boxes
BGG files as SEPARATE games — the escape (wrong match, on the Titles
edit panel) was two pages from where the dead end happens. Version
cards now carry "wrong game — re-match", posting the existing
reopen-match endpoint: the card moves to the Matches section where
re-search and manual id live.

And highlight() scrolled the active card into view on EVERY render —
for a mouse user the cursor idles on card one, so every button click
yanked the page to the top. Scrolling now happens only on keyboard
moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 21:37:35 -04:00
Eric WagonerandClaude Fable 5 3e61a03686 Catalog pairs same-title rows by photos, not csv position
The Wiz-War lines were displaying each other's rows: an edit re-queue
recreates its row at the END of matches.csv, and the catalog's
positional per-title pairing then crossed the wires — the 4504 line
wore 4528's open ballot while 4528's line offered 4504's pick-edition
button (whose click re-targeted by photos and safely hit the other
row, deepening the confusion). Pairing now matches run_resolve's rule:
exact photo set, then overlap, then positional fallback, with
unclaimed rows appended as their own lines. Plus a regression test
with deliberately reversed csv order, and the open-ballot marker
restyled as a quiet dotted link instead of a mis-wrapped button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 21:32:46 -04:00
Eric WagonerandClaude Fable 5 c94e8bc695 Confirmations pin to the viewport; open ballots mark their rows
Twice the feedback was the bug: the success banner lives at the top of
the page and the user acts at the bottom of a 136-row list — so a
working "pick edition" read as broken (it worked both times; the data
proves it). showToast() pins transient confirmations to the viewport
bottom (role=status, carries buttons, auto-dismisses), and pick-
edition/edit-saved use it. Better: state stops being transient at all —
any row whose edition ballot is open shows a persistent "ballot in
Review" link where its button was, so the answer to "where did it go?"
lives on the row forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 21:25:49 -04:00
Eric WagonerandClaude Fable 5 edb72514f4 The quiet successes learn to speak
Eric pressed "pick edition", the button vanished, and nothing said
where the ballot went; he edited a matched title and its row silently
entered re-resolve limbo until a resolve run nobody knew was owed. Now:
pick edition banners a link to the Review ballot it created; saving an
edit banners the re-queue contract with an inline "run resolve now"
button; and the waiting chip says "awaiting resolve" (its old label,
"awaiting BGG", described the token era). Data-wise this commit also
carries the re-resolved IMG_4504 Wiz-War copy — whose 2012 year cue
correctly matched NO version of game 589, the breadcrumb that it
belongs to the FFG entry (104710).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqdWSz9g
2026-08-05 21:16:16 -04:00
Eric WagonerandClaude Fable 5 ab28476741 Version ballots drop the silent eight-candidate cap
resolve_version truncated the plausible list to eight — a version
ranked ninth was silently unreachable, distinguishable from absent
only by API spelunking. Every plausible version now ships (the ballot
sorts by score, so the tail costs nothing), and version cards gain
"list every printing", wired to the existing open-versions endpoint,
for when even the plausible set doesn't hold the right box — cues can
mislead as well as shortlist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 20:40:05 -04:00
Eric WagonerandClaude Fable 5 d4c2bdf92d Titles actions uncramp: wrong-match joins the corrections panel
Four buttons in a nowrap cell clipped past the card edge. "wrong
match" is a correction like "remove", so it moves into the edit panel
beside it (the panel already carries the row's identity); the actions
cell drops nowrap so its remaining buttons wrap right-aligned instead
of clipping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 19:56:42 -04:00
Eric WagonerandClaude Fable 5 e3516ea043 Fix the reopen test's key collision with the fixture's Dungeon! row
find_row prefers the fixture's version_ambiguous slot on a duplicate
title, so the test reopened the other copy and asserted against the
untouched one. A unique title removes the ambiguity the test never
meant to have.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 19:50:09 -04:00
Eric WagonerandClaude Fable 5 c5daad023d Same-title copies become tellable apart; wrong autos become fixable
Three split Wiz-Wars meant three identical "Wiz-War" edition cards —
version cards now carry their copy's photo thumbnails and name the
source photo in the prompt, so each ballot says which box it's about.

And the .dungeon-class failure gets its escape hatch: "wrong match" on
any matched Titles row clears the match and returns the title to
Review's unmatched queue, where re-search and manual-id already live.
Help notes the BGG wrinkle that makes this matter for Wiz-War
specifically: early editions and the FFG remake are SEPARATE games, so
a copy whose edition isn't on the ballot belongs to the sibling entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 19:49:19 -04:00
Eric WagonerandClaude Fable 5 42ef34c3a7 Review cards sort alphabetically within each section
Same reasoning as the Titles page: csv order is extraction order,
which reads as random. Matches, Editions, and Merges each sort by
title (case-insensitive); the sort is stable within a payload so the
keyboard cursor doesn't jump between polls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 19:20:15 -04:00
Eric WagonerandClaude Fable 5 90e75daf98 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
2026-08-05 19:13:49 -04:00
Eric WagonerandClaude Fable 5 7b6ea90934 Job status classes get an s- prefix: .done was the celebration card
"resolve: done" rendered <span class="done"> — which is the review
page's all-done celebration card class, so the status line became a
floating bordered card squatting on the Activity heading. Status
spans are now s-idle/s-running/s-done/s-failed, scoped under
#jobstate, and done earns its green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 19:02:39 -04:00
Eric WagonerandClaude Fable 5 e187ed4f3f Off-BGG games become local library citizens
An unmatched title that's a REAL game BGG doesn't have dead-ended:
manual id or reject. The RPG local-citizen pattern generalizes to a
human decision — review (web + TUI, key l) gains "not on BGG — keep
locally": match_status "local" clears any BGG identity, diff routes it
to local_only (never queued), and enrich synthesizes a library entry
from the game's own photo reads (name, year, publisher cue — no API
call, so even a blocked run lands them; pruning keeps local keys).
Library and Titles show a "local — not on BGG" chip; Help's legend,
review description, and shortcuts cover the new verb, distinguishing
it from reject (bad read / not a game).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 18:59:56 -04:00
Eric WagonerandClaude Fable 5 59f4b8c43c The real-data era: token live, stubs retired, editions on demand
BGG application approved. The migration the stub markers guarded for
weeks: both synthetic caches deleted; tests/fixtures/bgg_cache
re-recorded from the live API (recording list extended to every
scenario the suite exercises — Civilization truncation, the Sorcerer
SPI tiebreak, StarForce, Flat Top's thematic year, Alice Is Missing's
rpgitem fallback); resolve --force re-matched all 133 titles for real
(109 auto, 6 ambiguous, 18 unmatched, 30 edition ballots);
data/STUB_DATA.marker deleted with its exit condition met — the guard
mechanism stays armed should stubs ever regenerate.

Reality fixed one bug and taught one lesson. The bug: a multi-type
search lists an expansion twice (once per matched type) and the parser
kept the generic boardgame entry — parse_search now dedupes by id
preferring the specific type, which is what keeps expansion tagging
(the base-vs-expansion review guard) alive on real data. The lesson:
hand-built ambiguity is tidier than the real thing — Wingspan has 46
versions with three plausible English Stonemaier printings, so the
suite's synthetic version ids and version_auto expectations became
real ballots (assertions updated to recorded reality; the cue-plumbing
test keeps its crafted two-version scenario via an injected
transport).

New: pick edition. A cue-less matched row is version_unknown by design
(never guess) — but the owner knows which printing the box is.
open_version_ballot() fetches the game's complete version list,
cue-scores it when cues exist, and marks the row version_ambiguous so
the normal Review edition pass presents it; the Titles page grows the
button (Eric's three Wiz-Wars: two cue-less copies can now each claim
their edition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 18:50:17 -04:00
Eric WagonerandClaude Fable 5 15d3120029 Onboarding follows the vision provider
The wizard's fresh config.toml now carries both [vision.*] blocks, and
its credential pass reads the ACTIVE provider before prompting: an
anthropic setup asks for ANTHROPIC_API_KEY as before, a keyed
openai-compatible endpoint asks for its configured key_env instead,
and a keyless local runtime says so and asks for nothing. Doc sweep
for the same: README's stage list and knobs line, CLAUDE.md's config
summary (which still claimed username lived there), the Help flow's
"Claude vision" wording, and .env.example's ANTHROPIC_API_KEY note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 18:48:22 -04:00
Eric WagonerandClaude Fable 5 118503e4e0 Per-provider vision blocks; parser repairs local models' almost-JSON
config.toml now carries a [vision.<provider>] block per backend —
model/base_url/key_env — with vision_provider picking the active one,
so the committed file documents every recipe and switching is a
one-line flip. Only the active block applies; typo'd block names and
keys warn like every other config mistake.

First real Ollama run (qwen2.5vl:7b) surfaced what local models emit:
almost-JSON with trailing commas. parse_vision_response now makes one
cheap repair pass before declaring a response unusable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 18:39:27 -04:00
Eric WagonerandClaude Fable 5 0358079da4 Vision backends: any OpenAI-compatible endpoint, including local models
The Anthropic key was the last hard gate for other users. extract's
VisionFn seam gains a second factory speaking the chat-completions
format — OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio,
llama.cpp, vLLM) via config.toml: vision_provider, vision_base_url,
and vision_key_env ("" = keyless local endpoint, no Authorization
header sent). Anthropic stays the default. load_config rejects unknown
providers loudly, the pipeline page's credentials warning follows the
configured provider (a keyless local endpoint warns about nothing),
and config.toml + README document the local-model trade honestly:
weaker spine reading means a longer proofread pass, which the shaky-
read workflow absorbs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 18:26:30 -04:00
Eric WagonerandClaude Fable 5 0cdbf74a02 Credibility pass 2: the copy-then-drift tells, excised
Two blind reviewers swept the 33 commits since 10f65d8 for signs of
machine generation. Verdict: production code and copy largely clean;
the tells clustered in duplication and tests.

JS: the six-times-pasted change-detection loop (three pages honoring a
LAST-after-render invariant, three violating it) becomes one
changeGate() factory in app.js; the reshoot ticket renderer and
dismiss wiring, duplicated across photos/photo pages, become
ticketCard()/wireDismiss(); review.html's hand-rolled fetch/post
collapse onto fetchJSON/apiPost keeping only its unique
saved-but-render-failed path; dead lastGood deleted; page-state naming
unified to CAPS (ACTIVE, RUNNING); a dead defensive rowix branch gone.

CSS: header no longer claims "two pages"; --focus derives from
--accent; five state tints become tokens (the header's tokens-for-roles
promise, kept); component button rules drop declarations the global
rule supplies; duplicate color declarations trimmed.

Python: dead seen_per_title vestige removed from resolve; redundant
ternary arm in the catalog builder collapsed; csv import hoisted; twin
VetoBody/SplitBody merged into RowRef; warn-once idiom deduplicated
into a closure; a stray "a bare arrays" typo.

Tests: the one assertion that could never fail (aria-current check
with an always-true fallback) replaced by a strict per-page check
across all seven pages; the traversal test asserts escape
unconditionally; stale "both pages" names updated; nine redundant
function-local imports hoisted to their module tops.

Docs: aria role="status" set once in the shell instead of per call;
joblog gets role="log"; README's --lan paragraph becomes a proper
"From your phone" quickstart subsection with the command visible, and
the seven-page list stops restating the screenshot captions; Help's
re-extract claim matches actual behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 17:41:35 -04:00
Eric WagonerandClaude Fable 5 7cfc3c5b7d README: four phone screenshots join the tour
Menu, camera-to-pipeline sheet, Titles proofreading cards, and the
piper credits card — side by side under the desktop tour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 17:25:34 -04:00
Eric WagonerandClaude Fable 5 be8e19b418 Docs: the phone flow, in all three places it lives
README gains the home-screen tip and the camera-to-pipeline flow with
its shelf-<timestamp> naming; Help's Photos description now speaks
phone (and clarifies that only same-FILE-NAME re-uploads trigger the
reshoot replacement); CLAUDE.md documents the --lan architecture (key
file, QR pairing, guard exemptions, camera-name minting) and adds
data/.lan_key to the never-commit list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 17:18:04 -04:00
Eric WagonerandClaude Fable 5 9610f3b774 Camera uploads stop overwriting each other
iOS names every camera capture "image.jpg"; the replace-to-reshoot
semantics (same name = re-extract this photo) then silently destroyed
the previous shot — which is how a shelf photo vanished today. Generic
capture names (image/photo/img/capture stems) now get minted unique
names (shelf-<timestamp>[-n]) server-side, colliding names within one
batch uniquify too, and an identical re-send of the same shot dedupes
to a no-op. Named photos (IMG_1234.jpeg) keep the deliberate reshoot
replacement flow. The upload feedback shows the minted names, so the
phone sees exactly what landed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 17:11:24 -04:00
Eric WagonerandClaude Fable 5 cf6dd5e134 Photo upload gets a visible lifecycle
A phone photo is several MB — seconds of dead air in which the user
reasonably assumes it worked and moves on, losing the upload if they
navigate or lock the phone (exactly how a photo went missing today).
The dropzone now narrates: uploading with count and size ("keep this
page open"), a green ✓ naming each saved file, reverting after a few
seconds; failures keep their alert but also reset the zone. A
beforeunload guard makes the browser ask before abandoning an
in-flight upload, re-picking the same file re-fires, and the gallery
refresh is forced so the new photo appears immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 17:10:13 -04:00
Eric WagonerandClaude Fable 5 0401c3351b Page titles lead with the page: "Titles · bggpipe"
Cramped contexts (home-screen labels, narrow tab strips) truncate the
tail of a title; "bggpipe — pipeline" lost the only distinctive word.
The separator becomes a middle dot, which wraps cleanly where the
em dash read as one glued token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 17:00:28 -04:00
Eric WagonerandClaude Fable 5 bd1bc5e020 Home-screen icon: the piper replaces iOS's letter tile
iOS asks for apple-touch-icon when saving to the home screen and we
served nothing — hence the gray "B". A 180x180 PNG cropped from the
mascot logo is linked from the shell, served from /static/, and also
answers the cookie-less root probes (/apple-touch-icon*.png) the guard
already treats as public. apple-mobile-web-app-title trims the clip
name to "bggpipe".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:59:27 -04:00
Eric WagonerandClaude Fable 5 44dd03d06a Pairing: scan a QR off the terminal once; the cookie lasts a year
The key already persisted across restarts, but the cookie was a
session cookie — Safari eventually drops those and the paste ritual
returned. The pairing cookie now lasts a year, and startup prints a QR
code of the pairing URL (qrcode dep, ASCII render) so a phone pairs by
pointing its camera at the terminal. Revoke every paired device by
deleting data/.lan_key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:50:36 -04:00
Eric WagonerandClaude Fable 5 78debdd62f Mobile nav becomes a hamburger menu; Juniper joins the Help page
The horizontally scrolling nav strip lost its scroll position on every
page load and hid the far entries. The top bar is now brand +
hamburger (aria-expanded/aria-controls, 44px target); the nav drops
down as the same stacked list the desktop rail shows — badges, gold
current-page marker — and navigation naturally closes it.

Help gains "The piper": Juniper's full artwork with credit and the
trademark attribution, which mobile previously never showed anywhere
(the sidebar portrait is hidden there). Also fixes the Help cards
flex-rowing their paragraphs into accidental columns on desktop —
visible in the README's own screenshot — via a card.prose block
variant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:47:40 -04:00
Eric WagonerandClaude Fable 5 f82489716a --lan key persists across restarts; stale tabs get a readable 403
The key rotated on every server restart, stranding every phone that
held the previous cookie — during active development that guaranteed a
wall of refusals from stale polling tabs after each restart. The key
now lives in data/.lan_key (gitignored, 0600 — the Playwright-state
treatment for credential-adjacent files) and is reused across
restarts; delete the file to rotate. A keyless browser navigation now
gets a one-line HTML page saying what to do instead of raw JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:39:24 -04:00
Eric WagonerandClaude Fable 5 344a930a8a --lan first load: redirect commits the key cookie; static goes public
The phone's first visit 403'd its own subresources: Safari's preload
scanner fetches /static/* before the document response's Set-Cookie is
committed, and favicon/apple-touch-icon probes are cookie-less system
fetches. A ?k= visit now answers 303-with-cookie to the same path —
the cookie is committed before any document loads, and the key is
scrubbed from the phone's address bar and history. /static/* and the
icon probe paths are exempt from the key: they're the app's own
css/js/artwork, no user data (shelf photos stay gated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:31:57 -04:00
Eric WagonerandClaude Fable 5 95fd18ca5c --lan startup: lead with the default-route address, demote the rest
A multi-homed machine (VM bridges, Ethernet + Wi-Fi) has several
addresses and the server cannot know which network the phone is on —
but the OS's default route is the right answer nearly always. The
banner now prints one "on your phone" URL from the route probe, with
the other interfaces on an if-that-doesn't-answer line; when the probe
fails, the settings hint plus candidates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:21:26 -04:00
Eric WagonerandClaude Fable 5 eb2e841d27 --lan: the desktop stays keyless; phone URLs drop loopback
Dogfooding caught both: the auto-opened desktop tab (and every old
polling tab) 403'd for lack of the key, and the "from your phone" list
offered 127.0.0.1. Loopback CLIENT connections now skip the key — a
network peer cannot arrive with a loopback client address — and fall
through to the same Host/Origin guard as the localhost default, so
rebinding pages (foreign Host) and cross-origin POSTs (foreign Origin)
from a local browser stay blocked. Loopback addresses are filtered out
of the printed phone URLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:16:55 -04:00
Eric WagonerandClaude Fable 5 429ceb1a21 LAN access key: 128-bit token (was 48-bit for typability)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:13:47 -04:00
Eric WagonerandClaude Fable 5 d4e619611a Audit round 6 (mobile + --lan): access key replaces trust-by-allowlist
Five blind reviewers over 2936d21; 13 confirmed fixes. The design
change: the Host allowlist was CSRF armor being asked to do access
control. --lan now mints a per-run access key — the printed URLs carry
?k=..., the first visit sets a cookie — required on EVERY request,
reads included (shelf photos and pipeline state are private). That
closes DNS-rebinding read exfiltration (GETs were exempt from the old
guard), closes any-LAN-device mutations via a forged localhost Host,
and frees phones from allowlist accuracy — multi-interface machines,
DHCP renewals, and failed IP discovery no longer strand writes. A
foreign Origin is still refused even with the key.

Guard hardening: Host parsed via url.hostname (ports, IPv6 brackets,
case) instead of a manual split; refusals now echo one stderr line
(they were invisible at log_level=warning) and the LAN 403 names the
remedy; startup warns when no LAN IP could be determined instead of
printing hostname-only URLs as if verified.

Silent failures: the queue page no longer freezes blank forever when a
render throws (LAST was recorded before render; one malformed CSV cell
would blank all three ledgers and blame the network) — all three
change-detection pages record LAST only after a successful render, and
the queue null-guards source_photos.

Mobile: touch-size the review/ticket/merge buttons the finger-sized
rule lost to on specificity. Style: the meta-cell builder is one shared
metaLine() helper; the Help page no longer claims localhost-only;
dead -webkit prefix dropped; --lan help text in house style.

Tests: token gating (reads and writes, cookie handoff, foreign-Origin
refusal), the Origin-present + Host-with-port path every real browser
mutation takes (was fully uncovered), run_web_review's lan branch via
monkeypatched uvicorn, and a lan_hosts test that actually pins the
lowercase/non-empty/v4-only invariants the guard depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:12:01 -04:00
Eric WagonerandClaude Fable 5 2936d21f0a Mobile: every page earns its phone layout, and --lan serves the network
Verified page by page in a 390px viewport against live data. The rail
collapses to a brand line plus one horizontally scrollable nav row
(was: two wrapped rows). The Titles table becomes stacked line-cards —
title, chips, match, photos, finger-sized actions — with empty cells
dropped (the templates now emit them tight so :empty applies); the
editor stacks full-width inputs. Queue/library ledgers scroll inside
their card and photo lists wrap at commas instead of clipping. The
review done-card stats wrap, keyboard hints hide on touch widths,
reshoot stencils go horizontal, buttons get touch-sized padding.
Also: post-rename copy on the remove button.

bggpipe web --lan binds 0.0.0.0 and extends the mutation guard's host
allowlist with this machine's names and addresses (never a wildcard —
DNS-rebinding arrives under the attacker's hostname, which an allowlist
rejects), prints every reachable URL, and warns loudly that the app has
no login. Default stays localhost-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 16:02:08 -04:00
Eric WagonerandClaude Fable 5 429c519237 Proofread pass: 11 edits, 11 removals from the Titles page
Eric's curation session through the new UI — corrected reads and cues
(Dungeons & Dragons, Huggermugger, Patch Work, Verdant, Wiz-War, the
Ain't It Cool Trivia Game, and others) and removed non-game or
duplicate-read lines (publisher spines read as titles like Slugfest
Games and Cheapass Games, a misread "Bird on Your Bread?!", stray
partial reads). titles.json reflects the replayed decisions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 15:20:32 -04:00
Eric WagonerandClaude Fable 5 c00b3a7ad8 README: a screenshot tour of all seven pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 15:20:32 -04:00
Eric WagonerandClaude Fable 5 1d700db012 Shaky reads become visible and clearable on the Titles page
The badge counted lines nothing on the page identified. Each shaky line
(vision confidence below high, nothing verified yet — the badge's exact
predicate, served per-line so page and badge can't disagree) now shows
a dashed gold "shaky read" chip, a filterbar toggle shows just them,
and a "✓ looks right" button confirms a correct read in one click —
recorded as a confidence-high edit record, so verification is as
durable as any other curation and, unlike a real edit, re-queues
nothing. Saving an unchanged edit form on a shaky line counts as
confirming it. Help's chip legend explains the chip and its three ways
to clear (confirm, edit, or a BGG match).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 00:25:48 -04:00
Eric WagonerandClaude Fable 5 f19b861251 The proofread checkpoint gets its place: nav order, badge, and a rename
Eric's observation: cleaning up raw reads happens BEFORE resolve, but
the app ordered Review ahead of Catalog and never said proofreading was
a step at all. The pipeline has two human checkpoints — proofread the
reads (after extract), decide the matches (after resolve) — and now the
app says so: the sidebar runs Pipeline, Photos, Titles, Review, Queue,
Library, Help in true workflow order; the Titles page (né Catalog — the
old name suggested a finished collection, which is the Library's job;
/catalog redirects) gets a badge counting unresolved shaky reads; an
edit marks its entry human-verified so the badge drains as you
proofread; the extract stage card nudges toward the proofread before
resolving; and the Help flow is rewritten as six stages + two
checkpoints with the loop called out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-03 00:19:59 -04:00
Eric WagonerandClaude Fable 5 25c1432a03 Docs catch-up + in-app Help page
README: the Catalog page description and a new "Fixing what the model
gets wrong" section cover the curation actions (edit/split/remove) and
their durability contract. Spec: amended-as-built notes — duplicate
same-edition copies are now supported via explicit splits, review is
both a TUI and the web app, and the data model lists the three curation
stores plus the dismissed-tickets file.

New Help page in the app (nav entry on every page): the six-stage flow,
what each page is for, the curation semantics with undo instructions, a
status-chip legend rendered with the real chips, keyboard shortcuts,
and where the data lives on disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-02 20:20:03 -04:00
Eric WagonerandClaude Fable 5 626f255c01 Remove-from-catalog: a third durable curation store
Titles that aren't games (misread box art, out-of-scope items) can now
be removed outright: a danger button in the catalog's edit panel posts
/api/remove-title, which drops the line's matches rows (veto'd ones
too — removal is the human explicitly discarding the line), records the
decision photo-scoped in data/title_removals.json, and replays
titles.json. Every rebuild filters removed sightings after edits and
before dedupe, so re-extraction cannot resurrect them; undo by deleting
the record from the store. The three stores now share one scoped-record
parser and recorder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-02 20:15:46 -04:00
Eric WagonerandClaude Fable 5 86d434a400 Audit round 5 (curation feature): 5 blind reviewers, 14 confirmed fixes
The standing post-feature audit over a7f0cfe. Correctness (data): splits
become photo-scoped store records so splitting one edition no longer
force-splits same-named editions, and renaming a split copy migrates its
protection to the corrected title instead of silently re-merging copies.
Correctness (web): edit scoping now counts siblings by NORMALIZED title
(matching how stored edits apply), same-title-same-photos edits are
refused rather than corrupting the sibling entry, split copies serve
their real per-photo cues to the edit form instead of blanks, and a
split whose row vanished underneath returns 409 instead of a false 200.
Silent failures: replay_titles refuses to rebuild from a PARTIAL raw
cache (fresh clone + one --only extract would have truncated the
committed titles.json); the edit endpoint writes in crash-safe order
(cull, record, replay); corrupt curation stores fail loud naming the
file; retried edits don't double-record. Review-decision durability:
drop_rows never drops dedupe_veto rows — a rename retitles them in
place — and writes through a no-reload path so a concurrent rewrite
can't silently discard the cull. Style: catalog action cells get their
own class (.rowactions' flex display broke table alignment), editor
inputs match the design system and stop overriding the global
focus-visible outline, EditBody's clear-semantics docstring scoped to
cue fields, "nothing to change" derived from the record itself.

Tests: 8 new (photo-scoped splits, veto preservation, photo-narrowed
drops, 409s on both curation endpoints under a running job, partial-raw
replay guard, rename-keeps-protection lifecycle, corrupt-store error,
cue-field editing) and the dead edition_hint key in the edit test now
exercises real cue fields. 259 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-02 20:04:15 -04:00
Eric WagonerandClaude Fable 5 a7f0cfee05 Durable curation: persisted splits + pre-resolve title edits, catalog A→Z
Wiz-War had no split button: can_split required a matches row, but fresh
extractions leave multi-photo titles rowless until resolve runs. Splits
are now a title-level decision persisted in data/title_splits.json,
honored by extract's dedupe and resolve's dedupe on every rebuild, with
the button on any multi-photo line — resolved or not.

Same mechanism carries human corrections: data/title_edits.json stores
fixed misreads and known cues (publisher/edition/year/language), applied
before dedupe on every titles.json rebuild, editable from a new inline
form on every catalog line. An edit drops the title's stale matches rows
so resolve re-queries with the corrected data.

The catalog page now sorts alphabetically (case-insensitive; split
copies stay adjacent) instead of extraction order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-02 19:47:08 -04:00
217 changed files with 43032 additions and 1056 deletions
+14 -1
View File
@@ -51,7 +51,20 @@ The first `/collection` call typically returns **HTTP 202** with a "please retry
## Search & matching heuristics ## Search & matching heuristics
1. Exact normalized-name match → strong candidate. 0. BGG's search is UNRELIABLE for generic and punctuated queries: results
truncate in the several-hundreds unordered (the game named "Dungeon!"
appears in neither the "Dungeon!" nor the "Dungeon" search), and
punctuation can hide matches. Every title is searched raw AND with
punctuation stripped, merged by id. A LONE surviving candidate never
auto-matches unless it's a true exact match whose owned-count clears
the dominance floor — the sole survivor may be an impostor standing
where a famous game should be, and the real match may need review's
manual-id entry.
1. Exact normalized-name match → strong candidate. A name that becomes
exact once its trailing parenthetical is stripped ("Wiz-War (Eighth
Edition)") is a SIBLING EDITION — BGG files new editions as separate
games — and counts as exact-grade so the choice between lineages
reaches review instead of hiding behind a confident auto.
2. Fuzzy match: `rapidfuzz` `token_sort_ratio ≥ 90` → good candidate. 2. Fuzzy match: `rapidfuzz` `token_sort_ratio ≥ 90` → good candidate.
3. Ties: fetch `/thing?stats=1` for top ~5 candidates; prefer higher owned-count / better BGG rank (well-known game beats obscure duplicate of the same name). 3. Ties: fetch `/thing?stats=1` for top ~5 candidates; prefer higher owned-count / better BGG rank (well-known game beats obscure duplicate of the same name).
- Search results include `boardgameexpansion` as a distinct `type` — keep expansions but tag them so review catches base/expansion confusion. - Search results include `boardgameexpansion` as a distinct `type` — keep expansions but tag them so review catches base/expansion confusion.
+2
View File
@@ -8,4 +8,6 @@ BGG_USERNAME=
BGG_PASSWORD= BGG_PASSWORD=
# Anthropic API key for the vision extract stage # Anthropic API key for the vision extract stage
# default vision provider; not needed for an openai-compatible/local
# setup — see the [vision.*] blocks in config.toml
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
+2
View File
@@ -1,6 +1,8 @@
# Secrets & credential-adjacent state # Secrets & credential-adjacent state
.env .env
*.storage_state.json *.storage_state.json
data/.lan_key
data/.own_repo
playwright/.auth/ playwright/.auth/
storage_state.json storage_state.json
+11 -10
View File
@@ -9,19 +9,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| # | Command | What it does | Status | | # | Command | What it does | Status |
|---|---------|--------------|--------| |---|---------|--------------|--------|
| 1 | `bggpipe extract` | Claude vision reads titles + edition cues from `photos/` | working | | 1 | `bggpipe extract` | Claude vision reads titles + edition cues from `photos/` | working |
| 2 | `bggpipe resolve` | match titles to BGG IDs and versions via XML API2 | working (stub data — see hard rules) | | 2 | `bggpipe resolve` | match titles to BGG IDs and versions via XML API2 | working (real API data since 2026-08-05) |
| 3 | `bggpipe review` | human review of ambiguous/unmatched items; `--web` serves a FastAPI UI on port 8377 | working | | 3 | `bggpipe review` | human review of ambiguous/unmatched items; `--web` serves a FastAPI UI on port 8377 | working |
| 4 | `bggpipe diff` | diff approved matches against the existing BGG collection | working | | 4 | `bggpipe diff` | diff approved matches against the existing BGG collection | working |
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | built; browser flows unverified until real data exists (`--dry-run` works now) | | 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) |
| 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working | | 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working |
Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`. Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
## Commands ## Commands
- `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`). - `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`).
- `uv run bggpipe web` — the app: six pages (Pipeline `/`, Photos, Review, Catalog, Queue, Library) in a shared sidebar shell; stage runs execute one-at-a-time in a background job. - `uv run bggpipe web` — the app: seven pages (Pipeline `/`, Photos, Titles, Review, Queue, Library, Help) in a shared sidebar shell (responsive: hamburger nav + stacked tables under 900px); stage runs execute one-at-a-time in a background job. `--lan` binds 0.0.0.0 behind a per-device access key: persisted in `data/.lan_key` (gitignored), printed as a QR at startup, cookie-paired for a year, required on EVERY network request (loopback clients and `/static/*` are exempt; the Host/Origin guard still applies). Phone camera uploads (generic `image.jpg` names) get minted `shelf-<timestamp>` names — only explicitly-named files trigger the replace-to-reshoot flow.
- `uv run bggpipe <stage>` — run a pipeline stage. Non-secret settings come from `config.toml` (username, dirs, vision model, rate limit); `--config` overrides the path. - `uv run bggpipe <stage>` — run a pipeline stage. Non-secret settings come from `config.toml` (dirs, rate limit, `vision_provider` + per-provider `[vision.*]` blocks — "anthropic" or any OpenAI-compatible endpoint incl. local Ollama); `--config` overrides the path.
- `uv run pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`. - `uv run pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`.
- `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format. - `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format.
@@ -29,28 +29,29 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel
- `src/bggpipe/``cli.py` (typer app), one module per stage (`extract`, `resolve`, `review` + `webreview`, `diff`, `upload`, `enrich`); `templates/shell.html` + `templates/pages/*` + `static/app.{css,js}` are the web UI (the stylesheet is the design system — tokens derive from the mascot art), plus `bgg_client.py` (rate-limited XML API2 client that caches responses to `data/bgg_cache/`), `jobs.py` (single-slot background stage runner for the web UI), `normalize.py` (title normalization), `models.py` (dataclasses), `config.py`, `fsio.py` (atomic writes), `init_wizard.py` (first-run setup). - `src/bggpipe/``cli.py` (typer app), one module per stage (`extract`, `resolve`, `review` + `webreview`, `diff`, `upload`, `enrich`); `templates/shell.html` + `templates/pages/*` + `static/app.{css,js}` are the web UI (the stylesheet is the design system — tokens derive from the mascot art), plus `bgg_client.py` (rate-limited XML API2 client that caches responses to `data/bgg_cache/`), `jobs.py` (single-slot background stage runner for the web UI), `normalize.py` (title normalization), `models.py` (dataclasses), `config.py`, `fsio.py` (atomic writes), `init_wizard.py` (first-run setup).
- `scripts/``write_stub_fixtures.py` / `write_photo_fixtures.py` generate synthetic fixtures; `record_fixtures.py` re-records real API responses once a token exists. - `scripts/``write_stub_fixtures.py` / `write_photo_fixtures.py` generate synthetic fixtures; `record_fixtures.py` re-records real API responses once a token exists.
- `tests/fixtures/bgg_cache/`stub XML fixtures the offline tests run against. - `tests/fixtures/bgg_cache/`recorded real XML fixtures the offline tests run against.
- `data/` — pipeline state (CSV/JSON artifacts are committed; caches are not — see Git). - `data/` — pipeline state (CSV/JSON artifacts are committed; caches are not — see Git).
## Hard rules (from spec — never violate) ## Hard rules (from spec — never violate)
- **≤1 request every 2 seconds** to any BGG endpoint; jittered backoff on 429/503. Upload stage: 24 s randomized delay between games. - **≤1 request every 2 seconds** to any BGG endpoint; jittered backoff on 429/503. Upload stage: 24 s randomized delay between games.
- **Credentials never touch disk or logs.** `ANTHROPIC_API_KEY`, `BGG_USERNAME`, `BGG_PASSWORD`, `BGG_API_TOKEN` come from env vars only. Playwright storage state is credential-adjacent — keep it gitignored. - **Credentials never touch disk or logs.** `ANTHROPIC_API_KEY`, `BGG_USERNAME`, `BGG_PASSWORD`, `BGG_API_TOKEN` come from env vars only. Playwright storage state is credential-adjacent — keep it gitignored.
- **The XML API requires a registered app token** (`Authorization: Bearer`, from `BGG_API_TOKEN`) — unregistered requests get 401. Until Eric's registration at boardgamegeek.com/applications is approved, tests run on the stub fixtures in `tests/fixtures/bgg_cache/`; re-record them with `scripts/record_fixtures.py` once the token exists. - **The XML API requires a registered app token** (`Authorization: Bearer`, from `BGG_API_TOKEN`) — unregistered requests get 401. The token is ACTIVE and `tests/fixtures/bgg_cache/` holds real recorded responses; add fixtures for new tests with `scripts/record_fixtures.py`.
- **Every stage is idempotent and resumable** — killing mid-run and restarting must lose no work; re-runs skip already-processed items. - **Every stage is idempotent and resumable** — killing mid-run and restarting must lose no work; re-runs skip already-processed items.
- Use only the XML API2 and the public website — no undocumented BGG endpoints (BGG tightened access policies in 2025). - Use only the XML API2 and the public website — no undocumented BGG endpoints (BGG tightened access policies in 2025).
- BGG has **no write API**: writes drive the real website with a logged-in Playwright session. - BGG has **no write API**: writes drive the real website with a logged-in Playwright session.
- **Stub-resolved data is never upload-ready.** All version_ids (and some game data) in `matches.csv`, `to_add.csv`, and `to_update.csv` currently come from SYNTHETIC stub fixtures — placeholders until real fixtures exist. When `BGG_API_TOKEN` arrives: delete both cache dirs, re-record fixtures, `resolve --force`, re-review. Two provenance markers guard this (both written by the fixture generators): `data/bgg_cache/STUB_FIXTURES.marker` (gitignored, travels with the stub XML) and `data/STUB_DATA.marker` (**committed**, so a fresh clone stays guarded). The upload stage MUST refuse to run while either exists; delete `data/STUB_DATA.marker` only after re-resolving from real fixtures. - **Synthetic data must never reach upload.** The stub era ended 2026-08-05: `matches.csv` and `tests/fixtures/bgg_cache/` now hold real API data. The guard mechanism stays armed: the stub-fixture generators write `data/bgg_cache/STUB_FIXTURES.marker` (gitignored) and `data/STUB_DATA.marker` (committed), and the upload stage MUST refuse to run while either exists — regenerating stubs re-locks upload automatically.
## Domain gotchas ## Domain gotchas
- 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). - 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. - 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. - Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
- **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). - **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), and `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). RPGGeek items carry their OWN link types (`rpgdesigner`, `rpgpublisher`, `rpggenre`, `rpgcategory`, `rpgmechanic`) — a board-game-only parser silently returns nothing for them. 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. - 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.
## Git ## Git
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub**`gh` CLI does not work here. - Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub**`gh` CLI does not work here.
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, Playwright storage state, or `.env`. - Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
+38 -74
View File
@@ -11,105 +11,69 @@ photos/ → [1 extract] → titles.json → [2 resolve] → matches.csv
→ [6 enrich] → games.json → [6 enrich] → games.json
``` ```
> **Status: working, not yet battle-tested.** All six stages are implemented with an offline test suite. The upload stage's browser flows follow documented selectors but await their first real run — start with `--dry-run`, then `--limit 1`. (This repo also carries its author's in-progress pipeline data; see [Bring your own shelves](#bring-your-own-shelves).) > **Status: battle-tested end to end.** The full pipeline has run against a live BGG account: shelf photos → 136 identified games → 62 additions and 36 version updates on a real collection. The result is public — [the author's collection on BGG](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built. Still sensible on a first run: `--dry-run`, then `--limit 1`.
## Why this exists ## Why this exists
BGG has no bulk import and no write API. Cataloging a few hundred games by hand means hours of searching, clicking, and second-guessing which of five editions you own. This pipeline replaces that with: take photos, run a command, resolve a handful of ambiguous matches in a review step, done. BGG has no bulk import and no write API. Cataloging a few hundred games by hand means hours of searching, clicking, and second-guessing which of five editions you own. I wanted to point a camera at my shelves instead. This pipeline replaces the typing with: take photos, run a command, settle a handful of ambiguous matches in a review step, done — and every judgment call along the way is yours, made in a review UI, never guessed by the machine.
## How it works ## How it works
1. **extract** — Shelf photos go to the Anthropic API (Claude vision), which reads game titles off spines and boxes along with edition cues: publisher, edition wording, print year, language. 1. **extract** — Shelf photos go to a vision model (Claude by default; any OpenAI-compatible endpoint or a local Ollama model works), which reads game titles off spines and boxes along with edition cues: publisher, edition wording, print year, language.
2. **resolve** — Titles are matched to BGG game IDs via the [XML API2](https://boardgamegeek.com/wiki/page/BGG_XML_API2) (exact + fuzzy matching, popularity tiebreaks), then edition cues are matched against BGG's version list for each game. Anything uncertain is flagged rather than guessed. 2. **resolve** — Titles are matched to BGG game IDs via the [XML API2](https://boardgamegeek.com/wiki/page/BGG_XML_API2) (exact + fuzzy matching, popularity tiebreaks), then edition cues are matched against BGG's version list for each game. Anything uncertain is flagged rather than guessed.
3. **review** — A local review step for ambiguous matches: pick the right game/version, or leave the version blank. Wrong guesses never reach your collection. 3. **review** — A local review step for ambiguous matches: pick the right game/version, or leave the version blank. Wrong guesses never reach your collection.
4. **diff** — Your existing BGG collection is fetched and compared, per copy (owning one edition of a game doesn't hide a second edition you also own). 4. **diff** — Your existing BGG collection is fetched and compared, per copy (owning one edition of a game doesn't hide a second edition you also own).
5. **upload** — A Playwright browser session logs into your BGG account and adds each game (with its version, when known) politely and slowly. Dry-run mode, per-game logging, and resumability included. 5. **upload** — A Playwright browser session logs into your BGG account and adds each game (with its version, when known) politely and slowly. Dry-run mode, per-game logging, and resumability included.
6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork URLs, version details) lands in `data/games.json`, the seed data for a future web frontend. 6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork, version details) lands in `data/games.json`, feeding a browsable library of your shelves.
Every stage is idempotent and resumable: kill it mid-run, restart, lose nothing. All artifacts are flat CSV/JSON files you can inspect and edit. Everything runs locally, every stage survives being killed mid-run, and all artifacts are flat CSV/JSON files you can inspect and edit. You can drive it from the terminal or from a local web app:
![Pipeline dashboard: six stage cards with live counts and Run buttons, showing a completed pipeline — 136 titles extracted, 115 resolved, 62 games added, 136 in the library](docs/screenshots/01.png)
**➔ [See the full tour](docs/tour.md)** — all seven pages, the game-detail view, and the phone experience.
## Requirements ## Requirements
- macOS or Linux, Python 3.12+, [uv](https://docs.astral.sh/uv/) - Python 3.12+ and [uv](https://docs.astral.sh/uv/), on macOS, Linux, or Windows. (Development happens on macOS; Windows is untested but nothing is platform-specific. One caveat: the owner-only file permissions bggpipe sets on `.env` and browser session state are POSIX-only — on Windows, keep those files in a directory protected by your account.)
- An [Anthropic API key](https://console.anthropic.com/) (vision extraction) - A vision model for extraction — an [Anthropic API key](https://console.anthropic.com/) by default, or any OpenAI-compatible endpoint including a free local [Ollama](https://ollama.com/) model. **Cost expectation:** extracting the author's whole collection — 65 shelf photos, 136 games — cost under a dollar with the default model (Claude Sonnet), one-time. Re-runs are free: every photo's read is cached, and only new or replaced photos go back to the model.
- A BoardGameGeek account **and a registered BGG application** — as of BGG's [2025 API policy](https://boardgamegeek.com/using_the_xml_api), the XML API requires a Bearer token from a registered app. Register a free non-commercial application at [boardgamegeek.com/applications](https://boardgamegeek.com/applications) (approval can take a week or more, so **apply on day one**), then create a token. Each user needs their own; tokens must not be shared. - A BoardGameGeek account **and a registered BGG application** — as of BGG's [2025 API policy](https://boardgamegeek.com/using_the_xml_api), the XML API requires a Bearer token from a registered app. Register a free non-commercial application at [boardgamegeek.com/applications](https://boardgamegeek.com/applications) (approval can take a week or more, so **apply on day one**), then create a token. Each user needs their own; tokens must not be shared. ([You can start before it arrives.](docs/guide.md#running-before-your-bgg-token-arrives))
### Why it asks for your BGG password — and where your credentials go
BGG has no write API: the **only** way to add games to a collection is the website itself. So the upload stage signs into boardgamegeek.com in a real browser window, on your machine, and clicks the same buttons you would — you can literally watch it work (the browser is visible by default). That's the whole reason the password is needed, and it's used for exactly that login and nothing else.
Everything stays on your computer. There is no bggpipe server, no telemetry, no analytics, and no account with me — I never see your credentials, your collection, or anything else, and the code is right here to check. Credentials live in a local `.env` file (owner-only permissions, never written to logs) and are sent only to boardgamegeek.com itself. The only other service the pipeline ever contacts is the vision provider *you* configure, which receives your shelf photos and nothing more — and with a local Ollama model, even those never leave the house.
## Quick start ## Quick start
```sh
uv tool install git+https://git.kestrelsnest.social/eric/bggpipe
mkdir shelves && cd shelves # any directory of your own — NOT a clone of this repo
bggpipe init # guided setup: folders, credentials, browser download
bggpipe web # the whole app at http://127.0.0.1:8377/
```
Drop shelf photos on the Photos page (or straight [from your phone's camera](docs/guide.md#from-your-phone)) and follow the pipeline left to right. Your photos and every pipeline artifact live in the directory where you run it, and `uv tool upgrade bggpipe` picks up fixes without going anywhere near your data — which is also why running inside a clone of this repo is the one unsupported setup: this repo carries its author's live pipeline data at git-tracked paths, and [a `git pull` on top of yours could destroy it](docs/guide.md#keeping-your-data-safe-from-git). bggpipe warns if it catches you doing this.
**➔ [The user's guide](docs/guide.md)** — credentials, every stage and flag, phone pairing, RPG handling, fixing misreads, uploading safely, and running before your token arrives.
## Development
```sh ```sh
git clone https://git.kestrelsnest.social/eric/bggpipe.git git clone https://git.kestrelsnest.social/eric/bggpipe.git
cd bggpipe cd bggpipe
uv sync # installs Python deps uv sync # installs Python deps
uv run bggpipe init # guided setup: folders, credentials, browser download uv run pytest # offline test suite (recorded fixtures, no network)
```
The `init` wizard is idempotent — re-run it anytime to check status or add keys you skipped. It prompts for the credentials below (hidden input, saved to a `.env` it creates with owner-only permissions) and offers the one-time Playwright Chromium download. Prefer doing it by hand? `cp .env.example .env`, fill it in, and run `uv run playwright install chromium` yourself.
Secrets live in environment variables only, never in config files, code, or logs. `.env` is gitignored. If you use [direnv](https://direnv.net/), the committed `.envrc` loads `.env` automatically after a one-time `direnv allow`; otherwise export the variables yourself (e.g. `set -a; source .env; set +a`).
| Variable | Used by | What it is |
|---|---|---|
| `ANTHROPIC_API_KEY` | extract | Anthropic API key |
| `BGG_API_TOKEN` | resolve, diff, enrich | Bearer token from your registered BGG application |
| `BGG_USERNAME` | diff, upload, enrich | Your BGG username (public, but kept in `.env` so it lives in one place) |
| `BGG_PASSWORD` | upload (website login) | Your BGG password |
Non-secret knobs (`photos_dir`, `data_dir`, the vision model, the rate limit) live in `config.toml`. From here you can drive everything from the browser:
```sh
uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
```
Six pages in one local app: **Pipeline** (run stages, watch live output), **Photos** (drag-and-drop upload, gallery, reshoot tickets), **Review** (keyboard-first match and edition decisions), **Catalog** (every extracted title and its status), **Queue** (exactly what upload will do, plus its full log), and **Library** (your enriched collection, browsable once real BGG data lands). The real upload sits behind a confirmation and behind the stub-data lock. Prefer the terminal? Every stage is also a command, and the two interfaces share all state:
```sh
uv run bggpipe extract # photos → titles.json (+ retake prompts)
uv run bggpipe resolve # titles → BGG ids/versions in matches.csv
uv run bggpipe review --web # review UI only
uv run bggpipe diff # compare against your BGG collection
uv run bggpipe upload --dry-run # ALWAYS inspect this first
uv run bggpipe upload --limit 1 # then one game, then small batches
uv run bggpipe enrich # full metadata → data/games.json
```
Each stage skips work it has already done; `--force`/`--refresh` flags redo it. `review` without `--web` runs in the terminal instead. `upload` also has `--retry-failed`, `--verify` (re-fetches your collection and cross-checks the log), and runs a **headed** browser by default — BGG's Cloudflare check blocks headless ones, and a first login may need one human click before the session is saved locally and reused.
### RPGs on your shelves
Tabletop RPGs aren't in BGG's board-game database — they live on RPGGeek, which shares the same underlying API. When a title isn't found as a board game, bggpipe retries as an RPG: matches are identified, enriched, and browsable in the library (filter: RPGs), but they stay **local only** — they're never uploaded, since your BGG collection can't hold them.
### Taking good shelf photos
Straight-on, one shelf (or part of one) per shot, close enough that spine text is legible to a human. If you can't read it, the model can't either. Overlap between shots is fine: duplicate reads are deduped automatically, with the merge shown (and veto-able) in review. Boxes the model spots but can't identify become retake prompts in `unidentified.json` and the review UI's "reshoot" list: photograph those boxes up close, drop the new photo in `photos/`, and run `extract` again.
## Bring your own shelves
This repo doubles as its author's live pipeline, so `data/` ships with his real artifacts — extracted titles, matches, and 2018 collection snapshots. Before running against *your* shelves, clear the data:
```sh
rm data/*.csv data/*.json data/*.xml data/STUB_DATA.marker
rm -rf data/bgg_cache data/extract_raw
```
Two of those files deserve a word:
- **`data/STUB_DATA.marker`** — the committed CSVs were resolved from *hand-written stub fixtures* (the author's BGG application is still awaiting approval), so every version id in them is a synthetic placeholder. The upload stage refuses to run while this marker exists, precisely so nobody (including a fresh clone) can push placeholder data to a real BGG account. Starting fresh with your own token, you'll never see it again.
- **`data/collection_snapshot_*.xml`** — with `BGG_API_TOKEN` set, `diff` fetches your collection live and you don't need these. Without a token (still waiting on approval?), you can use the logged-in-browser exemption: while signed in to BGG, save these two URLs as `data/collection_snapshot_base.xml` and `data/collection_snapshot_expansions.xml` (if you get a "queued" message, refresh after a few seconds):
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
No token yet? `extract` works immediately (it only needs the Anthropic key), and `resolve` does what it can, parking the rest as "waiting on BGG API token" — it picks them up automatically once the token exists. Everything is saved as you go.
## Development
```sh
uv run pytest # offline test suite (stub fixtures, no network)
uv run pytest --run-live # + a read-only live-API smoke test (needs token) uv run pytest --run-live # + a read-only live-API smoke test (needs token)
uv run ruff check src tests # lint uv run ruff check src tests # lint
uv run bggpipe review --web --dev # review UI with code hot-reload uv run bggpipe web --dev # the app with code hot-reload
``` ```
The review UI live-follows the data files — run `extract` or `resolve` in another terminal and the page updates itself. Architecture and contributor guidance: [CLAUDE.md](CLAUDE.md) and the [full spec](bgg-shelf-pipeline-spec.md); BGG automation notes: [docs/bgg-upload-flow.md](docs/bgg-upload-flow.md). Run your own *pipeline* from a different directory — the clone's `data/` is the author's live data. Architecture and contributor guidance: [CLAUDE.md](CLAUDE.md) and the [design contract](docs/spec.md); BGG automation notes: [docs/bgg-upload-flow.md](docs/bgg-upload-flow.md).
## Questions, bugs, ideas
Email [eric@ericwagoner.com](mailto:eric@ericwagoner.com), or find me on Mastodon at [@eric@toots.kestrelsnest.social](https://toots.kestrelsnest.social/@eric) or Bluesky at [@kestrelsnest.social](https://bsky.app/profile/kestrelsnest.social) — bug reports, confusions, and "it worked, here's my collection" notes all welcome. (This Gitea instance doesn't take public registrations, so there's no issue tracker to file into yet; if enough people show up, one will materialize.)
## A note on being a good BGG citizen ## A note on being a good BGG citizen
+15 -1
View File
@@ -3,5 +3,19 @@
photos_dir = "photos" photos_dir = "photos"
data_dir = "data" data_dir = "data"
model = "claude-sonnet-5"
rate_limit_seconds = 2.0 rate_limit_seconds = 2.0
# Which vision backend reads your shelf photos. Both recipes below stay
# on file; this line picks one.
vision_provider = "anthropic"
[vision.anthropic]
# reads ANTHROPIC_API_KEY from the environment
model = "claude-sonnet-5"
[vision."openai-compatible"]
# OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio, vLLM).
# key_env names the env var holding the key; "" = endpoint needs none.
base_url = "http://localhost:11434/v1"
model = "qwen2.5vl:7b"
key_env = ""
-1
View File
@@ -1 +0,0 @@
The CSVs in this directory were resolved from hand-written stub fixtures, not real BGG data — version_ids are SYNTHETIC. The upload stage refuses to run while this file exists. Delete it only after re-resolving against real recorded fixtures (BGG_API_TOKEN + scripts/record_fixtures.py + resolve --force).
+7618
View File
File diff suppressed because it is too large Load Diff
+128 -9
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
[
{
"title_raw": "Civilization: West Extension Map",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Artistry: Delightful Doorways Mini Expansion",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "castle panic wizard's tower",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "",
"art_notes": "",
"source_photos": []
}
]
+99
View File
@@ -0,0 +1,99 @@
[
{
"match": "Hangermuger 4 800",
"title_raw": "Huggermugger"
},
{
"match": "DOCTOR WHO THE GAME OF TIME",
"title_raw": "DOCTOR WHO THE card GAME"
},
{
"match": "CAPTAIN MARVEL SECRET SKRULLS",
"confidence": "high"
},
{
"match": "CHECKERS",
"title_raw": "Super Mario CHECKERS",
"confidence": "high"
},
{
"match": "DOCTOR WHO THE card GAME",
"confidence": "high"
},
{
"match": "DUNGEONS & DRAGONS",
"confidence": "high"
},
{
"match": "Huggermugger",
"confidence": "high"
},
{
"match": "Patch Work",
"confidence": "high"
},
{
"match": "The Ain't It Cool Trivia Game",
"title_raw": "The Rocky Horror Trivia Game",
"confidence": "high"
},
{
"match": "Verdant",
"confidence": "high"
},
{
"match": "Wiz-War",
"photos": [
"IMG_4502.jpeg"
],
"confidence": "high"
},
{
"match": "ACTION CASTLE",
"title_raw": "Parsely",
"confidence": "high"
},
{
"match": "Patch Work",
"title_raw": "Patchwork",
"confidence": "high"
},
{
"match": "SMALLWORLD",
"title_raw": "SMALL WORLD",
"confidence": "high"
},
{
"match": "ART NOUVEAU ARTISTRY",
"title_raw": "ARTISTRY",
"confidence": "high"
},
{
"match": "MAGE WARS ACADEMY CORE SET",
"title_raw": "MAGE WARS ACADEMY",
"confidence": "high"
},
{
"match": "MANTIS FALLS (a game of trust)",
"title_raw": "MANTIS FALLS",
"confidence": "high"
},
{
"match": "BOTANY EXPANSION TANTALIZING TREES",
"title_raw": "BOTANY: TANTALIZING TREES",
"confidence": "high"
},
{
"match": "BOTANY EXPANSION PERILOUS PERFUMES POISONOUS, CARNIVOROUS, PARASITIC AND BIZARRE PLANTS",
"title_raw": "BOTANY: PERILOUS PERFUMES",
"confidence": "high"
},
{
"match": "WIZ-WAR",
"photos": [
"IMG_4504.jpeg"
],
"year_hint": 2012,
"confidence": "high"
}
]
+86
View File
@@ -0,0 +1,86 @@
[
{
"title": "THE CATAN CARD GAME",
"photos": [
"IMG_4523.jpeg"
]
},
{
"title": "SLUGFEST GAMES",
"photos": [
"IMG_4528.jpeg"
]
},
{
"title": "SCRABBLE",
"photos": [
"IMG_4508.jpeg"
]
},
{
"title": "SCERU MERRY MEN",
"photos": [
"IMG_4518.jpeg"
]
},
{
"title": "Patchwork",
"photos": [
"IMG_4556.jpeg"
]
},
{
"title": "Jokin Ha...",
"photos": [
"IMG_4506.jpeg"
]
},
{
"title": "CHEAPASS GAMES",
"photos": [
"IMG_4519.jpeg"
]
},
{
"title": "CATAN",
"photos": [
"IMG_4523.jpeg"
]
},
{
"title": "CAR WARS",
"photos": [
"IMG_4532.jpeg"
]
},
{
"title": "Bird on Your Bread?!",
"photos": [
"IMG_4517.jpeg"
]
},
{
"title": "Super Mario CHECKERS",
"photos": [
"IMG_4507.jpeg"
]
},
{
"title": "ACTION CASTLE I",
"photos": [
"IMG_4573.jpeg"
]
},
{
"title": "SIX-GUN SHOWDOWN",
"photos": [
"IMG_4573.jpeg"
]
},
{
"title": "Blackboa[rd]",
"photos": [
"IMG_4573.jpeg"
]
}
]
+3
View File
@@ -0,0 +1,3 @@
[
"Wiz-War"
]
+207 -162
View File
@@ -22,7 +22,8 @@
"art_notes": "Box top shown horizontally on shelf, tagline 'Who's to Blame Logic Game', orange cat illustration", "art_notes": "Box top shown horizontally on shelf, tagline 'Who's to Blame Logic Game', orange cat illustration",
"source_photos": [ "source_photos": [
"IMG_4499.jpeg", "IMG_4499.jpeg",
"IMG_4505.jpeg" "IMG_4505.jpeg",
"shelf-20260803-215723.jpg"
], ],
"title_normalized": "cat crimes" "title_normalized": "cat crimes"
}, },
@@ -158,8 +159,8 @@
"title_normalized": "gentle rain" "title_normalized": "gentle rain"
}, },
{ {
"title_raw": "Patch Work", "title_raw": "Patchwork",
"confidence": "medium", "confidence": "high",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -261,15 +262,13 @@
{ {
"title_raw": "Wiz-War", "title_raw": "Wiz-War",
"confidence": "high", "confidence": "high",
"publisher_hint": "Fantasy Flight Games", "publisher_hint": "",
"edition_hint": "9th Edition", "edition_hint": "",
"year_hint": null, "year_hint": null,
"language_hint": "English", "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!'", "art_notes": "black spine with purple/blue text, partial visible 'Wiz-Wa...' with subtitle 'and board game of ...ly battle and treasures!'",
"source_photos": [ "source_photos": [
"IMG_4502.jpeg", "IMG_4502.jpeg"
"IMG_4504.jpeg",
"IMG_4528.jpeg"
], ],
"title_normalized": "wiz war" "title_normalized": "wiz war"
}, },
@@ -351,6 +350,19 @@
], ],
"title_normalized": "dungeon" "title_normalized": "dungeon"
}, },
{
"title_raw": "WIZ-WAR",
"confidence": "high",
"publisher_hint": "Fantasy Flight Games",
"edition_hint": "",
"year_hint": 2012,
"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", "title_raw": "TICKET TO RIDE",
"confidence": "high", "confidence": "high",
@@ -405,7 +417,7 @@
"title_normalized": "onitama" "title_normalized": "onitama"
}, },
{ {
"title_raw": "MANTIS FALLS (a game of trust)", "title_raw": "MANTIS FALLS",
"confidence": "high", "confidence": "high",
"publisher_hint": "Diligent Games", "publisher_hint": "Diligent Games",
"edition_hint": "", "edition_hint": "",
@@ -415,20 +427,7 @@
"source_photos": [ "source_photos": [
"IMG_4505.jpeg" "IMG_4505.jpeg"
], ],
"title_normalized": "mantis falls game of trust" "title_normalized": "mantis falls"
},
{
"title_raw": "Jokin Ha...",
"confidence": "medium",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "White box with simple stick-figure character illustration in blue shirt, partially obscured by a blue 'Nostalgia Pack' sleeve item and card deck; likely 'Joking Hazard' based on visible fragment and cyanide & happiness-style art",
"source_photos": [
"IMG_4506.jpeg"
],
"title_normalized": "jokin ha"
}, },
{ {
"title_raw": "Herbaceous", "title_raw": "Herbaceous",
@@ -457,19 +456,6 @@
], ],
"title_normalized": "labyrinth" "title_normalized": "labyrinth"
}, },
{
"title_raw": "CHECKERS",
"confidence": "medium",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Top-left corner box, red/black checkered pattern visible",
"source_photos": [
"IMG_4507.jpeg"
],
"title_normalized": "checkers"
},
{ {
"title_raw": "Super Mario Checkers Collector's Edition", "title_raw": "Super Mario Checkers Collector's Edition",
"confidence": "high", "confidence": "high",
@@ -511,19 +497,6 @@
], ],
"title_normalized": "bugs in kitchen" "title_normalized": "bugs in kitchen"
}, },
{
"title_raw": "SCRABBLE",
"confidence": "medium",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Red box edge visible top right, only partial letters 'RABBLE' legible",
"source_photos": [
"IMG_4508.jpeg"
],
"title_normalized": "scrabble"
},
{ {
"title_raw": "CATAN DICE GAME", "title_raw": "CATAN DICE GAME",
"confidence": "high", "confidence": "high",
@@ -772,8 +745,8 @@
"title_normalized": "dixit" "title_normalized": "dixit"
}, },
{ {
"title_raw": "Hangermuger 4 800", "title_raw": "Huggermugger",
"confidence": "low", "confidence": "high",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -782,11 +755,11 @@
"source_photos": [ "source_photos": [
"IMG_4514.jpeg" "IMG_4514.jpeg"
], ],
"title_normalized": "hangermuger 4 800" "title_normalized": "huggermugger"
}, },
{ {
"title_raw": "The Ain't It Cool Trivia Game", "title_raw": "The Rocky Horror Trivia Game",
"confidence": "medium", "confidence": "high",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -795,10 +768,10 @@
"source_photos": [ "source_photos": [
"IMG_4514.jpeg" "IMG_4514.jpeg"
], ],
"title_normalized": "ain t it cool trivia game" "title_normalized": "rocky horror trivia game"
}, },
{ {
"title_raw": "ART NOUVEAU ARTISTRY", "title_raw": "ARTISTRY",
"confidence": "high", "confidence": "high",
"publisher_hint": "Dux Somnium", "publisher_hint": "Dux Somnium",
"edition_hint": "", "edition_hint": "",
@@ -808,7 +781,7 @@
"source_photos": [ "source_photos": [
"IMG_4515.jpeg" "IMG_4515.jpeg"
], ],
"title_normalized": "art nouveau artistry" "title_normalized": "artistry"
}, },
{ {
"title_raw": "SPEECHLESS", "title_raw": "SPEECHLESS",
@@ -864,7 +837,7 @@
}, },
{ {
"title_raw": "CAPTAIN MARVEL SECRET SKRULLS", "title_raw": "CAPTAIN MARVEL SECRET SKRULLS",
"confidence": "medium", "confidence": "high",
"publisher_hint": "Marvel", "publisher_hint": "Marvel",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -888,19 +861,6 @@
], ],
"title_normalized": "pie face" "title_normalized": "pie face"
}, },
{
"title_raw": "Bird on Your Bread?!",
"confidence": "low",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Blue box with cartoon bird/character illustration, tall narrow spine",
"source_photos": [
"IMG_4517.jpeg"
],
"title_normalized": "bird on your bread"
},
{ {
"title_raw": "Walk the Plank!", "title_raw": "Walk the Plank!",
"confidence": "high", "confidence": "high",
@@ -942,7 +902,7 @@
"title_normalized": "meteor" "title_normalized": "meteor"
}, },
{ {
"title_raw": "MAGE WARS ACADEMY CORE SET", "title_raw": "MAGE WARS ACADEMY",
"confidence": "high", "confidence": "high",
"publisher_hint": "Arcane Wonders", "publisher_hint": "Arcane Wonders",
"edition_hint": "Core Set", "edition_hint": "Core Set",
@@ -952,24 +912,11 @@
"source_photos": [ "source_photos": [
"IMG_4518.jpeg" "IMG_4518.jpeg"
], ],
"title_normalized": "mage wars academy core set" "title_normalized": "mage wars academy"
}, },
{ {
"title_raw": "SCERU MERRY MEN", "title_raw": "DOCTOR WHO THE card GAME",
"confidence": "medium", "confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Orange box with dinosaur/creature illustration, fantasy medieval theme art",
"source_photos": [
"IMG_4518.jpeg"
],
"title_normalized": "sceru merry men"
},
{
"title_raw": "DOCTOR WHO THE GAME OF TIME",
"confidence": "medium",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -978,11 +925,11 @@
"source_photos": [ "source_photos": [
"IMG_4518.jpeg" "IMG_4518.jpeg"
], ],
"title_normalized": "doctor who game of time" "title_normalized": "doctor who card game"
}, },
{ {
"title_raw": "DUNGEONS & DRAGONS", "title_raw": "DUNGEONS & DRAGONS",
"confidence": "medium", "confidence": "high",
"publisher_hint": "TSR", "publisher_hint": "TSR",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -1006,19 +953,6 @@
], ],
"title_normalized": "dungeons and dragons fantasy game rules" "title_normalized": "dungeons and dragons fantasy game rules"
}, },
{
"title_raw": "CHEAPASS GAMES",
"confidence": "low",
"publisher_hint": "Cheapass Games",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Vertical white spine text near right side of shelf, appears to be publisher branding rather than a title",
"source_photos": [
"IMG_4519.jpeg"
],
"title_normalized": "cheapass games"
},
{ {
"title_raw": "Before I Kill You, Mister Bond...", "title_raw": "Before I Kill You, Mister Bond...",
"confidence": "high", "confidence": "high",
@@ -1150,32 +1084,6 @@
], ],
"title_normalized": "sleeping gods distant skies" "title_normalized": "sleeping gods distant skies"
}, },
{
"title_raw": "CATAN",
"confidence": "high",
"publisher_hint": "Catan",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Red spine, small circular emblem visible on spine",
"source_photos": [
"IMG_4523.jpeg"
],
"title_normalized": "catan"
},
{
"title_raw": "THE CATAN CARD GAME",
"confidence": "high",
"publisher_hint": "Catan",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Red/orange spine with yellow banner text",
"source_photos": [
"IMG_4523.jpeg"
],
"title_normalized": "catan card game"
},
{ {
"title_raw": "THE RIVALS FOR CATAN CARD GAME", "title_raw": "THE RIVALS FOR CATAN CARD GAME",
"confidence": "high", "confidence": "high",
@@ -1281,17 +1189,17 @@
"title_normalized": "red dragon inn smorgasbox" "title_normalized": "red dragon inn smorgasbox"
}, },
{ {
"title_raw": "SLUGFEST GAMES", "title_raw": "WIZ-WAR",
"confidence": "low", "confidence": "high",
"publisher_hint": "Slugfest Games", "publisher_hint": "",
"edition_hint": "", "edition_hint": "9th Edition",
"year_hint": null, "year_hint": null,
"language_hint": "English", "language_hint": "English",
"art_notes": "Partial box visible at top of image, red/orange background with illustrated characters, this may be a publisher logo rather than a game title", "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": [ "source_photos": [
"IMG_4528.jpeg" "IMG_4528.jpeg"
], ],
"title_normalized": "slugfest games" "title_normalized": "wiz war"
}, },
{ {
"title_raw": "RISK THE LORD OF THE RINGS: The Middle-earth Conquest Game", "title_raw": "RISK THE LORD OF THE RINGS: The Middle-earth Conquest Game",
@@ -1358,19 +1266,6 @@
], ],
"title_normalized": "gloomhaven" "title_normalized": "gloomhaven"
}, },
{
"title_raw": "CAR WARS",
"confidence": "medium",
"publisher_hint": "sjgames.com",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Red box top left showing miniature cars, partially visible at top of frame",
"source_photos": [
"IMG_4532.jpeg"
],
"title_normalized": "car wars"
},
{ {
"title_raw": "DEADLY DOODLES", "title_raw": "DEADLY DOODLES",
"confidence": "high", "confidence": "high",
@@ -1412,7 +1307,7 @@
}, },
{ {
"title_raw": "Verdant", "title_raw": "Verdant",
"confidence": "medium", "confidence": "high",
"publisher_hint": "AEG", "publisher_hint": "AEG",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -1696,19 +1591,6 @@
], ],
"title_normalized": "kill doctor lucky" "title_normalized": "kill doctor lucky"
}, },
{
"title_raw": "Patchwork",
"confidence": "low",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Orange/tan box, only partial text '...TCH ...ORK' visible with quilt/patchwork square pattern",
"source_photos": [
"IMG_4556.jpeg"
],
"title_normalized": "patchwork"
},
{ {
"title_raw": "ALICE IS MISSING SILENT FALLS EXPANSION", "title_raw": "ALICE IS MISSING SILENT FALLS EXPANSION",
"confidence": "high", "confidence": "high",
@@ -1734,5 +1616,168 @@
"IMG_4566.jpeg" "IMG_4566.jpeg"
], ],
"title_normalized": "alice is missing silent role playing game" "title_normalized": "alice is missing silent role playing game"
},
{
"title_raw": "ETHERFIELDS KITTENBURG EXPANSION",
"confidence": "high",
"publisher_hint": "Awaken Realms",
"edition_hint": "Expansion by Adrian Krawczyk",
"year_hint": null,
"language_hint": "English",
"art_notes": "Dark reddish-brown background with a large anthropomorphic cat figure in ornate robe, surrounded by smaller kittens, gothic fantasy style artwork",
"source_photos": [
"IMG_4570.jpeg"
],
"title_normalized": "etherfields kittenburg expansion"
},
{
"title_raw": "BOTANY: TANTALIZING TREES",
"confidence": "high",
"publisher_hint": "Dux Somnium",
"edition_hint": "Expansion",
"year_hint": null,
"language_hint": "English",
"art_notes": "Dark green box with gold ornate border, gold tree illustration",
"source_photos": [
"IMG_4571.jpeg"
],
"title_normalized": "botany tantalizing trees"
},
{
"title_raw": "BOTANY: PERILOUS PERFUMES",
"confidence": "high",
"publisher_hint": "Dux Somnium",
"edition_hint": "Expansion",
"year_hint": null,
"language_hint": "English",
"art_notes": "Dark purple/plum box with gold ornate border, gold carnivorous plant illustration",
"source_photos": [
"IMG_4571.jpeg"
],
"title_normalized": "botany perilous perfumes"
},
{
"title_raw": "Parsely",
"confidence": "high",
"publisher_hint": "Memento More Computers Inc.",
"edition_hint": "",
"year_hint": 2007,
"language_hint": "English",
"art_notes": "Green monochrome text on black computer screen display, styled as retro terminal game",
"source_photos": [
"IMG_4573.jpeg"
],
"title_normalized": "parsely"
},
{
"title_raw": "...and then, we held hands.",
"confidence": "high",
"publisher_hint": "LudiCreations",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Blue sky background with white stylized doves/hands motif, cursive white title text",
"source_photos": [
"image.jpg"
],
"title_normalized": "and then we held hands"
},
{
"title_raw": "Consentacle",
"confidence": "high",
"publisher_hint": "Dead Pixel • www.deadpixel.co",
"edition_hint": "",
"year_hint": 2018,
"language_hint": "English",
"art_notes": "Black box with purple tentacle/octopus illustration on spine",
"source_photos": [
"shelf-20260803-171316.jpg"
],
"title_normalized": "consentacle"
},
{
"title_raw": "The Simpsons Trivia Game",
"confidence": "high",
"publisher_hint": "Matt Groening / The Simpsons",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Green background with die-cut character silhouette shapes of Homer, Marge, Bart, Lisa, and Maggie; includes 'Ay Carumba!' badge and note about included cast poster",
"source_photos": [
"shelf-20260803-213735.jpg"
],
"title_normalized": "simpsons trivia game"
},
{
"title_raw": "THE CAT GAME",
"confidence": "high",
"publisher_hint": "Spin Master",
"edition_hint": "",
"year_hint": null,
"language_hint": "English/French",
"art_notes": "White box with faux-fur textured letters spelling CAT, image of cat wearing top hat and bow tie standing on a scratching post, hand holding a marker/pointer stick, tagline 'The Hair-Raising Drawing Game (with Cats)'",
"source_photos": [
"shelf-20260803-213756.jpg"
],
"title_normalized": "cat game"
},
{
"title_raw": "BLINK",
"confidence": "high",
"publisher_hint": "Mattel Games",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Orange sunburst background box with green bubble letters spelling BLINK, cards showing green stars, red flowers, blue triangles, yellow droplets",
"source_photos": [
"shelf-20260803-215650.jpg"
],
"title_normalized": "blink"
},
{
"title_raw": "The Office Trivia Game",
"confidence": "high",
"publisher_hint": "Cardinal",
"edition_hint": "Ready to Roll, Fast-Paced Game!",
"year_hint": null,
"language_hint": "English",
"art_notes": "Box shows three characters from The Office in an office/warehouse setting, notebook paper design border, red 'Ready to Roll' circular badge",
"source_photos": [
"shelf-20260803-215703.jpg"
],
"title_normalized": "office trivia game"
},
{
"title_raw": "Civilization: West Extension Map",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "civilization west extension map"
},
{
"title_raw": "Artistry: Delightful Doorways Mini Expansion",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "artistry delightful doorways mini expansion"
},
{
"title_raw": "castle panic wizard's tower",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "castle panic wizard s tower"
} }
] ]
-6
View File
@@ -1,7 +1 @@
bgg_id,bgg_name,year,type,version_id,version_name,title_raw,source_photos,second_copy bgg_id,bgg_name,year,type,version_id,version_name,title_raw,source_photos,second_copy
235096,Cat Crimes,2017,boardgame,360982,English edition,CAT CRIMES,IMG_4499.jpeg;IMG_4505.jpeg,
268163,A Gentle Rain,2019,boardgame,268164,Bloom Edition,a Gentle Rain,IMG_4501.jpeg;IMG_4507.jpeg,
50381,Cards Against Humanity,2009,boardgame,,,Cards Against Humanity,IMG_4501.jpeg;IMG_4556.jpeg,
1078,Skip-Bo,1967,boardgame,,,SKIP-BO,IMG_4501.jpeg;IMG_4542.jpeg,
218866,Scrawl,2017,boardgame,,,SCRAWL,IMG_4502.jpeg,
195314,Herbaceous,2017,boardgame,,,Herbaceous,IMG_4507.jpeg,
1 bgg_id bgg_name year type version_id version_name title_raw source_photos second_copy
235096 Cat Crimes 2017 boardgame 360982 English edition CAT CRIMES IMG_4499.jpeg;IMG_4505.jpeg
268163 A Gentle Rain 2019 boardgame 268164 Bloom Edition a Gentle Rain IMG_4501.jpeg;IMG_4507.jpeg
50381 Cards Against Humanity 2009 boardgame Cards Against Humanity IMG_4501.jpeg;IMG_4556.jpeg
1078 Skip-Bo 1967 boardgame SKIP-BO IMG_4501.jpeg;IMG_4542.jpeg
218866 Scrawl 2017 boardgame SCRAWL IMG_4502.jpeg
195314 Herbaceous 2017 boardgame Herbaceous IMG_4507.jpeg
+12
View File
@@ -360,5 +360,17 @@
"partial_text": "TOWN...FUKU (possibly Japanese text)", "partial_text": "TOWN...FUKU (possibly Japanese text)",
"art_notes": "Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items" "art_notes": "Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items"
} }
],
"IMG_4573.jpeg": [
{
"location": "Upper right area of the book cover art, stacked above 'ACTION CASTLE I' box",
"partial_text": "AC... (partially obscured by hand/fingers)",
"art_notes": "Red/orange box spine, appears to be part of a series with other 'Action Castle' related titles"
},
{
"location": "Bottom right of cover art, near 'Blackboard' box",
"partial_text": "",
"art_notes": "Small dark box with logo icon, title illegible due to size and angle"
}
] ]
} }
+48 -1
View File
@@ -1,45 +1,90 @@
[ [
"IMG_4501.jpeg|Bottom shelf, left portion, above 'Exploding Kittens' box|EXPANSION|Small box with partial text visible, colorful design, title cut off by frame edge",
"IMG_4501.jpeg|Bottom shelf, right side, near Exploding Kittens box|COSMIC|Colorful box spine, blue/green tones, title partially visible but not fully confirmable",
"IMG_4501.jpeg|Middle shelf, purple box beneath Joking Hazard, right side of stack|A Boardgame for 1 to 6 players, ages 8 and up.|Purple/violet box, text describing player count, title not legible from this angle",
"IMG_4501.jpeg|Right side, middle shelf, black spine sticking out between Santorini and purple box below|T|Black spine, single letter visible, rest obscured",
"IMG_4501.jpeg|Top shelf, far left edge, cut off by frame; near 'a Gentle Rain' box||Dark/black box, only a thin sliver visible",
"IMG_4502.jpeg|bottom-left corner of shelf, beneath the History of the World area, left of Dungeon!|A Boardgame for 1 to 6 players, ages 8 and up|purple/tan box with descriptive text visible but no title readable",
"IMG_4502.jpeg|far left side of shelf, stacked horizontally beneath the yellow-floral box, left of Santorini||small colorful box spines, red and white coloring, too angled/blurry to read title",
"IMG_4502.jpeg|left side of shelf, below the horizontal stack, left of Santorini and above Joking Hazard||blue box with white illustration, appears to be a game box but title not legible",
"IMG_4502.jpeg|top-left corner of shelf, above Santorini and to the left of Wiz-War|possibly 'Herbaceous' or similar, text partially cut off|cream/white box with floral yellow illustration, thin spine",
"IMG_4505.jpeg|Bottom area, left side near Mantis Falls, small figure/box partially visible||Small gold/tan colored object, possibly a game piece or small box, mostly obscured", "IMG_4505.jpeg|Bottom area, left side near Mantis Falls, small figure/box partially visible||Small gold/tan colored object, possibly a game piece or small box, mostly obscured",
"IMG_4505.jpeg|Top shelf, far left edge behind Cat Crimes, cut off by frame edge||Dark brown/maroon spine with faint gold decorative border, partially visible vertical box",
"IMG_4505.jpeg|Top shelf, far left edge, partially cut off by frame, to the left of Cat Crimes||Dark brown/maroon box spine with gold decorative swirl design, portion visible at left edge of image", "IMG_4505.jpeg|Top shelf, far left edge, partially cut off by frame, to the left of Cat Crimes||Dark brown/maroon box spine with gold decorative swirl design, portion visible at left edge of image",
"IMG_4506.jpeg|Middle area, below and left of Santorini, dark red/maroon box spine with small square icons||Dark reddish-brown spine with small colored square icons in a row, text not legible", "IMG_4506.jpeg|Middle area, below and left of Santorini, dark red/maroon box spine with small square icons||Dark reddish-brown spine with small colored square icons in a row, text not legible",
"IMG_4506.jpeg|Right side of image, behind Santorini box, dark box with small visible logo||Dark/black box with a small circular or square colored logo element, mostly obscured by foreground Santorini box",
"IMG_4506.jpeg|Right side, background behind Santorini box, partially visible dark box with small game piece icon||Dark background box with a small blue/white icon, mostly obscured by Santorini box in foreground", "IMG_4506.jpeg|Right side, background behind Santorini box, partially visible dark box with small game piece icon||Dark background box with a small blue/white icon, mostly obscured by Santorini box in foreground",
"IMG_4506.jpeg|Top shelf, above Santorini spine, partially visible box with patterned tiles/squares in yellow, blue, and cream tones||Patchwork/quilt-like pattern of colored squares (yellow, tan, blue) along the top edge of the box, style suggests a tile-based board game", "IMG_4506.jpeg|Top shelf, above Santorini spine, partially visible box with patterned tiles/squares in yellow, blue, and cream tones||Patchwork/quilt-like pattern of colored squares (yellow, tan, blue) along the top edge of the box, style suggests a tile-based board game",
"IMG_4506.jpeg|Top shelf, right side near blue corner box, small red 'X' symbols visible on light blue background|X X|Light blue box edge with red X pattern, possibly a tally or scoring game box", "IMG_4506.jpeg|Top shelf, right side near blue corner box, small red 'X' symbols visible on light blue background|X X|Light blue box edge with red X pattern, possibly a tally or scoring game box",
"IMG_4506.jpeg|Top shelf, spine visible above Santorini box, partially cut off at top of frame||Multicolored patchwork/tile pattern spine in yellow, tan, and blue tones with geometric designs; appears to be a board game box edge but no title text is legible",
"IMG_4506.jpeg|Upper right corner of image, above and right of Santorini spine, next to unidentified patterned box||Small red and blue box corner visible, mostly cut off by frame edge, no discernible text",
"IMG_4507.jpeg|Below 'LABYRINTH', left side of shelf, partially cut off at frame edge|DRAGO...|Green/dark box, only top edge visible, text cut off",
"IMG_4507.jpeg|Middle shelf area, partially hidden behind Labyrinth box and below Utter Nonsense, left side of the stack|Dragon...|Green/dark colored box spine, text cut off by adjacent boxes, only 'Dragon' prefix visible", "IMG_4507.jpeg|Middle shelf area, partially hidden behind Labyrinth box and below Utter Nonsense, left side of the stack|Dragon...|Green/dark colored box spine, text cut off by adjacent boxes, only 'Dragon' prefix visible",
"IMG_4507.jpeg|Top of stack, right of center, between 'a Gentle Rain' and 'SANToRINI'|PATCH / WORK (possibly)|Colorful patchwork quilt-style pattern on spine, small box",
"IMG_4507.jpeg|Upper right area near Santorini and a Gentle Rain, appears to be a narrow spine box|PATCH WORK or similar|Colorful patchwork/quilt-like pattern visible on spine, tan and multicolor squares design", "IMG_4507.jpeg|Upper right area near Santorini and a Gentle Rain, appears to be a narrow spine box|PATCH WORK or similar|Colorful patchwork/quilt-like pattern visible on spine, tan and multicolor squares design",
"IMG_4508.jpeg|Top shelf, center, small yellow tin sitting next to a black box near the stack of papers/magazines, above the Labyrinth box||Small yellow tin container, no visible text, could be a card game tin",
"IMG_4508.jpeg|Top shelf, center, yellow tin box sitting next to the Super Mario Checkers box||Small yellow tin, no visible title text, could be a card game tin", "IMG_4508.jpeg|Top shelf, center, yellow tin box sitting next to the Super Mario Checkers box||Small yellow tin, no visible title text, could be a card game tin",
"IMG_4508.jpeg|Top shelf, far left, behind the stack of papers/notebooks, next to the framed picture||Small black box with unreadable logo, mostly obscured by papers", "IMG_4508.jpeg|Top shelf, far left, behind the stack of papers/notebooks, next to the framed picture||Small black box with unreadable logo, mostly obscured by papers",
"IMG_4508.jpeg|Top shelf, far left, partially cut off by frame edge, next to a green-framed picture and above the Super Mario Checkers box||Teal/green colored box edge, mostly obscured, shape suggests a game box but no readable text",
"IMG_4508.jpeg|Top shelf, right of center, wooden box with metal clasp between the checkers box and the papers stack||Wooden box with brass hardware, no visible title, appears decorative or a game storage box", "IMG_4508.jpeg|Top shelf, right of center, wooden box with metal clasp between the checkers box and the papers stack||Wooden box with brass hardware, no visible title, appears decorative or a game storage box",
"IMG_4508.jpeg|Top shelf, right side, partially visible box behind the wooden box, between the stack of papers and the edge of the frame|SCRABBLE (partial letters visible)|Red and teal colored box edge, appears to be a Scrabble-branded box but mostly obscured", "IMG_4508.jpeg|Top shelf, right side, partially visible box behind the wooden box, between the stack of papers and the edge of the frame|SCRABBLE (partial letters visible)|Red and teal colored box edge, appears to be a Scrabble-branded box but mostly obscured",
"IMG_4511.jpeg|Middle shelf, behind Superfight boxes, green/black box with skeletal figure artwork - between Superfight stack and the Rook box|...CTHULHU|Dark green box, illustrated skeletal/robed figure, likely a Cthulhu-themed game (e.g., Cthulhu Fluxx or Cthulhu Gloom) but exact title obscured",
"IMG_4511.jpeg|Middle shelf, black box stacked below SUPERFIGHT, above ROOK; left side of shelf|...Go... Yourself|Black spine box, small white/colored text, similar size to Superfight box", "IMG_4511.jpeg|Middle shelf, black box stacked below SUPERFIGHT, above ROOK; left side of shelf|...Go... Yourself|Black spine box, small white/colored text, similar size to Superfight box",
"IMG_4511.jpeg|Middle shelf, black spine box stacked above Rook, to the left of the dice tray|...Yourself|Black box with orange/white text, only partial word 'Yourself' legible, title obscured by angle",
"IMG_4511.jpeg|Right edge of shelf, partially cut off by frame, next to Cthulhu box|HEX|Blue box with yellow/tan geometric pattern, only edge visible", "IMG_4511.jpeg|Right edge of shelf, partially cut off by frame, next to Cthulhu box|HEX|Blue box with yellow/tan geometric pattern, only edge visible",
"IMG_4511.jpeg|Top right corner of shelf, box mostly out of frame, above the Hex box|Drag...|Orange and blue box, only a fragment of text visible, likely a dragon-themed game title",
"IMG_4517.jpeg|Middle of shelf, between Pie Face! (left) and the blue box with bird artwork (right)|...Grenade...|Tall narrow box, dark red/brown color scheme, appears to have a hand grenade or bomb-like illustration", "IMG_4517.jpeg|Middle of shelf, between Pie Face! (left) and the blue box with bird artwork (right)|...Grenade...|Tall narrow box, dark red/brown color scheme, appears to have a hand grenade or bomb-like illustration",
"IMG_4517.jpeg|Middle of shelf, right of the grenade-themed box and left of Walk the Plank!|Bird ... Bird ...|Blue box with tall bird-like cartoon character illustration, small/narrow box shape", "IMG_4517.jpeg|Middle of shelf, right of the grenade-themed box and left of Walk the Plank!|Bird ... Bird ...|Blue box with tall bird-like cartoon character illustration, small/narrow box shape",
"IMG_4517.jpeg|Middle shelf area, second box from left, between 'Pie Face!' and 'Bird on Your Bread?!'|...renade... Nintendo?|Narrow tall box, red and tan coloring, partially obscured by shadow and angle, appears to have small figure illustration",
"IMG_4518.jpeg|Between Mage Wars Academy Core Set and Saberu Merry Men||Thin black spine, largely obscured, possibly a second small game box", "IMG_4518.jpeg|Between Mage Wars Academy Core Set and Saberu Merry Men||Thin black spine, largely obscured, possibly a second small game box",
"IMG_4519.jpeg|Far right of shelf, white spine box next to the two red 'G' logo boxes|Before I Kill You, Mister Bond... / Kill Dr. No Lucky|White worn spine with black text, possibly James Bond themed game, 'CHEAPEST GAMES' text also visible on spine which may be a store sticker rather than title", "IMG_4519.jpeg|Far right of shelf, white spine box next to the two red 'G' logo boxes|Before I Kill You, Mister Bond... / Kill Dr. No Lucky|White worn spine with black text, possibly James Bond themed game, 'CHEAPEST GAMES' text also visible on spine which may be a store sticker rather than title",
"IMG_4519.jpeg|Left shelf, red spine with black text between the cream D&D box and the red-orange boxes|THE BEST OF RA... (possibly 'RAMPHA' or similar)|Dark red spine with illustrated figure artwork, book-like thickness",
"IMG_4519.jpeg|Middle shelf area, two matching red boxes with orange circular 'G' logo, positioned between the white D&D boxes on the left and the white spined books on the right|G (stylized orange circular logo)|Two identical red boxes, narrow spine width, orange/yellow circular logo with letter G, taped/worn edges",
"IMG_4519.jpeg|Right-center of shelf, two matching red boxes standing between the D&D group on the left and the white 'Cheapest Games' spine on the right|Orange 'G' logo visible on both spines|Two identical red boxes with worn/taped edges, orange circular 'G' emblem, appears to be same game duplicated", "IMG_4519.jpeg|Right-center of shelf, two matching red boxes standing between the D&D group on the left and the white 'Cheapest Games' spine on the right|Orange 'G' logo visible on both spines|Two identical red boxes with worn/taped edges, orange circular 'G' emblem, appears to be same game duplicated",
"IMG_4520.jpeg|Middle shelf, between Carcassonne and Evolution: The Beginning||Plain wooden-colored vertical box, no visible text, appears to be a wood grain finish game box or insert", "IMG_4520.jpeg|Middle shelf, between Carcassonne and Evolution: The Beginning||Plain wooden-colored vertical box, no visible text, appears to be a wood grain finish game box or insert",
"IMG_4521.jpeg|Top edge of frame, above the Sleeping Gods box, partially cut off||Dark spines/boxes barely visible at top, too obscured by shadow and cropping to identify", "IMG_4521.jpeg|Top edge of frame, above the Sleeping Gods box, partially cut off||Dark spines/boxes barely visible at top, too obscured by shadow and cropping to identify",
"IMG_4521.jpeg|Top of shelf, above the Sleeping Gods box, partially cut off by frame edge||Dark spines of several boxes, indistinct due to shadow and angle, colors appear dark blue/black and tan",
"IMG_4527.jpeg|Left edge of shelf, partially visible spines to the left of the main game box, on the top shelf|01, T...|Appears to be book-like spines, red and dark covers, stacked vertically, likely not games but partially obscured", "IMG_4527.jpeg|Left edge of shelf, partially visible spines to the left of the main game box, on the top shelf|01, T...|Appears to be book-like spines, red and dark covers, stacked vertically, likely not games but partially obscured",
"IMG_4527.jpeg|Right edge of shelf, behind/beside the Red Dragon Inn box, partially cut off by frame|4, 3|Dark colored spine/box edge, numbers visible but title obscured",
"IMG_4528.jpeg|Top shelf, above Wiz-War box, partially obscured by rolled poster/tube on left side|SLUGFEST GAMES|Red and orange colored box with illustrated fantasy/comic style artwork, only top edge visible",
"IMG_4528.jpeg|Top shelf, back corner above the Wiz-War box; only the publisher logo area is visible, partially obscured by other items|SLUGFEST GAMES|Colorful red/orange box art, only top publisher banner visible, rest of box obscured", "IMG_4528.jpeg|Top shelf, back corner above the Wiz-War box; only the publisher logo area is visible, partially obscured by other items|SLUGFEST GAMES|Colorful red/orange box art, only top publisher banner visible, rest of box obscured",
"IMG_4529.jpeg|Below the 1000-piece puzzle box, spine showing armored warrior artwork, above the face-out Risk Lord of the Rings box|The Middle-earth|Dark battle scene with armored figures, likely same LOTR-themed game box viewed from spine angle", "IMG_4529.jpeg|Below the 1000-piece puzzle box, spine showing armored warrior artwork, above the face-out Risk Lord of the Rings box|The Middle-earth|Dark battle scene with armored figures, likely same LOTR-themed game box viewed from spine angle",
"IMG_4529.jpeg|Middle of stack between the puzzle box and Risk Lord of the Rings, tan/cream colored spine with Asian characters and blue castle icon|unreadable Asian characters, blue icon|Tan spine with small blue building/castle graphic and Asian script text", "IMG_4529.jpeg|Middle of stack between the puzzle box and Risk Lord of the Rings, tan/cream colored spine with Asian characters and blue castle icon|unreadable Asian characters, blue icon|Tan spine with small blue building/castle graphic and Asian script text",
"IMG_4529.jpeg|Top of stack, above the 1000-piece puzzle box, partially cut off at top of frame|V, 3|Dark spines with minimal visible text, one shows a circular 'V' logo, another shows number '3' on purple/dark background; too obscured to identify title",
"IMG_4529.jpeg|Top of stack, second partial box edge, above the puzzle box|0|White/light colored box edge with a circular icon, only corner visible", "IMG_4529.jpeg|Top of stack, second partial box edge, above the puzzle box|0|White/light colored box edge with a circular icon, only corner visible",
"IMG_4529.jpeg|Very top of stack, box behind/above the numbered fragments, purple/dark spine||Dark purple or navy spine, mostly obscured, edge of frame", "IMG_4529.jpeg|Very top of stack, box behind/above the numbered fragments, purple/dark spine||Dark purple or navy spine, mostly obscured, edge of frame",
"IMG_4529.jpeg|Very top of stack, partial box edge visible above the puzzle box|3|Dark colored spine/edge, only a sliver visible, number '3' printed in white", "IMG_4529.jpeg|Very top of stack, partial box edge visible above the puzzle box|3|Dark colored spine/edge, only a sliver visible, number '3' printed in white",
"IMG_4532.jpeg|Top right corner of frame, above Gloomhaven box, partially cut off|GLO...|Fiery orange/red background with dark textured lettering, appears to be another large box possibly related to Gloomhaven expansion",
"IMG_4532.jpeg|Top right corner of image, partially cut off, above and behind the Gloomhaven box|GLO... (possibly part of another title starting with GLO)|Dark box with fiery orange/red background and large white curved text, only edge visible", "IMG_4532.jpeg|Top right corner of image, partially cut off, above and behind the Gloomhaven box|GLO... (possibly part of another title starting with GLO)|Dark box with fiery orange/red background and large white curved text, only edge visible",
"IMG_4535.jpeg|Top shelf, far right edge of frame, next to the last visible Etherfields big box||Black box spine, partially cut off by frame edge, appears similar in size to adjacent Etherfields big boxes",
"IMG_4542.jpeg|Bottom right shelf area, dark spine next to unidentified train-themed box||Dark spine, illegible text, appears to be a board game box based on shape",
"IMG_4542.jpeg|Right side shelf, below CATAN, dark spine with red/white text|T...T / Ride (possible)|Dark red/maroon spine with train-like graphic element, narrow box spine",
"IMG_4542.jpeg|Right side shelf, below Catan box, next to unidentified black-spined game|T...C...|Teal/orange box with train-like graphic, partially obscured, could be a train-themed game", "IMG_4542.jpeg|Right side shelf, below Catan box, next to unidentified black-spined game|T...C...|Teal/orange box with train-like graphic, partially obscured, could be a train-themed game",
"IMG_4542.jpeg|Right side shelf, lower row near 'Catan' box||Dark spine with red accent, text too small/blurry to read", "IMG_4542.jpeg|Right side shelf, lower row near 'Catan' box||Dark spine with red accent, text too small/blurry to read",
"IMG_4542.jpeg|Top area, small red square box with white 'M' logo, near Dragon Land|M|Small red square box with large white letter M, possibly a classic board game logo",
"IMG_4542.jpeg|Top left corner of shelf, partially cut off by frame edge, above the Skip-Bo box|...O / From the Makers of...|Red box edge with white lettering, likely a card game box, mostly obscured",
"IMG_4542.jpeg|Top right shelf, partially hidden behind Dragon Land box|From the Makers of, M|Red box with a large stylized 'M' logo and small icons, likely a Mattel-published card game (possibly UNO), edge cut off by frame", "IMG_4542.jpeg|Top right shelf, partially hidden behind Dragon Land box|From the Makers of, M|Red box with a large stylized 'M' logo and small icons, likely a Mattel-published card game (possibly UNO), edge cut off by frame",
"IMG_4543.jpeg|Background, lower right area behind the hand holding Scrabble Slam, blurred and out of focus||Indistinct colorful box shapes, cannot make out title or artwork details due to heavy blur",
"IMG_4543.jpeg|Right edge of frame, middle row, next to the Scrabble Slam box|CAT...|Dark colored box spine, partially cut off at right edge of photo, red/dark background with light text",
"IMG_4543.jpeg|Top right corner of frame, behind and above the Scrabble Slam box, partially cut off by frame edge|Dogs Kitchen|Small box or book with illustrated cartoon-style cover, colorful, appears to feature a dog character; too small/blurry to confirm if it's a game",
"IMG_4543.jpeg|right edge of image, behind Scrabble Slam box|CAT|Yellow/orange box edge visible, partial text 'CAT' in bold letters, rest obscured by foreground box", "IMG_4543.jpeg|right edge of image, behind Scrabble Slam box|CAT|Yellow/orange box edge visible, partial text 'CAT' in bold letters, rest obscured by foreground box",
"IMG_4543.jpeg|top right corner of image, above and behind the main Scrabble Slam box|Dogs Kitchen|Small blue/purple box partially cut off at top edge, illustrated cartoon-style artwork", "IMG_4543.jpeg|top right corner of image, above and behind the main Scrabble Slam box|Dogs Kitchen|Small blue/purple box partially cut off at top edge, illustrated cartoon-style artwork",
"IMG_4544.jpeg|Background shelf behind the Cat Stax box, blurred and out of focus, appears to be a row of game/book boxes||Multiple colorful spines/boxes including orange, white, and dark tones, too blurry to make out any text", "IMG_4544.jpeg|Background shelf behind the Cat Stax box, blurred and out of focus, appears to be a row of game/book boxes||Multiple colorful spines/boxes including orange, white, and dark tones, too blurry to make out any text",
"IMG_4544.jpeg|Background shelf behind the Cat Stax box, top of frame, multiple stacked boxes with no clearly identifiable neighbors||Blurry stack of colorful boxes (orange, white, dark tones) in background, out of focus, too indistinct to read any text",
"IMG_4545.jpeg|Bottom right corner of image, yellowish box edge visible||Yellow/tan colored box spine, text not legible due to angle and cropping",
"IMG_4545.jpeg|Lower left shelf area, dark colored box partially visible behind hand holding Pocket Farkel||Dark/black box edge visible, no legible text",
"IMG_4545.jpeg|Top right shelf, box with 'Ravensburger' text visible, title obscured|Ravensburger|Colorful box, brand name visible but game title not legible",
"IMG_4545.jpeg|Top shelf, far right edge, partially cut off by frame border|Ravens...|Yellow/orange box with small logo resembling a bird, likely Ravensburger branding but game title not legible", "IMG_4545.jpeg|Top shelf, far right edge, partially cut off by frame border|Ravens...|Yellow/orange box with small logo resembling a bird, likely Ravensburger branding but game title not legible",
"IMG_4545.jpeg|Top shelf, right side, above the 'Ravensburger' labeled box, partial title visible at top edge of frame|DRAGON ...and|Orange/red box with dragon and fantasy artwork, cut off at top of image",
"IMG_4545.jpeg|Top shelf, upper right corner, above and behind the Pocket Farkel tin, to the right of 'Bugs in the Kitchen'|DRAGON, land|Colorful fantasy-style box art, orange/red hues, dragon imagery visible, title cut off at top edge of frame", "IMG_4545.jpeg|Top shelf, upper right corner, above and behind the Pocket Farkel tin, to the right of 'Bugs in the Kitchen'|DRAGON, land|Colorful fantasy-style box art, orange/red hues, dragon imagery visible, title cut off at top edge of frame",
"IMG_4547.jpeg|Top area, right of the maze-like box, small orange/red box||Small red/orange box, text illegible, largely obscured", "IMG_4547.jpeg|Top area, right of the maze-like box, small orange/red box||Small red/orange box, text illegible, largely obscured",
"IMG_4547.jpeg|Top center-right, small red/orange box near top edge||Small reddish box with indistinct artwork, mostly cut off at top frame", "IMG_4547.jpeg|Top center-right, small red/orange box near top edge||Small reddish box with indistinct artwork, mostly cut off at top frame",
"IMG_4547.jpeg|Top left area, next to the '...AZE' box, second spine from left|UTTLES or similar|Bright blue box with light green accents, small toy-like image visible on top",
"IMG_4547.jpeg|Top left corner of image, partially visible behind main box|AZE|Blue and green box, appears to be a puzzle or maze-themed game, mostly cut off by frame edge", "IMG_4547.jpeg|Top left corner of image, partially visible behind main box|AZE|Blue and green box, appears to be a puzzle or maze-themed game, mostly cut off by frame edge",
"IMG_4547.jpeg|Top left corner of shelf, above the main held box, leftmost visible spine|...AZE|Blue and green box, partial text visible, cut off at frame edge",
"IMG_4547.jpeg|Top right area, red-orange box between the dark box and top edge|Roll Pla...|Red/orange box with illustrated artwork, text partially cut off",
"IMG_4547.jpeg|Top right corner of image|Dark T...|Dark blue/black box with white text, appears to be 'Dark T' something, possibly 'Dark Tower' or similar, cut off at frame edge", "IMG_4547.jpeg|Top right corner of image|Dark T...|Dark blue/black box with white text, appears to be 'Dark T' something, possibly 'Dark Tower' or similar, cut off at frame edge",
"IMG_4547.jpeg|Top right corner of shelf, above and to the right of the main held box|Dark Terr... or Dark Territory|Dark navy/black box with red and white text, partially obscured by other boxes",
"IMG_4547.jpeg|Top right, near 'Dark T' box||Colorful box with red/orange tones, illustration unclear, partially obscured by other boxes", "IMG_4547.jpeg|Top right, near 'Dark T' box||Colorful box with red/orange tones, illustration unclear, partially obscured by other boxes",
"IMG_4552.jpeg|Left edge of frame, vertical spine, sharply cropped|Giv... / Ci...|Dark spine with white lettering, partially visible artwork, cropped by frame edge", "IMG_4552.jpeg|Left edge of frame, vertical spine, sharply cropped|Giv... / Ci...|Dark spine with white lettering, partially visible artwork, cropped by frame edge",
"IMG_4552.jpeg|Top left corner of frame, behind and above the Dark Cults package, no clearly identified games adjacent due to cropping|'Giv...'|Dark colored box with white/red text fragment, likely a card or board game box mostly out of frame", "IMG_4552.jpeg|Top left corner of frame, behind and above the Dark Cults package, no clearly identified games adjacent due to cropping|'Giv...'|Dark colored box with white/red text fragment, likely a card or board game box mostly out of frame",
@@ -63,5 +108,7 @@
"IMG_4556.jpeg|Top right corner, quilted patchwork-pattern box, right of the box with 'TCH ORK' text||Multicolored quilt/patchwork square pattern box, title not legible, partially cut at frame edge", "IMG_4556.jpeg|Top right corner, quilted patchwork-pattern box, right of the box with 'TCH ORK' text||Multicolored quilt/patchwork square pattern box, title not legible, partially cut at frame edge",
"IMG_4556.jpeg|Top row, green textured spine with cartoon dinosaur, left of 'Ravensbu...' spine||Green speckled/scaly texture spine with small cartoon dinosaur illustration", "IMG_4556.jpeg|Top row, green textured spine with cartoon dinosaur, left of 'Ravensbu...' spine||Green speckled/scaly texture spine with small cartoon dinosaur illustration",
"IMG_4556.jpeg|Top row, second shelf, spine reading 'Ravensbu...' between an unidentified dark box and green dinosaur-patterned spine|Ravensbu...|White spine with blue text, likely Ravensburger logo/publisher rather than title, top cut off", "IMG_4556.jpeg|Top row, second shelf, spine reading 'Ravensbu...' between an unidentified dark box and green dinosaur-patterned spine|Ravensbu...|White spine with blue text, likely Ravensburger logo/publisher rather than title, top cut off",
"IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items" "IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items",
"IMG_4573.jpeg|Bottom right of cover art, near 'Blackboard' box||Small dark box with logo icon, title illegible due to size and angle",
"IMG_4573.jpeg|Upper right area of the book cover art, stacked above 'ACTION CASTLE I' box|AC... (partially obscured by hand/fingers)|Red/orange box spine, appears to be part of a series with other 'Action Castle' related titles"
] ]
+117
View File
@@ -0,0 +1,117 @@
action,bgg_id,collid,name,version_id,second_copy,status,timestamp,error
add,334011,,A Gentle Rain,701315,,failed,2026-08-06T02:15:34+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""A\ Gentle\ Rain"", re.IGNORECASE)) to be visible"
add,589,,Wiz-War,27352,,failed,2026-08-06T02:20:33+00:00,"Error: Locator.check: Error: strict mode violation: get_by_role(""dialog"").get_by_label(""Own"") resolved to 2 elements: 1) <input type=""checkbox"" ng-model=""item.status.own"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Own"", exact=True) 2) <input type=""checkbox"" ng-model=""item.status.prevowned"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Prev. Owned"") Call log: - waiting for get_by_role(""dialog"").get_by_label(""Own"")"
add,334011,,A Gentle Rain,701315,,added,2026-08-06T02:21:32+00:00,
add,125921,,Catan: Junior,476263,,added,2026-08-06T02:24:22+00:00,
add,31260,,Agricola,297589,,added,2026-08-06T02:24:28+00:00,
add,181304,,Mysterium,536289,,added_no_version,2026-08-06T02:24:34+00:00,version 'English edition 2018-2' not in picker; added without version
add,312786,,Poetry for Neanderthals,514050,,added_no_version,2026-08-06T02:24:40+00:00,version 'English edition 2020' not in picker; added without version
add,273240,,The Red Dragon Inn Smorgasbox,446069,,added,2026-08-06T02:24:46+00:00,
add,419687,,Munchkin Big Box,711400,,failed,2026-08-06T02:38:12+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 55 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
add,252153,,Tang Garden,404702,,failed,2026-08-06T02:38:48+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 56 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:39:23+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 56 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
add,589,,Wiz-War,27352,,added,2026-08-06T02:45:10+00:00,
add,419687,,Munchkin Big Box,711400,,added,2026-08-06T02:45:16+00:00,
add,252153,,Tang Garden,404702,,added,2026-08-06T02:45:23+00:00,
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:45:58+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""li:has(.summary-item-thumbnail)"").filter(has_text=re.compile(r""Sleeping\ Gods\ \(English\ Gamefound\ edition\)\ \(2023\)"", re.IGNORECASE)).first"
add,283766,,Sleeping Gods: Tides of Ruin,463140,,added,2026-08-06T02:46:07+00:00,
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:49:44+00:00,RuntimeError: 'Sleeping Gods (English Gamefound edition) (2023)' was listed a moment ago but vanished on the second pass — retryable
add,280794,,Etherfields,458300,,added,2026-08-06T02:49:53+00:00,
add,380837,,Botany,650545,,added,2026-08-06T02:50:00+00:00,
add,393672,,Gloomhaven: Buttons & Bugs,669782,,failed,2026-08-06T02:50:07+00:00,RuntimeError: 'Gloomhaven: Buttons & Bugs (English edition) (2024)' was listed a moment ago but vanished on the second pass — retryable
add,14535,,SPANC: Space Pirate Amazon Ninja Catgirls,29075,,added,2026-08-06T02:50:15+00:00,
add,255984,,Sleeping Gods,701034,,added,2026-08-06T02:54:05+00:00,
add,393672,,Gloomhaven: Buttons & Bugs,669782,,added,2026-08-06T02:54:12+00:00,
add,9666,,Greed Quest,215137,,added,2026-08-06T02:54:19+00:00,
add,40508,,Scrabble Slam!,644326,,added,2026-08-06T02:54:27+00:00,
add,3181,,Farkle,87931,,added,2026-08-06T02:54:36+00:00,
add,193485,,Dastardly Dirigibles,300446,,added,2026-08-06T02:54:54+00:00,
add,226065,,Sheriff of Nottingham: Merry Men,353389,,added,2026-08-06T02:55:01+00:00,
add,143884,,Machi Koro,453051,,added,2026-08-06T02:55:09+00:00,
add,358880,,Etherfields: Kittenburg Expansion,603694,,added,2026-08-06T02:55:15+00:00,
add,432858,,Artistry,734890,,added,2026-08-06T02:55:21+00:00,
add,417060,,Botany: Tantalizing Trees,706506,,added,2026-08-06T02:55:27+00:00,
add,417064,,Botany: Perilous Perfumes,706510,,added,2026-08-06T02:55:33+00:00,
add,161928,,Utter Nonsense,,,added,2026-08-06T02:55:40+00:00,
add,50381,,Cards Against Humanity,,,added,2026-08-06T02:55:44+00:00,
add,1269,,Skip-Bo,,,added,2026-08-06T02:55:50+00:00,
add,202982,,Scrawl,,,added,2026-08-06T02:55:56+00:00,
add,195314,,Herbaceous,,,added,2026-08-06T02:56:02+00:00,
add,320202,,Dragon Land,,,added,2026-08-06T02:56:07+00:00,
add,2452,,Jenga,,,added,2026-08-06T02:56:12+00:00,
add,42063,,Code Master,,,added,2026-08-06T02:56:18+00:00,
add,340420,,Throw Throw Avocado,,,added,2026-08-06T02:56:23+00:00,
add,1260,,Rook,,,added,2026-08-06T02:56:28+00:00,
add,116,,Guillotine,,,added,2026-08-06T02:56:34+00:00,
add,169124,,Flick 'em Up!,,,added,2026-08-06T02:56:40+00:00,
add,20866,,The Rocky Horror Trivia Game,,,added,2026-08-06T02:56:45+00:00,
add,269564,,Captain Marvel: Secret Skrulls,,,added,2026-08-06T02:57:37+00:00,
add,18755,,Pie Face!,,,added,2026-08-06T02:57:41+00:00,
add,140509,,Dungeons & Dragons,,,failed,2026-08-06T02:58:01+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""Dungeons\ \&\ Dragons"", re.IGNORECASE)) to be visible"
add,201248,,Evolution: The Beginning,,,added,2026-08-06T02:58:05+00:00,
add,362205,,Sleeping Gods: Primeval Peril,,,added,2026-08-06T02:58:11+00:00,
add,453845,,Murder at the Manor,,,added,2026-08-06T02:58:16+00:00,
add,329230,,Bluffaneer,,,added,2026-08-06T02:58:20+00:00,
add,358320,,Sleeping Gods: Distant Skies,,,added,2026-08-06T02:58:26+00:00,
add,389113,,Rivals,,,added,2026-08-06T02:58:31+00:00,
add,324937,,Wiz-War (9th Edition),,,added,2026-08-06T02:58:35+00:00,
add,295770,,Frosthaven,,,added,2026-08-06T02:58:40+00:00,
add,174430,,Gloomhaven,,,added,2026-08-06T02:58:46+00:00,
add,442312,,Deadly Doodles,,,added,2026-08-06T02:58:51+00:00,
add,332398,,Everdell: The Complete Collection,,,added,2026-08-06T02:58:56+00:00,
add,334065,,Verdant,,,added,2026-08-06T02:59:01+00:00,
add,283155,,Calico,,,added,2026-08-06T02:59:06+00:00,
add,291457,,Gloomhaven: Jaws of the Lion,,,added,2026-08-06T02:59:12+00:00,
add,150925,,Hold Your Breath!,,,added,2026-08-06T02:59:18+00:00,
add,2980,,The Simpsons Trivia Game,,,added,2026-08-06T02:59:23+00:00,
add,1197,,Blink,,,added,2026-08-06T02:59:29+00:00,
add,140509,,Dungeons & Dragons,,,failed,2026-08-06T03:05:04+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""Dungeons\ \&\ Dragons"", re.IGNORECASE)) to be visible"
add,352819,,The Office Trivia Game,,,added,2026-08-06T03:05:09+00:00,
add,163412,,Patchwork,,,added,2026-08-06T03:05:14+00:00,
add,291847,,Mantis Falls,,,added,2026-08-06T03:05:19+00:00,
add,2058,,Civilization: West Extension Map,,,added,2026-08-06T03:05:24+00:00,
add,452833,,Artistry: Delightful Doorways Mini Expansion,,,added,2026-08-06T03:05:31+00:00,
update,240,53429559,Britannia,24621,,failed,2026-08-06T03:06:04+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Britannia"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
update,71,53429530,Civilization,24006,,failed,2026-08-06T03:06:39+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Civilization"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
update,177,53429542,Advanced Civilization,24972,,failed,2026-08-06T03:07:12+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Advanced\ Civilization"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
update,240,53429559,Britannia,24621,,failed,2026-08-06T03:08:42+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Britannia"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
update,71,53429530,Civilization,24006,,failed,2026-08-06T03:09:17+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Civilization"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
update,177,53429542,Advanced Civilization,24972,,failed,2026-08-06T03:09:50+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Advanced\ Civilization"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
update,240,53429559,Britannia,24621,,updated,2026-08-06T03:17:05+00:00,
update,71,53429530,Civilization,24006,,failed,2026-08-06T03:17:11+00:00,"Error: Page.goto: net::ERR_NETWORK_CHANGED at https://boardgamegeek.com/collection/user/***?objectid=71&own=1 Call log: - navigating to ""https://boardgamegeek.com/collection/user/***?objectid=71&own=1"", waiting until ""domcontentloaded"""
update,177,53429542,Advanced Civilization,24972,,updated,2026-08-06T03:17:17+00:00,
update,71,53429530,Civilization,24006,,updated,2026-08-06T03:17:36+00:00,
update,3090,53429504,Doctor Who: The Game of Time & Space,25782,,updated,2026-08-06T03:17:55+00:00,
update,3585,53429482,Sorcerer: The Game of Magical Conflict,211154,,updated,2026-08-06T03:18:02+00:00,
update,2524,53429367,StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century,219137,,updated,2026-08-06T03:18:06+00:00,
update,224,53429816,History of the World,20727,,updated,2026-08-06T03:18:11+00:00,
update,9209,53429636,Ticket to Ride,294188,,updated,2026-08-06T03:18:17+00:00,
update,13,53430595,Catan,347121,,updated,2026-08-06T03:18:24+00:00,
update,1219,53430485,Labyrinth,488621,,updated,2026-08-06T03:18:32+00:00,
update,137909,53429649,Bugs in the Kitchen,216474,,updated,2026-08-06T03:18:38+00:00,
update,27710,53430142,Catan Dice Game,606657,,updated,2026-08-06T03:18:44+00:00,
update,169784,53429953,Castle Panic: The Dark Titan,260454,,updated,2026-08-06T03:18:49+00:00,
update,147370,53429666,Robot Turtles,231664,,updated,2026-08-06T03:18:55+00:00,
update,43443,53430559,Castle Panic,103068,,updated,2026-08-06T03:19:02+00:00,
update,24310,53429632,The Red Dragon Inn,374258,,updated,2026-08-06T03:19:06+00:00,
update,39856,53429748,Dixit,296529,,updated,2026-08-06T03:19:11+00:00,
update,35505,53430257,Walk the Plank!,781669,,updated,2026-08-06T03:19:15+00:00,
update,164,53430020,"Before I Kill You, Mister Bond",28465,,updated,2026-08-06T03:19:20+00:00,
update,181304,148198097,Mysterium,536289,,updated,2026-08-06T03:19:27+00:00,
update,65244,53429887,Forbidden Island,31208,,updated,2026-08-06T03:19:33+00:00,
update,95386,53429908,Tempurra,290572,,updated,2026-08-06T03:19:41+00:00,
update,258,53430200,Fluxx,20840,,updated,2026-08-06T03:19:47+00:00,
update,312786,148198100,Poetry for Neanderthals,514050,,updated,2026-08-06T03:19:52+00:00,
update,188614,53430332,Simon's Cat Card Game,779792,,updated,2026-08-06T03:19:57+00:00,
update,4324,53429850,Risk: The Lord of the Rings,26859,,updated,2026-08-06T03:20:01+00:00,
update,1927,53429933,Munchkin,28144,,updated,2026-08-06T03:20:06+00:00,
update,1784,53430007,Dark Cults,110926,,updated,2026-08-06T03:20:11+00:00,
update,176,53429986,Give Me the Brain!,27673,,updated,2026-08-06T03:20:16+00:00,
update,257,53430040,Kill Doctor Lucky,520364,,updated,2026-08-06T03:20:21+00:00,
update,153999,53612880,"...and then, we held hands.",280845,,updated,2026-08-06T03:20:26+00:00,
update,166976,53612866,Consentacle,410382,,updated,2026-08-06T03:20:32+00:00,
update,231302,53430652,The Cat Game,361880,,updated,2026-08-06T03:20:36+00:00,
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,
1 action bgg_id collid name version_id second_copy status timestamp error
2 add 334011 A Gentle Rain 701315 failed 2026-08-06T02:15:34+00:00 TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role("dialog").get_by_role("heading", name=re.compile(r"A\ Gentle\ Rain", re.IGNORECASE)) to be visible
3 add 589 Wiz-War 27352 failed 2026-08-06T02:20:33+00:00 Error: Locator.check: Error: strict mode violation: get_by_role("dialog").get_by_label("Own") resolved to 2 elements: 1) <input type="checkbox" ng-model="item.status.own" class="ng-pristine ng-untouched ng-valid ng-empty"/> aka get_by_role("checkbox", name="Own", exact=True) 2) <input type="checkbox" ng-model="item.status.prevowned" class="ng-pristine ng-untouched ng-valid ng-empty"/> aka get_by_role("checkbox", name="Prev. Owned") Call log: - waiting for get_by_role("dialog").get_by_label("Own")
4 add 334011 A Gentle Rain 701315 added 2026-08-06T02:21:32+00:00
5 add 125921 Catan: Junior 476263 added 2026-08-06T02:24:22+00:00
6 add 31260 Agricola 297589 added 2026-08-06T02:24:28+00:00
7 add 181304 Mysterium 536289 added_no_version 2026-08-06T02:24:34+00:00 version 'English edition 2018-2' not in picker; added without version
8 add 312786 Poetry for Neanderthals 514050 added_no_version 2026-08-06T02:24:40+00:00 version 'English edition 2020' not in picker; added without version
9 add 273240 The Red Dragon Inn Smorgasbox 446069 added 2026-08-06T02:24:46+00:00
10 add 419687 Munchkin Big Box 711400 failed 2026-08-06T02:38:12+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("dialog").locator("ul.pagination a[title=\"First Page\"]").first - locator resolved to <a href="" role="menuitem" title="First Page" class="pagination-pager" ng-click="selectPage(1)"> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 55 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms
11 add 252153 Tang Garden 404702 failed 2026-08-06T02:38:48+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("dialog").locator("ul.pagination a[title=\"First Page\"]").first - locator resolved to <a href="" role="menuitem" title="First Page" class="pagination-pager" ng-click="selectPage(1)"> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 56 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms
12 add 255984 Sleeping Gods 701034 failed 2026-08-06T02:39:23+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("dialog").locator("ul.pagination a[title=\"First Page\"]").first - locator resolved to <a href="" role="menuitem" title="First Page" class="pagination-pager" ng-click="selectPage(1)"> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 56 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms
13 add 589 Wiz-War 27352 added 2026-08-06T02:45:10+00:00
14 add 419687 Munchkin Big Box 711400 added 2026-08-06T02:45:16+00:00
15 add 252153 Tang Garden 404702 added 2026-08-06T02:45:23+00:00
16 add 255984 Sleeping Gods 701034 failed 2026-08-06T02:45:58+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("dialog").locator("li:has(.summary-item-thumbnail)").filter(has_text=re.compile(r"Sleeping\ Gods\ \(English\ Gamefound\ edition\)\ \(2023\)", re.IGNORECASE)).first
17 add 283766 Sleeping Gods: Tides of Ruin 463140 added 2026-08-06T02:46:07+00:00
18 add 255984 Sleeping Gods 701034 failed 2026-08-06T02:49:44+00:00 RuntimeError: 'Sleeping Gods (English Gamefound edition) (2023)' was listed a moment ago but vanished on the second pass — retryable
19 add 280794 Etherfields 458300 added 2026-08-06T02:49:53+00:00
20 add 380837 Botany 650545 added 2026-08-06T02:50:00+00:00
21 add 393672 Gloomhaven: Buttons & Bugs 669782 failed 2026-08-06T02:50:07+00:00 RuntimeError: 'Gloomhaven: Buttons & Bugs (English edition) (2024)' was listed a moment ago but vanished on the second pass — retryable
22 add 14535 SPANC: Space Pirate Amazon Ninja Catgirls 29075 added 2026-08-06T02:50:15+00:00
23 add 255984 Sleeping Gods 701034 added 2026-08-06T02:54:05+00:00
24 add 393672 Gloomhaven: Buttons & Bugs 669782 added 2026-08-06T02:54:12+00:00
25 add 9666 Greed Quest 215137 added 2026-08-06T02:54:19+00:00
26 add 40508 Scrabble Slam! 644326 added 2026-08-06T02:54:27+00:00
27 add 3181 Farkle 87931 added 2026-08-06T02:54:36+00:00
28 add 193485 Dastardly Dirigibles 300446 added 2026-08-06T02:54:54+00:00
29 add 226065 Sheriff of Nottingham: Merry Men 353389 added 2026-08-06T02:55:01+00:00
30 add 143884 Machi Koro 453051 added 2026-08-06T02:55:09+00:00
31 add 358880 Etherfields: Kittenburg Expansion 603694 added 2026-08-06T02:55:15+00:00
32 add 432858 Artistry 734890 added 2026-08-06T02:55:21+00:00
33 add 417060 Botany: Tantalizing Trees 706506 added 2026-08-06T02:55:27+00:00
34 add 417064 Botany: Perilous Perfumes 706510 added 2026-08-06T02:55:33+00:00
35 add 161928 Utter Nonsense added 2026-08-06T02:55:40+00:00
36 add 50381 Cards Against Humanity added 2026-08-06T02:55:44+00:00
37 add 1269 Skip-Bo added 2026-08-06T02:55:50+00:00
38 add 202982 Scrawl added 2026-08-06T02:55:56+00:00
39 add 195314 Herbaceous added 2026-08-06T02:56:02+00:00
40 add 320202 Dragon Land added 2026-08-06T02:56:07+00:00
41 add 2452 Jenga added 2026-08-06T02:56:12+00:00
42 add 42063 Code Master added 2026-08-06T02:56:18+00:00
43 add 340420 Throw Throw Avocado added 2026-08-06T02:56:23+00:00
44 add 1260 Rook added 2026-08-06T02:56:28+00:00
45 add 116 Guillotine added 2026-08-06T02:56:34+00:00
46 add 169124 Flick 'em Up! added 2026-08-06T02:56:40+00:00
47 add 20866 The Rocky Horror Trivia Game added 2026-08-06T02:56:45+00:00
48 add 269564 Captain Marvel: Secret Skrulls added 2026-08-06T02:57:37+00:00
49 add 18755 Pie Face! added 2026-08-06T02:57:41+00:00
50 add 140509 Dungeons & Dragons failed 2026-08-06T02:58:01+00:00 TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role("dialog").get_by_role("heading", name=re.compile(r"Dungeons\ \&\ Dragons", re.IGNORECASE)) to be visible
51 add 201248 Evolution: The Beginning added 2026-08-06T02:58:05+00:00
52 add 362205 Sleeping Gods: Primeval Peril added 2026-08-06T02:58:11+00:00
53 add 453845 Murder at the Manor added 2026-08-06T02:58:16+00:00
54 add 329230 Bluffaneer added 2026-08-06T02:58:20+00:00
55 add 358320 Sleeping Gods: Distant Skies added 2026-08-06T02:58:26+00:00
56 add 389113 Rivals added 2026-08-06T02:58:31+00:00
57 add 324937 Wiz-War (9th Edition) added 2026-08-06T02:58:35+00:00
58 add 295770 Frosthaven added 2026-08-06T02:58:40+00:00
59 add 174430 Gloomhaven added 2026-08-06T02:58:46+00:00
60 add 442312 Deadly Doodles added 2026-08-06T02:58:51+00:00
61 add 332398 Everdell: The Complete Collection added 2026-08-06T02:58:56+00:00
62 add 334065 Verdant added 2026-08-06T02:59:01+00:00
63 add 283155 Calico added 2026-08-06T02:59:06+00:00
64 add 291457 Gloomhaven: Jaws of the Lion added 2026-08-06T02:59:12+00:00
65 add 150925 Hold Your Breath! added 2026-08-06T02:59:18+00:00
66 add 2980 The Simpsons Trivia Game added 2026-08-06T02:59:23+00:00
67 add 1197 Blink added 2026-08-06T02:59:29+00:00
68 add 140509 Dungeons & Dragons failed 2026-08-06T03:05:04+00:00 TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role("dialog").get_by_role("heading", name=re.compile(r"Dungeons\ \&\ Dragons", re.IGNORECASE)) to be visible
69 add 352819 The Office Trivia Game added 2026-08-06T03:05:09+00:00
70 add 163412 Patchwork added 2026-08-06T03:05:14+00:00
71 add 291847 Mantis Falls added 2026-08-06T03:05:19+00:00
72 add 2058 Civilization: West Extension Map added 2026-08-06T03:05:24+00:00
73 add 452833 Artistry: Delightful Doorways Mini Expansion added 2026-08-06T03:05:31+00:00
74 update 240 53429559 Britannia 24621 failed 2026-08-06T03:06:04+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("row").filter(has_text=re.compile(r"Britannia", re.IGNORECASE)).first.get_by_role("link", name=re.compile(r"own", re.IGNORECASE)).first
75 update 71 53429530 Civilization 24006 failed 2026-08-06T03:06:39+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("row").filter(has_text=re.compile(r"Civilization", re.IGNORECASE)).first.get_by_role("link", name=re.compile(r"own", re.IGNORECASE)).first
76 update 177 53429542 Advanced Civilization 24972 failed 2026-08-06T03:07:12+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("row").filter(has_text=re.compile(r"Advanced\ Civilization", re.IGNORECASE)).first.get_by_role("link", name=re.compile(r"own", re.IGNORECASE)).first
77 update 240 53429559 Britannia 24621 failed 2026-08-06T03:08:42+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("row").filter(has_text=re.compile(r"Britannia", re.IGNORECASE)).first.get_by_role("link", name=re.compile(r"own", re.IGNORECASE)).first
78 update 71 53429530 Civilization 24006 failed 2026-08-06T03:09:17+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("row").filter(has_text=re.compile(r"Civilization", re.IGNORECASE)).first.get_by_role("link", name=re.compile(r"own", re.IGNORECASE)).first
79 update 177 53429542 Advanced Civilization 24972 failed 2026-08-06T03:09:50+00:00 TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role("row").filter(has_text=re.compile(r"Advanced\ Civilization", re.IGNORECASE)).first.get_by_role("link", name=re.compile(r"own", re.IGNORECASE)).first
80 update 240 53429559 Britannia 24621 updated 2026-08-06T03:17:05+00:00
81 update 71 53429530 Civilization 24006 failed 2026-08-06T03:17:11+00:00 Error: Page.goto: net::ERR_NETWORK_CHANGED at https://boardgamegeek.com/collection/user/***?objectid=71&own=1 Call log: - navigating to "https://boardgamegeek.com/collection/user/***?objectid=71&own=1", waiting until "domcontentloaded"
82 update 177 53429542 Advanced Civilization 24972 updated 2026-08-06T03:17:17+00:00
83 update 71 53429530 Civilization 24006 updated 2026-08-06T03:17:36+00:00
84 update 3090 53429504 Doctor Who: The Game of Time & Space 25782 updated 2026-08-06T03:17:55+00:00
85 update 3585 53429482 Sorcerer: The Game of Magical Conflict 211154 updated 2026-08-06T03:18:02+00:00
86 update 2524 53429367 StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century 219137 updated 2026-08-06T03:18:06+00:00
87 update 224 53429816 History of the World 20727 updated 2026-08-06T03:18:11+00:00
88 update 9209 53429636 Ticket to Ride 294188 updated 2026-08-06T03:18:17+00:00
89 update 13 53430595 Catan 347121 updated 2026-08-06T03:18:24+00:00
90 update 1219 53430485 Labyrinth 488621 updated 2026-08-06T03:18:32+00:00
91 update 137909 53429649 Bugs in the Kitchen 216474 updated 2026-08-06T03:18:38+00:00
92 update 27710 53430142 Catan Dice Game 606657 updated 2026-08-06T03:18:44+00:00
93 update 169784 53429953 Castle Panic: The Dark Titan 260454 updated 2026-08-06T03:18:49+00:00
94 update 147370 53429666 Robot Turtles 231664 updated 2026-08-06T03:18:55+00:00
95 update 43443 53430559 Castle Panic 103068 updated 2026-08-06T03:19:02+00:00
96 update 24310 53429632 The Red Dragon Inn 374258 updated 2026-08-06T03:19:06+00:00
97 update 39856 53429748 Dixit 296529 updated 2026-08-06T03:19:11+00:00
98 update 35505 53430257 Walk the Plank! 781669 updated 2026-08-06T03:19:15+00:00
99 update 164 53430020 Before I Kill You, Mister Bond 28465 updated 2026-08-06T03:19:20+00:00
100 update 181304 148198097 Mysterium 536289 updated 2026-08-06T03:19:27+00:00
101 update 65244 53429887 Forbidden Island 31208 updated 2026-08-06T03:19:33+00:00
102 update 95386 53429908 Tempurra 290572 updated 2026-08-06T03:19:41+00:00
103 update 258 53430200 Fluxx 20840 updated 2026-08-06T03:19:47+00:00
104 update 312786 148198100 Poetry for Neanderthals 514050 updated 2026-08-06T03:19:52+00:00
105 update 188614 53430332 Simon's Cat Card Game 779792 updated 2026-08-06T03:19:57+00:00
106 update 4324 53429850 Risk: The Lord of the Rings 26859 updated 2026-08-06T03:20:01+00:00
107 update 1927 53429933 Munchkin 28144 updated 2026-08-06T03:20:06+00:00
108 update 1784 53430007 Dark Cults 110926 updated 2026-08-06T03:20:11+00:00
109 update 176 53429986 Give Me the Brain! 27673 updated 2026-08-06T03:20:16+00:00
110 update 257 53430040 Kill Doctor Lucky 520364 updated 2026-08-06T03:20:21+00:00
111 update 153999 53612880 ...and then, we held hands. 280845 updated 2026-08-06T03:20:26+00:00
112 update 166976 53612866 Consentacle 410382 updated 2026-08-06T03:20:32+00:00
113 update 231302 53430652 The Cat Game 361880 updated 2026-08-06T03:20:36+00:00
114 update 40692 53430517 Small World 294178 updated 2026-08-06T03:20:45+00:00
115 update 172503 53429718 Mage Wars Academy 265107 updated 2026-08-06T03:20:49+00:00
116 update 104710 53429642 Wiz-War (Eighth Edition) 117685 updated 2026-08-06T03:20:53+00:00
117 update 1339 53430097 Dungeon! 34390 updated 2026-08-06T04:01:15+00:00
+86
View File
@@ -100,3 +100,89 @@ the existing one).
page but did not overlay the form in the probe. page but did not overlay the form in the probe.
- Logged-in detection heuristic (unverified): the header shows a "Sign In" - Logged-in detection heuristic (unverified): the header shows a "Sign In"
link only when logged out. link only when logged out.
## Verified against the live site (2026-08-06, first real uploads)
The add flow works end to end; every failure on the way was in code the
earlier walkthrough had marked *verified*, and the parts marked
*unverified* were mostly right. Corrections:
- **Sign In is an `<a class="btn">` with no `href`.** It therefore has no
implicit `link` role: `get_by_role("link", name="Sign In")` matches
nothing in any state. The header also hydrates after
`domcontentloaded`, so for a moment neither Sign In nor Sign Out
exists — a check resting on one absence silently concludes "signed in"
and browses anonymously. Poll until one control or the other proves the
state; treat "neither, after 30s" as an error.
- **`get_by_label("Own")` also matches "Prev. Owned."** Use
`get_by_role("checkbox", name="Own", exact=True)`.
- **Version rows** are the `<li>`s carrying a thumbnail:
`li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
paging `<li>`s ("First", "Prev", "1", "…").
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
buttons: `a[title="Next Page"]`, with the parent `<li>` gaining
`disabled` at the end. Paging is client-side over an already-loaded
list — no request per page.
- **Every paging control renders TWICE**: a desktop set and a mobile set
inside `<li class="visible-xs-*">`. A selector matches both, and
`.first` may be the hidden one — Playwright then waits for it to become
visible until it times out. Always click the first *visible* match.
First/Prev may be unclickable on a desktop viewport (mobile-only
variant). To return to page 1, click the visible numbered **"1"**
anchor: the sub-view's paging state SURVIVES closing and reopening it
(Angular keeps the scope), so a reopen lands wherever it was left, not
on page 1.
- **Match row text in Python, not with `has_text`.** Playwright's
`has_text` regex tests raw `textContent`, which carries the markup's
tabs and newlines; a whitespace-normalized capture will never equal it.
Normalize both sides yourself and click the row by index.
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
name is localized (a Czech edition's row starts "Spící bohové"). Match
the version name inside its parentheses.
- **The API's version name is not always the picker's string.** BGG's
XML gives e.g. `English edition 2018-2` where the picker shows
`(English edition) (2018)`. Match the full name first, then retry with
a trailing year/printing qualifier stripped — and if that relaxed match
hits more than one row, refuse and add version-less (never guess).
- **An owned game's page has no "Add To" button**; it reads
"In Collections (Own…)". That is the update flow's entry point.
### The update flow (verified 2026-08-06)
Do **not** go through the game page's dialog for an existing entry — it
cannot say which copy it edits. The collection table can:
1. `/collection/user/<user>?objectid=<bgg_id>&own=1`.
2. The version cell carries its own collid:
`td.collection_version[onclick*="<collid>"]`. Clicking it opens an
inline editor (the cell shows *Editing* meanwhile).
3. The editor is a radio list whose **values are version ids**:
`form[id^="form_version"] input[type="radio"][value="<version_id>"]`.
Both the copy and the edition are therefore addressed exactly — no
name matching, no pagination.
4. Clicking the radio fires `CE_SaveData(cellid, collid, 'version')`
itself. **There is no Save button.** The save has landed when the
cell's text stops reading *Editing*.
This is strictly additive: it sets one field on one collid and cannot
create a duplicate entry.
### BGG's collection export lags the site
After a successful update the website shows the new version immediately
(the row's version cell reads e.g. "English first edition Year: 2012",
and reopening the editor shows that radio checked), but the XML API's
`/collection` export can still report the entry with no version — even
with a cache-busting re-request. `diff` reads the API, so it will
re-queue work that has already landed.
Consequences to keep in mind rather than "fix":
- The upload log is the authority on what this tool did; the API is the
authority on what BGG has published. They disagree for a while.
- `build_queue` skipping a job whose log says done is CORRECT here — the
work is applied, and re-running it would be a no-op at best.
- Anything user-facing should count PENDING jobs (queue rows minus what
the log completed), never raw queue rows, or settled work reads as
outstanding until the next diff.
+118
View File
@@ -0,0 +1,118 @@
# The bggpipe user's guide
Everything past the [README](../README.md)'s quick start: credentials, every stage and its flags, the web app, phones, RPGs, fixing the model's mistakes, and running without a BGG token. Screenshots of everything described here: the [tour](tour.md).
## Contents
- [Credentials and configuration](#credentials-and-configuration)
- [The web app](#the-web-app)
- [From your phone](#from-your-phone)
- [The stages, from the terminal](#the-stages-from-the-terminal)
- [Taking good shelf photos](#taking-good-shelf-photos)
- [Fixing what the model gets wrong](#fixing-what-the-model-gets-wrong)
- [RPGs on your shelves](#rpgs-on-your-shelves)
- [Uploading safely](#uploading-safely)
- [Running before your BGG token arrives](#running-before-your-bgg-token-arrives)
- [Keeping your data safe from git](#keeping-your-data-safe-from-git)
## Credentials and configuration
The `init` wizard is idempotent — re-run it anytime to check status or add keys you skipped. It prompts for the credentials below (hidden input, saved to a `.env` it creates with owner-only permissions) and offers the one-time Playwright Chromium download. Prefer doing it by hand? Copy [.env.example](../.env.example) beside your data, fill it in, and run `playwright install chromium` yourself.
Secrets live in environment variables only, never in config files, code, or logs, and `.env` is gitignored. Every `bggpipe` command loads `.env` from the working directory by itself — real environment variables always win over the file, so [direnv](https://direnv.net/) users and CI overrides keep working unchanged.
Your credentials never leave your machine except to sign in to boardgamegeek.com itself — [the README spells out the full privacy picture](../README.md#why-it-asks-for-your-bgg-password--and-where-your-credentials-go). The password exists solely because BGG has no write API: adding games means driving the real website, in a visible browser window, on your computer. The saved browser session (`storage_state.json`) is credential-adjacent — it stays local and gitignored too.
| Variable | Used by | What it is |
|---|---|---|
| `ANTHROPIC_API_KEY` | extract | Anthropic API key |
| `BGG_API_TOKEN` | resolve, diff, enrich | Bearer token from your registered BGG application |
| `BGG_USERNAME` | diff, upload, enrich | Your BGG username (public, but kept in `.env` so it lives in one place) |
| `BGG_PASSWORD` | upload (website login) | Your BGG password |
Non-secret knobs live in `config.toml`: `photos_dir`, `data_dir`, the BGG rate limit, and the vision setup — a `[vision.<provider>]` block per provider ("anthropic" or any OpenAI-compatible endpoint, including a local [Ollama](https://ollama.com/)), with `vision_provider` picking one. Local models read spines noticeably worse than frontier ones — expect a longer proofread pass on the Titles page, not a broken pipeline.
**What extraction costs:** the vision call is the pipeline's only paid step, and it's small — the author's full collection (65 shelf photos, 136 games) came to under a dollar on the default model (Claude Sonnet). It's also one-time: raw reads are cached per photo, so re-running extract is free, and only new or replaced photos are ever sent again. Everything BGG-side is free (the API token costs nothing).
## The web app
```sh
bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
```
Seven pages — Pipeline, Photos, Titles, Review, Queue, Library, and Help — all [pictured in the tour](tour.md). Stage runs execute one at a time in the background with live output:
![The Pipeline page mid-extract: stage cards above a live activity log listing each photo and how many titles it yielded](screenshots/15-extract-live.png)
Stage runs execute one at a time; every decision saves immediately; the pages live-follow the data files, so a stage run in another terminal shows up without a refresh. The real upload sits behind a confirmation (and behind a stub-data lock if synthetic test fixtures ever regenerate). The in-app **Help** page documents every status chip and keyboard shortcut.
## From your phone
The app is localhost-only by default. To use it from a phone or tablet on your network — proofreading from the couch, or shooting shelf photos straight into the pipeline — serve it to the LAN instead:
```sh
bggpipe web --lan # localhost + your network, behind an access key
```
Startup prints a pairing link (`?k=...`) and a QR code: point the phone's camera at the terminal and tap. Pairing is one-time per device — the key persists across restarts (`data/.lan_key`; delete it to revoke every paired device) and the cookie lasts a year. Save the page to the phone's home screen for the full-screen treatment, piper icon included.
To photograph shelves from the phone: on the Photos page, tap the drop zone and choose "Take Photo." The upload narrates its progress, and camera captures get unique `shelf-<timestamp>` names so rapid-fire shots never overwrite each other. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead).
## The stages, from the terminal
Every stage is also a command, and the two interfaces share all state:
```sh
bggpipe extract # photos → titles.json (+ retake prompts)
bggpipe resolve # titles → BGG ids/versions in matches.csv
bggpipe review --web # review UI only
bggpipe diff # compare against your BGG collection
bggpipe upload --dry-run # ALWAYS inspect this first
bggpipe upload --limit 1 # then one game, then small batches
bggpipe enrich # full metadata → data/games.json
```
Each stage skips work it has already done; `--force`/`--refresh` flags redo it. Every stage is idempotent and resumable: kill it mid-run, restart, lose nothing. All artifacts are flat CSV/JSON files you can inspect and edit. `review` without `--web` runs in the terminal instead.
## Taking good shelf photos
Straight-on, one shelf (or part of one) per shot, close enough that spine text is legible to a human. If you can't read it, the model can't either. Overlap between shots is fine: duplicate reads are deduped automatically, with the merge shown (and veto-able) in review. Boxes the model spots but can't identify become retake prompts in `unidentified.json` and the Photos page's "reshoot" tickets: photograph those boxes up close, drop the new photo in, and run `extract` again.
## Fixing what the model gets wrong
Vision reads aren't perfect, and you know things the photos don't show. The Titles page lets you **edit** a title (fix a misspelling, add publisher/edition/year/language cues you know offhand), **split** a line into per-photo copies when one title is actually several boxes, **remove** lines that aren't games at all, and **add** a game no photo caught. Every one of these is durable: the decision lands in a small committed store (`data/title_edits.json`, `data/title_splits.json`, `data/title_removals.json`, `data/title_additions.json`) that is replayed on every rebuild — re-running extract or resolve can never undo your curation. Undo any decision by deleting its record from the store.
Games BGG doesn't have at all can be kept as **local** library citizens: their detail page in the Library takes hand-written facts (players, playtime, publisher, notes) and a cover photo of your own, stored in `data/local_games.json` and `data/local_art/` — the only source such a game will ever have.
## RPGs on your shelves
Tabletop RPGs aren't in BGG's board-game database — they live on RPGGeek, which shares the same underlying API. When a title isn't found as a board game, bggpipe retries as an RPG: matches are identified, enriched (designers, publishers, genres from RPGGeek), and browsable in the library (filter: RPGs), but they stay **local only** — they're never uploaded, since your BGG collection can't hold them. When the automatic search can't reach the right database (BGG has board games named "Dungeons & Dragons" too), every Review card has explicit **search BGG** / **search RPGGeek** buttons.
## Uploading safely
`upload` drives a real logged-in browser session against your real account, so it is deliberately careful:
- `--dry-run` logs what would happen without touching the site — always read it first, then `--limit 1`, then small batches:
![A dry run's output: seven pending jobs listed as "would add" lines with BGG ids, and one "would set version" line naming the exact collection entry](screenshots/14-upload-dry-run.png)
- The browser runs **headed** by default — BGG's Cloudflare check blocks headless ones, and a first login may need one human click before the session is saved locally and reused.
- Requests are slow on purpose (seconds between actions, per BGG's API policy); the Queue page shows exactly what will run before it runs, and `upload_log.csv` keeps a permanent record of every attempt.
- `--retry-failed` re-attempts failures; `--verify` re-fetches your collection and cross-checks the log. Note that BGG's collection export can lag the website by hours — freshly-landed work may look missing to `diff`/`--verify` until it catches up.
- Review decisions outrank the queue: re-deciding a match after `diff` retires its queued job automatically.
## Running before your BGG token arrives
BGG application approval can take a week or more. Until then: `extract` works immediately (it only needs the vision key), and `resolve` does what it can, parking the rest as "waiting on BGG API token" — it picks them up automatically once the token exists. Everything is saved as you go.
`diff` normally fetches your collection live, but there's a logged-in-browser exemption that needs no token: while signed in to BGG, save these two URLs as `data/collection_snapshot_base.xml` and `data/collection_snapshot_expansions.xml` (if you get a "queued" message, refresh after a few seconds):
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
## Keeping your data safe from git
The [README's quick start](../README.md#quick-start) — installed as a tool, run in a directory of your own — is the only supported way to use bggpipe on your collection. Everything the pipeline produces lives where you run it, and `uv tool upgrade bggpipe` picks up fixes without going anywhere near your data.
**Why not clone and run?** The source repo doubles as its author's live pipeline: `data/` ships with their real artifacts, committed and updated often. Run inside a clone and *your* data lands at git-tracked paths — the next `git pull` will refuse to merge, and the usual remedies (`git reset --hard`, `git checkout .`, `git stash`, `git clean -fdx`) would destroy your review decisions, hand-written games, upload log, and photos. bggpipe detects this arrangement and warns at `init` and on the web dashboard; don't ignore it.
One committed file of the author's data deserves a word: `data/STUB_DATA.marker` is normally absent. It appears only if the synthetic stub fixtures (from `scripts/write_stub_fixtures.py`) regenerate the CSVs, and the upload stage refuses to run while it exists — so placeholder data can never reach a real BGG account.
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

+11 -5
View File
@@ -1,4 +1,8 @@
# Shelf-to-BGG Collection Pipeline — Build Spec # Shelf-to-BGG Collection Pipeline — Design Contract
> This is the pipeline's contract — what must stay true, and why — kept
> current as the design evolves. Read it before changing pipeline
> semantics. How to *use* the pipeline is the [user's guide](guide.md).
## Goal ## Goal
@@ -11,7 +15,7 @@ Beyond the bare game, capture **which edition/version I own** wherever the photo
## Constraints & Context ## Constraints & Context
- BGG has **no write API**. Reads go through the XML API2 (`https://boardgamegeek.com/xmlapi2/`); writes must automate the website itself with a real login session. - BGG has **no write API**. Reads go through the XML API2 (`https://boardgamegeek.com/xmlapi2/`); writes must automate the website itself with a real login session.
- The XML API **requires a registered application** (boardgamegeek.com/using_the_xml_api, policy 2025-07-02): every request carries `Authorization: Bearer <token>` from the `BGG_API_TOKEN` env var, sent to `boardgamegeek.com` without a leading `www`. Register a free non-commercial application at boardgamegeek.com/applications — approval can take a week+, so development runs on recorded/stub XML fixtures until then. - The XML API **requires a registered application** (boardgamegeek.com/using_the_xml_api, policy 2025-07-02): every request carries `Authorization: Bearer <token>` from the `BGG_API_TOKEN` env var, sent to `boardgamegeek.com` without a leading `www`. Register a free non-commercial application at boardgamegeek.com/applications — approval can take a week+; offline development and tests run on recorded XML fixtures.
- BGG's XML API queues collection requests: a first call may return HTTP 202 ("try again"). Retry with backoff. - BGG's XML API queues collection requests: a first call may return HTTP 202 ("try again"). Retry with backoff.
- BGG will throttle aggressive clients. Target ≤1 request every 2 seconds to any BGG endpoint, with jittered backoff on 429/503. - BGG will throttle aggressive clients. Target ≤1 request every 2 seconds to any BGG endpoint, with jittered backoff on 429/503.
- BGG changed API access policies in 2025; some older community tools broke. Don't depend on undocumented endpoints beyond XML API2 and the public website. - BGG changed API access policies in 2025; some older community tools broke. Don't depend on undocumented endpoints beyond XML API2 and the public website.
@@ -66,7 +70,7 @@ Each stage reads the previous stage's artifact and writes its own. Re-running a
### Stage 3 — `review`: Human review of ambiguous/unmatched ### Stage 3 — `review`: Human review of ambiguous/unmatched
- A minimal local review flow. A TUI is fine (e.g., `rich`/`textual`), or a tiny localhost web page — builder's choice, but keep it dependency-light. - A minimal local review flow. A TUI is fine (e.g., `rich`/`textual`), or a tiny localhost web page — builder's choice, but keep it dependency-light. *(As built: both — a rich TUI and a six-page FastAPI app sharing one decision engine.)*
- For each `ambiguous` item: show the raw title, source photo filename, and candidate list (name, year, type, BGG rank, owned count) → pick one, skip, or reject. - For each `ambiguous` item: show the raw title, source photo filename, and candidate list (name, year, type, BGG rank, owned count) → pick one, skip, or reject.
- For each `unmatched` item: allow manual BGG ID entry or a free-text re-search. - For each `unmatched` item: allow manual BGG ID entry or a free-text re-search.
- For each `version_ambiguous` item: show the version candidates (version name, publisher, year, language) next to the edition cues from the photo → pick one, or mark `version_unknown`. Keep this pass optional/skippable — version review shouldn't block getting games uploaded. - For each `version_ambiguous` item: show the version candidates (version name, publisher, year, language) next to the edition cues from the photo → pick one, or mark `version_unknown`. Keep this pass optional/skippable — version review shouldn't block getting games uploaded.
@@ -88,7 +92,7 @@ Each stage reads the previous stage's artifact and writes its own. Re-running a
- Log in to boardgamegeek.com with credentials from env vars (`BGG_USERNAME`, `BGG_PASSWORD`). Never write credentials to disk or logs. Persist the browser session/storage state locally so repeat runs don't re-login. - Log in to boardgamegeek.com with credentials from env vars (`BGG_USERNAME`, `BGG_PASSWORD`). Never write credentials to disk or logs. Persist the browser session/storage state locally so repeat runs don't re-login.
- For each row in `to_add.csv`: navigate to the game page, use the "Add to Collection" flow, set status **Owned**, and — when a `version_id` is present — set the specific version in the collection item's version picker before saving. Manually walk this flow once and document the selectors before automating; the version UI is the most fragile part. - For each row in `to_add.csv`: navigate to the game page, use the "Add to Collection" flow, set status **Owned**, and — when a `version_id` is present — set the specific version in the collection item's version picker before saving. Manually walk this flow once and document the selectors before automating; the version UI is the most fragile part.
- Adding a second copy of an already-owned game must create a NEW collection entry, not edit the existing one. - Adding a second copy of an already-owned game must create a NEW collection entry, not edit the existing one.
- **Update mode** (rows from `to_update.csv`): open the EXISTING collection entry (keyed by `collid`) rather than the add flow, set the version, save. Must never create a duplicate entry and never change any other field of the entry. Verify the already-owned dialog behavior manually first — flagged as unverified in `docs/bgg-upload-flow.md`. - **Update mode** (rows from `to_update.csv`): open the EXISTING collection entry (keyed by `collid`) rather than the add flow, set the version, save. Must never create a duplicate entry and never change any other field of the entry. The verified route (2026-08-06, documented in `docs/bgg-upload-flow.md`): the collection table's version cell (`td.collection_version[onclick*="<collid>"]`) opens an inline editor whose radio values ARE version ids; clicking a radio saves immediately.
- Log every attempt to `upload_log.csv`: `bgg_id, name, status (added|already_present|failed), timestamp, error`. - Log every attempt to `upload_log.csv`: `bgg_id, name, status (added|already_present|failed), timestamp, error`.
- Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`. - Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`.
- Deliberately slow: 24 s randomized delay between games. This is a real account on a community site — behave like a polite human. - Deliberately slow: 24 s randomized delay between games. This is a real account on a community site — behave like a polite human.
@@ -107,6 +111,8 @@ All artifacts are flat files in a `data/` directory — human-readable, git-frie
- `titles.json` — extraction output (stage 1) - `titles.json` — extraction output (stage 1)
- `unidentified.json` — game boxes seen but not identified (stage 1); retake prompts - `unidentified.json` — game boxes seen but not identified (stage 1); retake prompts
- `title_edits.json`, `title_splits.json`, `title_removals.json` — durable human curation (corrected reads/cues, split-into-copies decisions, removed lines); replayed on every titles.json rebuild so re-extraction never undoes them
- `unidentified_dismissed.json` — dismissed retake prompts (kept apart so rebuilds can't resurrect them)
- `bgg_cache/` — cached XML API responses - `bgg_cache/` — cached XML API responses
- `matches.csv` — the master matching table (stages 23) - `matches.csv` — the master matching table (stages 23)
- `to_add.csv` — upload queue, new entries (stage 4) - `to_add.csv` — upload queue, new entries (stage 4)
@@ -122,7 +128,7 @@ All artifacts are flat files in a `data/` directory — human-readable, git-frie
- **202 queue** on `/collection`: retry with backoff (2s, 5s, 10s, 30s; give up after ~5 tries with a clear message). - **202 queue** on `/collection`: retry with backoff (2s, 5s, 10s, 30s; give up after ~5 tries with a clear message).
- **HEIC photos** from iPhone: convert transparently. - **HEIC photos** from iPhone: convert transparently.
- **Duplicate copies**: a title appearing in multiple photos with consistent edition cues is one game (dedupe). But genuinely distinct editions of the same game ARE in scope — they stay separate entries end-to-end and become separate BGG collection entries. Identical duplicate copies of the same edition are out of scope (assume dedupe). - **Duplicate copies**: a title appearing in multiple photos with consistent edition cues is one game (dedupe). But genuinely distinct editions of the same game ARE in scope — they stay separate entries end-to-end and become separate BGG collection entries. *(Amended as built:)* identical duplicate copies of the same edition are supported too, as an explicit human decision — a photo-scoped "split into copies" recorded in `data/title_splits.json`, honored by every dedupe pass thereafter.
- **Non-game items** on shelves (books, card sleeves, storage boxes): the vision prompt should be instructed to include only board/card games; anything that slips through will fail resolution and land in review. - **Non-game items** on shelves (books, card sleeves, storage boxes): the vision prompt should be instructed to include only board/card games; anything that slips through will fail resolution and land in review.
- **Special characters** in titles (é, colons, ampersands): normalize consistently on both sides of the match. - **Special characters** in titles (é, colons, ampersands): normalize consistently on both sides of the match.
- **Base game vs. expansion vs. new edition**: the most common failure mode. Bias toward surfacing these as `ambiguous` rather than auto-matching. - **Base game vs. expansion vs. new edition**: the most common failure mode. Bias toward surfacing these as `ambiguous` rather than auto-matching.
+48
View File
@@ -0,0 +1,48 @@
# A tour of bggpipe
Seven pages in one local app — Pipeline, Photos, Titles, Review, Queue, Library, and Help. Every screenshot below is the real app on the author's real shelves, and the end product is public: [the author's BGG collection](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built. (Back to the [README](../README.md) · how to use it all: the [user's guide](guide.md).)
**The Pipeline page** — every stage is a card with live counts and a Run button; blockers (missing token, stub-data lock) surface as banners, not surprises. Here: the settled state after a full run — 136 titles read, 115 matched, 62 added.
![Pipeline dashboard: six stage cards with live counts and Run buttons, showing a completed pipeline — 136 titles extracted, 115 resolved, 62 games added, 136 in the library](screenshots/01.png)
**Photos** — drag shelf photos in; boxes the vision model saw but couldn't read become illustrated reshoot tickets with shelf directions a human can follow.
![Photos page: drag-and-drop upload zone, reshoot tickets with photo thumbnails and shelf-location descriptions, and the gallery of all shelf photos with per-photo title counts](screenshots/02.png)
**Titles** — every read off the shelves, alphabetized with status and source photos. This is the proofreading checkpoint: edit misreads, split multi-copy lines, remove non-games, add a game no photo caught.
![Titles page: 150 title reads with status chips and source photo links, shaky-read badges with one-click confirm, split-into-copies and edit actions, and the add-a-game panel open](screenshots/16-titles-proofread.png)
**Review** — keyboard-first decisions on ambiguous matches. Each card shows the shelf photo beside the candidates (with rank, owner counts, and a view-on-BGG link per candidate), plus hand-steered re-searches of BGG or RPGGeek when the automatic search can't reach the right database. Here: is that box base Agricola or the Revised Edition?
![Review page with live match ballots: Agricola showing two candidate editions with thumbnails, ranks and owner counts, and unmatched titles offering manual id entry and search BGG / search RPGGeek buttons](screenshots/11-review-matches.png)
**The editions pass** — after matches are settled, an optional pass picks which *printing* each copy is, scored against the cues read off the box. Skippable per game, and never blocks uploads. Here: five English Catan editions to choose between.
![Editions review pass: Castle Panic and Catan cards, each listing that game's published editions with years, publishers and cue scores, plus can't-tell / list-every-printing / wrong-game actions](screenshots/12-edition-ballots.png)
**Queue** — exactly what upload will do before it does it, and a permanent log of every attempt ever made. Here: six adds and one version update, pending.
![Upload queue: six pending collection adds with BGG ids and source photos, one pending version update with its collection id, and the empty upload log](screenshots/13-queue-pending.png)
**Library** — the enriched collection: searchable across titles, designers and mechanics, filterable by player count, sortable by rank/weight/year/time. Every card opens a detail page joining BGG's data with your own shelf photos; off-BGG games take hand-written facts and a cover photo there.
![Library page filled with 136 games as art cards — search, kind filters, player-count filter and sort controls above; RPG entries carry local-only chips](screenshots/06.png)
**A game's detail page** — BGG's stats, chips, and description joined with what only the pipeline knows: *your* edition, and the shelf photo it was read from.
![Game detail page for Britannia: box art beside player counts, playing time, weight, rank, designers and mechanics as chips, with a Your Edition card naming the owned printing](screenshots/10-game-detail.png)
**Help** — the whole flow, every page, every status, and every keyboard shortcut, documented in-app.
![Help page documenting the shelves-to-collection flow, what each page is for, and the proofreading and review checkpoints](screenshots/07.png)
**And on a phone** (`--lan`) — the same app, paired once by QR code: the hamburger menu, shooting shelf photos straight into the pipeline from the camera, proofreading titles from the couch, and the piper on the Help page.
<p>
<img src="screenshots/08-phone-menu.jpeg" alt="Phone view: the hamburger menu open over the Photos page, showing all seven pages with attention badges" width="24%">
<img src="screenshots/09-phone-camera.jpeg" alt="Phone view: tapping the photo drop zone offers iOS's Photo Library / Take Photo / Choose Files sheet" width="24%">
<img src="screenshots/10-phone-titles.jpeg" alt="Phone view: the Titles page as stacked cards with status chips, edit and split buttons, and the shaky-reads filter" width="24%">
<img src="screenshots/11-phone-piper.jpeg" alt="Phone view: the Help page's credits card with Juniper's full piper artwork and the BGG trademark attribution" width="24%">
</p>
+26
View File
@@ -2,7 +2,25 @@
name = "bggpipe" name = "bggpipe"
dynamic = ["version"] dynamic = ["version"]
description = "Shelf-to-BGG collection pipeline: photos in, BoardGameGeek collection out" description = "Shelf-to-BGG collection pipeline: photos in, BoardGameGeek collection out"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "Eric Wagoner", email = "eric@ericwagoner.com" }]
keywords = ["boardgamegeek", "bgg", "board-games", "collection", "vision", "cataloging"]
classifiers = [
# Beta is deliberate while the user base is one person on one platform,
# however battle-tested that person's collection is
"Development Status :: 4 - Beta",
"Environment :: Console",
"Environment :: Web Environment",
"Intended Audience :: End Users/Desktop",
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3.12",
"Topic :: Games/Entertainment :: Board Games",
]
dependencies = [ dependencies = [
"typer>=0.12", "typer>=0.12",
"httpx>=0.27", "httpx>=0.27",
@@ -17,8 +35,16 @@ dependencies = [
"playwright>=1.62.0", "playwright>=1.62.0",
"pydantic>=2.13.4", "pydantic>=2.13.4",
"python-multipart>=0.0.32", "python-multipart>=0.0.32",
"qrcode>=8.2",
] ]
[project.urls]
Homepage = "https://kestrelsnest.social"
Repository = "https://git.kestrelsnest.social/eric/bggpipe"
Documentation = "https://git.kestrelsnest.social/eric/bggpipe/src/branch/main/docs/guide.md"
Mastodon = "https://toots.kestrelsnest.social/@eric"
Bluesky = "https://bsky.app/profile/kestrelsnest.social"
[project.scripts] [project.scripts]
bggpipe = "bggpipe.cli:app" bggpipe = "bggpipe.cli:app"
+1 -1
View File
@@ -1,3 +1,3 @@
"""Shelf-to-BGG collection pipeline.""" """Shelf-to-BGG collection pipeline."""
__version__ = "0.1.0" __version__ = "1.0.0"
+20
View File
@@ -14,6 +14,17 @@ app = typer.Typer(
no_args_is_help=True, no_args_is_help=True,
) )
@app.callback()
def _load_dotenv() -> None:
# credentials live in ./.env (written by init); load them so a fresh
# directory works without direnv or manual sourcing. Real environment
# variables always take precedence over the file.
from bggpipe.init_wizard import load_env_file
load_env_file(Path(".env"))
ConfigOpt = Annotated[ ConfigOpt = Annotated[
Path | None, Path | None,
typer.Option("--config", help="Path to config.toml (default: ./config.toml)"), typer.Option("--config", help="Path to config.toml (default: ./config.toml)"),
@@ -96,6 +107,14 @@ def web(
no_browser: Annotated[ no_browser: Annotated[
bool, typer.Option("--no-browser", help="Don't open a browser tab") bool, typer.Option("--no-browser", help="Don't open a browser tab")
] = False, ] = False,
lan: Annotated[
bool,
typer.Option(
"--lan",
help="Also serve to your local network behind a per-run access "
"key (trusted networks only)",
),
] = False,
config: ConfigOpt = None, config: ConfigOpt = None,
) -> None: ) -> None:
"""The whole pipeline in a local web UI: photos, stages, review.""" """The whole pipeline in a local web UI: photos, stages, review."""
@@ -106,6 +125,7 @@ def web(
cfg, cfg,
port=port, port=port,
dev=dev, dev=dev,
lan=lan,
config_path=config, config_path=config,
landing="/", landing="/",
open_browser=not no_browser, open_browser=not no_browser,
+114 -1
View File
@@ -9,6 +9,7 @@ account has exactly one home.
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess
import tomllib import tomllib
import warnings import warnings
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
@@ -22,6 +23,9 @@ STUB_CACHE_MARKER_NAME = "STUB_FIXTURES.marker"
STUB_DATA_MARKER_NAME = "STUB_DATA.marker" STUB_DATA_MARKER_NAME = "STUB_DATA.marker"
VISION_PROVIDERS = ("anthropic", "openai-compatible")
@dataclass(frozen=True) @dataclass(frozen=True)
class Config: class Config:
bgg_username: str = "" bgg_username: str = ""
@@ -29,6 +33,11 @@ class Config:
data_dir: Path = Path("data") data_dir: Path = Path("data")
model: str = "claude-sonnet-5" model: str = "claude-sonnet-5"
rate_limit_seconds: float = 2.0 rate_limit_seconds: float = 2.0
# "anthropic" (default) or "openai-compatible" — the latter covers
# OpenAI, OpenRouter, and local runtimes (Ollama, LM Studio, vLLM)
vision_provider: str = "anthropic"
vision_base_url: str = "" # e.g. http://localhost:11434/v1 for Ollama
vision_key_env: str = "OPENAI_API_KEY" # "" = endpoint needs no key
@property @property
def cache_dir(self) -> Path: def cache_dir(self) -> Path:
@@ -66,6 +75,73 @@ class Config:
def games_path(self) -> Path: def games_path(self) -> Path:
return self.data_dir / "games.json" return self.data_dir / "games.json"
def tracked_data_warning(self) -> str | None:
"""The clone-and-run trap: pipeline data written at git-TRACKED
paths is one `git pull` + one panicked `git reset --hard` away
from destruction. Warn whenever artifacts under data_dir are
tracked — unless the owner has marked this checkout as their own
repo (the marker is gitignored, so a fresh clone never inherits
the exemption)."""
if (self.data_dir / ".own_repo").exists():
return None
try:
tracked = subprocess.run( # noqa: S603
["git", "ls-files", "--", str(self.data_dir)], # noqa: S607
capture_output=True,
text=True,
timeout=10,
cwd=self.data_dir.resolve().parent,
)
except (OSError, subprocess.TimeoutExpired):
return None # no git, no repo, no trap
if tracked.returncode != 0 or not tracked.stdout.strip():
return None
return (
f"your pipeline data ({self.data_dir}) sits at git-TRACKED "
"paths — a `git pull` here can refuse to merge, and the usual "
"remedies (reset --hard, checkout ., stash) would destroy your "
"review decisions, hand-written games, and upload log. Run "
"bggpipe from a directory outside this repository (see README: "
"Bring your own shelves). If this repo is genuinely where you "
f"version your own data, `touch {self.data_dir}/.own_repo` to "
"accept the arrangement and silence this warning."
)
@property
def local_games_path(self) -> Path:
# hand-written metadata for games BGG doesn't have — the only
# source of truth for them, so it is committed like the other stores
return self.data_dir / "local_games.json"
@property
def local_art_dir(self) -> Path:
# cover photos for off-BGG games (committed: nothing else has them)
return self.data_dir / "local_art"
@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) —
# filtered out of every titles.json rebuild
return self.data_dir / "title_removals.json"
@property
def title_edits_path(self) -> Path:
# human corrections to extracted reads (misspellings, known cues) —
# replayed on every titles.json rebuild
return self.data_dir / "title_edits.json"
@property
def title_splits_path(self) -> Path:
# titles the human declared to be MULTIPLE physical copies: extract
# and resolve dedupe must never cross-photo-merge them again
return self.data_dir / "title_splits.json"
@property @property
def dismissed_path(self) -> Path: def dismissed_path(self) -> Path:
return self.data_dir / "unidentified_dismissed.json" return self.data_dir / "unidentified_dismissed.json"
@@ -77,6 +153,12 @@ class Config:
self.data_dir / "collection_snapshot_expansions.xml", self.data_dir / "collection_snapshot_expansions.xml",
) )
@property
def lan_key_path(self) -> Path:
# the --lan access key, persisted so restarts don't strand phones'
# cookies; credential-adjacent like Playwright state — gitignored
return self.data_dir / ".lan_key"
@property @property
def storage_state_path(self) -> Path: def storage_state_path(self) -> Path:
# cwd-relative on purpose (credential-adjacent, gitignored) but # cwd-relative on purpose (credential-adjacent, gitignored) but
@@ -107,16 +189,47 @@ def load_config(path: Path | None = None) -> Config:
"data_dir": Path, "data_dir": Path,
"model": str, "model": str,
"rate_limit_seconds": float, "rate_limit_seconds": float,
"vision_provider": str,
} }
updates = {key: caster(raw[key]) for key, caster in known.items() if key in raw} updates = {key: caster(raw[key]) for key, caster in known.items() if key in raw}
if unknown := sorted(raw.keys() - known.keys()): if unknown := sorted(raw.keys() - known.keys() - {"vision"}):
# a typo'd key silently falling back to defaults is a debugging # a typo'd key silently falling back to defaults is a debugging
# trap ("No photos found in photos/") — say so up front # trap ("No photos found in photos/") — say so up front
warnings.warn( warnings.warn(
f"{p}: ignoring unknown key(s): {', '.join(unknown)}", f"{p}: ignoring unknown key(s): {', '.join(unknown)}",
stacklevel=2, stacklevel=2,
) )
# [vision.<provider>] blocks: every provider's recipe can live in
# the committed file; only the ACTIVE provider's block applies
vision_blocks = raw.get("vision") or {}
if stray := sorted(vision_blocks.keys() - set(VISION_PROVIDERS)):
warnings.warn(
f"{p}: ignoring [vision.*] block(s) for unknown provider(s): "
f"{', '.join(stray)}",
stacklevel=2,
)
provider = updates.get("vision_provider", cfg.vision_provider)
block = vision_blocks.get(provider) or {}
block_known = {
"model": "model",
"base_url": "vision_base_url",
"key_env": "vision_key_env",
}
if odd := sorted(block.keys() - block_known.keys()):
warnings.warn(
f"{p}: [vision.{provider}]: ignoring unknown key(s): {', '.join(odd)}",
stacklevel=2,
)
for key, field in block_known.items():
if key in block:
updates[field] = str(block[key])
cfg = replace(cfg, **updates) cfg = replace(cfg, **updates)
if cfg.vision_provider not in VISION_PROVIDERS:
# a typo here would surface as a confusing extract failure later
raise ValueError(
f"{p}: vision_provider must be one of {', '.join(VISION_PROVIDERS)}"
f" (got {cfg.vision_provider!r})"
)
if username := os.environ.get("BGG_USERNAME"): if username := os.environ.get("BGG_USERNAME"):
cfg = replace(cfg, bgg_username=username) cfg = replace(cfg, bgg_username=username)
return cfg return cfg
+11 -16
View File
@@ -135,6 +135,11 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
if status == "merged": if status == "merged":
result.merged += 1 # represented by its survivor row result.merged += 1 # represented by its survivor row
continue continue
if status == "local":
# the human's call: a real game BGG doesn't have — a library
# citizen only, never queued
result.local_only.append(row["title_raw"])
continue
if status not in RECOGNIZED_MATCH_STATUSES or not row["bgg_id"]: if status not in RECOGNIZED_MATCH_STATUSES or not row["bgg_id"]:
result.pending.append(row["title_raw"]) result.pending.append(row["title_raw"])
continue continue
@@ -178,25 +183,13 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
else: else:
leftover.append(row) leftover.append(row)
# 1b — versionless copies get upgraded. Guard: upload's row-edit flow # 1b — versionless copies get upgraded. The update flow addresses the
# targets rows by game NAME (a collid can't drive the UI), so an update # copy by collid and the edition by radio value, so a versioned
# is only safe when EVERY copy of the game is versionless — otherwise # sibling copy is never at risk.
# the browser could open the versioned copy and overwrite it.
still_left: list[dict] = [] still_left: list[dict] = []
for row in leftover: for row in leftover:
bgg_id = int(row["bgg_id"]) bgg_id = int(row["bgg_id"])
versionless = [c for c in unconsumed(bgg_id) if c.version_id is None] versionless = [c for c in unconsumed(bgg_id) if c.version_id is None]
any_versioned = any(c.version_id is not None for c in by_object.get(bgg_id, []))
if versionless and any_versioned:
consumed_collids.add(versionless[0].coll_id)
result.already_owned.append(row["title_raw"])
result.disagreements.append(
f"{row['title_raw']}: a versionless copy could take version "
f"{row['version_name']!r}, but another copy already carries "
"a version — set it by hand on BGG (the automated row edit "
"can't safely target a specific copy)"
)
continue
if versionless: if versionless:
target = versionless[0] target = versionless[0]
consumed_collids.add(target.coll_id) consumed_collids.add(target.coll_id)
@@ -316,7 +309,9 @@ def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult:
merged_note = f" · {result.merged} merged duplicate(s)" if result.merged else "" merged_note = f" · {result.merged} merged duplicate(s)" if result.merged else ""
local_note = ( local_note = (
f" · {len(result.local_only)} local-only (RPGs)" if result.local_only else "" f" · {len(result.local_only)} local-only (RPGs, off-BGG games)"
if result.local_only
else ""
) )
typer.echo( typer.echo(
f"\n{result.recognized} recognized · {len(result.already_owned)} already " f"\n{result.recognized} recognized · {len(result.already_owned)} already "
+87 -4
View File
@@ -28,7 +28,8 @@ from bggpipe.bgg_client import (
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text from bggpipe.fsio import atomic_write_text
from bggpipe.models import is_confident_version, is_recognized from bggpipe.models import is_confident_version, is_recognized
from bggpipe.resolve import read_matches from bggpipe.normalize import normalize_title
from bggpipe.resolve import load_titles, read_matches
BATCH_SIZE = 20 BATCH_SIZE = 20
@@ -95,12 +96,86 @@ def run_enrich(
games[key] = {**fetched[bgg_id], "version": version} games[key] = {**fetched[bgg_id], "version": version}
updated += 1 updated += 1
# games the human ruled off-BGG: library entries built from their own
# photo reads — no API involved, so a blocked run still lands them
local_keys: set[str] = set()
local_rows = [r for r in rows if r["match_status"] == "local"]
# hand-written metadata wins over the photo reads: for an off-BGG game
# it is the only real source there is
hand: dict = {}
if cfg.local_games_path.exists():
try:
hand = json.loads(cfg.local_games_path.read_text())
except json.JSONDecodeError as err:
raise ValueError(
f"{cfg.local_games_path} is corrupt ({err}) — it holds "
"hand-written game data, so check git history before deleting"
) from err
if local_rows:
try:
cues = {
(e.title_raw, ";".join(e.source_photos)): e
for e in load_titles(cfg.titles_path)
}
except FileNotFoundError:
cues = {}
for row in local_rows:
key = f"local:{normalize_title(row['title_raw'])}:{row['source_photos']}"
local_keys.add(key)
entry = cues.get((row["title_raw"], row["source_photos"]))
games[key] = {
"bgg_id": None,
"name": row["title_raw"],
"year": entry.year_hint if entry else None,
"type": "localgame",
"publishers": [entry.publisher_hint]
if entry and entry.publisher_hint
else [],
"source_photos": [p for p in row["source_photos"].split(";") if p],
}
games[key].update(
{k: v for k, v in (hand.get(key) or {}).items() if v not in (None, "")}
)
# the key embeds the photo list, so a new sighting or a title edit
# strands hand data under a key no row produces anymore. Same
# normalized title + exactly one candidate = unambiguous: migrate.
# Anything else is reported, never silently dropped.
orphans = [k for k in hand if k.startswith("local:") and k not in local_keys]
migrated = {}
for old_key in orphans:
title_part = old_key.split(":", 2)[1]
candidates = [k for k in local_keys if k.split(":", 2)[1] == title_part]
if len(candidates) == 1 and candidates[0] not in hand:
new_key = candidates[0]
hand[new_key] = hand.pop(old_key)
migrated[old_key] = new_key
games[new_key].update(
{k: v for k, v in hand[new_key].items() if v not in (None, "")}
)
else:
typer.echo(
f" warning: hand-written data for {old_key!r} matches "
"no current catalog line — the facts are safe in "
f"{cfg.local_games_path.name} but will not show in the "
"library until the key matches again"
)
if migrated:
atomic_write_text(
cfg.local_games_path,
json.dumps(hand, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
)
for old_key, new_key in migrated.items():
typer.echo(
f" migrated hand-written data {old_key!r} -> {new_key!r} "
"(photo set changed; same title)"
)
# prune keys no current target claims: a row whose version was approved # prune keys no current target claims: a row whose version was approved
# after a bare-key run (or was later rejected) must not leave an orphan # after a bare-key run (or was later rejected) must not leave an orphan
# entry in the frontend seed data. Only safe when nothing was blocked — # entry in the frontend seed data. Only safe when nothing was blocked —
# a token-less run knows too little to declare anything stale. # a token-less run knows too little to declare anything stale.
if not blocked: if not blocked:
current = {key for key, _, _ in targets} current = {key for key, _, _ in targets} | local_keys
stale = [k for k in games if k not in current] stale = [k for k in games if k not in current]
for k in stale: for k in stale:
del games[k] del games[k]
@@ -114,10 +189,18 @@ def run_enrich(
games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n" games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
) )
# count the two populations separately: local entries are built from
# photo reads and have no fetch target, so folding them into the API
# tally would drive "already present or waiting" negative
waiting = len(targets) - updated
parts = [f"{updated} fetched from BGG"]
if local_keys:
parts.append(f"{len(local_keys)} local-only")
if waiting:
parts.append(f"{waiting} already present or waiting")
typer.echo( typer.echo(
f"games.json: {len(games)} entr{'y' if len(games) == 1 else 'ies'} " f"games.json: {len(games)} entr{'y' if len(games) == 1 else 'ies'} "
f"({updated} added/refreshed this run; " f"({'; '.join(parts)})."
f"{len(targets) - updated} already present or waiting)."
) )
if blocked: if blocked:
remaining = [i for i in need if i not in fetched] remaining = [i for i in need if i not in fetched]
+355 -12
View File
@@ -1,7 +1,8 @@
"""Stage 1 — extract game titles + edition cues from shelf photos. """Stage 1 — extract game titles + edition cues from shelf photos.
Each photo is sent to Claude vision once and the raw result is cached under Each photo is sent to the configured vision model once and the raw result
data/extract_raw/<photo>.json (making re-runs free and --only targeted). is cached under data/extract_raw/<photo>.json (making re-runs free and
--only targeted).
titles.json is rebuilt from all raw files on every run, deduping identical titles.json is rebuilt from all raw files on every run, deduping identical
titles across photos unless their edition cues conflict — conflicting cues titles across photos unless their edition cues conflict — conflicting cues
mean two different editions on the shelf, which stay separate entries. mean two different editions on the shelf, which stay separate entries.
@@ -12,6 +13,7 @@ from __future__ import annotations
import base64 import base64
import io import io
import json import json
import os
import re import re
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
@@ -125,7 +127,12 @@ def parse_vision_response(text: str) -> tuple[list[dict], list[dict], int]:
if end == -1: if end == -1:
raise ValueError(f"unterminated JSON in vision response: {text[:200]!r}") raise ValueError(f"unterminated JSON in vision response: {text[:200]!r}")
cleaned = cleaned[start : end + 1] cleaned = cleaned[start : end + 1]
try:
data = json.loads(cleaned) data = json.loads(cleaned)
except json.JSONDecodeError:
# local models emit almost-JSON (trailing commas, mostly): one
# cheap repair pass before declaring the response unusable
data = json.loads(re.sub(r",\s*([}\]])", r"\1", cleaned))
if isinstance(data, list): if isinstance(data, list):
titles_raw, unidentified_raw = data, [] titles_raw, unidentified_raw = data, []
elif isinstance(data, dict): elif isinstance(data, dict):
@@ -176,6 +183,61 @@ def default_vision(model: str) -> VisionFn:
return vision return vision
def openai_compatible_vision(model: str, base_url: str, key_env: str) -> VisionFn:
"""Any endpoint speaking the OpenAI chat-completions format: OpenAI
itself, OpenRouter, or a local runtime (Ollama, LM Studio, llama.cpp,
vLLM — pass its /v1 base URL). A key is optional: local runtimes run
without one, so an empty key_env just sends no Authorization header."""
import httpx
headers = {}
if key_env and (key := os.environ.get(key_env)):
headers["Authorization"] = f"Bearer {key}"
def vision(image_b64: str, media_type: str) -> str:
response = httpx.post(
base_url.rstrip("/") + "/chat/completions",
headers=headers,
json={
"model": model,
"max_tokens": 4000,
"messages": [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{image_b64}"
},
},
{"type": "text", "text": VISION_PROMPT},
],
}
],
},
timeout=300.0, # local models on modest hardware are slow
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"] or ""
return vision
def vision_for(cfg: Config) -> VisionFn:
"""The configured vision backend (config.toml: vision_provider)."""
if cfg.vision_provider == "openai-compatible":
if not cfg.vision_base_url:
raise ValueError(
"vision_provider 'openai-compatible' needs vision_base_url "
"in config.toml (e.g. http://localhost:11434/v1 for Ollama)"
)
return openai_compatible_vision(
cfg.model, cfg.vision_base_url, cfg.vision_key_env
)
return default_vision(cfg.model)
def extract_photo(photo: Path, vision: VisionFn) -> dict: def extract_photo(photo: Path, vision: VisionFn) -> dict:
"""One photo -> {"titles": [...], "unidentified": [...]} (the raw-cache """One photo -> {"titles": [...], "unidentified": [...]} (the raw-cache
file format).""" file format)."""
@@ -240,15 +302,213 @@ def _merge(a: dict, b: dict) -> dict:
return merged return merged
def dedupe_entries(entries: list[dict]) -> list[dict]: # the fields a human correction may override on an extracted entry
"""Collapse same-normalized-title sightings unless their cues conflict.""" EDIT_FIELDS = (
"title_raw",
"confidence",
"publisher_hint",
"edition_hint",
"year_hint",
"language_hint",
"art_notes",
)
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": <title as displayed when the fix
was made>, "photos": [...] to target one copy (optional), <EDIT_FIELDS
to override>}. Applied on every rebuild, before dedupe."""
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"
)
def apply_title_edits(entries: list[dict], edits: list[dict]) -> list[dict]:
"""Apply stored corrections in order. Matching is by the title an entry
CURRENTLY carries, so a later edit made against an earlier edit's result
chains naturally; each record applies at most once per entry."""
if not edits:
return entries
for entry in entries:
applied: set[int] = set()
while True:
norm = normalize_title(entry["title_raw"])
photos = set(entry.get("source_photos") or [])
hit = next(
(
i
for i, e in enumerate(edits)
if i not in applied
and normalize_title(e["match"]) == norm
and (not e.get("photos") or set(e["photos"]) & photos)
),
None,
)
if hit is None:
break
applied.add(hit)
for key in EDIT_FIELDS:
if key in edits[hit]:
entry[key] = edits[hit][key]
return entries
def _scoped_records(path: Path) -> list[dict]:
"""Parse a store of photo-scoped title decisions. Each stored record is
{"title": ..., "photos": [...]} scoping the decision to the sightings
that were on the line (a bare string is legacy: unscoped). Returns
[{"norm": <normalized title>, "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 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) -> bool:
"""Record a hand-added game. False = an addition with this normalized
title is already on file (nothing written — edit that line instead);
a silent True here would let the caller report success for a no-op."""
existing = load_title_additions(path)
norm = normalize_title(entry["title_raw"])
if any(normalize_title(e["title_raw"]) == norm for e in existing):
return False
atomic_write_text(
path, json.dumps([*existing, entry], indent=2, ensure_ascii=False) + "\n"
)
return True
def rescind_title_removal(path: Path, title: str) -> bool:
"""Drop any UNSCOPED removal of this normalized title (a hand-added
line removed earlier). Re-adding a title those records would filter
from every rebuild is an explicit undo, not a conflict. Photo-scoped
removals stay: they veto specific sightings, not the game."""
norm = normalize_title(title)
stored = _load_store(path)
kept = [
item
for item in stored
if not (
(isinstance(item, str) and normalize_title(item) == norm)
or (
isinstance(item, dict)
and not item.get("photos")
and normalize_title(item["title"]) == norm
)
)
]
if len(kept) == len(stored):
return False
atomic_write_text(path, json.dumps(kept, indent=2, ensure_ascii=False) + "\n")
return True
def load_title_splits(path: Path) -> list[dict]:
"""The human's split-into-copies decisions — durable: they must survive
extract rebuilds and resolve --force."""
return _scoped_records(path)
def load_title_removals(path: Path) -> list[dict]:
"""Titles the human removed from the catalog (not a game, a misread of
box art, out of scope) — their sightings are filtered out of every
rebuild, so re-extraction cannot resurrect them. Undo by deleting the
record from the store file."""
return _scoped_records(path)
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 _record_scoped(path: Path, title: str, photos: list[str] | None) -> None:
if is_split(normalize_title(title), photos or [], _scoped_records(path)):
return # an existing record already covers this line
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 record_title_split(path: Path, title: str, photos: list[str] | None = None) -> None:
_record_scoped(path, title, photos)
def record_title_removal(
path: Path, title: str, photos: list[str] | None = None
) -> None:
_record_scoped(path, title, photos)
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 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] = [] result: list[dict] = []
for entry in entries: for entry in entries:
entry = {**entry, "title_normalized": normalize_title(entry["title_raw"])} entry = {**entry, "title_normalized": normalize_title(entry["title_raw"])}
if covered(entry):
result.append(entry)
continue
for existing in result: for existing in result:
if existing["title_normalized"] == entry[ if (
"title_normalized" existing["title_normalized"] == entry["title_normalized"]
] and not cues_conflict(existing, entry): and not covered(existing)
and not cues_conflict(existing, entry)
):
existing.update(_merge(existing, entry)) existing.update(_merge(existing, entry))
break break
else: else:
@@ -256,12 +516,33 @@ def dedupe_entries(entries: list[dict]) -> list[dict]:
return result return result
def apply_title_removals(entries: list[dict], removals: list[dict]) -> list[dict]:
"""Filter out sightings the human removed. Runs AFTER edits (records
key on the title as displayed when removal was clicked) and before
dedupe (so a removed sighting can't be absorbed into a survivor)."""
if not removals:
return entries
return [
e
for e in entries
if not is_split(
normalize_title(e["title_raw"]), e.get("source_photos"), removals
)
]
def rebuild_artifacts( def rebuild_artifacts(
raw_dir: Path, titles_path: Path, unidentified_path: Path raw_dir: Path,
titles_path: Path,
unidentified_path: Path,
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]]]: ) -> tuple[list[dict], dict[str, list[dict]]]:
"""Regenerate titles.json and unidentified.json from the per-photo raw """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 cache. A raw file is either an object with titles/unidentified or a bare
arrays — still readable.""" array — still readable."""
entries: list[dict] = [] entries: list[dict] = []
unidentified: dict[str, list[dict]] = {} unidentified: dict[str, list[dict]] = {}
for raw_file in sorted(raw_dir.glob("*.json")): for raw_file in sorted(raw_dir.glob("*.json")):
@@ -279,7 +560,11 @@ def rebuild_artifacts(
photo = raw_file.name.removesuffix(".json") photo = raw_file.name.removesuffix(".json")
if data.get("unidentified"): if data.get("unidentified"):
unidentified[photo] = data["unidentified"] unidentified[photo] = data["unidentified"]
deduped = dedupe_entries(entries) entries = entries + [dict(e) for e in additions or []]
entries = apply_title_removals(
apply_title_edits(entries, edits or []), removals or []
)
deduped = dedupe_entries(entries, splits)
titles_path.parent.mkdir(parents=True, exist_ok=True) titles_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text( atomic_write_text(
titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n" titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
@@ -290,6 +575,58 @@ def rebuild_artifacts(
return deduped, unidentified return deduped, unidentified
def replay_titles(cfg: Config) -> None:
"""Re-derive titles.json after a stored split or edit changed the rules.
Prefers the raw caches (per-photo cue fidelity); without them (trimmed
or hand-built data) it replays the current titles.json entries through
the same edit + dedupe path, exploding multi-photo entries first so
photo-level splits and targeted edits can take hold."""
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")}
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,
removals,
additions,
)
return
if not cfg.titles_path.exists():
return
exploded: list[dict] = []
for entry in json.loads(cfg.titles_path.read_text()):
photos = entry.get("source_photos") or []
if len(photos) > 1:
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
)
atomic_write_text(
cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
)
def run_extract( def run_extract(
cfg: Config, cfg: Config,
*, *,
@@ -318,7 +655,7 @@ def run_extract(
raw_dir = cfg.extract_raw_dir raw_dir = cfg.extract_raw_dir
raw_dir.mkdir(parents=True, exist_ok=True) raw_dir.mkdir(parents=True, exist_ok=True)
vision = vision or default_vision(cfg.model) vision = vision or vision_for(cfg)
failed: list[str] = [] failed: list[str] = []
consecutive: tuple[str, int] = ("", 0) consecutive: tuple[str, int] = ("", 0)
@@ -370,7 +707,13 @@ def run_extract(
typer.echo(f" {photo.name}: {len(result['titles'])} title(s){note}") typer.echo(f" {photo.name}: {len(result['titles'])} title(s){note}")
deduped, unidentified = rebuild_artifacts( deduped, unidentified = rebuild_artifacts(
raw_dir, cfg.titles_path, cfg.unidentified_path raw_dir,
cfg.titles_path,
cfg.unidentified_path,
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}.") typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
if failed: if failed:
+69 -2
View File
@@ -28,8 +28,22 @@ CONFIG_TEMPLATE = """\
photos_dir = "photos" photos_dir = "photos"
data_dir = "data" data_dir = "data"
model = "claude-sonnet-5"
rate_limit_seconds = 2.0 rate_limit_seconds = 2.0
# Which vision backend reads your shelf photos. Both recipes below stay
# on file; this line picks one.
vision_provider = "anthropic"
[vision.anthropic]
# reads ANTHROPIC_API_KEY from the environment
model = "claude-sonnet-5"
[vision."openai-compatible"]
# OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio, vLLM).
# key_env names the env var holding the key; "" = endpoint needs none.
base_url = "http://localhost:11434/v1"
model = "qwen2.5vl:7b"
key_env = ""
""" """
ENV_HEADER = """\ ENV_HEADER = """\
@@ -74,6 +88,32 @@ class InitReport:
keys_written: list[str] = field(default_factory=list) keys_written: list[str] = field(default_factory=list)
keys_missing: list[str] = field(default_factory=list) keys_missing: list[str] = field(default_factory=list)
browser_installed: bool = False browser_installed: bool = False
warnings: list[str] = field(default_factory=list)
def load_env_file(env_path: Path) -> list[str]:
"""Export .env values into this process's environment — only keys the
environment doesn't already set (a real env var always wins). Returns
the keys loaded. Same parsing rules as _env_file_keys; values go
straight into os.environ and are never printed or logged."""
if not env_path.exists():
return []
loaded = []
for line in env_path.read_text().splitlines():
line = line.strip()
if "=" not in line or line.startswith("#"):
continue
key, _, value = line.partition("=")
key = key.strip()
if key.startswith("export "):
key = key.removeprefix("export ").strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
value = value[1:-1]
if key and value and not os.environ.get(key):
os.environ[key] = value
loaded.append(key)
return loaded
def _env_file_keys(env_path: Path) -> set[str]: def _env_file_keys(env_path: Path) -> set[str]:
@@ -139,6 +179,9 @@ def run_init(
report = InitReport() report = InitReport()
typer.echo("bggpipe setup — checks what exists, fills only the gaps.\n") typer.echo("bggpipe setup — checks what exists, fills only the gaps.\n")
if trap := cfg.tracked_data_warning():
typer.echo(f"WARNING: {trap}\n", err=True)
report.warnings.append(trap)
# -- directories ---------------------------------------------------- # -- directories ----------------------------------------------------
for path in (project_dir / cfg.photos_dir, project_dir / cfg.data_dir): for path in (project_dir / cfg.photos_dir, project_dir / cfg.data_dir):
@@ -159,9 +202,33 @@ def run_init(
typer.echo(f" found {config_path}") typer.echo(f" found {config_path}")
# -- credentials ---------------------------------------------------- # -- credentials ----------------------------------------------------
# the vision key follows the configured provider: an openai-compatible
# setup prompts for ITS key env (or none, for a local endpoint)
from bggpipe.config import load_config
active = load_config(config_path if config_path.exists() else None)
env_keys = list(ENV_KEYS)
if active.vision_provider != "anthropic":
env_keys = [k for k in env_keys if k[0] != "ANTHROPIC_API_KEY"]
if active.vision_key_env:
env_keys.insert(
0,
(
active.vision_key_env,
True,
"vision extraction via "
+ (active.vision_base_url or "the configured endpoint"),
"your vision provider's console",
),
)
else:
typer.echo(
" vision: openai-compatible endpoint with no key configured "
"(local runtime) — nothing to prompt for"
)
env_path = project_dir / ".env" env_path = project_dir / ".env"
in_file = _env_file_keys(env_path) in_file = _env_file_keys(env_path)
for key, secret, why, where in ENV_KEYS: for key, secret, why, where in env_keys:
if os.environ.get(key) or key in in_file: if os.environ.get(key) or key in in_file:
report.keys_ready.append(key) report.keys_ready.append(key)
typer.echo(f" {key}: set") typer.echo(f" {key}: set")
+34 -9
View File
@@ -94,21 +94,34 @@ def _attr_int(elem: ET.Element | None, attr: str = "value") -> int | None:
def parse_search(xml_text: str) -> list[SearchResult]: def parse_search(xml_text: str) -> list[SearchResult]:
results = [] results = []
by_id: dict[int, int] = {} # bgg_id -> index in results
skipped = 0 skipped = 0
for item in _root(xml_text).findall("item"): for item in _root(xml_text).findall("item"):
name_elem = item.find("name") name_elem = item.find("name")
if name_elem is None or item.get("id") is None: if name_elem is None or item.get("id") is None:
skipped += 1 # tolerate stragglers; wholesale drift raises below skipped += 1 # tolerate stragglers; wholesale drift raises below
continue continue
results.append( result = SearchResult(
SearchResult(
bgg_id=int(item.get("id")), bgg_id=int(item.get("id")),
name=name_elem.get("value", ""), name=name_elem.get("value", ""),
name_type=name_elem.get("type", "primary"), name_type=name_elem.get("type", "primary"),
year=_attr_int(item.find("yearpublished")), year=_attr_int(item.find("yearpublished")),
type=item.get("type", "boardgame"), type=item.get("type", "boardgame"),
) )
) # a multi-type search lists an expansion TWICE — once per matched
# type; collapse only SAME-NAME duplicates, preferring the specific
# type so expansion tagging (the base-vs-expansion review guard)
# survives. A duplicate under a DIFFERENT name stays: it may be the
# alternate name that exact-matches the query, and dropping it
# would silently downgrade the match to fuzzy.
dedupe_key = (result.bgg_id, result.name.casefold())
if dedupe_key in by_id:
seen = results[by_id[dedupe_key]]
if seen.type == "boardgame" and result.type != "boardgame":
results[by_id[dedupe_key]] = result
continue
by_id[dedupe_key] = len(results)
results.append(result)
if skipped and results: if skipped and results:
warnings.warn( warnings.warn(
f"search: {skipped} unparseable item(s) tolerated — schema drift?", f"search: {skipped} unparseable item(s) tolerated — schema drift?",
@@ -146,6 +159,8 @@ def parse_things(xml_text: str) -> list[ThingDetails]:
for item in _root(xml_text).findall("item"): for item in _root(xml_text).findall("item"):
name = item.find("name[@type='primary']") name = item.find("name[@type='primary']")
rank_elem = item.find(".//ranks/rank[@name='boardgame']") rank_elem = item.find(".//ranks/rank[@name='boardgame']")
if rank_elem is None: # RPGGeek items rank in their own family
rank_elem = item.find(".//ranks/rank[@name='rpgitem']")
versions = [ versions = [
v v
for v_item in item.findall("versions/item") for v_item in item.findall("versions/item")
@@ -223,18 +238,28 @@ def parse_things_full(xml_text: str) -> list[dict]:
"min_playtime": _attr_int(item.find("minplaytime")), "min_playtime": _attr_int(item.find("minplaytime")),
"max_playtime": _attr_int(item.find("maxplaytime")), "max_playtime": _attr_int(item.find("maxplaytime")),
"min_age": _attr_int(item.find("minage")), "min_age": _attr_int(item.find("minage")),
"designers": links("boardgamedesigner"), # RPGGeek items live in the same database but use their own
"artists": links("boardgameartist"), # link types, so a board-game-only reader finds none of them
"publishers": links("boardgamepublisher"), "designers": links("boardgamedesigner") + links("rpgdesigner"),
"categories": links("boardgamecategory"), "artists": links("boardgameartist") + links("rpgartist"),
"mechanics": links("boardgamemechanic"), "publishers": links("boardgamepublisher") + links("rpgpublisher"),
"categories": links("boardgamecategory")
+ links("rpggenre")
+ links("rpgcategory"),
"mechanics": links("boardgamemechanic") + links("rpgmechanic"),
"producers": links("rpgproducer"),
"series": links("rpgseries"),
"rating": _attr_float(ratings.find("average")) "rating": _attr_float(ratings.find("average"))
if ratings is not None if ratings is not None
else None, else None,
"weight": _attr_float(ratings.find("averageweight")) "weight": _attr_float(ratings.find("averageweight"))
if ratings is not None if ratings is not None
else None, else None,
"rank": _attr_int(item.find(".//ranks/rank[@name='boardgame']")), "rank": _attr_int(
item.find(".//ranks/rank[@name='boardgame']")
if item.find(".//ranks/rank[@name='boardgame']") is not None
else item.find(".//ranks/rank[@name='rpgitem']")
),
"users_owned": _attr_int(ratings.find("owned")) "users_owned": _attr_int(ratings.find("owned"))
if ratings is not None if ratings is not None
else None, else None,
+100 -21
View File
@@ -22,7 +22,7 @@ from rapidfuzz import fuzz
from bggpipe.bgg_client import BGGAuthError, BGGClient, BGGQueueTimeout, client_for from bggpipe.bgg_client import BGGAuthError, BGGClient, BGGQueueTimeout, client_for
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.extract import cues_conflict from bggpipe.extract import cues_conflict, is_split, load_title_splits
from bggpipe.fsio import atomic_write_csv from bggpipe.fsio import atomic_write_csv
from bggpipe.models import ( from bggpipe.models import (
RECOGNIZED_MATCH_STATUSES, RECOGNIZED_MATCH_STATUSES,
@@ -89,6 +89,7 @@ class Candidate:
type: str type: str
exact: bool exact: bool
fuzzy: float fuzzy: float
sibling: bool = False # exactness earned via edition-suffix stripping
owned: int | None = None owned: int | None = None
rank: int | None = None rank: int | None = None
publishers: list[str] = field(default_factory=list) publishers: list[str] = field(default_factory=list)
@@ -190,11 +191,13 @@ def _truncation_heads(title_raw: str) -> list[str]:
match = _GAME_WORD.search(title_raw) match = _GAME_WORD.search(title_raw)
if match and match.start() > 0: if match and match.start() > 0:
heads.append(title_raw[: match.start()]) heads.append(title_raw[: match.start()])
# last resort: first two words — of the pre-subtitle part, so a title # last resort: shrink from the right, longest first — a printed title
# like "Blorvath: Quest of the Zzyzx" never yields "Blorvath: Quest" # can bury the real name in the middle ("ALICE IS MISSING A SILENT ROLE
# PLAYING GAME" is "Alice is Missing"). Only exact normalized matches
# count for heads, so a short head cannot match loosely.
words = (sep_head or title_raw).split() words = (sep_head or title_raw).split()
if len(words) > 2: for size in range(len(words) - 1, 1, -1):
heads.append(" ".join(words[:2])) heads.append(" ".join(words[:size]))
seen: set[str] = {normalize_title(title_raw)} seen: set[str] = {normalize_title(title_raw)}
unique: list[str] = [] unique: list[str] = []
@@ -203,7 +206,7 @@ def _truncation_heads(title_raw: str) -> list[str]:
if norm and norm not in seen: if norm and norm not in seen:
seen.add(norm) seen.add(norm)
unique.append(head) unique.append(head)
return unique[:3] return unique[:6]
def _plausible_candidates( def _plausible_candidates(
@@ -226,6 +229,18 @@ def _plausible_candidates(
for result in results: for result in results:
norm = normalize_title(result.name) norm = normalize_title(result.name)
exact = norm == entry.title_normalized exact = norm == entry.title_normalized
# BGG files new editions as SEPARATE games named "X (Nth Edition)":
# a name that equals the title once its trailing parenthetical is
# stripped is a sibling edition — exact-grade, or the match looks
# unanimously confident while hiding the real choice (Wiz-War has
# three same-named lineages; the spec's top failure mode)
sibling = False
if not exact and result.type != "boardgameexpansion":
sibling = (
normalize_title(_EDITION_SUFFIX.sub("", result.name))
== entry.title_normalized
)
exact = sibling
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized) fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
if not exact and fuzzy < FUZZY_THRESHOLD: if not exact and fuzzy < FUZZY_THRESHOLD:
if not (head_normalized and norm == head_normalized): if not (head_normalized and norm == head_normalized):
@@ -237,6 +252,7 @@ def _plausible_candidates(
year=result.year, year=result.year,
type=result.type, type=result.type,
exact=exact, exact=exact,
sibling=sibling,
fuzzy=fuzzy, fuzzy=fuzzy,
) )
prev = by_id.get(result.bgg_id) prev = by_id.get(result.bgg_id)
@@ -255,7 +271,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
return row return row
if len(cands) == 1: if len(cands) == 1:
chosen = cands[0] # a lone candidate must still earn trust: BGG's search visibly
# truncates generic queries (the game named "Dungeon!" appears in
# NEITHER of its own searches), so the sole survivor may be an
# impostor standing where the famous game should be. Sibling-grade
# exactness never autos alone, and a true exact must clear the
# same ownership floor the dominance rule enforces.
only = cands[0]
stats = {t.bgg_id: t for t in client.things([only.bgg_id], stats=True)}
if only.bgg_id in stats:
only.owned = stats[only.bgg_id].owned
only.rank = stats[only.bgg_id].rank
only.publishers = list(stats[only.bgg_id].publishers)
row.candidates = [only]
if only.sibling or not only.exact or (only.owned or 0) < DOMINANCE_MIN_OWNED:
row.match_status = "ambiguous"
return row
chosen = only
else: else:
top = cands[:5] top = cands[:5]
stats = { stats = {
@@ -380,7 +412,7 @@ def resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> None
"languages": list(v.languages), "languages": list(v.languages),
"score": s, "score": s,
} }
for v, s in plausible[:8] for v, s in plausible # every plausible version: no silent cap
] ]
if len(plausible) == 1 or plausible[0][1] > plausible[1][1]: if len(plausible) == 1 or plausible[0][1] > plausible[1][1]:
winner = plausible[0][0] winner = plausible[0][0]
@@ -391,8 +423,40 @@ def resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> None
row.version_status = "version_ambiguous" row.version_status = "version_ambiguous"
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow: _EDITION_SUFFIX = re.compile(r"\s*\([^)]*\)\s*$")
cands = _plausible_candidates(client, entry, entry.title_raw) _PUNCT = re.compile(r"[^\w\s]", re.UNICODE)
def _depunct(title: str) -> str:
"""BGG's search engine can choke on punctuation — the game literally
named "Dungeon!" is missing from its own 824-result search. A plain
query recovers it."""
return " ".join(_PUNCT.sub(" ", title).split())
def _merged_candidates(
client: BGGClient, entry: TitleEntry, query: str, types: str | None = None
) -> list[Candidate]:
"""Search the raw title AND its punctuation-free form, merged by id
(strongest evidence wins)."""
cands = _plausible_candidates(client, entry, query, types=types)
plain = _depunct(query)
if plain.casefold() != query.casefold():
by_id = {c.bgg_id: c for c in cands}
for c in _plausible_candidates(client, entry, plain, types=types):
prev = by_id.get(c.bgg_id)
if prev is None or (c.exact, c.fuzzy) > (prev.exact, prev.fuzzy):
by_id[c.bgg_id] = c
cands = sorted(by_id.values(), key=lambda c: (not c.exact, -c.fuzzy))
return cands
def find_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]:
"""Every search this pipeline knows, in order of confidence. Public
because review's reopen path must search exactly as resolve did — a
partial re-implementation there silently misses whatever the later
steps would have found."""
cands = _merged_candidates(client, entry, entry.title_raw)
if not cands: if not cands:
# Long transcribed box titles ("CIVILIZATION Game of the Heroic # Long transcribed box titles ("CIVILIZATION Game of the Heroic
# Age - ...") defeat search: retry with progressively shorter heads # Age - ...") defeat search: retry with progressively shorter heads
@@ -406,9 +470,7 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
# type=rpgitem (same API, same token). A hit becomes a LOCAL # type=rpgitem (same API, same token). A hit becomes a LOCAL
# library citizen: identified and enriched, never uploaded (diff # library citizen: identified and enriched, never uploaded (diff
# routes rpgitem rows to local_only). # routes rpgitem rows to local_only).
cands = _plausible_candidates( cands = _merged_candidates(client, entry, entry.title_raw, types="rpgitem")
client, entry, entry.title_raw, types="rpgitem"
)
if not cands: if not cands:
for head in _truncation_heads(entry.title_raw): for head in _truncation_heads(entry.title_raw):
cands = _plausible_candidates( cands = _plausible_candidates(
@@ -416,6 +478,11 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
) )
if cands: if cands:
break break
return cands
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
cands = find_candidates(client, entry)
row = _classify(client, entry, cands) row = _classify(client, entry, cands)
if row.match_status == "auto": if row.match_status == "auto":
resolve_version(client, entry, row) resolve_version(client, entry, row)
@@ -430,7 +497,11 @@ class MergeEvent:
bgg_id: str bgg_id: str
def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEvent]: def dedupe_matches(
rows: list[dict],
titles: list[TitleEntry],
splits: list[dict] | None = None,
) -> list[MergeEvent]:
"""Post-resolve dedupe: rows resolving to the same (bgg_id, version_id — """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 or both version-unknown) are the same physical game seen twice (a typo
read, a partial spine) UNLESS their extraction cues conflict, which read, a partial spine) UNLESS their extraction cues conflict, which
@@ -464,6 +535,12 @@ def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEven
# re-running resolve must never overturn that (spec: re-runs # re-running resolve must never overturn that (spec: re-runs
# lose no work, least of all review decisions) # lose no work, least of all review decisions)
continue continue
if is_split(
normalize_title(row["title_raw"]),
row["source_photos"].split(";"),
splits or [],
):
continue # human-split copies: per-photo rows stay separate
key = ( key = (
row["bgg_id"], row["bgg_id"],
row["version_id"] if is_confident_version(row) else "", row["version_id"] if is_confident_version(row) else "",
@@ -579,8 +656,6 @@ def run_resolve(
return candidates[0] return candidates[0]
return None return None
seen_per_title: dict[str, int] = {}
new_rows: list[MatchRow] = [] new_rows: list[MatchRow] = []
skipped = 0 skipped = 0
photos_updated = False photos_updated = False
@@ -599,13 +674,10 @@ def run_resolve(
paired_by_id[id(entry)] = row_dict paired_by_id[id(entry)] = row_dict
for entry in entries: for entry in entries:
seen_per_title[entry.title_raw] = seen_per_title.get(entry.title_raw, 0) + 1
row_dict = paired_by_id.get(id(entry)) row_dict = paired_by_id.get(id(entry))
if row_dict is not None: if row_dict is not None:
photos = ";".join(entry.source_photos) photos = ";".join(entry.source_photos)
if row_dict["source_photos"] != photos and not row_dict.get( if row_dict["source_photos"] != photos and not row_dict.get("dedupe_veto"):
"dedupe_veto"
):
# provenance follows the entry — except on split/vetoed rows, # provenance follows the entry — except on split/vetoed rows,
# whose per-copy photo sets are human-authored # whose per-copy photo sets are human-authored
row_dict["source_photos"] = photos row_dict["source_photos"] = photos
@@ -639,7 +711,7 @@ def run_resolve(
typer.echo(f" {entry.title_raw!r} -> {row.match_status}: {detail}{version}") typer.echo(f" {entry.title_raw!r} -> {row.match_status}: {detail}{version}")
all_rows = existing_rows + [row.to_csv() for row in new_rows] all_rows = existing_rows + [row.to_csv() for row in new_rows]
merges = dedupe_matches(all_rows, entries) merges = dedupe_matches(all_rows, entries, load_title_splits(cfg.title_splits_path))
if new_rows or photos_updated or merges: if new_rows or photos_updated or merges:
write_matches(cfg.matches_path, all_rows) # atomic full rewrite write_matches(cfg.matches_path, all_rows) # atomic full rewrite
if merges: if merges:
@@ -656,6 +728,13 @@ def run_resolve(
f"Resolved {len(new_rows)} title(s) ({summary or 'nothing new'}); " f"Resolved {len(new_rows)} title(s) ({summary or 'nothing new'}); "
f"skipped {skipped} already in {cfg.matches_path}." f"skipped {skipped} already in {cfg.matches_path}."
) )
waiting = sum(1 for r in all_rows if r.get("match_status") == "unmatched")
if waiting:
typer.echo(
f" {waiting} unmatched row(s) wait for YOUR call in review "
"(re-search or manual id) — resolve never overrides a human "
"decision, including 'this match is wrong'"
)
if blocked: if blocked:
typer.echo( typer.echo(
f"\n{len(blocked)} title(s) are waiting on the BGG API " f"\n{len(blocked)} title(s) are waiting on the BGG API "
+220 -6
View File
@@ -28,9 +28,13 @@ from bggpipe.models import (
UNDECIDED_MATCH_STATUSES, UNDECIDED_MATCH_STATUSES,
BGGResponseError, BGGResponseError,
) )
from bggpipe.normalize import normalize_title
from bggpipe.resolve import ( from bggpipe.resolve import (
Candidate,
MatchRow, MatchRow,
TitleEntry, TitleEntry,
_score_version,
find_candidates,
load_titles, load_titles,
read_matches, read_matches,
resolve_version, resolve_version,
@@ -158,6 +162,12 @@ class ReviewSession:
"you decided — decision NOT saved" "you decided — decision NOT saved"
) )
return 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: try:
own_mtime = write_matches(self.cfg.matches_path, self.rows) own_mtime = write_matches(self.cfg.matches_path, self.rows)
except OSError: except OSError:
@@ -258,6 +268,44 @@ class ReviewSession:
self._save(copies[0]) self._save(copies[0])
return copies return copies
def drop_rows(
self,
title_raw: str,
photos: list[str] | None = None,
new_title: str | None = None,
even_vetoed: bool = False,
) -> 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.
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.
`even_vetoed` is for title REMOVAL, where the human is explicitly
discarding the line, vetted or not."""
self.reload_if_changed()
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") and not even_vetoed:
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._write_rows()
return dropped
def veto_merge(self, row: dict) -> None: def veto_merge(self, row: dict) -> None:
"""The human says these are NOT the same physical game: restore the """The human says these are NOT the same physical game: restore the
row as a distinct, human-confirmed match.""" row as a distinct, human-confirmed match."""
@@ -283,9 +331,17 @@ class ReviewSession:
undecided = [ undecided = [
row for row in matches if row["match_status"] in UNDECIDED_MATCH_STATUSES row for row in matches if row["match_status"] in UNDECIDED_MATCH_STATUSES
] ]
if undecided or matches: if undecided:
return (undecided or matches)[0] return undecided[0]
return None # both siblings decided (two-edition duplicates): prefer the one
# whose VERSION is still open, as _adopt does — otherwise a stale
# row_ix would land a version pick on the already-decided sibling
version_open = [
row for row in matches if row["version_status"] == "version_ambiguous"
]
if version_open:
return version_open[0]
return matches[0] if matches else None
def cues_for( def cues_for(
self, title_raw: str, source_photos: str | None = None self, title_raw: str, source_photos: str | None = None
@@ -311,6 +367,158 @@ class ReviewSession:
row["match_status"] = "rejected" row["match_status"] = "rejected"
self._save(row) self._save(row)
def decide_local(self, row: dict) -> None:
"""A real game BGG simply doesn't have: a local library citizen —
listed and enriched from its own photo reads, never uploaded."""
row["match_status"] = "local"
row["bgg_id"] = ""
row["bgg_name"] = ""
row["version_status"] = ""
row["version_id"] = ""
row["version_name"] = ""
self._save(row)
def research(self, row: dict, query: str, types: str | None = None) -> int:
"""Re-search on the human's terms and put the results on the row's
ballot. `types` targets a specific database — "rpgitem" for
RPGGeek, whose entries the automatic cascade never reaches when BGG
has a same-named BOARD game (every D&D box hits this)."""
query = query.strip()
if not query:
raise ValueError("a search needs some text")
results = (
self.client.search(query, types) if types else self.client.search(query)
)
merged: dict[int, Candidate] = {}
for r in results:
cand = Candidate(
bgg_id=r.bgg_id,
name=r.name,
year=r.year,
type=r.type,
exact=normalize_title(r.name) == normalize_title(query),
fuzzy=0.0,
)
prior = merged.get(r.bgg_id)
# one ballot line per game; an alternate-name row that exact-
# matches the query outranks the primary-name row for it
if prior is None or (cand.exact and not prior.exact):
merged[r.bgg_id] = cand
cands = list(merged.values())[:12]
if cands:
try:
stats = {
t.bgg_id: t
for t in self.client.things([c.bgg_id for c in cands], stats=True)
}
except _BGG_ERRORS as err:
# owned counts and ranks only decorate the ballot — losing
# them must not lose the search the human just asked for
self._warn(f"couldn't fetch stats for these results ({err})")
stats = {}
for c in cands:
if c.bgg_id in stats:
c.owned = stats[c.bgg_id].owned
c.rank = stats[c.bgg_id].rank
c.publishers = list(stats[c.bgg_id].publishers)
row["match_status"] = "ambiguous" if cands else "unmatched"
row["candidates_json"] = json.dumps(
[c.as_json() for c in cands], ensure_ascii=False
)
# a fresh ballot supersedes any earlier verdict on this row —
# including the VERSION verdicts, which belong to the old game: a
# surviving version_id would ride into the next pick and put the
# wrong game's edition on the collection entry
row["bgg_id"] = ""
row["bgg_name"] = ""
row["version_status"] = ""
row["version_id"] = ""
row["version_name"] = ""
row["version_candidates_json"] = "[]"
self._save(row)
return len(cands)
def reopen_match(self, row: dict) -> None:
"""The human says a matched row is the WRONG game: clear the match
and immediately re-search so the card comes back as a ballot — the
machine re-OFFERS, only the human re-decides (run_resolve never
touches unmatched rows, so without this the row would just sit).
If BGG is unreachable the row still reopens, bare: re-search and
manual id remain available on the card."""
row["match_status"] = "unmatched"
row["bgg_id"] = ""
row["bgg_name"] = ""
row["year"] = ""
row["type"] = ""
row["candidates_json"] = "[]"
row["version_status"] = ""
row["version_id"] = ""
row["version_name"] = ""
row["version_candidates_json"] = "[]"
entry = self.cues_for(row["title_raw"], row["source_photos"]) or TitleEntry(
title_raw=row["title_raw"],
title_normalized=normalize_title(row["title_raw"]),
)
try:
# the FULL cascade resolve uses — board games, truncation heads,
# then RPGGeek — so a row parked as "local" gets the same look
cands = find_candidates(self.client, entry)[:8]
if cands:
stats = {
t.bgg_id: t
for t in self.client.things([c.bgg_id for c in cands], stats=True)
}
for c in cands:
if c.bgg_id in stats:
c.owned = stats[c.bgg_id].owned
c.rank = stats[c.bgg_id].rank
c.publishers = list(stats[c.bgg_id].publishers)
row["match_status"] = "ambiguous"
row["candidates_json"] = json.dumps(
[c.as_json() for c in cands], ensure_ascii=False
)
except _BGG_ERRORS as err:
self._warn(
f"re-search unavailable ({err}) — the row is reopened; use "
"(f) re-search or (m) manual id on its card"
)
self._save(row)
def open_version_ballot(self, row: dict) -> int:
"""The human knows which printing a box is even when the photo
showed no cues: put EVERY published version on the row's ballot
(cue-scored when cues exist) and mark it version_ambiguous so the
normal edition pass presents it. Returns the ballot size."""
if not row["bgg_id"]:
raise ValueError("no BGG match on this row yet")
entry = self.cues_for(row["title_raw"], row["source_photos"])
things = self.client.things([int(row["bgg_id"])], versions=True)
if not things or not things[0].versions:
raise ValueError("BGG lists no versions for this game")
scored = sorted(
((v, _score_version(entry, v) if entry else 0) for v in things[0].versions),
key=lambda pair: (-pair[1], str(pair[0].year or "")),
)
row["version_candidates_json"] = json.dumps(
[
{
"version_id": v.version_id,
"name": v.name,
"year": v.year,
"publishers": list(v.publishers),
"languages": list(v.languages),
"score": s,
}
for v, s in scored
],
ensure_ascii=False,
)
row["version_status"] = "version_ambiguous"
row["version_id"] = ""
row["version_name"] = ""
self._save(row)
return len(scored)
def decide_version(self, row: dict, version_id: int | None) -> None: def decide_version(self, row: dict, version_id: int | None) -> None:
"""Pick a version from the row's stored candidates, or None -> unknown.""" """Pick a version from the row's stored candidates, or None -> unknown."""
if version_id is None: if version_id is None:
@@ -362,10 +570,10 @@ class ReviewSession:
while True: while True:
self._show_item(row, candidates) self._show_item(row, candidates)
prompt = ( prompt = (
"[1-N] pick (s)kip (r)eject (m <id>) manual BGG id " "[1-N] pick (s)kip (r)eject (l)ocal — not on BGG "
"(f <text>) re-search (q)uit > " "(m <id>) manual BGG id (f <text>) re-search (q)uit > "
if row["match_status"] == "unmatched" if row["match_status"] == "unmatched"
else "[1-N] pick (s)kip (r)eject (q)uit > " else "[1-N] pick (s)kip (r)eject (l)ocal — not on BGG (q)uit > "
) )
answer = self._ask(prompt) answer = self._ask(prompt)
lowered = answer.lower() lowered = answer.lower()
@@ -374,6 +582,9 @@ class ReviewSession:
if lowered == "r": if lowered == "r":
self.decide_reject(row) self.decide_reject(row)
return return
if lowered == "l":
self.decide_local(row)
return
if answer.isdigit() and 1 <= int(answer) <= len(candidates): if answer.isdigit() and 1 <= int(answer) <= len(candidates):
self.decide_pick(row, candidates[int(answer) - 1]) self.decide_pick(row, candidates[int(answer) - 1])
return return
@@ -407,6 +618,9 @@ class ReviewSession:
def _research(self, query: str) -> list[dict]: def _research(self, query: str) -> list[dict]:
try: try:
results = self.client.search(query) results = self.client.search(query)
if not results:
# board games first, then RPGGeek — same database, same token
results = self.client.search(query, "rpgitem")
except _BGG_ERRORS as err: except _BGG_ERRORS as err:
self.console.print(f"[yellow]search unavailable: {err}[/yellow]") self.console.print(f"[yellow]search unavailable: {err}[/yellow]")
return [] return []
+203 -33
View File
@@ -1,4 +1,4 @@
/* bggpipe design system — one stylesheet, two pages (dashboard, review). /* bggpipe design system — one stylesheet for the whole app.
* *
* The visual world is Juniper's mascot drawing: a sky-blue day, flat cel * The visual world is Juniper's mascot drawing: a sky-blue day, flat cel
* color inside confident dark outlines, a cream game board with a rainbow * color inside confident dark outlines, a cream game board with a rainbow
@@ -30,7 +30,12 @@
--gold: #f2c04b; /* pipe fittings: rings, trim, hovers */ --gold: #f2c04b; /* pipe fittings: rings, trim, hovers */
--gold-ink: #8a6414; --gold-ink: #8a6414;
--ticket: #fdf3d2; /* reshoot work-orders */ --ticket: #fdf3d2; /* reshoot work-orders */
--focus: #8330c2; --go-tint: #e2f2e4; /* pale state washes of the role colors */
--stop-tint: #fbe3da;
--gold-tint: #fdeebb;
--accent-tint: #ece5f7;
--navy-tint: #e3ecf3;
--focus: var(--accent);
--path: linear-gradient(90deg, --path: linear-gradient(90deg,
#f767b8, #f79a3e, #f2c04b, #6fce6f, #5aa7f0, #9a5be0); #f767b8, #f79a3e, #f2c04b, #6fce6f, #5aa7f0, #9a5be0);
--path-v: linear-gradient(180deg, --path-v: linear-gradient(180deg,
@@ -59,7 +64,6 @@ body {
} }
main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; } main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; }
h2 { h2 {
color: var(--ink);
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 700; font-size: 1.1rem; letter-spacing: .02em; font-weight: 700; font-size: 1.1rem; letter-spacing: .02em;
margin: 2rem 0 .8rem; margin: 2rem 0 .8rem;
@@ -121,6 +125,7 @@ nav[aria-label="Primary"] a[aria-current="page"] {
display: block; margin: 0 auto; display: block; margin: 0 auto;
} }
.piperbox figcaption { font-size: .72rem; opacity: .8; margin-top: .45rem; } .piperbox figcaption { font-size: .72rem; opacity: .8; margin-top: .45rem; }
.navtoggle { display: none; }
.piperbox .legal { font-size: .62rem; opacity: .6; line-height: 1.45; text-align: left; } .piperbox .legal { font-size: .62rem; opacity: .6; line-height: 1.45; text-align: left; }
.skip { .skip {
position: absolute; left: -999px; top: 0; z-index: 10; position: absolute; left: -999px; top: 0; z-index: 10;
@@ -163,7 +168,7 @@ kbd {
border: var(--line); border: var(--line);
background: var(--board); background: var(--board);
} }
.banner.error { border-color: var(--stop); color: var(--stop-ink); background: #fbe3da; } .banner.error { border-color: var(--stop); color: var(--stop-ink); background: var(--stop-tint); }
.banner.warn { border-color: var(--gold-ink); color: var(--gold-ink); background: var(--ticket); } .banner.warn { border-color: var(--gold-ink); color: var(--gold-ink); background: var(--ticket); }
/* -- buttons ----------------------------------------------------------- */ /* -- buttons ----------------------------------------------------------- */
@@ -192,6 +197,17 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
display: flex; gap: 1.1rem; display: flex; gap: 1.1rem;
scroll-margin-top: 5rem; scroll-margin-top: 5rem;
} }
.card.prose { display: block; }
.card.prose p, .card.prose ol { margin: .55rem 0; line-height: 1.55; }
.card.prose > :first-child { margin-top: 0; }
.card.prose > :last-child { margin-bottom: 0; }
.artcard { text-align: center; }
.artcard img {
width: 100%; max-width: 15rem;
border: var(--line); border-radius: var(--radius-lg);
background: var(--sky);
}
.artcard .legal { font-size: .74rem; color: var(--ink-soft); max-width: 30rem; margin: .6rem auto 0; }
.card.active { .card.active {
border-color: var(--accent); border-color: var(--accent);
box-shadow: var(--shadow-raised); box-shadow: var(--shadow-raised);
@@ -231,11 +247,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
.cname { font-weight: 600; } .cname { font-weight: 600; }
.cmeta { color: var(--ink-soft); font-size: .8rem; margin-left: .4rem; } .cmeta { color: var(--ink-soft); font-size: .8rem; margin-left: .4rem; }
.rowactions { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; } .rowactions { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; }
.rowactions button { .rowactions button { padding: .25rem .7rem; box-shadow: none; }
font: inherit; border: var(--line); background: #fff;
border-radius: var(--radius); padding: .25rem .7rem; cursor: pointer;
}
.rowactions button.reject { color: var(--stop-ink); border-color: var(--stop); } .rowactions button.reject { color: var(--stop-ink); border-color: var(--stop); }
.research { display: flex; gap: .35rem; align-items: center; flex-wrap: wrap; }
.research input[type=text] { width: 12em; }
.research button { font-size: .78rem; }
.rowactions input[type=text] { .rowactions input[type=text] {
font: inherit; width: 8.5em; padding: .25rem .5rem; font: inherit; width: 8.5em; padding: .25rem .5rem;
border: 2px solid var(--board-edge); border-radius: var(--radius); border: 2px solid var(--board-edge); border-radius: var(--radius);
@@ -264,10 +280,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
.ticket .partial { font-family: var(--font-mono); font-size: .85rem; } .ticket .partial { font-family: var(--font-mono); font-size: .85rem; }
.ticket .notes { color: var(--gold-ink); font-size: .85rem; margin-top: .2rem; } .ticket .notes { color: var(--gold-ink); font-size: .85rem; margin-top: .2rem; }
.ticket button { .ticket button {
font: inherit; font-size: .8rem; margin-top: .5rem; font-size: .8rem; margin-top: .5rem;
background: none; border: 1px solid var(--gold-ink); color: var(--gold-ink); background: none; border: 1px solid var(--gold-ink); color: var(--gold-ink);
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer; padding: .2rem .6rem; box-shadow: none;
box-shadow: none;
} }
/* -- all-done celebration ---------------------------------------------- */ /* -- all-done celebration ---------------------------------------------- */
@@ -280,7 +295,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
border-radius: var(--radius-lg); border: var(--line); border-radius: var(--radius-lg); border: var(--line);
background: var(--sky); background: var(--sky);
} }
.done h2 { color: var(--ink); margin-top: 0; } .done h2 { margin-top: 0; }
.done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; } .done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
.done .nums div { font-size: 1.6rem; font-weight: 700; font-family: var(--font-display); } .done .nums div { font-size: 1.6rem; font-weight: 700; font-family: var(--font-display); }
.done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } .done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
@@ -310,26 +325,56 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
text-underline-offset: 2px; text-underline-offset: 2px;
} }
.catalog a:hover { color: var(--accent-ink); } .catalog a:hover { color: var(--accent-ink); }
.catalog td.actions { text-align: right; }
.catalog td.actions button { margin: .1rem 0 .1rem .3rem; white-space: nowrap; }
.catalog td.actions .ballotlink {
display: inline-block; margin: .1rem 0 .1rem .3rem; white-space: nowrap;
font-size: .78rem; color: var(--accent-ink);
text-decoration: underline dotted; text-underline-offset: 3px;
}
.catalog td.actions button { font-size: .78rem; padding: .2rem .6rem; 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 {
display: flex; flex-direction: column; gap: .15rem;
font-size: .72rem; text-transform: uppercase; letter-spacing: .06em;
color: var(--ink-soft);
}
.editform input {
font: inherit; font-size: .85rem; color: var(--ink);
border: 2px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem .45rem; background: #fff;
}
.editactions { display: flex; gap: .5rem; }
.edithint { font-size: .78rem; color: var(--ink-soft); align-self: center; }
.chip { .chip {
font-size: .68rem; text-transform: uppercase; letter-spacing: .06em; font-size: .68rem; text-transform: uppercase; letter-spacing: .06em;
border-radius: 999px; padding: .1rem .55rem; white-space: nowrap; border-radius: 999px; padding: .1rem .55rem; white-space: nowrap;
border: 1px solid currentColor; border: 1px solid currentColor;
} }
.chip.ok { background: #e2f2e4; color: var(--go-ink); } .card code {
font-family: var(--font-mono); font-size: .82em;
background: #fff; border: 1px solid var(--board-edge);
border-radius: 4px; padding: 0 .3em;
}
.card ol { padding-left: 1.3rem; }
.card ol li { margin: .35rem 0; }
.chip.ok { background: var(--go-tint); color: var(--go-ink); }
.chip.wait { background: var(--ticket); color: var(--gold-ink); } .chip.wait { background: var(--ticket); color: var(--gold-ink); }
.chip.no { background: #fbe3da; color: var(--stop-ink); } .chip.no { background: var(--stop-tint); color: var(--stop-ink); }
.chip.open { background: #ece5f7; color: var(--accent-ink); } .chip.open { background: var(--accent-tint); color: var(--accent-ink); }
.chip.merged { background: #e3ecf3; color: var(--navy); } .chip.merged { background: var(--navy-tint); color: var(--navy); }
.chip.shaky { background: var(--ticket); color: var(--gold-ink); border: 1px dashed var(--gold-ink); }
/* -- merge notices: slim, undoable ------------------------------------- */ /* -- merge notices: slim, undoable ------------------------------------- */
.card.merge { padding: .55rem 1rem; align-items: center; } .card.merge { padding: .55rem 1rem; align-items: center; }
.card.merge .body { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; } .card.merge .body { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
.card.merge .arrow { color: var(--ink-soft); } .card.merge .arrow { color: var(--ink-soft); }
.card.merge button { .card.merge button {
font: inherit; font-size: .8rem; margin-left: auto; font-size: .8rem; margin-left: auto;
background: none; border: 1px solid var(--board-edge); background: none; border: 1px solid var(--board-edge);
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer; padding: .2rem .6rem; box-shadow: none;
box-shadow: none;
} }
.card.merge button:hover { border-color: var(--stop); color: var(--stop-ink); } .card.merge button:hover { border-color: var(--stop); color: var(--stop-ink); }
@@ -364,7 +409,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
font: inherit; cursor: pointer; font: inherit; cursor: pointer;
box-shadow: none; box-shadow: none;
} }
#dropzone.hot, #dropzone:hover { border-style: solid; background: #fdeebb; } #dropzone.hot, #dropzone:hover { border-style: solid; background: var(--gold-tint); }
#dropzone[aria-busy="true"] { cursor: progress; opacity: .8; }
#dropzone.ok { border-style: solid; border-color: var(--go-ink); color: var(--go-ink); background: var(--go-tint); }
#joblog { #joblog {
background: var(--navy); color: #eaf1fb; background: var(--navy); color: #eaf1fb;
font-family: var(--font-mono); font-size: .78rem; font-family: var(--font-mono); font-size: .78rem;
@@ -373,13 +420,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
max-height: 20rem; overflow-y: auto; white-space: pre-wrap; max-height: 20rem; overflow-y: auto; white-space: pre-wrap;
} }
#jobstate { font-size: .85rem; color: var(--ink); margin-bottom: .4rem; } #jobstate { font-size: .85rem; color: var(--ink); margin-bottom: .4rem; }
#jobstate .running { color: var(--accent-ink); font-weight: 600; } #jobstate .s-running { color: var(--accent-ink); font-weight: 600; }
#jobstate .failed { color: var(--stop-ink); font-weight: 600; } #jobstate .s-done { color: var(--go-ink); font-weight: 600; }
#jobstate .s-failed { color: var(--stop-ink); font-weight: 600; }
@media (max-width: 700px) { /* (phone-width rules live in the responsive section at the end) */
.card, .ticket { flex-direction: column; }
.shots { flex-basis: auto; }
}
/* -- photos page: gallery + status ------------------------------------- */ /* -- photos page: gallery + status ------------------------------------- */
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .9rem; } .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .9rem; }
@@ -418,6 +463,41 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
.game .info { padding: .55rem .7rem .7rem; } .game .info { padding: .55rem .7rem .7rem; }
.game .gname { font-weight: 700; font-family: var(--font-display); } .game .gname { font-weight: 700; font-family: var(--font-display); }
.game .gmeta { font-size: .78rem; color: var(--ink-soft); margin-top: .2rem; line-height: 1.5; } .game .gmeta { font-size: .78rem; color: var(--ink-soft); margin-top: .2rem; line-height: 1.5; }
/* -- library: card grid links + one game's detail ---------------------- */
a.game { text-decoration: none; color: inherit; }
a.game:hover { border-color: var(--accent); box-shadow: var(--shadow-raised); }
a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.gamedetail { display: flex; gap: 1.4rem; align-items: flex-start; flex-wrap: wrap; }
.gameartcol { flex: 0 0 clamp(12rem, 28vw, 18rem); }
.gameart {
width: 100%; border: var(--line); border-radius: var(--radius-lg);
box-shadow: var(--shadow-card); display: block; background: var(--board);
}
.gameart.noart {
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
background: var(--sky-deep); color: var(--navy);
font-family: var(--font-display); font-size: 3rem; font-weight: 700;
}
.gamefacts {
flex: 1 1 18rem; background: var(--board); border: var(--line);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
padding: .9rem 1.1rem;
}
.factrow { margin: .45rem 0; line-height: 1.5; }
.factrow b {
font-size: .72rem; text-transform: uppercase; letter-spacing: .06em;
color: var(--ink-soft); margin-right: .4rem;
}
.chiplist { display: inline-flex; flex-wrap: wrap; gap: .3rem; vertical-align: middle; }
.gdesc { white-space: pre-wrap; line-height: 1.6; }
.artbtn { margin-top: .6rem; width: 100%; font-size: .8rem; }
.editform label.wide { flex: 1 1 100%; }
.editform textarea {
font: inherit; font-size: .85rem; color: var(--ink); width: 100%;
border: 2px solid var(--board-edge); border-radius: var(--radius);
padding: .35rem .45rem; background: #fff; resize: vertical;
}
.empty { .empty {
background: var(--board); border: 2px dashed var(--board-edge); background: var(--board); border: 2px dashed var(--board-edge);
border-radius: var(--radius-lg); padding: 2rem; text-align: center; border-radius: var(--radius-lg); padding: 2rem; text-align: center;
@@ -436,18 +516,108 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
border: var(--line); border-radius: var(--radius); background: #fff; border: var(--line); border-radius: var(--radius); background: #fff;
} }
/* -- responsive: rail collapses to a top strip -------------------------- */ /* -- toast: viewport-pinned transient confirmations --------------------- */
#toast {
position: fixed; bottom: 1.1rem; left: 50%; transform: translateX(-50%);
z-index: 20; max-width: min(92vw, 34rem);
background: var(--navy); color: #fff;
border: var(--line); border-radius: var(--radius-lg);
box-shadow: var(--shadow-raised);
padding: .7rem 1rem; font-size: .9rem; line-height: 1.45;
}
#toast a { color: var(--gold); }
#toast button {
font-size: .8rem; padding: .25rem .7rem; margin-left: .5rem;
box-shadow: none;
}
/* -- responsive -------------------------------------------------------- */
/* The rail collapses to a top bar: brand + hamburger; the nav drops
* down as a stacked menu (a scrolling strip lost its position on every
* page load and hid the far entries). */
@media (max-width: 900px) { @media (max-width: 900px) {
body { display: block; } body { display: block; }
.sidebar { .sidebar {
position: sticky; height: auto; flex-direction: row; align-items: center; position: sticky; top: 0; height: auto; z-index: 5;
flex-wrap: wrap; gap: 0 .5rem; z-index: 5; display: flex; flex-direction: row; align-items: center; flex-wrap: wrap;
border-right: none; border-bottom: 4px solid transparent; border-right: none; border-bottom: 4px solid transparent;
border-image: var(--path) 1; border-image: var(--path) 1;
} }
.brand { padding: .5rem .8rem; } .brand { padding: .5rem .8rem; flex: 1; }
nav[aria-label="Primary"] { flex-direction: row; flex-wrap: wrap; } .brand img { width: 30px; height: 30px; }
nav[aria-label="Primary"] a { border-left: none; border-bottom: 3px solid transparent; padding: .35rem .6rem; } .wordmark { font-size: 1.1rem; }
nav[aria-label="Primary"] a[aria-current="page"] { border-bottom-color: var(--gold); } .wordmark small { display: inline; margin-left: .5rem; font-size: .68rem; }
.navtoggle {
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 40px; margin-right: .6rem;
background: none; border: 2px solid rgba(255, 255, 255, .75);
border-radius: var(--radius); box-shadow: none; padding: 0;
}
.navtoggle .bars {
display: block; position: relative;
width: 18px; height: 2px; background: #fff; border-radius: 2px;
}
.navtoggle .bars::before, .navtoggle .bars::after {
content: ""; position: absolute; left: 0;
width: 18px; height: 2px; background: #fff; border-radius: 2px;
}
.navtoggle .bars::before { top: -6px; }
.navtoggle .bars::after { top: 6px; }
body.navopen .navtoggle { background: rgba(255, 255, 255, .15); }
nav[aria-label="Primary"] {
display: none; flex-basis: 100%; flex-direction: column;
padding: 0 0 .4rem;
}
body.navopen nav[aria-label="Primary"] { display: flex; }
nav[aria-label="Primary"] a { padding: .6rem 1rem; }
.piperbox { display: none; } .piperbox { display: none; }
} }
/* Phone-width layout: stacked cards, stacked tables, touch targets. */
@media (max-width: 700px) {
main { padding: 1rem .9rem 4rem; }
h1 { font-size: 1.3rem; margin: .9rem 0 .7rem; }
.keyhelp { display: none; } /* no keyboard on a phone */
button, .linkbtn { padding: .45rem .9rem; } /* finger-sized */
/* component buttons set their own padding at higher specificity —
* they need the touch bump spelled out */
.rowactions button, .ticket button, .card.merge button { padding: .4rem .8rem; }
.card, .ticket { flex-direction: column; }
.shots { flex-basis: auto; }
.ticket .stencil {
writing-mode: horizontal-tb; letter-spacing: .35em;
border-right: none; border-bottom: 1px solid var(--gold-ink);
padding: 0 0 .3rem;
}
.done { padding: 1.1rem .9rem; }
.done .nums { flex-wrap: wrap; gap: .8rem 1.6rem; }
.filterbar input[type=search] { min-width: 0; flex: 1 1 12rem; }
.editform { flex-direction: column; align-items: stretch; }
.editform input { width: 100%; }
.editactions { flex-wrap: wrap; }
.editactions button { flex: 1 1 auto; }
/* the titles/photo tables become stacked line-cards: one bordered
* block per game, cells flowing top to bottom, empty cells gone */
.catalog table, .catalog tbody, .catalog tr, .catalog td { display: block; }
.catalog tr { padding: .6rem 0; border-top: 1px solid var(--board-edge); }
.catalog tr:first-child { border-top: none; }
.catalog tr:hover td { background: none; }
.catalog td { border-top: none; padding: .12rem 0; }
.catalog td:empty { display: none; }
.catalog .t { max-width: none; font-size: .95rem; }
.catalog td.actions {
text-align: left; white-space: normal;
display: flex; gap: .5rem; flex-wrap: wrap; padding-top: .4rem;
}
.catalog td.actions button { padding: .4rem .8rem; }
.catalog tr.editrow { padding-top: 0; border-top: none; }
/* queue/library ledgers keep their columns but scroll inside their
* own card; long photo lists wrap within their cell */
.ledger { overflow-x: auto; }
.ledger td { overflow-wrap: anywhere; min-width: 6rem; }
}
+103 -2
View File
@@ -10,9 +10,25 @@ const esc = s => String(s ?? "").replace(/[&<>"']/g,
* pass through esc() at the call site. The API serves only local pipeline * pass through esc() at the call site. The API serves only local pipeline
* data, but photo names and BGG titles still count as untrusted. */ * data, but photo names and BGG titles still count as untrusted. */
function showBanner(html) { function showBanner(html) {
const el = document.getElementById("banner"); document.getElementById("banner").innerHTML = html;
}
/* Transient confirmation pinned to the viewport: the #banner region sits
* at the top of the page, which a user deep in a long list never sees.
* The toast may carry buttons — wire them before the timer clears it. */
function showToast(html, ms = 8000) {
let el = document.getElementById("toast");
if (!el) {
el = document.createElement("div");
el.id = "toast";
el.setAttribute("role", "status"); el.setAttribute("role", "status");
document.body.appendChild(el);
}
el.innerHTML = html; el.innerHTML = html;
el.hidden = false;
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => { el.hidden = true; }, ms);
return el;
} }
function errorBanner(detail) { function errorBanner(detail) {
@@ -49,6 +65,21 @@ async function apiPost(url, body) {
return res; return res;
} }
/* Per-page change gate for poll loops: render only when the payload
* changed, and record it only after render returns — a throw leaves the
* page stale, so the next poll retries instead of freezing on "current". */
function changeGate() {
let seen = null;
const gate = (value, render) => {
const key = JSON.stringify(value);
if (key === seen) return;
render();
seen = key;
};
gate.reset = () => { seen = null; };
return gate;
}
/* Poll `fn` every `ms`; after 3 consecutive failures show the lost-contact /* Poll `fn` every `ms`; after 3 consecutive failures show the lost-contact
* banner, and clear it (via `recovered`) on the next success. */ * banner, and clear it (via `recovered`) on the next success. */
function pollLoop(fn, ms, recovered) { function pollLoop(fn, ms, recovered) {
@@ -66,6 +97,16 @@ function pollLoop(fn, ms, recovered) {
}, ms); }, ms);
} }
/* Mobile: the top bar's hamburger opens the nav; navigation reloads the
* page, so each page starts with the menu closed. */
{
const toggle = document.querySelector(".navtoggle");
if (toggle) toggle.addEventListener("click", () => {
const open = document.body.classList.toggle("navopen");
toggle.setAttribute("aria-expanded", String(open));
});
}
/* Sidebar badges: the counts that mean "something wants your attention". */ /* Sidebar badges: the counts that mean "something wants your attention". */
async function refreshBadges() { async function refreshBadges() {
const p = await fetchJSON("/api/pipeline"); const p = await fetchJSON("/api/pipeline");
@@ -74,6 +115,7 @@ async function refreshBadges() {
if (el) el.textContent = n > 0 ? String(n) : ""; if (el) el.textContent = n > 0 ? String(n) : "";
}; };
set("photos", p.reshoot); set("photos", p.reshoot);
set("titles", p.shaky_reads);
set("review", p.pending_review); set("review", p.pending_review);
set("queue", p.to_add + p.to_update); set("queue", p.to_add + p.to_update);
return p; return p;
@@ -81,11 +123,70 @@ async function refreshBadges() {
refreshBadges().catch(() => {}); refreshBadges().catch(() => {});
setInterval(() => refreshBadges().catch(() => {}), 5000); setInterval(() => refreshBadges().catch(() => {}), 5000);
/* One-line match summary for a titles/photo table row. Empty when the
* row has no BGG data yet, so the mobile stacker can hide the cell. */
/* Canonical outbound URL for a matched thing. RPG items live on RPGGeek
* (same database, different site) — a /boardgame/ URL is the wrong home
* for them. */
function bggUrl(id, type) {
return type === "rpgitem"
? `https://rpggeek.com/rpgitem/${encodeURIComponent(id)}`
: `https://boardgamegeek.com/boardgame/${encodeURIComponent(id)}`;
}
function metaLine(c) {
const name = !c.bgg_name ? "" : c.bgg_id
? `<a href="${bggUrl(c.bgg_id, c.type)}" target="_blank" rel="noopener"
title="open on ${c.type === "rpgitem" ? "RPGGeek" : "BGG"}">${esc(c.bgg_name)} ↗</a>`
: esc(c.bgg_name);
return [
name,
c.version_name ? esc(c.version_name) : "",
c.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : "",
].filter(Boolean).join(" · ");
}
/* Reshoot work-order card, shared by the photos gallery and the photo
* detail page (which drops the thumbnail and source line — the photo is
* right there). Dismiss buttons are wired by wireDismiss below. */
function ticketCard(s, {showPhoto = true} = {}) {
const img = showPhoto && s.photo_exists
? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
<img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
: "";
return `
<section class="ticket"
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
<span class="stencil">reshoot</span>
${img}
<div>
<div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
${showPhoto ? `<div class="notes">from ${esc(s.photo)} — take a closer shot and drop it above</div>` : ""}
<button class="dismiss">dismiss — found it / not a game</button>
</div>
</section>`;
}
function wireDismiss(root, done) {
root.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
const t = b.closest(".ticket");
const res = await apiPost("/api/dismiss", {
photo: t.dataset.photo, location: t.dataset.location,
partial_text: t.dataset.partial, art_notes: t.dataset.art,
});
if (res) done();
}));
}
/* Status chip for a catalog entry — shared by the catalog and photo pages. */ /* Status chip for a catalog entry — shared by the catalog and photo pages. */
function statusChip(c) { function statusChip(c) {
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`; if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting resolve</span>`;
if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${esc(c.status)}</span>`; if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${esc(c.status)}</span>`;
if (c.status === "rejected") return `<span class="chip no">rejected</span>`; if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
if (c.status === "local") return `<span class="chip open">local — not on BGG</span>`;
if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`; if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
return `<span class="chip open">${esc(c.status)}</span>`; return `<span class="chip open">${esc(c.status)}</span>`;
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

-68
View File
@@ -1,68 +0,0 @@
<h1>Catalog</h1>
<div class="pagebar"><span id="catcount"></span></div>
<div class="filterbar">
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter catalog titles">
</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 = [];
function render() {
const q = document.getElementById("catsearch").value.trim().toLowerCase();
const rows = q
? CATALOG.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
: CATALOG;
document.getElementById("catcount").innerHTML =
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
document.getElementById("catbody").innerHTML = rows.length
? `<div class="catalog"><table>` + rows.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}
${c.split_copy ? `<span class="chip merged">copy</span>` : ""}</td>
<td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}
${c.type === "rpgitem" ? ` <span class="chip open">RPG · local only</span>` : ""}</td>
<td class="meta">${c.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(", ")}</td>
<td>${c.can_split
? `<button class="split" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix}"
title="one line, several boxes? make each photo its own copy">
split into copies</button>`
: ""}</td>
</tr>`).join("") + `</table></div>`
: `<p class="empty">${CATALOG.length
? "No titles match that filter."
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
}
let LAST = null;
async function refresh() {
const state = await fetchJSON("/api/state");
const payload = JSON.stringify(state.catalog);
if (payload === LAST) return;
LAST = payload;
CATALOG = state.catalog;
render();
}
document.getElementById("catbody").addEventListener("click", async e => {
const b = e.target.closest("button.split");
if (!b) return;
const n = b.dataset.photos.split(";").length;
if (!confirm(`Split "${b.dataset.title}" into ${n} separate copies (one per photo)? ` +
`Each picks its own edition afterward.`)) return;
const res = await apiPost("/api/split", {
title_raw: b.dataset.title,
source_photos: b.dataset.photos,
row_ix: Number(b.dataset.rowix),
});
if (res) refresh().catch(() => {});
});
document.getElementById("catsearch").addEventListener("input", render);
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner(""));
</script>
+80
View File
@@ -0,0 +1,80 @@
<h1>Help</h1>
<div class="pagebar">
<a href="#flow">The flow</a>
<a href="#pages">Pages</a>
<a href="#curation">Fixing the titles</a>
<a href="#statuses">Statuses</a>
<a href="#keys">Keyboard</a>
<a href="#files">Your data</a>
</div>
<h2 id="flow">The flow: shelves → collection</h2>
<div class="card prose">
<p>Six pipeline stages and two checkpoints that are yours. Stages run from the <a href="/">Pipeline</a> page (or the CLI — both share all state and either can pick up where the other left off):</p>
<ol>
<li><b>extract</b> — every photo goes to the vision model once (Claude by default; a local model works too — see the README); what it reads (titles plus edition cues: publisher, edition wording, year, language) lands on the Titles page. Boxes it can see but can't read become <b>reshoot tickets</b> on the Photos page.</li>
<li><b>proofread</b> <i>(you, on <a href="/titles">Titles</a>)</i> — fix misread titles, add cues you know, split multi-box lines, remove non-games, <a href="#curation">details below</a>. Worth doing <i>before</i> resolve: a fix made now is one BGG search done right; a fix made later sends the title back through resolve again.</li>
<li><b>resolve</b> — titles are matched to BoardGameGeek games and editions. Anything uncertain is flagged, never guessed. Without a BGG API token, titles wait as <i>awaiting BGG</i> and are picked up automatically once the token exists.</li>
<li><b>review</b> <i>(you, on <a href="/review">Review</a>)</i> — decide the flagged ones: which game, which edition, whether two reads are one box. Every decision saves immediately.</li>
<li><b>diff</b> — your existing BGG collection is fetched and compared, per copy. What's genuinely new lands in the <a href="/queue">Queue</a>.</li>
<li><b>upload</b> — a real browser logs into BGG and adds each queued game, slowly and politely. Always dry-run first; the buttons enforce that order.</li>
<li><b>enrich</b> — full metadata (players, weight, rank, artwork) fills the <a href="/library">Library</a>.</li>
</ol>
<p>It's a loop, not a line: new photos, edits, and splits feed both checkpoints again, and the sidebar badges show when a page has work for you. Every stage is resumable — stop anything mid-run and nothing is lost; re-runs skip work already done.</p>
</div>
<h2 id="pages">What each page is for</h2>
<div class="card prose">
<p><b><a href="/">Pipeline</a></b> — run stages one at a time and watch their live output. Shows what's blocking (missing keys, stub data) and the counts at every step.</p>
<p><b><a href="/photos">Photos</a></b> — drag photos in, drop them in the <code>photos/</code> folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique <code>shelf-…</code> names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same <i>file name</i> deliberately replaces it, and the next extract run re-reads it.</p>
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
<p><b><a href="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p>
</div>
<h2 id="curation">Fixing the titles: edit, split, remove</h2>
<div class="card prose">
<p>Vision reads aren't perfect, and you know things the photos don't show. Every line on the Titles page has curation actions, and every one of them is <b>durable</b>: the decision is saved in a small committed file and replayed on every rebuild, so re-running extract or resolve can never undo it.</p>
<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>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>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>
<h2 id="statuses">What the status chips mean</h2>
<div class="card prose">
<p><span class="chip wait">awaiting resolve</span> extracted (or re-queued by an edit) but not yet matched — run <b>resolve</b> from the Pipeline page.</p>
<p><span class="chip ok">auto</span> matched confidently, no review needed. <span class="chip ok">approved</span> you picked the match yourself.</p>
<p><span class="chip open">ambiguous</span> several plausible games — needs your pick on Review. <span class="chip open">unmatched</span> nothing plausible found — enter a BGG id or re-search on Review.</p>
<p><span class="chip merged">merged</span> two reads judged to be the same physical box; the merge is veto-able on Review. <span class="chip merged">copy</span> one copy of a title you split.</p>
<p><span class="chip open">local — not on BGG</span> you ruled it's a real game BGG doesn't have: it joins the Library from its own photo reads (fill in its facts and add a cover photo on its Library page), and never uploads. Changed your mind — or suspect it's on RPGGeek after all? <b>look it up</b> on its Titles row searches again, RPGGeek included.</p>
<p><span class="chip no">rejected</span> you ruled it's a bad read or not worth matching; it stays listed but goes no further.</p>
<p><span class="chip shaky">shaky read</span> the vision model wasn't sure of this transcription and nothing has verified it yet — these are what the Titles badge counts. Clear one by pressing its <b>✓ looks right</b> (the read is fine as-is) or by editing it (you fixed it). A BGG match also clears it: a wrong read wouldn't have matched.</p>
</div>
<h2 id="keys">Keyboard shortcuts</h2>
<div class="card prose">
<p><b>Review:</b> <kbd>j</kbd>/<kbd>k</kbd> move between cards · <kbd>1</kbd><kbd>9</kbd> pick a candidate · <kbd>r</kbd> reject · <kbd>l</kbd> keep local (not on BGG) · <kbd>m</kbd> manual BGG id · <kbd>u</kbd> edition unknown · <kbd>v</kbd> veto a merge.</p>
<p><b>Photo pages:</b> <kbd></kbd>/<kbd></kbd> move between photos.</p>
</div>
<h2 id="files">Your data, on disk</h2>
<div class="card prose">
<p>Everything lives in flat files under <code>data/</code> — inspectable, hand-editable, and git-friendly. The pipeline artifacts: <code>titles.json</code> (what was read), <code>matches.csv</code> (what it matched), <code>to_add.csv</code>/<code>to_update.csv</code> (what upload will do), <code>upload_log.csv</code> (what it did), <code>games.json</code> (the library). Your curation: <code>title_edits.json</code>, <code>title_splits.json</code>, <code>title_removals.json</code>, <code>unidentified_dismissed.json</code>.</p>
<p>Credentials never live in files — only environment variables, set up by <code>bggpipe init</code>. The app serves localhost only, unless started with <code>--lan</code> — that opens it to your network behind an access key: scan the QR code the server prints (or open the printed link) once per device, and a year-long cookie keeps it paired. Delete <code>data/.lan_key</code> to revoke every device. No login beyond the key; trusted networks only.</p>
<p>More depth: the README covers setup and photo technique; <code>docs/bgg-upload-flow.md</code> documents the upload automation.</p>
</div>
<h2 id="mascot">The piper</h2>
<div class="card prose artcard">
<img src="/static/logo-full.jpg"
alt="the bggpipe piper — a bagpiper whose bag is a board game box">
<p>Mascot art by Juniper, used with pride.</p>
<p class="legal">BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC.
bggpipe is an independent project, not affiliated with or endorsed by
BoardGameGeek.</p>
</div>
+63 -17
View File
@@ -1,10 +1,25 @@
<h1>Library</h1> <h1>Library</h1>
<div class="pagebar"><span id="libcount"></span></div> <div class="pagebar"><span id="libcount"></span></div>
<div class="filterbar" role="group" aria-label="filter by kind"> <div class="filterbar" role="group" aria-label="filter by kind">
<input type="search" id="libsearch" placeholder="search your games…" aria-label="search library"> <input type="search" id="libsearch" placeholder="search titles, designers, mechanics…"
aria-label="search library">
<button type="button" data-kind="" aria-pressed="true">All</button> <button type="button" data-kind="" aria-pressed="true">All</button>
<button type="button" data-kind="boardgame" aria-pressed="false">Board games</button> <button type="button" data-kind="boardgame" aria-pressed="false">Board games</button>
<button type="button" data-kind="rpgitem" aria-pressed="false">RPGs</button> <button type="button" data-kind="rpgitem" aria-pressed="false">RPGs</button>
<button type="button" data-kind="localgame" aria-pressed="false">Off BGG</button>
</div>
<div class="filterbar">
<label>plays with
<input type="number" id="libplayers" min="1" max="20" placeholder="any"
style="width:5em" aria-label="filter by player count"></label>
<label>sort
<select id="libsort" aria-label="sort library">
<option value="name">name</option>
<option value="year">year (newest)</option>
<option value="rank">BGG rank</option>
<option value="weight">weight (lightest)</option>
<option value="playtime">playing time</option>
</select></label>
</div> </div>
<div id="libbody"></div> <div id="libbody"></div>
<script> <script>
@@ -14,39 +29,72 @@ let KIND = ""; // "" = all; "boardgame" also covers expansions
function gameCard(g) { function gameCard(g) {
const art = g.thumbnail || g.image; const art = g.thumbnail || g.image;
const players = g.min_players const lo = g.min_players ?? g.max_players, hi = g.max_players ?? g.min_players;
? (g.min_players === g.max_players ? `${g.min_players}` : `${g.min_players}${g.max_players}`) + " players" const players = lo
? (lo === hi ? `${lo}` : `${lo}${hi}`) + " players"
: ""; : "";
const time = g.playtime ? `${g.playtime} min` : ""; const time = g.playtime ? `${g.playtime} min` : "";
const weight = g.weight ? `weight ${g.weight.toFixed(1)}` : ""; const weight = g.weight ? `weight ${g.weight.toFixed(1)}` : "";
const rank = g.rank ? `BGG rank ${g.rank}` : ""; const rank = g.rank ? `BGG rank ${g.rank}` : "";
return ` return `
<article class="game"> <a class="game" href="/library/game/${encodeURIComponent(g.key)}">
${art ${art
? `<img src="${esc(art)}" alt="" loading="lazy">` ? `<img src="${esc(art)}" alt="" loading="lazy">`
: `<div class="noart" aria-hidden="true">${esc((g.name || "?").charAt(0).toUpperCase())}</div>`} : `<div class="noart" aria-hidden="true">${esc((g.name || "?").charAt(0).toUpperCase())}</div>`}
<div class="info"> <div class="info">
<div class="gname">${esc(g.name)}${g.year ? ` <span class="meta">(${esc(g.year)})</span>` : ""} <div class="gname">${esc(g.name)}${g.year ? ` <span class="meta">(${esc(g.year)})</span>` : ""}
${g.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : ""}</div> ${g.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : ""}
${g.type === "localgame" ? `<span class="chip open">not on BGG · local</span>` : ""}</div>
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div> <div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""} ${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""}
</div> </div>
</article>`; </a>`;
} }
function matchesKind(g) { function matchesKind(g) {
if (!KIND) return true; if (!KIND) return true;
if (KIND === "boardgame") return g.type !== "rpgitem"; if (KIND === "boardgame") return !["rpgitem", "localgame"].includes(g.type);
return g.type === "rpgitem"; return g.type === KIND;
} }
// null sorts last whichever way the column runs: an unranked game is not
// "rank 0", and a game with no weight is not the lightest
function by(field, dir = 1) {
return (a, b) => {
const x = a[field], y = b[field];
if (x == null && y == null) return 0;
if (x == null) return 1;
if (y == null) return -1;
return (x - y) * dir;
};
}
const SORTS = {
name: (a, b) => (a.name || "").localeCompare(b.name || "", undefined, {sensitivity: "base"}),
year: by("year", -1),
rank: by("rank"),
weight: by("weight"),
playtime: by("playtime"),
};
function render() { function render() {
const q = document.getElementById("libsearch").value.trim().toLowerCase(); const q = document.getElementById("libsearch").value.trim().toLowerCase();
const seats = Number(document.getElementById("libplayers").value) || 0;
let rows = GAMES.filter(matchesKind); let rows = GAMES.filter(matchesKind);
if (q) rows = rows.filter(g => if (q) rows = rows.filter(g => [
(g.name + " " + (g.designers || []).join(" ")).toLowerCase().includes(q)); g.name,
(g.designers || []).join(" "),
(g.mechanics || []).join(" "),
(g.categories || []).join(" "),
g.version && g.version.name,
].filter(Boolean).join(" ").toLowerCase().includes(q));
if (seats) rows = rows.filter(g =>
(g.min_players || g.max_players || 0) <= seats
&& seats <= (g.max_players || g.min_players || 0));
rows = [...rows].sort(SORTS[document.getElementById("libsort").value] || SORTS.name);
document.getElementById("libcount").innerHTML = document.getElementById("libcount").innerHTML =
`<b>${rows.length}</b> of <b>${GAMES.length}</b> game(s)`; `<b>${rows.length}</b> of <b>${GAMES.length}</b> game(s)`
+ (seats ? ` playable with <b>${seats}</b>` : "");
document.getElementById("libbody").innerHTML = rows.length document.getElementById("libbody").innerHTML = rows.length
? `<div class="shelfgrid">${rows.map(gameCard).join("")}</div>` ? `<div class="shelfgrid">${rows.map(gameCard).join("")}</div>`
: `<p class="empty">${GAMES.length : `<p class="empty">${GAMES.length
@@ -56,17 +104,15 @@ function render() {
Run the pipeline through <code>enrich</code> to fill these shelves.`}</p>`; Run the pipeline through <code>enrich</code> to fill these shelves.`}</p>`;
} }
let LAST = null; const GATE = changeGate();
async function refresh() { async function refresh() {
const games = await fetchJSON("/api/library"); const games = await fetchJSON("/api/library");
const payload = JSON.stringify(games); GATE(games, () => { GAMES = games; render(); });
if (payload === LAST) return;
LAST = payload;
GAMES = games;
render();
} }
document.getElementById("libsearch").addEventListener("input", render); document.getElementById("libsearch").addEventListener("input", render);
document.getElementById("libplayers").addEventListener("input", render);
document.getElementById("libsort").addEventListener("change", render);
document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => { document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => {
KIND = b.dataset.kind; KIND = b.dataset.kind;
document.querySelectorAll("[data-kind]").forEach(o => document.querySelectorAll("[data-kind]").forEach(o =>
@@ -0,0 +1,185 @@
<h1 id="gname">Loading…</h1>
<div class="pagebar">
<a href="/library">← library</a>
<span id="gcrumb"></span>
</div>
<div id="gbody"><p class="empty">Loading…</p></div>
<script>
"use strict";
const KEY = decodeURIComponent(location.pathname.replace(/^\/library\/game\//, ""));
function chips(label, values) {
if (!values || !values.length) return "";
return `<div class="factrow"><b>${esc(label)}</b>
<span class="chiplist">${values.map(v =>
`<span class="cue">${esc(v)}</span>`).join("")}</span></div>`;
}
function fact(label, value) {
return value === null || value === undefined || value === ""
? ""
: `<div class="factrow"><b>${esc(label)}</b> ${esc(value)}</div>`;
}
function players(g) {
const lo = g.min_players, hi = g.max_players;
if (!lo && !hi) return "";
const range = lo === hi ? `${lo}` : `${lo ?? "?"}${hi ?? "?"}`;
const best = (g.best_player_counts || []).length
? ` (best at ${esc(g.best_player_counts.join(", "))})`
: "";
return `${range} players${best}`;
}
function playtime(g) {
if (!g.playtime && !g.min_playtime) return "";
if (g.min_playtime && g.max_playtime && g.min_playtime !== g.max_playtime)
return `${g.min_playtime}${g.max_playtime} min`;
return `${g.playtime || g.min_playtime} min`;
}
function localForm(g) {
const v = (x) => (x === null || x === undefined ? "" : x);
return `
<h2>Your notes</h2>
<div class="card prose">
<p class="meta">BGG has no entry for this game, so what you type here is
all it will ever know. Saved to <code>data/local_games.json</code>.</p>
<form class="editform" id="localform">
<label>Title <input name="name" value="${esc(v(g.name))}" required></label>
<label>Year <input name="year" value="${esc(v(g.year))}" inputmode="numeric" size="6"></label>
<label>Players from <input name="min_players" value="${esc(v(g.min_players))}" size="3"></label>
<label>to <input name="max_players" value="${esc(v(g.max_players))}" size="3"></label>
<label>Minutes <input name="playtime" value="${esc(v(g.playtime))}" size="5"></label>
<label>Publishers <input name="publishers" value="${esc((g.publishers || []).join(", "))}"></label>
<label>Designers <input name="designers" value="${esc((g.designers || []).join(", "))}"></label>
<label class="wide">Notes
<textarea name="description" rows="4">${esc(v(g.description))}</textarea></label>
<span class="editactions"><button type="submit" class="primary">save</button></span>
</form>
</div>`;
}
function wireLocal(g) {
const form = document.getElementById("localform");
if (form) form.addEventListener("submit", async e => {
e.preventDefault();
const f = Object.fromEntries(new FormData(form).entries());
const res = await apiPost(`/api/local-game/${encodeURIComponent(KEY)}`, f);
if (res) {
showToast("saved — run <b>enrich</b> to fold this into the library");
refresh();
}
});
const btn = document.getElementById("artbtn");
const file = document.getElementById("artfile");
if (btn) btn.addEventListener("click", () => file.click());
if (file) file.addEventListener("change", async () => {
if (!file.files.length) return;
const body = new FormData();
body.append("file", file.files[0]);
btn.disabled = true;
btn.textContent = "uploading…";
let res = null;
try {
res = await fetch(`/api/local-art/${encodeURIComponent(KEY)}`, {method: "POST", body});
} catch (err) {
alert("Upload failed: " + err);
}
btn.disabled = false;
if (res && res.ok) {
showToast("photo saved — run <b>enrich</b> to fold it into the library");
refresh();
} else if (res) {
const detail = await res.json().then(d => d.detail).catch(() => null);
alert("Upload failed: " + (detail ?? res.statusText));
refresh();
}
});
}
function render(g) {
document.getElementById("gname").textContent = g.name || "(unnamed)";
document.title = `${g.name} · bggpipe`;
const local = g.type === "localgame";
const rpg = g.type === "rpgitem";
document.getElementById("gcrumb").innerHTML = [
g.year ? `<b>${esc(g.year)}</b>` : "",
local ? `<span class="chip open">not on BGG · local</span>` : "",
rpg ? `<span class="chip open">RPG · local only</span>` : "",
g.bgg_id
? `<a href="${bggUrl(g.bgg_id, g.type)}" target="_blank" rel="noopener">
view on ${rpg ? "RPGGeek" : "BGG"} ↗</a>`
: "",
].filter(Boolean).join(" · ");
const art = g.image
? `<img class="gameart" src="${esc(g.image)}" alt="box art for ${esc(g.name)}">`
: `<div class="gameart noart">${esc((g.name || "?")[0])}</div>`;
// an off-BGG game has no publisher art and no API to fetch any: the
// owner's own photo is the only cover it will ever have
const artAdd = local
? `<button id="artbtn" class="artbtn">${g.image ? "replace" : "add"} a photo</button>
<input id="artfile" type="file" accept=".jpg,.jpeg,.png,.heic" hidden
aria-label="cover photo for ${esc(g.name)}">`
: "";
const facts = [
fact("players", players(g)),
fact("playing time", playtime(g)),
fact("ages", g.min_age ? `${g.min_age}+` : ""),
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
fact("BGG rank", g.rank || ""),
fact("BGG rating", g.rating ? g.rating.toFixed(2) : ""),
fact("owned by", g.users_owned ? `${g.users_owned.toLocaleString()} people` : ""),
chips("designers", g.designers),
chips("artists", (g.artists || []).slice(0, 8)),
chips("publishers", (g.publishers || []).slice(0, 6)),
chips("categories", g.categories),
chips("mechanics", g.mechanics),
].filter(Boolean).join("");
const version = g.version
? `<h2>Your edition</h2>
<div class="card prose">
<div class="factrow"><b>${esc(g.version.name || "—")}</b>
${g.version.year ? ` · ${esc(g.version.year)}` : ""}</div>
${chips("publishers", g.version.publishers)}
${chips("languages", g.version.languages)}
</div>`
: `<h2>Your edition</h2>
<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 photos = (g.photos || []).length
? `<h2>Seen on your shelves</h2>
<div class="card prose"><p>${g.photos.map(p =>
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(" · ")}</p></div>`
: "";
document.getElementById("gbody").innerHTML = `
<div class="gamedetail">
<div class="gameartcol">${art}${artAdd}</div>
<div class="gamefacts">${facts}</div>
</div>
${local ? localForm(g) : ""}
${version}
${photos}
${g.description && !local ? `<h2>About</h2>
<div class="card prose"><p class="gdesc">${esc(g.description)}</p></div>` : ""}`;
wireLocal(g);
}
async function refresh() {
try {
render(await fetchJSON(`/api/library/${encodeURIComponent(KEY)}`));
} catch (err) {
document.getElementById("gbody").innerHTML =
`<p class="empty">This game isn't in the library
(${esc(err.message || err)}) — it may need <b>enrich</b> to run.
Back to the <a href="/library">library</a>.</p>`;
}
}
refresh();
</script>
+6 -33
View File
@@ -13,22 +13,7 @@
const NAME = decodeURIComponent(location.pathname.split("/").pop()); const NAME = decodeURIComponent(location.pathname.split("/").pop());
document.getElementById("photoname").textContent = NAME; document.getElementById("photoname").textContent = NAME;
document.getElementById("rawlink").href = `/photos/${encodeURIComponent(NAME)}`; document.getElementById("rawlink").href = `/photos/${encodeURIComponent(NAME)}`;
document.title = `bggpipe — ${NAME}`; document.title = `${NAME} · bggpipe`;
function ticket(s) {
return `
<section class="ticket"
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
<span class="stencil">reshoot</span>
<div>
<div class="loc">${esc(s.location) || "somewhere in this photo"}</div>
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
<button class="dismiss">dismiss — found it / not a game</button>
</div>
</section>`;
}
function render(state, photos) { function render(state, photos) {
const info = photos.find(p => p.name === NAME); const info = photos.find(p => p.name === NAME);
@@ -64,8 +49,7 @@ function render(state, photos) {
<tr> <tr>
<td class="t">${esc(c.title_raw)}</td> <td class="t">${esc(c.title_raw)}</td>
<td>${statusChip(c)}</td> <td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""} <td class="meta">${metaLine(c)}</td>
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
</tr>`).join("") + `</table></div>` </tr>`).join("") + `</table></div>`
: `<p class="empty">${info.extracted : `<p class="empty">${info.extracted
? "No titles were read from this photo." ? "No titles were read from this photo."
@@ -73,30 +57,19 @@ function render(state, photos) {
if (tickets.length) { if (tickets.length) {
html += `<h2>Reshoot tickets <span class="count">— boxes seen here but not identified</span></h2>`; html += `<h2>Reshoot tickets <span class="count">— boxes seen here but not identified</span></h2>`;
html += tickets.map(ticket).join(""); html += tickets.map(s => ticketCard(s, {showPhoto: false})).join("");
} }
body.innerHTML = html; body.innerHTML = html;
wireDismiss(body, () => refresh().catch(() => {}));
body.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
const t = b.closest(".ticket");
const res = await apiPost("/api/dismiss", {
photo: t.dataset.photo, location: t.dataset.location,
partial_text: t.dataset.partial, art_notes: t.dataset.art,
});
if (res) refresh().catch(() => {});
}));
} }
let LAST = null; const GATE = changeGate();
async function refresh() { async function refresh() {
const [state, photos] = await Promise.all([ const [state, photos] = await Promise.all([
fetchJSON("/api/state"), fetchJSON("/api/state"),
fetchJSON("/api/photos-list"), fetchJSON("/api/photos-list"),
]); ]);
const payload = JSON.stringify([state.catalog, state.unidentified, photos]); GATE([state.catalog, state.unidentified, photos], () => render(state, photos));
if (payload === LAST) return;
LAST = payload;
render(state, photos);
} }
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
+36 -39
View File
@@ -11,40 +11,12 @@
<script> <script>
"use strict"; "use strict";
function ticket(s) {
const img = s.photo_exists
? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
<img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
: "";
return `
<section class="ticket"
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
<span class="stencil">reshoot</span>
${img}
<div>
<div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
<div class="notes">from ${esc(s.photo)} — take a closer shot and drop it above</div>
<button class="dismiss">dismiss — found it / not a game</button>
</div>
</section>`;
}
function render(state, photos) { function render(state, photos) {
const tickets = document.getElementById("tickets"); const tickets = document.getElementById("tickets");
tickets.innerHTML = state.unidentified.length tickets.innerHTML = state.unidentified.length
? state.unidentified.map(ticket).join("") ? state.unidentified.map(s => ticketCard(s)).join("")
: `<p class="empty">No open reshoot tickets.</p>`; : `<p class="empty">No open reshoot tickets.</p>`;
tickets.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => { wireDismiss(tickets, () => refresh().catch(() => {}));
const t = b.closest(".ticket");
const res = await apiPost("/api/dismiss", {
photo: t.dataset.photo, location: t.dataset.location,
partial_text: t.dataset.partial, art_notes: t.dataset.art,
});
if (res) refresh();
}));
document.getElementById("gallerycount").textContent = `${photos.length} on file`; document.getElementById("gallerycount").textContent = `${photos.length} on file`;
document.getElementById("shots").innerHTML = photos.map(p => ` document.getElementById("shots").innerHTML = photos.map(p => `
@@ -59,17 +31,13 @@ function render(state, photos) {
</figure>`).join(""); </figure>`).join("");
} }
let LAST = null; const GATE = changeGate(); // rebuilding innerHTML re-renders every <img>
async function refresh() { async function refresh() {
const [state, photos] = await Promise.all([ const [state, photos] = await Promise.all([
fetchJSON("/api/state"), fetchJSON("/api/state"),
fetchJSON("/api/photos-list"), fetchJSON("/api/photos-list"),
]); ]);
// re-render only on change: rebuilding innerHTML re-renders every <img> GATE([state.unidentified, state.warnings, photos], () => render(state, photos));
const payload = JSON.stringify([state.unidentified, state.warnings, photos]);
if (payload === LAST) return;
LAST = payload;
render(state, photos);
} }
const zone = document.getElementById("dropzone"); const zone = document.getElementById("dropzone");
@@ -83,25 +51,54 @@ zone.addEventListener("drop", e => {
}); });
pick.addEventListener("change", () => sendPhotos(pick.files)); pick.addEventListener("change", () => sendPhotos(pick.files));
const ZONE_IDLE = "drop shelf photos here, or click to choose";
let UPLOADING = false;
async function sendPhotos(files) { async function sendPhotos(files) {
if (!files.length) return; if (!files.length || UPLOADING) return;
const form = new FormData(); const form = new FormData();
for (const f of files) form.append("files", f); for (const f of files) form.append("files", f);
let res; const mb = ([...files].reduce((total, f) => total + f.size, 0) / 1048576).toFixed(1);
UPLOADING = true;
zone.disabled = true;
zone.setAttribute("aria-busy", "true");
zone.classList.remove("ok");
zone.textContent = `uploading ${files.length} photo(s) — ${mb} MB… keep this page open`;
let res = null;
try { try {
res = await fetch("/api/photos", {method: "POST", body: form}); res = await fetch("/api/photos", {method: "POST", body: form});
} catch (err) { } catch (err) {
alert("Upload failed (no response from the server): " + err); alert("Upload failed (no response from the server): " + err);
return;
} }
UPLOADING = false;
zone.disabled = false;
zone.removeAttribute("aria-busy");
pick.value = ""; // re-picking the same photo must fire change again
if (!res) { zone.textContent = ZONE_IDLE; return; }
if (!res.ok) { if (!res.ok) {
const detail = await res.json().then(d => d.detail).catch(() => null); const detail = await res.json().then(d => d.detail).catch(() => null);
zone.textContent = ZONE_IDLE;
alert("Upload failed: " + (detail ?? res.statusText)); alert("Upload failed: " + (detail ?? res.statusText));
return; return;
} }
const saved = await res.json().then(d => d.saved || []).catch(() => []);
zone.classList.add("ok");
zone.textContent = `✓ saved ${saved.join(", ")} — add another?`;
setTimeout(() => {
if (!UPLOADING && zone.classList.contains("ok")) {
zone.classList.remove("ok");
zone.textContent = ZONE_IDLE;
}
}, 6000);
GATE.reset(); // the new photo must appear even if nothing else changed
refresh().catch(() => {}); // the next poll self-heals a refresh hiccup refresh().catch(() => {}); // the next poll self-heals a refresh hiccup
} }
// leaving mid-upload silently loses the photo — make the browser ask
window.addEventListener("beforeunload", e => {
if (UPLOADING) e.preventDefault();
});
refresh().catch(err => errorBanner(err.message || err)); refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner("")); pollLoop(refresh, 5000, () => showBanner(""));
</script> </script>
+37 -20
View File
@@ -3,11 +3,11 @@
<h2 id="activity">Activity</h2> <h2 id="activity">Activity</h2>
<div id="jobstate" aria-live="polite">idle</div> <div id="jobstate" aria-live="polite">idle</div>
<div id="joblog" aria-label="stage output">(stage output appears here)</div> <div id="joblog" role="log" aria-label="stage output">(stage output appears here)</div>
<script> <script>
"use strict"; "use strict";
let P = null; let P = null;
let running = false; let RUNNING = false;
async function runStage(stage, body) { async function runStage(stage, body) {
const res = await apiPost(`/api/run/${stage}`, body); const res = await apiPost(`/api/run/${stage}`, body);
@@ -23,12 +23,12 @@ function stageCard(num, name, facts, actions) {
} }
function runBtn(stage, label) { function runBtn(stage, label) {
return `<button class="primary" data-run="${stage}" ${running ? "disabled" : ""}>${esc(label ?? "Run")}</button>`; return `<button class="primary" data-run="${stage}" ${RUNNING ? "disabled" : ""}>${esc(label ?? "Run")}</button>`;
} }
function render() { function render() {
const m = P.matches, log = P.upload_log; const m = P.matches, log = P.upload_log;
running = P.job.status === "running"; // buttons below depend on this RUNNING = P.job.status === "running"; // buttons below depend on this
const banners = []; const banners = [];
if (P.stub_data) banners.push( if (P.stub_data) banners.push(
@@ -42,35 +42,56 @@ function render() {
const uploadFacts = `<b>${P.to_add}</b> to add · <b>${P.to_update}</b> version updates const uploadFacts = `<b>${P.to_add}</b> to add · <b>${P.to_update}</b> version updates
${log.added || log.added_no_version ? `· <b>${(log.added ?? 0) + (log.added_no_version ?? 0)}</b> added` : ""} ${log.added || log.added_no_version ? `· <b>${(log.added ?? 0) + (log.added_no_version ?? 0)}</b> added` : ""}
${log.failed ? `· <b>${log.failed}</b> failed` : ""} ${P.upload_failed ? `· <b>${P.upload_failed}</b> failed` : ""}
· <a href="/queue">inspect the queue</a>`; · <a href="/queue">inspect the queue</a>
${!P.to_add && !P.to_update && P.queued_total
? `<br><span class="meta">${P.queued_total} queued row(s) already applied —
the next <b>diff</b> clears them (BGG's collection export can lag the
site by a while)</span>`
: ""}`;
document.getElementById("stages").innerHTML = [ document.getElementById("stages").innerHTML = [
stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a> — <b>${P.titles}</b> so far`, runBtn("extract")), stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a> — <b>${P.titles}</b> so far`
+ (P.titles > 0
? P.shaky_reads > 0
? ` · <b>${P.shaky_reads}</b> shaky read(s) worth a <a href="/titles">proofread</a> before resolving`
: ` · <a href="/titles">proofread the titles</a> before resolving`
: ""), runBtn("extract")),
stageCard(2, "resolve", `match titles to BGG ids — <b>${(m.auto ?? 0)}</b> auto · <b>${m.ambiguous ?? 0}</b> ambiguous · <b>${m.unmatched ?? 0}</b> unmatched`, runBtn("resolve")), stageCard(2, "resolve", `match titles to BGG ids — <b>${(m.auto ?? 0)}</b> auto · <b>${m.ambiguous ?? 0}</b> ambiguous · <b>${m.unmatched ?? 0}</b> unmatched`, runBtn("resolve")),
stageCard(3, "review", `<b>${P.pending_review}</b> item(s) waiting for your call`, `<a class="linkbtn" href="/review">Open review</a>`), stageCard(3, "review", `<b>${P.pending_review}</b> item(s) waiting for your call`, `<a class="linkbtn" href="/review">Open review</a>`),
stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")), stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")),
stageCard(5, "upload", uploadFacts, stageCard(5, "upload", uploadFacts,
`${runBtn("upload", "Dry run")} `${runBtn("upload", "Dry run")}
<button class="danger" data-upload-real ${running || P.stub_data ? "disabled" : ""}>Upload</button> <button class="danger" data-upload-real ${RUNNING || P.stub_data ? "disabled" : ""}>Upload</button>
<label>limit <input type="number" id="uplimit" min="1" placeholder="all"></label>`), <label>limit <input type="number" id="uplimit" min="1" placeholder="all"></label>
${P.upload_failed
? `<label title="failed jobs are skipped on normal runs so a broken one can't loop">
<input type="checkbox" id="upretry"> retry ${P.upload_failed} failed</label>`
: ""}`),
stageCard(6, "enrich", `<b>${P.games}</b> game(s) in the <a href="/library">library</a>`, runBtn("enrich")), stageCard(6, "enrich", `<b>${P.games}</b> game(s) in the <a href="/library">library</a>`, runBtn("enrich")),
].join(""); ].join("");
document.querySelectorAll("[data-run]").forEach(b => document.querySelectorAll("[data-run]").forEach(b =>
b.addEventListener("click", () => runStage(b.dataset.run, b.addEventListener("click", () => runStage(b.dataset.run,
b.dataset.run === "upload" ? {dry_run: true} : {}))); b.dataset.run === "upload" ? {dry_run: true, ...uploadOpts()} : {})));
const uploadOpts = () => ({
limit: Number(document.getElementById("uplimit").value) || null,
retry_failed: !!document.getElementById("upretry")?.checked,
});
const real = document.querySelector("[data-upload-real]"); const real = document.querySelector("[data-upload-real]");
if (real) real.addEventListener("click", () => { if (real) real.addEventListener("click", () => {
const limit = Number(document.getElementById("uplimit").value) || null; const {limit, retry_failed} = uploadOpts();
if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}? A browser window will open.`)) return; if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}` +
runStage("upload", {dry_run: false, limit}); `${retry_failed ? ", retrying previously failed jobs" : ""}? A browser window will open.`)) return;
runStage("upload", {dry_run: false, limit, retry_failed});
}); });
const job = P.job; const job = P.job;
const state = document.getElementById("jobstate"); const state = document.getElementById("jobstate");
if (job.status === "idle") state.textContent = "idle"; if (job.status === "idle") state.textContent = "idle";
else state.innerHTML = `<span class="${esc(job.status)}">${esc(job.stage)}: ${esc(job.status)}</span>` + // s- prefix: a bare status class collides with page classes (.done is
// the review celebration card)
else state.innerHTML = `<span class="s-${esc(job.status)}">${esc(job.stage)}: ${esc(job.status)}</span>` +
(job.error ? `${esc(job.error)}` : ""); (job.error ? `${esc(job.error)}` : "");
const logEl = document.getElementById("joblog"); const logEl = document.getElementById("joblog");
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4; const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4;
@@ -78,14 +99,10 @@ function render() {
if (atBottom) logEl.scrollTop = logEl.scrollHeight; if (atBottom) logEl.scrollTop = logEl.scrollHeight;
} }
let LAST = null; const GATE = changeGate();
async function refresh() { async function refresh() {
const p = await fetchJSON("/api/pipeline"); const p = await fetchJSON("/api/pipeline");
const payload = JSON.stringify(p); GATE(p, () => { P = p; render(); });
if (payload === LAST) return;
LAST = payload;
P = p;
render();
} }
refresh().catch(err => errorBanner(err.message || err)); refresh().catch(err => errorBanner(err.message || err));
+34 -11
View File
@@ -11,24 +11,50 @@ function table(headers, rows) {
${rows.join("")}</table></div>`; ${rows.join("")}</table></div>`;
} }
// a queue row's state comes from the upload log: the CSVs are diff-time
// snapshots and never shrink as work completes
function stateChip(r) {
if (r.stale) return `<span class="chip no" title="${esc(r.stale)}">retired</span>`;
if (r.state === "done" && r.last_status === "added_no_version")
return `<span class="chip ok" title="the add saved without an edition — set it on BGG by hand if it matters">done · no version</span>`;
if (r.state === "done") return `<span class="chip ok">done</span>`;
if (r.state === "failed") return `<span class="chip no">failed</span>`;
return `<span class="chip open">pending</span>`;
}
function tally(rows) {
const n = s => rows.filter(r => !r.stale && r.state === s).length;
const parts = [`<b>${n("")}</b> pending`];
if (n("done")) parts.push(`<b>${n("done")}</b> done`);
if (n("failed")) parts.push(`<b>${n("failed")}</b> failed`);
const retired = rows.filter(r => r.stale).length;
if (retired) parts.push(`<b>${retired}</b> retired by review`);
return parts.join(" · ");
}
function render(q) { function render(q) {
let html = ""; let html = "";
html += `<h2>To add <span class="count">— ${q.to_add.length} new collection entr${q.to_add.length === 1 ? "y" : "ies"}</span></h2>`; html += `<h2>To add <span class="count">— ${tally(q.to_add)}</span></h2>`;
html += q.to_add.length html += q.to_add.length
? table(["game", "version", "seen in"], q.to_add.map(r => ` ? table(["game", "version", "seen in", ""], q.to_add.map(r => `
<tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td> <tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td>
<td>${r.version_name ? esc(r.version_name) : `<span class="meta">no version</span>`}</td> <td>${r.version_name ? esc(r.version_name) : `<span class="meta">no version</span>`}</td>
<td class="meta">${esc(r.source_photos)}</td></tr>`)) <td class="meta">${esc((r.source_photos ?? "").split(";").join(", "))}</td>
<td>${stateChip(r)}</td></tr>`))
: `<p class="empty">Nothing queued — run <b>diff</b> from the <a href="/">pipeline</a> first.</p>`; : `<p class="empty">Nothing queued — run <b>diff</b> from the <a href="/">pipeline</a> first.</p>`;
html += `<h2>Version updates <span class="count">— ${q.to_update.length} existing entr${q.to_update.length === 1 ? "y" : "ies"} gaining a version</span></h2>`; html += `<h2>Version updates <span class="count">— ${tally(q.to_update)}</span></h2>`;
html += q.to_update.length html += q.to_update.length
? table(["game", "version to set", "collection id"], q.to_update.map(r => ` ? table(["game", "version to set", "collection id", ""], q.to_update.map(r => `
<tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td> <tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td>
<td>${esc(r.version_name)}</td> <td>${esc(r.version_name)}</td>
<td class="meta">${esc(r.collid)}</td></tr>`)) <td class="meta">${esc(r.collid)}</td>
<td>${stateChip(r)}</td></tr>`))
: `<p class="empty">No version updates pending.</p>`; : `<p class="empty">No version updates pending.</p>`;
if (q.to_add.concat(q.to_update).some(r => r.state === "done"))
html += `<p class="empty">Finished jobs stay listed until the next <b>diff</b>
rebuilds the queue — the log below is the permanent record.</p>`;
html += `<h2>Upload log <span class="count">— every attempt ever made (${q.log.length})</span></h2>`; html += `<h2>Upload log <span class="count">— every attempt ever made (${q.log.length})</span></h2>`;
html += q.log.length html += q.log.length
@@ -43,13 +69,10 @@ function render(q) {
document.getElementById("queuebody").innerHTML = html; document.getElementById("queuebody").innerHTML = html;
} }
let LAST = null; const GATE = changeGate();
async function refresh() { async function refresh() {
const q = await fetchJSON("/api/queue"); const q = await fetchJSON("/api/queue");
const payload = JSON.stringify(q); GATE(q, () => render(q));
if (payload === LAST) return;
LAST = payload;
render(q);
} }
refresh().catch(err => errorBanner(err.message || err)); refresh().catch(err => errorBanner(err.message || err));
+85 -40
View File
@@ -3,7 +3,7 @@
<span id="sectionlinks"></span> <span id="sectionlinks"></span>
<span class="keyhelp"> <span class="keyhelp">
<kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd><kbd>9</kbd> pick · <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd><kbd>9</kbd> pick ·
<kbd>r</kbd> reject · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown · <kbd>r</kbd> reject · <kbd>l</kbd> keep local · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
<kbd>v</kbd> veto merge <kbd>v</kbd> veto merge
</span> </span>
</div> </div>
@@ -11,32 +11,16 @@
<script> <script>
"use strict"; "use strict";
let STATE = null; let STATE = null;
let active = 0; let ACTIVE = 0;
async function refresh() { async function refresh() {
const res = await fetch("/api/state"); STATE = await fetchJSON("/api/state");
if (!res.ok) throw new Error(`${res.status}`);
STATE = await res.json();
render(); render();
} }
async function post(url, body) { async function post(url, body) {
let res; const res = await apiPost(url, body);
try { if (!res) return;
res = await fetch(url, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
} catch (err) {
alert("That didn't save (no response from the server): " + err);
return;
}
if (!res.ok) {
const detail = await res.json().then(d => d.detail).catch(() => null);
alert("That didn't save: " + (detail ?? res.statusText));
return;
}
try { try {
STATE = await res.json(); STATE = await res.json();
render(); render();
@@ -77,7 +61,9 @@ function matchCard(row, idx) {
<kbd>${i + 1}</kbd> ${thumbHtml(c)} <kbd>${i + 1}</kbd> ${thumbHtml(c)}
<span><span class="cname">${esc(c.name)}</span> <span><span class="cname">${esc(c.name)}</span>
<span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")} <span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")}
· rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}</span></span> · rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}
· <a href="${bggUrl(c.bgg_id, c.type)}" target="_blank" rel="noopener"
title="check this candidate before picking">view ↗</a></span></span>
</li>`).join(""); </li>`).join("");
return ` return `
<section class="card actionable" data-kind="match" data-rowix="${row.row_ix}" <section class="card actionable" data-kind="match" data-rowix="${row.row_ix}"
@@ -91,7 +77,16 @@ function matchCard(row, idx) {
<div class="rowactions"> <div class="rowactions">
<span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎" <span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎"
aria-label="manual BGG id"></span> aria-label="manual BGG id"></span>
<span class="research">
<input type="text" class="rq" value="${esc(row.title_raw)}"
aria-label="search text for ${esc(row.title_raw)}">
<button class="dosearch" data-types="">search BGG</button>
<button class="dosearch" data-types="rpgitem"
title="BGG's board-game entries hide same-named RPGs from the automatic search">
search RPGGeek</button>
</span>
<button class="reject" title="press r"><kbd>r</kbd> reject — not a game / bad read</button> <button class="reject" title="press r"><kbd>r</kbd> reject — not a game / bad read</button>
<button class="golocal" title="press l"><kbd>l</kbd> not on BGG — keep locally</button>
</div> </div>
</div> </div>
</section>`; </section>`;
@@ -109,12 +104,24 @@ function versionCard(row, idx) {
return ` return `
<section class="card actionable" data-kind="version" data-rowix="${row.row_ix}" <section class="card actionable" data-kind="version" data-rowix="${row.row_ix}"
data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}"> data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
${shots(row.photos || [])}
<div class="body"> <div class="body">
<p class="title">${esc(row.bgg_name || row.title_raw)}</p> <p class="title">${esc(row.bgg_name || row.title_raw)}
<p class="status">which edition? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p> ${row.bgg_id ? `<a class="cmeta" href="${bggUrl(row.bgg_id, row.type)}/versions"
target="_blank" rel="noopener"
title="BGG's own list of every printing">versions on BGG ↗</a>` : ""}</p>
<p class="status">which edition${row.photos && row.photos.length
? ` is the copy in ${esc(row.photos.join(", "))}`
: ""}? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
<ol class="cands">${cands}</ol> <ol class="cands">${cands}</ol>
<div class="rowactions"> <div class="rowactions">
<button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button> <button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button>
<button class="allversions"
title="the cues shortlisted these — fetch every published printing instead">
list every printing</button>
<button class="wronggame"
title="none of these printings is your box: BGG may file it as a SEPARATE game — re-match it">
wrong game — re-match</button>
</div> </div>
</div> </div>
</section>`; </section>`;
@@ -142,7 +149,7 @@ function render() {
<div class="done"> <div class="done">
<h2>${waiting <h2>${waiting
? "Resolved set fully reviewed" ? "Resolved set fully reviewed"
: "All reviewed — the catalog is diff-ready"}</h2> : "All reviewed — every title is diff-ready"}</h2>
<div class="nums"> <div class="nums">
<div>${s.summary.extracted}<span>extracted</span></div> <div>${s.summary.extracted}<span>extracted</span></div>
<div>${s.summary.recognized}<span>recognized</span></div> <div>${s.summary.recognized}<span>recognized</span></div>
@@ -150,23 +157,34 @@ function render() {
<div>${s.summary.rejected}<span>rejected</span></div> <div>${s.summary.rejected}<span>rejected</span></div>
</div> </div>
${waiting ${waiting
? `<p class="waiting">${waiting} titles are extracted but not yet matched ? s.summary.token_present
to BGG — they're waiting on the API token.</p> ? `<p class="waiting">${waiting} title(s) are extracted but not yet matched
to BGG.</p>
<p class="next">Run <b>resolve</b> from the <a href="/">pipeline</a> to match them.</p>`
: `<p class="waiting">${waiting} title(s) are extracted but not yet matched
to BGG — they're waiting on your API token
(<a href="https://boardgamegeek.com/applications" target="_blank"
rel="noopener">boardgamegeek.com/applications</a>).</p>
<p class="next">When it arrives, run <b>resolve</b> from the <a href="/">pipeline</a>.</p>` <p class="next">When it arrives, run <b>resolve</b> from the <a href="/">pipeline</a>.</p>`
: `<p class="next">Next: run <b>diff</b> from the <a href="/">pipeline</a>, then check the <a href="/queue">queue</a>.</p>`} : `<p class="next">Next: run <b>diff</b> from the <a href="/">pipeline</a>, then check the <a href="/queue">queue</a>.</p>`}
</div>`; </div>`;
} }
// csv order is extraction order — alphabetize so a long session has a
// findable, predictable shape (stable within a payload, so the keyboard
// cursor stays put between polls)
const byTitle = (a, b) =>
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" });
if (s.pending.length) { if (s.pending.length) {
html += `<h2 id="matches">Matches <span class="count">— pick the game each photo shows</span></h2>`; html += `<h2 id="matches">Matches <span class="count">— pick the game each photo shows</span></h2>`;
html += s.pending.map(matchCard).join(""); html += [...s.pending].sort(byTitle).map(matchCard).join("");
} }
if (s.versions.length) { if (s.versions.length) {
html += `<h2 id="editions">Editions <span class="count">— optional pass, never blocks uploads</span></h2>`; html += `<h2 id="editions">Editions <span class="count">— optional pass, never blocks uploads</span></h2>`;
html += s.versions.map(versionCard).join(""); html += [...s.versions].sort(byTitle).map(versionCard).join("");
} }
if (s.merges.length) { if (s.merges.length) {
html += `<h2 id="merges">Merges <span class="count">— duplicate reads folded into one game; veto if wrong</span></h2>`; html += `<h2 id="merges">Merges <span class="count">— duplicate reads folded into one game; veto if wrong</span></h2>`;
html += s.merges.map(mg => ` html += [...s.merges].sort(byTitle).map(mg => `
<section class="card merge actionable" data-kind="merge" <section class="card merge actionable" data-kind="merge"
data-rowix="${mg.row_ix}" data-rowix="${mg.row_ix}"
data-title="${esc(mg.title_raw)}" data-photos="${esc(mg.source_photos)}"> data-title="${esc(mg.title_raw)}" data-photos="${esc(mg.source_photos)}">
@@ -182,9 +200,12 @@ function render() {
m.innerHTML = html; m.innerHTML = html;
const cards = actionables(); const cards = actionables();
if (active >= cards.length) active = Math.max(0, cards.length - 1); if (ACTIVE >= cards.length) ACTIVE = Math.max(0, cards.length - 1);
highlight(); highlight();
// an outbound "view ↗" inside a pick row must not also cast the vote
m.querySelectorAll("[data-pick] a, [data-pickver] a").forEach(a =>
a.onclick = e => e.stopPropagation());
m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => { m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
const card = li.closest(".card"); const card = li.closest(".card");
decide(card, "pick", Number(li.dataset.pick)); decide(card, "pick", Number(li.dataset.pick));
@@ -195,6 +216,28 @@ function render() {
}); });
m.querySelectorAll(".reject").forEach(b => b.onclick = () => m.querySelectorAll(".reject").forEach(b => b.onclick = () =>
decide(b.closest(".card"), "reject")); decide(b.closest(".card"), "reject"));
m.querySelectorAll(".golocal").forEach(b => b.onclick = () =>
decide(b.closest(".card"), "local"));
m.querySelectorAll(".dosearch").forEach(b => b.onclick = () => {
const card = b.closest(".card");
post("/api/research", {
title_raw: card.dataset.title, source_photos: card.dataset.photos,
row_ix: rowIx(card), query: card.querySelector(".rq").value,
types: b.dataset.types || null,
});
});
m.querySelectorAll(".wronggame").forEach(b => b.onclick = () =>
post("/api/reopen-match", {
title_raw: b.closest(".card").dataset.title,
source_photos: b.closest(".card").dataset.photos,
row_ix: rowIx(b.closest(".card")),
}));
m.querySelectorAll(".allversions").forEach(b => b.onclick = () =>
post("/api/open-versions", {
title_raw: b.closest(".card").dataset.title,
source_photos: b.closest(".card").dataset.photos,
row_ix: rowIx(b.closest(".card")),
}));
m.querySelectorAll(".unknown").forEach(b => b.onclick = () => m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
version(b.closest(".card"), "unknown")); version(b.closest(".card"), "unknown"));
m.querySelectorAll(".veto").forEach(b => b.onclick = () => m.querySelectorAll(".veto").forEach(b => b.onclick = () =>
@@ -212,13 +255,15 @@ function render() {
const actionables = () => [...document.querySelectorAll(".actionable")]; const actionables = () => [...document.querySelectorAll(".actionable")];
function highlight() { function highlight(scroll = false) {
actionables().forEach((el, i) => el.classList.toggle("active", i === active)); actionables().forEach((el, i) => el.classList.toggle("active", i === ACTIVE));
const el = actionables()[active]; // scroll only for KEYBOARD moves: a mouse user's cursor idles on card
if (el) el.scrollIntoView({block: "nearest", behavior: "auto"}); // one, and scrolling to it on every re-render yanks them to the top
const el = actionables()[ACTIVE];
if (el && scroll) el.scrollIntoView({block: "nearest", behavior: "auto"});
} }
const rowIx = card => card.dataset.rowix === undefined ? null : Number(card.dataset.rowix); const rowIx = card => Number(card.dataset.rowix); // every card template sets it
const decide = (card, action, bgg_id = null) => post("/api/decision", { const decide = (card, action, bgg_id = null) => post("/api/decision", {
title_raw: card.dataset.title, source_photos: card.dataset.photos, title_raw: card.dataset.title, source_photos: card.dataset.photos,
row_ix: rowIx(card), action, bgg_id, row_ix: rowIx(card), action, bgg_id,
@@ -241,10 +286,10 @@ document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT") return; if (e.target.tagName === "INPUT") return;
const cards = actionables(); const cards = actionables();
if (!cards.length) return; if (!cards.length) return;
const card = cards[active]; const card = cards[ACTIVE];
const kind = card?.dataset.kind; const kind = card?.dataset.kind;
if (e.key === "j" || e.key === "ArrowDown") { active = Math.min(active + 1, cards.length - 1); highlight(); } if (e.key === "j" || e.key === "ArrowDown") { ACTIVE = Math.min(ACTIVE + 1, cards.length - 1); highlight(true); }
else if (e.key === "k" || e.key === "ArrowUp") { active = Math.max(active - 1, 0); highlight(); } else if (e.key === "k" || e.key === "ArrowUp") { ACTIVE = Math.max(ACTIVE - 1, 0); highlight(true); }
else if (/^[1-9]$/.test(e.key) && kind === "match") { else if (/^[1-9]$/.test(e.key) && kind === "match") {
const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1]; const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
if (li) decide(card, "pick", Number(li.dataset.pick)); if (li) decide(card, "pick", Number(li.dataset.pick));
@@ -254,6 +299,7 @@ document.addEventListener("keydown", e => {
if (li) version(card, "pick", Number(li.dataset.pickver)); if (li) version(card, "pick", Number(li.dataset.pickver));
} }
else if (e.key === "r" && kind === "match") decide(card, "reject"); else if (e.key === "r" && kind === "match") decide(card, "reject");
else if (e.key === "l" && kind === "match") decide(card, "local");
else if (e.key === "u" && kind === "version") version(card, "unknown"); else if (e.key === "u" && kind === "version") version(card, "unknown");
else if (e.key === "v" && kind === "merge") vetoMerge(card); else if (e.key === "v" && kind === "merge") vetoMerge(card);
else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); } else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
@@ -264,7 +310,6 @@ refresh().catch(err => errorBanner(err.message || err));
// Live-follow the data files; only re-render on an actual change (keeps // Live-follow the data files; only re-render on an actual change (keeps
// the keyboard cursor stable) and never mid-typing. Stale poll responses // the keyboard cursor stable) and never mid-typing. Stale poll responses
// (answered before a decision landed) are discarded by revision. // (answered before a decision landed) are discarded by revision.
let lastGood = null;
pollLoop(async () => { pollLoop(async () => {
const fresh = await fetchJSON("/api/state"); const fresh = await fetchJSON("/api/state");
// discard stale responses — but only within one server lifetime: a // discard stale responses — but only within one server lifetime: a
+284
View File
@@ -0,0 +1,284 @@
<h1>Titles</h1>
<div class="pagebar"><span id="catcount"></span></div>
<div class="filterbar">
<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";
let CATALOG = [];
let EDITING = null; // lineKey of the row whose editor is open
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
function editorRow(c) {
const cue = c.cues || {};
return `
<tr class="editrow"><td colspan="5">
<form class="editform" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}">
<label>Title <input name="title" value="${esc(c.title_raw)}" required></label>
<label>Publisher <input name="publisher" value="${esc(cue.publisher || "")}"></label>
<label>Edition <input name="edition" value="${esc(cue.edition || "")}"></label>
<label>Year <input name="year" value="${esc(cue.year ?? "")}" inputmode="numeric" size="6"></label>
<label>Language <input name="language" value="${esc(cue.language || "")}"></label>
<span class="editactions">
<button type="submit" class="primary">save</button>
<button type="button" class="canceledit">cancel</button>
${c.bgg_id && ["auto", "approved"].includes(c.status)
? `<button type="button" class="wrongmatch danger">wrong match — re-search</button>`
: ""}
<button type="button" class="removetitle danger"
title="not a game, a duplicate read of a copy already listed, or otherwise doesn't belong">
remove this line</button>
</span>
<span class="edithint">saving re-queues this title for resolve with the corrected data</span>
</form>
</td></tr>`;
}
let SHAKY_ONLY = false;
function render() {
const q = document.getElementById("catsearch").value.trim().toLowerCase();
const shakyCount = CATALOG.filter(c => c.shaky).length;
const fbtn = document.getElementById("shakyfilter");
fbtn.hidden = shakyCount === 0 && !SHAKY_ONLY;
fbtn.textContent = `shaky reads — ${shakyCount}`;
fbtn.setAttribute("aria-pressed", String(SHAKY_ONLY));
const sorted = [...CATALOG].sort((a, b) =>
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" }));
const searched = q
? sorted.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
: sorted;
const rows = SHAKY_ONLY ? searched.filter(c => c.shaky) : searched;
document.getElementById("catcount").innerHTML =
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
document.getElementById("catbody").innerHTML = rows.length
? `<div class="catalog"><table>` + rows.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}
${c.split_copy ? `<span class="chip merged">copy</span>` : ""}
${c.shaky ? `<span class="chip shaky"
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.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(";"))}"
data-rowix="${c.row_ix ?? ""}"
title="one line, several boxes? make each photo its own copy">
split into copies</button>`
: ""}
${c.status === "local"
? `<button class="wrongmatch" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
title="search BGG and RPGGeek again — RPGs and expansions often are listed">
look it up</button>`
: ""}
${c.version_status === "version_ambiguous"
? `<a class="ballotlink" href="/review#editions"
title="this copy's edition ballot is open">ballot in Review →</a>`
: ""}
${c.bgg_id && ["auto", "approved"].includes(c.status)
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
? `<button class="pickedition" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
title="you know which printing this box is — fetch the full edition list into Review">
pick edition</button>`
: ""}
${c.shaky ? `<button class="confirmread" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}"
title="the read is correct as-is — mark it verified">✓ looks right</button>` : ""}
<button class="edit" data-key="${esc(lineKey(c))}"
title="fix a misread title or add cues you already know">edit</button>
</td>
</tr>` + (EDITING === lineKey(c) ? editorRow(c) : "")).join("") + `</table></div>`
: `<p class="empty">${CATALOG.length
? "No titles match that filter."
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
}
const GATE = changeGate();
async function refresh() {
const state = await fetchJSON("/api/state");
if (EDITING) return; // never repaint under an open editor
GATE(state.catalog, () => { CATALOG = state.catalog; render(); });
}
document.getElementById("catbody").addEventListener("click", async e => {
const cancel = e.target.closest("button.canceledit");
if (cancel) { EDITING = null; GATE.reset(); render(); refresh().catch(() => {}); return; }
const wm = e.target.closest("button.wrongmatch");
if (wm) {
// in the edit panel it means "wrong match"; on a local row it means
// "look it up" — same reopen, different starting point
const f = wm.closest("form.editform");
const src = f ? f.dataset : wm.dataset;
if (f && !confirm(`"${src.title}" matched the wrong game? This clears the ` +
`match and sends it back to Review for a re-search.`)) return;
const res = await apiPost("/api/reopen-match", {
title_raw: src.title,
source_photos: src.photos,
row_ix: src.rowix === "" ? null : Number(src.rowix),
});
if (res) {
EDITING = null; GATE.reset(); refresh().catch(() => {});
showToast(`searching BGG and RPGGeek for <b>${esc(src.title)}</b> —
<a href="/review#matches">pick a match in Review</a>`);
}
return;
}
const pe = e.target.closest("button.pickedition");
if (pe) {
const res = await apiPost("/api/open-versions", {
title_raw: pe.dataset.title,
source_photos: pe.dataset.photos,
row_ix: pe.dataset.rowix === "" ? null : Number(pe.dataset.rowix),
});
if (res) {
showToast(`edition ballot ready for <b>${esc(pe.dataset.title)}</b> —
<a href="/review#editions">pick it in Review</a>`);
GATE.reset(); refresh().catch(() => {});
}
return;
}
const ok = e.target.closest("button.confirmread");
if (ok) {
const res = await apiPost("/api/edit-title", {
title_raw: ok.dataset.title,
source_photos: ok.dataset.photos,
confirm: true,
});
if (res) { GATE.reset(); refresh().catch(() => {}); }
return;
}
const rm = e.target.closest("button.removetitle");
if (rm) {
const f = rm.closest("form.editform");
if (!confirm(`Remove "${f.dataset.title}" from the titles list? ` +
`Right for a line that shouldn't exist: not a game, or a duplicate ` +
`read of a copy that's already its own line. ` +
`(One line for several REAL boxes? Use split instead.) ` +
`Re-running extract won't bring it back — the removal is saved ` +
`in data/title_removals.json (delete its record there to undo).`)) return;
const res = await apiPost("/api/remove-title", {
title_raw: f.dataset.title,
source_photos: f.dataset.photos,
});
if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); }
return;
}
const edit = e.target.closest("button.edit");
if (edit) {
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
render();
return;
}
const b = e.target.closest("button.split");
if (!b) return;
const n = b.dataset.photos.split(";").length;
if (!confirm(`Split "${b.dataset.title}" into ${n} separate copies (one per photo)? ` +
`Each picks its own edition afterward.`)) return;
const res = await apiPost("/api/split", {
title_raw: b.dataset.title,
source_photos: b.dataset.photos,
row_ix: b.dataset.rowix === "" ? null : Number(b.dataset.rowix),
});
if (res) refresh().catch(() => {});
});
document.getElementById("catbody").addEventListener("submit", async e => {
const f = e.target.closest("form.editform");
if (!f) return;
e.preventDefault();
const orig = CATALOG.find(c => lineKey(c) === EDITING) || {};
const cue = orig.cues || {};
const v = name => f.elements[name].value;
const body = { title_raw: f.dataset.title, source_photos: f.dataset.photos };
if (v("title").trim() !== f.dataset.title) body.title_new = v("title").trim();
if (v("publisher") !== (cue.publisher || "")) body.publisher = v("publisher");
if (v("edition") !== (cue.edition || "")) body.edition = v("edition");
if (v("year") !== String(cue.year ?? "")) body.year = v("year");
if (v("language") !== (cue.language || "")) body.language = v("language");
if (Object.keys(body).length <= 2) {
// saving unchanged: on a shaky line that means "it's right as-is"
if (orig.shaky) {
const res = await apiPost("/api/edit-title", { ...body, confirm: true });
if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); return; }
}
EDITING = null; render(); return;
}
const res = await apiPost("/api/edit-title", body);
if (res) {
EDITING = null; GATE.reset(); refresh().catch(() => {});
// an edit re-queues any matched row: without a resolve run the line
// sits "awaiting resolve" — say so, and offer the run right here
const toast = showToast(`saved — a previously matched title re-matches
on the next resolve run. <button id="resolvenow">run resolve now</button>`);
const btn = toast.querySelector("#resolvenow");
if (btn) btn.addEventListener("click", async () => {
const r = await apiPost("/api/run/resolve");
if (r) showToast(`resolve running — progress on the
<a href="/">Pipeline</a> page; this list updates itself`);
});
}
});
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;
render();
});
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner(""));
</script>
+6 -2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><!--TITLE--></title> <title><!--TITLE--></title>
<link rel="icon" type="image/png" href="/static/favicon.png"> <link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<meta name="apple-mobile-web-app-title" content="bggpipe">
<link rel="stylesheet" href="/static/app.css"> <link rel="stylesheet" href="/static/app.css">
</head> </head>
<body> <body>
@@ -14,7 +16,9 @@
<img src="/static/logo.jpg" alt=""> <img src="/static/logo.jpg" alt="">
<span class="wordmark">bggpipe<small>shelf → BGG pipeline</small></span> <span class="wordmark">bggpipe<small>shelf → BGG pipeline</small></span>
</a> </a>
<nav aria-label="Primary"> <button class="navtoggle" aria-expanded="false" aria-controls="primary-nav"
aria-label="Menu"><span class="bars"></span></button>
<nav id="primary-nav" aria-label="Primary">
<!--NAV--> <!--NAV-->
</nav> </nav>
<figure class="piperbox"> <figure class="piperbox">
@@ -27,7 +31,7 @@
</figure> </figure>
</div> </div>
<div class="content"> <div class="content">
<div id="banner"></div> <div id="banner" role="status"></div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<main id="main"> <main id="main">
<!--PAGE--> <!--PAGE-->
+399 -81
View File
@@ -39,7 +39,8 @@ import typer
from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text from bggpipe.fsio import atomic_write_text
from bggpipe.models import CollectionItem from bggpipe.models import CollectionItem, is_recognized
from bggpipe.resolve import read_matches
BGG = "https://boardgamegeek.com" BGG = "https://boardgamegeek.com"
UPLOAD_LOG_COLUMNS = [ UPLOAD_LOG_COLUMNS = [
@@ -57,6 +58,16 @@ DONE_STATUSES = {"added", "added_no_version", "updated", "already_present"}
MAX_VERSION_PAGES = 40 MAX_VERSION_PAGES = 40
_YEARISH = re.compile(r"[\s(]*\d{4}(?:-\d+)?[\s)]*")
def _loose_version_text(text: str) -> str:
"""Version names as displayed differ from the API's by punctuation,
parens and year qualifiers — compare only the words that name it."""
text = _YEARISH.sub(" ", text or "")
return " ".join(re.sub(r"[^\w\s]", " ", text.casefold()).split())
@dataclass(frozen=True) @dataclass(frozen=True)
class UploadJob: class UploadJob:
action: str # "add" | "update" action: str # "add" | "update"
@@ -89,6 +100,116 @@ def _job_key(row: dict) -> tuple[str, str, str]:
return _key(row["action"], row["bgg_id"], row["collid"], row["version_id"]) return _key(row["action"], row["bgg_id"], row["collid"], row["version_id"])
def annotate_queue(
queue_rows: list[dict], action: str, log_rows: list[dict]
) -> list[dict]:
"""Each queue row plus the outcome of its LAST upload attempt, in a
`state` field: "" (pending), "done", or "failed". to_add.csv and
to_update.csv are diff-time snapshots — nothing removes a row once its
job succeeds — so a reader without the log sees finished work as
outstanding forever."""
last: dict[tuple[str, str, str], str] = {}
done_count: Counter[tuple[str, str, str]] = Counter()
for row in log_rows:
last[_job_key(row)] = row["status"]
if row["status"] in DONE_STATUSES:
done_count[_job_key(row)] += 1
# vetoed duplicate copies share a key: one success must mark ONE row
# done, not both, or the queue reports a copy uploaded that never was
claimed: Counter[tuple[str, str, str]] = Counter()
out = []
for row in queue_rows:
key = _key(
action,
row.get("bgg_id", ""),
row.get("collid", ""),
row.get("version_id", ""),
)
status = last.get(key, "")
if status in DONE_STATUSES and claimed[key] >= done_count[key]:
status = "" # completions exhausted: this copy is still pending
if status in DONE_STATUSES:
claimed[key] += 1
out.append(
{
**row,
"state": "done"
if status in DONE_STATUSES
else "failed"
if status == "failed"
else "",
"last_status": status,
}
)
return out
def stale_jobs(queue_rows: list[dict], match_rows: list[dict]) -> dict[str, str]:
"""bgg_id -> why, for queued games the CURRENT matches.csv no longer
endorses. to_add.csv is a snapshot from the last diff; a review
decision taken afterwards (marking a game local, rejecting it, calling
a match wrong) must not still upload."""
if not match_rows:
# nothing to compare against (no matches.csv, or a test harness
# driving the queue directly): absence is not a verdict
return {}
live: dict[str, list[dict]] = {}
endorsed: Counter[tuple[str, str]] = Counter()
for row in match_rows:
if row.get("bgg_id"):
live.setdefault(row["bgg_id"], []).append(row)
if is_recognized(row):
endorsed[(row["bgg_id"], row.get("version_id", ""))] += 1
queued: Counter[tuple[str, str]] = Counter()
for row in queue_rows:
queued[(row.get("bgg_id") or "", row.get("version_id", ""))] += 1
stale = {}
for row in queue_rows:
bgg_id = row.get("bgg_id") or ""
rows = live.get(bgg_id, [])
if not rows:
stale[bgg_id] = "no longer matched to this game in matches.csv"
continue
if not any(is_recognized(r) for r in rows):
statuses = sorted({r["match_status"] for r in rows})
stale[bgg_id] = f"now {', '.join(statuses)} in matches.csv"
continue
# the game survives, but does THIS copy? Rejecting one of two
# editions must not ride along on the other's endorsement.
pair = (bgg_id, row.get("version_id", ""))
if queued[pair] > endorsed[pair]:
stale[bgg_id] = (
f"queued with version {row.get('version_id') or '(none)'} "
"but matches.csv no longer endorses that copy — re-run diff"
)
return stale
def outstanding_failures(
log_rows: list[dict],
queue_rows: list[dict] | None = None,
match_rows: list[dict] | None = None,
) -> int:
"""Failures still worth retrying: the job's LATEST attempt failed AND
it is still queued and still endorsed by matches.csv.
Two ways to over-count. upload_log.csv is append-only, so a successful
retry leaves its old 'failed' line behind; and a failed job whose
review decision has since changed (marked local, rejected) is not
pending work at all — it will never run again, so offering to retry it
is a lie."""
last: dict[tuple[str, str, str], tuple[str, str]] = {}
for row in log_rows:
last[_job_key(row)] = (row["status"], row.get("bgg_id", ""))
failed = [bgg_id for status, bgg_id in last.values() if status == "failed"]
if queue_rows is None:
return len(failed)
live = {r.get("bgg_id", "") for r in queue_rows} - set(
stale_jobs(queue_rows, match_rows or [])
)
return sum(1 for bgg_id in failed if bgg_id in live)
def _read_csv(path: Path) -> list[dict]: def _read_csv(path: Path) -> list[dict]:
if not path.exists(): if not path.exists():
return [] return []
@@ -114,10 +235,9 @@ def build_queue(
log_rows: list[dict], log_rows: list[dict],
*, *,
retry_failed: bool = False, retry_failed: bool = False,
) -> tuple[list[UploadJob], int, int, list[UploadJob]]: ) -> tuple[list[UploadJob], int, int]:
"""Turn the diff outputs into pending jobs, minus work the log says is """Turn the diff outputs into pending jobs, minus work the log says is
done. Returns (jobs, skipped_done, skipped_failed, deferred) — deferred done. Returns (jobs, skipped_done, skipped_failed).
being same-game updates held for a later run.
Completions are COUNTED per key, not looked up: two vetoed duplicate Completions are COUNTED per key, not looked up: two vetoed duplicate
copies share a key, and one logged success must complete exactly one copies share a key, and one logged success must complete exactly one
@@ -161,8 +281,6 @@ def build_queue(
jobs: list[UploadJob] = [] jobs: list[UploadJob] = []
skipped_done = skipped_failed = 0 skipped_done = skipped_failed = 0
deferred: list[UploadJob] = []
update_game_seen: set[str] = set()
seen: Counter[tuple[str, str, str]] = Counter() seen: Counter[tuple[str, str, str]] = Counter()
queued_versions: dict[str, set[str]] = {} queued_versions: dict[str, set[str]] = {}
for job in candidates: for job in candidates:
@@ -179,11 +297,13 @@ def build_queue(
prior = set() prior = set()
if prior and job.version_id not in prior: if prior and job.version_id not in prior:
typer.echo( typer.echo(
f" note: {job.name} was previously {job.action}ed with a " f" skipping {job.name}: previously {job.action}ed with a "
f"different version ({', '.join(sorted(prior)) or 'none'}) — " f"different version ({', '.join(sorted(prior)) or 'none'}) — "
"if re-review changed the version, the BGG entry needs a " "the entry exists on BGG, so re-adding can only duplicate "
"manual correction (additive-only rule)" "it; correct the version by hand (additive-only rule)"
) )
skipped_done += 1
continue
occurrence = seen[job.key] occurrence = seen[job.key]
seen[job.key] += 1 seen[job.key] += 1
if not job.name: if not job.name:
@@ -198,27 +318,23 @@ def build_queue(
skipped_done += 1 skipped_done += 1
elif last_status.get(job.key) == "failed" and not retry_failed: elif last_status.get(job.key) == "failed" and not retry_failed:
skipped_failed += 1 skipped_failed += 1
elif job.action == "update" and job.bgg_id in update_game_seen:
# The row-edit flow finds rows by game name, not collid — a
# second same-game update this run could reopen the copy the
# first one just versioned and overwrite it. One per run; the
# next run (after --verify) picks up the rest.
deferred.append(job)
else: else:
if job.action == "update": # same-game updates coexist in one run: update_entry addresses
update_game_seen.add(job.bgg_id) # the copy by collid and the edition by radio value, so a
# second update cannot reopen what the first just saved
jobs.append(job) jobs.append(job)
return jobs, skipped_done, skipped_failed, deferred return jobs, skipped_done, skipped_failed
def _scrub(text: str) -> str: def _scrub(text: str) -> str:
"""Credentials must never reach the log, even via a selector error that """Credentials must never reach the log, even via a selector error that
echoes filled form values.""" echoes filled form values. Playwright errors carry multi-line call
logs; a CSV cell keeps one line."""
for key in ("BGG_PASSWORD", "BGG_USERNAME"): for key in ("BGG_PASSWORD", "BGG_USERNAME"):
value = os.environ.get(key) value = os.environ.get(key)
if value: if value:
text = text.replace(value, "***") text = text.replace(value, "***")
return text return " ".join(text.split())
class LoginError(RuntimeError): class LoginError(RuntimeError):
@@ -236,10 +352,11 @@ class Uploader(Protocol):
class PlaywrightUploader: class PlaywrightUploader:
"""Drives the real site. Selector documentation: docs/bgg-upload-flow.md. """Drives the real site. Selector documentation: docs/bgg-upload-flow.md.
Verified selectors (2026-08-01): the login form (#inputUsername / All flows verified against the live site 2026-08-06 (adds, the version
#inputPassword / "Sign In") and the Add-to-Collection dialog structure. picker with pagination, and the collection-row version edit). The
UNVERIFIED: version-picker pagination, post-save behavior, and the whole remaining unknown is the second-copy add: BGG may edit the existing
collection-row edit flow for updates — expect first-real-run adjustments. entry instead of creating one, which run_upload reports as a copy-count
shortfall during --verify.
""" """
def __init__( def __init__(
@@ -275,11 +392,31 @@ class PlaywrightUploader:
# plus per-element auto-waiting is the reliable pattern. # plus per-element auto-waiting is the reliable pattern.
self._page.goto(url, wait_until="domcontentloaded") self._page.goto(url, wait_until="domcontentloaded")
# BGG's Sign In control is an <a class="btn"> with NO href, so it has
# no implicit link role — role-based queries cannot find it.
_SIGN_IN = 'a:has-text("Sign In"), button:has-text("Sign In")'
_SIGNED_IN = ':text-is("Sign Out"), :text-is("Log Out")'
def _signed_out(self) -> bool: def _signed_out(self) -> bool:
# Heuristic: the header shows a "Sign In" link only when logged out. """True = signed out. Waits for the header to prove ONE state or
return ( the other: BGG hydrates it after domcontentloaded, so an early read
self._page.get_by_role("link", name=re.compile(r"^sign in$", re.I)).count() finds NEITHER control — and any check resting on a single absence
> 0 guesses, silently, in the unsafe direction. Undeterminable state
raises rather than assumes."""
page = self._page
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
out = page.locator(self._SIGN_IN).count()
inside = page.locator(self._SIGNED_IN).count()
if out and not inside:
return True
if inside and not out:
return False
page.wait_for_timeout(250)
raise LoginError(
"could not tell whether this browser is signed in (neither a "
"Sign In nor a Sign Out control appeared in 30s) — BGG's header "
"may have changed; see docs/bgg-upload-flow.md"
) )
def _ensure_logged_in(self) -> None: def _ensure_logged_in(self) -> None:
@@ -313,6 +450,15 @@ class PlaywrightUploader:
"login did not complete (wrong credentials, or the login " "login did not complete (wrong credentials, or the login "
"page changed — see docs/bgg-upload-flow.md)" "page changed — see docs/bgg-upload-flow.md)"
) from err ) from err
# Verify the TRANSITION: the Sign In control was visible a
# moment ago, so its disappearance is evidence, not inference.
self._goto(f"{BGG}/")
if self._signed_out():
raise LoginError(
"the login form was submitted but the site still offers "
"Sign In — credentials rejected, or a challenge is "
"pending in the browser window"
)
self._context.storage_state(path=str(self._storage_state)) self._context.storage_state(path=str(self._storage_state))
self._storage_state.chmod(0o600) # session cookies: owner-only self._storage_state.chmod(0o600) # session cookies: owner-only
self._authed = True self._authed = True
@@ -332,14 +478,49 @@ class PlaywrightUploader:
raise raise
return dialog return dialog
def _select_version(self, dialog, version_name: str) -> bool: # Verified 2026-08-06 against the live picker: version rows are the
"""Page through the version sub-view matching the canonical version # <li>s carrying a thumbnail (the paging <li>s are not), and paging is
NAME (the list has no search box). Returns False — with the sub-view # an AngularJS <ul class="pagination"> of anchors, not buttons.
cancelled — when the name never shows up.""" _VERSION_ROWS = "li:has(.summary-item-thumbnail)"
# Paging controls render TWICE — a desktop set and a mobile set marked
# visible-xs-* — so every one of these selectors matches hidden nodes
# too; clicking one waits forever. Always pick the visible match.
_NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
_PAGE_LINKS = "ul.pagination a"
# Rows read "<game name> (<version name>) (<year>)", so the version name
# is matched inside its parentheses — bare substrings would let
# "English edition" match "English edition, second printing".
_TRAILING_YEAR = re.compile(r"\s+\d{4}(?:-\d+)?$")
def _rows_on_page(self, dialog) -> list[str]:
return [
" ".join(t.split())
for t in dialog.locator(self._VERSION_ROWS).all_text_contents()
]
def _visible(self, dialog, selector):
"""The first VISIBLE match, or None — see _NEXT_PAGE."""
loc = dialog.locator(selector)
for i in range(loc.count()):
candidate = loc.nth(i)
if candidate.is_visible():
return candidate
return None
def _has_next_page(self, dialog) -> bool:
nxt = self._visible(dialog, self._NEXT_PAGE)
if nxt is None:
return False # single-page list: the container is ng-show'd off
return "disabled" not in (nxt.evaluate("e => e.closest('li').className") or "")
def _select_version(self, dialog, version_name: str) -> tuple[bool, str]:
"""Pick the version whose name matches, paging the whole list first
so a decision is made against every candidate. Returns (picked,
reason); on failure the sub-view is cancelled and the caller adds
the game version-less — never a guessed edition (spec)."""
dialog.get_by_role("button", name="Set version/edition").click() dialog.get_by_role("button", name="Set version/edition").click()
pattern = re.compile(re.escape(version_name), re.I)
try: try:
dialog.get_by_role("listitem").first.wait_for(timeout=15_000) dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
except self._timeout_error as err: except self._timeout_error as err:
# a version resolve found on BGG can't be missing from the # a version resolve found on BGG can't be missing from the
# picker: an unrendered list means a slow page or changed markup # picker: an unrendered list means a slow page or changed markup
@@ -347,18 +528,28 @@ class PlaywrightUploader:
"version picker never rendered — site slow or markup " "version picker never rendered — site slow or markup "
"changed; attempt is retryable" "changed; attempt is retryable"
) from err ) from err
for _ in range(MAX_VERSION_PAGES):
items = dialog.get_by_role("listitem").filter(has_text=pattern) wanted = f"({version_name})".casefold()
if items.count(): # BGG's API name can carry a printing qualifier the picker omits
items.first.click() # ("English edition 2018-2" vs "(English edition) (2018)")
return True relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold()
# Pagination controls are UNVERIFIED (docs/bgg-upload-flow.md); self._goto_first_page(dialog) # a fresh sub-view opens on page 1,
# best guess is a next-page button, stopping when absent/disabled. # but never assume it: paging state outlives a close/reopen
nxt = dialog.get_by_role("button", name=re.compile("next|", re.I)).first exact: list[tuple[int, str]] = []
if nxt.count() == 0 or nxt.is_disabled(): loose: list[tuple[int, str]] = []
break # genuine end of list: added_no_version is honest pages = 0
nxt.click() for page_ix in range(MAX_VERSION_PAGES):
self._page.wait_for_timeout(2_000) # etiquette: paginating hits BGG too pages = page_ix + 1
for text in self._rows_on_page(dialog):
folded = text.casefold()
if wanted in folded:
exact.append((page_ix, text))
elif relaxed != wanted and relaxed in folded:
loose.append((page_ix, text))
if not self._has_next_page(dialog):
break
self._visible(dialog, self._NEXT_PAGE).click()
self._page.wait_for_timeout(400) # client-side paging: no request
else: else:
# never saw the end of the list: "not in picker" would be a false # never saw the end of the list: "not in picker" would be a false
# verdict frozen into DONE_STATUSES # verdict frozen into DONE_STATUSES
@@ -366,61 +557,182 @@ class PlaywrightUploader:
f"hit MAX_VERSION_PAGES ({MAX_VERSION_PAGES}) without " f"hit MAX_VERSION_PAGES ({MAX_VERSION_PAGES}) without "
"finding the version or the end of the list — retryable" "finding the version or the end of the list — retryable"
) )
hits = exact or loose
if len(hits) == 1:
_, text = hits[0]
if not self._click_row(dialog, text):
raise RuntimeError(
f"{text!r} was listed a moment ago but vanished on the "
"second pass — retryable"
)
how = "" if exact else f" (matched loosely as {text!r})"
return True, how
# Two-level dismissal: the sub-view has its own Cancel distinct from # Two-level dismissal: the sub-view has its own Cancel distinct from
# the main dialog's. # the main dialog's.
dialog.get_by_role("button", name="Cancel").first.click() dialog.get_by_role("button", name="Cancel").first.click()
if not hits:
return False, f"not offered in the picker ({pages} page(s) scanned)"
return False, (
f"{len(hits)} versions match {version_name!r} "
f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess"
)
def _goto_first_page(self, dialog) -> None:
"""Back to page 1 via the numbered "1" anchor. Closing and
reopening the sub-view does NOT reset Angular's paging state (it
reopens wherever it was left), and First/Prev render in a
mobile-only variant that a desktop viewport can never click."""
pager = dialog.locator(self._PAGE_LINKS)
for i in range(pager.count()):
anchor = pager.nth(i)
if anchor.is_visible() and (
" ".join((anchor.text_content() or "").split()) == "1"
):
anchor.click()
self._page.wait_for_timeout(400)
return
# no pagination rendered: a single-page list is already page 1
def _click_row(self, dialog, text: str) -> bool:
"""Find the row again by its NORMALIZED text and click it by index.
Playwright's has_text regex matches raw textContent — whose tabs and
newlines a normalized capture will never equal — so the comparison
happens in Python, and the click addresses a position."""
self._goto_first_page(dialog)
for _ in range(MAX_VERSION_PAGES):
for i, row_text in enumerate(self._rows_on_page(dialog)):
if row_text == text:
dialog.locator(self._VERSION_ROWS).nth(i).click()
return True
if not self._has_next_page(dialog):
return False return False
self._visible(dialog, self._NEXT_PAGE).click()
self._page.wait_for_timeout(400)
return False
def _owned_button(self):
"""The owned-game page replaces "Add To" with "In Collections"
positive evidence the collection already holds this game."""
return self._page.get_by_role("button", name="In Collections")
def add_game(self, job: UploadJob) -> tuple[str, str]: def add_game(self, job: UploadJob) -> tuple[str, str]:
self._ensure_logged_in() self._ensure_logged_in()
page = self._page page = self._page
# /boardgame/<id> redirects to the canonical slug for any subtype. # /boardgame/<id> redirects to the canonical slug for any subtype.
self._goto(f"{BGG}/boardgame/{job.bgg_id}/") self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
add_btn = page.get_by_role("button", name="Add To").first add_btn = page.get_by_role("button", name="Add To")
dialog = self._open_dialog(add_btn) for _ in range(60): # the header hydrates late: poll for EITHER state
# Content settles when the game-name heading replaces "Loading...". if add_btn.count() and add_btn.first.is_visible():
dialog.get_by_role( break
"heading", name=re.compile(re.escape(job.name), re.I) owned = self._owned_button()
).wait_for(timeout=15_000) if owned.count() and owned.first.is_visible():
dialog.get_by_label("Own").check() if job.second_copy:
# the second-copy path goes through the In Collections
# dialog, which this code has never driven live
raise RuntimeError(
"second copy of an owned game — the In Collections "
"add-a-copy flow is unverified; add this copy by hand"
)
return (
"already_present",
"the site already lists this game as owned — a previous "
"attempt likely landed without reaching the log",
)
page.wait_for_timeout(500)
dialog = self._open_dialog(add_btn.first)
# Wait for the form itself, NOT for a heading matching our stored
# name: a match made through an ALTERNATE name (BGG 140509 is
# "Dungeons & Dragons" to search, "Dragones Y Mazmorras" on the
# page) would never show it. The /boardgame/<id>/ URL already
# guarantees which game this is.
dialog.get_by_role("checkbox", name="Own", exact=True).wait_for(timeout=15_000)
# exact: "Own" is a substring of "Prev. Owned", and a loose label
# match resolves to both checkboxes (strict-mode violation)
dialog.get_by_role("checkbox", name="Own", exact=True).check()
status, note = "added", "" status, note = "added", ""
if job.version_name and not self._select_version(dialog, job.version_name): if job.version_name:
picked, why = self._select_version(dialog, job.version_name)
if picked:
note = why.strip()
else:
status = "added_no_version" status = "added_no_version"
note = f"version {job.version_name!r} not in picker; added without version" note = f"version {job.version_name!r}: {why}; added without version"
dialog.get_by_role("button", name="Save").click() dialog.get_by_role("button", name="Save").click()
# The dialog is hidden after save, not removed from the DOM. # The dialog is hidden after save, not removed from the DOM.
try:
dialog.wait_for(state="hidden", timeout=15_000) dialog.wait_for(state="hidden", timeout=15_000)
except self._timeout_error:
# a slow hide is not a failed save: reload and ask the page.
# Logging "failed" for a landed add would double-add on retry.
self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
owned = self._owned_button()
try:
owned.first.wait_for(timeout=15_000)
except self._timeout_error as err:
raise RuntimeError(
"the save dialog never closed and the page does not "
"show the game as owned — the add may not have landed"
) from err
note = (note + "; " if note else "") + "save confirmed via page reload"
return status, note return status, note
def update_entry(self, job: UploadJob) -> tuple[str, str]: def update_entry(self, job: UploadJob) -> tuple[str, str]:
"""Set the version on an EXISTING entry — strictly additive. """Set the version on an EXISTING entry — strictly additive.
UNVERIFIED FLOW: the collection table can't target a collid directly, Verified 2026-08-06. The collection table's version cell carries its
so this filters by game and opens the row's status link. With several own collid in an onclick and opens an inline editor whose radios
copies of one game the wrong row could open — acceptable only while carry VERSION IDS as their values, so both the copy and the edition
every 2018 entry is version-less; revisit after the first real run. are addressed exactly — no name matching, no dialog, no pagination.
Clicking a radio fires CE_SaveData itself; there is no Save button.
""" """
self._ensure_logged_in() self._ensure_logged_in()
page = self._page page = self._page
self._goto( self._goto(
f"{BGG}/collection/user/{self._username}?objectid={job.bgg_id}&own=1" f"{BGG}/collection/user/{self._username}?objectid={job.bgg_id}&own=1"
) )
row = ( cell = page.locator(f'td.collection_version[onclick*="{job.collid}"]')
page.get_by_role("row") if cell.count() == 0:
.filter(has_text=re.compile(re.escape(job.name), re.I))
.first
)
opener = row.get_by_role("link", name=re.compile("own", re.I)).first
dialog = self._open_dialog(opener)
if not self._select_version(dialog, job.version_name):
# Never guess a version: close without touching the entry.
dialog.get_by_role("button", name="Cancel").first.click()
raise RuntimeError( raise RuntimeError(
f"version {job.version_name!r} not in picker — entry left untouched" f"collid {job.collid} has no version cell on the collection "
"page — the entry may have been removed; re-run diff"
)
cell.first.click()
radio = page.locator(
f'form[id^="form_version"] input[type="radio"][value="{job.version_id}"]'
)
try:
radio.first.wait_for(timeout=15_000)
except self._timeout_error as err:
# the editor lists every version of the game: an absent id means
# the wrong game's editor opened, or BGG retired that version
page.keyboard.press("Escape")
raise RuntimeError(
f"version {job.version_id} not offered for collid "
f"{job.collid} — entry left untouched"
) from err
radio.first.click() # fires CE_SaveData: no separate Save button
want = _loose_version_text(job.version_name)
for _ in range(40):
settled = " ".join((cell.first.text_content() or "").split())
if settled and "editing" not in settled.casefold():
got = _loose_version_text(settled)
if want and want not in got and got not in want:
# the editor closed but re-rendered its OLD content:
# the AJAX save failed server-side. "updated" here
# would mark the job done forever without evidence.
raise RuntimeError(
f"the version cell settled on {settled!r}, not the "
f"chosen {job.version_name!r} — the save did not "
"land; safe to retry (same radio, same result)"
)
return "updated", ""
page.wait_for_timeout(500)
raise RuntimeError(
"the version cell never left its editing state — the save may "
"not have landed; re-run with --verify"
) )
dialog.get_by_role("button", name="Save").click()
dialog.wait_for(state="hidden", timeout=15_000)
return "updated", "row-edit flow is unverified; confirm with --verify"
def _process( def _process(
@@ -468,7 +780,7 @@ def _process(
suffix = f"{note}" if note else "" suffix = f"{note}" if note else ""
typer.echo(f" {job.name}: {status}{suffix}") typer.echo(f" {job.name}: {status}{suffix}")
if status == "failed": if status == "failed":
kind = note.split(":", 1)[0] # exception type from _scrub format kind = note # IDENTICAL means the whole message, not the class
consecutive = (kind, consecutive[1] + 1 if kind == consecutive[0] else 1) consecutive = (kind, consecutive[1] + 1 if kind == consecutive[0] else 1)
if consecutive[1] >= 3: if consecutive[1] >= 3:
typer.echo( typer.echo(
@@ -603,7 +915,19 @@ def run_upload(
to_update = _read_csv(cfg.to_update_path) to_update = _read_csv(cfg.to_update_path)
log_rows = _read_csv(log_path) log_rows = _read_csv(log_path)
jobs, skipped_done, skipped_failed, deferred = build_queue( # The queue is a snapshot; review decisions since the last diff win.
stale = stale_jobs(to_add + to_update, read_matches(cfg.matches_path))
if stale:
to_add = [r for r in to_add if r.get("bgg_id") not in stale]
to_update = [r for r in to_update if r.get("bgg_id") not in stale]
typer.echo(
f"Skipping {len(stale)} queued game(s) whose review decision "
"changed since the last diff — re-run diff to refresh the queue:"
)
for bgg_id, why in stale.items():
typer.echo(f" {bgg_id}: {why}")
jobs, skipped_done, skipped_failed = build_queue(
to_add, to_update, log_rows, retry_failed=retry_failed to_add, to_update, log_rows, retry_failed=retry_failed
) )
if limit is not None: if limit is not None:
@@ -615,12 +939,6 @@ def run_upload(
f" (skipping {skipped_done} already done, {skipped_failed} " f" (skipping {skipped_done} already done, {skipped_failed} "
"previously failed — use --retry-failed)" "previously failed — use --retry-failed)"
) )
if deferred:
typer.echo(
f"{len(deferred)} version update(s) deferred: one update per game "
"per run (the row-edit flow can't target a collid) — re-run "
"upload after --verify confirms this batch."
)
results: list[dict] = [] results: list[dict] = []
if dry_run: if dry_run:
+896 -65
View File
File diff suppressed because it is too large Load Diff
+91 -7
View File
@@ -2,7 +2,9 @@
{ {
"title_raw": "Catan", "title_raw": "Catan",
"confidence": "high", "confidence": "high",
"source_photos": ["hand-typed-test-list"] "source_photos": [
"hand-typed-test-list"
]
}, },
{ {
"title_raw": "Wingspan", "title_raw": "Wingspan",
@@ -10,31 +12,113 @@
"publisher_hint": "Stonemaier Games", "publisher_hint": "Stonemaier Games",
"year_hint": 2019, "year_hint": 2019,
"language_hint": "English", "language_hint": "English",
"source_photos": ["hand-typed-test-list"] "source_photos": [
"hand-typed-test-list"
]
}, },
{ {
"title_raw": "Wingspan: European Expansion", "title_raw": "Wingspan: European Expansion",
"confidence": "high", "confidence": "high",
"source_photos": ["hand-typed-test-list"] "source_photos": [
"hand-typed-test-list"
]
}, },
{ {
"title_raw": "Wingspan Europe", "title_raw": "Wingspan Europe",
"confidence": "medium", "confidence": "medium",
"source_photos": ["hand-typed-test-list"] "source_photos": [
"hand-typed-test-list"
]
}, },
{ {
"title_raw": "Café International", "title_raw": "Café International",
"confidence": "high", "confidence": "high",
"source_photos": ["hand-typed-test-list"] "source_photos": [
"hand-typed-test-list"
]
}, },
{ {
"title_raw": "Citadels", "title_raw": "Citadels",
"confidence": "high", "confidence": "high",
"source_photos": ["hand-typed-test-list"] "source_photos": [
"hand-typed-test-list"
]
}, },
{ {
"title_raw": "Blorvath: Quest of the Zzyzx", "title_raw": "Blorvath: Quest of the Zzyzx",
"confidence": "low", "confidence": "low",
"source_photos": ["hand-typed-test-list"] "source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC",
"confidence": "high",
"publisher_hint": "The Avalon Hill Game Company, Baltimore, Maryland",
"edition_hint": "Bookcase Game",
"language_hint": "English",
"source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "ADVANCED CIVILIZATION Game Expansion of the Heroic Age - Featuring New Civilization, Commodity, and Calamity Cards",
"confidence": "high",
"publisher_hint": "The Avalon Hill Game Company, Baltimore, Maryland",
"edition_hint": "Bookcase Game",
"language_hint": "English",
"source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "SORCERER The Game of Magical Conflict",
"confidence": "high",
"publisher_hint": "Simulations Publications Incorporated (SPI)",
"edition_hint": "Designer's Edition",
"language_hint": "English",
"source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century",
"confidence": "high",
"publisher_hint": "Simulations Publications Incorporated (SPI)",
"edition_hint": "Designer's Edition",
"language_hint": "English",
"source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "FLAT TOP",
"confidence": "high",
"year_hint": 1942,
"language_hint": "English",
"source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "ALICE IS MISSING: A SILENT ROLE PLAYING GAME",
"confidence": "high",
"source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "Dungeon!",
"confidence": "high",
"source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "WIZ-WAR",
"confidence": "high",
"source_photos": [
"hand-typed-test-list"
]
} }
] ]
-1
View File
@@ -1 +0,0 @@
This cache contains hand-written stub XML, not real BGG responses. Data resolved from it must not be uploaded.
@@ -1 +0,0 @@
<items total="1"><item type="boardgame" id="207830"><name type="primary" value="5-Minute Dungeon"/><yearpublished value="2017"/></item></items>
@@ -1 +0,0 @@
<items total="0"></items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1,9 @@
<items total="1"><item type="boardgameexpansion" id="177"><name type="primary" value="Advanced Civilization"/><yearpublished value="1991"/></item></items> <?xml version="1.0" encoding="utf-8"?><items total="2" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> <item type="boardgame" id="177">
<name type="primary" value="Advanced Civilization"/>
<yearpublished value="1991" />
</item>
<item type="boardgameexpansion" id="177">
<name type="primary" value="Advanced Civilization"/>
<yearpublished value="1991" />
</item>
</items>
@@ -1 +0,0 @@
<items total="0"></items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1 @@
<items total="1"><item type="rpgitem" id="400001"><name type="primary" value="Alice Is Missing: A Silent Role Playing Game"/><yearpublished value="2020"/></item></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +0,0 @@
<items total="0"></items>
@@ -1 +0,0 @@
<items total="1"><item type="rpgitem" id="400002"><name type="primary" value="Alice Is Missing: Silent Falls Expansion"/><yearpublished value="2023"/></item></items>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1,9 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="2" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> <item type="rpgitem" id="311654">
<name type="primary" value="Alice is Missing"/>
<yearpublished value="2020" />
</item>
<item type="rpgitem" id="380459">
<name type="primary" value="Alice is Missing: Silent Falls"/>
<yearpublished value="2023" />
</item>
</items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +0,0 @@
<items total="0"></items>
@@ -1 +0,0 @@
<items total="1"><item type="boardgame" id="26"><name type="primary" value="Age of Renaissance"/><yearpublished value="1996"/></item></items>
@@ -1 +0,0 @@
<items total="0"></items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +1 @@
<items total="0"></items> <?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
@@ -1 +0,0 @@
<items total="1"><item type="boardgame" id="240"><name type="primary" value="Britannia"/><yearpublished value="1986"/></item></items>
@@ -1 +0,0 @@
<items total="0"></items>
@@ -1 +0,0 @@
<items total="1"><item type="boardgame" id="235096"><name type="primary" value="Cat Crimes"/><yearpublished value="2017"/></item></items>
@@ -1 +0,0 @@
<items total="0"></items>

Some files were not shown because too many files have changed in this diff Show More