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
+15 -2
View File
@@ -20,6 +20,7 @@ list. Designed to be a one-shot invocation — it does not loop or daemon.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
@@ -33,6 +34,18 @@ from typing import Any, Iterable, Optional
import requests
from jinja2 import Environment, FileSystemLoader, select_autoescape
def _stable_id(*parts: str) -> str:
"""12-char sha1 prefix used as the per-item id for the X-button-to-hide
feature. Stable across editions (built from the source's native id),
cross-source-unique (prefixed with the source kind), and short enough
to live in JSON without bloat."""
h = hashlib.sha1()
for p in parts:
h.update(p.encode("utf-8", errors="replace"))
h.update(b"\x00")
return h.hexdigest()[:12]
# ── env config ───────────────────────────────────────────────────────
LLAMA_SWAP_URL = os.environ.get("LLAMA_SWAP_URL", "http://10.250.50.54:9292")
@@ -164,7 +177,7 @@ def fetch_miniflux_tech_items() -> list[Source]:
if len(src.items) >= MINIFLUX_MAX_PER_SOURCE:
continue
src.items.append(Item(
id=str(e["id"]),
id=_stable_id("miniflux", str(e["id"])),
title=e.get("title", "(untitled)"),
url=e.get("url", ""),
permalink=e.get("url", ""),
@@ -208,7 +221,7 @@ def fetch_reddit_top(sub: str) -> Source:
if ratio < REDDIT_MIN_RATIO: continue
if created < cutoff_ts: continue
items.append(Item(
id=d.get("id", ""),
id=_stable_id("reddit", d.get("id", "")),
title=d.get("title", "(untitled)"),
url=d.get("url", ""),
permalink=f"https://reddit.com{d.get('permalink', '')}",