Calm Filters MVP: device-local personalization across feed/brief/counts
- API endpoints (feed, brief, category-counts) accept a 'prefs' JSON query param, parsed tolerantly into FilterPrefs (bad blobs never break the feed). - Feed over-fetches then applies word-boundary filters in Python and slices to the page; brief is filtered down (no refill); counts are computed over the same filtered set so browse numbers match the feed exactly. - Pause.active() coerces naive datetimes to UTC; FilterPrefs.from_dict skips malformed pauses and non-string list entries. - Static site adds the humane ladder (Not today / Less like this / Always hide) plus a Calm filters panel managing pauses, mutes, and avoid-terms in localStorage. Nothing leaves the device. - Tests now 38 (added forgiving-parse and naive-now cases). README documents it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+46
-8
@@ -13,9 +13,10 @@ rather not see.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Split on any run of non-alphanumerics so matching is punctuation- and
|
||||
# case-insensitive, and anchored to whole words/phrases (no substring surprises:
|
||||
@@ -51,6 +52,12 @@ class Pause:
|
||||
until = datetime.fromisoformat(self.until.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
# Defensive: never crash on an aware-vs-naive comparison. Treat a naive
|
||||
# `now` (and a naive `until`) as UTC.
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
if until.tzinfo is None:
|
||||
until = until.replace(tzinfo=timezone.utc)
|
||||
return until > now
|
||||
|
||||
|
||||
@@ -65,14 +72,30 @@ class FilterPrefs:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict | None) -> "FilterPrefs":
|
||||
data = data or {}
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
def _str_list(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(v) for v in value if isinstance(v, str)]
|
||||
|
||||
# Be forgiving: a malformed pause is skipped, never raised — a bad
|
||||
# localStorage/API blob must not break the feed.
|
||||
pauses: list[Pause] = []
|
||||
for p in data.get("pauses") or []:
|
||||
try:
|
||||
pauses.append(Pause(kind=p["kind"], value=p["value"], until=p["until"]))
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
|
||||
return cls(
|
||||
include_topics=list(data.get("include_topics") or []),
|
||||
include_flavors=list(data.get("include_flavors") or []),
|
||||
mute_topics=list(data.get("mute_topics") or []),
|
||||
mute_flavors=list(data.get("mute_flavors") or []),
|
||||
avoid_terms=list(data.get("avoid_terms") or []),
|
||||
pauses=[Pause(**p) for p in (data.get("pauses") or [])],
|
||||
include_topics=_str_list(data.get("include_topics")),
|
||||
include_flavors=_str_list(data.get("include_flavors")),
|
||||
mute_topics=_str_list(data.get("mute_topics")),
|
||||
mute_flavors=_str_list(data.get("mute_flavors")),
|
||||
avoid_terms=_str_list(data.get("avoid_terms")),
|
||||
pauses=pauses,
|
||||
)
|
||||
|
||||
def muted_topics(self, now: datetime) -> set[str]:
|
||||
@@ -97,6 +120,21 @@ class FilterPrefs:
|
||||
)
|
||||
|
||||
|
||||
def prefs_from_json(raw: str | None) -> FilterPrefs:
|
||||
"""Parse a JSON prefs string (from a query param) into FilterPrefs.
|
||||
|
||||
Never raises on bad input — a malformed blob yields empty prefs so the feed
|
||||
keeps working.
|
||||
"""
|
||||
if not raw:
|
||||
return FilterPrefs()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return FilterPrefs()
|
||||
return FilterPrefs.from_dict(data)
|
||||
|
||||
|
||||
def allows(article: dict, prefs: FilterPrefs, now: datetime) -> bool:
|
||||
"""True if an article (a feed/brief row dict) survives the preferences."""
|
||||
topic = article.get("topic")
|
||||
|
||||
Reference in New Issue
Block a user