diff --git a/CLAUDE.md b/CLAUDE.md index 68dfcfa..97050a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) | | 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working | | + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working | -| + | `bggpipe dims` | offline shelf-space report over box dimensions enrich collects from BGG *versions* (dims live per-version, not per-game; 0 = never entered; versionless games need a unanimous chorus within 0.5"/axis else `conflicting` — never guessed) | 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`. diff --git a/data/games.json b/data/games.json index 52df273..86dacac 100644 --- a/data/games.json +++ b/data/games.json @@ -1452,7 +1452,7 @@ "weight_lb": null, "source": "version" }, - "stored_in": null + "stored_in": "173634" }, "39856:296529": { "bgg_id": 39856, @@ -4390,7 +4390,7 @@ "weight_lb": 1.0, "source": "version" }, - "stored_in": null + "stored_in": "380837" }, "417064:706510": { "bgg_id": 417064, @@ -4458,7 +4458,7 @@ "weight_lb": 1.0, "source": "version" }, - "stored_in": null + "stored_in": "380837" }, "104710:117685": { "bgg_id": 104710, diff --git a/data/matches.csv b/data/matches.csv index 857efa5..4d2c388 100644 --- a/data/matches.csv +++ b/data/matches.csv @@ -49,7 +49,7 @@ Guillotine,116,Guillotine,1998,boardgame,auto,28201,Wizards of the Coast English THE DOOM THAT CAME TO ATLANTIC CITY,124548,The Doom That Came to Atlantic City,2013,boardgame,auto,165214,English first edition,version_approved,"[{""bgg_id"": 124548, ""name"": ""The Doom That Came to Atlantic City"", ""year"": 2013, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 165214, ""name"": ""English first edition"", ""year"": 2013, ""publishers"": [""Cryptozoic Entertainment""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 433368, ""name"": ""English second edition"", ""year"": 2014, ""publishers"": [""Renegade Game Studios""], ""languages"": [""English""], ""score"": 1}]",IMG_4513.jpeg,,, Flick'em UP!,169124,Flick 'em Up!,2015,boardgame,auto,451577,English edition 2019,version_approved,"[{""bgg_id"": 169124, ""name"": ""Flick 'em Up!"", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 258971, ""name"": ""Multilingual edition"", ""year"": 2015, ""publishers"": [""Pretzel Games""], ""languages"": [""English"", ""French"", ""German""], ""score"": 1}, {""version_id"": 297831, ""name"": ""English edition"", ""year"": 2016, ""publishers"": [""Z-Man Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 517981, ""name"": ""German/English edition"", ""year"": 2016, ""publishers"": [""Z-Man Games""], ""languages"": [""English"", ""German""], ""score"": 1}, {""version_id"": 451577, ""name"": ""English edition 2019"", ""year"": 2019, ""publishers"": [""Pretzel Games""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 320719, ""name"": ""Chinese edition"", ""year"": 2016, ""publishers"": [""One Moment Games""], ""languages"": [""Chinese""], ""score"": 0}, {""version_id"": 369328, ""name"": ""French/Dutch edition"", ""year"": 2016, ""publishers"": [""Filosofia Éditions"", ""Z-Man Games""], ""languages"": [""Dutch"", ""French""], ""score"": 0}, {""version_id"": 337117, ""name"": ""Hungarian edition"", ""year"": 2016, ""publishers"": [""Z-Man Games""], ""languages"": [""Hungarian""], ""score"": 0}, {""version_id"": 316202, ""name"": ""Italian edition"", ""year"": 2016, ""publishers"": [""Ghenos Games""], ""languages"": [""Italian""], ""score"": 0}, {""version_id"": 293673, ""name"": ""Polish edition"", ""year"": 2016, ""publishers"": [""Lacerta"", ""Z-Man Games""], ""languages"": [""Polish""], ""score"": 0}, {""version_id"": 320247, ""name"": ""Portuguese edition"", ""year"": 2016, ""publishers"": [""Galápagos Jogos""], ""languages"": [""Portuguese""], ""score"": 0}, {""version_id"": 307227, ""name"": ""Spanish edition"", ""year"": 2016, ""publishers"": [""Ludonova""], ""languages"": [""Spanish""], ""score"": 0}, {""version_id"": 425563, ""name"": ""Swedish/Finnish edition"", ""year"": 2016, ""publishers"": [""Z-Man Games""], ""languages"": [""Finnish"", ""Swedish""], ""score"": 0}, {""version_id"": 423418, ""name"": ""Danish/Norwegian edition"", ""year"": 2017, ""publishers"": [""Z-Man Games""], ""languages"": [""Danish"", ""Norwegian""], ""score"": 0}]",IMG_4513.jpeg,,, Castle Panic,43443,Castle Panic,2009,boardgame,auto,103068,"English edition, fourth printing",version_approved,"[{""bgg_id"": 43443, ""name"": ""Castle Panic"", ""year"": 2009, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 468123, ""name"": ""English edition, eighteenth printing"", ""year"": 2018, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 222131, ""name"": ""English edition, eighth printing"", ""year"": 2013, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 265959, ""name"": ""English edition, eleventh printing"", ""year"": 2014, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 343185, ""name"": ""English edition, fifteenth printing"", ""year"": 2016, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 169933, ""name"": ""English edition, fifth printing"", ""year"": 2012, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 24624, ""name"": ""English edition, first printing"", ""year"": 2009, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 337551, ""name"": ""English edition, fourteenth printing"", ""year"": 2016, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 103068, ""name"": ""English edition, fourth printing"", ""year"": 2010, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4513.jpeg,,, -The Red Dragon Inn,24310,The Red Dragon Inn,2007,boardgame,auto,374258,English fifth edition,version_approved,"[{""bgg_id"": 24310, ""name"": ""The Red Dragon Inn"", ""year"": 2007, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 16870, ""rank"": 2164}, {""bgg_id"": 33451, ""name"": ""The Red Dragon Inn 2"", ""year"": 2008, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 93.3, ""owned"": 8355, ""rank"": 2056}, {""bgg_id"": 66510, ""name"": ""The Red Dragon Inn 3"", ""year"": 2011, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 93.3, ""owned"": 6246, ""rank"": 2363}, {""bgg_id"": 142402, ""name"": ""The Red Dragon Inn 4"", ""year"": 2013, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 93.3, ""owned"": 4107, ""rank"": 3367}, {""bgg_id"": 455834, ""name"": ""The Red Dragon Inn 10"", ""year"": 2026, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 90.3, ""owned"": 162, ""rank"": null}]","[{""version_id"": 400341, ""name"": ""English eighth edition"", ""year"": 2017, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 620786, ""name"": ""English eleventh edition"", ""year"": 2021, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 762888, ""name"": ""English fifteenth edition"", ""year"": 2024, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 374258, ""name"": ""English fifth edition"", ""year"": 2012, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 33932, ""name"": ""English first edition"", ""year"": 2007, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 728027, ""name"": ""English fourteenth edition"", ""year"": 2023, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 312374, ""name"": ""English seventh edition"", ""year"": 2015, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 389768, ""name"": ""English sixth edition"", ""year"": 2014, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4514.jpeg,,, +The Red Dragon Inn,24310,The Red Dragon Inn,2007,boardgame,auto,374258,English fifth edition,version_approved,"[{""bgg_id"": 24310, ""name"": ""The Red Dragon Inn"", ""year"": 2007, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 16870, ""rank"": 2164}, {""bgg_id"": 33451, ""name"": ""The Red Dragon Inn 2"", ""year"": 2008, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 93.3, ""owned"": 8355, ""rank"": 2056}, {""bgg_id"": 66510, ""name"": ""The Red Dragon Inn 3"", ""year"": 2011, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 93.3, ""owned"": 6246, ""rank"": 2363}, {""bgg_id"": 142402, ""name"": ""The Red Dragon Inn 4"", ""year"": 2013, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 93.3, ""owned"": 4107, ""rank"": 3367}, {""bgg_id"": 455834, ""name"": ""The Red Dragon Inn 10"", ""year"": 2026, ""type"": ""boardgame"", ""exact"": false, ""fuzzy"": 90.3, ""owned"": 162, ""rank"": null}]","[{""version_id"": 400341, ""name"": ""English eighth edition"", ""year"": 2017, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 620786, ""name"": ""English eleventh edition"", ""year"": 2021, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 762888, ""name"": ""English fifteenth edition"", ""year"": 2024, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 374258, ""name"": ""English fifth edition"", ""year"": 2012, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 33932, ""name"": ""English first edition"", ""year"": 2007, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 728027, ""name"": ""English fourteenth edition"", ""year"": 2023, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 312374, ""name"": ""English seventh edition"", ""year"": 2015, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 389768, ""name"": ""English sixth edition"", ""year"": 2014, ""publishers"": [""SlugFest Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4514.jpeg,,,173634 Dixit,39856,Dixit,2008,boardgame,auto,296529,Asmodee/Libellud English edition 2015,version_approved,"[{""bgg_id"": 39856, ""name"": ""Dixit"", ""year"": 2008, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 709762, ""name"": ""Libellud multilingual edition 2008"", ""year"": 2008, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""English"", ""French"", ""German"", ""Italian"", ""Polish"", ""Portuguese"", ""Spanish""], ""score"": 3}, {""version_id"": 24804, ""name"": ""Libellud multilingual edition 2009"", ""year"": 2009, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""English"", ""French"", ""German"", ""Italian"", ""Polish"", ""Portuguese"", ""Spanish""], ""score"": 3}, {""version_id"": 52013, ""name"": ""Libellud English third edition 2010"", ""year"": 2010, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 72265, ""name"": ""Libellud multilingual edition 2010 with SdJ"", ""year"": 2010, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""English"", ""French"", ""German"", ""Italian"", ""Polish"", ""Portuguese"", ""Spanish""], ""score"": 3}, {""version_id"": 752364, ""name"": ""Libellud multilingual edition 2010 with SdJ without product code"", ""year"": 2010, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""English"", ""French"", ""German"", ""Italian"", ""Polish"", ""Portuguese"", ""Spanish""], ""score"": 3}, {""version_id"": 160064, ""name"": ""English edition 2011"", ""year"": 2011, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 198912, ""name"": ""Libellud multilingual edition 2011"", ""year"": 2011, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""English"", ""French"", ""German"", ""Italian"", ""Polish"", ""Portuguese"", ""Spanish""], ""score"": 3}, {""version_id"": 193872, ""name"": ""English edition 2012"", ""year"": 2012, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 290752, ""name"": ""Libellud multilingual edition 2012"", ""year"": 2012, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""English"", ""French"", ""German"", ""Italian"", ""Polish"", ""Portuguese"", ""Spanish""], ""score"": 3}, {""version_id"": 241272, ""name"": ""English edition 2013"", ""year"": 2013, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 310539, ""name"": ""Libellud French/English edition with award"", ""year"": 2013, ""publishers"": [""Libellud""], ""languages"": [""English"", ""French""], ""score"": 3}, {""version_id"": 353868, ""name"": ""Libellud multilingual edition 2013"", ""year"": 2013, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""English"", ""French"", ""German"", ""Italian"", ""Polish"", ""Portuguese"", ""Spanish""], ""score"": 3}, {""version_id"": 256721, ""name"": ""English edition 2014"", ""year"": 2014, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 273210, ""name"": ""English edition 2014"", ""year"": 2014, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 283431, ""name"": ""Libellud English UK edition 2014"", ""year"": 2014, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 267586, ""name"": ""Libellud English/French edition 2014"", ""year"": 2014, ""publishers"": [""Libellud""], ""languages"": [""English"", ""French""], ""score"": 3}, {""version_id"": 512383, ""name"": ""Multilingual edition 2014"", ""year"": 2014, ""publishers"": [""Libellud"", ""Morapiaf""], ""languages"": [""Dutch"", ""English"", ""French"", ""German"", ""Italian"", ""Polish"", ""Portuguese"", ""Spanish""], ""score"": 3}, {""version_id"": 296529, ""name"": ""Asmodee/Libellud English edition 2015"", ""year"": 2015, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 360758, ""name"": ""Libellud English edition 2016"", ""year"": 2016, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 360076, ""name"": ""Multilingual edition 2016"", ""year"": 2016, ""publishers"": [""KADABRA"", ""Libellud"", ""Nordic Games ehf"", ""Paladium Games""], ""languages"": [""Bulgarian"", ""English"", ""Estonian"", ""Icelandic"", ""Latvian"", ""Lithuanian""], ""score"": 3}, {""version_id"": 475048, ""name"": ""English edition 2017"", ""year"": 2017, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 530393, ""name"": ""English edition 2017, second printing"", ""year"": 2017, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 476620, ""name"": ""English edition 2018"", ""year"": 2018, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 482308, ""name"": ""Afrikaans/English edition"", ""year"": 2019, ""publishers"": [""Libellud""], ""languages"": [""Afrikaans"", ""English""], ""score"": 3}, {""version_id"": 488774, ""name"": ""Arabic/English edition"", ""year"": 2019, ""publishers"": [""Boardgame Space"", ""Libellud""], ""languages"": [""Arabic"", ""English""], ""score"": 3}, {""version_id"": 634801, ""name"": ""English edition 2019"", ""year"": 2019, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 643844, ""name"": ""English edition 2020"", ""year"": 2020, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 582709, ""name"": ""English edition 2021"", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 739982, ""name"": ""English edition 2021 with \""8 million games sold\"""", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 744604, ""name"": ""English edition 2021 with “7 million units sold”"", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 730261, ""name"": ""English UK edition 2021"", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 786419, ""name"": ""English edition 2025 with “12 million games sold”"", ""year"": 2025, ""publishers"": [""asmodee"", ""Libellud""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 544006, ""name"": ""Libellud Italian edition"", ""year"": 0, ""publishers"": [""Libellud""], ""languages"": [""Italian""], ""score"": 2}, {""version_id"": 661163, ""name"": ""Spanish/Portuguese/Catalan edition with \""5 millones\"""", ""year"": 0, ""publishers"": [""Libellud""], ""languages"": [""Catalan"", ""Portuguese"", ""Spanish""], ""score"": 2}, {""version_id"": 33307, ""name"": ""Libellud French first edition 2008"", ""year"": 2008, ""publishers"": [""Libellud""], ""languages"": [""French""], ""score"": 2}, {""version_id"": 22278, ""name"": ""Lautapelit.fi Nordic edition 2009"", ""year"": 2009, ""publishers"": [""Lautapelit.fi"", ""Libellud""], ""languages"": [""Danish"", ""Finnish"", ""Norwegian"", ""Swedish""], ""score"": 2}, {""version_id"": 35119, ""name"": ""Morapiaf Spanish edition 2009"", ""year"": 2009, ""publishers"": [""Libellud"", ""Morapiaf""], ""languages"": [""Spanish""], ""score"": 2}, {""version_id"": 60016, ""name"": ""Lautapelit.fi Nordic second edition 2010"", ""year"": 2010, ""publishers"": [""Lautapelit.fi"", ""Libellud""], ""languages"": [""Danish"", ""Finnish"", ""Norwegian"", ""Swedish""], ""score"": 2}, {""version_id"": 77683, ""name"": ""Libellud East-European edition 2010"", ""year"": 2010, ""publishers"": [""Libellud""], ""languages"": [""Czech"", ""Estonian"", ""Greek"", ""Hungarian"", ""Latvian"", ""Lithuanian"", ""Russian"", ""Slovak""], ""score"": 2}, {""version_id"": 69079, ""name"": ""Libellud German edition 2010 with SdJ"", ""year"": 2010, ""publishers"": [""Libellud""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 154712, ""name"": ""Libellud Russian edition 2010"", ""year"": 2010, ""publishers"": [""Libellud""], ""languages"": [""Russian""], ""score"": 2}, {""version_id"": 168676, ""name"": ""Hobby Japan Japanese edition 2011"", ""year"": 2011, ""publishers"": [""Hobby Japan"", ""Libellud""], ""languages"": [""Japanese""], ""score"": 2}, {""version_id"": 132993, ""name"": ""Korean edition 2011"", ""year"": 2011, ""publishers"": [""Korea Boardgames"", ""Libellud""], ""languages"": [""Korean""], ""score"": 2}, {""version_id"": 171239, ""name"": ""Lautapelit.fi Nordic third edition 2011"", ""year"": 2011, ""publishers"": [""Lautapelit.fi"", ""Libellud""], ""languages"": [""Danish"", ""Finnish"", ""Norwegian"", ""Swedish""], ""score"": 2}, {""version_id"": 180533, ""name"": ""Swan Panasia Chinese edition 2011"", ""year"": 2011, ""publishers"": [""Libellud"", ""Swan Panasia Co., Ltd.""], ""languages"": [""Chinese""], ""score"": 2}, {""version_id"": 455396, ""name"": ""Lautapelit.fi Nordic edition 2012"", ""year"": 2012, ""publishers"": [""Lautapelit.fi"", ""Libellud""], ""languages"": [""Danish"", ""Finnish"", ""Norwegian"", ""Swedish""], ""score"": 2}, {""version_id"": 218513, ""name"": ""Galápagos Jogos Brazilian edition 2013"", ""year"": 2013, ""publishers"": [""Galápagos Jogos"", ""Libellud""], ""languages"": [""Portuguese""], ""score"": 2}, {""version_id"": 571272, ""name"": ""Korean edition 2013"", ""year"": 2013, ""publishers"": [""Korea Boardgames"", ""Libellud""], ""languages"": [""Korean""], ""score"": 2}, {""version_id"": 295526, ""name"": ""Lautapelit.fi Nordic edition 2013"", ""year"": 2013, ""publishers"": [""Lautapelit.fi"", ""Libellud""], ""languages"": [""Danish"", ""Finnish"", ""Norwegian"", ""Swedish""], ""score"": 2}, {""version_id"": 285443, ""name"": ""Asterion Press Italian edition 2014"", ""year"": 2014, ""publishers"": [""Asterion Press"", ""Libellud""], ""languages"": [""Italian""], ""score"": 2}, {""version_id"": 262742, ""name"": ""Gém Klub Hungarian edition 2014"", ""year"": 2014, ""publishers"": [""Gémklub"", ""Libellud""], ""languages"": [""Hungarian""], ""score"": 2}, {""version_id"": 241993, ""name"": ""German edition 2014"", ""year"": 2014, ""publishers"": [""Libellud""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 258097, ""name"": ""Kaissa Greek second edition"", ""year"": 2014, ""publishers"": [""Kaissa Chess & Games"", ""Libellud""], ""languages"": [""Greek""], ""score"": 2}, {""version_id"": 444352, ""name"": ""Libellud Dutch/French/German/Italian edition 2014"", ""year"": 2014, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""French"", ""German"", ""Italian""], ""score"": 2}, {""version_id"": 264541, ""name"": ""Libellud French edition 2014"", ""year"": 2014, ""publishers"": [""Libellud""], ""languages"": [""French""], ""score"": 2}, {""version_id"": 277730, ""name"": ""REBEL.pl Polish edition 2014"", ""year"": 2014, ""publishers"": [""Libellud"", ""Rebel Sp. z o.o.""], ""languages"": [""Polish""], ""score"": 2}, {""version_id"": 267692, ""name"": ""Swan Panasia Chinese second edition 2014"", ""year"": 2014, ""publishers"": [""Libellud"", ""Swan Panasia Co., Ltd.""], ""languages"": [""Chinese""], ""score"": 2}, {""version_id"": 673335, ""name"": ""German edition 2015"", ""year"": 2015, ""publishers"": [""Libellud""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 329856, ""name"": ""Libellud German edition 2015"", ""year"": 2015, ""publishers"": [""Libellud""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 302789, ""name"": ""Morapiaf Spanish/Portuguese/Catalan edition"", ""year"": 2015, ""publishers"": [""Libellud"", ""Morapiaf""], ""languages"": [""Catalan"", ""Portuguese"", ""Spanish""], ""score"": 2}, {""version_id"": 650415, ""name"": ""Multilingual edition 2015"", ""year"": 2015, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""French"", ""German"", ""Italian""], ""score"": 2}, {""version_id"": 616351, ""name"": ""Ukrainian edition"", ""year"": 2015, ""publishers"": [""Libellud"", ""Ігромаг""], ""languages"": [""Ukrainian""], ""score"": 2}, {""version_id"": 740355, ""name"": ""Asterion Press Italian edition 2016"", ""year"": 2016, ""publishers"": [""Asterion Press"", ""Libellud""], ""languages"": [""Italian""], ""score"": 2}, {""version_id"": 620400, ""name"": ""Czech edition 2016"", ""year"": 2016, ""publishers"": [""ADC Blackfire Entertainment"", ""Libellud""], ""languages"": [""Czech""], ""score"": 2}, {""version_id"": 445188, ""name"": ""Galápagos Portuguese edition 2016"", ""year"": 2016, ""publishers"": [""Galápagos Jogos"", ""Libellud""], ""languages"": [""Portuguese""], ""score"": 2}, {""version_id"": 351092, ""name"": ""KADABRA Baltic edition"", ""year"": 2016, ""publishers"": [""KADABRA"", ""Libellud""], ""languages"": [""Estonian"", ""Latvian"", ""Lithuanian""], ""score"": 2}, {""version_id"": 314149, ""name"": ""Kaissa Greek third edition"", ""year"": 2016, ""publishers"": [""Kaissa Chess & Games"", ""Libellud""], ""languages"": [""Greek""], ""score"": 2}, {""version_id"": 486910, ""name"": ""Bulgarian edition"", ""year"": 2017, ""publishers"": [""Libellud""], ""languages"": [""Bulgarian""], ""score"": 2}, {""version_id"": 441974, ""name"": ""French edition 2017"", ""year"": 2017, ""publishers"": [""Libellud""], ""languages"": [""French""], ""score"": 2}, {""version_id"": 572425, ""name"": ""German edition 2017"", ""year"": 2017, ""publishers"": [""Libellud""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 428190, ""name"": ""Multilingual edition 2017"", ""year"": 2017, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""French"", ""German"", ""Italian""], ""score"": 2}, {""version_id"": 568074, ""name"": ""Turkish edition 2017"", ""year"": 2017, ""publishers"": [""Libellud""], ""languages"": [""Turkish""], ""score"": 2}, {""version_id"": 558607, ""name"": ""Chinese edition"", ""year"": 2018, ""publishers"": [""Libellud""], ""languages"": [""Chinese""], ""score"": 2}, {""version_id"": 602470, ""name"": ""German edition 2020"", ""year"": 2020, ""publishers"": [""Libellud""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 709765, ""name"": ""Libellud Nordic edition 2020"", ""year"": 2020, ""publishers"": [""Libellud""], ""languages"": [""Danish"", ""Finnish"", ""Norwegian"", ""Swedish""], ""score"": 2}, {""version_id"": 710977, ""name"": ""Croatian/Macedonian/Serbian/Slovenian edition"", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""Croatian"", ""Macedonian"", ""Serbian"", ""Slovenian""], ""score"": 2}, {""version_id"": 592458, ""name"": ""Czech/Slovak edition 2021"", ""year"": 2021, ""publishers"": [""ADC Blackfire Entertainment"", ""Libellud""], ""languages"": [""Czech"", ""Slovak""], ""score"": 2}, {""version_id"": 636825, ""name"": ""French edition 2021"", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""French""], ""score"": 2}, {""version_id"": 801621, ""name"": ""French/German/Dutch edition 2021"", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""Dutch"", ""French"", ""German""], ""score"": 2}, {""version_id"": 624284, ""name"": ""Galápagos Portuguese edition 2021"", ""year"": 2021, ""publishers"": [""Galápagos Jogos"", ""Libellud""], ""languages"": [""Portuguese""], ""score"": 2}, {""version_id"": 645671, ""name"": ""German edition with \""Mehr als 8 Millionen verkaufte Exemplare\"""", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 571277, ""name"": ""Korean edition 2021"", ""year"": 2021, ""publishers"": [""Korea Boardgames"", ""Libellud""], ""languages"": [""Korean""], ""score"": 2}, {""version_id"": 580765, ""name"": ""Rebel Polish edition 2021"", ""year"": 2021, ""publishers"": [""Libellud"", ""Rebel Sp. z o.o.""], ""languages"": [""Polish""], ""score"": 2}, {""version_id"": 583823, ""name"": ""Spanish/Portuguese/Catalan edition 2021"", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""Catalan"", ""Portuguese"", ""Spanish""], ""score"": 2}, {""version_id"": 646232, ""name"": ""Turkish edition 2021"", ""year"": 2021, ""publishers"": [""Libellud""], ""languages"": [""Turkish""], ""score"": 2}, {""version_id"": 715849, ""name"": ""Czech/Slovak/Polish/Hungarian edition"", ""year"": 2022, ""publishers"": [""ADC Blackfire Entertainment"", ""Gémklub"", ""Libellud"", ""Rebel Sp. z o.o.""], ""languages"": [""Czech"", ""Hungarian"", ""Polish"", ""Slovak""], ""score"": 2}, {""version_id"": 694502, ""name"": ""Italian edition 2023"", ""year"": 2023, ""publishers"": [""Libellud""], ""languages"": [""Italian""], ""score"": 2}, {""version_id"": 641359, ""name"": ""Maldón Spanish edition"", ""year"": 2023, ""publishers"": [""Libellud"", ""Maldón""], ""languages"": [""Spanish""], ""score"": 2}, {""version_id"": 748806, ""name"": ""German edition 2024"", ""year"": 2024, ""publishers"": [""Libellud""], ""languages"": [""German""], ""score"": 2}, {""version_id"": 689598, ""name"": ""Arabic/English revised edition"", ""year"": 0, ""publishers"": [""Boardgame Space""], ""languages"": [""Arabic"", ""English""], ""score"": 1}, {""version_id"": 745746, ""name"": ""Gém Klub Hungarian edition 2021"", ""year"": 2021, ""publishers"": [""Gémklub""], ""languages"": [""Hungarian""], ""score"": 0}, {""version_id"": 733565, ""name"": ""Italian edition 2021"", ""year"": 2021, ""publishers"": [""asmodee""], ""languages"": [""Italian""], ""score"": 0}]",IMG_4514.jpeg,,, Huggermugger,2387,Huggermugger,1989,boardgame,auto,91428,First edition,version_approved,"[{""bgg_id"": 2387, ""name"": ""Huggermugger"", ""year"": 1989, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 91428, ""name"": ""First edition"", ""year"": 1989, ""publishers"": [""Golden"", ""Huggermugger Company"", ""Western Publishing Company""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 105698, ""name"": ""Golden travel edition 1992"", ""year"": 1992, ""publishers"": [""Golden""], ""languages"": [""English""], ""score"": 1}, {""version_id"": 176124, ""name"": ""Western Publishing travel edition"", ""year"": 1992, ""publishers"": [""Western Publishing Company""], ""languages"": [""English""], ""score"": 1}]",IMG_4514.jpeg,,, The Rocky Horror Trivia Game,20866,The Rocky Horror Trivia Game,2005,boardgame,auto,40025,English edition,version_approved,"[{""bgg_id"": 20866, ""name"": ""The Rocky Horror Trivia Game"", ""year"": 2005, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 40025, ""name"": ""English edition"", ""year"": 2005, ""publishers"": [""The Op Games""], ""languages"": [""English""], ""score"": 1}]",IMG_4514.jpeg,,, @@ -129,8 +129,8 @@ MANTIS FALLS,291847,Mantis Falls,2021,boardgame,auto,476820,English kickstarter SMALL WORLD,40692,Small World,2009,boardgame,auto,294178,English third edition,version_approved,"[{""bgg_id"": 40692, ""name"": ""Small World"", ""year"": 2009, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 488854, ""name"": ""English edition 2019"", ""year"": 2019, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 23936, ""name"": ""English first edition"", ""year"": 2009, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 174280, ""name"": ""English second edition"", ""year"": 2010, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 294178, ""name"": ""English third edition"", ""year"": 2012, ""publishers"": [""Days of Wonder""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 478262, ""name"": ""Chinese edition"", ""year"": 2018, ""publishers"": [""Days of Wonder""], ""languages"": [""Chinese""], ""score"": 2}, {""version_id"": 24053, ""name"": ""Dutch edition"", ""year"": 2009, ""publishers"": [""Days of Wonder""], ""languages"": [""Dutch""], ""score"": 2}, {""version_id"": 73633, ""name"": ""French first edition"", ""year"": 2009, ""publishers"": [""Days of Wonder""], ""languages"": [""French""], ""score"": 2}, {""version_id"": 287468, ""name"": ""French second edition"", ""year"": 2010, ""publishers"": [""Days of Wonder""], ""languages"": [""French""], ""score"": 2}]",IMG_4513.jpeg,,, ARTISTRY,432858,Artistry,2025,boardgame,auto,734890,English edition,version_approved,"[{""bgg_id"": 432858, ""name"": ""Artistry"", ""year"": 2025, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 762382, ""name"": ""English Deluxe edition"", ""year"": 2025, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 734890, ""name"": ""English edition"", ""year"": 2025, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 762383, ""name"": ""English Jewelry Box edition"", ""year"": 2025, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 777369, ""name"": ""Russian edition"", ""year"": 2026, ""publishers"": [""Choo Choo Games"", ""Dux Somnium Games""], ""languages"": [""Russian""], ""score"": 2}]",IMG_4515.jpeg,,, MAGE WARS ACADEMY,172503,Mage Wars Academy,2015,boardgame,auto,265107,English edition,version_auto,"[{""bgg_id"": 172503, ""name"": ""Mage Wars Academy"", ""year"": 2015, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 265107, ""name"": ""English edition"", ""year"": 2015, ""publishers"": [""Arcane Wonders""], ""languages"": [""English""], ""score"": 3}]",IMG_4518.jpeg,,, -BOTANY: TANTALIZING TREES,417060,Botany: Tantalizing Trees,2024,boardgameexpansion,auto,706506,English edition,version_auto,"[{""bgg_id"": 417060, ""name"": ""Botany: Tantalizing Trees"", ""year"": 2024, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 706506, ""name"": ""English edition"", ""year"": 2024, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4571.jpeg,,, -BOTANY: PERILOUS PERFUMES,417064,Botany: Perilous Perfumes,2024,boardgameexpansion,auto,706510,English edition,version_auto,"[{""bgg_id"": 417064, ""name"": ""Botany: Perilous Perfumes"", ""year"": 2024, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 706510, ""name"": ""English edition"", ""year"": 2024, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4571.jpeg,,, +BOTANY: TANTALIZING TREES,417060,Botany: Tantalizing Trees,2024,boardgameexpansion,auto,706506,English edition,version_auto,"[{""bgg_id"": 417060, ""name"": ""Botany: Tantalizing Trees"", ""year"": 2024, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 706506, ""name"": ""English edition"", ""year"": 2024, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4571.jpeg,,,380837 +BOTANY: PERILOUS PERFUMES,417064,Botany: Perilous Perfumes,2024,boardgameexpansion,auto,706510,English edition,version_auto,"[{""bgg_id"": 417064, ""name"": ""Botany: Perilous Perfumes"", ""year"": 2024, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 706510, ""name"": ""English edition"", ""year"": 2024, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 3}]",IMG_4571.jpeg,,,380837 Civilization: West Extension Map,2058,Civilization: West Extension Map,1986,boardgameexpansion,auto,28041,Avalon Hill edition,version_approved,"[{""bgg_id"": 2058, ""name"": ""Civilization: West Extension Map"", ""year"": 1986, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 293811, ""name"": ""Hartland Trefoil edition"", ""year"": 1986, ""publishers"": [""Hartland Trefoil Ltd.""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 28041, ""name"": ""Avalon Hill edition"", ""year"": 1988, ""publishers"": [""The Avalon Hill Game Co""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 67095, ""name"": ""German edition"", ""year"": 1992, ""publishers"": [""Welt der Spiele""], ""languages"": [""German""], ""score"": 0}]",,,, Artistry: Delightful Doorways Mini Expansion,452833,Artistry: Delightful Doorways Mini Expansion,2025,boardgameexpansion,auto,770724,English edition,version_approved,"[{""bgg_id"": 452833, ""name"": ""Artistry: Delightful Doorways Mini Expansion"", ""year"": 2025, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 770724, ""name"": ""English edition"", ""year"": 2025, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 770830, ""name"": ""English Jewelry Box edition"", ""year"": 2025, ""publishers"": [""Dux Somnium Games""], ""languages"": [""English""], ""score"": 0}]",,,, castle panic wizard's tower,104590,Castle Panic: The Wizard's Tower,2011,boardgameexpansion,auto,117256,"English first edition, first printing",version_approved,"[{""bgg_id"": 104590, ""name"": ""Castle Panic: The Wizard's Tower"", ""year"": 2011, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 117256, ""name"": ""English first edition, first printing"", ""year"": 2011, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 193172, ""name"": ""First edition, second printing"", ""year"": 2012, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 228207, ""name"": ""First edition, fourth printing"", ""year"": 2013, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 208993, ""name"": ""First edition, third printing"", ""year"": 2013, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 280403, ""name"": ""First edition, fifth printing"", ""year"": 2014, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 302898, ""name"": ""First edition, sixth printing"", ""year"": 2015, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 371339, ""name"": ""First edition, seventh printing"", ""year"": 2016, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 476666, ""name"": ""First edition, ninth printing"", ""year"": 2019, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 553422, ""name"": ""First edition, first printing"", ""year"": 2021, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}, {""version_id"": 624504, ""name"": ""English second edition"", ""year"": 2023, ""publishers"": [""Fireside Games""], ""languages"": [""English""], ""score"": 0}]",,,, diff --git a/docs/guide.md b/docs/guide.md index 2e648d7..185027d 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -109,6 +109,29 @@ BGG application approval can take a week or more. Until then: `extract` works im - `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1` - `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion` +## Where everything lives + +The **Shelves** page maps your physical storage: create units from presets +(IKEA Kallax cube 13.25″ × 13.25″ × 15.4″, Billy shelf, custom sizes) as +grids of rows × columns, add one-off openings (double-wides, a top shelf), +and no-size virtual spots ("travel case", "lent out"). Openings carry free- +text zones, are editable and reorderable, and everything lands in +`data/furniture.json` + `data/locations.json` — committed local stores; +your shelf layout is never sent anywhere. + +Assign games by tapping an unshelved game's suggested openings (only +openings it actually fits, with room to spare), 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. On a phone over `--lan`, the opening +view docks as a bottom sheet — the moving-day loop is search, tap, done. + +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. `bggpipe dims` reports per-opening +capacity, overfull warnings, and misfits against your real furniture. + ## Shelf-space planning Physical box dimensions live on BGG *versions*, not games, so enrich collects diff --git a/src/bggpipe/cli.py b/src/bggpipe/cli.py index c82a6f2..ef99312 100644 --- a/src/bggpipe/cli.py +++ b/src/bggpipe/cli.py @@ -193,6 +193,23 @@ def enrich( 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, Kallax fit.""" diff --git a/src/bggpipe/config.py b/src/bggpipe/config.py index 1529391..0b5a8c1 100644 --- a/src/bggpipe/config.py +++ b/src/bggpipe/config.py @@ -107,6 +107,18 @@ class Config: "accept the arrangement and silence this warning." ) + @property + def furniture_path(self) -> Path: + # storage units and their openings — the user's shelf layout is + # local-only data (never BGG's business), committed like the + # other curation stores + return self.data_dir / "furniture.json" + + @property + def locations_path(self) -> Path: + # game key -> {opening_id, note}: where each box physically lives + return self.data_dir / "locations.json" + @property def local_games_path(self) -> Path: # hand-written metadata for games BGG doesn't have — the only diff --git a/src/bggpipe/dims.py b/src/bggpipe/dims.py index ac29ded..12cb61f 100644 --- a/src/bggpipe/dims.py +++ b/src/bggpipe/dims.py @@ -1,33 +1,29 @@ """Shelf-space report over the box dimensions enrich collected. -Reads games.json only — fully offline. The fit target is an IKEA Kallax -cube: a box fits if SOME orientation puts two axes through the ~13.2" -square opening with the third axis within the ~15.4" depth. "Can't -verify" is reported as exactly that, never as a fit. +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 -from itertools import permutations import typer from bggpipe.config import Config - -KALLAX_OPENING_IN = 13.2 -KALLAX_DEPTH_IN = 15.4 +from bggpipe.shelves import ( + load_furniture, + load_locations, + opening_index, + opening_report, +) _AXES = ("width_in", "length_in", "depth_in") -def fits_kallax(width: float, length: float, depth: float) -> bool: - return any( - a <= KALLAX_OPENING_IN and b <= KALLAX_OPENING_IN and c <= KALLAX_DEPTH_IN - for a, b, c in permutations((width, length, depth)) - ) - - def _footprint(dims: dict) -> float: """The two largest axes multiplied — the shelf area a box claims lying in its flattest orientation.""" @@ -86,30 +82,61 @@ def run_dims_report(cfg: Config) -> dict: f" ({w:g} × {length:g} × {d:g} in)" ) - misfits = [ - (name, dims) - for name, dims in measured - if not fits_kallax(*(dims[a] for a in _AXES)) - ] unknown = [ name for source in ("conflicting", "absent") for name, _ in by_source.get(source, []) ] - typer.echo( - f'\nKallax check ({KALLAX_OPENING_IN:g}" × {KALLAX_OPENING_IN:g}" opening, ' - f'{KALLAX_DEPTH_IN:g}" deep):' - ) - if misfits: - typer.echo(f" {len(misfits)} box(es) do NOT fit in any orientation:") - for name, dims in misfits: - w, length, d = (dims[a] for a in _AXES) - typer.echo(f" {name} ({w:g} × {length:g} × {d:g} in)") + + 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: - typer.echo(" every measured box fits.") + locations = load_locations(cfg) + 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, [])) + dims_note = ( + f"{opening['width_in']:g}×{opening['height_in']:g}" + f"×{opening['depth_in']:g} in" + if opening.get("width_in") + else "no size limits" + ) + line = ( + f" {opening['unit']} · {opening['label']}" + f" ({dims_note}): {report['games']} game(s)" + ) + if report["stacked_in"]: + line += f', {report["stacked_in"]:g}" stacked' + if report["unmeasured"]: + line += f", {report['unmeasured']} unmeasured" + if report["overfull"]: + line += " ⚠ OVERFULL" + typer.echo(line) + for name in report["misfits"]: + misfit_names.append(name) + typer.echo(f" ⚠ doesn't fit: {name}") + homeless = [ + entry.get("name") or "?" + for key, entry in games.items() + if not entry.get("stored_in") + and not (locations.get(key) or {}).get("opening_id") + ] + if homeless: + typer.echo(f"\n{len(homeless)} game(s) unshelved.") if unknown: typer.echo( - f" {len(unknown)} game(s) can't be verified (no trusted " + f"\n{len(unknown)} game(s) can't be size-verified (no trusted " "dimensions — not the same as fitting):" ) for name in sorted(unknown): @@ -117,7 +144,7 @@ def run_dims_report(cfg: Config) -> dict: return { "by_source": {s: len(v) for s, v in by_source.items()}, - "misfits": [name for name, _ in misfits], + "misfits": misfit_names, "unknown": sorted(unknown), "contained": sorted(contained), } diff --git a/src/bggpipe/shelves.py b/src/bggpipe/shelves.py new file mode 100644 index 0000000..d872e51 --- /dev/null +++ b/src/bggpipe/shelves.py @@ -0,0 +1,253 @@ +"""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.normalize import normalize_title + +_AXES = ("width_in", "length_in", "depth_in") +_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 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 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_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; everything else reads locations.json.""" + container_id = entry.get("stored_in") + if container_id: + for other_key, other in games.items(): + if str(other.get("bgg_id")) == str(container_id): + loc = locations.get(other_key, {}) + return loc.get("opening_id"), loc.get("note", ""), other_key + return None, "", None + loc = locations.get(key, {}) + return loc.get("opening_id"), loc.get("note", ""), None + + +def opening_report(opening: dict, games_here: list[tuple[str, dict]]) -> dict: + """Capacity picture for one opening: fill (stacked thickness vs + interior height), misfits, and how many boxes are unmeasured.""" + thicknesses = [stack_thickness(e) for _, e in games_here] + stacked = sum(t for t in thicknesses if t) + unmeasured = sum(1 for t in thicknesses if t is None) + height = opening.get("height_in") + misfits = [ + entry.get("name") or "?" + for _, entry in games_here + if fits_opening(entry, opening) is False + ] + return { + "games": len(games_here), + "stacked_in": round(stacked, 2), + "unmeasured": unmeasured, + "fill": round(stacked / height, 2) if height and stacked else 0, + "overfull": bool(height and stacked > height), + "misfits": misfits, + } + + +# -- 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.""" + 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] = 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 or 'unit label'" + ) + continue + oid = oids[0] + if fits_opening(games[key], openings[oid]) is False: + rejects.append( + f"{name}: doesn't fit {openings[oid]['unit']} " + f"{openings[oid]['label']} in any orientation" + ) + continue + locations[key] = {"opening_id": oid, "note": ""} + 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} diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index 2fb69d2..696fb9b 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -542,6 +542,78 @@ select { #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 ------- */ +.unit .unithead { display: flex; align-items: center; gap: .7rem; + justify-content: space-between; flex-wrap: wrap; } +.openings { display: grid; gap: .5rem; + grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); } +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; +} +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(--ok, #4a7c4e); + border-radius: 1rem; } +.fillbar > div.over { background: #b3372f; } +.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); } + +/* 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; } +#sheetsearch { width: 100%; margin: .7rem 0 .35rem; font: inherit; + font-size: 1rem; padding: .55rem .7rem; + border: 2px solid var(--board-edge); border-radius: var(--radius); } +button.sheetadd { display: flex; gap: .6rem; align-items: center; + width: 100%; text-align: left; font: inherit; cursor: pointer; + background: #fff; border: 2px solid var(--board-edge); + border-radius: var(--radius); padding: .5rem .6rem; margin: .25rem 0; + min-height: 2.9rem; } +button.sheetadd img { width: 2.2rem; height: 2.2rem; object-fit: cover; + border-radius: .35rem; } +button.sheetadd:hover, button.sheetadd:focus-visible { + outline: 3px solid var(--accent); } + +@media (max-width: 900px) { + /* one-handed moving-day mode: the sheet docks to the bottom, search + first, tap targets stay thumb-sized */ + #opensheet { align-items: flex-end; } + .sheetcard { width: 100vw; max-height: 78vh; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; } + button.opening { min-height: 4.4rem; } + .unshelved-row .gos { margin-left: 0; width: 100%; } + button.suggest { padding: .5rem .8rem; } +} + .empty { background: var(--board); border: 2px dashed var(--board-edge); border-radius: var(--radius-lg); padding: 2rem; text-align: center; diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index 28c7a96..5753a72 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -30,6 +30,7 @@

Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.

Review — 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 (keep locally: it joins the Library, never uploads). Keyboard-first; see shortcuts.

Queue — 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 retry N failed checkbox. Each queued row shows what upload did with it — pending, done, failed, or retired (a review decision since the last diff withdrew it). Finished rows stay listed until the next diff rebuilds the queue; the log below them is the permanent record.

+

Shelves — where everything physically lives. Describe your furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns, custom sizes, and no-size spots like a travel case), then assign games to openings — by tapping suggestions next to unshelved games, by searching inside an opening, or from any game's detail page. Fill bars show stacked thickness against interior height; ⚠ flags overfull openings and boxes that can't fit. Games stored inside another box ride along with their container. Your layout is local data only — never sent anywhere. Bulk-load a plan with bggpipe shelve --import plan.csv (name,opening) and check capacity anytime with bggpipe dims.

Library — 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, your 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 data/ and folded in by the next enrich).

diff --git a/src/bggpipe/templates/pages/library.html b/src/bggpipe/templates/pages/library.html index 41a8e8c..2decf56 100644 --- a/src/bggpipe/templates/pages/library.html +++ b/src/bggpipe/templates/pages/library.html @@ -12,6 +12,11 @@ +