Compare commits
@@ -3,6 +3,8 @@
|
|||||||
*.storage_state.json
|
*.storage_state.json
|
||||||
data/.lan_key
|
data/.lan_key
|
||||||
data/.own_repo
|
data/.own_repo
|
||||||
|
# BGG's badge asset is licensed to YOUR registered app - get your own
|
||||||
|
data/powered-by-bgg.png
|
||||||
playwright/.auth/
|
playwright/.auth/
|
||||||
storage_state.json
|
storage_state.json
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 20456ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:8399/api/pipeline:0
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- link "Skip to content" [ref=e2] [cursor=pointer]:
|
||||||
|
- /url: "#main"
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- text: bggpipe
|
||||||
|
- generic [ref=e6]: shelf → BGG pipeline
|
||||||
|
- navigation "Primary" [ref=e7]:
|
||||||
|
- link "Pipeline" [ref=e8] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- link "Photos 4" [ref=e9] [cursor=pointer]:
|
||||||
|
- /url: /photos
|
||||||
|
- text: Photos
|
||||||
|
- generic [ref=e10]: "4"
|
||||||
|
- link "Titles" [ref=e11] [cursor=pointer]:
|
||||||
|
- /url: /titles
|
||||||
|
- link "Review" [ref=e12] [cursor=pointer]:
|
||||||
|
- /url: /review
|
||||||
|
- link "Queue" [ref=e13] [cursor=pointer]:
|
||||||
|
- /url: /queue
|
||||||
|
- link "Library" [ref=e14] [cursor=pointer]:
|
||||||
|
- /url: /library
|
||||||
|
- link "Shelves" [ref=e15] [cursor=pointer]:
|
||||||
|
- /url: /shelves
|
||||||
|
- link "Help" [ref=e16] [cursor=pointer]:
|
||||||
|
- /url: /help
|
||||||
|
- figure "art by Juniper" [ref=e17]:
|
||||||
|
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
|
||||||
|
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- status
|
||||||
|
- main [ref=e22]:
|
||||||
|
- heading "Shelves" [level=1] [ref=e23]
|
||||||
|
- heading "Add a unit" [level=2] [ref=e24]
|
||||||
|
- generic [ref=e25]:
|
||||||
|
- generic [ref=e26]:
|
||||||
|
- text: Unit name
|
||||||
|
- textbox "Unit name" [ref=e27]:
|
||||||
|
- /placeholder: Living room Kallax
|
||||||
|
- generic [ref=e28]:
|
||||||
|
- text: Preset
|
||||||
|
- combobox "Preset" [ref=e29]:
|
||||||
|
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
|
||||||
|
- option "IKEA Billy shelf (30.75 × 13 × 11)"
|
||||||
|
- option "custom size"
|
||||||
|
- option "no size — travel case, lent out…"
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=e31]: "1"
|
||||||
|
- generic [ref=e32]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=e33]: "1"
|
||||||
|
- generic [ref=e34]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=e35]:
|
||||||
|
- /placeholder: party games
|
||||||
|
- generic [ref=e36]:
|
||||||
|
- text: Description
|
||||||
|
- textbox "Description" [ref=e37]:
|
||||||
|
- /placeholder: the wall behind the couch — main game storage
|
||||||
|
- button "create" [ref=e39] [cursor=pointer]
|
||||||
|
- generic [ref=e40]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
|
||||||
|
- paragraph [ref=e42]: Loading…
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- link "Skip to content" [ref=e2] [cursor=pointer]:
|
||||||
|
- /url: "#main"
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- text: bggpipe
|
||||||
|
- generic [ref=e6]: shelf → BGG pipeline
|
||||||
|
- navigation "Primary" [ref=e7]:
|
||||||
|
- link "Pipeline" [ref=e8] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- link "Photos 4" [ref=e9] [cursor=pointer]:
|
||||||
|
- /url: /photos
|
||||||
|
- text: Photos
|
||||||
|
- generic [ref=e10]: "4"
|
||||||
|
- link "Titles" [ref=e11] [cursor=pointer]:
|
||||||
|
- /url: /titles
|
||||||
|
- link "Review" [ref=e12] [cursor=pointer]:
|
||||||
|
- /url: /review
|
||||||
|
- link "Queue" [ref=e13] [cursor=pointer]:
|
||||||
|
- /url: /queue
|
||||||
|
- link "Library" [ref=e14] [cursor=pointer]:
|
||||||
|
- /url: /library
|
||||||
|
- link "Shelves" [ref=e15] [cursor=pointer]:
|
||||||
|
- /url: /shelves
|
||||||
|
- link "Help" [ref=e16] [cursor=pointer]:
|
||||||
|
- /url: /help
|
||||||
|
- figure "art by Juniper" [ref=e17]:
|
||||||
|
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
|
||||||
|
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- status
|
||||||
|
- main [ref=e22]:
|
||||||
|
- heading "Shelves" [level=1] [ref=e23]
|
||||||
|
- heading "Add a unit" [level=2] [ref=e24]
|
||||||
|
- generic [ref=e25]:
|
||||||
|
- generic [ref=e26]:
|
||||||
|
- text: Unit name
|
||||||
|
- textbox "Unit name" [ref=e27]:
|
||||||
|
- /placeholder: Living room Kallax
|
||||||
|
- generic [ref=e28]:
|
||||||
|
- text: Preset
|
||||||
|
- combobox "Preset" [ref=e29]:
|
||||||
|
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
|
||||||
|
- option "IKEA Billy shelf (30.75 × 13 × 11)"
|
||||||
|
- option "custom size"
|
||||||
|
- option "no size — travel case, lent out…"
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=e31]: "1"
|
||||||
|
- generic [ref=e32]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=e33]: "1"
|
||||||
|
- generic [ref=e34]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=e35]:
|
||||||
|
- /placeholder: party games
|
||||||
|
- generic [ref=e36]:
|
||||||
|
- text: Description
|
||||||
|
- textbox "Description" [ref=e37]:
|
||||||
|
- /placeholder: the wall behind the couch — main game storage
|
||||||
|
- button "create" [ref=e39] [cursor=pointer]
|
||||||
|
- generic [ref=e40]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
|
||||||
|
- paragraph [ref=e42]: Loading…
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- link "Skip to content" [ref=e2] [cursor=pointer]:
|
||||||
|
- /url: "#main"
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- text: bggpipe
|
||||||
|
- generic [ref=e6]: shelf → BGG pipeline
|
||||||
|
- navigation "Primary" [ref=e7]:
|
||||||
|
- link "Pipeline" [ref=e8] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- link "Photos 4" [ref=e9] [cursor=pointer]:
|
||||||
|
- /url: /photos
|
||||||
|
- text: Photos
|
||||||
|
- generic [ref=e10]: "4"
|
||||||
|
- link "Titles" [ref=e11] [cursor=pointer]:
|
||||||
|
- /url: /titles
|
||||||
|
- link "Review" [ref=e12] [cursor=pointer]:
|
||||||
|
- /url: /review
|
||||||
|
- link "Queue" [ref=e13] [cursor=pointer]:
|
||||||
|
- /url: /queue
|
||||||
|
- link "Library" [ref=e14] [cursor=pointer]:
|
||||||
|
- /url: /library
|
||||||
|
- link "Shelves" [ref=e15] [cursor=pointer]:
|
||||||
|
- /url: /shelves
|
||||||
|
- link "Help" [ref=e16] [cursor=pointer]:
|
||||||
|
- /url: /help
|
||||||
|
- figure "art by Juniper" [ref=e17]:
|
||||||
|
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
|
||||||
|
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- status
|
||||||
|
- main [ref=e22]:
|
||||||
|
- heading "Shelves" [level=1] [ref=e23]
|
||||||
|
- heading "Add a unit" [level=2] [ref=e24]
|
||||||
|
- generic [ref=e25]:
|
||||||
|
- generic [ref=e26]:
|
||||||
|
- text: Unit name
|
||||||
|
- textbox "Unit name" [ref=e27]:
|
||||||
|
- /placeholder: Living room Kallax
|
||||||
|
- generic [ref=e28]:
|
||||||
|
- text: Preset
|
||||||
|
- combobox "Preset" [ref=e29]:
|
||||||
|
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
|
||||||
|
- option "IKEA Billy shelf (30.75 × 13 × 11)"
|
||||||
|
- option "custom size"
|
||||||
|
- option "no size — travel case, lent out…"
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=e31]: "1"
|
||||||
|
- generic [ref=e32]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=e33]: "1"
|
||||||
|
- generic [ref=e34]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=e35]:
|
||||||
|
- /placeholder: party games
|
||||||
|
- generic [ref=e36]:
|
||||||
|
- text: Description
|
||||||
|
- textbox "Description" [ref=e37]:
|
||||||
|
- /placeholder: the wall behind the couch — main game storage
|
||||||
|
- button "create" [ref=e39] [cursor=pointer]
|
||||||
|
- generic [ref=e40]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
|
||||||
|
- paragraph [ref=e42]: Loading…
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
- generic [active] [ref=f1e1]:
|
||||||
|
- link "Skip to content" [ref=f1e2] [cursor=pointer]:
|
||||||
|
- /url: "#main"
|
||||||
|
- generic [ref=f1e3]:
|
||||||
|
- link "bggpipe shelf → BGG pipeline" [ref=f1e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- generic [ref=f1e5]:
|
||||||
|
- text: bggpipe
|
||||||
|
- generic [ref=f1e6]: shelf → BGG pipeline
|
||||||
|
- navigation "Primary" [ref=f1e7]:
|
||||||
|
- link "Pipeline" [ref=f1e8] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- link "Photos 4" [ref=f1e9] [cursor=pointer]:
|
||||||
|
- /url: /photos
|
||||||
|
- text: Photos
|
||||||
|
- generic [ref=f1e10]: "4"
|
||||||
|
- link "Titles" [ref=f1e11] [cursor=pointer]:
|
||||||
|
- /url: /titles
|
||||||
|
- link "Review" [ref=f1e12] [cursor=pointer]:
|
||||||
|
- /url: /review
|
||||||
|
- link "Queue" [ref=f1e13] [cursor=pointer]:
|
||||||
|
- /url: /queue
|
||||||
|
- link "Library" [ref=f1e14] [cursor=pointer]:
|
||||||
|
- /url: /library
|
||||||
|
- link "Shelves" [ref=f1e15] [cursor=pointer]:
|
||||||
|
- /url: /shelves
|
||||||
|
- link "Help" [ref=f1e16] [cursor=pointer]:
|
||||||
|
- /url: /help
|
||||||
|
- figure "art by Juniper" [ref=f1e17]:
|
||||||
|
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=f1e18]
|
||||||
|
- generic [ref=f1e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
|
||||||
|
- generic [ref=f1e21]:
|
||||||
|
- status
|
||||||
|
- main [ref=f1e22]:
|
||||||
|
- heading "Shelves" [level=1] [ref=f1e23]
|
||||||
|
- generic [ref=f1e24]: 3 unit(s), 27 opening(s), 8 game(s) unshelved
|
||||||
|
- heading "Add a unit" [level=2] [ref=f1e26]
|
||||||
|
- generic [ref=f1e27]:
|
||||||
|
- generic [ref=f1e28]:
|
||||||
|
- text: Unit name
|
||||||
|
- textbox "Unit name" [ref=f1e29]:
|
||||||
|
- /placeholder: Living room Kallax
|
||||||
|
- generic [ref=f1e30]:
|
||||||
|
- text: Preset
|
||||||
|
- combobox "Preset" [ref=f1e31]:
|
||||||
|
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
|
||||||
|
- option "IKEA Billy shelf (30.75 × 13 × 11)"
|
||||||
|
- option "custom size"
|
||||||
|
- option "no size — travel case, lent out…"
|
||||||
|
- generic [ref=f1e32]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=f1e33]: "1"
|
||||||
|
- generic [ref=f1e34]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=f1e35]: "1"
|
||||||
|
- generic [ref=f1e36]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=f1e37]:
|
||||||
|
- /placeholder: party games
|
||||||
|
- generic [ref=f1e38]:
|
||||||
|
- text: Description
|
||||||
|
- textbox "Description" [ref=f1e39]:
|
||||||
|
- /placeholder: the wall behind the couch — main game storage
|
||||||
|
- button "create" [ref=f1e41] [cursor=pointer]
|
||||||
|
- generic [ref=f1e42]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
|
||||||
|
- generic [ref=f1e43]:
|
||||||
|
- generic [ref=f1e44]:
|
||||||
|
- generic [ref=f1e45]:
|
||||||
|
- heading "Library Kallax" [level=2] [ref=f1e46]
|
||||||
|
- generic [ref=f1e47]:
|
||||||
|
- button "duplicate" [ref=f1e48] [cursor=pointer]
|
||||||
|
- button "rename" [ref=f1e49] [cursor=pointer]
|
||||||
|
- button "remove unit" [ref=f1e50] [cursor=pointer]
|
||||||
|
- paragraph [ref=f1e51]:
|
||||||
|
- generic [ref=f1e52]: Centerpiece of the game wall in the library
|
||||||
|
- button "edit description" [ref=f1e53] [cursor=pointer]
|
||||||
|
- generic [ref=f1e54]:
|
||||||
|
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e56] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e57]: Top
|
||||||
|
- generic [ref=f1e58]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e59]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- generic [ref=f1e61]:
|
||||||
|
- button "A1 oversize 7 · 1 unmeasured flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f1e62] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e63]: A1
|
||||||
|
- generic [ref=f1e64]: oversize
|
||||||
|
- generic [ref=f1e65]: 7 · 1 unmeasured
|
||||||
|
- img "flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f1e66]:
|
||||||
|
- 'generic "flat stack: 99% of interior height" [ref=f1e67]'
|
||||||
|
- button "A2 oversize 6 · 1 unmeasured flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f1e70] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e71]: A2
|
||||||
|
- generic [ref=f1e72]: oversize
|
||||||
|
- generic [ref=f1e73]: 6 · 1 unmeasured
|
||||||
|
- img "flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f1e74]:
|
||||||
|
- 'generic "flat stack: 89% of interior height" [ref=f1e75]'
|
||||||
|
- generic [ref=f1e78]:
|
||||||
|
- 'button "B1 no way to pack: Meteor party 7 flat stack 98% of height, standing lane 0% of width, overfull" [ref=f1e79] [cursor=pointer]':
|
||||||
|
- generic [ref=f1e80]:
|
||||||
|
- text: B1
|
||||||
|
- 'img "no way to pack: Meteor" [ref=f1e81]': ⚠
|
||||||
|
- generic [ref=f1e82]: party
|
||||||
|
- generic [ref=f1e83]: "7"
|
||||||
|
- img "flat stack 98% of height, standing lane 0% of width, overfull" [ref=f1e84]:
|
||||||
|
- 'generic "flat stack: 98% of interior height" [ref=f1e85]'
|
||||||
|
- 'button "B2 no way to pack: The Red Dragon Inn Smorgasbox party 5 flat stack 89% of height, standing lane 0% of width, overfull" [ref=f1e87] [cursor=pointer]':
|
||||||
|
- generic [ref=f1e88]:
|
||||||
|
- text: B2
|
||||||
|
- 'img "no way to pack: The Red Dragon Inn Smorgasbox" [ref=f1e89]': ⚠
|
||||||
|
- generic [ref=f1e90]: party
|
||||||
|
- generic [ref=f1e91]: "5"
|
||||||
|
- img "flat stack 89% of height, standing lane 0% of width, overfull" [ref=f1e92]:
|
||||||
|
- 'generic "flat stack: 89% of interior height" [ref=f1e93]'
|
||||||
|
- button "B3 party 16 · 8 unmeasured flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f1e95] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e96]: B3
|
||||||
|
- generic [ref=f1e97]: party
|
||||||
|
- generic [ref=f1e98]: 16 · 8 unmeasured
|
||||||
|
- img "flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f1e99]:
|
||||||
|
- 'generic "flat stack: 36% of interior height" [ref=f1e100]'
|
||||||
|
- button "B4 two-player 6 · 1 unmeasured flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f1e103] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e104]: B4
|
||||||
|
- generic [ref=f1e105]: two-player
|
||||||
|
- generic [ref=f1e106]: 6 · 1 unmeasured
|
||||||
|
- img "flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f1e107]:
|
||||||
|
- 'generic "flat stack: 39% of interior height" [ref=f1e108]'
|
||||||
|
- generic [ref=f1e111]:
|
||||||
|
- 'button "C1 no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan general 12 · 3 unmeasured flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e112] [cursor=pointer]':
|
||||||
|
- generic [ref=f1e113]:
|
||||||
|
- text: C1
|
||||||
|
- 'img "no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan" [ref=f1e114]': ⚠
|
||||||
|
- generic [ref=f1e115]: general
|
||||||
|
- generic [ref=f1e116]: 12 · 3 unmeasured
|
||||||
|
- img "flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e117]:
|
||||||
|
- 'generic "flat stack: 100% of interior height" [ref=f1e118]'
|
||||||
|
- 'button "C2 no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens general 14 · 3 unmeasured flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e121] [cursor=pointer]':
|
||||||
|
- generic [ref=f1e122]:
|
||||||
|
- text: C2
|
||||||
|
- 'img "no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens" [ref=f1e123]': ⚠
|
||||||
|
- generic [ref=f1e124]: general
|
||||||
|
- generic [ref=f1e125]: 14 · 3 unmeasured
|
||||||
|
- img "flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e126]:
|
||||||
|
- 'generic "flat stack: 95% of interior height" [ref=f1e127]'
|
||||||
|
- 'button "C3 no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine general 14 · 3 unmeasured flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e130] [cursor=pointer]':
|
||||||
|
- generic [ref=f1e131]:
|
||||||
|
- text: C3
|
||||||
|
- 'img "no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine" [ref=f1e132]': ⚠
|
||||||
|
- generic [ref=f1e133]: general
|
||||||
|
- generic [ref=f1e134]: 14 · 3 unmeasured
|
||||||
|
- img "flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e135]:
|
||||||
|
- 'generic "flat stack: 97% of interior height" [ref=f1e136]'
|
||||||
|
- 'button "C4 no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls general 13 · 4 unmeasured flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f1e139] [cursor=pointer]':
|
||||||
|
- generic [ref=f1e140]:
|
||||||
|
- text: C4
|
||||||
|
- 'img "no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls" [ref=f1e141]': ⚠
|
||||||
|
- generic [ref=f1e142]: general
|
||||||
|
- generic [ref=f1e143]: 13 · 4 unmeasured
|
||||||
|
- img "flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f1e144]:
|
||||||
|
- 'generic "flat stack: 90% of interior height" [ref=f1e145]'
|
||||||
|
- generic [ref=f1e148]:
|
||||||
|
- 'button "D1 no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank! general 11 · 2 unmeasured flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f1e149] [cursor=pointer]':
|
||||||
|
- generic [ref=f1e150]:
|
||||||
|
- text: D1
|
||||||
|
- 'img "no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank!" [ref=f1e151]': ⚠
|
||||||
|
- generic [ref=f1e152]: general
|
||||||
|
- generic [ref=f1e153]: 11 · 2 unmeasured
|
||||||
|
- img "flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f1e154]:
|
||||||
|
- 'generic "flat stack: 94% of interior height" [ref=f1e155]'
|
||||||
|
- button "D2 vintage wargames 7 flat stack 75% of height, standing lane 100% of width" [ref=f1e158] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e159]: D2
|
||||||
|
- generic [ref=f1e160]: vintage wargames
|
||||||
|
- generic [ref=f1e161]: "7"
|
||||||
|
- img "flat stack 75% of height, standing lane 100% of width" [ref=f1e162]:
|
||||||
|
- 'generic "flat stack: 75% of interior height" [ref=f1e163]'
|
||||||
|
- button "D3 heavy 3 flat stack 42% of height, standing lane 94% of width" [ref=f1e166] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e167]: D3
|
||||||
|
- generic [ref=f1e168]: heavy
|
||||||
|
- generic [ref=f1e169]: "3"
|
||||||
|
- img "flat stack 42% of height, standing lane 94% of width" [ref=f1e170]:
|
||||||
|
- 'generic "flat stack: 42% of interior height" [ref=f1e171]'
|
||||||
|
- button "D4 campaign 4 flat stack 83% of height, standing lane 92% of width" [ref=f1e174] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e175]: D4
|
||||||
|
- generic [ref=f1e176]: campaign
|
||||||
|
- generic [ref=f1e177]: "4"
|
||||||
|
- img "flat stack 83% of height, standing lane 92% of width" [ref=f1e178]:
|
||||||
|
- 'generic "flat stack: 83% of interior height" [ref=f1e179]'
|
||||||
|
- paragraph [ref=f1e182]: Add an opening
|
||||||
|
- generic [ref=f1e183]:
|
||||||
|
- generic [ref=f1e184]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=f1e185]: "1"
|
||||||
|
- generic [ref=f1e186]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=f1e187]: "1"
|
||||||
|
- generic [ref=f1e188]:
|
||||||
|
- text: Label
|
||||||
|
- textbox "Label" [ref=f1e189]:
|
||||||
|
- /placeholder: wide top — for a single opening
|
||||||
|
- generic [ref=f1e190]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=f1e191]
|
||||||
|
- generic [ref=f1e192]:
|
||||||
|
- checkbox "at the top" [ref=f1e193]
|
||||||
|
- text: at the top
|
||||||
|
- generic [ref=f1e194]:
|
||||||
|
- text: W
|
||||||
|
- textbox "W" [ref=f1e195]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f1e196]:
|
||||||
|
- text: H
|
||||||
|
- textbox "H" [ref=f1e197]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f1e198]:
|
||||||
|
- text: D
|
||||||
|
- textbox "D" [ref=f1e199]:
|
||||||
|
- /placeholder: "15.4"
|
||||||
|
- button "add" [ref=f1e201] [cursor=pointer]
|
||||||
|
- generic [ref=f1e202]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
|
||||||
|
- generic [ref=f1e203]:
|
||||||
|
- generic [ref=f1e204]:
|
||||||
|
- heading "Library Right Shelving" [level=2] [ref=f1e205]
|
||||||
|
- generic [ref=f1e206]:
|
||||||
|
- button "duplicate" [ref=f1e207] [cursor=pointer]
|
||||||
|
- button "rename" [ref=f1e208] [cursor=pointer]
|
||||||
|
- button "remove unit" [ref=f1e209] [cursor=pointer]
|
||||||
|
- paragraph [ref=f1e210]:
|
||||||
|
- generic [ref=f1e211]: To the right of the Kallax
|
||||||
|
- button "edit description" [ref=f1e212] [cursor=pointer]
|
||||||
|
- generic [ref=f1e213]:
|
||||||
|
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e215] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e216]: Top
|
||||||
|
- generic [ref=f1e217]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e218]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e221] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e222]: A1
|
||||||
|
- generic [ref=f1e223]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e224]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e227] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e228]: B1
|
||||||
|
- generic [ref=f1e229]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e230]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e233] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e234]: C1
|
||||||
|
- generic [ref=f1e235]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e236]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "D1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e239] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e240]: D1
|
||||||
|
- generic [ref=f1e241]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e242]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "E1 6 · 3 unmeasured flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f1e245] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e246]: E1
|
||||||
|
- generic [ref=f1e247]: 6 · 3 unmeasured
|
||||||
|
- img "flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f1e248]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- paragraph [ref=f1e251]: Add an opening
|
||||||
|
- generic [ref=f1e252]:
|
||||||
|
- generic [ref=f1e253]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=f1e254]: "1"
|
||||||
|
- generic [ref=f1e255]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=f1e256]: "1"
|
||||||
|
- generic [ref=f1e257]:
|
||||||
|
- text: Label
|
||||||
|
- textbox "Label" [ref=f1e258]:
|
||||||
|
- /placeholder: wide top — for a single opening
|
||||||
|
- generic [ref=f1e259]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=f1e260]
|
||||||
|
- generic [ref=f1e261]:
|
||||||
|
- checkbox "at the top" [ref=f1e262]
|
||||||
|
- text: at the top
|
||||||
|
- generic [ref=f1e263]:
|
||||||
|
- text: W
|
||||||
|
- textbox "W" [ref=f1e264]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f1e265]:
|
||||||
|
- text: H
|
||||||
|
- textbox "H" [ref=f1e266]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f1e267]:
|
||||||
|
- text: D
|
||||||
|
- textbox "D" [ref=f1e268]:
|
||||||
|
- /placeholder: "15.4"
|
||||||
|
- button "add" [ref=f1e270] [cursor=pointer]
|
||||||
|
- generic [ref=f1e271]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
|
||||||
|
- generic [ref=f1e272]:
|
||||||
|
- generic [ref=f1e273]:
|
||||||
|
- heading "Library Left Shelving" [level=2] [ref=f1e274]
|
||||||
|
- generic [ref=f1e275]:
|
||||||
|
- button "duplicate" [ref=f1e276] [cursor=pointer]
|
||||||
|
- button "rename" [ref=f1e277] [cursor=pointer]
|
||||||
|
- button "remove unit" [ref=f1e278] [cursor=pointer]
|
||||||
|
- paragraph [ref=f1e279]:
|
||||||
|
- generic [ref=f1e280]: To the left of the Kallax
|
||||||
|
- button "edit description" [ref=f1e281] [cursor=pointer]
|
||||||
|
- generic [ref=f1e282]:
|
||||||
|
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e284] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e285]: Top
|
||||||
|
- generic [ref=f1e286]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e287]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e290] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e291]: A1
|
||||||
|
- generic [ref=f1e292]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e293]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e296] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e297]: B1
|
||||||
|
- generic [ref=f1e298]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e299]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e302] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e303]: C1
|
||||||
|
- generic [ref=f1e304]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e305]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "D1 4 · 4 unmeasured flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f1e308] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e309]: D1
|
||||||
|
- generic [ref=f1e310]: 4 · 4 unmeasured
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f1e311]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "E1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e314] [cursor=pointer]:
|
||||||
|
- generic [ref=f1e315]: E1
|
||||||
|
- generic [ref=f1e316]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e317]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- paragraph [ref=f1e319]: Add an opening
|
||||||
|
- generic [ref=f1e320]:
|
||||||
|
- generic [ref=f1e321]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=f1e322]: "1"
|
||||||
|
- generic [ref=f1e323]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=f1e324]: "1"
|
||||||
|
- generic [ref=f1e325]:
|
||||||
|
- text: Label
|
||||||
|
- textbox "Label" [ref=f1e326]:
|
||||||
|
- /placeholder: wide top — for a single opening
|
||||||
|
- generic [ref=f1e327]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=f1e328]
|
||||||
|
- generic [ref=f1e329]:
|
||||||
|
- checkbox "at the top" [ref=f1e330]
|
||||||
|
- text: at the top
|
||||||
|
- generic [ref=f1e331]:
|
||||||
|
- text: W
|
||||||
|
- textbox "W" [ref=f1e332]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f1e333]:
|
||||||
|
- text: H
|
||||||
|
- textbox "H" [ref=f1e334]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f1e335]:
|
||||||
|
- text: D
|
||||||
|
- textbox "D" [ref=f1e336]:
|
||||||
|
- /placeholder: "15.4"
|
||||||
|
- button "add" [ref=f1e338] [cursor=pointer]
|
||||||
|
- generic [ref=f1e339]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
|
||||||
|
- generic [ref=f1e340]:
|
||||||
|
- heading "Unshelved" [level=2] [ref=f1e341]
|
||||||
|
- generic [ref=f1e342]:
|
||||||
|
- link "DUNGEONS & DRAGONS" [ref=f1e343] [cursor=pointer]:
|
||||||
|
- /url: /library/game/local%3Adungeons%20and%20dragons%3AIMG_4519.jpeg
|
||||||
|
- generic "no trusted size — fit can't be verified" [ref=f1e344]: unmeasured
|
||||||
|
- generic [ref=f1e345]:
|
||||||
|
- link "DUNGEONS & DRAGONS FANTASY GAME RULES" [ref=f1e346] [cursor=pointer]:
|
||||||
|
- /url: /library/game/local%3Adungeons%20and%20dragons%20fantasy%20game%20rules%3AIMG_4519.jpeg
|
||||||
|
- generic "no trusted size — fit can't be verified" [ref=f1e347]: unmeasured
|
||||||
|
- generic [ref=f1e348]:
|
||||||
|
- 'link "Etherfields: Awakened Items & Actions" [ref=f1e349] [cursor=pointer]':
|
||||||
|
- /url: /library/game/355873%3A597861
|
||||||
|
- generic [ref=f1e350]:
|
||||||
|
- button "♥ Library Right Shelving · E1" [ref=f1e351] [cursor=pointer]
|
||||||
|
- button "→ Library Right Shelving · A1" [ref=f1e352] [cursor=pointer]
|
||||||
|
- button "→ Library Left Shelving · A1" [ref=f1e353] [cursor=pointer]
|
||||||
|
- generic [ref=f1e354]:
|
||||||
|
- 'link "Etherfields: Search Mini Deck" [ref=f1e355] [cursor=pointer]':
|
||||||
|
- /url: /library/game/309202%3A507805
|
||||||
|
- generic [ref=f1e356]:
|
||||||
|
- button "♥ Library Right Shelving · E1" [ref=f1e357] [cursor=pointer]
|
||||||
|
- button "→ Library Right Shelving · A1" [ref=f1e358] [cursor=pointer]
|
||||||
|
- button "→ Library Left Shelving · A1" [ref=f1e359] [cursor=pointer]
|
||||||
|
- generic [ref=f1e360]:
|
||||||
|
- 'link "Etherfields: Shifting Story Promo Deck" [ref=f1e361] [cursor=pointer]':
|
||||||
|
- /url: /library/game/381209%3A651213
|
||||||
|
- generic [ref=f1e362]:
|
||||||
|
- button "♥ Library Right Shelving · E1" [ref=f1e363] [cursor=pointer]
|
||||||
|
- button "→ Library Right Shelving · A1" [ref=f1e364] [cursor=pointer]
|
||||||
|
- button "→ Library Left Shelving · A1" [ref=f1e365] [cursor=pointer]
|
||||||
|
- generic [ref=f1e366]:
|
||||||
|
- 'link "Etherfields: Story Dice Expansion" [ref=f1e367] [cursor=pointer]':
|
||||||
|
- /url: /library/game/386805%3A658656
|
||||||
|
- generic "no trusted size — fit can't be verified" [ref=f1e368]: unmeasured
|
||||||
|
- generic [ref=f1e369]:
|
||||||
|
- 'link "Etherfields: Thorn Knight" [ref=f1e370] [cursor=pointer]':
|
||||||
|
- /url: /library/game/291126%3A475383
|
||||||
|
- generic "no trusted size — fit can't be verified" [ref=f1e371]: unmeasured
|
||||||
|
- generic [ref=f1e372]:
|
||||||
|
- link "Frosthaven" [ref=f1e373] [cursor=pointer]:
|
||||||
|
- /url: /library/game/295770%3A484007
|
||||||
|
- generic [ref=f1e374]:
|
||||||
|
- button "→ Library Kallax · A1" [ref=f1e375] [cursor=pointer]
|
||||||
|
- button "→ Library Kallax · Top" [ref=f1e376] [cursor=pointer]
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
- generic [active] [ref=f2e1]:
|
||||||
|
- link "Skip to content" [ref=f2e2] [cursor=pointer]:
|
||||||
|
- /url: "#main"
|
||||||
|
- generic [ref=f2e3]:
|
||||||
|
- link "bggpipe shelf → BGG pipeline" [ref=f2e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- generic [ref=f2e5]:
|
||||||
|
- text: bggpipe
|
||||||
|
- generic [ref=f2e6]: shelf → BGG pipeline
|
||||||
|
- navigation "Primary" [ref=f2e7]:
|
||||||
|
- link "Pipeline" [ref=f2e8] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- link "Photos 4" [ref=f2e9] [cursor=pointer]:
|
||||||
|
- /url: /photos
|
||||||
|
- text: Photos
|
||||||
|
- generic [ref=f2e10]: "4"
|
||||||
|
- link "Titles" [ref=f2e11] [cursor=pointer]:
|
||||||
|
- /url: /titles
|
||||||
|
- link "Review" [ref=f2e12] [cursor=pointer]:
|
||||||
|
- /url: /review
|
||||||
|
- link "Queue" [ref=f2e13] [cursor=pointer]:
|
||||||
|
- /url: /queue
|
||||||
|
- link "Library" [ref=f2e14] [cursor=pointer]:
|
||||||
|
- /url: /library
|
||||||
|
- link "Shelves" [ref=f2e15] [cursor=pointer]:
|
||||||
|
- /url: /shelves
|
||||||
|
- link "Help" [ref=f2e16] [cursor=pointer]:
|
||||||
|
- /url: /help
|
||||||
|
- figure "art by Juniper" [ref=f2e17]:
|
||||||
|
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=f2e18]
|
||||||
|
- generic [ref=f2e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
|
||||||
|
- generic [ref=f2e21]:
|
||||||
|
- status
|
||||||
|
- main [ref=f2e22]:
|
||||||
|
- heading "Shelves" [level=1] [ref=f2e23]
|
||||||
|
- generic [ref=f2e24]: 3 unit(s), 27 opening(s), 8 game(s) unshelved
|
||||||
|
- heading "Add a unit" [level=2] [ref=f2e26]
|
||||||
|
- generic [ref=f2e27]:
|
||||||
|
- generic [ref=f2e28]:
|
||||||
|
- text: Unit name
|
||||||
|
- textbox "Unit name" [ref=f2e29]:
|
||||||
|
- /placeholder: Living room Kallax
|
||||||
|
- generic [ref=f2e30]:
|
||||||
|
- text: Preset
|
||||||
|
- combobox "Preset" [ref=f2e31]:
|
||||||
|
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
|
||||||
|
- option "IKEA Billy shelf (30.75 × 13 × 11)"
|
||||||
|
- option "custom size"
|
||||||
|
- option "no size — travel case, lent out…"
|
||||||
|
- generic [ref=f2e32]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=f2e33]: "1"
|
||||||
|
- generic [ref=f2e34]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=f2e35]: "1"
|
||||||
|
- generic [ref=f2e36]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=f2e37]:
|
||||||
|
- /placeholder: party games
|
||||||
|
- generic [ref=f2e38]:
|
||||||
|
- text: Description
|
||||||
|
- textbox "Description" [ref=f2e39]:
|
||||||
|
- /placeholder: the wall behind the couch — main game storage
|
||||||
|
- button "create" [ref=f2e41] [cursor=pointer]
|
||||||
|
- generic [ref=f2e42]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
|
||||||
|
- generic [ref=f2e43]:
|
||||||
|
- generic [ref=f2e44]:
|
||||||
|
- generic [ref=f2e45]:
|
||||||
|
- heading "Library Kallax" [level=2] [ref=f2e46]
|
||||||
|
- generic [ref=f2e47]:
|
||||||
|
- button "duplicate" [ref=f2e48] [cursor=pointer]
|
||||||
|
- button "rename" [ref=f2e49] [cursor=pointer]
|
||||||
|
- button "remove unit" [ref=f2e50] [cursor=pointer]
|
||||||
|
- paragraph [ref=f2e51]:
|
||||||
|
- generic [ref=f2e52]: Centerpiece of the game wall in the library
|
||||||
|
- button "edit description" [ref=f2e53] [cursor=pointer]
|
||||||
|
- generic [ref=f2e54]:
|
||||||
|
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e56] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e57]: Top
|
||||||
|
- generic [ref=f2e58]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e59]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- generic [ref=f2e61]:
|
||||||
|
- button "A1 oversize 7 · 1 unmeasured flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f2e62] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e63]: A1
|
||||||
|
- generic [ref=f2e64]: oversize
|
||||||
|
- generic [ref=f2e65]: 7 · 1 unmeasured
|
||||||
|
- img "flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f2e66]:
|
||||||
|
- 'generic "flat stack: 99% of interior height" [ref=f2e67]'
|
||||||
|
- button "A2 oversize 6 · 1 unmeasured flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f2e70] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e71]: A2
|
||||||
|
- generic [ref=f2e72]: oversize
|
||||||
|
- generic [ref=f2e73]: 6 · 1 unmeasured
|
||||||
|
- img "flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f2e74]:
|
||||||
|
- 'generic "flat stack: 89% of interior height" [ref=f2e75]'
|
||||||
|
- generic [ref=f2e78]:
|
||||||
|
- 'button "B1 no way to pack: Meteor party 7 flat stack 98% of height, standing lane 0% of width, overfull" [ref=f2e79] [cursor=pointer]':
|
||||||
|
- generic [ref=f2e80]:
|
||||||
|
- text: B1
|
||||||
|
- 'img "no way to pack: Meteor" [ref=f2e81]': ⚠
|
||||||
|
- generic [ref=f2e82]: party
|
||||||
|
- generic [ref=f2e83]: "7"
|
||||||
|
- img "flat stack 98% of height, standing lane 0% of width, overfull" [ref=f2e84]:
|
||||||
|
- 'generic "flat stack: 98% of interior height" [ref=f2e85]'
|
||||||
|
- 'button "B2 no way to pack: The Red Dragon Inn Smorgasbox party 5 flat stack 89% of height, standing lane 0% of width, overfull" [ref=f2e87] [cursor=pointer]':
|
||||||
|
- generic [ref=f2e88]:
|
||||||
|
- text: B2
|
||||||
|
- 'img "no way to pack: The Red Dragon Inn Smorgasbox" [ref=f2e89]': ⚠
|
||||||
|
- generic [ref=f2e90]: party
|
||||||
|
- generic [ref=f2e91]: "5"
|
||||||
|
- img "flat stack 89% of height, standing lane 0% of width, overfull" [ref=f2e92]:
|
||||||
|
- 'generic "flat stack: 89% of interior height" [ref=f2e93]'
|
||||||
|
- button "B3 party 16 · 8 unmeasured flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f2e95] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e96]: B3
|
||||||
|
- generic [ref=f2e97]: party
|
||||||
|
- generic [ref=f2e98]: 16 · 8 unmeasured
|
||||||
|
- img "flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f2e99]:
|
||||||
|
- 'generic "flat stack: 36% of interior height" [ref=f2e100]'
|
||||||
|
- button "B4 two-player 6 · 1 unmeasured flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f2e103] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e104]: B4
|
||||||
|
- generic [ref=f2e105]: two-player
|
||||||
|
- generic [ref=f2e106]: 6 · 1 unmeasured
|
||||||
|
- img "flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f2e107]:
|
||||||
|
- 'generic "flat stack: 39% of interior height" [ref=f2e108]'
|
||||||
|
- generic [ref=f2e111]:
|
||||||
|
- 'button "C1 no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan general 12 · 3 unmeasured flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e112] [cursor=pointer]':
|
||||||
|
- generic [ref=f2e113]:
|
||||||
|
- text: C1
|
||||||
|
- 'img "no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan" [ref=f2e114]': ⚠
|
||||||
|
- generic [ref=f2e115]: general
|
||||||
|
- generic [ref=f2e116]: 12 · 3 unmeasured
|
||||||
|
- img "flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e117]:
|
||||||
|
- 'generic "flat stack: 100% of interior height" [ref=f2e118]'
|
||||||
|
- 'button "C2 no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens general 14 · 3 unmeasured flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e121] [cursor=pointer]':
|
||||||
|
- generic [ref=f2e122]:
|
||||||
|
- text: C2
|
||||||
|
- 'img "no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens" [ref=f2e123]': ⚠
|
||||||
|
- generic [ref=f2e124]: general
|
||||||
|
- generic [ref=f2e125]: 14 · 3 unmeasured
|
||||||
|
- img "flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e126]:
|
||||||
|
- 'generic "flat stack: 95% of interior height" [ref=f2e127]'
|
||||||
|
- 'button "C3 no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine general 14 · 3 unmeasured flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e130] [cursor=pointer]':
|
||||||
|
- generic [ref=f2e131]:
|
||||||
|
- text: C3
|
||||||
|
- 'img "no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine" [ref=f2e132]': ⚠
|
||||||
|
- generic [ref=f2e133]: general
|
||||||
|
- generic [ref=f2e134]: 14 · 3 unmeasured
|
||||||
|
- img "flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e135]:
|
||||||
|
- 'generic "flat stack: 97% of interior height" [ref=f2e136]'
|
||||||
|
- 'button "C4 no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls general 13 · 4 unmeasured flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f2e139] [cursor=pointer]':
|
||||||
|
- generic [ref=f2e140]:
|
||||||
|
- text: C4
|
||||||
|
- 'img "no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls" [ref=f2e141]': ⚠
|
||||||
|
- generic [ref=f2e142]: general
|
||||||
|
- generic [ref=f2e143]: 13 · 4 unmeasured
|
||||||
|
- img "flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f2e144]:
|
||||||
|
- 'generic "flat stack: 90% of interior height" [ref=f2e145]'
|
||||||
|
- generic [ref=f2e148]:
|
||||||
|
- 'button "D1 no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank! general 11 · 2 unmeasured flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f2e149] [cursor=pointer]':
|
||||||
|
- generic [ref=f2e150]:
|
||||||
|
- text: D1
|
||||||
|
- 'img "no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank!" [ref=f2e151]': ⚠
|
||||||
|
- generic [ref=f2e152]: general
|
||||||
|
- generic [ref=f2e153]: 11 · 2 unmeasured
|
||||||
|
- img "flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f2e154]:
|
||||||
|
- 'generic "flat stack: 94% of interior height" [ref=f2e155]'
|
||||||
|
- button "D2 vintage wargames 7 flat stack 75% of height, standing lane 100% of width" [ref=f2e158] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e159]: D2
|
||||||
|
- generic [ref=f2e160]: vintage wargames
|
||||||
|
- generic [ref=f2e161]: "7"
|
||||||
|
- img "flat stack 75% of height, standing lane 100% of width" [ref=f2e162]:
|
||||||
|
- 'generic "flat stack: 75% of interior height" [ref=f2e163]'
|
||||||
|
- button "D3 heavy 3 flat stack 42% of height, standing lane 94% of width" [ref=f2e166] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e167]: D3
|
||||||
|
- generic [ref=f2e168]: heavy
|
||||||
|
- generic [ref=f2e169]: "3"
|
||||||
|
- img "flat stack 42% of height, standing lane 94% of width" [ref=f2e170]:
|
||||||
|
- 'generic "flat stack: 42% of interior height" [ref=f2e171]'
|
||||||
|
- button "D4 campaign 4 flat stack 83% of height, standing lane 92% of width" [ref=f2e174] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e175]: D4
|
||||||
|
- generic [ref=f2e176]: campaign
|
||||||
|
- generic [ref=f2e177]: "4"
|
||||||
|
- img "flat stack 83% of height, standing lane 92% of width" [ref=f2e178]:
|
||||||
|
- 'generic "flat stack: 83% of interior height" [ref=f2e179]'
|
||||||
|
- paragraph [ref=f2e182]: Add an opening
|
||||||
|
- generic [ref=f2e183]:
|
||||||
|
- generic [ref=f2e184]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=f2e185]: "1"
|
||||||
|
- generic [ref=f2e186]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=f2e187]: "1"
|
||||||
|
- generic [ref=f2e188]:
|
||||||
|
- text: Label
|
||||||
|
- textbox "Label" [ref=f2e189]:
|
||||||
|
- /placeholder: wide top — for a single opening
|
||||||
|
- generic [ref=f2e190]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=f2e191]
|
||||||
|
- generic [ref=f2e192]:
|
||||||
|
- checkbox "at the top" [ref=f2e193]
|
||||||
|
- text: at the top
|
||||||
|
- generic [ref=f2e194]:
|
||||||
|
- text: W
|
||||||
|
- textbox "W" [ref=f2e195]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f2e196]:
|
||||||
|
- text: H
|
||||||
|
- textbox "H" [ref=f2e197]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f2e198]:
|
||||||
|
- text: D
|
||||||
|
- textbox "D" [ref=f2e199]:
|
||||||
|
- /placeholder: "15.4"
|
||||||
|
- button "add" [ref=f2e201] [cursor=pointer]
|
||||||
|
- generic [ref=f2e202]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
|
||||||
|
- generic [ref=f2e203]:
|
||||||
|
- generic [ref=f2e204]:
|
||||||
|
- heading "Library Right Shelving" [level=2] [ref=f2e205]
|
||||||
|
- generic [ref=f2e206]:
|
||||||
|
- button "duplicate" [ref=f2e207] [cursor=pointer]
|
||||||
|
- button "rename" [ref=f2e208] [cursor=pointer]
|
||||||
|
- button "remove unit" [ref=f2e209] [cursor=pointer]
|
||||||
|
- paragraph [ref=f2e210]:
|
||||||
|
- generic [ref=f2e211]: To the right of the Kallax
|
||||||
|
- button "edit description" [ref=f2e212] [cursor=pointer]
|
||||||
|
- generic [ref=f2e213]:
|
||||||
|
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e215] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e216]: Top
|
||||||
|
- generic [ref=f2e217]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e218]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e221] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e222]: A1
|
||||||
|
- generic [ref=f2e223]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e224]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e227] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e228]: B1
|
||||||
|
- generic [ref=f2e229]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e230]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e233] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e234]: C1
|
||||||
|
- generic [ref=f2e235]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e236]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "D1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e239] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e240]: D1
|
||||||
|
- generic [ref=f2e241]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e242]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "E1 6 · 3 unmeasured flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f2e245] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e246]: E1
|
||||||
|
- generic [ref=f2e247]: 6 · 3 unmeasured
|
||||||
|
- img "flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f2e248]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- paragraph [ref=f2e251]: Add an opening
|
||||||
|
- generic [ref=f2e252]:
|
||||||
|
- generic [ref=f2e253]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=f2e254]: "1"
|
||||||
|
- generic [ref=f2e255]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=f2e256]: "1"
|
||||||
|
- generic [ref=f2e257]:
|
||||||
|
- text: Label
|
||||||
|
- textbox "Label" [ref=f2e258]:
|
||||||
|
- /placeholder: wide top — for a single opening
|
||||||
|
- generic [ref=f2e259]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=f2e260]
|
||||||
|
- generic [ref=f2e261]:
|
||||||
|
- checkbox "at the top" [ref=f2e262]
|
||||||
|
- text: at the top
|
||||||
|
- generic [ref=f2e263]:
|
||||||
|
- text: W
|
||||||
|
- textbox "W" [ref=f2e264]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f2e265]:
|
||||||
|
- text: H
|
||||||
|
- textbox "H" [ref=f2e266]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f2e267]:
|
||||||
|
- text: D
|
||||||
|
- textbox "D" [ref=f2e268]:
|
||||||
|
- /placeholder: "15.4"
|
||||||
|
- button "add" [ref=f2e270] [cursor=pointer]
|
||||||
|
- generic [ref=f2e271]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
|
||||||
|
- generic [ref=f2e272]:
|
||||||
|
- generic [ref=f2e273]:
|
||||||
|
- heading "Library Left Shelving" [level=2] [ref=f2e274]
|
||||||
|
- generic [ref=f2e275]:
|
||||||
|
- button "duplicate" [ref=f2e276] [cursor=pointer]
|
||||||
|
- button "rename" [ref=f2e277] [cursor=pointer]
|
||||||
|
- button "remove unit" [ref=f2e278] [cursor=pointer]
|
||||||
|
- paragraph [ref=f2e279]:
|
||||||
|
- generic [ref=f2e280]: To the left of the Kallax
|
||||||
|
- button "edit description" [ref=f2e281] [cursor=pointer]
|
||||||
|
- generic [ref=f2e282]:
|
||||||
|
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e284] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e285]: Top
|
||||||
|
- generic [ref=f2e286]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e287]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e290] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e291]: A1
|
||||||
|
- generic [ref=f2e292]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e293]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e296] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e297]: B1
|
||||||
|
- generic [ref=f2e298]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e299]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e302] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e303]: C1
|
||||||
|
- generic [ref=f2e304]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e305]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "D1 4 · 4 unmeasured flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f2e308] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e309]: D1
|
||||||
|
- generic [ref=f2e310]: 4 · 4 unmeasured
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f2e311]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- button "E1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e314] [cursor=pointer]:
|
||||||
|
- generic [ref=f2e315]: E1
|
||||||
|
- generic [ref=f2e316]: —
|
||||||
|
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e317]:
|
||||||
|
- 'generic "flat stack: 0% of interior height"'
|
||||||
|
- paragraph [ref=f2e319]: Add an opening
|
||||||
|
- generic [ref=f2e320]:
|
||||||
|
- generic [ref=f2e321]:
|
||||||
|
- text: Rows
|
||||||
|
- textbox "Rows" [ref=f2e322]: "1"
|
||||||
|
- generic [ref=f2e323]:
|
||||||
|
- text: Columns
|
||||||
|
- textbox "Columns" [ref=f2e324]: "1"
|
||||||
|
- generic [ref=f2e325]:
|
||||||
|
- text: Label
|
||||||
|
- textbox "Label" [ref=f2e326]:
|
||||||
|
- /placeholder: wide top — for a single opening
|
||||||
|
- generic [ref=f2e327]:
|
||||||
|
- text: Zone
|
||||||
|
- textbox "Zone" [ref=f2e328]
|
||||||
|
- generic [ref=f2e329]:
|
||||||
|
- checkbox "at the top" [ref=f2e330]
|
||||||
|
- text: at the top
|
||||||
|
- generic [ref=f2e331]:
|
||||||
|
- text: W
|
||||||
|
- textbox "W" [ref=f2e332]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f2e333]:
|
||||||
|
- text: H
|
||||||
|
- textbox "H" [ref=f2e334]:
|
||||||
|
- /placeholder: "13.25"
|
||||||
|
- generic [ref=f2e335]:
|
||||||
|
- text: D
|
||||||
|
- textbox "D" [ref=f2e336]:
|
||||||
|
- /placeholder: "15.4"
|
||||||
|
- button "add" [ref=f2e338] [cursor=pointer]
|
||||||
|
- generic [ref=f2e339]: a grid (rows × columns) continues the row letters — 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label for a single opening. All three sizes, or none for a no-limit spot.
|
||||||
|
- generic [ref=f2e340]:
|
||||||
|
- heading "Unshelved" [level=2] [ref=f2e341]
|
||||||
|
- generic [ref=f2e342]:
|
||||||
|
- link "DUNGEONS & DRAGONS" [ref=f2e343] [cursor=pointer]:
|
||||||
|
- /url: /library/game/local%3Adungeons%20and%20dragons%3AIMG_4519.jpeg
|
||||||
|
- generic "no trusted size — fit can't be verified" [ref=f2e344]: unmeasured
|
||||||
|
- generic [ref=f2e345]:
|
||||||
|
- link "DUNGEONS & DRAGONS FANTASY GAME RULES" [ref=f2e346] [cursor=pointer]:
|
||||||
|
- /url: /library/game/local%3Adungeons%20and%20dragons%20fantasy%20game%20rules%3AIMG_4519.jpeg
|
||||||
|
- generic "no trusted size — fit can't be verified" [ref=f2e347]: unmeasured
|
||||||
|
- generic [ref=f2e348]:
|
||||||
|
- 'link "Etherfields: Awakened Items & Actions" [ref=f2e349] [cursor=pointer]':
|
||||||
|
- /url: /library/game/355873%3A597861
|
||||||
|
- generic [ref=f2e350]:
|
||||||
|
- button "♥ Library Right Shelving · E1" [ref=f2e351] [cursor=pointer]
|
||||||
|
- button "→ Library Right Shelving · A1" [ref=f2e352] [cursor=pointer]
|
||||||
|
- button "→ Library Left Shelving · A1" [ref=f2e353] [cursor=pointer]
|
||||||
|
- generic [ref=f2e354]:
|
||||||
|
- 'link "Etherfields: Search Mini Deck" [ref=f2e355] [cursor=pointer]':
|
||||||
|
- /url: /library/game/309202%3A507805
|
||||||
|
- generic [ref=f2e356]:
|
||||||
|
- button "♥ Library Right Shelving · E1" [ref=f2e357] [cursor=pointer]
|
||||||
|
- button "→ Library Right Shelving · A1" [ref=f2e358] [cursor=pointer]
|
||||||
|
- button "→ Library Left Shelving · A1" [ref=f2e359] [cursor=pointer]
|
||||||
|
- generic [ref=f2e360]:
|
||||||
|
- 'link "Etherfields: Shifting Story Promo Deck" [ref=f2e361] [cursor=pointer]':
|
||||||
|
- /url: /library/game/381209%3A651213
|
||||||
|
- generic [ref=f2e362]:
|
||||||
|
- button "♥ Library Right Shelving · E1" [ref=f2e363] [cursor=pointer]
|
||||||
|
- button "→ Library Right Shelving · A1" [ref=f2e364] [cursor=pointer]
|
||||||
|
- button "→ Library Left Shelving · A1" [ref=f2e365] [cursor=pointer]
|
||||||
|
- generic [ref=f2e366]:
|
||||||
|
- 'link "Etherfields: Story Dice Expansion" [ref=f2e367] [cursor=pointer]':
|
||||||
|
- /url: /library/game/386805%3A658656
|
||||||
|
- generic "no trusted size — fit can't be verified" [ref=f2e368]: unmeasured
|
||||||
|
- generic [ref=f2e369]:
|
||||||
|
- 'link "Etherfields: Thorn Knight" [ref=f2e370] [cursor=pointer]':
|
||||||
|
- /url: /library/game/291126%3A475383
|
||||||
|
- generic "no trusted size — fit can't be verified" [ref=f2e371]: unmeasured
|
||||||
|
- generic [ref=f2e372]:
|
||||||
|
- link "Frosthaven" [ref=f2e373] [cursor=pointer]:
|
||||||
|
- /url: /library/game/295770%3A484007
|
||||||
|
- generic [ref=f2e374]:
|
||||||
|
- button "→ Library Kallax · A1" [ref=f2e375] [cursor=pointer]
|
||||||
|
- button "→ Library Kallax · Top" [ref=f2e376] [cursor=pointer]
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- link "Skip to content" [ref=e2] [cursor=pointer]:
|
||||||
|
- /url: "#main"
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- text: bggpipe
|
||||||
|
- generic [ref=e6]: shelf → BGG pipeline
|
||||||
|
- navigation "Primary" [ref=e7]:
|
||||||
|
- link "Pipeline" [ref=e8] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- link "Photos 4" [ref=e9] [cursor=pointer]:
|
||||||
|
- /url: /photos
|
||||||
|
- text: Photos
|
||||||
|
- generic [ref=e10]: "4"
|
||||||
|
- link "Titles" [ref=e11] [cursor=pointer]:
|
||||||
|
- /url: /titles
|
||||||
|
- link "Review" [ref=e12] [cursor=pointer]:
|
||||||
|
- /url: /review
|
||||||
|
- link "Queue" [ref=e13] [cursor=pointer]:
|
||||||
|
- /url: /queue
|
||||||
|
- link "Library" [ref=e14] [cursor=pointer]:
|
||||||
|
- /url: /library
|
||||||
|
- link "Shelves" [ref=e15] [cursor=pointer]:
|
||||||
|
- /url: /shelves
|
||||||
|
- link "Help" [ref=e16] [cursor=pointer]:
|
||||||
|
- /url: /help
|
||||||
|
- figure "art by Juniper" [ref=e17]:
|
||||||
|
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
|
||||||
|
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- status
|
||||||
|
- main [ref=e22]:
|
||||||
|
- heading "Frosthaven" [level=1] [ref=e23]
|
||||||
|
- generic [ref=e24]:
|
||||||
|
- link "← library" [ref=e25] [cursor=pointer]:
|
||||||
|
- /url: /library
|
||||||
|
- generic [ref=e26]:
|
||||||
|
- text: 2022 ·
|
||||||
|
- link "view on BGG ↗" [ref=e27] [cursor=pointer]:
|
||||||
|
- /url: https://boardgamegeek.com/boardgame/295770
|
||||||
|
- generic [ref=e28]:
|
||||||
|
- generic [ref=e29]:
|
||||||
|
- img "box art for Frosthaven" [ref=e31]
|
||||||
|
- generic [ref=e32]:
|
||||||
|
- generic [ref=e33]: players 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
|
||||||
@@ -14,13 +14,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
| 4 | `bggpipe diff` | diff approved matches against the existing BGG collection | working |
|
| 4 | `bggpipe diff` | diff approved matches against the existing BGG collection | working |
|
||||||
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) |
|
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) |
|
||||||
| 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working |
|
| 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working |
|
||||||
|
| + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working |
|
||||||
|
| + | `bggpipe dims` | offline shelf-space report over box dimensions enrich collects from BGG *versions* (dims live per-version, not per-game; 0 = never entered; versionless games need a unanimous chorus within 0.5"/axis else `conflicting` — never guessed); fit checks run against `data/furniture.json` openings | working |
|
||||||
|
| + | `bggpipe shelve --import` | bulk-assign games to openings from a name,opening CSV; rejects (unknown/ambiguous/doesn't-fit/contained) reported, never guessed | working |
|
||||||
|
|
||||||
Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
|
Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`).
|
- `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`).
|
||||||
- `uv run bggpipe web` — the app: seven pages (Pipeline `/`, Photos, Titles, Review, Queue, Library, Help) in a shared sidebar shell (responsive: hamburger nav + stacked tables under 900px); stage runs execute one-at-a-time in a background job. `--lan` binds 0.0.0.0 behind a per-device access key: persisted in `data/.lan_key` (gitignored), printed as a QR at startup, cookie-paired for a year, required on EVERY network request (loopback clients and `/static/*` are exempt; the Host/Origin guard still applies). Phone camera uploads (generic `image.jpg` names) get minted `shelf-<timestamp>` names — only explicitly-named files trigger the replace-to-reshoot flow.
|
- `uv run bggpipe web` — the app: eight pages (Pipeline `/`, Photos, Titles, Review, Queue, Library, Shelves, Help) in a shared sidebar shell (responsive: hamburger nav + stacked tables under 900px); stage runs execute one-at-a-time in a background job. `--lan` binds 0.0.0.0 behind a per-device access key: persisted in `data/.lan_key` (gitignored), printed as a QR at startup, cookie-paired for a year, required on EVERY network request (loopback clients and `/static/*` are exempt; the Host/Origin guard still applies). Phone camera uploads (generic `image.jpg` names) get minted `shelf-<timestamp>` names — only explicitly-named files trigger the replace-to-reshoot flow.
|
||||||
- `uv run bggpipe <stage>` — run a pipeline stage. Non-secret settings come from `config.toml` (dirs, rate limit, `vision_provider` + per-provider `[vision.*]` blocks — "anthropic" or any OpenAI-compatible endpoint incl. local Ollama); `--config` overrides the path.
|
- `uv run bggpipe <stage>` — run a pipeline stage. Non-secret settings come from `config.toml` (dirs, rate limit, `vision_provider` + per-provider `[vision.*]` blocks — "anthropic" or any OpenAI-compatible endpoint incl. local Ollama); `--config` overrides the path.
|
||||||
- `uv run pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`.
|
- `uv run pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`.
|
||||||
- `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format.
|
- `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format.
|
||||||
@@ -47,11 +50,11 @@ Full design lives in `docs/spec.md` (read it before changing pipeline semantics)
|
|||||||
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
|
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
|
||||||
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
|
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
|
||||||
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
|
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
|
||||||
- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), and `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
|
- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich), and `data/furniture.json` + `data/locations.json` (the owner's shelf layout and each game's physical spot — local-only, never BGG's business; a location can carry `lane`, `acknowledged` — the human overruling the packer — and `away`, where the box IS right now (loans/travel; home stays reserved, contents inherit)), and `data/measurements.json` (hand-measured box dims, source `measured` — the owner's tape measure outranks BGG, overlaid on every games.json read) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
|
||||||
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). RPGGeek items carry their OWN link types (`rpgdesigner`, `rpgpublisher`, `rpggenre`, `rpgcategory`, `rpgmechanic`) — a board-game-only parser silently returns nothing for them. Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
|
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). RPGGeek items carry their OWN link types (`rpgdesigner`, `rpgpublisher`, `rpggenre`, `rpgcategory`, `rpgmechanic`) — a board-game-only parser silently returns nothing for them. Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
|
||||||
- Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift.
|
- Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift.
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub** — `gh` CLI does not work here.
|
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub** — `gh` CLI does not work here.
|
||||||
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
|
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/furniture.json`, `data/locations.json`, `data/measurements.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# bggpipe — Shelf-to-BoardGameGeek Collection Pipeline
|
# 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">
|
<img src="assets/logo-full.jpeg" alt="the bggpipe piper — a bagpiper whose bag is a board game box" width="220" align="right">
|
||||||
|
|
||||||
Photograph your board game shelves. End up with your whole collection — including which *edition* of each game you own — cataloged on [BoardGameGeek](https://boardgamegeek.com).
|
Photograph your board game shelves. End up with your whole collection — including which *edition* of each game you own — cataloged on [BoardGameGeek](https://boardgamegeek.com).
|
||||||
@@ -11,7 +14,7 @@ photos/ → [1 extract] → titles.json → [2 resolve] → matches.csv
|
|||||||
→ [6 enrich] → games.json
|
→ [6 enrich] → games.json
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Status: battle-tested end to end.** The full pipeline has run against a live BGG account: shelf photos → 136 identified games → 62 additions and 36 version updates on a real collection. The result is public — [the author's collection on BGG](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built. Still sensible on a first run: `--dry-run`, then `--limit 1`.
|
> **Status: battle-tested end to end.** The full pipeline has run against a live BGG account: shelf photos → 136 identified games → 62 additions and 36 version updates on a real collection. The result is public — [the author's collection on BGG](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built — and [this browsable library of the same shelves](https://blog.kestrelsnest.social/mygames/) is what the **enrich** stage turns that data into. Still sensible on a first run: `--dry-run`, then `--limit 1`.
|
||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
|
|
||||||
@@ -24,13 +27,13 @@ BGG has no bulk import and no write API. Cataloging a few hundred games by hand
|
|||||||
3. **review** — A local review step for ambiguous matches: pick the right game/version, or leave the version blank. Wrong guesses never reach your collection.
|
3. **review** — A local review step for ambiguous matches: pick the right game/version, or leave the version blank. Wrong guesses never reach your collection.
|
||||||
4. **diff** — Your existing BGG collection is fetched and compared, per copy (owning one edition of a game doesn't hide a second edition you also own).
|
4. **diff** — Your existing BGG collection is fetched and compared, per copy (owning one edition of a game doesn't hide a second edition you also own).
|
||||||
5. **upload** — A Playwright browser session logs into your BGG account and adds each game (with its version, when known) politely and slowly. Dry-run mode, per-game logging, and resumability included.
|
5. **upload** — A Playwright browser session logs into your BGG account and adds each game (with its version, when known) politely and slowly. Dry-run mode, per-game logging, and resumability included.
|
||||||
6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork, version details) lands in `data/games.json`, feeding a browsable library of your shelves.
|
6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork, version details) lands in `data/games.json`, feeding a browsable library of your shelves — which `bggpipe export` can publish as [static pages on your own site](docs/guide.md#publishing-your-library-on-your-own-site).
|
||||||
|
|
||||||
Everything runs locally, every stage survives being killed mid-run, and all artifacts are flat CSV/JSON files you can inspect and edit. You can drive it from the terminal or from a local web app:
|
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 seven pages, the game-detail view, and the phone experience.
|
**➔ [See the full tour](docs/tour.md)** — all eight pages, the game-detail view, and the phone experience.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+8020
-4209
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
|||||||
|
{
|
||||||
|
"104590:117256": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"104710:117685": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"116:28201": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"1197:78624": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"1219:488621": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"124548:165214": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"125675:167657": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"125921:476263": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"1260:156689": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"1269:29705": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"128554:207529": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"1339:34390": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"137909:216474": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"139443:202519": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"13:347121": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"143884:453051": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"14535:29075": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"147370:231664": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"150925:225659": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"153999:280845": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"156994:236280": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"157969:237864": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"160477:269392": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"161928:245124": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"163412:495998": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"164:28465": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"165986:277137": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"166109:253058": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"166976:410382": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"169124:451577": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"169784:260454": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"172225:296012": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"172503:265107": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"173057:266044": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"173634:266954": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"174430:520054": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"176:27673": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"177:24972": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"1784:110926": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-2-d1"
|
||||||
|
},
|
||||||
|
"181304:536289": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"18755:284056": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"188614:779792": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"1927:28144": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"193485:300446": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"193621:300682": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"194655:668826": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"195314:525268": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"196105:304900": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"201248:313478": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"202982:316090": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"2058:28041": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"207830:344075": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"208428:325455": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"20866:40025": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"2197:42495": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b4"
|
||||||
|
},
|
||||||
|
"220199:369805": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"224:20727": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"226065:353389": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"231302:361880": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"2387:91428": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"240:24621": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"2452:288640": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"252153:404702": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"2524:219137": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"2529:98655": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"255984:701034": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d4"
|
||||||
|
},
|
||||||
|
"257:520364": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"258849:449806": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-2-d1"
|
||||||
|
},
|
||||||
|
"258:20840": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"269564:439948": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"26:28607": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"273240:446069": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"27710:606657": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"280794:458300": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-e1"
|
||||||
|
},
|
||||||
|
"283155:462049": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"283766:463140": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d4"
|
||||||
|
},
|
||||||
|
"290840:474978": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-e1"
|
||||||
|
},
|
||||||
|
"291130:475387": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-e1"
|
||||||
|
},
|
||||||
|
"291131:475388": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-e1"
|
||||||
|
},
|
||||||
|
"291457:476060": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d3"
|
||||||
|
},
|
||||||
|
"291847:476820": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"295770:484007": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"away": "travel case",
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-2-e1"
|
||||||
|
},
|
||||||
|
"2980:138013": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"3090:25782": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"311654": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-2-d1"
|
||||||
|
},
|
||||||
|
"31260:297589": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d3"
|
||||||
|
},
|
||||||
|
"312786:514050": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"3181:87931": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"320202:528008": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"324937:537230": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"329230:547518": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"332398:553061": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"334011:701315": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"334065:759453": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"340420:567183": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b2"
|
||||||
|
},
|
||||||
|
"351544:588185": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-e1"
|
||||||
|
},
|
||||||
|
"352819:591147": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"35505:781669": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"358320:602661": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d4"
|
||||||
|
},
|
||||||
|
"3585:211154": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"358880:603694": {
|
||||||
|
"acknowledged": true,
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-e1"
|
||||||
|
},
|
||||||
|
"362205:612448": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d4"
|
||||||
|
},
|
||||||
|
"380459:743266": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-right-shelving-2-d1"
|
||||||
|
},
|
||||||
|
"380837:650545": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"389113:662372": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"393672:669782": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d3"
|
||||||
|
},
|
||||||
|
"39856:296529": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"40508:644326": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"40529:27692": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"40692:294178": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"419687:711400": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"42063:191736": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"4324:26859": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a2"
|
||||||
|
},
|
||||||
|
"432858:734890": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"43443:103068": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"442312:752105": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"453845:772660": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"483:48522": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"50381:134539": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"57215:295423": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b1"
|
||||||
|
},
|
||||||
|
"589:27352": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"65244:31208": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"71:24006": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d2"
|
||||||
|
},
|
||||||
|
"822:187468": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"9209:294188": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"95386:290572": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-a1"
|
||||||
|
},
|
||||||
|
"9666:215137": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-b3"
|
||||||
|
},
|
||||||
|
"97903:231971": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-d1"
|
||||||
|
},
|
||||||
|
"local:cat crimes:IMG_4499.jpeg;IMG_4505.jpeg;shelf-20260803-215723.jpg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c1"
|
||||||
|
},
|
||||||
|
"local:cat stax:IMG_4544.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"local:elite sportz equipment table top curling:IMG_4526.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c2"
|
||||||
|
},
|
||||||
|
"local:gravity maze:IMG_4510.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"local:laser maze beam bending logic game:IMG_4516.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c3"
|
||||||
|
},
|
||||||
|
"local:ouija:IMG_4529.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
},
|
||||||
|
"local:super mario checkers collector s edition:IMG_4508.jpeg": {
|
||||||
|
"note": "",
|
||||||
|
"opening_id": "library-kallax-c4"
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
-137
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
@@ -28,5 +28,245 @@
|
|||||||
"language_hint": "",
|
"language_hint": "",
|
||||||
"art_notes": "",
|
"art_notes": "",
|
||||||
"source_photos": []
|
"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": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1779,5 +1779,269 @@
|
|||||||
"art_notes": "",
|
"art_notes": "",
|
||||||
"source_photos": [],
|
"source_photos": [],
|
||||||
"title_normalized": "castle panic wizard s tower"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -115,3 +115,91 @@ update,40692,53430517,Small World,294178,,updated,2026-08-06T03:20:45+00:00,
|
|||||||
update,172503,53429718,Mage Wars Academy,265107,,updated,2026-08-06T03:20:49+00:00,
|
update,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,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,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
|
||||||
|
+139
-1
@@ -40,7 +40,7 @@ Non-secret knobs live in `config.toml`: `photos_dir`, `data_dir`, the BGG rate l
|
|||||||
bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
|
bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
|
||||||
```
|
```
|
||||||
|
|
||||||
Seven pages — Pipeline, Photos, Titles, Review, Queue, Library, and Help — all [pictured in the tour](tour.md). Stage runs execute one at a time in the background with live output:
|
Eight pages — Pipeline, Photos, Titles, Review, Queue, Library, Shelves, and Help — all [pictured in the tour](tour.md). Stage runs execute one at a time in the background with live output:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -109,6 +109,144 @@ BGG application approval can take a week or more. Until then: `extract` works im
|
|||||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
|
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
|
||||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
|
- `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
|
## 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.
|
The [README's quick start](../README.md#quick-start) — installed as a tool, run in a directory of your own — is the only supported way to use bggpipe on your collection. Everything the pipeline produces lives where you run it, and `uv tool upgrade bggpipe` picks up fixes without going anywhere near your data.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
@@ -18,6 +18,7 @@ Beyond the bare game, capture **which edition/version I own** wherever the photo
|
|||||||
- The XML API **requires a registered application** (boardgamegeek.com/using_the_xml_api, policy 2025-07-02): every request carries `Authorization: Bearer <token>` from the `BGG_API_TOKEN` env var, sent to `boardgamegeek.com` without a leading `www`. Register a free non-commercial application at boardgamegeek.com/applications — approval can take a week+; offline development and tests run on recorded XML fixtures.
|
- The XML API **requires a registered application** (boardgamegeek.com/using_the_xml_api, policy 2025-07-02): every request carries `Authorization: Bearer <token>` from the `BGG_API_TOKEN` env var, sent to `boardgamegeek.com` without a leading `www`. Register a free non-commercial application at boardgamegeek.com/applications — approval can take a week+; offline development and tests run on recorded XML fixtures.
|
||||||
- BGG's XML API queues collection requests: a first call may return HTTP 202 ("try again"). Retry with backoff.
|
- BGG's XML API queues collection requests: a first call may return HTTP 202 ("try again"). Retry with backoff.
|
||||||
- BGG will throttle aggressive clients. Target ≤1 request every 2 seconds to any BGG endpoint, with jittered backoff on 429/503.
|
- BGG will throttle aggressive clients. Target ≤1 request every 2 seconds to any BGG endpoint, with jittered backoff on 429/503.
|
||||||
|
- Image-CDN carve-out (adjudicated 2026-08-06): cover downloads from BGG's image CDN (`cf.geekdo-images.com`) during `export` pace at ≥1 second apart, fetch only files not already on disk, and re-fetch a file only when its source URL changes. The CDN is not the API, but it is still BGG's infrastructure — the spacing is mandatory, not advisory.
|
||||||
- BGG changed API access policies in 2025; some older community tools broke. Don't depend on undocumented endpoints beyond XML API2 and the public website.
|
- BGG changed API access policies in 2025; some older community tools broke. Don't depend on undocumented endpoints beyond XML API2 and the public website.
|
||||||
- Vision extraction uses the **Anthropic API** (Claude with vision). Assume `ANTHROPIC_API_KEY` in the environment.
|
- Vision extraction uses the **Anthropic API** (Claude with vision). Assume `ANTHROPIC_API_KEY` in the environment.
|
||||||
- Runs on macOS. Prefer **Python 3.12+** with `uv` for dependency management. Browser automation via **Playwright** (not Selenium).
|
- Runs on macOS. Prefer **Python 3.12+** with `uv` for dependency management. Browser automation via **Playwright** (not Selenium).
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
# A tour of bggpipe
|
# A tour of bggpipe
|
||||||
|
|
||||||
Seven pages in one local app — Pipeline, Photos, Titles, Review, Queue, Library, and Help. Every screenshot below is the real app on the author's real shelves, and the end product is public: [the author's BGG collection](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built. (Back to the [README](../README.md) · how to use it all: the [user's guide](guide.md).)
|
Eight pages in one local app — Pipeline, Photos, Titles, Review, Queue, Library, Shelves, and Help. Every screenshot below is the real app on the author's real shelves, and the end product is public: [the author's BGG collection](https://boardgamegeek.com/collection/user/ewagoner) is what this pipeline built. (Back to the [README](../README.md) · how to use it all: the [user's guide](guide.md).)
|
||||||
|
|
||||||
**The Pipeline page** — every stage is a card with live counts and a Run button; blockers (missing token, stub-data lock) surface as banners, not surprises. Here: the settled state after a full run — 136 titles read, 115 matched, 62 added.
|
**The Pipeline page** — every stage is a card with live counts and a Run button; blockers (missing token, stub-data lock) surface as banners, not surprises. Here: the settled state after a full run — 136 titles read, 115 matched, 62 added.
|
||||||
|
|
||||||
@@ -34,6 +34,10 @@ Seven pages in one local app — Pipeline, Photos, Titles, Review, Queue, Librar
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
**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.
|
**Help** — the whole flow, every page, every status, and every keyboard shortcut, documented in-app.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Shelf-to-BGG collection pipeline."""
|
"""Shelf-to-BGG collection pipeline."""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
|
|||||||
@@ -191,3 +191,59 @@ def enrich(
|
|||||||
|
|
||||||
cfg = load_config(config)
|
cfg = load_config(config)
|
||||||
run_enrich(cfg, refresh=refresh)
|
run_enrich(cfg, refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def shelve(
|
||||||
|
import_csv: Annotated[
|
||||||
|
Path,
|
||||||
|
typer.Option("--import", help="CSV of name,opening (ids or labels)"),
|
||||||
|
],
|
||||||
|
config: ConfigOpt = None,
|
||||||
|
) -> None:
|
||||||
|
"""Bulk-assign games to shelf openings from a CSV plan."""
|
||||||
|
from bggpipe.shelves import import_assignments
|
||||||
|
|
||||||
|
cfg = load_config(config)
|
||||||
|
result = import_assignments(cfg, import_csv)
|
||||||
|
if result["rejects"]:
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def dims(config: ConfigOpt = None) -> None:
|
||||||
|
"""Shelf-space report: box sizes, biggest footprints, per-opening fit."""
|
||||||
|
from bggpipe.dims import run_dims_report
|
||||||
|
|
||||||
|
cfg = load_config(config)
|
||||||
|
run_dims_report(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def export(
|
||||||
|
out: Annotated[
|
||||||
|
Path,
|
||||||
|
typer.Option("--out", help="Directory to write the static site into"),
|
||||||
|
],
|
||||||
|
title: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Option("--title", help="Site title shown on every page"),
|
||||||
|
] = "My Game Library",
|
||||||
|
no_shelves: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--no-shelves",
|
||||||
|
help="Omit the shelves page and locations (publishing your "
|
||||||
|
"shelf layout is a choice)",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
|
config: ConfigOpt = None,
|
||||||
|
) -> None:
|
||||||
|
"""Export the library as self-contained static pages (for a blog etc.)."""
|
||||||
|
from bggpipe.export import run_export
|
||||||
|
|
||||||
|
cfg = load_config(config)
|
||||||
|
summary = run_export(cfg, out, title=title, include_shelves=not no_shelves)
|
||||||
|
if summary["failures"]:
|
||||||
|
# scripted `export && rsync` must not publish an incomplete site
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ class Config:
|
|||||||
def games_path(self) -> Path:
|
def games_path(self) -> Path:
|
||||||
return self.data_dir / "games.json"
|
return self.data_dir / "games.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def measurements_path(self) -> Path:
|
||||||
|
return self.data_dir / "measurements.json"
|
||||||
|
|
||||||
def tracked_data_warning(self) -> str | None:
|
def tracked_data_warning(self) -> str | None:
|
||||||
"""The clone-and-run trap: pipeline data written at git-TRACKED
|
"""The clone-and-run trap: pipeline data written at git-TRACKED
|
||||||
paths is one `git pull` + one panicked `git reset --hard` away
|
paths is one `git pull` + one panicked `git reset --hard` away
|
||||||
@@ -107,6 +111,18 @@ class Config:
|
|||||||
"accept the arrangement and silence this warning."
|
"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
|
@property
|
||||||
def local_games_path(self) -> Path:
|
def local_games_path(self) -> Path:
|
||||||
# hand-written metadata for games BGG doesn't have — the only
|
# hand-written metadata for games BGG doesn't have — the only
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Shelf-space report over the box dimensions enrich collected.
|
||||||
|
|
||||||
|
Reads games.json and the local furniture/location stores — fully
|
||||||
|
offline. Fit checks run against the openings the user actually owns
|
||||||
|
(data/furniture.json); with no furniture on file the report covers
|
||||||
|
sizes only and says where to set shelves up. "Can't verify" is
|
||||||
|
reported as exactly that, never as a fit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from bggpipe.config import Config
|
||||||
|
from bggpipe.models import DIM_AXES
|
||||||
|
from bggpipe.shelves import (
|
||||||
|
apply_measurements,
|
||||||
|
load_furniture,
|
||||||
|
load_locations,
|
||||||
|
opening_index,
|
||||||
|
opening_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
_AXES = DIM_AXES
|
||||||
|
|
||||||
|
|
||||||
|
def _footprint(dims: dict) -> float:
|
||||||
|
"""The two largest axes multiplied — the shelf area a box claims
|
||||||
|
lying in its flattest orientation."""
|
||||||
|
axes = sorted((dims[a] for a in _AXES), reverse=True)
|
||||||
|
return axes[0] * axes[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run_dims_report(cfg: Config) -> dict:
|
||||||
|
if not cfg.games_path.exists():
|
||||||
|
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
games: dict[str, dict] = apply_measurements(
|
||||||
|
cfg, json.loads(cfg.games_path.read_text())
|
||||||
|
)
|
||||||
|
|
||||||
|
contained = [
|
||||||
|
entry.get("name") or "?" for entry in games.values() if entry.get("stored_in")
|
||||||
|
]
|
||||||
|
by_source: dict[str, list[tuple[str, dict]]] = {}
|
||||||
|
for entry in games.values():
|
||||||
|
if entry.get("stored_in"):
|
||||||
|
continue # lives inside another box: no shelf space of its own
|
||||||
|
dims = entry.get("dims") or dict.fromkeys(_AXES, None) | {"source": "absent"}
|
||||||
|
by_source.setdefault(dims["source"], []).append(
|
||||||
|
(entry.get("name") or "?", dims)
|
||||||
|
)
|
||||||
|
|
||||||
|
typer.echo(f"Box dimensions across {len(games)} game(s):")
|
||||||
|
if contained:
|
||||||
|
typer.echo(
|
||||||
|
f" {len(contained):>4} stored inside another box — no shelf "
|
||||||
|
"space of their own"
|
||||||
|
)
|
||||||
|
for source, label in (
|
||||||
|
("version", "from the exact owned version"),
|
||||||
|
("unanimous", "every listed version agrees"),
|
||||||
|
("conflicting", "versions disagree — not guessed"),
|
||||||
|
("absent", "no data on BGG (or no BGG entry)"),
|
||||||
|
):
|
||||||
|
if by_source.get(source):
|
||||||
|
typer.echo(f" {len(by_source[source]):>4} {source} — {label}")
|
||||||
|
|
||||||
|
measured = sorted(
|
||||||
|
(
|
||||||
|
(name, dims)
|
||||||
|
for source in ("version", "unanimous")
|
||||||
|
for name, dims in by_source.get(source, [])
|
||||||
|
),
|
||||||
|
key=lambda item: _footprint(item[1]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
if measured:
|
||||||
|
typer.echo("\nBiggest footprints (two largest axes, flattest lie):")
|
||||||
|
for name, dims in measured[:10]:
|
||||||
|
w, length, d = (dims[a] for a in _AXES)
|
||||||
|
typer.echo(
|
||||||
|
f" {_footprint(dims):>6.1f} sq in {name}"
|
||||||
|
f" ({w:g} × {length:g} × {d:g} in)"
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = [
|
||||||
|
name
|
||||||
|
for source in ("conflicting", "absent")
|
||||||
|
for name, _ in by_source.get(source, [])
|
||||||
|
]
|
||||||
|
|
||||||
|
units = load_furniture(cfg)
|
||||||
|
misfit_names: list[str] = []
|
||||||
|
if not units:
|
||||||
|
typer.echo(
|
||||||
|
"\nNo furniture on file — set up your shelves on the Shelves "
|
||||||
|
"page (bggpipe web) to get per-opening fit checks."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
lane_overrides = {
|
||||||
|
k: v.get("lane", "") for k, v in locations.items() if v.get("lane")
|
||||||
|
}
|
||||||
|
acked = {k for k, v in locations.items() if v.get("acknowledged")}
|
||||||
|
openings = opening_index(units)
|
||||||
|
by_opening: dict[str, list[tuple[str, dict]]] = {}
|
||||||
|
for key, entry in games.items():
|
||||||
|
oid = (locations.get(key) or {}).get("opening_id")
|
||||||
|
if oid:
|
||||||
|
by_opening.setdefault(oid, []).append((key, entry))
|
||||||
|
typer.echo("\nOpenings:")
|
||||||
|
for oid, opening in openings.items():
|
||||||
|
report = opening_report(
|
||||||
|
opening, by_opening.get(oid, []), lane_overrides, acked
|
||||||
|
)
|
||||||
|
sized = all(opening.get(k) for k in ("width_in", "height_in", "depth_in"))
|
||||||
|
dims_note = (
|
||||||
|
f"{opening['width_in']:g}×{opening['height_in']:g}"
|
||||||
|
f"×{opening['depth_in']:g} in"
|
||||||
|
if sized
|
||||||
|
else "no size limits"
|
||||||
|
)
|
||||||
|
line = (
|
||||||
|
f" {opening['unit']} · {opening['label']}"
|
||||||
|
f" ({dims_note}): {report['games']} game(s)"
|
||||||
|
)
|
||||||
|
if report["flat_in"]:
|
||||||
|
line += f', {report["flat_in"]:g}" flat stack'
|
||||||
|
if report["standing_in"]:
|
||||||
|
line += f', {report["standing_in"]:g}" standing'
|
||||||
|
if report["unmeasured"]:
|
||||||
|
line += f", {report['unmeasured']} unmeasured"
|
||||||
|
if report["overfull"]:
|
||||||
|
line += " ⚠ NO PACKING FITS"
|
||||||
|
typer.echo(line)
|
||||||
|
for name in report["laneless"]:
|
||||||
|
misfit_names.append(name)
|
||||||
|
typer.echo(f" ⚠ no lane can take: {name}")
|
||||||
|
for name in report["misfits"]:
|
||||||
|
if name not in misfit_names:
|
||||||
|
misfit_names.append(name)
|
||||||
|
typer.echo(f" ⚠ doesn't fit this opening at all: {name}")
|
||||||
|
for k, e in by_opening.get(oid, []):
|
||||||
|
if k in acked:
|
||||||
|
typer.echo(f" here anyway (yours): {e.get('name') or '?'}")
|
||||||
|
homeless = [
|
||||||
|
entry.get("name") or "?"
|
||||||
|
for key, entry in games.items()
|
||||||
|
if not entry.get("stored_in")
|
||||||
|
# an assignment to a vanished opening is homeless too — a
|
||||||
|
# truthy id must not hide a game from the count
|
||||||
|
and (locations.get(key) or {}).get("opening_id") not in openings
|
||||||
|
]
|
||||||
|
if homeless:
|
||||||
|
typer.echo(f"\n{len(homeless)} game(s) unshelved.")
|
||||||
|
if unknown:
|
||||||
|
typer.echo(
|
||||||
|
f"\n{len(unknown)} game(s) can't be size-verified (no trusted "
|
||||||
|
"dimensions — not the same as fitting):"
|
||||||
|
)
|
||||||
|
for name in sorted(unknown):
|
||||||
|
typer.echo(f" {name}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"by_source": {s: len(v) for s, v in by_source.items()},
|
||||||
|
"misfits": misfit_names,
|
||||||
|
"unknown": sorted(unknown),
|
||||||
|
"contained": sorted(contained),
|
||||||
|
}
|
||||||
+113
-1
@@ -27,12 +27,104 @@ from bggpipe.bgg_client import (
|
|||||||
)
|
)
|
||||||
from bggpipe.config import Config
|
from bggpipe.config import Config
|
||||||
from bggpipe.fsio import atomic_write_text
|
from bggpipe.fsio import atomic_write_text
|
||||||
from bggpipe.models import is_confident_version, is_recognized
|
from bggpipe.models import (
|
||||||
|
DIM_AXES,
|
||||||
|
is_confident_version,
|
||||||
|
is_recognized,
|
||||||
|
parse_version_dims,
|
||||||
|
)
|
||||||
from bggpipe.normalize import normalize_title
|
from bggpipe.normalize import normalize_title
|
||||||
from bggpipe.resolve import load_titles, read_matches
|
from bggpipe.resolve import load_titles, read_matches
|
||||||
|
|
||||||
BATCH_SIZE = 20
|
BATCH_SIZE = 20
|
||||||
|
|
||||||
|
_AXES = DIM_AXES
|
||||||
|
_AGREE_IN = 0.5 # per-axis tolerance for calling versionless dims unanimous
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_dims(entry: dict, records: list[dict]) -> dict:
|
||||||
|
"""The box-dimensions verdict for one game, from its versions' data.
|
||||||
|
|
||||||
|
Known version: that exact version's numbers or nothing. No chosen
|
||||||
|
version: only a chorus that AGREES (within 0.5" per axis, all axes
|
||||||
|
present) counts — the maximum per axis is kept, since the planning
|
||||||
|
question is "will it fit", and disagreement beyond tolerance stores
|
||||||
|
nulls rather than a guess."""
|
||||||
|
version_id = (entry.get("version") or {}).get("version_id")
|
||||||
|
if version_id:
|
||||||
|
rec = next((r for r in records if r["version_id"] == version_id), None)
|
||||||
|
if rec and all(rec[a] for a in _AXES):
|
||||||
|
return {
|
||||||
|
**{a: rec[a] for a in _AXES},
|
||||||
|
"weight_lb": rec["weight_lb"],
|
||||||
|
"source": "version",
|
||||||
|
}
|
||||||
|
return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
|
||||||
|
complete = [r for r in records if all(r[a] for a in _AXES)]
|
||||||
|
if not complete:
|
||||||
|
return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
|
||||||
|
dims: dict = {}
|
||||||
|
for axis in _AXES:
|
||||||
|
values = [r[axis] for r in complete]
|
||||||
|
if max(values) - min(values) > _AGREE_IN:
|
||||||
|
return dict.fromkeys((*_AXES, "weight_lb"), None) | {
|
||||||
|
"source": "conflicting"
|
||||||
|
}
|
||||||
|
dims[axis] = max(values)
|
||||||
|
weights = [r["weight_lb"] for r in complete if r["weight_lb"]]
|
||||||
|
dims["weight_lb"] = (
|
||||||
|
max(weights) if weights and max(weights) - min(weights) <= 0.5 else None
|
||||||
|
)
|
||||||
|
return dims | {"source": "unanimous"}
|
||||||
|
|
||||||
|
|
||||||
|
def _dims_pass(games: dict, client: BGGClient, *, refresh: bool) -> tuple[int, bool]:
|
||||||
|
"""Fill entry["dims"] for every BGG-backed game from versions=1 data
|
||||||
|
(physical dimensions live on VERSIONS, not games). Cached XML makes
|
||||||
|
re-runs free; rpgitems and local games get source "absent"."""
|
||||||
|
need_ids = sorted(
|
||||||
|
{
|
||||||
|
e["bgg_id"]
|
||||||
|
for e in games.values()
|
||||||
|
if e.get("type") in ("boardgame", "boardgameexpansion")
|
||||||
|
and e.get("bgg_id")
|
||||||
|
and (refresh or "dims" not in e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
by_game: dict[int, list[dict]] = {}
|
||||||
|
blocked = False
|
||||||
|
for start in range(0, len(need_ids), BATCH_SIZE):
|
||||||
|
batch = need_ids[start : start + BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
xml = client.get_xml(
|
||||||
|
"thing",
|
||||||
|
{"id": ",".join(str(i) for i in batch), "versions": "1"},
|
||||||
|
refresh=refresh,
|
||||||
|
)
|
||||||
|
except (BGGAuthError, BGGQueueTimeout):
|
||||||
|
blocked = True
|
||||||
|
break
|
||||||
|
by_game.update(parse_version_dims(xml))
|
||||||
|
filled = 0
|
||||||
|
for entry in games.values():
|
||||||
|
if not (refresh or "dims" not in entry):
|
||||||
|
continue
|
||||||
|
if entry.get("bgg_id") in by_game:
|
||||||
|
entry["dims"] = _entry_dims(entry, by_game[entry["bgg_id"]])
|
||||||
|
if entry["dims"]["source"] == "version" and entry.get("version"):
|
||||||
|
entry["version"].update(
|
||||||
|
{k: entry["dims"][k] for k in (*_AXES, "weight_lb")}
|
||||||
|
)
|
||||||
|
filled += 1
|
||||||
|
elif entry.get("bgg_id") is None or entry.get("type") not in (
|
||||||
|
"boardgame",
|
||||||
|
"boardgameexpansion",
|
||||||
|
):
|
||||||
|
entry["dims"] = dict.fromkeys((*_AXES, "weight_lb"), None) | {
|
||||||
|
"source": "absent"
|
||||||
|
}
|
||||||
|
return filled, blocked
|
||||||
|
|
||||||
|
|
||||||
def _version_info(row: dict) -> dict | None:
|
def _version_info(row: dict) -> dict | None:
|
||||||
if not is_confident_version(row):
|
if not is_confident_version(row):
|
||||||
@@ -184,6 +276,24 @@ def run_enrich(
|
|||||||
f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}"
|
f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# containment is row-level curation: carry it onto entries every run
|
||||||
|
# so the library and the shelf math both know what shares a box
|
||||||
|
stored_by_key = {}
|
||||||
|
for row in rows:
|
||||||
|
if is_recognized(row) and row.get("stored_in"):
|
||||||
|
version = _version_info(row)
|
||||||
|
key = (
|
||||||
|
f"{row['bgg_id']}:{version['version_id']}" if version else row["bgg_id"]
|
||||||
|
)
|
||||||
|
stored_by_key[key] = row["stored_in"]
|
||||||
|
for key, entry in games.items():
|
||||||
|
entry["stored_in"] = stored_by_key.get(key)
|
||||||
|
|
||||||
|
# box dimensions live on VERSIONS, not games: a second cached pass
|
||||||
|
# (read-only; upload is untouched) fills entry["dims"] for shelf math
|
||||||
|
dims_filled, dims_blocked = _dims_pass(games, client, refresh=refresh)
|
||||||
|
blocked = blocked or dims_blocked
|
||||||
|
|
||||||
games_path.parent.mkdir(parents=True, exist_ok=True)
|
games_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
atomic_write_text(
|
atomic_write_text(
|
||||||
games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
|
games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
|
||||||
@@ -194,6 +304,8 @@ def run_enrich(
|
|||||||
# tally would drive "already present or waiting" negative
|
# tally would drive "already present or waiting" negative
|
||||||
waiting = len(targets) - updated
|
waiting = len(targets) - updated
|
||||||
parts = [f"{updated} fetched from BGG"]
|
parts = [f"{updated} fetched from BGG"]
|
||||||
|
if dims_filled:
|
||||||
|
parts.append(f"box dims checked for {dims_filled}")
|
||||||
if local_keys:
|
if local_keys:
|
||||||
parts.append(f"{len(local_keys)} local-only")
|
parts.append(f"{len(local_keys)} local-only")
|
||||||
if waiting:
|
if waiting:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
+34
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import warnings
|
import warnings
|
||||||
import xml.etree.ElementTree as ET # parsing itself goes via defusedxml
|
import xml.etree.ElementTree as ET # parsing itself goes via defusedxml
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -22,6 +23,11 @@ PENDING_MATCH_STATUSES = ("ambiguous", "unmatched")
|
|||||||
UNDECIDED_MATCH_STATUSES = ("ambiguous", "unmatched", "merged")
|
UNDECIDED_MATCH_STATUSES = ("ambiguous", "unmatched", "merged")
|
||||||
|
|
||||||
|
|
||||||
|
# the write/read contract for entry["dims"]: enrich writes these keys,
|
||||||
|
# shelves and dims consume them
|
||||||
|
DIM_AXES = ("width_in", "length_in", "depth_in")
|
||||||
|
|
||||||
|
|
||||||
def is_recognized(row: dict) -> bool:
|
def is_recognized(row: dict) -> bool:
|
||||||
"""A row that reaches diff/enrich: matched and carrying a real id."""
|
"""A row that reaches diff/enrich: matched and carrying a real id."""
|
||||||
return row["match_status"] in RECOGNIZED_MATCH_STATUSES and bool(row["bgg_id"])
|
return row["match_status"] in RECOGNIZED_MATCH_STATUSES and bool(row["bgg_id"])
|
||||||
@@ -228,7 +234,12 @@ def parse_things_full(xml_text: str) -> list[dict]:
|
|||||||
"type": item.get("type", "boardgame"),
|
"type": item.get("type", "boardgame"),
|
||||||
"name": name.get("value", "") if name is not None else "",
|
"name": name.get("value", "") if name is not None else "",
|
||||||
"year": _attr_int(item.find("yearpublished")),
|
"year": _attr_int(item.find("yearpublished")),
|
||||||
"description": (item.findtext("description") or "").strip(),
|
# BGG serves descriptions with entities still encoded
|
||||||
|
# (’ etc): decode ONCE here so every consumer —
|
||||||
|
# app, export, games.json — gets plain text
|
||||||
|
"description": html.unescape(
|
||||||
|
(item.findtext("description") or "").strip()
|
||||||
|
),
|
||||||
"image": (item.findtext("image") or "").strip(),
|
"image": (item.findtext("image") or "").strip(),
|
||||||
"thumbnail": (item.findtext("thumbnail") or "").strip(),
|
"thumbnail": (item.findtext("thumbnail") or "").strip(),
|
||||||
"min_players": _attr_int(item.find("minplayers")),
|
"min_players": _attr_int(item.find("minplayers")),
|
||||||
@@ -268,6 +279,28 @@ def parse_things_full(xml_text: str) -> list[dict]:
|
|||||||
return games
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version_dims(xml_text: str) -> dict[int, list[dict]]:
|
||||||
|
"""Physical box data per game id from a versions=1 response. BGG uses
|
||||||
|
0 for "never entered" — a zero axis or weight parses as None, never
|
||||||
|
as a real dimension. Returns plain dicts (artifact data)."""
|
||||||
|
out: dict[int, list[dict]] = {}
|
||||||
|
for item in _root(xml_text).findall("item"):
|
||||||
|
game_id = int(_required_attr(item, "id"))
|
||||||
|
records = out.setdefault(game_id, [])
|
||||||
|
for v_item in item.findall("versions/item"):
|
||||||
|
record: dict = {"version_id": int(_required_attr(v_item, "id"))}
|
||||||
|
for xml_name, field_name in (
|
||||||
|
("width", "width_in"),
|
||||||
|
("length", "length_in"),
|
||||||
|
("depth", "depth_in"),
|
||||||
|
("weight", "weight_lb"),
|
||||||
|
):
|
||||||
|
value = _attr_float(v_item.find(xml_name))
|
||||||
|
record[field_name] = value if value else None
|
||||||
|
records.append(record)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _required_attr(item: ET.Element, name: str) -> str:
|
def _required_attr(item: ET.Element, name: str) -> str:
|
||||||
value = item.get(name)
|
value = item.get(name)
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ MATCH_COLUMNS = [
|
|||||||
"source_photos",
|
"source_photos",
|
||||||
"merged_into",
|
"merged_into",
|
||||||
"dedupe_veto",
|
"dedupe_veto",
|
||||||
|
# human curation: the bgg_id of the box this game physically lives
|
||||||
|
# inside (a compilation like a Character Trove) — "" = its own box
|
||||||
|
"stored_in",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -514,8 +514,10 @@ class ReviewSession:
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
row["version_status"] = "version_ambiguous"
|
row["version_status"] = "version_ambiguous"
|
||||||
row["version_id"] = ""
|
# a CURRENT pick survives the reopen: abandoning the ballot must
|
||||||
row["version_name"] = ""
|
# 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)
|
self._save(row)
|
||||||
return len(scored)
|
return len(scored)
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -498,6 +498,174 @@ a.game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
|||||||
padding: .35rem .45rem; background: #fff; resize: vertical;
|
padding: .35rem .45rem; background: #fff; resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* pick rows live inside an .editform, whose label style (stacked,
|
||||||
|
uppercase, gray) is for FIELDS — a checklist row overrides it back
|
||||||
|
to a plain reading line */
|
||||||
|
.editform label.pickrow {
|
||||||
|
flex-direction: row; align-items: center; gap: .55rem;
|
||||||
|
font-size: .9rem; text-transform: none; letter-spacing: normal;
|
||||||
|
color: var(--ink); padding: .32rem .35rem; cursor: pointer;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.editform label.pickrow:hover { background: rgba(122, 74, 158, .08); }
|
||||||
|
.pickrow input[type="checkbox"] {
|
||||||
|
margin: 0; width: 1.05rem; height: 1.05rem; flex: none;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.pickrow .meta { font-size: .78rem; color: var(--ink-soft); }
|
||||||
|
.pickrow .chip { margin-left: auto; }
|
||||||
|
#pickresults {
|
||||||
|
flex: 1 1 100%; max-height: 22rem; overflow-y: auto;
|
||||||
|
border: 1px solid var(--board-edge); border-radius: var(--radius);
|
||||||
|
padding: .25rem; background: #fff;
|
||||||
|
}
|
||||||
|
#pickfooter {
|
||||||
|
flex: 1 1 100%; display: flex; gap: .7rem; align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#pickfooter .edithint { flex: 1 1 100%; }
|
||||||
|
/* selects join the design system EVERYWHERE — the third time a native
|
||||||
|
dropdown showed up in a card, the lesson finally took: style the
|
||||||
|
element, not each container it appears in */
|
||||||
|
select {
|
||||||
|
font: inherit; font-size: .85rem; color: var(--ink);
|
||||||
|
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||||
|
padding: .25rem .45rem; background: #fff; max-width: 24rem;
|
||||||
|
}
|
||||||
|
/* :not([hidden]) so the hidden attribute still works — a bare
|
||||||
|
display:flex would override the browser's [hidden] rule and show
|
||||||
|
the form permanently */
|
||||||
|
#storedform:not([hidden]) { display: flex; gap: .5rem; align-items: center; }
|
||||||
|
#storedform select { flex: 1 1 auto; }
|
||||||
|
|
||||||
|
/* -- shelves: units as grids of openings, sheet for one opening ------- */
|
||||||
|
/* .card is flex for the review pages' photo-beside-body layout; a shelf
|
||||||
|
card is a vertical STACK — without this, unit headers share a row with
|
||||||
|
their grid and unshelved rows flow horizontally off the page */
|
||||||
|
.card.stack { display: block; }
|
||||||
|
.unit .unithead { display: flex; align-items: center; gap: .7rem;
|
||||||
|
justify-content: space-between; flex-wrap: wrap; }
|
||||||
|
/* rows mirror the unit's PHYSICAL arrangement: one .orow per label
|
||||||
|
row (A, B, ...), cells sized by flex-grow proportional to interior
|
||||||
|
width — a double-wide reads double wide */
|
||||||
|
.openings { display: flex; flex-direction: column; gap: .5rem;
|
||||||
|
margin-bottom: .8rem; }
|
||||||
|
.orow { display: flex; gap: .5rem; }
|
||||||
|
.formlabel { margin: 1.2rem 0 .35rem; }
|
||||||
|
.editform label.attop { flex-direction: row; align-items: center; gap: .35rem;
|
||||||
|
text-transform: none; letter-spacing: normal; align-self: end;
|
||||||
|
padding-bottom: .45rem; }
|
||||||
|
.renamerow:not([hidden]) { display: flex; gap: .4rem; margin: .3rem 0 .6rem; }
|
||||||
|
.renamerow input { font: inherit; font-size: .9rem;
|
||||||
|
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||||
|
padding: .25rem .45rem; flex: 0 1 18rem; }
|
||||||
|
.unitdesc { margin: -.3rem 0 .7rem; display: flex; gap: .6rem;
|
||||||
|
align-items: center; flex-wrap: wrap; }
|
||||||
|
.unitdesc .descform:not([hidden]) { display: flex; gap: .4rem; flex: 1 1 16rem; }
|
||||||
|
.unitdesc .descform input { flex: 1 1 auto; font: inherit; font-size: .85rem;
|
||||||
|
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||||
|
padding: .25rem .45rem; }
|
||||||
|
.formlabel.small { font-weight: 700; font-size: .85rem;
|
||||||
|
color: var(--ink-soft); margin: .8rem 0 .3rem; }
|
||||||
|
button.opening {
|
||||||
|
font: inherit; text-align: left; cursor: pointer;
|
||||||
|
background: #fff; border: 2px solid var(--board-edge);
|
||||||
|
border-radius: var(--radius); padding: .5rem .6rem;
|
||||||
|
display: flex; flex-direction: column; gap: .25rem; min-height: 5.2rem;
|
||||||
|
flex-basis: 0; min-width: 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
button.opening:hover, button.opening:focus-visible {
|
||||||
|
outline: 3px solid var(--accent); }
|
||||||
|
.olabel { font-weight: 700; display: flex; gap: .35rem; align-items: center; }
|
||||||
|
.ozone { font-size: .72rem; color: var(--accent); }
|
||||||
|
.ocount { font-size: .82rem; color: var(--ink-soft); }
|
||||||
|
.fillbar { height: .45rem; background: var(--board-edge);
|
||||||
|
border-radius: 1rem; overflow: hidden; }
|
||||||
|
.fillbar > div { height: 100%; background: var(--go, #2f8a45);
|
||||||
|
border-radius: 1rem; }
|
||||||
|
.fillbar > div.over { background: #b3372f; }
|
||||||
|
.fillbar.dual { margin-top: 2px; }
|
||||||
|
.fillbar > div.stand { background: #3e6fa3; }
|
||||||
|
.lanebtn { font: inherit; font-size: .72rem; cursor: pointer;
|
||||||
|
border: 1px solid var(--board-edge); border-radius: 1rem;
|
||||||
|
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
|
||||||
|
.lanebtn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.ackbtn { font: inherit; font-size: .72rem; cursor: pointer;
|
||||||
|
border: 1px solid #b3372f; border-radius: 1rem;
|
||||||
|
background: #fff; padding: .05rem .5rem; color: #b3372f; }
|
||||||
|
.ackbtn.on { border-color: var(--board-edge); background: var(--felt-soft, #eef2ea);
|
||||||
|
color: var(--ink-soft); }
|
||||||
|
.ackbtn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.measurebtn { font: inherit; font-size: .72rem; cursor: pointer;
|
||||||
|
border: 1px dashed var(--board-edge); border-radius: 1rem;
|
||||||
|
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
|
||||||
|
.measurebtn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.measureform { display: inline-flex; gap: .25rem; align-items: center;
|
||||||
|
font-size: .78rem; color: var(--ink-soft); }
|
||||||
|
.measureform input { width: 3.6rem; font: inherit; padding: .1rem .25rem;
|
||||||
|
border: 1px solid var(--board-edge); border-radius: .3rem; }
|
||||||
|
.measureform .measuresave { font: inherit; font-size: .72rem; cursor: pointer;
|
||||||
|
border: 1px solid var(--board-edge); border-radius: 1rem;
|
||||||
|
background: #fff; padding: .05rem .5rem; }
|
||||||
|
.unshelved-row { display: flex; gap: .6rem; align-items: center;
|
||||||
|
flex-wrap: wrap; padding: .4rem 0;
|
||||||
|
border-bottom: 1px solid var(--board-edge); }
|
||||||
|
.unshelved-row .gos { display: flex; gap: .4rem; flex-wrap: wrap;
|
||||||
|
margin-left: auto; }
|
||||||
|
button.suggest { font: inherit; font-size: .8rem; cursor: pointer;
|
||||||
|
border: 2px solid var(--board-edge); border-radius: 1rem;
|
||||||
|
background: var(--board); padding: .15rem .6rem; }
|
||||||
|
button.suggest:hover { border-color: var(--accent); }
|
||||||
|
button.suggest.reunites { border-color: var(--accent); font-weight: 700; }
|
||||||
|
|
||||||
|
/* the opening sheet: modal on desktop, bottom sheet on phones */
|
||||||
|
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 40; }
|
||||||
|
#opensheet[hidden] { display: none; }
|
||||||
|
.sheetcard { background: var(--board); border-radius: var(--radius-lg);
|
||||||
|
border: var(--line); box-shadow: var(--shadow-raised);
|
||||||
|
width: min(34rem, 92vw); max-height: 86vh; overflow-y: auto;
|
||||||
|
padding: 1rem 1.1rem; }
|
||||||
|
.sheethead { display: flex; justify-content: space-between;
|
||||||
|
align-items: center; gap: .7rem; margin-bottom: .5rem; }
|
||||||
|
#sheetclose { font: inherit; font-size: 1.1rem; background: none;
|
||||||
|
border: none; cursor: pointer; padding: .5rem; }
|
||||||
|
.sheetrow { display: flex; gap: .6rem; align-items: center;
|
||||||
|
padding: .35rem 0; border-bottom: 1px solid var(--board-edge); }
|
||||||
|
.sheetrow img { width: 2.2rem; height: 2.2rem; object-fit: cover;
|
||||||
|
border-radius: .35rem; }
|
||||||
|
.sheetrow .unassign { margin-left: auto; }
|
||||||
|
.addopeningform { margin-top: .6rem; }
|
||||||
|
#locform:not([hidden]) { display: flex; gap: .5rem; align-items: center;
|
||||||
|
flex-wrap: wrap; }
|
||||||
|
#locform select { max-width: 100%; flex: 1 1 14rem; }
|
||||||
|
#locform input { flex: 1 1 10rem; font: inherit; font-size: .85rem;
|
||||||
|
border: 2px solid var(--board-edge); border-radius: var(--radius);
|
||||||
|
padding: .25rem .45rem; }
|
||||||
|
#sheetsearch { width: 100%; margin: .7rem 0 .35rem; font: inherit;
|
||||||
|
font-size: 1rem; padding: .55rem .7rem;
|
||||||
|
border: 2px solid var(--board-edge); border-radius: var(--radius); }
|
||||||
|
button.sheetadd { display: flex; gap: .6rem; align-items: center;
|
||||||
|
width: 100%; text-align: left; font: inherit; cursor: pointer;
|
||||||
|
background: #fff; border: 2px solid var(--board-edge);
|
||||||
|
border-radius: var(--radius); padding: .5rem .6rem; margin: .25rem 0;
|
||||||
|
min-height: 2.9rem; }
|
||||||
|
button.sheetadd img { width: 2.2rem; height: 2.2rem; object-fit: cover;
|
||||||
|
border-radius: .35rem; }
|
||||||
|
button.sheetadd:hover, button.sheetadd:focus-visible {
|
||||||
|
outline: 3px solid var(--accent); }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
/* one-handed moving-day mode: the sheet docks to the bottom, search
|
||||||
|
first, tap targets stay thumb-sized */
|
||||||
|
#opensheet { align-items: flex-end; }
|
||||||
|
.sheetcard { width: 100vw; max-height: 78vh;
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
|
||||||
|
button.opening { min-height: 4.4rem; }
|
||||||
|
.unshelved-row .gos { margin-left: 0; width: 100%; }
|
||||||
|
button.suggest { padding: .5rem .8rem; }
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
background: var(--board); border: 2px dashed var(--board-edge);
|
background: var(--board); border: 2px dashed var(--board-edge);
|
||||||
border-radius: var(--radius-lg); padding: 2rem; text-align: center;
|
border-radius: var(--radius-lg); padding: 2rem; text-align: center;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
|
<p><b><a href="/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="/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="/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>
|
<p><b><a href="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@
|
|||||||
<p><b>pick edition</b> — a matched game with no legible edition cues stays version-less by design (never guess) — but you know which printing your box is. This fetches the game's complete version list into a Review ballot; pick yours there.</p>
|
<p><b>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>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>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><b>remove</b> (inside the edit panel) — for lines that shouldn't exist at all: a book read as a game, box art misread as a title, or a <i>duplicate read</i> — the same physical copy read differently from two photos, leaving two lines for one box (remove the worse read; the survivor keeps its own photos). This is different from <i>reject</i> on the Review page, which keeps the line visible as "no BGG match" — right for real games BGG doesn't know. And the mirror case — ONE line that's really several physical copies — wants <b>split</b>, not remove.</p>
|
||||||
<p>Undo: each decision is one record in <code>data/title_edits.json</code>, <code>data/title_splits.json</code>, or <code>data/title_removals.json</code> — delete the record and the next rebuild restores the old state.</p>
|
<p>Undo: each decision is one record in <code>data/title_edits.json</code>, <code>data/title_splits.json</code>, or <code>data/title_removals.json</code> — delete the record and the next rebuild restores the old state.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
<label>plays with
|
<label>plays with
|
||||||
<input type="number" id="libplayers" min="1" max="20" placeholder="any"
|
<input type="number" id="libplayers" min="1" max="20" placeholder="any"
|
||||||
style="width:5em" aria-label="filter by player count"></label>
|
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
|
<label>sort
|
||||||
<select id="libsort" aria-label="sort library">
|
<select id="libsort" aria-label="sort library">
|
||||||
<option value="name">name</option>
|
<option value="name">name</option>
|
||||||
@@ -47,6 +52,8 @@ function gameCard(g) {
|
|||||||
${g.type === "localgame" ? `<span class="chip open">not on BGG · local</span>` : ""}</div>
|
${g.type === "localgame" ? `<span class="chip open">not on BGG · local</span>` : ""}</div>
|
||||||
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
|
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
|
||||||
${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""}
|
${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""}
|
||||||
|
${g.location && g.location.text
|
||||||
|
? `<div class="gmeta gloc">📍 ${esc(g.location.text)}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
@@ -87,7 +94,16 @@ function render() {
|
|||||||
(g.mechanics || []).join(" "),
|
(g.mechanics || []).join(" "),
|
||||||
(g.categories || []).join(" "),
|
(g.categories || []).join(" "),
|
||||||
g.version && g.version.name,
|
g.version && g.version.name,
|
||||||
|
g.location && g.location.text,
|
||||||
|
g.location && g.location.zone,
|
||||||
].filter(Boolean).join(" ").toLowerCase().includes(q));
|
].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 =>
|
if (seats) rows = rows.filter(g =>
|
||||||
(g.min_players || g.max_players || 0) <= seats
|
(g.min_players || g.max_players || 0) <= seats
|
||||||
&& seats <= (g.max_players || g.min_players || 0));
|
&& seats <= (g.max_players || g.min_players || 0));
|
||||||
@@ -107,12 +123,24 @@ function render() {
|
|||||||
const GATE = changeGate();
|
const GATE = changeGate();
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const games = await fetchJSON("/api/library");
|
const games = await fetchJSON("/api/library");
|
||||||
GATE(games, () => { GAMES = games; render(); });
|
GATE(games, () => {
|
||||||
|
GAMES = games;
|
||||||
|
const units = [...new Set(games.map(g => g.location && g.location.unit)
|
||||||
|
.filter(Boolean))].sort();
|
||||||
|
const pick = document.getElementById("libwhere");
|
||||||
|
const current = pick.value;
|
||||||
|
pick.innerHTML = `<option value="">anywhere</option>
|
||||||
|
<option value="unshelved">unshelved</option>` +
|
||||||
|
units.map(u => `<option value="${esc(u)}">${esc(u)}</option>`).join("");
|
||||||
|
pick.value = current;
|
||||||
|
render();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("libsearch").addEventListener("input", render);
|
document.getElementById("libsearch").addEventListener("input", render);
|
||||||
document.getElementById("libplayers").addEventListener("input", render);
|
document.getElementById("libplayers").addEventListener("input", render);
|
||||||
document.getElementById("libsort").addEventListener("change", render);
|
document.getElementById("libsort").addEventListener("change", render);
|
||||||
|
document.getElementById("libwhere").addEventListener("change", render);
|
||||||
document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => {
|
document.querySelectorAll("[data-kind]").forEach(b => b.addEventListener("click", () => {
|
||||||
KIND = b.dataset.kind;
|
KIND = b.dataset.kind;
|
||||||
document.querySelectorAll("[data-kind]").forEach(o =>
|
document.querySelectorAll("[data-kind]").forEach(o =>
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
const KEY = decodeURIComponent(location.pathname.replace(/^\/library\/game\//, ""));
|
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) {
|
function chips(label, values) {
|
||||||
if (!values || !values.length) return "";
|
if (!values || !values.length) return "";
|
||||||
return `<div class="factrow"><b>${esc(label)}</b>
|
return `<div class="factrow"><b>${esc(label)}</b>
|
||||||
@@ -22,13 +30,15 @@ function fact(label, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function players(g) {
|
function players(g) {
|
||||||
const lo = g.min_players, hi = g.max_players;
|
// mirror of _players() in export.py — keep the two in sync
|
||||||
if (!lo && !hi) return "";
|
const lo = g.min_players ?? g.max_players, hi = g.max_players ?? g.min_players;
|
||||||
const range = lo === hi ? `${lo}` : `${lo ?? "?"}–${hi ?? "?"}`;
|
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
|
const best = (g.best_player_counts || []).length
|
||||||
? ` (best at ${esc(g.best_player_counts.join(", "))})`
|
? ` (best at ${esc(g.best_player_counts.join(", "))})`
|
||||||
: "";
|
: "";
|
||||||
return `${range} players${best}`;
|
return `${range} ${noun}${best}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function playtime(g) {
|
function playtime(g) {
|
||||||
@@ -60,6 +70,50 @@ function localForm(g) {
|
|||||||
</div>`;
|
</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) {
|
function wireLocal(g) {
|
||||||
const form = document.getElementById("localform");
|
const form = document.getElementById("localform");
|
||||||
if (form) form.addEventListener("submit", async e => {
|
if (form) form.addEventListener("submit", async e => {
|
||||||
@@ -124,9 +178,15 @@ function render(g) {
|
|||||||
aria-label="cover photo for ${esc(g.name)}">`
|
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 = [
|
const facts = [
|
||||||
fact("players", players(g)),
|
fact("players", players(g)),
|
||||||
fact("playing time", playtime(g)),
|
fact("playing time", playtime(g)),
|
||||||
|
fact("box", dims),
|
||||||
fact("ages", g.min_age ? `${g.min_age}+` : ""),
|
fact("ages", g.min_age ? `${g.min_age}+` : ""),
|
||||||
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
|
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
|
||||||
fact("BGG rank", g.rank || ""),
|
fact("BGG rank", g.rank || ""),
|
||||||
@@ -151,6 +211,67 @@ function render(g) {
|
|||||||
<div class="card prose"><p class="meta">No specific edition recorded —
|
<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>`;
|
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
|
const photos = (g.photos || []).length
|
||||||
? `<h2>Seen on your shelves</h2>
|
? `<h2>Seen on your shelves</h2>
|
||||||
<div class="card prose"><p>${g.photos.map(p =>
|
<div class="card prose"><p>${g.photos.map(p =>
|
||||||
@@ -165,10 +286,14 @@ function render(g) {
|
|||||||
</div>
|
</div>
|
||||||
${local ? localForm(g) : ""}
|
${local ? localForm(g) : ""}
|
||||||
${version}
|
${version}
|
||||||
|
${boxed}
|
||||||
|
${contains}
|
||||||
${photos}
|
${photos}
|
||||||
${g.description && !local ? `<h2>About</h2>
|
${g.description && !local ? `<h2>About</h2>
|
||||||
<div class="card prose"><p class="gdesc">${esc(g.description)}</p></div>` : ""}`;
|
<div class="card prose"><p class="gdesc">${esc(decodeEntities(g.description))}</p></div>` : ""}`;
|
||||||
wireLocal(g);
|
wireLocal(g);
|
||||||
|
wireStored(g);
|
||||||
|
wireLocation(g);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -17,13 +17,29 @@
|
|||||||
<button type="submit" class="primary">add</button>
|
<button type="submit" class="primary">add</button>
|
||||||
<button type="button" id="addcancel">cancel</button>
|
<button type="button" id="addcancel">cancel</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="edithint">joins the titles list like a photo read — resolve matches it next run</span>
|
<span class="edithint">joins the titles list like a photo read — resolve matches it
|
||||||
|
next run. A whole box of expansions? <button type="button" id="topicks">search BGG and tick them off</button></span>
|
||||||
</form>
|
</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>
|
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
|
||||||
<script>
|
<script>
|
||||||
"use strict";
|
"use strict";
|
||||||
let CATALOG = [];
|
let CATALOG = [];
|
||||||
let EDITING = null; // lineKey of the row whose editor is open
|
let EDITING = null; // lineKey of the row whose editor is open
|
||||||
|
let PICKS = []; // last BGG search results shown in the pick panel
|
||||||
|
|
||||||
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
|
function lineKey(c) { return c.title_raw + "|" + c.photos.join(";"); }
|
||||||
|
|
||||||
@@ -102,8 +118,13 @@ function render() {
|
|||||||
title="this copy's edition ballot is open">ballot in Review →</a>`
|
title="this copy's edition ballot is open">ballot in Review →</a>`
|
||||||
: ""}
|
: ""}
|
||||||
${c.bgg_id && ["auto", "approved"].includes(c.status)
|
${c.bgg_id && ["auto", "approved"].includes(c.status)
|
||||||
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
|
&& c.version_status !== "version_ambiguous"
|
||||||
|
? ["version_auto", "version_approved"].includes(c.version_status)
|
||||||
? `<button class="pickedition" data-title="${esc(c.title_raw)}"
|
? `<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 ?? ""}"
|
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">
|
title="you know which printing this box is — fetch the full edition list into Review">
|
||||||
pick edition</button>`
|
pick edition</button>`
|
||||||
@@ -258,6 +279,75 @@ document.getElementById("addcancel").addEventListener("click", () => {
|
|||||||
addBtn.setAttribute("aria-expanded", "false");
|
addBtn.setAttribute("aria-expanded", "false");
|
||||||
addForm.reset();
|
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 => {
|
addForm.addEventListener("submit", async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const v = name => addForm.elements[name].value;
|
const v = name => addForm.elements[name].value;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
<meta name="apple-mobile-web-app-title" content="bggpipe">
|
<meta name="apple-mobile-web-app-title" content="bggpipe">
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css?v=__ASSET_V__">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip" href="#main">Skip to content</a>
|
<a class="skip" href="#main">Skip to content</a>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div id="banner" role="status"></div>
|
<div id="banner" role="status"></div>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js?v=__ASSET_V__"></script>
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<!--PAGE-->
|
<!--PAGE-->
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+898
-4
@@ -20,6 +20,7 @@ import hashlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
@@ -60,8 +61,25 @@ from bggpipe.models import (
|
|||||||
RECOGNIZED_MATCH_STATUSES,
|
RECOGNIZED_MATCH_STATUSES,
|
||||||
)
|
)
|
||||||
from bggpipe.normalize import normalize_title
|
from bggpipe.normalize import normalize_title
|
||||||
from bggpipe.resolve import TitleEntry
|
from bggpipe.resolve import MATCH_COLUMNS, TitleEntry, read_matches, write_matches
|
||||||
from bggpipe.review import _BGG_ERRORS, ReviewSession
|
from bggpipe.review import _BGG_ERRORS, ReviewSession
|
||||||
|
from bggpipe.shelves import (
|
||||||
|
apply_measurements,
|
||||||
|
effective_away,
|
||||||
|
effective_location,
|
||||||
|
fits_opening,
|
||||||
|
game_dims,
|
||||||
|
load_furniture,
|
||||||
|
load_locations,
|
||||||
|
load_measurements,
|
||||||
|
new_opening_id,
|
||||||
|
opening_index,
|
||||||
|
opening_report,
|
||||||
|
related,
|
||||||
|
save_furniture,
|
||||||
|
save_locations,
|
||||||
|
save_measurements,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_thumbnails(cache_dir: Path) -> dict[int, str]:
|
def load_thumbnails(cache_dir: Path) -> dict[int, str]:
|
||||||
@@ -211,6 +229,81 @@ class ResearchBody(BaseModel):
|
|||||||
types: str | None = None # e.g. "rpgitem" to search RPGGeek
|
types: str | None = None # e.g. "rpgitem" to search RPGGeek
|
||||||
|
|
||||||
|
|
||||||
|
class UnitBody(BaseModel):
|
||||||
|
name: str
|
||||||
|
# None = leave alone (rename-only edits must not wipe a description)
|
||||||
|
description: str | None = None
|
||||||
|
new_name: str = "" # edit-unit only: rename
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningsBody(BaseModel):
|
||||||
|
"""One opening, or a rows × cols grid of identical ones."""
|
||||||
|
|
||||||
|
unit: str
|
||||||
|
zone: str = ""
|
||||||
|
at_top: bool = False # new openings land ABOVE the existing rows
|
||||||
|
label: str = "" # single mode
|
||||||
|
rows: int = 0 # grid mode when rows*cols > 0
|
||||||
|
cols: int = 0
|
||||||
|
width_in: float | None = None
|
||||||
|
height_in: float | None = None
|
||||||
|
depth_in: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningEditBody(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str | None = None
|
||||||
|
zone: str | None = None
|
||||||
|
description: str | None = None # free text for humans; zone is the keyword
|
||||||
|
# all three or none: a partial size cannot exist (see the endpoint)
|
||||||
|
width_in: float | None = None
|
||||||
|
height_in: float | None = None
|
||||||
|
depth_in: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningMoveBody(BaseModel):
|
||||||
|
id: str
|
||||||
|
direction: int # -1 up, +1 down within its unit
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningDeleteBody(BaseModel):
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class LocateBody(BaseModel):
|
||||||
|
key: str
|
||||||
|
opening_id: str = "" # "" = unshelve
|
||||||
|
note: str | None = None # None = leave the note alone; "" = clear it
|
||||||
|
lane: str = "" # "", "flat", "standing": how THIS box sits in the opening
|
||||||
|
|
||||||
|
|
||||||
|
class MeasureBody(BaseModel):
|
||||||
|
key: str
|
||||||
|
width_in: float | None = None # all three set = save; all empty = clear
|
||||||
|
length_in: float | None = None
|
||||||
|
depth_in: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AwayBody(BaseModel):
|
||||||
|
key: str
|
||||||
|
where: str = "" # "travel case", "loaned to Ben"; "" = back home
|
||||||
|
|
||||||
|
|
||||||
|
class AcknowledgeBody(BaseModel):
|
||||||
|
key: str
|
||||||
|
acknowledged: bool # True = "doesn't fit, shelved anyway" is deliberate
|
||||||
|
|
||||||
|
|
||||||
|
class StoredInBody(BaseModel):
|
||||||
|
key: str # games.json key of the game being housed
|
||||||
|
container: str = "" # container's bgg_id; "" = its own box again
|
||||||
|
|
||||||
|
|
||||||
|
class PickBody(BaseModel):
|
||||||
|
picks: list[dict] # {bgg_id, name, year, type} straight from search
|
||||||
|
stored_in: str = "" # container's bgg_id: all picks live in ONE box
|
||||||
|
|
||||||
|
|
||||||
class RemoveBody(BaseModel):
|
class RemoveBody(BaseModel):
|
||||||
title_raw: str
|
title_raw: str
|
||||||
source_photos: str = ""
|
source_photos: str = ""
|
||||||
@@ -274,6 +367,19 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]:
|
|||||||
# (href, page-name, label, badge-name) — badge names match app.js
|
# (href, page-name, label, badge-name) — badge names match app.js
|
||||||
# ordered as the real workflow runs: photograph, proofread the reads
|
# ordered as the real workflow runs: photograph, proofread the reads
|
||||||
# (titles), resolve + decide (review), then what ships (queue, library)
|
# (titles), resolve + decide (review), then what ships (queue, library)
|
||||||
|
def _asset_stamp() -> str:
|
||||||
|
"""Eight hex chars over the static bundle's bytes. Computed once per
|
||||||
|
request path is fine — the files cannot change under a running
|
||||||
|
server, and the read is microseconds."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
static = resources.files("bggpipe") / "static"
|
||||||
|
digest = hashlib.md5()
|
||||||
|
for name in ("app.css", "app.js"):
|
||||||
|
digest.update((static / name).read_bytes())
|
||||||
|
return digest.hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
NAV_PAGES = (
|
NAV_PAGES = (
|
||||||
("/", "pipeline", "Pipeline", ""),
|
("/", "pipeline", "Pipeline", ""),
|
||||||
("/photos", "photos", "Photos", "photos"),
|
("/photos", "photos", "Photos", "photos"),
|
||||||
@@ -281,6 +387,7 @@ NAV_PAGES = (
|
|||||||
("/review", "review", "Review", "review"),
|
("/review", "review", "Review", "review"),
|
||||||
("/queue", "queue", "Queue", "queue"),
|
("/queue", "queue", "Queue", "queue"),
|
||||||
("/library", "library", "Library", ""),
|
("/library", "library", "Library", ""),
|
||||||
|
("/shelves", "shelves", "Shelves", ""),
|
||||||
("/help", "help", "Help", ""),
|
("/help", "help", "Help", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -502,7 +609,7 @@ def create_app(
|
|||||||
if not cfg.games_path.exists():
|
if not cfg.games_path.exists():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
return json.loads(cfg.games_path.read_text())
|
return apply_measurements(cfg, json.loads(cfg.games_path.read_text()))
|
||||||
except (json.JSONDecodeError, OSError) as err:
|
except (json.JSONDecodeError, OSError) as err:
|
||||||
warn_once(f"{cfg.games_path.name} unreadable ({err}) — library unavailable")
|
warn_once(f"{cfg.games_path.name} unreadable ({err}) — library unavailable")
|
||||||
return {}
|
return {}
|
||||||
@@ -714,7 +821,14 @@ def create_app(
|
|||||||
(a detail page keeps its section lit)."""
|
(a detail page keeps its section lit)."""
|
||||||
active = active or name
|
active = active or name
|
||||||
templates = resources.files("bggpipe") / "templates"
|
templates = resources.files("bggpipe") / "templates"
|
||||||
shell = (templates / "shell.html").read_text()
|
# content-hashed asset URLs: the stamp changes whenever the FILES
|
||||||
|
# change — a version-number stamp proved worthless between
|
||||||
|
# releases, serving week-old CSS at an unchanged ?v=1.0.0
|
||||||
|
shell = (
|
||||||
|
(templates / "shell.html")
|
||||||
|
.read_text()
|
||||||
|
.replace("__ASSET_V__", _asset_stamp())
|
||||||
|
)
|
||||||
fragment = (templates / "pages" / f"{name}.html").read_text()
|
fragment = (templates / "pages" / f"{name}.html").read_text()
|
||||||
nav = "\n".join(
|
nav = "\n".join(
|
||||||
f' <a href="{href}"'
|
f' <a href="{href}"'
|
||||||
@@ -772,6 +886,10 @@ def create_app(
|
|||||||
# the fragment reads the key from its own URL; nothing interpolated
|
# the fragment reads the key from its own URL; nothing interpolated
|
||||||
return render_page("librarygame", active="library")
|
return render_page("librarygame", active="library")
|
||||||
|
|
||||||
|
@app.get("/shelves", response_class=HTMLResponse)
|
||||||
|
def shelves_page() -> str:
|
||||||
|
return render_page("shelves")
|
||||||
|
|
||||||
@app.get("/help", response_class=HTMLResponse)
|
@app.get("/help", response_class=HTMLResponse)
|
||||||
def help_page() -> str:
|
def help_page() -> str:
|
||||||
return render_page("help")
|
return render_page("help")
|
||||||
@@ -849,13 +967,38 @@ def create_app(
|
|||||||
photos_by_key.setdefault(key, []).extend(
|
photos_by_key.setdefault(key, []).extend(
|
||||||
p for p in row["source_photos"].split(";") if p
|
p for p in row["source_photos"].split(";") if p
|
||||||
)
|
)
|
||||||
|
units = load_furniture(cfg)
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
openings = opening_index(units)
|
||||||
out = {}
|
out = {}
|
||||||
for key, game in games.items():
|
for key, game in games.items():
|
||||||
photos = game.get("source_photos") or photos_by_key.get(key, [])
|
photos = game.get("source_photos") or photos_by_key.get(key, [])
|
||||||
|
oid, note, via = effective_location(key, game, games, locations)
|
||||||
|
opening = openings.get(oid) if oid else None
|
||||||
|
if opening:
|
||||||
|
where = f"{opening['unit']} · {opening['label']}"
|
||||||
|
elif via is not None:
|
||||||
|
container = games.get(via, {})
|
||||||
|
where = f"inside {container.get('name') or 'another box'}"
|
||||||
|
elif note:
|
||||||
|
where = note # a virtual location: "lent to Sarah, June"
|
||||||
|
else:
|
||||||
|
where = ""
|
||||||
out[key] = {
|
out[key] = {
|
||||||
**game,
|
**game,
|
||||||
"key": key,
|
"key": key,
|
||||||
"photos": [p for p in dict.fromkeys(photos) if p in available],
|
"photos": [p for p in dict.fromkeys(photos) if p in available],
|
||||||
|
"location": {
|
||||||
|
"opening_id": oid,
|
||||||
|
"unit": opening["unit"] if opening else None,
|
||||||
|
"label": opening["label"] if opening else None,
|
||||||
|
"zone": (opening.get("zone") or "") if opening else "",
|
||||||
|
"note": note,
|
||||||
|
"via": via,
|
||||||
|
"text": where,
|
||||||
|
"away": effective_away(key, game, games, locations),
|
||||||
|
"away_own": (locations.get(key) or {}).get("away", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -950,11 +1093,650 @@ def create_app(
|
|||||||
raise HTTPException(404, "no such image")
|
raise HTTPException(404, "no such image")
|
||||||
return FileResponse(target)
|
return FileResponse(target)
|
||||||
|
|
||||||
|
# -- the physical-location layer -----------------------------------
|
||||||
|
|
||||||
|
def _shelf_stores():
|
||||||
|
"""Furniture + locations for endpoints: corrupt stores become a
|
||||||
|
spoken 500, not a raw traceback."""
|
||||||
|
try:
|
||||||
|
return load_furniture(cfg), load_locations(cfg)
|
||||||
|
except ValueError as err:
|
||||||
|
raise HTTPException(500, str(err)) from err
|
||||||
|
|
||||||
|
def _shelf_state() -> dict:
|
||||||
|
"""Units with per-opening capacity + resident games, the unshelved
|
||||||
|
list with fit-checked suggestions, and virtual locations."""
|
||||||
|
games = read_games()
|
||||||
|
units, locations = _shelf_stores()
|
||||||
|
openings = opening_index(units)
|
||||||
|
residents: dict[str, list[tuple[str, dict]]] = {}
|
||||||
|
located_keys: set[str] = set()
|
||||||
|
lost_home: set[str] = set()
|
||||||
|
rides_in: dict[str, str] = {} # inherited resident -> its box's key
|
||||||
|
for key, entry in games.items():
|
||||||
|
oid, _, via = effective_location(key, entry, games, locations)
|
||||||
|
if oid and oid in openings:
|
||||||
|
residents.setdefault(oid, []).append((key, entry))
|
||||||
|
located_keys.add(key)
|
||||||
|
if via is not None:
|
||||||
|
rides_in[key] = via
|
||||||
|
elif oid and via is None:
|
||||||
|
# assigned to an opening that no longer exists (a store
|
||||||
|
# edited or reverted by hand): SURFACE it, never hide it
|
||||||
|
lost_home.add(key)
|
||||||
|
elif via is not None:
|
||||||
|
located_keys.add(key) # rides with its container
|
||||||
|
lane_overrides = {
|
||||||
|
k: v.get("lane", "") for k, v in locations.items() if v.get("lane")
|
||||||
|
}
|
||||||
|
acked = {k for k, v in locations.items() if v.get("acknowledged")}
|
||||||
|
out_units = []
|
||||||
|
for unit in units:
|
||||||
|
unit = {**unit, "description": unit.get("description", "")}
|
||||||
|
out_openings = []
|
||||||
|
for opening in unit.get("openings", []):
|
||||||
|
here = residents.get(opening["id"], [])
|
||||||
|
report = opening_report(opening, here, lane_overrides, acked)
|
||||||
|
out_openings.append(
|
||||||
|
{
|
||||||
|
**opening,
|
||||||
|
**report,
|
||||||
|
"resident_games": [
|
||||||
|
{
|
||||||
|
"key": k,
|
||||||
|
"name": e.get("name"),
|
||||||
|
"thumbnail": e.get("thumbnail") or "",
|
||||||
|
"inherited": bool(e.get("stored_in")),
|
||||||
|
"measured": game_dims(e) is not None,
|
||||||
|
"lane": report["lanes"].get(k, ""),
|
||||||
|
"lane_override": lane_overrides.get(k, ""),
|
||||||
|
"misfit": fits_opening(e, opening) is False,
|
||||||
|
"laneless": (e.get("name") or "?")
|
||||||
|
in report["laneless"],
|
||||||
|
"acknowledged": k in acked,
|
||||||
|
"wedged": (e.get("name") or "?") in report["wedged"],
|
||||||
|
"proud": (e.get("name") or "?") in report["proud"],
|
||||||
|
"away": effective_away(k, e, games, locations),
|
||||||
|
"via": rides_in.get(k, ""),
|
||||||
|
}
|
||||||
|
for k, e in sorted(
|
||||||
|
here,
|
||||||
|
key=lambda p: (p[1].get("name") or "").casefold(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out_units.append(
|
||||||
|
{
|
||||||
|
"name": unit["name"],
|
||||||
|
"description": unit.get("description", ""),
|
||||||
|
"openings": out_openings,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
unshelved = []
|
||||||
|
for key, entry in sorted(
|
||||||
|
games.items(), key=lambda p: (p[1].get("name") or "").casefold()
|
||||||
|
):
|
||||||
|
if key in located_keys or entry.get("stored_in"):
|
||||||
|
continue
|
||||||
|
note = (locations.get(key) or {}).get("note", "")
|
||||||
|
# relevance gates, in rank order. (1) Reunification beats
|
||||||
|
# geometry: an opening already holding a series-mate or base
|
||||||
|
# game ranks first — every confirmed placement teaches the
|
||||||
|
# suggester the owner's organization by example. (2) "Fits"
|
||||||
|
# means the REMAINING opening, not the empty one: the stack
|
||||||
|
# budget already spent is subtracted before offering, so
|
||||||
|
# honesty holds as shelves fill. Then tightest fit, so a
|
||||||
|
# small box is offered cubes, never the oversize row.
|
||||||
|
fitting = []
|
||||||
|
for oid, opening in openings.items():
|
||||||
|
if not opening.get("width_in"):
|
||||||
|
continue
|
||||||
|
if fits_opening(entry, opening) is not True:
|
||||||
|
continue
|
||||||
|
here = residents.get(oid, [])
|
||||||
|
# "fits" means the REMAINING opening: an opening whose
|
||||||
|
# current packing already fails is never offered, and the
|
||||||
|
# game must find a lane in the split with itself included
|
||||||
|
current = opening_report(opening, here, lane_overrides, acked)
|
||||||
|
if current["laneless"]:
|
||||||
|
continue
|
||||||
|
probe = opening_report(
|
||||||
|
opening, [*here, (key, entry)], lane_overrides, acked
|
||||||
|
)
|
||||||
|
if (entry.get("name") or "?") in probe["laneless"]:
|
||||||
|
continue
|
||||||
|
reunites = any(related(entry, e) for _, e in here)
|
||||||
|
volume = (
|
||||||
|
opening["width_in"] * opening["height_in"] * opening["depth_in"]
|
||||||
|
)
|
||||||
|
fitting.append((0 if reunites else 1, volume, oid, reunites))
|
||||||
|
fitting.sort(key=lambda item: item[:2])
|
||||||
|
suggestions = [
|
||||||
|
{"id": oid, "reunites": reunites} for _, _, oid, reunites in fitting[:3]
|
||||||
|
]
|
||||||
|
unshelved.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"name": entry.get("name"),
|
||||||
|
"thumbnail": entry.get("thumbnail") or "",
|
||||||
|
"note": note,
|
||||||
|
"away": effective_away(key, entry, games, locations),
|
||||||
|
"measured": game_dims(entry) is not None,
|
||||||
|
"lost_home": key in lost_home,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"units": out_units, "unshelved": unshelved}
|
||||||
|
|
||||||
|
@app.get("/api/shelves")
|
||||||
|
def api_shelves() -> dict:
|
||||||
|
with lock:
|
||||||
|
freshen()
|
||||||
|
return _shelf_state()
|
||||||
|
|
||||||
|
def _mutate_furniture(fn) -> dict:
|
||||||
|
# no _refuse_if_rewriting here: furniture/locations aren't pipeline
|
||||||
|
# artifacts — an extract/resolve rewrite can't clobber them (the
|
||||||
|
# stored-in endpoint edits MATCH rows, hence its guard)
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
freshen()
|
||||||
|
units, _ = _shelf_stores()
|
||||||
|
fn(units)
|
||||||
|
save_furniture(cfg, units)
|
||||||
|
return _shelf_state()
|
||||||
|
|
||||||
|
@app.post("/api/furniture/add-unit")
|
||||||
|
def api_add_unit(body: UnitBody) -> dict:
|
||||||
|
name = body.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "the unit needs a name")
|
||||||
|
|
||||||
|
def add(units):
|
||||||
|
if any(u["name"] == name for u in units):
|
||||||
|
raise HTTPException(409, f"a unit named {name!r} already exists")
|
||||||
|
units.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"description": (body.description or "").strip(),
|
||||||
|
"openings": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return _mutate_furniture(add)
|
||||||
|
|
||||||
|
@app.post("/api/furniture/edit-unit")
|
||||||
|
def api_edit_unit(body: UnitBody) -> dict:
|
||||||
|
def apply(units):
|
||||||
|
unit = next((u for u in units if u["name"] == body.name), None)
|
||||||
|
if unit is None:
|
||||||
|
raise HTTPException(404, "no such unit")
|
||||||
|
if body.new_name.strip() and body.new_name.strip() != body.name:
|
||||||
|
new = body.new_name.strip()
|
||||||
|
if any(u["name"] == new for u in units):
|
||||||
|
raise HTTPException(409, f"a unit named {new!r} already exists")
|
||||||
|
# opening ids are stable through a rename: locations and
|
||||||
|
# the export's URLs never notice
|
||||||
|
unit["name"] = new
|
||||||
|
if body.description is not None:
|
||||||
|
unit["description"] = body.description.strip()
|
||||||
|
|
||||||
|
return _mutate_furniture(apply)
|
||||||
|
|
||||||
|
@app.post("/api/furniture/duplicate-unit")
|
||||||
|
def api_duplicate_unit(body: UnitBody) -> dict:
|
||||||
|
"""Clone a unit's STRUCTURE — openings with dims, zones, and
|
||||||
|
descriptions, under fresh ids — never its game assignments. The
|
||||||
|
copy takes the first free '<name> 2'-style name."""
|
||||||
|
|
||||||
|
def clone(units):
|
||||||
|
source = next((u for u in units if u["name"] == body.name), None)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(404, "no such unit")
|
||||||
|
n = 2
|
||||||
|
while any(u["name"] == f"{body.name} {n}" for u in units):
|
||||||
|
n += 1
|
||||||
|
copy = {
|
||||||
|
"name": f"{body.name} {n}",
|
||||||
|
"description": source.get("description", ""),
|
||||||
|
"openings": [],
|
||||||
|
}
|
||||||
|
units.append(copy)
|
||||||
|
for opening in source.get("openings", []):
|
||||||
|
copy["openings"].append(
|
||||||
|
{
|
||||||
|
**{k: v for k, v in opening.items() if k != "id"},
|
||||||
|
"id": new_opening_id(units, copy["name"], opening["label"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return _mutate_furniture(clone)
|
||||||
|
|
||||||
|
@app.post("/api/furniture/delete-unit")
|
||||||
|
def api_delete_unit(body: UnitBody) -> dict:
|
||||||
|
def drop(units):
|
||||||
|
unit = next((u for u in units if u["name"] == body.name), None)
|
||||||
|
if unit is None:
|
||||||
|
raise HTTPException(404, "no such unit")
|
||||||
|
gone = {o["id"] for o in unit["openings"]}
|
||||||
|
units.remove(unit)
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
kept = {
|
||||||
|
k: v for k, v in locations.items() if v.get("opening_id") not in gone
|
||||||
|
}
|
||||||
|
if kept != locations:
|
||||||
|
save_locations(cfg, kept)
|
||||||
|
|
||||||
|
return _mutate_furniture(drop)
|
||||||
|
|
||||||
|
@app.post("/api/furniture/add-openings")
|
||||||
|
def api_add_openings(body: OpeningsBody) -> dict:
|
||||||
|
def add(units):
|
||||||
|
unit = next((u for u in units if u["name"] == body.unit), None)
|
||||||
|
if unit is None:
|
||||||
|
raise HTTPException(404, "no such unit")
|
||||||
|
dims = {
|
||||||
|
"width_in": body.width_in or None,
|
||||||
|
"height_in": body.height_in or None,
|
||||||
|
"depth_in": body.depth_in or None,
|
||||||
|
}
|
||||||
|
given = [v for v in dims.values() if v]
|
||||||
|
if given and len(given) < 3:
|
||||||
|
# a half-sized opening would silently become limitless:
|
||||||
|
# fit checks need all three axes, or none (virtual)
|
||||||
|
raise HTTPException(400, "give all three interior dimensions, or none")
|
||||||
|
added: list[dict] = []
|
||||||
|
|
||||||
|
def place(new_openings):
|
||||||
|
if body.at_top:
|
||||||
|
unit["openings"][:0] = new_openings
|
||||||
|
else:
|
||||||
|
unit["openings"].extend(new_openings)
|
||||||
|
|
||||||
|
if body.rows and body.cols:
|
||||||
|
# a second grid on the same unit continues the row letters
|
||||||
|
# (A1 already exists -> next section starts at C, say):
|
||||||
|
# labels stay unique, so label-addressed CSV imports work
|
||||||
|
used = [
|
||||||
|
ord(o["label"][0]) - 65
|
||||||
|
for o in unit["openings"]
|
||||||
|
if len(o["label"]) >= 2
|
||||||
|
and o["label"][0].isalpha()
|
||||||
|
and o["label"][1:].isdigit()
|
||||||
|
]
|
||||||
|
start = max(used) + 1 if used else 0
|
||||||
|
for r in range(start, start + body.rows):
|
||||||
|
letters = ""
|
||||||
|
n = r
|
||||||
|
while True:
|
||||||
|
letters = chr(65 + n % 26) + letters
|
||||||
|
n = n // 26 - 1
|
||||||
|
if n < 0:
|
||||||
|
break
|
||||||
|
for c in range(body.cols):
|
||||||
|
label = f"{letters}{c + 1}"
|
||||||
|
added.append(
|
||||||
|
{
|
||||||
|
"id": new_opening_id(units, unit["name"], label),
|
||||||
|
"label": label,
|
||||||
|
"zone": body.zone.strip(),
|
||||||
|
**dims,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
label = body.label.strip()
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(400, "the opening needs a label")
|
||||||
|
added.append(
|
||||||
|
{
|
||||||
|
"id": new_opening_id(units, unit["name"], label),
|
||||||
|
"label": label,
|
||||||
|
"zone": body.zone.strip(),
|
||||||
|
**dims,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
place(added)
|
||||||
|
|
||||||
|
return _mutate_furniture(add)
|
||||||
|
|
||||||
|
@app.post("/api/furniture/edit-opening")
|
||||||
|
def api_edit_opening(body: OpeningEditBody) -> dict:
|
||||||
|
def apply(units):
|
||||||
|
opening = opening_index(units).get(body.id)
|
||||||
|
if opening is None:
|
||||||
|
raise HTTPException(404, "no such opening")
|
||||||
|
real = next(o for u in units for o in u["openings"] if o["id"] == body.id)
|
||||||
|
if body.label is not None:
|
||||||
|
real["label"] = body.label.strip() or real["label"]
|
||||||
|
if body.zone is not None:
|
||||||
|
real["zone"] = body.zone.strip()
|
||||||
|
if body.description is not None:
|
||||||
|
real["description"] = body.description.strip()
|
||||||
|
# dims are all-or-none: three values sets, three blanks makes
|
||||||
|
# it virtual, anything else is a 400 — a partial edit must
|
||||||
|
# never silently strip an opening of its size limits
|
||||||
|
dims = [body.width_in, body.height_in, body.depth_in]
|
||||||
|
given = [v for v in dims if v]
|
||||||
|
if len(given) == 3:
|
||||||
|
real.update(
|
||||||
|
width_in=body.width_in,
|
||||||
|
height_in=body.height_in,
|
||||||
|
depth_in=body.depth_in,
|
||||||
|
)
|
||||||
|
elif not given:
|
||||||
|
real.update(width_in=None, height_in=None, depth_in=None)
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, "give all three interior dimensions, or none")
|
||||||
|
|
||||||
|
return _mutate_furniture(apply)
|
||||||
|
|
||||||
|
@app.post("/api/furniture/delete-opening")
|
||||||
|
def api_delete_opening(body: OpeningDeleteBody) -> dict:
|
||||||
|
def drop(units):
|
||||||
|
for unit in units:
|
||||||
|
for opening in unit["openings"]:
|
||||||
|
if opening["id"] == body.id:
|
||||||
|
unit["openings"].remove(opening)
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
kept = {
|
||||||
|
k: v
|
||||||
|
for k, v in locations.items()
|
||||||
|
if v.get("opening_id") != body.id
|
||||||
|
}
|
||||||
|
if kept != locations:
|
||||||
|
save_locations(cfg, kept)
|
||||||
|
return
|
||||||
|
raise HTTPException(404, "no such opening")
|
||||||
|
|
||||||
|
return _mutate_furniture(drop)
|
||||||
|
|
||||||
|
@app.post("/api/furniture/move-opening")
|
||||||
|
def api_move_opening(body: OpeningMoveBody) -> dict:
|
||||||
|
"""Move an opening within its ROW. The rendered layout groups
|
||||||
|
rows by label letter, so a move across a letter boundary would
|
||||||
|
fragment the wall diagram (A1, B1, A2 renders as three rows) —
|
||||||
|
the move clamps there instead."""
|
||||||
|
|
||||||
|
def row_of(label: str) -> str:
|
||||||
|
m = re.match(r"^([A-Za-z]+)\d+$", label or "")
|
||||||
|
return m.group(1).upper() if m else f"~{label}"
|
||||||
|
|
||||||
|
def move(units):
|
||||||
|
for unit in units:
|
||||||
|
ids = [o["id"] for o in unit["openings"]]
|
||||||
|
if body.id in ids:
|
||||||
|
ops = unit["openings"]
|
||||||
|
i = ids.index(body.id)
|
||||||
|
j = i + (1 if body.direction > 0 else -1)
|
||||||
|
if 0 <= j < len(ops) and row_of(ops[i]["label"]) == row_of(
|
||||||
|
ops[j]["label"]
|
||||||
|
):
|
||||||
|
ops[i], ops[j] = ops[j], ops[i]
|
||||||
|
return
|
||||||
|
raise HTTPException(404, "no such opening")
|
||||||
|
|
||||||
|
return _mutate_furniture(move)
|
||||||
|
|
||||||
|
@app.post("/api/locate")
|
||||||
|
def api_locate(body: LocateBody) -> dict:
|
||||||
|
"""Assign a game to an opening (or unshelve it). Contained games
|
||||||
|
inherit their container's spot and refuse direct assignment —
|
||||||
|
the container is the thing on the shelf."""
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
freshen()
|
||||||
|
games = read_games()
|
||||||
|
entry = games.get(body.key)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(404, "no such game in the library")
|
||||||
|
if entry.get("stored_in"):
|
||||||
|
container = next(
|
||||||
|
(
|
||||||
|
e.get("name")
|
||||||
|
for e in games.values()
|
||||||
|
if str(e.get("bgg_id")) == str(entry["stored_in"])
|
||||||
|
),
|
||||||
|
"its container",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"this game lives inside {container} — shelve that box "
|
||||||
|
"instead; its contents ride along",
|
||||||
|
)
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
opening_id = body.opening_id.strip()
|
||||||
|
if opening_id and opening_id not in opening_index(load_furniture(cfg)):
|
||||||
|
raise HTTPException(400, "no such opening")
|
||||||
|
prev = locations.get(body.key) or {}
|
||||||
|
# a lane flip or ack toggle omits the note — leave it alone
|
||||||
|
note = prev.get("note", "") if body.note is None else body.note.strip()
|
||||||
|
if opening_id or note or prev.get("away"):
|
||||||
|
record = {
|
||||||
|
"opening_id": opening_id or None,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
if body.lane in ("flat", "standing"):
|
||||||
|
record["lane"] = body.lane
|
||||||
|
# an acknowledged overhang belongs to a spot, not the
|
||||||
|
# game: it survives edits in place, resets on a move
|
||||||
|
if prev.get("acknowledged") and prev.get("opening_id") == (
|
||||||
|
opening_id or None
|
||||||
|
):
|
||||||
|
record["acknowledged"] = True
|
||||||
|
# being out on loan doesn't change where home is
|
||||||
|
if prev.get("away"):
|
||||||
|
record["away"] = prev["away"]
|
||||||
|
locations[body.key] = record
|
||||||
|
else:
|
||||||
|
locations.pop(body.key, None)
|
||||||
|
save_locations(cfg, locations)
|
||||||
|
return _shelf_state()
|
||||||
|
|
||||||
|
@app.post("/api/measure")
|
||||||
|
def api_measure(body: MeasureBody) -> dict:
|
||||||
|
"""Record (or clear) hand-measured box dimensions. The owner's
|
||||||
|
tape measure outranks BGG — this overlays enrich's data."""
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
freshen()
|
||||||
|
games = read_games()
|
||||||
|
if body.key not in games:
|
||||||
|
raise HTTPException(404, "no such game in the library")
|
||||||
|
values = (body.width_in, body.length_in, body.depth_in)
|
||||||
|
measurements = load_measurements(cfg)
|
||||||
|
if all(v is None for v in values):
|
||||||
|
measurements.pop(body.key, None)
|
||||||
|
elif all(v and v > 0 for v in values):
|
||||||
|
measurements[body.key] = {
|
||||||
|
"width_in": body.width_in,
|
||||||
|
"length_in": body.length_in,
|
||||||
|
"depth_in": body.depth_in,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, "all three sizes (inches), or none to clear")
|
||||||
|
save_measurements(cfg, measurements)
|
||||||
|
return _shelf_state()
|
||||||
|
|
||||||
|
@app.post("/api/away")
|
||||||
|
def api_away(body: AwayBody) -> dict:
|
||||||
|
"""Where the box IS right now, when that isn't home — the travel
|
||||||
|
case, a friend's house. Home (the opening) stays reserved; ""
|
||||||
|
brings it back."""
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
freshen()
|
||||||
|
games = read_games()
|
||||||
|
if body.key not in games:
|
||||||
|
raise HTTPException(404, "no such game in the library")
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
record = locations.get(body.key) or {"opening_id": None, "note": ""}
|
||||||
|
where = body.where.strip()
|
||||||
|
if where:
|
||||||
|
record["away"] = where
|
||||||
|
locations[body.key] = record
|
||||||
|
else:
|
||||||
|
record.pop("away", None)
|
||||||
|
if not (record.get("opening_id") or record.get("note")):
|
||||||
|
locations.pop(body.key, None)
|
||||||
|
else:
|
||||||
|
locations[body.key] = record
|
||||||
|
save_locations(cfg, locations)
|
||||||
|
return _shelf_state()
|
||||||
|
|
||||||
|
@app.post("/api/acknowledge")
|
||||||
|
def api_acknowledge(body: AcknowledgeBody) -> dict:
|
||||||
|
"""Accept (or retract accepting) a doesn't-fit placement — the
|
||||||
|
deliberate overhang: boxes standing proud of a shallow shelf."""
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
freshen()
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
record = locations.get(body.key)
|
||||||
|
if not record or not record.get("opening_id"):
|
||||||
|
raise HTTPException(404, "that game isn't shelved anywhere")
|
||||||
|
if body.acknowledged:
|
||||||
|
record["acknowledged"] = True
|
||||||
|
else:
|
||||||
|
record.pop("acknowledged", None)
|
||||||
|
save_locations(cfg, locations)
|
||||||
|
return _shelf_state()
|
||||||
|
|
||||||
|
@app.post("/api/stored-in")
|
||||||
|
def api_stored_in(body: StoredInBody) -> dict:
|
||||||
|
"""Declare (or clear) which box an already-cataloged game lives
|
||||||
|
inside. Updates the match rows (the durable record) and the
|
||||||
|
library entry in place, so the page reflects it immediately."""
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
_refuse_if_rewriting()
|
||||||
|
freshen()
|
||||||
|
games = read_games()
|
||||||
|
entry = games.get(body.key)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(404, "no such game in the library")
|
||||||
|
container = body.container.strip()
|
||||||
|
if container:
|
||||||
|
if str(entry.get("bgg_id")) == container:
|
||||||
|
raise HTTPException(400, "a box cannot live inside itself")
|
||||||
|
holder = next(
|
||||||
|
(e for e in games.values() if str(e.get("bgg_id")) == container),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if holder is None:
|
||||||
|
raise HTTPException(400, "the container must be a cataloged game")
|
||||||
|
# walk the WHOLE chain: A in B in C in A must refuse too
|
||||||
|
seen = {body.key}
|
||||||
|
probe = holder
|
||||||
|
while probe is not None:
|
||||||
|
probe_key = next((k for k, e in games.items() if e is probe), None)
|
||||||
|
if probe_key in seen:
|
||||||
|
raise HTTPException(
|
||||||
|
400, "that would make the boxes contain each other"
|
||||||
|
)
|
||||||
|
seen.add(probe_key)
|
||||||
|
inner = probe.get("stored_in")
|
||||||
|
probe = (
|
||||||
|
next(
|
||||||
|
(
|
||||||
|
e
|
||||||
|
for e in games.values()
|
||||||
|
if str(e.get("bgg_id")) == str(inner)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if inner
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if str(holder.get("stored_in") or "") == str(entry.get("bgg_id")):
|
||||||
|
raise HTTPException(400, "that box already lives inside THIS one")
|
||||||
|
rows = read_matches(cfg.matches_path)
|
||||||
|
bgg_id = str(entry.get("bgg_id") or "")
|
||||||
|
version_id = str((entry.get("version") or {}).get("version_id") or "")
|
||||||
|
hit = False
|
||||||
|
for row in rows:
|
||||||
|
if bgg_id and row["bgg_id"] == bgg_id:
|
||||||
|
if version_id and row.get("version_id") != version_id:
|
||||||
|
continue
|
||||||
|
row["stored_in"] = container
|
||||||
|
hit = True
|
||||||
|
elif not bgg_id and body.key.startswith("local:"):
|
||||||
|
norm = body.key.split(":", 2)[1]
|
||||||
|
if (
|
||||||
|
row["match_status"] == "local"
|
||||||
|
and normalize_title(row["title_raw"]) == norm
|
||||||
|
):
|
||||||
|
row["stored_in"] = container
|
||||||
|
hit = True
|
||||||
|
if not hit:
|
||||||
|
raise HTTPException(
|
||||||
|
409, "no match row backs this entry — re-run enrich first"
|
||||||
|
)
|
||||||
|
write_matches(cfg.matches_path, rows)
|
||||||
|
entry["stored_in"] = container or None
|
||||||
|
atomic_write_text(
|
||||||
|
cfg.games_path,
|
||||||
|
json.dumps(games, indent=2, ensure_ascii=False) + "\n",
|
||||||
|
)
|
||||||
|
if container:
|
||||||
|
# it rides with its container now: a lingering shelf spot
|
||||||
|
# of its own would count its thickness in TWO openings
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
if body.key in locations:
|
||||||
|
locations.pop(body.key)
|
||||||
|
save_locations(cfg, locations)
|
||||||
|
return {"stored_in": container or None}
|
||||||
|
|
||||||
@app.get("/api/library/{key:path}")
|
@app.get("/api/library/{key:path}")
|
||||||
def api_library_game(key: str) -> dict:
|
def api_library_game(key: str) -> dict:
|
||||||
game = library_entries().get(key)
|
entries = library_entries()
|
||||||
|
game = entries.get(key)
|
||||||
if game is None:
|
if game is None:
|
||||||
raise HTTPException(404, "no such game in the library")
|
raise HTTPException(404, "no such game in the library")
|
||||||
|
# containment, both directions: the box this lives in, and what
|
||||||
|
# lives in this box
|
||||||
|
if game.get("stored_in"):
|
||||||
|
container = next(
|
||||||
|
(
|
||||||
|
{"key": k, "name": e.get("name")}
|
||||||
|
for k, e in entries.items()
|
||||||
|
if str(e.get("bgg_id")) == str(game["stored_in"])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
game = {**game, "stored_in_game": container}
|
||||||
|
contents = sorted(
|
||||||
|
(
|
||||||
|
{"key": k, "name": e.get("name")}
|
||||||
|
for k, e in entries.items()
|
||||||
|
if e.get("stored_in") and str(e["stored_in"]) == str(game.get("bgg_id"))
|
||||||
|
),
|
||||||
|
key=lambda c: (c["name"] or "").casefold(),
|
||||||
|
)
|
||||||
|
if contents:
|
||||||
|
game = {**game, "contains": contents}
|
||||||
|
# the location picker's menu: openings grouped by unit, with the
|
||||||
|
# honest fit verdict for THIS game precomputed per opening
|
||||||
|
units = load_furniture(cfg)
|
||||||
|
game = {
|
||||||
|
**game,
|
||||||
|
"opening_menu": [
|
||||||
|
{
|
||||||
|
"unit": unit["name"],
|
||||||
|
"openings": [
|
||||||
|
{
|
||||||
|
"id": o["id"],
|
||||||
|
"label": o["label"],
|
||||||
|
"zone": o.get("zone") or "",
|
||||||
|
"virtual": not o.get("width_in"),
|
||||||
|
"fit": fits_opening(game, o),
|
||||||
|
}
|
||||||
|
for o in unit.get("openings", [])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for unit in units
|
||||||
|
],
|
||||||
|
}
|
||||||
if key.startswith("local:"):
|
if key.startswith("local:"):
|
||||||
# overlay the hand-written store LIVE: the edit form re-renders
|
# overlay the hand-written store LIVE: the edit form re-renders
|
||||||
# from this payload, and pre-enrich values here would resubmit
|
# from this payload, and pre-enrich values here would resubmit
|
||||||
@@ -1344,6 +2126,118 @@ def create_app(
|
|||||||
replay_titles(cfg)
|
replay_titles(cfg)
|
||||||
return state()
|
return state()
|
||||||
|
|
||||||
|
@app.get("/api/bgg-search")
|
||||||
|
def api_bgg_search(q: str) -> dict:
|
||||||
|
"""Raw BGG search results as a pick-list — for cataloging a box
|
||||||
|
of expansions in one pass. Marks ids the catalog already has so
|
||||||
|
the human isn't offered what's already shelved."""
|
||||||
|
query = q.strip()
|
||||||
|
if not query:
|
||||||
|
raise HTTPException(400, "a search needs some text")
|
||||||
|
with lock:
|
||||||
|
freshen()
|
||||||
|
have = {row["bgg_id"] for row in session.rows if row["bgg_id"]}
|
||||||
|
try:
|
||||||
|
results = session.client.search(query)
|
||||||
|
except _BGG_ERRORS as err:
|
||||||
|
raise HTTPException(502, f"search failed: {err}") from err
|
||||||
|
seen: set[int] = set()
|
||||||
|
out = []
|
||||||
|
for r in results:
|
||||||
|
if r.bgg_id in seen:
|
||||||
|
continue # one pick-list line per game, whatever name matched
|
||||||
|
seen.add(r.bgg_id)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"bgg_id": r.bgg_id,
|
||||||
|
"name": r.name,
|
||||||
|
"year": r.year,
|
||||||
|
"type": r.type,
|
||||||
|
"already": str(r.bgg_id) in have,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda r: (r["name"].casefold(), r["year"] or 0))
|
||||||
|
return {"results": out}
|
||||||
|
|
||||||
|
@app.post("/api/add-picked")
|
||||||
|
def api_add_picked(body: PickBody) -> dict:
|
||||||
|
"""Each ticked search result becomes a hand-added title AND an
|
||||||
|
approved match in one step — the human picked it off BGG's own
|
||||||
|
list, so there is nothing left for resolve to re-derive."""
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
_refuse_if_rewriting()
|
||||||
|
freshen()
|
||||||
|
added, skipped = 0, []
|
||||||
|
rows = read_matches(cfg.matches_path)
|
||||||
|
have_ids = {row["bgg_id"] for row in rows if row["bgg_id"]}
|
||||||
|
by_norm: dict[str, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
by_norm.setdefault(normalize_title(row["title_raw"]), row)
|
||||||
|
for pick in body.picks:
|
||||||
|
name = str(pick.get("name") or "").strip()
|
||||||
|
bgg_id = pick.get("bgg_id")
|
||||||
|
if not name or not bgg_id:
|
||||||
|
raise HTTPException(400, "each pick needs a name and bgg_id")
|
||||||
|
if str(bgg_id) in have_ids:
|
||||||
|
skipped.append(f"{name} — already in the catalog")
|
||||||
|
continue
|
||||||
|
existing = by_norm.get(normalize_title(name))
|
||||||
|
if existing is not None:
|
||||||
|
# merged rows stay merged — only live open lines adopt
|
||||||
|
if existing["match_status"] in ("ambiguous", "unmatched"):
|
||||||
|
# the pick IS the box already photographed: decide
|
||||||
|
# that line rather than duplicating it
|
||||||
|
existing.update(
|
||||||
|
{
|
||||||
|
"match_status": "approved",
|
||||||
|
"bgg_id": str(bgg_id),
|
||||||
|
"bgg_name": name,
|
||||||
|
"year": str(pick.get("year") or ""),
|
||||||
|
"type": str(pick.get("type") or "boardgame"),
|
||||||
|
"stored_in": body.stored_in.strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
have_ids.add(str(bgg_id))
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped.append(f"{name} — a same-named line already exists")
|
||||||
|
continue
|
||||||
|
entry = {
|
||||||
|
"title_raw": name,
|
||||||
|
"confidence": "high", # the human is holding the box
|
||||||
|
"publisher_hint": "",
|
||||||
|
"edition_hint": "",
|
||||||
|
"year_hint": pick.get("year"),
|
||||||
|
"language_hint": "",
|
||||||
|
"art_notes": "",
|
||||||
|
"source_photos": [],
|
||||||
|
}
|
||||||
|
rescind_title_removal(cfg.title_removals_path, name)
|
||||||
|
if not record_title_addition(cfg.title_additions_path, entry):
|
||||||
|
skipped.append(f"{name} — a same-named line already exists")
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
**dict.fromkeys(MATCH_COLUMNS, ""),
|
||||||
|
"title_raw": name,
|
||||||
|
"bgg_id": str(bgg_id),
|
||||||
|
"bgg_name": name,
|
||||||
|
"year": str(pick.get("year") or ""),
|
||||||
|
"type": str(pick.get("type") or "boardgame"),
|
||||||
|
"match_status": "approved",
|
||||||
|
"version_status": "version_unknown",
|
||||||
|
"candidates_json": "[]",
|
||||||
|
"version_candidates_json": "[]",
|
||||||
|
"stored_in": body.stored_in.strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
have_ids.add(str(bgg_id))
|
||||||
|
added += 1
|
||||||
|
write_matches(cfg.matches_path, rows)
|
||||||
|
replay_titles(cfg)
|
||||||
|
return {**state(), "added": added, "skipped": skipped}
|
||||||
|
|
||||||
@app.post("/api/add-title")
|
@app.post("/api/add-title")
|
||||||
def api_add_title(body: AddBody) -> dict:
|
def api_add_title(body: AddBody) -> dict:
|
||||||
with lock:
|
with lock:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""The shelf-space report: fit math, coverage buckets, honest unknowns."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from bggpipe.config import Config
|
||||||
|
from bggpipe.dims import run_dims_report
|
||||||
|
from bggpipe.shelves import save_furniture, save_locations
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
|
||||||
|
cfg = Config(data_dir=tmp_path / "data")
|
||||||
|
cfg.data_dir.mkdir(parents=True)
|
||||||
|
cfg.games_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"name": "Fits Fine",
|
||||||
|
"dims": {
|
||||||
|
"width_in": 11.6,
|
||||||
|
"length_in": 11.6,
|
||||||
|
"depth_in": 2.8,
|
||||||
|
"weight_lb": 4,
|
||||||
|
"source": "version",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"name": "Monster Box",
|
||||||
|
"dims": {
|
||||||
|
"width_in": 16.2,
|
||||||
|
"length_in": 16.2,
|
||||||
|
"depth_in": 4.0,
|
||||||
|
"weight_lb": 7,
|
||||||
|
"source": "unanimous",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"name": "Argued About",
|
||||||
|
"dims": {
|
||||||
|
"width_in": None,
|
||||||
|
"length_in": None,
|
||||||
|
"depth_in": None,
|
||||||
|
"weight_lb": None,
|
||||||
|
"source": "conflicting",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"4": {"name": "Never Measured"}, # pre-dims entry: absent
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# no furniture yet: sizes-only report, pointed at the Shelves page
|
||||||
|
summary = run_dims_report(cfg)
|
||||||
|
assert summary["by_source"] == {
|
||||||
|
"version": 1,
|
||||||
|
"unanimous": 1,
|
||||||
|
"conflicting": 1,
|
||||||
|
"absent": 1,
|
||||||
|
}
|
||||||
|
assert summary["misfits"] == []
|
||||||
|
assert summary["unknown"] == ["Argued About", "Never Measured"]
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "No furniture on file" in out
|
||||||
|
|
||||||
|
# with furniture: per-opening capacity, misfit and overfull warnings
|
||||||
|
save_furniture(
|
||||||
|
cfg,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Den",
|
||||||
|
"openings": [
|
||||||
|
{
|
||||||
|
"id": "den-a1",
|
||||||
|
"label": "A1",
|
||||||
|
"zone": "",
|
||||||
|
"width_in": 13.25,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
save_locations(
|
||||||
|
cfg,
|
||||||
|
{
|
||||||
|
"1": {"opening_id": "den-a1", "note": ""},
|
||||||
|
"2": {"opening_id": "den-a1", "note": ""},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
summary = run_dims_report(cfg)
|
||||||
|
assert summary["misfits"] == ["Monster Box"]
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Monster Box" in out # named as unplaceable/misfit
|
||||||
|
assert "unshelved" in out # the conflicting + absent games have no home
|
||||||
|
assert "can't be size-verified" in out and "Never Measured" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_without_games_json_exits_with_guidance(tmp_path):
|
||||||
|
cfg = Config(data_dir=tmp_path / "data")
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
run_dims_report(cfg)
|
||||||
+124
-2
@@ -151,11 +151,25 @@ def _cfg_with_matches(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def _seed_batch_fixture(cache_dir):
|
def _seed_batch_fixture(cache_dir):
|
||||||
"""The batch cache entry run_enrich will ask for: sorted ids 13,266192."""
|
"""The cache entries run_enrich will ask for: the stats batch and the
|
||||||
|
dims pass's versions batch (sorted ids 13,266192)."""
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
combined = FULL_THING_XML.replace("<items>", "<items>" + CATAN_MINIMAL[7:-8], 1)
|
combined = FULL_THING_XML.replace("<items>", "<items>" + CATAN_MINIMAL[7:-8], 1)
|
||||||
key = cache_key("thing", {"id": "13,266192", "stats": "1"})
|
key = cache_key("thing", {"id": "13,266192", "stats": "1"})
|
||||||
(cache_dir / key).write_text(combined)
|
(cache_dir / key).write_text(combined)
|
||||||
|
versions_key = cache_key("thing", {"id": "13,266192", "versions": "1"})
|
||||||
|
(cache_dir / versions_key).write_text(
|
||||||
|
"""<items>
|
||||||
|
<item type="boardgame" id="13"><versions>
|
||||||
|
<item type="boardgameversion" id="7"><width value="9.5" />
|
||||||
|
<length value="11.5" /><depth value="3" /><weight value="3" /></item>
|
||||||
|
</versions></item>
|
||||||
|
<item type="boardgame" id="266192"><versions>
|
||||||
|
<item type="boardgameversion" id="465063"><width value="11.7" />
|
||||||
|
<length value="11.7" /><depth value="2.8" /><weight value="4.4" /></item>
|
||||||
|
</versions></item>
|
||||||
|
</items>"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_run_enrich_writes_games_json_with_versions(tmp_path):
|
def test_run_enrich_writes_games_json_with_versions(tmp_path):
|
||||||
@@ -209,7 +223,9 @@ def test_refresh_bypasses_cache_read(tmp_path):
|
|||||||
|
|
||||||
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(handler))
|
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(handler))
|
||||||
games = run_enrich(cfg, refresh=True, client=client)
|
games = run_enrich(cfg, refresh=True, client=client)
|
||||||
assert len(requests) == 1 # cache read skipped, live fetch happened
|
# cache reads skipped, live fetches happened: stats batch + dims pass
|
||||||
|
assert any("stats=1" in u for u in requests)
|
||||||
|
assert any("versions=1" in u for u in requests)
|
||||||
assert games["266192:465063"]["rating"] == 7.5
|
assert games["266192:465063"]["rating"] == 7.5
|
||||||
|
|
||||||
|
|
||||||
@@ -338,3 +354,109 @@ def test_corrupt_local_games_store_fails_loud(tmp_path):
|
|||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="local_games.json is corrupt"):
|
with pytest.raises(ValueError, match="local_games.json is corrupt"):
|
||||||
run_enrich(cfg, client=bare)
|
run_enrich(cfg, client=bare)
|
||||||
|
|
||||||
|
|
||||||
|
DIMS_XML = """<items>
|
||||||
|
<item type="boardgame" id="240">
|
||||||
|
<versions>
|
||||||
|
<item type="boardgameversion" id="24621">
|
||||||
|
<width value="8.4" /><length value="11.5" /><depth value="2.1" />
|
||||||
|
<weight value="2" />
|
||||||
|
</item>
|
||||||
|
<item type="boardgameversion" id="99999">
|
||||||
|
<width value="0" /><length value="0" /><depth value="0" />
|
||||||
|
<weight value="0" />
|
||||||
|
</item>
|
||||||
|
</versions>
|
||||||
|
</item>
|
||||||
|
<item type="boardgame" id="500">
|
||||||
|
<versions>
|
||||||
|
<item type="boardgameversion" id="1"><width value="11.6" />
|
||||||
|
<length value="11.6" /><depth value="2.8" /><weight value="4.1" /></item>
|
||||||
|
<item type="boardgameversion" id="2"><width value="11.8" />
|
||||||
|
<length value="11.4" /><depth value="3.0" /><weight value="4.3" /></item>
|
||||||
|
<item type="boardgameversion" id="3"><width value="0" />
|
||||||
|
<length value="0" /><depth value="0" /><weight value="0" /></item>
|
||||||
|
</versions>
|
||||||
|
</item>
|
||||||
|
<item type="boardgame" id="600">
|
||||||
|
<versions>
|
||||||
|
<item type="boardgameversion" id="4"><width value="8.0" />
|
||||||
|
<length value="8.0" /><depth value="2.0" /><weight value="1" /></item>
|
||||||
|
<item type="boardgameversion" id="5"><width value="12.0" />
|
||||||
|
<length value="12.0" /><depth value="3.0" /><weight value="5" /></item>
|
||||||
|
</versions>
|
||||||
|
</item>
|
||||||
|
<item type="boardgame" id="700">
|
||||||
|
<versions>
|
||||||
|
<item type="boardgameversion" id="6"><width value="16.2" />
|
||||||
|
<length value="16.2" /><depth value="4.0" /><weight value="7" /></item>
|
||||||
|
</versions>
|
||||||
|
</item>
|
||||||
|
</items>"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_version_dims_treats_zero_as_absent():
|
||||||
|
from bggpipe.models import parse_version_dims
|
||||||
|
|
||||||
|
by_game = parse_version_dims(DIMS_XML)
|
||||||
|
exact = next(r for r in by_game[240] if r["version_id"] == 24621)
|
||||||
|
assert exact["width_in"] == 8.4 and exact["weight_lb"] == 2
|
||||||
|
zeroed = next(r for r in by_game[240] if r["version_id"] == 99999)
|
||||||
|
assert all(
|
||||||
|
zeroed[f] is None for f in ("width_in", "length_in", "depth_in", "weight_lb")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_dims_verdicts():
|
||||||
|
"""The four sources: exact version, unanimous chorus (within 0.5"),
|
||||||
|
conflicting chorus (nulls, never a guess), and absent."""
|
||||||
|
from bggpipe.enrich import _entry_dims
|
||||||
|
from bggpipe.models import parse_version_dims
|
||||||
|
|
||||||
|
by_game = parse_version_dims(DIMS_XML)
|
||||||
|
|
||||||
|
versioned = _entry_dims({"version": {"version_id": 24621}}, by_game[240])
|
||||||
|
assert versioned["source"] == "version" and versioned["width_in"] == 8.4
|
||||||
|
|
||||||
|
# chosen version exists but carries only zeros: absent, not borrowed
|
||||||
|
zeroed = _entry_dims({"version": {"version_id": 99999}}, by_game[240])
|
||||||
|
assert zeroed["source"] == "absent" and zeroed["width_in"] is None
|
||||||
|
|
||||||
|
unanimous = _entry_dims({"version": None}, by_game[500])
|
||||||
|
assert unanimous["source"] == "unanimous"
|
||||||
|
assert unanimous["width_in"] == 11.8 # max per axis: the fit question
|
||||||
|
assert unanimous["depth_in"] == 3.0
|
||||||
|
|
||||||
|
conflicting = _entry_dims({"version": None}, by_game[600])
|
||||||
|
assert conflicting["source"] == "conflicting"
|
||||||
|
assert conflicting["width_in"] is None
|
||||||
|
|
||||||
|
assert _entry_dims({"version": None}, [])["source"] == "absent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enrich_fills_dims_from_cached_versions(tmp_path):
|
||||||
|
from bggpipe.enrich import run_enrich
|
||||||
|
|
||||||
|
cfg = _cfg_with_matches(tmp_path)
|
||||||
|
cache = tmp_path / "cache"
|
||||||
|
_seed_batch_fixture(cache)
|
||||||
|
(cache / cache_key("thing", {"id": "13,266192", "versions": "1"})).write_text(
|
||||||
|
"""<items>
|
||||||
|
<item type="boardgame" id="13"><versions>
|
||||||
|
<item type="boardgameversion" id="7"><width value="11.6" />
|
||||||
|
<length value="11.6" /><depth value="3.0" /><weight value="4" /></item>
|
||||||
|
</versions></item>
|
||||||
|
<item type="boardgame" id="266192"><versions>
|
||||||
|
<item type="boardgameversion" id="465063"><width value="11.7" />
|
||||||
|
<length value="11.7" /><depth value="2.8" /><weight value="4.4" /></item>
|
||||||
|
</versions></item>
|
||||||
|
</items>"""
|
||||||
|
)
|
||||||
|
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(_no_network))
|
||||||
|
games = run_enrich(cfg, client=client)
|
||||||
|
wingspan = games["266192:465063"]
|
||||||
|
assert wingspan["dims"]["source"] == "version"
|
||||||
|
assert wingspan["dims"]["depth_in"] == 2.8
|
||||||
|
assert wingspan["version"]["width_in"] == 11.7 # mirrored onto the version
|
||||||
|
assert games["13"]["dims"]["source"] == "unanimous"
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"""Static export: pages, art downloads, badge slot, privacy. Offline —
|
||||||
|
cover downloads run against a mock CDN transport."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bggpipe.config import Config
|
||||||
|
from bggpipe.export import run_export
|
||||||
|
|
||||||
|
GAMES = {
|
||||||
|
"240:24621": {
|
||||||
|
"bgg_id": 240,
|
||||||
|
"type": "boardgame",
|
||||||
|
"name": "Britannia",
|
||||||
|
"year": 1986,
|
||||||
|
"description": "A game of <invasions> & settlement.",
|
||||||
|
"image": "https://cf.geekdo-images.com/orig/pic1.jpg",
|
||||||
|
"thumbnail": "https://cf.geekdo-images.com/thumb/pic1.jpg",
|
||||||
|
"min_players": 3,
|
||||||
|
"max_players": 5,
|
||||||
|
"best_player_counts": ["4"],
|
||||||
|
"playtime": 240,
|
||||||
|
"designers": ["Lewis Pulsipher"],
|
||||||
|
"rank": 1210,
|
||||||
|
"weight": 3.16,
|
||||||
|
"version": {
|
||||||
|
"version_id": 24621,
|
||||||
|
"name": "Avalon Hill second edition",
|
||||||
|
"year": 1987,
|
||||||
|
"publishers": ["Avalon Hill"],
|
||||||
|
"languages": ["English"],
|
||||||
|
},
|
||||||
|
"source_photos": ["IMG_1.jpeg"],
|
||||||
|
},
|
||||||
|
"311654": {
|
||||||
|
"bgg_id": 311654,
|
||||||
|
"type": "rpgitem",
|
||||||
|
"name": "Alice is Missing",
|
||||||
|
"year": 2020,
|
||||||
|
"image": "https://cf.geekdo-images.com/orig/pic2.png",
|
||||||
|
"thumbnail": "",
|
||||||
|
"source_photos": ["IMG_2.jpeg"],
|
||||||
|
},
|
||||||
|
"local:homebrew:x.jpg": {
|
||||||
|
"type": "localgame",
|
||||||
|
"name": "Britannia", # deliberate name collision with 240:24621
|
||||||
|
"image": "/local-art/abc123.jpg",
|
||||||
|
"source_photos": ["x.jpg"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(tmp_path: Path) -> Config:
|
||||||
|
cfg = Config(data_dir=tmp_path / "data")
|
||||||
|
cfg.data_dir.mkdir(parents=True)
|
||||||
|
cfg.games_path.write_text(json.dumps(GAMES))
|
||||||
|
cfg.local_art_dir.mkdir()
|
||||||
|
(cfg.local_art_dir / "abc123.jpg").write_bytes(FAKE_JPEG)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
FAKE_JPEG = b"\xff\xd8" + b"\x00" * 100 # magic + enough body to pass sniffing
|
||||||
|
|
||||||
|
|
||||||
|
def _cdn(requests_log: list[str]) -> httpx.Client:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests_log.append(str(request.url))
|
||||||
|
return httpx.Response(200, content=FAKE_JPEG)
|
||||||
|
|
||||||
|
return httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_writes_pages_art_and_no_shelf_photos(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
out = tmp_path / "site"
|
||||||
|
log: list[str] = []
|
||||||
|
summary = run_export(
|
||||||
|
cfg, out, title="Test Shelves", fetch=_cdn(log), sleep=lambda s: None
|
||||||
|
)
|
||||||
|
assert summary == {
|
||||||
|
"games": 3,
|
||||||
|
"downloaded": 3,
|
||||||
|
"failures": 0,
|
||||||
|
"removed": 0,
|
||||||
|
"shelves": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
index = (out / "index.html").read_text()
|
||||||
|
assert "Test Shelves" in index and "Powered by BGG" in index
|
||||||
|
assert "trademarks of" in index
|
||||||
|
|
||||||
|
# name collision -> distinct stable slugs
|
||||||
|
assert (out / "britannia" / "index.html").exists()
|
||||||
|
assert (out / "britannia-2" / "index.html").exists()
|
||||||
|
assert (out / "alice-is-missing" / "index.html").exists()
|
||||||
|
|
||||||
|
detail = (out / "britannia" / "index.html").read_text()
|
||||||
|
assert "Avalon Hill second edition" in detail
|
||||||
|
assert "<invasions> & settlement" in detail # escaped, not raw
|
||||||
|
# BGG double-encodes entities; the page shows the character, not ’
|
||||||
|
assert "&rsquo" not in detail
|
||||||
|
assert "boardgamegeek.com/boardgame/240" in detail
|
||||||
|
rpg = (out / "alice-is-missing" / "index.html").read_text()
|
||||||
|
assert "rpggeek.com/rpgitem/311654" in rpg
|
||||||
|
|
||||||
|
# covers landed locally and pages never hotlink the CDN
|
||||||
|
# 2 full + 1 thumb + local art + the colophon's piper
|
||||||
|
assert len(list((out / "art").glob("*"))) == 5
|
||||||
|
all_html = index + detail + rpg
|
||||||
|
assert "geekdo-images.com" not in all_html
|
||||||
|
|
||||||
|
# the privacy rule: shelf photo names appear nowhere in the output
|
||||||
|
assert "IMG_1.jpeg" not in all_html and "IMG_2.jpeg" not in all_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_is_idempotent_and_offline_on_rerun(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
out = tmp_path / "site"
|
||||||
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
|
||||||
|
def refuse(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise AssertionError(f"re-run fetched {request.url}")
|
||||||
|
|
||||||
|
summary = run_export(
|
||||||
|
cfg,
|
||||||
|
out,
|
||||||
|
fetch=httpx.Client(transport=httpx.MockTransport(refuse)),
|
||||||
|
sleep=lambda s: None,
|
||||||
|
)
|
||||||
|
assert summary["downloaded"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_cover_reports_and_ships_page_anyway(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
|
||||||
|
def flaky(request: httpx.Request) -> httpx.Response:
|
||||||
|
if "pic2" in str(request.url):
|
||||||
|
return httpx.Response(503)
|
||||||
|
return httpx.Response(200, content=FAKE_JPEG)
|
||||||
|
|
||||||
|
summary = run_export(
|
||||||
|
cfg,
|
||||||
|
tmp_path / "site",
|
||||||
|
fetch=httpx.Client(transport=httpx.MockTransport(flaky)),
|
||||||
|
sleep=lambda s: None,
|
||||||
|
)
|
||||||
|
assert summary["failures"] == 1
|
||||||
|
page = (tmp_path / "site" / "alice-is-missing" / "index.html").read_text()
|
||||||
|
assert "Alice is Missing" in page # the page exists, just coverless
|
||||||
|
# nothing half-written left behind to poison the idempotent re-run
|
||||||
|
assert not list((tmp_path / "site" / "art").glob("*.part"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_official_badge_upgrades_the_footer(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
(cfg.data_dir / "powered-by-bgg.png").write_bytes(b"\x89PNG" + b"\x00" * 100)
|
||||||
|
out = tmp_path / "site"
|
||||||
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert (out / "art" / "powered-by-bgg.png").exists()
|
||||||
|
index = (out / "index.html").read_text()
|
||||||
|
assert "art/powered-by-bgg.png" in index and "Powered by BGG" in index
|
||||||
|
detail = (out / "britannia" / "index.html").read_text()
|
||||||
|
assert "../art/powered-by-bgg.png" in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_without_games_json_exits_with_guidance(tmp_path):
|
||||||
|
cfg = Config(data_dir=tmp_path / "data")
|
||||||
|
import typer
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_urls_survive_adding_and_removing_games(tmp_path):
|
||||||
|
"""The manifest's whole point: a published URL never moves and is
|
||||||
|
never stolen — not by an earlier-sorting new edition, not by the
|
||||||
|
base-slug holder leaving."""
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
out = tmp_path / "site"
|
||||||
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
original = (out / "britannia" / "index.html").read_text()
|
||||||
|
assert "Avalon Hill second edition" in original
|
||||||
|
|
||||||
|
# a new same-named edition whose key sorts FIRST must not steal slugs
|
||||||
|
games = json.loads(cfg.games_path.read_text())
|
||||||
|
games["100:1"] = {"bgg_id": 100, "type": "boardgame", "name": "Britannia"}
|
||||||
|
cfg.games_path.write_text(json.dumps(games))
|
||||||
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert (
|
||||||
|
"Avalon Hill second edition" in (out / "britannia" / "index.html").read_text()
|
||||||
|
)
|
||||||
|
assert (out / "britannia-3" / "index.html").exists() # the newcomer
|
||||||
|
|
||||||
|
# removing the base-slug holder must not renumber the survivors —
|
||||||
|
# and must actually remove the stale page
|
||||||
|
del games["240:24621"]
|
||||||
|
cfg.games_path.write_text(json.dumps(games))
|
||||||
|
summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert summary["removed"] == 1
|
||||||
|
assert not (out / "britannia" / "index.html").exists()
|
||||||
|
assert (out / "britannia-2" / "index.html").exists() # localgame, unmoved
|
||||||
|
index = (out / "index.html").read_text()
|
||||||
|
assert "britannia/" not in index.replace("britannia-2/", "").replace(
|
||||||
|
"britannia-3/", ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_cover_url_is_refetched(tmp_path):
|
||||||
|
"""BGG replaces box art under the same extension: the manifest records
|
||||||
|
each cover's source URL so a change re-downloads."""
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
out = tmp_path / "site"
|
||||||
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
|
||||||
|
games = json.loads(cfg.games_path.read_text())
|
||||||
|
games["240:24621"]["image"] = "https://cf.geekdo-images.com/orig/pic9.jpg"
|
||||||
|
cfg.games_path.write_text(json.dumps(games))
|
||||||
|
log: list[str] = []
|
||||||
|
summary = run_export(cfg, out, fetch=_cdn(log), sleep=lambda s: None)
|
||||||
|
assert summary["downloaded"] == 1
|
||||||
|
assert any("pic9" in u for u in log)
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_200_is_a_failure_not_a_cached_cover(tmp_path):
|
||||||
|
"""A CDN interstitial served with 200 must not be cached as a cover:
|
||||||
|
exists() would then skip it forever."""
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
|
||||||
|
def interstitial(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, content=b"<html>challenge page</html>" * 10)
|
||||||
|
|
||||||
|
summary = run_export(
|
||||||
|
cfg,
|
||||||
|
tmp_path / "site",
|
||||||
|
fetch=httpx.Client(transport=httpx.MockTransport(interstitial)),
|
||||||
|
sleep=lambda s: None,
|
||||||
|
)
|
||||||
|
assert summary["failures"] == 3
|
||||||
|
assert summary["downloaded"] == 0
|
||||||
|
# only the local-art copy survives; no CDN garbage was cached
|
||||||
|
names = {f.name for f in (tmp_path / "site" / "art").iterdir()}
|
||||||
|
assert names == {"abc123.jpg", "bggpipe-piper.jpg"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_local_art_is_a_counted_failure(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
(cfg.local_art_dir / "abc123.jpg").unlink()
|
||||||
|
summary = run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert summary["failures"] == 1 # local_art is the ONLY source: say so
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_download_leaves_no_cover_and_recovers(tmp_path, monkeypatch):
|
||||||
|
"""The non-vacuous atomicity test: die BETWEEN response and write,
|
||||||
|
assert nothing half-made survives and the re-run completes."""
|
||||||
|
import bggpipe.export as export_mod
|
||||||
|
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
out = tmp_path / "site"
|
||||||
|
real_write = export_mod.atomic_write_bytes
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def die_on_second(path, data):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 2:
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
real_write(path, data)
|
||||||
|
|
||||||
|
monkeypatch.setattr(export_mod, "atomic_write_bytes", die_on_second)
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert len(list((out / "art").glob("*.part"))) == 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(export_mod, "atomic_write_bytes", real_write)
|
||||||
|
summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert summary["failures"] == 0
|
||||||
|
assert (out / "index.html").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimal_and_numeric_entries_render(tmp_path):
|
||||||
|
"""Entries vary wildly: a name-only localgame and enrich-shaped
|
||||||
|
numerics (int best counts, int year) must render, not crash."""
|
||||||
|
cfg = Config(data_dir=tmp_path / "data")
|
||||||
|
cfg.data_dir.mkdir(parents=True)
|
||||||
|
cfg.games_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"local:mystery:x.jpg": {"type": "localgame", "name": "Mystery"},
|
||||||
|
"7": {
|
||||||
|
"bgg_id": 7,
|
||||||
|
"type": "boardgame",
|
||||||
|
"name": "Numeric",
|
||||||
|
"year": 1980,
|
||||||
|
"min_players": 1,
|
||||||
|
"max_players": 1,
|
||||||
|
"best_player_counts": [1],
|
||||||
|
"weight": 2,
|
||||||
|
"rating": 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out = tmp_path / "site"
|
||||||
|
run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
numeric = (out / "numeric" / "index.html").read_text()
|
||||||
|
assert "1 player" in numeric and "best at 1" in numeric
|
||||||
|
assert (out / "mystery" / "index.html").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_export_wiring(tmp_path, monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from bggpipe.cli import app
|
||||||
|
|
||||||
|
received = {}
|
||||||
|
|
||||||
|
def fake_run_export(cfg, out, *, title, include_shelves=True):
|
||||||
|
received.update(out=out, title=title)
|
||||||
|
return {"games": 0, "downloaded": 0, "failures": 0, "removed": 0}
|
||||||
|
|
||||||
|
monkeypatch.setattr("bggpipe.export.run_export", fake_run_export)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["export", "--out", str(tmp_path / "s"), "--title", "T"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert received["title"] == "T" and received["out"] == tmp_path / "s"
|
||||||
|
assert runner.invoke(app, ["export"]).exit_code != 0 # --out is required
|
||||||
|
|
||||||
|
def failing_run_export(cfg, out, *, title, include_shelves=True):
|
||||||
|
return {"games": 1, "downloaded": 0, "failures": 2, "removed": 0}
|
||||||
|
|
||||||
|
monkeypatch.setattr("bggpipe.export.run_export", failing_run_export)
|
||||||
|
result = runner.invoke(app, ["export", "--out", str(tmp_path / "s")])
|
||||||
|
assert result.exit_code == 1 # scripted publishes must see failures
|
||||||
|
|
||||||
|
|
||||||
|
def test_colophon_card_closes_the_shelf(tmp_path):
|
||||||
|
"""The last card: piper, badge, fine print — a div the search filter
|
||||||
|
(which selects a.game) can never hide."""
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
out = tmp_path / "site"
|
||||||
|
run_export(cfg, out, title="Test Shelves", fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
index = (out / "index.html").read_text()
|
||||||
|
assert 'class="game colophon"' in index
|
||||||
|
assert "bggpipe-piper.jpg" in index and "Piper art by Juniper" in index
|
||||||
|
assert "trademarks of BoardGameGeek" in index
|
||||||
|
assert (out / "art" / "bggpipe-piper.jpg").exists()
|
||||||
|
# the filter only ever hides <a class="game"> — the colophon is a div
|
||||||
|
assert '<div class="game colophon"' in index
|
||||||
|
|
||||||
|
|
||||||
|
def test_shelves_export_and_optout(tmp_path):
|
||||||
|
"""The wall ships with the site — locations on game pages, a shelves
|
||||||
|
page drawn as the physical arrangement — unless the owner opts out
|
||||||
|
(publishing a shelf layout is a choice)."""
|
||||||
|
from bggpipe.shelves import save_furniture, save_locations
|
||||||
|
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
save_furniture(
|
||||||
|
cfg,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Den Kallax",
|
||||||
|
"description": "the wall behind the couch",
|
||||||
|
"openings": [
|
||||||
|
{
|
||||||
|
"id": "den-a1",
|
||||||
|
"label": "A1",
|
||||||
|
"zone": "oversize",
|
||||||
|
"width_in": 26.5,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "den-b1",
|
||||||
|
"label": "B1",
|
||||||
|
"zone": "cubes",
|
||||||
|
"width_in": 13.25,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
save_locations(cfg, {"240:24621": {"opening_id": "den-b1", "note": ""}})
|
||||||
|
out = tmp_path / "site"
|
||||||
|
summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert summary["shelves"] is True # the run SAYS the wall shipped
|
||||||
|
|
||||||
|
shelves = (out / "shelves" / "index.html").read_text()
|
||||||
|
assert "Den Kallax" in shelves and "the wall behind the couch" in shelves
|
||||||
|
assert "Britannia" in shelves # the resident links from its opening
|
||||||
|
assert 'href="../britannia/"' in shelves
|
||||||
|
detail = (out / "britannia" / "index.html").read_text()
|
||||||
|
assert "where it lives" in detail and "Den Kallax · B1" in detail
|
||||||
|
# containment renders on both pages
|
||||||
|
local = (out / "britannia-2" / "index.html").read_text()
|
||||||
|
assert "in this box" not in local # the localgame contains nothing
|
||||||
|
index = (out / "index.html").read_text()
|
||||||
|
assert "see the shelves" in index
|
||||||
|
|
||||||
|
# opt-out: no shelves page, no location lines, no link
|
||||||
|
out2 = tmp_path / "site2"
|
||||||
|
run_export(cfg, out2, include_shelves=False, fetch=_cdn([]), sleep=lambda s: None)
|
||||||
|
assert not (out2 / "shelves").exists()
|
||||||
|
assert "where it lives" not in (out2 / "britannia" / "index.html").read_text()
|
||||||
|
assert "see the shelves" not in (out2 / "index.html").read_text()
|
||||||
@@ -761,3 +761,34 @@ def test_research_clears_the_previous_games_version(tmp_path):
|
|||||||
assert row["version_status"] == ""
|
assert row["version_status"] == ""
|
||||||
assert row["version_id"] == "" and row["version_name"] == ""
|
assert row["version_id"] == "" and row["version_name"] == ""
|
||||||
assert row["version_candidates_json"] == "[]"
|
assert row["version_candidates_json"] == "[]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopening_a_version_ballot_keeps_the_current_pick(tmp_path):
|
||||||
|
"""Change-edition must be lossless: opening the full printing list on
|
||||||
|
an approved row keeps the pick until a new decision, and the lingering
|
||||||
|
id is inert while ambiguous."""
|
||||||
|
from bggpipe.models import is_confident_version
|
||||||
|
|
||||||
|
cfg = _setup(
|
||||||
|
tmp_path,
|
||||||
|
[
|
||||||
|
_row(
|
||||||
|
title_raw="Wingspan",
|
||||||
|
match_status="approved",
|
||||||
|
bgg_id="266192",
|
||||||
|
version_status="version_approved",
|
||||||
|
version_id="465063",
|
||||||
|
version_name="English first edition",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session = ReviewSession(
|
||||||
|
cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client()
|
||||||
|
)
|
||||||
|
row = session.rows[0]
|
||||||
|
assert session.open_version_ballot(row) > 1
|
||||||
|
assert row["version_status"] == "version_ambiguous"
|
||||||
|
assert row["version_id"] == "465063" # survives until a new choice
|
||||||
|
assert not is_confident_version(row) # but inert for diff/upload
|
||||||
|
session.decide_version(row, None) # "can't tell" clears it properly
|
||||||
|
assert row["version_id"] == ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -205,12 +205,21 @@ def test_stylesheet_is_served_and_linked_by_every_page(tmp_path):
|
|||||||
assert css.headers["content-type"].startswith("text/css")
|
assert css.headers["content-type"].startswith("text/css")
|
||||||
assert "--accent" in css.text # the token layer, not an empty file
|
assert "--accent" in css.text # the token layer, not an empty file
|
||||||
for path in ("/", "/review"):
|
for path in ("/", "/review"):
|
||||||
assert 'href="/static/app.css"' in web.get(path).text
|
assert 'href="/static/app.css?v=' in web.get(path).text
|
||||||
|
|
||||||
|
|
||||||
def test_every_page_marks_itself_current_in_the_nav(tmp_path):
|
def test_every_page_marks_itself_current_in_the_nav(tmp_path):
|
||||||
web = _app(_cfg(tmp_path))
|
web = _app(_cfg(tmp_path))
|
||||||
for path in ("/", "/photos", "/titles", "/review", "/queue", "/library", "/help"):
|
for path in (
|
||||||
|
"/",
|
||||||
|
"/photos",
|
||||||
|
"/titles",
|
||||||
|
"/review",
|
||||||
|
"/queue",
|
||||||
|
"/library",
|
||||||
|
"/shelves",
|
||||||
|
"/help",
|
||||||
|
):
|
||||||
html = web.get(path).text
|
html = web.get(path).text
|
||||||
assert f'href="{path}" aria-current="page"' in html, path
|
assert f'href="{path}" aria-current="page"' in html, path
|
||||||
assert 'class="skip"' in html, path
|
assert 'class="skip"' in html, path
|
||||||
@@ -653,7 +662,7 @@ def test_photo_detail_page_serves_with_photos_nav_active(tmp_path):
|
|||||||
|
|
||||||
(current,) = re.findall(r'<a href="([^"]+)" aria-current="page"', html)
|
(current,) = re.findall(r'<a href="([^"]+)" aria-current="page"', html)
|
||||||
assert current == "/photos"
|
assert current == "/photos"
|
||||||
assert 'src="/static/app.js"' in html
|
assert 'src="/static/app.js?v=' in html
|
||||||
|
|
||||||
|
|
||||||
def test_pipeline_counts_pending_work_not_queue_rows(tmp_path):
|
def test_pipeline_counts_pending_work_not_queue_rows(tmp_path):
|
||||||
@@ -675,3 +684,19 @@ def test_pipeline_counts_pending_work_not_queue_rows(tmp_path):
|
|||||||
p = _app(cfg).get("/api/pipeline").json()
|
p = _app(cfg).get("/api/pipeline").json()
|
||||||
assert p["to_update"] == 0 # applied
|
assert p["to_update"] == 0 # applied
|
||||||
assert p["queued_total"] == 1 # still listed until diff reruns
|
assert p["queued_total"] == 1 # still listed until diff reruns
|
||||||
|
|
||||||
|
|
||||||
|
def test_assets_are_version_stamped(tmp_path):
|
||||||
|
"""A cached stylesheet from last week must not survive an upgrade:
|
||||||
|
asset URLs carry the app version."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
html = _app(_cfg(tmp_path)).get("/").text
|
||||||
|
(stamp,) = set(re.findall(r'/static/app\.css\?v=([0-9a-f]{8})"', html))
|
||||||
|
assert f"/static/app.js?v={stamp}" in html
|
||||||
|
assert "__ASSET_V__" not in html
|
||||||
|
# the stamp is a CONTENT hash: it must change when the css changes,
|
||||||
|
# not when the release version does — that was the original sin
|
||||||
|
from bggpipe import webreview
|
||||||
|
|
||||||
|
assert stamp == webreview._asset_stamp()
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ def make_cfg(tmp_path) -> Config:
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"title_raw": "Citadels",
|
"title_raw": "Citadels",
|
||||||
|
"confidence": "high",
|
||||||
"publisher_hint": "Fantasy Flight",
|
"publisher_hint": "Fantasy Flight",
|
||||||
"edition_hint": "",
|
"edition_hint": "",
|
||||||
"source_photos": ["shelf.jpg"],
|
"source_photos": ["shelf.jpg"],
|
||||||
@@ -1443,3 +1444,124 @@ def test_review_summary_reports_token_presence_not_value(tmp_path, monkeypatch):
|
|||||||
monkeypatch.delenv("BGG_API_TOKEN")
|
monkeypatch.delenv("BGG_API_TOKEN")
|
||||||
web2, _ = make_client(tmp_path / "tokenless")
|
web2, _ = make_client(tmp_path / "tokenless")
|
||||||
assert web2.get("/api/state").json()["summary"]["token_present"] is False
|
assert web2.get("/api/state").json()["summary"]["token_present"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_bgg_search_and_add_picked_flow(tmp_path):
|
||||||
|
"""A whole box of expansions in one pass: search once, tick, and each
|
||||||
|
pick lands already-matched with its container recorded."""
|
||||||
|
cfg = make_cfg(tmp_path)
|
||||||
|
web = TestClient(create_app(cfg, client=fixture_search_client()))
|
||||||
|
|
||||||
|
found = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
|
||||||
|
assert len(found) >= 2
|
||||||
|
ids = {r["bgg_id"] for r in found}
|
||||||
|
assert 478 in ids
|
||||||
|
assert all(r["already"] is False for r in found)
|
||||||
|
|
||||||
|
picks = [r for r in found if r["bgg_id"] in (478, 13291, 205398)]
|
||||||
|
res = web.post(
|
||||||
|
"/api/add-picked", json={"picks": picks, "stored_in": "173634"}
|
||||||
|
).json()
|
||||||
|
# 478 and The Dark City land; 205398 is BGG's OTHER game named
|
||||||
|
# "Citadels" — a true name twin, skipped honestly rather than fused
|
||||||
|
assert res["added"] == 2
|
||||||
|
assert res["skipped"] == ["Citadels — a same-named line already exists"]
|
||||||
|
|
||||||
|
rows = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
|
||||||
|
# "Citadels" already existed as an undecided photo line: the pick
|
||||||
|
# DECIDED that line (photos kept) instead of duplicating it
|
||||||
|
assert rows["478"]["match_status"] == "approved"
|
||||||
|
assert rows["478"]["source_photos"] == "shelf.jpg"
|
||||||
|
assert rows["478"]["stored_in"] == "173634"
|
||||||
|
# the expansion had no line: appended photo-less, already matched
|
||||||
|
assert rows["13291"]["match_status"] == "approved"
|
||||||
|
assert rows["13291"]["source_photos"] == ""
|
||||||
|
|
||||||
|
# re-searching now flags them; re-adding skips instead of duplicating
|
||||||
|
found2 = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
|
||||||
|
assert next(r for r in found2 if r["bgg_id"] == 478)["already"] is True
|
||||||
|
res2 = web.post("/api/add-picked", json={"picks": picks}).json()
|
||||||
|
assert res2["added"] == 0 and len(res2["skipped"]) == 3
|
||||||
|
|
||||||
|
# blank query is a 400, and BGG-down is a 502, not a crash
|
||||||
|
assert web.get("/api/bgg-search", params={"q": " "}).status_code == 400
|
||||||
|
down = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
||||||
|
assert down.get("/api/bgg-search", params={"q": "x"}).status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_search_client() -> BGGClient:
|
||||||
|
from pathlib import Path as _P
|
||||||
|
|
||||||
|
fixtures = _P(__file__).parent / "fixtures" / "bgg_cache"
|
||||||
|
return BGGClient(
|
||||||
|
cache_dir=fixtures,
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda req: httpx.Response(401, text="Unauthorized")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_detail_shows_containment_both_ways(tmp_path):
|
||||||
|
cfg = make_cfg(tmp_path)
|
||||||
|
rows = read_matches(cfg.matches_path)
|
||||||
|
rows.append(
|
||||||
|
_row(
|
||||||
|
title_raw="The Trove",
|
||||||
|
match_status="approved",
|
||||||
|
bgg_id="173634",
|
||||||
|
source_photos="shelf.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
_row(
|
||||||
|
title_raw="Witchdoctor",
|
||||||
|
match_status="approved",
|
||||||
|
bgg_id="999",
|
||||||
|
source_photos="",
|
||||||
|
stored_in="173634",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
write_matches(cfg.matches_path, rows)
|
||||||
|
cfg.games_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"173634": {"bgg_id": 173634, "name": "The Trove", "type": "boardgame"},
|
||||||
|
"999": {
|
||||||
|
"bgg_id": 999,
|
||||||
|
"name": "Witchdoctor",
|
||||||
|
"type": "boardgameexpansion",
|
||||||
|
"stored_in": "173634",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
||||||
|
inside = web.get("/api/library/999").json()
|
||||||
|
assert inside["stored_in_game"] == {"key": "173634", "name": "The Trove"}
|
||||||
|
box = web.get("/api/library/173634").json()
|
||||||
|
assert box["contains"] == [{"key": "999", "name": "Witchdoctor"}]
|
||||||
|
|
||||||
|
# containment is editable after the fact: clear it, then set it back
|
||||||
|
assert web.post("/api/stored-in", json={"key": "999", "container": ""}).json() == {
|
||||||
|
"stored_in": None
|
||||||
|
}
|
||||||
|
rows2 = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
|
||||||
|
assert rows2["999"]["stored_in"] == ""
|
||||||
|
assert "stored_in_game" not in web.get("/api/library/999").json()
|
||||||
|
|
||||||
|
assert web.post(
|
||||||
|
"/api/stored-in", json={"key": "999", "container": "173634"}
|
||||||
|
).json() == {"stored_in": "173634"}
|
||||||
|
assert read_matches(cfg.matches_path)[-1]["stored_in"] == "173634"
|
||||||
|
|
||||||
|
# guards: self-containment and direct cycles refuse
|
||||||
|
assert (
|
||||||
|
web.post("/api/stored-in", json={"key": "999", "container": "999"}).status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
web.post(
|
||||||
|
"/api/stored-in", json={"key": "173634", "container": "999"}
|
||||||
|
).status_code
|
||||||
|
== 400
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user