Web review UI: bggpipe review --web (FastAPI, localhost, no build step)

One self-contained page (inline CSS/JS, system fonts, works offline):
match cards show source photos, extracted cues, and candidates with
cached-XML thumbnails (placeholder tiles until real fixtures exist);
actions are pick / manual BGG id / reject, plus a skippable editions
pass (pick or unknown). Keyboard-first: j/k navigate, 1-9 pick, r
reject, m manual, u unknown, d dismiss. Every decision writes
matches.csv through the same ReviewSession methods the TUI now shares —
the TUI remains as the no-flag fallback. unidentified.json renders as
visually distinct reshoot work-orders with dismissals persisted in
data/unidentified_dismissed.json (survives extract rebuilds). Progress
tally and a diff-ready done screen; photo serving is allowlisted to
photos/ contents; server binds 127.0.0.1 only. Layout leaves room for
a later games.json browse view.

Provenance guard: fixture generators now write STUB_FIXTURES.marker
into their cache dirs, and CLAUDE.md gains the hard rule that stub-
resolved version_ids are placeholders — upload must refuse to run
while data/bgg_cache/STUB_FIXTURES.marker exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 15:13:29 -04:00
co-authored by Claude Fable 5
parent 4af83d3626
commit 2273ac7cf7
11 changed files with 1070 additions and 16 deletions
+15 -4
View File
@@ -59,12 +59,23 @@ def resolve(
@app.command()
def review(config: ConfigOpt = None) -> None:
def review(
web: Annotated[
bool, typer.Option("--web", help="Serve the review UI on localhost")
] = False,
port: Annotated[int, typer.Option("--port", help="Port for --web")] = 8377,
config: ConfigOpt = None,
) -> None:
"""Stage 3: human review of ambiguous/unmatched items."""
from bggpipe.review import run_review
cfg = load_config(config)
run_review(cfg)
if web:
from bggpipe.webreview import run_web_review
run_web_review(cfg, port=port)
else:
from bggpipe.review import run_review
run_review(cfg)
@app.command()
+43 -12
View File
@@ -109,6 +109,45 @@ class ReviewSession:
shim.version_candidates, ensure_ascii=False
)
# -- decision API (shared by the TUI and the web UI) ----------------
def pending_rows(self) -> list[dict]:
return [r for r in self.rows if r["match_status"] in ("ambiguous", "unmatched")]
def version_rows(self) -> list[dict]:
return [r for r in self.rows if r["version_status"] == "version_ambiguous"]
def cues_for(self, title_raw: str):
return self._titles.get(title_raw)
def decide_pick(self, row: dict, candidate: dict) -> None:
self._apply_choice(row, candidate)
def decide_manual(self, row: dict, bgg_id: int) -> None:
self._manual_id(row, bgg_id)
def decide_reject(self, row: dict) -> None:
row["match_status"] = "rejected"
self._save()
def decide_version(self, row: dict, version_id: int | None) -> None:
"""Pick a version from the row's stored candidates, or None -> unknown."""
if version_id is None:
row["version_status"] = "version_unknown"
row["version_id"] = ""
row["version_name"] = ""
else:
candidates = json.loads(row["version_candidates_json"] or "[]")
chosen = next(
(v for v in candidates if v.get("version_id") == version_id), None
)
if chosen is None:
raise ValueError(f"version {version_id} is not a stored candidate")
row["version_status"] = "version_approved"
row["version_id"] = str(version_id)
row["version_name"] = chosen.get("name") or ""
self._save()
# -- displays -------------------------------------------------------
def _show_item(self, row: dict, candidates: list[dict]) -> None:
@@ -152,11 +191,10 @@ class ReviewSession:
if lowered == "s":
return
if lowered == "r":
row["match_status"] = "rejected"
self._save()
self.decide_reject(row)
return
if answer.isdigit() and 1 <= int(answer) <= len(candidates):
self._apply_choice(row, candidates[int(answer) - 1])
self.decide_pick(row, candidates[int(answer) - 1])
return
if lowered.startswith("m ") and answer[2:].strip().isdigit():
self._manual_id(row, int(answer[2:].strip()))
@@ -214,17 +252,10 @@ class ReviewSession:
if lowered == "s":
return
if lowered == "u":
row["version_status"] = "version_unknown"
row["version_id"] = ""
row["version_name"] = ""
self._save()
self.decide_version(row, None)
return
if answer.isdigit() and 1 <= int(answer) <= len(candidates):
chosen = candidates[int(answer) - 1]
row["version_status"] = "version_approved"
row["version_id"] = str(chosen.get("version_id") or "")
row["version_name"] = chosen.get("name") or ""
self._save()
self.decide_version(row, candidates[int(answer) - 1].get("version_id"))
return
self.console.print("[yellow]didn't understand that — try again[/yellow]")
+419
View File
@@ -0,0 +1,419 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bggpipe review</title>
<style>
:root {
--felt: #2c4136;
--felt-deep: #24362d;
--paper: #f7f4ec;
--paper-edge: #e6e0d2;
--ink: #24291f;
--ink-soft: #5c6355;
--brass: #c08f2f;
--brass-deep: #93691c;
--kraft: #efe4cd;
--approve: #3e6b4f;
--reject: #96473a;
--focus: #7fb08f;
}
* { box-sizing: border-box; }
html { background: var(--felt); }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.06), transparent 60%),
var(--felt);
min-height: 100vh;
}
header {
position: sticky; top: 0; z-index: 5;
background: var(--felt-deep);
color: var(--paper);
padding: .6rem 1.2rem;
display: flex; align-items: baseline; gap: 1.2rem; flex-wrap: wrap;
border-bottom: 1px solid rgba(255,255,255,.12);
}
.wordmark {
font-family: "Iowan Old Style", Palatino, Georgia, serif;
font-size: 1.25rem; letter-spacing: .02em;
}
.wordmark small { opacity: .55; font-family: system-ui, sans-serif; font-size: .75rem; margin-left: .5rem; }
#tally { font-size: .85rem; opacity: .85; display: flex; gap: 1rem; }
#tally b { color: var(--brass); font-weight: 600; }
.keyhelp { margin-left: auto; font-size: .75rem; opacity: .6; }
kbd {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: .72rem;
background: var(--paper);
color: var(--ink);
border: 1px solid var(--paper-edge);
border-bottom-width: 2px;
border-radius: 4px;
padding: 0 .35em;
display: inline-block; min-width: 1.4em; text-align: center;
}
header kbd { background: rgba(255,255,255,.14); color: var(--paper); border-color: rgba(255,255,255,.25); }
main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; }
h2 {
color: var(--paper);
font-family: "Iowan Old Style", Palatino, Georgia, serif;
font-weight: 500; font-size: 1.05rem; letter-spacing: .04em;
margin: 2rem 0 .8rem;
}
h2 .count { opacity: .6; font-size: .85rem; }
.card {
background: var(--paper);
border-radius: 8px;
border-left: 6px solid transparent;
box-shadow: 0 1px 3px rgba(0,0,0,.35);
padding: 1rem 1.1rem;
margin-bottom: 1rem;
display: flex; gap: 1.1rem;
scroll-margin-top: 5rem;
}
.card.active {
border-left-color: var(--brass);
box-shadow: 0 6px 18px rgba(0,0,0,.45);
}
@media (prefers-reduced-motion: no-preference) {
.card { transition: box-shadow .15s ease, border-color .15s ease; }
}
.shots { flex: 0 0 180px; display: flex; flex-direction: column; gap: .5rem; }
.shots img { width: 100%; border-radius: 4px; border: 1px solid var(--paper-edge); display: block; }
.shots .noshot {
color: var(--ink-soft); font-size: .8rem; border: 1px dashed var(--paper-edge);
border-radius: 4px; padding: 1.2rem .6rem; text-align: center;
}
.body { flex: 1; min-width: 0; }
.title { font-size: 1.15rem; font-weight: 650; margin: 0 0 .15rem; }
.status { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
.cues { margin: .5rem 0 .7rem; display: flex; flex-wrap: wrap; gap: .35rem; }
.cue {
font-size: .74rem; background: #eee9db; border: 1px solid var(--paper-edge);
border-radius: 999px; padding: .1rem .6rem; color: var(--ink);
}
.cue b { font-weight: 600; color: var(--ink-soft); }
.cands { list-style: none; margin: 0; padding: 0; }
.cands li {
display: flex; align-items: center; gap: .6rem;
padding: .4rem .5rem; border-radius: 6px; cursor: pointer;
}
.cands li:hover { background: #ede8da; }
.thumb { width: 42px; height: 42px; border-radius: 4px; object-fit: cover; border: 1px solid var(--paper-edge); flex: 0 0 42px; }
.thumb.ph {
display: flex; align-items: center; justify-content: center;
background: var(--felt); color: var(--paper);
font-family: "Iowan Old Style", Palatino, Georgia, serif; font-size: 1.2rem;
}
.cname { font-weight: 600; }
.cmeta { color: var(--ink-soft); font-size: .8rem; margin-left: .4rem; }
.rowactions { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; }
.rowactions button {
font: inherit; border: 1px solid var(--paper-edge); background: #fff;
border-radius: 6px; padding: .25rem .7rem; cursor: pointer;
}
.rowactions button.reject { color: var(--reject); border-color: var(--reject); }
.rowactions input[type=text] {
font: inherit; width: 8.5em; padding: .25rem .5rem;
border: 1px solid var(--paper-edge); border-radius: 6px;
}
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
/* reshoot work-orders: deliberately not cards, tickets */
.ticket {
background: var(--kraft);
border: 2px dashed var(--brass-deep);
border-radius: 4px;
padding: .8rem 1rem;
margin-bottom: .8rem;
display: flex; gap: 1rem; align-items: flex-start;
scroll-margin-top: 5rem;
}
.ticket.active { border-style: solid; box-shadow: 0 6px 18px rgba(0,0,0,.45); }
.ticket .stencil {
writing-mode: vertical-rl; text-orientation: mixed;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: .7rem; letter-spacing: .35em; font-weight: 700;
color: var(--brass-deep); text-transform: uppercase;
border-right: 1px solid var(--brass-deep); padding-right: .5rem;
}
.ticket img { width: 130px; border-radius: 3px; border: 1px solid var(--brass-deep); }
.ticket .loc { font-weight: 600; margin-bottom: .25rem; }
.ticket .partial { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .85rem; }
.ticket .notes { color: #6b5b33; font-size: .85rem; margin-top: .2rem; }
.ticket button {
font: inherit; font-size: .8rem; margin-top: .5rem;
background: none; border: 1px solid var(--brass-deep); color: var(--brass-deep);
border-radius: 6px; padding: .2rem .6rem; cursor: pointer;
}
.done {
background: var(--paper); border-radius: 8px; padding: 1.6rem;
text-align: center; box-shadow: 0 6px 18px rgba(0,0,0,.4);
}
.done h2 { color: var(--ink); margin-top: 0; }
.done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
.done .nums div { font-size: 1.6rem; font-weight: 700; }
.done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
.done code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
background: var(--felt); color: var(--paper);
padding: .3rem .8rem; border-radius: 6px;
}
@media (max-width: 700px) {
.card, .ticket { flex-direction: column; }
.shots { flex-basis: auto; }
}
</style>
</head>
<body>
<header>
<span class="wordmark">bggpipe <small>review</small></span>
<span id="tally"></span>
<span class="keyhelp">
<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>d</kbd> dismiss
</span>
</header>
<main id="main"></main>
<script>
"use strict";
let STATE = null;
let active = 0;
const esc = s => String(s ?? "").replace(/[&<>"']/g,
c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
async function refresh() {
STATE = await (await fetch("/api/state")).json();
render();
}
async function post(url, body) {
const res = await fetch(url, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = (await res.json()).detail || res.statusText;
alert("That didn't save: " + detail);
return;
}
STATE = await res.json();
render();
}
function cueChips(cues) {
const parts = [];
if (cues.publisher) parts.push(`<span class="cue"><b>publisher</b> ${esc(cues.publisher)}</span>`);
if (cues.edition) parts.push(`<span class="cue"><b>edition</b> ${esc(cues.edition)}</span>`);
if (cues.year) parts.push(`<span class="cue"><b>year</b> ${esc(cues.year)}</span>`);
if (cues.language) parts.push(`<span class="cue"><b>language</b> ${esc(cues.language)}</span>`);
if (cues.art_notes) parts.push(`<span class="cue"><b>art</b> ${esc(cues.art_notes)}</span>`);
return parts.length ? `<div class="cues">${parts.join("")}</div>` : "";
}
function shots(photos) {
if (!photos.length) return `<div class="shots"><div class="noshot">photo not on disk</div></div>`;
return `<div class="shots">` + photos.map(p =>
`<a href="/photos/${encodeURIComponent(p)}" target="_blank" tabindex="-1">
<img src="/photos/${encodeURIComponent(p)}" alt="source photo ${esc(p)}"></a>`
).join("") + `</div>`;
}
function thumbHtml(c) {
if (c.thumbnail) return `<img class="thumb" src="${esc(c.thumbnail)}" alt="">`;
const initial = (c.name || "?").trim().charAt(0).toUpperCase();
return `<div class="thumb ph" aria-hidden="true">${esc(initial)}</div>`;
}
function matchCard(row, idx) {
const cands = row.candidates.map((c, i) => `
<li data-pick="${c.bgg_id}" title="press ${i + 1}">
<kbd>${i + 1}</kbd> ${thumbHtml(c)}
<span><span class="cname">${esc(c.name)}</span>
<span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")}
· rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}</span></span>
</li>`).join("");
return `
<section class="card actionable" data-kind="match" data-idx="${idx}"
data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
${shots(row.photos)}
<div class="body">
<p class="title">${esc(row.title_raw)}</p>
<p class="status">${esc(row.match_status)} · ${idx + 1} of ${STATE.pending.length + STATE.versions.length} to review</p>
${cueChips(row.cues)}
<ol class="cands">${cands || "<li class='cmeta'>no candidates — enter a BGG id or reject</li>"}</ol>
<div class="rowactions">
<span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎"
aria-label="manual BGG id"></span>
<button class="reject" title="press r"><kbd>r</kbd> reject — not a game / bad read</button>
</div>
</div>
</section>`;
}
function versionCard(row, idx) {
const cands = row.candidates.map((v, i) => `
<li data-pickver="${v.version_id}" title="press ${i + 1}">
<kbd>${i + 1}</kbd>
<span><span class="cname">${esc(v.name)}</span>
<span class="cmeta">${esc(v.year ?? "—")} · ${esc((v.publishers || []).join(", "))}
· ${esc((v.languages || []).join(", "))} · score ${esc(v.score ?? "—")}</span></span>
</li>`).join("");
return `
<section class="card actionable" data-kind="version" data-idx="${idx}"
data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
<div class="body">
<p class="title">${esc(row.bgg_name || row.title_raw)}</p>
<p class="status">which edition? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
<ol class="cands">${cands}</ol>
<div class="rowactions">
<button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button>
</div>
</div>
</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() {
const m = document.getElementById("main");
const s = STATE;
document.getElementById("tally").innerHTML =
`<span><b>${s.pending.length}</b> matches</span>
<span><b>${s.versions.length}</b> editions</span>
<span><b>${s.unidentified.length}</b> reshoot</span>
<span>${s.decisions} decided this sitting</span>`;
let html = "";
if (!s.pending.length && !s.versions.length) {
html += `
<div class="done">
<h2>All reviewed — this catalog is diff-ready</h2>
<div class="nums">
<div>${s.summary.recognized}<span>recognized</span></div>
<div>${s.summary.version_updates}<span>with versions</span></div>
<div>${s.summary.rejected}<span>rejected</span></div>
</div>
<p>Next: <code>uv run bggpipe diff</code></p>
${s.unidentified.length ? `<p class="status">${s.unidentified.length} reshoot ticket(s) below — they don't block the diff.</p>` : ""}
</div>`;
}
if (s.pending.length) {
html += `<h2>Matches <span class="count">— pick the game each photo shows</span></h2>`;
html += s.pending.map(matchCard).join("");
}
if (s.versions.length) {
html += `<h2>Editions <span class="count">— optional pass, never blocks uploads</span></h2>`;
html += s.versions.map(versionCard).join("");
}
if (s.unidentified.length) {
html += `<h2>Reshoot <span class="count">— boxes seen but not identified</span></h2>`;
html += s.unidentified.map(ticket).join("");
}
m.innerHTML = html;
const cards = actionables();
if (active >= cards.length) active = Math.max(0, cards.length - 1);
highlight();
m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
const card = li.closest(".card");
decide(card, "pick", Number(li.dataset.pick));
});
m.querySelectorAll("[data-pickver]").forEach(li => li.onclick = () => {
const card = li.closest(".card");
version(card, "pick", Number(li.dataset.pickver));
});
m.querySelectorAll(".reject").forEach(b => b.onclick = () =>
decide(b.closest(".card"), "reject"));
m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
version(b.closest(".card"), "unknown"));
m.querySelectorAll(".ticket button").forEach(b => b.onclick = () =>
dismiss(b.closest(".ticket")));
m.querySelectorAll(".rowactions input").forEach(inp => {
inp.onkeydown = e => {
if (e.key === "Enter" && inp.value.trim().match(/^\d+$/)) {
decide(inp.closest(".card"), "manual", Number(inp.value.trim()));
}
if (e.key === "Escape") inp.blur();
e.stopPropagation();
};
});
}
const actionables = () => [...document.querySelectorAll(".actionable")];
function highlight() {
actionables().forEach((el, i) => el.classList.toggle("active", i === active));
const el = actionables()[active];
if (el) el.scrollIntoView({block: "nearest", behavior: "auto"});
}
const decide = (card, action, bgg_id = null) => post("/api/decision", {
title_raw: card.dataset.title, source_photos: card.dataset.photos,
action, bgg_id,
});
const version = (card, action, version_id = null) => post("/api/version", {
title_raw: card.dataset.title, source_photos: card.dataset.photos,
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,
});
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT") return;
const cards = actionables();
if (!cards.length) return;
const card = cards[active];
const kind = card?.dataset.kind;
if (e.key === "j" || e.key === "ArrowDown") { active = Math.min(active + 1, cards.length - 1); highlight(); }
else if (e.key === "k" || e.key === "ArrowUp") { active = Math.max(active - 1, 0); highlight(); }
else if (/^[1-9]$/.test(e.key) && kind === "match") {
const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
if (li) decide(card, "pick", Number(li.dataset.pick));
}
else if (/^[1-9]$/.test(e.key) && kind === "version") {
const li = card.querySelectorAll("[data-pickver]")[Number(e.key) - 1];
if (li) version(card, "pick", Number(li.dataset.pickver));
}
else if (e.key === "r" && kind === "match") decide(card, "reject");
else if (e.key === "u" && kind === "version") version(card, "unknown");
else if (e.key === "d" && kind === "ticket") dismiss(card);
else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
});
refresh();
</script>
</body>
</html>
+251
View File
@@ -0,0 +1,251 @@
"""`bggpipe review --web` — the review TUI's local web face.
FastAPI + one self-contained HTML page (inline CSS/JS, no build step),
served on localhost only. All decision logic and matches.csv writes go
through ReviewSession this module is purely an interface. Also renders
data/unidentified.json as reshoot work-orders with a persisted dismiss
action (data/unidentified_dismissed.json survives extract rebuilds).
The page layout is shared-shell by design: a future "browse" view of
games.json mounts as a sibling section without touching the review code.
"""
from __future__ import annotations
import io
import json
from importlib import resources
from pathlib import Path
import typer
from defusedxml.ElementTree import fromstring as _safe_fromstring
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
from rich.console import Console
from bggpipe.bgg_client import BGGClient
from bggpipe.config import Config
from bggpipe.review import ReviewSession
DEFAULT_PORT = 8377
def load_thumbnails(cache_dir: Path) -> dict[int, str]:
"""bgg_id -> thumbnail URL, from cached /thing XML only (no live calls).
Stub fixtures carry no thumbnails the UI shows placeholders then."""
thumbnails: dict[int, str] = {}
if not cache_dir.is_dir():
return thumbnails
for path in cache_dir.glob("thing_*.xml"):
try:
root = _safe_fromstring(path.read_text())
except Exception: # noqa: BLE001 — a corrupt cache file must not kill the UI
continue
for item in root.findall("item"):
thumb = (item.findtext("thumbnail") or "").strip()
if thumb and item.get("id"):
thumbnails[int(item.get("id"))] = thumb
return thumbnails
def _sighting_key(photo: str, sighting: dict) -> str:
return "|".join(
[
photo,
sighting.get("location", ""),
sighting.get("partial_text", ""),
sighting.get("art_notes", ""),
]
)
class DismissStore:
"""Dismissed reshoot sightings, kept apart from unidentified.json so
extract's rebuilds can't resurrect them."""
def __init__(self, path: Path) -> None:
self.path = path
self.keys: set[str] = (
set(json.loads(path.read_text())) if path.exists() else set()
)
def add(self, key: str) -> None:
self.keys.add(key)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(sorted(self.keys), indent=2) + "\n")
class DecisionBody(BaseModel):
title_raw: str
source_photos: str
action: str # "pick" | "manual" | "reject"
bgg_id: int | None = None
class VersionBody(BaseModel):
title_raw: str
source_photos: str
action: str # "pick" | "unknown"
version_id: int | None = None
class DismissBody(BaseModel):
photo: str
location: str = ""
partial_text: str = ""
art_notes: str = ""
def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
app = FastAPI(title="bggpipe review")
session = ReviewSession(
cfg,
console=Console(file=io.StringIO()),
input_fn=lambda prompt: "",
client=client,
)
thumbnails = load_thumbnails(cfg.cache_dir)
dismissed = DismissStore(cfg.data_dir / "unidentified_dismissed.json")
def find_row(title_raw: str, source_photos: str) -> dict:
for row in session.rows:
if row["title_raw"] == title_raw and row["source_photos"] == source_photos:
return row
raise HTTPException(404, "row not found — matches.csv changed underneath?")
def photo_names() -> set[str]:
if not cfg.photos_dir.is_dir():
return set()
return {p.name for p in cfg.photos_dir.iterdir() if p.is_file()}
def row_payload(row: dict) -> dict:
entry = session.cues_for(row["title_raw"])
available = photo_names()
candidates = json.loads(row["candidates_json"] or "[]")
for c in candidates:
c["thumbnail"] = thumbnails.get(c.get("bgg_id"))
return {
"title_raw": row["title_raw"],
"source_photos": row["source_photos"],
"match_status": row["match_status"],
"photos": [p for p in row["source_photos"].split(";") if p in available],
"cues": {
"publisher": entry.publisher_hint if entry else "",
"edition": entry.edition_hint if entry else "",
"year": entry.year_hint if entry else None,
"language": entry.language_hint if entry else "",
"art_notes": entry.art_notes if entry else "",
},
"candidates": candidates,
}
def version_payload(row: dict) -> dict:
return {
"title_raw": row["title_raw"],
"source_photos": row["source_photos"],
"bgg_name": row["bgg_name"],
"candidates": json.loads(row["version_candidates_json"] or "[]"),
}
def state() -> dict:
counts: dict[str, int] = {}
for row in session.rows:
counts[row["match_status"]] = counts.get(row["match_status"], 0) + 1
version_updates = sum(
1
for r in session.rows
if r["version_status"] in ("version_auto", "version_approved")
and r["version_id"]
)
available = photo_names()
sightings = []
if cfg.unidentified_path.exists():
for photo, entries in json.loads(cfg.unidentified_path.read_text()).items():
for s in entries:
if _sighting_key(photo, s) in dismissed.keys:
continue
sightings.append(
{**s, "photo": photo, "photo_exists": photo in available}
)
return {
"pending": [row_payload(r) for r in session.pending_rows()],
"versions": [version_payload(r) for r in session.version_rows()],
"unidentified": sightings,
"decisions": session.decisions,
"summary": {
"recognized": counts.get("auto", 0) + counts.get("approved", 0),
"ambiguous": counts.get("ambiguous", 0),
"unmatched": counts.get("unmatched", 0),
"rejected": counts.get("rejected", 0),
"version_updates": version_updates,
"total": len(session.rows),
},
}
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return (resources.files("bggpipe") / "templates" / "review.html").read_text()
@app.get("/api/state")
def api_state() -> dict:
return state()
@app.post("/api/decision")
def api_decision(body: DecisionBody) -> dict:
row = find_row(body.title_raw, body.source_photos)
if body.action == "pick":
candidates = json.loads(row["candidates_json"] or "[]")
chosen = next(
(c for c in candidates if c.get("bgg_id") == body.bgg_id), None
)
if chosen is None:
raise HTTPException(400, f"bgg_id {body.bgg_id} is not a candidate")
session.decide_pick(row, chosen)
elif body.action == "manual":
if not body.bgg_id:
raise HTTPException(400, "manual decision needs a bgg_id")
session.decide_manual(row, body.bgg_id)
elif body.action == "reject":
session.decide_reject(row)
else:
raise HTTPException(400, f"unknown action {body.action!r}")
return state()
@app.post("/api/version")
def api_version(body: VersionBody) -> dict:
row = find_row(body.title_raw, body.source_photos)
if body.action == "unknown":
session.decide_version(row, None)
elif body.action == "pick":
try:
session.decide_version(row, body.version_id)
except ValueError as err:
raise HTTPException(400, str(err)) from err
else:
raise HTTPException(400, f"unknown action {body.action!r}")
return state()
@app.post("/api/dismiss")
def api_dismiss(body: DismissBody) -> dict:
dismissed.add(_sighting_key(body.photo, body.model_dump(exclude={"photo"})))
return state()
@app.get("/photos/{name}")
def photo(name: str):
if name not in photo_names(): # also blocks any path traversal
raise HTTPException(404, "no such photo")
return FileResponse(cfg.photos_dir / name)
return app
def run_web_review(cfg: Config, *, port: int = DEFAULT_PORT) -> None:
import uvicorn
app = create_app(cfg)
typer.echo(
f"Review UI: http://127.0.0.1:{port}/ (localhost only; every "
"decision saves to matches.csv immediately — Ctrl-C anytime)"
)
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")