Compare commits
@@ -51,7 +51,20 @@ The first `/collection` call typically returns **HTTP 202** with a "please retry
|
||||
|
||||
## Search & matching heuristics
|
||||
|
||||
1. Exact normalized-name match → strong candidate.
|
||||
0. BGG's search is UNRELIABLE for generic and punctuated queries: results
|
||||
truncate in the several-hundreds unordered (the game named "Dungeon!"
|
||||
appears in neither the "Dungeon!" nor the "Dungeon" search), and
|
||||
punctuation can hide matches. Every title is searched raw AND with
|
||||
punctuation stripped, merged by id. A LONE surviving candidate never
|
||||
auto-matches unless it's a true exact match whose owned-count clears
|
||||
the dominance floor — the sole survivor may be an impostor standing
|
||||
where a famous game should be, and the real match may need review's
|
||||
manual-id entry.
|
||||
1. Exact normalized-name match → strong candidate. A name that becomes
|
||||
exact once its trailing parenthetical is stripped ("Wiz-War (Eighth
|
||||
Edition)") is a SIBLING EDITION — BGG files new editions as separate
|
||||
games — and counts as exact-grade so the choice between lineages
|
||||
reaches review instead of hiding behind a confident auto.
|
||||
2. Fuzzy match: `rapidfuzz` `token_sort_ratio ≥ 90` → good candidate.
|
||||
3. Ties: fetch `/thing?stats=1` for top ~5 candidates; prefer higher owned-count / better BGG rank (well-known game beats obscure duplicate of the same name).
|
||||
- Search results include `boardgameexpansion` as a distinct `type` — keep expansions but tag them so review catches base/expansion confusion.
|
||||
|
||||
@@ -8,4 +8,6 @@ BGG_USERNAME=
|
||||
BGG_PASSWORD=
|
||||
|
||||
# Anthropic API key for the vision extract stage
|
||||
# default vision provider; not needed for an openai-compatible/local
|
||||
# setup — see the [vision.*] blocks in config.toml
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Secrets & credential-adjacent state
|
||||
.env
|
||||
*.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 1–4 players (best at 3)
|
||||
- generic [ref=e34]: playing time 90–180 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
|
||||
@@ -9,19 +9,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
| # | Command | What it does | Status |
|
||||
|---|---------|--------------|--------|
|
||||
| 1 | `bggpipe extract` | Claude vision reads titles + edition cues from `photos/` | working |
|
||||
| 2 | `bggpipe resolve` | match titles to BGG IDs and versions via XML API2 | working (stub data — see hard rules) |
|
||||
| 2 | `bggpipe resolve` | match titles to BGG IDs and versions via XML API2 | working (real API data since 2026-08-05) |
|
||||
| 3 | `bggpipe review` | human review of ambiguous/unmatched items; `--web` serves a FastAPI UI on port 8377 | working |
|
||||
| 4 | `bggpipe diff` | diff approved matches against the existing BGG collection | working |
|
||||
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | built; browser flows unverified until real data exists (`--dry-run` works now) |
|
||||
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) |
|
||||
| 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working |
|
||||
| + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working |
|
||||
| + | `bggpipe dims` | offline shelf-space report over box dimensions enrich collects from BGG *versions* (dims live per-version, not per-game; 0 = never entered; versionless games need a unanimous chorus within 0.5"/axis else `conflicting` — never guessed); fit checks run against `data/furniture.json` openings | working |
|
||||
| + | `bggpipe shelve --import` | bulk-assign games to openings from a name,opening CSV; rejects (unknown/ambiguous/doesn't-fit/contained) reported, never guessed | working |
|
||||
|
||||
Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
|
||||
Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
|
||||
|
||||
## Commands
|
||||
|
||||
- `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`).
|
||||
- `uv run bggpipe web` — the app: six pages (Pipeline `/`, Photos, Review, Catalog, Queue, Library) in a shared sidebar shell; stage runs execute one-at-a-time in a background job.
|
||||
- `uv run bggpipe <stage>` — run a pipeline stage. Non-secret settings come from `config.toml` (username, dirs, vision model, rate limit); `--config` overrides the path.
|
||||
- `uv run bggpipe 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.
|
||||
|
||||
@@ -29,29 +32,29 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel
|
||||
|
||||
- `src/bggpipe/` — `cli.py` (typer app), one module per stage (`extract`, `resolve`, `review` + `webreview`, `diff`, `upload`, `enrich`); `templates/shell.html` + `templates/pages/*` + `static/app.{css,js}` are the web UI (the stylesheet is the design system — tokens derive from the mascot art), plus `bgg_client.py` (rate-limited XML API2 client that caches responses to `data/bgg_cache/`), `jobs.py` (single-slot background stage runner for the web UI), `normalize.py` (title normalization), `models.py` (dataclasses), `config.py`, `fsio.py` (atomic writes), `init_wizard.py` (first-run setup).
|
||||
- `scripts/` — `write_stub_fixtures.py` / `write_photo_fixtures.py` generate synthetic fixtures; `record_fixtures.py` re-records real API responses once a token exists.
|
||||
- `tests/fixtures/bgg_cache/` — stub XML fixtures the offline tests run against.
|
||||
- `tests/fixtures/bgg_cache/` — recorded real XML fixtures the offline tests run against.
|
||||
- `data/` — pipeline state (CSV/JSON artifacts are committed; caches are not — see Git).
|
||||
|
||||
## Hard rules (from spec — never violate)
|
||||
|
||||
- **≤1 request every 2 seconds** to any BGG endpoint; jittered backoff on 429/503. Upload stage: 2–4 s randomized delay between games.
|
||||
- **Credentials never touch disk or logs.** `ANTHROPIC_API_KEY`, `BGG_USERNAME`, `BGG_PASSWORD`, `BGG_API_TOKEN` come from env vars only. Playwright storage state is credential-adjacent — keep it gitignored.
|
||||
- **The XML API requires a registered app token** (`Authorization: Bearer`, from `BGG_API_TOKEN`) — unregistered requests get 401. Until Eric's registration at boardgamegeek.com/applications is approved, tests run on the stub fixtures in `tests/fixtures/bgg_cache/`; re-record them with `scripts/record_fixtures.py` once the token exists.
|
||||
- **The XML API requires a registered app token** (`Authorization: Bearer`, from `BGG_API_TOKEN`) — unregistered requests get 401. The token is ACTIVE and `tests/fixtures/bgg_cache/` holds real recorded responses; add fixtures for new tests with `scripts/record_fixtures.py`.
|
||||
- **Every stage is idempotent and resumable** — killing mid-run and restarting must lose no work; re-runs skip already-processed items.
|
||||
- Use only the XML API2 and the public website — no undocumented BGG endpoints (BGG tightened access policies in 2025).
|
||||
- BGG has **no write API**: writes drive the real website with a logged-in Playwright session.
|
||||
- **Stub-resolved data is never upload-ready.** All version_ids (and some game data) in `matches.csv`, `to_add.csv`, and `to_update.csv` currently come from SYNTHETIC stub fixtures — placeholders until real fixtures exist. When `BGG_API_TOKEN` arrives: delete both cache dirs, re-record fixtures, `resolve --force`, re-review. Two provenance markers guard this (both written by the fixture generators): `data/bgg_cache/STUB_FIXTURES.marker` (gitignored, travels with the stub XML) and `data/STUB_DATA.marker` (**committed**, so a fresh clone stays guarded). The upload stage MUST refuse to run while either exists; delete `data/STUB_DATA.marker` only after re-resolving from real fixtures.
|
||||
- **Synthetic data must never reach upload.** The stub era ended 2026-08-05: `matches.csv` and `tests/fixtures/bgg_cache/` now hold real API data. The guard mechanism stays armed: the stub-fixture generators write `data/bgg_cache/STUB_FIXTURES.marker` (gitignored) and `data/STUB_DATA.marker` (committed), and the upload stage MUST refuse to run while either exists — regenerating stubs re-locks upload automatically.
|
||||
|
||||
## Domain gotchas
|
||||
|
||||
- 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) and `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place).
|
||||
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
|
||||
- **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/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, Playwright storage state, or `.env`.
|
||||
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/furniture.json`, `data/locations.json`, `data/measurements.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# bggpipe — Shelf-to-BoardGameGeek Collection Pipeline
|
||||
|
||||

|
||||

|
||||
|
||||
<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,105 +14,69 @@ photos/ → [1 extract] → titles.json → [2 resolve] → matches.csv
|
||||
→ [6 enrich] → games.json
|
||||
```
|
||||
|
||||
> **Status: working, not yet battle-tested.** All six stages are implemented with an offline test suite. The upload stage's browser flows follow documented selectors but await their first real run — start with `--dry-run`, then `--limit 1`. (This repo also carries its author's in-progress pipeline data; see [Bring your own shelves](#bring-your-own-shelves).)
|
||||
> **Status: battle-tested end to end.** The full pipeline has run against a live BGG account: shelf photos → 136 identified games → 62 additions and 36 version updates on a real collection. The result is public — [the author's collection on BGG](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built — and [this browsable library of the same shelves](https://blog.kestrelsnest.social/mygames/) is what the **enrich** stage turns that data into. Still sensible on a first run: `--dry-run`, then `--limit 1`.
|
||||
|
||||
## Why this exists
|
||||
|
||||
BGG has no bulk import and no write API. Cataloging a few hundred games by hand means hours of searching, clicking, and second-guessing which of five editions you own. This pipeline replaces that with: take photos, run a command, resolve a handful of ambiguous matches in a review step, done.
|
||||
BGG has no bulk import and no write API. Cataloging a few hundred games by hand means hours of searching, clicking, and second-guessing which of five editions you own. I wanted to point a camera at my shelves instead. This pipeline replaces the typing with: take photos, run a command, settle a handful of ambiguous matches in a review step, done — and every judgment call along the way is yours, made in a review UI, never guessed by the machine.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **extract** — Shelf photos go to the Anthropic API (Claude vision), which reads game titles off spines and boxes along with edition cues: publisher, edition wording, print year, language.
|
||||
1. **extract** — Shelf photos go to a vision model (Claude by default; any OpenAI-compatible endpoint or a local Ollama model works), which reads game titles off spines and boxes along with edition cues: publisher, edition wording, print year, language.
|
||||
2. **resolve** — Titles are matched to BGG game IDs via the [XML API2](https://boardgamegeek.com/wiki/page/BGG_XML_API2) (exact + fuzzy matching, popularity tiebreaks), then edition cues are matched against BGG's version list for each game. Anything uncertain is flagged rather than guessed.
|
||||
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 URLs, version details) lands in `data/games.json`, the seed data for a future web frontend.
|
||||
6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork, version details) lands in `data/games.json`, feeding a browsable library of your shelves — which `bggpipe export` can publish as [static pages on your own site](docs/guide.md#publishing-your-library-on-your-own-site).
|
||||
|
||||
Every stage is idempotent and resumable: kill it mid-run, restart, lose nothing. All artifacts are flat CSV/JSON files you can inspect and edit.
|
||||
Everything runs locally, every stage survives being killed mid-run, and all artifacts are flat CSV/JSON files you can inspect and edit. You can drive it from the terminal or from a local web app:
|
||||
|
||||

|
||||
|
||||
**➔ [See the full tour](docs/tour.md)** — all eight pages, the game-detail view, and the phone experience.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS or Linux, Python 3.12+, [uv](https://docs.astral.sh/uv/)
|
||||
- An [Anthropic API key](https://console.anthropic.com/) (vision extraction)
|
||||
- A BoardGameGeek account **and a registered BGG application** — as of BGG's [2025 API policy](https://boardgamegeek.com/using_the_xml_api), the XML API requires a Bearer token from a registered app. Register a free non-commercial application at [boardgamegeek.com/applications](https://boardgamegeek.com/applications) (approval can take a week or more, so **apply on day one**), then create a token. Each user needs their own; tokens must not be shared.
|
||||
- Python 3.12+ and [uv](https://docs.astral.sh/uv/), on macOS, Linux, or Windows. (Development happens on macOS; Windows is untested but nothing is platform-specific. One caveat: the owner-only file permissions bggpipe sets on `.env` and browser session state are POSIX-only — on Windows, keep those files in a directory protected by your account.)
|
||||
- A vision model for extraction — an [Anthropic API key](https://console.anthropic.com/) by default, or any OpenAI-compatible endpoint including a free local [Ollama](https://ollama.com/) model. **Cost expectation:** extracting the author's whole collection — 65 shelf photos, 136 games — cost under a dollar with the default model (Claude Sonnet), one-time. Re-runs are free: every photo's read is cached, and only new or replaced photos go back to the model.
|
||||
- A BoardGameGeek account **and a registered BGG application** — as of BGG's [2025 API policy](https://boardgamegeek.com/using_the_xml_api), the XML API requires a Bearer token from a registered app. Register a free non-commercial application at [boardgamegeek.com/applications](https://boardgamegeek.com/applications) (approval can take a week or more, so **apply on day one**), then create a token. Each user needs their own; tokens must not be shared. ([You can start before it arrives.](docs/guide.md#running-before-your-bgg-token-arrives))
|
||||
|
||||
### Why it asks for your BGG password — and where your credentials go
|
||||
|
||||
BGG has no write API: the **only** way to add games to a collection is the website itself. So the upload stage signs into boardgamegeek.com in a real browser window, on your machine, and clicks the same buttons you would — you can literally watch it work (the browser is visible by default). That's the whole reason the password is needed, and it's used for exactly that login and nothing else.
|
||||
|
||||
Everything stays on your computer. There is no bggpipe server, no telemetry, no analytics, and no account with me — I never see your credentials, your collection, or anything else, and the code is right here to check. Credentials live in a local `.env` file (owner-only permissions, never written to logs) and are sent only to boardgamegeek.com itself. The only other service the pipeline ever contacts is the vision provider *you* configure, which receives your shelf photos and nothing more — and with a local Ollama model, even those never leave the house.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
uv tool install git+https://git.kestrelsnest.social/eric/bggpipe
|
||||
mkdir shelves && cd shelves # any directory of your own — NOT a clone of this repo
|
||||
bggpipe init # guided setup: folders, credentials, browser download
|
||||
bggpipe web # the whole app at http://127.0.0.1:8377/
|
||||
```
|
||||
|
||||
Drop shelf photos on the Photos page (or straight [from your phone's camera](docs/guide.md#from-your-phone)) and follow the pipeline left to right. Your photos and every pipeline artifact live in the directory where you run it, and `uv tool upgrade bggpipe` picks up fixes without going anywhere near your data — which is also why running inside a clone of this repo is the one unsupported setup: this repo carries its author's live pipeline data at git-tracked paths, and [a `git pull` on top of yours could destroy it](docs/guide.md#keeping-your-data-safe-from-git). bggpipe warns if it catches you doing this.
|
||||
|
||||
**➔ [The user's guide](docs/guide.md)** — credentials, every stage and flag, phone pairing, RPG handling, fixing misreads, uploading safely, and running before your token arrives.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
git clone https://git.kestrelsnest.social/eric/bggpipe.git
|
||||
cd bggpipe
|
||||
uv sync # installs Python deps
|
||||
uv run bggpipe init # guided setup: folders, credentials, browser download
|
||||
```
|
||||
|
||||
The `init` wizard is idempotent — re-run it anytime to check status or add keys you skipped. It prompts for the credentials below (hidden input, saved to a `.env` it creates with owner-only permissions) and offers the one-time Playwright Chromium download. Prefer doing it by hand? `cp .env.example .env`, fill it in, and run `uv run playwright install chromium` yourself.
|
||||
|
||||
Secrets live in environment variables only, never in config files, code, or logs. `.env` is gitignored. If you use [direnv](https://direnv.net/), the committed `.envrc` loads `.env` automatically after a one-time `direnv allow`; otherwise export the variables yourself (e.g. `set -a; source .env; set +a`).
|
||||
|
||||
| Variable | Used by | What it is |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | extract | Anthropic API key |
|
||||
| `BGG_API_TOKEN` | resolve, diff, enrich | Bearer token from your registered BGG application |
|
||||
| `BGG_USERNAME` | diff, upload, enrich | Your BGG username (public, but kept in `.env` so it lives in one place) |
|
||||
| `BGG_PASSWORD` | upload (website login) | Your BGG password |
|
||||
|
||||
Non-secret knobs (`photos_dir`, `data_dir`, the vision model, the rate limit) live in `config.toml`. From here you can drive everything from the browser:
|
||||
|
||||
```sh
|
||||
uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
|
||||
```
|
||||
|
||||
Six pages in one local app: **Pipeline** (run stages, watch live output), **Photos** (drag-and-drop upload, gallery, reshoot tickets), **Review** (keyboard-first match and edition decisions), **Catalog** (every extracted title and its status), **Queue** (exactly what upload will do, plus its full log), and **Library** (your enriched collection, browsable once real BGG data lands). The real upload sits behind a confirmation and behind the stub-data lock. Prefer the terminal? Every stage is also a command, and the two interfaces share all state:
|
||||
|
||||
```sh
|
||||
uv run bggpipe extract # photos → titles.json (+ retake prompts)
|
||||
uv run bggpipe resolve # titles → BGG ids/versions in matches.csv
|
||||
uv run bggpipe review --web # review UI only
|
||||
uv run bggpipe diff # compare against your BGG collection
|
||||
uv run bggpipe upload --dry-run # ALWAYS inspect this first
|
||||
uv run bggpipe upload --limit 1 # then one game, then small batches
|
||||
uv run bggpipe enrich # full metadata → data/games.json
|
||||
```
|
||||
|
||||
Each stage skips work it has already done; `--force`/`--refresh` flags redo it. `review` without `--web` runs in the terminal instead. `upload` also has `--retry-failed`, `--verify` (re-fetches your collection and cross-checks the log), and runs a **headed** browser by default — BGG's Cloudflare check blocks headless ones, and a first login may need one human click before the session is saved locally and reused.
|
||||
|
||||
### RPGs on your shelves
|
||||
|
||||
Tabletop RPGs aren't in BGG's board-game database — they live on RPGGeek, which shares the same underlying API. When a title isn't found as a board game, bggpipe retries as an RPG: matches are identified, enriched, and browsable in the library (filter: RPGs), but they stay **local only** — they're never uploaded, since your BGG collection can't hold them.
|
||||
|
||||
### Taking good shelf photos
|
||||
|
||||
Straight-on, one shelf (or part of one) per shot, close enough that spine text is legible to a human. If you can't read it, the model can't either. Overlap between shots is fine: duplicate reads are deduped automatically, with the merge shown (and veto-able) in review. Boxes the model spots but can't identify become retake prompts in `unidentified.json` and the review UI's "reshoot" list: photograph those boxes up close, drop the new photo in `photos/`, and run `extract` again.
|
||||
|
||||
## Bring your own shelves
|
||||
|
||||
This repo doubles as its author's live pipeline, so `data/` ships with his real artifacts — extracted titles, matches, and 2018 collection snapshots. Before running against *your* shelves, clear the data:
|
||||
|
||||
```sh
|
||||
rm data/*.csv data/*.json data/*.xml data/STUB_DATA.marker
|
||||
rm -rf data/bgg_cache data/extract_raw
|
||||
```
|
||||
|
||||
Two of those files deserve a word:
|
||||
|
||||
- **`data/STUB_DATA.marker`** — the committed CSVs were resolved from *hand-written stub fixtures* (the author's BGG application is still awaiting approval), so every version id in them is a synthetic placeholder. The upload stage refuses to run while this marker exists, precisely so nobody (including a fresh clone) can push placeholder data to a real BGG account. Starting fresh with your own token, you'll never see it again.
|
||||
- **`data/collection_snapshot_*.xml`** — with `BGG_API_TOKEN` set, `diff` fetches your collection live and you don't need these. Without a token (still waiting on approval?), you can use the logged-in-browser exemption: while signed in to BGG, save these two URLs as `data/collection_snapshot_base.xml` and `data/collection_snapshot_expansions.xml` (if you get a "queued" message, refresh after a few seconds):
|
||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
|
||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
|
||||
|
||||
No token yet? `extract` works immediately (it only needs the Anthropic key), and `resolve` does what it can, parking the rest as "waiting on BGG API token" — it picks them up automatically once the token exists. Everything is saved as you go.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
uv run pytest # offline test suite (stub fixtures, no network)
|
||||
uv run pytest # offline test suite (recorded fixtures, no network)
|
||||
uv run pytest --run-live # + a read-only live-API smoke test (needs token)
|
||||
uv run ruff check src tests # lint
|
||||
uv run bggpipe review --web --dev # review UI with code hot-reload
|
||||
uv run bggpipe web --dev # the app with code hot-reload
|
||||
```
|
||||
|
||||
The review UI live-follows the data files — run `extract` or `resolve` in another terminal and the page updates itself. Architecture and contributor guidance: [CLAUDE.md](CLAUDE.md) and the [full spec](bgg-shelf-pipeline-spec.md); BGG automation notes: [docs/bgg-upload-flow.md](docs/bgg-upload-flow.md).
|
||||
Run your own *pipeline* from a different directory — the clone's `data/` is the author's live data. Architecture and contributor guidance: [CLAUDE.md](CLAUDE.md) and the [design contract](docs/spec.md); BGG automation notes: [docs/bgg-upload-flow.md](docs/bgg-upload-flow.md).
|
||||
|
||||
## Questions, bugs, ideas
|
||||
|
||||
Email [eric@ericwagoner.com](mailto:eric@ericwagoner.com), or find me on Mastodon at [@eric@toots.kestrelsnest.social](https://toots.kestrelsnest.social/@eric) or Bluesky at [@kestrelsnest.social](https://bsky.app/profile/kestrelsnest.social) — bug reports, confusions, and "it worked, here's my collection" notes all welcome. (This Gitea instance doesn't take public registrations, so there's no issue tracker to file into yet; if enough people show up, one will materialize.)
|
||||
|
||||
## A note on being a good BGG citizen
|
||||
|
||||
|
||||
@@ -3,5 +3,19 @@
|
||||
|
||||
photos_dir = "photos"
|
||||
data_dir = "data"
|
||||
model = "claude-sonnet-5"
|
||||
rate_limit_seconds = 2.0
|
||||
|
||||
# Which vision backend reads your shelf photos. Both recipes below stay
|
||||
# on file; this line picks one.
|
||||
vision_provider = "anthropic"
|
||||
|
||||
[vision.anthropic]
|
||||
# reads ANTHROPIC_API_KEY from the environment
|
||||
model = "claude-sonnet-5"
|
||||
|
||||
[vision."openai-compatible"]
|
||||
# OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio, vLLM).
|
||||
# key_env names the env var holding the key; "" = endpoint needs none.
|
||||
base_url = "http://localhost:11434/v1"
|
||||
model = "qwen2.5vl:7b"
|
||||
key_env = ""
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
The CSVs in this directory were resolved from hand-written stub fixtures, not real BGG data — version_ids are SYNTHETIC. The upload stage refuses to run while this file exists. Delete it only after re-resolving against real recorded fixtures (BGG_API_TOKEN + scripts/record_fixtures.py + resolve --force).
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,272 @@
|
||||
[
|
||||
{
|
||||
"title_raw": "Civilization: West Extension Map",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Artistry: Delightful Doorways Mini Expansion",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "castle panic wizard's tower",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 5: The Character Trove",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2015,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 10",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2026,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 2",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2008,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 3",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2011,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 4",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2013,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 6: Villains",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2017,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 7: The Tavern Crew",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2018,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 8: Pub Crawl",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 9: The Undercity",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2023,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Evil Pooky",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2021,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Halden the Unhinged",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2015,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Jin vs Greppa",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2025,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Melvyn vs. Marah",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2024,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Pooky",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2012,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Wrench",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2015,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: 5th Player Expansion",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2020,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Awakened Items & Actions",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Funeral Witch Campaign",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Search Mini Deck",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2020,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Shifting Story Promo Deck",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2023,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Sphinx Campaign",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Story Dice Expansion",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2024,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Thorn Knight",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2020,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
[
|
||||
{
|
||||
"match": "Hangermuger 4 800",
|
||||
"title_raw": "Huggermugger"
|
||||
},
|
||||
{
|
||||
"match": "DOCTOR WHO THE GAME OF TIME",
|
||||
"title_raw": "DOCTOR WHO THE card GAME"
|
||||
},
|
||||
{
|
||||
"match": "CAPTAIN MARVEL SECRET SKRULLS",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "CHECKERS",
|
||||
"title_raw": "Super Mario CHECKERS",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "DOCTOR WHO THE card GAME",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "DUNGEONS & DRAGONS",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "Huggermugger",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "Patch Work",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "The Ain't It Cool Trivia Game",
|
||||
"title_raw": "The Rocky Horror Trivia Game",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "Verdant",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "Wiz-War",
|
||||
"photos": [
|
||||
"IMG_4502.jpeg"
|
||||
],
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "ACTION CASTLE",
|
||||
"title_raw": "Parsely",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "Patch Work",
|
||||
"title_raw": "Patchwork",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "SMALLWORLD",
|
||||
"title_raw": "SMALL WORLD",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "ART NOUVEAU ARTISTRY",
|
||||
"title_raw": "ARTISTRY",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "MAGE WARS ACADEMY CORE SET",
|
||||
"title_raw": "MAGE WARS ACADEMY",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "MANTIS FALLS (a game of trust)",
|
||||
"title_raw": "MANTIS FALLS",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "BOTANY EXPANSION TANTALIZING TREES",
|
||||
"title_raw": "BOTANY: TANTALIZING TREES",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "BOTANY EXPANSION PERILOUS PERFUMES POISONOUS, CARNIVOROUS, PARASITIC AND BIZARRE PLANTS",
|
||||
"title_raw": "BOTANY: PERILOUS PERFUMES",
|
||||
"confidence": "high"
|
||||
},
|
||||
{
|
||||
"match": "WIZ-WAR",
|
||||
"photos": [
|
||||
"IMG_4504.jpeg"
|
||||
],
|
||||
"year_hint": 2012,
|
||||
"confidence": "high"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
[
|
||||
{
|
||||
"title": "THE CATAN CARD GAME",
|
||||
"photos": [
|
||||
"IMG_4523.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "SLUGFEST GAMES",
|
||||
"photos": [
|
||||
"IMG_4528.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "SCRABBLE",
|
||||
"photos": [
|
||||
"IMG_4508.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "SCERU MERRY MEN",
|
||||
"photos": [
|
||||
"IMG_4518.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Patchwork",
|
||||
"photos": [
|
||||
"IMG_4556.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Jokin Ha...",
|
||||
"photos": [
|
||||
"IMG_4506.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "CHEAPASS GAMES",
|
||||
"photos": [
|
||||
"IMG_4519.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "CATAN",
|
||||
"photos": [
|
||||
"IMG_4523.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "CAR WARS",
|
||||
"photos": [
|
||||
"IMG_4532.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Bird on Your Bread?!",
|
||||
"photos": [
|
||||
"IMG_4517.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Super Mario CHECKERS",
|
||||
"photos": [
|
||||
"IMG_4507.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "ACTION CASTLE I",
|
||||
"photos": [
|
||||
"IMG_4573.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "SIX-GUN SHOWDOWN",
|
||||
"photos": [
|
||||
"IMG_4573.jpeg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Blackboa[rd]",
|
||||
"photos": [
|
||||
"IMG_4573.jpeg"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -22,7 +22,8 @@
|
||||
"art_notes": "Box top shown horizontally on shelf, tagline 'Who's to Blame Logic Game', orange cat illustration",
|
||||
"source_photos": [
|
||||
"IMG_4499.jpeg",
|
||||
"IMG_4505.jpeg"
|
||||
"IMG_4505.jpeg",
|
||||
"shelf-20260803-215723.jpg"
|
||||
],
|
||||
"title_normalized": "cat crimes"
|
||||
},
|
||||
@@ -158,8 +159,8 @@
|
||||
"title_normalized": "gentle rain"
|
||||
},
|
||||
{
|
||||
"title_raw": "Patch Work",
|
||||
"confidence": "medium",
|
||||
"title_raw": "Patchwork",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
@@ -260,7 +261,7 @@
|
||||
},
|
||||
{
|
||||
"title_raw": "Wiz-War",
|
||||
"confidence": "medium",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
@@ -354,7 +355,7 @@
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Fantasy Flight Games",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"year_hint": 2012,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Dark spine with wizard/warrior artwork, sepia tones",
|
||||
"source_photos": [
|
||||
@@ -416,7 +417,7 @@
|
||||
"title_normalized": "onitama"
|
||||
},
|
||||
{
|
||||
"title_raw": "MANTIS FALLS (a game of trust)",
|
||||
"title_raw": "MANTIS FALLS",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Diligent Games",
|
||||
"edition_hint": "",
|
||||
@@ -426,20 +427,7 @@
|
||||
"source_photos": [
|
||||
"IMG_4505.jpeg"
|
||||
],
|
||||
"title_normalized": "mantis falls game of trust"
|
||||
},
|
||||
{
|
||||
"title_raw": "Jokin Ha...",
|
||||
"confidence": "medium",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "White box with simple stick-figure character illustration in blue shirt, partially obscured by a blue 'Nostalgia Pack' sleeve item and card deck; likely 'Joking Hazard' based on visible fragment and cyanide & happiness-style art",
|
||||
"source_photos": [
|
||||
"IMG_4506.jpeg"
|
||||
],
|
||||
"title_normalized": "jokin ha"
|
||||
"title_normalized": "mantis falls"
|
||||
},
|
||||
{
|
||||
"title_raw": "Herbaceous",
|
||||
@@ -468,19 +456,6 @@
|
||||
],
|
||||
"title_normalized": "labyrinth"
|
||||
},
|
||||
{
|
||||
"title_raw": "CHECKERS",
|
||||
"confidence": "medium",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Top-left corner box, red/black checkered pattern visible",
|
||||
"source_photos": [
|
||||
"IMG_4507.jpeg"
|
||||
],
|
||||
"title_normalized": "checkers"
|
||||
},
|
||||
{
|
||||
"title_raw": "Super Mario Checkers Collector's Edition",
|
||||
"confidence": "high",
|
||||
@@ -522,19 +497,6 @@
|
||||
],
|
||||
"title_normalized": "bugs in kitchen"
|
||||
},
|
||||
{
|
||||
"title_raw": "SCRABBLE",
|
||||
"confidence": "medium",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Red box edge visible top right, only partial letters 'RABBLE' legible",
|
||||
"source_photos": [
|
||||
"IMG_4508.jpeg"
|
||||
],
|
||||
"title_normalized": "scrabble"
|
||||
},
|
||||
{
|
||||
"title_raw": "CATAN DICE GAME",
|
||||
"confidence": "high",
|
||||
@@ -783,8 +745,8 @@
|
||||
"title_normalized": "dixit"
|
||||
},
|
||||
{
|
||||
"title_raw": "Hangermuger 4 800",
|
||||
"confidence": "low",
|
||||
"title_raw": "Huggermugger",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
@@ -793,11 +755,11 @@
|
||||
"source_photos": [
|
||||
"IMG_4514.jpeg"
|
||||
],
|
||||
"title_normalized": "hangermuger 4 800"
|
||||
"title_normalized": "huggermugger"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Ain't It Cool Trivia Game",
|
||||
"confidence": "medium",
|
||||
"title_raw": "The Rocky Horror Trivia Game",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
@@ -806,10 +768,10 @@
|
||||
"source_photos": [
|
||||
"IMG_4514.jpeg"
|
||||
],
|
||||
"title_normalized": "ain t it cool trivia game"
|
||||
"title_normalized": "rocky horror trivia game"
|
||||
},
|
||||
{
|
||||
"title_raw": "ART NOUVEAU ARTISTRY",
|
||||
"title_raw": "ARTISTRY",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Dux Somnium",
|
||||
"edition_hint": "",
|
||||
@@ -819,7 +781,7 @@
|
||||
"source_photos": [
|
||||
"IMG_4515.jpeg"
|
||||
],
|
||||
"title_normalized": "art nouveau artistry"
|
||||
"title_normalized": "artistry"
|
||||
},
|
||||
{
|
||||
"title_raw": "SPEECHLESS",
|
||||
@@ -875,7 +837,7 @@
|
||||
},
|
||||
{
|
||||
"title_raw": "CAPTAIN MARVEL SECRET SKRULLS",
|
||||
"confidence": "medium",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Marvel",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
@@ -899,19 +861,6 @@
|
||||
],
|
||||
"title_normalized": "pie face"
|
||||
},
|
||||
{
|
||||
"title_raw": "Bird on Your Bread?!",
|
||||
"confidence": "low",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Blue box with cartoon bird/character illustration, tall narrow spine",
|
||||
"source_photos": [
|
||||
"IMG_4517.jpeg"
|
||||
],
|
||||
"title_normalized": "bird on your bread"
|
||||
},
|
||||
{
|
||||
"title_raw": "Walk the Plank!",
|
||||
"confidence": "high",
|
||||
@@ -953,7 +902,7 @@
|
||||
"title_normalized": "meteor"
|
||||
},
|
||||
{
|
||||
"title_raw": "MAGE WARS ACADEMY CORE SET",
|
||||
"title_raw": "MAGE WARS ACADEMY",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Arcane Wonders",
|
||||
"edition_hint": "Core Set",
|
||||
@@ -963,24 +912,11 @@
|
||||
"source_photos": [
|
||||
"IMG_4518.jpeg"
|
||||
],
|
||||
"title_normalized": "mage wars academy core set"
|
||||
"title_normalized": "mage wars academy"
|
||||
},
|
||||
{
|
||||
"title_raw": "SCERU MERRY MEN",
|
||||
"confidence": "medium",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Orange box with dinosaur/creature illustration, fantasy medieval theme art",
|
||||
"source_photos": [
|
||||
"IMG_4518.jpeg"
|
||||
],
|
||||
"title_normalized": "sceru merry men"
|
||||
},
|
||||
{
|
||||
"title_raw": "DOCTOR WHO THE GAME OF TIME",
|
||||
"confidence": "medium",
|
||||
"title_raw": "DOCTOR WHO THE card GAME",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
@@ -989,11 +925,11 @@
|
||||
"source_photos": [
|
||||
"IMG_4518.jpeg"
|
||||
],
|
||||
"title_normalized": "doctor who game of time"
|
||||
"title_normalized": "doctor who card game"
|
||||
},
|
||||
{
|
||||
"title_raw": "DUNGEONS & DRAGONS",
|
||||
"confidence": "medium",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "TSR",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
@@ -1017,19 +953,6 @@
|
||||
],
|
||||
"title_normalized": "dungeons and dragons fantasy game rules"
|
||||
},
|
||||
{
|
||||
"title_raw": "CHEAPASS GAMES",
|
||||
"confidence": "low",
|
||||
"publisher_hint": "Cheapass Games",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Vertical white spine text near right side of shelf, appears to be publisher branding rather than a title",
|
||||
"source_photos": [
|
||||
"IMG_4519.jpeg"
|
||||
],
|
||||
"title_normalized": "cheapass games"
|
||||
},
|
||||
{
|
||||
"title_raw": "Before I Kill You, Mister Bond...",
|
||||
"confidence": "high",
|
||||
@@ -1161,32 +1084,6 @@
|
||||
],
|
||||
"title_normalized": "sleeping gods distant skies"
|
||||
},
|
||||
{
|
||||
"title_raw": "CATAN",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Catan",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Red spine, small circular emblem visible on spine",
|
||||
"source_photos": [
|
||||
"IMG_4523.jpeg"
|
||||
],
|
||||
"title_normalized": "catan"
|
||||
},
|
||||
{
|
||||
"title_raw": "THE CATAN CARD GAME",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Catan",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Red/orange spine with yellow banner text",
|
||||
"source_photos": [
|
||||
"IMG_4523.jpeg"
|
||||
],
|
||||
"title_normalized": "catan card game"
|
||||
},
|
||||
{
|
||||
"title_raw": "THE RIVALS FOR CATAN CARD GAME",
|
||||
"confidence": "high",
|
||||
@@ -1304,19 +1201,6 @@
|
||||
],
|
||||
"title_normalized": "wiz war"
|
||||
},
|
||||
{
|
||||
"title_raw": "SLUGFEST GAMES",
|
||||
"confidence": "low",
|
||||
"publisher_hint": "Slugfest Games",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Partial box visible at top of image, red/orange background with illustrated characters, this may be a publisher logo rather than a game title",
|
||||
"source_photos": [
|
||||
"IMG_4528.jpeg"
|
||||
],
|
||||
"title_normalized": "slugfest games"
|
||||
},
|
||||
{
|
||||
"title_raw": "RISK THE LORD OF THE RINGS: The Middle-earth Conquest Game",
|
||||
"confidence": "high",
|
||||
@@ -1382,19 +1266,6 @@
|
||||
],
|
||||
"title_normalized": "gloomhaven"
|
||||
},
|
||||
{
|
||||
"title_raw": "CAR WARS",
|
||||
"confidence": "medium",
|
||||
"publisher_hint": "sjgames.com",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Red box top left showing miniature cars, partially visible at top of frame",
|
||||
"source_photos": [
|
||||
"IMG_4532.jpeg"
|
||||
],
|
||||
"title_normalized": "car wars"
|
||||
},
|
||||
{
|
||||
"title_raw": "DEADLY DOODLES",
|
||||
"confidence": "high",
|
||||
@@ -1436,7 +1307,7 @@
|
||||
},
|
||||
{
|
||||
"title_raw": "Verdant",
|
||||
"confidence": "medium",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "AEG",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
@@ -1720,19 +1591,6 @@
|
||||
],
|
||||
"title_normalized": "kill doctor lucky"
|
||||
},
|
||||
{
|
||||
"title_raw": "Patchwork",
|
||||
"confidence": "low",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Orange/tan box, only partial text '...TCH ...ORK' visible with quilt/patchwork square pattern",
|
||||
"source_photos": [
|
||||
"IMG_4556.jpeg"
|
||||
],
|
||||
"title_normalized": "patchwork"
|
||||
},
|
||||
{
|
||||
"title_raw": "ALICE IS MISSING SILENT FALLS EXPANSION",
|
||||
"confidence": "high",
|
||||
@@ -1758,5 +1616,432 @@
|
||||
"IMG_4566.jpeg"
|
||||
],
|
||||
"title_normalized": "alice is missing silent role playing game"
|
||||
},
|
||||
{
|
||||
"title_raw": "ETHERFIELDS KITTENBURG EXPANSION",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Awaken Realms",
|
||||
"edition_hint": "Expansion by Adrian Krawczyk",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Dark reddish-brown background with a large anthropomorphic cat figure in ornate robe, surrounded by smaller kittens, gothic fantasy style artwork",
|
||||
"source_photos": [
|
||||
"IMG_4570.jpeg"
|
||||
],
|
||||
"title_normalized": "etherfields kittenburg expansion"
|
||||
},
|
||||
{
|
||||
"title_raw": "BOTANY: TANTALIZING TREES",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Dux Somnium",
|
||||
"edition_hint": "Expansion",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Dark green box with gold ornate border, gold tree illustration",
|
||||
"source_photos": [
|
||||
"IMG_4571.jpeg"
|
||||
],
|
||||
"title_normalized": "botany tantalizing trees"
|
||||
},
|
||||
{
|
||||
"title_raw": "BOTANY: PERILOUS PERFUMES",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Dux Somnium",
|
||||
"edition_hint": "Expansion",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Dark purple/plum box with gold ornate border, gold carnivorous plant illustration",
|
||||
"source_photos": [
|
||||
"IMG_4571.jpeg"
|
||||
],
|
||||
"title_normalized": "botany perilous perfumes"
|
||||
},
|
||||
{
|
||||
"title_raw": "Parsely",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Memento More Computers Inc.",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2007,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Green monochrome text on black computer screen display, styled as retro terminal game",
|
||||
"source_photos": [
|
||||
"IMG_4573.jpeg"
|
||||
],
|
||||
"title_normalized": "parsely"
|
||||
},
|
||||
{
|
||||
"title_raw": "...and then, we held hands.",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "LudiCreations",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Blue sky background with white stylized doves/hands motif, cursive white title text",
|
||||
"source_photos": [
|
||||
"image.jpg"
|
||||
],
|
||||
"title_normalized": "and then we held hands"
|
||||
},
|
||||
{
|
||||
"title_raw": "Consentacle",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Dead Pixel • www.deadpixel.co",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2018,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Black box with purple tentacle/octopus illustration on spine",
|
||||
"source_photos": [
|
||||
"shelf-20260803-171316.jpg"
|
||||
],
|
||||
"title_normalized": "consentacle"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Simpsons Trivia Game",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Matt Groening / The Simpsons",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Green background with die-cut character silhouette shapes of Homer, Marge, Bart, Lisa, and Maggie; includes 'Ay Carumba!' badge and note about included cast poster",
|
||||
"source_photos": [
|
||||
"shelf-20260803-213735.jpg"
|
||||
],
|
||||
"title_normalized": "simpsons trivia game"
|
||||
},
|
||||
{
|
||||
"title_raw": "THE CAT GAME",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Spin Master",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English/French",
|
||||
"art_notes": "White box with faux-fur textured letters spelling CAT, image of cat wearing top hat and bow tie standing on a scratching post, hand holding a marker/pointer stick, tagline 'The Hair-Raising Drawing Game (with Cats)'",
|
||||
"source_photos": [
|
||||
"shelf-20260803-213756.jpg"
|
||||
],
|
||||
"title_normalized": "cat game"
|
||||
},
|
||||
{
|
||||
"title_raw": "BLINK",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Mattel Games",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Orange sunburst background box with green bubble letters spelling BLINK, cards showing green stars, red flowers, blue triangles, yellow droplets",
|
||||
"source_photos": [
|
||||
"shelf-20260803-215650.jpg"
|
||||
],
|
||||
"title_normalized": "blink"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Office Trivia Game",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Cardinal",
|
||||
"edition_hint": "Ready to Roll, Fast-Paced Game!",
|
||||
"year_hint": null,
|
||||
"language_hint": "English",
|
||||
"art_notes": "Box shows three characters from The Office in an office/warehouse setting, notebook paper design border, red 'Ready to Roll' circular badge",
|
||||
"source_photos": [
|
||||
"shelf-20260803-215703.jpg"
|
||||
],
|
||||
"title_normalized": "office trivia game"
|
||||
},
|
||||
{
|
||||
"title_raw": "Civilization: West Extension Map",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "civilization west extension map"
|
||||
},
|
||||
{
|
||||
"title_raw": "Artistry: Delightful Doorways Mini Expansion",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "artistry delightful doorways mini expansion"
|
||||
},
|
||||
{
|
||||
"title_raw": "castle panic wizard's tower",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": null,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "castle panic wizard s tower"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 5: The Character Trove",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2015,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 5 character trove"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 10",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2026,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 10"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 2",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2008,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 2"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 3",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2011,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 3"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 4",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2013,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 4"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 6: Villains",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2017,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 6 villains"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 7: The Tavern Crew",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2018,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 7 tavern crew"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 8: Pub Crawl",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 8 pub crawl"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn 9: The Undercity",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2023,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn 9 undercity"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Evil Pooky",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2021,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn allies evil pooky"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Halden the Unhinged",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2015,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn allies halden unhinged"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Jin vs Greppa",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2025,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn allies jin vs greppa"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Melvyn vs. Marah",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2024,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn allies melvyn vs marah"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Pooky",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2012,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn allies pooky"
|
||||
},
|
||||
{
|
||||
"title_raw": "The Red Dragon Inn: Allies – Wrench",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2015,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "red dragon inn allies wrench"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: 5th Player Expansion",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2020,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields 5th player expansion"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Awakened Items & Actions",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields awakened items and actions"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Funeral Witch Campaign",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields funeral witch campaign"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Search Mini Deck",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2020,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields search mini deck"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Shifting Story Promo Deck",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2023,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields shifting story promo deck"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Sphinx Campaign",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields sphinx campaign"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Story Dice Expansion",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2024,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields story dice expansion"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2022,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields stretch goals harpy and she wolf campaigns"
|
||||
},
|
||||
{
|
||||
"title_raw": "Etherfields: Thorn Knight",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "",
|
||||
"edition_hint": "",
|
||||
"year_hint": 2020,
|
||||
"language_hint": "",
|
||||
"art_notes": "",
|
||||
"source_photos": [],
|
||||
"title_normalized": "etherfields thorn knight"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
bgg_id,bgg_name,year,type,version_id,version_name,title_raw,source_photos,second_copy
|
||||
235096,Cat Crimes,2017,boardgame,360982,English edition,CAT CRIMES,IMG_4499.jpeg;IMG_4505.jpeg,
|
||||
268163,A Gentle Rain,2019,boardgame,268164,Bloom Edition,a Gentle Rain,IMG_4501.jpeg;IMG_4507.jpeg,
|
||||
50381,Cards Against Humanity,2009,boardgame,,,Cards Against Humanity,IMG_4501.jpeg;IMG_4556.jpeg,
|
||||
1078,Skip-Bo,1967,boardgame,,,SKIP-BO,IMG_4501.jpeg;IMG_4542.jpeg,
|
||||
218866,Scrawl,2017,boardgame,,,SCRAWL,IMG_4502.jpeg,
|
||||
195314,Herbaceous,2017,boardgame,,,Herbaceous,IMG_4507.jpeg,
|
||||
|
||||
|
@@ -360,5 +360,17 @@
|
||||
"partial_text": "TOWN...FUKU (possibly Japanese text)",
|
||||
"art_notes": "Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items"
|
||||
}
|
||||
],
|
||||
"IMG_4573.jpeg": [
|
||||
{
|
||||
"location": "Upper right area of the book cover art, stacked above 'ACTION CASTLE I' box",
|
||||
"partial_text": "AC... (partially obscured by hand/fingers)",
|
||||
"art_notes": "Red/orange box spine, appears to be part of a series with other 'Action Castle' related titles"
|
||||
},
|
||||
{
|
||||
"location": "Bottom right of cover art, near 'Blackboard' box",
|
||||
"partial_text": "",
|
||||
"art_notes": "Small dark box with logo icon, title illegible due to size and angle"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -108,5 +108,7 @@
|
||||
"IMG_4556.jpeg|Top right corner, quilted patchwork-pattern box, right of the box with 'TCH ORK' text||Multicolored quilt/patchwork square pattern box, title not legible, partially cut at frame edge",
|
||||
"IMG_4556.jpeg|Top row, green textured spine with cartoon dinosaur, left of 'Ravensbu...' spine||Green speckled/scaly texture spine with small cartoon dinosaur illustration",
|
||||
"IMG_4556.jpeg|Top row, second shelf, spine reading 'Ravensbu...' between an unidentified dark box and green dinosaur-patterned spine|Ravensbu...|White spine with blue text, likely Ravensburger logo/publisher rather than title, top cut off",
|
||||
"IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items"
|
||||
"IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items",
|
||||
"IMG_4573.jpeg|Bottom right of cover art, near 'Blackboard' box||Small dark box with logo icon, title illegible due to size and angle",
|
||||
"IMG_4573.jpeg|Upper right area of the book cover art, stacked above 'ACTION CASTLE I' box|AC... (partially obscured by hand/fingers)|Red/orange box spine, appears to be part of a series with other 'Action Castle' related titles"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
action,bgg_id,collid,name,version_id,second_copy,status,timestamp,error
|
||||
add,334011,,A Gentle Rain,701315,,failed,2026-08-06T02:15:34+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""A\ Gentle\ Rain"", re.IGNORECASE)) to be visible"
|
||||
add,589,,Wiz-War,27352,,failed,2026-08-06T02:20:33+00:00,"Error: Locator.check: Error: strict mode violation: get_by_role(""dialog"").get_by_label(""Own"") resolved to 2 elements: 1) <input type=""checkbox"" ng-model=""item.status.own"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Own"", exact=True) 2) <input type=""checkbox"" ng-model=""item.status.prevowned"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Prev. Owned"") Call log: - waiting for get_by_role(""dialog"").get_by_label(""Own"")"
|
||||
add,334011,,A Gentle Rain,701315,,added,2026-08-06T02:21:32+00:00,
|
||||
add,125921,,Catan: Junior,476263,,added,2026-08-06T02:24:22+00:00,
|
||||
add,31260,,Agricola,297589,,added,2026-08-06T02:24:28+00:00,
|
||||
add,181304,,Mysterium,536289,,added_no_version,2026-08-06T02:24:34+00:00,version 'English edition 2018-2' not in picker; added without version
|
||||
add,312786,,Poetry for Neanderthals,514050,,added_no_version,2026-08-06T02:24:40+00:00,version 'English edition 2020' not in picker; added without version
|
||||
add,273240,,The Red Dragon Inn Smorgasbox,446069,,added,2026-08-06T02:24:46+00:00,
|
||||
add,419687,,Munchkin Big Box,711400,,failed,2026-08-06T02:38:12+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 55 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
|
||||
add,252153,,Tang Garden,404702,,failed,2026-08-06T02:38:48+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 56 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
|
||||
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:39:23+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""ul.pagination a[title=\""First Page\""]"").first - locator resolved to <a href="""" role=""menuitem"" title=""First Page"" class=""pagination-pager"" ng-click=""selectPage(1)""> ⇆⇆⇆⇆First ⇆⇆⇆</a> - attempting click action 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 20ms 2 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 100ms 56 × waiting for element to be visible, enabled and stable - element is not visible - retrying click action - waiting 500ms"
|
||||
add,589,,Wiz-War,27352,,added,2026-08-06T02:45:10+00:00,
|
||||
add,419687,,Munchkin Big Box,711400,,added,2026-08-06T02:45:16+00:00,
|
||||
add,252153,,Tang Garden,404702,,added,2026-08-06T02:45:23+00:00,
|
||||
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:45:58+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""dialog"").locator(""li:has(.summary-item-thumbnail)"").filter(has_text=re.compile(r""Sleeping\ Gods\ \(English\ Gamefound\ edition\)\ \(2023\)"", re.IGNORECASE)).first"
|
||||
add,283766,,Sleeping Gods: Tides of Ruin,463140,,added,2026-08-06T02:46:07+00:00,
|
||||
add,255984,,Sleeping Gods,701034,,failed,2026-08-06T02:49:44+00:00,RuntimeError: 'Sleeping Gods (English Gamefound edition) (2023)' was listed a moment ago but vanished on the second pass — retryable
|
||||
add,280794,,Etherfields,458300,,added,2026-08-06T02:49:53+00:00,
|
||||
add,380837,,Botany,650545,,added,2026-08-06T02:50:00+00:00,
|
||||
add,393672,,Gloomhaven: Buttons & Bugs,669782,,failed,2026-08-06T02:50:07+00:00,RuntimeError: 'Gloomhaven: Buttons & Bugs (English edition) (2024)' was listed a moment ago but vanished on the second pass — retryable
|
||||
add,14535,,SPANC: Space Pirate Amazon Ninja Catgirls,29075,,added,2026-08-06T02:50:15+00:00,
|
||||
add,255984,,Sleeping Gods,701034,,added,2026-08-06T02:54:05+00:00,
|
||||
add,393672,,Gloomhaven: Buttons & Bugs,669782,,added,2026-08-06T02:54:12+00:00,
|
||||
add,9666,,Greed Quest,215137,,added,2026-08-06T02:54:19+00:00,
|
||||
add,40508,,Scrabble Slam!,644326,,added,2026-08-06T02:54:27+00:00,
|
||||
add,3181,,Farkle,87931,,added,2026-08-06T02:54:36+00:00,
|
||||
add,193485,,Dastardly Dirigibles,300446,,added,2026-08-06T02:54:54+00:00,
|
||||
add,226065,,Sheriff of Nottingham: Merry Men,353389,,added,2026-08-06T02:55:01+00:00,
|
||||
add,143884,,Machi Koro,453051,,added,2026-08-06T02:55:09+00:00,
|
||||
add,358880,,Etherfields: Kittenburg Expansion,603694,,added,2026-08-06T02:55:15+00:00,
|
||||
add,432858,,Artistry,734890,,added,2026-08-06T02:55:21+00:00,
|
||||
add,417060,,Botany: Tantalizing Trees,706506,,added,2026-08-06T02:55:27+00:00,
|
||||
add,417064,,Botany: Perilous Perfumes,706510,,added,2026-08-06T02:55:33+00:00,
|
||||
add,161928,,Utter Nonsense,,,added,2026-08-06T02:55:40+00:00,
|
||||
add,50381,,Cards Against Humanity,,,added,2026-08-06T02:55:44+00:00,
|
||||
add,1269,,Skip-Bo,,,added,2026-08-06T02:55:50+00:00,
|
||||
add,202982,,Scrawl,,,added,2026-08-06T02:55:56+00:00,
|
||||
add,195314,,Herbaceous,,,added,2026-08-06T02:56:02+00:00,
|
||||
add,320202,,Dragon Land,,,added,2026-08-06T02:56:07+00:00,
|
||||
add,2452,,Jenga,,,added,2026-08-06T02:56:12+00:00,
|
||||
add,42063,,Code Master,,,added,2026-08-06T02:56:18+00:00,
|
||||
add,340420,,Throw Throw Avocado,,,added,2026-08-06T02:56:23+00:00,
|
||||
add,1260,,Rook,,,added,2026-08-06T02:56:28+00:00,
|
||||
add,116,,Guillotine,,,added,2026-08-06T02:56:34+00:00,
|
||||
add,169124,,Flick 'em Up!,,,added,2026-08-06T02:56:40+00:00,
|
||||
add,20866,,The Rocky Horror Trivia Game,,,added,2026-08-06T02:56:45+00:00,
|
||||
add,269564,,Captain Marvel: Secret Skrulls,,,added,2026-08-06T02:57:37+00:00,
|
||||
add,18755,,Pie Face!,,,added,2026-08-06T02:57:41+00:00,
|
||||
add,140509,,Dungeons & Dragons,,,failed,2026-08-06T02:58:01+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""Dungeons\ \&\ Dragons"", re.IGNORECASE)) to be visible"
|
||||
add,201248,,Evolution: The Beginning,,,added,2026-08-06T02:58:05+00:00,
|
||||
add,362205,,Sleeping Gods: Primeval Peril,,,added,2026-08-06T02:58:11+00:00,
|
||||
add,453845,,Murder at the Manor,,,added,2026-08-06T02:58:16+00:00,
|
||||
add,329230,,Bluffaneer,,,added,2026-08-06T02:58:20+00:00,
|
||||
add,358320,,Sleeping Gods: Distant Skies,,,added,2026-08-06T02:58:26+00:00,
|
||||
add,389113,,Rivals,,,added,2026-08-06T02:58:31+00:00,
|
||||
add,324937,,Wiz-War (9th Edition),,,added,2026-08-06T02:58:35+00:00,
|
||||
add,295770,,Frosthaven,,,added,2026-08-06T02:58:40+00:00,
|
||||
add,174430,,Gloomhaven,,,added,2026-08-06T02:58:46+00:00,
|
||||
add,442312,,Deadly Doodles,,,added,2026-08-06T02:58:51+00:00,
|
||||
add,332398,,Everdell: The Complete Collection,,,added,2026-08-06T02:58:56+00:00,
|
||||
add,334065,,Verdant,,,added,2026-08-06T02:59:01+00:00,
|
||||
add,283155,,Calico,,,added,2026-08-06T02:59:06+00:00,
|
||||
add,291457,,Gloomhaven: Jaws of the Lion,,,added,2026-08-06T02:59:12+00:00,
|
||||
add,150925,,Hold Your Breath!,,,added,2026-08-06T02:59:18+00:00,
|
||||
add,2980,,The Simpsons Trivia Game,,,added,2026-08-06T02:59:23+00:00,
|
||||
add,1197,,Blink,,,added,2026-08-06T02:59:29+00:00,
|
||||
add,140509,,Dungeons & Dragons,,,failed,2026-08-06T03:05:04+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""Dungeons\ \&\ Dragons"", re.IGNORECASE)) to be visible"
|
||||
add,352819,,The Office Trivia Game,,,added,2026-08-06T03:05:09+00:00,
|
||||
add,163412,,Patchwork,,,added,2026-08-06T03:05:14+00:00,
|
||||
add,291847,,Mantis Falls,,,added,2026-08-06T03:05:19+00:00,
|
||||
add,2058,,Civilization: West Extension Map,,,added,2026-08-06T03:05:24+00:00,
|
||||
add,452833,,Artistry: Delightful Doorways Mini Expansion,,,added,2026-08-06T03:05:31+00:00,
|
||||
update,240,53429559,Britannia,24621,,failed,2026-08-06T03:06:04+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Britannia"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
|
||||
update,71,53429530,Civilization,24006,,failed,2026-08-06T03:06:39+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Civilization"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
|
||||
update,177,53429542,Advanced Civilization,24972,,failed,2026-08-06T03:07:12+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Advanced\ Civilization"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
|
||||
update,240,53429559,Britannia,24621,,failed,2026-08-06T03:08:42+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Britannia"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
|
||||
update,71,53429530,Civilization,24006,,failed,2026-08-06T03:09:17+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Civilization"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
|
||||
update,177,53429542,Advanced Civilization,24972,,failed,2026-08-06T03:09:50+00:00,"TimeoutError: Locator.click: Timeout 30000ms exceeded. Call log: - waiting for get_by_role(""row"").filter(has_text=re.compile(r""Advanced\ Civilization"", re.IGNORECASE)).first.get_by_role(""link"", name=re.compile(r""own"", re.IGNORECASE)).first"
|
||||
update,240,53429559,Britannia,24621,,updated,2026-08-06T03:17:05+00:00,
|
||||
update,71,53429530,Civilization,24006,,failed,2026-08-06T03:17:11+00:00,"Error: Page.goto: net::ERR_NETWORK_CHANGED at https://boardgamegeek.com/collection/user/***?objectid=71&own=1 Call log: - navigating to ""https://boardgamegeek.com/collection/user/***?objectid=71&own=1"", waiting until ""domcontentloaded"""
|
||||
update,177,53429542,Advanced Civilization,24972,,updated,2026-08-06T03:17:17+00:00,
|
||||
update,71,53429530,Civilization,24006,,updated,2026-08-06T03:17:36+00:00,
|
||||
update,3090,53429504,Doctor Who: The Game of Time & Space,25782,,updated,2026-08-06T03:17:55+00:00,
|
||||
update,3585,53429482,Sorcerer: The Game of Magical Conflict,211154,,updated,2026-08-06T03:18:02+00:00,
|
||||
update,2524,53429367,StarForce 'Alpha Centauri': Interstellar Conflict in the 25th Century,219137,,updated,2026-08-06T03:18:06+00:00,
|
||||
update,224,53429816,History of the World,20727,,updated,2026-08-06T03:18:11+00:00,
|
||||
update,9209,53429636,Ticket to Ride,294188,,updated,2026-08-06T03:18:17+00:00,
|
||||
update,13,53430595,Catan,347121,,updated,2026-08-06T03:18:24+00:00,
|
||||
update,1219,53430485,Labyrinth,488621,,updated,2026-08-06T03:18:32+00:00,
|
||||
update,137909,53429649,Bugs in the Kitchen,216474,,updated,2026-08-06T03:18:38+00:00,
|
||||
update,27710,53430142,Catan Dice Game,606657,,updated,2026-08-06T03:18:44+00:00,
|
||||
update,169784,53429953,Castle Panic: The Dark Titan,260454,,updated,2026-08-06T03:18:49+00:00,
|
||||
update,147370,53429666,Robot Turtles,231664,,updated,2026-08-06T03:18:55+00:00,
|
||||
update,43443,53430559,Castle Panic,103068,,updated,2026-08-06T03:19:02+00:00,
|
||||
update,24310,53429632,The Red Dragon Inn,374258,,updated,2026-08-06T03:19:06+00:00,
|
||||
update,39856,53429748,Dixit,296529,,updated,2026-08-06T03:19:11+00:00,
|
||||
update,35505,53430257,Walk the Plank!,781669,,updated,2026-08-06T03:19:15+00:00,
|
||||
update,164,53430020,"Before I Kill You, Mister Bond",28465,,updated,2026-08-06T03:19:20+00:00,
|
||||
update,181304,148198097,Mysterium,536289,,updated,2026-08-06T03:19:27+00:00,
|
||||
update,65244,53429887,Forbidden Island,31208,,updated,2026-08-06T03:19:33+00:00,
|
||||
update,95386,53429908,Tempurra,290572,,updated,2026-08-06T03:19:41+00:00,
|
||||
update,258,53430200,Fluxx,20840,,updated,2026-08-06T03:19:47+00:00,
|
||||
update,312786,148198100,Poetry for Neanderthals,514050,,updated,2026-08-06T03:19:52+00:00,
|
||||
update,188614,53430332,Simon's Cat Card Game,779792,,updated,2026-08-06T03:19:57+00:00,
|
||||
update,4324,53429850,Risk: The Lord of the Rings,26859,,updated,2026-08-06T03:20:01+00:00,
|
||||
update,1927,53429933,Munchkin,28144,,updated,2026-08-06T03:20:06+00:00,
|
||||
update,1784,53430007,Dark Cults,110926,,updated,2026-08-06T03:20:11+00:00,
|
||||
update,176,53429986,Give Me the Brain!,27673,,updated,2026-08-06T03:20:16+00:00,
|
||||
update,257,53430040,Kill Doctor Lucky,520364,,updated,2026-08-06T03:20:21+00:00,
|
||||
update,153999,53612880,"...and then, we held hands.",280845,,updated,2026-08-06T03:20:26+00:00,
|
||||
update,166976,53612866,Consentacle,410382,,updated,2026-08-06T03:20:32+00:00,
|
||||
update,231302,53430652,The Cat Game,361880,,updated,2026-08-06T03:20:36+00:00,
|
||||
update,40692,53430517,Small World,294178,,updated,2026-08-06T03:20:45+00:00,
|
||||
update,172503,53429718,Mage Wars Academy,265107,,updated,2026-08-06T03:20:49+00:00,
|
||||
update,104710,53429642,Wiz-War (Eighth Edition),117685,,updated,2026-08-06T03:20:53+00:00,
|
||||
update,1339,53430097,Dungeon!,34390,,updated,2026-08-06T04:01:15+00:00,
|
||||
update,165986,53429696,Royals,277137,,updated,2026-08-09T15:42:48+00:00,
|
||||
update,40529,53429871,Cosmic Encounter,27692,,updated,2026-08-09T15:42:56+00:00,
|
||||
update,157969,53430297,Sheriff of Nottingham,237864,,updated,2026-08-09T15:43:07+00:00,
|
||||
update,207830,53429613,5-Minute Dungeon,344075,,updated,2026-08-09T15:43:13+00:00,
|
||||
update,194655,53430612,Santorini,668826,,updated,2026-08-09T15:43:19+00:00,
|
||||
update,161928,148198650,Utter Nonsense,245124,,updated,2026-08-09T15:43:25+00:00,
|
||||
update,50381,148198655,Cards Against Humanity,134539,,updated,2026-08-09T15:43:32+00:00,
|
||||
update,193621,53430387,Joking Hazard,300682,,updated,2026-08-09T15:43:37+00:00,
|
||||
update,1269,148198659,Skip-Bo,29705,,updated,2026-08-09T15:43:44+00:00,
|
||||
update,172225,53430108,Exploding Kittens,296012,,updated,2026-08-09T15:43:50+00:00,
|
||||
update,202982,148198665,Scrawl,316090,,updated,2026-08-09T15:43:58+00:00,
|
||||
update,2529,53429839,Flat Top,98655,,updated,2026-08-09T15:44:04+00:00,
|
||||
update,483,49111776,Diplomacy,48522,,updated,2026-08-09T15:44:11+00:00,
|
||||
update,26,53429831,Age of Renaissance,28607,,updated,2026-08-09T15:44:21+00:00,
|
||||
update,97903,53430468,Rampage,231971,,updated,2026-08-09T15:44:27+00:00,
|
||||
update,160477,53430375,Onitama,269392,,updated,2026-08-09T15:44:34+00:00,
|
||||
update,195314,148198667,Herbaceous,525268,,updated,2026-08-09T15:44:39+00:00,
|
||||
update,320202,148198669,Dragon Land,528008,,updated,2026-08-09T15:44:43+00:00,
|
||||
update,2452,148198672,Jenga,288640,,updated,2026-08-09T15:44:50+00:00,
|
||||
update,208428,53429689,"No Thank You, Evil!",325455,,updated,2026-08-09T15:44:58+00:00,
|
||||
update,42063,148198675,Code Master,191736,,updated,2026-08-09T15:45:09+00:00,
|
||||
update,57215,53430150,Googly Eyes,295423,,updated,2026-08-09T15:45:14+00:00,
|
||||
update,340420,148198677,Throw Throw Avocado,567183,,updated,2026-08-09T15:45:19+00:00,
|
||||
update,139443,53430424,Superfight,202519,,updated,2026-08-09T15:45:24+00:00,
|
||||
update,1260,148198681,Rook,156689,,updated,2026-08-09T15:45:29+00:00,
|
||||
update,2197,53430503,Kensington,42495,,updated,2026-08-09T15:45:35+00:00,
|
||||
update,116,148198685,Guillotine,28201,,updated,2026-08-09T15:45:42+00:00,
|
||||
update,124548,53430526,The Doom That Came to Atlantic City,165214,,updated,2026-08-09T15:45:48+00:00,
|
||||
update,169124,148198688,Flick 'em Up!,451577,,updated,2026-08-09T15:45:54+00:00,
|
||||
update,2387,54518133,Huggermugger,91428,,updated,2026-08-09T15:45:59+00:00,
|
||||
update,20866,148198690,The Rocky Horror Trivia Game,40025,,updated,2026-08-09T15:46:07+00:00,
|
||||
update,196105,53430662,Speechless,304900,,updated,2026-08-09T15:46:13+00:00,
|
||||
update,156994,53430578,Skylanders Portal Master,236280,,updated,2026-08-09T15:46:18+00:00,
|
||||
update,269564,148198703,Captain Marvel: Secret Skrulls,439948,,updated,2026-08-09T15:46:24+00:00,
|
||||
update,18755,148198705,Pie Face!,284056,,updated,2026-08-09T15:46:30+00:00,
|
||||
update,166109,53429921,Meteor,253058,,updated,2026-08-09T15:46:36+00:00,
|
||||
update,125675,53429970,Doctor Who: The Card Game,167657,,updated,2026-08-09T15:46:41+00:00,
|
||||
update,128554,53429733,Völuspá,207529,,updated,2026-08-09T15:46:46+00:00,
|
||||
update,822,53429625,Carcassonne,187468,,updated,2026-08-09T15:46:53+00:00,
|
||||
update,201248,148198712,Evolution: The Beginning,313478,,updated,2026-08-09T15:46:59+00:00,
|
||||
update,362205,148198714,Sleeping Gods: Primeval Peril,612448,,updated,2026-08-09T15:47:05+00:00,
|
||||
update,220199,53430477,Snappy Dressers,369805,,updated,2026-08-09T15:47:09+00:00,
|
||||
update,453845,148198716,Murder at the Manor,772660,,updated,2026-08-09T15:47:15+00:00,
|
||||
update,329230,148198718,Bluffaneer,547518,,updated,2026-08-09T15:47:23+00:00,
|
||||
update,358320,148198720,Sleeping Gods: Distant Skies,602661,,updated,2026-08-09T15:47:29+00:00,
|
||||
update,389113,148198726,Rivals,662372,,updated,2026-08-09T15:47:34+00:00,
|
||||
update,324937,148198729,Wiz-War (9th Edition),537230,,updated,2026-08-09T15:47:43+00:00,
|
||||
update,295770,148198731,Frosthaven,484007,,updated,2026-08-09T15:47:49+00:00,
|
||||
update,174430,148198734,Gloomhaven,520054,,updated,2026-08-09T15:47:55+00:00,
|
||||
update,442312,148198737,Deadly Doodles,752105,,updated,2026-08-09T15:48:03+00:00,
|
||||
update,332398,148198739,Everdell: The Complete Collection,553061,,updated,2026-08-09T15:48:12+00:00,
|
||||
update,334065,148198740,Verdant,759453,,updated,2026-08-09T15:48:19+00:00,
|
||||
update,283155,148198745,Calico,462049,,updated,2026-08-09T15:48:27+00:00,
|
||||
update,291457,148198746,Gloomhaven: Jaws of the Lion,476060,,updated,2026-08-09T15:48:36+00:00,
|
||||
update,173057,53430364,Go Fish Yourself,266044,,updated,2026-08-09T15:48:43+00:00,
|
||||
update,150925,148198748,Hold Your Breath!,225659,,updated,2026-08-09T15:48:49+00:00,
|
||||
update,2980,148198751,The Simpsons Trivia Game,138013,,updated,2026-08-09T15:48:54+00:00,
|
||||
update,1197,148198753,Blink,78624,,updated,2026-08-09T15:49:03+00:00,
|
||||
update,352819,148198874,The Office Trivia Game,591147,,updated,2026-08-09T15:49:08+00:00,
|
||||
update,163412,148198877,Patchwork,495998,,updated,2026-08-09T15:49:20+00:00,
|
||||
update,291847,148198878,Mantis Falls,476820,,updated,2026-08-09T15:49:29+00:00,
|
||||
update,2058,148198880,Civilization: West Extension Map,28041,,updated,2026-08-09T15:49:34+00:00,
|
||||
update,452833,148198886,Artistry: Delightful Doorways Mini Expansion,770724,,updated,2026-08-09T15:49:39+00:00,
|
||||
update,104590,53430550,Castle Panic: The Wizard's Tower,117256,,updated,2026-08-09T15:49:45+00:00,
|
||||
add,173634,,The Red Dragon Inn 5: The Character Trove,266954,,added,2026-08-09T16:24:23+00:00,
|
||||
add,455834,,The Red Dragon Inn 10,791302,,added,2026-08-09T16:24:28+00:00,
|
||||
add,33451,,The Red Dragon Inn 2,629314,,added,2026-08-09T16:24:35+00:00,
|
||||
add,66510,,The Red Dragon Inn 3,629315,,added,2026-08-09T16:24:41+00:00,
|
||||
add,142402,,The Red Dragon Inn 4,657689,,added,2026-08-09T16:24:48+00:00,
|
||||
add,212662,,The Red Dragon Inn 6: Villains,367695,,added,2026-08-09T16:24:54+00:00,
|
||||
add,244258,,The Red Dragon Inn 7: The Tavern Crew,421810,,added,2026-08-09T16:25:02+00:00,
|
||||
add,330097,,The Red Dragon Inn 8: Pub Crawl,557415,,added,2026-08-09T16:25:08+00:00,
|
||||
add,380868,,The Red Dragon Inn 9: The Undercity,800879,,added,2026-08-09T16:25:14+00:00,
|
||||
add,302181,,The Red Dragon Inn: Allies – Evil Pooky,629307,,added,2026-08-09T16:25:20+00:00,
|
||||
add,174922,,The Red Dragon Inn: Allies – Halden the Unhinged,657858,,added,2026-08-09T16:25:27+00:00,
|
||||
add,442637,,The Red Dragon Inn: Allies – Jin vs Greppa,752778,,added,2026-08-09T16:25:32+00:00,
|
||||
add,403503,,The Red Dragon Inn: Allies – Melvyn vs. Marah,712612,,added,2026-08-09T16:25:40+00:00,
|
||||
add,125472,,The Red Dragon Inn: Allies – Pooky,629302,,added,2026-08-09T16:25:47+00:00,
|
||||
add,168857,,The Red Dragon Inn: Allies – Wrench,657857,,added,2026-08-09T16:25:54+00:00,
|
||||
add,291130,,Etherfields: 5th Player Expansion,475387,,added,2026-08-09T18:47:14+00:00,
|
||||
add,355873,,Etherfields: Awakened Items & Actions,597861,,added,2026-08-09T18:47:22+00:00,
|
||||
add,291131,,Etherfields: Funeral Witch Campaign,475388,,added,2026-08-09T18:47:30+00:00,
|
||||
add,309202,,Etherfields: Search Mini Deck,507805,,added,2026-08-09T18:47:37+00:00,
|
||||
add,381209,,Etherfields: Shifting Story Promo Deck,651213,,added,2026-08-09T18:47:46+00:00,
|
||||
add,290840,,Etherfields: Sphinx Campaign,474978,,added,2026-08-09T18:47:54+00:00,
|
||||
add,386805,,Etherfields: Story Dice Expansion,658656,,added,2026-08-09T18:48:00+00:00,
|
||||
add,351544,,Etherfields: Stretch Goals – Harpy & She-Wolf Campaigns,588185,,added,2026-08-09T18:48:08+00:00,
|
||||
add,291126,,Etherfields: Thorn Knight,475383,,added,2026-08-09T18:48:17+00:00,
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -100,3 +100,89 @@ the existing one).
|
||||
page but did not overlay the form in the probe.
|
||||
- Logged-in detection heuristic (unverified): the header shows a "Sign In"
|
||||
link only when logged out.
|
||||
|
||||
|
||||
## Verified against the live site (2026-08-06, first real uploads)
|
||||
|
||||
The add flow works end to end; every failure on the way was in code the
|
||||
earlier walkthrough had marked *verified*, and the parts marked
|
||||
*unverified* were mostly right. Corrections:
|
||||
|
||||
- **Sign In is an `<a class="btn">` with no `href`.** It therefore has no
|
||||
implicit `link` role: `get_by_role("link", name="Sign In")` matches
|
||||
nothing in any state. The header also hydrates after
|
||||
`domcontentloaded`, so for a moment neither Sign In nor Sign Out
|
||||
exists — a check resting on one absence silently concludes "signed in"
|
||||
and browses anonymously. Poll until one control or the other proves the
|
||||
state; treat "neither, after 30s" as an error.
|
||||
- **`get_by_label("Own")` also matches "Prev. Owned."** Use
|
||||
`get_by_role("checkbox", name="Own", exact=True)`.
|
||||
- **Version rows** are the `<li>`s carrying a thumbnail:
|
||||
`li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
|
||||
paging `<li>`s ("First", "Prev", "1", "…").
|
||||
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
|
||||
buttons: `a[title="Next Page"]`, with the parent `<li>` gaining
|
||||
`disabled` at the end. Paging is client-side over an already-loaded
|
||||
list — no request per page.
|
||||
- **Every paging control renders TWICE**: a desktop set and a mobile set
|
||||
inside `<li class="visible-xs-*">`. A selector matches both, and
|
||||
`.first` may be the hidden one — Playwright then waits for it to become
|
||||
visible until it times out. Always click the first *visible* match.
|
||||
First/Prev may be unclickable on a desktop viewport (mobile-only
|
||||
variant). To return to page 1, click the visible numbered **"1"**
|
||||
anchor: the sub-view's paging state SURVIVES closing and reopening it
|
||||
(Angular keeps the scope), so a reopen lands wherever it was left, not
|
||||
on page 1.
|
||||
- **Match row text in Python, not with `has_text`.** Playwright's
|
||||
`has_text` regex tests raw `textContent`, which carries the markup's
|
||||
tabs and newlines; a whitespace-normalized capture will never equal it.
|
||||
Normalize both sides yourself and click the row by index.
|
||||
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
|
||||
name is localized (a Czech edition's row starts "Spící bohové"). Match
|
||||
the version name inside its parentheses.
|
||||
- **The API's version name is not always the picker's string.** BGG's
|
||||
XML gives e.g. `English edition 2018-2` where the picker shows
|
||||
`(English edition) (2018)`. Match the full name first, then retry with
|
||||
a trailing year/printing qualifier stripped — and if that relaxed match
|
||||
hits more than one row, refuse and add version-less (never guess).
|
||||
- **An owned game's page has no "Add To" button**; it reads
|
||||
"In Collections (Own…)". That is the update flow's entry point.
|
||||
|
||||
### The update flow (verified 2026-08-06)
|
||||
|
||||
Do **not** go through the game page's dialog for an existing entry — it
|
||||
cannot say which copy it edits. The collection table can:
|
||||
|
||||
1. `/collection/user/<user>?objectid=<bgg_id>&own=1`.
|
||||
2. The version cell carries its own collid:
|
||||
`td.collection_version[onclick*="<collid>"]`. Clicking it opens an
|
||||
inline editor (the cell shows *Editing* meanwhile).
|
||||
3. The editor is a radio list whose **values are version ids**:
|
||||
`form[id^="form_version"] input[type="radio"][value="<version_id>"]`.
|
||||
Both the copy and the edition are therefore addressed exactly — no
|
||||
name matching, no pagination.
|
||||
4. Clicking the radio fires `CE_SaveData(cellid, collid, 'version')`
|
||||
itself. **There is no Save button.** The save has landed when the
|
||||
cell's text stops reading *Editing*.
|
||||
|
||||
This is strictly additive: it sets one field on one collid and cannot
|
||||
create a duplicate entry.
|
||||
|
||||
### BGG's collection export lags the site
|
||||
|
||||
After a successful update the website shows the new version immediately
|
||||
(the row's version cell reads e.g. "English first edition Year: 2012",
|
||||
and reopening the editor shows that radio checked), but the XML API's
|
||||
`/collection` export can still report the entry with no version — even
|
||||
with a cache-busting re-request. `diff` reads the API, so it will
|
||||
re-queue work that has already landed.
|
||||
|
||||
Consequences to keep in mind rather than "fix":
|
||||
|
||||
- The upload log is the authority on what this tool did; the API is the
|
||||
authority on what BGG has published. They disagree for a while.
|
||||
- `build_queue` skipping a job whose log says done is CORRECT here — the
|
||||
work is applied, and re-running it would be a no-op at best.
|
||||
- Anything user-facing should count PENDING jobs (queue rows minus what
|
||||
the log completed), never raw queue rows, or settled work reads as
|
||||
outstanding until the next diff.
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# The bggpipe user's guide
|
||||
|
||||
Everything past the [README](../README.md)'s quick start: credentials, every stage and its flags, the web app, phones, RPGs, fixing the model's mistakes, and running without a BGG token. Screenshots of everything described here: the [tour](tour.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Credentials and configuration](#credentials-and-configuration)
|
||||
- [The web app](#the-web-app)
|
||||
- [From your phone](#from-your-phone)
|
||||
- [The stages, from the terminal](#the-stages-from-the-terminal)
|
||||
- [Taking good shelf photos](#taking-good-shelf-photos)
|
||||
- [Fixing what the model gets wrong](#fixing-what-the-model-gets-wrong)
|
||||
- [RPGs on your shelves](#rpgs-on-your-shelves)
|
||||
- [Uploading safely](#uploading-safely)
|
||||
- [Running before your BGG token arrives](#running-before-your-bgg-token-arrives)
|
||||
- [Keeping your data safe from git](#keeping-your-data-safe-from-git)
|
||||
|
||||
## Credentials and configuration
|
||||
|
||||
The `init` wizard is idempotent — re-run it anytime to check status or add keys you skipped. It prompts for the credentials below (hidden input, saved to a `.env` it creates with owner-only permissions) and offers the one-time Playwright Chromium download. Prefer doing it by hand? Copy [.env.example](../.env.example) beside your data, fill it in, and run `playwright install chromium` yourself.
|
||||
|
||||
Secrets live in environment variables only, never in config files, code, or logs, and `.env` is gitignored. Every `bggpipe` command loads `.env` from the working directory by itself — real environment variables always win over the file, so [direnv](https://direnv.net/) users and CI overrides keep working unchanged.
|
||||
|
||||
Your credentials never leave your machine except to sign in to boardgamegeek.com itself — [the README spells out the full privacy picture](../README.md#why-it-asks-for-your-bgg-password--and-where-your-credentials-go). The password exists solely because BGG has no write API: adding games means driving the real website, in a visible browser window, on your computer. The saved browser session (`storage_state.json`) is credential-adjacent — it stays local and gitignored too.
|
||||
|
||||
| Variable | Used by | What it is |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | extract | Anthropic API key |
|
||||
| `BGG_API_TOKEN` | resolve, diff, enrich | Bearer token from your registered BGG application |
|
||||
| `BGG_USERNAME` | diff, upload, enrich | Your BGG username (public, but kept in `.env` so it lives in one place) |
|
||||
| `BGG_PASSWORD` | upload (website login) | Your BGG password |
|
||||
|
||||
Non-secret knobs live in `config.toml`: `photos_dir`, `data_dir`, the BGG rate limit, and the vision setup — a `[vision.<provider>]` block per provider ("anthropic" or any OpenAI-compatible endpoint, including a local [Ollama](https://ollama.com/)), with `vision_provider` picking one. Local models read spines noticeably worse than frontier ones — expect a longer proofread pass on the Titles page, not a broken pipeline.
|
||||
|
||||
**What extraction costs:** the vision call is the pipeline's only paid step, and it's small — the author's full collection (65 shelf photos, 136 games) came to under a dollar on the default model (Claude Sonnet). It's also one-time: raw reads are cached per photo, so re-running extract is free, and only new or replaced photos are ever sent again. Everything BGG-side is free (the API token costs nothing).
|
||||
|
||||
## The web app
|
||||
|
||||
```sh
|
||||
bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
|
||||
```
|
||||
|
||||
Eight pages — Pipeline, Photos, Titles, Review, Queue, Library, Shelves, and Help — all [pictured in the tour](tour.md). Stage runs execute one at a time in the background with live output:
|
||||
|
||||

|
||||
|
||||
Stage runs execute one at a time; every decision saves immediately; the pages live-follow the data files, so a stage run in another terminal shows up without a refresh. The real upload sits behind a confirmation (and behind a stub-data lock if synthetic test fixtures ever regenerate). The in-app **Help** page documents every status chip and keyboard shortcut.
|
||||
|
||||
## From your phone
|
||||
|
||||
The app is localhost-only by default. To use it from a phone or tablet on your network — proofreading from the couch, or shooting shelf photos straight into the pipeline — serve it to the LAN instead:
|
||||
|
||||
```sh
|
||||
bggpipe web --lan # localhost + your network, behind an access key
|
||||
```
|
||||
|
||||
Startup prints a pairing link (`?k=...`) and a QR code: point the phone's camera at the terminal and tap. Pairing is one-time per device — the key persists across restarts (`data/.lan_key`; delete it to revoke every paired device) and the cookie lasts a year. Save the page to the phone's home screen for the full-screen treatment, piper icon included.
|
||||
|
||||
To photograph shelves from the phone: on the Photos page, tap the drop zone and choose "Take Photo." The upload narrates its progress, and camera captures get unique `shelf-<timestamp>` names so rapid-fire shots never overwrite each other. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead).
|
||||
|
||||
## The stages, from the terminal
|
||||
|
||||
Every stage is also a command, and the two interfaces share all state:
|
||||
|
||||
```sh
|
||||
bggpipe extract # photos → titles.json (+ retake prompts)
|
||||
bggpipe resolve # titles → BGG ids/versions in matches.csv
|
||||
bggpipe review --web # review UI only
|
||||
bggpipe diff # compare against your BGG collection
|
||||
bggpipe upload --dry-run # ALWAYS inspect this first
|
||||
bggpipe upload --limit 1 # then one game, then small batches
|
||||
bggpipe enrich # full metadata → data/games.json
|
||||
```
|
||||
|
||||
Each stage skips work it has already done; `--force`/`--refresh` flags redo it. Every stage is idempotent and resumable: kill it mid-run, restart, lose nothing. All artifacts are flat CSV/JSON files you can inspect and edit. `review` without `--web` runs in the terminal instead.
|
||||
|
||||
## Taking good shelf photos
|
||||
|
||||
Straight-on, one shelf (or part of one) per shot, close enough that spine text is legible to a human. If you can't read it, the model can't either. Overlap between shots is fine: duplicate reads are deduped automatically, with the merge shown (and veto-able) in review. Boxes the model spots but can't identify become retake prompts in `unidentified.json` and the Photos page's "reshoot" tickets: photograph those boxes up close, drop the new photo in, and run `extract` again.
|
||||
|
||||
## Fixing what the model gets wrong
|
||||
|
||||
Vision reads aren't perfect, and you know things the photos don't show. The Titles page lets you **edit** a title (fix a misspelling, add publisher/edition/year/language cues you know offhand), **split** a line into per-photo copies when one title is actually several boxes, **remove** lines that aren't games at all, and **add** a game no photo caught. Every one of these is durable: the decision lands in a small committed store (`data/title_edits.json`, `data/title_splits.json`, `data/title_removals.json`, `data/title_additions.json`) that is replayed on every rebuild — re-running extract or resolve can never undo your curation. Undo any decision by deleting its record from the store.
|
||||
|
||||
Games BGG doesn't have at all can be kept as **local** library citizens: their detail page in the Library takes hand-written facts (players, playtime, publisher, notes) and a cover photo of your own, stored in `data/local_games.json` and `data/local_art/` — the only source such a game will ever have.
|
||||
|
||||
## RPGs on your shelves
|
||||
|
||||
Tabletop RPGs aren't in BGG's board-game database — they live on RPGGeek, which shares the same underlying API. When a title isn't found as a board game, bggpipe retries as an RPG: matches are identified, enriched (designers, publishers, genres from RPGGeek), and browsable in the library (filter: RPGs), but they stay **local only** — they're never uploaded, since your BGG collection can't hold them. When the automatic search can't reach the right database (BGG has board games named "Dungeons & Dragons" too), every Review card has explicit **search BGG** / **search RPGGeek** buttons.
|
||||
|
||||
## Uploading safely
|
||||
|
||||
`upload` drives a real logged-in browser session against your real account, so it is deliberately careful:
|
||||
|
||||
- `--dry-run` logs what would happen without touching the site — always read it first, then `--limit 1`, then small batches:
|
||||
|
||||

|
||||
- The browser runs **headed** by default — BGG's Cloudflare check blocks headless ones, and a first login may need one human click before the session is saved locally and reused.
|
||||
- Requests are slow on purpose (seconds between actions, per BGG's API policy); the Queue page shows exactly what will run before it runs, and `upload_log.csv` keeps a permanent record of every attempt.
|
||||
- `--retry-failed` re-attempts failures; `--verify` re-fetches your collection and cross-checks the log. Note that BGG's collection export can lag the website by hours — freshly-landed work may look missing to `diff`/`--verify` until it catches up.
|
||||
- Review decisions outrank the queue: re-deciding a match after `diff` retires its queued job automatically.
|
||||
|
||||
## Running before your BGG token arrives
|
||||
|
||||
BGG application approval can take a week or more. Until then: `extract` works immediately (it only needs the vision key), and `resolve` does what it can, parking the rest as "waiting on BGG API token" — it picks them up automatically once the token exists. Everything is saved as you go.
|
||||
|
||||
`diff` normally fetches your collection live, but there's a logged-in-browser exemption that needs no token: while signed in to BGG, save these two URLs as `data/collection_snapshot_base.xml` and `data/collection_snapshot_expansions.xml` (if you get a "queued" message, refresh after a few seconds):
|
||||
|
||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
|
||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
|
||||
|
||||
## Where everything lives
|
||||
|
||||
The **Shelves** page maps your physical storage and draws it as it really
|
||||
stands: openings group into rows by their label letters, and each cell's
|
||||
width is proportional to its interior width, so a Kallax with a double-wide
|
||||
top row renders as exactly that — a diagram of the wall, not a list. Create
|
||||
units from presets (IKEA Kallax cube 13.25″ × 13.25″ × 15.4″, Billy shelf,
|
||||
custom, or a no-size virtual spot like "travel case") as grids of rows ×
|
||||
columns; add more sections to an existing unit the same way — a later grid
|
||||
continues the row letters, so 3 × 4 under an A row lands as B1…D4. Duplicate a
|
||||
unit to stamp out identical furniture — the copy takes the structure
|
||||
(openings, sizes, zones, descriptions) under a fresh auto-numbered name,
|
||||
never the games — and rename any unit in place. Openings
|
||||
are editable (dimensions are all-or-none: three numbers or a no-limit
|
||||
spot), reorderable within their row (moves clamp at row boundaries so the
|
||||
diagram can't fragment), and deletable. Both units and openings take a
|
||||
free-text **description** alongside the **zone** keyword — the label is
|
||||
the address, the zone matches, the description explains. Everything lands
|
||||
in `data/furniture.json` + `data/locations.json` — committed local stores;
|
||||
your shelf layout is never sent anywhere.
|
||||
|
||||
Assigning games: every measured unshelved game shows up to three suggested
|
||||
openings, ranked by a strict relevance doctrine. An opening already holding
|
||||
a **series-mate or base game ranks first** (marked ♥) — kinship is the
|
||||
pre-colon name stem plus BGG's series field, no taxonomy to maintain, so
|
||||
every placement you confirm teaches the suggester your organization and
|
||||
expansions chase their base games automatically. "Fits" always means the
|
||||
**remaining** opening — stack budget already spent is subtracted — and
|
||||
after that, tightest verified fit, so small boxes are offered cubes and
|
||||
never the oversize row. A measured game with nowhere suitable says
|
||||
"no matching openings yet"; an unmeasured game gets no suggestions at all,
|
||||
because can't-verify is not the same as fits. You can also assign by
|
||||
searching inside an opening, or from a game's detail page (openings
|
||||
grouped by unit, each labeled fits / doesn't fit / can't verify). Games
|
||||
stored inside another box inherit their container's spot — through whole
|
||||
chains (minis in an insert in a big box live where the big box does) —
|
||||
and the space view lists only the physical boxes: contents collapse into
|
||||
a "holds N inside" hint on their container's row (hover for names; the
|
||||
full list lives on the container's game page). On a
|
||||
phone over `--lan`, the opening view docks as a bottom sheet — the
|
||||
moving-day loop is search, tap, done.
|
||||
|
||||
When you publish with `bggpipe export`, the shelves come too: game pages
|
||||
say where each box lives, containers list their contents, and a shelves
|
||||
page draws the wall as it physically stands, every opening linking its
|
||||
residents. Publishing your shelf layout is a choice — `--no-shelves`
|
||||
keeps the whole layer out of the export.
|
||||
|
||||
Bulk-load a reviewed plan with `bggpipe shelve --import plan.csv` (columns
|
||||
`name,opening`, accepting opening ids or labels) — rejects are reported
|
||||
(unknown name, ambiguous copies, unknown opening, doesn't fit, lives
|
||||
inside another box), never guessed. Re-importing is an upsert, not a
|
||||
reset: rows move their games, games absent from the plan keep their
|
||||
spots, and hand-entered notes, lane choices, and acknowledgments survive
|
||||
whenever the opening is unchanged.
|
||||
|
||||
The packer is deliberately conservative — one flat pile plus one
|
||||
standing row per opening — and real shelves are 3-D: boxes sit
|
||||
crosswise, ride on top of piles, stand proud of a shallow shelf. When
|
||||
the model refuses a placement you can see with your own eyes, overrule
|
||||
it: the **it's here anyway** button in the opening's view (or an
|
||||
`acknowledge` column, `yes`, in the import CSV) confirms the box is
|
||||
really there. An acknowledged box takes a lane when one works — the
|
||||
spine-out overhang case lands "standing, proud", skipping only the
|
||||
depth check — and when even that fails it's marked **wedged in**:
|
||||
really there, outside the capacity bars, never warned about. Tap
|
||||
"here ✓" to retract. Assignment and capacity are separate truths:
|
||||
a game listed in an opening IS there regardless of what the packer
|
||||
thinks of the arrangement.
|
||||
|
||||
Where a game *lives* and where it *is* can differ — the travel case,
|
||||
a friend's house. Every game's detail page has a **Right now** field:
|
||||
type "travel case" or "loaned to Ben" and the box is marked out
|
||||
everywhere (an "out:" chip in its home opening, on the export's game
|
||||
page too) while its home spot stays reserved — the shelf math doesn't
|
||||
change because a loan isn't a move. Contents ride along: games stored
|
||||
inside an out box read as out with it. Blank the field and it's back;
|
||||
nothing else is touched.
|
||||
|
||||
Unmeasured games can't join the capacity math at all — so measure
|
||||
them. The "unmeasured — measure it" button in the opening's view takes
|
||||
width × length × depth in inches, saved to `data/measurements.json`
|
||||
(committed, like all curation). Your tape measure outranks BGG: hand
|
||||
measurements overlay whatever enrich found, everywhere sizes are used
|
||||
(fit checks, suggestions, `bggpipe dims`, the export). Save with all
|
||||
three fields empty to clear one.
|
||||
An acknowledged box takes the standing lane without the depth check —
|
||||
the width budget stays honest — and stops counting as a warning. Once
|
||||
acknowledged, later imports needn't re-state it. `bggpipe dims` reports per-opening capacity, overfull warnings, and
|
||||
misfits against your real furniture. Capacity uses a **two-lane packing
|
||||
model**: each opening holds a flat stack (thinnest axes summing against
|
||||
interior height, claiming the widest flat box's width) and a standing lane
|
||||
beside it (boxes on edge against the remaining width) — the largest boxes
|
||||
lie flat, smaller ones stand, and you can flip any box's lane in the
|
||||
opening's view. An opening warns only when *no* packing fits. Unmeasured
|
||||
boxes are counted and shown, never silently assumed to fit.
|
||||
|
||||
## Shelf-space planning
|
||||
|
||||
Physical box dimensions live on BGG *versions*, not games, so enrich collects
|
||||
them in a second cached pass: your exact version's numbers where you chose
|
||||
one, otherwise a value only when every listed printing agrees (within half an
|
||||
inch per axis — disagreement is stored as `conflicting`, never guessed, and
|
||||
BGG's `0` means "never entered", not zero inches). Then:
|
||||
|
||||
```sh
|
||||
bggpipe dims
|
||||
```
|
||||
|
||||
reports coverage, the ten biggest footprints, and per-opening fit checks
|
||||
against your own furniture (see *Where everything lives*) — capacity,
|
||||
overfull warnings, boxes that fit in **no** orientation, and every game whose
|
||||
dimensions can't be verified, because can't-verify is not the same as fits.
|
||||
Trusted dimensions also show on each game's Library detail page.
|
||||
|
||||
## Publishing your library on your own site
|
||||
|
||||
```sh
|
||||
bggpipe export --out ~/my-site/static/library --title "My Game Shelves"
|
||||
```
|
||||
|
||||
Writes the library as self-contained static pages: an index with search, one
|
||||
page per game, and a local copy of every cover (public pages must not hotlink
|
||||
BGG's image CDN). No server, no build step — drop the directory into any
|
||||
static host, Hugo/Jekyll `static/` folder included. Re-runs are idempotent:
|
||||
already-downloaded covers are kept, failed downloads are retried, and page
|
||||
URLs stay stable.
|
||||
|
||||
What's included follows a privacy rule: cover art, stats, your editions, and
|
||||
your hand-added local games — **never your shelf photos** (they picture the
|
||||
inside of your home; they stay in the private app).
|
||||
|
||||
BGG's API policy asks public-facing apps to display the "Powered by BGG"
|
||||
badge. The footer carries a text badge automatically; to show the official
|
||||
logo, save it from your registered application page at
|
||||
[boardgamegeek.com/applications](https://boardgamegeek.com/applications) as
|
||||
`data/powered-by-bgg.png` and re-export.
|
||||
|
||||
## Keeping your data safe from git
|
||||
|
||||
The [README's quick start](../README.md#quick-start) — installed as a tool, run in a directory of your own — is the only supported way to use bggpipe on your collection. Everything the pipeline produces lives where you run it, and `uv tool upgrade bggpipe` picks up fixes without going anywhere near your data.
|
||||
|
||||
**Why not clone and run?** The source repo doubles as its author's live pipeline: `data/` ships with their real artifacts, committed and updated often. Run inside a clone and *your* data lands at git-tracked paths — the next `git pull` will refuse to merge, and the usual remedies (`git reset --hard`, `git checkout .`, `git stash`, `git clean -fdx`) would destroy your review decisions, hand-written games, upload log, and photos. bggpipe detects this arrangement and warns at `init` and on the web dashboard; don't ignore it.
|
||||
|
||||
One committed file of the author's data deserves a word: `data/STUB_DATA.marker` is normally absent. It appears only if the synthetic stub fixtures (from `scripts/write_stub_fixtures.py`) regenerate the CSVs, and the upload stage refuses to run while it exists — so placeholder data can never reach a real BGG account.
|
||||
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 748 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 588 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 731 KiB |
|
After Width: | Height: | Size: 841 KiB |
|
After Width: | Height: | Size: 695 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 229 KiB |
@@ -1,4 +1,8 @@
|
||||
# Shelf-to-BGG Collection Pipeline — Build Spec
|
||||
# Shelf-to-BGG Collection Pipeline — Design Contract
|
||||
|
||||
> This is the pipeline's contract — what must stay true, and why — kept
|
||||
> current as the design evolves. Read it before changing pipeline
|
||||
> semantics. How to *use* the pipeline is the [user's guide](guide.md).
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -11,9 +15,10 @@ Beyond the bare game, capture **which edition/version I own** wherever the photo
|
||||
## Constraints & Context
|
||||
|
||||
- BGG has **no write API**. Reads go through the XML API2 (`https://boardgamegeek.com/xmlapi2/`); writes must automate the website itself with a real login session.
|
||||
- The XML API **requires a registered application** (boardgamegeek.com/using_the_xml_api, policy 2025-07-02): every request carries `Authorization: Bearer <token>` from the `BGG_API_TOKEN` env var, sent to `boardgamegeek.com` without a leading `www`. Register a free non-commercial application at boardgamegeek.com/applications — approval can take a week+, so development runs on recorded/stub XML fixtures until then.
|
||||
- The XML API **requires a registered application** (boardgamegeek.com/using_the_xml_api, policy 2025-07-02): every request carries `Authorization: Bearer <token>` from the `BGG_API_TOKEN` env var, sent to `boardgamegeek.com` without a leading `www`. Register a free non-commercial application at boardgamegeek.com/applications — approval can take a week+; offline development and tests run on recorded XML fixtures.
|
||||
- BGG's XML API queues collection requests: a first call may return HTTP 202 ("try again"). Retry with backoff.
|
||||
- BGG 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).
|
||||
@@ -66,7 +71,7 @@ Each stage reads the previous stage's artifact and writes its own. Re-running a
|
||||
|
||||
### Stage 3 — `review`: Human review of ambiguous/unmatched
|
||||
|
||||
- A minimal local review flow. A TUI is fine (e.g., `rich`/`textual`), or a tiny localhost web page — builder's choice, but keep it dependency-light.
|
||||
- A minimal local review flow. A TUI is fine (e.g., `rich`/`textual`), or a tiny localhost web page — builder's choice, but keep it dependency-light. *(As built: both — a rich TUI and a six-page FastAPI app sharing one decision engine.)*
|
||||
- For each `ambiguous` item: show the raw title, source photo filename, and candidate list (name, year, type, BGG rank, owned count) → pick one, skip, or reject.
|
||||
- For each `unmatched` item: allow manual BGG ID entry or a free-text re-search.
|
||||
- For each `version_ambiguous` item: show the version candidates (version name, publisher, year, language) next to the edition cues from the photo → pick one, or mark `version_unknown`. Keep this pass optional/skippable — version review shouldn't block getting games uploaded.
|
||||
@@ -88,7 +93,7 @@ Each stage reads the previous stage's artifact and writes its own. Re-running a
|
||||
- Log in to boardgamegeek.com with credentials from env vars (`BGG_USERNAME`, `BGG_PASSWORD`). Never write credentials to disk or logs. Persist the browser session/storage state locally so repeat runs don't re-login.
|
||||
- For each row in `to_add.csv`: navigate to the game page, use the "Add to Collection" flow, set status **Owned**, and — when a `version_id` is present — set the specific version in the collection item's version picker before saving. Manually walk this flow once and document the selectors before automating; the version UI is the most fragile part.
|
||||
- Adding a second copy of an already-owned game must create a NEW collection entry, not edit the existing one.
|
||||
- **Update mode** (rows from `to_update.csv`): open the EXISTING collection entry (keyed by `collid`) rather than the add flow, set the version, save. Must never create a duplicate entry and never change any other field of the entry. Verify the already-owned dialog behavior manually first — flagged as unverified in `docs/bgg-upload-flow.md`.
|
||||
- **Update mode** (rows from `to_update.csv`): open the EXISTING collection entry (keyed by `collid`) rather than the add flow, set the version, save. Must never create a duplicate entry and never change any other field of the entry. The verified route (2026-08-06, documented in `docs/bgg-upload-flow.md`): the collection table's version cell (`td.collection_version[onclick*="<collid>"]`) opens an inline editor whose radio values ARE version ids; clicking a radio saves immediately.
|
||||
- Log every attempt to `upload_log.csv`: `bgg_id, name, status (added|already_present|failed), timestamp, error`.
|
||||
- Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`.
|
||||
- Deliberately slow: 2–4 s randomized delay between games. This is a real account on a community site — behave like a polite human.
|
||||
@@ -107,6 +112,8 @@ All artifacts are flat files in a `data/` directory — human-readable, git-frie
|
||||
|
||||
- `titles.json` — extraction output (stage 1)
|
||||
- `unidentified.json` — game boxes seen but not identified (stage 1); retake prompts
|
||||
- `title_edits.json`, `title_splits.json`, `title_removals.json` — durable human curation (corrected reads/cues, split-into-copies decisions, removed lines); replayed on every titles.json rebuild so re-extraction never undoes them
|
||||
- `unidentified_dismissed.json` — dismissed retake prompts (kept apart so rebuilds can't resurrect them)
|
||||
- `bgg_cache/` — cached XML API responses
|
||||
- `matches.csv` — the master matching table (stages 2–3)
|
||||
- `to_add.csv` — upload queue, new entries (stage 4)
|
||||
@@ -122,7 +129,7 @@ All artifacts are flat files in a `data/` directory — human-readable, git-frie
|
||||
|
||||
- **202 queue** on `/collection`: retry with backoff (2s, 5s, 10s, 30s; give up after ~5 tries with a clear message).
|
||||
- **HEIC photos** from iPhone: convert transparently.
|
||||
- **Duplicate copies**: a title appearing in multiple photos with consistent edition cues is one game (dedupe). But genuinely distinct editions of the same game ARE in scope — they stay separate entries end-to-end and become separate BGG collection entries. Identical duplicate copies of the same edition are out of scope (assume dedupe).
|
||||
- **Duplicate copies**: a title appearing in multiple photos with consistent edition cues is one game (dedupe). But genuinely distinct editions of the same game ARE in scope — they stay separate entries end-to-end and become separate BGG collection entries. *(Amended as built:)* identical duplicate copies of the same edition are supported too, as an explicit human decision — a photo-scoped "split into copies" recorded in `data/title_splits.json`, honored by every dedupe pass thereafter.
|
||||
- **Non-game items** on shelves (books, card sleeves, storage boxes): the vision prompt should be instructed to include only board/card games; anything that slips through will fail resolution and land in review.
|
||||
- **Special characters** in titles (é, colons, ampersands): normalize consistently on both sides of the match.
|
||||
- **Base game vs. expansion vs. new edition**: the most common failure mode. Bias toward surfacing these as `ambiguous` rather than auto-matching.
|
||||
@@ -0,0 +1,52 @@
|
||||
# A tour of bggpipe
|
||||
|
||||
Eight pages in one local app — Pipeline, Photos, Titles, Review, Queue, Library, Shelves, and Help. Every screenshot below is the real app on the author's real shelves, and the end product is public: [the author's BGG collection](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built. (Back to the [README](../README.md) · how to use it all: the [user's guide](guide.md).)
|
||||
|
||||
**The Pipeline page** — every stage is a card with live counts and a Run button; blockers (missing token, stub-data lock) surface as banners, not surprises. Here: the settled state after a full run — 136 titles read, 115 matched, 62 added.
|
||||
|
||||

|
||||
|
||||
**Photos** — drag shelf photos in; boxes the vision model saw but couldn't read become illustrated reshoot tickets with shelf directions a human can follow.
|
||||
|
||||

|
||||
|
||||
**Titles** — every read off the shelves, alphabetized with status and source photos. This is the proofreading checkpoint: edit misreads, split multi-copy lines, remove non-games, add a game no photo caught.
|
||||
|
||||

|
||||
|
||||
**Review** — keyboard-first decisions on ambiguous matches. Each card shows the shelf photo beside the candidates (with rank, owner counts, and a view-on-BGG link per candidate), plus hand-steered re-searches of BGG or RPGGeek when the automatic search can't reach the right database. Here: is that box base Agricola or the Revised Edition?
|
||||
|
||||

|
||||
|
||||
**The editions pass** — after matches are settled, an optional pass picks which *printing* each copy is, scored against the cues read off the box. Skippable per game, and never blocks uploads. Here: five English Catan editions to choose between.
|
||||
|
||||

|
||||
|
||||
**Queue** — exactly what upload will do before it does it, and a permanent log of every attempt ever made. Here: six adds and one version update, pending.
|
||||
|
||||

|
||||
|
||||
**Library** — the enriched collection: searchable across titles, designers and mechanics, filterable by player count, sortable by rank/weight/year/time. Every card opens a detail page joining BGG's data with your own shelf photos; off-BGG games take hand-written facts and a cover photo there.
|
||||
|
||||

|
||||
|
||||
**A game's detail page** — BGG's stats, chips, and description joined with what only the pipeline knows: *your* edition, and the shelf photo it was read from.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
**Help** — the whole flow, every page, every status, and every keyboard shortcut, documented in-app.
|
||||
|
||||

|
||||
|
||||
**And on a phone** (`--lan`) — the same app, paired once by QR code: the hamburger menu, shooting shelf photos straight into the pipeline from the camera, proofreading titles from the couch, and the piper on the Help page.
|
||||
|
||||
<p>
|
||||
<img src="screenshots/08-phone-menu.jpeg" alt="Phone view: the hamburger menu open over the Photos page, showing all seven pages with attention badges" width="24%">
|
||||
<img src="screenshots/09-phone-camera.jpeg" alt="Phone view: tapping the photo drop zone offers iOS's Photo Library / Take Photo / Choose Files sheet" width="24%">
|
||||
<img src="screenshots/10-phone-titles.jpeg" alt="Phone view: the Titles page as stacked cards with status chips, edit and split buttons, and the shaky-reads filter" width="24%">
|
||||
<img src="screenshots/11-phone-piper.jpeg" alt="Phone view: the Help page's credits card with Juniper's full piper artwork and the BGG trademark attribution" width="24%">
|
||||
</p>
|
||||
@@ -2,7 +2,25 @@
|
||||
name = "bggpipe"
|
||||
dynamic = ["version"]
|
||||
description = "Shelf-to-BGG collection pipeline: photos in, BoardGameGeek collection out"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "Eric Wagoner", email = "eric@ericwagoner.com" }]
|
||||
keywords = ["boardgamegeek", "bgg", "board-games", "collection", "vision", "cataloging"]
|
||||
classifiers = [
|
||||
# Beta is deliberate while the user base is one person on one platform,
|
||||
# however battle-tested that person's collection is
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Operating System :: MacOS",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Games/Entertainment :: Board Games",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"typer>=0.12",
|
||||
"httpx>=0.27",
|
||||
@@ -17,8 +35,16 @@ dependencies = [
|
||||
"playwright>=1.62.0",
|
||||
"pydantic>=2.13.4",
|
||||
"python-multipart>=0.0.32",
|
||||
"qrcode>=8.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://kestrelsnest.social"
|
||||
Repository = "https://git.kestrelsnest.social/eric/bggpipe"
|
||||
Documentation = "https://git.kestrelsnest.social/eric/bggpipe/src/branch/main/docs/guide.md"
|
||||
Mastodon = "https://toots.kestrelsnest.social/@eric"
|
||||
Bluesky = "https://bsky.app/profile/kestrelsnest.social"
|
||||
|
||||
[project.scripts]
|
||||
bggpipe = "bggpipe.cli:app"
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Shelf-to-BGG collection pipeline."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
@@ -14,6 +14,17 @@ app = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _load_dotenv() -> None:
|
||||
# credentials live in ./.env (written by init); load them so a fresh
|
||||
# directory works without direnv or manual sourcing. Real environment
|
||||
# variables always take precedence over the file.
|
||||
from bggpipe.init_wizard import load_env_file
|
||||
|
||||
load_env_file(Path(".env"))
|
||||
|
||||
|
||||
ConfigOpt = Annotated[
|
||||
Path | None,
|
||||
typer.Option("--config", help="Path to config.toml (default: ./config.toml)"),
|
||||
@@ -96,6 +107,14 @@ def web(
|
||||
no_browser: Annotated[
|
||||
bool, typer.Option("--no-browser", help="Don't open a browser tab")
|
||||
] = False,
|
||||
lan: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--lan",
|
||||
help="Also serve to your local network behind a per-run access "
|
||||
"key (trusted networks only)",
|
||||
),
|
||||
] = False,
|
||||
config: ConfigOpt = None,
|
||||
) -> None:
|
||||
"""The whole pipeline in a local web UI: photos, stages, review."""
|
||||
@@ -106,6 +125,7 @@ def web(
|
||||
cfg,
|
||||
port=port,
|
||||
dev=dev,
|
||||
lan=lan,
|
||||
config_path=config,
|
||||
landing="/",
|
||||
open_browser=not no_browser,
|
||||
@@ -171,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)
|
||||
|
||||
@@ -9,6 +9,7 @@ account has exactly one home.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tomllib
|
||||
import warnings
|
||||
from dataclasses import dataclass, replace
|
||||
@@ -22,6 +23,9 @@ STUB_CACHE_MARKER_NAME = "STUB_FIXTURES.marker"
|
||||
STUB_DATA_MARKER_NAME = "STUB_DATA.marker"
|
||||
|
||||
|
||||
VISION_PROVIDERS = ("anthropic", "openai-compatible")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
bgg_username: str = ""
|
||||
@@ -29,6 +33,11 @@ class Config:
|
||||
data_dir: Path = Path("data")
|
||||
model: str = "claude-sonnet-5"
|
||||
rate_limit_seconds: float = 2.0
|
||||
# "anthropic" (default) or "openai-compatible" — the latter covers
|
||||
# OpenAI, OpenRouter, and local runtimes (Ollama, LM Studio, vLLM)
|
||||
vision_provider: str = "anthropic"
|
||||
vision_base_url: str = "" # e.g. http://localhost:11434/v1 for Ollama
|
||||
vision_key_env: str = "OPENAI_API_KEY" # "" = endpoint needs no key
|
||||
|
||||
@property
|
||||
def cache_dir(self) -> Path:
|
||||
@@ -66,6 +75,77 @@ 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
|
||||
from destruction. Warn whenever artifacts under data_dir are
|
||||
tracked — unless the owner has marked this checkout as their own
|
||||
repo (the marker is gitignored, so a fresh clone never inherits
|
||||
the exemption)."""
|
||||
if (self.data_dir / ".own_repo").exists():
|
||||
return None
|
||||
try:
|
||||
tracked = subprocess.run( # noqa: S603
|
||||
["git", "ls-files", "--", str(self.data_dir)], # noqa: S607
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
cwd=self.data_dir.resolve().parent,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None # no git, no repo, no trap
|
||||
if tracked.returncode != 0 or not tracked.stdout.strip():
|
||||
return None
|
||||
return (
|
||||
f"your pipeline data ({self.data_dir}) sits at git-TRACKED "
|
||||
"paths — a `git pull` here can refuse to merge, and the usual "
|
||||
"remedies (reset --hard, checkout ., stash) would destroy your "
|
||||
"review decisions, hand-written games, and upload log. Run "
|
||||
"bggpipe from a directory outside this repository (see README: "
|
||||
"Bring your own shelves). If this repo is genuinely where you "
|
||||
f"version your own data, `touch {self.data_dir}/.own_repo` to "
|
||||
"accept the arrangement and silence this warning."
|
||||
)
|
||||
|
||||
@property
|
||||
def furniture_path(self) -> Path:
|
||||
# storage units and their openings — the user's shelf layout is
|
||||
# local-only data (never BGG's business), committed like the
|
||||
# other curation stores
|
||||
return self.data_dir / "furniture.json"
|
||||
|
||||
@property
|
||||
def locations_path(self) -> Path:
|
||||
# game key -> {opening_id, note}: where each box physically lives
|
||||
return self.data_dir / "locations.json"
|
||||
|
||||
@property
|
||||
def local_games_path(self) -> Path:
|
||||
# hand-written metadata for games BGG doesn't have — the only
|
||||
# source of truth for them, so it is committed like the other stores
|
||||
return self.data_dir / "local_games.json"
|
||||
|
||||
@property
|
||||
def local_art_dir(self) -> Path:
|
||||
# cover photos for off-BGG games (committed: nothing else has them)
|
||||
return self.data_dir / "local_art"
|
||||
|
||||
@property
|
||||
def title_additions_path(self) -> Path:
|
||||
# games the human added without a photo (expansions stored inside
|
||||
# base boxes, unshelved games) — joined into every rebuild
|
||||
return self.data_dir / "title_additions.json"
|
||||
|
||||
@property
|
||||
def title_removals_path(self) -> Path:
|
||||
# titles the human removed from the catalog (not a game, misread) —
|
||||
# filtered out of every titles.json rebuild
|
||||
return self.data_dir / "title_removals.json"
|
||||
|
||||
@property
|
||||
def title_edits_path(self) -> Path:
|
||||
# human corrections to extracted reads (misspellings, known cues) —
|
||||
@@ -89,6 +169,12 @@ class Config:
|
||||
self.data_dir / "collection_snapshot_expansions.xml",
|
||||
)
|
||||
|
||||
@property
|
||||
def lan_key_path(self) -> Path:
|
||||
# the --lan access key, persisted so restarts don't strand phones'
|
||||
# cookies; credential-adjacent like Playwright state — gitignored
|
||||
return self.data_dir / ".lan_key"
|
||||
|
||||
@property
|
||||
def storage_state_path(self) -> Path:
|
||||
# cwd-relative on purpose (credential-adjacent, gitignored) but
|
||||
@@ -119,16 +205,47 @@ def load_config(path: Path | None = None) -> Config:
|
||||
"data_dir": Path,
|
||||
"model": str,
|
||||
"rate_limit_seconds": float,
|
||||
"vision_provider": str,
|
||||
}
|
||||
updates = {key: caster(raw[key]) for key, caster in known.items() if key in raw}
|
||||
if unknown := sorted(raw.keys() - known.keys()):
|
||||
if unknown := sorted(raw.keys() - known.keys() - {"vision"}):
|
||||
# a typo'd key silently falling back to defaults is a debugging
|
||||
# trap ("No photos found in photos/") — say so up front
|
||||
warnings.warn(
|
||||
f"{p}: ignoring unknown key(s): {', '.join(unknown)}",
|
||||
stacklevel=2,
|
||||
)
|
||||
# [vision.<provider>] blocks: every provider's recipe can live in
|
||||
# the committed file; only the ACTIVE provider's block applies
|
||||
vision_blocks = raw.get("vision") or {}
|
||||
if stray := sorted(vision_blocks.keys() - set(VISION_PROVIDERS)):
|
||||
warnings.warn(
|
||||
f"{p}: ignoring [vision.*] block(s) for unknown provider(s): "
|
||||
f"{', '.join(stray)}",
|
||||
stacklevel=2,
|
||||
)
|
||||
provider = updates.get("vision_provider", cfg.vision_provider)
|
||||
block = vision_blocks.get(provider) or {}
|
||||
block_known = {
|
||||
"model": "model",
|
||||
"base_url": "vision_base_url",
|
||||
"key_env": "vision_key_env",
|
||||
}
|
||||
if odd := sorted(block.keys() - block_known.keys()):
|
||||
warnings.warn(
|
||||
f"{p}: [vision.{provider}]: ignoring unknown key(s): {', '.join(odd)}",
|
||||
stacklevel=2,
|
||||
)
|
||||
for key, field in block_known.items():
|
||||
if key in block:
|
||||
updates[field] = str(block[key])
|
||||
cfg = replace(cfg, **updates)
|
||||
if cfg.vision_provider not in VISION_PROVIDERS:
|
||||
# a typo here would surface as a confusing extract failure later
|
||||
raise ValueError(
|
||||
f"{p}: vision_provider must be one of {', '.join(VISION_PROVIDERS)}"
|
||||
f" (got {cfg.vision_provider!r})"
|
||||
)
|
||||
if username := os.environ.get("BGG_USERNAME"):
|
||||
cfg = replace(cfg, bgg_username=username)
|
||||
return cfg
|
||||
|
||||
@@ -135,6 +135,11 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
|
||||
if status == "merged":
|
||||
result.merged += 1 # represented by its survivor row
|
||||
continue
|
||||
if status == "local":
|
||||
# the human's call: a real game BGG doesn't have — a library
|
||||
# citizen only, never queued
|
||||
result.local_only.append(row["title_raw"])
|
||||
continue
|
||||
if status not in RECOGNIZED_MATCH_STATUSES or not row["bgg_id"]:
|
||||
result.pending.append(row["title_raw"])
|
||||
continue
|
||||
@@ -178,25 +183,13 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu
|
||||
else:
|
||||
leftover.append(row)
|
||||
|
||||
# 1b — versionless copies get upgraded. Guard: upload's row-edit flow
|
||||
# targets rows by game NAME (a collid can't drive the UI), so an update
|
||||
# is only safe when EVERY copy of the game is versionless — otherwise
|
||||
# the browser could open the versioned copy and overwrite it.
|
||||
# 1b — versionless copies get upgraded. The update flow addresses the
|
||||
# copy by collid and the edition by radio value, so a versioned
|
||||
# sibling copy is never at risk.
|
||||
still_left: list[dict] = []
|
||||
for row in leftover:
|
||||
bgg_id = int(row["bgg_id"])
|
||||
versionless = [c for c in unconsumed(bgg_id) if c.version_id is None]
|
||||
any_versioned = any(c.version_id is not None for c in by_object.get(bgg_id, []))
|
||||
if versionless and any_versioned:
|
||||
consumed_collids.add(versionless[0].coll_id)
|
||||
result.already_owned.append(row["title_raw"])
|
||||
result.disagreements.append(
|
||||
f"{row['title_raw']}: a versionless copy could take version "
|
||||
f"{row['version_name']!r}, but another copy already carries "
|
||||
"a version — set it by hand on BGG (the automated row edit "
|
||||
"can't safely target a specific copy)"
|
||||
)
|
||||
continue
|
||||
if versionless:
|
||||
target = versionless[0]
|
||||
consumed_collids.add(target.coll_id)
|
||||
@@ -316,7 +309,9 @@ def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult:
|
||||
|
||||
merged_note = f" · {result.merged} merged duplicate(s)" if result.merged else ""
|
||||
local_note = (
|
||||
f" · {len(result.local_only)} local-only (RPGs)" if result.local_only else ""
|
||||
f" · {len(result.local_only)} local-only (RPGs, off-BGG games)"
|
||||
if result.local_only
|
||||
else ""
|
||||
)
|
||||
typer.echo(
|
||||
f"\n{result.recognized} recognized · {len(result.already_owned)} already "
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -27,11 +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.resolve import read_matches
|
||||
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):
|
||||
@@ -95,12 +188,86 @@ def run_enrich(
|
||||
games[key] = {**fetched[bgg_id], "version": version}
|
||||
updated += 1
|
||||
|
||||
# games the human ruled off-BGG: library entries built from their own
|
||||
# photo reads — no API involved, so a blocked run still lands them
|
||||
local_keys: set[str] = set()
|
||||
local_rows = [r for r in rows if r["match_status"] == "local"]
|
||||
# hand-written metadata wins over the photo reads: for an off-BGG game
|
||||
# it is the only real source there is
|
||||
hand: dict = {}
|
||||
if cfg.local_games_path.exists():
|
||||
try:
|
||||
hand = json.loads(cfg.local_games_path.read_text())
|
||||
except json.JSONDecodeError as err:
|
||||
raise ValueError(
|
||||
f"{cfg.local_games_path} is corrupt ({err}) — it holds "
|
||||
"hand-written game data, so check git history before deleting"
|
||||
) from err
|
||||
if local_rows:
|
||||
try:
|
||||
cues = {
|
||||
(e.title_raw, ";".join(e.source_photos)): e
|
||||
for e in load_titles(cfg.titles_path)
|
||||
}
|
||||
except FileNotFoundError:
|
||||
cues = {}
|
||||
for row in local_rows:
|
||||
key = f"local:{normalize_title(row['title_raw'])}:{row['source_photos']}"
|
||||
local_keys.add(key)
|
||||
entry = cues.get((row["title_raw"], row["source_photos"]))
|
||||
games[key] = {
|
||||
"bgg_id": None,
|
||||
"name": row["title_raw"],
|
||||
"year": entry.year_hint if entry else None,
|
||||
"type": "localgame",
|
||||
"publishers": [entry.publisher_hint]
|
||||
if entry and entry.publisher_hint
|
||||
else [],
|
||||
"source_photos": [p for p in row["source_photos"].split(";") if p],
|
||||
}
|
||||
games[key].update(
|
||||
{k: v for k, v in (hand.get(key) or {}).items() if v not in (None, "")}
|
||||
)
|
||||
# the key embeds the photo list, so a new sighting or a title edit
|
||||
# strands hand data under a key no row produces anymore. Same
|
||||
# normalized title + exactly one candidate = unambiguous: migrate.
|
||||
# Anything else is reported, never silently dropped.
|
||||
orphans = [k for k in hand if k.startswith("local:") and k not in local_keys]
|
||||
migrated = {}
|
||||
for old_key in orphans:
|
||||
title_part = old_key.split(":", 2)[1]
|
||||
candidates = [k for k in local_keys if k.split(":", 2)[1] == title_part]
|
||||
if len(candidates) == 1 and candidates[0] not in hand:
|
||||
new_key = candidates[0]
|
||||
hand[new_key] = hand.pop(old_key)
|
||||
migrated[old_key] = new_key
|
||||
games[new_key].update(
|
||||
{k: v for k, v in hand[new_key].items() if v not in (None, "")}
|
||||
)
|
||||
else:
|
||||
typer.echo(
|
||||
f" warning: hand-written data for {old_key!r} matches "
|
||||
"no current catalog line — the facts are safe in "
|
||||
f"{cfg.local_games_path.name} but will not show in the "
|
||||
"library until the key matches again"
|
||||
)
|
||||
if migrated:
|
||||
atomic_write_text(
|
||||
cfg.local_games_path,
|
||||
json.dumps(hand, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
|
||||
)
|
||||
for old_key, new_key in migrated.items():
|
||||
typer.echo(
|
||||
f" migrated hand-written data {old_key!r} -> {new_key!r} "
|
||||
"(photo set changed; same title)"
|
||||
)
|
||||
|
||||
# prune keys no current target claims: a row whose version was approved
|
||||
# after a bare-key run (or was later rejected) must not leave an orphan
|
||||
# entry in the frontend seed data. Only safe when nothing was blocked —
|
||||
# a token-less run knows too little to declare anything stale.
|
||||
if not blocked:
|
||||
current = {key for key, _, _ in targets}
|
||||
current = {key for key, _, _ in targets} | local_keys
|
||||
stale = [k for k in games if k not in current]
|
||||
for k in stale:
|
||||
del games[k]
|
||||
@@ -109,15 +276,43 @@ 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"
|
||||
)
|
||||
|
||||
# count the two populations separately: local entries are built from
|
||||
# photo reads and have no fetch target, so folding them into the API
|
||||
# tally would drive "already present or waiting" negative
|
||||
waiting = len(targets) - updated
|
||||
parts = [f"{updated} fetched from BGG"]
|
||||
if dims_filled:
|
||||
parts.append(f"box dims checked for {dims_filled}")
|
||||
if local_keys:
|
||||
parts.append(f"{len(local_keys)} local-only")
|
||||
if waiting:
|
||||
parts.append(f"{waiting} already present or waiting")
|
||||
typer.echo(
|
||||
f"games.json: {len(games)} entr{'y' if len(games) == 1 else 'ies'} "
|
||||
f"({updated} added/refreshed this run; "
|
||||
f"{len(targets) - updated} already present or waiting)."
|
||||
f"({'; '.join(parts)})."
|
||||
)
|
||||
if blocked:
|
||||
remaining = [i for i in need if i not in fetched]
|
||||
|
||||
@@ -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 (’ 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),
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Stage 1 — extract game titles + edition cues from shelf photos.
|
||||
|
||||
Each photo is sent to Claude vision once and the raw result is cached under
|
||||
data/extract_raw/<photo>.json (making re-runs free and --only targeted).
|
||||
Each photo is sent to the configured vision model once and the raw result
|
||||
is cached under data/extract_raw/<photo>.json (making re-runs free and
|
||||
--only targeted).
|
||||
titles.json is rebuilt from all raw files on every run, deduping identical
|
||||
titles across photos unless their edition cues conflict — conflicting cues
|
||||
mean two different editions on the shelf, which stay separate entries.
|
||||
@@ -12,6 +13,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
@@ -125,7 +127,12 @@ def parse_vision_response(text: str) -> tuple[list[dict], list[dict], int]:
|
||||
if end == -1:
|
||||
raise ValueError(f"unterminated JSON in vision response: {text[:200]!r}")
|
||||
cleaned = cleaned[start : end + 1]
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
# local models emit almost-JSON (trailing commas, mostly): one
|
||||
# cheap repair pass before declaring the response unusable
|
||||
data = json.loads(re.sub(r",\s*([}\]])", r"\1", cleaned))
|
||||
if isinstance(data, list):
|
||||
titles_raw, unidentified_raw = data, []
|
||||
elif isinstance(data, dict):
|
||||
@@ -176,6 +183,61 @@ def default_vision(model: str) -> VisionFn:
|
||||
return vision
|
||||
|
||||
|
||||
def openai_compatible_vision(model: str, base_url: str, key_env: str) -> VisionFn:
|
||||
"""Any endpoint speaking the OpenAI chat-completions format: OpenAI
|
||||
itself, OpenRouter, or a local runtime (Ollama, LM Studio, llama.cpp,
|
||||
vLLM — pass its /v1 base URL). A key is optional: local runtimes run
|
||||
without one, so an empty key_env just sends no Authorization header."""
|
||||
import httpx
|
||||
|
||||
headers = {}
|
||||
if key_env and (key := os.environ.get(key_env)):
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
|
||||
def vision(image_b64: str, media_type: str) -> str:
|
||||
response = httpx.post(
|
||||
base_url.rstrip("/") + "/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": model,
|
||||
"max_tokens": 4000,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:{media_type};base64,{image_b64}"
|
||||
},
|
||||
},
|
||||
{"type": "text", "text": VISION_PROMPT},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
timeout=300.0, # local models on modest hardware are slow
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["choices"][0]["message"]["content"] or ""
|
||||
|
||||
return vision
|
||||
|
||||
|
||||
def vision_for(cfg: Config) -> VisionFn:
|
||||
"""The configured vision backend (config.toml: vision_provider)."""
|
||||
if cfg.vision_provider == "openai-compatible":
|
||||
if not cfg.vision_base_url:
|
||||
raise ValueError(
|
||||
"vision_provider 'openai-compatible' needs vision_base_url "
|
||||
"in config.toml (e.g. http://localhost:11434/v1 for Ollama)"
|
||||
)
|
||||
return openai_compatible_vision(
|
||||
cfg.model, cfg.vision_base_url, cfg.vision_key_env
|
||||
)
|
||||
return default_vision(cfg.model)
|
||||
|
||||
|
||||
def extract_photo(photo: Path, vision: VisionFn) -> dict:
|
||||
"""One photo -> {"titles": [...], "unidentified": [...]} (the raw-cache
|
||||
file format)."""
|
||||
@@ -243,6 +305,7 @@ def _merge(a: dict, b: dict) -> dict:
|
||||
# the fields a human correction may override on an extracted entry
|
||||
EDIT_FIELDS = (
|
||||
"title_raw",
|
||||
"confidence",
|
||||
"publisher_hint",
|
||||
"edition_hint",
|
||||
"year_hint",
|
||||
@@ -313,11 +376,10 @@ def apply_title_edits(entries: list[dict], edits: list[dict]) -> list[dict]:
|
||||
return entries
|
||||
|
||||
|
||||
def load_title_splits(path: Path) -> list[dict]:
|
||||
"""The human's split-into-copies decisions — durable: they must survive
|
||||
extract rebuilds and resolve --force. Each stored record is
|
||||
{"title": ..., "photos": [...]} scoping the split to the sightings that
|
||||
were on the split line (a bare string is legacy: unscoped). Returns
|
||||
def _scoped_records(path: Path) -> list[dict]:
|
||||
"""Parse a store of photo-scoped title decisions. Each stored record is
|
||||
{"title": ..., "photos": [...]} scoping the decision to the sightings
|
||||
that were on the line (a bare string is legacy: unscoped). Returns
|
||||
[{"norm": <normalized title>, "photos": set | None}]."""
|
||||
records = []
|
||||
for item in _load_store(path):
|
||||
@@ -334,6 +396,67 @@ def load_title_splits(path: Path) -> list[dict]:
|
||||
return records
|
||||
|
||||
|
||||
def load_title_additions(path: Path) -> list[dict]:
|
||||
"""Entries the human added without a photo — an expansion stored inside
|
||||
a base box, a game away from the shelves. Shaped like raw reads and fed
|
||||
into every rebuild BEFORE edits and dedupe, so corrections apply and a
|
||||
later photo sighting of the same game merges instead of duplicating."""
|
||||
return _load_store(path)
|
||||
|
||||
|
||||
def record_title_addition(path: Path, entry: dict) -> bool:
|
||||
"""Record a hand-added game. False = an addition with this normalized
|
||||
title is already on file (nothing written — edit that line instead);
|
||||
a silent True here would let the caller report success for a no-op."""
|
||||
existing = load_title_additions(path)
|
||||
norm = normalize_title(entry["title_raw"])
|
||||
if any(normalize_title(e["title_raw"]) == norm for e in existing):
|
||||
return False
|
||||
atomic_write_text(
|
||||
path, json.dumps([*existing, entry], indent=2, ensure_ascii=False) + "\n"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def rescind_title_removal(path: Path, title: str) -> bool:
|
||||
"""Drop any UNSCOPED removal of this normalized title (a hand-added
|
||||
line removed earlier). Re-adding a title those records would filter
|
||||
from every rebuild is an explicit undo, not a conflict. Photo-scoped
|
||||
removals stay: they veto specific sightings, not the game."""
|
||||
norm = normalize_title(title)
|
||||
stored = _load_store(path)
|
||||
kept = [
|
||||
item
|
||||
for item in stored
|
||||
if not (
|
||||
(isinstance(item, str) and normalize_title(item) == norm)
|
||||
or (
|
||||
isinstance(item, dict)
|
||||
and not item.get("photos")
|
||||
and normalize_title(item["title"]) == norm
|
||||
)
|
||||
)
|
||||
]
|
||||
if len(kept) == len(stored):
|
||||
return False
|
||||
atomic_write_text(path, json.dumps(kept, indent=2, ensure_ascii=False) + "\n")
|
||||
return True
|
||||
|
||||
|
||||
def load_title_splits(path: Path) -> list[dict]:
|
||||
"""The human's split-into-copies decisions — durable: they must survive
|
||||
extract rebuilds and resolve --force."""
|
||||
return _scoped_records(path)
|
||||
|
||||
|
||||
def load_title_removals(path: Path) -> list[dict]:
|
||||
"""Titles the human removed from the catalog (not a game, a misread of
|
||||
box art, out of scope) — their sightings are filtered out of every
|
||||
rebuild, so re-extraction cannot resurrect them. Undo by deleting the
|
||||
record from the store file."""
|
||||
return _scoped_records(path)
|
||||
|
||||
|
||||
def is_split(norm: str, photos, splits: list[dict]) -> bool:
|
||||
"""Does a split decision cover this (normalized title, photo set)?
|
||||
Photo-scoped records only bind sightings from the photos that were on
|
||||
@@ -345,9 +468,9 @@ def is_split(norm: str, photos, splits: list[dict]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def record_title_split(path: Path, title: str, photos: list[str] | None = None) -> None:
|
||||
if is_split(normalize_title(title), photos or [], load_title_splits(path)):
|
||||
return
|
||||
def _record_scoped(path: Path, title: str, photos: list[str] | None) -> None:
|
||||
if is_split(normalize_title(title), photos or [], _scoped_records(path)):
|
||||
return # an existing record already covers this line
|
||||
existing = _load_store(path)
|
||||
record = {"title": title, "photos": sorted(photos) if photos else None}
|
||||
atomic_write_text(
|
||||
@@ -355,6 +478,16 @@ def record_title_split(path: Path, title: str, photos: list[str] | None = None)
|
||||
)
|
||||
|
||||
|
||||
def record_title_split(path: Path, title: str, photos: list[str] | None = None) -> None:
|
||||
_record_scoped(path, title, photos)
|
||||
|
||||
|
||||
def record_title_removal(
|
||||
path: Path, title: str, photos: list[str] | None = None
|
||||
) -> None:
|
||||
_record_scoped(path, title, photos)
|
||||
|
||||
|
||||
def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> list[dict]:
|
||||
"""Collapse same-normalized-title sightings unless their cues conflict —
|
||||
or unless the human declared them split (several physical copies):
|
||||
@@ -383,16 +516,33 @@ def dedupe_entries(entries: list[dict], splits: list[dict] | None = None) -> lis
|
||||
return result
|
||||
|
||||
|
||||
def apply_title_removals(entries: list[dict], removals: list[dict]) -> list[dict]:
|
||||
"""Filter out sightings the human removed. Runs AFTER edits (records
|
||||
key on the title as displayed when removal was clicked) and before
|
||||
dedupe (so a removed sighting can't be absorbed into a survivor)."""
|
||||
if not removals:
|
||||
return entries
|
||||
return [
|
||||
e
|
||||
for e in entries
|
||||
if not is_split(
|
||||
normalize_title(e["title_raw"]), e.get("source_photos"), removals
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def rebuild_artifacts(
|
||||
raw_dir: Path,
|
||||
titles_path: Path,
|
||||
unidentified_path: Path,
|
||||
splits: list[dict] | None = None,
|
||||
edits: list[dict] | None = None,
|
||||
removals: list[dict] | None = None,
|
||||
additions: list[dict] | None = None,
|
||||
) -> tuple[list[dict], dict[str, list[dict]]]:
|
||||
"""Regenerate titles.json and unidentified.json from the per-photo raw
|
||||
cache. A raw file is either an object with titles/unidentified or a bare
|
||||
arrays — still readable."""
|
||||
array — still readable."""
|
||||
entries: list[dict] = []
|
||||
unidentified: dict[str, list[dict]] = {}
|
||||
for raw_file in sorted(raw_dir.glob("*.json")):
|
||||
@@ -410,7 +560,11 @@ def rebuild_artifacts(
|
||||
photo = raw_file.name.removesuffix(".json")
|
||||
if data.get("unidentified"):
|
||||
unidentified[photo] = data["unidentified"]
|
||||
deduped = dedupe_entries(apply_title_edits(entries, edits or []), splits)
|
||||
entries = entries + [dict(e) for e in additions or []]
|
||||
entries = apply_title_removals(
|
||||
apply_title_edits(entries, edits or []), removals or []
|
||||
)
|
||||
deduped = dedupe_entries(entries, splits)
|
||||
titles_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_text(
|
||||
titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
|
||||
@@ -429,6 +583,8 @@ def replay_titles(cfg: Config) -> None:
|
||||
photo-level splits and targeted edits can take hold."""
|
||||
splits = load_title_splits(cfg.title_splits_path)
|
||||
edits = load_title_edits(cfg.title_edits_path)
|
||||
removals = load_title_removals(cfg.title_removals_path)
|
||||
additions = load_title_additions(cfg.title_additions_path)
|
||||
raw_dir = cfg.extract_raw_dir
|
||||
raw_photos = (
|
||||
{f.name.removesuffix(".json") for f in raw_dir.glob("*.json")}
|
||||
@@ -444,7 +600,13 @@ def replay_titles(cfg: Config) -> None:
|
||||
# them would silently truncate the committed catalog
|
||||
if raw_photos and raw_photos >= known_photos:
|
||||
rebuild_artifacts(
|
||||
raw_dir, cfg.titles_path, cfg.unidentified_path, splits, edits
|
||||
raw_dir,
|
||||
cfg.titles_path,
|
||||
cfg.unidentified_path,
|
||||
splits,
|
||||
edits,
|
||||
removals,
|
||||
additions,
|
||||
)
|
||||
return
|
||||
if not cfg.titles_path.exists():
|
||||
@@ -456,7 +618,10 @@ def replay_titles(cfg: Config) -> None:
|
||||
exploded.extend({**entry, "source_photos": [p]} for p in photos)
|
||||
else:
|
||||
exploded.append(dict(entry))
|
||||
deduped = dedupe_entries(apply_title_edits(exploded, edits), splits)
|
||||
exploded.extend(dict(e) for e in additions)
|
||||
deduped = dedupe_entries(
|
||||
apply_title_removals(apply_title_edits(exploded, edits), removals), splits
|
||||
)
|
||||
atomic_write_text(
|
||||
cfg.titles_path, json.dumps(deduped, indent=2, ensure_ascii=False) + "\n"
|
||||
)
|
||||
@@ -490,7 +655,7 @@ def run_extract(
|
||||
|
||||
raw_dir = cfg.extract_raw_dir
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
vision = vision or default_vision(cfg.model)
|
||||
vision = vision or vision_for(cfg)
|
||||
|
||||
failed: list[str] = []
|
||||
consecutive: tuple[str, int] = ("", 0)
|
||||
@@ -547,6 +712,8 @@ def run_extract(
|
||||
cfg.unidentified_path,
|
||||
load_title_splits(cfg.title_splits_path),
|
||||
load_title_edits(cfg.title_edits_path),
|
||||
load_title_removals(cfg.title_removals_path),
|
||||
load_title_additions(cfg.title_additions_path),
|
||||
)
|
||||
typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.")
|
||||
if failed:
|
||||
|
||||
@@ -28,8 +28,22 @@ CONFIG_TEMPLATE = """\
|
||||
|
||||
photos_dir = "photos"
|
||||
data_dir = "data"
|
||||
model = "claude-sonnet-5"
|
||||
rate_limit_seconds = 2.0
|
||||
|
||||
# Which vision backend reads your shelf photos. Both recipes below stay
|
||||
# on file; this line picks one.
|
||||
vision_provider = "anthropic"
|
||||
|
||||
[vision.anthropic]
|
||||
# reads ANTHROPIC_API_KEY from the environment
|
||||
model = "claude-sonnet-5"
|
||||
|
||||
[vision."openai-compatible"]
|
||||
# OpenAI, OpenRouter, or a local runtime (Ollama, LM Studio, vLLM).
|
||||
# key_env names the env var holding the key; "" = endpoint needs none.
|
||||
base_url = "http://localhost:11434/v1"
|
||||
model = "qwen2.5vl:7b"
|
||||
key_env = ""
|
||||
"""
|
||||
|
||||
ENV_HEADER = """\
|
||||
@@ -74,6 +88,32 @@ class InitReport:
|
||||
keys_written: list[str] = field(default_factory=list)
|
||||
keys_missing: list[str] = field(default_factory=list)
|
||||
browser_installed: bool = False
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_env_file(env_path: Path) -> list[str]:
|
||||
"""Export .env values into this process's environment — only keys the
|
||||
environment doesn't already set (a real env var always wins). Returns
|
||||
the keys loaded. Same parsing rules as _env_file_keys; values go
|
||||
straight into os.environ and are never printed or logged."""
|
||||
if not env_path.exists():
|
||||
return []
|
||||
loaded = []
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if "=" not in line or line.startswith("#"):
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
if key.startswith("export "):
|
||||
key = key.removeprefix("export ").strip()
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
||||
value = value[1:-1]
|
||||
if key and value and not os.environ.get(key):
|
||||
os.environ[key] = value
|
||||
loaded.append(key)
|
||||
return loaded
|
||||
|
||||
|
||||
def _env_file_keys(env_path: Path) -> set[str]:
|
||||
@@ -139,6 +179,9 @@ def run_init(
|
||||
|
||||
report = InitReport()
|
||||
typer.echo("bggpipe setup — checks what exists, fills only the gaps.\n")
|
||||
if trap := cfg.tracked_data_warning():
|
||||
typer.echo(f"WARNING: {trap}\n", err=True)
|
||||
report.warnings.append(trap)
|
||||
|
||||
# -- directories ----------------------------------------------------
|
||||
for path in (project_dir / cfg.photos_dir, project_dir / cfg.data_dir):
|
||||
@@ -159,9 +202,33 @@ def run_init(
|
||||
typer.echo(f" found {config_path}")
|
||||
|
||||
# -- credentials ----------------------------------------------------
|
||||
# the vision key follows the configured provider: an openai-compatible
|
||||
# setup prompts for ITS key env (or none, for a local endpoint)
|
||||
from bggpipe.config import load_config
|
||||
|
||||
active = load_config(config_path if config_path.exists() else None)
|
||||
env_keys = list(ENV_KEYS)
|
||||
if active.vision_provider != "anthropic":
|
||||
env_keys = [k for k in env_keys if k[0] != "ANTHROPIC_API_KEY"]
|
||||
if active.vision_key_env:
|
||||
env_keys.insert(
|
||||
0,
|
||||
(
|
||||
active.vision_key_env,
|
||||
True,
|
||||
"vision extraction via "
|
||||
+ (active.vision_base_url or "the configured endpoint"),
|
||||
"your vision provider's console",
|
||||
),
|
||||
)
|
||||
else:
|
||||
typer.echo(
|
||||
" vision: openai-compatible endpoint with no key configured "
|
||||
"(local runtime) — nothing to prompt for"
|
||||
)
|
||||
env_path = project_dir / ".env"
|
||||
in_file = _env_file_keys(env_path)
|
||||
for key, secret, why, where in ENV_KEYS:
|
||||
for key, secret, why, where in env_keys:
|
||||
if os.environ.get(key) or key in in_file:
|
||||
report.keys_ready.append(key)
|
||||
typer.echo(f" {key}: set")
|
||||
|
||||
@@ -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"])
|
||||
@@ -94,21 +100,34 @@ def _attr_int(elem: ET.Element | None, attr: str = "value") -> int | None:
|
||||
|
||||
def parse_search(xml_text: str) -> list[SearchResult]:
|
||||
results = []
|
||||
by_id: dict[int, int] = {} # bgg_id -> index in results
|
||||
skipped = 0
|
||||
for item in _root(xml_text).findall("item"):
|
||||
name_elem = item.find("name")
|
||||
if name_elem is None or item.get("id") is None:
|
||||
skipped += 1 # tolerate stragglers; wholesale drift raises below
|
||||
continue
|
||||
results.append(
|
||||
SearchResult(
|
||||
result = SearchResult(
|
||||
bgg_id=int(item.get("id")),
|
||||
name=name_elem.get("value", ""),
|
||||
name_type=name_elem.get("type", "primary"),
|
||||
year=_attr_int(item.find("yearpublished")),
|
||||
type=item.get("type", "boardgame"),
|
||||
)
|
||||
)
|
||||
# a multi-type search lists an expansion TWICE — once per matched
|
||||
# type; collapse only SAME-NAME duplicates, preferring the specific
|
||||
# type so expansion tagging (the base-vs-expansion review guard)
|
||||
# survives. A duplicate under a DIFFERENT name stays: it may be the
|
||||
# alternate name that exact-matches the query, and dropping it
|
||||
# would silently downgrade the match to fuzzy.
|
||||
dedupe_key = (result.bgg_id, result.name.casefold())
|
||||
if dedupe_key in by_id:
|
||||
seen = results[by_id[dedupe_key]]
|
||||
if seen.type == "boardgame" and result.type != "boardgame":
|
||||
results[by_id[dedupe_key]] = result
|
||||
continue
|
||||
by_id[dedupe_key] = len(results)
|
||||
results.append(result)
|
||||
if skipped and results:
|
||||
warnings.warn(
|
||||
f"search: {skipped} unparseable item(s) tolerated — schema drift?",
|
||||
@@ -146,6 +165,8 @@ def parse_things(xml_text: str) -> list[ThingDetails]:
|
||||
for item in _root(xml_text).findall("item"):
|
||||
name = item.find("name[@type='primary']")
|
||||
rank_elem = item.find(".//ranks/rank[@name='boardgame']")
|
||||
if rank_elem is None: # RPGGeek items rank in their own family
|
||||
rank_elem = item.find(".//ranks/rank[@name='rpgitem']")
|
||||
versions = [
|
||||
v
|
||||
for v_item in item.findall("versions/item")
|
||||
@@ -213,7 +234,12 @@ def parse_things_full(xml_text: str) -> list[dict]:
|
||||
"type": item.get("type", "boardgame"),
|
||||
"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
|
||||
# (’ 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")),
|
||||
@@ -223,18 +249,28 @@ def parse_things_full(xml_text: str) -> list[dict]:
|
||||
"min_playtime": _attr_int(item.find("minplaytime")),
|
||||
"max_playtime": _attr_int(item.find("maxplaytime")),
|
||||
"min_age": _attr_int(item.find("minage")),
|
||||
"designers": links("boardgamedesigner"),
|
||||
"artists": links("boardgameartist"),
|
||||
"publishers": links("boardgamepublisher"),
|
||||
"categories": links("boardgamecategory"),
|
||||
"mechanics": links("boardgamemechanic"),
|
||||
# RPGGeek items live in the same database but use their own
|
||||
# link types, so a board-game-only reader finds none of them
|
||||
"designers": links("boardgamedesigner") + links("rpgdesigner"),
|
||||
"artists": links("boardgameartist") + links("rpgartist"),
|
||||
"publishers": links("boardgamepublisher") + links("rpgpublisher"),
|
||||
"categories": links("boardgamecategory")
|
||||
+ links("rpggenre")
|
||||
+ links("rpgcategory"),
|
||||
"mechanics": links("boardgamemechanic") + links("rpgmechanic"),
|
||||
"producers": links("rpgproducer"),
|
||||
"series": links("rpgseries"),
|
||||
"rating": _attr_float(ratings.find("average"))
|
||||
if ratings is not None
|
||||
else None,
|
||||
"weight": _attr_float(ratings.find("averageweight"))
|
||||
if ratings is not None
|
||||
else None,
|
||||
"rank": _attr_int(item.find(".//ranks/rank[@name='boardgame']")),
|
||||
"rank": _attr_int(
|
||||
item.find(".//ranks/rank[@name='boardgame']")
|
||||
if item.find(".//ranks/rank[@name='boardgame']") is not None
|
||||
else item.find(".//ranks/rank[@name='rpgitem']")
|
||||
),
|
||||
"users_owned": _attr_int(ratings.find("owned"))
|
||||
if ratings is not None
|
||||
else None,
|
||||
@@ -243,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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -89,6 +92,7 @@ class Candidate:
|
||||
type: str
|
||||
exact: bool
|
||||
fuzzy: float
|
||||
sibling: bool = False # exactness earned via edition-suffix stripping
|
||||
owned: int | None = None
|
||||
rank: int | None = None
|
||||
publishers: list[str] = field(default_factory=list)
|
||||
@@ -190,11 +194,13 @@ def _truncation_heads(title_raw: str) -> list[str]:
|
||||
match = _GAME_WORD.search(title_raw)
|
||||
if match and match.start() > 0:
|
||||
heads.append(title_raw[: match.start()])
|
||||
# last resort: first two words — of the pre-subtitle part, so a title
|
||||
# like "Blorvath: Quest of the Zzyzx" never yields "Blorvath: Quest"
|
||||
# last resort: shrink from the right, longest first — a printed title
|
||||
# can bury the real name in the middle ("ALICE IS MISSING A SILENT ROLE
|
||||
# PLAYING GAME" is "Alice is Missing"). Only exact normalized matches
|
||||
# count for heads, so a short head cannot match loosely.
|
||||
words = (sep_head or title_raw).split()
|
||||
if len(words) > 2:
|
||||
heads.append(" ".join(words[:2]))
|
||||
for size in range(len(words) - 1, 1, -1):
|
||||
heads.append(" ".join(words[:size]))
|
||||
|
||||
seen: set[str] = {normalize_title(title_raw)}
|
||||
unique: list[str] = []
|
||||
@@ -203,7 +209,7 @@ def _truncation_heads(title_raw: str) -> list[str]:
|
||||
if norm and norm not in seen:
|
||||
seen.add(norm)
|
||||
unique.append(head)
|
||||
return unique[:3]
|
||||
return unique[:6]
|
||||
|
||||
|
||||
def _plausible_candidates(
|
||||
@@ -226,6 +232,18 @@ def _plausible_candidates(
|
||||
for result in results:
|
||||
norm = normalize_title(result.name)
|
||||
exact = norm == entry.title_normalized
|
||||
# BGG files new editions as SEPARATE games named "X (Nth Edition)":
|
||||
# a name that equals the title once its trailing parenthetical is
|
||||
# stripped is a sibling edition — exact-grade, or the match looks
|
||||
# unanimously confident while hiding the real choice (Wiz-War has
|
||||
# three same-named lineages; the spec's top failure mode)
|
||||
sibling = False
|
||||
if not exact and result.type != "boardgameexpansion":
|
||||
sibling = (
|
||||
normalize_title(_EDITION_SUFFIX.sub("", result.name))
|
||||
== entry.title_normalized
|
||||
)
|
||||
exact = sibling
|
||||
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
|
||||
if not exact and fuzzy < FUZZY_THRESHOLD:
|
||||
if not (head_normalized and norm == head_normalized):
|
||||
@@ -237,6 +255,7 @@ def _plausible_candidates(
|
||||
year=result.year,
|
||||
type=result.type,
|
||||
exact=exact,
|
||||
sibling=sibling,
|
||||
fuzzy=fuzzy,
|
||||
)
|
||||
prev = by_id.get(result.bgg_id)
|
||||
@@ -255,7 +274,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M
|
||||
return row
|
||||
|
||||
if len(cands) == 1:
|
||||
chosen = cands[0]
|
||||
# a lone candidate must still earn trust: BGG's search visibly
|
||||
# truncates generic queries (the game named "Dungeon!" appears in
|
||||
# NEITHER of its own searches), so the sole survivor may be an
|
||||
# impostor standing where the famous game should be. Sibling-grade
|
||||
# exactness never autos alone, and a true exact must clear the
|
||||
# same ownership floor the dominance rule enforces.
|
||||
only = cands[0]
|
||||
stats = {t.bgg_id: t for t in client.things([only.bgg_id], stats=True)}
|
||||
if only.bgg_id in stats:
|
||||
only.owned = stats[only.bgg_id].owned
|
||||
only.rank = stats[only.bgg_id].rank
|
||||
only.publishers = list(stats[only.bgg_id].publishers)
|
||||
row.candidates = [only]
|
||||
if only.sibling or not only.exact or (only.owned or 0) < DOMINANCE_MIN_OWNED:
|
||||
row.match_status = "ambiguous"
|
||||
return row
|
||||
chosen = only
|
||||
else:
|
||||
top = cands[:5]
|
||||
stats = {
|
||||
@@ -380,7 +415,7 @@ def resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> None
|
||||
"languages": list(v.languages),
|
||||
"score": s,
|
||||
}
|
||||
for v, s in plausible[:8]
|
||||
for v, s in plausible # every plausible version: no silent cap
|
||||
]
|
||||
if len(plausible) == 1 or plausible[0][1] > plausible[1][1]:
|
||||
winner = plausible[0][0]
|
||||
@@ -391,8 +426,40 @@ def resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> None
|
||||
row.version_status = "version_ambiguous"
|
||||
|
||||
|
||||
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
|
||||
cands = _plausible_candidates(client, entry, entry.title_raw)
|
||||
_EDITION_SUFFIX = re.compile(r"\s*\([^)]*\)\s*$")
|
||||
_PUNCT = re.compile(r"[^\w\s]", re.UNICODE)
|
||||
|
||||
|
||||
def _depunct(title: str) -> str:
|
||||
"""BGG's search engine can choke on punctuation — the game literally
|
||||
named "Dungeon!" is missing from its own 824-result search. A plain
|
||||
query recovers it."""
|
||||
return " ".join(_PUNCT.sub(" ", title).split())
|
||||
|
||||
|
||||
def _merged_candidates(
|
||||
client: BGGClient, entry: TitleEntry, query: str, types: str | None = None
|
||||
) -> list[Candidate]:
|
||||
"""Search the raw title AND its punctuation-free form, merged by id
|
||||
(strongest evidence wins)."""
|
||||
cands = _plausible_candidates(client, entry, query, types=types)
|
||||
plain = _depunct(query)
|
||||
if plain.casefold() != query.casefold():
|
||||
by_id = {c.bgg_id: c for c in cands}
|
||||
for c in _plausible_candidates(client, entry, plain, types=types):
|
||||
prev = by_id.get(c.bgg_id)
|
||||
if prev is None or (c.exact, c.fuzzy) > (prev.exact, prev.fuzzy):
|
||||
by_id[c.bgg_id] = c
|
||||
cands = sorted(by_id.values(), key=lambda c: (not c.exact, -c.fuzzy))
|
||||
return cands
|
||||
|
||||
|
||||
def find_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]:
|
||||
"""Every search this pipeline knows, in order of confidence. Public
|
||||
because review's reopen path must search exactly as resolve did — a
|
||||
partial re-implementation there silently misses whatever the later
|
||||
steps would have found."""
|
||||
cands = _merged_candidates(client, entry, entry.title_raw)
|
||||
if not cands:
|
||||
# Long transcribed box titles ("CIVILIZATION Game of the Heroic
|
||||
# Age - ...") defeat search: retry with progressively shorter heads
|
||||
@@ -406,7 +473,7 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
|
||||
# type=rpgitem (same API, same token). A hit becomes a LOCAL
|
||||
# library citizen: identified and enriched, never uploaded (diff
|
||||
# routes rpgitem rows to local_only).
|
||||
cands = _plausible_candidates(client, entry, entry.title_raw, types="rpgitem")
|
||||
cands = _merged_candidates(client, entry, entry.title_raw, types="rpgitem")
|
||||
if not cands:
|
||||
for head in _truncation_heads(entry.title_raw):
|
||||
cands = _plausible_candidates(
|
||||
@@ -414,6 +481,11 @@ def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
|
||||
)
|
||||
if cands:
|
||||
break
|
||||
return cands
|
||||
|
||||
|
||||
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
|
||||
cands = find_candidates(client, entry)
|
||||
row = _classify(client, entry, cands)
|
||||
if row.match_status == "auto":
|
||||
resolve_version(client, entry, row)
|
||||
@@ -587,8 +659,6 @@ def run_resolve(
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
seen_per_title: dict[str, int] = {}
|
||||
|
||||
new_rows: list[MatchRow] = []
|
||||
skipped = 0
|
||||
photos_updated = False
|
||||
@@ -607,7 +677,6 @@ def run_resolve(
|
||||
paired_by_id[id(entry)] = row_dict
|
||||
|
||||
for entry in entries:
|
||||
seen_per_title[entry.title_raw] = seen_per_title.get(entry.title_raw, 0) + 1
|
||||
row_dict = paired_by_id.get(id(entry))
|
||||
if row_dict is not None:
|
||||
photos = ";".join(entry.source_photos)
|
||||
@@ -662,6 +731,13 @@ def run_resolve(
|
||||
f"Resolved {len(new_rows)} title(s) ({summary or 'nothing new'}); "
|
||||
f"skipped {skipped} already in {cfg.matches_path}."
|
||||
)
|
||||
waiting = sum(1 for r in all_rows if r.get("match_status") == "unmatched")
|
||||
if waiting:
|
||||
typer.echo(
|
||||
f" {waiting} unmatched row(s) wait for YOUR call in review "
|
||||
"(re-search or manual id) — resolve never overrides a human "
|
||||
"decision, including 'this match is wrong'"
|
||||
)
|
||||
if blocked:
|
||||
typer.echo(
|
||||
f"\n{len(blocked)} title(s) are waiting on the BGG API "
|
||||
|
||||
@@ -30,8 +30,11 @@ from bggpipe.models import (
|
||||
)
|
||||
from bggpipe.normalize import normalize_title
|
||||
from bggpipe.resolve import (
|
||||
Candidate,
|
||||
MatchRow,
|
||||
TitleEntry,
|
||||
_score_version,
|
||||
find_candidates,
|
||||
load_titles,
|
||||
read_matches,
|
||||
resolve_version,
|
||||
@@ -270,13 +273,16 @@ class ReviewSession:
|
||||
title_raw: str,
|
||||
photos: list[str] | None = None,
|
||||
new_title: str | None = None,
|
||||
even_vetoed: bool = False,
|
||||
) -> int:
|
||||
"""An edit invalidated these rows — the BGG match was made against
|
||||
the uncorrected read. Remove them so resolve re-queries with the
|
||||
fix; `photos` narrows the cull to one copy of a split title.
|
||||
Rows carrying a human veto (dedupe_veto) are never dropped — the
|
||||
match itself was human-vetted, and dropping would erase the veto;
|
||||
a rename updates their title in place so they follow the entry."""
|
||||
a rename updates their title in place so they follow the entry.
|
||||
`even_vetoed` is for title REMOVAL, where the human is explicitly
|
||||
discarding the line, vetted or not."""
|
||||
self.reload_if_changed()
|
||||
norm = normalize_title(title_raw)
|
||||
keep: list[dict] = []
|
||||
@@ -288,7 +294,7 @@ class ReviewSession:
|
||||
)
|
||||
if not targeted:
|
||||
keep.append(row)
|
||||
elif row.get("dedupe_veto"):
|
||||
elif row.get("dedupe_veto") and not even_vetoed:
|
||||
if new_title and row["title_raw"] != new_title:
|
||||
row["title_raw"] = new_title
|
||||
renamed += 1
|
||||
@@ -325,9 +331,17 @@ class ReviewSession:
|
||||
undecided = [
|
||||
row for row in matches if row["match_status"] in UNDECIDED_MATCH_STATUSES
|
||||
]
|
||||
if undecided or matches:
|
||||
return (undecided or matches)[0]
|
||||
return None
|
||||
if undecided:
|
||||
return undecided[0]
|
||||
# both siblings decided (two-edition duplicates): prefer the one
|
||||
# whose VERSION is still open, as _adopt does — otherwise a stale
|
||||
# row_ix would land a version pick on the already-decided sibling
|
||||
version_open = [
|
||||
row for row in matches if row["version_status"] == "version_ambiguous"
|
||||
]
|
||||
if version_open:
|
||||
return version_open[0]
|
||||
return matches[0] if matches else None
|
||||
|
||||
def cues_for(
|
||||
self, title_raw: str, source_photos: str | None = None
|
||||
@@ -353,6 +367,160 @@ class ReviewSession:
|
||||
row["match_status"] = "rejected"
|
||||
self._save(row)
|
||||
|
||||
def decide_local(self, row: dict) -> None:
|
||||
"""A real game BGG simply doesn't have: a local library citizen —
|
||||
listed and enriched from its own photo reads, never uploaded."""
|
||||
row["match_status"] = "local"
|
||||
row["bgg_id"] = ""
|
||||
row["bgg_name"] = ""
|
||||
row["version_status"] = ""
|
||||
row["version_id"] = ""
|
||||
row["version_name"] = ""
|
||||
self._save(row)
|
||||
|
||||
def research(self, row: dict, query: str, types: str | None = None) -> int:
|
||||
"""Re-search on the human's terms and put the results on the row's
|
||||
ballot. `types` targets a specific database — "rpgitem" for
|
||||
RPGGeek, whose entries the automatic cascade never reaches when BGG
|
||||
has a same-named BOARD game (every D&D box hits this)."""
|
||||
query = query.strip()
|
||||
if not query:
|
||||
raise ValueError("a search needs some text")
|
||||
results = (
|
||||
self.client.search(query, types) if types else self.client.search(query)
|
||||
)
|
||||
merged: dict[int, Candidate] = {}
|
||||
for r in results:
|
||||
cand = Candidate(
|
||||
bgg_id=r.bgg_id,
|
||||
name=r.name,
|
||||
year=r.year,
|
||||
type=r.type,
|
||||
exact=normalize_title(r.name) == normalize_title(query),
|
||||
fuzzy=0.0,
|
||||
)
|
||||
prior = merged.get(r.bgg_id)
|
||||
# one ballot line per game; an alternate-name row that exact-
|
||||
# matches the query outranks the primary-name row for it
|
||||
if prior is None or (cand.exact and not prior.exact):
|
||||
merged[r.bgg_id] = cand
|
||||
cands = list(merged.values())[:12]
|
||||
if cands:
|
||||
try:
|
||||
stats = {
|
||||
t.bgg_id: t
|
||||
for t in self.client.things([c.bgg_id for c in cands], stats=True)
|
||||
}
|
||||
except _BGG_ERRORS as err:
|
||||
# owned counts and ranks only decorate the ballot — losing
|
||||
# them must not lose the search the human just asked for
|
||||
self._warn(f"couldn't fetch stats for these results ({err})")
|
||||
stats = {}
|
||||
for c in cands:
|
||||
if c.bgg_id in stats:
|
||||
c.owned = stats[c.bgg_id].owned
|
||||
c.rank = stats[c.bgg_id].rank
|
||||
c.publishers = list(stats[c.bgg_id].publishers)
|
||||
row["match_status"] = "ambiguous" if cands else "unmatched"
|
||||
row["candidates_json"] = json.dumps(
|
||||
[c.as_json() for c in cands], ensure_ascii=False
|
||||
)
|
||||
# a fresh ballot supersedes any earlier verdict on this row —
|
||||
# including the VERSION verdicts, which belong to the old game: a
|
||||
# surviving version_id would ride into the next pick and put the
|
||||
# wrong game's edition on the collection entry
|
||||
row["bgg_id"] = ""
|
||||
row["bgg_name"] = ""
|
||||
row["version_status"] = ""
|
||||
row["version_id"] = ""
|
||||
row["version_name"] = ""
|
||||
row["version_candidates_json"] = "[]"
|
||||
self._save(row)
|
||||
return len(cands)
|
||||
|
||||
def reopen_match(self, row: dict) -> None:
|
||||
"""The human says a matched row is the WRONG game: clear the match
|
||||
and immediately re-search so the card comes back as a ballot — the
|
||||
machine re-OFFERS, only the human re-decides (run_resolve never
|
||||
touches unmatched rows, so without this the row would just sit).
|
||||
If BGG is unreachable the row still reopens, bare: re-search and
|
||||
manual id remain available on the card."""
|
||||
row["match_status"] = "unmatched"
|
||||
row["bgg_id"] = ""
|
||||
row["bgg_name"] = ""
|
||||
row["year"] = ""
|
||||
row["type"] = ""
|
||||
row["candidates_json"] = "[]"
|
||||
row["version_status"] = ""
|
||||
row["version_id"] = ""
|
||||
row["version_name"] = ""
|
||||
row["version_candidates_json"] = "[]"
|
||||
entry = self.cues_for(row["title_raw"], row["source_photos"]) or TitleEntry(
|
||||
title_raw=row["title_raw"],
|
||||
title_normalized=normalize_title(row["title_raw"]),
|
||||
)
|
||||
try:
|
||||
# the FULL cascade resolve uses — board games, truncation heads,
|
||||
# then RPGGeek — so a row parked as "local" gets the same look
|
||||
cands = find_candidates(self.client, entry)[:8]
|
||||
if cands:
|
||||
stats = {
|
||||
t.bgg_id: t
|
||||
for t in self.client.things([c.bgg_id for c in cands], stats=True)
|
||||
}
|
||||
for c in cands:
|
||||
if c.bgg_id in stats:
|
||||
c.owned = stats[c.bgg_id].owned
|
||||
c.rank = stats[c.bgg_id].rank
|
||||
c.publishers = list(stats[c.bgg_id].publishers)
|
||||
row["match_status"] = "ambiguous"
|
||||
row["candidates_json"] = json.dumps(
|
||||
[c.as_json() for c in cands], ensure_ascii=False
|
||||
)
|
||||
except _BGG_ERRORS as err:
|
||||
self._warn(
|
||||
f"re-search unavailable ({err}) — the row is reopened; use "
|
||||
"(f) re-search or (m) manual id on its card"
|
||||
)
|
||||
self._save(row)
|
||||
|
||||
def open_version_ballot(self, row: dict) -> int:
|
||||
"""The human knows which printing a box is even when the photo
|
||||
showed no cues: put EVERY published version on the row's ballot
|
||||
(cue-scored when cues exist) and mark it version_ambiguous so the
|
||||
normal edition pass presents it. Returns the ballot size."""
|
||||
if not row["bgg_id"]:
|
||||
raise ValueError("no BGG match on this row yet")
|
||||
entry = self.cues_for(row["title_raw"], row["source_photos"])
|
||||
things = self.client.things([int(row["bgg_id"])], versions=True)
|
||||
if not things or not things[0].versions:
|
||||
raise ValueError("BGG lists no versions for this game")
|
||||
scored = sorted(
|
||||
((v, _score_version(entry, v) if entry else 0) for v in things[0].versions),
|
||||
key=lambda pair: (-pair[1], str(pair[0].year or "")),
|
||||
)
|
||||
row["version_candidates_json"] = json.dumps(
|
||||
[
|
||||
{
|
||||
"version_id": v.version_id,
|
||||
"name": v.name,
|
||||
"year": v.year,
|
||||
"publishers": list(v.publishers),
|
||||
"languages": list(v.languages),
|
||||
"score": s,
|
||||
}
|
||||
for v, s in scored
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
row["version_status"] = "version_ambiguous"
|
||||
# a CURRENT pick survives the reopen: abandoning the ballot must
|
||||
# not lose an approved edition. The lingering id is inert while
|
||||
# ambiguous (is_confident_version gates diff and upload), and the
|
||||
# next decide_version overwrites or clears it.
|
||||
self._save(row)
|
||||
return len(scored)
|
||||
|
||||
def decide_version(self, row: dict, version_id: int | None) -> None:
|
||||
"""Pick a version from the row's stored candidates, or None -> unknown."""
|
||||
if version_id is None:
|
||||
@@ -404,10 +572,10 @@ class ReviewSession:
|
||||
while True:
|
||||
self._show_item(row, candidates)
|
||||
prompt = (
|
||||
"[1-N] pick (s)kip (r)eject (m <id>) manual BGG id "
|
||||
"(f <text>) re-search (q)uit > "
|
||||
"[1-N] pick (s)kip (r)eject (l)ocal — not on BGG "
|
||||
"(m <id>) manual BGG id (f <text>) re-search (q)uit > "
|
||||
if row["match_status"] == "unmatched"
|
||||
else "[1-N] pick (s)kip (r)eject (q)uit > "
|
||||
else "[1-N] pick (s)kip (r)eject (l)ocal — not on BGG (q)uit > "
|
||||
)
|
||||
answer = self._ask(prompt)
|
||||
lowered = answer.lower()
|
||||
@@ -416,6 +584,9 @@ class ReviewSession:
|
||||
if lowered == "r":
|
||||
self.decide_reject(row)
|
||||
return
|
||||
if lowered == "l":
|
||||
self.decide_local(row)
|
||||
return
|
||||
if answer.isdigit() and 1 <= int(answer) <= len(candidates):
|
||||
self.decide_pick(row, candidates[int(answer) - 1])
|
||||
return
|
||||
@@ -449,6 +620,9 @@ class ReviewSession:
|
||||
def _research(self, query: str) -> list[dict]:
|
||||
try:
|
||||
results = self.client.search(query)
|
||||
if not results:
|
||||
# board games first, then RPGGeek — same database, same token
|
||||
results = self.client.search(query, "rpgitem")
|
||||
except _BGG_ERRORS as err:
|
||||
self.console.print(f"[yellow]search unavailable: {err}[/yellow]")
|
||||
return []
|
||||
|
||||
@@ -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}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* bggpipe design system — one stylesheet, two pages (dashboard, review).
|
||||
/* bggpipe design system — one stylesheet for the whole app.
|
||||
*
|
||||
* The visual world is Juniper's mascot drawing: a sky-blue day, flat cel
|
||||
* color inside confident dark outlines, a cream game board with a rainbow
|
||||
@@ -30,7 +30,12 @@
|
||||
--gold: #f2c04b; /* pipe fittings: rings, trim, hovers */
|
||||
--gold-ink: #8a6414;
|
||||
--ticket: #fdf3d2; /* reshoot work-orders */
|
||||
--focus: #8330c2;
|
||||
--go-tint: #e2f2e4; /* pale state washes of the role colors */
|
||||
--stop-tint: #fbe3da;
|
||||
--gold-tint: #fdeebb;
|
||||
--accent-tint: #ece5f7;
|
||||
--navy-tint: #e3ecf3;
|
||||
--focus: var(--accent);
|
||||
--path: linear-gradient(90deg,
|
||||
#f767b8, #f79a3e, #f2c04b, #6fce6f, #5aa7f0, #9a5be0);
|
||||
--path-v: linear-gradient(180deg,
|
||||
@@ -59,7 +64,6 @@ body {
|
||||
}
|
||||
main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; }
|
||||
h2 {
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700; font-size: 1.1rem; letter-spacing: .02em;
|
||||
margin: 2rem 0 .8rem;
|
||||
@@ -121,6 +125,7 @@ nav[aria-label="Primary"] a[aria-current="page"] {
|
||||
display: block; margin: 0 auto;
|
||||
}
|
||||
.piperbox figcaption { font-size: .72rem; opacity: .8; margin-top: .45rem; }
|
||||
.navtoggle { display: none; }
|
||||
.piperbox .legal { font-size: .62rem; opacity: .6; line-height: 1.45; text-align: left; }
|
||||
.skip {
|
||||
position: absolute; left: -999px; top: 0; z-index: 10;
|
||||
@@ -163,7 +168,7 @@ kbd {
|
||||
border: var(--line);
|
||||
background: var(--board);
|
||||
}
|
||||
.banner.error { border-color: var(--stop); color: var(--stop-ink); background: #fbe3da; }
|
||||
.banner.error { border-color: var(--stop); color: var(--stop-ink); background: var(--stop-tint); }
|
||||
.banner.warn { border-color: var(--gold-ink); color: var(--gold-ink); background: var(--ticket); }
|
||||
|
||||
/* -- buttons ----------------------------------------------------------- */
|
||||
@@ -192,6 +197,17 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
display: flex; gap: 1.1rem;
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
.card.prose { display: block; }
|
||||
.card.prose p, .card.prose ol { margin: .55rem 0; line-height: 1.55; }
|
||||
.card.prose > :first-child { margin-top: 0; }
|
||||
.card.prose > :last-child { margin-bottom: 0; }
|
||||
.artcard { text-align: center; }
|
||||
.artcard img {
|
||||
width: 100%; max-width: 15rem;
|
||||
border: var(--line); border-radius: var(--radius-lg);
|
||||
background: var(--sky);
|
||||
}
|
||||
.artcard .legal { font-size: .74rem; color: var(--ink-soft); max-width: 30rem; margin: .6rem auto 0; }
|
||||
.card.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-raised);
|
||||
@@ -231,11 +247,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
.cname { font-weight: 600; }
|
||||
.cmeta { color: var(--ink-soft); font-size: .8rem; margin-left: .4rem; }
|
||||
.rowactions { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; }
|
||||
.rowactions button {
|
||||
font: inherit; border: var(--line); background: #fff;
|
||||
border-radius: var(--radius); padding: .25rem .7rem; cursor: pointer;
|
||||
}
|
||||
.rowactions button { padding: .25rem .7rem; box-shadow: none; }
|
||||
.rowactions button.reject { color: var(--stop-ink); border-color: var(--stop); }
|
||||
.research { display: flex; gap: .35rem; align-items: center; flex-wrap: wrap; }
|
||||
.research input[type=text] { width: 12em; }
|
||||
.research button { font-size: .78rem; }
|
||||
.rowactions input[type=text] {
|
||||
font: inherit; width: 8.5em; padding: .25rem .5rem;
|
||||
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||
@@ -264,10 +280,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
.ticket .partial { font-family: var(--font-mono); font-size: .85rem; }
|
||||
.ticket .notes { color: var(--gold-ink); font-size: .85rem; margin-top: .2rem; }
|
||||
.ticket button {
|
||||
font: inherit; font-size: .8rem; margin-top: .5rem;
|
||||
font-size: .8rem; margin-top: .5rem;
|
||||
background: none; border: 1px solid var(--gold-ink); color: var(--gold-ink);
|
||||
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer;
|
||||
box-shadow: none;
|
||||
padding: .2rem .6rem; box-shadow: none;
|
||||
}
|
||||
|
||||
/* -- all-done celebration ---------------------------------------------- */
|
||||
@@ -280,7 +295,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
border-radius: var(--radius-lg); border: var(--line);
|
||||
background: var(--sky);
|
||||
}
|
||||
.done h2 { color: var(--ink); margin-top: 0; }
|
||||
.done h2 { margin-top: 0; }
|
||||
.done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
|
||||
.done .nums div { font-size: 1.6rem; font-weight: 700; font-family: var(--font-display); }
|
||||
.done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
|
||||
@@ -310,12 +325,14 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.catalog a:hover { color: var(--accent-ink); }
|
||||
.catalog td.actions { text-align: right; white-space: nowrap; }
|
||||
.catalog td.actions button {
|
||||
font: inherit; font-size: .78rem; border: var(--line); background: #fff;
|
||||
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer;
|
||||
box-shadow: none;
|
||||
.catalog td.actions { text-align: right; }
|
||||
.catalog td.actions button { margin: .1rem 0 .1rem .3rem; white-space: nowrap; }
|
||||
.catalog td.actions .ballotlink {
|
||||
display: inline-block; margin: .1rem 0 .1rem .3rem; white-space: nowrap;
|
||||
font-size: .78rem; color: var(--accent-ink);
|
||||
text-decoration: underline dotted; text-underline-offset: 3px;
|
||||
}
|
||||
.catalog td.actions button { font-size: .78rem; padding: .2rem .6rem; box-shadow: none; }
|
||||
.catalog td.actions button:hover { background: var(--board); }
|
||||
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
|
||||
.editform { display: flex; gap: .7rem; align-items: end; flex-wrap: wrap; }
|
||||
@@ -336,21 +353,28 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
border-radius: 999px; padding: .1rem .55rem; white-space: nowrap;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.chip.ok { background: #e2f2e4; color: var(--go-ink); }
|
||||
.card code {
|
||||
font-family: var(--font-mono); font-size: .82em;
|
||||
background: #fff; border: 1px solid var(--board-edge);
|
||||
border-radius: 4px; padding: 0 .3em;
|
||||
}
|
||||
.card ol { padding-left: 1.3rem; }
|
||||
.card ol li { margin: .35rem 0; }
|
||||
.chip.ok { background: var(--go-tint); color: var(--go-ink); }
|
||||
.chip.wait { background: var(--ticket); color: var(--gold-ink); }
|
||||
.chip.no { background: #fbe3da; color: var(--stop-ink); }
|
||||
.chip.open { background: #ece5f7; color: var(--accent-ink); }
|
||||
.chip.merged { background: #e3ecf3; color: var(--navy); }
|
||||
.chip.no { background: var(--stop-tint); color: var(--stop-ink); }
|
||||
.chip.open { background: var(--accent-tint); color: var(--accent-ink); }
|
||||
.chip.merged { background: var(--navy-tint); color: var(--navy); }
|
||||
.chip.shaky { background: var(--ticket); color: var(--gold-ink); border: 1px dashed var(--gold-ink); }
|
||||
|
||||
/* -- merge notices: slim, undoable ------------------------------------- */
|
||||
.card.merge { padding: .55rem 1rem; align-items: center; }
|
||||
.card.merge .body { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
|
||||
.card.merge .arrow { color: var(--ink-soft); }
|
||||
.card.merge button {
|
||||
font: inherit; font-size: .8rem; margin-left: auto;
|
||||
font-size: .8rem; margin-left: auto;
|
||||
background: none; border: 1px solid var(--board-edge);
|
||||
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer;
|
||||
box-shadow: none;
|
||||
padding: .2rem .6rem; box-shadow: none;
|
||||
}
|
||||
.card.merge button:hover { border-color: var(--stop); color: var(--stop-ink); }
|
||||
|
||||
@@ -385,7 +409,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
font: inherit; cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
#dropzone.hot, #dropzone:hover { border-style: solid; background: #fdeebb; }
|
||||
#dropzone.hot, #dropzone:hover { border-style: solid; background: var(--gold-tint); }
|
||||
#dropzone[aria-busy="true"] { cursor: progress; opacity: .8; }
|
||||
#dropzone.ok { border-style: solid; border-color: var(--go-ink); color: var(--go-ink); background: var(--go-tint); }
|
||||
#joblog {
|
||||
background: var(--navy); color: #eaf1fb;
|
||||
font-family: var(--font-mono); font-size: .78rem;
|
||||
@@ -394,13 +420,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
max-height: 20rem; overflow-y: auto; white-space: pre-wrap;
|
||||
}
|
||||
#jobstate { font-size: .85rem; color: var(--ink); margin-bottom: .4rem; }
|
||||
#jobstate .running { color: var(--accent-ink); font-weight: 600; }
|
||||
#jobstate .failed { color: var(--stop-ink); font-weight: 600; }
|
||||
#jobstate .s-running { color: var(--accent-ink); font-weight: 600; }
|
||||
#jobstate .s-done { color: var(--go-ink); font-weight: 600; }
|
||||
#jobstate .s-failed { color: var(--stop-ink); font-weight: 600; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.card, .ticket { flex-direction: column; }
|
||||
.shots { flex-basis: auto; }
|
||||
}
|
||||
/* (phone-width rules live in the responsive section at the end) */
|
||||
|
||||
/* -- photos page: gallery + status ------------------------------------- */
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .9rem; }
|
||||
@@ -439,6 +463,209 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
.game .info { padding: .55rem .7rem .7rem; }
|
||||
.game .gname { font-weight: 700; font-family: var(--font-display); }
|
||||
.game .gmeta { font-size: .78rem; color: var(--ink-soft); margin-top: .2rem; line-height: 1.5; }
|
||||
/* -- library: card grid links + one game's detail ---------------------- */
|
||||
a.game { text-decoration: none; color: inherit; }
|
||||
a.game:hover { border-color: var(--accent); box-shadow: var(--shadow-raised); }
|
||||
a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.gamedetail { display: flex; gap: 1.4rem; align-items: flex-start; flex-wrap: wrap; }
|
||||
.gameartcol { flex: 0 0 clamp(12rem, 28vw, 18rem); }
|
||||
.gameart {
|
||||
width: 100%; border: var(--line); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card); display: block; background: var(--board);
|
||||
}
|
||||
.gameart.noart {
|
||||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--sky-deep); color: var(--navy);
|
||||
font-family: var(--font-display); font-size: 3rem; font-weight: 700;
|
||||
}
|
||||
.gamefacts {
|
||||
flex: 1 1 18rem; background: var(--board); border: var(--line);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
|
||||
padding: .9rem 1.1rem;
|
||||
}
|
||||
.factrow { margin: .45rem 0; line-height: 1.5; }
|
||||
.factrow b {
|
||||
font-size: .72rem; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: var(--ink-soft); margin-right: .4rem;
|
||||
}
|
||||
.chiplist { display: inline-flex; flex-wrap: wrap; gap: .3rem; vertical-align: middle; }
|
||||
.gdesc { white-space: pre-wrap; line-height: 1.6; }
|
||||
.artbtn { margin-top: .6rem; width: 100%; font-size: .8rem; }
|
||||
.editform label.wide { flex: 1 1 100%; }
|
||||
.editform textarea {
|
||||
font: inherit; font-size: .85rem; color: var(--ink); width: 100%;
|
||||
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||
padding: .35rem .45rem; background: #fff; resize: vertical;
|
||||
}
|
||||
|
||||
/* pick rows live inside an .editform, whose label style (stacked,
|
||||
uppercase, gray) is for FIELDS — a checklist row overrides it back
|
||||
to a plain reading line */
|
||||
.editform label.pickrow {
|
||||
flex-direction: row; align-items: center; gap: .55rem;
|
||||
font-size: .9rem; text-transform: none; letter-spacing: normal;
|
||||
color: var(--ink); padding: .32rem .35rem; cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.editform label.pickrow:hover { background: rgba(122, 74, 158, .08); }
|
||||
.pickrow input[type="checkbox"] {
|
||||
margin: 0; width: 1.05rem; height: 1.05rem; flex: none;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.pickrow .meta { font-size: .78rem; color: var(--ink-soft); }
|
||||
.pickrow .chip { margin-left: auto; }
|
||||
#pickresults {
|
||||
flex: 1 1 100%; max-height: 22rem; overflow-y: auto;
|
||||
border: 1px solid var(--board-edge); border-radius: var(--radius);
|
||||
padding: .25rem; background: #fff;
|
||||
}
|
||||
#pickfooter {
|
||||
flex: 1 1 100%; display: flex; gap: .7rem; align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#pickfooter .edithint { flex: 1 1 100%; }
|
||||
/* selects join the design system EVERYWHERE — the third time a native
|
||||
dropdown showed up in a card, the lesson finally took: style the
|
||||
element, not each container it appears in */
|
||||
select {
|
||||
font: inherit; font-size: .85rem; color: var(--ink);
|
||||
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||
padding: .25rem .45rem; background: #fff; max-width: 24rem;
|
||||
}
|
||||
/* :not([hidden]) so the hidden attribute still works — a bare
|
||||
display:flex would override the browser's [hidden] rule and show
|
||||
the form permanently */
|
||||
#storedform:not([hidden]) { display: flex; gap: .5rem; align-items: center; }
|
||||
#storedform select { flex: 1 1 auto; }
|
||||
|
||||
/* -- shelves: units as grids of openings, sheet for one opening ------- */
|
||||
/* .card is flex for the review pages' photo-beside-body layout; a shelf
|
||||
card is a vertical STACK — without this, unit headers share a row with
|
||||
their grid and unshelved rows flow horizontally off the page */
|
||||
.card.stack { display: block; }
|
||||
.unit .unithead { display: flex; align-items: center; gap: .7rem;
|
||||
justify-content: space-between; flex-wrap: wrap; }
|
||||
/* rows mirror the unit's PHYSICAL arrangement: one .orow per label
|
||||
row (A, B, ...), cells sized by flex-grow proportional to interior
|
||||
width — a double-wide reads double wide */
|
||||
.openings { display: flex; flex-direction: column; gap: .5rem;
|
||||
margin-bottom: .8rem; }
|
||||
.orow { display: flex; gap: .5rem; }
|
||||
.formlabel { margin: 1.2rem 0 .35rem; }
|
||||
.editform label.attop { flex-direction: row; align-items: center; gap: .35rem;
|
||||
text-transform: none; letter-spacing: normal; align-self: end;
|
||||
padding-bottom: .45rem; }
|
||||
.renamerow:not([hidden]) { display: flex; gap: .4rem; margin: .3rem 0 .6rem; }
|
||||
.renamerow input { font: inherit; font-size: .9rem;
|
||||
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||
padding: .25rem .45rem; flex: 0 1 18rem; }
|
||||
.unitdesc { margin: -.3rem 0 .7rem; display: flex; gap: .6rem;
|
||||
align-items: center; flex-wrap: wrap; }
|
||||
.unitdesc .descform:not([hidden]) { display: flex; gap: .4rem; flex: 1 1 16rem; }
|
||||
.unitdesc .descform input { flex: 1 1 auto; font: inherit; font-size: .85rem;
|
||||
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||
padding: .25rem .45rem; }
|
||||
.formlabel.small { font-weight: 700; font-size: .85rem;
|
||||
color: var(--ink-soft); margin: .8rem 0 .3rem; }
|
||||
button.opening {
|
||||
font: inherit; text-align: left; cursor: pointer;
|
||||
background: #fff; border: 2px solid var(--board-edge);
|
||||
border-radius: var(--radius); padding: .5rem .6rem;
|
||||
display: flex; flex-direction: column; gap: .25rem; min-height: 5.2rem;
|
||||
flex-basis: 0; min-width: 0; overflow: hidden;
|
||||
}
|
||||
button.opening:hover, button.opening:focus-visible {
|
||||
outline: 3px solid var(--accent); }
|
||||
.olabel { font-weight: 700; display: flex; gap: .35rem; align-items: center; }
|
||||
.ozone { font-size: .72rem; color: var(--accent); }
|
||||
.ocount { font-size: .82rem; color: var(--ink-soft); }
|
||||
.fillbar { height: .45rem; background: var(--board-edge);
|
||||
border-radius: 1rem; overflow: hidden; }
|
||||
.fillbar > div { height: 100%; background: var(--go, #2f8a45);
|
||||
border-radius: 1rem; }
|
||||
.fillbar > div.over { background: #b3372f; }
|
||||
.fillbar.dual { margin-top: 2px; }
|
||||
.fillbar > div.stand { background: #3e6fa3; }
|
||||
.lanebtn { font: inherit; font-size: .72rem; cursor: pointer;
|
||||
border: 1px solid var(--board-edge); border-radius: 1rem;
|
||||
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
|
||||
.lanebtn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.ackbtn { font: inherit; font-size: .72rem; cursor: pointer;
|
||||
border: 1px solid #b3372f; border-radius: 1rem;
|
||||
background: #fff; padding: .05rem .5rem; color: #b3372f; }
|
||||
.ackbtn.on { border-color: var(--board-edge); background: var(--felt-soft, #eef2ea);
|
||||
color: var(--ink-soft); }
|
||||
.ackbtn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.measurebtn { font: inherit; font-size: .72rem; cursor: pointer;
|
||||
border: 1px dashed var(--board-edge); border-radius: 1rem;
|
||||
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
|
||||
.measurebtn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.measureform { display: inline-flex; gap: .25rem; align-items: center;
|
||||
font-size: .78rem; color: var(--ink-soft); }
|
||||
.measureform input { width: 3.6rem; font: inherit; padding: .1rem .25rem;
|
||||
border: 1px solid var(--board-edge); border-radius: .3rem; }
|
||||
.measureform .measuresave { font: inherit; font-size: .72rem; cursor: pointer;
|
||||
border: 1px solid var(--board-edge); border-radius: 1rem;
|
||||
background: #fff; padding: .05rem .5rem; }
|
||||
.unshelved-row { display: flex; gap: .6rem; align-items: center;
|
||||
flex-wrap: wrap; padding: .4rem 0;
|
||||
border-bottom: 1px solid var(--board-edge); }
|
||||
.unshelved-row .gos { display: flex; gap: .4rem; flex-wrap: wrap;
|
||||
margin-left: auto; }
|
||||
button.suggest { font: inherit; font-size: .8rem; cursor: pointer;
|
||||
border: 2px solid var(--board-edge); border-radius: 1rem;
|
||||
background: var(--board); padding: .15rem .6rem; }
|
||||
button.suggest:hover { border-color: var(--accent); }
|
||||
button.suggest.reunites { border-color: var(--accent); font-weight: 700; }
|
||||
|
||||
/* the opening sheet: modal on desktop, bottom sheet on phones */
|
||||
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 40; }
|
||||
#opensheet[hidden] { display: none; }
|
||||
.sheetcard { background: var(--board); border-radius: var(--radius-lg);
|
||||
border: var(--line); box-shadow: var(--shadow-raised);
|
||||
width: min(34rem, 92vw); max-height: 86vh; overflow-y: auto;
|
||||
padding: 1rem 1.1rem; }
|
||||
.sheethead { display: flex; justify-content: space-between;
|
||||
align-items: center; gap: .7rem; margin-bottom: .5rem; }
|
||||
#sheetclose { font: inherit; font-size: 1.1rem; background: none;
|
||||
border: none; cursor: pointer; padding: .5rem; }
|
||||
.sheetrow { display: flex; gap: .6rem; align-items: center;
|
||||
padding: .35rem 0; border-bottom: 1px solid var(--board-edge); }
|
||||
.sheetrow img { width: 2.2rem; height: 2.2rem; object-fit: cover;
|
||||
border-radius: .35rem; }
|
||||
.sheetrow .unassign { margin-left: auto; }
|
||||
.addopeningform { margin-top: .6rem; }
|
||||
#locform:not([hidden]) { display: flex; gap: .5rem; align-items: center;
|
||||
flex-wrap: wrap; }
|
||||
#locform select { max-width: 100%; flex: 1 1 14rem; }
|
||||
#locform input { flex: 1 1 10rem; font: inherit; font-size: .85rem;
|
||||
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||
padding: .25rem .45rem; }
|
||||
#sheetsearch { width: 100%; margin: .7rem 0 .35rem; font: inherit;
|
||||
font-size: 1rem; padding: .55rem .7rem;
|
||||
border: 2px solid var(--board-edge); border-radius: var(--radius); }
|
||||
button.sheetadd { display: flex; gap: .6rem; align-items: center;
|
||||
width: 100%; text-align: left; font: inherit; cursor: pointer;
|
||||
background: #fff; border: 2px solid var(--board-edge);
|
||||
border-radius: var(--radius); padding: .5rem .6rem; margin: .25rem 0;
|
||||
min-height: 2.9rem; }
|
||||
button.sheetadd img { width: 2.2rem; height: 2.2rem; object-fit: cover;
|
||||
border-radius: .35rem; }
|
||||
button.sheetadd:hover, button.sheetadd:focus-visible {
|
||||
outline: 3px solid var(--accent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* one-handed moving-day mode: the sheet docks to the bottom, search
|
||||
first, tap targets stay thumb-sized */
|
||||
#opensheet { align-items: flex-end; }
|
||||
.sheetcard { width: 100vw; max-height: 78vh;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
|
||||
button.opening { min-height: 4.4rem; }
|
||||
.unshelved-row .gos { margin-left: 0; width: 100%; }
|
||||
button.suggest { padding: .5rem .8rem; }
|
||||
}
|
||||
|
||||
.empty {
|
||||
background: var(--board); border: 2px dashed var(--board-edge);
|
||||
border-radius: var(--radius-lg); padding: 2rem; text-align: center;
|
||||
@@ -457,18 +684,108 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
||||
border: var(--line); border-radius: var(--radius); background: #fff;
|
||||
}
|
||||
|
||||
/* -- responsive: rail collapses to a top strip -------------------------- */
|
||||
/* -- toast: viewport-pinned transient confirmations --------------------- */
|
||||
#toast {
|
||||
position: fixed; bottom: 1.1rem; left: 50%; transform: translateX(-50%);
|
||||
z-index: 20; max-width: min(92vw, 34rem);
|
||||
background: var(--navy); color: #fff;
|
||||
border: var(--line); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-raised);
|
||||
padding: .7rem 1rem; font-size: .9rem; line-height: 1.45;
|
||||
}
|
||||
#toast a { color: var(--gold); }
|
||||
#toast button {
|
||||
font-size: .8rem; padding: .25rem .7rem; margin-left: .5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* -- responsive -------------------------------------------------------- */
|
||||
|
||||
/* The rail collapses to a top bar: brand + hamburger; the nav drops
|
||||
* down as a stacked menu (a scrolling strip lost its position on every
|
||||
* page load and hid the far entries). */
|
||||
@media (max-width: 900px) {
|
||||
body { display: block; }
|
||||
.sidebar {
|
||||
position: sticky; height: auto; flex-direction: row; align-items: center;
|
||||
flex-wrap: wrap; gap: 0 .5rem; z-index: 5;
|
||||
position: sticky; top: 0; height: auto; z-index: 5;
|
||||
display: flex; flex-direction: row; align-items: center; flex-wrap: wrap;
|
||||
border-right: none; border-bottom: 4px solid transparent;
|
||||
border-image: var(--path) 1;
|
||||
}
|
||||
.brand { padding: .5rem .8rem; }
|
||||
nav[aria-label="Primary"] { flex-direction: row; flex-wrap: wrap; }
|
||||
nav[aria-label="Primary"] a { border-left: none; border-bottom: 3px solid transparent; padding: .35rem .6rem; }
|
||||
nav[aria-label="Primary"] a[aria-current="page"] { border-bottom-color: var(--gold); }
|
||||
.brand { padding: .5rem .8rem; flex: 1; }
|
||||
.brand img { width: 30px; height: 30px; }
|
||||
.wordmark { font-size: 1.1rem; }
|
||||
.wordmark small { display: inline; margin-left: .5rem; font-size: .68rem; }
|
||||
.navtoggle {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 44px; height: 40px; margin-right: .6rem;
|
||||
background: none; border: 2px solid rgba(255, 255, 255, .75);
|
||||
border-radius: var(--radius); box-shadow: none; padding: 0;
|
||||
}
|
||||
.navtoggle .bars {
|
||||
display: block; position: relative;
|
||||
width: 18px; height: 2px; background: #fff; border-radius: 2px;
|
||||
}
|
||||
.navtoggle .bars::before, .navtoggle .bars::after {
|
||||
content: ""; position: absolute; left: 0;
|
||||
width: 18px; height: 2px; background: #fff; border-radius: 2px;
|
||||
}
|
||||
.navtoggle .bars::before { top: -6px; }
|
||||
.navtoggle .bars::after { top: 6px; }
|
||||
body.navopen .navtoggle { background: rgba(255, 255, 255, .15); }
|
||||
nav[aria-label="Primary"] {
|
||||
display: none; flex-basis: 100%; flex-direction: column;
|
||||
padding: 0 0 .4rem;
|
||||
}
|
||||
body.navopen nav[aria-label="Primary"] { display: flex; }
|
||||
nav[aria-label="Primary"] a { padding: .6rem 1rem; }
|
||||
.piperbox { display: none; }
|
||||
}
|
||||
|
||||
/* Phone-width layout: stacked cards, stacked tables, touch targets. */
|
||||
@media (max-width: 700px) {
|
||||
main { padding: 1rem .9rem 4rem; }
|
||||
h1 { font-size: 1.3rem; margin: .9rem 0 .7rem; }
|
||||
.keyhelp { display: none; } /* no keyboard on a phone */
|
||||
button, .linkbtn { padding: .45rem .9rem; } /* finger-sized */
|
||||
/* component buttons set their own padding at higher specificity —
|
||||
* they need the touch bump spelled out */
|
||||
.rowactions button, .ticket button, .card.merge button { padding: .4rem .8rem; }
|
||||
|
||||
.card, .ticket { flex-direction: column; }
|
||||
.shots { flex-basis: auto; }
|
||||
.ticket .stencil {
|
||||
writing-mode: horizontal-tb; letter-spacing: .35em;
|
||||
border-right: none; border-bottom: 1px solid var(--gold-ink);
|
||||
padding: 0 0 .3rem;
|
||||
}
|
||||
.done { padding: 1.1rem .9rem; }
|
||||
.done .nums { flex-wrap: wrap; gap: .8rem 1.6rem; }
|
||||
|
||||
.filterbar input[type=search] { min-width: 0; flex: 1 1 12rem; }
|
||||
.editform { flex-direction: column; align-items: stretch; }
|
||||
.editform input { width: 100%; }
|
||||
.editactions { flex-wrap: wrap; }
|
||||
.editactions button { flex: 1 1 auto; }
|
||||
|
||||
/* the titles/photo tables become stacked line-cards: one bordered
|
||||
* block per game, cells flowing top to bottom, empty cells gone */
|
||||
.catalog table, .catalog tbody, .catalog tr, .catalog td { display: block; }
|
||||
.catalog tr { padding: .6rem 0; border-top: 1px solid var(--board-edge); }
|
||||
.catalog tr:first-child { border-top: none; }
|
||||
.catalog tr:hover td { background: none; }
|
||||
.catalog td { border-top: none; padding: .12rem 0; }
|
||||
.catalog td:empty { display: none; }
|
||||
.catalog .t { max-width: none; font-size: .95rem; }
|
||||
.catalog td.actions {
|
||||
text-align: left; white-space: normal;
|
||||
display: flex; gap: .5rem; flex-wrap: wrap; padding-top: .4rem;
|
||||
}
|
||||
.catalog td.actions button { padding: .4rem .8rem; }
|
||||
.catalog tr.editrow { padding-top: 0; border-top: none; }
|
||||
|
||||
/* queue/library ledgers keep their columns but scroll inside their
|
||||
* own card; long photo lists wrap within their cell */
|
||||
.ledger { overflow-x: auto; }
|
||||
.ledger td { overflow-wrap: anywhere; min-width: 6rem; }
|
||||
}
|
||||
|
||||
@@ -10,9 +10,25 @@ const esc = s => String(s ?? "").replace(/[&<>"']/g,
|
||||
* pass through esc() at the call site. The API serves only local pipeline
|
||||
* data, but photo names and BGG titles still count as untrusted. */
|
||||
function showBanner(html) {
|
||||
const el = document.getElementById("banner");
|
||||
document.getElementById("banner").innerHTML = html;
|
||||
}
|
||||
|
||||
/* Transient confirmation pinned to the viewport: the #banner region sits
|
||||
* at the top of the page, which a user deep in a long list never sees.
|
||||
* The toast may carry buttons — wire them before the timer clears it. */
|
||||
function showToast(html, ms = 8000) {
|
||||
let el = document.getElementById("toast");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "toast";
|
||||
el.setAttribute("role", "status");
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.innerHTML = html;
|
||||
el.hidden = false;
|
||||
clearTimeout(showToast._timer);
|
||||
showToast._timer = setTimeout(() => { el.hidden = true; }, ms);
|
||||
return el;
|
||||
}
|
||||
|
||||
function errorBanner(detail) {
|
||||
@@ -49,6 +65,21 @@ async function apiPost(url, body) {
|
||||
return res;
|
||||
}
|
||||
|
||||
/* Per-page change gate for poll loops: render only when the payload
|
||||
* changed, and record it only after render returns — a throw leaves the
|
||||
* page stale, so the next poll retries instead of freezing on "current". */
|
||||
function changeGate() {
|
||||
let seen = null;
|
||||
const gate = (value, render) => {
|
||||
const key = JSON.stringify(value);
|
||||
if (key === seen) return;
|
||||
render();
|
||||
seen = key;
|
||||
};
|
||||
gate.reset = () => { seen = null; };
|
||||
return gate;
|
||||
}
|
||||
|
||||
/* Poll `fn` every `ms`; after 3 consecutive failures show the lost-contact
|
||||
* banner, and clear it (via `recovered`) on the next success. */
|
||||
function pollLoop(fn, ms, recovered) {
|
||||
@@ -66,6 +97,16 @@ function pollLoop(fn, ms, recovered) {
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/* Mobile: the top bar's hamburger opens the nav; navigation reloads the
|
||||
* page, so each page starts with the menu closed. */
|
||||
{
|
||||
const toggle = document.querySelector(".navtoggle");
|
||||
if (toggle) toggle.addEventListener("click", () => {
|
||||
const open = document.body.classList.toggle("navopen");
|
||||
toggle.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
}
|
||||
|
||||
/* Sidebar badges: the counts that mean "something wants your attention". */
|
||||
async function refreshBadges() {
|
||||
const p = await fetchJSON("/api/pipeline");
|
||||
@@ -74,6 +115,7 @@ async function refreshBadges() {
|
||||
if (el) el.textContent = n > 0 ? String(n) : "";
|
||||
};
|
||||
set("photos", p.reshoot);
|
||||
set("titles", p.shaky_reads);
|
||||
set("review", p.pending_review);
|
||||
set("queue", p.to_add + p.to_update);
|
||||
return p;
|
||||
@@ -81,11 +123,70 @@ async function refreshBadges() {
|
||||
refreshBadges().catch(() => {});
|
||||
setInterval(() => refreshBadges().catch(() => {}), 5000);
|
||||
|
||||
/* One-line match summary for a titles/photo table row. Empty when the
|
||||
* row has no BGG data yet, so the mobile stacker can hide the cell. */
|
||||
/* Canonical outbound URL for a matched thing. RPG items live on RPGGeek
|
||||
* (same database, different site) — a /boardgame/ URL is the wrong home
|
||||
* for them. */
|
||||
function bggUrl(id, type) {
|
||||
return type === "rpgitem"
|
||||
? `https://rpggeek.com/rpgitem/${encodeURIComponent(id)}`
|
||||
: `https://boardgamegeek.com/boardgame/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
function metaLine(c) {
|
||||
const name = !c.bgg_name ? "" : c.bgg_id
|
||||
? `<a href="${bggUrl(c.bgg_id, c.type)}" target="_blank" rel="noopener"
|
||||
title="open on ${c.type === "rpgitem" ? "RPGGeek" : "BGG"}">${esc(c.bgg_name)} ↗</a>`
|
||||
: esc(c.bgg_name);
|
||||
return [
|
||||
name,
|
||||
c.version_name ? esc(c.version_name) : "",
|
||||
c.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/* Reshoot work-order card, shared by the photos gallery and the photo
|
||||
* detail page (which drops the thumbnail and source line — the photo is
|
||||
* right there). Dismiss buttons are wired by wireDismiss below. */
|
||||
function ticketCard(s, {showPhoto = true} = {}) {
|
||||
const img = showPhoto && s.photo_exists
|
||||
? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
|
||||
<img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
|
||||
: "";
|
||||
return `
|
||||
<section class="ticket"
|
||||
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
|
||||
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
|
||||
<span class="stencil">reshoot</span>
|
||||
${img}
|
||||
<div>
|
||||
<div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
|
||||
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
|
||||
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
|
||||
${showPhoto ? `<div class="notes">from ${esc(s.photo)} — take a closer shot and drop it above</div>` : ""}
|
||||
<button class="dismiss">dismiss — found it / not a game</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function wireDismiss(root, done) {
|
||||
root.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
|
||||
const t = b.closest(".ticket");
|
||||
const res = await apiPost("/api/dismiss", {
|
||||
photo: t.dataset.photo, location: t.dataset.location,
|
||||
partial_text: t.dataset.partial, art_notes: t.dataset.art,
|
||||
});
|
||||
if (res) done();
|
||||
}));
|
||||
}
|
||||
|
||||
/* Status chip for a catalog entry — shared by the catalog and photo pages. */
|
||||
function statusChip(c) {
|
||||
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
|
||||
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting resolve</span>`;
|
||||
if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${esc(c.status)}</span>`;
|
||||
if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
|
||||
if (c.status === "local") return `<span class="chip open">local — not on BGG</span>`;
|
||||
if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
|
||||
return `<span class="chip open">${esc(c.status)}</span>`;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -1,125 +0,0 @@
|
||||
<h1>Catalog</h1>
|
||||
<div class="pagebar"><span id="catcount"></span></div>
|
||||
<div class="filterbar">
|
||||
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter catalog titles">
|
||||
</div>
|
||||
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
|
||||
<script>
|
||||
"use strict";
|
||||
let CATALOG = [];
|
||||
let EDITING = null; // lineKey of the row whose editor is open
|
||||
|
||||
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
|
||||
|
||||
function editorRow(c) {
|
||||
const cue = c.cues || {};
|
||||
return `
|
||||
<tr class="editrow"><td colspan="5">
|
||||
<form class="editform" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}">
|
||||
<label>Title <input name="title" value="${esc(c.title_raw)}" required></label>
|
||||
<label>Publisher <input name="publisher" value="${esc(cue.publisher || "")}"></label>
|
||||
<label>Edition <input name="edition" value="${esc(cue.edition || "")}"></label>
|
||||
<label>Year <input name="year" value="${esc(cue.year ?? "")}" inputmode="numeric" size="6"></label>
|
||||
<label>Language <input name="language" value="${esc(cue.language || "")}"></label>
|
||||
<span class="editactions">
|
||||
<button type="submit" class="primary">save</button>
|
||||
<button type="button" class="canceledit">cancel</button>
|
||||
</span>
|
||||
<span class="edithint">saving re-queues this title for resolve with the corrected data</span>
|
||||
</form>
|
||||
</td></tr>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const q = document.getElementById("catsearch").value.trim().toLowerCase();
|
||||
const sorted = [...CATALOG].sort((a, b) =>
|
||||
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" }));
|
||||
const rows = q
|
||||
? sorted.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
|
||||
: sorted;
|
||||
document.getElementById("catcount").innerHTML =
|
||||
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
|
||||
document.getElementById("catbody").innerHTML = rows.length
|
||||
? `<div class="catalog"><table>` + rows.map(c => `
|
||||
<tr>
|
||||
<td class="t">${esc(c.title_raw)}
|
||||
${c.split_copy ? `<span class="chip merged">copy</span>` : ""}</td>
|
||||
<td>${statusChip(c)}</td>
|
||||
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
|
||||
${c.version_name ? " · " + esc(c.version_name) : ""}
|
||||
${c.type === "rpgitem" ? ` <span class="chip open">RPG · local only</span>` : ""}</td>
|
||||
<td class="meta">${c.photos.map(p =>
|
||||
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
|
||||
).join(", ")}</td>
|
||||
<td class="actions">${c.can_split
|
||||
? `<button class="split" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}"
|
||||
data-rowix="${c.row_ix ?? ""}"
|
||||
title="one line, several boxes? make each photo its own copy">
|
||||
split into copies</button>`
|
||||
: ""}
|
||||
<button class="edit" data-key="${esc(lineKey(c))}"
|
||||
title="fix a misread title or add cues you already know">edit</button>
|
||||
</td>
|
||||
</tr>` + (EDITING === lineKey(c) ? editorRow(c) : "")).join("") + `</table></div>`
|
||||
: `<p class="empty">${CATALOG.length
|
||||
? "No titles match that filter."
|
||||
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
|
||||
}
|
||||
|
||||
let LAST = null;
|
||||
async function refresh() {
|
||||
const state = await fetchJSON("/api/state");
|
||||
if (EDITING) return; // never repaint under an open editor
|
||||
const payload = JSON.stringify(state.catalog);
|
||||
if (payload === LAST) return;
|
||||
LAST = payload;
|
||||
CATALOG = state.catalog;
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById("catbody").addEventListener("click", async e => {
|
||||
const cancel = e.target.closest("button.canceledit");
|
||||
if (cancel) { EDITING = null; LAST = null; render(); refresh().catch(() => {}); return; }
|
||||
const edit = e.target.closest("button.edit");
|
||||
if (edit) {
|
||||
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const b = e.target.closest("button.split");
|
||||
if (!b) return;
|
||||
const n = b.dataset.photos.split(";").length;
|
||||
if (!confirm(`Split "${b.dataset.title}" into ${n} separate copies (one per photo)? ` +
|
||||
`Each picks its own edition afterward.`)) return;
|
||||
const res = await apiPost("/api/split", {
|
||||
title_raw: b.dataset.title,
|
||||
source_photos: b.dataset.photos,
|
||||
row_ix: b.dataset.rowix === "" ? null : Number(b.dataset.rowix),
|
||||
});
|
||||
if (res) refresh().catch(() => {});
|
||||
});
|
||||
|
||||
document.getElementById("catbody").addEventListener("submit", async e => {
|
||||
const f = e.target.closest("form.editform");
|
||||
if (!f) return;
|
||||
e.preventDefault();
|
||||
const orig = CATALOG.find(c => lineKey(c) === EDITING) || {};
|
||||
const cue = orig.cues || {};
|
||||
const v = name => f.elements[name].value;
|
||||
const body = { title_raw: f.dataset.title, source_photos: f.dataset.photos };
|
||||
if (v("title").trim() !== f.dataset.title) body.title_new = v("title").trim();
|
||||
if (v("publisher") !== (cue.publisher || "")) body.publisher = v("publisher");
|
||||
if (v("edition") !== (cue.edition || "")) body.edition = v("edition");
|
||||
if (v("year") !== String(cue.year ?? "")) body.year = v("year");
|
||||
if (v("language") !== (cue.language || "")) body.language = v("language");
|
||||
if (Object.keys(body).length <= 2) { EDITING = null; render(); return; }
|
||||
const res = await apiPost("/api/edit-title", body);
|
||||
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
|
||||
});
|
||||
|
||||
document.getElementById("catsearch").addEventListener("input", render);
|
||||
refresh().catch(err => errorBanner(err.message || err));
|
||||
pollLoop(refresh, 5000, () => showBanner(""));
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<h1>Help</h1>
|
||||
<div class="pagebar">
|
||||
<a href="#flow">The flow</a>
|
||||
<a href="#pages">Pages</a>
|
||||
<a href="#curation">Fixing the titles</a>
|
||||
<a href="#statuses">Statuses</a>
|
||||
<a href="#keys">Keyboard</a>
|
||||
<a href="#files">Your data</a>
|
||||
</div>
|
||||
|
||||
<h2 id="flow">The flow: shelves → collection</h2>
|
||||
<div class="card prose">
|
||||
<p>Six pipeline stages and two checkpoints that are yours. Stages run from the <a href="/">Pipeline</a> page (or the CLI — both share all state and either can pick up where the other left off):</p>
|
||||
<ol>
|
||||
<li><b>extract</b> — every photo goes to the vision model once (Claude by default; a local model works too — see the README); what it reads (titles plus edition cues: publisher, edition wording, year, language) lands on the Titles page. Boxes it can see but can't read become <b>reshoot tickets</b> on the Photos page.</li>
|
||||
<li><b>proofread</b> <i>(you, on <a href="/titles">Titles</a>)</i> — fix misread titles, add cues you know, split multi-box lines, remove non-games, <a href="#curation">details below</a>. Worth doing <i>before</i> resolve: a fix made now is one BGG search done right; a fix made later sends the title back through resolve again.</li>
|
||||
<li><b>resolve</b> — titles are matched to BoardGameGeek games and editions. Anything uncertain is flagged, never guessed. Without a BGG API token, titles wait as <i>awaiting BGG</i> and are picked up automatically once the token exists.</li>
|
||||
<li><b>review</b> <i>(you, on <a href="/review">Review</a>)</i> — decide the flagged ones: which game, which edition, whether two reads are one box. Every decision saves immediately.</li>
|
||||
<li><b>diff</b> — your existing BGG collection is fetched and compared, per copy. What's genuinely new lands in the <a href="/queue">Queue</a>.</li>
|
||||
<li><b>upload</b> — a real browser logs into BGG and adds each queued game, slowly and politely. Always dry-run first; the buttons enforce that order.</li>
|
||||
<li><b>enrich</b> — full metadata (players, weight, rank, artwork) fills the <a href="/library">Library</a>.</li>
|
||||
</ol>
|
||||
<p>It's a loop, not a line: new photos, edits, and splits feed both checkpoints again, and the sidebar badges show when a page has work for you. Every stage is resumable — stop anything mid-run and nothing is lost; re-runs skip work already done.</p>
|
||||
</div>
|
||||
|
||||
<h2 id="pages">What each page is for</h2>
|
||||
<div class="card prose">
|
||||
<p><b><a href="/">Pipeline</a></b> — run stages one at a time and watch their live output. Shows what's blocking (missing keys, stub data) and the counts at every step.</p>
|
||||
<p><b><a href="/photos">Photos</a></b> — drag photos in, drop them in the <code>photos/</code> folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique <code>shelf-…</code> names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same <i>file name</i> deliberately replaces it, and the next extract run re-reads it.</p>
|
||||
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
|
||||
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
|
||||
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
|
||||
<p><b><a href="/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>
|
||||
|
||||
<h2 id="curation">Fixing the titles: edit, split, remove</h2>
|
||||
<div class="card prose">
|
||||
<p>Vision reads aren't perfect, and you know things the photos don't show. Every line on the Titles page has curation actions, and every one of them is <b>durable</b>: the decision is saved in a small committed file and replayed on every rebuild, so re-running extract or resolve can never undo it.</p>
|
||||
<p><b>edit</b> — fix a misread title or add cues you already know (publisher, edition, year, language). A corrected misspelling automatically merges with a correctly-read sighting of the same game from another photo. If the line already had a BGG match, saving re-queues it so resolve searches again with the corrected data.</p>
|
||||
<p><b>split into copies</b> — one line, several physical boxes? Splitting makes each photo its own copy, and each copy picks its own edition afterward. Appears on any line whose title was seen in more than one photo. Splitting one game never affects a same-named different edition.</p>
|
||||
<p><b>pick edition</b> — a matched game with no legible edition cues stays version-less by design (never guess) — but you know which printing your box is. This fetches the game's complete version list into a Review ballot; pick yours there.</p>
|
||||
<p><b>wrong match</b> (inside the edit panel) — an auto-match landed on the wrong game (same-name impostors happen). This clears the match, re-searches immediately, and returns the title to Review as a fresh ballot of candidates (including same-named sibling editions); manual-id entry is there too for games BGG's search can't find. Note BGG sometimes splits one game's lineage across entries — Wiz-War's early editions and its FFG remake are separate games — so a copy whose edition isn't on the ballot may belong to the sibling entry.</p>
|
||||
<p><b>add a game</b> (top of the Titles page) — a game no photo shows: an expansion stored inside a base box, a game away from the shelves. It joins the list like any read (BGG wants base game and expansion as separate collection entries, so boxes that hold both need this for the hidden half), and if a later photo shows it, the sighting merges instead of duplicating.</p>
|
||||
<p><b>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>
|
||||
|
||||
<h2 id="statuses">What the status chips mean</h2>
|
||||
<div class="card prose">
|
||||
<p><span class="chip wait">awaiting resolve</span> extracted (or re-queued by an edit) but not yet matched — run <b>resolve</b> from the Pipeline page.</p>
|
||||
<p><span class="chip ok">auto</span> matched confidently, no review needed. <span class="chip ok">approved</span> you picked the match yourself.</p>
|
||||
<p><span class="chip open">ambiguous</span> several plausible games — needs your pick on Review. <span class="chip open">unmatched</span> nothing plausible found — enter a BGG id or re-search on Review.</p>
|
||||
<p><span class="chip merged">merged</span> two reads judged to be the same physical box; the merge is veto-able on Review. <span class="chip merged">copy</span> one copy of a title you split.</p>
|
||||
<p><span class="chip open">local — not on BGG</span> you ruled it's a real game BGG doesn't have: it joins the Library from its own photo reads (fill in its facts and add a cover photo on its Library page), and never uploads. Changed your mind — or suspect it's on RPGGeek after all? <b>look it up</b> on its Titles row searches again, RPGGeek included.</p>
|
||||
<p><span class="chip no">rejected</span> you ruled it's a bad read or not worth matching; it stays listed but goes no further.</p>
|
||||
<p><span class="chip shaky">shaky read</span> the vision model wasn't sure of this transcription and nothing has verified it yet — these are what the Titles badge counts. Clear one by pressing its <b>✓ looks right</b> (the read is fine as-is) or by editing it (you fixed it). A BGG match also clears it: a wrong read wouldn't have matched.</p>
|
||||
</div>
|
||||
|
||||
<h2 id="keys">Keyboard shortcuts</h2>
|
||||
<div class="card prose">
|
||||
<p><b>Review:</b> <kbd>j</kbd>/<kbd>k</kbd> move between cards · <kbd>1</kbd>–<kbd>9</kbd> pick a candidate · <kbd>r</kbd> reject · <kbd>l</kbd> keep local (not on BGG) · <kbd>m</kbd> manual BGG id · <kbd>u</kbd> edition unknown · <kbd>v</kbd> veto a merge.</p>
|
||||
<p><b>Photo pages:</b> <kbd>←</kbd>/<kbd>→</kbd> move between photos.</p>
|
||||
</div>
|
||||
|
||||
<h2 id="files">Your data, on disk</h2>
|
||||
<div class="card prose">
|
||||
<p>Everything lives in flat files under <code>data/</code> — inspectable, hand-editable, and git-friendly. The pipeline artifacts: <code>titles.json</code> (what was read), <code>matches.csv</code> (what it matched), <code>to_add.csv</code>/<code>to_update.csv</code> (what upload will do), <code>upload_log.csv</code> (what it did), <code>games.json</code> (the library). Your curation: <code>title_edits.json</code>, <code>title_splits.json</code>, <code>title_removals.json</code>, <code>unidentified_dismissed.json</code>.</p>
|
||||
<p>Credentials never live in files — only environment variables, set up by <code>bggpipe init</code>. The app serves localhost only, unless started with <code>--lan</code> — that opens it to your network behind an access key: scan the QR code the server prints (or open the printed link) once per device, and a year-long cookie keeps it paired. Delete <code>data/.lan_key</code> to revoke every device. No login beyond the key; trusted networks only.</p>
|
||||
<p>More depth: the README covers setup and photo technique; <code>docs/bgg-upload-flow.md</code> documents the upload automation.</p>
|
||||
</div>
|
||||
|
||||
<h2 id="mascot">The piper</h2>
|
||||
<div class="card prose artcard">
|
||||
<img src="/static/logo-full.jpg"
|
||||
alt="the bggpipe piper — a bagpiper whose bag is a board game box">
|
||||
<p>Mascot art by Juniper, used with pride.</p>
|
||||
<p class="legal">BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC.
|
||||
bggpipe is an independent project, not affiliated with or endorsed by
|
||||
BoardGameGeek.</p>
|
||||
</div>
|
||||
@@ -1,10 +1,30 @@
|
||||
<h1>Library</h1>
|
||||
<div class="pagebar"><span id="libcount"></span></div>
|
||||
<div class="filterbar" role="group" aria-label="filter by kind">
|
||||
<input type="search" id="libsearch" placeholder="search your games…" aria-label="search library">
|
||||
<input type="search" id="libsearch" placeholder="search titles, designers, mechanics…"
|
||||
aria-label="search library">
|
||||
<button type="button" data-kind="" aria-pressed="true">All</button>
|
||||
<button type="button" data-kind="boardgame" aria-pressed="false">Board games</button>
|
||||
<button type="button" data-kind="rpgitem" aria-pressed="false">RPGs</button>
|
||||
<button type="button" data-kind="localgame" aria-pressed="false">Off BGG</button>
|
||||
</div>
|
||||
<div class="filterbar">
|
||||
<label>plays with
|
||||
<input type="number" id="libplayers" min="1" max="20" placeholder="any"
|
||||
style="width:5em" aria-label="filter by player count"></label>
|
||||
<label>where
|
||||
<select id="libwhere" aria-label="filter by location">
|
||||
<option value="">anywhere</option>
|
||||
<option value="unshelved">unshelved</option>
|
||||
</select></label>
|
||||
<label>sort
|
||||
<select id="libsort" aria-label="sort library">
|
||||
<option value="name">name</option>
|
||||
<option value="year">year (newest)</option>
|
||||
<option value="rank">BGG rank</option>
|
||||
<option value="weight">weight (lightest)</option>
|
||||
<option value="playtime">playing time</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div id="libbody"></div>
|
||||
<script>
|
||||
@@ -14,39 +34,83 @@ let KIND = ""; // "" = all; "boardgame" also covers expansions
|
||||
|
||||
function gameCard(g) {
|
||||
const art = g.thumbnail || g.image;
|
||||
const players = g.min_players
|
||||
? (g.min_players === g.max_players ? `${g.min_players}` : `${g.min_players}–${g.max_players}`) + " players"
|
||||
const lo = g.min_players ?? g.max_players, hi = g.max_players ?? g.min_players;
|
||||
const players = lo
|
||||
? (lo === hi ? `${lo}` : `${lo}–${hi}`) + " players"
|
||||
: "";
|
||||
const time = g.playtime ? `${g.playtime} min` : "";
|
||||
const weight = g.weight ? `weight ${g.weight.toFixed(1)}` : "";
|
||||
const rank = g.rank ? `BGG rank ${g.rank}` : "";
|
||||
return `
|
||||
<article class="game">
|
||||
<a class="game" href="/library/game/${encodeURIComponent(g.key)}">
|
||||
${art
|
||||
? `<img src="${esc(art)}" alt="" loading="lazy">`
|
||||
: `<div class="noart" aria-hidden="true">${esc((g.name || "?").charAt(0).toUpperCase())}</div>`}
|
||||
<div class="info">
|
||||
<div class="gname">${esc(g.name)}${g.year ? ` <span class="meta">(${esc(g.year)})</span>` : ""}
|
||||
${g.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : ""}</div>
|
||||
${g.type === "rpgitem" ? `<span class="chip open">RPG · local only</span>` : ""}
|
||||
${g.type === "localgame" ? `<span class="chip open">not on BGG · local</span>` : ""}</div>
|
||||
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
|
||||
${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>
|
||||
</article>`;
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function matchesKind(g) {
|
||||
if (!KIND) return true;
|
||||
if (KIND === "boardgame") return g.type !== "rpgitem";
|
||||
return g.type === "rpgitem";
|
||||
if (KIND === "boardgame") return !["rpgitem", "localgame"].includes(g.type);
|
||||
return g.type === KIND;
|
||||
}
|
||||
|
||||
// null sorts last whichever way the column runs: an unranked game is not
|
||||
// "rank 0", and a game with no weight is not the lightest
|
||||
function by(field, dir = 1) {
|
||||
return (a, b) => {
|
||||
const x = a[field], y = b[field];
|
||||
if (x == null && y == null) return 0;
|
||||
if (x == null) return 1;
|
||||
if (y == null) return -1;
|
||||
return (x - y) * dir;
|
||||
};
|
||||
}
|
||||
|
||||
const SORTS = {
|
||||
name: (a, b) => (a.name || "").localeCompare(b.name || "", undefined, {sensitivity: "base"}),
|
||||
year: by("year", -1),
|
||||
rank: by("rank"),
|
||||
weight: by("weight"),
|
||||
playtime: by("playtime"),
|
||||
};
|
||||
|
||||
function render() {
|
||||
const q = document.getElementById("libsearch").value.trim().toLowerCase();
|
||||
const seats = Number(document.getElementById("libplayers").value) || 0;
|
||||
let rows = GAMES.filter(matchesKind);
|
||||
if (q) rows = rows.filter(g =>
|
||||
(g.name + " " + (g.designers || []).join(" ")).toLowerCase().includes(q));
|
||||
if (q) rows = rows.filter(g => [
|
||||
g.name,
|
||||
(g.designers || []).join(" "),
|
||||
(g.mechanics || []).join(" "),
|
||||
(g.categories || []).join(" "),
|
||||
g.version && g.version.name,
|
||||
g.location && g.location.text,
|
||||
g.location && g.location.zone,
|
||||
].filter(Boolean).join(" ").toLowerCase().includes(q));
|
||||
const where = document.getElementById("libwhere").value;
|
||||
if (where === "unshelved")
|
||||
// same definition as the Shelves page: no opening and not riding a
|
||||
// container — a note-only spot ("lent out") still counts as unshelved
|
||||
rows = rows.filter(g => !(g.location && (g.location.unit || g.location.via)));
|
||||
else if (where)
|
||||
rows = rows.filter(g => g.location && g.location.unit === where);
|
||||
if (seats) rows = rows.filter(g =>
|
||||
(g.min_players || g.max_players || 0) <= seats
|
||||
&& seats <= (g.max_players || g.min_players || 0));
|
||||
rows = [...rows].sort(SORTS[document.getElementById("libsort").value] || SORTS.name);
|
||||
document.getElementById("libcount").innerHTML =
|
||||
`<b>${rows.length}</b> of <b>${GAMES.length}</b> game(s)`;
|
||||
`<b>${rows.length}</b> of <b>${GAMES.length}</b> game(s)`
|
||||
+ (seats ? ` playable with <b>${seats}</b>` : "");
|
||||
document.getElementById("libbody").innerHTML = rows.length
|
||||
? `<div class="shelfgrid">${rows.map(gameCard).join("")}</div>`
|
||||
: `<p class="empty">${GAMES.length
|
||||
@@ -56,17 +120,27 @@ function render() {
|
||||
Run the pipeline through <code>enrich</code> to fill these shelves.`}</p>`;
|
||||
}
|
||||
|
||||
let LAST = null;
|
||||
const GATE = changeGate();
|
||||
async function refresh() {
|
||||
const games = await fetchJSON("/api/library");
|
||||
const payload = JSON.stringify(games);
|
||||
if (payload === LAST) return;
|
||||
LAST = payload;
|
||||
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 =>
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<h1 id="gname">Loading…</h1>
|
||||
<div class="pagebar">
|
||||
<a href="/library">← library</a>
|
||||
<span id="gcrumb"></span>
|
||||
</div>
|
||||
<div id="gbody"><p class="empty">Loading…</p></div>
|
||||
<script>
|
||||
"use strict";
|
||||
const KEY = decodeURIComponent(location.pathname.replace(/^\/library\/game\//, ""));
|
||||
|
||||
// entries enriched before descriptions were decoded at parse still hold
|
||||
// ’-style entities: decode once before escaping for display
|
||||
function decodeEntities(s) {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.innerHTML = s;
|
||||
return ta.value;
|
||||
}
|
||||
|
||||
function chips(label, values) {
|
||||
if (!values || !values.length) return "";
|
||||
return `<div class="factrow"><b>${esc(label)}</b>
|
||||
<span class="chiplist">${values.map(v =>
|
||||
`<span class="cue">${esc(v)}</span>`).join("")}</span></div>`;
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
return value === null || value === undefined || value === ""
|
||||
? ""
|
||||
: `<div class="factrow"><b>${esc(label)}</b> ${esc(value)}</div>`;
|
||||
}
|
||||
|
||||
function players(g) {
|
||||
// mirror of _players() in export.py — keep the two in sync
|
||||
const lo = g.min_players ?? g.max_players, hi = g.max_players ?? g.min_players;
|
||||
if (!lo) return "";
|
||||
const range = lo === hi ? `${lo}` : `${lo}–${hi}`;
|
||||
const noun = lo === 1 && hi === 1 ? "player" : "players";
|
||||
const best = (g.best_player_counts || []).length
|
||||
? ` (best at ${esc(g.best_player_counts.join(", "))})`
|
||||
: "";
|
||||
return `${range} ${noun}${best}`;
|
||||
}
|
||||
|
||||
function playtime(g) {
|
||||
if (!g.playtime && !g.min_playtime) return "";
|
||||
if (g.min_playtime && g.max_playtime && g.min_playtime !== g.max_playtime)
|
||||
return `${g.min_playtime}–${g.max_playtime} min`;
|
||||
return `${g.playtime || g.min_playtime} min`;
|
||||
}
|
||||
|
||||
function localForm(g) {
|
||||
const v = (x) => (x === null || x === undefined ? "" : x);
|
||||
return `
|
||||
<h2>Your notes</h2>
|
||||
<div class="card prose">
|
||||
<p class="meta">BGG has no entry for this game, so what you type here is
|
||||
all it will ever know. Saved to <code>data/local_games.json</code>.</p>
|
||||
<form class="editform" id="localform">
|
||||
<label>Title <input name="name" value="${esc(v(g.name))}" required></label>
|
||||
<label>Year <input name="year" value="${esc(v(g.year))}" inputmode="numeric" size="6"></label>
|
||||
<label>Players from <input name="min_players" value="${esc(v(g.min_players))}" size="3"></label>
|
||||
<label>to <input name="max_players" value="${esc(v(g.max_players))}" size="3"></label>
|
||||
<label>Minutes <input name="playtime" value="${esc(v(g.playtime))}" size="5"></label>
|
||||
<label>Publishers <input name="publishers" value="${esc((g.publishers || []).join(", "))}"></label>
|
||||
<label>Designers <input name="designers" value="${esc((g.designers || []).join(", "))}"></label>
|
||||
<label class="wide">Notes
|
||||
<textarea name="description" rows="4">${esc(v(g.description))}</textarea></label>
|
||||
<span class="editactions"><button type="submit" class="primary">save</button></span>
|
||||
</form>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wireLocation(g) {
|
||||
document.getElementById("locsave")?.addEventListener("click", async () => {
|
||||
const res = await apiPost("/api/locate", {
|
||||
key: KEY,
|
||||
opening_id: document.getElementById("locpick").value,
|
||||
note: document.getElementById("locnote").value,
|
||||
});
|
||||
if (res) { showToast("shelved"); refresh(); }
|
||||
});
|
||||
document.getElementById("awaysave")?.addEventListener("click", async () => {
|
||||
const where = document.getElementById("awayinput").value.trim();
|
||||
const res = await apiPost("/api/away", {key: KEY, where});
|
||||
if (res) { showToast(where ? "marked out" : "welcome home"); refresh(); }
|
||||
});
|
||||
}
|
||||
|
||||
function wireStored(g) {
|
||||
const btn = document.getElementById("editstored");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.closest("p").hidden = true; // the sentence yields to the form
|
||||
const form = document.getElementById("storedform");
|
||||
form.hidden = false;
|
||||
const pick = document.getElementById("storedpick");
|
||||
pick.focus();
|
||||
const all = await fetchJSON("/api/library");
|
||||
pick.innerHTML = `<option value="">its own box</option>` + all
|
||||
.filter(o => o.bgg_id && o.bgg_id !== g.bgg_id)
|
||||
.sort((a, b) => (a.name || "").localeCompare(b.name || ""))
|
||||
.map(o => `<option value="${esc(String(o.bgg_id))}"
|
||||
${String(o.bgg_id) === String(g.stored_in || "") ? "selected" : ""}>
|
||||
${esc(o.name)}</option>`).join("");
|
||||
});
|
||||
document.getElementById("storedsave")?.addEventListener("click", async () => {
|
||||
const res = await apiPost("/api/stored-in", {
|
||||
key: KEY, container: document.getElementById("storedpick").value,
|
||||
});
|
||||
if (res) {
|
||||
showToast("saved — the shelf math knows");
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireLocal(g) {
|
||||
const form = document.getElementById("localform");
|
||||
if (form) form.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const f = Object.fromEntries(new FormData(form).entries());
|
||||
const res = await apiPost(`/api/local-game/${encodeURIComponent(KEY)}`, f);
|
||||
if (res) {
|
||||
showToast("saved — run <b>enrich</b> to fold this into the library");
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
const btn = document.getElementById("artbtn");
|
||||
const file = document.getElementById("artfile");
|
||||
if (btn) btn.addEventListener("click", () => file.click());
|
||||
if (file) file.addEventListener("change", async () => {
|
||||
if (!file.files.length) return;
|
||||
const body = new FormData();
|
||||
body.append("file", file.files[0]);
|
||||
btn.disabled = true;
|
||||
btn.textContent = "uploading…";
|
||||
let res = null;
|
||||
try {
|
||||
res = await fetch(`/api/local-art/${encodeURIComponent(KEY)}`, {method: "POST", body});
|
||||
} catch (err) {
|
||||
alert("Upload failed: " + err);
|
||||
}
|
||||
btn.disabled = false;
|
||||
if (res && res.ok) {
|
||||
showToast("photo saved — run <b>enrich</b> to fold it into the library");
|
||||
refresh();
|
||||
} else if (res) {
|
||||
const detail = await res.json().then(d => d.detail).catch(() => null);
|
||||
alert("Upload failed: " + (detail ?? res.statusText));
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render(g) {
|
||||
document.getElementById("gname").textContent = g.name || "(unnamed)";
|
||||
document.title = `${g.name} · bggpipe`;
|
||||
const local = g.type === "localgame";
|
||||
const rpg = g.type === "rpgitem";
|
||||
document.getElementById("gcrumb").innerHTML = [
|
||||
g.year ? `<b>${esc(g.year)}</b>` : "",
|
||||
local ? `<span class="chip open">not on BGG · local</span>` : "",
|
||||
rpg ? `<span class="chip open">RPG · local only</span>` : "",
|
||||
g.bgg_id
|
||||
? `<a href="${bggUrl(g.bgg_id, g.type)}" target="_blank" rel="noopener">
|
||||
view on ${rpg ? "RPGGeek" : "BGG"} ↗</a>`
|
||||
: "",
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
const art = g.image
|
||||
? `<img class="gameart" src="${esc(g.image)}" alt="box art for ${esc(g.name)}">`
|
||||
: `<div class="gameart noart">${esc((g.name || "?")[0])}</div>`;
|
||||
// an off-BGG game has no publisher art and no API to fetch any: the
|
||||
// owner's own photo is the only cover it will ever have
|
||||
const artAdd = local
|
||||
? `<button id="artbtn" class="artbtn">${g.image ? "replace" : "add"} a photo</button>
|
||||
<input id="artfile" type="file" accept=".jpg,.jpeg,.png,.heic" hidden
|
||||
aria-label="cover photo for ${esc(g.name)}">`
|
||||
: "";
|
||||
|
||||
const dims = g.dims && g.dims.width_in
|
||||
? `${g.dims.width_in} × ${g.dims.length_in} × ${g.dims.depth_in} in`
|
||||
+ (g.dims.weight_lb ? ` · ${g.dims.weight_lb} lb` : "")
|
||||
+ (g.dims.source === "unanimous" ? " (all printings agree)" : "")
|
||||
: "";
|
||||
const facts = [
|
||||
fact("players", players(g)),
|
||||
fact("playing time", playtime(g)),
|
||||
fact("box", dims),
|
||||
fact("ages", g.min_age ? `${g.min_age}+` : ""),
|
||||
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
|
||||
fact("BGG rank", g.rank || ""),
|
||||
fact("BGG rating", g.rating ? g.rating.toFixed(2) : ""),
|
||||
fact("owned by", g.users_owned ? `${g.users_owned.toLocaleString()} people` : ""),
|
||||
chips("designers", g.designers),
|
||||
chips("artists", (g.artists || []).slice(0, 8)),
|
||||
chips("publishers", (g.publishers || []).slice(0, 6)),
|
||||
chips("categories", g.categories),
|
||||
chips("mechanics", g.mechanics),
|
||||
].filter(Boolean).join("");
|
||||
|
||||
const version = g.version
|
||||
? `<h2>Your edition</h2>
|
||||
<div class="card prose">
|
||||
<div class="factrow"><b>${esc(g.version.name || "—")}</b>
|
||||
${g.version.year ? ` · ${esc(g.version.year)}` : ""}</div>
|
||||
${chips("publishers", g.version.publishers)}
|
||||
${chips("languages", g.version.languages)}
|
||||
</div>`
|
||||
: `<h2>Your edition</h2>
|
||||
<div class="card prose"><p class="meta">No specific edition recorded —
|
||||
set one from the <a href="/titles">Titles</a> page if you know it.</p></div>`;
|
||||
|
||||
const loc = g.location || {};
|
||||
const fitWord = f => f === true ? "fits" : f === false ? "doesn't fit"
|
||||
: "can't verify";
|
||||
const menu = (g.opening_menu || []).map(u => `
|
||||
<optgroup label="${esc(u.unit)}">${u.openings.map(o => `
|
||||
<option value="${esc(o.id)}" ${o.id === loc.opening_id ? "selected" : ""}>
|
||||
${esc(o.label)}${o.zone ? ` — ${esc(o.zone)}` : ""}
|
||||
${o.virtual ? "" : ` (${fitWord(o.fit)})`}
|
||||
</option>`).join("")}</optgroup>`).join("");
|
||||
const awayRow = `<p id="awayform">
|
||||
Right now:
|
||||
${loc.away ? `<b>${esc(loc.away)}</b>` : `<span class="meta">home${loc.away_own === undefined ? "" : ""}</span>`}
|
||||
<input id="awayinput" placeholder="travel case · loaned to Ben — blank = it's back"
|
||||
value="${esc(loc.away_own || "")}" aria-label="where it is right now">
|
||||
<button id="awaysave">save</button>
|
||||
<span class="edithint">home stays reserved — this is where the box IS,
|
||||
not where it lives</span>
|
||||
</p>`;
|
||||
const locBlock = g.stored_in_game
|
||||
? `<p>It rides with its box: see
|
||||
<a href="/library/game/${encodeURIComponent(g.stored_in_game.key)}">
|
||||
${esc(g.stored_in_game.name)}</a>${loc.text && !loc.text.startsWith("inside")
|
||||
? ` — currently ${esc(loc.text)}` : ""}${loc.away
|
||||
? ` — right now: <b>${esc(loc.away)}</b>` : ""}.</p>${awayRow}`
|
||||
: `<p>${loc.text
|
||||
? `Lives: <b>${esc(loc.text)}</b>${loc.note && loc.text !== loc.note
|
||||
? ` <span class="meta">(${esc(loc.note)})</span>` : ""}`
|
||||
: `<span class="meta">Unshelved.</span>`}</p>
|
||||
${awayRow}
|
||||
<p id="locform">
|
||||
<select id="locpick" aria-label="shelf opening">
|
||||
<option value="">nowhere / a note only</option>${menu}
|
||||
</select>
|
||||
<input id="locnote" placeholder="note — lent to Sarah, June"
|
||||
value="${esc(loc.note || "")}">
|
||||
<button class="primary" id="locsave">save</button>
|
||||
</p>`;
|
||||
const boxed = `<h2>Where it lives</h2>
|
||||
<div class="card prose">
|
||||
${locBlock}
|
||||
${g.stored_in_game
|
||||
? `<p class="meta">This game has no box of its own — it lives inside
|
||||
${esc(g.stored_in_game.name)}'s.
|
||||
<button id="editstored">change</button></p>`
|
||||
: g.stored_in
|
||||
? `<p class="meta">Stored inside BGG game ${esc(g.stored_in)}
|
||||
(not in the library yet — run <b>enrich</b>).
|
||||
<button id="editstored">change</button></p>`
|
||||
: `<p class="meta">In its own box.
|
||||
<button id="editstored">it lives inside another box…</button></p>`}
|
||||
<p id="storedform" hidden>
|
||||
<select id="storedpick"><option value="">its own box</option></select>
|
||||
<button class="primary" id="storedsave">save</button>
|
||||
</p>
|
||||
</div>`;
|
||||
const contains = (g.contains || []).length
|
||||
? `<h2>In this box</h2>
|
||||
<div class="card prose"><p>${g.contains.map(c =>
|
||||
`<a href="/library/game/${encodeURIComponent(c.key)}">${esc(c.name)}</a>`
|
||||
).join(" · ")}</p></div>`
|
||||
: "";
|
||||
const photos = (g.photos || []).length
|
||||
? `<h2>Seen on your shelves</h2>
|
||||
<div class="card prose"><p>${g.photos.map(p =>
|
||||
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
|
||||
).join(" · ")}</p></div>`
|
||||
: "";
|
||||
|
||||
document.getElementById("gbody").innerHTML = `
|
||||
<div class="gamedetail">
|
||||
<div class="gameartcol">${art}${artAdd}</div>
|
||||
<div class="gamefacts">${facts}</div>
|
||||
</div>
|
||||
${local ? localForm(g) : ""}
|
||||
${version}
|
||||
${boxed}
|
||||
${contains}
|
||||
${photos}
|
||||
${g.description && !local ? `<h2>About</h2>
|
||||
<div class="card prose"><p class="gdesc">${esc(decodeEntities(g.description))}</p></div>` : ""}`;
|
||||
wireLocal(g);
|
||||
wireStored(g);
|
||||
wireLocation(g);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
render(await fetchJSON(`/api/library/${encodeURIComponent(KEY)}`));
|
||||
} catch (err) {
|
||||
document.getElementById("gbody").innerHTML =
|
||||
`<p class="empty">This game isn't in the library
|
||||
(${esc(err.message || err)}) — it may need <b>enrich</b> to run.
|
||||
Back to the <a href="/library">library</a>.</p>`;
|
||||
}
|
||||
}
|
||||
refresh();
|
||||
</script>
|
||||
@@ -13,22 +13,7 @@
|
||||
const NAME = decodeURIComponent(location.pathname.split("/").pop());
|
||||
document.getElementById("photoname").textContent = NAME;
|
||||
document.getElementById("rawlink").href = `/photos/${encodeURIComponent(NAME)}`;
|
||||
document.title = `bggpipe — ${NAME}`;
|
||||
|
||||
function ticket(s) {
|
||||
return `
|
||||
<section class="ticket"
|
||||
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
|
||||
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
|
||||
<span class="stencil">reshoot</span>
|
||||
<div>
|
||||
<div class="loc">${esc(s.location) || "somewhere in this photo"}</div>
|
||||
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
|
||||
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
|
||||
<button class="dismiss">dismiss — found it / not a game</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
document.title = `${NAME} · bggpipe`;
|
||||
|
||||
function render(state, photos) {
|
||||
const info = photos.find(p => p.name === NAME);
|
||||
@@ -64,8 +49,7 @@ function render(state, photos) {
|
||||
<tr>
|
||||
<td class="t">${esc(c.title_raw)}</td>
|
||||
<td>${statusChip(c)}</td>
|
||||
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
|
||||
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
|
||||
<td class="meta">${metaLine(c)}</td>
|
||||
</tr>`).join("") + `</table></div>`
|
||||
: `<p class="empty">${info.extracted
|
||||
? "No titles were read from this photo."
|
||||
@@ -73,30 +57,19 @@ function render(state, photos) {
|
||||
|
||||
if (tickets.length) {
|
||||
html += `<h2>Reshoot tickets <span class="count">— boxes seen here but not identified</span></h2>`;
|
||||
html += tickets.map(ticket).join("");
|
||||
html += tickets.map(s => ticketCard(s, {showPhoto: false})).join("");
|
||||
}
|
||||
body.innerHTML = html;
|
||||
|
||||
body.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
|
||||
const t = b.closest(".ticket");
|
||||
const res = await apiPost("/api/dismiss", {
|
||||
photo: t.dataset.photo, location: t.dataset.location,
|
||||
partial_text: t.dataset.partial, art_notes: t.dataset.art,
|
||||
});
|
||||
if (res) refresh().catch(() => {});
|
||||
}));
|
||||
wireDismiss(body, () => refresh().catch(() => {}));
|
||||
}
|
||||
|
||||
let LAST = null;
|
||||
const GATE = changeGate();
|
||||
async function refresh() {
|
||||
const [state, photos] = await Promise.all([
|
||||
fetchJSON("/api/state"),
|
||||
fetchJSON("/api/photos-list"),
|
||||
]);
|
||||
const payload = JSON.stringify([state.catalog, state.unidentified, photos]);
|
||||
if (payload === LAST) return;
|
||||
LAST = payload;
|
||||
render(state, photos);
|
||||
GATE([state.catalog, state.unidentified, photos], () => render(state, photos));
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
|
||||
@@ -11,40 +11,12 @@
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
function ticket(s) {
|
||||
const img = s.photo_exists
|
||||
? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
|
||||
<img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
|
||||
: "";
|
||||
return `
|
||||
<section class="ticket"
|
||||
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
|
||||
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
|
||||
<span class="stencil">reshoot</span>
|
||||
${img}
|
||||
<div>
|
||||
<div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
|
||||
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
|
||||
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
|
||||
<div class="notes">from ${esc(s.photo)} — take a closer shot and drop it above</div>
|
||||
<button class="dismiss">dismiss — found it / not a game</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function render(state, photos) {
|
||||
const tickets = document.getElementById("tickets");
|
||||
tickets.innerHTML = state.unidentified.length
|
||||
? state.unidentified.map(ticket).join("")
|
||||
? state.unidentified.map(s => ticketCard(s)).join("")
|
||||
: `<p class="empty">No open reshoot tickets.</p>`;
|
||||
tickets.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
|
||||
const t = b.closest(".ticket");
|
||||
const res = await apiPost("/api/dismiss", {
|
||||
photo: t.dataset.photo, location: t.dataset.location,
|
||||
partial_text: t.dataset.partial, art_notes: t.dataset.art,
|
||||
});
|
||||
if (res) refresh();
|
||||
}));
|
||||
wireDismiss(tickets, () => refresh().catch(() => {}));
|
||||
|
||||
document.getElementById("gallerycount").textContent = `— ${photos.length} on file`;
|
||||
document.getElementById("shots").innerHTML = photos.map(p => `
|
||||
@@ -59,17 +31,13 @@ function render(state, photos) {
|
||||
</figure>`).join("");
|
||||
}
|
||||
|
||||
let LAST = null;
|
||||
const GATE = changeGate(); // rebuilding innerHTML re-renders every <img>
|
||||
async function refresh() {
|
||||
const [state, photos] = await Promise.all([
|
||||
fetchJSON("/api/state"),
|
||||
fetchJSON("/api/photos-list"),
|
||||
]);
|
||||
// re-render only on change: rebuilding innerHTML re-renders every <img>
|
||||
const payload = JSON.stringify([state.unidentified, state.warnings, photos]);
|
||||
if (payload === LAST) return;
|
||||
LAST = payload;
|
||||
render(state, photos);
|
||||
GATE([state.unidentified, state.warnings, photos], () => render(state, photos));
|
||||
}
|
||||
|
||||
const zone = document.getElementById("dropzone");
|
||||
@@ -83,25 +51,54 @@ zone.addEventListener("drop", e => {
|
||||
});
|
||||
pick.addEventListener("change", () => sendPhotos(pick.files));
|
||||
|
||||
const ZONE_IDLE = "drop shelf photos here, or click to choose";
|
||||
let UPLOADING = false;
|
||||
|
||||
async function sendPhotos(files) {
|
||||
if (!files.length) return;
|
||||
if (!files.length || UPLOADING) return;
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append("files", f);
|
||||
let res;
|
||||
const mb = ([...files].reduce((total, f) => total + f.size, 0) / 1048576).toFixed(1);
|
||||
UPLOADING = true;
|
||||
zone.disabled = true;
|
||||
zone.setAttribute("aria-busy", "true");
|
||||
zone.classList.remove("ok");
|
||||
zone.textContent = `uploading ${files.length} photo(s) — ${mb} MB… keep this page open`;
|
||||
let res = null;
|
||||
try {
|
||||
res = await fetch("/api/photos", {method: "POST", body: form});
|
||||
} catch (err) {
|
||||
alert("Upload failed (no response from the server): " + err);
|
||||
return;
|
||||
}
|
||||
UPLOADING = false;
|
||||
zone.disabled = false;
|
||||
zone.removeAttribute("aria-busy");
|
||||
pick.value = ""; // re-picking the same photo must fire change again
|
||||
if (!res) { zone.textContent = ZONE_IDLE; return; }
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().then(d => d.detail).catch(() => null);
|
||||
zone.textContent = ZONE_IDLE;
|
||||
alert("Upload failed: " + (detail ?? res.statusText));
|
||||
return;
|
||||
}
|
||||
const saved = await res.json().then(d => d.saved || []).catch(() => []);
|
||||
zone.classList.add("ok");
|
||||
zone.textContent = `✓ saved ${saved.join(", ")} — add another?`;
|
||||
setTimeout(() => {
|
||||
if (!UPLOADING && zone.classList.contains("ok")) {
|
||||
zone.classList.remove("ok");
|
||||
zone.textContent = ZONE_IDLE;
|
||||
}
|
||||
}, 6000);
|
||||
GATE.reset(); // the new photo must appear even if nothing else changed
|
||||
refresh().catch(() => {}); // the next poll self-heals a refresh hiccup
|
||||
}
|
||||
|
||||
// leaving mid-upload silently loses the photo — make the browser ask
|
||||
window.addEventListener("beforeunload", e => {
|
||||
if (UPLOADING) e.preventDefault();
|
||||
});
|
||||
|
||||
refresh().catch(err => errorBanner(err.message || err));
|
||||
pollLoop(refresh, 5000, () => showBanner(""));
|
||||
</script>
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
|
||||
<h2 id="activity">Activity</h2>
|
||||
<div id="jobstate" aria-live="polite">idle</div>
|
||||
<div id="joblog" aria-label="stage output">(stage output appears here)</div>
|
||||
<div id="joblog" role="log" aria-label="stage output">(stage output appears here)</div>
|
||||
<script>
|
||||
"use strict";
|
||||
let P = null;
|
||||
let running = false;
|
||||
let RUNNING = false;
|
||||
|
||||
async function runStage(stage, body) {
|
||||
const res = await apiPost(`/api/run/${stage}`, body);
|
||||
@@ -23,12 +23,12 @@ function stageCard(num, name, facts, actions) {
|
||||
}
|
||||
|
||||
function runBtn(stage, label) {
|
||||
return `<button class="primary" data-run="${stage}" ${running ? "disabled" : ""}>${esc(label ?? "Run")}</button>`;
|
||||
return `<button class="primary" data-run="${stage}" ${RUNNING ? "disabled" : ""}>${esc(label ?? "Run")}</button>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const m = P.matches, log = P.upload_log;
|
||||
running = P.job.status === "running"; // buttons below depend on this
|
||||
RUNNING = P.job.status === "running"; // buttons below depend on this
|
||||
|
||||
const banners = [];
|
||||
if (P.stub_data) banners.push(
|
||||
@@ -42,35 +42,56 @@ function render() {
|
||||
|
||||
const uploadFacts = `<b>${P.to_add}</b> to add · <b>${P.to_update}</b> version updates
|
||||
${log.added || log.added_no_version ? `· <b>${(log.added ?? 0) + (log.added_no_version ?? 0)}</b> added` : ""}
|
||||
${log.failed ? `· <b>${log.failed}</b> failed` : ""}
|
||||
· <a href="/queue">inspect the queue</a>`;
|
||||
${P.upload_failed ? `· <b>${P.upload_failed}</b> failed` : ""}
|
||||
· <a href="/queue">inspect the queue</a>
|
||||
${!P.to_add && !P.to_update && P.queued_total
|
||||
? `<br><span class="meta">${P.queued_total} queued row(s) already applied —
|
||||
the next <b>diff</b> clears them (BGG's collection export can lag the
|
||||
site by a while)</span>`
|
||||
: ""}`;
|
||||
|
||||
document.getElementById("stages").innerHTML = [
|
||||
stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a> — <b>${P.titles}</b> so far`, runBtn("extract")),
|
||||
stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a> — <b>${P.titles}</b> so far`
|
||||
+ (P.titles > 0
|
||||
? P.shaky_reads > 0
|
||||
? ` · <b>${P.shaky_reads}</b> shaky read(s) worth a <a href="/titles">proofread</a> before resolving`
|
||||
: ` · <a href="/titles">proofread the titles</a> before resolving`
|
||||
: ""), runBtn("extract")),
|
||||
stageCard(2, "resolve", `match titles to BGG ids — <b>${(m.auto ?? 0)}</b> auto · <b>${m.ambiguous ?? 0}</b> ambiguous · <b>${m.unmatched ?? 0}</b> unmatched`, runBtn("resolve")),
|
||||
stageCard(3, "review", `<b>${P.pending_review}</b> item(s) waiting for your call`, `<a class="linkbtn" href="/review">Open review</a>`),
|
||||
stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")),
|
||||
stageCard(5, "upload", uploadFacts,
|
||||
`${runBtn("upload", "Dry run")}
|
||||
<button class="danger" data-upload-real ${running || P.stub_data ? "disabled" : ""}>Upload</button>
|
||||
<label>limit <input type="number" id="uplimit" min="1" placeholder="all"></label>`),
|
||||
<button class="danger" data-upload-real ${RUNNING || P.stub_data ? "disabled" : ""}>Upload</button>
|
||||
<label>limit <input type="number" id="uplimit" min="1" placeholder="all"></label>
|
||||
${P.upload_failed
|
||||
? `<label title="failed jobs are skipped on normal runs so a broken one can't loop">
|
||||
<input type="checkbox" id="upretry"> retry ${P.upload_failed} failed</label>`
|
||||
: ""}`),
|
||||
stageCard(6, "enrich", `<b>${P.games}</b> game(s) in the <a href="/library">library</a>`, runBtn("enrich")),
|
||||
].join("");
|
||||
|
||||
document.querySelectorAll("[data-run]").forEach(b =>
|
||||
b.addEventListener("click", () => runStage(b.dataset.run,
|
||||
b.dataset.run === "upload" ? {dry_run: true} : {})));
|
||||
b.dataset.run === "upload" ? {dry_run: true, ...uploadOpts()} : {})));
|
||||
const uploadOpts = () => ({
|
||||
limit: Number(document.getElementById("uplimit").value) || null,
|
||||
retry_failed: !!document.getElementById("upretry")?.checked,
|
||||
});
|
||||
const real = document.querySelector("[data-upload-real]");
|
||||
if (real) real.addEventListener("click", () => {
|
||||
const limit = Number(document.getElementById("uplimit").value) || null;
|
||||
if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}? A browser window will open.`)) return;
|
||||
runStage("upload", {dry_run: false, limit});
|
||||
const {limit, retry_failed} = uploadOpts();
|
||||
if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}` +
|
||||
`${retry_failed ? ", retrying previously failed jobs" : ""}? A browser window will open.`)) return;
|
||||
runStage("upload", {dry_run: false, limit, retry_failed});
|
||||
});
|
||||
|
||||
const job = P.job;
|
||||
const state = document.getElementById("jobstate");
|
||||
if (job.status === "idle") state.textContent = "idle";
|
||||
else state.innerHTML = `<span class="${esc(job.status)}">${esc(job.stage)}: ${esc(job.status)}</span>` +
|
||||
// s- prefix: a bare status class collides with page classes (.done is
|
||||
// the review celebration card)
|
||||
else state.innerHTML = `<span class="s-${esc(job.status)}">${esc(job.stage)}: ${esc(job.status)}</span>` +
|
||||
(job.error ? ` — ${esc(job.error)}` : "");
|
||||
const logEl = document.getElementById("joblog");
|
||||
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4;
|
||||
@@ -78,14 +99,10 @@ function render() {
|
||||
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
let LAST = null;
|
||||
const GATE = changeGate();
|
||||
async function refresh() {
|
||||
const p = await fetchJSON("/api/pipeline");
|
||||
const payload = JSON.stringify(p);
|
||||
if (payload === LAST) return;
|
||||
LAST = payload;
|
||||
P = p;
|
||||
render();
|
||||
GATE(p, () => { P = p; render(); });
|
||||
}
|
||||
|
||||
refresh().catch(err => errorBanner(err.message || err));
|
||||
|
||||
@@ -11,24 +11,50 @@ function table(headers, rows) {
|
||||
${rows.join("")}</table></div>`;
|
||||
}
|
||||
|
||||
// a queue row's state comes from the upload log: the CSVs are diff-time
|
||||
// snapshots and never shrink as work completes
|
||||
function stateChip(r) {
|
||||
if (r.stale) return `<span class="chip no" title="${esc(r.stale)}">retired</span>`;
|
||||
if (r.state === "done" && r.last_status === "added_no_version")
|
||||
return `<span class="chip ok" title="the add saved without an edition — set it on BGG by hand if it matters">done · no version</span>`;
|
||||
if (r.state === "done") return `<span class="chip ok">done</span>`;
|
||||
if (r.state === "failed") return `<span class="chip no">failed</span>`;
|
||||
return `<span class="chip open">pending</span>`;
|
||||
}
|
||||
|
||||
function tally(rows) {
|
||||
const n = s => rows.filter(r => !r.stale && r.state === s).length;
|
||||
const parts = [`<b>${n("")}</b> pending`];
|
||||
if (n("done")) parts.push(`<b>${n("done")}</b> done`);
|
||||
if (n("failed")) parts.push(`<b>${n("failed")}</b> failed`);
|
||||
const retired = rows.filter(r => r.stale).length;
|
||||
if (retired) parts.push(`<b>${retired}</b> retired by review`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function render(q) {
|
||||
let html = "";
|
||||
|
||||
html += `<h2>To add <span class="count">— ${q.to_add.length} new collection entr${q.to_add.length === 1 ? "y" : "ies"}</span></h2>`;
|
||||
html += `<h2>To add <span class="count">— ${tally(q.to_add)}</span></h2>`;
|
||||
html += q.to_add.length
|
||||
? table(["game", "version", "seen in"], q.to_add.map(r => `
|
||||
? table(["game", "version", "seen in", ""], q.to_add.map(r => `
|
||||
<tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td>
|
||||
<td>${r.version_name ? esc(r.version_name) : `<span class="meta">no version</span>`}</td>
|
||||
<td class="meta">${esc(r.source_photos)}</td></tr>`))
|
||||
<td class="meta">${esc((r.source_photos ?? "").split(";").join(", "))}</td>
|
||||
<td>${stateChip(r)}</td></tr>`))
|
||||
: `<p class="empty">Nothing queued — run <b>diff</b> from the <a href="/">pipeline</a> first.</p>`;
|
||||
|
||||
html += `<h2>Version updates <span class="count">— ${q.to_update.length} existing entr${q.to_update.length === 1 ? "y" : "ies"} gaining a version</span></h2>`;
|
||||
html += `<h2>Version updates <span class="count">— ${tally(q.to_update)}</span></h2>`;
|
||||
html += q.to_update.length
|
||||
? table(["game", "version to set", "collection id"], q.to_update.map(r => `
|
||||
? table(["game", "version to set", "collection id", ""], q.to_update.map(r => `
|
||||
<tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td>
|
||||
<td>${esc(r.version_name)}</td>
|
||||
<td class="meta">${esc(r.collid)}</td></tr>`))
|
||||
<td class="meta">${esc(r.collid)}</td>
|
||||
<td>${stateChip(r)}</td></tr>`))
|
||||
: `<p class="empty">No version updates pending.</p>`;
|
||||
if (q.to_add.concat(q.to_update).some(r => r.state === "done"))
|
||||
html += `<p class="empty">Finished jobs stay listed until the next <b>diff</b>
|
||||
rebuilds the queue — the log below is the permanent record.</p>`;
|
||||
|
||||
html += `<h2>Upload log <span class="count">— every attempt ever made (${q.log.length})</span></h2>`;
|
||||
html += q.log.length
|
||||
@@ -43,13 +69,10 @@ function render(q) {
|
||||
document.getElementById("queuebody").innerHTML = html;
|
||||
}
|
||||
|
||||
let LAST = null;
|
||||
const GATE = changeGate();
|
||||
async function refresh() {
|
||||
const q = await fetchJSON("/api/queue");
|
||||
const payload = JSON.stringify(q);
|
||||
if (payload === LAST) return;
|
||||
LAST = payload;
|
||||
render(q);
|
||||
GATE(q, () => render(q));
|
||||
}
|
||||
|
||||
refresh().catch(err => errorBanner(err.message || err));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span id="sectionlinks"></span>
|
||||
<span class="keyhelp">
|
||||
<kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>–<kbd>9</kbd> pick ·
|
||||
<kbd>r</kbd> reject · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
|
||||
<kbd>r</kbd> reject · <kbd>l</kbd> keep local · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
|
||||
<kbd>v</kbd> veto merge
|
||||
</span>
|
||||
</div>
|
||||
@@ -11,32 +11,16 @@
|
||||
<script>
|
||||
"use strict";
|
||||
let STATE = null;
|
||||
let active = 0;
|
||||
let ACTIVE = 0;
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch("/api/state");
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
STATE = await res.json();
|
||||
STATE = await fetchJSON("/api/state");
|
||||
render();
|
||||
}
|
||||
|
||||
async function post(url, body) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
alert("That didn't save (no response from the server): " + err);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().then(d => d.detail).catch(() => null);
|
||||
alert("That didn't save: " + (detail ?? res.statusText));
|
||||
return;
|
||||
}
|
||||
const res = await apiPost(url, body);
|
||||
if (!res) return;
|
||||
try {
|
||||
STATE = await res.json();
|
||||
render();
|
||||
@@ -77,7 +61,9 @@ function matchCard(row, idx) {
|
||||
<kbd>${i + 1}</kbd> ${thumbHtml(c)}
|
||||
<span><span class="cname">${esc(c.name)}</span>
|
||||
<span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")}
|
||||
· rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}</span></span>
|
||||
· rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}
|
||||
· <a href="${bggUrl(c.bgg_id, c.type)}" target="_blank" rel="noopener"
|
||||
title="check this candidate before picking">view ↗</a></span></span>
|
||||
</li>`).join("");
|
||||
return `
|
||||
<section class="card actionable" data-kind="match" data-rowix="${row.row_ix}"
|
||||
@@ -91,7 +77,16 @@ function matchCard(row, idx) {
|
||||
<div class="rowactions">
|
||||
<span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎"
|
||||
aria-label="manual BGG id"></span>
|
||||
<span class="research">
|
||||
<input type="text" class="rq" value="${esc(row.title_raw)}"
|
||||
aria-label="search text for ${esc(row.title_raw)}">
|
||||
<button class="dosearch" data-types="">search BGG</button>
|
||||
<button class="dosearch" data-types="rpgitem"
|
||||
title="BGG's board-game entries hide same-named RPGs from the automatic search">
|
||||
search RPGGeek</button>
|
||||
</span>
|
||||
<button class="reject" title="press r"><kbd>r</kbd> reject — not a game / bad read</button>
|
||||
<button class="golocal" title="press l"><kbd>l</kbd> not on BGG — keep locally</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
@@ -109,12 +104,24 @@ function versionCard(row, idx) {
|
||||
return `
|
||||
<section class="card actionable" data-kind="version" data-rowix="${row.row_ix}"
|
||||
data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
|
||||
${shots(row.photos || [])}
|
||||
<div class="body">
|
||||
<p class="title">${esc(row.bgg_name || row.title_raw)}</p>
|
||||
<p class="status">which edition? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
|
||||
<p class="title">${esc(row.bgg_name || row.title_raw)}
|
||||
${row.bgg_id ? `<a class="cmeta" href="${bggUrl(row.bgg_id, row.type)}/versions"
|
||||
target="_blank" rel="noopener"
|
||||
title="BGG's own list of every printing">versions on BGG ↗</a>` : ""}</p>
|
||||
<p class="status">which edition${row.photos && row.photos.length
|
||||
? ` is the copy in ${esc(row.photos.join(", "))}`
|
||||
: ""}? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
|
||||
<ol class="cands">${cands}</ol>
|
||||
<div class="rowactions">
|
||||
<button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button>
|
||||
<button class="allversions"
|
||||
title="the cues shortlisted these — fetch every published printing instead">
|
||||
list every printing</button>
|
||||
<button class="wronggame"
|
||||
title="none of these printings is your box: BGG may file it as a SEPARATE game — re-match it">
|
||||
wrong game — re-match</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
@@ -142,7 +149,7 @@ function render() {
|
||||
<div class="done">
|
||||
<h2>${waiting
|
||||
? "Resolved set fully reviewed"
|
||||
: "All reviewed — the catalog is diff-ready"}</h2>
|
||||
: "All reviewed — every title is diff-ready"}</h2>
|
||||
<div class="nums">
|
||||
<div>${s.summary.extracted}<span>extracted</span></div>
|
||||
<div>${s.summary.recognized}<span>recognized</span></div>
|
||||
@@ -150,23 +157,34 @@ function render() {
|
||||
<div>${s.summary.rejected}<span>rejected</span></div>
|
||||
</div>
|
||||
${waiting
|
||||
? `<p class="waiting">${waiting} titles are extracted but not yet matched
|
||||
to BGG — they're waiting on the API token.</p>
|
||||
? s.summary.token_present
|
||||
? `<p class="waiting">${waiting} title(s) are extracted but not yet matched
|
||||
to BGG.</p>
|
||||
<p class="next">Run <b>resolve</b> from the <a href="/">pipeline</a> to match them.</p>`
|
||||
: `<p class="waiting">${waiting} title(s) are extracted but not yet matched
|
||||
to BGG — they're waiting on your API token
|
||||
(<a href="https://boardgamegeek.com/applications" target="_blank"
|
||||
rel="noopener">boardgamegeek.com/applications</a>).</p>
|
||||
<p class="next">When it arrives, run <b>resolve</b> from the <a href="/">pipeline</a>.</p>`
|
||||
: `<p class="next">Next: run <b>diff</b> from the <a href="/">pipeline</a>, then check the <a href="/queue">queue</a>.</p>`}
|
||||
</div>`;
|
||||
}
|
||||
// csv order is extraction order — alphabetize so a long session has a
|
||||
// findable, predictable shape (stable within a payload, so the keyboard
|
||||
// cursor stays put between polls)
|
||||
const byTitle = (a, b) =>
|
||||
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" });
|
||||
if (s.pending.length) {
|
||||
html += `<h2 id="matches">Matches <span class="count">— pick the game each photo shows</span></h2>`;
|
||||
html += s.pending.map(matchCard).join("");
|
||||
html += [...s.pending].sort(byTitle).map(matchCard).join("");
|
||||
}
|
||||
if (s.versions.length) {
|
||||
html += `<h2 id="editions">Editions <span class="count">— optional pass, never blocks uploads</span></h2>`;
|
||||
html += s.versions.map(versionCard).join("");
|
||||
html += [...s.versions].sort(byTitle).map(versionCard).join("");
|
||||
}
|
||||
if (s.merges.length) {
|
||||
html += `<h2 id="merges">Merges <span class="count">— duplicate reads folded into one game; veto if wrong</span></h2>`;
|
||||
html += s.merges.map(mg => `
|
||||
html += [...s.merges].sort(byTitle).map(mg => `
|
||||
<section class="card merge actionable" data-kind="merge"
|
||||
data-rowix="${mg.row_ix}"
|
||||
data-title="${esc(mg.title_raw)}" data-photos="${esc(mg.source_photos)}">
|
||||
@@ -182,9 +200,12 @@ function render() {
|
||||
m.innerHTML = html;
|
||||
|
||||
const cards = actionables();
|
||||
if (active >= cards.length) active = Math.max(0, cards.length - 1);
|
||||
if (ACTIVE >= cards.length) ACTIVE = Math.max(0, cards.length - 1);
|
||||
highlight();
|
||||
|
||||
// an outbound "view ↗" inside a pick row must not also cast the vote
|
||||
m.querySelectorAll("[data-pick] a, [data-pickver] a").forEach(a =>
|
||||
a.onclick = e => e.stopPropagation());
|
||||
m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
|
||||
const card = li.closest(".card");
|
||||
decide(card, "pick", Number(li.dataset.pick));
|
||||
@@ -195,6 +216,28 @@ function render() {
|
||||
});
|
||||
m.querySelectorAll(".reject").forEach(b => b.onclick = () =>
|
||||
decide(b.closest(".card"), "reject"));
|
||||
m.querySelectorAll(".golocal").forEach(b => b.onclick = () =>
|
||||
decide(b.closest(".card"), "local"));
|
||||
m.querySelectorAll(".dosearch").forEach(b => b.onclick = () => {
|
||||
const card = b.closest(".card");
|
||||
post("/api/research", {
|
||||
title_raw: card.dataset.title, source_photos: card.dataset.photos,
|
||||
row_ix: rowIx(card), query: card.querySelector(".rq").value,
|
||||
types: b.dataset.types || null,
|
||||
});
|
||||
});
|
||||
m.querySelectorAll(".wronggame").forEach(b => b.onclick = () =>
|
||||
post("/api/reopen-match", {
|
||||
title_raw: b.closest(".card").dataset.title,
|
||||
source_photos: b.closest(".card").dataset.photos,
|
||||
row_ix: rowIx(b.closest(".card")),
|
||||
}));
|
||||
m.querySelectorAll(".allversions").forEach(b => b.onclick = () =>
|
||||
post("/api/open-versions", {
|
||||
title_raw: b.closest(".card").dataset.title,
|
||||
source_photos: b.closest(".card").dataset.photos,
|
||||
row_ix: rowIx(b.closest(".card")),
|
||||
}));
|
||||
m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
|
||||
version(b.closest(".card"), "unknown"));
|
||||
m.querySelectorAll(".veto").forEach(b => b.onclick = () =>
|
||||
@@ -212,13 +255,15 @@ function render() {
|
||||
|
||||
const actionables = () => [...document.querySelectorAll(".actionable")];
|
||||
|
||||
function highlight() {
|
||||
actionables().forEach((el, i) => el.classList.toggle("active", i === active));
|
||||
const el = actionables()[active];
|
||||
if (el) el.scrollIntoView({block: "nearest", behavior: "auto"});
|
||||
function highlight(scroll = false) {
|
||||
actionables().forEach((el, i) => el.classList.toggle("active", i === ACTIVE));
|
||||
// scroll only for KEYBOARD moves: a mouse user's cursor idles on card
|
||||
// one, and scrolling to it on every re-render yanks them to the top
|
||||
const el = actionables()[ACTIVE];
|
||||
if (el && scroll) el.scrollIntoView({block: "nearest", behavior: "auto"});
|
||||
}
|
||||
|
||||
const rowIx = card => card.dataset.rowix === undefined ? null : Number(card.dataset.rowix);
|
||||
const rowIx = card => Number(card.dataset.rowix); // every card template sets it
|
||||
const decide = (card, action, bgg_id = null) => post("/api/decision", {
|
||||
title_raw: card.dataset.title, source_photos: card.dataset.photos,
|
||||
row_ix: rowIx(card), action, bgg_id,
|
||||
@@ -241,10 +286,10 @@ document.addEventListener("keydown", e => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
const cards = actionables();
|
||||
if (!cards.length) return;
|
||||
const card = cards[active];
|
||||
const card = cards[ACTIVE];
|
||||
const kind = card?.dataset.kind;
|
||||
if (e.key === "j" || e.key === "ArrowDown") { active = Math.min(active + 1, cards.length - 1); highlight(); }
|
||||
else if (e.key === "k" || e.key === "ArrowUp") { active = Math.max(active - 1, 0); highlight(); }
|
||||
if (e.key === "j" || e.key === "ArrowDown") { ACTIVE = Math.min(ACTIVE + 1, cards.length - 1); highlight(true); }
|
||||
else if (e.key === "k" || e.key === "ArrowUp") { ACTIVE = Math.max(ACTIVE - 1, 0); highlight(true); }
|
||||
else if (/^[1-9]$/.test(e.key) && kind === "match") {
|
||||
const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
|
||||
if (li) decide(card, "pick", Number(li.dataset.pick));
|
||||
@@ -254,6 +299,7 @@ document.addEventListener("keydown", e => {
|
||||
if (li) version(card, "pick", Number(li.dataset.pickver));
|
||||
}
|
||||
else if (e.key === "r" && kind === "match") decide(card, "reject");
|
||||
else if (e.key === "l" && kind === "match") decide(card, "local");
|
||||
else if (e.key === "u" && kind === "version") version(card, "unknown");
|
||||
else if (e.key === "v" && kind === "merge") vetoMerge(card);
|
||||
else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
|
||||
@@ -264,7 +310,6 @@ refresh().catch(err => errorBanner(err.message || err));
|
||||
// Live-follow the data files; only re-render on an actual change (keeps
|
||||
// the keyboard cursor stable) and never mid-typing. Stale poll responses
|
||||
// (answered before a decision landed) are discarded by revision.
|
||||
let lastGood = null;
|
||||
pollLoop(async () => {
|
||||
const fresh = await fetchJSON("/api/state");
|
||||
// discard stale responses — but only within one server lifetime: a
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,374 @@
|
||||
<h1>Titles</h1>
|
||||
<div class="pagebar"><span id="catcount"></span></div>
|
||||
<div class="filterbar">
|
||||
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter titles">
|
||||
<button id="shakyfilter" aria-pressed="false" hidden
|
||||
title="reads the model wasn't sure of — confirm or fix each one">shaky reads</button>
|
||||
<button id="addtitle" aria-expanded="false"
|
||||
title="a game no photo shows — an expansion inside a base box, a game away from the shelves">add a game</button>
|
||||
</div>
|
||||
<form id="addform" class="editform card" hidden>
|
||||
<label>Title <input name="title" required></label>
|
||||
<label>Publisher <input name="publisher"></label>
|
||||
<label>Edition <input name="edition"></label>
|
||||
<label>Year <input name="year" inputmode="numeric" size="6"></label>
|
||||
<label>Language <input name="language"></label>
|
||||
<span class="editactions">
|
||||
<button type="submit" class="primary">add</button>
|
||||
<button type="button" id="addcancel">cancel</button>
|
||||
</span>
|
||||
<span class="edithint">joins the titles list like a photo read — resolve matches it
|
||||
next run. 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(";"); }
|
||||
|
||||
function editorRow(c) {
|
||||
const cue = c.cues || {};
|
||||
return `
|
||||
<tr class="editrow"><td colspan="5">
|
||||
<form class="editform" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}">
|
||||
<label>Title <input name="title" value="${esc(c.title_raw)}" required></label>
|
||||
<label>Publisher <input name="publisher" value="${esc(cue.publisher || "")}"></label>
|
||||
<label>Edition <input name="edition" value="${esc(cue.edition || "")}"></label>
|
||||
<label>Year <input name="year" value="${esc(cue.year ?? "")}" inputmode="numeric" size="6"></label>
|
||||
<label>Language <input name="language" value="${esc(cue.language || "")}"></label>
|
||||
<span class="editactions">
|
||||
<button type="submit" class="primary">save</button>
|
||||
<button type="button" class="canceledit">cancel</button>
|
||||
${c.bgg_id && ["auto", "approved"].includes(c.status)
|
||||
? `<button type="button" class="wrongmatch danger">wrong match — re-search</button>`
|
||||
: ""}
|
||||
<button type="button" class="removetitle danger"
|
||||
title="not a game, a duplicate read of a copy already listed, or otherwise doesn't belong">
|
||||
remove this line</button>
|
||||
</span>
|
||||
<span class="edithint">saving re-queues this title for resolve with the corrected data</span>
|
||||
</form>
|
||||
</td></tr>`;
|
||||
}
|
||||
|
||||
let SHAKY_ONLY = false;
|
||||
|
||||
function render() {
|
||||
const q = document.getElementById("catsearch").value.trim().toLowerCase();
|
||||
const shakyCount = CATALOG.filter(c => c.shaky).length;
|
||||
const fbtn = document.getElementById("shakyfilter");
|
||||
fbtn.hidden = shakyCount === 0 && !SHAKY_ONLY;
|
||||
fbtn.textContent = `shaky reads — ${shakyCount}`;
|
||||
fbtn.setAttribute("aria-pressed", String(SHAKY_ONLY));
|
||||
const sorted = [...CATALOG].sort((a, b) =>
|
||||
a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" }));
|
||||
const searched = q
|
||||
? sorted.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
|
||||
: sorted;
|
||||
const rows = SHAKY_ONLY ? searched.filter(c => c.shaky) : searched;
|
||||
document.getElementById("catcount").innerHTML =
|
||||
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
|
||||
document.getElementById("catbody").innerHTML = rows.length
|
||||
? `<div class="catalog"><table>` + rows.map(c => `
|
||||
<tr>
|
||||
<td class="t">${esc(c.title_raw)}
|
||||
${c.split_copy ? `<span class="chip merged">copy</span>` : ""}
|
||||
${c.shaky ? `<span class="chip shaky"
|
||||
title="the model wasn't sure of this read — press ✓ if it's right, or edit it">shaky read</span>` : ""}</td>
|
||||
<td>${statusChip(c)}</td>
|
||||
<td class="meta">${metaLine(c)}</td>
|
||||
<td class="meta">${c.photos.length
|
||||
? c.photos.map(p =>
|
||||
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
|
||||
).join(", ")
|
||||
: `<span class="chip merged">added by hand</span>`}</td>
|
||||
<td class="actions">${c.can_split
|
||||
? `<button class="split" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}"
|
||||
data-rowix="${c.row_ix ?? ""}"
|
||||
title="one line, several boxes? make each photo its own copy">
|
||||
split into copies</button>`
|
||||
: ""}
|
||||
${c.status === "local"
|
||||
? `<button class="wrongmatch" data-title="${esc(c.title_raw)}"
|
||||
data-photos="${esc(c.photos.join(";"))}" data-rowix="${c.row_ix ?? ""}"
|
||||
title="search BGG and RPGGeek again — RPGs and expansions often are listed">
|
||||
look it up</button>`
|
||||
: ""}
|
||||
${c.version_status === "version_ambiguous"
|
||||
? `<a class="ballotlink" href="/review#editions"
|
||||
title="this copy's edition ballot is open">ballot in Review →</a>`
|
||||
: ""}
|
||||
${c.bgg_id && ["auto", "approved"].includes(c.status)
|
||||
&& 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(";"))}"
|
||||
title="the read is correct as-is — mark it verified">✓ looks right</button>` : ""}
|
||||
<button class="edit" data-key="${esc(lineKey(c))}"
|
||||
title="fix a misread title or add cues you already know">edit</button>
|
||||
</td>
|
||||
</tr>` + (EDITING === lineKey(c) ? editorRow(c) : "")).join("") + `</table></div>`
|
||||
: `<p class="empty">${CATALOG.length
|
||||
? "No titles match that filter."
|
||||
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
|
||||
}
|
||||
|
||||
const GATE = changeGate();
|
||||
async function refresh() {
|
||||
const state = await fetchJSON("/api/state");
|
||||
if (EDITING) return; // never repaint under an open editor
|
||||
GATE(state.catalog, () => { CATALOG = state.catalog; render(); });
|
||||
}
|
||||
|
||||
document.getElementById("catbody").addEventListener("click", async e => {
|
||||
const cancel = e.target.closest("button.canceledit");
|
||||
if (cancel) { EDITING = null; GATE.reset(); render(); refresh().catch(() => {}); return; }
|
||||
const wm = e.target.closest("button.wrongmatch");
|
||||
if (wm) {
|
||||
// in the edit panel it means "wrong match"; on a local row it means
|
||||
// "look it up" — same reopen, different starting point
|
||||
const f = wm.closest("form.editform");
|
||||
const src = f ? f.dataset : wm.dataset;
|
||||
if (f && !confirm(`"${src.title}" matched the wrong game? This clears the ` +
|
||||
`match and sends it back to Review for a re-search.`)) return;
|
||||
const res = await apiPost("/api/reopen-match", {
|
||||
title_raw: src.title,
|
||||
source_photos: src.photos,
|
||||
row_ix: src.rowix === "" ? null : Number(src.rowix),
|
||||
});
|
||||
if (res) {
|
||||
EDITING = null; GATE.reset(); refresh().catch(() => {});
|
||||
showToast(`searching BGG and RPGGeek for <b>${esc(src.title)}</b> —
|
||||
<a href="/review#matches">pick a match in Review</a>`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const pe = e.target.closest("button.pickedition");
|
||||
if (pe) {
|
||||
const res = await apiPost("/api/open-versions", {
|
||||
title_raw: pe.dataset.title,
|
||||
source_photos: pe.dataset.photos,
|
||||
row_ix: pe.dataset.rowix === "" ? null : Number(pe.dataset.rowix),
|
||||
});
|
||||
if (res) {
|
||||
showToast(`edition ballot ready for <b>${esc(pe.dataset.title)}</b> —
|
||||
<a href="/review#editions">pick it in Review</a>`);
|
||||
GATE.reset(); refresh().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const ok = e.target.closest("button.confirmread");
|
||||
if (ok) {
|
||||
const res = await apiPost("/api/edit-title", {
|
||||
title_raw: ok.dataset.title,
|
||||
source_photos: ok.dataset.photos,
|
||||
confirm: true,
|
||||
});
|
||||
if (res) { GATE.reset(); refresh().catch(() => {}); }
|
||||
return;
|
||||
}
|
||||
const rm = e.target.closest("button.removetitle");
|
||||
if (rm) {
|
||||
const f = rm.closest("form.editform");
|
||||
if (!confirm(`Remove "${f.dataset.title}" from the titles list? ` +
|
||||
`Right for a line that shouldn't exist: not a game, or a duplicate ` +
|
||||
`read of a copy that's already its own line. ` +
|
||||
`(One line for several REAL boxes? Use split instead.) ` +
|
||||
`Re-running extract won't bring it back — the removal is saved ` +
|
||||
`in data/title_removals.json (delete its record there to undo).`)) return;
|
||||
const res = await apiPost("/api/remove-title", {
|
||||
title_raw: f.dataset.title,
|
||||
source_photos: f.dataset.photos,
|
||||
});
|
||||
if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); }
|
||||
return;
|
||||
}
|
||||
const edit = e.target.closest("button.edit");
|
||||
if (edit) {
|
||||
EDITING = EDITING === edit.dataset.key ? null : edit.dataset.key;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const b = e.target.closest("button.split");
|
||||
if (!b) return;
|
||||
const n = b.dataset.photos.split(";").length;
|
||||
if (!confirm(`Split "${b.dataset.title}" into ${n} separate copies (one per photo)? ` +
|
||||
`Each picks its own edition afterward.`)) return;
|
||||
const res = await apiPost("/api/split", {
|
||||
title_raw: b.dataset.title,
|
||||
source_photos: b.dataset.photos,
|
||||
row_ix: b.dataset.rowix === "" ? null : Number(b.dataset.rowix),
|
||||
});
|
||||
if (res) refresh().catch(() => {});
|
||||
});
|
||||
|
||||
document.getElementById("catbody").addEventListener("submit", async e => {
|
||||
const f = e.target.closest("form.editform");
|
||||
if (!f) return;
|
||||
e.preventDefault();
|
||||
const orig = CATALOG.find(c => lineKey(c) === EDITING) || {};
|
||||
const cue = orig.cues || {};
|
||||
const v = name => f.elements[name].value;
|
||||
const body = { title_raw: f.dataset.title, source_photos: f.dataset.photos };
|
||||
if (v("title").trim() !== f.dataset.title) body.title_new = v("title").trim();
|
||||
if (v("publisher") !== (cue.publisher || "")) body.publisher = v("publisher");
|
||||
if (v("edition") !== (cue.edition || "")) body.edition = v("edition");
|
||||
if (v("year") !== String(cue.year ?? "")) body.year = v("year");
|
||||
if (v("language") !== (cue.language || "")) body.language = v("language");
|
||||
if (Object.keys(body).length <= 2) {
|
||||
// saving unchanged: on a shaky line that means "it's right as-is"
|
||||
if (orig.shaky) {
|
||||
const res = await apiPost("/api/edit-title", { ...body, confirm: true });
|
||||
if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); return; }
|
||||
}
|
||||
EDITING = null; render(); return;
|
||||
}
|
||||
const res = await apiPost("/api/edit-title", body);
|
||||
if (res) {
|
||||
EDITING = null; GATE.reset(); refresh().catch(() => {});
|
||||
// an edit re-queues any matched row: without a resolve run the line
|
||||
// sits "awaiting resolve" — say so, and offer the run right here
|
||||
const toast = showToast(`saved — a previously matched title re-matches
|
||||
on the next resolve run. <button id="resolvenow">run resolve now</button>`);
|
||||
const btn = toast.querySelector("#resolvenow");
|
||||
if (btn) btn.addEventListener("click", async () => {
|
||||
const r = await apiPost("/api/run/resolve");
|
||||
if (r) showToast(`resolve running — progress on the
|
||||
<a href="/">Pipeline</a> page; this list updates itself`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const addForm = document.getElementById("addform");
|
||||
const addBtn = document.getElementById("addtitle");
|
||||
addBtn.addEventListener("click", () => {
|
||||
addForm.hidden = !addForm.hidden;
|
||||
addBtn.setAttribute("aria-expanded", String(!addForm.hidden));
|
||||
if (!addForm.hidden) addForm.elements.title.focus();
|
||||
});
|
||||
document.getElementById("addcancel").addEventListener("click", () => {
|
||||
addForm.hidden = true;
|
||||
addBtn.setAttribute("aria-expanded", "false");
|
||||
addForm.reset();
|
||||
});
|
||||
|
||||
// -- the pick panel: one BGG search, tick off a whole box ---------------
|
||||
const pickPanel = document.getElementById("pickpanel");
|
||||
document.getElementById("topicks").addEventListener("click", () => {
|
||||
addForm.hidden = true;
|
||||
pickPanel.hidden = false;
|
||||
document.getElementById("pickq").focus();
|
||||
});
|
||||
document.getElementById("pickcancel").addEventListener("click", () => {
|
||||
pickPanel.hidden = true;
|
||||
document.getElementById("pickresults").innerHTML = "";
|
||||
document.getElementById("pickfooter").hidden = true;
|
||||
});
|
||||
async function pickSearch() {
|
||||
const q = document.getElementById("pickq").value.trim();
|
||||
if (!q) return;
|
||||
const box = document.getElementById("pickresults");
|
||||
box.innerHTML = `<p class="meta">searching BGG…</p>`;
|
||||
let data;
|
||||
try {
|
||||
data = await fetchJSON(`/api/bgg-search?q=${encodeURIComponent(q)}`);
|
||||
} catch (err) {
|
||||
box.innerHTML = `<p class="meta">search failed: ${esc(err.message || err)}</p>`;
|
||||
return;
|
||||
}
|
||||
PICKS = data.results;
|
||||
box.innerHTML = PICKS.length
|
||||
? PICKS.map((r, i) => `
|
||||
<label class="pickrow">
|
||||
<input type="checkbox" data-pick-ix="${i}" ${r.already ? "disabled" : ""}>
|
||||
${esc(r.name)} <span class="meta">${esc(r.year ?? "—")} · ${esc(r.type)}</span>
|
||||
${r.already ? `<span class="chip ok">already cataloged</span>` : ""}
|
||||
</label>`).join("")
|
||||
: `<p class="meta">BGG found nothing for that.</p>`;
|
||||
document.getElementById("pickfooter").hidden = !PICKS.length;
|
||||
// container choices: anything already matched, the searched family first
|
||||
const owned = CATALOG.filter(c => c.bgg_id && ["auto", "approved"].includes(c.status));
|
||||
const ql = q.toLowerCase();
|
||||
owned.sort((a, b) =>
|
||||
(b.bgg_name || "").toLowerCase().includes(ql) -
|
||||
(a.bgg_name || "").toLowerCase().includes(ql));
|
||||
document.getElementById("pickcontainer").innerHTML =
|
||||
`<option value="">their own boxes</option>` + owned.map(c =>
|
||||
`<option value="${esc(c.bgg_id)}">${esc(c.bgg_name)}</option>`).join("");
|
||||
}
|
||||
document.getElementById("dopicksearch").addEventListener("click", pickSearch);
|
||||
document.getElementById("pickq").addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") { e.preventDefault(); pickSearch(); }
|
||||
});
|
||||
document.getElementById("addpicks").addEventListener("click", async () => {
|
||||
const picks = Array.from(
|
||||
document.querySelectorAll("#pickresults input:checked")
|
||||
).map(cb => PICKS[Number(cb.dataset.pickIx)]);
|
||||
if (!picks.length) return;
|
||||
const res = await apiPost("/api/add-picked", {
|
||||
picks, stored_in: document.getElementById("pickcontainer").value,
|
||||
});
|
||||
if (res) {
|
||||
// apiPost returns the raw Response; the counts live in its body
|
||||
const data = await res.json().catch(() => ({added: picks.length, skipped: []}));
|
||||
showToast(`added <b>${data.added}</b> game(s)` +
|
||||
(data.skipped.length ? ` — ${data.skipped.length} skipped (already on file)` : ""));
|
||||
pickPanel.hidden = true;
|
||||
document.getElementById("pickresults").innerHTML = "";
|
||||
document.getElementById("pickfooter").hidden = true;
|
||||
GATE.reset();
|
||||
refresh().catch(() => {});
|
||||
}
|
||||
});
|
||||
addForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const v = name => addForm.elements[name].value;
|
||||
const res = await apiPost("/api/add-title", {
|
||||
title: v("title"), publisher: v("publisher"), edition: v("edition"),
|
||||
year: v("year"), language: v("language"),
|
||||
});
|
||||
if (res) {
|
||||
addForm.reset();
|
||||
addForm.hidden = true;
|
||||
addBtn.setAttribute("aria-expanded", "false");
|
||||
GATE.reset();
|
||||
refresh().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("catsearch").addEventListener("input", render);
|
||||
document.getElementById("shakyfilter").addEventListener("click", () => {
|
||||
SHAKY_ONLY = !SHAKY_ONLY;
|
||||
render();
|
||||
});
|
||||
refresh().catch(err => errorBanner(err.message || err));
|
||||
pollLoop(refresh, 5000, () => showBanner(""));
|
||||
</script>
|
||||
@@ -5,7 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><!--TITLE--></title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-title" content="bggpipe">
|
||||
<link rel="stylesheet" href="/static/app.css?v=__ASSET_V__">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
@@ -14,7 +16,9 @@
|
||||
<img src="/static/logo.jpg" alt="">
|
||||
<span class="wordmark">bggpipe<small>shelf → BGG pipeline</small></span>
|
||||
</a>
|
||||
<nav aria-label="Primary">
|
||||
<button class="navtoggle" aria-expanded="false" aria-controls="primary-nav"
|
||||
aria-label="Menu"><span class="bars"></span></button>
|
||||
<nav id="primary-nav" aria-label="Primary">
|
||||
<!--NAV-->
|
||||
</nav>
|
||||
<figure class="piperbox">
|
||||
@@ -27,8 +31,8 @@
|
||||
</figure>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div id="banner"></div>
|
||||
<script src="/static/app.js"></script>
|
||||
<div id="banner" role="status"></div>
|
||||
<script src="/static/app.js?v=__ASSET_V__"></script>
|
||||
<main id="main">
|
||||
<!--PAGE-->
|
||||
</main>
|
||||
|
||||
@@ -39,7 +39,8 @@ import typer
|
||||
from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.fsio import atomic_write_text
|
||||
from bggpipe.models import CollectionItem
|
||||
from bggpipe.models import CollectionItem, is_recognized
|
||||
from bggpipe.resolve import read_matches
|
||||
|
||||
BGG = "https://boardgamegeek.com"
|
||||
UPLOAD_LOG_COLUMNS = [
|
||||
@@ -57,6 +58,16 @@ DONE_STATUSES = {"added", "added_no_version", "updated", "already_present"}
|
||||
MAX_VERSION_PAGES = 40
|
||||
|
||||
|
||||
_YEARISH = re.compile(r"[\s(]*\d{4}(?:-\d+)?[\s)]*")
|
||||
|
||||
|
||||
def _loose_version_text(text: str) -> str:
|
||||
"""Version names as displayed differ from the API's by punctuation,
|
||||
parens and year qualifiers — compare only the words that name it."""
|
||||
text = _YEARISH.sub(" ", text or "")
|
||||
return " ".join(re.sub(r"[^\w\s]", " ", text.casefold()).split())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadJob:
|
||||
action: str # "add" | "update"
|
||||
@@ -89,6 +100,116 @@ def _job_key(row: dict) -> tuple[str, str, str]:
|
||||
return _key(row["action"], row["bgg_id"], row["collid"], row["version_id"])
|
||||
|
||||
|
||||
def annotate_queue(
|
||||
queue_rows: list[dict], action: str, log_rows: list[dict]
|
||||
) -> list[dict]:
|
||||
"""Each queue row plus the outcome of its LAST upload attempt, in a
|
||||
`state` field: "" (pending), "done", or "failed". to_add.csv and
|
||||
to_update.csv are diff-time snapshots — nothing removes a row once its
|
||||
job succeeds — so a reader without the log sees finished work as
|
||||
outstanding forever."""
|
||||
last: dict[tuple[str, str, str], str] = {}
|
||||
done_count: Counter[tuple[str, str, str]] = Counter()
|
||||
for row in log_rows:
|
||||
last[_job_key(row)] = row["status"]
|
||||
if row["status"] in DONE_STATUSES:
|
||||
done_count[_job_key(row)] += 1
|
||||
# vetoed duplicate copies share a key: one success must mark ONE row
|
||||
# done, not both, or the queue reports a copy uploaded that never was
|
||||
claimed: Counter[tuple[str, str, str]] = Counter()
|
||||
out = []
|
||||
for row in queue_rows:
|
||||
key = _key(
|
||||
action,
|
||||
row.get("bgg_id", ""),
|
||||
row.get("collid", ""),
|
||||
row.get("version_id", ""),
|
||||
)
|
||||
status = last.get(key, "")
|
||||
if status in DONE_STATUSES and claimed[key] >= done_count[key]:
|
||||
status = "" # completions exhausted: this copy is still pending
|
||||
if status in DONE_STATUSES:
|
||||
claimed[key] += 1
|
||||
out.append(
|
||||
{
|
||||
**row,
|
||||
"state": "done"
|
||||
if status in DONE_STATUSES
|
||||
else "failed"
|
||||
if status == "failed"
|
||||
else "",
|
||||
"last_status": status,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def stale_jobs(queue_rows: list[dict], match_rows: list[dict]) -> dict[str, str]:
|
||||
"""bgg_id -> why, for queued games the CURRENT matches.csv no longer
|
||||
endorses. to_add.csv is a snapshot from the last diff; a review
|
||||
decision taken afterwards (marking a game local, rejecting it, calling
|
||||
a match wrong) must not still upload."""
|
||||
if not match_rows:
|
||||
# nothing to compare against (no matches.csv, or a test harness
|
||||
# driving the queue directly): absence is not a verdict
|
||||
return {}
|
||||
live: dict[str, list[dict]] = {}
|
||||
endorsed: Counter[tuple[str, str]] = Counter()
|
||||
for row in match_rows:
|
||||
if row.get("bgg_id"):
|
||||
live.setdefault(row["bgg_id"], []).append(row)
|
||||
if is_recognized(row):
|
||||
endorsed[(row["bgg_id"], row.get("version_id", ""))] += 1
|
||||
queued: Counter[tuple[str, str]] = Counter()
|
||||
for row in queue_rows:
|
||||
queued[(row.get("bgg_id") or "", row.get("version_id", ""))] += 1
|
||||
stale = {}
|
||||
for row in queue_rows:
|
||||
bgg_id = row.get("bgg_id") or ""
|
||||
rows = live.get(bgg_id, [])
|
||||
if not rows:
|
||||
stale[bgg_id] = "no longer matched to this game in matches.csv"
|
||||
continue
|
||||
if not any(is_recognized(r) for r in rows):
|
||||
statuses = sorted({r["match_status"] for r in rows})
|
||||
stale[bgg_id] = f"now {', '.join(statuses)} in matches.csv"
|
||||
continue
|
||||
# the game survives, but does THIS copy? Rejecting one of two
|
||||
# editions must not ride along on the other's endorsement.
|
||||
pair = (bgg_id, row.get("version_id", ""))
|
||||
if queued[pair] > endorsed[pair]:
|
||||
stale[bgg_id] = (
|
||||
f"queued with version {row.get('version_id') or '(none)'} "
|
||||
"but matches.csv no longer endorses that copy — re-run diff"
|
||||
)
|
||||
return stale
|
||||
|
||||
|
||||
def outstanding_failures(
|
||||
log_rows: list[dict],
|
||||
queue_rows: list[dict] | None = None,
|
||||
match_rows: list[dict] | None = None,
|
||||
) -> int:
|
||||
"""Failures still worth retrying: the job's LATEST attempt failed AND
|
||||
it is still queued and still endorsed by matches.csv.
|
||||
|
||||
Two ways to over-count. upload_log.csv is append-only, so a successful
|
||||
retry leaves its old 'failed' line behind; and a failed job whose
|
||||
review decision has since changed (marked local, rejected) is not
|
||||
pending work at all — it will never run again, so offering to retry it
|
||||
is a lie."""
|
||||
last: dict[tuple[str, str, str], tuple[str, str]] = {}
|
||||
for row in log_rows:
|
||||
last[_job_key(row)] = (row["status"], row.get("bgg_id", ""))
|
||||
failed = [bgg_id for status, bgg_id in last.values() if status == "failed"]
|
||||
if queue_rows is None:
|
||||
return len(failed)
|
||||
live = {r.get("bgg_id", "") for r in queue_rows} - set(
|
||||
stale_jobs(queue_rows, match_rows or [])
|
||||
)
|
||||
return sum(1 for bgg_id in failed if bgg_id in live)
|
||||
|
||||
|
||||
def _read_csv(path: Path) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
@@ -114,10 +235,9 @@ def build_queue(
|
||||
log_rows: list[dict],
|
||||
*,
|
||||
retry_failed: bool = False,
|
||||
) -> tuple[list[UploadJob], int, int, list[UploadJob]]:
|
||||
) -> tuple[list[UploadJob], int, int]:
|
||||
"""Turn the diff outputs into pending jobs, minus work the log says is
|
||||
done. Returns (jobs, skipped_done, skipped_failed, deferred) — deferred
|
||||
being same-game updates held for a later run.
|
||||
done. Returns (jobs, skipped_done, skipped_failed).
|
||||
|
||||
Completions are COUNTED per key, not looked up: two vetoed duplicate
|
||||
copies share a key, and one logged success must complete exactly one
|
||||
@@ -161,8 +281,6 @@ def build_queue(
|
||||
|
||||
jobs: list[UploadJob] = []
|
||||
skipped_done = skipped_failed = 0
|
||||
deferred: list[UploadJob] = []
|
||||
update_game_seen: set[str] = set()
|
||||
seen: Counter[tuple[str, str, str]] = Counter()
|
||||
queued_versions: dict[str, set[str]] = {}
|
||||
for job in candidates:
|
||||
@@ -179,11 +297,13 @@ def build_queue(
|
||||
prior = set()
|
||||
if prior and job.version_id not in prior:
|
||||
typer.echo(
|
||||
f" note: {job.name} was previously {job.action}ed with a "
|
||||
f" skipping {job.name}: previously {job.action}ed with a "
|
||||
f"different version ({', '.join(sorted(prior)) or 'none'}) — "
|
||||
"if re-review changed the version, the BGG entry needs a "
|
||||
"manual correction (additive-only rule)"
|
||||
"the entry exists on BGG, so re-adding can only duplicate "
|
||||
"it; correct the version by hand (additive-only rule)"
|
||||
)
|
||||
skipped_done += 1
|
||||
continue
|
||||
occurrence = seen[job.key]
|
||||
seen[job.key] += 1
|
||||
if not job.name:
|
||||
@@ -198,27 +318,23 @@ def build_queue(
|
||||
skipped_done += 1
|
||||
elif last_status.get(job.key) == "failed" and not retry_failed:
|
||||
skipped_failed += 1
|
||||
elif job.action == "update" and job.bgg_id in update_game_seen:
|
||||
# The row-edit flow finds rows by game name, not collid — a
|
||||
# second same-game update this run could reopen the copy the
|
||||
# first one just versioned and overwrite it. One per run; the
|
||||
# next run (after --verify) picks up the rest.
|
||||
deferred.append(job)
|
||||
else:
|
||||
if job.action == "update":
|
||||
update_game_seen.add(job.bgg_id)
|
||||
# same-game updates coexist in one run: update_entry addresses
|
||||
# the copy by collid and the edition by radio value, so a
|
||||
# second update cannot reopen what the first just saved
|
||||
jobs.append(job)
|
||||
return jobs, skipped_done, skipped_failed, deferred
|
||||
return jobs, skipped_done, skipped_failed
|
||||
|
||||
|
||||
def _scrub(text: str) -> str:
|
||||
"""Credentials must never reach the log, even via a selector error that
|
||||
echoes filled form values."""
|
||||
echoes filled form values. Playwright errors carry multi-line call
|
||||
logs; a CSV cell keeps one line."""
|
||||
for key in ("BGG_PASSWORD", "BGG_USERNAME"):
|
||||
value = os.environ.get(key)
|
||||
if value:
|
||||
text = text.replace(value, "***")
|
||||
return text
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
class LoginError(RuntimeError):
|
||||
@@ -236,10 +352,11 @@ class Uploader(Protocol):
|
||||
class PlaywrightUploader:
|
||||
"""Drives the real site. Selector documentation: docs/bgg-upload-flow.md.
|
||||
|
||||
Verified selectors (2026-08-01): the login form (#inputUsername /
|
||||
#inputPassword / "Sign In") and the Add-to-Collection dialog structure.
|
||||
UNVERIFIED: version-picker pagination, post-save behavior, and the whole
|
||||
collection-row edit flow for updates — expect first-real-run adjustments.
|
||||
All flows verified against the live site 2026-08-06 (adds, the version
|
||||
picker with pagination, and the collection-row version edit). The
|
||||
remaining unknown is the second-copy add: BGG may edit the existing
|
||||
entry instead of creating one, which run_upload reports as a copy-count
|
||||
shortfall during --verify.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -275,11 +392,31 @@ class PlaywrightUploader:
|
||||
# plus per-element auto-waiting is the reliable pattern.
|
||||
self._page.goto(url, wait_until="domcontentloaded")
|
||||
|
||||
# BGG's Sign In control is an <a class="btn"> with NO href, so it has
|
||||
# no implicit link role — role-based queries cannot find it.
|
||||
_SIGN_IN = 'a:has-text("Sign In"), button:has-text("Sign In")'
|
||||
_SIGNED_IN = ':text-is("Sign Out"), :text-is("Log Out")'
|
||||
|
||||
def _signed_out(self) -> bool:
|
||||
# Heuristic: the header shows a "Sign In" link only when logged out.
|
||||
return (
|
||||
self._page.get_by_role("link", name=re.compile(r"^sign in$", re.I)).count()
|
||||
> 0
|
||||
"""True = signed out. Waits for the header to prove ONE state or
|
||||
the other: BGG hydrates it after domcontentloaded, so an early read
|
||||
finds NEITHER control — and any check resting on a single absence
|
||||
guesses, silently, in the unsafe direction. Undeterminable state
|
||||
raises rather than assumes."""
|
||||
page = self._page
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
out = page.locator(self._SIGN_IN).count()
|
||||
inside = page.locator(self._SIGNED_IN).count()
|
||||
if out and not inside:
|
||||
return True
|
||||
if inside and not out:
|
||||
return False
|
||||
page.wait_for_timeout(250)
|
||||
raise LoginError(
|
||||
"could not tell whether this browser is signed in (neither a "
|
||||
"Sign In nor a Sign Out control appeared in 30s) — BGG's header "
|
||||
"may have changed; see docs/bgg-upload-flow.md"
|
||||
)
|
||||
|
||||
def _ensure_logged_in(self) -> None:
|
||||
@@ -313,6 +450,15 @@ class PlaywrightUploader:
|
||||
"login did not complete (wrong credentials, or the login "
|
||||
"page changed — see docs/bgg-upload-flow.md)"
|
||||
) from err
|
||||
# Verify the TRANSITION: the Sign In control was visible a
|
||||
# moment ago, so its disappearance is evidence, not inference.
|
||||
self._goto(f"{BGG}/")
|
||||
if self._signed_out():
|
||||
raise LoginError(
|
||||
"the login form was submitted but the site still offers "
|
||||
"Sign In — credentials rejected, or a challenge is "
|
||||
"pending in the browser window"
|
||||
)
|
||||
self._context.storage_state(path=str(self._storage_state))
|
||||
self._storage_state.chmod(0o600) # session cookies: owner-only
|
||||
self._authed = True
|
||||
@@ -332,14 +478,49 @@ class PlaywrightUploader:
|
||||
raise
|
||||
return dialog
|
||||
|
||||
def _select_version(self, dialog, version_name: str) -> bool:
|
||||
"""Page through the version sub-view matching the canonical version
|
||||
NAME (the list has no search box). Returns False — with the sub-view
|
||||
cancelled — when the name never shows up."""
|
||||
# Verified 2026-08-06 against the live picker: version rows are the
|
||||
# <li>s carrying a thumbnail (the paging <li>s are not), and paging is
|
||||
# an AngularJS <ul class="pagination"> of anchors, not buttons.
|
||||
_VERSION_ROWS = "li:has(.summary-item-thumbnail)"
|
||||
# Paging controls render TWICE — a desktop set and a mobile set marked
|
||||
# visible-xs-* — so every one of these selectors matches hidden nodes
|
||||
# too; clicking one waits forever. Always pick the visible match.
|
||||
_NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
|
||||
_PAGE_LINKS = "ul.pagination a"
|
||||
# Rows read "<game name> (<version name>) (<year>)", so the version name
|
||||
# is matched inside its parentheses — bare substrings would let
|
||||
# "English edition" match "English edition, second printing".
|
||||
_TRAILING_YEAR = re.compile(r"\s+\d{4}(?:-\d+)?$")
|
||||
|
||||
def _rows_on_page(self, dialog) -> list[str]:
|
||||
return [
|
||||
" ".join(t.split())
|
||||
for t in dialog.locator(self._VERSION_ROWS).all_text_contents()
|
||||
]
|
||||
|
||||
def _visible(self, dialog, selector):
|
||||
"""The first VISIBLE match, or None — see _NEXT_PAGE."""
|
||||
loc = dialog.locator(selector)
|
||||
for i in range(loc.count()):
|
||||
candidate = loc.nth(i)
|
||||
if candidate.is_visible():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _has_next_page(self, dialog) -> bool:
|
||||
nxt = self._visible(dialog, self._NEXT_PAGE)
|
||||
if nxt is None:
|
||||
return False # single-page list: the container is ng-show'd off
|
||||
return "disabled" not in (nxt.evaluate("e => e.closest('li').className") or "")
|
||||
|
||||
def _select_version(self, dialog, version_name: str) -> tuple[bool, str]:
|
||||
"""Pick the version whose name matches, paging the whole list first
|
||||
so a decision is made against every candidate. Returns (picked,
|
||||
reason); on failure the sub-view is cancelled and the caller adds
|
||||
the game version-less — never a guessed edition (spec)."""
|
||||
dialog.get_by_role("button", name="Set version/edition").click()
|
||||
pattern = re.compile(re.escape(version_name), re.I)
|
||||
try:
|
||||
dialog.get_by_role("listitem").first.wait_for(timeout=15_000)
|
||||
dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
|
||||
except self._timeout_error as err:
|
||||
# a version resolve found on BGG can't be missing from the
|
||||
# picker: an unrendered list means a slow page or changed markup
|
||||
@@ -347,18 +528,28 @@ class PlaywrightUploader:
|
||||
"version picker never rendered — site slow or markup "
|
||||
"changed; attempt is retryable"
|
||||
) from err
|
||||
for _ in range(MAX_VERSION_PAGES):
|
||||
items = dialog.get_by_role("listitem").filter(has_text=pattern)
|
||||
if items.count():
|
||||
items.first.click()
|
||||
return True
|
||||
# Pagination controls are UNVERIFIED (docs/bgg-upload-flow.md);
|
||||
# best guess is a next-page button, stopping when absent/disabled.
|
||||
nxt = dialog.get_by_role("button", name=re.compile("next|›|»", re.I)).first
|
||||
if nxt.count() == 0 or nxt.is_disabled():
|
||||
break # genuine end of list: added_no_version is honest
|
||||
nxt.click()
|
||||
self._page.wait_for_timeout(2_000) # etiquette: paginating hits BGG too
|
||||
|
||||
wanted = f"({version_name})".casefold()
|
||||
# BGG's API name can carry a printing qualifier the picker omits
|
||||
# ("English edition 2018-2" vs "(English edition) (2018)")
|
||||
relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold()
|
||||
self._goto_first_page(dialog) # a fresh sub-view opens on page 1,
|
||||
# but never assume it: paging state outlives a close/reopen
|
||||
exact: list[tuple[int, str]] = []
|
||||
loose: list[tuple[int, str]] = []
|
||||
pages = 0
|
||||
for page_ix in range(MAX_VERSION_PAGES):
|
||||
pages = page_ix + 1
|
||||
for text in self._rows_on_page(dialog):
|
||||
folded = text.casefold()
|
||||
if wanted in folded:
|
||||
exact.append((page_ix, text))
|
||||
elif relaxed != wanted and relaxed in folded:
|
||||
loose.append((page_ix, text))
|
||||
if not self._has_next_page(dialog):
|
||||
break
|
||||
self._visible(dialog, self._NEXT_PAGE).click()
|
||||
self._page.wait_for_timeout(400) # client-side paging: no request
|
||||
else:
|
||||
# never saw the end of the list: "not in picker" would be a false
|
||||
# verdict frozen into DONE_STATUSES
|
||||
@@ -366,61 +557,182 @@ class PlaywrightUploader:
|
||||
f"hit MAX_VERSION_PAGES ({MAX_VERSION_PAGES}) without "
|
||||
"finding the version or the end of the list — retryable"
|
||||
)
|
||||
|
||||
hits = exact or loose
|
||||
if len(hits) == 1:
|
||||
_, text = hits[0]
|
||||
if not self._click_row(dialog, text):
|
||||
raise RuntimeError(
|
||||
f"{text!r} was listed a moment ago but vanished on the "
|
||||
"second pass — retryable"
|
||||
)
|
||||
how = "" if exact else f" (matched loosely as {text!r})"
|
||||
return True, how
|
||||
|
||||
# Two-level dismissal: the sub-view has its own Cancel distinct from
|
||||
# the main dialog's.
|
||||
dialog.get_by_role("button", name="Cancel").first.click()
|
||||
if not hits:
|
||||
return False, f"not offered in the picker ({pages} page(s) scanned)"
|
||||
return False, (
|
||||
f"{len(hits)} versions match {version_name!r} "
|
||||
f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess"
|
||||
)
|
||||
|
||||
def _goto_first_page(self, dialog) -> None:
|
||||
"""Back to page 1 via the numbered "1" anchor. Closing and
|
||||
reopening the sub-view does NOT reset Angular's paging state (it
|
||||
reopens wherever it was left), and First/Prev render in a
|
||||
mobile-only variant that a desktop viewport can never click."""
|
||||
pager = dialog.locator(self._PAGE_LINKS)
|
||||
for i in range(pager.count()):
|
||||
anchor = pager.nth(i)
|
||||
if anchor.is_visible() and (
|
||||
" ".join((anchor.text_content() or "").split()) == "1"
|
||||
):
|
||||
anchor.click()
|
||||
self._page.wait_for_timeout(400)
|
||||
return
|
||||
# no pagination rendered: a single-page list is already page 1
|
||||
|
||||
def _click_row(self, dialog, text: str) -> bool:
|
||||
"""Find the row again by its NORMALIZED text and click it by index.
|
||||
Playwright's has_text regex matches raw textContent — whose tabs and
|
||||
newlines a normalized capture will never equal — so the comparison
|
||||
happens in Python, and the click addresses a position."""
|
||||
self._goto_first_page(dialog)
|
||||
for _ in range(MAX_VERSION_PAGES):
|
||||
for i, row_text in enumerate(self._rows_on_page(dialog)):
|
||||
if row_text == text:
|
||||
dialog.locator(self._VERSION_ROWS).nth(i).click()
|
||||
return True
|
||||
if not self._has_next_page(dialog):
|
||||
return False
|
||||
self._visible(dialog, self._NEXT_PAGE).click()
|
||||
self._page.wait_for_timeout(400)
|
||||
return False
|
||||
|
||||
def _owned_button(self):
|
||||
"""The owned-game page replaces "Add To" with "In Collections" —
|
||||
positive evidence the collection already holds this game."""
|
||||
return self._page.get_by_role("button", name="In Collections")
|
||||
|
||||
def add_game(self, job: UploadJob) -> tuple[str, str]:
|
||||
self._ensure_logged_in()
|
||||
page = self._page
|
||||
# /boardgame/<id> redirects to the canonical slug for any subtype.
|
||||
self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
|
||||
add_btn = page.get_by_role("button", name="Add To").first
|
||||
dialog = self._open_dialog(add_btn)
|
||||
# Content settles when the game-name heading replaces "Loading...".
|
||||
dialog.get_by_role(
|
||||
"heading", name=re.compile(re.escape(job.name), re.I)
|
||||
).wait_for(timeout=15_000)
|
||||
dialog.get_by_label("Own").check()
|
||||
add_btn = page.get_by_role("button", name="Add To")
|
||||
for _ in range(60): # the header hydrates late: poll for EITHER state
|
||||
if add_btn.count() and add_btn.first.is_visible():
|
||||
break
|
||||
owned = self._owned_button()
|
||||
if owned.count() and owned.first.is_visible():
|
||||
if job.second_copy:
|
||||
# the second-copy path goes through the In Collections
|
||||
# dialog, which this code has never driven live
|
||||
raise RuntimeError(
|
||||
"second copy of an owned game — the In Collections "
|
||||
"add-a-copy flow is unverified; add this copy by hand"
|
||||
)
|
||||
return (
|
||||
"already_present",
|
||||
"the site already lists this game as owned — a previous "
|
||||
"attempt likely landed without reaching the log",
|
||||
)
|
||||
page.wait_for_timeout(500)
|
||||
dialog = self._open_dialog(add_btn.first)
|
||||
# Wait for the form itself, NOT for a heading matching our stored
|
||||
# name: a match made through an ALTERNATE name (BGG 140509 is
|
||||
# "Dungeons & Dragons" to search, "Dragones Y Mazmorras" on the
|
||||
# page) would never show it. The /boardgame/<id>/ URL already
|
||||
# guarantees which game this is.
|
||||
dialog.get_by_role("checkbox", name="Own", exact=True).wait_for(timeout=15_000)
|
||||
# exact: "Own" is a substring of "Prev. Owned", and a loose label
|
||||
# match resolves to both checkboxes (strict-mode violation)
|
||||
dialog.get_by_role("checkbox", name="Own", exact=True).check()
|
||||
status, note = "added", ""
|
||||
if job.version_name and not self._select_version(dialog, job.version_name):
|
||||
if job.version_name:
|
||||
picked, why = self._select_version(dialog, job.version_name)
|
||||
if picked:
|
||||
note = why.strip()
|
||||
else:
|
||||
status = "added_no_version"
|
||||
note = f"version {job.version_name!r} not in picker; added without version"
|
||||
note = f"version {job.version_name!r}: {why}; added without version"
|
||||
dialog.get_by_role("button", name="Save").click()
|
||||
# The dialog is hidden after save, not removed from the DOM.
|
||||
try:
|
||||
dialog.wait_for(state="hidden", timeout=15_000)
|
||||
except self._timeout_error:
|
||||
# a slow hide is not a failed save: reload and ask the page.
|
||||
# Logging "failed" for a landed add would double-add on retry.
|
||||
self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
|
||||
owned = self._owned_button()
|
||||
try:
|
||||
owned.first.wait_for(timeout=15_000)
|
||||
except self._timeout_error as err:
|
||||
raise RuntimeError(
|
||||
"the save dialog never closed and the page does not "
|
||||
"show the game as owned — the add may not have landed"
|
||||
) from err
|
||||
note = (note + "; " if note else "") + "save confirmed via page reload"
|
||||
return status, note
|
||||
|
||||
def update_entry(self, job: UploadJob) -> tuple[str, str]:
|
||||
"""Set the version on an EXISTING entry — strictly additive.
|
||||
|
||||
UNVERIFIED FLOW: the collection table can't target a collid directly,
|
||||
so this filters by game and opens the row's status link. With several
|
||||
copies of one game the wrong row could open — acceptable only while
|
||||
every 2018 entry is version-less; revisit after the first real run.
|
||||
Verified 2026-08-06. The collection table's version cell carries its
|
||||
own collid in an onclick and opens an inline editor whose radios
|
||||
carry VERSION IDS as their values, so both the copy and the edition
|
||||
are addressed exactly — no name matching, no dialog, no pagination.
|
||||
Clicking a radio fires CE_SaveData itself; there is no Save button.
|
||||
"""
|
||||
self._ensure_logged_in()
|
||||
page = self._page
|
||||
self._goto(
|
||||
f"{BGG}/collection/user/{self._username}?objectid={job.bgg_id}&own=1"
|
||||
)
|
||||
row = (
|
||||
page.get_by_role("row")
|
||||
.filter(has_text=re.compile(re.escape(job.name), re.I))
|
||||
.first
|
||||
)
|
||||
opener = row.get_by_role("link", name=re.compile("own", re.I)).first
|
||||
dialog = self._open_dialog(opener)
|
||||
if not self._select_version(dialog, job.version_name):
|
||||
# Never guess a version: close without touching the entry.
|
||||
dialog.get_by_role("button", name="Cancel").first.click()
|
||||
cell = page.locator(f'td.collection_version[onclick*="{job.collid}"]')
|
||||
if cell.count() == 0:
|
||||
raise RuntimeError(
|
||||
f"version {job.version_name!r} not in picker — entry left untouched"
|
||||
f"collid {job.collid} has no version cell on the collection "
|
||||
"page — the entry may have been removed; re-run diff"
|
||||
)
|
||||
cell.first.click()
|
||||
radio = page.locator(
|
||||
f'form[id^="form_version"] input[type="radio"][value="{job.version_id}"]'
|
||||
)
|
||||
try:
|
||||
radio.first.wait_for(timeout=15_000)
|
||||
except self._timeout_error as err:
|
||||
# the editor lists every version of the game: an absent id means
|
||||
# the wrong game's editor opened, or BGG retired that version
|
||||
page.keyboard.press("Escape")
|
||||
raise RuntimeError(
|
||||
f"version {job.version_id} not offered for collid "
|
||||
f"{job.collid} — entry left untouched"
|
||||
) from err
|
||||
radio.first.click() # fires CE_SaveData: no separate Save button
|
||||
want = _loose_version_text(job.version_name)
|
||||
for _ in range(40):
|
||||
settled = " ".join((cell.first.text_content() or "").split())
|
||||
if settled and "editing" not in settled.casefold():
|
||||
got = _loose_version_text(settled)
|
||||
if want and want not in got and got not in want:
|
||||
# the editor closed but re-rendered its OLD content:
|
||||
# the AJAX save failed server-side. "updated" here
|
||||
# would mark the job done forever without evidence.
|
||||
raise RuntimeError(
|
||||
f"the version cell settled on {settled!r}, not the "
|
||||
f"chosen {job.version_name!r} — the save did not "
|
||||
"land; safe to retry (same radio, same result)"
|
||||
)
|
||||
return "updated", ""
|
||||
page.wait_for_timeout(500)
|
||||
raise RuntimeError(
|
||||
"the version cell never left its editing state — the save may "
|
||||
"not have landed; re-run with --verify"
|
||||
)
|
||||
dialog.get_by_role("button", name="Save").click()
|
||||
dialog.wait_for(state="hidden", timeout=15_000)
|
||||
return "updated", "row-edit flow is unverified; confirm with --verify"
|
||||
|
||||
|
||||
def _process(
|
||||
@@ -468,7 +780,7 @@ def _process(
|
||||
suffix = f" — {note}" if note else ""
|
||||
typer.echo(f" {job.name}: {status}{suffix}")
|
||||
if status == "failed":
|
||||
kind = note.split(":", 1)[0] # exception type from _scrub format
|
||||
kind = note # IDENTICAL means the whole message, not the class
|
||||
consecutive = (kind, consecutive[1] + 1 if kind == consecutive[0] else 1)
|
||||
if consecutive[1] >= 3:
|
||||
typer.echo(
|
||||
@@ -603,7 +915,19 @@ def run_upload(
|
||||
to_update = _read_csv(cfg.to_update_path)
|
||||
log_rows = _read_csv(log_path)
|
||||
|
||||
jobs, skipped_done, skipped_failed, deferred = build_queue(
|
||||
# The queue is a snapshot; review decisions since the last diff win.
|
||||
stale = stale_jobs(to_add + to_update, read_matches(cfg.matches_path))
|
||||
if stale:
|
||||
to_add = [r for r in to_add if r.get("bgg_id") not in stale]
|
||||
to_update = [r for r in to_update if r.get("bgg_id") not in stale]
|
||||
typer.echo(
|
||||
f"Skipping {len(stale)} queued game(s) whose review decision "
|
||||
"changed since the last diff — re-run diff to refresh the queue:"
|
||||
)
|
||||
for bgg_id, why in stale.items():
|
||||
typer.echo(f" {bgg_id}: {why}")
|
||||
|
||||
jobs, skipped_done, skipped_failed = build_queue(
|
||||
to_add, to_update, log_rows, retry_failed=retry_failed
|
||||
)
|
||||
if limit is not None:
|
||||
@@ -615,12 +939,6 @@ def run_upload(
|
||||
f" (skipping {skipped_done} already done, {skipped_failed} "
|
||||
"previously failed — use --retry-failed)"
|
||||
)
|
||||
if deferred:
|
||||
typer.echo(
|
||||
f"{len(deferred)} version update(s) deferred: one update per game "
|
||||
"per run (the row-edit flow can't target a collid) — re-run "
|
||||
"upload after --verify confirms this batch."
|
||||
)
|
||||
|
||||
results: list[dict] = []
|
||||
if dry_run:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
{
|
||||
"title_raw": "Catan",
|
||||
"confidence": "high",
|
||||
"source_photos": ["hand-typed-test-list"]
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "Wingspan",
|
||||
@@ -10,31 +12,113 @@
|
||||
"publisher_hint": "Stonemaier Games",
|
||||
"year_hint": 2019,
|
||||
"language_hint": "English",
|
||||
"source_photos": ["hand-typed-test-list"]
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "Wingspan: European Expansion",
|
||||
"confidence": "high",
|
||||
"source_photos": ["hand-typed-test-list"]
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "Wingspan Europe",
|
||||
"confidence": "medium",
|
||||
"source_photos": ["hand-typed-test-list"]
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "Café International",
|
||||
"confidence": "high",
|
||||
"source_photos": ["hand-typed-test-list"]
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "Citadels",
|
||||
"confidence": "high",
|
||||
"source_photos": ["hand-typed-test-list"]
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "Blorvath: Quest of the Zzyzx",
|
||||
"confidence": "low",
|
||||
"source_photos": ["hand-typed-test-list"]
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "The Avalon Hill Game Company, Baltimore, Maryland",
|
||||
"edition_hint": "Bookcase Game",
|
||||
"language_hint": "English",
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "ADVANCED CIVILIZATION Game Expansion of the Heroic Age - Featuring New Civilization, Commodity, and Calamity Cards",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "The Avalon Hill Game Company, Baltimore, Maryland",
|
||||
"edition_hint": "Bookcase Game",
|
||||
"language_hint": "English",
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "SORCERER The Game of Magical Conflict",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Simulations Publications Incorporated (SPI)",
|
||||
"edition_hint": "Designer's Edition",
|
||||
"language_hint": "English",
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century",
|
||||
"confidence": "high",
|
||||
"publisher_hint": "Simulations Publications Incorporated (SPI)",
|
||||
"edition_hint": "Designer's Edition",
|
||||
"language_hint": "English",
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "FLAT TOP",
|
||||
"confidence": "high",
|
||||
"year_hint": 1942,
|
||||
"language_hint": "English",
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "ALICE IS MISSING: A SILENT ROLE PLAYING GAME",
|
||||
"confidence": "high",
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "Dungeon!",
|
||||
"confidence": "high",
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title_raw": "WIZ-WAR",
|
||||
"confidence": "high",
|
||||
"source_photos": [
|
||||
"hand-typed-test-list"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This cache contains hand-written stub XML, not real BGG responses. Data resolved from it must not be uploaded.
|
||||
@@ -1 +0,0 @@
|
||||
<items total="1"><item type="boardgame" id="207830"><name type="primary" value="5-Minute Dungeon"/><yearpublished value="2017"/></item></items>
|
||||
@@ -1 +0,0 @@
|
||||
<items total="0"></items>
|
||||
@@ -1 +0,0 @@
|
||||
<items total="0"></items>
|
||||
@@ -1 +1 @@
|
||||
<items total="0"></items>
|
||||
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
|
||||
@@ -1 +1 @@
|
||||
<items total="0"></items>
|
||||
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
|
||||
@@ -1 +0,0 @@
|
||||
<items total="0"></items>
|
||||
@@ -1 +1,9 @@
|
||||
<items total="1"><item type="boardgameexpansion" id="177"><name type="primary" value="Advanced Civilization"/><yearpublished value="1991"/></item></items>
|
||||
<?xml version="1.0" encoding="utf-8"?><items total="2" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> <item type="boardgame" id="177">
|
||||
<name type="primary" value="Advanced Civilization"/>
|
||||
<yearpublished value="1991" />
|
||||
</item>
|
||||
<item type="boardgameexpansion" id="177">
|
||||
<name type="primary" value="Advanced Civilization"/>
|
||||
<yearpublished value="1991" />
|
||||
</item>
|
||||
</items>
|
||||
@@ -1 +0,0 @@
|
||||
<items total="0"></items>
|
||||
@@ -1 +1 @@
|
||||
<items total="0"></items>
|
||||
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
|
||||
@@ -1 +1 @@
|
||||
<items total="1"><item type="rpgitem" id="400001"><name type="primary" value="Alice Is Missing: A Silent Role Playing Game"/><yearpublished value="2020"/></item></items>
|
||||
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
|
||||
@@ -1 +1 @@
|
||||
<items total="0"></items>
|
||||
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
|
||||
@@ -1 +0,0 @@
|
||||
<items total="0"></items>
|
||||
@@ -1 +0,0 @@
|
||||
<items total="0"></items>
|
||||
@@ -1 +0,0 @@
|
||||
<items total="1"><item type="rpgitem" id="400002"><name type="primary" value="Alice Is Missing: Silent Falls Expansion"/><yearpublished value="2023"/></item></items>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><items total="0" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse"> </items>
|
||||