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:
@@ -20,6 +20,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **Every stage is idempotent and resumable** — killing mid-run and restarting must lose no work; re-runs skip already-processed items.
|
||||
- Use only the XML API2 and the public website — no undocumented BGG endpoints (BGG tightened access policies in 2025).
|
||||
- BGG has **no write API**: writes drive the real website with a logged-in Playwright session.
|
||||
- **Stub-resolved data is never upload-ready.** All version_ids (and some game data) in `matches.csv`, `to_add.csv`, and `to_update.csv` currently come from SYNTHETIC stub fixtures — placeholders until real fixtures exist. When `BGG_API_TOKEN` arrives: delete both cache dirs, re-record fixtures, `resolve --force`, re-review. The caches carry a `STUB_FIXTURES.marker` provenance file (written by the fixture generators); the upload stage MUST refuse to run while `data/bgg_cache/STUB_FIXTURES.marker` exists.
|
||||
|
||||
## Domain gotchas
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ dependencies = [
|
||||
"pillow>=12.3.0",
|
||||
"pillow-heif>=1.5.0",
|
||||
"rich>=15.0.0",
|
||||
"fastapi>=0.141.1",
|
||||
"uvicorn>=0.52.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -348,6 +348,12 @@ def main() -> None:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for name, xml in files.items():
|
||||
(target / name).write_text(xml)
|
||||
# provenance marker: anything resolved from this cache is stub-derived
|
||||
# and NOT upload-ready; re-recording real fixtures removes the marker
|
||||
(target / "STUB_FIXTURES.marker").write_text(
|
||||
"This cache contains hand-written stub XML, not real BGG "
|
||||
"responses. Data resolved from it must not be uploaded.\n"
|
||||
)
|
||||
print(f"Wrote {len(files)} fixture file(s) to {' and '.join(map(str, TARGETS))}")
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ THINGS = {
|
||||
|
||||
def main() -> None:
|
||||
FIXTURE_CACHE.mkdir(parents=True, exist_ok=True)
|
||||
(FIXTURE_CACHE / "STUB_FIXTURES.marker").write_text(
|
||||
"This cache contains hand-written stub XML, not real BGG "
|
||||
"responses. Data resolved from it must not be uploaded.\n"
|
||||
)
|
||||
for query, items in SEARCHES.items():
|
||||
key = cache_key("search", {"query": query, "type": SEARCH_TYPES})
|
||||
total = items.count("<item ")
|
||||
|
||||
+13
-2
@@ -59,11 +59,22 @@ 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."""
|
||||
cfg = load_config(config)
|
||||
if web:
|
||||
from bggpipe.webreview import run_web_review
|
||||
|
||||
run_web_review(cfg, port=port)
|
||||
else:
|
||||
from bggpipe.review import run_review
|
||||
|
||||
cfg = load_config(config)
|
||||
run_review(cfg)
|
||||
|
||||
|
||||
|
||||
+43
-12
@@ -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]")
|
||||
|
||||
|
||||
@@ -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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
This cache contains hand-written stub XML, not real BGG responses. Data resolved from it must not be uploaded.
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Web review UI tests via FastAPI's TestClient — no server, no network,
|
||||
no live BGG (the injected client 401s on any cache miss)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from bggpipe.bgg_client import BGGClient, cache_key
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.resolve import read_matches, write_matches
|
||||
from bggpipe.webreview import create_app, load_thumbnails
|
||||
|
||||
CITADELS_CANDIDATES = json.dumps(
|
||||
[
|
||||
{
|
||||
"bgg_id": 478,
|
||||
"name": "Citadels",
|
||||
"year": 2000,
|
||||
"type": "boardgame",
|
||||
"owned": 85000,
|
||||
"rank": 250,
|
||||
},
|
||||
{
|
||||
"bgg_id": 205398,
|
||||
"name": "Citadels",
|
||||
"year": 2016,
|
||||
"type": "boardgame",
|
||||
"owned": 24000,
|
||||
"rank": 400,
|
||||
},
|
||||
]
|
||||
)
|
||||
VERSION_CANDIDATES = json.dumps(
|
||||
[
|
||||
{
|
||||
"version_id": 111,
|
||||
"name": "First edition",
|
||||
"year": 1975,
|
||||
"publishers": ["TSR"],
|
||||
"languages": ["English"],
|
||||
"score": 3,
|
||||
},
|
||||
{
|
||||
"version_id": 222,
|
||||
"name": "Second edition",
|
||||
"year": 1980,
|
||||
"publishers": ["TSR"],
|
||||
"languages": ["English"],
|
||||
"score": 3,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _row(**overrides) -> dict:
|
||||
row = {
|
||||
"title_raw": "",
|
||||
"bgg_id": "",
|
||||
"bgg_name": "",
|
||||
"year": "",
|
||||
"type": "",
|
||||
"match_status": "auto",
|
||||
"version_id": "",
|
||||
"version_name": "",
|
||||
"version_status": "version_unknown",
|
||||
"candidates_json": "[]",
|
||||
"version_candidates_json": "[]",
|
||||
"source_photos": "shelf.jpg",
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
def make_cfg(tmp_path) -> Config:
|
||||
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
||||
cfg.photos_dir.mkdir(parents=True)
|
||||
(cfg.photos_dir / "shelf.jpg").write_bytes(b"\xff\xd8\xff\xdbfakejpeg")
|
||||
write_matches(
|
||||
cfg.matches_path,
|
||||
[
|
||||
_row(
|
||||
title_raw="Citadels",
|
||||
match_status="ambiguous",
|
||||
candidates_json=CITADELS_CANDIDATES,
|
||||
),
|
||||
_row(title_raw="Mystery", match_status="unmatched"),
|
||||
_row(
|
||||
title_raw="Dungeon!",
|
||||
match_status="auto",
|
||||
bgg_id="1339",
|
||||
bgg_name="Dungeon!",
|
||||
version_status="version_ambiguous",
|
||||
version_candidates_json=VERSION_CANDIDATES,
|
||||
),
|
||||
],
|
||||
)
|
||||
cfg.titles_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"title_raw": "Citadels",
|
||||
"publisher_hint": "Fantasy Flight",
|
||||
"edition_hint": "",
|
||||
"source_photos": ["shelf.jpg"],
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
cfg.unidentified_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"shelf.jpg": [
|
||||
{
|
||||
"location": "top shelf, far left",
|
||||
"partial_text": "EMP",
|
||||
"art_notes": "black box, gold letters",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def unauthorized_client(tmp_path) -> BGGClient:
|
||||
return BGGClient(
|
||||
cache_dir=tmp_path / "no_cache",
|
||||
transport=httpx.MockTransport(
|
||||
lambda req: httpx.Response(401, text="Unauthorized")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def make_client(tmp_path) -> tuple[TestClient, Config]:
|
||||
cfg = make_cfg(tmp_path)
|
||||
app = create_app(cfg, client=unauthorized_client(tmp_path))
|
||||
return TestClient(app), cfg
|
||||
|
||||
|
||||
def test_index_serves_page(tmp_path):
|
||||
web, _ = make_client(tmp_path)
|
||||
response = web.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "bggpipe" in response.text
|
||||
|
||||
|
||||
def test_state_lists_pending_versions_and_tickets(tmp_path):
|
||||
web, _ = make_client(tmp_path)
|
||||
state = web.get("/api/state").json()
|
||||
assert [r["title_raw"] for r in state["pending"]] == ["Citadels", "Mystery"]
|
||||
citadels = state["pending"][0]
|
||||
assert citadels["cues"]["publisher"] == "Fantasy Flight"
|
||||
assert citadels["photos"] == ["shelf.jpg"]
|
||||
assert citadels["candidates"][0]["thumbnail"] is None # stub cache: placeholder
|
||||
assert [v["title_raw"] for v in state["versions"]] == ["Dungeon!"]
|
||||
assert state["unidentified"][0]["location"] == "top shelf, far left"
|
||||
assert state["summary"]["total"] == 3
|
||||
|
||||
|
||||
def test_pick_candidate_persists(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
state = web.post(
|
||||
"/api/decision",
|
||||
json={
|
||||
"title_raw": "Citadels",
|
||||
"source_photos": "shelf.jpg",
|
||||
"action": "pick",
|
||||
"bgg_id": 205398,
|
||||
},
|
||||
).json()
|
||||
assert [r["title_raw"] for r in state["pending"]] == ["Mystery"]
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Citadels"]["match_status"] == "approved"
|
||||
assert saved["Citadels"]["bgg_id"] == "205398"
|
||||
|
||||
|
||||
def test_manual_id_degrades_without_token(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
response = web.post(
|
||||
"/api/decision",
|
||||
json={
|
||||
"title_raw": "Mystery",
|
||||
"source_photos": "shelf.jpg",
|
||||
"action": "manual",
|
||||
"bgg_id": 99999,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Mystery"]["match_status"] == "approved"
|
||||
assert saved["Mystery"]["bgg_id"] == "99999"
|
||||
assert saved["Mystery"]["bgg_name"] == "" # lookup blocked, id recorded
|
||||
|
||||
|
||||
def test_reject_and_bad_pick(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
web.post(
|
||||
"/api/decision",
|
||||
json={"title_raw": "Mystery", "source_photos": "shelf.jpg", "action": "reject"},
|
||||
)
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Mystery"]["match_status"] == "rejected"
|
||||
bad = web.post(
|
||||
"/api/decision",
|
||||
json={
|
||||
"title_raw": "Citadels",
|
||||
"source_photos": "shelf.jpg",
|
||||
"action": "pick",
|
||||
"bgg_id": 42,
|
||||
},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_version_pick_and_unknown(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
state = web.post(
|
||||
"/api/version",
|
||||
json={
|
||||
"title_raw": "Dungeon!",
|
||||
"source_photos": "shelf.jpg",
|
||||
"action": "pick",
|
||||
"version_id": 222,
|
||||
},
|
||||
).json()
|
||||
assert state["versions"] == []
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Dungeon!"]["version_status"] == "version_approved"
|
||||
assert saved["Dungeon!"]["version_id"] == "222"
|
||||
assert state["summary"]["version_updates"] == 1
|
||||
|
||||
|
||||
def test_dismiss_persists_across_restarts(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
state = web.post(
|
||||
"/api/dismiss",
|
||||
json={
|
||||
"photo": "shelf.jpg",
|
||||
"location": "top shelf, far left",
|
||||
"partial_text": "EMP",
|
||||
"art_notes": "black box, gold letters",
|
||||
},
|
||||
).json()
|
||||
assert state["unidentified"] == []
|
||||
# a brand-new app instance (fresh server) still honors the dismissal
|
||||
web2 = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
||||
assert web2.get("/api/state").json()["unidentified"] == []
|
||||
|
||||
|
||||
def test_photo_serving_is_locked_down(tmp_path):
|
||||
web, _ = make_client(tmp_path)
|
||||
assert web.get("/photos/shelf.jpg").status_code == 200
|
||||
assert web.get("/photos/nope.jpg").status_code == 404
|
||||
assert web.get("/photos/..%2Fdata%2Fmatches.csv").status_code == 404
|
||||
|
||||
|
||||
def test_thumbnails_come_from_cached_thing_xml(tmp_path):
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
key = cache_key("thing", {"id": "478", "stats": "1"})
|
||||
(cache / key).write_text(
|
||||
'<items><item type="boardgame" id="478">'
|
||||
"<thumbnail>https://cf.example/citadels.jpg</thumbnail>"
|
||||
'<name type="primary" value="Citadels"/></item></items>'
|
||||
)
|
||||
thumbs = load_thumbnails(cache)
|
||||
assert thumbs == {478: "https://cf.example/citadels.jpg"}
|
||||
@@ -59,12 +59,14 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pillow-heif" },
|
||||
{ name = "rapidfuzz" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -77,12 +79,14 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "anthropic", specifier = ">=0.120.2" },
|
||||
{ name = "defusedxml", specifier = ">=0.7.1" },
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "pillow", specifier = ">=12.3.0" },
|
||||
{ name = "pillow-heif", specifier = ">=1.5.0" },
|
||||
{ name = "rapidfuzz", specifier = ">=3.9" },
|
||||
{ name = "rich", specifier = ">=15.0.0" },
|
||||
{ name = "typer", specifier = ">=0.12" },
|
||||
{ name = "uvicorn", specifier = ">=0.52.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -100,6 +104,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -136,6 +152,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -646,6 +678,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.27.0"
|
||||
@@ -681,3 +726,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.52.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user