diff --git a/src/bggpipe/templates/pages/help.html b/src/bggpipe/templates/pages/help.html index e33f206..4a8fbba 100644 --- a/src/bggpipe/templates/pages/help.html +++ b/src/bggpipe/templates/pages/help.html @@ -29,7 +29,7 @@
Photos — drag photos in, drop them in the photos/ folder, or (on a paired phone) tap the drop zone and shoot straight from the camera; camera captures get unique shelf-… 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 file name deliberately replaces it, and the next extract run re-reads it.
Titles — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: edit, split, remove. Its badge counts shaky read lines — the model wasn't sure and nothing has verified them; filter to them, then press ✓ looks right or edit each one.
Review — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (keep locally: it joins the Library, never uploads). Keyboard-first; see shortcuts.
-Queue — 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.
+Queue — 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. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a retry N failed checkbox.
Library — your enriched collection: filter by board games or RPGs. RPG matches are identified and enriched but never uploaded — BGG collections can't hold them, so they stay local citizens.
diff --git a/src/bggpipe/templates/pages/pipeline.html b/src/bggpipe/templates/pages/pipeline.html index be99331..e9719c4 100644 --- a/src/bggpipe/templates/pages/pipeline.html +++ b/src/bggpipe/templates/pages/pipeline.html @@ -58,18 +58,27 @@ function render() { stageCard(5, "upload", uploadFacts, `${runBtn("upload", "Dry run")} - `), + + ${P.upload_failed + ? `` + : ""}`), stageCard(6, "enrich", `${P.games} game(s) in the library`, runBtn("enrich")), ].join(""); document.querySelectorAll("[data-run]").forEach(b => b.addEventListener("click", () => runStage(b.dataset.run, - b.dataset.run === "upload" ? {dry_run: true} : {}))); + b.dataset.run === "upload" ? {dry_run: true, ...uploadOpts()} : {}))); + const uploadOpts = () => ({ + limit: Number(document.getElementById("uplimit").value) || null, + retry_failed: !!document.getElementById("upretry")?.checked, + }); const real = document.querySelector("[data-upload-real]"); if (real) real.addEventListener("click", () => { - const limit = Number(document.getElementById("uplimit").value) || null; - if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}? A browser window will open.`)) return; - runStage("upload", {dry_run: false, limit}); + const {limit, retry_failed} = uploadOpts(); + if (!confirm(`Really add games to your live BGG collection${limit ? ` (limit ${limit})` : ""}` + + `${retry_failed ? ", retrying previously failed jobs" : ""}? A browser window will open.`)) return; + runStage("upload", {dry_run: false, limit, retry_failed}); }); const job = P.job; diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 3eb95c3..b75c303 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -194,6 +194,7 @@ class RemoveBody(BaseModel): class RunBody(BaseModel): dry_run: bool = True # upload only; the safe direction is the default limit: int | None = None + retry_failed: bool = False class DismissBody(BaseModel): @@ -222,10 +223,14 @@ def _default_stages(cfg: Config) -> dict[str, Callable[..., object]]: return run_diff(cfg) - def upload(dry_run: bool = True, limit: int | None = None) -> object: + def upload( + dry_run: bool = True, + limit: int | None = None, + retry_failed: bool = False, + ) -> object: from bggpipe.upload import run_upload - return run_upload(cfg, dry_run=dry_run, limit=limit) + return run_upload(cfg, dry_run=dry_run, limit=limit, retry_failed=retry_failed) def enrich() -> object: from bggpipe.enrich import run_enrich @@ -824,6 +829,7 @@ def create_app( "to_add": _csv_count(cfg.to_add_path), "to_update": _csv_count(cfg.to_update_path), "upload_log": dict(log_counts), + "upload_failed": log_counts.get("failed", 0), "games": games, "job": jobs.snapshot(), } @@ -834,7 +840,12 @@ def create_app( raise HTTPException(404, f"unknown stage {stage!r}") body = body or RunBody() if stage == "upload": - fn = partial(stages["upload"], dry_run=body.dry_run, limit=body.limit) + fn = partial( + stages["upload"], + dry_run=body.dry_run, + limit=body.limit, + retry_failed=body.retry_failed, + ) else: fn = stages[stage] if not jobs.start(stage, fn): diff --git a/tests/test_web_dashboard.py b/tests/test_web_dashboard.py index 1c702a8..841089a 100644 --- a/tests/test_web_dashboard.py +++ b/tests/test_web_dashboard.py @@ -113,17 +113,21 @@ def test_upload_defaults_to_dry_run(tmp_path): calls = [] jobs = JobRunner() - def upload(dry_run=True, limit=None): - calls.append({"dry_run": dry_run, "limit": limit}) + def upload(dry_run=True, limit=None, retry_failed=False): + calls.append({"dry_run": dry_run, "limit": limit, "retry_failed": retry_failed}) web = _app(cfg, stages={"upload": upload}, jobs=jobs) web.post("/api/run/upload") # no body: the safe direction jobs.wait() web.post("/api/run/upload", json={"dry_run": False, "limit": 2}) jobs.wait() + # a failed job is skipped on normal runs; the UI opts back in + web.post("/api/run/upload", json={"dry_run": False, "retry_failed": True}) + jobs.wait() assert calls == [ - {"dry_run": True, "limit": None}, - {"dry_run": False, "limit": 2}, + {"dry_run": True, "limit": None, "retry_failed": False}, + {"dry_run": False, "limit": 2, "retry_failed": False}, + {"dry_run": False, "limit": None, "retry_failed": True}, ]