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:
72
src/main.rs
72
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<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);
|
||||
|
||||
Reference in New Issue
Block a user