Compare commits

111 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 883de3606f v1.1.0: the library learns where everything lives
Version bump for the post-announcement era: the static export (with
the colophon and the Powered-by-BGG badge slot), box dimensions and
the dims report, boxes-within-boxes containment with pick-and-tick
bulk adds, and the whole Shelves layer — furniture drawn as it really
stands, two-lane packing, the human-overrule vocabulary (acknowledged,
wedged, measured, away), and shelves shipping with the export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 16:35:42 -04:00
Eric WagonerandClaude Fable 5 7ed21de82a The space view lists boxes, not their contents
Eric, looking at the Red Dragon Inn shelf: a game stored inside
another box doesn't need its own row in the space view — it's clutter.
Right: the shelf holds physical boxes; what's inside them is the
container's business, already listed on its game page.

The opening sheet now renders only physical residents, collapsing
contents into a "holds N inside" hint on the container's row (hover
for the names). Eric's real B2 goes from 20 rows to 5 — Speechless,
Superfight, the Character Trove ("holds 15 inside"), Smorgasbox, Throw
Throw Avocado. The exported wall page mirrors it with "(+N inside)"
after the container's link. The payload keeps every rider (with a new
via field naming its box) so the sheet's add-a-game search still
excludes games already present through containment, and riders still
never appear in the unshelved backlog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 16:21:58 -04:00
Eric WagonerandClaude Fable 5 0ed2145f80 Where it lives vs where it IS: the away field for loans and travel
Eric: this shows where games live, but Frosthaven is in the travel
case right now, and sometimes a game is at a friend's house. A home
and a whereabouts are different facts, so a location record can now
carry away: free text for where the box IS when that isn't home.

Set it in the Right now field on any game's detail page ("travel
case", "loaned to Ben"); blank brings it back and leaves no husk of a
record behind. Home is untouched throughout: the opening assignment
stays, the spot stays reserved in the packing math — a loan is not a
move. Contents ride along the same way they ride on shelves: games
stored inside an out box read as out with it, through whole chains.
The home opening's sheet shows an "out:" chip, unshelved rows show it
too, and the exported game page appends "right now: …" beside where
it lives. Away survives locate edits, lane flips, and CSV re-imports.

Verified live, with Eric's actual fact as the test: Frosthaven marked
out to the travel case from its page — its E1 sheet row now reads
"out: travel case · standing, proud", zero page errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 16:17:31 -04:00
Eric WagonerandClaude Fable 5 6c95953dc0 "It's here anyway" + the tape measure: the human overrules the packer
Eric, looking at a dense cube full of "no room" and "unmeasured":
how do I say it really IS here, and in what orientation? Two answers.

The packer models one flat pile and one standing row; real shelves are
3-D — boxes sit crosswise, ride on top of piles, stand proud of the
edge. Acknowledge generalizes from overhang-only to the human
overruling the model: "it's here anyway" on any resident the lanes
can't take. An acknowledged box takes a lane when one works (honoring
a flat/standing override; the spine-out case lands relaxed standing,
now tagged ", proud" by the packer itself instead of guessed from
misfit-ness) and otherwise is WEDGED IN: really there, in some
arrangement the model can't see, outside the capacity bars and never
warned about. The previous commit's ackable gate is gone — accepting
always does something now, so no placebo remains to guard against.

And "unmeasured" is no longer a dead end: the chip is a button. Tap,
enter width × length × depth from your own tape measure, and it saves
to data/measurements.json (a new committed curation store) with dims
source "measured" — overlaid on every games.json read (web, dims,
export, CSV import), because the owner's ruler outranks BGG's
database. All three fields empty clears one.

Verified live: Etherfields on the tower reads "standing, proud", an
acknowledged box in a width-starved cube reads "wedged in", the
measure form opens with three inputs, zero page errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 16:10:35 -04:00
Eric WagonerandClaude Fable 5 4952e69920 The one-way lane flip: an override into no-room now carries its undo
Eric tapped "flat" on Flick 'em Up in a packed cube. The flip means
"stand this instead" — but the cube's standing lane is 1.55 inches and
the box is 2.8 thick, so the override landed it laneless. And laneless
meant no lane button: the override that caused the problem had no
control left to revoke it. A one-way door, sprung mid-curation.

Now an override that leaves its box without a lane renders as
"no room ✱ undo" — one tap clears the override and the packer chooses
again. The plain "no room" chip (no override, nothing to undo) stays a
chip. Verified live on the real B1: flip-trapped box showed the undo,
one tap returned it to the flat pile, override cleared from
locations.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 15:58:39 -04:00
Eric WagonerandClaude Fable 5 f0cf4ec271 No placebo buttons: accept-overhang only offered when it would help
Eric clicked accept-overhang on two boxes in a Kallax cube and got an
"overhang ✓" pill with no lane behind it. The cube's flat pile claims
11.7 of 13.25 inches of width, leaving 1.55 inches of standing lane —
acknowledging waives depth and height, never width, so nothing changed.
That opening is overfull, not overhung, and the button was a placebo.

The sheet now probes before offering: a laneless resident gets the
accept button only when acknowledging would actually land it a lane;
otherwise it's marked "no room" with the honest tooltip. Acknowledged
boxes that DID land show "▮ standing, proud" (their lane isn't
flippable — proud of the shelf is the whole point), plus the
retractable "overhang ✓". Verified against the live wall: the three
Etherfields monoliths on the tower's big row read standing-proud, the
squeezed cube residents read no-room, zero page errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 15:53:02 -04:00
Eric WagonerandClaude Fable 5 7d7d3bb8da The acknowledged overhang: "doesn't fit, shelved anyway" goes legit
Eric's office towers are 9.5 inches deep; Etherfields boxes are 11.8
inches square. Spine-out they stand 2.3 inches proud of the shelf edge
— clearly livable, it's how they sit today — but the lane model will
honestly refuse forever: can't stand (11.8 > 9.5 depth), can't stack
flat (same), so the opening warns NO PACKING FITS. The first deliberate
misfit deserves first-class status.

An assignment can now carry acknowledged: true. An acknowledged box
takes the standing lane WITHOUT the height/depth check — the human has
seen the overhang — while the width budget stays honest (nine
acknowledged monoliths still overflow a 23.25-inch row). It drops out
of misfit/laneless warnings everywhere: the wall diagram, the sheet,
and bggpipe dims (which now says "overhangs, acknowledged" instead).

Ways in: an "accept overhang" button in the opening's sheet (appears
on any resident the lanes can't take; tap again to retract), or an
acknowledge column (yes) in shelve --import. Once acknowledged, later
imports needn't re-state it.

