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:
@@ -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', '')}",
|
||||
|
||||
Reference in New Issue
Block a user