diff --git a/CLAUDE.md b/CLAUDE.md index 16d5b25..206c8a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,14 +20,14 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel ## Commands - `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`). -- `uv run bggpipe web` — the full pipeline as a local web app (dashboard at `/`, review at `/review`); stage runs execute one-at-a-time in a background job. +- `uv run bggpipe web` — the app: six pages (Pipeline `/`, Photos, Review, Catalog, Queue, Library) in a shared sidebar shell; stage runs execute one-at-a-time in a background job. - `uv run bggpipe ` — run a pipeline stage. Non-secret settings come from `config.toml` (username, dirs, vision model, rate limit); `--config` overrides the path. - `uv run pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`. - `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format. ## Layout -- `src/bggpipe/` — `cli.py` (typer app), one module per stage (`extract`, `resolve`, `review` + `webreview`, `diff`, `upload`, `enrich`); `templates/` + `static/app.css` are the web UI (one shared stylesheet is the design system — its tokens derive from the mascot art), plus `bgg_client.py` (rate-limited XML API2 client that caches responses to `data/bgg_cache/`), `jobs.py` (single-slot background stage runner for the web UI), `normalize.py` (title normalization), `models.py` (dataclasses), `config.py`, `fsio.py` (atomic writes), `init_wizard.py` (first-run setup). +- `src/bggpipe/` — `cli.py` (typer app), one module per stage (`extract`, `resolve`, `review` + `webreview`, `diff`, `upload`, `enrich`); `templates/shell.html` + `templates/pages/*` + `static/app.{css,js}` are the web UI (the stylesheet is the design system — tokens derive from the mascot art), plus `bgg_client.py` (rate-limited XML API2 client that caches responses to `data/bgg_cache/`), `jobs.py` (single-slot background stage runner for the web UI), `normalize.py` (title normalization), `models.py` (dataclasses), `config.py`, `fsio.py` (atomic writes), `init_wizard.py` (first-run setup). - `scripts/` — `write_stub_fixtures.py` / `write_photo_fixtures.py` generate synthetic fixtures; `record_fixtures.py` re-records real API responses once a token exists. - `tests/fixtures/bgg_cache/` — stub XML fixtures the offline tests run against. - `data/` — pipeline state (CSV/JSON artifacts are committed; caches are not — see Git). diff --git a/README.md b/README.md index 9ef3e13..c063618 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,10 @@ Secrets live in environment variables only, never in config files, code, or logs Non-secret knobs (`photos_dir`, `data_dir`, the vision model, the rate limit) live in `config.toml`. From here you can drive everything from the browser: ```sh -uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole pipeline in one page +uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser ``` -The dashboard shows every stage's status, takes photos by drag-and-drop, runs each stage with live output, links to the review page, and keeps the real upload behind a confirmation (and behind the stub-data lock). Prefer the terminal? Every stage is also a command, and the two interfaces share all state: +Six pages in one local app: **Pipeline** (run stages, watch live output), **Photos** (drag-and-drop upload, gallery, reshoot tickets), **Review** (keyboard-first match and edition decisions), **Catalog** (every extracted title and its status), **Queue** (exactly what upload will do, plus its full log), and **Library** (your enriched collection, browsable once real BGG data lands). The real upload sits behind a confirmation and behind the stub-data lock. Prefer the terminal? Every stage is also a command, and the two interfaces share all state: ```sh uv run bggpipe extract # photos → titles.json (+ retake prompts) @@ -115,4 +115,7 @@ This tool is **not affiliated with or supported by BoardGameGeek**. It uses only MIT — see [LICENSE](LICENSE). +BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an +independent project, not affiliated with or endorsed by BoardGameGeek. + Mascot art by Juniper, used with pride. diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index c241a87..7ad6db1 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -33,6 +33,8 @@ --focus: #8330c2; --path: linear-gradient(90deg, #f767b8, #f79a3e, #f2c04b, #6fce6f, #5aa7f0, #9a5be0); + --path-v: linear-gradient(180deg, + #f767b8, #f79a3e, #f2c04b, #6fce6f, #5aa7f0, #9a5be0); --font-display: ui-rounded, "Hiragino Maru Gothic ProN", "Arial Rounded MT Bold", var(--font-body); --font-body: system-ui, -apple-system, "Segoe UI", sans-serif; --font-mono: ui-monospace, "SF Mono", Menlo, monospace; @@ -65,17 +67,21 @@ h2 { h2 .count { color: var(--ink-soft); font-size: .85rem; font-weight: 400; } :focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } -/* -- header + navigation ---------------------------------------------- */ -header { - position: sticky; top: 0; z-index: 5; - background: var(--navy); - color: #fff; - padding: .45rem 1.2rem; - display: flex; align-items: center; gap: 1.2rem; flex-wrap: wrap; - border-bottom: 4px solid transparent; - border-image: var(--path) 1; +/* -- layout: sidebar rail + content column ----------------------------- */ +body { display: grid; grid-template-columns: 15.5rem minmax(0, 1fr); } +.sidebar { + position: sticky; top: 0; height: 100vh; + background: var(--navy); color: #fff; + display: flex; flex-direction: column; + border-right: 4px solid transparent; + /* the rainbow game path, running the rail top to bottom */ + border-image: var(--path-v) 1; +} +.brand { + display: flex; align-items: center; gap: .6rem; + text-decoration: none; color: inherit; + padding: .9rem 1rem .7rem; } -.brand { display: flex; align-items: center; gap: .6rem; text-decoration: none; color: inherit; } .brand img { width: 38px; height: 38px; border-radius: 50%; border: 2px solid var(--gold); object-fit: cover; display: block; @@ -85,27 +91,57 @@ header { font-family: var(--font-display); font-size: 1.3rem; font-weight: 700; letter-spacing: .01em; } -.wordmark small { opacity: .8; font-family: var(--font-body); font-weight: 400; font-size: .75rem; margin-left: .5rem; } -nav[aria-label="Primary"] { display: flex; gap: .9rem; font-size: .9rem; } +.wordmark small { display: block; opacity: .8; font-family: var(--font-body); font-weight: 400; font-size: .72rem; } +nav[aria-label="Primary"] { display: flex; flex-direction: column; padding: .4rem 0; } nav[aria-label="Primary"] a { color: #fff; text-decoration: none; opacity: .85; - padding: .1rem 0; border-bottom: 2px solid transparent; + padding: .45rem 1rem; + display: flex; align-items: center; gap: .6rem; + border-left: 4px solid transparent; + font-size: .95rem; } -nav[aria-label="Primary"] a:hover { opacity: 1; } +nav[aria-label="Primary"] a:hover { opacity: 1; background: rgba(255,255,255,.07); } nav[aria-label="Primary"] a[aria-current="page"] { - opacity: 1; border-bottom-color: var(--gold); font-weight: 600; + opacity: 1; border-left-color: var(--gold); font-weight: 600; + background: rgba(255,255,255,.1); } +.navbadge { + margin-left: auto; + font-size: .72rem; font-weight: 700; + background: var(--gold); color: var(--navy-deep); + border-radius: 999px; padding: .05rem .5rem; + min-width: 1.5em; text-align: center; +} +.navbadge:empty { display: none; } +.piperbox { margin-top: auto; padding: 1rem 1rem 1.1rem; text-align: center; } +.piperbox img { + width: 100%; max-width: 11.5rem; + border-radius: var(--radius-lg); + border: 2px solid var(--gold); + display: block; margin: 0 auto; +} +.piperbox figcaption { font-size: .72rem; opacity: .8; margin-top: .45rem; } +.piperbox .legal { font-size: .62rem; opacity: .6; line-height: 1.45; text-align: left; } .skip { position: absolute; left: -999px; top: 0; z-index: 10; background: var(--board); color: var(--ink); padding: .5rem 1rem; border-radius: 0 0 var(--radius) 0; } .skip:focus { left: 0; } -#tally { font-size: .85rem; opacity: .95; display: flex; gap: 1rem; flex-wrap: wrap; } -#tally b { color: var(--gold); font-weight: 700; } -#tally a { color: inherit; text-decoration: none; border-bottom: 1px dotted rgba(255,255,255,.5); } -#tally a:hover { border-bottom-color: var(--gold); } -.keyhelp { margin-left: auto; font-size: .75rem; opacity: .85; } + +/* per-page strip under the page title: section links, shortcuts, counts */ +.pagebar { + display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; + font-size: .85rem; color: var(--ink-soft); margin: -.4rem 0 1rem; +} +.pagebar a { color: inherit; text-decoration: underline dotted; text-underline-offset: 3px; } +.pagebar a:hover { color: var(--accent-ink); } +.pagebar b { color: var(--ink); } +.keyhelp { margin-left: auto; font-size: .75rem; } +h1 { + font-family: var(--font-display); font-weight: 700; + font-size: 1.45rem; margin: 1.2rem 0 .9rem; +} kbd { font-family: var(--font-mono); font-size: .72rem; @@ -117,7 +153,7 @@ kbd { padding: 0 .35em; display: inline-block; min-width: 1.4em; text-align: center; } -header kbd { background: rgba(255,255,255,.16); color: #fff; border-color: rgba(255,255,255,.3); } + /* -- banners ----------------------------------------------------------- */ #banner { max-width: 62rem; margin: 0 auto; padding: 0 1.2rem; } @@ -344,3 +380,66 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .card, .ticket { flex-direction: column; } .shots { flex-basis: auto; } } + +/* -- photos page: gallery + status ------------------------------------- */ +.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: .9rem; } +.shot { + background: var(--board); border: var(--line); border-radius: var(--radius); + box-shadow: var(--shadow-card); overflow: hidden; + display: flex; flex-direction: column; +} +.shot img { width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block; } +.shot .meta { padding: .4rem .6rem; font-size: .78rem; color: var(--ink-soft); } +.shot .meta b { color: var(--ink); } + +/* -- queue + library --------------------------------------------------- */ +.ledger { background: var(--board); border: var(--line); border-radius: var(--radius-lg); padding: .4rem 1rem; box-shadow: var(--shadow-card); } +.ledger table { width: 100%; border-collapse: collapse; font-size: .85rem; } +.ledger td, .ledger th { padding: .38rem .5rem; border-top: 1px solid var(--board-edge); vertical-align: top; text-align: left; } +.ledger th { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-soft); border-top: none; } +.ledger .meta { color: var(--ink-soft); } +.shelfgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: .9rem; } +.game { + background: var(--board); border: var(--line); border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); overflow: hidden; + display: flex; flex-direction: column; +} +.game img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; background: var(--sky-deep); } +.game .noart { + width: 100%; aspect-ratio: 1; display: flex; align-items: center; justify-content: center; + background: var(--sky-deep); color: var(--navy); + font-family: var(--font-display); font-size: 2.2rem; font-weight: 700; +} +.game .info { padding: .55rem .7rem .7rem; } +.game .gname { font-weight: 700; font-family: var(--font-display); } +.game .gmeta { font-size: .78rem; color: var(--ink-soft); margin-top: .2rem; line-height: 1.5; } +.empty { + background: var(--board); border: 2px dashed var(--board-edge); + border-radius: var(--radius-lg); padding: 2rem; text-align: center; + color: var(--ink-soft); line-height: 1.6; +} +.empty code { + font-family: var(--font-mono); background: var(--navy); color: #fff; + padding: .15rem .5rem; border-radius: 6px; +} +.filterbar { display: flex; gap: .6rem; margin-bottom: 1rem; flex-wrap: wrap; } +.filterbar input[type=search] { + font: inherit; padding: .35rem .7rem; min-width: 16rem; + border: var(--line); border-radius: var(--radius); background: #fff; +} + +/* -- responsive: rail collapses to a top strip -------------------------- */ +@media (max-width: 900px) { + body { display: block; } + .sidebar { + position: sticky; height: auto; flex-direction: row; align-items: center; + flex-wrap: wrap; gap: 0 .5rem; z-index: 5; + border-right: none; border-bottom: 4px solid transparent; + border-image: var(--path) 1; + } + .brand { padding: .5rem .8rem; } + nav[aria-label="Primary"] { flex-direction: row; flex-wrap: wrap; } + nav[aria-label="Primary"] a { border-left: none; border-bottom: 3px solid transparent; padding: .35rem .6rem; } + nav[aria-label="Primary"] a[aria-current="page"] { border-bottom-color: var(--gold); } + .piperbox { display: none; } +} diff --git a/src/bggpipe/static/app.js b/src/bggpipe/static/app.js new file mode 100644 index 0000000..6ee7f40 --- /dev/null +++ b/src/bggpipe/static/app.js @@ -0,0 +1,79 @@ +/* Shared client plumbing for every bggpipe page: escaping, banners, + * fetch helpers, and the sidebar's live count badges. Loaded as a + * blocking script before each page's own script. */ +"use strict"; + +const esc = s => String(s ?? "").replace(/[&<>"']/g, + c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c])); + +/* Contract for every innerHTML sink in this app: interpolated values MUST + * pass through esc() at the call site. The API serves only local pipeline + * data, but photo names and BGG titles still count as untrusted. */ +function showBanner(html) { + const el = document.getElementById("banner"); + el.setAttribute("role", "status"); + el.innerHTML = html; +} + +function errorBanner(detail) { + showBanner(``); +} + +async function fetchJSON(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function apiPost(url, body) { + let res; + try { + res = await fetch(url, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body ?? {}), + }); + } catch (err) { + alert("No response from the server: " + err); + return null; + } + if (!res.ok) { + const detail = await res.json().then(d => d.detail).catch(() => null); + alert("That didn't work: " + (detail ?? res.statusText)); + return null; + } + return res; +} + +/* Poll `fn` every `ms`; after 3 consecutive failures show the lost-contact + * banner, and clear it (via `recovered`) on the next success. */ +function pollLoop(fn, ms, recovered) { + let misses = 0; + setInterval(async () => { + const el = document.activeElement; + if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return; + try { + await fn(); + if (misses >= 3 && recovered) recovered(); + misses = 0; + } catch (err) { + if (++misses >= 3) errorBanner(err.message || err); + } + }, ms); +} + +/* Sidebar badges: the counts that mean "something wants your attention". */ +async function refreshBadges() { + const p = await fetchJSON("/api/pipeline"); + const set = (name, n) => { + const el = document.querySelector(`[data-badge="${name}"]`); + if (el) el.textContent = n > 0 ? String(n) : ""; + }; + set("photos", p.reshoot); + set("review", p.pending_review); + set("queue", p.to_add + p.to_update); + return p; +} +refreshBadges().catch(() => {}); +setInterval(() => refreshBadges().catch(() => {}), 5000); diff --git a/src/bggpipe/templates/dashboard.html b/src/bggpipe/templates/dashboard.html deleted file mode 100644 index eb36408..0000000 --- a/src/bggpipe/templates/dashboard.html +++ /dev/null @@ -1,179 +0,0 @@ - - - - - -bggpipe — dashboard - - - - - -
- - - bggpipeshelf → BGG pipeline - - - -
- -
-

