Credibility pass 2: the copy-then-drift tells, excised
Two blind reviewers swept the 33 commits since 10f65d8 for signs of
machine generation. Verdict: production code and copy largely clean;
the tells clustered in duplication and tests.
JS: the six-times-pasted change-detection loop (three pages honoring a
LAST-after-render invariant, three violating it) becomes one
changeGate() factory in app.js; the reshoot ticket renderer and
dismiss wiring, duplicated across photos/photo pages, become
ticketCard()/wireDismiss(); review.html's hand-rolled fetch/post
collapse onto fetchJSON/apiPost keeping only its unique
saved-but-render-failed path; dead lastGood deleted; page-state naming
unified to CAPS (ACTIVE, RUNNING); a dead defensive rowix branch gone.
CSS: header no longer claims "two pages"; --focus derives from
--accent; five state tints become tokens (the header's tokens-for-roles
promise, kept); component button rules drop declarations the global
rule supplies; duplicate color declarations trimmed.
Python: dead seen_per_title vestige removed from resolve; redundant
ternary arm in the catalog builder collapsed; csv import hoisted; twin
VetoBody/SplitBody merged into RowRef; warn-once idiom deduplicated
into a closure; a stray "a bare arrays" typo.
Tests: the one assertion that could never fail (aria-current check
with an always-true fallback) replaced by a strict per-page check
across all seven pages; the traversal test asserts escape
unconditionally; stale "both pages" names updated; nine redundant
function-local imports hoisted to their module tops.
Docs: aria role="status" set once in the shell instead of per call;
joblog gets role="log"; README's --lan paragraph becomes a proper
"From your phone" quickstart subsection with the command visible, and
the seven-page list stops restating the screenshot captions; Help's
re-extract claim matches actual behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
7cfc3c5b7d
commit
0cdbf74a02
@@ -99,9 +99,19 @@ Non-secret knobs (`photos_dir`, `data_dir`, the vision model, the rate limit) li
|
|||||||
uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
|
uv run bggpipe web # opens http://127.0.0.1:8377/ — the whole app in the browser
|
||||||
```
|
```
|
||||||
|
|
||||||
The app is localhost-only by default. `--lan` also serves it to your local network — handy for proofreading from the couch or snapping shelf photos on your phone and uploading them straight into the Photos page. It prints a pairing link carrying an access key (`?k=...`) plus a QR code — point the phone's camera at the terminal and tap. Pairing is one-time per device: the key persists across restarts (`data/.lan_key`; delete it to revoke every paired device) and the cookie lasts a year. Save the app to the phone's home screen for the full-screen treatment (piper icon included). On the Photos page the phone can shoot straight into the pipeline: tap the drop zone, choose "Take Photo," and the shot uploads with visible progress — camera captures get unique `shelf-<timestamp>` names, so rapid-fire shots never overwrite each other. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead).
|
### From your phone
|
||||||
|
|
||||||
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), **Titles** (every read off your shelves, alphabetized — and where you proofread them: fix misreads, add cues, split multi-copy lines, remove non-games), **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:
|
The app is localhost-only by default. To use it from a phone or tablet on your network — proofreading from the couch, or shooting shelf photos straight into the pipeline — serve it to the LAN instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run bggpipe web --lan # localhost + your network, behind an access key
|
||||||
|
```
|
||||||
|
|
||||||
|
Startup prints a pairing link (`?k=...`) and a QR code: point the phone's camera at the terminal and tap. Pairing is one-time per device — the key persists across restarts (`data/.lan_key`; delete it to revoke every paired device) and the cookie lasts a year. Save the page to the phone's home screen for the full-screen treatment, piper icon included.
|
||||||
|
|
||||||
|
To photograph shelves from the phone: on the Photos page, tap the drop zone and choose "Take Photo." The upload narrates its progress, and camera captures get unique `shelf-<timestamp>` names so rapid-fire shots never overwrite each other. The key is the only lock — there is no login behind it — so still prefer networks you trust (or use a device VPN like Tailscale against the localhost default instead).
|
||||||
|
|
||||||
|
Seven pages in one local app — Pipeline, Photos, Titles, Review, Queue, Library, and Help, each pictured above. 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)
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ def rebuild_artifacts(
|
|||||||
) -> tuple[list[dict], dict[str, list[dict]]]:
|
) -> tuple[list[dict], dict[str, list[dict]]]:
|
||||||
"""Regenerate titles.json and unidentified.json from the per-photo raw
|
"""Regenerate titles.json and unidentified.json from the per-photo raw
|
||||||
cache. A raw file is either an object with titles/unidentified or a bare
|
cache. A raw file is either an object with titles/unidentified or a bare
|
||||||
arrays — still readable."""
|
array — still readable."""
|
||||||
entries: list[dict] = []
|
entries: list[dict] = []
|
||||||
unidentified: dict[str, list[dict]] = {}
|
unidentified: dict[str, list[dict]] = {}
|
||||||
for raw_file in sorted(raw_dir.glob("*.json")):
|
for raw_file in sorted(raw_dir.glob("*.json")):
|
||||||
|
|||||||
@@ -587,8 +587,6 @@ def run_resolve(
|
|||||||
return candidates[0]
|
return candidates[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
seen_per_title: dict[str, int] = {}
|
|
||||||
|
|
||||||
new_rows: list[MatchRow] = []
|
new_rows: list[MatchRow] = []
|
||||||
skipped = 0
|
skipped = 0
|
||||||
photos_updated = False
|
photos_updated = False
|
||||||
@@ -607,7 +605,6 @@ def run_resolve(
|
|||||||
paired_by_id[id(entry)] = row_dict
|
paired_by_id[id(entry)] = row_dict
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
seen_per_title[entry.title_raw] = seen_per_title.get(entry.title_raw, 0) + 1
|
|
||||||
row_dict = paired_by_id.get(id(entry))
|
row_dict = paired_by_id.get(id(entry))
|
||||||
if row_dict is not None:
|
if row_dict is not None:
|
||||||
photos = ";".join(entry.source_photos)
|
photos = ";".join(entry.source_photos)
|
||||||
|
|||||||
+21
-26
@@ -1,4 +1,4 @@
|
|||||||
/* bggpipe design system — one stylesheet, two pages (dashboard, review).
|
/* bggpipe design system — one stylesheet for the whole app.
|
||||||
*
|
*
|
||||||
* The visual world is Juniper's mascot drawing: a sky-blue day, flat cel
|
* The visual world is Juniper's mascot drawing: a sky-blue day, flat cel
|
||||||
* color inside confident dark outlines, a cream game board with a rainbow
|
* color inside confident dark outlines, a cream game board with a rainbow
|
||||||
@@ -30,7 +30,12 @@
|
|||||||
--gold: #f2c04b; /* pipe fittings: rings, trim, hovers */
|
--gold: #f2c04b; /* pipe fittings: rings, trim, hovers */
|
||||||
--gold-ink: #8a6414;
|
--gold-ink: #8a6414;
|
||||||
--ticket: #fdf3d2; /* reshoot work-orders */
|
--ticket: #fdf3d2; /* reshoot work-orders */
|
||||||
--focus: #8330c2;
|
--go-tint: #e2f2e4; /* pale state washes of the role colors */
|
||||||
|
--stop-tint: #fbe3da;
|
||||||
|
--gold-tint: #fdeebb;
|
||||||
|
--accent-tint: #ece5f7;
|
||||||
|
--navy-tint: #e3ecf3;
|
||||||
|
--focus: var(--accent);
|
||||||
--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,
|
--path-v: linear-gradient(180deg,
|
||||||
@@ -59,7 +64,6 @@ body {
|
|||||||
}
|
}
|
||||||
main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; }
|
main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; }
|
||||||
h2 {
|
h2 {
|
||||||
color: var(--ink);
|
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 700; font-size: 1.1rem; letter-spacing: .02em;
|
font-weight: 700; font-size: 1.1rem; letter-spacing: .02em;
|
||||||
margin: 2rem 0 .8rem;
|
margin: 2rem 0 .8rem;
|
||||||
@@ -164,7 +168,7 @@ kbd {
|
|||||||
border: var(--line);
|
border: var(--line);
|
||||||
background: var(--board);
|
background: var(--board);
|
||||||
}
|
}
|
||||||
.banner.error { border-color: var(--stop); color: var(--stop-ink); background: #fbe3da; }
|
.banner.error { border-color: var(--stop); color: var(--stop-ink); background: var(--stop-tint); }
|
||||||
.banner.warn { border-color: var(--gold-ink); color: var(--gold-ink); background: var(--ticket); }
|
.banner.warn { border-color: var(--gold-ink); color: var(--gold-ink); background: var(--ticket); }
|
||||||
|
|
||||||
/* -- buttons ----------------------------------------------------------- */
|
/* -- buttons ----------------------------------------------------------- */
|
||||||
@@ -243,10 +247,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
.cname { font-weight: 600; }
|
.cname { font-weight: 600; }
|
||||||
.cmeta { color: var(--ink-soft); font-size: .8rem; margin-left: .4rem; }
|
.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 { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; }
|
||||||
.rowactions button {
|
.rowactions button { padding: .25rem .7rem; box-shadow: none; }
|
||||||
font: inherit; border: var(--line); background: #fff;
|
|
||||||
border-radius: var(--radius); padding: .25rem .7rem; cursor: pointer;
|
|
||||||
}
|
|
||||||
.rowactions button.reject { color: var(--stop-ink); border-color: var(--stop); }
|
.rowactions button.reject { color: var(--stop-ink); border-color: var(--stop); }
|
||||||
.rowactions input[type=text] {
|
.rowactions input[type=text] {
|
||||||
font: inherit; width: 8.5em; padding: .25rem .5rem;
|
font: inherit; width: 8.5em; padding: .25rem .5rem;
|
||||||
@@ -276,10 +277,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
.ticket .partial { font-family: var(--font-mono); font-size: .85rem; }
|
.ticket .partial { font-family: var(--font-mono); font-size: .85rem; }
|
||||||
.ticket .notes { color: var(--gold-ink); font-size: .85rem; margin-top: .2rem; }
|
.ticket .notes { color: var(--gold-ink); font-size: .85rem; margin-top: .2rem; }
|
||||||
.ticket button {
|
.ticket button {
|
||||||
font: inherit; font-size: .8rem; margin-top: .5rem;
|
font-size: .8rem; margin-top: .5rem;
|
||||||
background: none; border: 1px solid var(--gold-ink); color: var(--gold-ink);
|
background: none; border: 1px solid var(--gold-ink); color: var(--gold-ink);
|
||||||
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer;
|
padding: .2rem .6rem; box-shadow: none;
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- all-done celebration ---------------------------------------------- */
|
/* -- all-done celebration ---------------------------------------------- */
|
||||||
@@ -292,7 +292,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
border-radius: var(--radius-lg); border: var(--line);
|
border-radius: var(--radius-lg); border: var(--line);
|
||||||
background: var(--sky);
|
background: var(--sky);
|
||||||
}
|
}
|
||||||
.done h2 { color: var(--ink); margin-top: 0; }
|
.done h2 { margin-top: 0; }
|
||||||
.done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
|
.done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
|
||||||
.done .nums div { font-size: 1.6rem; font-weight: 700; font-family: var(--font-display); }
|
.done .nums div { font-size: 1.6rem; font-weight: 700; font-family: var(--font-display); }
|
||||||
.done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
|
.done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
|
||||||
@@ -323,11 +323,7 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
}
|
}
|
||||||
.catalog a:hover { color: var(--accent-ink); }
|
.catalog a:hover { color: var(--accent-ink); }
|
||||||
.catalog td.actions { text-align: right; white-space: nowrap; }
|
.catalog td.actions { text-align: right; white-space: nowrap; }
|
||||||
.catalog td.actions button {
|
.catalog td.actions button { font-size: .78rem; padding: .2rem .6rem; box-shadow: none; }
|
||||||
font: inherit; font-size: .78rem; border: var(--line); background: #fff;
|
|
||||||
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.catalog td.actions button:hover { background: var(--board); }
|
.catalog td.actions button:hover { background: var(--board); }
|
||||||
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
|
.catalog tr.editrow td { background: #fff; border-top: none; padding: .2rem .5rem .7rem; }
|
||||||
.editform { display: flex; gap: .7rem; align-items: end; flex-wrap: wrap; }
|
.editform { display: flex; gap: .7rem; align-items: end; flex-wrap: wrap; }
|
||||||
@@ -355,11 +351,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
}
|
}
|
||||||
.card ol { padding-left: 1.3rem; }
|
.card ol { padding-left: 1.3rem; }
|
||||||
.card ol li { margin: .35rem 0; }
|
.card ol li { margin: .35rem 0; }
|
||||||
.chip.ok { background: #e2f2e4; color: var(--go-ink); }
|
.chip.ok { background: var(--go-tint); color: var(--go-ink); }
|
||||||
.chip.wait { background: var(--ticket); color: var(--gold-ink); }
|
.chip.wait { background: var(--ticket); color: var(--gold-ink); }
|
||||||
.chip.no { background: #fbe3da; color: var(--stop-ink); }
|
.chip.no { background: var(--stop-tint); color: var(--stop-ink); }
|
||||||
.chip.open { background: #ece5f7; color: var(--accent-ink); }
|
.chip.open { background: var(--accent-tint); color: var(--accent-ink); }
|
||||||
.chip.merged { background: #e3ecf3; color: var(--navy); }
|
.chip.merged { background: var(--navy-tint); color: var(--navy); }
|
||||||
.chip.shaky { background: var(--ticket); color: var(--gold-ink); border: 1px dashed var(--gold-ink); }
|
.chip.shaky { background: var(--ticket); color: var(--gold-ink); border: 1px dashed var(--gold-ink); }
|
||||||
|
|
||||||
/* -- merge notices: slim, undoable ------------------------------------- */
|
/* -- merge notices: slim, undoable ------------------------------------- */
|
||||||
@@ -367,10 +363,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
.card.merge .body { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
|
.card.merge .body { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
|
||||||
.card.merge .arrow { color: var(--ink-soft); }
|
.card.merge .arrow { color: var(--ink-soft); }
|
||||||
.card.merge button {
|
.card.merge button {
|
||||||
font: inherit; font-size: .8rem; margin-left: auto;
|
font-size: .8rem; margin-left: auto;
|
||||||
background: none; border: 1px solid var(--board-edge);
|
background: none; border: 1px solid var(--board-edge);
|
||||||
border-radius: var(--radius); padding: .2rem .6rem; cursor: pointer;
|
padding: .2rem .6rem; box-shadow: none;
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
.card.merge button:hover { border-color: var(--stop); color: var(--stop-ink); }
|
.card.merge button:hover { border-color: var(--stop); color: var(--stop-ink); }
|
||||||
|
|
||||||
@@ -405,9 +400,9 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
|
|||||||
font: inherit; cursor: pointer;
|
font: inherit; cursor: pointer;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
#dropzone.hot, #dropzone:hover { border-style: solid; background: #fdeebb; }
|
#dropzone.hot, #dropzone:hover { border-style: solid; background: var(--gold-tint); }
|
||||||
#dropzone[aria-busy="true"] { cursor: progress; opacity: .8; }
|
#dropzone[aria-busy="true"] { cursor: progress; opacity: .8; }
|
||||||
#dropzone.ok { border-style: solid; border-color: var(--go-ink); color: var(--go-ink); background: #e2f2e4; }
|
#dropzone.ok { border-style: solid; border-color: var(--go-ink); color: var(--go-ink); background: var(--go-tint); }
|
||||||
#joblog {
|
#joblog {
|
||||||
background: var(--navy); color: #eaf1fb;
|
background: var(--navy); color: #eaf1fb;
|
||||||
font-family: var(--font-mono); font-size: .78rem;
|
font-family: var(--font-mono); font-size: .78rem;
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ const esc = s => String(s ?? "").replace(/[&<>"']/g,
|
|||||||
* pass through esc() at the call site. The API serves only local pipeline
|
* pass through esc() at the call site. The API serves only local pipeline
|
||||||
* data, but photo names and BGG titles still count as untrusted. */
|
* data, but photo names and BGG titles still count as untrusted. */
|
||||||
function showBanner(html) {
|
function showBanner(html) {
|
||||||
const el = document.getElementById("banner");
|
document.getElementById("banner").innerHTML = html;
|
||||||
el.setAttribute("role", "status");
|
|
||||||
el.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorBanner(detail) {
|
function errorBanner(detail) {
|
||||||
@@ -49,6 +47,21 @@ async function apiPost(url, body) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-page change gate for poll loops: render only when the payload
|
||||||
|
* changed, and record it only after render returns — a throw leaves the
|
||||||
|
* page stale, so the next poll retries instead of freezing on "current". */
|
||||||
|
function changeGate() {
|
||||||
|
let seen = null;
|
||||||
|
const gate = (value, render) => {
|
||||||
|
const key = JSON.stringify(value);
|
||||||
|
if (key === seen) return;
|
||||||
|
render();
|
||||||
|
seen = key;
|
||||||
|
};
|
||||||
|
gate.reset = () => { seen = null; };
|
||||||
|
return gate;
|
||||||
|
}
|
||||||
|
|
||||||
/* Poll `fn` every `ms`; after 3 consecutive failures show the lost-contact
|
/* Poll `fn` every `ms`; after 3 consecutive failures show the lost-contact
|
||||||
* banner, and clear it (via `recovered`) on the next success. */
|
* banner, and clear it (via `recovered`) on the next success. */
|
||||||
function pollLoop(fn, ms, recovered) {
|
function pollLoop(fn, ms, recovered) {
|
||||||
@@ -102,6 +115,41 @@ function metaLine(c) {
|
|||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reshoot work-order card, shared by the photos gallery and the photo
|
||||||
|
* detail page (which drops the thumbnail and source line — the photo is
|
||||||
|
* right there). Dismiss buttons are wired by wireDismiss below. */
|
||||||
|
function ticketCard(s, {showPhoto = true} = {}) {
|
||||||
|
const img = showPhoto && 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>` : ""}
|
||||||
|
${showPhoto ? `<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 wireDismiss(root, done) {
|
||||||
|
root.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) done();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/* Status chip for a catalog entry — shared by the catalog and photo pages. */
|
/* Status chip for a catalog entry — shared by the catalog and photo pages. */
|
||||||
function statusChip(c) {
|
function statusChip(c) {
|
||||||
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
|
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<h2 id="pages">What each page is for</h2>
|
<h2 id="pages">What each page is for</h2>
|
||||||
<div class="card prose">
|
<div class="card prose">
|
||||||
<p><b><a href="/">Pipeline</a></b> — run stages one at a time and watch their live output. Shows what's blocking (missing keys, stub data) and the counts at every step.</p>
|
<p><b><a href="/">Pipeline</a></b> — run stages one at a time and watch their live output. Shows what's blocking (missing keys, stub data) and the counts at every step.</p>
|
||||||
<p><b><a href="/photos">Photos</a></b> — drag photos in, drop them in the <code>photos/</code> folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique <code>shelf-…</code> names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same <i>file name</i> deliberately replaces it and re-extracts.</p>
|
<p><b><a href="/photos">Photos</a></b> — drag photos in, drop them in the <code>photos/</code> folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique <code>shelf-…</code> names so they can never overwrite each other. Each photo has its own page listing every title read from it and any reshoot tickets — boxes seen but not identified. Photograph those up close, drop the new shot in, and extract again. Re-uploading a photo under the same <i>file name</i> deliberately replaces it, and the next extract run re-reads it.</p>
|
||||||
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
|
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
|
||||||
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, and whether two same-game reads are really one box (merges show a veto). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
|
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, and whether two same-game reads are really one box (merges show a veto). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
|
||||||
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first.</p>
|
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first.</p>
|
||||||
|
|||||||
@@ -56,14 +56,10 @@ function render() {
|
|||||||
Run the pipeline through <code>enrich</code> to fill these shelves.`}</p>`;
|
Run the pipeline through <code>enrich</code> to fill these shelves.`}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let LAST = null;
|
const GATE = changeGate();
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const games = await fetchJSON("/api/library");
|
const games = await fetchJSON("/api/library");
|
||||||
const payload = JSON.stringify(games);
|
GATE(games, () => { GAMES = games; render(); });
|
||||||
if (payload === LAST) return;
|
|
||||||
LAST = payload;
|
|
||||||
GAMES = games;
|
|
||||||
render();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("libsearch").addEventListener("input", render);
|
document.getElementById("libsearch").addEventListener("input", render);
|
||||||
|
|||||||
@@ -15,21 +15,6 @@ document.getElementById("photoname").textContent = NAME;
|
|||||||
document.getElementById("rawlink").href = `/photos/${encodeURIComponent(NAME)}`;
|
document.getElementById("rawlink").href = `/photos/${encodeURIComponent(NAME)}`;
|
||||||
document.title = `${NAME} · bggpipe`;
|
document.title = `${NAME} · bggpipe`;
|
||||||
|
|
||||||
function ticket(s) {
|
|
||||||
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>
|
|
||||||
<div>
|
|
||||||
<div class="loc">${esc(s.location) || "somewhere in this 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>` : ""}
|
|
||||||
<button class="dismiss">dismiss — found it / not a game</button>
|
|
||||||
</div>
|
|
||||||
</section>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(state, photos) {
|
function render(state, photos) {
|
||||||
const info = photos.find(p => p.name === NAME);
|
const info = photos.find(p => p.name === NAME);
|
||||||
const body = document.getElementById("photobody");
|
const body = document.getElementById("photobody");
|
||||||
@@ -72,30 +57,19 @@ function render(state, photos) {
|
|||||||
|
|
||||||
if (tickets.length) {
|
if (tickets.length) {
|
||||||
html += `<h2>Reshoot tickets <span class="count">— boxes seen here but not identified</span></h2>`;
|
html += `<h2>Reshoot tickets <span class="count">— boxes seen here but not identified</span></h2>`;
|
||||||
html += tickets.map(ticket).join("");
|
html += tickets.map(s => ticketCard(s, {showPhoto: false})).join("");
|
||||||
}
|
}
|
||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
|
wireDismiss(body, () => refresh().catch(() => {}));
|
||||||
body.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().catch(() => {});
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let LAST = null;
|
const GATE = changeGate();
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const [state, photos] = await Promise.all([
|
const [state, photos] = await Promise.all([
|
||||||
fetchJSON("/api/state"),
|
fetchJSON("/api/state"),
|
||||||
fetchJSON("/api/photos-list"),
|
fetchJSON("/api/photos-list"),
|
||||||
]);
|
]);
|
||||||
const payload = JSON.stringify([state.catalog, state.unidentified, photos]);
|
GATE([state.catalog, state.unidentified, photos], () => render(state, photos));
|
||||||
if (payload === LAST) return;
|
|
||||||
render(state, photos);
|
|
||||||
LAST = payload; // after render: a throw must not freeze the page as "current"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", e => {
|
document.addEventListener("keydown", e => {
|
||||||
|
|||||||
@@ -11,40 +11,12 @@
|
|||||||
<script>
|
<script>
|
||||||
"use strict";
|
"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) {
|
function render(state, photos) {
|
||||||
const tickets = document.getElementById("tickets");
|
const tickets = document.getElementById("tickets");
|
||||||
tickets.innerHTML = state.unidentified.length
|
tickets.innerHTML = state.unidentified.length
|
||||||
? state.unidentified.map(ticket).join("")
|
? state.unidentified.map(s => ticketCard(s)).join("")
|
||||||
: `<p class="empty">No open reshoot tickets.</p>`;
|
: `<p class="empty">No open reshoot tickets.</p>`;
|
||||||
tickets.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
|
wireDismiss(tickets, () => refresh().catch(() => {}));
|
||||||
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("gallerycount").textContent = `— ${photos.length} on file`;
|
||||||
document.getElementById("shots").innerHTML = photos.map(p => `
|
document.getElementById("shots").innerHTML = photos.map(p => `
|
||||||
@@ -59,17 +31,13 @@ function render(state, photos) {
|
|||||||
</figure>`).join("");
|
</figure>`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
let LAST = null;
|
const GATE = changeGate(); // rebuilding innerHTML re-renders every <img>
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const [state, photos] = await Promise.all([
|
const [state, photos] = await Promise.all([
|
||||||
fetchJSON("/api/state"),
|
fetchJSON("/api/state"),
|
||||||
fetchJSON("/api/photos-list"),
|
fetchJSON("/api/photos-list"),
|
||||||
]);
|
]);
|
||||||
// re-render only on change: rebuilding innerHTML re-renders every <img>
|
GATE([state.unidentified, state.warnings, photos], () => render(state, photos));
|
||||||
const payload = JSON.stringify([state.unidentified, state.warnings, photos]);
|
|
||||||
if (payload === LAST) return;
|
|
||||||
LAST = payload;
|
|
||||||
render(state, photos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const zone = document.getElementById("dropzone");
|
const zone = document.getElementById("dropzone");
|
||||||
@@ -122,7 +90,7 @@ async function sendPhotos(files) {
|
|||||||
zone.textContent = ZONE_IDLE;
|
zone.textContent = ZONE_IDLE;
|
||||||
}
|
}
|
||||||
}, 6000);
|
}, 6000);
|
||||||
LAST = null; // the new photo must appear even if nothing else changed
|
GATE.reset(); // the new photo must appear even if nothing else changed
|
||||||
refresh().catch(() => {}); // the next poll self-heals a refresh hiccup
|
refresh().catch(() => {}); // the next poll self-heals a refresh hiccup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
|
|
||||||
<h2 id="activity">Activity</h2>
|
<h2 id="activity">Activity</h2>
|
||||||
<div id="jobstate" aria-live="polite">idle</div>
|
<div id="jobstate" aria-live="polite">idle</div>
|
||||||
<div id="joblog" aria-label="stage output">(stage output appears here)</div>
|
<div id="joblog" role="log" aria-label="stage output">(stage output appears here)</div>
|
||||||
<script>
|
<script>
|
||||||
"use strict";
|
"use strict";
|
||||||
let P = null;
|
let P = null;
|
||||||
let running = false;
|
let RUNNING = false;
|
||||||
|
|
||||||
async function runStage(stage, body) {
|
async function runStage(stage, body) {
|
||||||
const res = await apiPost(`/api/run/${stage}`, body);
|
const res = await apiPost(`/api/run/${stage}`, body);
|
||||||
@@ -23,12 +23,12 @@ function stageCard(num, name, facts, actions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runBtn(stage, label) {
|
function runBtn(stage, label) {
|
||||||
return `<button class="primary" data-run="${stage}" ${running ? "disabled" : ""}>${esc(label ?? "Run")}</button>`;
|
return `<button class="primary" data-run="${stage}" ${RUNNING ? "disabled" : ""}>${esc(label ?? "Run")}</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const m = P.matches, log = P.upload_log;
|
const m = P.matches, log = P.upload_log;
|
||||||
running = P.job.status === "running"; // buttons below depend on this
|
RUNNING = P.job.status === "running"; // buttons below depend on this
|
||||||
|
|
||||||
const banners = [];
|
const banners = [];
|
||||||
if (P.stub_data) banners.push(
|
if (P.stub_data) banners.push(
|
||||||
@@ -57,7 +57,7 @@ function render() {
|
|||||||
stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")),
|
stageCard(4, "diff", `compare against your BGG collection`, runBtn("diff")),
|
||||||
stageCard(5, "upload", uploadFacts,
|
stageCard(5, "upload", uploadFacts,
|
||||||
`${runBtn("upload", "Dry run")}
|
`${runBtn("upload", "Dry run")}
|
||||||
<button class="danger" data-upload-real ${running || P.stub_data ? "disabled" : ""}>Upload</button>
|
<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>`),
|
<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")),
|
stageCard(6, "enrich", `<b>${P.games}</b> game(s) in the <a href="/library">library</a>`, runBtn("enrich")),
|
||||||
].join("");
|
].join("");
|
||||||
@@ -83,14 +83,10 @@ function render() {
|
|||||||
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
let LAST = null;
|
const GATE = changeGate();
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const p = await fetchJSON("/api/pipeline");
|
const p = await fetchJSON("/api/pipeline");
|
||||||
const payload = JSON.stringify(p);
|
GATE(p, () => { P = p; render(); });
|
||||||
if (payload === LAST) return;
|
|
||||||
LAST = payload;
|
|
||||||
P = p;
|
|
||||||
render();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh().catch(err => errorBanner(err.message || err));
|
refresh().catch(err => errorBanner(err.message || err));
|
||||||
|
|||||||
@@ -43,13 +43,10 @@ function render(q) {
|
|||||||
document.getElementById("queuebody").innerHTML = html;
|
document.getElementById("queuebody").innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
let LAST = null;
|
const GATE = changeGate();
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const q = await fetchJSON("/api/queue");
|
const q = await fetchJSON("/api/queue");
|
||||||
const payload = JSON.stringify(q);
|
GATE(q, () => render(q));
|
||||||
if (payload === LAST) return;
|
|
||||||
render(q);
|
|
||||||
LAST = payload; // after render: a throw must not freeze the page as "current"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh().catch(err => errorBanner(err.message || err));
|
refresh().catch(err => errorBanner(err.message || err));
|
||||||
|
|||||||
@@ -11,32 +11,16 @@
|
|||||||
<script>
|
<script>
|
||||||
"use strict";
|
"use strict";
|
||||||
let STATE = null;
|
let STATE = null;
|
||||||
let active = 0;
|
let ACTIVE = 0;
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const res = await fetch("/api/state");
|
STATE = await fetchJSON("/api/state");
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
|
||||||
STATE = await res.json();
|
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(url, body) {
|
async function post(url, body) {
|
||||||
let res;
|
const res = await apiPost(url, body);
|
||||||
try {
|
if (!res) return;
|
||||||
res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
alert("That didn't save (no response from the server): " + err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = await res.json().then(d => d.detail).catch(() => null);
|
|
||||||
alert("That didn't save: " + (detail ?? res.statusText));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
STATE = await res.json();
|
STATE = await res.json();
|
||||||
render();
|
render();
|
||||||
@@ -182,7 +166,7 @@ function render() {
|
|||||||
m.innerHTML = html;
|
m.innerHTML = html;
|
||||||
|
|
||||||
const cards = actionables();
|
const cards = actionables();
|
||||||
if (active >= cards.length) active = Math.max(0, cards.length - 1);
|
if (ACTIVE >= cards.length) ACTIVE = Math.max(0, cards.length - 1);
|
||||||
highlight();
|
highlight();
|
||||||
|
|
||||||
m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
|
m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
|
||||||
@@ -213,12 +197,12 @@ function render() {
|
|||||||
const actionables = () => [...document.querySelectorAll(".actionable")];
|
const actionables = () => [...document.querySelectorAll(".actionable")];
|
||||||
|
|
||||||
function highlight() {
|
function highlight() {
|
||||||
actionables().forEach((el, i) => el.classList.toggle("active", i === active));
|
actionables().forEach((el, i) => el.classList.toggle("active", i === ACTIVE));
|
||||||
const el = actionables()[active];
|
const el = actionables()[ACTIVE];
|
||||||
if (el) el.scrollIntoView({block: "nearest", behavior: "auto"});
|
if (el) el.scrollIntoView({block: "nearest", behavior: "auto"});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowIx = card => card.dataset.rowix === undefined ? null : Number(card.dataset.rowix);
|
const rowIx = card => Number(card.dataset.rowix); // every card template sets it
|
||||||
const decide = (card, action, bgg_id = null) => post("/api/decision", {
|
const decide = (card, action, bgg_id = null) => post("/api/decision", {
|
||||||
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, bgg_id,
|
row_ix: rowIx(card), action, bgg_id,
|
||||||
@@ -241,10 +225,10 @@ document.addEventListener("keydown", e => {
|
|||||||
if (e.target.tagName === "INPUT") return;
|
if (e.target.tagName === "INPUT") return;
|
||||||
const cards = actionables();
|
const cards = actionables();
|
||||||
if (!cards.length) return;
|
if (!cards.length) return;
|
||||||
const card = cards[active];
|
const card = cards[ACTIVE];
|
||||||
const kind = card?.dataset.kind;
|
const kind = card?.dataset.kind;
|
||||||
if (e.key === "j" || e.key === "ArrowDown") { active = Math.min(active + 1, cards.length - 1); highlight(); }
|
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 (e.key === "k" || e.key === "ArrowUp") { ACTIVE = Math.max(ACTIVE - 1, 0); highlight(); }
|
||||||
else if (/^[1-9]$/.test(e.key) && kind === "match") {
|
else if (/^[1-9]$/.test(e.key) && kind === "match") {
|
||||||
const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
|
const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
|
||||||
if (li) decide(card, "pick", Number(li.dataset.pick));
|
if (li) decide(card, "pick", Number(li.dataset.pick));
|
||||||
@@ -264,7 +248,6 @@ refresh().catch(err => errorBanner(err.message || err));
|
|||||||
// Live-follow the data files; only re-render on an actual change (keeps
|
// Live-follow the data files; only re-render on an actual change (keeps
|
||||||
// the keyboard cursor stable) and never mid-typing. Stale poll responses
|
// the keyboard cursor stable) and never mid-typing. Stale poll responses
|
||||||
// (answered before a decision landed) are discarded by revision.
|
// (answered before a decision landed) are discarded by revision.
|
||||||
let lastGood = null;
|
|
||||||
pollLoop(async () => {
|
pollLoop(async () => {
|
||||||
const fresh = await fetchJSON("/api/state");
|
const fresh = await fetchJSON("/api/state");
|
||||||
// discard stale responses — but only within one server lifetime: a
|
// discard stale responses — but only within one server lifetime: a
|
||||||
|
|||||||
@@ -82,20 +82,16 @@ function render() {
|
|||||||
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
|
: `Nothing extracted yet — start on the <a href="/photos">photos page</a>.`}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let LAST = null;
|
const GATE = changeGate();
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const state = await fetchJSON("/api/state");
|
const state = await fetchJSON("/api/state");
|
||||||
if (EDITING) return; // never repaint under an open editor
|
if (EDITING) return; // never repaint under an open editor
|
||||||
const payload = JSON.stringify(state.catalog);
|
GATE(state.catalog, () => { CATALOG = state.catalog; render(); });
|
||||||
if (payload === LAST) return;
|
|
||||||
CATALOG = state.catalog;
|
|
||||||
render();
|
|
||||||
LAST = payload; // after render: a throw must not freeze the page as "current"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("catbody").addEventListener("click", async e => {
|
document.getElementById("catbody").addEventListener("click", async e => {
|
||||||
const cancel = e.target.closest("button.canceledit");
|
const cancel = e.target.closest("button.canceledit");
|
||||||
if (cancel) { EDITING = null; LAST = null; render(); refresh().catch(() => {}); return; }
|
if (cancel) { EDITING = null; GATE.reset(); render(); refresh().catch(() => {}); return; }
|
||||||
const ok = e.target.closest("button.confirmread");
|
const ok = e.target.closest("button.confirmread");
|
||||||
if (ok) {
|
if (ok) {
|
||||||
const res = await apiPost("/api/edit-title", {
|
const res = await apiPost("/api/edit-title", {
|
||||||
@@ -103,7 +99,7 @@ document.getElementById("catbody").addEventListener("click", async e => {
|
|||||||
source_photos: ok.dataset.photos,
|
source_photos: ok.dataset.photos,
|
||||||
confirm: true,
|
confirm: true,
|
||||||
});
|
});
|
||||||
if (res) { LAST = null; refresh().catch(() => {}); }
|
if (res) { GATE.reset(); refresh().catch(() => {}); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rm = e.target.closest("button.removetitle");
|
const rm = e.target.closest("button.removetitle");
|
||||||
@@ -116,7 +112,7 @@ document.getElementById("catbody").addEventListener("click", async e => {
|
|||||||
title_raw: f.dataset.title,
|
title_raw: f.dataset.title,
|
||||||
source_photos: f.dataset.photos,
|
source_photos: f.dataset.photos,
|
||||||
});
|
});
|
||||||
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
|
if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const edit = e.target.closest("button.edit");
|
const edit = e.target.closest("button.edit");
|
||||||
@@ -155,12 +151,12 @@ document.getElementById("catbody").addEventListener("submit", async e => {
|
|||||||
// saving unchanged: on a shaky line that means "it's right as-is"
|
// saving unchanged: on a shaky line that means "it's right as-is"
|
||||||
if (orig.shaky) {
|
if (orig.shaky) {
|
||||||
const res = await apiPost("/api/edit-title", { ...body, confirm: true });
|
const res = await apiPost("/api/edit-title", { ...body, confirm: true });
|
||||||
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); return; }
|
if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); return; }
|
||||||
}
|
}
|
||||||
EDITING = null; render(); return;
|
EDITING = null; render(); return;
|
||||||
}
|
}
|
||||||
const res = await apiPost("/api/edit-title", body);
|
const res = await apiPost("/api/edit-title", body);
|
||||||
if (res) { EDITING = null; LAST = null; refresh().catch(() => {}); }
|
if (res) { EDITING = null; GATE.reset(); refresh().catch(() => {}); }
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("catsearch").addEventListener("input", render);
|
document.getElementById("catsearch").addEventListener("input", render);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div id="banner"></div>
|
<div id="banner" role="status"></div>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<!--PAGE-->
|
<!--PAGE-->
|
||||||
|
|||||||
+15
-25
@@ -14,6 +14,7 @@ execute one at a time in a JobRunner.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -148,13 +149,10 @@ class VersionBody(BaseModel):
|
|||||||
version_id: int | None = None
|
version_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class VetoBody(BaseModel):
|
class RowRef(BaseModel):
|
||||||
title_raw: str
|
"""Addresses one matches.csv row: by ordinal when given, else by key
|
||||||
source_photos: str
|
(the veto and split endpoints both act on exactly this)."""
|
||||||
row_ix: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SplitBody(BaseModel):
|
|
||||||
title_raw: str
|
title_raw: str
|
||||||
source_photos: str
|
source_photos: str
|
||||||
row_ix: int | None = None
|
row_ix: int | None = None
|
||||||
@@ -316,6 +314,10 @@ def create_app(
|
|||||||
# unreadable thumbnails, torn artifacts)
|
# unreadable thumbnails, torn artifacts)
|
||||||
app_warnings: list[str] = list(startup_notes)
|
app_warnings: list[str] = list(startup_notes)
|
||||||
|
|
||||||
|
def warn_once(note: str) -> None:
|
||||||
|
if note not in app_warnings:
|
||||||
|
app_warnings.append(note)
|
||||||
|
|
||||||
ALLOWED_HOSTS = {"127.0.0.1", "localhost", "testserver"} | (allowed_hosts or set())
|
ALLOWED_HOSTS = {"127.0.0.1", "localhost", "testserver"} | (allowed_hosts or set())
|
||||||
LAN_COOKIE = "bggpipe_key"
|
LAN_COOKIE = "bggpipe_key"
|
||||||
|
|
||||||
@@ -428,12 +430,10 @@ def create_app(
|
|||||||
data = json.loads(cfg.unidentified_path.read_text())
|
data = json.loads(cfg.unidentified_path.read_text())
|
||||||
except (json.JSONDecodeError, OSError) as err:
|
except (json.JSONDecodeError, OSError) as err:
|
||||||
# one torn file must not 500 every page and badge at once
|
# one torn file must not 500 every page and badge at once
|
||||||
note = (
|
warn_once(
|
||||||
f"{cfg.unidentified_path.name} unreadable ({err}) — "
|
f"{cfg.unidentified_path.name} unreadable ({err}) — "
|
||||||
"reshoot list unavailable"
|
"reshoot list unavailable"
|
||||||
)
|
)
|
||||||
if note not in app_warnings:
|
|
||||||
app_warnings.append(note)
|
|
||||||
return []
|
return []
|
||||||
for photo, entries in data.items():
|
for photo, entries in data.items():
|
||||||
for s in entries:
|
for s in entries:
|
||||||
@@ -450,9 +450,7 @@ def create_app(
|
|||||||
try:
|
try:
|
||||||
return json.loads(cfg.games_path.read_text())
|
return json.loads(cfg.games_path.read_text())
|
||||||
except (json.JSONDecodeError, OSError) as err:
|
except (json.JSONDecodeError, OSError) as err:
|
||||||
note = f"{cfg.games_path.name} unreadable ({err}) — library unavailable"
|
warn_once(f"{cfg.games_path.name} unreadable ({err}) — library unavailable")
|
||||||
if note not in app_warnings:
|
|
||||||
app_warnings.append(note)
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _find_entry(title_raw: str, source_photos: str) -> TitleEntry | None:
|
def _find_entry(title_raw: str, source_photos: str) -> TitleEntry | None:
|
||||||
@@ -578,8 +576,6 @@ def create_app(
|
|||||||
# the row's own photos for split copies
|
# the row's own photos for split copies
|
||||||
catalog.append(
|
catalog.append(
|
||||||
catalog_line(None if row and row.get("dedupe_veto") else entry, row)
|
catalog_line(None if row and row.get("dedupe_veto") else entry, row)
|
||||||
if row
|
|
||||||
else catalog_line(entry, None)
|
|
||||||
)
|
)
|
||||||
# surplus rows beyond the entry count — split copies — are real
|
# surplus rows beyond the entry count — split copies — are real
|
||||||
# physical games and get their own catalog lines
|
# physical games and get their own catalog lines
|
||||||
@@ -725,9 +721,7 @@ def create_app(
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
with path.open(newline="") as f:
|
with path.open(newline="") as f:
|
||||||
import csv as _csv
|
return list(csv.DictReader(f))
|
||||||
|
|
||||||
return list(_csv.DictReader(f))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"to_add": rows(cfg.to_add_path),
|
"to_add": rows(cfg.to_add_path),
|
||||||
@@ -755,9 +749,7 @@ def create_app(
|
|||||||
log_path = cfg.upload_log_path
|
log_path = cfg.upload_log_path
|
||||||
if log_path.exists():
|
if log_path.exists():
|
||||||
with log_path.open(newline="") as f:
|
with log_path.open(newline="") as f:
|
||||||
import csv as _csv
|
log_counts = Counter(row["status"] for row in csv.DictReader(f))
|
||||||
|
|
||||||
log_counts = Counter(row["status"] for row in _csv.DictReader(f))
|
|
||||||
games = len(read_games())
|
games = len(read_games())
|
||||||
return {
|
return {
|
||||||
"boot": boot,
|
"boot": boot,
|
||||||
@@ -922,7 +914,7 @@ def create_app(
|
|||||||
return state()
|
return state()
|
||||||
|
|
||||||
@app.post("/api/split")
|
@app.post("/api/split")
|
||||||
def api_split(body: SplitBody) -> dict:
|
def api_split(body: RowRef) -> dict:
|
||||||
with lock:
|
with lock:
|
||||||
revision["n"] += 1
|
revision["n"] += 1
|
||||||
_refuse_if_rewriting()
|
_refuse_if_rewriting()
|
||||||
@@ -1088,7 +1080,7 @@ def create_app(
|
|||||||
return state()
|
return state()
|
||||||
|
|
||||||
@app.post("/api/veto-merge")
|
@app.post("/api/veto-merge")
|
||||||
def api_veto_merge(body: VetoBody) -> dict:
|
def api_veto_merge(body: RowRef) -> dict:
|
||||||
with lock:
|
with lock:
|
||||||
revision["n"] += 1
|
revision["n"] += 1
|
||||||
_refuse_if_rewriting()
|
_refuse_if_rewriting()
|
||||||
@@ -1154,13 +1146,11 @@ def _dev_app() -> FastAPI:
|
|||||||
def _print_qr(url: str) -> None:
|
def _print_qr(url: str) -> None:
|
||||||
"""Pairing without typing: the phone's camera reads the URL straight
|
"""Pairing without typing: the phone's camera reads the URL straight
|
||||||
off the terminal."""
|
off the terminal."""
|
||||||
import io as _io
|
|
||||||
|
|
||||||
import qrcode
|
import qrcode
|
||||||
|
|
||||||
qr = qrcode.QRCode(border=1)
|
qr = qrcode.QRCode(border=1)
|
||||||
qr.add_data(url)
|
qr.add_data(url)
|
||||||
buffer = _io.StringIO()
|
buffer = io.StringIO()
|
||||||
qr.print_ascii(out=buffer, invert=True)
|
qr.print_ascii(out=buffer, invert=True)
|
||||||
for line in buffer.getvalue().splitlines():
|
for line in buffer.getvalue().splitlines():
|
||||||
typer.echo(f" {line}")
|
typer.echo(f" {line}")
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from bggpipe.config import Config, load_config
|
from bggpipe.config import Config, load_config
|
||||||
|
|
||||||
|
|
||||||
@@ -40,8 +42,6 @@ def test_username_comes_from_env_only(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
def test_unknown_toml_keys_warn(tmp_path, monkeypatch):
|
def test_unknown_toml_keys_warn(tmp_path, monkeypatch):
|
||||||
# a typo'd knob must not silently fall back to defaults
|
# a typo'd knob must not silently fall back to defaults
|
||||||
import pytest
|
|
||||||
|
|
||||||
monkeypatch.delenv("BGG_USERNAME", raising=False)
|
monkeypatch.delenv("BGG_USERNAME", raising=False)
|
||||||
p = tmp_path / "config.toml"
|
p = tmp_path / "config.toml"
|
||||||
p.write_text('photo_dir = "oops"\n')
|
p.write_text('photo_dir = "oops"\n')
|
||||||
@@ -50,7 +50,5 @@ def test_unknown_toml_keys_warn(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_explicit_missing_config_errors_instead_of_silent_defaults(tmp_path):
|
def test_explicit_missing_config_errors_instead_of_silent_defaults(tmp_path):
|
||||||
import pytest
|
|
||||||
|
|
||||||
with pytest.raises(FileNotFoundError, match="does not exist"):
|
with pytest.raises(FileNotFoundError, match="does not exist"):
|
||||||
load_config(tmp_path / "nope.toml")
|
load_config(tmp_path / "nope.toml")
|
||||||
|
|||||||
@@ -486,9 +486,6 @@ def test_corrupt_raw_cache_is_reextracted_not_skipped(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_systemic_failures_abort_and_exit_nonzero(tmp_path):
|
def test_systemic_failures_abort_and_exit_nonzero(tmp_path):
|
||||||
import pytest
|
|
||||||
import typer as _typer
|
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def broken_vision(image_b64, media_type):
|
def broken_vision(image_b64, media_type):
|
||||||
@@ -499,6 +496,6 @@ def test_systemic_failures_abort_and_exit_nonzero(tmp_path):
|
|||||||
cfg.photos_dir.mkdir()
|
cfg.photos_dir.mkdir()
|
||||||
for i in range(6):
|
for i in range(6):
|
||||||
_write_image(cfg.photos_dir / f"p{i}.jpg")
|
_write_image(cfg.photos_dir / f"p{i}.jpg")
|
||||||
with pytest.raises(_typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
run_extract(cfg, vision=broken_vision)
|
run_extract(cfg, vision=broken_vision)
|
||||||
assert len(calls) == 3 # aborted after 3 identical failures
|
assert len(calls) == 3 # aborted after 3 identical failures
|
||||||
|
|||||||
@@ -505,8 +505,6 @@ def test_split_row_makes_per_photo_vetoed_copies(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_split_copies_survive_resolve_rerun(tmp_path):
|
def test_split_copies_survive_resolve_rerun(tmp_path):
|
||||||
import json as _json
|
|
||||||
|
|
||||||
from bggpipe.resolve import read_matches, run_resolve
|
from bggpipe.resolve import read_matches, run_resolve
|
||||||
|
|
||||||
cfg = _setup(
|
cfg = _setup(
|
||||||
@@ -521,7 +519,7 @@ def test_split_copies_survive_resolve_rerun(tmp_path):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
(cfg.data_dir / "titles.json").write_text(
|
(cfg.data_dir / "titles.json").write_text(
|
||||||
_json.dumps(
|
json.dumps(
|
||||||
[{"title_raw": "Wiz-War", "source_photos": ["a.jpg", "b.jpg", "c.jpg"]}]
|
[{"title_raw": "Wiz-War", "source_photos": ["a.jpg", "b.jpg", "c.jpg"]}]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+17
-26
@@ -3,6 +3,7 @@ stage functions are injected so nothing slow or networked ever runs."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -174,9 +175,11 @@ def test_photo_upload_rejects_non_photos_and_path_tricks(tmp_path):
|
|||||||
"/api/photos",
|
"/api/photos",
|
||||||
files={"files": ("../../escape.jpg", b"x", "image/jpeg")},
|
files={"files": ("../../escape.jpg", b"x", "image/jpeg")},
|
||||||
)
|
)
|
||||||
if res.status_code == 200: # client may strip the path; the name must be bare
|
# whether the server accepts a stripped bare name or rejects outright,
|
||||||
|
# nothing may land outside photos_dir
|
||||||
|
assert not (tmp_path / "escape.jpg").exists()
|
||||||
|
if res.status_code == 200:
|
||||||
assert (cfg.photos_dir / "escape.jpg").exists()
|
assert (cfg.photos_dir / "escape.jpg").exists()
|
||||||
assert not (tmp_path / "escape.jpg").exists()
|
|
||||||
|
|
||||||
|
|
||||||
# -- pages --------------------------------------------------------------
|
# -- pages --------------------------------------------------------------
|
||||||
@@ -191,7 +194,7 @@ def test_dashboard_and_review_pages_serve(tmp_path):
|
|||||||
# -- design system + navigation -----------------------------------------
|
# -- design system + navigation -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_stylesheet_is_served_and_linked_by_both_pages(tmp_path):
|
def test_stylesheet_is_served_and_linked_by_every_page(tmp_path):
|
||||||
web = _app(_cfg(tmp_path))
|
web = _app(_cfg(tmp_path))
|
||||||
css = web.get("/static/app.css")
|
css = web.get("/static/app.css")
|
||||||
assert css.status_code == 200
|
assert css.status_code == 200
|
||||||
@@ -201,15 +204,12 @@ def test_stylesheet_is_served_and_linked_by_both_pages(tmp_path):
|
|||||||
assert 'href="/static/app.css"' in web.get(path).text
|
assert 'href="/static/app.css"' in web.get(path).text
|
||||||
|
|
||||||
|
|
||||||
def test_both_pages_carry_navigation_and_skip_link(tmp_path):
|
def test_every_page_marks_itself_current_in_the_nav(tmp_path):
|
||||||
web = _app(_cfg(tmp_path))
|
web = _app(_cfg(tmp_path))
|
||||||
for path, current in (("/", 'href="/"'), ("/review", 'href="/review"')):
|
for path in ("/", "/photos", "/titles", "/review", "/queue", "/library", "/help"):
|
||||||
html = web.get(path).text
|
html = web.get(path).text
|
||||||
assert 'aria-label="Primary"' in html
|
assert f'href="{path}" aria-current="page"' in html, path
|
||||||
assert f'<a {current} aria-current="page"' in html.replace("\n", " ") or (
|
assert 'class="skip"' in html, path
|
||||||
current in html and 'aria-current="page"' in html
|
|
||||||
)
|
|
||||||
assert 'class="skip"' in html
|
|
||||||
|
|
||||||
|
|
||||||
def test_activity_region_announces_politely(tmp_path):
|
def test_activity_region_announces_politely(tmp_path):
|
||||||
@@ -240,14 +240,13 @@ def test_every_page_serves_with_shared_shell(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_photos_list_reports_extraction_state(tmp_path):
|
def test_photos_list_reports_extraction_state(tmp_path):
|
||||||
import json as _json
|
|
||||||
|
|
||||||
cfg = _cfg(tmp_path)
|
cfg = _cfg(tmp_path)
|
||||||
(cfg.photos_dir / "done.jpg").write_bytes(b"x")
|
(cfg.photos_dir / "done.jpg").write_bytes(b"x")
|
||||||
(cfg.photos_dir / "fresh.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.mkdir(parents=True)
|
||||||
(cfg.extract_raw_dir / "done.jpg.json").write_text(
|
(cfg.extract_raw_dir / "done.jpg.json").write_text(
|
||||||
_json.dumps({"titles": [{"title_raw": "Catan"}], "unidentified": [{}, {}]})
|
json.dumps({"titles": [{"title_raw": "Catan"}], "unidentified": [{}, {}]})
|
||||||
)
|
)
|
||||||
listing = {p["name"]: p for p in _app(cfg).get("/api/photos-list").json()}
|
listing = {p["name"]: p for p in _app(cfg).get("/api/photos-list").json()}
|
||||||
assert listing["done.jpg"] == {
|
assert listing["done.jpg"] == {
|
||||||
@@ -268,13 +267,12 @@ def test_queue_endpoint_serves_all_three_ledgers(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_library_serves_games_sorted_or_empty(tmp_path):
|
def test_library_serves_games_sorted_or_empty(tmp_path):
|
||||||
import json as _json
|
|
||||||
|
|
||||||
cfg = _cfg(tmp_path)
|
cfg = _cfg(tmp_path)
|
||||||
web = _app(cfg)
|
web = _app(cfg)
|
||||||
assert web.get("/api/library").json() == []
|
assert web.get("/api/library").json() == []
|
||||||
cfg.games_path.write_text(
|
cfg.games_path.write_text(
|
||||||
_json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"13": {"name": "Catan", "year": 1995},
|
"13": {"name": "Catan", "year": 1995},
|
||||||
"266192": {"name": "Wingspan", "year": 2019},
|
"266192": {"name": "Wingspan", "year": 2019},
|
||||||
@@ -287,23 +285,19 @@ def test_library_serves_games_sorted_or_empty(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_pipeline_reports_reshoot_count(tmp_path):
|
def test_pipeline_reports_reshoot_count(tmp_path):
|
||||||
import json as _json
|
|
||||||
|
|
||||||
cfg = _cfg(tmp_path)
|
cfg = _cfg(tmp_path)
|
||||||
cfg.unidentified_path.write_text(
|
cfg.unidentified_path.write_text(json.dumps({"a.jpg": [{"location": "top shelf"}]}))
|
||||||
_json.dumps({"a.jpg": [{"location": "top shelf"}]})
|
|
||||||
)
|
|
||||||
assert _app(cfg).get("/api/pipeline").json()["reshoot"] == 1
|
assert _app(cfg).get("/api/pipeline").json()["reshoot"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_photos_list_tolerates_bare_array_raw_cache(tmp_path):
|
def test_photos_list_tolerates_bare_array_raw_cache(tmp_path):
|
||||||
import json as _json
|
|
||||||
|
|
||||||
cfg = _cfg(tmp_path)
|
cfg = _cfg(tmp_path)
|
||||||
(cfg.photos_dir / "old.jpg").write_bytes(b"x")
|
(cfg.photos_dir / "old.jpg").write_bytes(b"x")
|
||||||
cfg.extract_raw_dir.mkdir(parents=True)
|
cfg.extract_raw_dir.mkdir(parents=True)
|
||||||
(cfg.extract_raw_dir / "old.jpg.json").write_text(
|
(cfg.extract_raw_dir / "old.jpg.json").write_text(
|
||||||
_json.dumps([{"title_raw": "Catan"}, {"title_raw": "Risk"}])
|
json.dumps([{"title_raw": "Catan"}, {"title_raw": "Risk"}])
|
||||||
)
|
)
|
||||||
(item,) = _app(cfg).get("/api/photos-list").json()
|
(item,) = _app(cfg).get("/api/photos-list").json()
|
||||||
assert item["extracted"] is True and item["titles"] == 2
|
assert item["extracted"] is True and item["titles"] == 2
|
||||||
@@ -322,7 +316,6 @@ def test_non_photo_files_are_invisible(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_running_snapshot_shows_partial_line_then_finishes():
|
def test_running_snapshot_shows_partial_line_then_finishes():
|
||||||
import typer as _typer
|
|
||||||
|
|
||||||
runner = JobRunner()
|
runner = JobRunner()
|
||||||
release = threading.Event()
|
release = threading.Event()
|
||||||
@@ -330,7 +323,7 @@ def test_running_snapshot_shows_partial_line_then_finishes():
|
|||||||
def stage():
|
def stage():
|
||||||
print("progress: 40%", end="", flush=True) # no newline yet
|
print("progress: 40%", end="", flush=True) # no newline yet
|
||||||
release.wait()
|
release.wait()
|
||||||
_typer.echo(" done")
|
typer.echo(" done")
|
||||||
|
|
||||||
runner.start("extract", stage)
|
runner.start("extract", stage)
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
@@ -346,7 +339,6 @@ def test_running_snapshot_shows_partial_line_then_finishes():
|
|||||||
|
|
||||||
|
|
||||||
def test_log_serves_last_200_lines_and_buffer_is_bounded():
|
def test_log_serves_last_200_lines_and_buffer_is_bounded():
|
||||||
import typer as _typer
|
|
||||||
|
|
||||||
from bggpipe.jobs import MAX_LOG_LINES
|
from bggpipe.jobs import MAX_LOG_LINES
|
||||||
|
|
||||||
@@ -354,7 +346,7 @@ def test_log_serves_last_200_lines_and_buffer_is_bounded():
|
|||||||
|
|
||||||
def stage():
|
def stage():
|
||||||
for i in range(MAX_LOG_LINES + 300):
|
for i in range(MAX_LOG_LINES + 300):
|
||||||
_typer.echo(f"line {i}")
|
typer.echo(f"line {i}")
|
||||||
|
|
||||||
runner.start("extract", stage)
|
runner.start("extract", stage)
|
||||||
runner.wait()
|
runner.wait()
|
||||||
@@ -364,9 +356,8 @@ def test_log_serves_last_200_lines_and_buffer_is_bounded():
|
|||||||
|
|
||||||
|
|
||||||
def test_zero_exit_codes_count_as_done():
|
def test_zero_exit_codes_count_as_done():
|
||||||
import typer as _typer
|
|
||||||
|
|
||||||
for exc in (_typer.Exit(), SystemExit(0)):
|
for exc in (typer.Exit(), SystemExit(0)):
|
||||||
runner = JobRunner()
|
runner = JobRunner()
|
||||||
runner.start("diff", lambda exc=exc: (_ for _ in ()).throw(exc))
|
runner.start("diff", lambda exc=exc: (_ for _ in ()).throw(exc))
|
||||||
runner.wait()
|
runner.wait()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ no live BGG (the injected client 401s on any cache miss)."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -408,7 +409,6 @@ def test_session_warnings_surface_in_state(tmp_path):
|
|||||||
def test_duplicate_rows_are_individually_decidable_via_row_ix(tmp_path):
|
def test_duplicate_rows_are_individually_decidable_via_row_ix(tmp_path):
|
||||||
# two editions of one game in one photo: byte-identical rows. The
|
# two editions of one game in one photo: byte-identical rows. The
|
||||||
# ordinal must land each decision on its own row.
|
# ordinal must land each decision on its own row.
|
||||||
from bggpipe.resolve import read_matches as read_m
|
|
||||||
from bggpipe.resolve import write_matches as write_m
|
from bggpipe.resolve import write_matches as write_m
|
||||||
|
|
||||||
cfg = make_cfg(tmp_path)
|
cfg = make_cfg(tmp_path)
|
||||||
@@ -430,7 +430,7 @@ def test_duplicate_rows_are_individually_decidable_via_row_ix(tmp_path):
|
|||||||
"action": "reject",
|
"action": "reject",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
rows = read_m(cfg.matches_path)
|
rows = read_matches(cfg.matches_path)
|
||||||
assert [r["match_status"] for r in rows] == ["unmatched", "rejected"]
|
assert [r["match_status"] for r in rows] == ["unmatched", "rejected"]
|
||||||
|
|
||||||
|
|
||||||
@@ -605,8 +605,6 @@ def test_edit_preserves_vetoed_rows_and_renames_them(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_drop_rows_photo_narrowing_spares_other_copy(tmp_path):
|
def test_drop_rows_photo_narrowing_spares_other_copy(tmp_path):
|
||||||
import io as _io
|
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from bggpipe.review import ReviewSession
|
from bggpipe.review import ReviewSession
|
||||||
@@ -621,7 +619,7 @@ def test_drop_rows_photo_narrowing_spares_other_copy(tmp_path):
|
|||||||
)
|
)
|
||||||
session = ReviewSession(
|
session = ReviewSession(
|
||||||
cfg,
|
cfg,
|
||||||
console=Console(file=_io.StringIO()),
|
console=Console(file=io.StringIO()),
|
||||||
input_fn=lambda prompt: "",
|
input_fn=lambda prompt: "",
|
||||||
client=unauthorized_client(tmp_path),
|
client=unauthorized_client(tmp_path),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user