"""news-digest-web — FastAPI app that serves the digest + a tiny hidden-items API. Replaces the old nginx web container. Two responsibilities: 1. Serve every file in /output as static content (index.html, edition-*.html, archive.html, style.css, favicon.svg, app.js). 2. Expose /api/{hidden,hide,restore} so the per-item × button can persist hidden state server-side, shared across every device the user opens the digest from. Storage is a single /output/hidden.json — array of item IDs the user has hidden. Atomic writes via tempfile + rename; a threading lock serializes the read-modify-write inside this single uvicorn worker. Single-user setup, no auth (the digest itself is unauthenticated on LAN; same trust boundary applies). Item IDs are stable 12-char sha1 prefixes computed by digest.py at render time and embedded in the page as `data-id` on each `.item`. The frontend (templates/app.js) reads /api/hidden once on page load, hides matching items pre-paint, and hits /api/hide and /api/restore on user interactions. """ from __future__ import annotations import json import os import threading from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from pydantic import BaseModel OUTPUT_DIR = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output")) HIDDEN_FILE = OUTPUT_DIR / "hidden.json" app = FastAPI(title="news-digest-web") _lock = threading.Lock() def _load_hidden() -> set[str]: if not HIDDEN_FILE.exists(): return set() try: data = json.loads(HIDDEN_FILE.read_text()) return set(data) if isinstance(data, list) else set() except (json.JSONDecodeError, OSError): return set() def _save_hidden(ids: set[str]) -> None: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) tmp = HIDDEN_FILE.with_suffix(".json.tmp") tmp.write_text(json.dumps(sorted(ids))) tmp.replace(HIDDEN_FILE) class IdBody(BaseModel): id: str @app.get("/api/hidden") def get_hidden() -> list[str]: return sorted(_load_hidden()) @app.post("/api/hide") def post_hide(body: IdBody) -> dict[str, object]: with _lock: ids = _load_hidden() ids.add(body.id) _save_hidden(ids) return {"ok": True, "count": len(ids)} @app.post("/api/restore") def post_restore(body: IdBody) -> dict[str, object]: with _lock: ids = _load_hidden() ids.discard(body.id) _save_hidden(ids) return {"ok": True, "count": len(ids)} # Mounted last so /api/* routes win precedence over a (nonexistent) # /api/* file. html=True makes index.html the directory default, # matching nginx's `try_files` behavior we used to rely on. app.mount("/", StaticFiles(directory=str(OUTPUT_DIR), html=True), name="static")