diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e7518..f644945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/db.rs b/src/db.rs index fb40291..c871f8a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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, pub allow_explicit: Option, pub max_new_per_check: Option, + /// 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, } /// Someone who can sign in. `pass_hash` is None for an account that only ever arrives @@ -200,6 +206,7 @@ impl From 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. - self.exec( - "DELETE FROM entry_state WHERE NOT EXISTS ( - SELECT 1 FROM entries e - WHERE e.feed_id = entry_state.feed_id AND e.guid = entry_state.guid)", - vec![], - ) - .await?; + for table in ["entry_state", "hidden"] { + self.exec( + &format!( + "DELETE FROM {table} WHERE NOT EXISTS ( + SELECT 1 FROM entries e + 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::(&from.orm, &tx).await?), ("catalogue", copy_table::(&from.orm, &tx).await?), ("settings", copy_table::(&from.orm, &tx).await?), + ("blocklists", copy_table::(&from.orm, &tx).await?), + ("hidden", copy_table::(&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( - "INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - vec![user_id.into(), feed_id.into()], - ) - .await?; + 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> { + 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)> = 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| keywords(w).unwrap_or_default()) + .collect(); + Ok((r.try_get("", "user_id")?, words)) + }) + .collect::>()?; + 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 = 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','

No politics here, honest

',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::>(); + + 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. diff --git a/src/download.rs b/src/download.rs index ea5e9ad..ad8251d 100644 --- a/src/download.rs +++ b/src/download.rs @@ -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, &["

ai, again

"]), "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"])); + } } diff --git a/src/entity.rs b/src/entity.rs index 409307d..8906e18 100644 --- a/src/entity.rs +++ b/src/entity.rs @@ -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::*; diff --git a/src/main.rs b/src/main.rs index 073cf38..5f30962 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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>, + /// What each subscriber fetching the feed wants. + pub wants: Vec, 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, + pub blocked: Vec, +} + +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 { 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); diff --git a/src/web.rs b/src/web.rs index 407b767..64a30d9 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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, + mode: Option, + /// Words that hide an item in every feed you read. + blocked: Option>, } -/// 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, user: crate::db::User, Json(body): Json, ) -> Result { - // The page's script knows the themes; this only makes sure what is kept is safe to write - // into the page's tag, which is where index() puts it. - if !theme_ok(&body.theme, &body.mode) { - return Err(ApiError::bad_request("not a theme")); + 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 tag, which is where index() puts it. + if !theme_ok(&theme, &mode) { + return Err(ApiError::bad_request("not a theme")); + } + 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?; } - state.ctx.db.set_theme(user.id, &body.theme, &body.mode).await?; Ok(StatusCode::NO_CONTENT) } @@ -639,6 +650,8 @@ struct FeedRow { category: Option, feed_category: Option, keywords: Vec, + /// Your words that hide an item of this feed, beside your list for every feed. + blocked: Vec, allow_explicit: bool, auto_download: bool, max_new_per_check: Option, @@ -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>, keywords: Option>, + blocked: Option>, allow_explicit: Option, auto_download: Option, #[serde(default, deserialize_with = "double_option")] @@ -1279,6 +1294,21 @@ struct FeedPatch { pinned: Option, } +/// 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) -> Result, ApiError> { + let mut out: Vec = 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>, 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 diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 3d905f3..5f4b390 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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: [] }) })); +}); diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index badb923..47ee44e 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -234,6 +234,11 @@ async function prefsModal(){ Export saves your subscriptions as OPML for another podcast app. Import subscribes you to every feed in one. +
+ + 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.
${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 admin page.':'Only an admin changes this.'}
@@ -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(`

${esc(f.title||f.id)}

@@ -257,6 +272,11 @@ function settingsModal(f, newUrl?: string){
Comma separated. Empty takes everything.
+ ${isGroup?'':`
+ + 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.
`}
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={ - 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();