From 189c534315ed0073d05636f1363b8bea1ec27069 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 2 Aug 2026 17:34:17 -0400 Subject: [PATCH] Six-page app: sidebar shell with Juniper's portrait, whole-workflow IA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-page dashboard/review split becomes a proper information architecture: Pipeline (stages + live activity), Photos (drag-and-drop, gallery with per-photo extraction state, reshoot tickets — photo work lives with photos), Review (decisions only, keyboard-first), Catalog (the full title ledger with filtering), Queue (what upload will do and everything it has done), and Library (the enriched collection browser, with an honest empty state until real BGG data lands). Pages render server-side from a shared shell — sidebar rail with the rainbow path running its edge, live count badges on Photos/Review/Queue, and Juniper's full portrait finally displayed, with her credit and a standard third-party trademark attribution beneath it (one notice, not per-mention symbols — the convention for referring to another party's mark). Shared client plumbing moves to static/app.js (escaping contract documented at the innerHTML sink). New endpoints: /api/photos-list, /api/queue, /api/library, plus a reshoot count in /api/pipeline. Screenshot review caught two real bugs: photos-list crashed on bare-array raw caches, and .DS_Store was listed as a shelf photo — photo_names() now filters by suffix everywhere, including the /photos allowlist. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- README.md | 7 +- src/bggpipe/static/app.css | 141 ++++++++++++-- src/bggpipe/static/app.js | 79 ++++++++ src/bggpipe/templates/dashboard.html | 179 ------------------ src/bggpipe/templates/pages/catalog.html | 51 +++++ src/bggpipe/templates/pages/library.html | 56 ++++++ src/bggpipe/templates/pages/photos.html | 96 ++++++++++ src/bggpipe/templates/pages/pipeline.html | 88 +++++++++ src/bggpipe/templates/pages/queue.html | 52 +++++ src/bggpipe/templates/{ => pages}/review.html | 159 +++------------- src/bggpipe/templates/shell.html | 37 ++++ src/bggpipe/webreview.py | 150 +++++++++++++-- tests/test_web_dashboard.py | 101 ++++++++++ 14 files changed, 844 insertions(+), 356 deletions(-) create mode 100644 src/bggpipe/static/app.js delete mode 100644 src/bggpipe/templates/dashboard.html create mode 100644 src/bggpipe/templates/pages/catalog.html create mode 100644 src/bggpipe/templates/pages/library.html create mode 100644 src/bggpipe/templates/pages/photos.html create mode 100644 src/bggpipe/templates/pages/pipeline.html create mode 100644 src/bggpipe/templates/pages/queue.html rename src/bggpipe/templates/{ => pages}/review.html (62%) create mode 100644 src/bggpipe/templates/shell.html 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