Block lists: words that hide items and keep them from downloading (#47)

Each person has a list for every feed they read and one per feed. An item whose title or text
holds one of the words, matched as whole words so "ai" does not hide everything that "said"
anything, is hidden from them and, since the scanner now keeps each subscriber's filters
separate, is fetched only if someone else still wants it.

Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are
worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan
brings in new items, and the queries only look that table up. Both new tables are tables rather
than columns because create_missing adds tables but never columns. Hidden counts as read for
the reaper and for "others still want this file", since whoever it is hidden from is as done
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 13:13:03 +00:00
parent 868dd673f3
commit bdda9b3d2e
8 changed files with 383 additions and 55 deletions

View File

@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
list of its own; an item with one of those words or phrases in its title or text is hidden
from you and is not downloaded on your account. Other people's lists never hide anything from
you.
### Fixed
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a

181
src/db.rs
View File

@@ -2,7 +2,7 @@
//! per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result};
use crate::entity::{catalogue, enclosures, entries, feeds, sessions, settings, subscriptions, users};
use crate::entity::{blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
use sea_orm::sea_query::{Expr, Func};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
@@ -57,6 +57,8 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(sessions::Entity),
schema.create_table_from_entity(catalogue::Entity),
schema.create_table_from_entity(settings::Entity),
schema.create_table_from_entity(blocklists::Entity),
schema.create_table_from_entity(hidden::Entity),
] {
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
}
@@ -156,6 +158,10 @@ pub struct Sub {
pub auto_download: Option<bool>,
pub allow_explicit: Option<bool>,
pub max_new_per_check: Option<i64>,
/// Words that keep an item from being downloaded for this person: their list for every feed
/// and their list for this one together. Filled by `subscribers` only; it is kept apart from
/// the settings above (`Db::set_blocklist`).
pub blocked: Vec<String>,
}
/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives
@@ -200,6 +206,7 @@ impl From<subscriptions::Model> for Sub {
auto_download: s.auto_download,
allow_explicit: s.allow_explicit,
max_new_per_check: s.max_new_per_check,
blocked: vec![],
}
}
}
@@ -620,8 +627,11 @@ impl Db {
FROM enclosures e
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
ON subs.feed_id = e.feed_id
LEFT JOIN (SELECT feed_id, guid, count(*) AS n FROM entry_state
WHERE read GROUP BY feed_id, guid) readers
-- Someone an item is hidden from is as done with it as someone who read it.
LEFT JOIN (SELECT feed_id, guid, count(*) AS n
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
UNION SELECT user_id, feed_id, guid FROM hidden) done
GROUP BY feed_id, guid) readers
ON readers.feed_id = e.feed_id AND readers.guid = e.guid
WHERE e.path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM entry_state s
@@ -683,13 +693,17 @@ impl Db {
.await?;
// Whatever went takes everyone's read state with it, rather than leaving rows
// pointing at an item that no longer exists.
for table in ["entry_state", "hidden"] {
self.exec(
"DELETE FROM entry_state WHERE NOT EXISTS (
&format!(
"DELETE FROM {table} WHERE NOT EXISTS (
SELECT 1 FROM entries e
WHERE e.feed_id = entry_state.feed_id AND e.guid = entry_state.guid)",
WHERE e.feed_id = {table}.feed_id AND e.guid = {table}.guid)"
),
vec![],
)
.await?;
}
Ok(n as usize)
}
}
@@ -701,7 +715,9 @@ impl Db {
"SELECT count(*) AS n FROM entries e
LEFT JOIN entry_state s
ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)",
WHERE e.feed_id = $1 AND NOT coalesce(s.read, false)
AND NOT EXISTS (SELECT 1 FROM hidden h
WHERE h.user_id = $2 AND h.feed_id = e.feed_id AND h.guid = e.guid)",
vec![feed_id.into(), user_id.into()],
)
.await?;
@@ -776,8 +792,16 @@ fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filte
format!(
"FROM entries e
LEFT JOIN entry_state s ON s.user_id = {user} AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE {scope} AND {} AND {search}",
filter.sql()
WHERE {scope} AND {} AND {search} AND {}",
filter.sql(),
not_hidden(&user)
)
}
/// Not hidden from this person by their block lists. `e` is entries.
fn not_hidden(user: &str) -> String {
format!(
"NOT EXISTS (SELECT 1 FROM hidden h WHERE h.user_id = {user} AND h.feed_id = e.feed_id AND h.guid = e.guid)"
)
}
@@ -1079,6 +1103,8 @@ impl Db {
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?),
("blocklists", copy_table::<blocklists::Entity>(&from.orm, &tx).await?),
("hidden", copy_table::<hidden::Entity>(&from.orm, &tx).await?),
];
if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres {
// The copied ids came with the rows; the counters that hand out new ones start past
@@ -1166,9 +1192,12 @@ impl Db {
"SELECT coalesce(c.keywords, p.keywords) AS keywords,
coalesce(c.auto_download, p.auto_download) AS auto_download,
coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit,
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check
coalesce(c.max_new_per_check, p.max_new_per_check) AS max_new_per_check,
g.words AS global_blocked, b.words AS blocked
FROM subscriptions c
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2
LEFT JOIN blocklists g ON g.user_id = c.user_id AND g.feed_id = ''
LEFT JOIN blocklists b ON b.user_id = c.user_id AND b.feed_id = c.feed_id
WHERE c.feed_id = $1",
vec![feed_id.into(), group.map(str::to_owned).into()],
)
@@ -1181,6 +1210,10 @@ impl Db {
auto_download: r.try_get("", "auto_download")?,
allow_explicit: r.try_get("", "allow_explicit")?,
max_new_per_check: r.try_get("", "max_new_per_check")?,
blocked: [r.try_get("", "global_blocked")?, r.try_get("", "blocked")?]
.into_iter()
.flat_map(|w| keywords(w).unwrap_or_default())
.collect(),
})
})
.collect()
@@ -1217,11 +1250,13 @@ impl Db {
let r = self
.rows(
"SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred,
sum(CASE WHEN st.read THEN 0 ELSE 1 END) AS unread
sum(CASE WHEN st.read OR h.guid IS NOT NULL THEN 0 ELSE 1 END) AS unread
FROM enclosures e
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
LEFT JOIN entry_state st
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
LEFT JOIN hidden h
ON h.user_id = s.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid
WHERE e.id = $1",
vec![enclosure_id.into(), user_id.into()],
)
@@ -1234,11 +1269,99 @@ impl Db {
}
pub async fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
self.exec(
let new = self
.exec(
"INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
vec![user_id.into(), feed_id.into()],
)
.await?;
if new > 0 {
// Their list for every feed applies to this one from the start.
self.rehide(feed_id).await?;
}
Ok(())
}
/// Someone's block list: for one feed, or with `feed_id` empty, for every feed.
pub async fn blocklist(&self, user_id: i64, feed_id: &str) -> Result<Vec<String>> {
Ok(blocklists::Entity::find_by_id((user_id, feed_id.to_owned()))
.one(&self.orm)
.await?
.and_then(|b| keywords(Some(b.words)))
.unwrap_or_default())
}
/// Replaces a block list and works out again what it hides: in that feed, or with
/// `feed_id` empty, in every feed the person reads.
pub async fn set_blocklist(&self, user_id: i64, feed_id: &str, words: &[String]) -> Result<()> {
if words.is_empty() {
blocklists::Entity::delete_by_id((user_id, feed_id.to_owned())).exec(&self.orm).await?;
} else {
self.exec(
"INSERT INTO blocklists (user_id, feed_id, words) VALUES ($1, $2, $3)
ON CONFLICT (user_id, feed_id) DO UPDATE SET words = excluded.words",
vec![user_id.into(), feed_id.into(), serde_json::to_string(words)?.into()],
)
.await?;
}
if feed_id.is_empty() {
for sub in self.subscriptions_for(user_id).await? {
self.rehide(&sub.feed_id).await?;
}
} else {
self.rehide(feed_id).await?;
}
Ok(())
}
/// Works out again which of a feed's items each subscriber's block lists hide from them.
///
/// ponytail: every item of the feed for every subscriber, each time the feed is scanned with
/// something new or a list changes. Fine at hundreds of items a feed; look only at new items
/// on a scan if a feed with thousands makes scans slow.
pub async fn rehide(&self, feed_id: &str) -> Result<()> {
let lists: Vec<(i64, Vec<String>)> = self
.rows(
"SELECT s.user_id, g.words AS global, b.words AS feed
FROM subscriptions s
LEFT JOIN blocklists g ON g.user_id = s.user_id AND g.feed_id = ''
LEFT JOIN blocklists b ON b.user_id = s.user_id AND b.feed_id = s.feed_id
WHERE s.feed_id = $1",
vec![feed_id.into()],
)
.await?
.iter()
.map(|r| {
let words = [r.try_get("", "global")?, r.try_get("", "feed")?]
.into_iter()
.flat_map(|w: Option<String>| keywords(w).unwrap_or_default())
.collect();
Ok((r.try_get("", "user_id")?, words))
})
.collect::<Result<_>>()?;
hidden::Entity::delete_many().filter(hidden::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
let lists: Vec<_> = lists.into_iter().filter(|(_, w)| !w.is_empty()).collect();
if lists.is_empty() {
return Ok(());
}
let items = entries::Entity::find().filter(entries::Column::FeedId.eq(feed_id)).all(&self.orm).await?;
let rows: Vec<hidden::ActiveModel> = items
.iter()
.flat_map(|e| {
let hay = [e.title.as_deref().unwrap_or(""), e.description.as_deref().unwrap_or("")];
lists
.iter()
.filter(move |(_, words)| crate::download::blocked(words, &hay))
.map(|(user, _)| hidden::ActiveModel {
user_id: Set(*user),
feed_id: Set(feed_id.to_owned()),
guid: Set(e.guid.clone()),
})
})
.collect();
if !rows.is_empty() {
hidden::Entity::insert_many(rows).exec_without_returning(&self.orm).await?;
}
Ok(())
}
@@ -2050,6 +2173,42 @@ mod tests {
assert_eq!(listening().await, ["h", "e"]);
}
#[tokio::test]
async fn block_lists_hide_items_from_whoever_keeps_them() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'), (2,'f');
INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
('f','x','Election night','',1),
('f','y','Baking bread','<p>No politics here, honest</p>',2),
('f','z','Gardening','',3);",
)
.await
.unwrap();
let shown = async |user| db.count_in(user, Some("f"), Filter::All, None).await.unwrap();
let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
db.set_blocklist(1, "", &words(&["election"])).await.unwrap();
db.set_blocklist(1, "f", &words(&["politics"])).await.unwrap();
assert_eq!(shown(1).await, 1, "a title and a body each hide an item, both lists apply");
assert_eq!(db.unread_count(1, "f").await.unwrap(), 1, "hidden is not unread");
assert_eq!(shown(2).await, 3, "someone else's list hides nothing from you");
db.set_blocklist(1, "", &[]).await.unwrap();
assert_eq!(shown(1).await, 2, "emptying a list brings its items back");
assert!(db.blocklist(1, "").await.unwrap().is_empty());
// Someone subscribing starts with their list applied, and the scanner sees it.
db.set_blocklist(2, "", &words(&["gardening"])).await.unwrap();
db.exec_for_test("INSERT INTO entries (feed_id, guid, title, first_seen) VALUES ('g','w','Gardening',4)")
.await
.unwrap();
db.subscribe(2, "g").await.unwrap();
assert_eq!(db.count_in(2, Some("g"), Filter::All, None).await.unwrap(), 0);
assert_eq!(db.subscribers("g", None).await.unwrap()[0].blocked, ["gardening"]);
}
#[tokio::test]
async fn pending_takes_the_latest_episodes_first() {
// A cap of 3 must mean the three newest, not the three recorded first.

View File

@@ -305,6 +305,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
})
}
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
/// "Trump's" is by "trump".
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
// Letters and digits only, each run of anything else one space, padded so a phrase matches
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
let norm = |s: &str| {
let mut out = String::from(" ");
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
out.push_str(&w.to_lowercase());
out.push(' ');
}
out
};
let hay = norm(&haystacks.join(" "));
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -404,4 +423,16 @@ mod tests {
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
}
#[test]
fn blocking_matches_whole_words_and_phrases() {
let words = vec!["AI".to_string(), "new york".to_string()];
assert!(blocked(&words, &["What AI means now"]));
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
assert!(!blocked(&words, &["New Yorkshire"]));
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
assert!(!blocked(&[], &["anything"]));
}
}

