diff --git a/goodnews/digest.py b/goodnews/digest.py index d0a456e..fc3aca2 100644 --- a/goodnews/digest.py +++ b/goodnews/digest.py @@ -52,6 +52,53 @@ def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> l return items +def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, limit: int = 3) -> list[dict]: + """Up to `limit` recent items from the user's followed sources/tags for the + "From what you follow" section — same accepted/non-dup/visible gate as the feed, + excluding what's already in the brief, capped to one per source so a single + follow can't dominate. Returns [] if they follow nothing (→ section omitted).""" + frows = conn.execute("SELECT kind, value FROM user_follows WHERE user_id = ?", (user_id,)).fetchall() + fsources = [int(r["value"]) for r in frows if r["kind"] == "source" and str(r["value"]).isdigit()] + ftags = [str(r["value"]).lower() for r in frows if r["kind"] == "tag"] + if not fsources and not ftags: + return [] + ors, params = [], [] + if fsources: + ors.append(f"a.source_id IN ({','.join('?' * len(fsources))})") + params += fsources + if ftags: + ors.append( + f"EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id " + f"AND at.tag IN ({','.join('?' * len(ftags))}))" + ) + params += ftags + rows = conn.execute( + f""" + SELECT a.id, a.title, a.canonical_url, s.name AS source, a.source_id, sc.reason_text, + (SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary + FROM articles a + JOIN sources s ON s.id = a.source_id + JOIN article_scores sc ON sc.article_id = a.id + WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1 + AND ({' OR '.join(ors)}) + ORDER BY COALESCE(a.published_at, a.discovered_at) DESC + LIMIT 30 + """, + params, + ).fetchall() + exclude, per_source, out = set(exclude_ids), {}, [] + for r in rows: + d = dict(r) + if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1: + continue + per_source[d["source_id"]] = 1 + d["paywalled"] = is_paywalled(d["canonical_url"]) + out.append(d) + if len(out) >= limit: + break + return out + + def _weekday(brief_date: str) -> str: try: return datetime.strptime(brief_date, "%Y-%m-%d").strftime("%A") @@ -59,9 +106,43 @@ def _weekday(brief_date: str) -> str: return "today" -def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str | None = None) -> tuple[str, str, str]: - """Return (subject, text, html) for the digest — calm and dated, no urgency.""" +def _item_text_lines(it: dict, base: str) -> list[str]: + lines = [f"• {it['title']} ({it['source']})"] + if it.get("summary"): + lines.append(f" {it['summary']}") + if it.get("reason_text"): + lines.append(f" Why it's here: {it['reason_text']}") + lines.append(f" Read: {base}/a/{it['id']}") + lines.append(f" Source: {it['canonical_url']}\n") + return lines + + +def _item_html(it: dict, base: str) -> str: + summary = f'
That’s today’s highlights — more good news is ' f'always waiting on Upbeat Bytes. See you tomorrow.
' f'You’re getting this because you turned on ' @@ -164,7 +237,8 @@ def send_due_digests(conn: sqlite3.Connection, force: bool = False, base: str | conn.commit() user["digest_unsub_token"] = token link = unsub_url(user, base) - subject, text, html = build_digest(items, brief_date, link, base) + followed = followed_digest_items(conn, user["id"], [it["id"] for it in items]) + subject, text, html = build_digest(items, brief_date, link, base, followed=followed) # RFC 2369 / 8058: let inboxes offer native one-click unsubscribe. headers = {"List-Unsubscribe": f"<{link}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"} try: diff --git a/tests/test_digest.py b/tests/test_digest.py index fd14634..fc891a8 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -62,3 +62,30 @@ def test_send_respects_opt_out(monkeypatch, tmp_path): c.execute("UPDATE users SET digest_enabled=0 WHERE id=1"); c.commit() monkeypatch.setattr(digest, "local_today", lambda: date) assert digest.send_due_digests(c, force=True) == 0 and sent == [] + + +def test_followed_digest_items_caps_excludes_and_section(tmp_path): + c = connect(str(tmp_path / "fd.db")); init_db(c) + date = _seed(c, n=5) # brief sources/articles 1..5 + user 1 + # two NON-brief accepted articles from followed source 1 → cap should keep 1 + for aid in (10, 11): + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (?,1,?,?,?)", + (aid, f"http://a/{aid}", f"Followed {aid}", f"h{aid}")) + c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,)) + c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','1')") + c.commit() + fol = digest.followed_digest_items(c, 1, exclude_ids=[1, 2, 3, 4, 5], limit=3) + assert len(fol) == 1 and fol[0]["source_id"] == 1 # one-per-source cap, brief ids excluded + assert fol[0]["id"] in (10, 11) + # section appears with followed items, omitted without (brief stays the star) + brief = [{"id": 1, "title": "B", "canonical_url": "http://b/1", "source": "S", "summary": "s", "reason_text": "r", "paywalled": False}] + _, text, html = digest.build_digest(brief, "2026-06-09", "http://u", followed=fol) + assert "From what you follow" in html and "From what you follow" in text + _, _, html2 = digest.build_digest(brief, "2026-06-09", "http://u", followed=[]) + assert "From what you follow" not in html2 + + +def test_followed_digest_items_empty_when_no_follows(tmp_path): + c = connect(str(tmp_path / "fd2.db")); init_db(c) + _seed(c, n=5) + assert digest.followed_digest_items(c, 1, exclude_ids=[], limit=3) == []