The Shelves layer: where every box physically lives
Eric's spec, all nine points. Two committed local-only stores follow
the local_games.json pattern — furniture.json (units of openings with
interior dims; a dimensionless opening is a virtual spot like a travel
case) and locations.json (game key -> opening + note). Shelf layouts
are nobody's data but the owner's; nothing touches upload.
The Shelves page builds furniture without hand-editing JSON — the
acceptance bar (two double-wides above three rows of four cubes, two
bookcases, a travel case) is a TEST, driven entirely through the
endpoints the UI calls. Presets for Kallax/Billy/custom/virtual,
grid creation with A1-style labels, openings editable/deletable/
reorderable. Units render as grids: zone, count, fill bar (stacked
thinnest-axis vs interior height), ⚠ on overfull or any resident that
can't fit. Openings open as a modal — a bottom sheet at phone widths,
search-first with thumb-sized targets for the moving-day loop.
Unshelved games list alongside with one-tap suggestions (only openings
they verifiably fit, with room).
Containment composes: a game stored inside another box inherits its
container's location, rides along in the opening's resident list
(marked), and refuses direct assignment naming its container. The
detail page's where-it-lives card gains the picker (openings grouped
by unit, each labeled fits / doesn't fit / can't verify) plus virtual
notes ("lent to Sarah, June"); the Library list shows a location line,
filters by unit or unshelved, and search matches location text and
zones.
bggpipe dims drops its hardcoded Kallax for the user's actual
furniture: per-opening capacity, overfull and misfit warnings,
unshelved count. bggpipe shelve --import loads a name,opening CSV
(ids or labels), rejecting — never guessing — unknown names, ambiguous
copies, unknown/ambiguous openings, misfits, and contained games.
Ten new tests incl. the acceptance flow, inheritance, CSV rejects,
and a phone-sheet smoke; 372 total.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
e85f72c546
commit
4f446f6f2a
@@ -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) |
|
| 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 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`.
|
Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1452,7 +1452,7 @@
|
|||||||
"weight_lb": null,
|
"weight_lb": null,
|
||||||
"source": "version"
|
"source": "version"
|
||||||
},
|
},
|
||||||
"stored_in": null
|
"stored_in": "173634"
|
||||||
},
|
},
|
||||||
"39856:296529": {
|
"39856:296529": {
|
||||||
"bgg_id": 39856,
|
"bgg_id": 39856,
|
||||||
@@ -4390,7 +4390,7 @@
|
|||||||
"weight_lb": 1.0,
|
"weight_lb": 1.0,
|
||||||
"source": "version"
|
"source": "version"
|
||||||
},
|
},
|
||||||
"stored_in": null
|
"stored_in": "380837"
|
||||||
},
|
},
|
||||||
"417064:706510": {
|
"417064:706510": {
|
||||||
"bgg_id": 417064,
|
"bgg_id": 417064,
|
||||||
@@ -4458,7 +4458,7 @@
|
|||||||
"weight_lb": 1.0,
|
"weight_lb": 1.0,
|
||||||
"source": "version"
|
"source": "version"
|
||||||
},
|
},
|
||||||
"stored_in": null
|
"stored_in": "380837"
|
||||||
},
|
},
|
||||||
"104710:117685": {
|
"104710:117685": {
|
||||||
"bgg_id": 104710,
|
"bgg_id": 104710,
|
||||||
|
|||||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -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`
|
||||||
- `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: 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
|
## Shelf-space planning
|
||||||
|
|
||||||
Physical box dimensions live on BGG *versions*, not games, so enrich collects
|
Physical box dimensions live on BGG *versions*, not games, so enrich collects
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ def enrich(
|
|||||||
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()
|
@app.command()
|
||||||
def dims(config: ConfigOpt = None) -> None:
|
def dims(config: ConfigOpt = None) -> None:
|
||||||
"""Shelf-space report: box sizes, biggest footprints, Kallax fit."""
|
"""Shelf-space report: box sizes, biggest footprints, Kallax fit."""
|
||||||
|
|||||||
@@ -107,6 +107,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
|
||||||
|
|||||||
+59
-32
@@ -1,33 +1,29 @@
|
|||||||
"""Shelf-space report over the box dimensions enrich collected.
|
"""Shelf-space report over the box dimensions enrich collected.
|
||||||
|
|
||||||
Reads games.json only — fully offline. The fit target is an IKEA Kallax
|
Reads games.json and the local furniture/location stores — fully
|
||||||
cube: a box fits if SOME orientation puts two axes through the ~13.2"
|
offline. Fit checks run against the openings the user actually owns
|
||||||
square opening with the third axis within the ~15.4" depth. "Can't
|
(data/furniture.json); with no furniture on file the report covers
|
||||||
verify" is reported as exactly that, never as a fit.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from itertools import permutations
|
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from bggpipe.config import Config
|
from bggpipe.config import Config
|
||||||
|
from bggpipe.shelves import (
|
||||||
KALLAX_OPENING_IN = 13.2
|
load_furniture,
|
||||||
KALLAX_DEPTH_IN = 15.4
|
load_locations,
|
||||||
|
opening_index,
|
||||||
|
opening_report,
|
||||||
|
)
|
||||||
|
|
||||||
_AXES = ("width_in", "length_in", "depth_in")
|
_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:
|
def _footprint(dims: dict) -> float:
|
||||||
"""The two largest axes multiplied — the shelf area a box claims
|
"""The two largest axes multiplied — the shelf area a box claims
|
||||||
lying in its flattest orientation."""
|
lying in its flattest orientation."""
|
||||||
@@ -86,30 +82,61 @@ def run_dims_report(cfg: Config) -> dict:
|
|||||||
f" ({w:g} × {length:g} × {d:g} in)"
|
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 = [
|
unknown = [
|
||||||
name
|
name
|
||||||
for source in ("conflicting", "absent")
|
for source in ("conflicting", "absent")
|
||||||
for name, _ in by_source.get(source, [])
|
for name, _ in by_source.get(source, [])
|
||||||
]
|
]
|
||||||
typer.echo(
|
|
||||||
f'\nKallax check ({KALLAX_OPENING_IN:g}" × {KALLAX_OPENING_IN:g}" opening, '
|
units = load_furniture(cfg)
|
||||||
f'{KALLAX_DEPTH_IN:g}" deep):'
|
misfit_names: list[str] = []
|
||||||
)
|
if not units:
|
||||||
if misfits:
|
typer.echo(
|
||||||
typer.echo(f" {len(misfits)} box(es) do NOT fit in any orientation:")
|
"\nNo furniture on file — set up your shelves on the Shelves "
|
||||||
for name, dims in misfits:
|
"page (bggpipe web) to get per-opening fit checks."
|
||||||
w, length, d = (dims[a] for a in _AXES)
|
)
|
||||||
typer.echo(f" {name} ({w:g} × {length:g} × {d:g} in)")
|
|
||||||
else:
|
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:
|
if unknown:
|
||||||
typer.echo(
|
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):"
|
"dimensions — not the same as fitting):"
|
||||||
)
|
)
|
||||||
for name in sorted(unknown):
|
for name in sorted(unknown):
|
||||||
@@ -117,7 +144,7 @@ def run_dims_report(cfg: Config) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"by_source": {s: len(v) for s, v in by_source.items()},
|
"by_source": {s: len(v) for s, v in by_source.items()},
|
||||||
"misfits": [name for name, _ in misfits],
|
"misfits": misfit_names,
|
||||||
"unknown": sorted(unknown),
|
"unknown": sorted(unknown),
|
||||||
"contained": sorted(contained),
|
"contained": sorted(contained),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -542,6 +542,78 @@ select {
|
|||||||
#storedform:not([hidden]) { display: flex; gap: .5rem; align-items: center; }
|
#storedform:not([hidden]) { display: flex; gap: .5rem; align-items: center; }
|
||||||
#storedform select { flex: 1 1 auto; }
|
#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 {
|
.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. 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 <code>bggpipe shelve --import plan.csv</code> (name,opening) 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 @@ 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")
|
||||||
|
rows = rows.filter(g => !(g.location && (g.location.text || 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 +121,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 =>
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ 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(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function wireStored(g) {
|
function wireStored(g) {
|
||||||
const btn = document.getElementById("editstored");
|
const btn = document.getElementById("editstored");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -195,18 +206,44 @@ 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 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)}` : ""}.</p>`
|
||||||
|
: `<p>${loc.text
|
||||||
|
? `Currently: <b>${esc(loc.text)}</b>${loc.note && loc.text !== loc.note
|
||||||
|
? ` <span class="meta">(${esc(loc.note)})</span>` : ""}`
|
||||||
|
: `<span class="meta">Unshelved.</span>`}</p>
|
||||||
|
<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>
|
const boxed = `<h2>Where it lives</h2>
|
||||||
<div class="card prose">
|
<div class="card prose">
|
||||||
|
${locBlock}
|
||||||
${g.stored_in_game
|
${g.stored_in_game
|
||||||
? `<p>This one has no box of its own — it lives inside
|
? `<p class="meta">This game has no box of its own — it lives inside
|
||||||
<a href="/library/game/${encodeURIComponent(g.stored_in_game.key)}">
|
${esc(g.stored_in_game.name)}'s.
|
||||||
${esc(g.stored_in_game.name)}</a>.
|
|
||||||
<button class="linkish" id="editstored">change</button></p>`
|
<button class="linkish" id="editstored">change</button></p>`
|
||||||
: g.stored_in
|
: g.stored_in
|
||||||
? `<p class="meta">Stored inside BGG game ${esc(g.stored_in)}
|
? `<p class="meta">Stored inside BGG game ${esc(g.stored_in)}
|
||||||
(not in the library yet — run <b>enrich</b>).
|
(not in the library yet — run <b>enrich</b>).
|
||||||
<button class="linkish" id="editstored">change</button></p>`
|
<button class="linkish" id="editstored">change</button></p>`
|
||||||
: `<p class="meta">In its own box on a shelf.
|
: `<p class="meta">In its own box.
|
||||||
<button class="linkish" id="editstored">it lives inside another box…</button></p>`}
|
<button class="linkish" id="editstored">it lives inside another box…</button></p>`}
|
||||||
<p id="storedform" hidden>
|
<p id="storedform" hidden>
|
||||||
<select id="storedpick"><option value="">its own box</option></select>
|
<select id="storedpick"><option value="">its own box</option></select>
|
||||||
@@ -240,6 +277,7 @@ function render(g) {
|
|||||||
<div class="card prose"><p class="gdesc">${esc(decodeEntities(g.description))}</p></div>` : ""}`;
|
<div class="card prose"><p class="gdesc">${esc(decodeEntities(g.description))}</p></div>` : ""}`;
|
||||||
wireLocal(g);
|
wireLocal(g);
|
||||||
wireStored(g);
|
wireStored(g);
|
||||||
|
wireLocation(g);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
<h1>Shelves</h1>
|
||||||
|
<div class="pagebar"><span id="shelfcount"></span>
|
||||||
|
<button id="addunit" aria-expanded="false">add a unit</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="unitform" class="editform card" hidden>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<span class="editactions">
|
||||||
|
<button type="submit" class="primary">create</button>
|
||||||
|
<button type="button" id="unitcancel">cancel</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">
|
||||||
|
<div class="sheethead">
|
||||||
|
<b id="sheettitle"></b>
|
||||||
|
<button id="sheetclose" aria-label="close">✕</button>
|
||||||
|
</div>
|
||||||
|
<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>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">↑</button>
|
||||||
|
<button type="button" id="openingdown">↓</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 pct = Math.min(100, Math.round((o.fill || 0) * 100));
|
||||||
|
return `<div class="fillbar" role="img"
|
||||||
|
aria-label="${pct}% full${o.unmeasured ? `, ${o.unmeasured} unmeasured` : ""}">
|
||||||
|
<div style="width:${pct}%" class="${o.overfull ? "over" : ""}"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openingCell(o) {
|
||||||
|
const warn = o.overfull || o.misfits.length
|
||||||
|
? `<span class="chip no" title="${esc(o.overfull ? "stacked past the interior height" : "")
|
||||||
|
}${esc(o.misfits.length ? " doesn't fit: " + o.misfits.join(", ") : "")}">⚠</span>`
|
||||||
|
: "";
|
||||||
|
return `<button class="opening" data-id="${esc(o.id)}">
|
||||||
|
<span class="olabel">${esc(o.label)}${warn}</span>
|
||||||
|
${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""}
|
||||||
|
<span class="ocount">${o.games || "—"}</span>
|
||||||
|
${fillBar(o)}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitBlock(u) {
|
||||||
|
const cols = Math.max(1, Math.round(Math.sqrt(u.openings.length)));
|
||||||
|
return `<section class="card unit">
|
||||||
|
<div class="unithead"><h2>${esc(u.name)}</h2>
|
||||||
|
<span class="editactions">
|
||||||
|
<button class="addopening" data-unit="${esc(u.name)}">add opening</button>
|
||||||
|
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
|
||||||
|
</span></div>
|
||||||
|
<div class="openings">${u.openings.map(openingCell).join("")}</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(id => {
|
||||||
|
const o = openingById(id);
|
||||||
|
return o ? `<button class="suggest" data-key="${esc(g.key)}" data-id="${esc(id)}"
|
||||||
|
title="fits, with room to spare">→ ${esc(o.unit)} · ${esc(o.label)}</button>` : "";
|
||||||
|
}).join("");
|
||||||
|
return `<div class="unshelved-row">
|
||||||
|
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims}
|
||||||
|
${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""}
|
||||||
|
<span class="gos">${gos}</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"><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)` : "");
|
||||||
|
document.getElementById("sheetgames").innerHTML = o.resident_games.length
|
||||||
|
? o.resident_games.map(g => `
|
||||||
|
<div class="sheetrow">
|
||||||
|
${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""}
|
||||||
|
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>
|
||||||
|
${g.inherited
|
||||||
|
? `<span class="meta">inside another box here</span>`
|
||||||
|
: `<button class="unassign" data-key="${esc(g.key)}">remove</button>`}
|
||||||
|
</div>`).join("")
|
||||||
|
: `<p class="meta">Nothing here yet.</p>`;
|
||||||
|
const f = document.getElementById("openingform");
|
||||||
|
f.elements.label.value = o.label;
|
||||||
|
f.elements.zone.value = o.zone || "";
|
||||||
|
f.elements.width_in.value = o.width_in ?? "";
|
||||||
|
f.elements.height_in.value = o.height_in ?? "";
|
||||||
|
f.elements.depth_in.value = o.depth_in ?? "";
|
||||||
|
document.getElementById("sheetsearch").value = "";
|
||||||
|
document.getElementById("sheetmatches").innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSheet(id) {
|
||||||
|
OPEN = id;
|
||||||
|
document.getElementById("opensheet").hidden = false;
|
||||||
|
sheetRender();
|
||||||
|
document.getElementById("sheetsearch").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
SHELVES = await fetchJSON("/api/shelves");
|
||||||
|
if (!LIBRARY.length) LIBRARY = await fetchJSON("/api/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(".addopening").forEach(b =>
|
||||||
|
b.onclick = () => {
|
||||||
|
const label = prompt("Label for the new opening (e.g. wide top left):");
|
||||||
|
if (!label) return;
|
||||||
|
const w = prompt("Interior width in inches (blank = no size limits):");
|
||||||
|
const h = w ? prompt("Interior height in inches:") : "";
|
||||||
|
const d = w ? prompt("Interior depth in inches:") : "";
|
||||||
|
post("/api/furniture/add-openings", {
|
||||||
|
unit: b.dataset.unit, label,
|
||||||
|
width_in: parseFloat(w) || null, height_in: parseFloat(h) || null,
|
||||||
|
depth_in: parseFloat(d) || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- the sheet ----------------------------------------------------------
|
||||||
|
document.getElementById("sheetclose").addEventListener("click", () => {
|
||||||
|
document.getElementById("opensheet").hidden = true;
|
||||||
|
OPEN = null;
|
||||||
|
});
|
||||||
|
document.getElementById("opensheet").addEventListener("click", e => {
|
||||||
|
if (e.target.id === "opensheet") {
|
||||||
|
document.getElementById("opensheet").hidden = true;
|
||||||
|
OPEN = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("sheetgames").addEventListener("click", e => {
|
||||||
|
const b = e.target.closest(".unassign");
|
||||||
|
if (b) post("/api/locate", {key: b.dataset.key, opening_id: ""});
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
post("/api/furniture/edit-opening", {
|
||||||
|
id: OPEN, label: f.label.value, zone: f.zone.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,
|
||||||
|
clear_dims: !f.width_in.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
OPEN = null;
|
||||||
|
document.getElementById("opensheet").hidden = true;
|
||||||
|
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");
|
||||||
|
const addUnitBtn = document.getElementById("addunit");
|
||||||
|
addUnitBtn.addEventListener("click", () => {
|
||||||
|
unitForm.hidden = !unitForm.hidden;
|
||||||
|
addUnitBtn.setAttribute("aria-expanded", String(!unitForm.hidden));
|
||||||
|
if (!unitForm.hidden) unitForm.elements.name.focus();
|
||||||
|
});
|
||||||
|
document.getElementById("unitcancel").addEventListener("click", () => {
|
||||||
|
unitForm.hidden = true;
|
||||||
|
unitForm.reset();
|
||||||
|
});
|
||||||
|
unitForm.elements.preset.addEventListener("change", () => {
|
||||||
|
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.addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
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 || {};
|
||||||
|
const made = await post("/api/furniture/add-unit", {name: f.name.value});
|
||||||
|
if (!made) return;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
unitForm.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh().catch(err => errorBanner(err.message || err));
|
||||||
|
pollLoop(refresh, 12000, () => showBanner(""));
|
||||||
|
</script>
|
||||||
@@ -62,6 +62,18 @@ from bggpipe.models import (
|
|||||||
from bggpipe.normalize import normalize_title
|
from bggpipe.normalize import normalize_title
|
||||||
from bggpipe.resolve import MATCH_COLUMNS, TitleEntry, read_matches, write_matches
|
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 (
|
||||||
|
effective_location,
|
||||||
|
fits_opening,
|
||||||
|
game_dims,
|
||||||
|
load_furniture,
|
||||||
|
load_locations,
|
||||||
|
new_opening_id,
|
||||||
|
opening_index,
|
||||||
|
opening_report,
|
||||||
|
save_furniture,
|
||||||
|
save_locations,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_thumbnails(cache_dir: Path) -> dict[int, str]:
|
def load_thumbnails(cache_dir: Path) -> dict[int, str]:
|
||||||
@@ -211,6 +223,48 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
class OpeningsBody(BaseModel):
|
||||||
|
"""One opening, or a rows × cols grid of identical ones."""
|
||||||
|
|
||||||
|
unit: str
|
||||||
|
zone: str = ""
|
||||||
|
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
|
||||||
|
width_in: float | None = None
|
||||||
|
height_in: float | None = None
|
||||||
|
depth_in: float | None = None
|
||||||
|
clear_dims: bool = False # explicit: make it a virtual location
|
||||||
|
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
|
||||||
|
|
||||||
class StoredInBody(BaseModel):
|
class StoredInBody(BaseModel):
|
||||||
key: str # games.json key of the game being housed
|
key: str # games.json key of the game being housed
|
||||||
container: str = "" # container's bgg_id; "" = its own box again
|
container: str = "" # container's bgg_id; "" = its own box again
|
||||||
@@ -291,6 +345,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", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -788,6 +843,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")
|
||||||
@@ -865,13 +924,36 @@ 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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -966,6 +1048,256 @@ 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_state() -> dict:
|
||||||
|
"""Units with per-opening capacity + resident games, the unshelved
|
||||||
|
list with fit-checked suggestions, and virtual locations."""
|
||||||
|
games = read_games()
|
||||||
|
units = load_furniture(cfg)
|
||||||
|
locations = load_locations(cfg)
|
||||||
|
openings = opening_index(units)
|
||||||
|
residents: dict[str, list[tuple[str, dict]]] = {}
|
||||||
|
located_keys: set[str] = set()
|
||||||
|
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)
|
||||||
|
elif via is not None:
|
||||||
|
located_keys.add(key) # inherits; container is unshelved
|
||||||
|
out_units = []
|
||||||
|
for unit in units:
|
||||||
|
out_openings = []
|
||||||
|
for opening in unit.get("openings", []):
|
||||||
|
here = residents.get(opening["id"], [])
|
||||||
|
report = opening_report(opening, here)
|
||||||
|
out_openings.append(
|
||||||
|
{
|
||||||
|
**opening,
|
||||||
|
**report,
|
||||||
|
"resident_games": [
|
||||||
|
{
|
||||||
|
"key": k,
|
||||||
|
"name": e.get("name"),
|
||||||
|
"thumbnail": e.get("thumbnail") or "",
|
||||||
|
"inherited": bool(e.get("stored_in")),
|
||||||
|
}
|
||||||
|
for k, e in sorted(
|
||||||
|
here,
|
||||||
|
key=lambda p: (p[1].get("name") or "").casefold(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out_units.append({"name": unit["name"], "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
|
||||||
|
if (locations.get(key) or {}).get("opening_id"):
|
||||||
|
continue # assigned to an opening that no longer exists? keep out
|
||||||
|
note = (locations.get(key) or {}).get("note", "")
|
||||||
|
suggestions = [
|
||||||
|
oid
|
||||||
|
for oid, opening in openings.items()
|
||||||
|
if opening.get("width_in")
|
||||||
|
and fits_opening(entry, opening) is True
|
||||||
|
and not opening_report(opening, residents.get(oid, []))["overfull"]
|
||||||
|
][:3]
|
||||||
|
unshelved.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"name": entry.get("name"),
|
||||||
|
"thumbnail": entry.get("thumbnail") or "",
|
||||||
|
"note": note,
|
||||||
|
"measured": game_dims(entry) is not None,
|
||||||
|
"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:
|
||||||
|
with lock:
|
||||||
|
revision["n"] += 1
|
||||||
|
units = load_furniture(cfg)
|
||||||
|
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, "openings": []})
|
||||||
|
|
||||||
|
return _mutate_furniture(add)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
}
|
||||||
|
if body.rows and body.cols:
|
||||||
|
for r in range(body.rows):
|
||||||
|
for c in range(body.cols):
|
||||||
|
label = f"{chr(65 + r)}{c + 1}"
|
||||||
|
unit["openings"].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")
|
||||||
|
unit["openings"].append(
|
||||||
|
{
|
||||||
|
"id": new_opening_id(units, unit["name"], label),
|
||||||
|
"label": label,
|
||||||
|
"zone": body.zone.strip(),
|
||||||
|
**dims,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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.clear_dims:
|
||||||
|
real.update(width_in=None, height_in=None, depth_in=None)
|
||||||
|
else:
|
||||||
|
for field in ("width_in", "height_in", "depth_in"):
|
||||||
|
value = getattr(body, field)
|
||||||
|
if value is not None:
|
||||||
|
real[field] = value
|
||||||
|
|
||||||
|
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:
|
||||||
|
def move(units):
|
||||||
|
for unit in units:
|
||||||
|
ids = [o["id"] for o in unit["openings"]]
|
||||||
|
if body.id in ids:
|
||||||
|
i = ids.index(body.id)
|
||||||
|
j = i + (1 if body.direction > 0 else -1)
|
||||||
|
if 0 <= j < len(unit["openings"]):
|
||||||
|
ops = unit["openings"]
|
||||||
|
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")
|
||||||
|
if opening_id or body.note.strip():
|
||||||
|
locations[body.key] = {
|
||||||
|
"opening_id": opening_id or None,
|
||||||
|
"note": body.note.strip(),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
locations.pop(body.key, None)
|
||||||
|
save_locations(cfg, locations)
|
||||||
|
return _shelf_state()
|
||||||
|
|
||||||
@app.post("/api/stored-in")
|
@app.post("/api/stored-in")
|
||||||
def api_stored_in(body: StoredInBody) -> dict:
|
def api_stored_in(body: StoredInBody) -> dict:
|
||||||
"""Declare (or clear) which box an already-cataloged game lives
|
"""Declare (or clear) which box an already-cataloged game lives
|
||||||
@@ -1049,6 +1381,28 @@ def create_app(
|
|||||||
)
|
)
|
||||||
if contents:
|
if contents:
|
||||||
game = {**game, "contains": 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
|
||||||
|
|||||||
+38
-17
@@ -8,19 +8,8 @@ import pytest
|
|||||||
import typer
|
import typer
|
||||||
|
|
||||||
from bggpipe.config import Config
|
from bggpipe.config import Config
|
||||||
from bggpipe.dims import fits_kallax, run_dims_report
|
from bggpipe.dims import run_dims_report
|
||||||
|
from bggpipe.shelves import save_furniture, save_locations
|
||||||
|
|
||||||
def test_fits_kallax_tries_every_orientation():
|
|
||||||
assert fits_kallax(11.6, 11.6, 2.8) # ordinary big-box
|
|
||||||
# too tall to stand, but slides in lying down: depth axis takes 15.0
|
|
||||||
assert fits_kallax(15.0, 12.0, 3.0)
|
|
||||||
# two axes over the opening: no orientation works
|
|
||||||
assert not fits_kallax(16.2, 16.2, 4.0)
|
|
||||||
# fits the opening but too deep to close the wall behind it
|
|
||||||
assert not fits_kallax(12.0, 12.0, 15.5)
|
|
||||||
# exact boundary counts as fitting
|
|
||||||
assert fits_kallax(13.2, 13.2, 15.4)
|
|
||||||
|
|
||||||
|
|
||||||
def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
|
def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
|
||||||
@@ -63,6 +52,7 @@ def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# no furniture yet: sizes-only report, pointed at the Shelves page
|
||||||
summary = run_dims_report(cfg)
|
summary = run_dims_report(cfg)
|
||||||
assert summary["by_source"] == {
|
assert summary["by_source"] == {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -70,12 +60,43 @@ def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
|
|||||||
"conflicting": 1,
|
"conflicting": 1,
|
||||||
"absent": 1,
|
"absent": 1,
|
||||||
}
|
}
|
||||||
assert summary["misfits"] == ["Monster Box"]
|
assert summary["misfits"] == []
|
||||||
# can't verify is NOT the same as fits: both unknowns are named
|
|
||||||
assert summary["unknown"] == ["Argued About", "Never Measured"]
|
assert summary["unknown"] == ["Argued About", "Never Measured"]
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "do NOT fit" in out and "Monster Box" in out
|
assert "No furniture on file" in out
|
||||||
assert "can't be verified" in out and "Never Measured" 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 "doesn't fit: Monster Box" in out
|
||||||
|
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):
|
def test_report_without_games_json_exits_with_guidance(tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
"""The location layer: stores, fit epistemics, inheritance, CSV import."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from bggpipe.config import Config
|
||||||
|
from bggpipe.shelves import (
|
||||||
|
effective_location,
|
||||||
|
fits_opening,
|
||||||
|
import_assignments,
|
||||||
|
load_furniture,
|
||||||
|
new_opening_id,
|
||||||
|
opening_report,
|
||||||
|
save_furniture,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(name="Game", w=11.6, length=11.6, d=2.8, **extra):
|
||||||
|
dims = (
|
||||||
|
{"width_in": w, "length_in": length, "depth_in": d, "source": "version"}
|
||||||
|
if w
|
||||||
|
else {"width_in": None, "length_in": None, "depth_in": None, "source": "absent"}
|
||||||
|
)
|
||||||
|
return {"name": name, "dims": dims, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
KALLAX = {
|
||||||
|
"id": "k1",
|
||||||
|
"label": "A1",
|
||||||
|
"zone": "party",
|
||||||
|
"width_in": 13.25,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
}
|
||||||
|
DOUBLE_WIDE = {
|
||||||
|
"id": "dw",
|
||||||
|
"label": "wide top",
|
||||||
|
"zone": "big boxes",
|
||||||
|
"width_in": 26.5,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
}
|
||||||
|
TRAVEL = {"id": "travel", "label": "travel case", "zone": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fits_opening_speaks_the_house_language():
|
||||||
|
assert fits_opening(_entry(), KALLAX) is True
|
||||||
|
# 17" box: fails the cube, fits the double-wide — the acceptance case
|
||||||
|
big = _entry("Bugs in the Kitchen", 11.5, 17, 2.25)
|
||||||
|
assert fits_opening(big, KALLAX) is False
|
||||||
|
assert fits_opening(big, DOUBLE_WIDE) is True
|
||||||
|
# no game dims -> can't verify, never "fits"
|
||||||
|
assert fits_opening(_entry("Mystery", w=None), KALLAX) is None
|
||||||
|
# a virtual location imposes no limits
|
||||||
|
assert fits_opening(big, TRAVEL) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_opening_report_counts_stack_and_warns():
|
||||||
|
thin = _entry("Thin", 11, 11, 2)
|
||||||
|
thick = _entry("Thick", 11, 11, 4)
|
||||||
|
unmeasured = _entry("Mystery", w=None)
|
||||||
|
report = opening_report(KALLAX, [("a", thin), ("b", thick), ("c", unmeasured)])
|
||||||
|
assert report["games"] == 3
|
||||||
|
assert report["stacked_in"] == 6
|
||||||
|
assert report["unmeasured"] == 1
|
||||||
|
assert report["overfull"] is False
|
||||||
|
# seven thick boxes overflow a 13.25" interior
|
||||||
|
stack = [(str(i), thick) for i in range(7)]
|
||||||
|
assert opening_report(KALLAX, stack)["overfull"] is True
|
||||||
|
# a misfit is named even when the stack has room
|
||||||
|
report = opening_report(KALLAX, [("a", _entry("Too Long", 11.5, 17, 2.25))])
|
||||||
|
assert report["misfits"] == ["Too Long"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_location_inherits_from_container():
|
||||||
|
games = {
|
||||||
|
"173634": _entry("Trove", bgg_id=173634),
|
||||||
|
"999": _entry("Witchdoctor", bgg_id=999, stored_in="173634"),
|
||||||
|
"13": _entry("Catan", bgg_id=13),
|
||||||
|
}
|
||||||
|
locations = {"173634": {"opening_id": "k1", "note": "top shelf"}}
|
||||||
|
oid, note, via = effective_location("999", games["999"], games, locations)
|
||||||
|
assert (oid, note, via) == ("k1", "top shelf", "173634")
|
||||||
|
# the container itself reads its own record
|
||||||
|
assert effective_location("173634", games["173634"], games, locations)[2] is None
|
||||||
|
# unassigned, uncontained: unshelved
|
||||||
|
assert effective_location("13", games["13"], games, locations) == (None, "", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_opening_ids_are_stable_and_unique():
|
||||||
|
units = [
|
||||||
|
{
|
||||||
|
"name": "Den Kallax",
|
||||||
|
"openings": [{"id": "den-kallax-a1", "label": "A1", "zone": ""}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert new_opening_id(units, "Den Kallax", "A2") == "den-kallax-a2"
|
||||||
|
assert new_opening_id(units, "Den Kallax", "A1") == "den-kallax-a1-2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_furniture_round_trip_and_corrupt_store(tmp_path):
|
||||||
|
cfg = Config(data_dir=tmp_path / "data")
|
||||||
|
cfg.data_dir.mkdir(parents=True)
|
||||||
|
units = [{"name": "Den", "openings": [KALLAX, DOUBLE_WIDE]}]
|
||||||
|
save_furniture(cfg, units)
|
||||||
|
assert load_furniture(cfg) == units
|
||||||
|
cfg.furniture_path.write_text("{torn")
|
||||||
|
with pytest.raises(ValueError, match="furniture.json is corrupt"):
|
||||||
|
load_furniture(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _import_cfg(tmp_path):
|
||||||
|
cfg = Config(data_dir=tmp_path / "data")
|
||||||
|
cfg.data_dir.mkdir(parents=True)
|
||||||
|
cfg.games_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"1": _entry("Catan", 11.6, 11.6, 3, bgg_id=1),
|
||||||
|
"2": _entry("Bugs in the Kitchen", 11.5, 17, 2.25, bgg_id=2),
|
||||||
|
"3": _entry("Witchdoctor", 8, 8, 2, bgg_id=3, stored_in="1"),
|
||||||
|
"4a": _entry("Twin", 8, 8, 2, bgg_id=40),
|
||||||
|
"4b": _entry("Twin", 8, 8, 2, bgg_id=41),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
save_furniture(
|
||||||
|
cfg,
|
||||||
|
[
|
||||||
|
{"name": "Den Kallax", "openings": [KALLAX]},
|
||||||
|
{"name": "Loft", "openings": [DOUBLE_WIDE, TRAVEL]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_import_assigns_and_rejects_honestly(tmp_path, capsys):
|
||||||
|
cfg = _import_cfg(tmp_path)
|
||||||
|
plan = tmp_path / "plan.csv"
|
||||||
|
plan.write_text(
|
||||||
|
"name,opening\n"
|
||||||
|
"Catan,A1\n" # by label
|
||||||
|
"Bugs in the Kitchen,dw\n" # by id, needs the double-wide
|
||||||
|
"Bugs in the Kitchen,A1\n" # doesn't fit the cube: reject
|
||||||
|
"Witchdoctor,A1\n" # contained: reject
|
||||||
|
"Twin,A1\n" # two copies: reject
|
||||||
|
"Ghost Game,A1\n" # unknown name: reject
|
||||||
|
"Catan,Z9\n" # unknown opening: reject
|
||||||
|
)
|
||||||
|
result = import_assignments(cfg, plan)
|
||||||
|
assert result["assigned"] == 2
|
||||||
|
assert len(result["rejects"]) == 5
|
||||||
|
saved = json.loads(cfg.locations_path.read_text())
|
||||||
|
assert saved["1"]["opening_id"] == "k1"
|
||||||
|
assert saved["2"]["opening_id"] == "dw"
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "doesn't fit" in out and "lives inside another box" in out
|
||||||
|
assert "matches 2 copies" in out and "no game by that name" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_without_furniture_exits_with_guidance(tmp_path):
|
||||||
|
cfg = Config(data_dir=tmp_path / "data")
|
||||||
|
cfg.data_dir.mkdir(parents=True)
|
||||||
|
cfg.games_path.write_text("{}")
|
||||||
|
plan = tmp_path / "plan.csv"
|
||||||
|
plan.write_text("name,opening\n")
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
import_assignments(cfg, plan)
|
||||||
|
|
||||||
|
|
||||||
|
# -- web layer ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _web(tmp_path, games):
|
||||||
|
import httpx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from bggpipe.bgg_client import BGGClient
|
||||||
|
from bggpipe.resolve import write_matches
|
||||||
|
from bggpipe.webreview import create_app
|
||||||
|
|
||||||
|
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
||||||
|
cfg.photos_dir.mkdir(parents=True)
|
||||||
|
cfg.data_dir.mkdir(parents=True)
|
||||||
|
write_matches(cfg.matches_path, [])
|
||||||
|
cfg.games_path.write_text(json.dumps(games))
|
||||||
|
client = BGGClient(
|
||||||
|
cache_dir=tmp_path / "no_cache",
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda req: httpx.Response(401, text="Unauthorized")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return TestClient(create_app(cfg, client=client)), cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_full_furniture_flow_without_touching_json(tmp_path):
|
||||||
|
"""The spec's bar: two double-wide openings above three rows of four
|
||||||
|
cubes, plus two bookcases of shelves, plus a travel case — expressed
|
||||||
|
entirely through the endpoints the UI calls."""
|
||||||
|
web, cfg = _web(tmp_path, {})
|
||||||
|
|
||||||
|
assert (
|
||||||
|
web.post("/api/furniture/add-unit", json={"name": "Den wall"}).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
# two double-wides, one at a time
|
||||||
|
for label in ("wide left", "wide right"):
|
||||||
|
web.post(
|
||||||
|
"/api/furniture/add-openings",
|
||||||
|
json={
|
||||||
|
"unit": "Den wall",
|
||||||
|
"label": label,
|
||||||
|
"zone": "big boxes",
|
||||||
|
"width_in": 26.5,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# three rows of four cubes as a grid
|
||||||
|
web.post(
|
||||||
|
"/api/furniture/add-openings",
|
||||||
|
json={
|
||||||
|
"unit": "Den wall",
|
||||||
|
"rows": 3,
|
||||||
|
"cols": 4,
|
||||||
|
"zone": "party",
|
||||||
|
"width_in": 13.25,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for name in ("Bookcase north", "Bookcase south"):
|
||||||
|
web.post("/api/furniture/add-unit", json={"name": name})
|
||||||
|
web.post(
|
||||||
|
"/api/furniture/add-openings",
|
||||||
|
json={
|
||||||
|
"unit": name,
|
||||||
|
"rows": 5,
|
||||||
|
"cols": 1,
|
||||||
|
"zone": "long games",
|
||||||
|
"width_in": 30.75,
|
||||||
|
"height_in": 13,
|
||||||
|
"depth_in": 11,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
web.post("/api/furniture/add-unit", json={"name": "Travel"})
|
||||||
|
web.post(
|
||||||
|
"/api/furniture/add-openings",
|
||||||
|
json={
|
||||||
|
"unit": "Travel",
|
||||||
|
"label": "travel case",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
state = web.get("/api/shelves").json()
|
||||||
|
counts = {u["name"]: len(u["openings"]) for u in state["units"]}
|
||||||
|
assert counts == {
|
||||||
|
"Den wall": 14,
|
||||||
|
"Bookcase north": 5,
|
||||||
|
"Bookcase south": 5,
|
||||||
|
"Travel": 1,
|
||||||
|
}
|
||||||
|
# the store round-trips as plain committed JSON
|
||||||
|
saved = json.loads(cfg.furniture_path.read_text())["units"]
|
||||||
|
assert saved[0]["openings"][0]["label"] == "wide left"
|
||||||
|
assert saved[-1]["openings"][0].get("width_in") is None # virtual
|
||||||
|
|
||||||
|
# editable, deletable, reorderable
|
||||||
|
first_cube = saved[0]["openings"][2]["id"]
|
||||||
|
web.post("/api/furniture/edit-opening", json={"id": first_cube, "zone": "kids"})
|
||||||
|
web.post("/api/furniture/move-opening", json={"id": first_cube, "direction": -1})
|
||||||
|
web.post(
|
||||||
|
"/api/furniture/delete-opening", json={"id": saved[0]["openings"][3]["id"]}
|
||||||
|
)
|
||||||
|
saved2 = json.loads(cfg.furniture_path.read_text())["units"]
|
||||||
|
assert len(saved2[0]["openings"]) == 13
|
||||||
|
assert saved2[0]["openings"][1]["id"] == first_cube # moved up one
|
||||||
|
|
||||||
|
|
||||||
|
def test_locate_flow_and_stored_in_refusal(tmp_path):
|
||||||
|
games = {
|
||||||
|
"1": {
|
||||||
|
"bgg_id": 1,
|
||||||
|
"name": "Trove",
|
||||||
|
"dims": {
|
||||||
|
"width_in": 12,
|
||||||
|
"length_in": 12,
|
||||||
|
"depth_in": 4,
|
||||||
|
"source": "version",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"2": {"bgg_id": 2, "name": "Witchdoctor", "stored_in": "1"},
|
||||||
|
"3": {
|
||||||
|
"bgg_id": 3,
|
||||||
|
"name": "Longboi",
|
||||||
|
"dims": {
|
||||||
|
"width_in": 11.5,
|
||||||
|
"length_in": 17,
|
||||||
|
"depth_in": 2.25,
|
||||||
|
"source": "version",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
web, cfg = _web(tmp_path, games)
|
||||||
|
web.post("/api/furniture/add-unit", json={"name": "Den"})
|
||||||
|
web.post(
|
||||||
|
"/api/furniture/add-openings",
|
||||||
|
json={
|
||||||
|
"unit": "Den",
|
||||||
|
"rows": 1,
|
||||||
|
"cols": 2,
|
||||||
|
"zone": "",
|
||||||
|
"width_in": 13.25,
|
||||||
|
"height_in": 13.25,
|
||||||
|
"depth_in": 15.4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
|
||||||
|
|
||||||
|
# a contained game refuses direct assignment, naming its container
|
||||||
|
res = web.post("/api/locate", json={"key": "2", "opening_id": opening})
|
||||||
|
assert res.status_code == 409 and "Trove" in res.json()["detail"]
|
||||||
|
|
||||||
|
# the container shelves; its contents ride along into the opening
|
||||||
|
assert (
|
||||||
|
web.post("/api/locate", json={"key": "1", "opening_id": opening}).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
state = web.get("/api/shelves").json()
|
||||||
|
resident = state["units"][0]["openings"][0]["resident_games"]
|
||||||
|
assert {g["name"] for g in resident} == {"Trove", "Witchdoctor"}
|
||||||
|
assert next(g for g in resident if g["name"] == "Witchdoctor")["inherited"]
|
||||||
|
|
||||||
|
# suggestions never offer an opening the game can't fit
|
||||||
|
unshelved = state["unshelved"]
|
||||||
|
longboi = next(g for g in unshelved if g["name"] == "Longboi")
|
||||||
|
assert longboi["suggestions"] == [] # 17" beats every 13.25" cube
|
||||||
|
|
||||||
|
# the library list knows where everything lives
|
||||||
|
lib = {g["name"]: g for g in web.get("/api/library").json()}
|
||||||
|
assert lib["Trove"]["location"]["text"] == "Den · A1"
|
||||||
|
assert lib["Witchdoctor"]["location"]["via"] == "1"
|
||||||
|
assert lib["Longboi"]["location"]["text"] == ""
|
||||||
|
|
||||||
|
# a virtual note without an opening reads as the location
|
||||||
|
web.post("/api/locate", json={"key": "3", "note": "lent to Sarah, June"})
|
||||||
|
lib = {g["name"]: g for g in web.get("/api/library").json()}
|
||||||
|
assert lib["Longboi"]["location"]["text"] == "lent to Sarah, June"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shelves_page_serves_with_phone_sheet(tmp_path):
|
||||||
|
"""Phone smoke: the page serves, and the template carries the
|
||||||
|
bottom-sheet structure the mobile styles dock to the thumb."""
|
||||||
|
web, _ = _web(tmp_path, {})
|
||||||
|
html = web.get("/shelves").text
|
||||||
|
assert 'id="opensheet"' in html and 'id="sheetsearch"' in html
|
||||||
|
assert 'href="/shelves" aria-current="page"' in html
|
||||||
|
css = web.get("/static/app.css").text
|
||||||
|
assert "max-width: 900px" in css and ".sheetcard" in css
|
||||||
@@ -210,7 +210,16 @@ def test_stylesheet_is_served_and_linked_by_every_page(tmp_path):
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user