diff --git a/src/bggpipe/cli.py b/src/bggpipe/cli.py index 50d0b12..736e6b3 100644 --- a/src/bggpipe/cli.py +++ b/src/bggpipe/cli.py @@ -57,6 +57,9 @@ def review( bool, typer.Option("--web", help="Serve the review UI on localhost") ] = False, port: Annotated[int, typer.Option("--port", help="Port for --web")] = 8377, + dev: Annotated[ + bool, typer.Option("--dev", help="With --web: restart on source changes") + ] = False, config: ConfigOpt = None, ) -> None: """Stage 3: human review of ambiguous/unmatched items.""" @@ -64,7 +67,7 @@ def review( if web: from bggpipe.webreview import run_web_review - run_web_review(cfg, port=port) + run_web_review(cfg, port=port, dev=dev, config_path=config) else: from bggpipe.review import run_review diff --git a/src/bggpipe/review.py b/src/bggpipe/review.py index 30b78f1..755b29c 100644 --- a/src/bggpipe/review.py +++ b/src/bggpipe/review.py @@ -11,6 +11,7 @@ from __future__ import annotations import json from collections.abc import Callable +from pathlib import Path import httpx from rich.console import Console @@ -57,15 +58,37 @@ class ReviewSession: self.console = console or Console() self.input_fn = input_fn or (lambda prompt: self.console.input(prompt)) self.client = client or BGGClient(cfg.cache_dir, cfg.rate_limit_seconds) - self.rows = read_matches(cfg.matches_path) self.decisions = 0 + self._load() + + # -- plumbing ------------------------------------------------------- + + def _data_mtimes(self) -> tuple[int | None, int | None]: + def mtime(path: Path) -> int | None: + try: + return path.stat().st_mtime_ns + except FileNotFoundError: + return None + + return (mtime(self.cfg.matches_path), mtime(self.cfg.titles_path)) + + def _load(self) -> None: + self.rows = read_matches(self.cfg.matches_path) try: - self.titles = load_titles(cfg.titles_path) + self.titles = load_titles(self.cfg.titles_path) except FileNotFoundError: self.titles = [] self._titles = {e.title_raw: e for e in self.titles} + self._loaded_mtimes = self._data_mtimes() - # -- plumbing ------------------------------------------------------- + def reload_if_changed(self) -> bool: + """Re-read matches.csv/titles.json when another process (extract, + resolve, a git pull) rewrote them, so the web UI never serves — or + saves decisions over — stale rows. Returns True when it reloaded.""" + if self._data_mtimes() == self._loaded_mtimes: + return False + self._load() + return True def _ask(self, prompt: str) -> str: try: @@ -78,6 +101,7 @@ class ReviewSession: def _save(self) -> None: write_matches(self.cfg.matches_path, self.rows) + self._loaded_mtimes = self._data_mtimes() # own writes aren't "changes" self.decisions += 1 def _apply_choice(self, row: dict, candidate: dict) -> None: diff --git a/src/bggpipe/templates/review.html b/src/bggpipe/templates/review.html index bc327a1..5270adf 100644 --- a/src/bggpipe/templates/review.html +++ b/src/bggpipe/templates/review.html @@ -520,6 +520,23 @@ document.addEventListener("keydown", e => { }); refresh(); + +// Live-follow the data files: extract/resolve runs in another terminal show +// up on the next poll. Only re-render on an actual change (keeps the +// keyboard cursor stable) and never mid-typing in a manual-ID input. +setInterval(async () => { + const el = document.activeElement; + if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return; + try { + const fresh = await (await fetch("/api/state")).json(); + if (JSON.stringify(fresh) !== JSON.stringify(STATE)) { + STATE = fresh; + render(); + } + } catch { + /* server restarting (--dev) — retry on the next tick */ + } +}, 3000); diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 3950c90..b1403e9 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -14,6 +14,7 @@ from __future__ import annotations import io import json +import os from importlib import resources from pathlib import Path @@ -113,7 +114,15 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: thumbnails = load_thumbnails(cfg.cache_dir) dismissed = DismissStore(cfg.data_dir / "unidentified_dismissed.json") + def freshen() -> None: + """Serve every request from the current file state: an extract or + resolve run in another terminal must show up without a restart.""" + nonlocal thumbnails + if session.reload_if_changed(): + thumbnails = load_thumbnails(cfg.cache_dir) + def find_row(title_raw: str, source_photos: str) -> dict: + freshen() for row in session.rows: if row["title_raw"] == title_raw and row["source_photos"] == source_photos: return row @@ -154,6 +163,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: } def state() -> dict: + freshen() counts: dict[str, int] = {} for row in session.rows: counts[row["match_status"]] = counts.get(row["match_status"], 0) + 1 @@ -303,12 +313,43 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: return app -def run_web_review(cfg: Config, *, port: int = DEFAULT_PORT) -> None: +def _dev_app() -> FastAPI: + """uvicorn factory for --dev code reload (config path via env var, + since uvicorn re-imports this module in a fresh process).""" + from bggpipe.config import load_config + + path = os.environ.get("BGGPIPE_CONFIG") or None + return create_app(load_config(Path(path) if path else None)) + + +def run_web_review( + cfg: Config, + *, + port: int = DEFAULT_PORT, + dev: bool = False, + config_path: Path | None = None, +) -> 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") + if dev: + # Code hot-reload. Watch only the package source: uvicorn's default + # (cwd, recursive) would restart the server on every matches.csv + # write — i.e. on every review decision. + if config_path: + os.environ["BGGPIPE_CONFIG"] = str(config_path) + typer.echo(" --dev: restarting on source changes") + uvicorn.run( + "bggpipe.webreview:_dev_app", + factory=True, + reload=True, + reload_dirs=[str(Path(__file__).parent)], + host="127.0.0.1", + port=port, + log_level="warning", + ) + else: + uvicorn.run(create_app(cfg), host="127.0.0.1", port=port, log_level="warning") diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 5ff581e..f3c9fb3 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -334,3 +334,52 @@ def test_static_assets_served_with_allowlist(tmp_path): assert web.get("/static/favicon.png").status_code == 200 assert web.get("/static/nope.js").status_code == 404 assert web.get("/static/..%2Ftemplates%2Freview.html").status_code == 404 + + +# -- live data reload --------------------------------------------------- + + +def test_state_picks_up_external_matches_rewrite(tmp_path): + web, cfg = make_client(tmp_path) + assert web.get("/api/state").json()["summary"]["total"] == 3 + + rows = read_matches(cfg.matches_path) + rows.append(_row(title_raw="Newcomer", match_status="unmatched")) + write_matches(cfg.matches_path, rows) + + fresh = web.get("/api/state").json() + assert fresh["summary"]["total"] == 4 + assert any(p["title_raw"] == "Newcomer" for p in fresh["pending"]) + + +def test_decision_lands_on_externally_added_row(tmp_path): + # A row that did not exist at server start is still reviewable. + web, cfg = make_client(tmp_path) + rows = read_matches(cfg.matches_path) + rows.append(_row(title_raw="Newcomer", match_status="unmatched")) + write_matches(cfg.matches_path, rows) + + res = web.post( + "/api/decision", + json={ + "title_raw": "Newcomer", + "source_photos": "shelf.jpg", + "action": "reject", + }, + ) + assert res.status_code == 200 + saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)} + assert saved["Newcomer"]["match_status"] == "rejected" + + +def test_own_saves_do_not_count_as_external_changes(tmp_path): + from bggpipe.review import ReviewSession + + cfg = make_cfg(tmp_path) + session = ReviewSession(cfg, client=unauthorized_client(tmp_path)) + row = next(r for r in session.rows if r["title_raw"] == "Mystery") + session.decide_reject(row) + assert session.reload_if_changed() is False # my own write + write_matches(cfg.matches_path, session.rows + [_row(title_raw="X")]) + assert session.reload_if_changed() is True # someone else's + assert any(r["title_raw"] == "X" for r in session.rows)