Photos

- - - -

Pipeline

-
- -

Activity

-
idle
-
(stage output appears here)
-
- - - diff --git a/src/bggpipe/templates/pages/catalog.html b/src/bggpipe/templates/pages/catalog.html new file mode 100644 index 0000000..929af89 --- /dev/null +++ b/src/bggpipe/templates/pages/catalog.html @@ -0,0 +1,51 @@ +

Catalog

+
+
+ +
+

Nothing extracted yet — start on the photos page.

+ diff --git a/src/bggpipe/templates/pages/library.html b/src/bggpipe/templates/pages/library.html new file mode 100644 index 0000000..dee19df --- /dev/null +++ b/src/bggpipe/templates/pages/library.html @@ -0,0 +1,56 @@ +

Library

+
+
+ +
+
+ diff --git a/src/bggpipe/templates/pages/photos.html b/src/bggpipe/templates/pages/photos.html new file mode 100644 index 0000000..4957017 --- /dev/null +++ b/src/bggpipe/templates/pages/photos.html @@ -0,0 +1,96 @@ +

Photos

+ + + +

Reshoot — boxes seen but not identified; nothing blocks on these

+

No open reshoot tickets.

+ + + + diff --git a/src/bggpipe/templates/pages/pipeline.html b/src/bggpipe/templates/pages/pipeline.html new file mode 100644 index 0000000..bf5b59d --- /dev/null +++ b/src/bggpipe/templates/pages/pipeline.html @@ -0,0 +1,88 @@ +

