news-digest: per-item × button + cross-device hidden tray

Adds a small × on each item that hides it from the page. State is
server-side at /output/hidden.json so the same hidden set follows
the user across devices (home, ipad, laptop, work). A "Hidden (N)"
tray at the bottom shows what's hidden on the current page with a
restore button per row; older hidden ids that aren't on this page
sit silently and continue to filter future editions that include
the same article.

Architecture change: news-digest-web swaps from nginx:alpine to a
FastAPI app on uvicorn, built from the same Dockerfile as the
worker. Same image, different command (`uvicorn web:app` overrides
the worker's cron entrypoint via compose). Drops one image dependency,
adds /api/{hidden,hide,restore}.

Item ids are stable 12-char sha1 prefixes (`reddit:<post_id>` /
`miniflux:<entry_id>`) computed in digest.py at render time and
emitted as `data-id` on each .item. The frontend reads /api/hidden
once on load, applies `is-hidden` to matching items, and POSTs
hide/restore on user interaction (optimistic, with rollback on
network error).

Storage: single JSON array at /output/hidden.json, atomic writes
via tempfile + rename, threading.Lock around the read-modify-write
inside the single uvicorn worker. No auth — the digest itself is
unauthenticated on LAN; same trust boundary applies.

Playbook also drops the DOCKER_BUILDKIT=0 fallback now that
ana-docker is on docker-ce 29, and adds three verify steps
(/api/hidden returns a JSON array, app.js is reachable, full
hide/restore round-trip with a synthetic id).
This commit is contained in:
vh
2026-04-26 15:05:25 -07:00
parent c1427adc0f
commit f692b7ec7a
9 changed files with 596 additions and 45 deletions
+88
View File
@@ -0,0 +1,88 @@
"""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")