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:
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(`<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);
|
||||||
@@ -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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|
|
||||||
@@ -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, 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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user