View File

@@ -228,6 +228,46 @@ pub mod entry_state {
owned_by_user!();
}
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
/// every feed they read.
pub mod blocklists {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "blocklists")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
/// JSON array of strings.
#[sea_orm(column_type = "Text")]
pub words: String,
}
owned_by_user!();
}
/// The items someone's block lists hide from them, worked out whenever a list or the feed
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
pub mod hidden {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "hidden")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
}
owned_by_user!();
}
pub mod sessions {
use sea_orm::entity::prelude::*;

View File

@@ -1254,6 +1254,10 @@ async fn scan_one(
}
}
if scan.new_entries > 0 {
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
}
let budget = policy.budget;
if policy.auto_download && budget > 0 {
let cfg = ctx.cfg();
@@ -1458,15 +1462,10 @@ fn reject(
entry.description.as_deref().unwrap_or(""),
categories.as_str(),
];
// One file serves everyone subscribed, so an item is wanted if it is wanted by
// anyone: any one person's keyword set matching is enough.
let wanted_by_someone = policy.keyword_sets.is_empty()
|| policy
.keyword_sets
.iter()
.any(|set| download::matches_keywords(set, &haystacks));
if !wanted_by_someone {
return Some("no keyword match");
let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
if !policy.wanted(&haystacks, &text) {
// Worded for whichever filter could have let it through, the keywords if none blocked it.
return Some(if policy.wanted(&haystacks, &[]) { "blocked word" } else { "no keyword match" });
}
None
}
@@ -1480,11 +1479,28 @@ fn reject(
pub struct Policy {
pub auto_download: bool,
pub allow_explicit: bool,
/// Empty means take everything. Otherwise one set per subscriber who filters.
pub keyword_sets: Vec<Vec<String>>,
/// What each subscriber fetching the feed wants.
pub wants: Vec<Want>,
pub budget: usize,
}
/// One person's filters: an item is theirs if it matches their keywords (all of it, with none)
/// and none of their blocked words.
pub struct Want {
pub keywords: Vec<String>,
pub blocked: Vec<String>,
}
impl Policy {
/// One file serves everyone subscribed, so an item is wanted if anyone wants it. Keywords
/// look at `haystacks`, blocked words at `text`, the item's title and body only.
fn wanted(&self, haystacks: &[&str], text: &[&str]) -> bool {
self.wants.iter().any(|w| {
download::matches_keywords(&w.keywords, haystacks) && !download::blocked(&w.blocked, text)
})
}
}
async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> {
let global = ctx.cfg().general.max_new_per_check;
Ok(merge_policy(&ctx.db.subscribers(id, feed_cfg.group.as_deref()).await?, feed_cfg, global))
@@ -1497,11 +1513,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
return Policy {
auto_download: feed_cfg.auto_download,
allow_explicit: feed_cfg.allow_explicit,
keyword_sets: if feed_cfg.keywords.is_empty() {
vec![]
} else {
vec![feed_cfg.keywords.clone()]
},
wants: vec![Want { keywords: feed_cfg.keywords.clone(), blocked: vec![] }],
budget: cap(feed_cfg.max_new_per_check),
};
}
@@ -1509,7 +1521,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
let mut policy = Policy {
auto_download: false,
allow_explicit: false,
keyword_sets: vec![],
wants: vec![],
budget: 0,
};
for sub in subs {
@@ -1521,12 +1533,10 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
policy.budget = policy
.budget
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
let kw = sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone());
if kw.is_empty() {
// Somebody takes everything, so no filter can apply to the shared copy.
return Policy { keyword_sets: vec![], ..policy };
}
policy.keyword_sets.push(kw);
policy.wants.push(Want {
keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
blocked: sub.blocked.clone(),
});
}
policy
}
@@ -1795,6 +1805,7 @@ mod tests {
auto_download: auto,
allow_explicit: None,
max_new_per_check: max,
blocked: vec![],
}
}
@@ -1804,7 +1815,7 @@ mod tests {
let p = merge_policy(&[], &feed(), 3);
assert!(p.auto_download);
assert_eq!(p.budget, 3);
assert!(p.keyword_sets.is_empty());
assert!(p.wanted(&["anything"], &[]));
// Two filters: an item wanted by either of them is fetched, since one file serves
// both. The larger per-scan cap wins for the same reason.
@@ -1813,12 +1824,21 @@ mod tests {
&feed(),
3,
);
assert_eq!(p.keyword_sets.len(), 2);
assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
assert!(!p.wanted(&["python"], &[]));
assert_eq!(p.budget, 9);
// One person taking everything removes the filter for the shared copy.
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
assert!(p.keyword_sets.is_empty());
assert!(p.wanted(&["python"], &[]));
// A blocked word keeps an item from being fetched for that person, not for the rest.
let blocking = |w: &str| db::Sub { blocked: vec![w.into()], ..sub(None, None, None) };
let p = merge_policy(&[blocking("politics")], &feed(), 3);
assert!(!p.wanted(&[], &["Politics today"]));
assert!(p.wanted(&[], &["Cooking today"]));
let p = merge_policy(&[blocking("politics"), sub(None, None, None)], &feed(), 3);
assert!(p.wanted(&[], &["Politics today"]), "someone else still wants it");
// Everyone has auto-download off: nothing is fetched automatically.
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3);

