f692b7ec7a
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).
89 lines
2.7 KiB
Python
89 lines
2.7 KiB
Python
"""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")
|