Re-import hardening, prompted by Eric's "will a new import make a
mess?": it's an upsert, not a reset — and now genuinely so. Previously
a re-import preserved notes but silently wiped lane overrides; now
notes, lanes, and acknowledgments all survive when the opening is
unchanged (a move resets lane and acknowledgment — new spot, new
verdict). Same fix in /api/locate: the lane-flip button used to wipe
the hand-entered note because LocateBody.note defaulted to "" —
it's now tri-state, None means leave it alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 15:45:36 -04:00
Eric WagonerandClaude Fable 5 4ce7706521 The arrow that unbound half of wire(), and openings for the unit's top
Eric: styled buttons look great, but edit-description does nothing.
One missing character — .forEach(b = instead of (b => — turned the
callback into a strict-mode ReferenceError that killed wire() midway:
everything bound before the typo (rename, duplicate) worked,
everything after (edit description, its save, the add-opening submit)
silently never bound, and the throw hid inside an async refresh where
no pageerror fires. Fixed and verified live: description edits save,
add-opening works again.

And Eric's next question answered in code: things live ON the
furniture too. The add-opening form gains an "at the top" checkbox —
new openings (or whole grids) land above the existing rows instead of
below, so "on top of the Kallax" is one labeled opening away. Give it
real width/depth and the height to your ceiling, or no sizes for a
no-limit display spot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 15:22:56 -04:00
Eric WagonerandClaude Fable 5 2f526335e8 Every button looks like a button: .linkish retires
Eric's screenshot: "rename" and "edit description" rendered as bare
underlined links beside properly-drawn buttons. The .linkish class
deliberately stripped the design system — introduced for one inline
case, it spread to toolbars where it read as missing styling. All six
sites (rename, edit description, the stored-in change control, the
search-BGG entry point) now inherit the app's real button treatment,
and the class is deleted so it can't spread again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 15:16:40 -04:00
Eric WagonerandClaude Fable 5 79d42c43ab The export summary admits the shelves page exists
Eric re-exported to see the new shelves page, got the same summary
line as always, and reasonably concluded nothing happened — while the
wall page sat freshly written on disk. A feature that ships silently
looks like a no-op. The echo now reads "exported N game page(s) + the
shelves page" and the summary dict carries shelves: bool, pinned by
test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 15:13:56 -04:00
Eric WagonerandClaude Fable 5 10f99e23f5 Duplicate a unit: describe the furniture once, stamp out the rest
Eric's ask. duplicate-unit clones the STRUCTURE — openings with their
sizes, zones, and descriptions, under fresh ids and the first free
"<name> 2"-style name — never the game assignments. And because a
copy immediately wants a real name, units gained rename: opening ids
are stable through it so locations and export URLs never notice, and
a rename-only edit no longer risks wiping the description (the body
field learned the None-means-leave-alone convention). Tests pin the
disjoint ids, the empty copy, the numbering past taken names, and
the wipe-nothing rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 15:10:47 -04:00
Eric WagonerandClaude Fable 5 e90a53fb6c Two-lane packing, and the shelves ship with the export
Eric's wall answered the orientation question with a screenshot full
of red: the capacity model assumed one flat stack per opening, so his
spine-out cubes read 200% full. His reviewer's prescription, built to
its acceptance cases: each opening packs a FLAT lane (thinnest axes
against interior height, claiming the widest flat box's width) and a
STANDING lane beside it (boxes on edge against the remaining width;
standing boxes must fit height and depth upright). The largest-
footprint class lies flat, smaller boxes stand, any box's lane is
flippable per-assignment (▬/▮ toggle in the opening view, ✱ marks an
override), and ⚠ now means NO packing fits — not merely "tall stack".
Unmeasured boxes take no lane but keep their honesty tag; dual fill
bars show each lane's budget; a broken opening is never offered by
the suggester. On the real wall: the double-wides and half the cubes
went green (7 games = 13.12" flat + 2.91" standing), and the
remaining warnings mark cubes that genuinely hold 12-16 boxes.
Both reviewer acceptance tests pass verbatim.

And the export gains the humanity Eric asked for: game pages say
where each box lives, containers list their contents, and a shelves
page draws the wall as it physically stands — proportional cells,
zones, descriptions, every opening linking its residents. Publishing
a shelf layout is a choice: --no-shelves keeps the layer out.
"shelves" joins "art" as a reserved slug. 387 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:53:42 -04:00
Eric WagonerandClaude Fable 5 d6ff5e73e9 The docs catch up with the shelves the audits built
Eric asked whether the shelf work was documented — the guide and Help
described the launch version, not the three audit rounds and design
passes since. Both now cover the physical wall rendering, the
suggestion doctrine (♥ reunification, remaining-room honesty, tightest
fit, honest empties), descriptions at both furniture levels,
row-clamped reordering, grid continuation on existing units, chained
containment, and the fill-bar epistemics. The tour gains a Shelves
entry with a screenshot of the drawn Kallax, and four documents stop
claiming the app has seven pages — Shelves made it eight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:34:48 -04:00
Eric WagonerandClaude Fable 5 7ac80d1526 The opening description hint speaks at opening scale
Eric's catch: the placeholder still said "tall bookcase by the
window" — a sentence about a UNIT, sitting in the opening's form.
Now "oversized boxes lie flat here — bottom row, easy reach".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:32:49 -04:00
Eric WagonerandClaude Fable 5 e58cc789f3 Units get descriptions too — both levels of the furniture speak
Eric's correction: the opening-level description shipped, but the
unit deserved one as well (my own example sentence was unit-scale
prose). Units take a description at creation and edit it in place —
a linkish add/edit control under the unit's name with an inline
input — shown as quiet meta text on the card and stored in
furniture.json. Label addresses, zone matches, descriptions explain;
now at both scales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:30:58 -04:00
Eric WagonerandClaude Fable 5 ce4b0e09d4 Openings get descriptions: zone is the keyword, this is the sentence
Eric's ask. Free text on any opening ("tall bookcase by the window —
kids reach the bottom rows"), edited in the opening settings sheet,
shown under the sheet's title, and surfaced as the cell's hover title
on the wall diagram. Stored in furniture.json like everything else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:29:44 -04:00
Eric WagonerandClaude Fable 5 a23b97d2f9 Opening moves become row-honest: ← → within the row, clamped at edges
Eric asked what the ↑↓ buttons did — the honest answer was "reorder a
flat list that no longer exists, and fragment your wall diagram if
pushed across a row boundary" (A1, B1, A2 renders as THREE rows under
the letter-grouped layout). The useful half survives as ← → "move
within its row"; the endpoint clamps at row-letter boundaries so the
diagram can't fragment. Tests pin both the clamp and the legitimate
within-row swap; the acceptance flow's reorder step updated to match
the new semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:28:15 -04:00
Eric WagonerandClaude Fable 5 75162c3e2e The suggestion pills return: the client learns the object shape
Eric: "Everything says no matching openings yet. How would I get
started?" — because the client still mapped suggestion STRINGS while
the server sent {id, reunites} objects. The edit teaching the pills
the new shape (and the ♥ reunification marker) died in a script abort
during the ranking batch, and the retry re-applied everything except
it; the arrangement screenshot that "verified" the batch predated the
ranking change, so no check ever looked at pills until now. Verified
against the real library this time: 296 pills, zero false
no-matching rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:24:25 -04:00
Eric WagonerandClaude Fable 5 f4f3877651 Units draw their real arrangement; suggestions learn the owner's plan
Three requests from Eric plus his reviewer's ranking doctrine.

The unit renders its PHYSICAL shape: openings group into rows by
their label letters and each cell's width is proportional to its
interior width — the Kallax reads as two double-wides spanning the
top, then four-across cube rows. A diagram of the wall, not a list.

The toggle buttons died: "Add a unit" and "Add an opening" are
labeled, always-visible forms — the button that merely revealed the
form below it was ceremony.

Suggestions got their relevance doctrine, three rules deep:
(1) Reunification beats geometry — an opening holding a series-mate
or base game (kinship = the normalized pre-colon name stem, plus the
series field where enrich has one; no taxonomy invented) ranks first,
marked ♥. Every placement the owner confirms teaches the suggester
their organization by example; expansions chase their base games
automatically during the move. (2) "Fits" means the REMAINING
opening: the stack budget already spent is subtracted before
offering, so the suggester's honesty holds precisely as shelves fill
— the direction it used to degrade. (3) Then tightest verified fit,
so a small box is offered cubes and never the oversize row. Nothing
matching says "no matching openings yet" instead of offering
everything the wall has.

One composed test pins the interplay: a full shelf isn't offered even
to a series-mate (honesty outranks reunification), and freeing the
space flips the same suggestion to ♥-first. 382 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:20:16 -04:00
Eric WagonerandClaude Fable 5 446579e96c Asset stamps become content hashes — version stamps bust nothing
Eric's screenshot showed week-old CSS again, straight through the
cache-buster: the stamp was the app VERSION, unchanged at 1.0.0 since
release, so every stylesheet change shipped at the same ?v= URL and
browsers rightly kept their copies. The stamp is now an 8-char hash
over the static bundle's bytes — it changes exactly when the files do,
releases or not. The test now pins that property instead of the
version equality it used to celebrate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:10:54 -04:00
Eric WagonerandClaude Fable 5 708883313f The per-unit add form learns grids — and Eric's unit comes back
Eric walked the real flow: top row of double-wides done, now add
three rows of four cubes — and found no way, because grid creation
lived only in the CREATE form (the "repeat with another grid" advice
pointed at a non-obvious trick). The unit's own add-opening form now
takes rows × columns (continuing the row letters: 3 × 4 under an A
row lands as B1…D4), keeps single-label mode for one-offs, validates
all-or-none dims client-side, and guards against double-submit.

Also restored: Library Kallax. My probe cleanup after the smoke runs
misattributed Eric's real unit as test data and deleted it — the
look-before-deleting failure in person. Rebuilt with the intended
geometry (two 26.5" double-wides, zone oversize) and the twelve
cubes, committed as his data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:07:30 -04:00
Eric WagonerandClaude Fable 5 6ab4a836b4 Audit round 9 lands: the location layer keeps its promises
Five blind reviewers over the day-old shelves layer, ~28 verified
findings — plus Eric's screenshot catching the biggest one live: the
generic .card is flex (built for review's photo-beside-ballot layout),
so unit headers shared a row with their grids and unshelved rows
flowed horizontally off the page. Shelf cards are now .card.stack.

The model fixes. Containment chains resolve recursively with a cycle
guard — minis inside an insert inside a big box live where the big box
does, instead of vanishing from every list; the stored-in endpoint
walks the whole chain when refusing cycles, and clears the newly
contained game's own shelf spot (one box must never consume capacity
in two openings). Contained games are listed residents but occupy no
shelf space: only physical boxes are stacked and fit-checked — the web
report now agrees with the dims report about the same opening. A
location pointing at a vanished opening (hand-edited or reverted
store) SURFACES as unshelved with a "shelf gone" chip in the app and
counts as homeless in the CLI, instead of hiding the game from every
list while the page declares everything has a home.

Honest edges. Opening dimensions are all-or-none everywhere (a
half-sized opening silently became limitless; the CLI report crashed
formatting it); a second grid on a unit continues the row letters so
labels stay unique and label-addressed CSV imports keep working, and
row letters survive past Z; CSV re-imports preserve hand-entered
notes; the ambiguity reject names the fix that actually works;
corrupt furniture/locations stores speak a 500 instead of a raw
traceback; the dims help text stops saying Kallax; DIM_AXES gets one
home in models.py instead of three drifting copies; the new stores
join CLAUDE.md's commit registry.

The page behaves. Custom-dims fields hide unless the custom preset is
chosen (typed values were silently discarded); the sheet is a real
dialog (role, aria-modal, Escape, focus return, one layer at a time);
backdrop close requires press AND release on the backdrop (a text-
selection drag out of the search box no longer dismisses); refresh
goes through changeGate and stops wiping the search mid-interaction;
the prompt() chain is an inline per-unit form with client-side
all-or-none validation; unit-create only toasts success after the
openings actually land, recovers from its own half-failures, and
guards against double-submit (a click retried against the re-rendering
DOM built a second grid — caught live in a Playwright run); warnings
speak (aria-labels on ⚠ and overfull in the fill bar's label);
unmeasured boxes are visible in cells and sheet rows; the library's
unshelved filter matches the Shelves page's definition; reorder
buttons have names; the detail locform wraps at phone width.

Eight new regression tests from the seats' sketches; 380 total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 14:02:00 -04:00
Eric WagonerandClaude Fable 5 4f446f6f2a The Shelves layer: where every box physically lives
Eric's spec, all nine points. Two committed local-only stores follow
the local_games.json pattern — furniture.json (units of openings with
interior dims; a dimensionless opening is a virtual spot like a travel
case) and locations.json (game key -> opening + note). Shelf layouts
are nobody's data but the owner's; nothing touches upload.

The Shelves page builds furniture without hand-editing JSON — the
acceptance bar (two double-wides above three rows of four cubes, two
bookcases, a travel case) is a TEST, driven entirely through the
endpoints the UI calls. Presets for Kallax/Billy/custom/virtual,
grid creation with A1-style labels, openings editable/deletable/
reorderable. Units render as grids: zone, count, fill bar (stacked
thinnest-axis vs interior height), ⚠ on overfull or any resident that
can't fit. Openings open as a modal — a bottom sheet at phone widths,
search-first with thumb-sized targets for the moving-day loop.
Unshelved games list alongside with one-tap suggestions (only openings
they verifiably fit, with room).

Containment composes: a game stored inside another box inherits its
container's location, rides along in the opening's resident list
(marked), and refuses direct assignment naming its container. The
detail page's where-it-lives card gains the picker (openings grouped
by unit, each labeled fits / doesn't fit / can't verify) plus virtual
notes ("lent to Sarah, June"); the Library list shows a location line,
filters by unit or unshelved, and search matches location text and
zones.

bggpipe dims drops its hardcoded Kallax for the user's actual
furniture: per-opening capacity, overfull and misfit warnings,
unshelved count. bggpipe shelve --import loads a name,opening CSV
(ids or labels), rejecting — never guessing — unknown names, ambiguous
copies, unknown/ambiguous openings, misfits, and contained games.

Ten new tests incl. the acceptance flow, inheritance, CSV rejects,
and a phone-sheet smoke; 372 total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 13:45:36 -04:00
Eric WagonerandClaude Fable 5 e85f72c546 The where-it-lives card actually behaves; assets stop serving stale
Eric, again with the screenshot that tells the truth: "when I click it
it greys out and nothing seems to happen." Two roots. My own
#storedform { display:flex } silently DEFEATED the hidden attribute
(an author display beats the UA's [hidden] rule), so the form was
visible from load and the click's only visible effect was disabling
the link. The rule is now :not([hidden])-scoped, the click swaps the
sentence for the form and focuses the select. And the "unstyled"
select he saw was last week's stylesheet: /static assets had no
cache-busting, so my fresh-fetch Chrome verification passed while his
browser held the old CSS. Asset URLs now carry ?v=<app version> —
an upgrade busts every browser's cache by construction.

Verified the full interaction in-browser this time: form hidden on
load, click reveals with 142 container options in a styled select.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 13:14:55 -04:00
Eric WagonerandClaude Fable 5 f435cffea0 Selects join the design system at the element; entities decode at the parser
Eric, correctly: "Didn't learn from last time I see :)". The container
select in the where-it-lives card was native — because the previous fix
styled .editform select, the container, not the element. The rule is
now bare `select` (every page here is ours), so the fourth dropdown
gets it for free, and the CSS comment records the lesson.

Same screenshot, same class of miss: the About card showed raw &rsquo;
entities — the export got the double-encoding fix, the app never did.
Fixed at the PARSER this time (models.parse_things_full decodes once,
so every consumer — app, export, games.json — receives plain text),
with a render-time decode covering entries enriched before the fix.
Verified in-browser: "Great Britain's history", apostrophe and all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 13:07:52 -04:00
Eric WagonerandClaude Fable 5 fb611e0c87 Containment becomes editable: the where-it-lives card grows controls
Eric's question exposed the gap: stored_in was settable only at
pick-time, with no path for a game already in the system. The Library
detail page's "Where it lives" card now renders for every entry — "in
its own box on a shelf" with an it-lives-inside-another-box control,
or the current container with a change button — saving through a new
/api/stored-in endpoint that updates the match rows (the durable
record) AND the library entry in place, so the page and the shelf math
reflect it immediately, no enrich run needed. Guards refuse
self-containment and direct cycles; local games are addressable
through their key's normalized title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqdW79g
2026-08-09 12:55:36 -04:00
Eric WagonerandClaude Fable 5 f825a91ea4 Editions become changeable — losslessly
Eric hit a diff version-disagreement and found there was no way to
revisit a set edition: the Titles pick-edition button only appeared
while the version was UNSET. It now shows as "change edition" on
version_auto/version_approved rows — and reopening the ballot no
longer clears the current pick, because change must be lossless:
abandoning the ballot keeps the approved edition, the lingering id is
inert while ambiguous (is_confident_version gates diff and upload),
and the next decision overwrites or clears it.

Help documents the other half of the disagreement story: the pipeline
NEVER edits a version already set on a BGG collection entry (the
additive-only rule), so a diff disagreement is resolved either by
changing the pipeline's pick here or by fixing the entry by hand on
BGG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 12:28:59 -04:00
Eric WagonerandClaude Fable 5 fb9f654cd8 add-checked feedback returns: parse the Response before reading counts
Eric's report: no toast, no list update — yet the add landed. apiPost
returns the raw Response (every other caller only truth-checks it);
reading .added off it gave undefined and .skipped.length then THREW,
killing the toast, the panel close, and the refresh in an unhandled
rejection. The handler now parses the body first. Verified live: toast
fires with the count, panel closes, the catalog updates in place
(probe row removed from the data afterward).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 12:20:32 -04:00
Eric WagonerandClaude Fable 5 06902538fe The pick panel earns its styling
Eric's screenshot told the story: .editform label's field styling
(stacked, uppercase, gray) was swallowing the checklist rows, and the
container select was browser-native. Pick rows override back to plain
reading lines — checkbox, name in ink, year·type as quiet meta, the
already-cataloged chip pushed to the row's edge, hover tint — inside
a bordered scrollable pane; selects join the design system alongside
inputs. Verified against a live 117-result search.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 12:11:42 -04:00
Eric WagonerandClaude Fable 5 e153556fbf Help documents the pick-and-tick flow
Follow-up to dbc1899: the in-app Help's curation section now covers
the search-BGG checklist, containment, and the decides-instead-of-
duplicates behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 12:06:23 -04:00
Eric WagonerandClaude Fable 5 dbc1899759 A box of expansions in one pass: search, tick, done — with containment
Eric's Character Trove problem, both halves. The Titles add panel gains
"search BGG and tick them off": one API search (rate-limited, cached)
returns the whole family as a checklist, already-cataloged ids greyed
out, and every ticked result lands as an APPROVED match row plus a
title addition — the human picked it off BGG's own list, so resolve
has nothing left to derive. A pick whose name matches an undecided
photo line decides THAT line (photos kept) instead of duplicating it;
BGG's true name twins (two games both called "Citadels") skip with an
honest message rather than fusing.

And the half Eric spotted mid-build: containment is real data, not a
convention. A stored_in column on the match row (the container's
bgg_id — human curation, riding the same durable CSV as dedupe_veto)
is set by the pick panel's "they all live inside" selector, flows
through enrich onto games.json, and surfaces both directions in the
Library — "where it lives" on the content, "in this box" on the
container. The dims report excludes contained games from the Kallax
unknowns and counts them separately: a game with no box of its own
has no shelf space to plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 12:05:43 -04:00
Eric WagonerandClaude Fable 5 102507b040 Box dimensions: enrich learns shelf math, dims reports the Kallax truth
Eric's brief, implemented to the letter. BGG keeps physical dimensions
on VERSIONS, not games, so enrich runs a second cached pass over
thing?versions=1 (same batching, token, rate limit, and cache as every
call). A game with a chosen version takes that exact version's numbers
(source "version", mirrored onto its version dict); a versionless game
gets numbers only when every printing with data agrees within 0.5" per
axis (source "unanimous", keeping the MAX per axis — the planning
question is "will it fit"); disagreement stores nulls as "conflicting"
— never a guess — and BGG's 0 parses as "never entered", not a real
dimension. rpgitems and local games are "absent". Read-only: upload
untouched.

The new offline `bggpipe dims` reports coverage by source, the ten
biggest footprints, and a Kallax fit check (13.2" square opening,
15.4" deep; a box fits if SOME orientation puts two axes through the
opening within the depth) — naming every misfit and every game whose
dimensions can't be verified, because can't-verify ≠ fits. Trusted
numbers surface on the Library detail page as a "box" row.

First real run: 54 version-exact, 16 unanimous, 39 conflicting, 27
absent; three genuine misfits (Bugs in the Kitchen's 17" box, History
of the World and Risk LotR both over the 15.4" depth).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 11:18:48 -04:00
Eric Wagoner 9d752c4109 README: link the live game library built from the enrich stage 2026-08-06 19:23:06 -04:00
Eric WagonerandClaude Fable 5 47409d0487 The colophon card: the shelf ends with the piper and the fine print
Eric's idea — the last card in the grid, always visible, carrying the
full piper, the Powered-by-BGG badge, and the legal text. A <div>
rather than an <a>, so the search filter (which selects a.game) can
never hide it: the compliance text is part of the collection, not a
skippable footer. The piper ships from the package's own static art,
credited to Juniper on the card itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 19:07:12 -04:00
Eric WagonerandClaude Fable 5 c7fdc60f87 Audit round 8: the export earns its publishing promises
Five blind reviewers over the day-old export stage; ~30 findings
verified, the big ones sharing one root — a static-site generator
makes promises a pipeline stage doesn't, and the first cut kept none
of them.

URL stability was empirically false two ways: adding an edition whose
key sorted first STOLE the base slug (every colliding URL reshuffled),
and removing the base holder renumbered survivors over the stale
pages' corpses — wrong content at live URLs, not even 404s. And
nothing ever deleted anything: removed games stayed published forever.
One mechanism fixes all of it — a manifest (.bggpipe-export.json) in
the output directory records which slugs the export owns and which
source URL produced each cover. Slugs persist across runs (a published
URL never moves and can never be stolen), stale pages are removed
(only ever manifest-claimed ones — user files are not ours to touch),
replaced box art re-fetches when its URL changes, and "art" is a
reserved name so a game called Art can't move into the asset dir.

Trust-the-network fixes: a 200 response must LOOK like an image (magic
bytes + size) before it's cached, else a CDN interstitial became a
permanent "cover" that re-runs skipped forever; downloads go through
fsio.atomic_write_bytes instead of a hand-rolled fixed-tmp-name dance
(the exact hazard fsio's own docstring warns about); a missing
hand-added cover counts as a failure instead of silently shipping
coverless; the badge file is sniffed too; CDN pacing raised to 1s and
written into the spec as an adjudicated carve-out rather than a code
comment's private opinion.

Ship-shape: pages write atomically with the index LAST (a killed run
can't publish links to pages that don't exist); the CLI exits nonzero
on failures so `export && rsync` can't publish an incomplete site;
footer/fine-print contrast now clears WCAG AA on the sky background;
meta description, og:title/og:image and a favicon stop bare unfurls;
the BGG link moved out of the h1; the noart tile is aria-hidden; the
search box gained a no-matches message; numeric fields from enrich
render instead of crashing the join; years and ids are escaped; the
players/playtime formatters are aligned with their JS twins and both
sides carry keep-in-sync constraint comments; export moved after
enrich in the CLI listing.

Twelve export tests now, including the previously-vacuous atomicity
test rebuilt to actually interrupt a write. One honest loose end: one
full cover re-fetch occurred during rollout that the identical naming
code can't explain; the manifest's URL records make any recurrence
diagnosable. 352 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 18:51:05 -04:00
Eric WagonerandClaude Fable 5 f291b9c190 The Powered-by-BGG badge stays out of the repo
It's BGG's asset, provided through the owner's registered application
page — redistributing it in a public repo isn't ours to do. Each user
saves their own as data/powered-by-bgg.png (the export says so when
the file is absent), same principle as API tokens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 18:34:55 -04:00
Eric WagonerandClaude Fable 5 f677c7ce65 bggpipe export: the library as static pages for any site
Eric wanted the library on his blog; the spec always called games.json
"the seed for a future web frontend." The new export stage renders it
as self-contained static pages — an index with search, one page per
game with facts, chips, the owner's edition and the description — that
drop into any static host (Hugo's static/ folder included). No server,
no build step, no external requests from the published pages.

Public pages carry obligations a localhost app doesn't. Cover art is
downloaded once from BGG's CDN instead of hotlinked (0.3s between
fetches — a guest, not a crawler; part-file writes so a failure never
leaves a truncated image; re-runs skip what exists, so the export is
idempotent and resumable like every stage). The footer shows a
Powered-by-BGG badge per BGG's public-app policy — text by default,
upgraded to the official logo when the owner saves it from their
registered-application page as data/powered-by-bgg.png — plus the
trademark attribution. And one privacy rule, tested: shelf photos are
never exported; they picture the inside of the owner's home. Covers
and hand-added local art only, per Eric's explicit choice.

Slugs are deterministic and collision-stable (two editions of one game
get -2 suffixes in sorted-key order) so re-exports keep every URL.
Descriptions un-double-encode BGG's entities. First real run: 136
pages, 254 covers, 64MB, live on the blog's static directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-06 18:34:23 -04:00
Eric Wagoner e778452dd1 README: add live visitor and install badges 2026-08-06 18:11:50 -04:00
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
237 changed files with 54520 additions and 906 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=
+4
View File
@@ -1,6 +1,10 @@
# Secrets & credential-adjacent state # Secrets & credential-adjacent state
.env .env
*.storage_state.json *.storage_state.json
data/.lan_key
data/.own_repo
# BGG's badge asset is licensed to YOUR registered app - get your own
data/powered-by-bgg.png
playwright/.auth/ playwright/.auth/
storage_state.json storage_state.json
@@ -0,0 +1 @@
[ 20456ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:8399/api/pipeline:0
@@ -0,0 +1,65 @@
- generic [active] [ref=e1]:
- link "Skip to content" [ref=e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=e3]:
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
- /url: /
- generic [ref=e5]:
- text: bggpipe
- generic [ref=e6]: shelf → BGG pipeline
- navigation "Primary" [ref=e7]:
- link "Pipeline" [ref=e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=e10]: "4"
- link "Titles" [ref=e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=e21]:
- status
- main [ref=e22]:
- heading "Shelves" [level=1] [ref=e23]
- heading "Add a unit" [level=2] [ref=e24]
- generic [ref=e25]:
- generic [ref=e26]:
- text: Unit name
- textbox "Unit name" [ref=e27]:
- /placeholder: Living room Kallax
- generic [ref=e28]:
- text: Preset
- combobox "Preset" [ref=e29]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=e30]:
- text: Rows
- textbox "Rows" [ref=e31]: "1"
- generic [ref=e32]:
- text: Columns
- textbox "Columns" [ref=e33]: "1"
- generic [ref=e34]:
- text: Zone
- textbox "Zone" [ref=e35]:
- /placeholder: party games
- generic [ref=e36]:
- text: Description
- textbox "Description" [ref=e37]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=e39] [cursor=pointer]
- generic [ref=e40]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- paragraph [ref=e42]: Loading…
@@ -0,0 +1,65 @@
- generic [active] [ref=e1]:
- link "Skip to content" [ref=e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=e3]:
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
- /url: /
- generic [ref=e5]:
- text: bggpipe
- generic [ref=e6]: shelf → BGG pipeline
- navigation "Primary" [ref=e7]:
- link "Pipeline" [ref=e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=e10]: "4"
- link "Titles" [ref=e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=e21]:
- status
- main [ref=e22]:
- heading "Shelves" [level=1] [ref=e23]
- heading "Add a unit" [level=2] [ref=e24]
- generic [ref=e25]:
- generic [ref=e26]:
- text: Unit name
- textbox "Unit name" [ref=e27]:
- /placeholder: Living room Kallax
- generic [ref=e28]:
- text: Preset
- combobox "Preset" [ref=e29]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=e30]:
- text: Rows
- textbox "Rows" [ref=e31]: "1"
- generic [ref=e32]:
- text: Columns
- textbox "Columns" [ref=e33]: "1"
- generic [ref=e34]:
- text: Zone
- textbox "Zone" [ref=e35]:
- /placeholder: party games
- generic [ref=e36]:
- text: Description
- textbox "Description" [ref=e37]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=e39] [cursor=pointer]
- generic [ref=e40]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- paragraph [ref=e42]: Loading…
@@ -0,0 +1,65 @@
- generic [active] [ref=e1]:
- link "Skip to content" [ref=e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=e3]:
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
- /url: /
- generic [ref=e5]:
- text: bggpipe
- generic [ref=e6]: shelf → BGG pipeline
- navigation "Primary" [ref=e7]:
- link "Pipeline" [ref=e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=e10]: "4"
- link "Titles" [ref=e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=e21]:
- status
- main [ref=e22]:
- heading "Shelves" [level=1] [ref=e23]
- heading "Add a unit" [level=2] [ref=e24]
- generic [ref=e25]:
- generic [ref=e26]:
- text: Unit name
- textbox "Unit name" [ref=e27]:
- /placeholder: Living room Kallax
- generic [ref=e28]:
- text: Preset
- combobox "Preset" [ref=e29]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=e30]:
- text: Rows
- textbox "Rows" [ref=e31]: "1"
- generic [ref=e32]:
- text: Columns
- textbox "Columns" [ref=e33]: "1"
- generic [ref=e34]:
- text: Zone
- textbox "Zone" [ref=e35]:
- /placeholder: party games
- generic [ref=e36]:
- text: Description
- textbox "Description" [ref=e37]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=e39] [cursor=pointer]
- generic [ref=e40]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- paragraph [ref=e42]: Loading…
@@ -0,0 +1,407 @@
- generic [active] [ref=f1e1]:
- link "Skip to content" [ref=f1e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=f1e3]:
- link "bggpipe shelf → BGG pipeline" [ref=f1e4] [cursor=pointer]:
- /url: /
- generic [ref=f1e5]:
- text: bggpipe
- generic [ref=f1e6]: shelf → BGG pipeline
- navigation "Primary" [ref=f1e7]:
- link "Pipeline" [ref=f1e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=f1e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=f1e10]: "4"
- link "Titles" [ref=f1e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=f1e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=f1e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=f1e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=f1e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=f1e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=f1e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=f1e18]
- generic [ref=f1e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=f1e21]:
- status
- main [ref=f1e22]:
- heading "Shelves" [level=1] [ref=f1e23]
- generic [ref=f1e24]: 3 unit(s), 27 opening(s), 8 game(s) unshelved
- heading "Add a unit" [level=2] [ref=f1e26]
- generic [ref=f1e27]:
- generic [ref=f1e28]:
- text: Unit name
- textbox "Unit name" [ref=f1e29]:
- /placeholder: Living room Kallax
- generic [ref=f1e30]:
- text: Preset
- combobox "Preset" [ref=f1e31]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=f1e32]:
- text: Rows
- textbox "Rows" [ref=f1e33]: "1"
- generic [ref=f1e34]:
- text: Columns
- textbox "Columns" [ref=f1e35]: "1"
- generic [ref=f1e36]:
- text: Zone
- textbox "Zone" [ref=f1e37]:
- /placeholder: party games
- generic [ref=f1e38]:
- text: Description
- textbox "Description" [ref=f1e39]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=f1e41] [cursor=pointer]
- generic [ref=f1e42]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- generic [ref=f1e43]:
- generic [ref=f1e44]:
- generic [ref=f1e45]:
- heading "Library Kallax" [level=2] [ref=f1e46]
- generic [ref=f1e47]:
- button "duplicate" [ref=f1e48] [cursor=pointer]
- button "rename" [ref=f1e49] [cursor=pointer]
- button "remove unit" [ref=f1e50] [cursor=pointer]
- paragraph [ref=f1e51]:
- generic [ref=f1e52]: Centerpiece of the game wall in the library
- button "edit description" [ref=f1e53] [cursor=pointer]
- generic [ref=f1e54]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e56] [cursor=pointer]:
- generic [ref=f1e57]: Top
- generic [ref=f1e58]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e59]:
- 'generic "flat stack: 0% of interior height"'
- generic [ref=f1e61]:
- button "A1 oversize 7 · 1 unmeasured flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f1e62] [cursor=pointer]:
- generic [ref=f1e63]: A1
- generic [ref=f1e64]: oversize
- generic [ref=f1e65]: 7 · 1 unmeasured
- img "flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f1e66]:
- 'generic "flat stack: 99% of interior height" [ref=f1e67]'
- button "A2 oversize 6 · 1 unmeasured flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f1e70] [cursor=pointer]:
- generic [ref=f1e71]: A2
- generic [ref=f1e72]: oversize
- generic [ref=f1e73]: 6 · 1 unmeasured
- img "flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f1e74]:
- 'generic "flat stack: 89% of interior height" [ref=f1e75]'
- generic [ref=f1e78]:
- 'button "B1 no way to pack: Meteor party 7 flat stack 98% of height, standing lane 0% of width, overfull" [ref=f1e79] [cursor=pointer]':
- generic [ref=f1e80]:
- text: B1
- 'img "no way to pack: Meteor" [ref=f1e81]':
- generic [ref=f1e82]: party
- generic [ref=f1e83]: "7"
- img "flat stack 98% of height, standing lane 0% of width, overfull" [ref=f1e84]:
- 'generic "flat stack: 98% of interior height" [ref=f1e85]'
- 'button "B2 no way to pack: The Red Dragon Inn Smorgasbox party 5 flat stack 89% of height, standing lane 0% of width, overfull" [ref=f1e87] [cursor=pointer]':
- generic [ref=f1e88]:
- text: B2
- 'img "no way to pack: The Red Dragon Inn Smorgasbox" [ref=f1e89]':
- generic [ref=f1e90]: party
- generic [ref=f1e91]: "5"
- img "flat stack 89% of height, standing lane 0% of width, overfull" [ref=f1e92]:
- 'generic "flat stack: 89% of interior height" [ref=f1e93]'
- button "B3 party 16 · 8 unmeasured flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f1e95] [cursor=pointer]:
- generic [ref=f1e96]: B3
- generic [ref=f1e97]: party
- generic [ref=f1e98]: 16 · 8 unmeasured
- img "flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f1e99]:
- 'generic "flat stack: 36% of interior height" [ref=f1e100]'
- button "B4 two-player 6 · 1 unmeasured flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f1e103] [cursor=pointer]:
- generic [ref=f1e104]: B4
- generic [ref=f1e105]: two-player
- generic [ref=f1e106]: 6 · 1 unmeasured
- img "flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f1e107]:
- 'generic "flat stack: 39% of interior height" [ref=f1e108]'
- generic [ref=f1e111]:
- 'button "C1 no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan general 12 · 3 unmeasured flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e112] [cursor=pointer]':
- generic [ref=f1e113]:
- text: C1
- 'img "no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan" [ref=f1e114]':
- generic [ref=f1e115]: general
- generic [ref=f1e116]: 12 · 3 unmeasured
- img "flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e117]:
- 'generic "flat stack: 100% of interior height" [ref=f1e118]'
- 'button "C2 no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens general 14 · 3 unmeasured flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e121] [cursor=pointer]':
- generic [ref=f1e122]:
- text: C2
- 'img "no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens" [ref=f1e123]':
- generic [ref=f1e124]: general
- generic [ref=f1e125]: 14 · 3 unmeasured
- img "flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e126]:
- 'generic "flat stack: 95% of interior height" [ref=f1e127]'
- 'button "C3 no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine general 14 · 3 unmeasured flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e130] [cursor=pointer]':
- generic [ref=f1e131]:
- text: C3
- 'img "no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine" [ref=f1e132]':
- generic [ref=f1e133]: general
- generic [ref=f1e134]: 14 · 3 unmeasured
- img "flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e135]:
- 'generic "flat stack: 97% of interior height" [ref=f1e136]'
- 'button "C4 no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls general 13 · 4 unmeasured flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f1e139] [cursor=pointer]':
- generic [ref=f1e140]:
- text: C4
- 'img "no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls" [ref=f1e141]':
- generic [ref=f1e142]: general
- generic [ref=f1e143]: 13 · 4 unmeasured
- img "flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f1e144]:
- 'generic "flat stack: 90% of interior height" [ref=f1e145]'
- generic [ref=f1e148]:
- 'button "D1 no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank! general 11 · 2 unmeasured flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f1e149] [cursor=pointer]':
- generic [ref=f1e150]:
- text: D1
- 'img "no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank!" [ref=f1e151]':
- generic [ref=f1e152]: general
- generic [ref=f1e153]: 11 · 2 unmeasured
- img "flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f1e154]:
- 'generic "flat stack: 94% of interior height" [ref=f1e155]'
- button "D2 vintage wargames 7 flat stack 75% of height, standing lane 100% of width" [ref=f1e158] [cursor=pointer]:
- generic [ref=f1e159]: D2
- generic [ref=f1e160]: vintage wargames
- generic [ref=f1e161]: "7"
- img "flat stack 75% of height, standing lane 100% of width" [ref=f1e162]:
- 'generic "flat stack: 75% of interior height" [ref=f1e163]'
- button "D3 heavy 3 flat stack 42% of height, standing lane 94% of width" [ref=f1e166] [cursor=pointer]:
- generic [ref=f1e167]: D3
- generic [ref=f1e168]: heavy
- generic [ref=f1e169]: "3"
- img "flat stack 42% of height, standing lane 94% of width" [ref=f1e170]:
- 'generic "flat stack: 42% of interior height" [ref=f1e171]'
- button "D4 campaign 4 flat stack 83% of height, standing lane 92% of width" [ref=f1e174] [cursor=pointer]:
- generic [ref=f1e175]: D4
- generic [ref=f1e176]: campaign
- generic [ref=f1e177]: "4"
- img "flat stack 83% of height, standing lane 92% of width" [ref=f1e178]:
- 'generic "flat stack: 83% of interior height" [ref=f1e179]'
- paragraph [ref=f1e182]: Add an opening
- generic [ref=f1e183]:
- generic [ref=f1e184]:
- text: Rows
- textbox "Rows" [ref=f1e185]: "1"
- generic [ref=f1e186]:
- text: Columns
- textbox "Columns" [ref=f1e187]: "1"
- generic [ref=f1e188]:
- text: Label
- textbox "Label" [ref=f1e189]:
- /placeholder: wide top — for a single opening
- generic [ref=f1e190]:
- text: Zone
- textbox "Zone" [ref=f1e191]
- generic [ref=f1e192]:
- checkbox "at the top" [ref=f1e193]
- text: at the top
- generic [ref=f1e194]:
- text: W
- textbox "W" [ref=f1e195]:
- /placeholder: "13.25"
- generic [ref=f1e196]:
- text: H
- textbox "H" [ref=f1e197]:
- /placeholder: "13.25"
- generic [ref=f1e198]:
- text: D
- textbox "D" [ref=f1e199]:
- /placeholder: "15.4"
- button "add" [ref=f1e201] [cursor=pointer]
- generic [ref=f1e202]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=f1e203]:
- generic [ref=f1e204]:
- heading "Library Right Shelving" [level=2] [ref=f1e205]
- generic [ref=f1e206]:
- button "duplicate" [ref=f1e207] [cursor=pointer]
- button "rename" [ref=f1e208] [cursor=pointer]
- button "remove unit" [ref=f1e209] [cursor=pointer]
- paragraph [ref=f1e210]:
- generic [ref=f1e211]: To the right of the Kallax
- button "edit description" [ref=f1e212] [cursor=pointer]
- generic [ref=f1e213]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e215] [cursor=pointer]:
- generic [ref=f1e216]: Top
- generic [ref=f1e217]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e218]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e221] [cursor=pointer]:
- generic [ref=f1e222]: A1
- generic [ref=f1e223]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e224]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e227] [cursor=pointer]:
- generic [ref=f1e228]: B1
- generic [ref=f1e229]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e230]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e233] [cursor=pointer]:
- generic [ref=f1e234]: C1
- generic [ref=f1e235]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e236]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e239] [cursor=pointer]:
- generic [ref=f1e240]: D1
- generic [ref=f1e241]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e242]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 6 · 3 unmeasured flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f1e245] [cursor=pointer]:
- generic [ref=f1e246]: E1
- generic [ref=f1e247]: 6 · 3 unmeasured
- img "flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f1e248]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=f1e251]: Add an opening
- generic [ref=f1e252]:
- generic [ref=f1e253]:
- text: Rows
- textbox "Rows" [ref=f1e254]: "1"
- generic [ref=f1e255]:
- text: Columns
- textbox "Columns" [ref=f1e256]: "1"
- generic [ref=f1e257]:
- text: Label
- textbox "Label" [ref=f1e258]:
- /placeholder: wide top — for a single opening
- generic [ref=f1e259]:
- text: Zone
- textbox "Zone" [ref=f1e260]
- generic [ref=f1e261]:
- checkbox "at the top" [ref=f1e262]
- text: at the top
- generic [ref=f1e263]:
- text: W
- textbox "W" [ref=f1e264]:
- /placeholder: "13.25"
- generic [ref=f1e265]:
- text: H
- textbox "H" [ref=f1e266]:
- /placeholder: "13.25"
- generic [ref=f1e267]:
- text: D
- textbox "D" [ref=f1e268]:
- /placeholder: "15.4"
- button "add" [ref=f1e270] [cursor=pointer]
- generic [ref=f1e271]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=f1e272]:
- generic [ref=f1e273]:
- heading "Library Left Shelving" [level=2] [ref=f1e274]
- generic [ref=f1e275]:
- button "duplicate" [ref=f1e276] [cursor=pointer]
- button "rename" [ref=f1e277] [cursor=pointer]
- button "remove unit" [ref=f1e278] [cursor=pointer]
- paragraph [ref=f1e279]:
- generic [ref=f1e280]: To the left of the Kallax
- button "edit description" [ref=f1e281] [cursor=pointer]
- generic [ref=f1e282]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e284] [cursor=pointer]:
- generic [ref=f1e285]: Top
- generic [ref=f1e286]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e287]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e290] [cursor=pointer]:
- generic [ref=f1e291]: A1
- generic [ref=f1e292]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e293]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e296] [cursor=pointer]:
- generic [ref=f1e297]: B1
- generic [ref=f1e298]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e299]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e302] [cursor=pointer]:
- generic [ref=f1e303]: C1
- generic [ref=f1e304]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e305]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 4 · 4 unmeasured flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f1e308] [cursor=pointer]:
- generic [ref=f1e309]: D1
- generic [ref=f1e310]: 4 · 4 unmeasured
- img "flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f1e311]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e314] [cursor=pointer]:
- generic [ref=f1e315]: E1
- generic [ref=f1e316]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e317]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=f1e319]: Add an opening
- generic [ref=f1e320]:
- generic [ref=f1e321]:
- text: Rows
- textbox "Rows" [ref=f1e322]: "1"
- generic [ref=f1e323]:
- text: Columns
- textbox "Columns" [ref=f1e324]: "1"
- generic [ref=f1e325]:
- text: Label
- textbox "Label" [ref=f1e326]:
- /placeholder: wide top — for a single opening
- generic [ref=f1e327]:
- text: Zone
- textbox "Zone" [ref=f1e328]
- generic [ref=f1e329]:
- checkbox "at the top" [ref=f1e330]
- text: at the top
- generic [ref=f1e331]:
- text: W
- textbox "W" [ref=f1e332]:
- /placeholder: "13.25"
- generic [ref=f1e333]:
- text: H
- textbox "H" [ref=f1e334]:
- /placeholder: "13.25"
- generic [ref=f1e335]:
- text: D
- textbox "D" [ref=f1e336]:
- /placeholder: "15.4"
- button "add" [ref=f1e338] [cursor=pointer]
- generic [ref=f1e339]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=f1e340]:
- heading "Unshelved" [level=2] [ref=f1e341]
- generic [ref=f1e342]:
- link "DUNGEONS & DRAGONS" [ref=f1e343] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=f1e344]: unmeasured
- generic [ref=f1e345]:
- link "DUNGEONS & DRAGONS FANTASY GAME RULES" [ref=f1e346] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%20fantasy%20game%20rules%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=f1e347]: unmeasured
- generic [ref=f1e348]:
- 'link "Etherfields: Awakened Items & Actions" [ref=f1e349] [cursor=pointer]':
- /url: /library/game/355873%3A597861
- generic [ref=f1e350]:
- button "♥ Library Right Shelving · E1" [ref=f1e351] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f1e352] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f1e353] [cursor=pointer]
- generic [ref=f1e354]:
- 'link "Etherfields: Search Mini Deck" [ref=f1e355] [cursor=pointer]':
- /url: /library/game/309202%3A507805
- generic [ref=f1e356]:
- button "♥ Library Right Shelving · E1" [ref=f1e357] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f1e358] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f1e359] [cursor=pointer]
- generic [ref=f1e360]:
- 'link "Etherfields: Shifting Story Promo Deck" [ref=f1e361] [cursor=pointer]':
- /url: /library/game/381209%3A651213
- generic [ref=f1e362]:
- button "♥ Library Right Shelving · E1" [ref=f1e363] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f1e364] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f1e365] [cursor=pointer]
- generic [ref=f1e366]:
- 'link "Etherfields: Story Dice Expansion" [ref=f1e367] [cursor=pointer]':
- /url: /library/game/386805%3A658656
- generic "no trusted size — fit can't be verified" [ref=f1e368]: unmeasured
- generic [ref=f1e369]:
- 'link "Etherfields: Thorn Knight" [ref=f1e370] [cursor=pointer]':
- /url: /library/game/291126%3A475383
- generic "no trusted size — fit can't be verified" [ref=f1e371]: unmeasured
- generic [ref=f1e372]:
- link "Frosthaven" [ref=f1e373] [cursor=pointer]:
- /url: /library/game/295770%3A484007
- generic [ref=f1e374]:
- button "→ Library Kallax · A1" [ref=f1e375] [cursor=pointer]
- button "→ Library Kallax · Top" [ref=f1e376] [cursor=pointer]
@@ -0,0 +1,407 @@
- generic [active] [ref=f2e1]:
- link "Skip to content" [ref=f2e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=f2e3]:
- link "bggpipe shelf → BGG pipeline" [ref=f2e4] [cursor=pointer]:
- /url: /
- generic [ref=f2e5]:
- text: bggpipe
- generic [ref=f2e6]: shelf → BGG pipeline
- navigation "Primary" [ref=f2e7]:
- link "Pipeline" [ref=f2e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=f2e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=f2e10]: "4"
- link "Titles" [ref=f2e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=f2e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=f2e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=f2e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=f2e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=f2e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=f2e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=f2e18]
- generic [ref=f2e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=f2e21]:
- status
- main [ref=f2e22]:
- heading "Shelves" [level=1] [ref=f2e23]
- generic [ref=f2e24]: 3 unit(s), 27 opening(s), 8 game(s) unshelved
- heading "Add a unit" [level=2] [ref=f2e26]
- generic [ref=f2e27]:
- generic [ref=f2e28]:
- text: Unit name
- textbox "Unit name" [ref=f2e29]:
- /placeholder: Living room Kallax
- generic [ref=f2e30]:
- text: Preset
- combobox "Preset" [ref=f2e31]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=f2e32]:
- text: Rows
- textbox "Rows" [ref=f2e33]: "1"
- generic [ref=f2e34]:
- text: Columns
- textbox "Columns" [ref=f2e35]: "1"
- generic [ref=f2e36]:
- text: Zone
- textbox "Zone" [ref=f2e37]:
- /placeholder: party games
- generic [ref=f2e38]:
- text: Description
- textbox "Description" [ref=f2e39]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=f2e41] [cursor=pointer]
- generic [ref=f2e42]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- generic [ref=f2e43]:
- generic [ref=f2e44]:
- generic [ref=f2e45]:
- heading "Library Kallax" [level=2] [ref=f2e46]
- generic [ref=f2e47]:
- button "duplicate" [ref=f2e48] [cursor=pointer]
- button "rename" [ref=f2e49] [cursor=pointer]
- button "remove unit" [ref=f2e50] [cursor=pointer]
- paragraph [ref=f2e51]:
- generic [ref=f2e52]: Centerpiece of the game wall in the library
- button "edit description" [ref=f2e53] [cursor=pointer]
- generic [ref=f2e54]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e56] [cursor=pointer]:
- generic [ref=f2e57]: Top
- generic [ref=f2e58]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e59]:
- 'generic "flat stack: 0% of interior height"'
- generic [ref=f2e61]:
- button "A1 oversize 7 · 1 unmeasured flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f2e62] [cursor=pointer]:
- generic [ref=f2e63]: A1
- generic [ref=f2e64]: oversize
- generic [ref=f2e65]: 7 · 1 unmeasured
- img "flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f2e66]:
- 'generic "flat stack: 99% of interior height" [ref=f2e67]'
- button "A2 oversize 6 · 1 unmeasured flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f2e70] [cursor=pointer]:
- generic [ref=f2e71]: A2
- generic [ref=f2e72]: oversize
- generic [ref=f2e73]: 6 · 1 unmeasured
- img "flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f2e74]:
- 'generic "flat stack: 89% of interior height" [ref=f2e75]'
- generic [ref=f2e78]:
- 'button "B1 no way to pack: Meteor party 7 flat stack 98% of height, standing lane 0% of width, overfull" [ref=f2e79] [cursor=pointer]':
- generic [ref=f2e80]:
- text: B1
- 'img "no way to pack: Meteor" [ref=f2e81]':
- generic [ref=f2e82]: party
- generic [ref=f2e83]: "7"
- img "flat stack 98% of height, standing lane 0% of width, overfull" [ref=f2e84]:
- 'generic "flat stack: 98% of interior height" [ref=f2e85]'
- 'button "B2 no way to pack: The Red Dragon Inn Smorgasbox party 5 flat stack 89% of height, standing lane 0% of width, overfull" [ref=f2e87] [cursor=pointer]':
- generic [ref=f2e88]:
- text: B2
- 'img "no way to pack: The Red Dragon Inn Smorgasbox" [ref=f2e89]':
- generic [ref=f2e90]: party
- generic [ref=f2e91]: "5"
- img "flat stack 89% of height, standing lane 0% of width, overfull" [ref=f2e92]:
- 'generic "flat stack: 89% of interior height" [ref=f2e93]'
- button "B3 party 16 · 8 unmeasured flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f2e95] [cursor=pointer]:
- generic [ref=f2e96]: B3
- generic [ref=f2e97]: party
- generic [ref=f2e98]: 16 · 8 unmeasured
- img "flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f2e99]:
- 'generic "flat stack: 36% of interior height" [ref=f2e100]'
- button "B4 two-player 6 · 1 unmeasured flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f2e103] [cursor=pointer]:
- generic [ref=f2e104]: B4
- generic [ref=f2e105]: two-player
- generic [ref=f2e106]: 6 · 1 unmeasured
- img "flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f2e107]:
- 'generic "flat stack: 39% of interior height" [ref=f2e108]'
- generic [ref=f2e111]:
- 'button "C1 no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan general 12 · 3 unmeasured flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e112] [cursor=pointer]':
- generic [ref=f2e113]:
- text: C1
- 'img "no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan" [ref=f2e114]':
- generic [ref=f2e115]: general
- generic [ref=f2e116]: 12 · 3 unmeasured
- img "flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e117]:
- 'generic "flat stack: 100% of interior height" [ref=f2e118]'
- 'button "C2 no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens general 14 · 3 unmeasured flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e121] [cursor=pointer]':
- generic [ref=f2e122]:
- text: C2
- 'img "no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens" [ref=f2e123]':
- generic [ref=f2e124]: general
- generic [ref=f2e125]: 14 · 3 unmeasured
- img "flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e126]:
- 'generic "flat stack: 95% of interior height" [ref=f2e127]'
- 'button "C3 no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine general 14 · 3 unmeasured flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e130] [cursor=pointer]':
- generic [ref=f2e131]:
- text: C3
- 'img "no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine" [ref=f2e132]':
- generic [ref=f2e133]: general
- generic [ref=f2e134]: 14 · 3 unmeasured
- img "flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e135]:
- 'generic "flat stack: 97% of interior height" [ref=f2e136]'
- 'button "C4 no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls general 13 · 4 unmeasured flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f2e139] [cursor=pointer]':
- generic [ref=f2e140]:
- text: C4
- 'img "no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls" [ref=f2e141]':
- generic [ref=f2e142]: general
- generic [ref=f2e143]: 13 · 4 unmeasured
- img "flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f2e144]:
- 'generic "flat stack: 90% of interior height" [ref=f2e145]'
- generic [ref=f2e148]:
- 'button "D1 no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank! general 11 · 2 unmeasured flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f2e149] [cursor=pointer]':
- generic [ref=f2e150]:
- text: D1
- 'img "no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank!" [ref=f2e151]':
- generic [ref=f2e152]: general
- generic [ref=f2e153]: 11 · 2 unmeasured
- img "flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f2e154]:
- 'generic "flat stack: 94% of interior height" [ref=f2e155]'
- button "D2 vintage wargames 7 flat stack 75% of height, standing lane 100% of width" [ref=f2e158] [cursor=pointer]:
- generic [ref=f2e159]: D2
- generic [ref=f2e160]: vintage wargames
- generic [ref=f2e161]: "7"
- img "flat stack 75% of height, standing lane 100% of width" [ref=f2e162]:
- 'generic "flat stack: 75% of interior height" [ref=f2e163]'
- button "D3 heavy 3 flat stack 42% of height, standing lane 94% of width" [ref=f2e166] [cursor=pointer]:
- generic [ref=f2e167]: D3
- generic [ref=f2e168]: heavy
- generic [ref=f2e169]: "3"
- img "flat stack 42% of height, standing lane 94% of width" [ref=f2e170]:
- 'generic "flat stack: 42% of interior height" [ref=f2e171]'
- button "D4 campaign 4 flat stack 83% of height, standing lane 92% of width" [ref=f2e174] [cursor=pointer]:
- generic [ref=f2e175]: D4
- generic [ref=f2e176]: campaign
- generic [ref=f2e177]: "4"
- img "flat stack 83% of height, standing lane 92% of width" [ref=f2e178]:
- 'generic "flat stack: 83% of interior height" [ref=f2e179]'
- paragraph [ref=f2e182]: Add an opening
- generic [ref=f2e183]:
- generic [ref=f2e184]:
- text: Rows
- textbox "Rows" [ref=f2e185]: "1"
- generic [ref=f2e186]:
- text: Columns
- textbox "Columns" [ref=f2e187]: "1"
- generic [ref=f2e188]:
- text: Label
- textbox "Label" [ref=f2e189]:
- /placeholder: wide top — for a single opening
- generic [ref=f2e190]:
- text: Zone
- textbox "Zone" [ref=f2e191]
- generic [ref=f2e192]:
- checkbox "at the top" [ref=f2e193]
- text: at the top
- generic [ref=f2e194]:
- text: W
- textbox "W" [ref=f2e195]:
- /placeholder: "13.25"
- generic [ref=f2e196]:
- text: H
- textbox "H" [ref=f2e197]:
- /placeholder: "13.25"
- generic [ref=f2e198]:
- text: D
- textbox "D" [ref=f2e199]:
- /placeholder: "15.4"
- button "add" [ref=f2e201] [cursor=pointer]
- generic [ref=f2e202]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=f2e203]:
- generic [ref=f2e204]:
- heading "Library Right Shelving" [level=2] [ref=f2e205]
- generic [ref=f2e206]:
- button "duplicate" [ref=f2e207] [cursor=pointer]
- button "rename" [ref=f2e208] [cursor=pointer]
- button "remove unit" [ref=f2e209] [cursor=pointer]
- paragraph [ref=f2e210]:
- generic [ref=f2e211]: To the right of the Kallax
- button "edit description" [ref=f2e212] [cursor=pointer]
- generic [ref=f2e213]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e215] [cursor=pointer]:
- generic [ref=f2e216]: Top
- generic [ref=f2e217]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e218]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e221] [cursor=pointer]:
- generic [ref=f2e222]: A1
- generic [ref=f2e223]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e224]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e227] [cursor=pointer]:
- generic [ref=f2e228]: B1
- generic [ref=f2e229]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e230]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e233] [cursor=pointer]:
- generic [ref=f2e234]: C1
- generic [ref=f2e235]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e236]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e239] [cursor=pointer]:
- generic [ref=f2e240]: D1
- generic [ref=f2e241]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e242]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 6 · 3 unmeasured flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f2e245] [cursor=pointer]:
- generic [ref=f2e246]: E1
- generic [ref=f2e247]: 6 · 3 unmeasured
- img "flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f2e248]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=f2e251]: Add an opening
- generic [ref=f2e252]:
- generic [ref=f2e253]:
- text: Rows
- textbox "Rows" [ref=f2e254]: "1"
- generic [ref=f2e255]:
- text: Columns
- textbox "Columns" [ref=f2e256]: "1"
- generic [ref=f2e257]:
- text: Label
- textbox "Label" [ref=f2e258]:
- /placeholder: wide top — for a single opening
- generic [ref=f2e259]:
- text: Zone
- textbox "Zone" [ref=f2e260]
- generic [ref=f2e261]:
- checkbox "at the top" [ref=f2e262]
- text: at the top
- generic [ref=f2e263]:
- text: W
- textbox "W" [ref=f2e264]:
- /placeholder: "13.25"
- generic [ref=f2e265]:
- text: H
- textbox "H" [ref=f2e266]:
- /placeholder: "13.25"
- generic [ref=f2e267]:
- text: D
- textbox "D" [ref=f2e268]:
- /placeholder: "15.4"
- button "add" [ref=f2e270] [cursor=pointer]
- generic [ref=f2e271]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=f2e272]:
- generic [ref=f2e273]:
- heading "Library Left Shelving" [level=2] [ref=f2e274]
- generic [ref=f2e275]:
- button "duplicate" [ref=f2e276] [cursor=pointer]
- button "rename" [ref=f2e277] [cursor=pointer]
- button "remove unit" [ref=f2e278] [cursor=pointer]
- paragraph [ref=f2e279]:
- generic [ref=f2e280]: To the left of the Kallax
- button "edit description" [ref=f2e281] [cursor=pointer]
- generic [ref=f2e282]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e284] [cursor=pointer]:
- generic [ref=f2e285]: Top
- generic [ref=f2e286]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e287]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e290] [cursor=pointer]:
- generic [ref=f2e291]: A1
- generic [ref=f2e292]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e293]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e296] [cursor=pointer]:
- generic [ref=f2e297]: B1
- generic [ref=f2e298]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e299]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e302] [cursor=pointer]:
- generic [ref=f2e303]: C1
- generic [ref=f2e304]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e305]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 4 · 4 unmeasured flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f2e308] [cursor=pointer]:
- generic [ref=f2e309]: D1
- generic [ref=f2e310]: 4 · 4 unmeasured
- img "flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f2e311]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e314] [cursor=pointer]:
- generic [ref=f2e315]: E1
- generic [ref=f2e316]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e317]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=f2e319]: Add an opening
- generic [ref=f2e320]:
- generic [ref=f2e321]:
- text: Rows
- textbox "Rows" [ref=f2e322]: "1"
- generic [ref=f2e323]:
- text: Columns
- textbox "Columns" [ref=f2e324]: "1"
- generic [ref=f2e325]:
- text: Label
- textbox "Label" [ref=f2e326]:
- /placeholder: wide top — for a single opening
- generic [ref=f2e327]:
- text: Zone
- textbox "Zone" [ref=f2e328]
- generic [ref=f2e329]:
- checkbox "at the top" [ref=f2e330]
- text: at the top
- generic [ref=f2e331]:
- text: W
- textbox "W" [ref=f2e332]:
- /placeholder: "13.25"
- generic [ref=f2e333]:
- text: H
- textbox "H" [ref=f2e334]:
- /placeholder: "13.25"
- generic [ref=f2e335]:
- text: D
- textbox "D" [ref=f2e336]:
- /placeholder: "15.4"
- button "add" [ref=f2e338] [cursor=pointer]
- generic [ref=f2e339]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=f2e340]:
- heading "Unshelved" [level=2] [ref=f2e341]
- generic [ref=f2e342]:
- link "DUNGEONS & DRAGONS" [ref=f2e343] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=f2e344]: unmeasured
- generic [ref=f2e345]:
- link "DUNGEONS & DRAGONS FANTASY GAME RULES" [ref=f2e346] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%20fantasy%20game%20rules%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=f2e347]: unmeasured
- generic [ref=f2e348]:
- 'link "Etherfields: Awakened Items & Actions" [ref=f2e349] [cursor=pointer]':
- /url: /library/game/355873%3A597861
- generic [ref=f2e350]:
- button "♥ Library Right Shelving · E1" [ref=f2e351] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f2e352] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f2e353] [cursor=pointer]
- generic [ref=f2e354]:
- 'link "Etherfields: Search Mini Deck" [ref=f2e355] [cursor=pointer]':
- /url: /library/game/309202%3A507805
- generic [ref=f2e356]:
- button "♥ Library Right Shelving · E1" [ref=f2e357] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f2e358] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f2e359] [cursor=pointer]
- generic [ref=f2e360]:
- 'link "Etherfields: Shifting Story Promo Deck" [ref=f2e361] [cursor=pointer]':
- /url: /library/game/381209%3A651213
- generic [ref=f2e362]:
- button "♥ Library Right Shelving · E1" [ref=f2e363] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f2e364] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f2e365] [cursor=pointer]
- generic [ref=f2e366]:
- 'link "Etherfields: Story Dice Expansion" [ref=f2e367] [cursor=pointer]':
- /url: /library/game/386805%3A658656
- generic "no trusted size — fit can't be verified" [ref=f2e368]: unmeasured
- generic [ref=f2e369]:
- 'link "Etherfields: Thorn Knight" [ref=f2e370] [cursor=pointer]':
- /url: /library/game/291126%3A475383
- generic "no trusted size — fit can't be verified" [ref=f2e371]: unmeasured
- generic [ref=f2e372]:
- link "Frosthaven" [ref=f2e373] [cursor=pointer]:
- /url: /library/game/295770%3A484007
- generic [ref=f2e374]:
- button "→ Library Kallax · A1" [ref=f2e375] [cursor=pointer]
- button "→ Library Kallax · Top" [ref=f2e376] [cursor=pointer]
@@ -0,0 +1,160 @@
- generic [active] [ref=e1]:
- link "Skip to content" [ref=e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=e3]:
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
- /url: /
- generic [ref=e5]:
- text: bggpipe
- generic [ref=e6]: shelf → BGG pipeline
- navigation "Primary" [ref=e7]:
- link "Pipeline" [ref=e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=e10]: "4"
- link "Titles" [ref=e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=e21]:
- status
- main [ref=e22]:
- heading "Frosthaven" [level=1] [ref=e23]
- generic [ref=e24]:
- link "← library" [ref=e25] [cursor=pointer]:
- /url: /library
- generic [ref=e26]:
- text: 2022 ·
- link "view on BGG ↗" [ref=e27] [cursor=pointer]:
- /url: https://boardgamegeek.com/boardgame/295770
- generic [ref=e28]:
- generic [ref=e29]:
- img "box art for Frosthaven" [ref=e31]
- generic [ref=e32]:
- generic [ref=e33]: players 14 players (best at 3)
- generic [ref=e34]: playing time 90180 min
- generic [ref=e35]: box 11.811 × 16.1417 × 9.84252 in
- generic [ref=e36]: ages 14+
- generic [ref=e37]: weight 4.41 / 5
- generic [ref=e38]: BGG rank 20
- generic [ref=e39]: BGG rating 8.72
- generic [ref=e40]: owned by 25,025 people
- generic [ref=e41]:
- text: designers
- generic [ref=e42]: Isaac Childres
- generic [ref=e44]:
- text: artists
- generic [ref=e45]:
- generic [ref=e46]: Francesca Baerald
- generic [ref=e47]: David Demaret
- generic [ref=e48]: Alexandr Elichev
- generic [ref=e49]: Josh T. McDowell
- generic [ref=e50]:
- text: publishers
- generic [ref=e51]:
- generic [ref=e52]: Cephalofair Games
- generic [ref=e53]: Albi
- generic [ref=e54]: Albi Polska
- generic [ref=e55]: CMON Global Limited
- generic [ref=e56]: Delta Vision Publishing
- generic [ref=e57]: Feuerland Spiele
- generic [ref=e58]:
- text: categories
- generic [ref=e59]:
- generic [ref=e60]: Adventure
- generic [ref=e61]: Exploration
- generic [ref=e62]: Fantasy
- generic [ref=e63]: Fighting
- generic [ref=e64]: Miniatures
- generic [ref=e65]:
- text: mechanics
- generic [ref=e66]:
- generic [ref=e67]: Campaign / Battle Card Driven
- generic [ref=e68]: Communication Limits
- generic [ref=e69]: Cooperative Game
- generic [ref=e70]: Deck Construction
- generic [ref=e71]: Deck, Bag, and Pool Building
- generic [ref=e72]: Grid Movement
- generic [ref=e73]: Hand Management
- generic [ref=e74]: Hexagon Grid
- generic [ref=e75]: Legacy Game
- generic [ref=e76]: Modular Board
- generic [ref=e77]: Multi-Use Cards
- generic [ref=e78]: Narrative Choice / Paragraph
- generic [ref=e79]: Once-Per-Game Abilities
- generic [ref=e80]: Scenario / Mission / Campaign Game
- generic [ref=e81]: Simultaneous Action Selection
- generic [ref=e82]: Solo / Solitaire Game
- generic [ref=e83]: Variable Player Powers
- heading "Your edition" [level=2] [ref=e84]
- generic [ref=e85]:
- generic [ref=e86]: English Kickstarter edition, first printing · 2022
- generic [ref=e87]:
- text: publishers
- generic [ref=e88]: Cephalofair Games
- generic [ref=e90]:
- text: languages
- generic [ref=e91]: English
- heading "Where it lives" [level=2] [ref=e93]
- generic [ref=e94]:
- paragraph [ref=e95]: "Lives: Library Left Shelving · E1"
- paragraph [ref=e96]:
- text: "Right now: home"
- textbox "where it is right now" [ref=e97]:
- /placeholder: travel case · loaned to Ben — blank = it's back
- button "save" [ref=e98] [cursor=pointer]
- text: home stays reserved — this is where the box IS, not where it lives
- paragraph [ref=e99]:
- combobox "shelf opening" [ref=e100]:
- option "nowhere / a note only"
- option "Top (fits)"
- option "A1 — oversize (fits)"
- option "A2 — oversize (fits)"
- option "B1 — party (doesn't fit)"
- option "B2 — party (doesn't fit)"
- option "B3 — party (doesn't fit)"
- option "B4 — two-player (doesn't fit)"
- option "C1 — general (doesn't fit)"
- option "C2 — general (doesn't fit)"
- option "C3 — general (doesn't fit)"
- option "C4 — general (doesn't fit)"
- option "D1 — general (doesn't fit)"
- option "D2 — vintage wargames (doesn't fit)"
- option "D3 — heavy (doesn't fit)"
- option "D4 — campaign (doesn't fit)"
- option "Top (doesn't fit)"
- option "A1 (doesn't fit)"
- option "B1 (doesn't fit)"
- option "C1 (doesn't fit)"
- option "D1 (doesn't fit)"
- option "E1 (doesn't fit)"
- option "Top (doesn't fit)"
- option "A1 (doesn't fit)"
- option "B1 (doesn't fit)"
- option "C1 (doesn't fit)"
- option "D1 (doesn't fit)"
- option "E1 (doesn't fit)" [selected]
- textbox "note — lent to Sarah, June" [ref=e101]
- button "save" [ref=e102] [cursor=pointer]
- paragraph [ref=e103]:
- text: In its own box.
- button "it lives inside another box…" [ref=e104] [cursor=pointer]
- heading "Seen on your shelves" [level=2] [ref=e105]
- paragraph [ref=e107]:
- link "IMG_4531.jpeg" [ref=e108] [cursor=pointer]:
- /url: /photos/view/IMG_4531.jpeg
- heading "About" [level=2] [ref=e109]
- paragraph [ref=e111]: "Frosthaven is the story of a small outpost far to the north of the capital city of White Oak. It's an outpost barely surviving the harsh weather let alone invasions from forces both known and unknown. However, a group of mercenaries, at the end of their rope, will help bring this settlement back from the edge of destruction. Not only will they have to deal with the harsh elements, but with other, far more dangerous threats out in the unforgiving cold, as well. There are: Algox, the bigger, more yeti-like cousins of the Inox, attacking from the mountains; Lurkers flooding in from the northern sea; and rumors have it that there are machines that wander the frozen wastes of their own free will. The party of mercenaries must face all of these perils, and perhaps in doing so, make peace with these new races so they can work together against even more sinister forces. Frosthaven is a standalone adventure from the designer and publisher of Gloomhaven that features sixteen new characters, three new races, more than twenty new enemies, more than one hundred new items, and a new, 100-scenario campaign. Characters and items from Gloomhaven will be usable in Frosthaven, and vice versa. In addition to using the well-known combat mechanisms of Gloomhaven, Frosthaven features other elements, such as mysteries to solve, a seasonal event system to live through, and player control over how the ramshackle village expands, with each new building offering new ways to progress. Frosthaven has a whole new set of items but there is a mechanism for bringing items over from 'Gloomhaven'. However, as Frosthaven's outpost is a remote location, these products may be imported but are not present as standard items. Resources are much more valuable and you have to build items through a crafting system rather than just buying them. —description from the publisher"
@@ -0,0 +1,63 @@
- generic [active] [ref=f1e1]:
- link "Skip to content" [ref=f1e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=f1e3]:
- link "bggpipe shelf → BGG pipeline" [ref=f1e4] [cursor=pointer]:
- /url: /
- generic [ref=f1e5]:
- text: bggpipe
- generic [ref=f1e6]: shelf → BGG pipeline
- navigation "Primary" [ref=f1e7]:
- link "Pipeline" [ref=f1e8] [cursor=pointer]:
- /url: /
- link "Photos" [ref=f1e9] [cursor=pointer]:
- /url: /photos
- link "Titles" [ref=f1e10] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=f1e11] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=f1e12] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=f1e13] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=f1e14] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=f1e15] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=f1e16]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=f1e17]
- generic [ref=f1e19]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=f1e20]:
- status
- main [ref=f1e21]:
- heading "Shelves" [level=1] [ref=f1e22]
- heading "Add a unit" [level=2] [ref=f1e23]
- generic [ref=f1e24]:
- generic [ref=f1e25]:
- text: Unit name
- textbox "Unit name" [ref=f1e26]:
- /placeholder: Living room Kallax
- generic [ref=f1e27]:
- text: Preset
- combobox "Preset" [ref=f1e28]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=f1e29]:
- text: Rows
- textbox "Rows" [ref=f1e30]: "1"
- generic [ref=f1e31]:
- text: Columns
- textbox "Columns" [ref=f1e32]: "1"
- generic [ref=f1e33]:
- text: Zone
- textbox "Zone" [ref=f1e34]:
- /placeholder: party games
- generic [ref=f1e35]:
- text: Description
- textbox "Description" [ref=f1e36]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=f1e38] [cursor=pointer]
- generic [ref=f1e39]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- paragraph [ref=f1e41]: Loading…
@@ -0,0 +1,399 @@
- generic [active] [ref=e1]:
- link "Skip to content" [ref=e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=e3]:
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
- /url: /
- generic [ref=e5]:
- text: bggpipe
- generic [ref=e6]: shelf → BGG pipeline
- navigation "Primary" [ref=e7]:
- link "Pipeline" [ref=e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=e10]: "4"
- link "Titles" [ref=e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=e21]:
- status
- main [ref=e22]:
- heading "Shelves" [level=1] [ref=e23]
- generic [ref=e24]: 3 unit(s), 27 opening(s), 7 game(s) unshelved
- heading "Add a unit" [level=2] [ref=e26]
- generic [ref=e27]:
- generic [ref=e28]:
- text: Unit name
- textbox "Unit name" [ref=e29]:
- /placeholder: Living room Kallax
- generic [ref=e30]:
- text: Preset
- combobox "Preset" [ref=e31]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=e32]:
- text: Rows
- textbox "Rows" [ref=e33]: "1"
- generic [ref=e34]:
- text: Columns
- textbox "Columns" [ref=e35]: "1"
- generic [ref=e36]:
- text: Zone
- textbox "Zone" [ref=e37]:
- /placeholder: party games
- generic [ref=e38]:
- text: Description
- textbox "Description" [ref=e39]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=e41] [cursor=pointer]
- generic [ref=e42]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- generic [ref=e43]:
- generic [ref=e44]:
- generic [ref=e45]:
- heading "Library Kallax" [level=2] [ref=e46]
- generic [ref=e47]:
- button "duplicate" [ref=e48] [cursor=pointer]
- button "rename" [ref=e49] [cursor=pointer]
- button "remove unit" [ref=e50] [cursor=pointer]
- paragraph [ref=e51]:
- generic [ref=e52]: Centerpiece of the game wall in the library
- button "edit description" [ref=e53] [cursor=pointer]
- generic [ref=e54]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=e56] [cursor=pointer]:
- generic [ref=e57]: Top
- generic [ref=e58]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e59]:
- 'generic "flat stack: 0% of interior height"'
- generic [ref=e61]:
- button "A1 oversize 7 · 1 unmeasured flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=e62] [cursor=pointer]:
- generic [ref=e63]: A1
- generic [ref=e64]: oversize
- generic [ref=e65]: 7 · 1 unmeasured
- img "flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=e66]:
- 'generic "flat stack: 99% of interior height" [ref=e67]'
- button "A2 oversize 6 · 1 unmeasured flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=e70] [cursor=pointer]:
- generic [ref=e71]: A2
- generic [ref=e72]: oversize
- generic [ref=e73]: 6 · 1 unmeasured
- img "flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=e74]:
- 'generic "flat stack: 89% of interior height" [ref=e75]'
- generic [ref=e78]:
- button "B1 party 7 flat stack 98% of height, standing lane 0% of width" [ref=e79] [cursor=pointer]:
- generic [ref=e80]: B1
- generic [ref=e81]: party
- generic [ref=e82]: "7"
- img "flat stack 98% of height, standing lane 0% of width" [ref=e83]:
- 'generic "flat stack: 98% of interior height" [ref=e84]'
- 'button "B2 no way to pack: The Red Dragon Inn Smorgasbox party 5 flat stack 89% of height, standing lane 0% of width, overfull" [ref=e86] [cursor=pointer]':
- generic [ref=e87]:
- text: B2
- 'img "no way to pack: The Red Dragon Inn Smorgasbox" [ref=e88]':
- generic [ref=e89]: party
- generic [ref=e90]: "5"
- img "flat stack 89% of height, standing lane 0% of width, overfull" [ref=e91]:
- 'generic "flat stack: 89% of interior height" [ref=e92]'
- button "B3 party 16 · 8 unmeasured flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=e94] [cursor=pointer]:
- generic [ref=e95]: B3
- generic [ref=e96]: party
- generic [ref=e97]: 16 · 8 unmeasured
- img "flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=e98]:
- 'generic "flat stack: 36% of interior height" [ref=e99]'
- button "B4 two-player 6 · 1 unmeasured flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=e102] [cursor=pointer]:
- generic [ref=e103]: B4
- generic [ref=e104]: two-player
- generic [ref=e105]: 6 · 1 unmeasured
- img "flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=e106]:
- 'generic "flat stack: 39% of interior height" [ref=e107]'
- generic [ref=e110]:
- 'button "C1 no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan general 11 · 2 unmeasured flat stack 100% of height, standing lane 60% of width, overfull, 2 unmeasured boxes not counted" [ref=e111] [cursor=pointer]':
- generic [ref=e112]:
- text: C1
- 'img "no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan" [ref=e113]':
- generic [ref=e114]: general
- generic [ref=e115]: 11 · 2 unmeasured
- img "flat stack 100% of height, standing lane 60% of width, overfull, 2 unmeasured boxes not counted" [ref=e116]:
- 'generic "flat stack: 100% of interior height" [ref=e117]'
- 'button "C2 no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens general 14 · 3 unmeasured flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=e120] [cursor=pointer]':
- generic [ref=e121]:
- text: C2
- 'img "no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens" [ref=e122]':
- generic [ref=e123]: general
- generic [ref=e124]: 14 · 3 unmeasured
- img "flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=e125]:
- 'generic "flat stack: 95% of interior height" [ref=e126]'
- 'button "C3 no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine general 14 · 3 unmeasured flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=e129] [cursor=pointer]':
- generic [ref=e130]:
- text: C3
- 'img "no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine" [ref=e131]':
- generic [ref=e132]: general
- generic [ref=e133]: 14 · 3 unmeasured
- img "flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=e134]:
- 'generic "flat stack: 97% of interior height" [ref=e135]'
- 'button "C4 no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls general 13 · 4 unmeasured flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=e138] [cursor=pointer]':
- generic [ref=e139]:
- text: C4
- 'img "no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls" [ref=e140]':
- generic [ref=e141]: general
- generic [ref=e142]: 13 · 4 unmeasured
- img "flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=e143]:
- 'generic "flat stack: 90% of interior height" [ref=e144]'
- generic [ref=e147]:
- 'button "D1 no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank! general 11 · 2 unmeasured flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=e148] [cursor=pointer]':
- generic [ref=e149]:
- text: D1
- 'img "no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank!" [ref=e150]':
- generic [ref=e151]: general
- generic [ref=e152]: 11 · 2 unmeasured
- img "flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=e153]:
- 'generic "flat stack: 94% of interior height" [ref=e154]'
- button "D2 vintage wargames 7 flat stack 75% of height, standing lane 100% of width" [ref=e157] [cursor=pointer]:
- generic [ref=e158]: D2
- generic [ref=e159]: vintage wargames
- generic [ref=e160]: "7"
- img "flat stack 75% of height, standing lane 100% of width" [ref=e161]:
- 'generic "flat stack: 75% of interior height" [ref=e162]'
- button "D3 heavy 3 flat stack 42% of height, standing lane 94% of width" [ref=e165] [cursor=pointer]:
- generic [ref=e166]: D3
- generic [ref=e167]: heavy
- generic [ref=e168]: "3"
- img "flat stack 42% of height, standing lane 94% of width" [ref=e169]:
- 'generic "flat stack: 42% of interior height" [ref=e170]'
- button "D4 campaign 4 flat stack 83% of height, standing lane 92% of width" [ref=e173] [cursor=pointer]:
- generic [ref=e174]: D4
- generic [ref=e175]: campaign
- generic [ref=e176]: "4"
- img "flat stack 83% of height, standing lane 92% of width" [ref=e177]:
- 'generic "flat stack: 83% of interior height" [ref=e178]'
- paragraph [ref=e181]: Add an opening
- generic [ref=e182]:
- generic [ref=e183]:
- text: Rows
- textbox "Rows" [ref=e184]: "1"
- generic [ref=e185]:
- text: Columns
- textbox "Columns" [ref=e186]: "1"
- generic [ref=e187]:
- text: Label
- textbox "Label" [ref=e188]:
- /placeholder: wide top — for a single opening
- generic [ref=e189]:
- text: Zone
- textbox "Zone" [ref=e190]
- generic [ref=e191]:
- checkbox "at the top" [ref=e192]
- text: at the top
- generic [ref=e193]:
- text: W
- textbox "W" [ref=e194]:
- /placeholder: "13.25"
- generic [ref=e195]:
- text: H
- textbox "H" [ref=e196]:
- /placeholder: "13.25"
- generic [ref=e197]:
- text: D
- textbox "D" [ref=e198]:
- /placeholder: "15.4"
- button "add" [ref=e200] [cursor=pointer]
- generic [ref=e201]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=e202]:
- generic [ref=e203]:
- heading "Library Right Shelving" [level=2] [ref=e204]
- generic [ref=e205]:
- button "duplicate" [ref=e206] [cursor=pointer]
- button "rename" [ref=e207] [cursor=pointer]
- button "remove unit" [ref=e208] [cursor=pointer]
- paragraph [ref=e209]:
- generic [ref=e210]: To the right of the Kallax
- button "edit description" [ref=e211] [cursor=pointer]
- generic [ref=e212]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=e214] [cursor=pointer]:
- generic [ref=e215]: Top
- generic [ref=e216]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e217]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=e220] [cursor=pointer]:
- generic [ref=e221]: A1
- generic [ref=e222]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e223]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=e226] [cursor=pointer]:
- generic [ref=e227]: B1
- generic [ref=e228]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e229]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=e232] [cursor=pointer]:
- generic [ref=e233]: C1
- generic [ref=e234]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e235]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 — flat stack 0% of height, standing lane 0% of width" [ref=e238] [cursor=pointer]:
- generic [ref=e239]: D1
- generic [ref=e240]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e241]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 6 · 3 unmeasured flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=e244] [cursor=pointer]:
- generic [ref=e245]: E1
- generic [ref=e246]: 6 · 3 unmeasured
- img "flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=e247]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=e250]: Add an opening
- generic [ref=e251]:
- generic [ref=e252]:
- text: Rows
- textbox "Rows" [ref=e253]: "1"
- generic [ref=e254]:
- text: Columns
- textbox "Columns" [ref=e255]: "1"
- generic [ref=e256]:
- text: Label
- textbox "Label" [ref=e257]:
- /placeholder: wide top — for a single opening
- generic [ref=e258]:
- text: Zone
- textbox "Zone" [ref=e259]
- generic [ref=e260]:
- checkbox "at the top" [ref=e261]
- text: at the top
- generic [ref=e262]:
- text: W
- textbox "W" [ref=e263]:
- /placeholder: "13.25"
- generic [ref=e264]:
- text: H
- textbox "H" [ref=e265]:
- /placeholder: "13.25"
- generic [ref=e266]:
- text: D
- textbox "D" [ref=e267]:
- /placeholder: "15.4"
- button "add" [ref=e269] [cursor=pointer]
- generic [ref=e270]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=e271]:
- generic [ref=e272]:
- heading "Library Left Shelving" [level=2] [ref=e273]
- generic [ref=e274]:
- button "duplicate" [ref=e275] [cursor=pointer]
- button "rename" [ref=e276] [cursor=pointer]
- button "remove unit" [ref=e277] [cursor=pointer]
- paragraph [ref=e278]:
- generic [ref=e279]: To the left of the Kallax
- button "edit description" [ref=e280] [cursor=pointer]
- generic [ref=e281]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=e283] [cursor=pointer]:
- generic [ref=e284]: Top
- generic [ref=e285]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e286]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=e289] [cursor=pointer]:
- generic [ref=e290]: A1
- generic [ref=e291]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e292]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=e295] [cursor=pointer]:
- generic [ref=e296]: B1
- generic [ref=e297]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e298]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=e301] [cursor=pointer]:
- generic [ref=e302]: C1
- generic [ref=e303]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=e304]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 4 · 4 unmeasured flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=e307] [cursor=pointer]:
- generic [ref=e308]: D1
- generic [ref=e309]: 4 · 4 unmeasured
- img "flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=e310]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 1 flat stack 0% of height, standing lane 42% of width" [ref=e313] [cursor=pointer]:
- generic [ref=e314]: E1
- generic [ref=e315]: "1"
- img "flat stack 0% of height, standing lane 42% of width" [ref=e316]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=e319]: Add an opening
- generic [ref=e320]:
- generic [ref=e321]:
- text: Rows
- textbox "Rows" [ref=e322]: "1"
- generic [ref=e323]:
- text: Columns
- textbox "Columns" [ref=e324]: "1"
- generic [ref=e325]:
- text: Label
- textbox "Label" [ref=e326]:
- /placeholder: wide top — for a single opening
- generic [ref=e327]:
- text: Zone
- textbox "Zone" [ref=e328]
- generic [ref=e329]:
- checkbox "at the top" [ref=e330]
- text: at the top
- generic [ref=e331]:
- text: W
- textbox "W" [ref=e332]:
- /placeholder: "13.25"
- generic [ref=e333]:
- text: H
- textbox "H" [ref=e334]:
- /placeholder: "13.25"
- generic [ref=e335]:
- text: D
- textbox "D" [ref=e336]:
- /placeholder: "15.4"
- button "add" [ref=e338] [cursor=pointer]
- generic [ref=e339]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
- generic [ref=e340]:
- heading "Unshelved" [level=2] [ref=e341]
- generic [ref=e342]:
- link "DUNGEONS & DRAGONS" [ref=e343] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=e344]: unmeasured
- generic [ref=e345]:
- link "DUNGEONS & DRAGONS FANTASY GAME RULES" [ref=e346] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%20fantasy%20game%20rules%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=e347]: unmeasured
- generic [ref=e348]:
- 'link "Etherfields: Awakened Items & Actions" [ref=e349] [cursor=pointer]':
- /url: /library/game/355873%3A597861
- generic [ref=e350]:
- button "♥ Library Right Shelving · E1" [ref=e351] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=e352] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=e353] [cursor=pointer]
- generic [ref=e354]:
- 'link "Etherfields: Search Mini Deck" [ref=e355] [cursor=pointer]':
- /url: /library/game/309202%3A507805
- generic [ref=e356]:
- button "♥ Library Right Shelving · E1" [ref=e357] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=e358] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=e359] [cursor=pointer]
- generic [ref=e360]:
- 'link "Etherfields: Shifting Story Promo Deck" [ref=e361] [cursor=pointer]':
- /url: /library/game/381209%3A651213
- generic [ref=e362]:
- button "♥ Library Right Shelving · E1" [ref=e363] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=e364] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=e365] [cursor=pointer]
- generic [ref=e366]:
- 'link "Etherfields: Story Dice Expansion" [ref=e367] [cursor=pointer]':
- /url: /library/game/386805%3A658656
- generic "no trusted size — fit can't be verified" [ref=e368]: unmeasured
- generic [ref=e369]:
- 'link "Etherfields: Thorn Knight" [ref=e370] [cursor=pointer]':
- /url: /library/game/291126%3A475383
- generic "no trusted size — fit can't be verified" [ref=e371]: unmeasured
+14 -11
View File
@@ -9,19 +9,22 @@ 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 |
| + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working |
| + | `bggpipe dims` | offline shelf-space report over box dimensions enrich collects from BGG *versions* (dims live per-version, not per-game; 0 = never entered; versionless games need a unanimous chorus within 0.5"/axis else `conflicting` — never guessed); fit checks run against `data/furniture.json` openings | working |
| + | `bggpipe shelve --import` | bulk-assign games to openings from a name,opening CSV; rejects (unknown/ambiguous/doesn't-fit/contained) reported, never guessed | 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: seven pages (Pipeline `/`, Photos, Titles, Review, Queue, Library, Help) in a shared sidebar shell; stage runs execute one-at-a-time in a background job. - `uv run bggpipe web` — the app: eight pages (Pipeline `/`, Photos, Titles, Review, Queue, Library, Shelves, 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,29 +32,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.
- **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), and `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo) 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). - **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), `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), and `data/furniture.json` + `data/locations.json` (the owner's shelf layout and each game's physical spot — local-only, never BGG's business; a location can carry `lane`, `acknowledged` — the human overruling the packer — and `away`, where the box IS right now (loans/travel; home stays reserved, contents inherit)), and `data/measurements.json` (hand-measured box dims, source `measured` — the owner's tape measure outranks BGG, overlaid on every games.json read) 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). 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). - **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/title_splits.json`, `data/title_edits.json`, `data/title_removals.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/furniture.json`, `data/locations.json`, `data/measurements.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/`, `data/.lan_key`, Playwright storage state, or `.env`.
+37 -74
View File
@@ -1,5 +1,8 @@
# bggpipe — Shelf-to-BoardGameGeek Collection Pipeline # bggpipe — Shelf-to-BoardGameGeek Collection Pipeline
![Unique visitors, last 7 days](https://blog.kestrelsnest.social/badges/bggpipe-visitors.svg)
![Unique installs, all-time](https://blog.kestrelsnest.social/badges/bggpipe-installs.svg)
<img src="assets/logo-full.jpeg" alt="the bggpipe piper — a bagpiper whose bag is a board game box" width="220" align="right"> <img src="assets/logo-full.jpeg" alt="the bggpipe piper — a bagpiper whose bag is a board game box" width="220" align="right">
Photograph your board game shelves. End up with your whole collection — including which *edition* of each game you own — cataloged on [BoardGameGeek](https://boardgamegeek.com). Photograph your board game shelves. End up with your whole collection — including which *edition* of each game you own — cataloged on [BoardGameGeek](https://boardgamegeek.com).
@@ -11,109 +14,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 — and [this browsable library of the same shelves](https://blog.kestrelsnest.social/mygames/) is what the **enrich** stage turns that data into. 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 — which `bggpipe export` can publish as [static pages on your own site](docs/guide.md#publishing-your-library-on-your-own-site).
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 eight 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 ```sh
git clone https://git.kestrelsnest.social/eric/bggpipe.git uv tool install git+https://git.kestrelsnest.social/eric/bggpipe
cd bggpipe mkdir shelves && cd shelves # any directory of your own — NOT a clone of this repo
uv sync # installs Python deps bggpipe init # guided setup: folders, credentials, browser download
uv run bggpipe init # guided setup: folders, credentials, browser download bggpipe web # the whole app at http://127.0.0.1:8377/
``` ```
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. 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.
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`). **➔ [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.
| 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), **Titles** (every read off your shelves, alphabetized — and where you proofread them: fix misreads, add cues, split multi-copy lines, remove non-games), **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.
### 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, and **remove** lines that aren't games at all. 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`) 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. The in-app **Help** page documents all of it.
### 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 ## Development
```sh ```sh
uv run pytest # offline test suite (stub fixtures, no network) git clone https://git.kestrelsnest.social/eric/bggpipe.git
cd bggpipe
uv sync # installs Python deps
uv run pytest # offline test suite (recorded 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).
+249
View File
@@ -0,0 +1,249 @@
{
"units": [
{
"name": "Library Kallax",
"openings": [
{
"id": "library-kallax-top",
"label": "Top",
"zone": "",
"width_in": 53.0,
"height_in": 38.0,
"depth_in": 15.4
},
{
"id": "library-kallax-a1",
"label": "A1",
"zone": "oversize",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-a2",
"label": "A2",
"zone": "oversize",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-b1",
"label": "B1",
"zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
"description": "big boxes a-m"
},
{
"id": "library-kallax-b2",
"label": "B2",
"zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
"description": "big boxes n-z"
},
{
"id": "library-kallax-b3",
"label": "B3",
"zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
"description": "card rows standing"
},
{
"id": "library-kallax-b4",
"label": "B4",
"zone": "two-player",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-c1",
"label": "C1",
"zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-c2",
"label": "C2",
"zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-c3",
"label": "C3",
"zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-c4",
"label": "C4",
"zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-d1",
"label": "D1",
"zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-d2",
"label": "D2",
"zone": "vintage wargames",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-d3",
"label": "D3",
"zone": "heavy",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
},
{
"id": "library-kallax-d4",
"label": "D4",
"zone": "campaign",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
}
],
"description": "Centerpiece of the game wall in the library"
},
{
"name": "Library Right Shelving",
"description": "To the right of the Kallax",
"openings": [
{
"id": "library-right-shelving-top",
"label": "Top",
"zone": "",
"width_in": 23.5,
"height_in": 25.0,
"depth_in": 9.5
},
{
"id": "library-right-shelving-a1",
"label": "A1",
"zone": "",
"width_in": 23.25,
"height_in": 10.25,
"depth_in": 9.5
},
{
"id": "library-right-shelving-b1",
"label": "B1",
"zone": "",
"width_in": 23.25,
"height_in": 10.75,
"depth_in": 9.5,
"description": ""
},
{
"id": "library-right-shelving-c1",
"label": "C1",
"zone": "",
"width_in": 23.25,
"height_in": 12.25,
"depth_in": 9.5,
"description": ""
},
{
"id": "library-right-shelving-d1",
"label": "D1",
"zone": "",
"width_in": 23.25,
"height_in": 13.25,
"depth_in": 9.5,
"description": ""
},
{
"id": "library-right-shelving-e1",
"label": "E1",
"zone": "",
"width_in": 23.25,
"height_in": 19.0,
"depth_in": 9.5,
"description": ""
}
]
},
{
"name": "Library Left Shelving",
"description": "To the left of the Kallax",
"openings": [
{
"label": "Top",
"zone": "",
"width_in": 23.5,
"height_in": 25.0,
"depth_in": 9.5,
"id": "library-right-shelving-2-top"
},
{
"label": "A1",
"zone": "",
"width_in": 23.25,
"height_in": 10.25,
"depth_in": 9.5,
"id": "library-right-shelving-2-a1"
},
{
"label": "B1",
"zone": "",
"width_in": 23.25,
"height_in": 10.75,
"depth_in": 9.5,
"description": "",
"id": "library-right-shelving-2-b1"
},
{
"label": "C1",
"zone": "",
"width_in": 23.25,
"height_in": 12.25,
"depth_in": 9.5,
"description": "",
"id": "library-right-shelving-2-c1"
},
{
"label": "D1",
"zone": "",
"width_in": 23.25,
"height_in": 13.25,
"depth_in": 9.5,
"description": "",
"id": "library-right-shelving-2-d1"
},
{
"label": "E1",
"zone": "",
"width_in": 23.25,
"height_in": 19.0,
"depth_in": 9.5,
"description": "",
"id": "library-right-shelving-2-e1"
}
]
}
]
}
+11429
View File
File diff suppressed because it is too large Load Diff
+553
View File
@@ -0,0 +1,553 @@
{
"104590:117256": {
"note": "",
"opening_id": "library-kallax-c1"
},
"104710:117685": {
"note": "",
"opening_id": "library-kallax-d1"
},
"116:28201": {
"note": "",
"opening_id": "library-kallax-c3"
},
"1197:78624": {
"note": "",
"opening_id": "library-kallax-c1"
},
"1219:488621": {
"note": "",
"opening_id": "library-kallax-c3"
},
"124548:165214": {
"note": "",
"opening_id": "library-kallax-d1"
},
"125675:167657": {
"note": "",
"opening_id": "library-kallax-c2"
},
"125921:476263": {
"note": "",
"opening_id": "library-kallax-c2"
},
"1260:156689": {
"note": "",
"opening_id": "library-kallax-b3"
},
"1269:29705": {
"note": "",
"opening_id": "library-kallax-a1"
},
"128554:207529": {
"note": "",
"opening_id": "library-kallax-d1"
},
"1339:34390": {
"note": "",
"opening_id": "library-kallax-c2"
},
"137909:216474": {
"note": "",
"opening_id": "library-kallax-a2"
},
"139443:202519": {
"note": "",
"opening_id": "library-kallax-b2"
},
"13:347121": {
"note": "",
"opening_id": "library-kallax-c2"
},
"143884:453051": {
"note": "",
"opening_id": "library-kallax-c3"
},
"14535:29075": {
"note": "",
"opening_id": "library-kallax-c4"
},
"147370:231664": {
"note": "",
"opening_id": "library-kallax-c4"
},
"150925:225659": {
"note": "",
"opening_id": "library-kallax-c3"
},
"153999:280845": {
"note": "",
"opening_id": "library-kallax-b4"
},
"156994:236280": {
"note": "",
"opening_id": "library-kallax-c4"
},
"157969:237864": {
"acknowledged": true,
"note": "",
"opening_id": "library-kallax-b1"
},
"160477:269392": {
"note": "",
"opening_id": "library-kallax-b4"
},
"161928:245124": {
"note": "",
"opening_id": "library-kallax-a2"
},
"163412:495998": {
"note": "",
"opening_id": "library-kallax-b4"
},
"164:28465": {
"note": "",
"opening_id": "library-kallax-b3"
},
"165986:277137": {
"note": "",
"opening_id": "library-kallax-c4"
},
"166109:253058": {
"acknowledged": true,
"note": "",
"opening_id": "library-kallax-b1"
},
"166976:410382": {
"note": "",
"opening_id": "library-kallax-b4"
},
"169124:451577": {
"note": "",
"opening_id": "library-kallax-b1"
},
"169784:260454": {
"note": "",
"opening_id": "library-kallax-c1"
},
"172225:296012": {
"note": "",
"opening_id": "library-kallax-c2"
},
"172503:265107": {
"note": "",
"opening_id": "library-kallax-b4"
},
"173057:266044": {
"note": "",
"opening_id": "library-kallax-b3"
},
"173634:266954": {
"note": "",
"opening_id": "library-kallax-b2"
},
"174430:520054": {
"note": "",
"opening_id": "library-kallax-a1"
},
"176:27673": {
"note": "",
"opening_id": "library-kallax-b3"
},
"177:24972": {
"note": "",
"opening_id": "library-kallax-d2"
},
"1784:110926": {
"note": "",
"opening_id": "library-right-shelving-2-d1"
},
"181304:536289": {
"note": "",
"opening_id": "library-kallax-b1"
},
"18755:284056": {
"note": "",
"opening_id": "library-kallax-c4"
},
"188614:779792": {
"note": "",
"opening_id": "library-kallax-a1"
},
"1927:28144": {
"note": "",
"opening_id": "library-kallax-c3"
},
"193485:300446": {
"note": "",
"opening_id": "library-kallax-c2"
},
"193621:300682": {
"note": "",
"opening_id": "library-kallax-b3"
},
"194655:668826": {
"note": "",
"opening_id": "library-kallax-c4"
},
"195314:525268": {
"note": "",
"opening_id": "library-kallax-c3"
},
"196105:304900": {
"note": "",
"opening_id": "library-kallax-b2"
},
"201248:313478": {
"note": "",
"opening_id": "library-kallax-c2"
},
"202982:316090": {
"note": "",
"opening_id": "library-kallax-b3"
},
"2058:28041": {
"note": "",
"opening_id": "library-kallax-d2"
},
"207830:344075": {
"note": "",
"opening_id": "library-kallax-c1"
},
"208428:325455": {
"note": "",
"opening_id": "library-kallax-c3"
},
"20866:40025": {
"note": "",
"opening_id": "library-kallax-a2"
},
"2197:42495": {
"note": "",
"opening_id": "library-kallax-b4"
},
"220199:369805": {
"note": "",
"opening_id": "library-kallax-a1"
},
"224:20727": {
"note": "",
"opening_id": "library-kallax-a1"
},
"226065:353389": {
"note": "",
"opening_id": "library-kallax-b1"
},
"231302:361880": {
"note": "",
"opening_id": "library-kallax-a2"
},
"2387:91428": {
"note": "",
"opening_id": "library-kallax-c3"
},
"240:24621": {
"note": "",
"opening_id": "library-kallax-d2"
},
"2452:288640": {
"note": "",
"opening_id": "library-kallax-b3"
},
"252153:404702": {
"note": "",
"opening_id": "library-kallax-c4"
},
"2524:219137": {
"note": "",
"opening_id": "library-kallax-d2"
},
"2529:98655": {
"note": "",
"opening_id": "library-kallax-d2"
},
"255984:701034": {
"note": "",
"opening_id": "library-kallax-d4"
},
"257:520364": {
"note": "",
"opening_id": "library-kallax-b3"
},
"258849:449806": {
"note": "",
"opening_id": "library-right-shelving-2-d1"
},
"258:20840": {
"note": "",
"opening_id": "library-kallax-b3"
},
"269564:439948": {
"note": "",
"opening_id": "library-kallax-b3"
},
"26:28607": {
"note": "",
"opening_id": "library-kallax-a1"
},
"273240:446069": {
"acknowledged": true,
"note": "",
"opening_id": "library-kallax-b2"
},
"27710:606657": {
"note": "",
"opening_id": "library-kallax-c2"
},
"280794:458300": {
"acknowledged": true,
"note": "",
"opening_id": "library-right-shelving-e1"
},
"283155:462049": {
"note": "",
"opening_id": "library-kallax-c1"
},
"283766:463140": {
"note": "",
"opening_id": "library-kallax-d4"
},
"290840:474978": {
"acknowledged": true,
"note": "",
"opening_id": "library-right-shelving-e1"
},
"291130:475387": {
"acknowledged": true,
"note": "",
"opening_id": "library-right-shelving-e1"
},
"291131:475388": {
"acknowledged": true,
"note": "",
"opening_id": "library-right-shelving-e1"
},
"291457:476060": {
"note": "",
"opening_id": "library-kallax-d3"
},
"291847:476820": {
"note": "",
"opening_id": "library-kallax-c3"
},
"295770:484007": {
"acknowledged": true,
"away": "travel case",
"note": "",
"opening_id": "library-right-shelving-2-e1"
},
"2980:138013": {
"note": "",
"opening_id": "library-kallax-d1"
},
"3090:25782": {
"note": "",
"opening_id": "library-kallax-c2"
},
"311654": {
"note": "",
"opening_id": "library-right-shelving-2-d1"
},
"31260:297589": {
"note": "",
"opening_id": "library-kallax-d3"
},
"312786:514050": {
"note": "",
"opening_id": "library-kallax-b3"
},
"3181:87931": {
"note": "",
"opening_id": "library-kallax-b3"
},
"320202:528008": {
"note": "",
"opening_id": "library-kallax-c2"
},
"324937:537230": {
"note": "",
"opening_id": "library-kallax-d1"
},
"329230:547518": {
"note": "",
"opening_id": "library-kallax-b3"
},
"332398:553061": {
"note": "",
"opening_id": "library-kallax-a2"
},
"334011:701315": {
"note": "",
"opening_id": "library-kallax-c1"
},
"334065:759453": {
"note": "",
"opening_id": "library-kallax-d1"
},
"340420:567183": {
"note": "",
"opening_id": "library-kallax-b2"
},
"351544:588185": {
"acknowledged": true,
"note": "",
"opening_id": "library-right-shelving-e1"
},
"352819:591147": {
"note": "",
"opening_id": "library-kallax-d1"
},
"35505:781669": {
"note": "",
"opening_id": "library-kallax-d1"
},
"358320:602661": {
"note": "",
"opening_id": "library-kallax-d4"
},
"3585:211154": {
"note": "",
"opening_id": "library-kallax-c4"
},
"358880:603694": {
"acknowledged": true,
"note": "",
"opening_id": "library-right-shelving-e1"
},
"362205:612448": {
"note": "",
"opening_id": "library-kallax-d4"
},
"380459:743266": {
"note": "",
"opening_id": "library-right-shelving-2-d1"
},
"380837:650545": {
"note": "",
"opening_id": "library-kallax-c1"
},
"389113:662372": {
"note": "",
"opening_id": "library-kallax-c4"
},
"393672:669782": {
"note": "",
"opening_id": "library-kallax-d3"
},
"39856:296529": {
"note": "",
"opening_id": "library-kallax-b1"
},
"40508:644326": {
"note": "",
"opening_id": "library-kallax-c4"
},
"40529:27692": {
"note": "",
"opening_id": "library-kallax-c2"
},
"40692:294178": {
"note": "",
"opening_id": "library-kallax-c4"
},
"419687:711400": {
"note": "",
"opening_id": "library-kallax-c3"
},
"42063:191736": {
"note": "",
"opening_id": "library-kallax-b3"
},
"4324:26859": {
"note": "",
"opening_id": "library-kallax-a2"
},
"432858:734890": {
"note": "",
"opening_id": "library-kallax-c1"
},
"43443:103068": {
"note": "",
"opening_id": "library-kallax-c1"
},
"442312:752105": {
"note": "",
"opening_id": "library-kallax-c2"
},
"453845:772660": {
"note": "",
"opening_id": "library-kallax-c3"
},
"483:48522": {
"note": "",
"opening_id": "library-kallax-d2"
},
"50381:134539": {
"note": "",
"opening_id": "library-kallax-b3"
},
"57215:295423": {
"note": "",
"opening_id": "library-kallax-b1"
},
"589:27352": {
"note": "",
"opening_id": "library-kallax-d1"
},
"65244:31208": {
"note": "",
"opening_id": "library-kallax-c3"
},
"71:24006": {
"note": "",
"opening_id": "library-kallax-d2"
},
"822:187468": {
"note": "",
"opening_id": "library-kallax-c1"
},
"9209:294188": {
"note": "",
"opening_id": "library-kallax-d1"
},
"95386:290572": {
"note": "",
"opening_id": "library-kallax-a1"
},
"9666:215137": {
"note": "",
"opening_id": "library-kallax-b3"
},
"97903:231971": {
"note": "",
"opening_id": "library-kallax-d1"
},
"local:cat crimes:IMG_4499.jpeg;IMG_4505.jpeg;shelf-20260803-215723.jpg": {
"note": "",
"opening_id": "library-kallax-c1"
},
"local:cat stax:IMG_4544.jpeg": {
"note": "",
"opening_id": "library-kallax-c2"
},
"local:elite sportz equipment table top curling:IMG_4526.jpeg": {
"note": "",
"opening_id": "library-kallax-c2"
},
"local:gravity maze:IMG_4510.jpeg": {
"note": "",
"opening_id": "library-kallax-c3"
},
"local:laser maze beam bending logic game:IMG_4516.jpeg": {
"note": "",
"opening_id": "library-kallax-c3"
},
"local:ouija:IMG_4529.jpeg": {
"note": "",
"opening_id": "library-kallax-c4"
},
"local:super mario checkers collector s edition:IMG_4508.jpeg": {
"note": "",
"opening_id": "library-kallax-c4"
}
}
+161 -18
View File
File diff suppressed because one or more lines are too long
+136
View File
@@ -0,0 +1,136 @@
name,opening,acknowledge
Gloomhaven,library-kallax-a1,
History of the World,library-kallax-a1,
Age of Renaissance,library-kallax-a1,
Simon's Cat Card Game,library-kallax-a1,
Skip-Bo,library-kallax-a1,
Snappy Dressers,library-kallax-a1,
Tem-Purr-A,library-kallax-a1,
Everdell: The Complete Collection,library-kallax-a2,
Bugs in the Kitchen,library-kallax-a2,
Risk: The Lord of the Rings,library-kallax-a2,
The Cat Game,library-kallax-a2,
The Rocky Horror Trivia Game,library-kallax-a2,
Utter Nonsense,library-kallax-a2,
Dixit,library-kallax-b1,
Flick 'em Up!,library-kallax-b1,
Googly Eyes,library-kallax-b1,
Meteor,library-kallax-b1,
Mysterium,library-kallax-b1,
Sheriff of Nottingham,library-kallax-b1,
Sheriff of Nottingham: Merry Men,library-kallax-b1,
Speechless,library-kallax-b2,
Superfight,library-kallax-b2,
The Red Dragon Inn 5: The Character Trove,library-kallax-b2,
The Red Dragon Inn Smorgasbox,library-kallax-b2,
Throw Throw Avocado,library-kallax-b2,
"Before I Kill You, Mister Spy...",library-kallax-b3,
Bluffaneer,library-kallax-b3,
Captain Marvel: Secret Skrulls,library-kallax-b3,
Cards Against Humanity,library-kallax-b3,
Code Master,library-kallax-b3,
Farkle,library-kallax-b3,
Fluxx,library-kallax-b3,
Give Me the Brain!,library-kallax-b3,
Go Fish Yourself,library-kallax-b3,
Greed Quest,library-kallax-b3,
Jenga,library-kallax-b3,
Joking Hazard,library-kallax-b3,
Kill Doctor Lucky,library-kallax-b3,
Poetry for Neanderthals,library-kallax-b3,
Rook,library-kallax-b3,
Scrawl,library-kallax-b3,
"...and then, we held hands.",library-kallax-b4,
Consentacle,library-kallax-b4,
Kensington,library-kallax-b4,
Mage Wars Academy,library-kallax-b4,
Onitama,library-kallax-b4,
Patchwork,library-kallax-b4,
5-Minute Dungeon,library-kallax-c1,
A Gentle Rain,library-kallax-c1,
Artistry,library-kallax-c1,
Blink,library-kallax-c1,
Botany,library-kallax-c1,
Calico,library-kallax-c1,
Carcassonne,library-kallax-c1,
Castle Panic,library-kallax-c1,
Castle Panic: The Dark Titan,library-kallax-c1,
Castle Panic: The Wizard's Tower,library-kallax-c1,
CAT CRIMES,library-kallax-c1,
CAT STAX,library-kallax-c2,
Catan,library-kallax-c2,
Catan Dice Game,library-kallax-c2,
Catan: Junior,library-kallax-c2,
Cosmic Encounter,library-kallax-c2,
Dastardly Dirigibles,library-kallax-c2,
Deadly Doodles,library-kallax-c2,
Doctor Who: The Card Game,library-kallax-c2,
Doctor Who: The Game of Time & Space,library-kallax-c2,
Dragon Land,library-kallax-c2,
Dungeon!,library-kallax-c2,
Elite Sportz Equipment Table Top Curling,library-kallax-c2,
Evolution: The Beginning,library-kallax-c2,
Exploding Kittens,library-kallax-c2,
Forbidden Island,library-kallax-c3,
GRAVITY MAZE,library-kallax-c3,
Guillotine,library-kallax-c3,
Herbaceous,library-kallax-c3,
Hold Your Breath!,library-kallax-c3,
Huggermugger,library-kallax-c3,
Labyrinth,library-kallax-c3,
LASER MAZE BEAM-BENDING LOGIC GAME,library-kallax-c3,
Machi Koro,library-kallax-c3,
Mantis Falls,library-kallax-c3,
Munchkin,library-kallax-c3,
Munchkin Big Box,library-kallax-c3,
Murder at the Manor,library-kallax-c3,
"No Thank You, Evil!",library-kallax-c3,
Ouija,library-kallax-c4,
Pie Face!,library-kallax-c4,
Rivals,library-kallax-c4,
Robot Turtles,library-kallax-c4,
Royals,library-kallax-c4,
Santorini,library-kallax-c4,
Scrabble Slam!,library-kallax-c4,
Skylanders Portal Master,library-kallax-c4,
Small World,library-kallax-c4,
Sorcerer: The Game of Magical Conflict,library-kallax-c4,
SPANC: Space Pirate Amazon Ninja Catgirls,library-kallax-c4,
Super Mario Checkers Collector's Edition,library-kallax-c4,
Tang Garden,library-kallax-c4,
Terror in Meeple City,library-kallax-d1,
The Doom That Came to Atlantic City,library-kallax-d1,
The Office Trivia Game,library-kallax-d1,
The Simpsons Trivia Game,library-kallax-d1,
Ticket to Ride,library-kallax-d1,
Verdant,library-kallax-d1,
Völuspá,library-kallax-d1,
Walk the Plank!,library-kallax-d1,
Wiz-War,library-kallax-d1,
Wiz-War (Eighth Edition),library-kallax-d1,
Wiz-War (9th Edition),library-kallax-d1,
Advanced Civilization,library-kallax-d2,
Britannia,library-kallax-d2,
Civilization,library-kallax-d2,
Civilization: West Extension Map,library-kallax-d2,
Diplomacy,library-kallax-d2,
Flat Top,library-kallax-d2,
StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century,library-kallax-d2,
Agricola,library-kallax-d3,
Gloomhaven: Buttons & Bugs,library-kallax-d3,
Gloomhaven: Jaws of the Lion,library-kallax-d3,
Sleeping Gods,library-kallax-d4,
Sleeping Gods: Distant Skies,library-kallax-d4,
Sleeping Gods: Primeval Peril,library-kallax-d4,
Sleeping Gods: Tides of Ruin,library-kallax-d4,
Etherfields,library-right-shelving-e1,yes
Etherfields: Kittenburg Expansion,library-right-shelving-e1,yes
Etherfields: 5th Player Expansion,library-right-shelving-e1,yes
Etherfields: Funeral Witch Campaign,library-right-shelving-e1,yes
Etherfields: Sphinx Campaign,library-right-shelving-e1,yes
Etherfields: Stretch Goals Harpy & She-Wolf Campaigns,library-right-shelving-e1,yes
Alice is Missing,library-right-shelving-2-d1,
Alice is Missing: Silent Falls,library-right-shelving-2-d1,
Parsely,library-right-shelving-2-d1,
Dark Cults,library-right-shelving-2-d1,
Frosthaven,library-right-shelving-2-e1,yes
1 name opening acknowledge
2 Gloomhaven library-kallax-a1
3 History of the World library-kallax-a1
4 Age of Renaissance library-kallax-a1
5 Simon's Cat Card Game library-kallax-a1
6 Skip-Bo library-kallax-a1
7 Snappy Dressers library-kallax-a1
8 Tem-Purr-A library-kallax-a1
9 Everdell: The Complete Collection library-kallax-a2
10 Bugs in the Kitchen library-kallax-a2
11 Risk: The Lord of the Rings library-kallax-a2
12 The Cat Game library-kallax-a2
13 The Rocky Horror Trivia Game library-kallax-a2
14 Utter Nonsense library-kallax-a2
15 Dixit library-kallax-b1
16 Flick 'em Up! library-kallax-b1
17 Googly Eyes library-kallax-b1
18 Meteor library-kallax-b1
19 Mysterium library-kallax-b1
20 Sheriff of Nottingham library-kallax-b1
21 Sheriff of Nottingham: Merry Men library-kallax-b1
22 Speechless library-kallax-b2
23 Superfight library-kallax-b2
24 The Red Dragon Inn 5: The Character Trove library-kallax-b2
25 The Red Dragon Inn Smorgasbox library-kallax-b2
26 Throw Throw Avocado library-kallax-b2
27 Before I Kill You, Mister Spy... library-kallax-b3
28 Bluffaneer library-kallax-b3
29 Captain Marvel: Secret Skrulls library-kallax-b3
30 Cards Against Humanity library-kallax-b3
31 Code Master library-kallax-b3
32 Farkle library-kallax-b3
33 Fluxx library-kallax-b3
34 Give Me the Brain! library-kallax-b3
35 Go Fish Yourself library-kallax-b3
36 Greed Quest library-kallax-b3
37 Jenga library-kallax-b3
38 Joking Hazard library-kallax-b3
39 Kill Doctor Lucky library-kallax-b3
40 Poetry for Neanderthals library-kallax-b3
41 Rook library-kallax-b3
42 Scrawl library-kallax-b3
43 ...and then, we held hands. library-kallax-b4
44 Consentacle library-kallax-b4
45 Kensington library-kallax-b4
46 Mage Wars Academy library-kallax-b4
47 Onitama library-kallax-b4
48 Patchwork library-kallax-b4
49 5-Minute Dungeon library-kallax-c1
50 A Gentle Rain library-kallax-c1
51 Artistry library-kallax-c1
52 Blink library-kallax-c1
53 Botany library-kallax-c1
54 Calico library-kallax-c1
55 Carcassonne library-kallax-c1
56 Castle Panic library-kallax-c1
57 Castle Panic: The Dark Titan library-kallax-c1
58 Castle Panic: The Wizard's Tower library-kallax-c1
59 CAT CRIMES library-kallax-c1
60 CAT STAX library-kallax-c2
61 Catan library-kallax-c2
62 Catan Dice Game library-kallax-c2
63 Catan: Junior library-kallax-c2
64 Cosmic Encounter library-kallax-c2
65 Dastardly Dirigibles library-kallax-c2
66 Deadly Doodles library-kallax-c2
67 Doctor Who: The Card Game library-kallax-c2
68 Doctor Who: The Game of Time & Space library-kallax-c2
69 Dragon Land library-kallax-c2
70 Dungeon! library-kallax-c2
71 Elite Sportz Equipment Table Top Curling library-kallax-c2
72 Evolution: The Beginning library-kallax-c2
73 Exploding Kittens library-kallax-c2
74 Forbidden Island library-kallax-c3
75 GRAVITY MAZE library-kallax-c3
76 Guillotine library-kallax-c3
77 Herbaceous library-kallax-c3
78 Hold Your Breath! library-kallax-c3
79 Huggermugger library-kallax-c3
80 Labyrinth library-kallax-c3
81 LASER MAZE BEAM-BENDING LOGIC GAME library-kallax-c3
82 Machi Koro library-kallax-c3
83 Mantis Falls library-kallax-c3
84 Munchkin library-kallax-c3
85 Munchkin Big Box library-kallax-c3
86 Murder at the Manor library-kallax-c3
87 No Thank You, Evil! library-kallax-c3
88 Ouija library-kallax-c4
89 Pie Face! library-kallax-c4
90 Rivals library-kallax-c4
91 Robot Turtles library-kallax-c4
92 Royals library-kallax-c4
93 Santorini library-kallax-c4
94 Scrabble Slam! library-kallax-c4
95 Skylanders Portal Master library-kallax-c4
96 Small World library-kallax-c4
97 Sorcerer: The Game of Magical Conflict library-kallax-c4
98 SPANC: Space Pirate Amazon Ninja Catgirls library-kallax-c4
99 Super Mario Checkers Collector's Edition library-kallax-c4
100 Tang Garden library-kallax-c4
101 Terror in Meeple City library-kallax-d1
102 The Doom That Came to Atlantic City library-kallax-d1
103 The Office Trivia Game library-kallax-d1
104 The Simpsons Trivia Game library-kallax-d1
105 Ticket to Ride library-kallax-d1
106 Verdant library-kallax-d1
107 Völuspá library-kallax-d1
108 Walk the Plank! library-kallax-d1
109 Wiz-War library-kallax-d1
110 Wiz-War (Eighth Edition) library-kallax-d1
111 Wiz-War (9th Edition) library-kallax-d1
112 Advanced Civilization library-kallax-d2
113 Britannia library-kallax-d2
114 Civilization library-kallax-d2
115 Civilization: West Extension Map library-kallax-d2
116 Diplomacy library-kallax-d2
117 Flat Top library-kallax-d2
118 StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century library-kallax-d2
119 Agricola library-kallax-d3
120 Gloomhaven: Buttons & Bugs library-kallax-d3
121 Gloomhaven: Jaws of the Lion library-kallax-d3
122 Sleeping Gods library-kallax-d4
123 Sleeping Gods: Distant Skies library-kallax-d4
124 Sleeping Gods: Primeval Peril library-kallax-d4
125 Sleeping Gods: Tides of Ruin library-kallax-d4
126 Etherfields library-right-shelving-e1 yes
127 Etherfields: Kittenburg Expansion library-right-shelving-e1 yes
128 Etherfields: 5th Player Expansion library-right-shelving-e1 yes
129 Etherfields: Funeral Witch Campaign library-right-shelving-e1 yes
130 Etherfields: Sphinx Campaign library-right-shelving-e1 yes
131 Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns library-right-shelving-e1 yes
132 Alice is Missing library-right-shelving-2-d1
133 Alice is Missing: Silent Falls library-right-shelving-2-d1
134 Parsely library-right-shelving-2-d1
135 Dark Cults library-right-shelving-2-d1
136 Frosthaven library-right-shelving-2-e1 yes
+272
View File
@@ -0,0 +1,272 @@
[
{
"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": []
},
{
"title_raw": "The Red Dragon Inn 5: The Character Trove",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2015,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn 10",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2026,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn 2",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2008,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn 3",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2011,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn 4",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2013,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn 6: Villains",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2017,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn 7: The Tavern Crew",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2018,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn 8: Pub Crawl",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn 9: The Undercity",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2023,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn: Allies Evil Pooky",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2021,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn: Allies Halden the Unhinged",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2015,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn: Allies Jin vs Greppa",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2025,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn: Allies Melvyn vs. Marah",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2024,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn: Allies Pooky",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2012,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "The Red Dragon Inn: Allies Wrench",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2015,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: 5th Player Expansion",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2020,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: Awakened Items & Actions",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: Funeral Witch Campaign",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: Search Mini Deck",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2020,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: Shifting Story Promo Deck",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2023,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: Sphinx Campaign",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: Story Dice Expansion",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2024,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: Stretch Goals Harpy & She-Wolf Campaigns",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": []
},
{
"title_raw": "Etherfields: Thorn Knight",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2020,
"language_hint": "",
"art_notes": "",
"source_photos": []
}
]
+89
View File
@@ -6,5 +6,94 @@
{ {
"match": "DOCTOR WHO THE GAME OF TIME", "match": "DOCTOR WHO THE GAME OF TIME",
"title_raw": "DOCTOR WHO THE card GAME" "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"
} }
] ]
+24
View File
@@ -58,5 +58,29 @@
"photos": [ "photos": [
"IMG_4517.jpeg" "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"
]
} }
] ]
+450 -35
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,
@@ -168,7 +169,7 @@
"source_photos": [ "source_photos": [
"IMG_4501.jpeg" "IMG_4501.jpeg"
], ],
"title_normalized": "patch work" "title_normalized": "patchwork"
}, },
{ {
"title_raw": "SANTORINI", "title_raw": "SANTORINI",
@@ -260,7 +261,7 @@
}, },
{ {
"title_raw": "Wiz-War", "title_raw": "Wiz-War",
"confidence": "medium", "confidence": "high",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -354,7 +355,7 @@
"confidence": "high", "confidence": "high",
"publisher_hint": "Fantasy Flight Games", "publisher_hint": "Fantasy Flight Games",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": 2012,
"language_hint": "English", "language_hint": "English",
"art_notes": "Dark spine with wizard/warrior artwork, sepia tones", "art_notes": "Dark spine with wizard/warrior artwork, sepia tones",
"source_photos": [ "source_photos": [
@@ -416,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": "",
@@ -426,7 +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": "Herbaceous", "title_raw": "Herbaceous",
@@ -455,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",
@@ -692,7 +680,7 @@
"title_normalized": "doom that came to atlantic city" "title_normalized": "doom that came to atlantic city"
}, },
{ {
"title_raw": "SMALLWORLD", "title_raw": "SMALL WORLD",
"confidence": "high", "confidence": "high",
"publisher_hint": "Days of Wonder", "publisher_hint": "Days of Wonder",
"edition_hint": "", "edition_hint": "",
@@ -702,7 +690,7 @@
"source_photos": [ "source_photos": [
"IMG_4513.jpeg" "IMG_4513.jpeg"
], ],
"title_normalized": "smallworld" "title_normalized": "small world"
}, },
{ {
"title_raw": "Flick'em UP!", "title_raw": "Flick'em UP!",
@@ -758,7 +746,7 @@
}, },
{ {
"title_raw": "Huggermugger", "title_raw": "Huggermugger",
"confidence": "low", "confidence": "high",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -770,8 +758,8 @@
"title_normalized": "huggermugger" "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,
@@ -780,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": "",
@@ -793,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",
@@ -849,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,
@@ -914,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",
@@ -924,11 +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": "DOCTOR WHO THE card GAME", "title_raw": "DOCTOR WHO THE card GAME",
"confidence": "medium", "confidence": "high",
"publisher_hint": "", "publisher_hint": "",
"edition_hint": "", "edition_hint": "",
"year_hint": null, "year_hint": null,
@@ -941,7 +929,7 @@
}, },
{ {
"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,
@@ -1319,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,
@@ -1628,5 +1616,432 @@
"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"
},
{
"title_raw": "The Red Dragon Inn 5: The Character Trove",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2015,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 5 character trove"
},
{
"title_raw": "The Red Dragon Inn 10",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2026,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 10"
},
{
"title_raw": "The Red Dragon Inn 2",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2008,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 2"
},
{
"title_raw": "The Red Dragon Inn 3",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2011,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 3"
},
{
"title_raw": "The Red Dragon Inn 4",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2013,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 4"
},
{
"title_raw": "The Red Dragon Inn 6: Villains",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2017,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 6 villains"
},
{
"title_raw": "The Red Dragon Inn 7: The Tavern Crew",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2018,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 7 tavern crew"
},
{
"title_raw": "The Red Dragon Inn 8: Pub Crawl",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 8 pub crawl"
},
{
"title_raw": "The Red Dragon Inn 9: The Undercity",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2023,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn 9 undercity"
},
{
"title_raw": "The Red Dragon Inn: Allies Evil Pooky",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2021,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn allies evil pooky"
},
{
"title_raw": "The Red Dragon Inn: Allies Halden the Unhinged",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2015,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn allies halden unhinged"
},
{
"title_raw": "The Red Dragon Inn: Allies Jin vs Greppa",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2025,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn allies jin vs greppa"
},
{
"title_raw": "The Red Dragon Inn: Allies Melvyn vs. Marah",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2024,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn allies melvyn vs marah"
},
{
"title_raw": "The Red Dragon Inn: Allies Pooky",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2012,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn allies pooky"
},
{
"title_raw": "The Red Dragon Inn: Allies Wrench",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2015,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "red dragon inn allies wrench"
},
{
"title_raw": "Etherfields: 5th Player Expansion",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2020,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields 5th player expansion"
},
{
"title_raw": "Etherfields: Awakened Items & Actions",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields awakened items and actions"
},
{
"title_raw": "Etherfields: Funeral Witch Campaign",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields funeral witch campaign"
},
{
"title_raw": "Etherfields: Search Mini Deck",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2020,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields search mini deck"
},
{
"title_raw": "Etherfields: Shifting Story Promo Deck",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2023,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields shifting story promo deck"
},
{
"title_raw": "Etherfields: Sphinx Campaign",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields sphinx campaign"
},
{
"title_raw": "Etherfields: Story Dice Expansion",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2024,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields story dice expansion"
},
{
"title_raw": "Etherfields: Stretch Goals Harpy & She-Wolf Campaigns",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2022,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields stretch goals harpy and she wolf campaigns"
},
{
"title_raw": "Etherfields: Thorn Knight",
"confidence": "high",
"publisher_hint": "",
"edition_hint": "",
"year_hint": 2020,
"language_hint": "",
"art_notes": "",
"source_photos": [],
"title_normalized": "etherfields thorn knight"
} }
] ]
-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"
}
] ]
} }
+3 -1
View File
@@ -108,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"
] ]
+205
View File
@@ -0,0 +1,205 @@
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,
update,165986,53429696,Royals,277137,,updated,2026-08-09T15:42:48+00:00,
update,40529,53429871,Cosmic Encounter,27692,,updated,2026-08-09T15:42:56+00:00,
update,157969,53430297,Sheriff of Nottingham,237864,,updated,2026-08-09T15:43:07+00:00,
update,207830,53429613,5-Minute Dungeon,344075,,updated,2026-08-09T15:43:13+00:00,
update,194655,53430612,Santorini,668826,,updated,2026-08-09T15:43:19+00:00,
update,161928,148198650,Utter Nonsense,245124,,updated,2026-08-09T15:43:25+00:00,
update,50381,148198655,Cards Against Humanity,134539,,updated,2026-08-09T15:43:32+00:00,
update,193621,53430387,Joking Hazard,300682,,updated,2026-08-09T15:43:37+00:00,
update,1269,148198659,Skip-Bo,29705,,updated,2026-08-09T15:43:44+00:00,
update,172225,53430108,Exploding Kittens,296012,,updated,2026-08-09T15:43:50+00:00,
update,202982,148198665,Scrawl,316090,,updated,2026-08-09T15:43:58+00:00,
update,2529,53429839,Flat Top,98655,,updated,2026-08-09T15:44:04+00:00,
update,483,49111776,Diplomacy,48522,,updated,2026-08-09T15:44:11+00:00,
update,26,53429831,Age of Renaissance,28607,,updated,2026-08-09T15:44:21+00:00,
update,97903,53430468,Rampage,231971,,updated,2026-08-09T15:44:27+00:00,
update,160477,53430375,Onitama,269392,,updated,2026-08-09T15:44:34+00:00,
update,195314,148198667,Herbaceous,525268,,updated,2026-08-09T15:44:39+00:00,
update,320202,148198669,Dragon Land,528008,,updated,2026-08-09T15:44:43+00:00,
update,2452,148198672,Jenga,288640,,updated,2026-08-09T15:44:50+00:00,
update,208428,53429689,"No Thank You, Evil!",325455,,updated,2026-08-09T15:44:58+00:00,
update,42063,148198675,Code Master,191736,,updated,2026-08-09T15:45:09+00:00,
update,57215,53430150,Googly Eyes,295423,,updated,2026-08-09T15:45:14+00:00,
update,340420,148198677,Throw Throw Avocado,567183,,updated,2026-08-09T15:45:19+00:00,
update,139443,53430424,Superfight,202519,,updated,2026-08-09T15:45:24+00:00,
update,1260,148198681,Rook,156689,,updated,2026-08-09T15:45:29+00:00,
update,2197,53430503,Kensington,42495,,updated,2026-08-09T15:45:35+00:00,
update,116,148198685,Guillotine,28201,,updated,2026-08-09T15:45:42+00:00,
update,124548,53430526,The Doom That Came to Atlantic City,165214,,updated,2026-08-09T15:45:48+00:00,
update,169124,148198688,Flick 'em Up!,451577,,updated,2026-08-09T15:45:54+00:00,
update,2387,54518133,Huggermugger,91428,,updated,2026-08-09T15:45:59+00:00,
update,20866,148198690,The Rocky Horror Trivia Game,40025,,updated,2026-08-09T15:46:07+00:00,
update,196105,53430662,Speechless,304900,,updated,2026-08-09T15:46:13+00:00,
update,156994,53430578,Skylanders Portal Master,236280,,updated,2026-08-09T15:46:18+00:00,
update,269564,148198703,Captain Marvel: Secret Skrulls,439948,,updated,2026-08-09T15:46:24+00:00,
update,18755,148198705,Pie Face!,284056,,updated,2026-08-09T15:46:30+00:00,
update,166109,53429921,Meteor,253058,,updated,2026-08-09T15:46:36+00:00,
update,125675,53429970,Doctor Who: The Card Game,167657,,updated,2026-08-09T15:46:41+00:00,
update,128554,53429733,Völuspá,207529,,updated,2026-08-09T15:46:46+00:00,
update,822,53429625,Carcassonne,187468,,updated,2026-08-09T15:46:53+00:00,
update,201248,148198712,Evolution: The Beginning,313478,,updated,2026-08-09T15:46:59+00:00,
update,362205,148198714,Sleeping Gods: Primeval Peril,612448,,updated,2026-08-09T15:47:05+00:00,
update,220199,53430477,Snappy Dressers,369805,,updated,2026-08-09T15:47:09+00:00,
update,453845,148198716,Murder at the Manor,772660,,updated,2026-08-09T15:47:15+00:00,
update,329230,148198718,Bluffaneer,547518,,updated,2026-08-09T15:47:23+00:00,
update,358320,148198720,Sleeping Gods: Distant Skies,602661,,updated,2026-08-09T15:47:29+00:00,
update,389113,148198726,Rivals,662372,,updated,2026-08-09T15:47:34+00:00,
update,324937,148198729,Wiz-War (9th Edition),537230,,updated,2026-08-09T15:47:43+00:00,
update,295770,148198731,Frosthaven,484007,,updated,2026-08-09T15:47:49+00:00,
update,174430,148198734,Gloomhaven,520054,,updated,2026-08-09T15:47:55+00:00,
update,442312,148198737,Deadly Doodles,752105,,updated,2026-08-09T15:48:03+00:00,
update,332398,148198739,Everdell: The Complete Collection,553061,,updated,2026-08-09T15:48:12+00:00,
update,334065,148198740,Verdant,759453,,updated,2026-08-09T15:48:19+00:00,
update,283155,148198745,Calico,462049,,updated,2026-08-09T15:48:27+00:00,
update,291457,148198746,Gloomhaven: Jaws of the Lion,476060,,updated,2026-08-09T15:48:36+00:00,
update,173057,53430364,Go Fish Yourself,266044,,updated,2026-08-09T15:48:43+00:00,
update,150925,148198748,Hold Your Breath!,225659,,updated,2026-08-09T15:48:49+00:00,
update,2980,148198751,The Simpsons Trivia Game,138013,,updated,2026-08-09T15:48:54+00:00,
update,1197,148198753,Blink,78624,,updated,2026-08-09T15:49:03+00:00,
update,352819,148198874,The Office Trivia Game,591147,,updated,2026-08-09T15:49:08+00:00,
update,163412,148198877,Patchwork,495998,,updated,2026-08-09T15:49:20+00:00,
update,291847,148198878,Mantis Falls,476820,,updated,2026-08-09T15:49:29+00:00,
update,2058,148198880,Civilization: West Extension Map,28041,,updated,2026-08-09T15:49:34+00:00,
update,452833,148198886,Artistry: Delightful Doorways Mini Expansion,770724,,updated,2026-08-09T15:49:39+00:00,
update,104590,53430550,Castle Panic: The Wizard's Tower,117256,,updated,2026-08-09T15:49:45+00:00,
add,173634,,The Red Dragon Inn 5: The Character Trove,266954,,added,2026-08-09T16:24:23+00:00,
add,455834,,The Red Dragon Inn 10,791302,,added,2026-08-09T16:24:28+00:00,
add,33451,,The Red Dragon Inn 2,629314,,added,2026-08-09T16:24:35+00:00,
add,66510,,The Red Dragon Inn 3,629315,,added,2026-08-09T16:24:41+00:00,
add,142402,,The Red Dragon Inn 4,657689,,added,2026-08-09T16:24:48+00:00,
add,212662,,The Red Dragon Inn 6: Villains,367695,,added,2026-08-09T16:24:54+00:00,
add,244258,,The Red Dragon Inn 7: The Tavern Crew,421810,,added,2026-08-09T16:25:02+00:00,
add,330097,,The Red Dragon Inn 8: Pub Crawl,557415,,added,2026-08-09T16:25:08+00:00,
add,380868,,The Red Dragon Inn 9: The Undercity,800879,,added,2026-08-09T16:25:14+00:00,
add,302181,,The Red Dragon Inn: Allies Evil Pooky,629307,,added,2026-08-09T16:25:20+00:00,
add,174922,,The Red Dragon Inn: Allies Halden the Unhinged,657858,,added,2026-08-09T16:25:27+00:00,
add,442637,,The Red Dragon Inn: Allies Jin vs Greppa,752778,,added,2026-08-09T16:25:32+00:00,
add,403503,,The Red Dragon Inn: Allies Melvyn vs. Marah,712612,,added,2026-08-09T16:25:40+00:00,
add,125472,,The Red Dragon Inn: Allies Pooky,629302,,added,2026-08-09T16:25:47+00:00,
add,168857,,The Red Dragon Inn: Allies Wrench,657857,,added,2026-08-09T16:25:54+00:00,
add,291130,,Etherfields: 5th Player Expansion,475387,,added,2026-08-09T18:47:14+00:00,
add,355873,,Etherfields: Awakened Items & Actions,597861,,added,2026-08-09T18:47:22+00:00,
add,291131,,Etherfields: Funeral Witch Campaign,475388,,added,2026-08-09T18:47:30+00:00,
add,309202,,Etherfields: Search Mini Deck,507805,,added,2026-08-09T18:47:37+00:00,
add,381209,,Etherfields: Shifting Story Promo Deck,651213,,added,2026-08-09T18:47:46+00:00,
add,290840,,Etherfields: Sphinx Campaign,474978,,added,2026-08-09T18:47:54+00:00,
add,386805,,Etherfields: Story Dice Expansion,658656,,added,2026-08-09T18:48:00+00:00,
add,351544,,Etherfields: Stretch Goals Harpy & She-Wolf Campaigns,588185,,added,2026-08-09T18:48:08+00:00,
add,291126,,Etherfields: Thorn Knight,475383,,added,2026-08-09T18:48:17+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
118 update 165986 53429696 Royals 277137 updated 2026-08-09T15:42:48+00:00
119 update 40529 53429871 Cosmic Encounter 27692 updated 2026-08-09T15:42:56+00:00
120 update 157969 53430297 Sheriff of Nottingham 237864 updated 2026-08-09T15:43:07+00:00
121 update 207830 53429613 5-Minute Dungeon 344075 updated 2026-08-09T15:43:13+00:00
122 update 194655 53430612 Santorini 668826 updated 2026-08-09T15:43:19+00:00
123 update 161928 148198650 Utter Nonsense 245124 updated 2026-08-09T15:43:25+00:00
124 update 50381 148198655 Cards Against Humanity 134539 updated 2026-08-09T15:43:32+00:00
125 update 193621 53430387 Joking Hazard 300682 updated 2026-08-09T15:43:37+00:00
126 update 1269 148198659 Skip-Bo 29705 updated 2026-08-09T15:43:44+00:00
127 update 172225 53430108 Exploding Kittens 296012 updated 2026-08-09T15:43:50+00:00
128 update 202982 148198665 Scrawl 316090 updated 2026-08-09T15:43:58+00:00
129 update 2529 53429839 Flat Top 98655 updated 2026-08-09T15:44:04+00:00
130 update 483 49111776 Diplomacy 48522 updated 2026-08-09T15:44:11+00:00
131 update 26 53429831 Age of Renaissance 28607 updated 2026-08-09T15:44:21+00:00
132 update 97903 53430468 Rampage 231971 updated 2026-08-09T15:44:27+00:00
133 update 160477 53430375 Onitama 269392 updated 2026-08-09T15:44:34+00:00
134 update 195314 148198667 Herbaceous 525268 updated 2026-08-09T15:44:39+00:00
135 update 320202 148198669 Dragon Land 528008 updated 2026-08-09T15:44:43+00:00
136 update 2452 148198672 Jenga 288640 updated 2026-08-09T15:44:50+00:00
137 update 208428 53429689 No Thank You, Evil! 325455 updated 2026-08-09T15:44:58+00:00
138 update 42063 148198675 Code Master 191736 updated 2026-08-09T15:45:09+00:00
139 update 57215 53430150 Googly Eyes 295423 updated 2026-08-09T15:45:14+00:00
140 update 340420 148198677 Throw Throw Avocado 567183 updated 2026-08-09T15:45:19+00:00
141 update 139443 53430424 Superfight 202519 updated 2026-08-09T15:45:24+00:00
142 update 1260 148198681 Rook 156689 updated 2026-08-09T15:45:29+00:00
143 update 2197 53430503 Kensington 42495 updated 2026-08-09T15:45:35+00:00
144 update 116 148198685 Guillotine 28201 updated 2026-08-09T15:45:42+00:00
145 update 124548 53430526 The Doom That Came to Atlantic City 165214 updated 2026-08-09T15:45:48+00:00
146 update 169124 148198688 Flick 'em Up! 451577 updated 2026-08-09T15:45:54+00:00
147 update 2387 54518133 Huggermugger 91428 updated 2026-08-09T15:45:59+00:00
148 update 20866 148198690 The Rocky Horror Trivia Game 40025 updated 2026-08-09T15:46:07+00:00
149 update 196105 53430662 Speechless 304900 updated 2026-08-09T15:46:13+00:00
150 update 156994 53430578 Skylanders Portal Master 236280 updated 2026-08-09T15:46:18+00:00
151 update 269564 148198703 Captain Marvel: Secret Skrulls 439948 updated 2026-08-09T15:46:24+00:00
152 update 18755 148198705 Pie Face! 284056 updated 2026-08-09T15:46:30+00:00
153 update 166109 53429921 Meteor 253058 updated 2026-08-09T15:46:36+00:00
154 update 125675 53429970 Doctor Who: The Card Game 167657 updated 2026-08-09T15:46:41+00:00
155 update 128554 53429733 Völuspá 207529 updated 2026-08-09T15:46:46+00:00
156 update 822 53429625 Carcassonne 187468 updated 2026-08-09T15:46:53+00:00
157 update 201248 148198712 Evolution: The Beginning 313478 updated 2026-08-09T15:46:59+00:00
158 update 362205 148198714 Sleeping Gods: Primeval Peril 612448 updated 2026-08-09T15:47:05+00:00
159 update 220199 53430477 Snappy Dressers 369805 updated 2026-08-09T15:47:09+00:00
160 update 453845 148198716 Murder at the Manor 772660 updated 2026-08-09T15:47:15+00:00
161 update 329230 148198718 Bluffaneer 547518 updated 2026-08-09T15:47:23+00:00
162 update 358320 148198720 Sleeping Gods: Distant Skies 602661 updated 2026-08-09T15:47:29+00:00
163 update 389113 148198726 Rivals 662372 updated 2026-08-09T15:47:34+00:00
164 update 324937 148198729 Wiz-War (9th Edition) 537230 updated 2026-08-09T15:47:43+00:00
165 update 295770 148198731 Frosthaven 484007 updated 2026-08-09T15:47:49+00:00
166 update 174430 148198734 Gloomhaven 520054 updated 2026-08-09T15:47:55+00:00
167 update 442312 148198737 Deadly Doodles 752105 updated 2026-08-09T15:48:03+00:00
168 update 332398 148198739 Everdell: The Complete Collection 553061 updated 2026-08-09T15:48:12+00:00
169 update 334065 148198740 Verdant 759453 updated 2026-08-09T15:48:19+00:00
170 update 283155 148198745 Calico 462049 updated 2026-08-09T15:48:27+00:00
171 update 291457 148198746 Gloomhaven: Jaws of the Lion 476060 updated 2026-08-09T15:48:36+00:00
172 update 173057 53430364 Go Fish Yourself 266044 updated 2026-08-09T15:48:43+00:00
173 update 150925 148198748 Hold Your Breath! 225659 updated 2026-08-09T15:48:49+00:00
174 update 2980 148198751 The Simpsons Trivia Game 138013 updated 2026-08-09T15:48:54+00:00
175 update 1197 148198753 Blink 78624 updated 2026-08-09T15:49:03+00:00
176 update 352819 148198874 The Office Trivia Game 591147 updated 2026-08-09T15:49:08+00:00
177 update 163412 148198877 Patchwork 495998 updated 2026-08-09T15:49:20+00:00
178 update 291847 148198878 Mantis Falls 476820 updated 2026-08-09T15:49:29+00:00
179 update 2058 148198880 Civilization: West Extension Map 28041 updated 2026-08-09T15:49:34+00:00
180 update 452833 148198886 Artistry: Delightful Doorways Mini Expansion 770724 updated 2026-08-09T15:49:39+00:00
181 update 104590 53430550 Castle Panic: The Wizard's Tower 117256 updated 2026-08-09T15:49:45+00:00
182 add 173634 The Red Dragon Inn 5: The Character Trove 266954 added 2026-08-09T16:24:23+00:00
183 add 455834 The Red Dragon Inn 10 791302 added 2026-08-09T16:24:28+00:00
184 add 33451 The Red Dragon Inn 2 629314 added 2026-08-09T16:24:35+00:00
185 add 66510 The Red Dragon Inn 3 629315 added 2026-08-09T16:24:41+00:00
186 add 142402 The Red Dragon Inn 4 657689 added 2026-08-09T16:24:48+00:00
187 add 212662 The Red Dragon Inn 6: Villains 367695 added 2026-08-09T16:24:54+00:00
188 add 244258 The Red Dragon Inn 7: The Tavern Crew 421810 added 2026-08-09T16:25:02+00:00
189 add 330097 The Red Dragon Inn 8: Pub Crawl 557415 added 2026-08-09T16:25:08+00:00
190 add 380868 The Red Dragon Inn 9: The Undercity 800879 added 2026-08-09T16:25:14+00:00
191 add 302181 The Red Dragon Inn: Allies – Evil Pooky 629307 added 2026-08-09T16:25:20+00:00
192 add 174922 The Red Dragon Inn: Allies – Halden the Unhinged 657858 added 2026-08-09T16:25:27+00:00
193 add 442637 The Red Dragon Inn: Allies – Jin vs Greppa 752778 added 2026-08-09T16:25:32+00:00
194 add 403503 The Red Dragon Inn: Allies – Melvyn vs. Marah 712612 added 2026-08-09T16:25:40+00:00
195 add 125472 The Red Dragon Inn: Allies – Pooky 629302 added 2026-08-09T16:25:47+00:00
196 add 168857 The Red Dragon Inn: Allies – Wrench 657857 added 2026-08-09T16:25:54+00:00
197 add 291130 Etherfields: 5th Player Expansion 475387 added 2026-08-09T18:47:14+00:00
198 add 355873 Etherfields: Awakened Items & Actions 597861 added 2026-08-09T18:47:22+00:00
199 add 291131 Etherfields: Funeral Witch Campaign 475388 added 2026-08-09T18:47:30+00:00
200 add 309202 Etherfields: Search Mini Deck 507805 added 2026-08-09T18:47:37+00:00
201 add 381209 Etherfields: Shifting Story Promo Deck 651213 added 2026-08-09T18:47:46+00:00
202 add 290840 Etherfields: Sphinx Campaign 474978 added 2026-08-09T18:47:54+00:00
203 add 386805 Etherfields: Story Dice Expansion 658656 added 2026-08-09T18:48:00+00:00
204 add 351544 Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns 588185 added 2026-08-09T18:48:08+00:00
205 add 291126 Etherfields: Thorn Knight 475383 added 2026-08-09T18:48:17+00:00
+11
View File
@@ -0,0 +1,11 @@
name,opening
Etherfields,library-right-shelving-e1
Etherfields: Kittenburg Expansion,library-right-shelving-e1
Etherfields: 5th Player Expansion,library-right-shelving-e1
Etherfields: Funeral Witch Campaign,library-right-shelving-e1
Etherfields: Sphinx Campaign,library-right-shelving-e1
Etherfields: Stretch Goals Harpy & She-Wolf Campaigns,library-right-shelving-e1
Alice is Missing,library-right-shelving-2-d1
Alice is Missing: Silent Falls,library-right-shelving-2-d1
Parsely,library-right-shelving-2-d1
Dark Cults,library-right-shelving-2-d1
1 name opening
2 Etherfields library-right-shelving-e1
3 Etherfields: Kittenburg Expansion library-right-shelving-e1
4 Etherfields: 5th Player Expansion library-right-shelving-e1
5 Etherfields: Funeral Witch Campaign library-right-shelving-e1
6 Etherfields: Sphinx Campaign library-right-shelving-e1
7 Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns library-right-shelving-e1
8 Alice is Missing library-right-shelving-2-d1
9 Alice is Missing: Silent Falls library-right-shelving-2-d1
10 Parsely library-right-shelving-2-d1
11 Dark Cults library-right-shelving-2-d1
+131
View File
@@ -0,0 +1,131 @@
name,opening
Gloomhaven,A1
History of the World,A1
Age of Renaissance,A1
Simon's Cat Card Game,A1
Skip-Bo,A1
Snappy Dressers,A1
Tem-Purr-A,A1
Everdell: The Complete Collection,A2
Bugs in the Kitchen,A2
Risk: The Lord of the Rings,A2
The Cat Game,A2
The Rocky Horror Trivia Game,A2
Utter Nonsense,A2
Dixit,B1
Flick 'em Up!,B1
Googly Eyes,B1
Meteor,B1
Mysterium,B1
Sheriff of Nottingham,B1
Sheriff of Nottingham: Merry Men,B1
Speechless,B2
Superfight,B2
The Red Dragon Inn,B2
The Red Dragon Inn 5: The Character Trove,B2
The Red Dragon Inn Smorgasbox,B2
Throw Throw Avocado,B2
"Before I Kill You, Mister Spy...",B3
Bluffaneer,B3
Captain Marvel: Secret Skrulls,B3
Cards Against Humanity,B3
Code Master,B3
Farkle,B3
Fluxx,B3
Give Me the Brain!,B3
Go Fish Yourself,B3
Greed Quest,B3
Jenga,B3
Joking Hazard,B3
Kill Doctor Lucky,B3
Poetry for Neanderthals,B3
Rook,B3
Scrawl,B3
"...and then, we held hands.",B4
Consentacle,B4
Kensington,B4
Mage Wars Academy,B4
Onitama,B4
Patchwork,B4
5-Minute Dungeon,C1
A Gentle Rain,C1
Artistry,C1
Artistry: Delightful Doorways Mini Expansion,C1
Blink,C1
Botany,C1
Botany: Perilous Perfumes,C1
Botany: Tantalizing Trees,C1
Calico,C1
Carcassonne,C1
Castle Panic,C1
Castle Panic: The Dark Titan,C1
Castle Panic: The Wizard's Tower,C1
CAT CRIMES,C1
CAT STAX,C2
Catan,C2
Catan Dice Game,C2
Catan: Junior,C2
Cosmic Encounter,C2
Dastardly Dirigibles,C2
Deadly Doodles,C2
Doctor Who: The Card Game,C2
Doctor Who: The Game of Time & Space,C2
Dragon Land,C2
Dungeon!,C2
Elite Sportz Equipment Table Top Curling,C2
Evolution: The Beginning,C2
Exploding Kittens,C2
Forbidden Island,C3
GRAVITY MAZE,C3
Guillotine,C3
Herbaceous,C3
Hold Your Breath!,C3
Huggermugger,C3
Labyrinth,C3
LASER MAZE BEAM-BENDING LOGIC GAME,C3
Machi Koro,C3
Mantis Falls,C3
Munchkin,C3
Munchkin Big Box,C3
Murder at the Manor,C3
"No Thank You, Evil!",C3
Ouija,C4
Pie Face!,C4
Rivals,C4
Robot Turtles,C4
Royals,C4
Santorini,C4
Scrabble Slam!,C4
Skylanders Portal Master,C4
Small World,C4
Sorcerer: The Game of Magical Conflict,C4
SPANC: Space Pirate Amazon Ninja Catgirls,C4
Super Mario Checkers Collector's Edition,C4
Tang Garden,C4
Terror in Meeple City,D1
The Doom That Came to Atlantic City,D1
The Office Trivia Game,D1
The Simpsons Trivia Game,D1
Ticket to Ride,D1
Verdant,D1
Völuspá,D1
Walk the Plank!,D1
Wiz-War,D1
Wiz-War (Eighth Edition),D1
Wiz-War (9th Edition),D1
Advanced Civilization,D2
Britannia,D2
Civilization,D2
Civilization: West Extension Map,D2
Diplomacy,D2
Flat Top,D2
StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century,D2
Agricola,D3
Etherfields,D3
Etherfields: Kittenburg Expansion,D3
Gloomhaven: Buttons & Bugs,D3
Gloomhaven: Jaws of the Lion,D3
Sleeping Gods,D4
Sleeping Gods: Distant Skies,D4
Sleeping Gods: Primeval Peril,D4
Sleeping Gods: Tides of Ruin,D4
1 name opening
2 Gloomhaven A1
3 History of the World A1
4 Age of Renaissance A1
5 Simon's Cat Card Game A1
6 Skip-Bo A1
7 Snappy Dressers A1
8 Tem-Purr-A A1
9 Everdell: The Complete Collection A2
10 Bugs in the Kitchen A2
11 Risk: The Lord of the Rings A2
12 The Cat Game A2
13 The Rocky Horror Trivia Game A2
14 Utter Nonsense A2
15 Dixit B1
16 Flick 'em Up! B1
17 Googly Eyes B1
18 Meteor B1
19 Mysterium B1
20 Sheriff of Nottingham B1
21 Sheriff of Nottingham: Merry Men B1
22 Speechless B2
23 Superfight B2
24 The Red Dragon Inn B2
25 The Red Dragon Inn 5: The Character Trove B2
26 The Red Dragon Inn Smorgasbox B2
27 Throw Throw Avocado B2
28 Before I Kill You, Mister Spy... B3
29 Bluffaneer B3
30 Captain Marvel: Secret Skrulls B3
31 Cards Against Humanity B3
32 Code Master B3
33 Farkle B3
34 Fluxx B3
35 Give Me the Brain! B3
36 Go Fish Yourself B3
37 Greed Quest B3
38 Jenga B3
39 Joking Hazard B3
40 Kill Doctor Lucky B3
41 Poetry for Neanderthals B3
42 Rook B3
43 Scrawl B3
44 ...and then, we held hands. B4
45 Consentacle B4
46 Kensington B4
47 Mage Wars Academy B4
48 Onitama B4
49 Patchwork B4
50 5-Minute Dungeon C1
51 A Gentle Rain C1
52 Artistry C1
53 Artistry: Delightful Doorways Mini Expansion C1
54 Blink C1
55 Botany C1
56 Botany: Perilous Perfumes C1
57 Botany: Tantalizing Trees C1
58 Calico C1
59 Carcassonne C1
60 Castle Panic C1
61 Castle Panic: The Dark Titan C1
62 Castle Panic: The Wizard's Tower C1
63 CAT CRIMES C1
64 CAT STAX C2
65 Catan C2
66 Catan Dice Game C2
67 Catan: Junior C2
68 Cosmic Encounter C2
69 Dastardly Dirigibles C2
70 Deadly Doodles C2
71 Doctor Who: The Card Game C2
72 Doctor Who: The Game of Time & Space C2
73 Dragon Land C2
74 Dungeon! C2
75 Elite Sportz Equipment Table Top Curling C2
76 Evolution: The Beginning C2
77 Exploding Kittens C2
78 Forbidden Island C3
79 GRAVITY MAZE C3
80 Guillotine C3
81 Herbaceous C3
82 Hold Your Breath! C3
83 Huggermugger C3
84 Labyrinth C3
85 LASER MAZE BEAM-BENDING LOGIC GAME C3
86 Machi Koro C3
87 Mantis Falls C3
88 Munchkin C3
89 Munchkin Big Box C3
90 Murder at the Manor C3
91 No Thank You, Evil! C3
92 Ouija C4
93 Pie Face! C4
94 Rivals C4
95 Robot Turtles C4
96 Royals C4
97 Santorini C4
98 Scrabble Slam! C4
99 Skylanders Portal Master C4
100 Small World C4
101 Sorcerer: The Game of Magical Conflict C4
102 SPANC: Space Pirate Amazon Ninja Catgirls C4
103 Super Mario Checkers Collector's Edition C4
104 Tang Garden C4
105 Terror in Meeple City D1
106 The Doom That Came to Atlantic City D1
107 The Office Trivia Game D1
108 The Simpsons Trivia Game D1
109 Ticket to Ride D1
110 Verdant D1
111 Völuspá D1
112 Walk the Plank! D1
113 Wiz-War D1
114 Wiz-War (Eighth Edition) D1
115 Wiz-War (9th Edition) D1
116 Advanced Civilization D2
117 Britannia D2
118 Civilization D2
119 Civilization: West Extension Map D2
120 Diplomacy D2
121 Flat Top D2
122 StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century D2
123 Agricola D3
124 Etherfields D3
125 Etherfields: Kittenburg Expansion D3
126 Gloomhaven: Buttons & Bugs D3
127 Gloomhaven: Jaws of the Lion D3
128 Sleeping Gods D4
129 Sleeping Gods: Distant Skies D4
130 Sleeping Gods: Primeval Peril D4
131 Sleeping Gods: Tides of Ruin D4
+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.
+256
View File
@@ -0,0 +1,256 @@
# 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
```
Eight pages — Pipeline, Photos, Titles, Review, Queue, Library, Shelves, 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`
## Where everything lives
The **Shelves** page maps your physical storage and draws it as it really
stands: openings group into rows by their label letters, and each cell's
width is proportional to its interior width, so a Kallax with a double-wide
top row renders as exactly that — a diagram of the wall, not a list. Create
units from presets (IKEA Kallax cube 13.25″ × 13.25″ × 15.4″, Billy shelf,
custom, or a no-size virtual spot like "travel case") as grids of rows ×
columns; add more sections to an existing unit the same way — a later grid
continues the row letters, so 3 × 4 under an A row lands as B1…D4. Duplicate a
unit to stamp out identical furniture — the copy takes the structure
(openings, sizes, zones, descriptions) under a fresh auto-numbered name,
never the games — and rename any unit in place. Openings
are editable (dimensions are all-or-none: three numbers or a no-limit
spot), reorderable within their row (moves clamp at row boundaries so the
diagram can't fragment), and deletable. Both units and openings take a
free-text **description** alongside the **zone** keyword — the label is
the address, the zone matches, the description explains. Everything lands
in `data/furniture.json` + `data/locations.json` — committed local stores;
your shelf layout is never sent anywhere.
Assigning games: every measured unshelved game shows up to three suggested
openings, ranked by a strict relevance doctrine. An opening already holding
a **series-mate or base game ranks first** (marked ♥) — kinship is the
pre-colon name stem plus BGG's series field, no taxonomy to maintain, so
every placement you confirm teaches the suggester your organization and
expansions chase their base games automatically. "Fits" always means the
**remaining** opening — stack budget already spent is subtracted — and
after that, tightest verified fit, so small boxes are offered cubes and
never the oversize row. A measured game with nowhere suitable says
"no matching openings yet"; an unmeasured game gets no suggestions at all,
because can't-verify is not the same as fits. You can also assign by
searching inside an opening, or from a game's detail page (openings
grouped by unit, each labeled fits / doesn't fit / can't verify). Games
stored inside another box inherit their container's spot — through whole
chains (minis in an insert in a big box live where the big box does) —
and the space view lists only the physical boxes: contents collapse into
a "holds N inside" hint on their container's row (hover for names; the
full list lives on the container's game page). On a
phone over `--lan`, the opening view docks as a bottom sheet — the
moving-day loop is search, tap, done.
When you publish with `bggpipe export`, the shelves come too: game pages
say where each box lives, containers list their contents, and a shelves
page draws the wall as it physically stands, every opening linking its
residents. Publishing your shelf layout is a choice — `--no-shelves`
keeps the whole layer out of the export.
Bulk-load a reviewed plan with `bggpipe shelve --import plan.csv` (columns
`name,opening`, accepting opening ids or labels) — rejects are reported
(unknown name, ambiguous copies, unknown opening, doesn't fit, lives
inside another box), never guessed. Re-importing is an upsert, not a
reset: rows move their games, games absent from the plan keep their
spots, and hand-entered notes, lane choices, and acknowledgments survive
whenever the opening is unchanged.
The packer is deliberately conservative — one flat pile plus one
standing row per opening — and real shelves are 3-D: boxes sit
crosswise, ride on top of piles, stand proud of a shallow shelf. When
the model refuses a placement you can see with your own eyes, overrule
it: the **it's here anyway** button in the opening's view (or an
`acknowledge` column, `yes`, in the import CSV) confirms the box is
really there. An acknowledged box takes a lane when one works — the
spine-out overhang case lands "standing, proud", skipping only the
depth check — and when even that fails it's marked **wedged in**:
really there, outside the capacity bars, never warned about. Tap
"here ✓" to retract. Assignment and capacity are separate truths:
a game listed in an opening IS there regardless of what the packer
thinks of the arrangement.
Where a game *lives* and where it *is* can differ — the travel case,
a friend's house. Every game's detail page has a **Right now** field:
type "travel case" or "loaned to Ben" and the box is marked out
everywhere (an "out:" chip in its home opening, on the export's game
page too) while its home spot stays reserved — the shelf math doesn't
change because a loan isn't a move. Contents ride along: games stored
inside an out box read as out with it. Blank the field and it's back;
nothing else is touched.
Unmeasured games can't join the capacity math at all — so measure
them. The "unmeasured — measure it" button in the opening's view takes
width × length × depth in inches, saved to `data/measurements.json`
(committed, like all curation). Your tape measure outranks BGG: hand
measurements overlay whatever enrich found, everywhere sizes are used
(fit checks, suggestions, `bggpipe dims`, the export). Save with all
three fields empty to clear one.
An acknowledged box takes the standing lane without the depth check —
the width budget stays honest — and stops counting as a warning. Once
acknowledged, later imports needn't re-state it. `bggpipe dims` reports per-opening capacity, overfull warnings, and
misfits against your real furniture. Capacity uses a **two-lane packing
model**: each opening holds a flat stack (thinnest axes summing against
interior height, claiming the widest flat box's width) and a standing lane
beside it (boxes on edge against the remaining width) — the largest boxes
lie flat, smaller ones stand, and you can flip any box's lane in the
opening's view. An opening warns only when *no* packing fits. Unmeasured
boxes are counted and shown, never silently assumed to fit.
## Shelf-space planning
Physical box dimensions live on BGG *versions*, not games, so enrich collects
them in a second cached pass: your exact version's numbers where you chose
one, otherwise a value only when every listed printing agrees (within half an
inch per axis — disagreement is stored as `conflicting`, never guessed, and
BGG's `0` means "never entered", not zero inches). Then:
```sh
bggpipe dims
```
reports coverage, the ten biggest footprints, and per-opening fit checks
against your own furniture (see *Where everything lives*) — capacity,
overfull warnings, boxes that fit in **no** orientation, and every game whose
dimensions can't be verified, because can't-verify is not the same as fits.
Trusted dimensions also show on each game's Library detail page.
## Publishing your library on your own site
```sh
bggpipe export --out ~/my-site/static/library --title "My Game Shelves"
```
Writes the library as self-contained static pages: an index with search, one
page per game, and a local copy of every cover (public pages must not hotlink
BGG's image CDN). No server, no build step — drop the directory into any
static host, Hugo/Jekyll `static/` folder included. Re-runs are idempotent:
already-downloaded covers are kept, failed downloads are retried, and page
URLs stay stable.
What's included follows a privacy rule: cover art, stats, your editions, and
your hand-added local games — **never your shelf photos** (they picture the
inside of your home; they stay in the private app).
BGG's API policy asks public-facing apps to display the "Powered by BGG"
badge. The footer carries a text badge automatically; to show the official
logo, save it from your registered application page at
[boardgamegeek.com/applications](https://boardgamegeek.com/applications) as
`data/powered-by-bgg.png` and re-export.
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

+8 -3
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,9 +15,10 @@ 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.
- Image-CDN carve-out (adjudicated 2026-08-06): cover downloads from BGG's image CDN (`cf.geekdo-images.com`) during `export` pace at ≥1 second apart, fetch only files not already on disk, and re-fetch a file only when its source URL changes. The CDN is not the API, but it is still BGG's infrastructure — the spacing is mandatory, not advisory.
- 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.
- Vision extraction uses the **Anthropic API** (Claude with vision). Assume `ANTHROPIC_API_KEY` in the environment. - Vision extraction uses the **Anthropic API** (Claude with vision). Assume `ANTHROPIC_API_KEY` in the environment.
- Runs on macOS. Prefer **Python 3.12+** with `uv` for dependency management. Browser automation via **Playwright** (not Selenium). - Runs on macOS. Prefer **Python 3.12+** with `uv` for dependency management. Browser automation via **Playwright** (not Selenium).
@@ -88,7 +93,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.
+52
View File
@@ -0,0 +1,52 @@
# A tour of bggpipe
Eight pages in one local app — Pipeline, Photos, Titles, Review, Queue, Library, Shelves, 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)
**Shelves** — your furniture drawn as it stands: units render their physical arrangement (a double-wide top row spans the top), with fill bars, zones, and a suggestion engine that learns your organization from every placement you confirm.
![Shelves page: the add-a-unit form with presets above a Library Kallax unit drawn as its real arrangement — two double-wide openings spanning the top, three rows of four cubes beneath, each with zone label and fill bar](screenshots/17-shelves.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.1.0"
+76
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,
@@ -171,3 +191,59 @@ def enrich(
cfg = load_config(config) cfg = load_config(config)
run_enrich(cfg, refresh=refresh) run_enrich(cfg, refresh=refresh)
@app.command()
def shelve(
import_csv: Annotated[
Path,
typer.Option("--import", help="CSV of name,opening (ids or labels)"),
],
config: ConfigOpt = None,
) -> None:
"""Bulk-assign games to shelf openings from a CSV plan."""
from bggpipe.shelves import import_assignments
cfg = load_config(config)
result = import_assignments(cfg, import_csv)
if result["rejects"]:
raise typer.Exit(code=1)
@app.command()
def dims(config: ConfigOpt = None) -> None:
"""Shelf-space report: box sizes, biggest footprints, per-opening fit."""
from bggpipe.dims import run_dims_report
cfg = load_config(config)
run_dims_report(cfg)
@app.command()
def export(
out: Annotated[
Path,
typer.Option("--out", help="Directory to write the static site into"),
],
title: Annotated[
str,
typer.Option("--title", help="Site title shown on every page"),
] = "My Game Library",
no_shelves: Annotated[
bool,
typer.Option(
"--no-shelves",
help="Omit the shelves page and locations (publishing your "
"shelf layout is a choice)",
),
] = False,
config: ConfigOpt = None,
) -> None:
"""Export the library as self-contained static pages (for a blog etc.)."""
from bggpipe.export import run_export
cfg = load_config(config)
summary = run_export(cfg, out, title=title, include_shelves=not no_shelves)
if summary["failures"]:
# scripted `export && rsync` must not publish an incomplete site
raise typer.Exit(code=1)
+112 -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,71 @@ class Config:
def games_path(self) -> Path: def games_path(self) -> Path:
return self.data_dir / "games.json" return self.data_dir / "games.json"
@property
def measurements_path(self) -> Path:
return self.data_dir / "measurements.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 furniture_path(self) -> Path:
# storage units and their openings — the user's shelf layout is
# local-only data (never BGG's business), committed like the
# other curation stores
return self.data_dir / "furniture.json"
@property
def locations_path(self) -> Path:
# game key -> {opening_id, note}: where each box physically lives
return self.data_dir / "locations.json"
@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 @property
def title_removals_path(self) -> Path: def title_removals_path(self) -> Path:
# titles the human removed from the catalog (not a game, misread) — # titles the human removed from the catalog (not a game, misread) —
@@ -95,6 +169,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
@@ -125,16 +205,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 "
+172
View File
@@ -0,0 +1,172 @@
"""Shelf-space report over the box dimensions enrich collected.
Reads games.json and the local furniture/location stores — fully
offline. Fit checks run against the openings the user actually owns
(data/furniture.json); with no furniture on file the report covers
sizes only and says where to set shelves up. "Can't verify" is
reported as exactly that, never as a fit.
"""
from __future__ import annotations
import json
import typer
from bggpipe.config import Config
from bggpipe.models import DIM_AXES
from bggpipe.shelves import (
apply_measurements,
load_furniture,
load_locations,
opening_index,
opening_report,
)
_AXES = DIM_AXES
def _footprint(dims: dict) -> float:
"""The two largest axes multiplied — the shelf area a box claims
lying in its flattest orientation."""
axes = sorted((dims[a] for a in _AXES), reverse=True)
return axes[0] * axes[1]
def run_dims_report(cfg: Config) -> dict:
if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1)
games: dict[str, dict] = apply_measurements(
cfg, json.loads(cfg.games_path.read_text())
)
contained = [
entry.get("name") or "?" for entry in games.values() if entry.get("stored_in")
]
by_source: dict[str, list[tuple[str, dict]]] = {}
for entry in games.values():
if entry.get("stored_in"):
continue # lives inside another box: no shelf space of its own
dims = entry.get("dims") or dict.fromkeys(_AXES, None) | {"source": "absent"}
by_source.setdefault(dims["source"], []).append(
(entry.get("name") or "?", dims)
)
typer.echo(f"Box dimensions across {len(games)} game(s):")
if contained:
typer.echo(
f" {len(contained):>4} stored inside another box — no shelf "
"space of their own"
)
for source, label in (
("version", "from the exact owned version"),
("unanimous", "every listed version agrees"),
("conflicting", "versions disagree — not guessed"),
("absent", "no data on BGG (or no BGG entry)"),
):
if by_source.get(source):
typer.echo(f" {len(by_source[source]):>4} {source}{label}")
measured = sorted(
(
(name, dims)
for source in ("version", "unanimous")
for name, dims in by_source.get(source, [])
),
key=lambda item: _footprint(item[1]),
reverse=True,
)
if measured:
typer.echo("\nBiggest footprints (two largest axes, flattest lie):")
for name, dims in measured[:10]:
w, length, d = (dims[a] for a in _AXES)
typer.echo(
f" {_footprint(dims):>6.1f} sq in {name}"
f" ({w:g} × {length:g} × {d:g} in)"
)
unknown = [
name
for source in ("conflicting", "absent")
for name, _ in by_source.get(source, [])
]
units = load_furniture(cfg)
misfit_names: list[str] = []
if not units:
typer.echo(
"\nNo furniture on file — set up your shelves on the Shelves "
"page (bggpipe web) to get per-opening fit checks."
)
else:
locations = load_locations(cfg)
lane_overrides = {
k: v.get("lane", "") for k, v in locations.items() if v.get("lane")
}
acked = {k for k, v in locations.items() if v.get("acknowledged")}
openings = opening_index(units)
by_opening: dict[str, list[tuple[str, dict]]] = {}
for key, entry in games.items():
oid = (locations.get(key) or {}).get("opening_id")
if oid:
by_opening.setdefault(oid, []).append((key, entry))
typer.echo("\nOpenings:")
for oid, opening in openings.items():
report = opening_report(
opening, by_opening.get(oid, []), lane_overrides, acked
)
sized = all(opening.get(k) for k in ("width_in", "height_in", "depth_in"))
dims_note = (
f"{opening['width_in']:g}×{opening['height_in']:g}"
f"×{opening['depth_in']:g} in"
if sized
else "no size limits"
)
line = (
f" {opening['unit']} · {opening['label']}"
f" ({dims_note}): {report['games']} game(s)"
)
if report["flat_in"]:
line += f', {report["flat_in"]:g}" flat stack'
if report["standing_in"]:
line += f', {report["standing_in"]:g}" standing'
if report["unmeasured"]:
line += f", {report['unmeasured']} unmeasured"
if report["overfull"]:
line += " ⚠ NO PACKING FITS"
typer.echo(line)
for name in report["laneless"]:
misfit_names.append(name)
typer.echo(f" ⚠ no lane can take: {name}")
for name in report["misfits"]:
if name not in misfit_names:
misfit_names.append(name)
typer.echo(f" ⚠ doesn't fit this opening at all: {name}")
for k, e in by_opening.get(oid, []):
if k in acked:
typer.echo(f" here anyway (yours): {e.get('name') or '?'}")
homeless = [
entry.get("name") or "?"
for key, entry in games.items()
if not entry.get("stored_in")
# an assignment to a vanished opening is homeless too — a
# truthy id must not hide a game from the count
and (locations.get(key) or {}).get("opening_id") not in openings
]
if homeless:
typer.echo(f"\n{len(homeless)} game(s) unshelved.")
if unknown:
typer.echo(
f"\n{len(unknown)} game(s) can't be size-verified (no trusted "
"dimensions — not the same as fitting):"
)
for name in sorted(unknown):
typer.echo(f" {name}")
return {
"by_source": {s: len(v) for s, v in by_source.items()},
"misfits": misfit_names,
"unknown": sorted(unknown),
"contained": sorted(contained),
}
+200 -5
View File
@@ -27,11 +27,104 @@ 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 (
from bggpipe.resolve import read_matches DIM_AXES,
is_confident_version,
is_recognized,
parse_version_dims,
)
from bggpipe.normalize import normalize_title
from bggpipe.resolve import load_titles, read_matches
BATCH_SIZE = 20 BATCH_SIZE = 20
_AXES = DIM_AXES
_AGREE_IN = 0.5 # per-axis tolerance for calling versionless dims unanimous
def _entry_dims(entry: dict, records: list[dict]) -> dict:
"""The box-dimensions verdict for one game, from its versions' data.
Known version: that exact version's numbers or nothing. No chosen
version: only a chorus that AGREES (within 0.5" per axis, all axes
present) counts — the maximum per axis is kept, since the planning
question is "will it fit", and disagreement beyond tolerance stores
nulls rather than a guess."""
version_id = (entry.get("version") or {}).get("version_id")
if version_id:
rec = next((r for r in records if r["version_id"] == version_id), None)
if rec and all(rec[a] for a in _AXES):
return {
**{a: rec[a] for a in _AXES},
"weight_lb": rec["weight_lb"],
"source": "version",
}
return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
complete = [r for r in records if all(r[a] for a in _AXES)]
if not complete:
return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
dims: dict = {}
for axis in _AXES:
values = [r[axis] for r in complete]
if max(values) - min(values) > _AGREE_IN:
return dict.fromkeys((*_AXES, "weight_lb"), None) | {
"source": "conflicting"
}
dims[axis] = max(values)
weights = [r["weight_lb"] for r in complete if r["weight_lb"]]
dims["weight_lb"] = (
max(weights) if weights and max(weights) - min(weights) <= 0.5 else None
)
return dims | {"source": "unanimous"}
def _dims_pass(games: dict, client: BGGClient, *, refresh: bool) -> tuple[int, bool]:
"""Fill entry["dims"] for every BGG-backed game from versions=1 data
(physical dimensions live on VERSIONS, not games). Cached XML makes
re-runs free; rpgitems and local games get source "absent"."""
need_ids = sorted(
{
e["bgg_id"]
for e in games.values()
if e.get("type") in ("boardgame", "boardgameexpansion")
and e.get("bgg_id")
and (refresh or "dims" not in e)
}
)
by_game: dict[int, list[dict]] = {}
blocked = False
for start in range(0, len(need_ids), BATCH_SIZE):
batch = need_ids[start : start + BATCH_SIZE]
try:
xml = client.get_xml(
"thing",
{"id": ",".join(str(i) for i in batch), "versions": "1"},
refresh=refresh,
)
except (BGGAuthError, BGGQueueTimeout):
blocked = True
break
by_game.update(parse_version_dims(xml))
filled = 0
for entry in games.values():
if not (refresh or "dims" not in entry):
continue
if entry.get("bgg_id") in by_game:
entry["dims"] = _entry_dims(entry, by_game[entry["bgg_id"]])
if entry["dims"]["source"] == "version" and entry.get("version"):
entry["version"].update(
{k: entry["dims"][k] for k in (*_AXES, "weight_lb")}
)
filled += 1
elif entry.get("bgg_id") is None or entry.get("type") not in (
"boardgame",
"boardgameexpansion",
):
entry["dims"] = dict.fromkeys((*_AXES, "weight_lb"), None) | {
"source": "absent"
}
return filled, blocked
def _version_info(row: dict) -> dict | None: def _version_info(row: dict) -> dict | None:
if not is_confident_version(row): if not is_confident_version(row):
@@ -95,12 +188,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]
@@ -109,15 +276,43 @@ def run_enrich(
f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}" f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}"
) )
# containment is row-level curation: carry it onto entries every run
# so the library and the shelf math both know what shares a box
stored_by_key = {}
for row in rows:
if is_recognized(row) and row.get("stored_in"):
version = _version_info(row)
key = (
f"{row['bgg_id']}:{version['version_id']}" if version else row["bgg_id"]
)
stored_by_key[key] = row["stored_in"]
for key, entry in games.items():
entry["stored_in"] = stored_by_key.get(key)
# box dimensions live on VERSIONS, not games: a second cached pass
# (read-only; upload is untouched) fills entry["dims"] for shelf math
dims_filled, dims_blocked = _dims_pass(games, client, refresh=refresh)
blocked = blocked or dims_blocked
games_path.parent.mkdir(parents=True, exist_ok=True) games_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_text( atomic_write_text(
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 dims_filled:
parts.append(f"box dims checked for {dims_filled}")
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]
+777
View File
@@ -0,0 +1,777 @@
"""Export the library as self-contained static pages for any web host.
Reads games.json (the enrich output) and writes an index page, one page
per game, and a local copy of every cover image — no server, no build
step, no external requests from the published pages. Cover art is
downloaded once from BGG's CDN (public pages must not hotlink it) and
re-runs skip images already on disk, so the export is idempotent and
resumable like every other stage.
Shelf photos are deliberately NOT exported: they picture the inside of
the owner's home. Covers and hand-added local art only.
"""
from __future__ import annotations
import html
import json
import re
import shutil
import time
from pathlib import Path
from urllib.parse import urlsplit
import httpx
import typer
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_bytes, atomic_write_text
from bggpipe.shelves import (
apply_measurements,
effective_away,
effective_location,
load_furniture,
load_locations,
opening_index,
)
# records which slugs THIS export owns, and their key assignments: slug
# stability across runs (a published URL never changes or is stolen) and
# stale-page cleanup (only ever deletes what the manifest claims) both
# hang off it
MANIFEST_NAME = ".bggpipe-export.json"
_SLUG_RE = re.compile(r"[^a-z0-9]+")
# BGG's API policy: public-facing apps must display the "Powered by BGG"
# logo linking back. The official asset comes from the owner's registered
# application page — save it as data/powered-by-bgg.png and the export
# ships the image badge; until then, a text badge keeps the link.
BADGE_NAME = "powered-by-bgg.png"
def _powered_by(rel: str, badge_present: bool) -> str:
inner = (
f'<img src="{rel}art/{BADGE_NAME}" alt="Powered by BGG" height="40">'
if badge_present
else "Powered by BGG"
)
return f'<a class="pbb" href="https://boardgamegeek.com" rel="noopener">{inner}</a>'
def _slug(name: str) -> str:
return _SLUG_RE.sub("-", (name or "game").casefold()).strip("-") or "game"
def _slugs(games: dict[str, dict], prior: dict[str, str]) -> dict[str, str]:
"""Deterministic, unique page names that survive re-export. A key seen
before KEEPS its slug (a published URL never moves, even after a
rename), and a new key can never steal one — collisions take -2, -3...
in sorted-key order."""
out: dict[str, str] = {k: v for k, v in prior.items() if k in games}
# "art" is the asset directory and "shelves" is the wall page: games
# named Art or Shelves must not move in
taken: set[str] = set(out.values()) | {"art", "shelves"}
for key in sorted(games):
if key in out:
continue
base = _slug(games[key].get("name", ""))
slug, n = base, 2
while slug in taken:
slug = f"{base}-{n}"
n += 1
taken.add(slug)
out[key] = slug
return out
_IMAGE_MAGIC = (b"\xff\xd8", b"\x89PNG", b"GIF8", b"RIFF", b"<svg", b"<?xml")
def _looks_like_image(body: bytes) -> bool:
return len(body) > 64 and body.lstrip()[:5].startswith(_IMAGE_MAGIC)
def _art_name(key: str, url: str, *, thumb: bool) -> str:
ext = Path(urlsplit(url).path).suffix.lower() or ".jpg"
safe = _SLUG_RE.sub("-", key.casefold()).strip("-")
return f"{safe}-thumb{ext}" if thumb else f"{safe}{ext}"
def _fetch_art(
games: dict[str, dict],
art_dir: Path,
cfg: Config,
fetch: httpx.Client,
sleep,
prior_urls: dict[str, dict[str, str]] | None = None,
) -> tuple[dict[str, dict[str, str]], int, list[str], dict[str, dict[str, str]]]:
"""Local filename per game for thumb/full art. Downloads only what is
missing; a failed download is reported and the page ships without
that image — never a broken half-written file."""
art: dict[str, dict[str, str]] = {}
urls: dict[str, dict[str, str]] = {}
prior_urls = prior_urls or {}
downloaded = 0
failures: list[str] = []
art_dir.mkdir(parents=True, exist_ok=True)
for key, game in games.items():
entry: dict[str, str] = {}
image = game.get("image") or ""
thumb = game.get("thumbnail") or ""
if image.startswith("/local-art/"):
# hand-added cover: already on disk, just carried across.
# local_art is the ONLY source for these — a missing file is a
# failure to report, not a silent coverless page
src = cfg.local_art_dir / Path(image).name
if src.exists():
atomic_write_bytes(art_dir / src.name, src.read_bytes())
entry["full"] = entry["thumb"] = src.name
else:
failures.append(
f"{game.get('name', key)}: hand-added cover "
f"{src.name} missing from {cfg.local_art_dir}"
)
else:
for url, is_thumb in ((image, False), (thumb, True)):
if not url.startswith("http"):
continue
name = _art_name(key, url, thumb=is_thumb)
target = art_dir / name
slot = "thumb" if is_thumb else "full"
urls.setdefault(key, {})[slot] = url
# BGG replaces box art under the SAME extension: an existing
# file only counts if it came from this URL (absent record =
# grandfathered pre-manifest download, assumed current)
recorded = prior_urls.get(key, {}).get(slot)
if not target.exists() or (recorded and recorded != url):
try:
resp = fetch.get(url)
resp.raise_for_status()
except httpx.HTTPError as err:
failures.append(f"{game.get('name', key)}: {err}")
continue
if not _looks_like_image(resp.content):
# a 200 with an interstitial/error body must not be
# cached as a cover: exists() would skip it forever
failures.append(
f"{game.get('name', key)}: response is not an image"
)
continue
atomic_write_bytes(target, resp.content)
downloaded += 1
sleep(1.0) # image-CDN spacing per docs/spec.md
entry[slot] = name
if "thumb" not in entry and "full" in entry:
entry["thumb"] = entry["full"]
art[key] = entry
return art, downloaded, failures, urls
_CSS = """
:root { --board:#faf3e3; --ink:#2a2438; --ink-soft:#6b6478; --line:#d8cdb4;
--sky:#cfe4f5; --navy:#2f3d5c; --accent:#7a4a9e;
/* on the sky background, --ink-soft misses WCAG AA (4.32:1); this
darker variant clears it — use it for any fine print outside a card */
--ink-soft-on-sky:#5d5669; }
* { box-sizing:border-box; }
body { margin:0; font:16px/1.55 system-ui, sans-serif; color:var(--ink);
background:var(--sky); }
main { max-width:72rem; margin:0 auto; padding:1.2rem; }
header.site { background:var(--navy); color:#fff; padding:.9rem 1.2rem; }
header.site a { color:#fff; text-decoration:none; font-weight:700; }
header.site .sub { color:#cfe4f5; font-size:.85rem; }
.controls { display:flex; gap:.5rem; flex-wrap:wrap; margin:1rem 0; }
.controls input[type=search] { flex:1 1 14rem; padding:.45rem .6rem;
border:2px solid var(--line); border-radius:.5rem; font:inherit; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11.5rem,1fr));
gap:1rem; }
a.game { background:var(--board); border:1px solid var(--line);
border-radius:.8rem; overflow:hidden; text-decoration:none; color:inherit;
display:flex; flex-direction:column; box-shadow:0 2px 5px rgba(42,36,56,.15); }
a.game:hover, a.game:focus-visible { outline:3px solid var(--accent); }
a.game img { width:100%; aspect-ratio:1; object-fit:cover; display:block;
background:#e8ddc5; }
a.game .noart { width:100%; aspect-ratio:1; display:flex; align-items:center;
justify-content:center; font-size:3rem; font-weight:700; color:var(--navy);
background:#e8ddc5; }
a.game .info, .colophon .info { padding:.55rem .7rem .7rem; }
.colophon { background:var(--board); border:1px solid var(--line);
border-radius:.8rem; overflow:hidden; display:flex; flex-direction:column;
box-shadow:0 2px 5px rgba(42,36,56,.15); }
.colophon > img { width:100%; aspect-ratio:1; object-fit:cover; display:block;
background:#cfe4f5; }
.colophon .meta { margin:.35rem 0 0; font-size:.72rem; line-height:1.45; }
.colophon a { color:var(--accent); }
.gname { font-weight:700; }
.meta { color:var(--ink-soft); font-size:.82rem; }
main > .meta { color:var(--ink-soft-on-sky); }
.chip { display:inline-block; font-size:.7rem; border:1px solid var(--accent);
color:var(--accent); border-radius:1rem; padding:0 .5rem; }
.detail { display:flex; gap:1.4rem; flex-wrap:wrap; align-items:flex-start;
margin-top:1rem; }
.detail .art { flex:0 0 clamp(11rem,26vw,17rem); }
.detail .art img { width:100%; border-radius:.8rem; border:1px solid var(--line); }
.facts { flex:1 1 18rem; background:var(--board); border:1px solid var(--line);
border-radius:.8rem; padding:.9rem 1.1rem; }
.factrow { margin:.45rem 0; }
.factrow b { font-size:.72rem; text-transform:uppercase; letter-spacing:.06em;
color:var(--ink-soft); margin-right:.4rem; }
.card { background:var(--board); border:1px solid var(--line);
border-radius:.8rem; padding:.9rem 1.1rem; margin-top:1rem; }
.desc { white-space:pre-wrap; }
.wall { display:flex; flex-direction:column; gap:.5rem; margin:.7rem 0 1.4rem; }
.orow { display:flex; gap:.5rem; }
.ocell { flex-basis:0; min-width:0; background:#fff;
border:1px solid var(--line); border-radius:.6rem; padding:.5rem .6rem; }
.ozone { font-size:.72rem; color:var(--accent); margin-left:.3rem; }
.ogames { margin:.3rem 0 0; font-size:.82rem; line-height:1.5; }
footer { margin:2rem 0 1rem; color:var(--ink-soft-on-sky); font-size:.8rem;
text-align:center; }
footer a { color:var(--accent); }
a.pbb { font-weight:700; }
"""
def _chips(label: str, values: list | None) -> str:
if not values:
return ""
spans = " ".join(f'<span class="chip">{html.escape(str(v))}</span>' for v in values)
return f'<div class="factrow"><b>{html.escape(label)}</b> {spans}</div>'
def _fact(label: str, value) -> str:
if value in (None, "", []):
return ""
return (
f'<div class="factrow"><b>{html.escape(label)}</b> '
f"{html.escape(str(value))}</div>"
)
def _players(g: dict) -> str:
# mirror of players() in templates/pages/library.html — keep in sync
lo, hi = g.get("min_players"), g.get("max_players")
lo, hi = lo or hi, hi or lo
if not lo:
return ""
span = str(lo) if lo == hi else f"{lo}{hi}"
noun = "player" if lo == hi == 1 else "players"
best = [str(b) for b in g.get("best_player_counts") or []]
return f"{span} {noun}" + (f" (best at {', '.join(best)})" if best else "")
def _playtime(g: dict) -> str:
lo, hi = g.get("min_playtime"), g.get("max_playtime")
if lo and hi and lo != hi:
return f"{lo}{hi} min"
minutes = g.get("playtime") or lo
return f"{minutes} min" if minutes else ""
def _footer(title: str, rel: str, badge_present: bool) -> str:
return f"""<footer>
<p>{_powered_by(rel, badge_present)}</p>
<p>{html.escape(title)} · cataloged from shelf photos by
<a href="https://git.kestrelsnest.social/eric/bggpipe"
rel="noopener">bggpipe</a></p>
<p>Game data and cover images from <a href="https://boardgamegeek.com"
rel="noopener">BoardGameGeek</a>. BoardGameGeek and BGG are trademarks of
BoardGameGeek, LLC; this page is an independent project, not affiliated
with or endorsed by BoardGameGeek.</p>
</footer>"""
_PIPER_NAME = "bggpipe-piper.jpg"
_PIPER_SRC = Path(__file__).parent / "static" / "logo-full.jpg"
def _colophon_card(title: str, badge_present: bool) -> str:
"""The last card on the shelf: mascot, badge, and the fine print. A
<div>, not an <a> — the search filter selects a.game, so the colophon
stays visible whatever is typed."""
badge = (
f'<img src="art/{BADGE_NAME}" alt="Powered by BGG" height="34">'
if badge_present
else "Powered by BGG"
)
return f"""<div class="game colophon">
<img src="art/{_PIPER_NAME}"
alt="the bggpipe piper — a bagpiper whose bag is a board game box">
<div class="info">
<div class="gname">{html.escape(title)}</div>
<p class="meta">Cataloged from shelf photos by
<a href="https://git.kestrelsnest.social/eric/bggpipe" rel="noopener">bggpipe</a>.
Game data and covers from
<a href="https://boardgamegeek.com" rel="noopener">{badge}</a></p>
<p class="meta">BoardGameGeek and BGG are trademarks of BoardGameGeek,
LLC; this site is an independent project, not affiliated with or
endorsed by BoardGameGeek. Piper art by Juniper.</p>
</div>
</div>"""
_FAVICON = (
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' "
"viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎲</text></svg>"
)
def _page(
title: str,
body: str,
*,
rel: str,
site_title: str,
badge_present: bool,
description: str = "",
og_image: str = "",
) -> str:
description = description or f"{site_title}: a board game library"
og = (
f'<meta property="og:image" content="{html.escape(og_image)}">'
if og_image
else ""
)
return f"""<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<meta name="description" content="{html.escape(description)}">
<meta property="og:title" content="{html.escape(title)}">
{og}
<link rel="icon" href="{_FAVICON}">
<link rel="stylesheet" href="{rel}style.css">
<header class="site"><a href="{rel}./">{html.escape(site_title)}</a>
<span class="sub">a board game library</span></header>
<main>
{body}
{_footer(site_title, rel, badge_present)}
</main>
</html>"""
def _card(g: dict, slug: str, art: dict[str, str]) -> str:
img = (
f'<img src="art/{html.escape(art["thumb"])}" alt="" loading="lazy">'
if art.get("thumb")
else '<div class="noart" aria-hidden="true">'
+ html.escape((g.get("name") or "?")[0].upper())
+ "</div>"
)
year = (
f' <span class="meta">({html.escape(str(g["year"]))})</span>'
if g.get("year")
else ""
)
bits = " · ".join(
b
for b in (
_players(g),
_playtime(g),
f"rank {g['rank']}" if g.get("rank") else "",
)
if b
)
kind = ""
if g.get("type") == "rpgitem":
kind = ' <span class="chip">RPG</span>'
elif g.get("type") == "localgame":
kind = ' <span class="chip">not on BGG</span>'
name = html.escape(g.get("name") or "?")
return f"""<a class="game" href="{slug}/" data-name="{name}">
{img}
<div class="info"><div class="gname">{name}{year}{kind}</div>
<div class="meta">{html.escape(bits)}</div></div>
</a>"""
def _rows_of(openings: list[dict]) -> list[list[dict]]:
# mirror of unitRows() in templates/pages/shelves.html — keep in sync
rows: list[list[dict]] = []
key = None
for o in openings:
m = re.match(r"^([A-Za-z]+)\d+$", o.get("label") or "")
row_key = m.group(1).upper() if m else f"single-{o['id']}"
if row_key != key:
rows.append([])
key = row_key
rows[-1].append(o)
return rows
def _shelves_body(
units: list[dict],
locations: dict,
games: dict[str, dict],
slugs: dict[str, str],
) -> str:
"""The wall, drawn for the public page: units as their physical rows,
each opening listing its residents as links."""
openings = opening_index(units)
residents: dict[str, list[tuple[str, dict]]] = {}
holds: dict[str, int] = {} # physical box key -> games riding inside
for key, entry in games.items():
oid, _, via = effective_location(key, entry, games, locations)
if oid and oid in openings:
if via is not None:
# contents ride inside a box that IS listed — the box on
# the shelf is the physical object; its contents live on
# its game page, not as wall clutter
holds[via] = holds.get(via, 0) + 1
else:
residents.setdefault(oid, []).append((key, entry))
parts = []
for unit in units:
cells_rows = []
for row in _rows_of(unit.get("openings", [])):
cells = []
for o in row:
here = sorted(
residents.get(o["id"], []),
key=lambda p: (p[1].get("name") or "").casefold(),
)
links = " · ".join(
f'<a href="../{slugs[k]}/">{html.escape(e.get("name") or "?")}</a>'
+ (f" (+{holds[k]} inside)" if holds.get(k) else "")
for k, e in here
)
zone = (
f'<span class="ozone">{html.escape(o.get("zone") or "")}</span>'
if o.get("zone")
else ""
)
desc = (
f'<p class="meta">{html.escape(o["description"])}</p>'
if o.get("description")
else ""
)
width = (o.get("width_in") or 13.25) * 4
cells.append(
f'<div class="ocell" style="flex-grow:{width:.0f}">'
f"<b>{html.escape(o['label'])}</b> {zone}{desc}"
f'<p class="ogames">{links or "<span class=meta>empty</span>"}</p>'
"</div>"
)
cells_rows.append(f'<div class="orow">{"".join(cells)}</div>')
desc = (
f'<p class="meta">{html.escape(unit["description"])}</p>'
if unit.get("description")
else ""
)
parts.append(
f"<h2>{html.escape(unit['name'])}</h2>{desc}"
f'<div class="wall">{"".join(cells_rows)}</div>'
)
return (
'<p><a href="../">← the whole library</a></p>'
"<h1>The shelves</h1>"
'<p class="meta">Where everything lives — drawn as the wall stands.</p>'
+ "".join(parts)
)
def _detail_body(g: dict, art: dict[str, str]) -> str:
art_html = (
f'<img src="../art/{html.escape(art["full"])}" '
f'alt="box art for {html.escape(g.get("name") or "")}">'
if art.get("full")
else ""
)
bgg_link = ""
if g.get("bgg_id") and g.get("type") != "localgame":
bgg_id = html.escape(str(g["bgg_id"]))
site = (
f"https://rpggeek.com/rpgitem/{bgg_id}"
if g.get("type") == "rpgitem"
else f"https://boardgamegeek.com/boardgame/{bgg_id}"
)
label = "RPGGeek" if g.get("type") == "rpgitem" else "BGG"
bgg_link = f'<a href="{site}" rel="noopener">view on {label} ↗</a>'
facts = "".join(
(
_fact("players", _players(g)),
_fact("playing time", _playtime(g)),
_fact("ages", f"{g['min_age']}+" if g.get("min_age") else ""),
_fact("weight", f"{g['weight']:.2f} / 5" if g.get("weight") else ""),
_fact("BGG rank", g.get("rank")),
_fact("BGG rating", f"{g['rating']:.2f}" if g.get("rating") else ""),
_chips("designers", g.get("designers")),
_chips("artists", (g.get("artists") or [])[:8]),
_chips("publishers", (g.get("publishers") or [])[:6]),
_chips("categories", g.get("categories")),
_chips("mechanics", g.get("mechanics")),
)
)
version = ""
if g.get("version"):
v = g["version"]
vyear = f" · {html.escape(str(v['year']))}" if v.get("year") else ""
version = (
'<div class="card"><div class="factrow">'
f"<b>my copy</b> {html.escape(v.get('name') or '')}{vyear}</div>"
+ _chips("publishers", v.get("publishers"))
+ _chips("languages", v.get("languages"))
+ "</div>"
)
desc = (
# BGG descriptions arrive with entities still encoded (&rsquo; etc);
# unescape their layer, then escape once for this page
f'<div class="card desc">{html.escape(html.unescape(g["description"]))}</div>'
if g.get("description")
else ""
)
year = (
f' <span class="meta">({html.escape(str(g["year"]))})</span>'
if g.get("year")
else ""
)
return f"""<p><a href="../">← the whole library</a> · {bgg_link}</p>
<h1>{html.escape(g.get("name") or "?")}{year}</h1>
<div class="detail">
<div class="art">{art_html}</div>
<div class="facts">{facts}</div>
</div>
{version}
{desc}"""
_INDEX_JS = """
<script>
"use strict";
const cards = Array.from(document.querySelectorAll("a.game"));
const rows = cards.map(c => ({el: c, text: (c.dataset.name + " " +
c.textContent).toLowerCase()}));
const q = document.getElementById("q");
const nomatch = document.getElementById("nomatch");
q.addEventListener("input", () => {
const needle = q.value.trim().toLowerCase();
let shown = 0;
rows.forEach(r => {
const hit = !needle || r.text.includes(needle);
r.el.style.display = hit ? "" : "none";
if (hit) shown += 1;
});
nomatch.hidden = shown > 0;
});
</script>
"""
def run_export(
cfg: Config,
out_dir: Path,
*,
title: str = "My Game Library",
include_shelves: bool = True,
fetch: httpx.Client | None = None,
sleep=time.sleep,
) -> dict:
if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1)
games: dict[str, dict] = apply_measurements(
cfg, json.loads(cfg.games_path.read_text())
)
out_dir.mkdir(parents=True, exist_ok=True)
manifest_path = out_dir / MANIFEST_NAME
prior: dict[str, str] = {}
prior_urls: dict[str, dict[str, str]] = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text())
prior = manifest.get("slugs", {})
prior_urls = manifest.get("art_urls", {})
except json.JSONDecodeError:
typer.echo(
f" warning: {MANIFEST_NAME} is corrupt — slug assignments "
"reset; published URLs may move this run"
)
slugs = _slugs(games, prior)
own_client = fetch is None
fetch = fetch or httpx.Client(
timeout=30, follow_redirects=True, headers={"User-Agent": "bggpipe-export"}
)
try:
art, downloaded, failures, art_urls = _fetch_art(
games, out_dir / "art", cfg, fetch, sleep, prior_urls
)
finally:
if own_client:
fetch.close()
atomic_write_text(out_dir / "style.css", _CSS)
# the official badge, when the owner has saved it from their BGG
# application page; the footer falls back to a text badge without it
badge_src = cfg.data_dir / BADGE_NAME
badge_bytes = badge_src.read_bytes() if badge_src.exists() else b""
badge_present = _looks_like_image(badge_bytes)
if badge_present:
atomic_write_bytes(out_dir / "art" / BADGE_NAME, badge_bytes)
elif badge_bytes:
typer.echo(
f" warning: {badge_src} doesn't look like an image — "
"footer falls back to the text badge"
)
else:
typer.echo(
" note: no data/powered-by-bgg.png — footer uses a text "
'"Powered by BGG" link; save the official badge from '
"boardgamegeek.com/applications to upgrade it"
)
# the location layer, when the owner has one and wants it published
units = load_furniture(cfg) if include_shelves else []
locations = load_locations(cfg) if include_shelves else {}
openings = opening_index(units)
where: dict[str, str] = {}
contains: dict[str, list[str]] = {}
for key, game in games.items():
oid, note, via = effective_location(key, game, games, locations)
opening = openings.get(oid) if oid else None
if opening:
where[key] = f"{opening['unit']} · {opening['label']}"
elif via is None and note:
where[key] = note
if game.get("stored_in"):
for other_key, other in games.items():
if str(other.get("bgg_id")) == str(game["stored_in"]):
contains.setdefault(other_key, []).append(key)
# game pages FIRST, index last: a killed run must not leave an index
# linking to pages that were never written
for key, game in games.items():
page_dir = out_dir / slugs[key]
page_dir.mkdir(exist_ok=True)
cover = art.get(key, {}).get("full", "")
extras = ""
if where.get(key):
away = effective_away(key, game, games, locations)
extras += (
'<div class="card"><div class="factrow"><b>where it lives</b> '
f"{html.escape(where[key])}"
+ (
f' <span class="meta">— right now: {html.escape(away)}</span>'
if away
else ""
)
+ "</div></div>"
)
container_key = next(
(
k
for k, other in games.items()
if game.get("stored_in")
and str(other.get("bgg_id")) == str(game["stored_in"])
),
None,
)
if container_key:
extras += (
'<div class="card"><div class="factrow"><b>where it lives</b> '
f'inside <a href="../{slugs[container_key]}/">'
f"{html.escape(games[container_key].get('name') or '?')}</a>"
"</div></div>"
)
if contains.get(key):
links = " · ".join(
f'<a href="../{slugs[k]}/">'
f"{html.escape(games[k].get('name') or '?')}</a>"
for k in sorted(
contains[key],
key=lambda c: (games[c].get("name") or "").casefold(),
)
)
extras += (
'<div class="card"><div class="factrow"><b>in this box</b> '
f"{links}</div></div>"
)
atomic_write_text(
page_dir / "index.html",
_page(
f"{game.get('name') or 'game'} · {title}",
_detail_body(game, art.get(key, {})) + extras,
rel="../",
site_title=title,
badge_present=badge_present,
description=f"{game.get('name') or 'A game'} in {title}",
og_image=f"../art/{cover}" if cover else "",
),
)
ordered = sorted(games, key=lambda k: (games[k].get("name") or "").casefold())
cards = "\n".join(_card(games[k], slugs[k], art.get(k, {})) for k in ordered)
index_body = (
f"<h1>{html.escape(title)}</h1>"
f'<p class="meta">{len(games)} games, cataloged from shelf photos.</p>'
'<div class="controls"><input type="search" id="q" '
'placeholder="search the shelves…" aria-label="search"></div>'
f'<div class="grid">{cards}\n{_colophon_card(title, badge_present)}</div>'
'<p class="meta" id="nomatch" hidden>No games match that search.</p>'
f"{_INDEX_JS}"
)
if units:
shelves_dir = out_dir / "shelves"
shelves_dir.mkdir(exist_ok=True)
atomic_write_text(
shelves_dir / "index.html",
_page(
f"The shelves · {title}",
_shelves_body(units, locations, games, slugs),
rel="../",
site_title=title,
badge_present=badge_present,
),
)
index_body = index_body.replace(
'<div class="controls">',
'<p><a href="shelves/">see the shelves — where everything lives →</a></p>'
'<div class="controls">',
1,
)
atomic_write_text(
out_dir / "index.html",
_page(title, index_body, rel="", site_title=title, badge_present=badge_present),
)
# stale cleanup: pages the PRIOR manifest owned whose games are gone,
# and art files no current entry references. Only manifest-claimed
# slugs are ever deleted — user files in out_dir are not ours to touch.
removed = 0
current_slugs = set(slugs.values())
for old_slug in set(prior.values()) - current_slugs:
stale_dir = out_dir / old_slug
if stale_dir.is_dir():
shutil.rmtree(stale_dir)
removed += 1
if _PIPER_SRC.exists():
atomic_write_bytes(out_dir / "art" / _PIPER_NAME, _PIPER_SRC.read_bytes())
wanted_art = {n for entry in art.values() for n in entry.values()}
wanted_art.update((BADGE_NAME, _PIPER_NAME))
for art_file in (out_dir / "art").iterdir():
if art_file.name not in wanted_art:
art_file.unlink()
atomic_write_text(
manifest_path,
json.dumps(
{"slugs": slugs, "art_urls": art_urls},
indent=2,
ensure_ascii=False,
sort_keys=True,
)
+ "\n",
)
for line in failures:
typer.echo(f" warning: cover not fetched — {line}")
stale_note = f", {removed} stale page(s) removed" if removed else ""
shelves_note = " + the shelves page" if units else ""
typer.echo(
f"exported {len(games)} game page(s){shelves_note} to {out_dir} "
f"({downloaded} cover(s) downloaded, "
f"{len(failures)} failed — re-run to retry{stale_note})"
)
return {
"games": len(games),
"downloaded": downloaded,
"failures": len(failures),
"removed": removed,
"shelves": bool(units),
}
+126 -6
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]
data = json.loads(cleaned) try:
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)."""
@@ -334,6 +396,53 @@ def _scoped_records(path: Path) -> list[dict]:
return records 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]: def load_title_splits(path: Path) -> list[dict]:
"""The human's split-into-copies decisions — durable: they must survive """The human's split-into-copies decisions — durable: they must survive
extract rebuilds and resolve --force.""" extract rebuilds and resolve --force."""
@@ -429,10 +538,11 @@ def rebuild_artifacts(
splits: list[dict] | None = None, splits: list[dict] | None = None,
edits: list[dict] | None = None, edits: list[dict] | None = None,
removals: 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")):
@@ -450,6 +560,7 @@ 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"]
entries = entries + [dict(e) for e in additions or []]
entries = apply_title_removals( entries = apply_title_removals(
apply_title_edits(entries, edits or []), removals or [] apply_title_edits(entries, edits or []), removals or []
) )
@@ -473,6 +584,7 @@ def replay_titles(cfg: Config) -> None:
splits = load_title_splits(cfg.title_splits_path) splits = load_title_splits(cfg.title_splits_path)
edits = load_title_edits(cfg.title_edits_path) edits = load_title_edits(cfg.title_edits_path)
removals = load_title_removals(cfg.title_removals_path) removals = load_title_removals(cfg.title_removals_path)
additions = load_title_additions(cfg.title_additions_path)
raw_dir = cfg.extract_raw_dir raw_dir = cfg.extract_raw_dir
raw_photos = ( raw_photos = (
{f.name.removesuffix(".json") for f in raw_dir.glob("*.json")} {f.name.removesuffix(".json") for f in raw_dir.glob("*.json")}
@@ -488,7 +600,13 @@ def replay_titles(cfg: Config) -> None:
# them would silently truncate the committed catalog # them would silently truncate the committed catalog
if raw_photos and raw_photos >= known_photos: if raw_photos and raw_photos >= known_photos:
rebuild_artifacts( rebuild_artifacts(
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits, removals raw_dir,
cfg.titles_path,
cfg.unidentified_path,
splits,
edits,
removals,
additions,
) )
return return
if not cfg.titles_path.exists(): if not cfg.titles_path.exists():
@@ -500,6 +618,7 @@ def replay_titles(cfg: Config) -> None:
exploded.extend({**entry, "source_photos": [p]} for p in photos) exploded.extend({**entry, "source_photos": [p]} for p in photos)
else: else:
exploded.append(dict(entry)) exploded.append(dict(entry))
exploded.extend(dict(e) for e in additions)
deduped = dedupe_entries( deduped = dedupe_entries(
apply_title_removals(apply_title_edits(exploded, edits), removals), splits apply_title_removals(apply_title_edits(exploded, edits), removals), splits
) )
@@ -536,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)
@@ -594,6 +713,7 @@ def run_extract(
load_title_splits(cfg.title_splits_path), load_title_splits(cfg.title_splits_path),
load_title_edits(cfg.title_edits_path), load_title_edits(cfg.title_edits_path),
load_title_removals(cfg.title_removals_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")
+73 -15
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import html
import warnings import warnings
import xml.etree.ElementTree as ET # parsing itself goes via defusedxml import xml.etree.ElementTree as ET # parsing itself goes via defusedxml
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -22,6 +23,11 @@ PENDING_MATCH_STATUSES = ("ambiguous", "unmatched")
UNDECIDED_MATCH_STATUSES = ("ambiguous", "unmatched", "merged") UNDECIDED_MATCH_STATUSES = ("ambiguous", "unmatched", "merged")
# the write/read contract for entry["dims"]: enrich writes these keys,
# shelves and dims consume them
DIM_AXES = ("width_in", "length_in", "depth_in")
def is_recognized(row: dict) -> bool: def is_recognized(row: dict) -> bool:
"""A row that reaches diff/enrich: matched and carrying a real id.""" """A row that reaches diff/enrich: matched and carrying a real id."""
return row["match_status"] in RECOGNIZED_MATCH_STATUSES and bool(row["bgg_id"]) return row["match_status"] in RECOGNIZED_MATCH_STATUSES and bool(row["bgg_id"])
@@ -94,21 +100,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 +165,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")
@@ -213,7 +234,12 @@ def parse_things_full(xml_text: str) -> list[dict]:
"type": item.get("type", "boardgame"), "type": item.get("type", "boardgame"),
"name": name.get("value", "") if name is not None else "", "name": name.get("value", "") if name is not None else "",
"year": _attr_int(item.find("yearpublished")), "year": _attr_int(item.find("yearpublished")),
"description": (item.findtext("description") or "").strip(), # BGG serves descriptions with entities still encoded
# (&rsquo; etc): decode ONCE here so every consumer —
# app, export, games.json — gets plain text
"description": html.unescape(
(item.findtext("description") or "").strip()
),
"image": (item.findtext("image") or "").strip(), "image": (item.findtext("image") or "").strip(),
"thumbnail": (item.findtext("thumbnail") or "").strip(), "thumbnail": (item.findtext("thumbnail") or "").strip(),
"min_players": _attr_int(item.find("minplayers")), "min_players": _attr_int(item.find("minplayers")),
@@ -223,18 +249,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,
@@ -243,6 +279,28 @@ def parse_things_full(xml_text: str) -> list[dict]:
return games return games
def parse_version_dims(xml_text: str) -> dict[int, list[dict]]:
"""Physical box data per game id from a versions=1 response. BGG uses
0 for "never entered" — a zero axis or weight parses as None, never
as a real dimension. Returns plain dicts (artifact data)."""
out: dict[int, list[dict]] = {}
for item in _root(xml_text).findall("item"):
game_id = int(_required_attr(item, "id"))
records = out.setdefault(game_id, [])
for v_item in item.findall("versions/item"):
record: dict = {"version_id": int(_required_attr(v_item, "id"))}
for xml_name, field_name in (
("width", "width_in"),
("length", "length_in"),
("depth", "depth_in"),
("weight", "weight_lb"),
):
value = _attr_float(v_item.find(xml_name))
record[field_name] = value if value else None
records.append(record)
return out
def _required_attr(item: ET.Element, name: str) -> str: def _required_attr(item: ET.Element, name: str) -> str:
value = item.get(name) value = item.get(name)
if not value: if not value:
+89 -13
View File
@@ -57,6 +57,9 @@ MATCH_COLUMNS = [
"source_photos", "source_photos",
"merged_into", "merged_into",
"dedupe_veto", "dedupe_veto",
# human curation: the bgg_id of the box this game physically lives
# inside (a compilation like a Character Trove) — "" = its own box
"stored_in",
] ]
@@ -89,6 +92,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 +194,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 +209,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 +232,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 +255,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 +274,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 +415,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 +426,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,7 +473,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(client, entry, entry.title_raw, types="rpgitem") cands = _merged_candidates(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(
@@ -414,6 +481,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)
@@ -587,8 +659,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
@@ -607,7 +677,6 @@ 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)
@@ -662,6 +731,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 "
+177 -6
View File
@@ -30,8 +30,11 @@ from bggpipe.models import (
) )
from bggpipe.normalize import normalize_title 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,
@@ -328,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
@@ -356,6 +367,160 @@ 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"
# a CURRENT pick survives the reopen: abandoning the ballot must
# not lose an approved edition. The lingering id is inert while
# ambiguous (is_confident_version gates diff and upload), and the
# next decide_version overwrites or clears it.
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:
@@ -407,10 +572,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()
@@ -419,6 +584,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
@@ -452,6 +620,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 []
+558
View File
@@ -0,0 +1,558 @@
"""The physical-location layer: which opening of which shelf each box
lives in.
Local-only — a shelf layout is not BGG data — and additive to the
pipeline: nothing here touches upload. Two committed stores follow the
local_games.json pattern: furniture.json (units and their openings,
with interior dimensions; an opening without dims is a virtual location
like "travel case" that imposes no size limits) and locations.json
(games.json key -> {opening_id, note}).
Fit language matches the rest of the project: fits / doesn't fit /
can't verify — unknown is never reported as fitting. A game with
stored_in set inherits its container's location and cannot be assigned
directly.
"""
from __future__ import annotations
import csv
import json
import re
from itertools import permutations
from pathlib import Path
import typer
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import DIM_AXES
from bggpipe.normalize import normalize_title
_AXES = DIM_AXES
_SLUG = re.compile(r"[^a-z0-9]+")
# -- stores -------------------------------------------------------------
def load_furniture(cfg: Config) -> list[dict]:
"""Units: {name, openings: [{id, label, zone, width_in, height_in,
depth_in}]}. Corrupt stores fail loud — this is hand-curated data."""
if not cfg.furniture_path.exists():
return []
try:
return json.loads(cfg.furniture_path.read_text())["units"]
except (json.JSONDecodeError, KeyError, TypeError) as err:
raise ValueError(
f"{cfg.furniture_path} is corrupt ({err}) — it holds your shelf "
"layout, so check git history before deleting"
) from err
def save_furniture(cfg: Config, units: list[dict]) -> None:
atomic_write_text(
cfg.furniture_path,
json.dumps({"units": units}, indent=2, ensure_ascii=False) + "\n",
)
def load_locations(cfg: Config) -> dict[str, dict]:
if not cfg.locations_path.exists():
return {}
try:
return json.loads(cfg.locations_path.read_text())
except json.JSONDecodeError as err:
raise ValueError(
f"{cfg.locations_path} is corrupt ({err}) — it records where "
"every game lives, so check git history before deleting"
) from err
def save_locations(cfg: Config, locations: dict[str, dict]) -> None:
atomic_write_text(
cfg.locations_path,
json.dumps(locations, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
)
def load_measurements(cfg: Config) -> dict[str, dict]:
"""Hand-measured box dimensions, keyed like games.json. The owner's
tape measure outranks BGG: these overlay whatever enrich found."""
if not cfg.measurements_path.exists():
return {}
try:
return json.loads(cfg.measurements_path.read_text())
except json.JSONDecodeError as err:
raise ValueError(
f"{cfg.measurements_path} is corrupt ({err}) — it holds your "
"hand-measured box sizes, so check git history before deleting"
) from err
def save_measurements(cfg: Config, measurements: dict[str, dict]) -> None:
atomic_write_text(
cfg.measurements_path,
json.dumps(measurements, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
)
def apply_measurements(cfg: Config, games: dict[str, dict]) -> dict[str, dict]:
"""Overlay hand measurements onto a loaded games dict, in place."""
for key, m in load_measurements(cfg).items():
entry = games.get(key)
if entry is not None and all(m.get(a) for a in _AXES):
entry["dims"] = {**{a: m[a] for a in _AXES}, "source": "measured"}
return games
def opening_index(units: list[dict]) -> dict[str, dict]:
"""opening_id -> opening (with its unit's name attached)."""
out: dict[str, dict] = {}
for unit in units:
for opening in unit.get("openings", []):
out[opening["id"]] = {**opening, "unit": unit["name"]}
return out
def new_opening_id(units: list[dict], unit_name: str, label: str) -> str:
"""Stable, readable, unique: unit-slug + label-slug, -2 on collision."""
base = f"{_SLUG.sub('-', unit_name.casefold()).strip('-')}-" + (
_SLUG.sub("-", label.casefold()).strip("-") or "x"
)
taken = set(opening_index(units))
candidate, n = base, 2
while candidate in taken:
candidate = f"{base}-{n}"
n += 1
return candidate
# -- fit epistemics -----------------------------------------------------
def game_dims(entry: dict) -> tuple[float, float, float] | None:
"""The trusted box dimensions, or None when they can't be verified."""
dims = entry.get("dims") or {}
values = [dims.get(a) for a in _AXES]
return tuple(values) if all(values) else None
def fits_opening(entry: dict, opening: dict) -> bool | None:
"""True/False when both sides have dimensions; None = can't verify
(missing game dims) or no limits (a virtual opening). Unknown is
never reported as fitting."""
interior = [opening.get(k) for k in ("width_in", "height_in", "depth_in")]
if not all(interior):
return None # virtual location: no size limits to check
dims = game_dims(entry)
if dims is None:
return None
width, height, depth = interior
return any(
a <= width and b <= height and c <= depth for a, b, c in permutations(dims)
)
def family_stem(entry: dict) -> str:
"""The taxonomy-free kinship signal: the normalized pre-colon stem of
the name ("Castle Panic: The Wizard's Tower" -> "castle panic"). Base
games and their expansions share it without anyone defining a series."""
name = (entry.get("name") or "").split(":")[0]
return normalize_title(name)
def related(entry: dict, other: dict) -> bool:
"""Same family stem, or overlapping series field where enrich has one."""
if family_stem(entry) and family_stem(entry) == family_stem(other):
return True
mine = set(entry.get("series") or [])
return bool(mine and mine & set(other.get("series") or []))
def stack_thickness(entry: dict) -> float | None:
"""A box on a shelf stack contributes its thinnest axis."""
dims = game_dims(entry)
return min(dims) if dims else None
# -- the resolved picture ------------------------------------------------
def effective_away(
key: str, entry: dict, games: dict[str, dict], locations: dict[str, dict]
) -> str:
"""Where the box IS right now, when that isn't home: its own away
note, else the nearest container's — contents ride along on trips
the same way they ride along on shelves. "" = at home."""
visited = {key}
current_key, current = key, entry
while True:
away = (locations.get(current_key) or {}).get("away", "")
if away:
return away
if not current.get("stored_in"):
return ""
container = next(
(
(k, e)
for k, e in games.items()
if str(e.get("bgg_id")) == str(current["stored_in"])
),
None,
)
if container is None or container[0] in visited:
return ""
current_key, current = container
visited.add(current_key)
def effective_location(
key: str, entry: dict, games: dict[str, dict], locations: dict[str, dict]
) -> tuple[str | None, str, str | None]:
"""(opening_id, note, inherited_from_key). A contained game inherits
its container's location through the WHOLE chain — minis inside an
insert inside a big box live wherever the big box does. A cycle (which
the endpoints refuse, but hand-edited stores can hold) terminates as
unlocated rather than looping."""
visited = {key}
via: str | None = None
current_key, current = key, entry
while current.get("stored_in"):
container = next(
(
(k, e)
for k, e in games.items()
if str(e.get("bgg_id")) == str(current["stored_in"])
),
None,
)
if container is None or container[0] in visited:
return None, "", via
current_key, current = container
visited.add(current_key)
via = current_key
loc = locations.get(current_key, {})
return loc.get("opening_id"), loc.get("note", ""), via if via != key else None
def _flat_claim(dims: tuple, opening: dict) -> float | None:
"""A flat box's claim on the opening's WIDTH: its footprint (the two
larger axes) must fit width × depth; the claim is the footprint axis
left running along the width, minimized over the two orientations.
None = the footprint fits neither way."""
t = min(dims)
rest = sorted(dims)
rest.remove(t)
a, b = rest # a <= b
claims = []
if b <= opening["depth_in"] and a <= opening["width_in"]:
claims.append(a)
if a <= opening["depth_in"] and b <= opening["width_in"]:
claims.append(b)
return min(claims) if claims else None
def _stands(dims: tuple, opening: dict) -> bool:
"""On edge, the thinnest axis runs along the width; the other two
must fit height × depth one way or the other."""
t = min(dims)
rest = sorted(dims)
rest.remove(t)
a, b = rest
return (a <= opening["height_in"] and b <= opening["depth_in"]) or (
b <= opening["height_in"] and a <= opening["depth_in"]
)
def lane_split(
opening: dict,
physical: list[tuple[str, dict, str]],
acknowledged: set[str] | None = None,
) -> dict:
"""The two-lane packing model. Each opening holds a flat stack
(thinnest axes summing against interior HEIGHT, claiming the widest
flat box's width) and a standing lane beside it (boxes on edge,
thinnest axes summing against the REMAINING width). Lanes are chosen
greedily — larger footprints lie flat, smaller boxes stand — unless
an assignment carries an explicit lane override. A box no lane can
take is laneless; the opening warns only then.
physical: [(key, entry, override)] with override in "", "flat",
"standing". Unmeasured boxes take no lane but are counted.
An ACKNOWLEDGED key is the human overruling the model: "it really
is here". It takes its lane where one exists (standing without the
height/depth check — the overhang case), and when even that fails
it is WEDGED: really there, in some arrangement the packer can't
model, outside the capacity math and never warned about."""
acknowledged = acknowledged or set()
height = opening.get("height_in")
width = opening.get("width_in")
lanes: dict[str, str] = {}
laneless: list[str] = []
wedged: list[str] = []
proud: list[str] = [] # standing via relaxed check: a real overhang
unmeasured = 0
flat_in = 0.0
flat_claim = 0.0
standing: list[tuple[str, float]] = [] # (key, thickness)
def footprint(dims):
axes = sorted(dims, reverse=True)
return axes[0] * axes[1]
measured = []
for key, entry, override in physical:
dims = game_dims(entry)
if dims is None:
unmeasured += 1
else:
measured.append((key, entry, override, dims))
measured.sort(key=lambda item: footprint(item[3]), reverse=True)
def try_flat(key, dims):
nonlocal flat_in, flat_claim
claim = _flat_claim(dims, opening)
thickness = min(dims)
if claim is None or flat_in + thickness > height:
return False
flat_in += thickness
flat_claim = max(flat_claim, claim)
lanes[key] = "flat"
return True
def try_standing(key, dims, relaxed=False):
thickness = min(dims)
used = sum(t for _, t in standing)
if not (relaxed or _stands(dims, opening)):
return False
if used + thickness > width - flat_claim:
return False
standing.append((key, thickness))
lanes[key] = "standing"
return True
max_fp = footprint(measured[0][3]) if measured else 0
for key, entry, override, dims in measured:
if key in acknowledged:
# the human overrules: honor a lane override, else take any
# real lane, else relaxed standing (the overhang), else
# wedged — confirmed here, beyond the model
if override == "flat":
placed = try_flat(key, dims)
elif override == "standing":
placed = try_standing(key, dims, relaxed=True)
else:
placed = try_flat(key, dims) or try_standing(key, dims, relaxed=True)
if not placed:
wedged.append(entry.get("name") or "?")
elif lanes.get(key) == "standing" and not _stands(dims, opening):
proud.append(entry.get("name") or "?")
continue
if override == "flat":
placed = try_flat(key, dims)
elif override == "standing":
placed = try_standing(key, dims)
elif footprint(dims) >= max_fp - 1e-6:
# the largest-footprint class lies flat; smaller boxes stand
# beside the stack when they can
placed = try_flat(key, dims) or try_standing(key, dims)
else:
placed = try_standing(key, dims) or try_flat(key, dims)
if not placed:
laneless.append(entry.get("name") or "?")
# the flat claim can grow after standing boxes were placed: re-check
while standing and sum(t for _, t in standing) > width - flat_claim:
key, _ = standing.pop()
name = next((e.get("name") or "?" for k, e, _o in physical if k == key), "?")
(wedged if key in acknowledged else laneless).append(name)
lanes.pop(key, None)
return {
"lanes": lanes,
"flat_in": round(flat_in, 2),
"flat_claim": round(flat_claim, 2),
"standing_in": round(sum(t for _, t in standing), 2),
"laneless": laneless,
"wedged": wedged,
"proud": proud,
"unmeasured": unmeasured,
}
def opening_report(
opening: dict,
games_here: list[tuple[str, dict]],
overrides: dict[str, str] | None = None,
acknowledged: set[str] | None = None,
) -> dict:
"""Capacity picture for one opening under the two-lane model. Games
stored INSIDE another box are listed residents but occupy no shelf
space of their own — only their container is packed, the same stance
the dims report takes."""
overrides = overrides or {}
physical = [
(k, e, overrides.get(k, "")) for k, e in games_here if not e.get("stored_in")
]
height = opening.get("height_in")
width = opening.get("width_in")
if not (height and width):
return {
"games": len(physical),
"flat_in": 0,
"standing_in": 0,
"flat_claim": 0,
"lanes": {},
"unmeasured": sum(1 for _, e, _o in physical if game_dims(e) is None),
"fill": 0,
"standing_fill": 0,
"overfull": False,
"misfits": [],
"laneless": [],
"wedged": [],
"proud": [],
}
split = lane_split(opening, physical, acknowledged)
acknowledged = acknowledged or set()
misfits = [
entry.get("name") or "?"
for key, entry, _o in physical
if fits_opening(entry, opening) is False and key not in acknowledged
]
standing_budget = max(width - split["flat_claim"], 0.01)
return {
"games": len(physical),
"flat_in": split["flat_in"],
"standing_in": split["standing_in"],
"flat_claim": split["flat_claim"],
"lanes": split["lanes"],
"unmeasured": split["unmeasured"],
"fill": round(split["flat_in"] / height, 2) if split["flat_in"] else 0,
"standing_fill": (
round(split["standing_in"] / standing_budget, 2)
if split["standing_in"]
else 0
),
"overfull": bool(split["laneless"]),
"misfits": misfits,
"laneless": split["laneless"],
"wedged": split["wedged"],
"proud": split["proud"],
}
# -- CSV import ---------------------------------------------------------
def import_assignments(cfg: Config, csv_path: Path) -> dict:
"""Bulk-assign from a name,opening CSV (opening ids or labels).
Rejects are reported, never guessed: unknown name, ambiguous name,
unknown/ambiguous opening, doesn't fit, or lives inside another box.
An optional `acknowledge` column (yes/true/1) accepts a doesn't-fit
placement deliberately — a box standing proud of a shallow shelf.
Re-importing is an upsert: rows move their game, absent games keep
their spots, and notes / lane choices / acknowledgments survive
when the opening is unchanged."""
if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1)
games: dict[str, dict] = apply_measurements(
cfg, json.loads(cfg.games_path.read_text())
)
units = load_furniture(cfg)
if not units:
typer.echo(
f"{cfg.furniture_path} is empty — set up your shelves first "
"(the Shelves page, or bggpipe web)."
)
raise typer.Exit(code=1)
openings = opening_index(units)
by_label: dict[str, list[str]] = {}
for oid, opening in openings.items():
by_label.setdefault(opening["label"].casefold(), []).append(oid)
by_label.setdefault(
f"{opening['unit']} {opening['label']}".casefold(), []
).append(oid)
by_name: dict[str, list[str]] = {}
for key, entry in games.items():
by_name.setdefault(normalize_title(entry.get("name") or ""), []).append(key)
locations = load_locations(cfg)
assigned = 0
rejects: list[str] = []
with csv_path.open(newline="") as f:
for row in csv.DictReader(f):
name = (row.get("name") or "").strip()
opening_ref = (row.get("opening") or "").strip()
if not name or not opening_ref:
rejects.append(f"{name or '(blank)'}: name and opening both required")
continue
keys = by_name.get(normalize_title(name), [])
if not keys:
rejects.append(f"{name}: no game by that name in the library")
continue
if len(keys) > 1:
rejects.append(
f"{name}: matches {len(keys)} copies — assign those in the UI"
)
continue
key = keys[0]
if games[key].get("stored_in"):
rejects.append(
f"{name}: lives inside another box — assign the container"
)
continue
if opening_ref in openings:
oid = opening_ref
else:
oids = by_label.get(opening_ref.casefold(), [])
if not oids:
rejects.append(f"{name}: no opening {opening_ref!r} on file")
continue
if len(set(oids)) > 1:
rejects.append(
f"{name}: opening label {opening_ref!r} is ambiguous — "
"use the opening id (see data/furniture.json)"
)
continue
oid = oids[0]
acknowledge = (row.get("acknowledge") or "").strip().casefold() in (
"yes",
"true",
"1",
"x",
)
prev = locations.get(key) or {}
# an overhang acknowledged once (in the UI or a prior import)
# stays acknowledged — a plan file needn't re-state it
# (and being out on loan doesn't change where home is)
already = bool(prev.get("acknowledged")) and prev.get("opening_id") == oid
if fits_opening(games[key], openings[oid]) is False and not (
acknowledge or already
):
rejects.append(
f"{name}: doesn't fit {openings[oid]['unit']} "
f"{openings[oid]['label']} in any orientation — add an "
"acknowledge column with yes to shelve it anyway"
)
continue
# a re-imported plan must not erase hand-entered curation:
# the note always survives; the lane choice and a standing
# acknowledgment survive as long as the opening is unchanged
record = {"opening_id": oid, "note": prev.get("note", "")}
if prev.get("away"):
record["away"] = prev["away"]
if prev.get("opening_id") == oid:
if prev.get("lane"):
record["lane"] = prev["lane"]
if prev.get("acknowledged"):
record["acknowledged"] = True
if acknowledge:
record["acknowledged"] = True
locations[key] = record
assigned += 1
save_locations(cfg, locations)
typer.echo(f"assigned {assigned} game(s); {len(rejects)} reject(s)")
for line in rejects:
typer.echo(f" rejected — {line}")
return {"assigned": assigned, "rejects": rejects}
+347 -38
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,12 +325,14 @@ 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; white-space: nowrap; } .catalog td.actions { text-align: right; }
.catalog td.actions button { .catalog td.actions button { margin: .1rem 0 .1rem .3rem; white-space: nowrap; }
font: inherit; font-size: .78rem; border: var(--line); background: #fff; .catalog td.actions .ballotlink {
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer; display: inline-block; margin: .1rem 0 .1rem .3rem; white-space: nowrap;
box-shadow: none; 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 td.actions button:hover { background: var(--board); }
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; } .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 { display: flex; gap: .7rem; align-items: end; flex-wrap: wrap; }
@@ -343,11 +360,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
} }
.card ol { padding-left: 1.3rem; } .card ol { padding-left: 1.3rem; }
.card ol li { margin: .35rem 0; } .card ol li { margin: .35rem 0; }
.chip.ok { background: #e2f2e4; color: var(--go-ink); } .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); } .chip.shaky { background: var(--ticket); color: var(--gold-ink); border: 1px dashed var(--gold-ink); }
/* -- merge notices: slim, undoable ------------------------------------- */ /* -- merge notices: slim, undoable ------------------------------------- */
@@ -355,10 +372,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
.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); }
@@ -393,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;
@@ -402,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; }
@@ -447,6 +463,209 @@ 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;
}
/* pick rows live inside an .editform, whose label style (stacked,
uppercase, gray) is for FIELDS — a checklist row overrides it back
to a plain reading line */
.editform label.pickrow {
flex-direction: row; align-items: center; gap: .55rem;
font-size: .9rem; text-transform: none; letter-spacing: normal;
color: var(--ink); padding: .32rem .35rem; cursor: pointer;
border-radius: var(--radius);
}
.editform label.pickrow:hover { background: rgba(122, 74, 158, .08); }
.pickrow input[type="checkbox"] {
margin: 0; width: 1.05rem; height: 1.05rem; flex: none;
accent-color: var(--accent);
}
.pickrow .meta { font-size: .78rem; color: var(--ink-soft); }
.pickrow .chip { margin-left: auto; }
#pickresults {
flex: 1 1 100%; max-height: 22rem; overflow-y: auto;
border: 1px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem; background: #fff;
}
#pickfooter {
flex: 1 1 100%; display: flex; gap: .7rem; align-items: end;
flex-wrap: wrap;
}
#pickfooter .edithint { flex: 1 1 100%; }
/* selects join the design system EVERYWHERE — the third time a native
dropdown showed up in a card, the lesson finally took: style the
element, not each container it appears in */
select {
font: inherit; font-size: .85rem; color: var(--ink);
border: 2px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem .45rem; background: #fff; max-width: 24rem;
}
/* :not([hidden]) so the hidden attribute still works — a bare
display:flex would override the browser's [hidden] rule and show
the form permanently */
#storedform:not([hidden]) { display: flex; gap: .5rem; align-items: center; }
#storedform select { flex: 1 1 auto; }
/* -- shelves: units as grids of openings, sheet for one opening ------- */
/* .card is flex for the review pages' photo-beside-body layout; a shelf
card is a vertical STACK — without this, unit headers share a row with
their grid and unshelved rows flow horizontally off the page */
.card.stack { display: block; }
.unit .unithead { display: flex; align-items: center; gap: .7rem;
justify-content: space-between; flex-wrap: wrap; }
/* rows mirror the unit's PHYSICAL arrangement: one .orow per label
row (A, B, ...), cells sized by flex-grow proportional to interior
width — a double-wide reads double wide */
.openings { display: flex; flex-direction: column; gap: .5rem;
margin-bottom: .8rem; }
.orow { display: flex; gap: .5rem; }
.formlabel { margin: 1.2rem 0 .35rem; }
.editform label.attop { flex-direction: row; align-items: center; gap: .35rem;
text-transform: none; letter-spacing: normal; align-self: end;
padding-bottom: .45rem; }
.renamerow:not([hidden]) { display: flex; gap: .4rem; margin: .3rem 0 .6rem; }
.renamerow input { font: inherit; font-size: .9rem;
border: 2px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem .45rem; flex: 0 1 18rem; }
.unitdesc { margin: -.3rem 0 .7rem; display: flex; gap: .6rem;
align-items: center; flex-wrap: wrap; }
.unitdesc .descform:not([hidden]) { display: flex; gap: .4rem; flex: 1 1 16rem; }
.unitdesc .descform input { flex: 1 1 auto; font: inherit; font-size: .85rem;
border: 2px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem .45rem; }
.formlabel.small { font-weight: 700; font-size: .85rem;
color: var(--ink-soft); margin: .8rem 0 .3rem; }
button.opening {
font: inherit; text-align: left; cursor: pointer;
background: #fff; border: 2px solid var(--board-edge);
border-radius: var(--radius); padding: .5rem .6rem;
display: flex; flex-direction: column; gap: .25rem; min-height: 5.2rem;
flex-basis: 0; min-width: 0; overflow: hidden;
}
button.opening:hover, button.opening:focus-visible {
outline: 3px solid var(--accent); }
.olabel { font-weight: 700; display: flex; gap: .35rem; align-items: center; }
.ozone { font-size: .72rem; color: var(--accent); }
.ocount { font-size: .82rem; color: var(--ink-soft); }
.fillbar { height: .45rem; background: var(--board-edge);
border-radius: 1rem; overflow: hidden; }
.fillbar > div { height: 100%; background: var(--go, #2f8a45);
border-radius: 1rem; }
.fillbar > div.over { background: #b3372f; }
.fillbar.dual { margin-top: 2px; }
.fillbar > div.stand { background: #3e6fa3; }
.lanebtn { font: inherit; font-size: .72rem; cursor: pointer;
border: 1px solid var(--board-edge); border-radius: 1rem;
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
.lanebtn:hover { border-color: var(--accent); color: var(--accent); }
.ackbtn { font: inherit; font-size: .72rem; cursor: pointer;
border: 1px solid #b3372f; border-radius: 1rem;
background: #fff; padding: .05rem .5rem; color: #b3372f; }
.ackbtn.on { border-color: var(--board-edge); background: var(--felt-soft, #eef2ea);
color: var(--ink-soft); }
.ackbtn:hover { border-color: var(--accent); color: var(--accent); }
.measurebtn { font: inherit; font-size: .72rem; cursor: pointer;
border: 1px dashed var(--board-edge); border-radius: 1rem;
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
.measurebtn:hover { border-color: var(--accent); color: var(--accent); }
.measureform { display: inline-flex; gap: .25rem; align-items: center;
font-size: .78rem; color: var(--ink-soft); }
.measureform input { width: 3.6rem; font: inherit; padding: .1rem .25rem;
border: 1px solid var(--board-edge); border-radius: .3rem; }
.measureform .measuresave { font: inherit; font-size: .72rem; cursor: pointer;
border: 1px solid var(--board-edge); border-radius: 1rem;
background: #fff; padding: .05rem .5rem; }
.unshelved-row { display: flex; gap: .6rem; align-items: center;
flex-wrap: wrap; padding: .4rem 0;
border-bottom: 1px solid var(--board-edge); }
.unshelved-row .gos { display: flex; gap: .4rem; flex-wrap: wrap;
margin-left: auto; }
button.suggest { font: inherit; font-size: .8rem; cursor: pointer;
border: 2px solid var(--board-edge); border-radius: 1rem;
background: var(--board); padding: .15rem .6rem; }
button.suggest:hover { border-color: var(--accent); }
button.suggest.reunites { border-color: var(--accent); font-weight: 700; }
/* the opening sheet: modal on desktop, bottom sheet on phones */
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
display: flex; align-items: center; justify-content: center; z-index: 40; }
#opensheet[hidden] { display: none; }
.sheetcard { background: var(--board); border-radius: var(--radius-lg);
border: var(--line); box-shadow: var(--shadow-raised);
width: min(34rem, 92vw); max-height: 86vh; overflow-y: auto;
padding: 1rem 1.1rem; }
.sheethead { display: flex; justify-content: space-between;
align-items: center; gap: .7rem; margin-bottom: .5rem; }
#sheetclose { font: inherit; font-size: 1.1rem; background: none;
border: none; cursor: pointer; padding: .5rem; }
.sheetrow { display: flex; gap: .6rem; align-items: center;
padding: .35rem 0; border-bottom: 1px solid var(--board-edge); }
.sheetrow img { width: 2.2rem; height: 2.2rem; object-fit: cover;
border-radius: .35rem; }
.sheetrow .unassign { margin-left: auto; }
.addopeningform { margin-top: .6rem; }
#locform:not([hidden]) { display: flex; gap: .5rem; align-items: center;
flex-wrap: wrap; }
#locform select { max-width: 100%; flex: 1 1 14rem; }
#locform input { flex: 1 1 10rem; font: inherit; font-size: .85rem;
border: 2px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem .45rem; }
#sheetsearch { width: 100%; margin: .7rem 0 .35rem; font: inherit;
font-size: 1rem; padding: .55rem .7rem;
border: 2px solid var(--board-edge); border-radius: var(--radius); }
button.sheetadd { display: flex; gap: .6rem; align-items: center;
width: 100%; text-align: left; font: inherit; cursor: pointer;
background: #fff; border: 2px solid var(--board-edge);
border-radius: var(--radius); padding: .5rem .6rem; margin: .25rem 0;
min-height: 2.9rem; }
button.sheetadd img { width: 2.2rem; height: 2.2rem; object-fit: cover;
border-radius: .35rem; }
button.sheetadd:hover, button.sheetadd:focus-visible {
outline: 3px solid var(--accent); }
@media (max-width: 900px) {
/* one-handed moving-day mode: the sheet docks to the bottom, search
first, tap targets stay thumb-sized */
#opensheet { align-items: flex-end; }
.sheetcard { width: 100vw; max-height: 78vh;
border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
button.opening { min-height: 4.4rem; }
.unshelved-row .gos { margin-left: 0; width: 100%; }
button.suggest { padding: .5rem .8rem; }
}
.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;
@@ -465,18 +684,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 -3
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;
el.setAttribute("role", "status"); }
/* 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");
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");
@@ -82,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

+33 -16
View File
@@ -9,10 +9,10 @@
</div> </div>
<h2 id="flow">The flow: shelves → collection</h2> <h2 id="flow">The flow: shelves → collection</h2>
<div class="card"> <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> <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> <ol>
<li><b>extract</b> — every photo goes to Claude vision once; 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>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>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>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>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>
@@ -24,43 +24,60 @@
</div> </div>
<h2 id="pages">What each page is for</h2> <h2 id="pages">What each page is for</h2>
<div class="card"> <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="/">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 (or drop them in the <code>photos/</code> folder). 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 with the same name re-extracts it.</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="/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, and whether two same-game reads are really one box (merges show a veto). Keyboard-first; see <a href="#keys">shortcuts</a>.</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.</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: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.</p> <p><b><a href="/shelves">Shelves</a></b>where everything physically lives, drawn as it really stands: openings group into rows by label letter with widths proportional to their interiors, so the page is a diagram of your wall. Describe furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns — a later grid continues the row letters — custom sizes, and no-size spots like a travel case); units and openings both take a free-text description beside the zone keyword. Suggestions rank an opening holding a <b>series-mate or base game first</b> (marked ♥ — every placement you confirm teaches the suggester your organization), always against the <i>remaining</i> room, then tightest verified fit; a measured game with nowhere suitable says "no matching openings yet", and unmeasured games get no suggestions because can't-verify ≠ fits. Assign by tapping suggestions, searching inside an opening, or from any game's detail page. Capacity is a two-lane model — a flat stack against interior height plus a standing lane against the remaining width; the largest boxes lie flat, smaller ones stand, and each box's lane is flippable in the opening's view. ⚠ means <i>no packing fits</i>, not merely "tall stack" — and the packer is deliberately conservative (one flat pile, one standing row) while real shelves are 3-D. When it refuses a placement you can see with your own eyes, the <b>it's here anyway</b> button overrules it: the box takes a lane where one works (spine-out overhangs land "standing, proud") and otherwise is marked <b>wedged in</b> — really there, outside the capacity bars, never warned about. Unmeasured boxes get an <b>unmeasured — measure it</b> button: enter your own tape-measure numbers and they outrank BGG's everywhere sizes are used. And where a game <i>lives</i> vs where it <i>is</i> can differ: the <b>Right now</b> field on any game's detail page marks a box out (travel case, loaned to a friend) without touching its home — the spot stays reserved, contents ride along, and blanking the field brings it back. Games stored inside another box ride along with their container, through whole chains — and the space view lists only the physical boxes, collapsing contents into a "holds N inside" hint on the container's row (the full list lives on its game page). Your layout is local data only — never sent anywhere. Bulk-load a plan with <code>bggpipe shelve --import plan.csv</code> (name,opening, plus an optional acknowledge column for deliberate overhangs) — re-imports are upserts that keep notes, lanes, and acknowledgments — and check capacity anytime with <code>bggpipe dims</code>.</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> </div>
<h2 id="curation">Fixing the titles: edit, split, remove</h2> <h2 id="curation">Fixing the titles: edit, split, remove</h2>
<div class="card"> <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>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>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>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>remove</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title. The line and its matches are discarded and stay gone. This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know.</p> <p><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>change edition</b> (on any matched line whose printing is set) — reopens the full edition list in Review; the current pick stays until you choose, so backing out loses nothing. Note the pipeline never edits a version already set on your BGG collection entry (strictly additive): if diff reports a "version disagreement", either fix the pipeline's pick here, or fix the entry by hand on BGG — the pipeline won't overwrite it.</p>
<p><b>search BGG and tick them off</b> (inside the add panel) — for a box holding many games at once, like a compilation of two dozen expansions: search BGG once, tick every one that's in your box, and optionally name the container they all live inside. Each tick lands <i>already matched</i> — you picked it off BGG's own list, so resolve has nothing to re-derive; a pick whose name matches an undecided photo line decides that line instead of duplicating it. Contained games show "where it lives" in the Library, the container lists what's in it, and the shelf-space report knows they take no shelf of their own. Containment is editable after the fact: every game's Library detail page has a "where it lives" card — change it, clear it, or declare it there anytime.</p>
<p><b>remove</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title, or a <i>duplicate read</i> — the same physical copy read differently from two photos, leaving two lines for one box (remove the worse read; the survivor keeps its own photos). This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know. And the mirror case — ONE line that's really several physical copies — wants <b>split</b>, not remove.</p>
<p>Undo: each decision is one record in <code>data/title_edits.json</code>, <code>data/title_splits.json</code>, or <code>data/title_removals.json</code> — delete the record and the next rebuild restores the old state.</p> <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> </div>
<h2 id="statuses">What the status chips mean</h2> <h2 id="statuses">What the status chips mean</h2>
<div class="card"> <div class="card prose">
<p><span class="chip wait">awaiting BGG</span> extracted, not yet matched — usually waiting on the BGG API token.</p> <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 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 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 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 no">rejected</span> you ruled it's not on BGG (or not a game worth matching); it stays listed but goes no further.</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> <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> </div>
<h2 id="keys">Keyboard shortcuts</h2> <h2 id="keys">Keyboard shortcuts</h2>
<div class="card"> <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>m</kbd> manual BGG id · <kbd>u</kbd> edition unknown · <kbd>v</kbd> veto a merge.</p> <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> <p><b>Photo pages:</b> <kbd></kbd>/<kbd></kbd> move between photos.</p>
</div> </div>
<h2 id="files">Your data, on disk</h2> <h2 id="files">Your data, on disk</h2>
<div class="card"> <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>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 runs on localhost only.</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> <p>More depth: the README covers setup and photo technique; <code>docs/bgg-upload-flow.md</code> documents the upload automation.</p>
</div> </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>
+91 -17
View File
@@ -1,10 +1,30 @@
<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>where
<select id="libwhere" aria-label="filter by location">
<option value="">anywhere</option>
<option value="unshelved">unshelved</option>
</select></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 +34,83 @@ 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>` : ""}
${g.location && g.location.text
? `<div class="gmeta gloc">📍 ${esc(g.location.text)}</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,
g.location && g.location.text,
g.location && g.location.zone,
].filter(Boolean).join(" ").toLowerCase().includes(q));
const where = document.getElementById("libwhere").value;
if (where === "unshelved")
// same definition as the Shelves page: no opening and not riding a
// container — a note-only spot ("lent out") still counts as unshelved
rows = rows.filter(g => !(g.location && (g.location.unit || g.location.via)));
else if (where)
rows = rows.filter(g => g.location && g.location.unit === where);
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 +120,27 @@ 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, () => {
if (payload === LAST) return; GAMES = games;
LAST = payload; const units = [...new Set(games.map(g => g.location && g.location.unit)
GAMES = games; .filter(Boolean))].sort();
render(); const pick = document.getElementById("libwhere");
const current = pick.value;
pick.innerHTML = `<option value="">anywhere</option>
<option value="unshelved">unshelved</option>` +
units.map(u => `<option value="${esc(u)}">${esc(u)}</option>`).join("");
pick.value = current;
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.getElementById("libwhere").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,310 @@
<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\//, ""));
// entries enriched before descriptions were decoded at parse still hold
// &rsquo;-style entities: decode once before escaping for display
function decodeEntities(s) {
const ta = document.createElement("textarea");
ta.innerHTML = s;
return ta.value;
}
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) {
// mirror of _players() in export.py — keep the two in sync
const lo = g.min_players ?? g.max_players, hi = g.max_players ?? g.min_players;
if (!lo) return "";
const range = lo === hi ? `${lo}` : `${lo}${hi}`;
const noun = lo === 1 && hi === 1 ? "player" : "players";
const best = (g.best_player_counts || []).length
? ` (best at ${esc(g.best_player_counts.join(", "))})`
: "";
return `${range} ${noun}${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 wireLocation(g) {
document.getElementById("locsave")?.addEventListener("click", async () => {
const res = await apiPost("/api/locate", {
key: KEY,
opening_id: document.getElementById("locpick").value,
note: document.getElementById("locnote").value,
});
if (res) { showToast("shelved"); refresh(); }
});
document.getElementById("awaysave")?.addEventListener("click", async () => {
const where = document.getElementById("awayinput").value.trim();
const res = await apiPost("/api/away", {key: KEY, where});
if (res) { showToast(where ? "marked out" : "welcome home"); refresh(); }
});
}
function wireStored(g) {
const btn = document.getElementById("editstored");
if (!btn) return;
btn.addEventListener("click", async () => {
btn.closest("p").hidden = true; // the sentence yields to the form
const form = document.getElementById("storedform");
form.hidden = false;
const pick = document.getElementById("storedpick");
pick.focus();
const all = await fetchJSON("/api/library");
pick.innerHTML = `<option value="">its own box</option>` + all
.filter(o => o.bgg_id && o.bgg_id !== g.bgg_id)
.sort((a, b) => (a.name || "").localeCompare(b.name || ""))
.map(o => `<option value="${esc(String(o.bgg_id))}"
${String(o.bgg_id) === String(g.stored_in || "") ? "selected" : ""}>
${esc(o.name)}</option>`).join("");
});
document.getElementById("storedsave")?.addEventListener("click", async () => {
const res = await apiPost("/api/stored-in", {
key: KEY, container: document.getElementById("storedpick").value,
});
if (res) {
showToast("saved — the shelf math knows");
refresh();
}
});
}
function wireLocal(g) {
const form = document.getElementById("localform");
if (form) form.addEventListener("submit", async e => {
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 dims = g.dims && g.dims.width_in
? `${g.dims.width_in} × ${g.dims.length_in} × ${g.dims.depth_in} in`
+ (g.dims.weight_lb ? ` · ${g.dims.weight_lb} lb` : "")
+ (g.dims.source === "unanimous" ? " (all printings agree)" : "")
: "";
const facts = [
fact("players", players(g)),
fact("playing time", playtime(g)),
fact("box", dims),
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 loc = g.location || {};
const fitWord = f => f === true ? "fits" : f === false ? "doesn't fit"
: "can't verify";
const menu = (g.opening_menu || []).map(u => `
<optgroup label="${esc(u.unit)}">${u.openings.map(o => `
<option value="${esc(o.id)}" ${o.id === loc.opening_id ? "selected" : ""}>
${esc(o.label)}${o.zone ? `${esc(o.zone)}` : ""}
${o.virtual ? "" : ` (${fitWord(o.fit)})`}
</option>`).join("")}</optgroup>`).join("");
const awayRow = `<p id="awayform">
Right now:
${loc.away ? `<b>${esc(loc.away)}</b>` : `<span class="meta">home${loc.away_own === undefined ? "" : ""}</span>`}
<input id="awayinput" placeholder="travel case · loaned to Ben — blank = it's back"
value="${esc(loc.away_own || "")}" aria-label="where it is right now">
<button id="awaysave">save</button>
<span class="edithint">home stays reserved — this is where the box IS,
not where it lives</span>
</p>`;
const locBlock = g.stored_in_game
? `<p>It rides with its box: see
<a href="/library/game/${encodeURIComponent(g.stored_in_game.key)}">
${esc(g.stored_in_game.name)}</a>${loc.text && !loc.text.startsWith("inside")
? ` — currently ${esc(loc.text)}` : ""}${loc.away
? ` — right now: <b>${esc(loc.away)}</b>` : ""}.</p>${awayRow}`
: `<p>${loc.text
? `Lives: <b>${esc(loc.text)}</b>${loc.note && loc.text !== loc.note
? ` <span class="meta">(${esc(loc.note)})</span>` : ""}`
: `<span class="meta">Unshelved.</span>`}</p>
${awayRow}
<p id="locform">
<select id="locpick" aria-label="shelf opening">
<option value="">nowhere / a note only</option>${menu}
</select>
<input id="locnote" placeholder="note — lent to Sarah, June"
value="${esc(loc.note || "")}">
<button class="primary" id="locsave">save</button>
</p>`;
const boxed = `<h2>Where it lives</h2>
<div class="card prose">
${locBlock}
${g.stored_in_game
? `<p class="meta">This game has no box of its own — it lives inside
${esc(g.stored_in_game.name)}'s.
<button id="editstored">change</button></p>`
: g.stored_in
? `<p class="meta">Stored inside BGG game ${esc(g.stored_in)}
(not in the library yet — run <b>enrich</b>).
<button id="editstored">change</button></p>`
: `<p class="meta">In its own box.
<button id="editstored">it lives inside another box…</button></p>`}
<p id="storedform" hidden>
<select id="storedpick"><option value="">its own box</option></select>
<button class="primary" id="storedsave">save</button>
</p>
</div>`;
const contains = (g.contains || []).length
? `<h2>In this box</h2>
<div class="card prose"><p>${g.contains.map(c =>
`<a href="/library/game/${encodeURIComponent(c.key)}">${esc(c.name)}</a>`
).join(" · ")}</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}
${boxed}
${contains}
${photos}
${g.description && !local ? `<h2>About</h2>
<div class="card prose"><p class="gdesc">${esc(decodeEntities(g.description))}</p></div>` : ""}`;
wireLocal(g);
wireStored(g);
wireLocation(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>
+31 -19
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,8 +42,13 @@ 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` stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a> — <b>${P.titles}</b> so far`
@@ -57,25 +62,36 @@ function render() {
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;
@@ -83,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>`;
@@ -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
<p class="next">When it arrives, run <b>resolve</b> from the <a href="/">pipeline</a>.</p>` 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">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
+556
View File
@@ -0,0 +1,556 @@
<h1>Shelves</h1>
<div class="pagebar"><span id="shelfcount"></span></div>
<h2 class="formlabel">Add a unit</h2>
<form id="unitform" class="editform card">
<label>Unit name <input name="name" required placeholder="Living room Kallax"></label>
<label>Preset
<select name="preset">
<option value="kallax">IKEA Kallax cube (13.25 × 13.25 × 15.4)</option>
<option value="billy">IKEA Billy shelf (30.75 × 13 × 11)</option>
<option value="custom">custom size</option>
<option value="virtual">no size — travel case, lent out…</option>
</select></label>
<label>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label>
<label>Columns <input name="cols" inputmode="numeric" size="3" value="1"></label>
<span id="customdims" hidden>
<label>W <input name="width_in" size="5"></label>
<label>H <input name="height_in" size="5"></label>
<label>D <input name="depth_in" size="5"></label>
</span>
<label>Zone <input name="zone" placeholder="party games"></label>
<label class="wide">Description
<textarea name="description" rows="2"
placeholder="the wall behind the couch — main game storage"></textarea></label>
<span class="editactions">
<button type="submit" class="primary">create</button>
</span>
<span class="edithint">grid mode labels openings A1, A2… by row; you can edit,
add, or remove openings afterward — repeat with another grid to stack
sections (double-wides above cubes, say) in the same unit</span>
</form>
<div id="shelfbody"><p class="empty">Loading…</p></div>
<div id="opensheet" hidden>
<div class="sheetcard" role="dialog" aria-modal="true" aria-labelledby="sheettitle">
<div class="sheethead">
<b id="sheettitle"></b>
<button id="sheetclose" aria-label="close"></button>
</div>
<p id="sheetdesc" class="meta"></p>
<div id="sheetgames"></div>
<input type="search" id="sheetsearch" placeholder="add a game — search the library…">
<div id="sheetmatches"></div>
<details id="openingtools">
<summary>opening settings</summary>
<form id="openingform" class="editform">
<label>Label <input name="label"></label>
<label>Zone <input name="zone"></label>
<label class="wide">Description
<textarea name="description" rows="2"
placeholder="oversized boxes lie flat here — bottom row, easy reach"></textarea></label>
<label>W <input name="width_in" size="5"></label>
<label>H <input name="height_in" size="5"></label>
<label>D <input name="depth_in" size="5"></label>
<span class="editactions">
<button type="submit" class="primary">save</button>
<button type="button" id="openingup"
aria-label="move opening left within its row" title="move left in its row"></button>
<button type="button" id="openingdown"
aria-label="move opening right within its row" title="move right in its row"></button>
<button type="button" id="openingdelete" class="danger">delete opening</button>
</span>
</form>
</details>
</div>
</div>
<script>
"use strict";
let SHELVES = {units: [], unshelved: []};
let LIBRARY = [];
let OPEN = null; // opening id shown in the sheet
function openingById(id) {
for (const u of SHELVES.units)
for (const o of u.openings) if (o.id === id) return {...o, unit: u.name};
return null;
}
function fillBar(o) {
if (!o.width_in) return `<span class="meta">no size limits</span>`;
const flat = Math.min(100, Math.round((o.fill || 0) * 100));
const stand = Math.min(100, Math.round((o.standing_fill || 0) * 100));
const label = `flat stack ${flat}% of height, standing lane ${stand}% of width`
+ (o.overfull ? ", overfull" : "")
+ (o.unmeasured ? `, ${o.unmeasured} unmeasured boxes not counted` : "");
return `<div class="fillbar dual" role="img" aria-label="${esc(label)}">
<div style="width:${flat}%" class="${o.overfull ? "over" : ""}"
title="flat stack: ${flat}% of interior height"></div>
</div>
<div class="fillbar dual" role="img" aria-hidden="true">
<div style="width:${stand}%" class="stand ${o.overfull ? "over" : ""}"
title="standing lane: ${stand}% of remaining width"></div>
</div>`;
}
function openingCell(o) {
const why = [
o.overfull ? `no way to pack: ${o.laneless.join(", ")}` : "",
o.misfits.length ? "doesn't fit this opening at all: " + o.misfits.join(", ") : "",
].filter(Boolean).join("; ");
const warn = why
? `<span class="chip no" role="img" aria-label="${esc(why)}" title="${esc(why)}">⚠</span>`
: "";
const count = o.games
? `${o.games}${o.unmeasured ? ` · ${o.unmeasured} unmeasured` : ""}`
: "—";
return `<button class="opening" data-id="${esc(o.id)}"
${o.description ? `title="${esc(o.description)}"` : ""}
style="flex-grow:${Math.round((o.width_in || 13.25) * 4)}">
<span class="olabel">${esc(o.label)}${warn}</span>
${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""}
<span class="ocount">${count}</span>
${fillBar(o)}
</button>`;
}
function unitBlock(u) {
const cols = Math.max(1, Math.round(Math.sqrt(u.openings.length)));
return `<section class="card stack unit">
<div class="unithead"><h2>${esc(u.name)}</h2>
<span class="editactions">
<button class="dupunit" data-unit="${esc(u.name)}"
title="copy this unit's openings — sizes, zones, descriptions — as a new empty unit">
duplicate</button>
<button class="renameunit" data-unit="${esc(u.name)}">rename</button>
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
</span></div>
<p class="renamerow" hidden>
<input class="renameinput" value="${esc(u.name)}" aria-label="new unit name">
<button class="primary saverename" data-unit="${esc(u.name)}">save</button>
</p>
<p class="unitdesc">
<span class="meta">${esc(u.description || "")}</span>
<button class="editunitdesc" data-unit="${esc(u.name)}">
${u.description ? "edit description" : "add a description…"}</button>
<span class="descform" hidden>
<input class="unitdescinput" value="${esc(u.description || "")}"
placeholder="the wall behind the couch">
<button class="primary saveunitdesc" data-unit="${esc(u.name)}">save</button>
</span>
</p>
${unitRows(u.openings)}
<p class="formlabel small">Add an opening</p>
<form class="editform addopeningform" data-unit="${esc(u.name)}">
<label>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label>
<label>Columns <input name="cols" inputmode="numeric" size="3" value="1"></label>
<label>Label <input name="label" placeholder="wide top — for a single opening"></label>
<label>Zone <input name="zone"></label>
<label class="attop"><input type="checkbox" name="at_top"> at the top</label>
<label>W <input name="width_in" size="5" placeholder="13.25"></label>
<label>H <input name="height_in" size="5" placeholder="13.25"></label>
<label>D <input name="depth_in" size="5" placeholder="15.4"></label>
<span class="editactions"><button type="submit" class="primary">add</button></span>
<span class="edithint">a grid (rows × columns) continues the row letters —
3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label
for a single opening. All three sizes, or none for a no-limit spot.</span>
</form>
</section>`;
}
// the unit draws its ACTUAL arrangement: openings group into rows by
// their label letters (A1 A2 | B1..B4), and each cell's width is
// proportional to its interior width — a double-wide reads double wide
function unitRows(openings) {
const rows = [];
let key = null;
for (const o of openings) {
const m = /^([A-Za-z]+)\d+$/.exec(o.label || "");
const rowKey = m ? m[1].toUpperCase() : `single-${o.id}`;
if (rowKey !== key) { rows.push([]); key = rowKey; }
rows[rows.length - 1].push(o);
}
return `<div class="openings">` + rows.map(row =>
`<div class="orow">${row.map(openingCell).join("")}</div>`
).join("") + `</div>`;
}
function unshelvedRow(g) {
const dims = g.measured ? "" : ` <span class="chip open" title="no trusted size — fit can't be verified">unmeasured</span>`;
const gos = g.suggestions.map(s => {
const o = openingById(s.id);
if (!o) return "";
const why = s.reunites
? "its series-mates live here — fits, with room"
: "fits, with room to spare";
return `<button class="suggest ${s.reunites ? "reunites" : ""}"
data-key="${esc(g.key)}" data-id="${esc(s.id)}" title="${esc(why)}">
${s.reunites ? "♥ " : "→ "}${esc(o.unit)} · ${esc(o.label)}</button>`;
}).join("");
const gosOut = gos
|| (g.measured ? `<span class="meta">no matching openings yet</span>` : "");
return `<div class="unshelved-row">
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims}
${g.away ? ` <span class="chip open" title="no home shelf, and the box is
elsewhere right now">out: ${esc(g.away)}</span>` : ""}
${g.lost_home ? `<span class="chip no"
title="its assigned opening no longer exists — the shelves changed">shelf gone</span>` : ""}
${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""}
<span class="gos">${gosOut}</span>
</div>`;
}
function render() {
const total = SHELVES.units.reduce((n, u) => n + u.openings.length, 0);
document.getElementById("shelfcount").innerHTML =
`<b>${SHELVES.units.length}</b> unit(s), <b>${total}</b> opening(s), ` +
`<b>${SHELVES.unshelved.length}</b> game(s) unshelved`;
const body = document.getElementById("shelfbody");
if (!SHELVES.units.length) {
body.innerHTML = `<p class="empty">No shelves yet. Every home's furniture is
different — describe yours with <b>add a unit</b>: pick a preset or custom
size, give it rows and columns, and repeat for each bookcase, cube unit,
or travel case. Then games get homes.</p>`;
return;
}
body.innerHTML = SHELVES.units.map(unitBlock).join("") +
(SHELVES.unshelved.length
? `<section class="card stack"><h2>Unshelved</h2>` +
SHELVES.unshelved.map(unshelvedRow).join("") + `</section>`
: `<p class="empty">Everything has a home. 🎲</p>`);
wire();
}
function sheetRender() {
const o = openingById(OPEN);
if (!o) return;
document.getElementById("sheettitle").textContent =
`${o.unit} · ${o.label}` + (o.width_in
? ` (${o.width_in}×${o.height_in}×${o.depth_in} in)` : "");
// contents ride inside a box that IS listed — the box on the shelf is
// the physical object; what's inside collapses to a "holds N" hint
// (its games stay on the container's page and in the search exclusion)
const inside = {};
o.resident_games.filter(g => g.inherited).forEach(g => {
(inside[g.via] = inside[g.via] || []).push(g.name);
});
const physical = o.resident_games.filter(g => !g.inherited);
document.getElementById("sheetgames").innerHTML = physical.length
? physical.map(g => `
<div class="sheetrow">
${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""}
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>
${inside[g.key] ? `<span class="meta" title="${esc(inside[g.key].join(", "))}">
holds ${inside[g.key].length} inside</span>` : ""}
${g.away ? `<span class="chip open" title="this is its home spot — the box
itself is elsewhere right now (set from the game's page)">out: ${esc(g.away)}</span>` : ""}
${g.measured ? "" : `<button class="measurebtn" data-key="${esc(g.key)}"
title="no trusted size — tap to enter your own tape-measure numbers
(they outrank BGG's)">unmeasured — measure it</button>`}
${g.laneless && !g.acknowledged && g.lane_override
? `<button class="lanebtn" data-key="${esc(g.key)}" data-next=""
title="your ${esc(g.lane_override)} override left this box without
a lane — tap to undo and let the packer choose again">no room ✱ undo</button>` : ""}
${g.wedged
? `<span class="chip open" title="confirmed here in some arrangement the
packer can't model (crosswise, on top of a pile…) — outside the capacity bars,
never warned about">wedged in</span>` : ""}
${g.lane
? `<button class="lanebtn" data-key="${esc(g.key)}"
data-next="${g.lane === "flat" ? "standing" : "flat"}"
title="how this box sits — tap to flip${g.lane_override ? " (your override)" : ""}">
${g.lane === "flat" ? "▬ flat" : "▮ standing"}${g.proud ? ", proud" : ""}${g.lane_override ? " ✱" : ""}</button>`
: ""}
${g.acknowledged || g.laneless
? (g.acknowledged
? `<button class="ackbtn on" data-key="${esc(g.key)}" data-ack="false"
title="you confirmed this is really here — tap to retract">here ✓</button>`
: `<button class="ackbtn" data-key="${esc(g.key)}" data-ack="true"
title="the packer can't place this box, but shelves are 3-D and
you can see yours — confirm it's really here and the warning stands down">it's here anyway</button>`)
: ""}
<button class="unassign" data-key="${esc(g.key)}">remove</button>
</div>`).join("")
: `<p class="meta">Nothing here yet.</p>`;
document.getElementById("sheetdesc").textContent = o.description || "";
document.getElementById("sheetdesc").hidden = !o.description;
const f = document.getElementById("openingform");
f.elements.label.value = o.label;
f.elements.zone.value = o.zone || "";
f.elements.description.value = o.description || "";
f.elements.width_in.value = o.width_in ?? "";
f.elements.height_in.value = o.height_in ?? "";
f.elements.depth_in.value = o.depth_in ?? "";
}
let SHEET_OPENER = null; // the button to hand focus back to on close
function openSheet(id) {
OPEN = id;
SHEET_OPENER = document.activeElement;
document.getElementById("sheetsearch").value = "";
document.getElementById("sheetmatches").innerHTML = "";
document.getElementById("opensheet").hidden = false;
sheetRender();
document.getElementById("sheetsearch").focus();
}
function closeSheet() {
document.getElementById("opensheet").hidden = true;
OPEN = null;
if (SHEET_OPENER && document.contains(SHEET_OPENER)) SHEET_OPENER.focus();
SHEET_OPENER = null;
}
const GATE = changeGate();
async function refresh() {
// the library refreshes too: a game enriched mid-session must be
// findable in the sheet's search without a page reload
const [shelves, library] = await Promise.all([
fetchJSON("/api/shelves"), fetchJSON("/api/library"),
]);
GATE({shelves, library}, () => {
SHELVES = shelves;
LIBRARY = library;
render();
if (OPEN) sheetRender();
});
}
async function post(url, body) {
const res = await apiPost(url, body);
if (res) {
SHELVES = await res.json();
render();
if (OPEN) sheetRender();
}
return res;
}
function wire() {
document.querySelectorAll(".opening").forEach(b =>
b.onclick = () => openSheet(b.dataset.id));
document.querySelectorAll(".suggest").forEach(b =>
b.onclick = () => post("/api/locate", {key: b.dataset.key, opening_id: b.dataset.id}));
document.querySelectorAll(".delunit").forEach(b =>
b.onclick = () => {
if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`))
post("/api/furniture/delete-unit", {name: b.dataset.unit});
});
document.querySelectorAll(".dupunit").forEach(b =>
b.onclick = async () => {
const res = await post("/api/furniture/duplicate-unit", {name: b.dataset.unit});
if (res) showToast(`duplicated — rename the copy and start filling it`);
});
document.querySelectorAll(".renameunit").forEach(b =>
b.onclick = () => {
const row = b.closest(".unithead").nextElementSibling;
row.hidden = !row.hidden;
if (!row.hidden) row.querySelector("input").focus();
});
document.querySelectorAll(".saverename").forEach(b =>
b.onclick = () => post("/api/furniture/edit-unit", {
name: b.dataset.unit,
new_name: b.closest(".renamerow").querySelector("input").value,
}));
document.querySelectorAll(".editunitdesc").forEach(b =>
b.onclick = () => {
const form = b.closest(".unitdesc").querySelector(".descform");
form.hidden = !form.hidden;
if (!form.hidden) form.querySelector("input").focus();
});
document.querySelectorAll(".saveunitdesc").forEach(b =>
b.onclick = () => post("/api/furniture/edit-unit", {
name: b.dataset.unit,
description: b.closest(".descform").querySelector("input").value,
}));
document.querySelectorAll(".addopeningform").forEach(form =>
form.onsubmit = async e => {
e.preventDefault();
const f = form.elements;
const dims = [f.width_in.value, f.height_in.value, f.depth_in.value];
const given = dims.filter(v => v.trim()).length;
if (given && given < 3) {
alert("Give all three interior dimensions, or none for a no-limit spot.");
return;
}
const rows = parseInt(f.rows.value) || 1;
const cols = parseInt(f.cols.value) || 1;
const grid = rows * cols > 1;
if (!grid && !f.label.value.trim()) {
alert("A single opening needs a label (or set rows × columns for a grid).");
return;
}
const btn = form.querySelector('button[type="submit"]');
if (btn.disabled) return;
btn.disabled = true;
try {
await post("/api/furniture/add-openings", {
unit: form.dataset.unit, zone: f.zone.value,
at_top: f.at_top.checked,
...(grid ? {rows, cols} : {label: f.label.value}),
width_in: parseFloat(f.width_in.value) || null,
height_in: parseFloat(f.height_in.value) || null,
depth_in: parseFloat(f.depth_in.value) || null,
});
} finally {
btn.disabled = false;
}
});
}
// -- the sheet ----------------------------------------------------------
document.getElementById("sheetclose").addEventListener("click", closeSheet);
document.addEventListener("keydown", e => {
if (e.key === "Escape" && OPEN) closeSheet();
});
// close only when BOTH press and release land on the backdrop — a text-
// selection drag out of the search box must not dismiss the sheet
let downOnBackdrop = false;
document.getElementById("opensheet").addEventListener("pointerdown", e => {
downOnBackdrop = e.target.id === "opensheet";
});
document.getElementById("opensheet").addEventListener("click", e => {
if (downOnBackdrop && e.target.id === "opensheet") closeSheet();
downOnBackdrop = false;
});
document.getElementById("sheetgames").addEventListener("click", e => {
const b = e.target.closest(".unassign");
if (b) { post("/api/locate", {key: b.dataset.key, opening_id: ""}); return; }
const lane = e.target.closest(".lanebtn");
if (lane) { post("/api/locate", {
key: lane.dataset.key, opening_id: OPEN, lane: lane.dataset.next,
}); return; }
const ack = e.target.closest(".ackbtn");
if (ack) { post("/api/acknowledge", {
key: ack.dataset.key, acknowledged: ack.dataset.ack === "true",
}); return; }
const mb = e.target.closest(".measurebtn");
if (mb) {
const span = document.createElement("span");
span.className = "measureform";
span.innerHTML = `
<input type="number" step="0.05" min="0.1" placeholder="W" aria-label="width, inches">
× <input type="number" step="0.05" min="0.1" placeholder="L" aria-label="length, inches">
× <input type="number" step="0.05" min="0.1" placeholder="D" aria-label="depth, inches"> in
<button class="measuresave" data-key="${mb.dataset.key}">save</button>`;
mb.replaceWith(span);
span.querySelector("input").focus();
return;
}
const ms = e.target.closest(".measuresave");
if (ms) {
const [w, l, d] = [...ms.parentElement.querySelectorAll("input")]
.map(i => parseFloat(i.value) || null);
if (w && l && d) post("/api/measure", {
key: ms.dataset.key, width_in: w, length_in: l, depth_in: d,
});
}
});
document.getElementById("sheetsearch").addEventListener("input", () => {
const q = document.getElementById("sheetsearch").value.trim().toLowerCase();
const box = document.getElementById("sheetmatches");
if (!q) { box.innerHTML = ""; return; }
const here = new Set((openingById(OPEN)?.resident_games || []).map(g => g.key));
const hits = LIBRARY.filter(g =>
!here.has(g.key) && (g.name || "").toLowerCase().includes(q)).slice(0, 8);
box.innerHTML = hits.map(g => `
<button class="sheetadd" data-key="${esc(g.key)}">
${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""}
${esc(g.name)}
${g.stored_in ? `<span class="meta">inside another box</span>` : ""}
</button>`).join("");
box.querySelectorAll(".sheetadd").forEach(b =>
b.onclick = () => post("/api/locate", {key: b.dataset.key, opening_id: OPEN}));
});
document.getElementById("openingform").addEventListener("submit", e => {
e.preventDefault();
const f = e.target.elements;
const given = [f.width_in.value, f.height_in.value, f.depth_in.value]
.filter(v => v.trim()).length;
if (given && given < 3) {
alert("Give all three interior dimensions, or none to remove size limits.");
return;
}
post("/api/furniture/edit-opening", {
id: OPEN, label: f.label.value, zone: f.zone.value,
description: f.description.value,
width_in: parseFloat(f.width_in.value) || null,
height_in: parseFloat(f.height_in.value) || null,
depth_in: parseFloat(f.depth_in.value) || null,
});
});
document.getElementById("openingup").addEventListener("click", () =>
post("/api/furniture/move-opening", {id: OPEN, direction: -1}));
document.getElementById("openingdown").addEventListener("click", () =>
post("/api/furniture/move-opening", {id: OPEN, direction: 1}));
document.getElementById("openingdelete").addEventListener("click", () => {
if (confirm("Delete this opening? Its games become unshelved.")) {
const gone = OPEN;
closeSheet();
post("/api/furniture/delete-opening", {id: gone});
}
});
// -- add-a-unit flow ----------------------------------------------------
const PRESETS = {
kallax: {width_in: 13.25, height_in: 13.25, depth_in: 15.4},
billy: {width_in: 30.75, height_in: 13, depth_in: 11},
custom: null,
virtual: {width_in: null, height_in: null, depth_in: null},
};
const unitForm = document.getElementById("unitform");
syncDims();
function syncDims() {
const v = unitForm.elements.preset.value;
document.getElementById("customdims").hidden = v !== "custom";
if (v === "virtual") {
unitForm.elements.rows.value = 1;
unitForm.elements.cols.value = 1;
}
}
unitForm.elements.preset.addEventListener("change", syncDims);
unitForm.addEventListener("submit", async e => {
e.preventDefault();
// a double-click (or a click retried against the re-rendering DOM)
// must not create a second grid: one submit in flight at a time
const createBtn = unitForm.querySelector('button[type="submit"]');
if (createBtn.disabled) return;
createBtn.disabled = true;
try {
const f = unitForm.elements;
const preset = PRESETS[f.preset.value];
const dims = f.preset.value === "custom"
? {width_in: parseFloat(f.width_in.value) || null,
height_in: parseFloat(f.height_in.value) || null,
depth_in: parseFloat(f.depth_in.value) || null}
: preset || {};
// create-then-fill is two calls: an existing empty unit (a previous
// half-failure) is fine to fill, so a 409 on step one does not stop us
const existing = SHELVES.units.find(u => u.name === f.name.value.trim());
if (!existing || existing.openings.length) {
const made = await post("/api/furniture/add-unit", {
name: f.name.value, description: f.description.value,
});
if (!made && !existing) return;
}
const filled = await post("/api/furniture/add-openings", {
unit: f.name.value.trim(), zone: f.zone.value,
rows: parseInt(f.rows.value) || 1, cols: parseInt(f.cols.value) || 1,
...dims,
});
if (!filled) return; // the unit exists but empty: resubmit retries
showToast(`<b>${esc(f.name.value)}</b> is up — click any opening to fill it,
or add more openings for its other sections`);
unitForm.reset();
syncDims();
} finally {
createBtn.disabled = false;
}
});
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 12000, () => showBanner(""));
</script>
+219 -20
View File
@@ -4,12 +4,42 @@
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter titles"> <input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter titles">
<button id="shakyfilter" aria-pressed="false" hidden <button id="shakyfilter" aria-pressed="false" hidden
title="reads the model wasn't sure of — confirm or fix each one">shaky reads</button> 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. A whole box of expansions? <button type="button" id="topicks">search BGG and tick them off</button></span>
</form>
<div id="pickpanel" class="editform card" hidden>
<label>Search BGG <input id="pickq" placeholder="red dragon inn" size="28"></label>
<span class="editactions"><button type="button" id="dopicksearch" class="primary">search</button>
<button type="button" id="pickcancel">cancel</button></span>
<div id="pickresults"></div>
<div id="pickfooter" hidden>
<label>They all live inside
<select id="pickcontainer"><option value="">their own boxes</option></select>
</label>
<button type="button" id="addpicks" class="primary">add checked</button>
<span class="edithint">each checked result is added already-matched (you just
picked it off BGG's list) — no resolve pass needed</span>
</div>
</div> </div>
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div> <div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
<script> <script>
"use strict"; "use strict";
let CATALOG = []; let CATALOG = [];
let EDITING = null; // lineKey of the row whose editor is open let EDITING = null; // lineKey of the row whose editor is open
let PICKS = []; // last BGG search results shown in the pick panel
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); } function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
@@ -18,7 +48,7 @@ function editorRow(c) {
return ` return `
<tr class="editrow"><td colspan="5"> <tr class="editrow"><td colspan="5">
<form class="editform" data-title="${esc(c.title_raw)}" <form class="editform" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}"> data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}">
<label>Title <input name="title" value="${esc(c.title_raw)}" required></label> <label>Title <input name="title" value="${esc(c.title_raw)}" required></label>
<label>Publisher <input name="publisher" value="${esc(cue.publisher || "")}"></label> <label>Publisher <input name="publisher" value="${esc(cue.publisher || "")}"></label>
<label>Edition <input name="edition" value="${esc(cue.edition || "")}"></label> <label>Edition <input name="edition" value="${esc(cue.edition || "")}"></label>
@@ -27,7 +57,12 @@ function editorRow(c) {
<span class="editactions"> <span class="editactions">
<button type="submit" class="primary">save</button> <button type="submit" class="primary">save</button>
<button type="button" class="canceledit">cancel</button> <button type="button" class="canceledit">cancel</button>
<button type="button" class="removetitle danger">remove from catalog</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>
<span class="edithint">saving re-queues this title for resolve with the corrected data</span> <span class="edithint">saving re-queues this title for resolve with the corrected data</span>
</form> </form>
@@ -59,12 +94,12 @@ function render() {
${c.shaky ? `<span class="chip shaky" ${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> 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>${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 class="meta">${c.photos.length
${c.type === "rpgitem" ? ` <span class="chip open">RPG · local only</span>` : ""}</td> ? c.photos.map(p =>
<td class="meta">${c.photos.map(p => `<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>` ).join(", ")
).join(", ")}</td> : `<span class="chip merged">added by hand</span>`}</td>
<td class="actions">${c.can_split <td class="actions">${c.can_split
? `<button class="split" data-title="${esc(c.title_raw)}" ? `<button class="split" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-photos="${esc(c.photos.join(";"))}"
@@ -72,6 +107,28 @@ function render() {
title="one line, several boxes? make each photo its own copy"> title="one line, several boxes? make each photo its own copy">
split into copies</button>` 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)
&& c.version_status !== "version_ambiguous"
? ["version_auto", "version_approved"].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="wrong printing? reopen the full edition list — the current pick stays until you choose">
change edition</button>`
: `<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)}" ${c.shaky ? `<button class="confirmread" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-photos="${esc(c.photos.join(";"))}"
title="the read is correct as-is — mark it verified">✓ looks right</button>` : ""} title="the read is correct as-is — mark it verified">✓ looks right</button>` : ""}
@@ -84,20 +141,50 @@ function render() {
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`; : `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
} }
let LAST = null; const GATE = changeGate();
async function refresh() { async function refresh() {
const state = await fetchJSON("/api/state"); const state = await fetchJSON("/api/state");
if (EDITING) return; // never repaint under an open editor if (EDITING) return; // never repaint under an open editor
const payload = JSON.stringify(state.catalog); GATE(state.catalog, () => { CATALOG = state.catalog; render(); });
if (payload === LAST) return;
LAST = payload;
CATALOG = state.catalog;
render();
} }
document.getElementById("catbody").addEventListener("click", async e => { document.getElementById("catbody").addEventListener("click", async e => {
const cancel = e.target.closest("button.canceledit"); const cancel = e.target.closest("button.canceledit");
if (cancel) { EDITING = null; LAST = null; render(); refresh().catch(() => {}); return; } 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"); const ok = e.target.closest("button.confirmread");
if (ok) { if (ok) {
const res = await apiPost("/api/edit-title", { const res = await apiPost("/api/edit-title", {
@@ -105,20 +192,23 @@ document.getElementById("catbody").addEventListener("click", async e => {
source_photos: ok.dataset.photos, source_photos: ok.dataset.photos,
confirm: true, confirm: true,
}); });
if (res) { LAST = null; refresh().catch(() => {}); } if (res) { GATE.reset(); refresh().catch(() => {}); }
return; return;
} }
const rm = e.target.closest("button.removetitle"); const rm = e.target.closest("button.removetitle");
if (rm) { if (rm) {
const f = rm.closest("form.editform"); const f = rm.closest("form.editform");
if (!confirm(`Remove "${f.dataset.title}" from the catalog? ` + 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 ` + `Re-running extract won't bring it back — the removal is saved ` +
`in data/title_removals.json (delete its record there to undo).`)) return; `in data/title_removals.json (delete its record there to undo).`)) return;
const res = await apiPost("/api/remove-title", { const res = await apiPost("/api/remove-title", {
title_raw: f.dataset.title, title_raw: f.dataset.title,
source_photos: f.dataset.photos, source_photos: f.dataset.photos,
}); });
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); } if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); }
return; return;
} }
const edit = e.target.closest("button.edit"); const edit = e.target.closest("button.edit");
@@ -157,12 +247,121 @@ document.getElementById("catbody").addEventListener("submit", async e => {
// saving unchanged: on a shaky line that means "it's right as-is" // saving unchanged: on a shaky line that means "it's right as-is"
if (orig.shaky) { if (orig.shaky) {
const res = await apiPost("/api/edit-title", { ...body, confirm: true }); const res = await apiPost("/api/edit-title", { ...body, confirm: true });
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); return; } if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); return; }
} }
EDITING = null; render(); return; EDITING = null; render(); return;
} }
const res = await apiPost("/api/edit-title", body); const res = await apiPost("/api/edit-title", body);
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); } 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();
});
// -- the pick panel: one BGG search, tick off a whole box ---------------
const pickPanel = document.getElementById("pickpanel");
document.getElementById("topicks").addEventListener("click", () => {
addForm.hidden = true;
pickPanel.hidden = false;
document.getElementById("pickq").focus();
});
document.getElementById("pickcancel").addEventListener("click", () => {
pickPanel.hidden = true;
document.getElementById("pickresults").innerHTML = "";
document.getElementById("pickfooter").hidden = true;
});
async function pickSearch() {
const q = document.getElementById("pickq").value.trim();
if (!q) return;
const box = document.getElementById("pickresults");
box.innerHTML = `<p class="meta">searching BGG…</p>`;
let data;
try {
data = await fetchJSON(`/api/bgg-search?q=${encodeURIComponent(q)}`);
} catch (err) {
box.innerHTML = `<p class="meta">search failed: ${esc(err.message || err)}</p>`;
return;
}
PICKS = data.results;
box.innerHTML = PICKS.length
? PICKS.map((r, i) => `
<label class="pickrow">
<input type="checkbox" data-pick-ix="${i}" ${r.already ? "disabled" : ""}>
${esc(r.name)} <span class="meta">${esc(r.year ?? "—")} · ${esc(r.type)}</span>
${r.already ? `<span class="chip ok">already cataloged</span>` : ""}
</label>`).join("")
: `<p class="meta">BGG found nothing for that.</p>`;
document.getElementById("pickfooter").hidden = !PICKS.length;
// container choices: anything already matched, the searched family first
const owned = CATALOG.filter(c => c.bgg_id && ["auto", "approved"].includes(c.status));
const ql = q.toLowerCase();
owned.sort((a, b) =>
(b.bgg_name || "").toLowerCase().includes(ql) -
(a.bgg_name || "").toLowerCase().includes(ql));
document.getElementById("pickcontainer").innerHTML =
`<option value="">their own boxes</option>` + owned.map(c =>
`<option value="${esc(c.bgg_id)}">${esc(c.bgg_name)}</option>`).join("");
}
document.getElementById("dopicksearch").addEventListener("click", pickSearch);
document.getElementById("pickq").addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); pickSearch(); }
});
document.getElementById("addpicks").addEventListener("click", async () => {
const picks = Array.from(
document.querySelectorAll("#pickresults input:checked")
).map(cb => PICKS[Number(cb.dataset.pickIx)]);
if (!picks.length) return;
const res = await apiPost("/api/add-picked", {
picks, stored_in: document.getElementById("pickcontainer").value,
});
if (res) {
// apiPost returns the raw Response; the counts live in its body
const data = await res.json().catch(() => ({added: picks.length, skipped: []}));
showToast(`added <b>${data.added}</b> game(s)` +
(data.skipped.length ? `${data.skipped.length} skipped (already on file)` : ""));
pickPanel.hidden = true;
document.getElementById("pickresults").innerHTML = "";
document.getElementById("pickfooter").hidden = true;
GATE.reset();
refresh().catch(() => {});
}
});
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("catsearch").addEventListener("input", render);
+8 -4
View File
@@ -5,7 +5,9 @@
<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="stylesheet" href="/static/app.css"> <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?v=__ASSET_V__">
</head> </head>
<body> <body>
<a class="skip" href="#main">Skip to content</a> <a class="skip" href="#main">Skip to content</a>
@@ -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,8 +31,8 @@
</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?v=__ASSET_V__"></script>
<main id="main"> <main id="main">
<!--PAGE--> <!--PAGE-->
</main> </main>
+401 -83
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
self._visible(dialog, self._NEXT_PAGE).click()
self._page.wait_for_timeout(400)
return False 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:
status = "added_no_version" picked, why = self._select_version(dialog, job.version_name)
note = f"version {job.version_name!r} not in picker; added without version" if picked:
note = why.strip()
else:
status = "added_no_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.
dialog.wait_for(state="hidden", timeout=15_000) try:
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"
) )
dialog.get_by_role("button", name="Save").click() cell.first.click()
dialog.wait_for(state="hidden", timeout=15_000) radio = page.locator(
return "updated", "row-edit flow is unverified; confirm with --verify" 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"
)
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:
+1566 -76
View File
File diff suppressed because it is too large Load Diff
+122 -38
View File
@@ -1,40 +1,124 @@
[ [
{ {
"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", },
"confidence": "high", {
"publisher_hint": "Stonemaier Games", "title_raw": "Wingspan",
"year_hint": 2019, "confidence": "high",
"language_hint": "English", "publisher_hint": "Stonemaier Games",
"source_photos": ["hand-typed-test-list"] "year_hint": 2019,
}, "language_hint": "English",
{ "source_photos": [
"title_raw": "Wingspan: European Expansion", "hand-typed-test-list"
"confidence": "high", ]
"source_photos": ["hand-typed-test-list"] },
}, {
{ "title_raw": "Wingspan: European Expansion",
"title_raw": "Wingspan Europe", "confidence": "high",
"confidence": "medium", "source_photos": [
"source_photos": ["hand-typed-test-list"] "hand-typed-test-list"
}, ]
{ },
"title_raw": "Café International", {
"confidence": "high", "title_raw": "Wingspan Europe",
"source_photos": ["hand-typed-test-list"] "confidence": "medium",
}, "source_photos": [
{ "hand-typed-test-list"
"title_raw": "Citadels", ]
"confidence": "high", },
"source_photos": ["hand-typed-test-list"] {
}, "title_raw": "Café International",
{ "confidence": "high",
"title_raw": "Blorvath: Quest of the Zzyzx", "source_photos": [
"confidence": "low", "hand-typed-test-list"
"source_photos": ["hand-typed-test-list"] ]
} },
{
"title_raw": "Citadels",
"confidence": "high",
"source_photos": [
"hand-typed-test-list"
]
},
{
"title_raw": "Blorvath: Quest of the Zzyzx",
"confidence": "low",
"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>

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