View File

@@ -332,29 +332,40 @@ async fn me(
let url = state.ctx.cfg().web.sign_out_url.clone();
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
let (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
"blocked": blocked,
}))
}
#[derive(Deserialize)]
struct MePatch {
theme: String,
mode: String,
theme: Option<String>,
mode: Option<String>,
/// Words that hide an item in every feed you read.
blocked: Option<Vec<String>>,
}
/// Saves the theme to the account, so it follows the person rather than the browser.
/// Saves the theme to the account, so it follows the person rather than the browser, and the
/// block list for every feed.
async fn patch_me(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> {
if body.theme.is_some() || body.mode.is_some() {
let (theme, mode) = (body.theme.unwrap_or_default(), body.mode.unwrap_or_default());
// The page's script knows the themes; this only makes sure what is kept is safe to write
// into the page's <html> tag, which is where index() puts it.
if !theme_ok(&body.theme, &body.mode) {
if !theme_ok(&theme, &mode) {
return Err(ApiError::bad_request("not a theme"));
}
state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?;
state.ctx.db.set_theme(user.id, &theme, &mode).await?;
}
if let Some(words) = body.blocked {
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
}
Ok(StatusCode::NO_CONTENT)
}
@@ -639,6 +650,8 @@ struct FeedRow {
category: Option<String>,
feed_category: Option<String>,
keywords: Vec<String>,
/// Your words that hide an item of this feed, beside your list for every feed.
blocked: Vec<String>,
allow_explicit: bool,
auto_download: bool,
max_new_per_check: Option<usize>,
@@ -715,6 +728,7 @@ async fn feeds(
.clone()
.or_else(|| up.and_then(|u| u.keywords.clone()))
.unwrap_or_else(|| feed.keywords.clone()),
blocked: state.ctx.db.blocklist(user.id, id).await?,
allow_explicit: mine
.allow_explicit
.or(up.and_then(|u| u.allow_explicit))
@@ -1272,6 +1286,7 @@ struct FeedPatch {
#[serde(default, deserialize_with = "double_option")]
category: Option<Option<String>>,
keywords: Option<Vec<String>>,
blocked: Option<Vec<String>>,
allow_explicit: Option<bool>,
auto_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")]
@@ -1279,6 +1294,21 @@ struct FeedPatch {
pinned: Option<bool>,
}
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
/// looked for in every item of every feed the list covers.
fn clean_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
let mut out: Vec<String> = Vec::new();
for w in words.iter().map(|w| w.trim()).filter(|w| !w.is_empty()) {
if !out.iter().any(|o| o.eq_ignore_ascii_case(w)) {
out.push(w.to_owned());
}
}
if out.len() > 500 || out.iter().any(|w| w.len() > 200) {
return Err(ApiError::bad_request("a block list holds up to 500 words of up to 200 characters"));
}
Ok(out)
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
@@ -1327,6 +1357,9 @@ async fn patch_feed(
if touched {
state.ctx.db.set_subscription(user.id, &mine).await?;
}
if let Some(v) = body.blocked.clone() {
state.ctx.db.set_blocklist(user.id, &id, &clean_words(v)?).await?;
}
}
// The rest describes the feed itself -- where its files land, its address, when it is

View File

@@ -1344,3 +1344,20 @@ test('"check every feed" checks only the feeds of the person asking', async ({ b
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close();
});
test('words in Settings hide the items that mention them', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
await page.locator('#prefs').click();
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#sblock').press('Tab');
expect((await saved).status()).toBe(204);
await page.locator('#modal .cardx').click();
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
});

View File

@@ -234,6 +234,11 @@ async function prefsModal(){
</div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div>
<div class="field"><label for="sblock">Hide items mentioning</label>
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
one in its title or text is hidden from you and not downloaded for you. Each feed's
settings can add more.</span></div>
<div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>
@@ -242,8 +247,18 @@ async function prefsModal(){
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal;
$('#sblock').onchange=async e=>{
const blocked=splitWords(e.target.value);
try{
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
if(S.me) S.me.blocked=blocked;
toast('Saved'); await loadFeeds(true); loadEntries();
}catch(err){ toast(err.message,true); }
};
}
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
function settingsModal(f, newUrl?: string){
const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3>
@@ -257,6 +272,11 @@ function settingsModal(f, newUrl?: string){
<div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div>
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
<span class="hint">Comma separated words or phrases. An item with one in its title or text
is hidden from you and not downloaded for you, as well as those your Settings hide
everywhere.</span></div>`}
<div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
@@ -294,9 +314,10 @@ function settingsModal(f, newUrl?: string){
const max=$('#smax').value;
try{
const patch: Record<string, unknown>={
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
keywords:splitWords($('#skw').value),
max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
// The shared half is an admin's to change, and the API refuses it from anyone else.
if(S.me&&S.me.admin){
patch.url=$('#surl').value.trim();