38 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
51 changed files with 17387 additions and 4394 deletions
+2
View File
@@ -3,6 +3,8 @@
*.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/
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
+6 -3
View File
@@ -14,13 +14,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| 4 | `bggpipe diff` | diff approved matches against the existing BGG collection | working |
| 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 |
| + | `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 `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
## 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 run bggpipe web` — the app: seven pages (Pipeline `/`, Photos, Titles, Review, Queue, Library, Help) in a shared sidebar shell (responsive: hamburger nav + stacked tables under 900px); stage runs execute one-at-a-time in a background job. `--lan` binds 0.0.0.0 behind a per-device access key: persisted in `data/.lan_key` (gitignored), printed as a QR at startup, cookie-paired for a year, required on EVERY network request (loopback clients and `/static/*` are exempt; the Host/Origin guard still applies). Phone camera uploads (generic `image.jpg` names) get minted `shelf-<timestamp>` names — only explicitly-named files trigger the replace-to-reshoot flow.
- `uv run bggpipe 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` (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 ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format.
@@ -47,11 +50,11 @@ Full design lives in `docs/spec.md` (read it before changing pipeline semantics)
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), and `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
- **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). 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.
## Git
- 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/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
- 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`.
+6 -3
View File
@@ -1,5 +1,8 @@
# 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">
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,7 +14,7 @@ photos/ → [1 extract] → titles.json → [2 resolve] → matches.csv
→ [6 enrich] → games.json
```
> **Status: battle-tested end to end.** The full pipeline has run against a live BGG account: shelf photos → 136 identified games → 62 additions and 36 version updates on a real collection. The result is public — [the author's collection on BGG](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built. Still sensible on a first run: `--dry-run`, then `--limit 1`.
> **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
@@ -24,13 +27,13 @@ BGG has no bulk import and no write API. Cataloging a few hundred games by hand
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).
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, version details) lands in `data/games.json`, feeding a browsable library of your shelves.
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).
Everything runs locally, every stage survives being killed mid-run, and all artifacts are flat CSV/JSON files you can inspect and edit. You can drive it from the terminal or from a local web app:
![Pipeline dashboard: six stage cards with live counts and Run buttons, showing a completed pipeline — 136 titles extracted, 115 resolved, 62 games added, 136 in the library](docs/screenshots/01.png)
**➔ [See the full tour](docs/tour.md)** — all seven pages, the game-detail view, and the phone experience.
**➔ [See the full tour](docs/tour.md)** — all eight pages, the game-detail view, and the phone experience.
## Requirements
+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"
}
]
}
]
}
+8032 -4221
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 -137
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
+240
View File
@@ -28,5 +28,245 @@
"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": []
}
]
+264
View File
@@ -1779,5 +1779,269 @@
"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"
}
]
+88
View File
@@ -115,3 +115,91 @@ 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
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
+139 -1
View File
@@ -40,7 +40,7 @@ Non-secret knobs live in `config.toml`: `photos_dir`, `data_dir`, the BGG rate l
bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
```
Seven pages — Pipeline, Photos, Titles, Review, Queue, Library, and Help — all [pictured in the tour](tour.md). Stage runs execute one at a time in the background with live output:
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)
@@ -109,6 +109,144 @@ BGG application approval can take a week or more. Until then: `extract` works im
- `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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

+1
View File
@@ -18,6 +18,7 @@ Beyond the bare game, capture **which edition/version I own** wherever the photo
- 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 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.
- 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).
+5 -1
View File
@@ -1,6 +1,6 @@
# A tour of bggpipe
Seven pages in one local app — Pipeline, Photos, Titles, Review, Queue, Library, and Help. Every screenshot below is the real app on the author's real shelves, and the end product is public: [the author's BGG collection](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built. (Back to the [README](../README.md) · how to use it all: the [user's guide](guide.md).)
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.
@@ -34,6 +34,10 @@ Seven pages in one local app — Pipeline, Photos, Titles, Review, Queue, Librar
![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)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Shelf-to-BGG collection pipeline."""
__version__ = "1.0.0"
__version__ = "1.1.0"
+56
View File
@@ -191,3 +191,59 @@ def enrich(
cfg = load_config(config)
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)
+16
View File
@@ -75,6 +75,10 @@ class Config:
def games_path(self) -> Path:
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
@@ -107,6 +111,18 @@ class Config:
"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
+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),
}
+113 -1
View File
@@ -27,12 +27,104 @@ from bggpipe.bgg_client import (
)
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import is_confident_version, is_recognized
from bggpipe.models import (
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
_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:
if not is_confident_version(row):
@@ -184,6 +276,24 @@ def run_enrich(
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)
atomic_write_text(
games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
@@ -194,6 +304,8 @@ def run_enrich(
# 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:
+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),
}
+34 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import html
import warnings
import xml.etree.ElementTree as ET # parsing itself goes via defusedxml
from dataclasses import dataclass, field
@@ -22,6 +23,11 @@ PENDING_MATCH_STATUSES = ("ambiguous", "unmatched")
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:
"""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"])
@@ -228,7 +234,12 @@ def parse_things_full(xml_text: str) -> list[dict]:
"type": item.get("type", "boardgame"),
"name": name.get("value", "") if name is not None else "",
"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(),
"thumbnail": (item.findtext("thumbnail") or "").strip(),
"min_players": _attr_int(item.find("minplayers")),
@@ -268,6 +279,28 @@ def parse_things_full(xml_text: str) -> list[dict]:
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:
value = item.get(name)
if not value:
+3
View File
@@ -57,6 +57,9 @@ MATCH_COLUMNS = [
"source_photos",
"merged_into",
"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",
]
+4 -2
View File
@@ -514,8 +514,10 @@ class ReviewSession:
ensure_ascii=False,
)
row["version_status"] = "version_ambiguous"
row["version_id"] = ""
row["version_name"] = ""
# 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)
+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}
+168
View File
@@ -498,6 +498,174 @@ a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
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 {
background: var(--board); border: 2px dashed var(--board-edge);
border-radius: var(--radius-lg); padding: 2rem; text-align: center;
+3
View File
@@ -30,6 +30,7 @@
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
<p><b><a href="/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>
@@ -41,6 +42,8 @@
<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>
</div>
+29 -1
View File
@@ -12,6 +12,11 @@
<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>
@@ -47,6 +52,8 @@ function gameCard(g) {
${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>
${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>
</a>`;
}
@@ -87,7 +94,16 @@ function render() {
(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));
@@ -107,12 +123,24 @@ function render() {
const GATE = changeGate();
async function refresh() {
const games = await fetchJSON("/api/library");
GATE(games, () => { GAMES = games; render(); });
GATE(games, () => {
GAMES = games;
const units = [...new Set(games.map(g => g.location && g.location.unit)
.filter(Boolean))].sort();
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("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", () => {
KIND = b.dataset.kind;
document.querySelectorAll("[data-kind]").forEach(o =>
+130 -5
View File
@@ -8,6 +8,14 @@
"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>
@@ -22,13 +30,15 @@ function fact(label, value) {
}
function players(g) {
const lo = g.min_players, hi = g.max_players;
if (!lo && !hi) return "";
const range = lo === hi ? `${lo}` : `${lo ?? "?"}${hi ?? "?"}`;
// 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} players${best}`;
return `${range} ${noun}${best}`;
}
function playtime(g) {
@@ -60,6 +70,50 @@ function localForm(g) {
</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 => {
@@ -124,9 +178,15 @@ function render(g) {
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 || ""),
@@ -151,6 +211,67 @@ function render(g) {
<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 =>
@@ -165,10 +286,14 @@ function render(g) {
</div>
${local ? localForm(g) : ""}
${version}
${boxed}
${contains}
${photos}
${g.description && !local ? `<h2>About</h2>
<div class="card prose"><p class="gdesc">${esc(g.description)}</p></div>` : ""}`;
<div class="card prose"><p class="gdesc">${esc(decodeEntities(g.description))}</p></div>` : ""}`;
wireLocal(g);
wireStored(g);
wireLocation(g);
}
async function refresh() {
+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>
+96 -6
View File
@@ -17,13 +17,29 @@
<button type="submit" class="primary">add</button>
<button type="button" id="addcancel">cancel</button>
</span>
<span class="edithint">joins the titles list like a photo read — resolve matches it next run</span>
<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 id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
<script>
"use strict";
let CATALOG = [];
let EDITING = null; // lineKey of the row whose editor is open
let PICKS = []; // last BGG search results shown in the pick panel
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
@@ -102,11 +118,16 @@ function render() {
title="this copy's edition ballot is open">ballot in Review →</a>`
: ""}
${c.bgg_id && ["auto", "approved"].includes(c.status)
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
? `<button class="pickedition" data-title="${esc(c.title_raw)}"
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
title="you know which printing this box is — fetch the full edition list into Review">
pick edition</button>`
&& c.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)}"
data-photos="${esc(c.photos.join(";"))}"
@@ -258,6 +279,75 @@ document.getElementById("addcancel").addEventListener("click", () => {
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;
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<meta name="apple-mobile-web-app-title" content="bggpipe">
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/app.css?v=__ASSET_V__">
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
@@ -32,7 +32,7 @@
</div>
<div class="content">
<div id="banner" role="status"></div>
<script src="/static/app.js"></script>
<script src="/static/app.js?v=__ASSET_V__"></script>
<main id="main">
<!--PAGE-->
</main>
+898 -4
View File
@@ -20,6 +20,7 @@ import hashlib
import io
import json
import os
import re
import secrets
import socket
import threading
@@ -60,8 +61,25 @@ from bggpipe.models import (
RECOGNIZED_MATCH_STATUSES,
)
from bggpipe.normalize import normalize_title
from bggpipe.resolve import TitleEntry
from bggpipe.resolve import MATCH_COLUMNS, TitleEntry, read_matches, write_matches
from bggpipe.review import _BGG_ERRORS, ReviewSession
from bggpipe.shelves import (
apply_measurements,
effective_away,
effective_location,
fits_opening,
game_dims,
load_furniture,
load_locations,
load_measurements,
new_opening_id,
opening_index,
opening_report,
related,
save_furniture,
save_locations,
save_measurements,
)
def load_thumbnails(cache_dir: Path) -> dict[int, str]:
@@ -211,6 +229,81 @@ class ResearchBody(BaseModel):
types: str | None = None # e.g. "rpgitem" to search RPGGeek
class UnitBody(BaseModel):
name: str
# None = leave alone (rename-only edits must not wipe a description)
description: str | None = None
new_name: str = "" # edit-unit only: rename
class OpeningsBody(BaseModel):
"""One opening, or a rows × cols grid of identical ones."""
unit: str
zone: str = ""
at_top: bool = False # new openings land ABOVE the existing rows
label: str = "" # single mode
rows: int = 0 # grid mode when rows*cols > 0
cols: int = 0
width_in: float | None = None
height_in: float | None = None
depth_in: float | None = None
class OpeningEditBody(BaseModel):
id: str
label: str | None = None
zone: str | None = None
description: str | None = None # free text for humans; zone is the keyword
# all three or none: a partial size cannot exist (see the endpoint)
width_in: float | None = None
height_in: float | None = None
depth_in: float | None = None
class OpeningMoveBody(BaseModel):
id: str
direction: int # -1 up, +1 down within its unit
class OpeningDeleteBody(BaseModel):
id: str
class LocateBody(BaseModel):
key: str
opening_id: str = "" # "" = unshelve
note: str | None = None # None = leave the note alone; "" = clear it
lane: str = "" # "", "flat", "standing": how THIS box sits in the opening
class MeasureBody(BaseModel):
key: str
width_in: float | None = None # all three set = save; all empty = clear
length_in: float | None = None
depth_in: float | None = None
class AwayBody(BaseModel):
key: str
where: str = "" # "travel case", "loaned to Ben"; "" = back home
class AcknowledgeBody(BaseModel):
key: str
acknowledged: bool # True = "doesn't fit, shelved anyway" is deliberate
class StoredInBody(BaseModel):
key: str # games.json key of the game being housed
container: str = "" # container's bgg_id; "" = its own box again
class PickBody(BaseModel):
picks: list[dict] # {bgg_id, name, year, type} straight from search
stored_in: str = "" # container's bgg_id: all picks live in ONE box
class RemoveBody(BaseModel):
title_raw: str
source_photos: str = ""
@@ -274,6 +367,19 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]:
# (href, page-name, label, badge-name) — badge names match app.js
# ordered as the real workflow runs: photograph, proofread the reads
# (titles), resolve + decide (review), then what ships (queue, library)
def _asset_stamp() -> str:
"""Eight hex chars over the static bundle's bytes. Computed once per
request path is fine the files cannot change under a running
server, and the read is microseconds."""
import hashlib
static = resources.files("bggpipe") / "static"
digest = hashlib.md5()
for name in ("app.css", "app.js"):
digest.update((static / name).read_bytes())
return digest.hexdigest()[:8]
NAV_PAGES = (
("/", "pipeline", "Pipeline", ""),
("/photos", "photos", "Photos", "photos"),
@@ -281,6 +387,7 @@ NAV_PAGES = (
("/review", "review", "Review", "review"),
("/queue", "queue", "Queue", "queue"),
("/library", "library", "Library", ""),
("/shelves", "shelves", "Shelves", ""),
("/help", "help", "Help", ""),
)
@@ -502,7 +609,7 @@ def create_app(
if not cfg.games_path.exists():
return {}
try:
return json.loads(cfg.games_path.read_text())
return apply_measurements(cfg, json.loads(cfg.games_path.read_text()))
except (json.JSONDecodeError, OSError) as err:
warn_once(f"{cfg.games_path.name} unreadable ({err}) — library unavailable")
return {}
@@ -714,7 +821,14 @@ def create_app(
(a detail page keeps its section lit)."""
active = active or name
templates = resources.files("bggpipe") / "templates"
shell = (templates / "shell.html").read_text()
# content-hashed asset URLs: the stamp changes whenever the FILES
# change — a version-number stamp proved worthless between
# releases, serving week-old CSS at an unchanged ?v=1.0.0
shell = (
(templates / "shell.html")
.read_text()
.replace("__ASSET_V__", _asset_stamp())
)
fragment = (templates / "pages" / f"{name}.html").read_text()
nav = "\n".join(
f' <a href="{href}"'
@@ -772,6 +886,10 @@ def create_app(
# the fragment reads the key from its own URL; nothing interpolated
return render_page("librarygame", active="library")
@app.get("/shelves", response_class=HTMLResponse)
def shelves_page() -> str:
return render_page("shelves")
@app.get("/help", response_class=HTMLResponse)
def help_page() -> str:
return render_page("help")
@@ -849,13 +967,38 @@ def create_app(
photos_by_key.setdefault(key, []).extend(
p for p in row["source_photos"].split(";") if p
)
units = load_furniture(cfg)
locations = load_locations(cfg)
openings = opening_index(units)
out = {}
for key, game in games.items():
photos = game.get("source_photos") or photos_by_key.get(key, [])
oid, note, via = effective_location(key, game, games, locations)
opening = openings.get(oid) if oid else None
if opening:
where = f"{opening['unit']} · {opening['label']}"
elif via is not None:
container = games.get(via, {})
where = f"inside {container.get('name') or 'another box'}"
elif note:
where = note # a virtual location: "lent to Sarah, June"
else:
where = ""
out[key] = {
**game,
"key": key,
"photos": [p for p in dict.fromkeys(photos) if p in available],
"location": {
"opening_id": oid,
"unit": opening["unit"] if opening else None,
"label": opening["label"] if opening else None,
"zone": (opening.get("zone") or "") if opening else "",
"note": note,
"via": via,
"text": where,
"away": effective_away(key, game, games, locations),
"away_own": (locations.get(key) or {}).get("away", ""),
},
}
return out
@@ -950,11 +1093,650 @@ def create_app(
raise HTTPException(404, "no such image")
return FileResponse(target)
# -- the physical-location layer -----------------------------------
def _shelf_stores():
"""Furniture + locations for endpoints: corrupt stores become a
spoken 500, not a raw traceback."""
try:
return load_furniture(cfg), load_locations(cfg)
except ValueError as err:
raise HTTPException(500, str(err)) from err
def _shelf_state() -> dict:
"""Units with per-opening capacity + resident games, the unshelved
list with fit-checked suggestions, and virtual locations."""
games = read_games()
units, locations = _shelf_stores()
openings = opening_index(units)
residents: dict[str, list[tuple[str, dict]]] = {}
located_keys: set[str] = set()
lost_home: set[str] = set()
rides_in: dict[str, str] = {} # inherited resident -> its box's key
for key, entry in games.items():
oid, _, via = effective_location(key, entry, games, locations)
if oid and oid in openings:
residents.setdefault(oid, []).append((key, entry))
located_keys.add(key)
if via is not None:
rides_in[key] = via
elif oid and via is None:
# assigned to an opening that no longer exists (a store
# edited or reverted by hand): SURFACE it, never hide it
lost_home.add(key)
elif via is not None:
located_keys.add(key) # rides with its container
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")}
out_units = []
for unit in units:
unit = {**unit, "description": unit.get("description", "")}
out_openings = []
for opening in unit.get("openings", []):
here = residents.get(opening["id"], [])
report = opening_report(opening, here, lane_overrides, acked)
out_openings.append(
{
**opening,
**report,
"resident_games": [
{
"key": k,
"name": e.get("name"),
"thumbnail": e.get("thumbnail") or "",
"inherited": bool(e.get("stored_in")),
"measured": game_dims(e) is not None,
"lane": report["lanes"].get(k, ""),
"lane_override": lane_overrides.get(k, ""),
"misfit": fits_opening(e, opening) is False,
"laneless": (e.get("name") or "?")
in report["laneless"],
"acknowledged": k in acked,
"wedged": (e.get("name") or "?") in report["wedged"],
"proud": (e.get("name") or "?") in report["proud"],
"away": effective_away(k, e, games, locations),
"via": rides_in.get(k, ""),
}
for k, e in sorted(
here,
key=lambda p: (p[1].get("name") or "").casefold(),
)
],
}
)
out_units.append(
{
"name": unit["name"],
"description": unit.get("description", ""),
"openings": out_openings,
}
)
unshelved = []
for key, entry in sorted(
games.items(), key=lambda p: (p[1].get("name") or "").casefold()
):
if key in located_keys or entry.get("stored_in"):
continue
note = (locations.get(key) or {}).get("note", "")
# relevance gates, in rank order. (1) Reunification beats
# geometry: an opening already holding a series-mate or base
# game ranks first — every confirmed placement teaches the
# suggester the owner's organization by example. (2) "Fits"
# means the REMAINING opening, not the empty one: the stack
# budget already spent is subtracted before offering, so
# honesty holds as shelves fill. Then tightest fit, so a
# small box is offered cubes, never the oversize row.
fitting = []
for oid, opening in openings.items():
if not opening.get("width_in"):
continue
if fits_opening(entry, opening) is not True:
continue
here = residents.get(oid, [])
# "fits" means the REMAINING opening: an opening whose
# current packing already fails is never offered, and the
# game must find a lane in the split with itself included
current = opening_report(opening, here, lane_overrides, acked)
if current["laneless"]:
continue
probe = opening_report(
opening, [*here, (key, entry)], lane_overrides, acked
)
if (entry.get("name") or "?") in probe["laneless"]:
continue
reunites = any(related(entry, e) for _, e in here)
volume = (
opening["width_in"] * opening["height_in"] * opening["depth_in"]
)
fitting.append((0 if reunites else 1, volume, oid, reunites))
fitting.sort(key=lambda item: item[:2])
suggestions = [
{"id": oid, "reunites": reunites} for _, _, oid, reunites in fitting[:3]
]
unshelved.append(
{
"key": key,
"name": entry.get("name"),
"thumbnail": entry.get("thumbnail") or "",
"note": note,
"away": effective_away(key, entry, games, locations),
"measured": game_dims(entry) is not None,
"lost_home": key in lost_home,
"suggestions": suggestions,
}
)
return {"units": out_units, "unshelved": unshelved}
@app.get("/api/shelves")
def api_shelves() -> dict:
with lock:
freshen()
return _shelf_state()
def _mutate_furniture(fn) -> dict:
# no _refuse_if_rewriting here: furniture/locations aren't pipeline
# artifacts — an extract/resolve rewrite can't clobber them (the
# stored-in endpoint edits MATCH rows, hence its guard)
with lock:
revision["n"] += 1
freshen()
units, _ = _shelf_stores()
fn(units)
save_furniture(cfg, units)
return _shelf_state()
@app.post("/api/furniture/add-unit")
def api_add_unit(body: UnitBody) -> dict:
name = body.name.strip()
if not name:
raise HTTPException(400, "the unit needs a name")
def add(units):
if any(u["name"] == name for u in units):
raise HTTPException(409, f"a unit named {name!r} already exists")
units.append(
{
"name": name,
"description": (body.description or "").strip(),
"openings": [],
}
)
return _mutate_furniture(add)
@app.post("/api/furniture/edit-unit")
def api_edit_unit(body: UnitBody) -> dict:
def apply(units):
unit = next((u for u in units if u["name"] == body.name), None)
if unit is None:
raise HTTPException(404, "no such unit")
if body.new_name.strip() and body.new_name.strip() != body.name:
new = body.new_name.strip()
if any(u["name"] == new for u in units):
raise HTTPException(409, f"a unit named {new!r} already exists")
# opening ids are stable through a rename: locations and
# the export's URLs never notice
unit["name"] = new
if body.description is not None:
unit["description"] = body.description.strip()
return _mutate_furniture(apply)
@app.post("/api/furniture/duplicate-unit")
def api_duplicate_unit(body: UnitBody) -> dict:
"""Clone a unit's STRUCTURE — openings with dims, zones, and
descriptions, under fresh ids never its game assignments. The
copy takes the first free '<name> 2'-style name."""
def clone(units):
source = next((u for u in units if u["name"] == body.name), None)
if source is None:
raise HTTPException(404, "no such unit")
n = 2
while any(u["name"] == f"{body.name} {n}" for u in units):
n += 1
copy = {
"name": f"{body.name} {n}",
"description": source.get("description", ""),
"openings": [],
}
units.append(copy)
for opening in source.get("openings", []):
copy["openings"].append(
{
**{k: v for k, v in opening.items() if k != "id"},
"id": new_opening_id(units, copy["name"], opening["label"]),
}
)
return _mutate_furniture(clone)
@app.post("/api/furniture/delete-unit")
def api_delete_unit(body: UnitBody) -> dict:
def drop(units):
unit = next((u for u in units if u["name"] == body.name), None)
if unit is None:
raise HTTPException(404, "no such unit")
gone = {o["id"] for o in unit["openings"]}
units.remove(unit)
locations = load_locations(cfg)
kept = {
k: v for k, v in locations.items() if v.get("opening_id") not in gone
}
if kept != locations:
save_locations(cfg, kept)
return _mutate_furniture(drop)
@app.post("/api/furniture/add-openings")
def api_add_openings(body: OpeningsBody) -> dict:
def add(units):
unit = next((u for u in units if u["name"] == body.unit), None)
if unit is None:
raise HTTPException(404, "no such unit")
dims = {
"width_in": body.width_in or None,
"height_in": body.height_in or None,
"depth_in": body.depth_in or None,
}
given = [v for v in dims.values() if v]
if given and len(given) < 3:
# a half-sized opening would silently become limitless:
# fit checks need all three axes, or none (virtual)
raise HTTPException(400, "give all three interior dimensions, or none")
added: list[dict] = []
def place(new_openings):
if body.at_top:
unit["openings"][:0] = new_openings
else:
unit["openings"].extend(new_openings)
if body.rows and body.cols:
# a second grid on the same unit continues the row letters
# (A1 already exists -> next section starts at C, say):
# labels stay unique, so label-addressed CSV imports work
used = [
ord(o["label"][0]) - 65
for o in unit["openings"]
if len(o["label"]) >= 2
and o["label"][0].isalpha()
and o["label"][1:].isdigit()
]
start = max(used) + 1 if used else 0
for r in range(start, start + body.rows):
letters = ""
n = r
while True:
letters = chr(65 + n % 26) + letters
n = n // 26 - 1
if n < 0:
break
for c in range(body.cols):
label = f"{letters}{c + 1}"
added.append(
{
"id": new_opening_id(units, unit["name"], label),
"label": label,
"zone": body.zone.strip(),
**dims,
}
)
else:
label = body.label.strip()
if not label:
raise HTTPException(400, "the opening needs a label")
added.append(
{
"id": new_opening_id(units, unit["name"], label),
"label": label,
"zone": body.zone.strip(),
**dims,
}
)
place(added)
return _mutate_furniture(add)
@app.post("/api/furniture/edit-opening")
def api_edit_opening(body: OpeningEditBody) -> dict:
def apply(units):
opening = opening_index(units).get(body.id)
if opening is None:
raise HTTPException(404, "no such opening")
real = next(o for u in units for o in u["openings"] if o["id"] == body.id)
if body.label is not None:
real["label"] = body.label.strip() or real["label"]
if body.zone is not None:
real["zone"] = body.zone.strip()
if body.description is not None:
real["description"] = body.description.strip()
# dims are all-or-none: three values sets, three blanks makes
# it virtual, anything else is a 400 — a partial edit must
# never silently strip an opening of its size limits
dims = [body.width_in, body.height_in, body.depth_in]
given = [v for v in dims if v]
if len(given) == 3:
real.update(
width_in=body.width_in,
height_in=body.height_in,
depth_in=body.depth_in,
)
elif not given:
real.update(width_in=None, height_in=None, depth_in=None)
else:
raise HTTPException(400, "give all three interior dimensions, or none")
return _mutate_furniture(apply)
@app.post("/api/furniture/delete-opening")
def api_delete_opening(body: OpeningDeleteBody) -> dict:
def drop(units):
for unit in units:
for opening in unit["openings"]:
if opening["id"] == body.id:
unit["openings"].remove(opening)
locations = load_locations(cfg)
kept = {
k: v
for k, v in locations.items()
if v.get("opening_id") != body.id
}
if kept != locations:
save_locations(cfg, kept)
return
raise HTTPException(404, "no such opening")
return _mutate_furniture(drop)
@app.post("/api/furniture/move-opening")
def api_move_opening(body: OpeningMoveBody) -> dict:
"""Move an opening within its ROW. The rendered layout groups
rows by label letter, so a move across a letter boundary would
fragment the wall diagram (A1, B1, A2 renders as three rows)
the move clamps there instead."""
def row_of(label: str) -> str:
m = re.match(r"^([A-Za-z]+)\d+$", label or "")
return m.group(1).upper() if m else f"~{label}"
def move(units):
for unit in units:
ids = [o["id"] for o in unit["openings"]]
if body.id in ids:
ops = unit["openings"]
i = ids.index(body.id)
j = i + (1 if body.direction > 0 else -1)
if 0 <= j < len(ops) and row_of(ops[i]["label"]) == row_of(
ops[j]["label"]
):
ops[i], ops[j] = ops[j], ops[i]
return
raise HTTPException(404, "no such opening")
return _mutate_furniture(move)
@app.post("/api/locate")
def api_locate(body: LocateBody) -> dict:
"""Assign a game to an opening (or unshelve it). Contained games
inherit their container's spot and refuse direct assignment —
the container is the thing on the shelf."""
with lock:
revision["n"] += 1
freshen()
games = read_games()
entry = games.get(body.key)
if entry is None:
raise HTTPException(404, "no such game in the library")
if entry.get("stored_in"):
container = next(
(
e.get("name")
for e in games.values()
if str(e.get("bgg_id")) == str(entry["stored_in"])
),
"its container",
)
raise HTTPException(
409,
f"this game lives inside {container} — shelve that box "
"instead; its contents ride along",
)
locations = load_locations(cfg)
opening_id = body.opening_id.strip()
if opening_id and opening_id not in opening_index(load_furniture(cfg)):
raise HTTPException(400, "no such opening")
prev = locations.get(body.key) or {}
# a lane flip or ack toggle omits the note — leave it alone
note = prev.get("note", "") if body.note is None else body.note.strip()
if opening_id or note or prev.get("away"):
record = {
"opening_id": opening_id or None,
"note": note,
}
if body.lane in ("flat", "standing"):
record["lane"] = body.lane
# an acknowledged overhang belongs to a spot, not the
# game: it survives edits in place, resets on a move
if prev.get("acknowledged") and prev.get("opening_id") == (
opening_id or None
):
record["acknowledged"] = True
# being out on loan doesn't change where home is
if prev.get("away"):
record["away"] = prev["away"]
locations[body.key] = record
else:
locations.pop(body.key, None)
save_locations(cfg, locations)
return _shelf_state()
@app.post("/api/measure")
def api_measure(body: MeasureBody) -> dict:
"""Record (or clear) hand-measured box dimensions. The owner's
tape measure outranks BGG this overlays enrich's data."""
with lock:
revision["n"] += 1
freshen()
games = read_games()
if body.key not in games:
raise HTTPException(404, "no such game in the library")
values = (body.width_in, body.length_in, body.depth_in)
measurements = load_measurements(cfg)
if all(v is None for v in values):
measurements.pop(body.key, None)
elif all(v and v > 0 for v in values):
measurements[body.key] = {
"width_in": body.width_in,
"length_in": body.length_in,
"depth_in": body.depth_in,
}
else:
raise HTTPException(400, "all three sizes (inches), or none to clear")
save_measurements(cfg, measurements)
return _shelf_state()
@app.post("/api/away")
def api_away(body: AwayBody) -> dict:
"""Where the box IS right now, when that isn't home — the travel
case, a friend's house. Home (the opening) stays reserved; ""
brings it back."""
with lock:
revision["n"] += 1
freshen()
games = read_games()
if body.key not in games:
raise HTTPException(404, "no such game in the library")
locations = load_locations(cfg)
record = locations.get(body.key) or {"opening_id": None, "note": ""}
where = body.where.strip()
if where:
record["away"] = where
locations[body.key] = record
else:
record.pop("away", None)
if not (record.get("opening_id") or record.get("note")):
locations.pop(body.key, None)
else:
locations[body.key] = record
save_locations(cfg, locations)
return _shelf_state()
@app.post("/api/acknowledge")
def api_acknowledge(body: AcknowledgeBody) -> dict:
"""Accept (or retract accepting) a doesn't-fit placement — the
deliberate overhang: boxes standing proud of a shallow shelf."""
with lock:
revision["n"] += 1
freshen()
locations = load_locations(cfg)
record = locations.get(body.key)
if not record or not record.get("opening_id"):
raise HTTPException(404, "that game isn't shelved anywhere")
if body.acknowledged:
record["acknowledged"] = True
else:
record.pop("acknowledged", None)
save_locations(cfg, locations)
return _shelf_state()
@app.post("/api/stored-in")
def api_stored_in(body: StoredInBody) -> dict:
"""Declare (or clear) which box an already-cataloged game lives
inside. Updates the match rows (the durable record) and the
library entry in place, so the page reflects it immediately."""
with lock:
revision["n"] += 1
_refuse_if_rewriting()
freshen()
games = read_games()
entry = games.get(body.key)
if entry is None:
raise HTTPException(404, "no such game in the library")
container = body.container.strip()
if container:
if str(entry.get("bgg_id")) == container:
raise HTTPException(400, "a box cannot live inside itself")
holder = next(
(e for e in games.values() if str(e.get("bgg_id")) == container),
None,
)
if holder is None:
raise HTTPException(400, "the container must be a cataloged game")
# walk the WHOLE chain: A in B in C in A must refuse too
seen = {body.key}
probe = holder
while probe is not None:
probe_key = next((k for k, e in games.items() if e is probe), None)
if probe_key in seen:
raise HTTPException(
400, "that would make the boxes contain each other"
)
seen.add(probe_key)
inner = probe.get("stored_in")
probe = (
next(
(
e
for e in games.values()
if str(e.get("bgg_id")) == str(inner)
),
None,
)
if inner
else None
)
if str(holder.get("stored_in") or "") == str(entry.get("bgg_id")):
raise HTTPException(400, "that box already lives inside THIS one")
rows = read_matches(cfg.matches_path)
bgg_id = str(entry.get("bgg_id") or "")
version_id = str((entry.get("version") or {}).get("version_id") or "")
hit = False
for row in rows:
if bgg_id and row["bgg_id"] == bgg_id:
if version_id and row.get("version_id") != version_id:
continue
row["stored_in"] = container
hit = True
elif not bgg_id and body.key.startswith("local:"):
norm = body.key.split(":", 2)[1]
if (
row["match_status"] == "local"
and normalize_title(row["title_raw"]) == norm
):
row["stored_in"] = container
hit = True
if not hit:
raise HTTPException(
409, "no match row backs this entry — re-run enrich first"
)
write_matches(cfg.matches_path, rows)
entry["stored_in"] = container or None
atomic_write_text(
cfg.games_path,
json.dumps(games, indent=2, ensure_ascii=False) + "\n",
)
if container:
# it rides with its container now: a lingering shelf spot
# of its own would count its thickness in TWO openings
locations = load_locations(cfg)
if body.key in locations:
locations.pop(body.key)
save_locations(cfg, locations)
return {"stored_in": container or None}
@app.get("/api/library/{key:path}")
def api_library_game(key: str) -> dict:
game = library_entries().get(key)
entries = library_entries()
game = entries.get(key)
if game is None:
raise HTTPException(404, "no such game in the library")
# containment, both directions: the box this lives in, and what
# lives in this box
if game.get("stored_in"):
container = next(
(
{"key": k, "name": e.get("name")}
for k, e in entries.items()
if str(e.get("bgg_id")) == str(game["stored_in"])
),
None,
)
game = {**game, "stored_in_game": container}
contents = sorted(
(
{"key": k, "name": e.get("name")}
for k, e in entries.items()
if e.get("stored_in") and str(e["stored_in"]) == str(game.get("bgg_id"))
),
key=lambda c: (c["name"] or "").casefold(),
)
if contents:
game = {**game, "contains": contents}
# the location picker's menu: openings grouped by unit, with the
# honest fit verdict for THIS game precomputed per opening
units = load_furniture(cfg)
game = {
**game,
"opening_menu": [
{
"unit": unit["name"],
"openings": [
{
"id": o["id"],
"label": o["label"],
"zone": o.get("zone") or "",
"virtual": not o.get("width_in"),
"fit": fits_opening(game, o),
}
for o in unit.get("openings", [])
],
}
for unit in units
],
}
if key.startswith("local:"):
# overlay the hand-written store LIVE: the edit form re-renders
# from this payload, and pre-enrich values here would resubmit
@@ -1344,6 +2126,118 @@ def create_app(
replay_titles(cfg)
return state()
@app.get("/api/bgg-search")
def api_bgg_search(q: str) -> dict:
"""Raw BGG search results as a pick-list — for cataloging a box
of expansions in one pass. Marks ids the catalog already has so
the human isn't offered what's already shelved."""
query = q.strip()
if not query:
raise HTTPException(400, "a search needs some text")
with lock:
freshen()
have = {row["bgg_id"] for row in session.rows if row["bgg_id"]}
try:
results = session.client.search(query)
except _BGG_ERRORS as err:
raise HTTPException(502, f"search failed: {err}") from err
seen: set[int] = set()
out = []
for r in results:
if r.bgg_id in seen:
continue # one pick-list line per game, whatever name matched
seen.add(r.bgg_id)
out.append(
{
"bgg_id": r.bgg_id,
"name": r.name,
"year": r.year,
"type": r.type,
"already": str(r.bgg_id) in have,
}
)
out.sort(key=lambda r: (r["name"].casefold(), r["year"] or 0))
return {"results": out}
@app.post("/api/add-picked")
def api_add_picked(body: PickBody) -> dict:
"""Each ticked search result becomes a hand-added title AND an
approved match in one step the human picked it off BGG's own
list, so there is nothing left for resolve to re-derive."""
with lock:
revision["n"] += 1
_refuse_if_rewriting()
freshen()
added, skipped = 0, []
rows = read_matches(cfg.matches_path)
have_ids = {row["bgg_id"] for row in rows if row["bgg_id"]}
by_norm: dict[str, dict] = {}
for row in rows:
by_norm.setdefault(normalize_title(row["title_raw"]), row)
for pick in body.picks:
name = str(pick.get("name") or "").strip()
bgg_id = pick.get("bgg_id")
if not name or not bgg_id:
raise HTTPException(400, "each pick needs a name and bgg_id")
if str(bgg_id) in have_ids:
skipped.append(f"{name} — already in the catalog")
continue
existing = by_norm.get(normalize_title(name))
if existing is not None:
# merged rows stay merged — only live open lines adopt
if existing["match_status"] in ("ambiguous", "unmatched"):
# the pick IS the box already photographed: decide
# that line rather than duplicating it
existing.update(
{
"match_status": "approved",
"bgg_id": str(bgg_id),
"bgg_name": name,
"year": str(pick.get("year") or ""),
"type": str(pick.get("type") or "boardgame"),
"stored_in": body.stored_in.strip(),
}
)
have_ids.add(str(bgg_id))
added += 1
else:
skipped.append(f"{name} — a same-named line already exists")
continue
entry = {
"title_raw": name,
"confidence": "high", # the human is holding the box
"publisher_hint": "",
"edition_hint": "",
"year_hint": pick.get("year"),
"language_hint": "",
"art_notes": "",
"source_photos": [],
}
rescind_title_removal(cfg.title_removals_path, name)
if not record_title_addition(cfg.title_additions_path, entry):
skipped.append(f"{name} — a same-named line already exists")
continue
rows.append(
{
**dict.fromkeys(MATCH_COLUMNS, ""),
"title_raw": name,
"bgg_id": str(bgg_id),
"bgg_name": name,
"year": str(pick.get("year") or ""),
"type": str(pick.get("type") or "boardgame"),
"match_status": "approved",
"version_status": "version_unknown",
"candidates_json": "[]",
"version_candidates_json": "[]",
"stored_in": body.stored_in.strip(),
}
)
have_ids.add(str(bgg_id))
added += 1
write_matches(cfg.matches_path, rows)
replay_titles(cfg)
return {**state(), "added": added, "skipped": skipped}
@app.post("/api/add-title")
def api_add_title(body: AddBody) -> dict:
with lock:
+105
View File
@@ -0,0 +1,105 @@
"""The shelf-space report: fit math, coverage buckets, honest unknowns."""
from __future__ import annotations
import json
import pytest
import typer
from bggpipe.config import Config
from bggpipe.dims import run_dims_report
from bggpipe.shelves import save_furniture, save_locations
def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(
json.dumps(
{
"1": {
"name": "Fits Fine",
"dims": {
"width_in": 11.6,
"length_in": 11.6,
"depth_in": 2.8,
"weight_lb": 4,
"source": "version",
},
},
"2": {
"name": "Monster Box",
"dims": {
"width_in": 16.2,
"length_in": 16.2,
"depth_in": 4.0,
"weight_lb": 7,
"source": "unanimous",
},
},
"3": {
"name": "Argued About",
"dims": {
"width_in": None,
"length_in": None,
"depth_in": None,
"weight_lb": None,
"source": "conflicting",
},
},
"4": {"name": "Never Measured"}, # pre-dims entry: absent
}
)
)
# no furniture yet: sizes-only report, pointed at the Shelves page
summary = run_dims_report(cfg)
assert summary["by_source"] == {
"version": 1,
"unanimous": 1,
"conflicting": 1,
"absent": 1,
}
assert summary["misfits"] == []
assert summary["unknown"] == ["Argued About", "Never Measured"]
out = capsys.readouterr().out
assert "No furniture on file" in out
# with furniture: per-opening capacity, misfit and overfull warnings
save_furniture(
cfg,
[
{
"name": "Den",
"openings": [
{
"id": "den-a1",
"label": "A1",
"zone": "",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
}
],
}
],
)
save_locations(
cfg,
{
"1": {"opening_id": "den-a1", "note": ""},
"2": {"opening_id": "den-a1", "note": ""},
},
)
summary = run_dims_report(cfg)
assert summary["misfits"] == ["Monster Box"]
out = capsys.readouterr().out
assert "Monster Box" in out # named as unplaceable/misfit
assert "unshelved" in out # the conflicting + absent games have no home
assert "can't be size-verified" in out and "Never Measured" in out
def test_report_without_games_json_exits_with_guidance(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
with pytest.raises(typer.Exit):
run_dims_report(cfg)
+124 -2
View File
@@ -151,11 +151,25 @@ def _cfg_with_matches(tmp_path):
def _seed_batch_fixture(cache_dir):
"""The batch cache entry run_enrich will ask for: sorted ids 13,266192."""
"""The cache entries run_enrich will ask for: the stats batch and the
dims pass's versions batch (sorted ids 13,266192)."""
cache_dir.mkdir(parents=True, exist_ok=True)
combined = FULL_THING_XML.replace("<items>", "<items>" + CATAN_MINIMAL[7:-8], 1)
key = cache_key("thing", {"id": "13,266192", "stats": "1"})
(cache_dir / key).write_text(combined)
versions_key = cache_key("thing", {"id": "13,266192", "versions": "1"})
(cache_dir / versions_key).write_text(
"""<items>
<item type="boardgame" id="13"><versions>
<item type="boardgameversion" id="7"><width value="9.5" />
<length value="11.5" /><depth value="3" /><weight value="3" /></item>
</versions></item>
<item type="boardgame" id="266192"><versions>
<item type="boardgameversion" id="465063"><width value="11.7" />
<length value="11.7" /><depth value="2.8" /><weight value="4.4" /></item>
</versions></item>
</items>"""
)
def test_run_enrich_writes_games_json_with_versions(tmp_path):
@@ -209,7 +223,9 @@ def test_refresh_bypasses_cache_read(tmp_path):
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(handler))
games = run_enrich(cfg, refresh=True, client=client)
assert len(requests) == 1 # cache read skipped, live fetch happened
# cache reads skipped, live fetches happened: stats batch + dims pass
assert any("stats=1" in u for u in requests)
assert any("versions=1" in u for u in requests)
assert games["266192:465063"]["rating"] == 7.5
@@ -338,3 +354,109 @@ def test_corrupt_local_games_store_fails_loud(tmp_path):
)
with pytest.raises(ValueError, match="local_games.json is corrupt"):
run_enrich(cfg, client=bare)
DIMS_XML = """<items>
<item type="boardgame" id="240">
<versions>
<item type="boardgameversion" id="24621">
<width value="8.4" /><length value="11.5" /><depth value="2.1" />
<weight value="2" />
</item>
<item type="boardgameversion" id="99999">
<width value="0" /><length value="0" /><depth value="0" />
<weight value="0" />
</item>
</versions>
</item>
<item type="boardgame" id="500">
<versions>
<item type="boardgameversion" id="1"><width value="11.6" />
<length value="11.6" /><depth value="2.8" /><weight value="4.1" /></item>
<item type="boardgameversion" id="2"><width value="11.8" />
<length value="11.4" /><depth value="3.0" /><weight value="4.3" /></item>
<item type="boardgameversion" id="3"><width value="0" />
<length value="0" /><depth value="0" /><weight value="0" /></item>
</versions>
</item>
<item type="boardgame" id="600">
<versions>
<item type="boardgameversion" id="4"><width value="8.0" />
<length value="8.0" /><depth value="2.0" /><weight value="1" /></item>
<item type="boardgameversion" id="5"><width value="12.0" />
<length value="12.0" /><depth value="3.0" /><weight value="5" /></item>
</versions>
</item>
<item type="boardgame" id="700">
<versions>
<item type="boardgameversion" id="6"><width value="16.2" />
<length value="16.2" /><depth value="4.0" /><weight value="7" /></item>
</versions>
</item>
</items>"""
def test_parse_version_dims_treats_zero_as_absent():
from bggpipe.models import parse_version_dims
by_game = parse_version_dims(DIMS_XML)
exact = next(r for r in by_game[240] if r["version_id"] == 24621)
assert exact["width_in"] == 8.4 and exact["weight_lb"] == 2
zeroed = next(r for r in by_game[240] if r["version_id"] == 99999)
assert all(
zeroed[f] is None for f in ("width_in", "length_in", "depth_in", "weight_lb")
)
def test_entry_dims_verdicts():
"""The four sources: exact version, unanimous chorus (within 0.5"),
conflicting chorus (nulls, never a guess), and absent."""
from bggpipe.enrich import _entry_dims
from bggpipe.models import parse_version_dims
by_game = parse_version_dims(DIMS_XML)
versioned = _entry_dims({"version": {"version_id": 24621}}, by_game[240])
assert versioned["source"] == "version" and versioned["width_in"] == 8.4
# chosen version exists but carries only zeros: absent, not borrowed
zeroed = _entry_dims({"version": {"version_id": 99999}}, by_game[240])
assert zeroed["source"] == "absent" and zeroed["width_in"] is None
unanimous = _entry_dims({"version": None}, by_game[500])
assert unanimous["source"] == "unanimous"
assert unanimous["width_in"] == 11.8 # max per axis: the fit question
assert unanimous["depth_in"] == 3.0
conflicting = _entry_dims({"version": None}, by_game[600])
assert conflicting["source"] == "conflicting"
assert conflicting["width_in"] is None
assert _entry_dims({"version": None}, [])["source"] == "absent"
def test_enrich_fills_dims_from_cached_versions(tmp_path):
from bggpipe.enrich import run_enrich
cfg = _cfg_with_matches(tmp_path)
cache = tmp_path / "cache"
_seed_batch_fixture(cache)
(cache / cache_key("thing", {"id": "13,266192", "versions": "1"})).write_text(
"""<items>
<item type="boardgame" id="13"><versions>
<item type="boardgameversion" id="7"><width value="11.6" />
<length value="11.6" /><depth value="3.0" /><weight value="4" /></item>
</versions></item>
<item type="boardgame" id="266192"><versions>
<item type="boardgameversion" id="465063"><width value="11.7" />
<length value="11.7" /><depth value="2.8" /><weight value="4.4" /></item>
</versions></item>
</items>"""
)
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(_no_network))
games = run_enrich(cfg, client=client)
wingspan = games["266192:465063"]
assert wingspan["dims"]["source"] == "version"
assert wingspan["dims"]["depth_in"] == 2.8
assert wingspan["version"]["width_in"] == 11.7 # mirrored onto the version
assert games["13"]["dims"]["source"] == "unanimous"
+413
View File
@@ -0,0 +1,413 @@
"""Static export: pages, art downloads, badge slot, privacy. Offline —
cover downloads run against a mock CDN transport."""
from __future__ import annotations
import json
from pathlib import Path
import httpx
import pytest
from bggpipe.config import Config
from bggpipe.export import run_export
GAMES = {
"240:24621": {
"bgg_id": 240,
"type": "boardgame",
"name": "Britannia",
"year": 1986,
"description": "A game of <invasions> & settlement.",
"image": "https://cf.geekdo-images.com/orig/pic1.jpg",
"thumbnail": "https://cf.geekdo-images.com/thumb/pic1.jpg",
"min_players": 3,
"max_players": 5,
"best_player_counts": ["4"],
"playtime": 240,
"designers": ["Lewis Pulsipher"],
"rank": 1210,
"weight": 3.16,
"version": {
"version_id": 24621,
"name": "Avalon Hill second edition",
"year": 1987,
"publishers": ["Avalon Hill"],
"languages": ["English"],
},
"source_photos": ["IMG_1.jpeg"],
},
"311654": {
"bgg_id": 311654,
"type": "rpgitem",
"name": "Alice is Missing",
"year": 2020,
"image": "https://cf.geekdo-images.com/orig/pic2.png",
"thumbnail": "",
"source_photos": ["IMG_2.jpeg"],
},
"local:homebrew:x.jpg": {
"type": "localgame",
"name": "Britannia", # deliberate name collision with 240:24621
"image": "/local-art/abc123.jpg",
"source_photos": ["x.jpg"],
},
}
def _cfg(tmp_path: Path) -> Config:
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(json.dumps(GAMES))
cfg.local_art_dir.mkdir()
(cfg.local_art_dir / "abc123.jpg").write_bytes(FAKE_JPEG)
return cfg
FAKE_JPEG = b"\xff\xd8" + b"\x00" * 100 # magic + enough body to pass sniffing
def _cdn(requests_log: list[str]) -> httpx.Client:
def handler(request: httpx.Request) -> httpx.Response:
requests_log.append(str(request.url))
return httpx.Response(200, content=FAKE_JPEG)
return httpx.Client(transport=httpx.MockTransport(handler))
def test_export_writes_pages_art_and_no_shelf_photos(tmp_path):
cfg = _cfg(tmp_path)
out = tmp_path / "site"
log: list[str] = []
summary = run_export(
cfg, out, title="Test Shelves", fetch=_cdn(log), sleep=lambda s: None
)
assert summary == {
"games": 3,
"downloaded": 3,
"failures": 0,
"removed": 0,
"shelves": False,
}
index = (out / "index.html").read_text()
assert "Test Shelves" in index and "Powered by BGG" in index
assert "trademarks of" in index
# name collision -> distinct stable slugs
assert (out / "britannia" / "index.html").exists()
assert (out / "britannia-2" / "index.html").exists()
assert (out / "alice-is-missing" / "index.html").exists()
detail = (out / "britannia" / "index.html").read_text()
assert "Avalon Hill second edition" in detail
assert "&lt;invasions&gt; &amp; settlement" in detail # escaped, not raw
# BGG double-encodes entities; the page shows the character, not &rsquo;
assert "&amp;rsquo" not in detail
assert "boardgamegeek.com/boardgame/240" in detail
rpg = (out / "alice-is-missing" / "index.html").read_text()
assert "rpggeek.com/rpgitem/311654" in rpg
# covers landed locally and pages never hotlink the CDN
# 2 full + 1 thumb + local art + the colophon's piper
assert len(list((out / "art").glob("*"))) == 5
all_html = index + detail + rpg
assert "geekdo-images.com" not in all_html
# the privacy rule: shelf photo names appear nowhere in the output
assert "IMG_1.jpeg" not in all_html and "IMG_2.jpeg" not in all_html
def test_export_is_idempotent_and_offline_on_rerun(tmp_path):
cfg = _cfg(tmp_path)
out = tmp_path / "site"
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
def refuse(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"re-run fetched {request.url}")
summary = run_export(
cfg,
out,
fetch=httpx.Client(transport=httpx.MockTransport(refuse)),
sleep=lambda s: None,
)
assert summary["downloaded"] == 0
def test_failed_cover_reports_and_ships_page_anyway(tmp_path):
cfg = _cfg(tmp_path)
def flaky(request: httpx.Request) -> httpx.Response:
if "pic2" in str(request.url):
return httpx.Response(503)
return httpx.Response(200, content=FAKE_JPEG)
summary = run_export(
cfg,
tmp_path / "site",
fetch=httpx.Client(transport=httpx.MockTransport(flaky)),
sleep=lambda s: None,
)
assert summary["failures"] == 1
page = (tmp_path / "site" / "alice-is-missing" / "index.html").read_text()
assert "Alice is Missing" in page # the page exists, just coverless
# nothing half-written left behind to poison the idempotent re-run
assert not list((tmp_path / "site" / "art").glob("*.part"))
def test_official_badge_upgrades_the_footer(tmp_path):
cfg = _cfg(tmp_path)
(cfg.data_dir / "powered-by-bgg.png").write_bytes(b"\x89PNG" + b"\x00" * 100)
out = tmp_path / "site"
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
assert (out / "art" / "powered-by-bgg.png").exists()
index = (out / "index.html").read_text()
assert "art/powered-by-bgg.png" in index and "Powered by BGG" in index
detail = (out / "britannia" / "index.html").read_text()
assert "../art/powered-by-bgg.png" in detail
def test_export_without_games_json_exits_with_guidance(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
import typer
with pytest.raises(typer.Exit):
run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)
def test_urls_survive_adding_and_removing_games(tmp_path):
"""The manifest's whole point: a published URL never moves and is
never stolen not by an earlier-sorting new edition, not by the
base-slug holder leaving."""
cfg = _cfg(tmp_path)
out = tmp_path / "site"
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
original = (out / "britannia" / "index.html").read_text()
assert "Avalon Hill second edition" in original
# a new same-named edition whose key sorts FIRST must not steal slugs
games = json.loads(cfg.games_path.read_text())
games["100:1"] = {"bgg_id": 100, "type": "boardgame", "name": "Britannia"}
cfg.games_path.write_text(json.dumps(games))
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
assert (
"Avalon Hill second edition" in (out / "britannia" / "index.html").read_text()
)
assert (out / "britannia-3" / "index.html").exists() # the newcomer
# removing the base-slug holder must not renumber the survivors —
# and must actually remove the stale page
del games["240:24621"]
cfg.games_path.write_text(json.dumps(games))
summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
assert summary["removed"] == 1
assert not (out / "britannia" / "index.html").exists()
assert (out / "britannia-2" / "index.html").exists() # localgame, unmoved
index = (out / "index.html").read_text()
assert "britannia/" not in index.replace("britannia-2/", "").replace(
"britannia-3/", ""
)
def test_changed_cover_url_is_refetched(tmp_path):
"""BGG replaces box art under the same extension: the manifest records
each cover's source URL so a change re-downloads."""
cfg = _cfg(tmp_path)
out = tmp_path / "site"
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
games = json.loads(cfg.games_path.read_text())
games["240:24621"]["image"] = "https://cf.geekdo-images.com/orig/pic9.jpg"
cfg.games_path.write_text(json.dumps(games))
log: list[str] = []
summary = run_export(cfg, out, fetch=_cdn(log), sleep=lambda s: None)
assert summary["downloaded"] == 1
assert any("pic9" in u for u in log)
def test_garbage_200_is_a_failure_not_a_cached_cover(tmp_path):
"""A CDN interstitial served with 200 must not be cached as a cover:
exists() would then skip it forever."""
cfg = _cfg(tmp_path)
def interstitial(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=b"<html>challenge page</html>" * 10)
summary = run_export(
cfg,
tmp_path / "site",
fetch=httpx.Client(transport=httpx.MockTransport(interstitial)),
sleep=lambda s: None,
)
assert summary["failures"] == 3
assert summary["downloaded"] == 0
# only the local-art copy survives; no CDN garbage was cached
names = {f.name for f in (tmp_path / "site" / "art").iterdir()}
assert names == {"abc123.jpg", "bggpipe-piper.jpg"}
def test_missing_local_art_is_a_counted_failure(tmp_path):
cfg = _cfg(tmp_path)
(cfg.local_art_dir / "abc123.jpg").unlink()
summary = run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)
assert summary["failures"] == 1 # local_art is the ONLY source: say so
def test_interrupted_download_leaves_no_cover_and_recovers(tmp_path, monkeypatch):
"""The non-vacuous atomicity test: die BETWEEN response and write,
assert nothing half-made survives and the re-run completes."""
import bggpipe.export as export_mod
cfg = _cfg(tmp_path)
out = tmp_path / "site"
real_write = export_mod.atomic_write_bytes
calls = {"n": 0}
def die_on_second(path, data):
calls["n"] += 1
if calls["n"] == 2:
raise KeyboardInterrupt
real_write(path, data)
monkeypatch.setattr(export_mod, "atomic_write_bytes", die_on_second)
with pytest.raises(KeyboardInterrupt):
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
assert len(list((out / "art").glob("*.part"))) == 0
monkeypatch.setattr(export_mod, "atomic_write_bytes", real_write)
summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
assert summary["failures"] == 0
assert (out / "index.html").exists()
def test_minimal_and_numeric_entries_render(tmp_path):
"""Entries vary wildly: a name-only localgame and enrich-shaped
numerics (int best counts, int year) must render, not crash."""
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(
json.dumps(
{
"local:mystery:x.jpg": {"type": "localgame", "name": "Mystery"},
"7": {
"bgg_id": 7,
"type": "boardgame",
"name": "Numeric",
"year": 1980,
"min_players": 1,
"max_players": 1,
"best_player_counts": [1],
"weight": 2,
"rating": 7,
},
}
)
)
out = tmp_path / "site"
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
numeric = (out / "numeric" / "index.html").read_text()
assert "1 player" in numeric and "best at 1" in numeric
assert (out / "mystery" / "index.html").exists()
def test_cli_export_wiring(tmp_path, monkeypatch):
from typer.testing import CliRunner
from bggpipe.cli import app
received = {}
def fake_run_export(cfg, out, *, title, include_shelves=True):
received.update(out=out, title=title)
return {"games": 0, "downloaded": 0, "failures": 0, "removed": 0}
monkeypatch.setattr("bggpipe.export.run_export", fake_run_export)
runner = CliRunner()
result = runner.invoke(
app, ["export", "--out", str(tmp_path / "s"), "--title", "T"]
)
assert result.exit_code == 0
assert received["title"] == "T" and received["out"] == tmp_path / "s"
assert runner.invoke(app, ["export"]).exit_code != 0 # --out is required
def failing_run_export(cfg, out, *, title, include_shelves=True):
return {"games": 1, "downloaded": 0, "failures": 2, "removed": 0}
monkeypatch.setattr("bggpipe.export.run_export", failing_run_export)
result = runner.invoke(app, ["export", "--out", str(tmp_path / "s")])
assert result.exit_code == 1 # scripted publishes must see failures
def test_colophon_card_closes_the_shelf(tmp_path):
"""The last card: piper, badge, fine print — a div the search filter
(which selects a.game) can never hide."""
cfg = _cfg(tmp_path)
out = tmp_path / "site"
run_export(cfg, out, title="Test Shelves", fetch=_cdn([]), sleep=lambda s: None)
index = (out / "index.html").read_text()
assert 'class="game colophon"' in index
assert "bggpipe-piper.jpg" in index and "Piper art by Juniper" in index
assert "trademarks of BoardGameGeek" in index
assert (out / "art" / "bggpipe-piper.jpg").exists()
# the filter only ever hides <a class="game"> — the colophon is a div
assert '<div class="game colophon"' in index
def test_shelves_export_and_optout(tmp_path):
"""The wall ships with the site — locations on game pages, a shelves
page drawn as the physical arrangement unless the owner opts out
(publishing a shelf layout is a choice)."""
from bggpipe.shelves import save_furniture, save_locations
cfg = _cfg(tmp_path)
save_furniture(
cfg,
[
{
"name": "Den Kallax",
"description": "the wall behind the couch",
"openings": [
{
"id": "den-a1",
"label": "A1",
"zone": "oversize",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
},
{
"id": "den-b1",
"label": "B1",
"zone": "cubes",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
],
}
],
)
save_locations(cfg, {"240:24621": {"opening_id": "den-b1", "note": ""}})
out = tmp_path / "site"
summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
assert summary["shelves"] is True # the run SAYS the wall shipped
shelves = (out / "shelves" / "index.html").read_text()
assert "Den Kallax" in shelves and "the wall behind the couch" in shelves
assert "Britannia" in shelves # the resident links from its opening
assert 'href="../britannia/"' in shelves
detail = (out / "britannia" / "index.html").read_text()
assert "where it lives" in detail and "Den Kallax · B1" in detail
# containment renders on both pages
local = (out / "britannia-2" / "index.html").read_text()
assert "in this box" not in local # the localgame contains nothing
index = (out / "index.html").read_text()
assert "see the shelves" in index
# opt-out: no shelves page, no location lines, no link
out2 = tmp_path / "site2"
run_export(cfg, out2, include_shelves=False, fetch=_cdn([]), sleep=lambda s: None)
assert not (out2 / "shelves").exists()
assert "where it lives" not in (out2 / "britannia" / "index.html").read_text()
assert "see the shelves" not in (out2 / "index.html").read_text()
+31
View File
@@ -761,3 +761,34 @@ def test_research_clears_the_previous_games_version(tmp_path):
assert row["version_status"] == ""
assert row["version_id"] == "" and row["version_name"] == ""
assert row["version_candidates_json"] == "[]"
def test_reopening_a_version_ballot_keeps_the_current_pick(tmp_path):
"""Change-edition must be lossless: opening the full printing list on
an approved row keeps the pick until a new decision, and the lingering
id is inert while ambiguous."""
from bggpipe.models import is_confident_version
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wingspan",
match_status="approved",
bgg_id="266192",
version_status="version_approved",
version_id="465063",
version_name="English first edition",
)
],
)
session = ReviewSession(
cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client()
)
row = session.rows[0]
assert session.open_version_ballot(row) > 1
assert row["version_status"] == "version_ambiguous"
assert row["version_id"] == "465063" # survives until a new choice
assert not is_confident_version(row) # but inert for diff/upload
session.decide_version(row, None) # "can't tell" clears it properly
assert row["version_id"] == ""
File diff suppressed because it is too large Load Diff
+28 -3
View File
@@ -205,12 +205,21 @@ def test_stylesheet_is_served_and_linked_by_every_page(tmp_path):
assert css.headers["content-type"].startswith("text/css")
assert "--accent" in css.text # the token layer, not an empty file
for path in ("/", "/review"):
assert 'href="/static/app.css"' in web.get(path).text
assert 'href="/static/app.css?v=' in web.get(path).text
def test_every_page_marks_itself_current_in_the_nav(tmp_path):
web = _app(_cfg(tmp_path))
for path in ("/", "/photos", "/titles", "/review", "/queue", "/library", "/help"):
for path in (
"/",
"/photos",
"/titles",
"/review",
"/queue",
"/library",
"/shelves",
"/help",
):
html = web.get(path).text
assert f'href="{path}" aria-current="page"' in html, path
assert 'class="skip"' in html, path
@@ -653,7 +662,7 @@ def test_photo_detail_page_serves_with_photos_nav_active(tmp_path):
(current,) = re.findall(r'<a href="([^"]+)" aria-current="page"', html)
assert current == "/photos"
assert 'src="/static/app.js"' in html
assert 'src="/static/app.js?v=' in html
def test_pipeline_counts_pending_work_not_queue_rows(tmp_path):
@@ -675,3 +684,19 @@ def test_pipeline_counts_pending_work_not_queue_rows(tmp_path):
p = _app(cfg).get("/api/pipeline").json()
assert p["to_update"] == 0 # applied
assert p["queued_total"] == 1 # still listed until diff reruns
def test_assets_are_version_stamped(tmp_path):
"""A cached stylesheet from last week must not survive an upgrade:
asset URLs carry the app version."""
import re
html = _app(_cfg(tmp_path)).get("/").text
(stamp,) = set(re.findall(r'/static/app\.css\?v=([0-9a-f]{8})"', html))
assert f"/static/app.js?v={stamp}" in html
assert "__ASSET_V__" not in html
# the stamp is a CONTENT hash: it must change when the css changes,
# not when the release version does — that was the original sin
from bggpipe import webreview
assert stamp == webreview._asset_stamp()
+122
View File
@@ -103,6 +103,7 @@ def make_cfg(tmp_path) -> Config:
[
{
"title_raw": "Citadels",
"confidence": "high",
"publisher_hint": "Fantasy Flight",
"edition_hint": "",
"source_photos": ["shelf.jpg"],
@@ -1443,3 +1444,124 @@ def test_review_summary_reports_token_presence_not_value(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_API_TOKEN")
web2, _ = make_client(tmp_path / "tokenless")
assert web2.get("/api/state").json()["summary"]["token_present"] is False
def test_bgg_search_and_add_picked_flow(tmp_path):
"""A whole box of expansions in one pass: search once, tick, and each
pick lands already-matched with its container recorded."""
cfg = make_cfg(tmp_path)
web = TestClient(create_app(cfg, client=fixture_search_client()))
found = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
assert len(found) >= 2
ids = {r["bgg_id"] for r in found}
assert 478 in ids
assert all(r["already"] is False for r in found)
picks = [r for r in found if r["bgg_id"] in (478, 13291, 205398)]
res = web.post(
"/api/add-picked", json={"picks": picks, "stored_in": "173634"}
).json()
# 478 and The Dark City land; 205398 is BGG's OTHER game named
# "Citadels" — a true name twin, skipped honestly rather than fused
assert res["added"] == 2
assert res["skipped"] == ["Citadels — a same-named line already exists"]
rows = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
# "Citadels" already existed as an undecided photo line: the pick
# DECIDED that line (photos kept) instead of duplicating it
assert rows["478"]["match_status"] == "approved"
assert rows["478"]["source_photos"] == "shelf.jpg"
assert rows["478"]["stored_in"] == "173634"
# the expansion had no line: appended photo-less, already matched
assert rows["13291"]["match_status"] == "approved"
assert rows["13291"]["source_photos"] == ""
# re-searching now flags them; re-adding skips instead of duplicating
found2 = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
assert next(r for r in found2 if r["bgg_id"] == 478)["already"] is True
res2 = web.post("/api/add-picked", json={"picks": picks}).json()
assert res2["added"] == 0 and len(res2["skipped"]) == 3
# blank query is a 400, and BGG-down is a 502, not a crash
assert web.get("/api/bgg-search", params={"q": " "}).status_code == 400
down = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
assert down.get("/api/bgg-search", params={"q": "x"}).status_code == 502
def fixture_search_client() -> BGGClient:
from pathlib import Path as _P
fixtures = _P(__file__).parent / "fixtures" / "bgg_cache"
return BGGClient(
cache_dir=fixtures,
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
)
def test_library_detail_shows_containment_both_ways(tmp_path):
cfg = make_cfg(tmp_path)
rows = read_matches(cfg.matches_path)
rows.append(
_row(
title_raw="The Trove",
match_status="approved",
bgg_id="173634",
source_photos="shelf.jpg",
)
)
rows.append(
_row(
title_raw="Witchdoctor",
match_status="approved",
bgg_id="999",
source_photos="",
stored_in="173634",
)
)
write_matches(cfg.matches_path, rows)
cfg.games_path.write_text(
json.dumps(
{
"173634": {"bgg_id": 173634, "name": "The Trove", "type": "boardgame"},
"999": {
"bgg_id": 999,
"name": "Witchdoctor",
"type": "boardgameexpansion",
"stored_in": "173634",
},
}
)
)
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
inside = web.get("/api/library/999").json()
assert inside["stored_in_game"] == {"key": "173634", "name": "The Trove"}
box = web.get("/api/library/173634").json()
assert box["contains"] == [{"key": "999", "name": "Witchdoctor"}]
# containment is editable after the fact: clear it, then set it back
assert web.post("/api/stored-in", json={"key": "999", "container": ""}).json() == {
"stored_in": None
}
rows2 = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
assert rows2["999"]["stored_in"] == ""
assert "stored_in_game" not in web.get("/api/library/999").json()
assert web.post(
"/api/stored-in", json={"key": "999", "container": "173634"}
).json() == {"stored_in": "173634"}
assert read_matches(cfg.matches_path)[-1]["stored_in"] == "173634"
# guards: self-containment and direct cycles refuse
assert (
web.post("/api/stored-in", json={"key": "999", "container": "999"}).status_code
== 400
)
assert (
web.post(
"/api/stored-in", json={"key": "173634", "container": "999"}
).status_code
== 400
)