Six-page app: sidebar shell with Juniper's portrait, whole-workflow IA

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 17:34:17 -04:00
parent d3cf0c6151
commit 189c534315
14 changed files with 844 additions and 356 deletions
+2 -2
View File
@@ -20,14 +20,14 @@ Full design lives in `bgg-shelf-pipeline-spec.md` (read it before changing pipel
## Commands ## Commands
- `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`). - `uv sync` — install deps (Python 3.12+, managed by **uv**; use `uv add`, never pip). `uv run bggpipe init` handles first-run setup (folders, .env credentials, the one-time `playwright install chromium`).
- `uv run bggpipe web` — the 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 <stage>` — run a pipeline stage. Non-secret settings come from `config.toml` (username, dirs, vision model, rate limit); `--config` overrides the path. - `uv run bggpipe <stage>` — 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 pytest` — the suite runs fully offline against fixtures. Tests marked `live` hit the real BGG API (read-only) and are skipped unless you pass `--run-live`.
- `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format. - `uv run ruff check` / `uv run ruff format` — lint (rules E, F, I, UP, B, SIM) and format.
## Layout ## 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. - `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. - `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). - `data/` — pipeline state (CSV/JSON artifacts are committed; caches are not — see Git).
+5 -2
View File
@@ -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: 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 ```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 ```sh
uv run bggpipe extract # photos → titles.json (+ retake prompts) 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). 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. Mascot art by Juniper, used with pride.
+120 -21
View File
@@ -33,6 +33,8 @@
--focus: #8330c2; --focus: #8330c2;
--path: linear-gradient(90deg, --path: linear-gradient(90deg,
#f767b8, #f79a3e, #f2c04b, #6fce6f, #5aa7f0, #9a5be0); #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-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-body: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace; --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; } h2 .count { color: var(--ink-soft); font-size: .85rem; font-weight: 400; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } :focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
/* -- header + navigation ---------------------------------------------- */ /* -- layout: sidebar rail + content column ----------------------------- */
header { body { display: grid; grid-template-columns: 15.5rem minmax(0, 1fr); }
position: sticky; top: 0; z-index: 5; .sidebar {
background: var(--navy); position: sticky; top: 0; height: 100vh;
color: #fff; background: var(--navy); color: #fff;
padding: .45rem 1.2rem; display: flex; flex-direction: column;
display: flex; align-items: center; gap: 1.2rem; flex-wrap: wrap; border-right: 4px solid transparent;
border-bottom: 4px solid transparent; /* the rainbow game path, running the rail top to bottom */
border-image: var(--path) 1; 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 { .brand img {
width: 38px; height: 38px; border-radius: 50%; width: 38px; height: 38px; border-radius: 50%;
border: 2px solid var(--gold); object-fit: cover; display: block; border: 2px solid var(--gold); object-fit: cover; display: block;
@@ -85,27 +91,57 @@ header {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.3rem; font-weight: 700; letter-spacing: .01em; 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; } .wordmark small { display: block; opacity: .8; font-family: var(--font-body); font-weight: 400; font-size: .72rem; }
nav[aria-label="Primary"] { display: flex; gap: .9rem; font-size: .9rem; } nav[aria-label="Primary"] { display: flex; flex-direction: column; padding: .4rem 0; }
nav[aria-label="Primary"] a { nav[aria-label="Primary"] a {
color: #fff; text-decoration: none; opacity: .85; 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"] { 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 { .skip {
position: absolute; left: -999px; top: 0; z-index: 10; position: absolute; left: -999px; top: 0; z-index: 10;
background: var(--board); color: var(--ink); background: var(--board); color: var(--ink);
padding: .5rem 1rem; border-radius: 0 0 var(--radius) 0; padding: .5rem 1rem; border-radius: 0 0 var(--radius) 0;
} }
.skip:focus { left: 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; } /* per-page strip under the page title: section links, shortcuts, counts */
#tally a { color: inherit; text-decoration: none; border-bottom: 1px dotted rgba(255,255,255,.5); } .pagebar {
#tally a:hover { border-bottom-color: var(--gold); } display: flex; gap: 1rem; align-items: center; flex-wrap: wrap;
.keyhelp { margin-left: auto; font-size: .75rem; opacity: .85; } 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 { kbd {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: .72rem; font-size: .72rem;
@@ -117,7 +153,7 @@ kbd {
padding: 0 .35em; padding: 0 .35em;
display: inline-block; min-width: 1.4em; text-align: center; 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 ----------------------------------------------------------- */ /* -- banners ----------------------------------------------------------- */
#banner { max-width: 62rem; margin: 0 auto; padding: 0 1.2rem; } #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; } .card, .ticket { flex-direction: column; }
.shots { flex-basis: auto; } .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; }
}
+79
View File
@@ -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 => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[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(`<div class="banner error">lost contact with the server ` +
`(${esc(detail)}) — check its terminal</div>`);
}
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);
-179
View File
@@ -1,179 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bggpipe — dashboard</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header>
<a class="brand" href="/">
<img src="/static/logo.jpg" alt="">
<span class="wordmark">bggpipe<small>shelf → BGG pipeline</small></span>
</a>
<nav aria-label="Primary">
<a href="/" aria-current="page">Dashboard</a>
<a href="/review">Review</a>
</nav>
<span id="tally"></span>
</header>
<div id="banner"></div>
<main id="main">
<h2 id="photos">Photos</h2>
<button id="dropzone" type="button">drop shelf photos here, or click to choose</button>
<input id="filepick" type="file" accept=".jpg,.jpeg,.png,.heic" multiple hidden
aria-label="choose shelf photos">
<h2 id="pipeline">Pipeline</h2>
<div class="stages" id="stages"></div>
<h2 id="activity">Activity</h2>
<div id="jobstate" aria-live="polite">idle</div>
<div id="joblog" aria-label="stage output">(stage output appears here)</div>
</main>
<script>
"use strict";
let P = null; // /api/pipeline payload
let running = false;
const esc = s => String(s ?? "").replace(/[&<>"']/g,
c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
function showBanner(html) { document.getElementById("banner").innerHTML = html; }
document.getElementById("banner").setAttribute("role", "status");
async function runStage(stage, body) {
const res = await fetch(`/api/run/${stage}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body || {}),
});
if (!res.ok) {
const detail = await res.json().then(d => d.detail).catch(() => null);
alert("Couldn't start: " + (detail ?? res.statusText));
return;
}
refresh();
}
function stageCard(num, name, facts, actions) {
return `<section class="stage">
<div class="top"><span class="num">${num}</span><span class="name">${name}</span></div>
<div class="facts">${facts}</div>
<div class="act">${actions}</div>
</section>`;
}
function runBtn(stage, label) {
return `<button class="primary" data-run="${stage}" ${running ? "disabled" : ""}>${esc(label ?? "Run")}</button>`;
}
function render() {
const m = P.matches, log = P.upload_log;
const decided = (m.auto ?? 0) + (m.approved ?? 0);
document.getElementById("tally").innerHTML =
`<a href="#photos"><b>${P.photos}</b> photos</a>
<a href="/review#catalog"><b>${P.titles}</b> titles</a>
<a href="/review#catalog"><b>${decided}</b> matched</a>
<a href="/review"><b>${P.pending_review}</b> to review</a>
<a href="#pipeline"><b>${P.games}</b> enriched</a>`;
const banners = [];
if (P.stub_data) banners.push(
`<div class="banner warn">Stub fixtures active: resolved ids are placeholders and the
real upload stays locked until real BGG data replaces them.</div>`);
const missing = Object.entries(P.env).filter(([, ok]) => !ok).map(([k]) => k);
if (missing.length) banners.push(
`<div class="banner warn">Credentials not loaded in this server's environment:
${missing.map(esc).join(", ")} — run <code>bggpipe init</code> or load .env, then restart.</div>`);
showBanner(banners.join(""));
const uploadFacts = `<b>${P.to_add}</b> to add · <b>${P.to_update}</b> version updates
${log.added || log.added_no_version ? `· <b>${(log.added ?? 0) + (log.added_no_version ?? 0)}</b> added` : ""}
${log.failed ? `· <b>${log.failed}</b> failed` : ""}`;
document.getElementById("stages").innerHTML = [
stageCard(1, "extract", `read titles off <b>${P.photos}</b> photo(s) — <b>${P.titles}</b> so far`, runBtn("extract")),
stageCard(2, "resolve", `match titles to BGG ids — <b>${(m.auto ?? 0)}</b> auto · <b>${m.ambiguous ?? 0}</b> ambiguous · <b>${m.unmatched ?? 0}</b> unmatched`, runBtn("resolve")),
stageCard(3, "review", `<b>${P.pending_review}</b> item(s) waiting for your call`, `<a class="linkbtn" href="/review">Open review</a>`),
stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")),
stageCard(5, "upload", uploadFacts,
`${runBtn("upload", "Dry run")}
<button class="danger" data-upload-real ${running || P.stub_data ? "disabled" : ""}>Upload</button>
<label>limit <input type="number" id="uplimit" min="1" placeholder="all"></label>`),
stageCard(6, "enrich", `<b>${P.games}</b> game(s) in games.json`, runBtn("enrich")),
].join("");
document.querySelectorAll("[data-run]").forEach(b =>
b.addEventListener("click", () => runStage(b.dataset.run,
b.dataset.run === "upload" ? {dry_run: true} : {})));
const real = document.querySelector("[data-upload-real]");
if (real) real.addEventListener("click", () => {
const limit = Number(document.getElementById("uplimit").value) || null;
if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}? A browser window will open.`)) return;
runStage("upload", {dry_run: false, limit});
});
const job = P.job;
running = job.status === "running";
const state = document.getElementById("jobstate");
if (job.status === "idle") state.textContent = "idle";
else state.innerHTML = `<span class="${esc(job.status)}">${esc(job.stage)}: ${esc(job.status)}</span>` +
(job.error ? `${esc(job.error)}` : "");
const logEl = document.getElementById("joblog");
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4;
logEl.textContent = job.log.length ? job.log.join("\n") : "(stage output appears here)";
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
}
async function refresh() {
const res = await fetch("/api/pipeline");
if (!res.ok) throw new Error(`${res.status}`);
P = await res.json();
render();
}
// -- photo upload -------------------------------------------------------
const zone = document.getElementById("dropzone");
const pick = document.getElementById("filepick");
zone.addEventListener("click", () => pick.click());
zone.addEventListener("dragover", e => { e.preventDefault(); zone.classList.add("hot"); });
zone.addEventListener("dragleave", () => zone.classList.remove("hot"));
zone.addEventListener("drop", e => {
e.preventDefault(); zone.classList.remove("hot");
sendPhotos(e.dataTransfer.files);
});
pick.addEventListener("change", () => sendPhotos(pick.files));
async function sendPhotos(files) {
if (!files.length) return;
const form = new FormData();
for (const f of files) form.append("files", f);
const res = await fetch("/api/photos", {method: "POST", body: form});
if (!res.ok) {
const detail = await res.json().then(d => d.detail).catch(() => null);
alert("Upload failed: " + (detail ?? res.statusText));
return;
}
refresh();
}
refresh().catch(err => showBanner(
`<div class="banner error">couldn't load pipeline state: ${esc(err.message || err)}</div>`));
let misses = 0;
setInterval(async () => {
try {
await refresh();
misses = 0;
} catch (err) {
if (++misses >= 3) showBanner(
`<div class="banner error">lost contact with the server (${esc(err.message || err)})</div>`);
}
}, 2000);
</script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<h1>Catalog</h1>
<div class="pagebar"><span id="catcount"></span></div>
<div class="filterbar">
<input type="search" id="catsearch" placeholder="filter titles…" aria-label="filter catalog titles">
</div>
<div id="catbody"><p class="empty">Nothing extracted yet — start on the <a href="/photos">photos page</a>.</p></div>
<script>
"use strict";
let CATALOG = [];
function chip(c) {
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${esc(c.status)}</span>`;
if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
return `<span class="chip open">${esc(c.status)}</span>`;
}
function render() {
const q = document.getElementById("catsearch").value.trim().toLowerCase();
const rows = q
? CATALOG.filter(c => (c.title_raw + " " + c.bgg_name).toLowerCase().includes(q))
: CATALOG;
document.getElementById("catcount").innerHTML =
`<b>${rows.length}</b> of <b>${CATALOG.length}</b> title(s)`;
document.getElementById("catbody").innerHTML = rows.length
? `<div class="catalog"><table>` + rows.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}</td>
<td>${chip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
<td class="meta">${c.photos.map(p =>
`<a href="/photos/${encodeURIComponent(p)}" target="_blank">${esc(p)}</a>`
).join(", ")}</td>
</tr>`).join("") + `</table></div>`
: `<p class="empty">${CATALOG.length
? "No titles match that filter."
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
}
async function refresh() {
const state = await fetchJSON("/api/state");
CATALOG = state.catalog;
render();
}
document.getElementById("catsearch").addEventListener("input", render);
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000);
</script>
+56
View File
@@ -0,0 +1,56 @@
<h1>Library</h1>
<div class="pagebar"><span id="libcount"></span></div>
<div class="filterbar">
<input type="search" id="libsearch" placeholder="search your games…" aria-label="search library">
</div>
<div id="libbody"></div>
<script>
"use strict";
let GAMES = [];
function gameCard(g) {
const art = g.thumbnail || g.image;
const players = g.min_players
? (g.min_players === g.max_players ? `${g.min_players}` : `${g.min_players}${g.max_players}`) + " players"
: "";
const time = g.playtime ? `${g.playtime} min` : "";
const weight = g.weight ? `weight ${g.weight.toFixed(1)}` : "";
const rank = g.rank ? `BGG rank ${g.rank}` : "";
return `
<article class="game">
${art
? `<img src="${esc(art)}" alt="" loading="lazy">`
: `<div class="noart" aria-hidden="true">${esc((g.name || "?").charAt(0).toUpperCase())}</div>`}
<div class="info">
<div class="gname">${esc(g.name)}${g.year ? ` <span class="meta">(${esc(g.year)})</span>` : ""}</div>
<div class="gmeta">${[players, time, weight, rank].filter(Boolean).map(esc).join(" · ")}</div>
${g.version ? `<div class="gmeta">${esc(g.version.name || "")}</div>` : ""}
</div>
</article>`;
}
function render() {
const q = document.getElementById("libsearch").value.trim().toLowerCase();
const rows = q
? GAMES.filter(g => (g.name + " " + (g.designers || []).join(" ")).toLowerCase().includes(q))
: GAMES;
document.getElementById("libcount").innerHTML =
`<b>${rows.length}</b> of <b>${GAMES.length}</b> game(s)`;
document.getElementById("libbody").innerHTML = rows.length
? `<div class="shelfgrid">${rows.map(gameCard).join("")}</div>`
: `<p class="empty">${GAMES.length
? "No games match that search."
: `Your library appears here after <b>enrich</b> runs — full metadata for
every cataloged game: art, player counts, playtime, and more.<br>
Run the pipeline through <code>enrich</code> to fill these shelves.`}</p>`;
}
async function refresh() {
GAMES = await fetchJSON("/api/library");
render();
}
document.getElementById("libsearch").addEventListener("input", render);
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 10000);
</script>
+96
View File
@@ -0,0 +1,96 @@
<h1>Photos</h1>
<button id="dropzone" type="button">drop shelf photos here, or click to choose</button>
<input id="filepick" type="file" accept=".jpg,.jpeg,.png,.heic" multiple hidden
aria-label="choose shelf photos">
<h2 id="reshoot">Reshoot <span class="count">— boxes seen but not identified; nothing blocks on these</span></h2>
<div id="tickets"><p class="empty">No open reshoot tickets.</p></div>
<h2 id="gallery">Shelf photos <span class="count" id="gallerycount"></span></h2>
<div class="gallery" id="shots"></div>
<script>
"use strict";
function ticket(s) {
const img = s.photo_exists
? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
<img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
: "";
return `
<section class="ticket"
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
<span class="stencil">reshoot</span>
${img}
<div>
<div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
<div class="notes">from ${esc(s.photo)} — take a closer shot and drop it above</div>
<button class="dismiss">dismiss — found it / not a game</button>
</div>
</section>`;
}
function render(state, photos) {
const tickets = document.getElementById("tickets");
tickets.innerHTML = state.unidentified.length
? state.unidentified.map(ticket).join("")
: `<p class="empty">No open reshoot tickets.</p>`;
tickets.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
const t = b.closest(".ticket");
const res = await apiPost("/api/dismiss", {
photo: t.dataset.photo, location: t.dataset.location,
partial_text: t.dataset.partial, art_notes: t.dataset.art,
});
if (res) refresh();
}));
document.getElementById("gallerycount").textContent = `${photos.length} on file`;
document.getElementById("shots").innerHTML = photos.map(p => `
<figure class="shot">
<a href="/photos/${encodeURIComponent(p.name)}" target="_blank" aria-label="open ${esc(p.name)} full size">
<img src="/photos/${encodeURIComponent(p.name)}" alt="shelf photo ${esc(p.name)}" loading="lazy"></a>
<figcaption class="meta">${esc(p.name)}<br>
${p.extracted
? `<b>${p.titles}</b> title(s)${p.unidentified ? ` · ${p.unidentified} unidentified` : ""}`
: `not extracted yet — run <b>extract</b>`}
</figcaption>
</figure>`).join("");
}
async function refresh() {
const [state, photos] = await Promise.all([
fetchJSON("/api/state"),
fetchJSON("/api/photos-list"),
]);
render(state, photos);
}
const zone = document.getElementById("dropzone");
const pick = document.getElementById("filepick");
zone.addEventListener("click", () => pick.click());
zone.addEventListener("dragover", e => { e.preventDefault(); zone.classList.add("hot"); });
zone.addEventListener("dragleave", () => zone.classList.remove("hot"));
zone.addEventListener("drop", e => {
e.preventDefault(); zone.classList.remove("hot");
sendPhotos(e.dataTransfer.files);
});
pick.addEventListener("change", () => sendPhotos(pick.files));
async function sendPhotos(files) {
if (!files.length) return;
const form = new FormData();
for (const f of files) form.append("files", f);
const res = await fetch("/api/photos", {method: "POST", body: form});
if (!res.ok) {
const detail = await res.json().then(d => d.detail).catch(() => null);
alert("Upload failed: " + (detail ?? res.statusText));
return;
}
refresh();
}
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000);
</script>
+88
View File
@@ -0,0 +1,88 @@
<h1>Pipeline</h1>
<div class="stages" id="stages"></div>
<h2 id="activity">Activity</h2>
<div id="jobstate" aria-live="polite">idle</div>
<div id="joblog" aria-label="stage output">(stage output appears here)</div>
<script>
"use strict";
let P = null;
let running = false;
async function runStage(stage, body) {
const res = await apiPost(`/api/run/${stage}`, body);
if (res) refresh();
}
function stageCard(num, name, facts, actions) {
return `<section class="stage">
<div class="top"><span class="num">${num}</span><span class="name">${name}</span></div>
<div class="facts">${facts}</div>
<div class="act">${actions}</div>
</section>`;
}
function runBtn(stage, label) {
return `<button class="primary" data-run="${stage}" ${running ? "disabled" : ""}>${esc(label ?? "Run")}</button>`;
}
function render() {
const m = P.matches, log = P.upload_log;
const banners = [];
if (P.stub_data) banners.push(
`<div class="banner warn">Stub fixtures active: resolved ids are placeholders and the
real upload stays locked until real BGG data replaces them.</div>`);
const missing = Object.entries(P.env).filter(([, ok]) => !ok).map(([k]) => k);
if (missing.length) banners.push(
`<div class="banner warn">Credentials not loaded in this server's environment:
${missing.map(esc).join(", ")} — run <code>bggpipe init</code> or load .env, then restart.</div>`);
showBanner(banners.join(""));
const uploadFacts = `<b>${P.to_add}</b> to add · <b>${P.to_update}</b> version updates
${log.added || log.added_no_version ? `· <b>${(log.added ?? 0) + (log.added_no_version ?? 0)}</b> added` : ""}
${log.failed ? `· <b>${log.failed}</b> failed` : ""}
· <a href="/queue">inspect the queue</a>`;
document.getElementById("stages").innerHTML = [
stageCard(1, "extract", `read titles off <b>${P.photos}</b> <a href="/photos">photo(s)</a> — <b>${P.titles}</b> so far`, runBtn("extract")),
stageCard(2, "resolve", `match titles to BGG ids — <b>${(m.auto ?? 0)}</b> auto · <b>${m.ambiguous ?? 0}</b> ambiguous · <b>${m.unmatched ?? 0}</b> unmatched`, runBtn("resolve")),
stageCard(3, "review", `<b>${P.pending_review}</b> item(s) waiting for your call`, `<a class="linkbtn" href="/review">Open review</a>`),
stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")),
stageCard(5, "upload", uploadFacts,
`${runBtn("upload", "Dry run")}
<button class="danger" data-upload-real ${running || P.stub_data ? "disabled" : ""}>Upload</button>
<label>limit <input type="number" id="uplimit" min="1" placeholder="all"></label>`),
stageCard(6, "enrich", `<b>${P.games}</b> game(s) in the <a href="/library">library</a>`, runBtn("enrich")),
].join("");
document.querySelectorAll("[data-run]").forEach(b =>
b.addEventListener("click", () => runStage(b.dataset.run,
b.dataset.run === "upload" ? {dry_run: true} : {})));
const real = document.querySelector("[data-upload-real]");
if (real) real.addEventListener("click", () => {
const limit = Number(document.getElementById("uplimit").value) || null;
if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}? A browser window will open.`)) return;
runStage("upload", {dry_run: false, limit});
});
const job = P.job;
running = job.status === "running";
const state = document.getElementById("jobstate");
if (job.status === "idle") state.textContent = "idle";
else state.innerHTML = `<span class="${esc(job.status)}">${esc(job.stage)}: ${esc(job.status)}</span>` +
(job.error ? `${esc(job.error)}` : "");
const logEl = document.getElementById("joblog");
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4;
logEl.textContent = job.log.length ? job.log.join("\n") : "(stage output appears here)";
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
}
async function refresh() {
P = await fetchJSON("/api/pipeline");
render();
}
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 2000, () => render());
</script>
+52
View File
@@ -0,0 +1,52 @@
<h1>Upload queue</h1>
<p class="pagebar">Exactly what the upload stage will do, and what it has already done —
inspect here before any real run.</p>
<div id="queuebody"></div>
<script>
"use strict";
function table(headers, rows) {
return `<div class="ledger"><table>
<tr>${headers.map(h => `<th scope="col">${esc(h)}</th>`).join("")}</tr>
${rows.join("")}</table></div>`;
}
function render(q) {
let html = "";
html += `<h2>To add <span class="count">— ${q.to_add.length} new collection entr${q.to_add.length === 1 ? "y" : "ies"}</span></h2>`;
html += q.to_add.length
? table(["game", "version", "seen in"], q.to_add.map(r => `
<tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td>
<td>${r.version_name ? esc(r.version_name) : `<span class="meta">no version</span>`}</td>
<td class="meta">${esc(r.source_photos)}</td></tr>`))
: `<p class="empty">Nothing queued — run <b>diff</b> from the <a href="/">pipeline</a> first.</p>`;
html += `<h2>Version updates <span class="count">— ${q.to_update.length} existing entr${q.to_update.length === 1 ? "y" : "ies"} gaining a version</span></h2>`;
html += q.to_update.length
? table(["game", "version to set", "collection id"], q.to_update.map(r => `
<tr><td class="t">${esc(r.bgg_name)} <span class="meta">· ${esc(r.bgg_id)}</span></td>
<td>${esc(r.version_name)}</td>
<td class="meta">${esc(r.collid)}</td></tr>`))
: `<p class="empty">No version updates pending.</p>`;
html += `<h2>Upload log <span class="count">— every attempt ever made (${q.log.length})</span></h2>`;
html += q.log.length
? table(["when", "action", "game", "status", "note"], q.log.slice().reverse().map(r => `
<tr><td class="meta">${esc(r.timestamp)}</td>
<td class="meta">${esc(r.action)}</td>
<td class="t">${esc(r.name)}</td>
<td>${esc(r.status)}</td>
<td class="meta">${esc(r.error)}</td></tr>`))
: `<p class="empty">No uploads attempted yet.</p>`;
document.getElementById("queuebody").innerHTML = html;
}
async function refresh() {
render(await fetchJSON("/api/queue"));
}
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000);
</script>
@@ -1,52 +1,21 @@
<!doctype html> <h1>Review</h1>
<html lang="en"> <div class="pagebar">
<head> <span id="sectionlinks"></span>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bggpipe — review</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header>
<a class="brand" href="/">
<img src="/static/logo.jpg" alt="the bggpipe piper — a bagpiper whose bag is a board game box">
<span class="wordmark">bggpipe <small>review</small></span>
</a>
<nav aria-label="Primary">
<a href="/">Dashboard</a>
<a href="/review" aria-current="page">Review</a>
</nav>
<span id="tally"></span>
<span class="keyhelp"> <span class="keyhelp">
<kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd><kbd>9</kbd> pick · <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd><kbd>9</kbd> pick ·
<kbd>r</kbd> reject · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown · <kbd>r</kbd> reject · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
<kbd>v</kbd> veto merge · <kbd>d</kbd> dismiss <kbd>v</kbd> veto merge
</span> </span>
</header> </div>
<div id="banner"></div> <div id="cards"></div>
<main id="main"></main>
<script> <script>
"use strict"; "use strict";
let STATE = null; let STATE = null;
let active = 0; let active = 0;
const esc = s => String(s ?? "").replace(/[&<>"']/g,
c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
function showBanner(html) {
document.getElementById("banner").innerHTML = html;
}
function errorBanner(detail) {
showBanner(`<div class="banner error">couldn't load review state: ${esc(detail)}` +
` — is the server running? (check its terminal)</div>`);
}
async function refresh() { async function refresh() {
const res = await fetch("/api/state"); const res = await fetch("/api/state");
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => res.statusText)}`); if (!res.ok) throw new Error(`${res.status}`);
STATE = await res.json(); STATE = await res.json();
render(); render();
} }
@@ -151,54 +120,29 @@ function versionCard(row, idx) {
</section>`; </section>`;
} }
function ticket(s) {
const img = s.photo_exists
? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
<img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
: "";
return `
<section class="ticket actionable" data-kind="ticket"
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
<span class="stencil">reshoot</span>
${img}
<div>
<div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
<div class="notes">from ${esc(s.photo)} — take a closer shot, drop it in photos/, run extract</div>
<button title="press d"><kbd>d</kbd> dismiss — found it / not a game</button>
</div>
</section>`;
}
function render() { function render() {
const m = document.getElementById("main"); const m = document.getElementById("cards");
const s = STATE; const s = STATE;
showBanner((s.warnings || []).length showBanner((s.warnings || []).length
? `<div class="banner warn">${s.warnings.map(esc).join("<br>")}</div>` : ""); ? `<div class="banner warn">${s.warnings.map(esc).join("<br>")}</div>` : "");
const tallyItem = (n, label, anchor) => n
? `<a href="#${anchor}"><b>${n}</b> ${label}</a>` const link = (n, label, anchor) => n ? `<a href="#${anchor}"><b>${n}</b> ${label}</a>` : "";
: `<span><b>0</b> ${label}</span>`; document.getElementById("sectionlinks").innerHTML = [
document.getElementById("tally").innerHTML = link(s.pending.length, "matches", "matches"),
`${tallyItem(s.pending.length, "matches", "matches")} link(s.versions.length, "editions", "editions"),
${tallyItem(s.versions.length, "editions", "editions")} link(s.merges.length, "merged", "merges"),
${tallyItem(s.unidentified.length, "reshoot", "reshoot")} s.summary.unresolved ? `<span><b>${s.summary.unresolved}</b> awaiting resolve</span>` : "",
${s.merges.length ? tallyItem(s.merges.length, "merged", "merges") : ""} `<span>${s.decisions} decided this sitting</span>`,
${s.summary.unresolved ? `<span><b>${s.summary.unresolved}</b> awaiting resolve</span>` : ""} ].filter(Boolean).join(" ");
${tallyItem(s.catalog.length, "catalog", "catalog")}
<span>${s.decisions} decided this sitting</span>`;
let html = ""; let html = "";
if (!s.pending.length && !s.versions.length) { if (!s.pending.length && !s.versions.length) {
const waiting = s.summary.unresolved; const waiting = s.summary.unresolved;
html += ` html += `
<div class="done"> <div class="done">
<img class="piper" src="/static/logo-full.jpg"
alt="the bggpipe piper plays a celebratory tune">
<h2>${waiting <h2>${waiting
? "Resolved set fully reviewed" ? "Resolved set fully reviewed"
: "All reviewed — this catalog is diff-ready"}</h2> : "All reviewed — the catalog is diff-ready"}</h2>
<div class="nums"> <div class="nums">
<div>${s.summary.extracted}<span>extracted</span></div> <div>${s.summary.extracted}<span>extracted</span></div>
<div>${s.summary.recognized}<span>recognized</span></div> <div>${s.summary.recognized}<span>recognized</span></div>
@@ -208,9 +152,8 @@ function render() {
${waiting ${waiting
? `<p class="waiting">${waiting} titles are extracted but not yet matched ? `<p class="waiting">${waiting} titles are extracted but not yet matched
to BGG — they're waiting on the API token.</p> to BGG — they're waiting on the API token.</p>
<p class="next">When it arrives: <code>uv run bggpipe resolve</code></p>` <p class="next">When it arrives, run <b>resolve</b> from the <a href="/">pipeline</a>.</p>`
: `<p class="next">Next: <code>uv run bggpipe diff</code></p>`} : `<p class="next">Next: run <b>diff</b> from the <a href="/">pipeline</a>, then check the <a href="/queue">queue</a>.</p>`}
${s.unidentified.length ? `<p class="status">${s.unidentified.length} reshoot ticket(s) below — they don't block anything.</p>` : ""}
</div>`; </div>`;
} }
if (s.pending.length) { if (s.pending.length) {
@@ -236,30 +179,6 @@ function render() {
</div> </div>
</section>`).join(""); </section>`).join("");
} }
if (s.unidentified.length) {
html += `<h2 id="reshoot">Reshoot <span class="count">— boxes seen but not identified</span></h2>`;
html += s.unidentified.map(ticket).join("");
}
if (s.catalog.length) {
const chip = c => {
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${c.status}</span>`;
if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
return `<span class="chip open">${esc(c.status)}</span>`;
};
html += `<h2 id="catalog">Catalog <span class="count">— every title extracted so far (${s.catalog.length})</span></h2>
<div class="catalog"><table>` + s.catalog.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}</td>
<td>${chip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
<td class="meta">${c.photos.map(p =>
`<a href="/photos/${encodeURIComponent(p)}" target="_blank">${esc(p)}</a>`
).join(", ")}</td>
</tr>`).join("") + `</table></div>`;
}
m.innerHTML = html; m.innerHTML = html;
const cards = actionables(); const cards = actionables();
@@ -278,8 +197,6 @@ function render() {
decide(b.closest(".card"), "reject")); decide(b.closest(".card"), "reject"));
m.querySelectorAll(".unknown").forEach(b => b.onclick = () => m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
version(b.closest(".card"), "unknown")); version(b.closest(".card"), "unknown"));
m.querySelectorAll(".ticket button").forEach(b => b.onclick = () =>
dismiss(b.closest(".ticket")));
m.querySelectorAll(".veto").forEach(b => b.onclick = () => m.querySelectorAll(".veto").forEach(b => b.onclick = () =>
vetoMerge(b.closest(".card"))); vetoMerge(b.closest(".card")));
m.querySelectorAll(".rowactions input").forEach(inp => { m.querySelectorAll(".rowactions input").forEach(inp => {
@@ -310,10 +227,6 @@ const version = (card, action, version_id = null) => post("/api/version", {
title_raw: card.dataset.title, source_photos: card.dataset.photos, title_raw: card.dataset.title, source_photos: card.dataset.photos,
row_ix: rowIx(card), action, version_id, row_ix: rowIx(card), action, version_id,
}); });
const dismiss = t => post("/api/dismiss", {
photo: t.dataset.photo, location: t.dataset.location,
partial_text: t.dataset.partial, art_notes: t.dataset.art,
});
const vetoMerge = card => post("/api/veto-merge", { const vetoMerge = card => post("/api/veto-merge", {
title_raw: card.dataset.title, source_photos: card.dataset.photos, title_raw: card.dataset.title, source_photos: card.dataset.photos,
row_ix: rowIx(card), row_ix: rowIx(card),
@@ -342,38 +255,22 @@ document.addEventListener("keydown", e => {
} }
else if (e.key === "r" && kind === "match") decide(card, "reject"); else if (e.key === "r" && kind === "match") decide(card, "reject");
else if (e.key === "u" && kind === "version") version(card, "unknown"); else if (e.key === "u" && kind === "version") version(card, "unknown");
else if (e.key === "d" && kind === "ticket") dismiss(card);
else if (e.key === "v" && kind === "merge") vetoMerge(card); else if (e.key === "v" && kind === "merge") vetoMerge(card);
else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); } else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
}); });
refresh().catch(err => errorBanner(err.message || err)); refresh().catch(err => errorBanner(err.message || err));
// Live-follow the data files: extract/resolve runs in another terminal show // Live-follow the data files; only re-render on an actual change (keeps
// up on the next poll. Only re-render on an actual change (keeps the // the keyboard cursor stable) and never mid-typing. Stale poll responses
// keyboard cursor stable) and never mid-typing in a manual-ID input. // (answered before a decision landed) are discarded by revision.
let pollMisses = 0; let lastGood = null;
setInterval(async () => { pollLoop(async () => {
const el = document.activeElement; const fresh = await fetchJSON("/api/state");
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return; if (STATE && fresh.revision < STATE.revision) return;
let fresh = null;
try {
const res = await fetch("/api/state");
if (!res.ok) throw new Error(`${res.status}`);
fresh = await res.json();
} catch (err) {
// transient during --dev restarts; persistent means the server is gone
if (++pollMisses >= 3) errorBanner(`lost contact (${err.message || err})`);
return;
}
if (pollMisses >= 3) render(); // recovered: rebuild banners from state
pollMisses = 0;
if (STATE && fresh.revision < STATE.revision) return; // stale poll response
if (JSON.stringify(fresh) !== JSON.stringify(STATE)) { if (JSON.stringify(fresh) !== JSON.stringify(STATE)) {
STATE = fresh; STATE = fresh;
render(); render();
} }
}, 3000); }, 3000, () => render());
</script> </script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><!--TITLE--></title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<div class="sidebar">
<a class="brand" href="/">
<img src="/static/logo.jpg" alt="">
<span class="wordmark">bggpipe<small>shelf → BGG pipeline</small></span>
</a>
<nav aria-label="Primary">
<!--NAV-->
</nav>
<figure class="piperbox">
<img src="/static/logo-full.jpg"
alt="the bggpipe piper — a bagpiper whose bag is a board game box">
<figcaption>art by Juniper</figcaption>
<figcaption class="legal">BoardGameGeek and BGG are trademarks of
BoardGameGeek,&nbsp;LLC. bggpipe is an independent project, not
affiliated with or endorsed by BoardGameGeek.</figcaption>
</figure>
</div>
<div class="content">
<div id="banner"></div>
<script src="/static/app.js"></script>
<main id="main">
<!--PAGE-->
</main>
</div>
</body>
</html>
+129 -21
View File
@@ -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), Pipeline (/), Photos, Review, Catalog, Queue, and Library — rendered
served on localhost only. All decision logic and matches.csv writes go server-side from templates/shell.html plus one fragment per page, with
through ReviewSession — this module is purely an interface. Also renders static/app.css as the design system and static/app.js as shared client
data/unidentified.json as reshoot work-orders with a persisted dismiss plumbing. No template engine and no build step; served on localhost only.
action (data/unidentified_dismissed.json survives extract rebuilds).
The page layout is shared-shell by design: a future "browse" view of All decision logic and matches.csv writes go through ReviewSession —
games.json mounts as a sibling section without touching the review code. 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 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"} PHOTO_SUFFIXES = {".jpg", ".jpeg", ".png", ".heic"}
@@ -223,6 +235,19 @@ def create_app(
revision["n"] += 1 revision["n"] += 1
thumbnails = load_thumbnails(cfg.cache_dir) 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: def find_row(title_raw: str, source_photos: str, row_ix: int | None = None) -> dict:
freshen() freshen()
row = session.find_row(title_raw, source_photos, row_ix) row = session.find_row(title_raw, source_photos, row_ix)
@@ -233,7 +258,11 @@ def create_app(
def photo_names() -> set[str]: def photo_names() -> set[str]:
if not cfg.photos_dir.is_dir(): if not cfg.photos_dir.is_dir():
return set() 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: def row_payload(row: dict) -> dict:
entry = session.cues_for(row["title_raw"], row["source_photos"]) entry = session.cues_for(row["title_raw"], row["source_photos"])
@@ -302,16 +331,7 @@ def create_app(
for r in session.rows for r in session.rows
if r["version_status"] in CONFIDENT_VERSION_STATUSES and r["version_id"] if r["version_status"] in CONFIDENT_VERSION_STATUSES and r["version_id"]
) )
available = photo_names() sightings = open_sightings()
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}
)
merges = [ merges = [
{ {
"row_ix": _ix_of(session.rows, r), "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' <a href="{href}"'
+ (' aria-current="page"' if page == name else "")
+ f">{label}"
+ (f'<span class="navbadge" data-badge="{badge}"></span>' if badge else "")
+ "</a>"
for href, page, label, badge in NAV_PAGES
)
return (
shell.replace("<!--TITLE-->", f"bggpipe — {name}")
.replace("<!--NAV-->", nav)
.replace("<!--PAGE-->", fragment)
)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index() -> str: 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) @app.get("/review", response_class=HTMLResponse)
def review_page() -> str: 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: def _csv_count(path: Path) -> int:
if not path.exists(): if not path.exists():
@@ -387,6 +493,7 @@ def create_app(
}, },
"stub_data": any(m.exists() for m in cfg.stub_marker_paths), "stub_data": any(m.exists() for m in cfg.stub_marker_paths),
"photos": len(photo_names()), "photos": len(photo_names()),
"reshoot": len(open_sightings()),
"titles": len(session.titles), "titles": len(session.titles),
"matches": dict(match_counts), "matches": dict(match_counts),
"pending_review": len(session.pending_rows()) "pending_review": len(session.pending_rows())
@@ -509,6 +616,7 @@ def create_app(
_STATIC = { _STATIC = {
"app.css": "text/css", "app.css": "text/css",
"app.js": "text/javascript",
"logo.jpg": "image/jpeg", "logo.jpg": "image/jpeg",
"logo-full.jpg": "image/jpeg", "logo-full.jpg": "image/jpeg",
"favicon.png": "image/png", "favicon.png": "image/png",
+101
View File
@@ -214,3 +214,104 @@ def test_both_pages_carry_navigation_and_skip_link(tmp_path):
def test_activity_region_announces_politely(tmp_path): def test_activity_region_announces_politely(tmp_path):
html = _app(_cfg(tmp_path)).get("/").text html = _app(_cfg(tmp_path)).get("/").text
assert 'aria-live="polite"' in html 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