Pipeline

+
+ +

Activity

+
idle
+
(stage output appears here)
+ diff --git a/src/bggpipe/templates/pages/queue.html b/src/bggpipe/templates/pages/queue.html new file mode 100644 index 0000000..f5453e9 --- /dev/null +++ b/src/bggpipe/templates/pages/queue.html @@ -0,0 +1,52 @@ +

Upload queue

+

Exactly what the upload stage will do, and what it has already done — +inspect here before any real run.

+
+ diff --git a/src/bggpipe/templates/review.html b/src/bggpipe/templates/pages/review.html similarity index 62% rename from src/bggpipe/templates/review.html rename to src/bggpipe/templates/pages/review.html index 82cab71..581c5d7 100644 --- a/src/bggpipe/templates/review.html +++ b/src/bggpipe/templates/pages/review.html @@ -1,52 +1,21 @@ - - - - - -bggpipe — review - - - - - -
- - the bggpipe piper — a bagpiper whose bag is a board game box - bggpipe review - - - +

Review

+
+ j/k move · 19 pick · r reject · m manual id · u unknown · - v veto merge · d dismiss + v veto merge -
- -
+ +
- - diff --git a/src/bggpipe/templates/shell.html b/src/bggpipe/templates/shell.html new file mode 100644 index 0000000..78a8453 --- /dev/null +++ b/src/bggpipe/templates/shell.html @@ -0,0 +1,37 @@ + + + + + +<!--TITLE--> + + + + + + +
+ + +
+ +
+
+ + diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 115dcf7..3770f89 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -1,13 +1,15 @@ -"""The local web app: a pipeline dashboard at / and the review UI at /review. +"""The local web app: six pages in a shared sidebar shell. -FastAPI + one self-contained HTML page (inline CSS/JS, no build step), -served on localhost only. All decision logic and matches.csv writes go -through ReviewSession — this module is purely an interface. Also renders -data/unidentified.json as reshoot work-orders with a persisted dismiss -action (data/unidentified_dismissed.json survives extract rebuilds). +Pipeline (/), Photos, Review, Catalog, Queue, and Library — rendered +server-side from templates/shell.html plus one fragment per page, with +static/app.css as the design system and static/app.js as shared client +plumbing. No template engine and no build step; served on localhost only. -The page layout is shared-shell by design: a future "browse" view of -games.json mounts as a sibling section without touching the review code. +All decision logic and matches.csv writes go through ReviewSession — +this module is purely an interface. Reshoot work-orders come from +data/unidentified.json with a persisted dismiss action +(data/unidentified_dismissed.json survives extract rebuilds); stage runs +execute one at a time in a JobRunner. """ from __future__ import annotations @@ -188,6 +190,16 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]: } +# (href, page-name, label, badge-name) — badge names match app.js +NAV_PAGES = ( + ("/", "pipeline", "Pipeline", ""), + ("/photos", "photos", "Photos", "photos"), + ("/review", "review", "Review", "review"), + ("/catalog", "catalog", "Catalog", ""), + ("/queue", "queue", "Queue", "queue"), + ("/library", "library", "Library", ""), +) + PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"} @@ -223,6 +235,19 @@ def create_app( revision["n"] += 1 thumbnails = load_thumbnails(cfg.cache_dir) + def open_sightings() -> list[dict]: + available = photo_names() + sightings = [] + if cfg.unidentified_path.exists(): + for photo, entries in json.loads(cfg.unidentified_path.read_text()).items(): + for s in entries: + if _sighting_key(photo, s) in dismissed.keys: + continue + sightings.append( + {**s, "photo": photo, "photo_exists": photo in available} + ) + return sightings + def find_row(title_raw: str, source_photos: str, row_ix: int | None = None) -> dict: freshen() row = session.find_row(title_raw, source_photos, row_ix) @@ -233,7 +258,11 @@ def create_app( def photo_names() -> set[str]: if not cfg.photos_dir.is_dir(): return set() - return {p.name for p in cfg.photos_dir.iterdir() if p.is_file()} + return { + p.name + for p in cfg.photos_dir.iterdir() + if p.is_file() and p.suffix.lower() in PHOTO_SUFFIXES + } def row_payload(row: dict) -> dict: entry = session.cues_for(row["title_raw"], row["source_photos"]) @@ -302,16 +331,7 @@ def create_app( for r in session.rows if r["version_status"] in CONFIDENT_VERSION_STATUSES and r["version_id"] ) - available = photo_names() - sightings = [] - if cfg.unidentified_path.exists(): - for photo, entries in json.loads(cfg.unidentified_path.read_text()).items(): - for s in entries: - if _sighting_key(photo, s) in dismissed.keys: - continue - sightings.append( - {**s, "photo": photo, "photo_exists": photo in available} - ) + sightings = open_sightings() merges = [ { "row_ix": _ix_of(session.rows, r), @@ -344,13 +364,99 @@ def create_app( }, } + def render_page(name: str) -> str: + """Server-side shell: shared sidebar + nav with aria-current, page + fragment substituted in. No template engine — three placeholders.""" + templates = resources.files("bggpipe") / "templates" + shell = (templates / "shell.html").read_text() + fragment = (templates / "pages" / f"{name}.html").read_text() + nav = "\n".join( + f' {label}" + + (f'' if badge else "") + + "" + for href, page, label, badge in NAV_PAGES + ) + return ( + shell.replace("", f"bggpipe — {name}") + .replace("", nav) + .replace("", fragment) + ) + @app.get("/", response_class=HTMLResponse) def index() -> str: - return (resources.files("bggpipe") / "templates" / "dashboard.html").read_text() + return render_page("pipeline") + + @app.get("/photos", response_class=HTMLResponse) + def photos_page() -> str: + return render_page("photos") @app.get("/review", response_class=HTMLResponse) def review_page() -> str: - return (resources.files("bggpipe") / "templates" / "review.html").read_text() + return render_page("review") + + @app.get("/catalog", response_class=HTMLResponse) + def catalog_page() -> str: + return render_page("catalog") + + @app.get("/queue", response_class=HTMLResponse) + def queue_page() -> str: + return render_page("queue") + + @app.get("/library", response_class=HTMLResponse) + def library_page() -> str: + return render_page("library") + + @app.get("/api/photos-list") + def api_photos_list() -> list[dict]: + out = [] + for name in sorted(photo_names()): + raw_path = cfg.extract_raw_dir / f"{name}.json" + titles = unidentified = 0 + extracted = raw_path.exists() + if extracted: + try: + raw = json.loads(raw_path.read_text()) + if isinstance(raw, list): # bare-array shape + titles, unidentified = len(raw), 0 + else: + titles = len(raw.get("titles", [])) + unidentified = len(raw.get("unidentified", [])) + except (json.JSONDecodeError, OSError): + extracted = False # torn cache: shown as needing extraction + out.append( + { + "name": name, + "extracted": extracted, + "titles": titles, + "unidentified": unidentified, + } + ) + return out + + @app.get("/api/queue") + def api_queue() -> dict: + def rows(path: Path) -> list[dict]: + if not path.exists(): + return [] + with path.open(newline="") as f: + import csv as _csv + + return list(_csv.DictReader(f)) + + return { + "to_add": rows(cfg.to_add_path), + "to_update": rows(cfg.to_update_path), + "log": rows(cfg.upload_log_path), + } + + @app.get("/api/library") + def api_library() -> list[dict]: + if not cfg.games_path.exists(): + return [] + games = json.loads(cfg.games_path.read_text()) + return sorted(games.values(), key=lambda g: (g.get("name") or "").casefold()) def _csv_count(path: Path) -> int: if not path.exists(): @@ -387,6 +493,7 @@ def create_app( }, "stub_data": any(m.exists() for m in cfg.stub_marker_paths), "photos": len(photo_names()), + "reshoot": len(open_sightings()), "titles": len(session.titles), "matches": dict(match_counts), "pending_review": len(session.pending_rows()) @@ -509,6 +616,7 @@ def create_app( _STATIC = { "app.css": "text/css", + "app.js": "text/javascript", "logo.jpg": "image/jpeg", "logo-full.jpg": "image/jpeg", "favicon.png": "image/png", diff --git a/tests/test_web_dashboard.py b/tests/test_web_dashboard.py index 3ce0c82..f7b42a5 100644 --- a/tests/test_web_dashboard.py +++ b/tests/test_web_dashboard.py @@ -214,3 +214,104 @@ def test_both_pages_carry_navigation_and_skip_link(tmp_path): def test_activity_region_announces_politely(tmp_path): html = _app(_cfg(tmp_path)).get("/").text assert 'aria-live="polite"' in html + + +# -- six-page shell ----------------------------------------------------- + + +def test_every_page_serves_with_shared_shell(tmp_path): + web = _app(_cfg(tmp_path)) + for path, marker in ( + ("/", "Pipeline"), + ("/photos", "Reshoot"), + ("/review", "Review"), + ("/catalog", "Catalog"), + ("/queue", "Upload queue"), + ("/library", "Library"), + ): + html = web.get(path).text + assert marker in html, path + assert 'nav aria-label="Primary"' in html, path + assert 'aria-current="page"' in html, path + assert "logo-full.jpg" in html, path # Juniper's portrait in the rail + assert "art by Juniper" in html, path + assert "trademarks of" in html, path # attribution notice + + +def test_photos_list_reports_extraction_state(tmp_path): + import json as _json + + cfg = _cfg(tmp_path) + (cfg.photos_dir / "done.jpg").write_bytes(b"x") + (cfg.photos_dir / "fresh.jpg").write_bytes(b"x") + cfg.extract_raw_dir.mkdir(parents=True) + (cfg.extract_raw_dir / "done.jpg.json").write_text( + _json.dumps({"titles": [{"title_raw": "Catan"}], "unidentified": [{}, {}]}) + ) + listing = {p["name"]: p for p in _app(cfg).get("/api/photos-list").json()} + assert listing["done.jpg"] == { + "name": "done.jpg", + "extracted": True, + "titles": 1, + "unidentified": 2, + } + assert listing["fresh.jpg"]["extracted"] is False + + +def test_queue_endpoint_serves_all_three_ledgers(tmp_path): + cfg = _cfg(tmp_path) + (cfg.to_add_path).write_text("bgg_id,bgg_name\n13,Catan\n") + q = _app(cfg).get("/api/queue").json() + assert q["to_add"] == [{"bgg_id": "13", "bgg_name": "Catan"}] + assert q["to_update"] == [] and q["log"] == [] + + +def test_library_serves_games_sorted_or_empty(tmp_path): + import json as _json + + cfg = _cfg(tmp_path) + web = _app(cfg) + assert web.get("/api/library").json() == [] + cfg.games_path.write_text( + _json.dumps( + { + "13": {"name": "Catan", "year": 1995}, + "266192": {"name": "Wingspan", "year": 2019}, + "1": {"name": "aliens", "year": 2000}, + } + ) + ) + names = [g["name"] for g in web.get("/api/library").json()] + assert names == ["aliens", "Catan", "Wingspan"] # casefold sort + + +def test_pipeline_reports_reshoot_count(tmp_path): + import json as _json + + cfg = _cfg(tmp_path) + cfg.unidentified_path.write_text( + _json.dumps({"a.jpg": [{"location": "top shelf"}]}) + ) + assert _app(cfg).get("/api/pipeline").json()["reshoot"] == 1 + + +def test_photos_list_tolerates_bare_array_raw_cache(tmp_path): + import json as _json + + cfg = _cfg(tmp_path) + (cfg.photos_dir / "old.jpg").write_bytes(b"x") + cfg.extract_raw_dir.mkdir(parents=True) + (cfg.extract_raw_dir / "old.jpg.json").write_text( + _json.dumps([{"title_raw": "Catan"}, {"title_raw": "Risk"}]) + ) + (item,) = _app(cfg).get("/api/photos-list").json() + assert item["extracted"] is True and item["titles"] == 2 + + +def test_non_photo_files_are_invisible(tmp_path): + cfg = _cfg(tmp_path) + (cfg.photos_dir / "shelf.jpg").write_bytes(b"x") + (cfg.photos_dir / ".DS_Store").write_bytes(b"junk") + web = _app(cfg) + assert [p["name"] for p in web.get("/api/photos-list").json()] == ["shelf.jpg"] + assert web.get("/photos/.DS_Store").status_code == 404