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:
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Fixed
|
||||||
|
|
||||||
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
|
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
|
||||||
|
|||||||
181
src/db.rs
181
src/db.rs
@@ -2,7 +2,7 @@
|
|||||||
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
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::sea_query::{Expr, Func};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
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(sessions::Entity),
|
||||||
schema.create_table_from_entity(catalogue::Entity),
|
schema.create_table_from_entity(catalogue::Entity),
|
||||||
schema.create_table_from_entity(settings::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")?;
|
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
||||||
}
|
}
|
||||||
@@ -156,6 +158,10 @@ pub struct Sub {
|
|||||||
pub auto_download: Option<bool>,
|
pub auto_download: Option<bool>,
|
||||||
pub allow_explicit: Option<bool>,
|
pub allow_explicit: Option<bool>,
|
||||||
pub max_new_per_check: Option<i64>,
|
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
|
/// 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,
|
auto_download: s.auto_download,
|
||||||
allow_explicit: s.allow_explicit,
|
allow_explicit: s.allow_explicit,
|
||||||
max_new_per_check: s.max_new_per_check,
|
max_new_per_check: s.max_new_per_check,
|
||||||
|
blocked: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,8 +627,11 @@ impl Db {
|
|||||||
FROM enclosures e
|
FROM enclosures e
|
||||||
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
|
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
|
||||||
ON subs.feed_id = e.feed_id
|
ON subs.feed_id = e.feed_id
|
||||||
LEFT JOIN (SELECT feed_id, guid, count(*) AS n FROM entry_state
|
-- Someone an item is hidden from is as done with it as someone who read it.
|
||||||
WHERE read GROUP BY feed_id, guid) readers
|
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
|
ON readers.feed_id = e.feed_id AND readers.guid = e.guid
|
||||||
WHERE e.path IS NOT NULL
|
WHERE e.path IS NOT NULL
|
||||||
AND NOT EXISTS (SELECT 1 FROM entry_state s
|
AND NOT EXISTS (SELECT 1 FROM entry_state s
|
||||||
@@ -683,13 +693,17 @@ impl Db {
|
|||||||
.await?;
|
.await?;
|
||||||
// Whatever went takes everyone's read state with it, rather than leaving rows
|
// Whatever went takes everyone's read state with it, rather than leaving rows
|
||||||
// pointing at an item that no longer exists.
|
// pointing at an item that no longer exists.
|
||||||
|
for table in ["entry_state", "hidden"] {
|
||||||
self.exec(
|
self.exec(
|
||||||
"DELETE FROM entry_state WHERE NOT EXISTS (
|
&format!(
|
||||||
|
"DELETE FROM {table} WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM entries e
|
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![],
|
vec![],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
Ok(n as usize)
|
Ok(n as usize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -701,7 +715,9 @@ impl Db {
|
|||||||
"SELECT count(*) AS n FROM entries e
|
"SELECT count(*) AS n FROM entries e
|
||||||
LEFT JOIN entry_state s
|
LEFT JOIN entry_state s
|
||||||
ON s.user_id = $2 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
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()],
|
vec![feed_id.into(), user_id.into()],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -776,8 +792,16 @@ fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filte
|
|||||||
format!(
|
format!(
|
||||||
"FROM entries e
|
"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
|
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}",
|
WHERE {scope} AND {} AND {search} AND {}",
|
||||||
filter.sql()
|
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?),
|
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
|
||||||
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
|
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
|
||||||
("settings", copy_table::<settings::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 {
|
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
|
// 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,
|
"SELECT coalesce(c.keywords, p.keywords) AS keywords,
|
||||||
coalesce(c.auto_download, p.auto_download) AS auto_download,
|
coalesce(c.auto_download, p.auto_download) AS auto_download,
|
||||||
coalesce(c.allow_explicit, p.allow_explicit) AS allow_explicit,
|
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
|
FROM subscriptions c
|
||||||
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = $2
|
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",
|
WHERE c.feed_id = $1",
|
||||||
vec![feed_id.into(), group.map(str::to_owned).into()],
|
vec![feed_id.into(), group.map(str::to_owned).into()],
|
||||||
)
|
)
|
||||||
@@ -1181,6 +1210,10 @@ impl Db {
|
|||||||
auto_download: r.try_get("", "auto_download")?,
|
auto_download: r.try_get("", "auto_download")?,
|
||||||
allow_explicit: r.try_get("", "allow_explicit")?,
|
allow_explicit: r.try_get("", "allow_explicit")?,
|
||||||
max_new_per_check: r.try_get("", "max_new_per_check")?,
|
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()
|
.collect()
|
||||||
@@ -1217,11 +1250,13 @@ impl Db {
|
|||||||
let r = self
|
let r = self
|
||||||
.rows(
|
.rows(
|
||||||
"SELECT sum(CASE WHEN st.flagged THEN 1 ELSE 0 END) AS starred,
|
"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
|
FROM enclosures e
|
||||||
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
|
JOIN subscriptions s ON s.feed_id = e.feed_id AND s.user_id <> $2
|
||||||
LEFT JOIN entry_state st
|
LEFT JOIN entry_state st
|
||||||
ON st.user_id = s.user_id AND st.feed_id = e.feed_id AND st.guid = e.guid
|
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",
|
WHERE e.id = $1",
|
||||||
vec![enclosure_id.into(), user_id.into()],
|
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<()> {
|
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",
|
"INSERT INTO subscriptions (user_id, feed_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||||
vec![user_id.into(), feed_id.into()],
|
vec![user_id.into(), feed_id.into()],
|
||||||
)
|
)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2050,6 +2173,42 @@ mod tests {
|
|||||||
assert_eq!(listening().await, ["h", "e"]);
|
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]
|
#[tokio::test]
|
||||||
async fn pending_takes_the_latest_episodes_first() {
|
async fn pending_takes_the_latest_episodes_first() {
|
||||||
// A cap of 3 must mean the three newest, not the three recorded first.
|
// A cap of 3 must mean the three newest, not the three recorded first.
|
||||||
|
|||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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(&kws, &["Just a dive"]), "half a keyword is not a match");
|
||||||
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
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"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,46 @@ pub mod entry_state {
|
|||||||
owned_by_user!();
|
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 {
|
pub mod sessions {
|
||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
|||||||
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;
|
let budget = policy.budget;
|
||||||
if policy.auto_download && budget > 0 {
|
if policy.auto_download && budget > 0 {
|
||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
@@ -1458,15 +1462,10 @@ fn reject(
|
|||||||
entry.description.as_deref().unwrap_or(""),
|
entry.description.as_deref().unwrap_or(""),
|
||||||
categories.as_str(),
|
categories.as_str(),
|
||||||
];
|
];
|
||||||
// One file serves everyone subscribed, so an item is wanted if it is wanted by
|
let text = [entry.title.as_deref().unwrap_or(""), entry.description.as_deref().unwrap_or("")];
|
||||||
// anyone: any one person's keyword set matching is enough.
|
if !policy.wanted(&haystacks, &text) {
|
||||||
let wanted_by_someone = policy.keyword_sets.is_empty()
|
// Worded for whichever filter could have let it through, the keywords if none blocked it.
|
||||||
|| policy
|
return Some(if policy.wanted(&haystacks, &[]) { "blocked word" } else { "no keyword match" });
|
||||||
.keyword_sets
|
|
||||||
.iter()
|
|
||||||
.any(|set| download::matches_keywords(set, &haystacks));
|
|
||||||
if !wanted_by_someone {
|
|
||||||
return Some("no keyword match");
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -1480,11 +1479,28 @@ fn reject(
|
|||||||
pub struct Policy {
|
pub struct Policy {
|
||||||
pub auto_download: bool,
|
pub auto_download: bool,
|
||||||
pub allow_explicit: bool,
|
pub allow_explicit: bool,
|
||||||
/// Empty means take everything. Otherwise one set per subscriber who filters.
|
/// What each subscriber fetching the feed wants.
|
||||||
pub keyword_sets: Vec<Vec<String>>,
|
pub wants: Vec<Want>,
|
||||||
pub budget: usize,
|
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> {
|
async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> {
|
||||||
let global = ctx.cfg().general.max_new_per_check;
|
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))
|
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 {
|
return Policy {
|
||||||
auto_download: feed_cfg.auto_download,
|
auto_download: feed_cfg.auto_download,
|
||||||
allow_explicit: feed_cfg.allow_explicit,
|
allow_explicit: feed_cfg.allow_explicit,
|
||||||
keyword_sets: if feed_cfg.keywords.is_empty() {
|
wants: vec![Want { keywords: feed_cfg.keywords.clone(), blocked: vec![] }],
|
||||||
vec![]
|
|
||||||
} else {
|
|
||||||
vec![feed_cfg.keywords.clone()]
|
|
||||||
},
|
|
||||||
budget: cap(feed_cfg.max_new_per_check),
|
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 {
|
let mut policy = Policy {
|
||||||
auto_download: false,
|
auto_download: false,
|
||||||
allow_explicit: false,
|
allow_explicit: false,
|
||||||
keyword_sets: vec![],
|
wants: vec![],
|
||||||
budget: 0,
|
budget: 0,
|
||||||
};
|
};
|
||||||
for sub in subs {
|
for sub in subs {
|
||||||
@@ -1521,12 +1533,10 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
|||||||
policy.budget = policy
|
policy.budget = policy
|
||||||
.budget
|
.budget
|
||||||
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
|
.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());
|
policy.wants.push(Want {
|
||||||
if kw.is_empty() {
|
keywords: sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone()),
|
||||||
// Somebody takes everything, so no filter can apply to the shared copy.
|
blocked: sub.blocked.clone(),
|
||||||
return Policy { keyword_sets: vec![], ..policy };
|
});
|
||||||
}
|
|
||||||
policy.keyword_sets.push(kw);
|
|
||||||
}
|
}
|
||||||
policy
|
policy
|
||||||
}
|
}
|
||||||
@@ -1795,6 +1805,7 @@ mod tests {
|
|||||||
auto_download: auto,
|
auto_download: auto,
|
||||||
allow_explicit: None,
|
allow_explicit: None,
|
||||||
max_new_per_check: max,
|
max_new_per_check: max,
|
||||||
|
blocked: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1804,7 +1815,7 @@ mod tests {
|
|||||||
let p = merge_policy(&[], &feed(), 3);
|
let p = merge_policy(&[], &feed(), 3);
|
||||||
assert!(p.auto_download);
|
assert!(p.auto_download);
|
||||||
assert_eq!(p.budget, 3);
|
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
|
// 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.
|
// both. The larger per-scan cap wins for the same reason.
|
||||||
@@ -1813,12 +1824,21 @@ mod tests {
|
|||||||
&feed(),
|
&feed(),
|
||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
assert_eq!(p.keyword_sets.len(), 2);
|
assert!(p.wanted(&["rust"], &[]) && p.wanted(&["sqlite"], &[]));
|
||||||
|
assert!(!p.wanted(&["python"], &[]));
|
||||||
assert_eq!(p.budget, 9);
|
assert_eq!(p.budget, 9);
|
||||||
|
|
||||||
// One person taking everything removes the filter for the shared copy.
|
// 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);
|
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.
|
// 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);
|
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3);
|
||||||
|
|||||||
43
src/web.rs
43
src/web.rs
@@ -332,29 +332,40 @@ async fn me(
|
|||||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
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 (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!({
|
Json(serde_json::json!({
|
||||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode,
|
||||||
|
"blocked": blocked,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct MePatch {
|
struct MePatch {
|
||||||
theme: String,
|
theme: Option<String>,
|
||||||
mode: 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(
|
async fn patch_me(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<MePatch>,
|
Json(body): Json<MePatch>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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
|
// 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.
|
// 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"));
|
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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,6 +650,8 @@ struct FeedRow {
|
|||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
feed_category: Option<String>,
|
feed_category: Option<String>,
|
||||||
keywords: Vec<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,
|
allow_explicit: bool,
|
||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
max_new_per_check: Option<usize>,
|
max_new_per_check: Option<usize>,
|
||||||
@@ -715,6 +728,7 @@ async fn feeds(
|
|||||||
.clone()
|
.clone()
|
||||||
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
||||||
.unwrap_or_else(|| feed.keywords.clone()),
|
.unwrap_or_else(|| feed.keywords.clone()),
|
||||||
|
blocked: state.ctx.db.blocklist(user.id, id).await?,
|
||||||
allow_explicit: mine
|
allow_explicit: mine
|
||||||
.allow_explicit
|
.allow_explicit
|
||||||
.or(up.and_then(|u| u.allow_explicit))
|
.or(up.and_then(|u| u.allow_explicit))
|
||||||
@@ -1272,6 +1286,7 @@ struct FeedPatch {
|
|||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
category: Option<Option<String>>,
|
category: Option<Option<String>>,
|
||||||
keywords: Option<Vec<String>>,
|
keywords: Option<Vec<String>>,
|
||||||
|
blocked: Option<Vec<String>>,
|
||||||
allow_explicit: Option<bool>,
|
allow_explicit: Option<bool>,
|
||||||
auto_download: Option<bool>,
|
auto_download: Option<bool>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
@@ -1279,6 +1294,21 @@ struct FeedPatch {
|
|||||||
pinned: Option<bool>,
|
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>
|
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
where
|
where
|
||||||
T: Deserialize<'de>,
|
T: Deserialize<'de>,
|
||||||
@@ -1327,6 +1357,9 @@ async fn patch_feed(
|
|||||||
if touched {
|
if touched {
|
||||||
state.ctx.db.set_subscription(user.id, &mine).await?;
|
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
|
// The rest describes the feed itself -- where its files land, its address, when it is
|
||||||
|
|||||||
@@ -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);
|
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
||||||
await ctx.close();
|
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: [] }) }));
|
||||||
|
});
|
||||||
|
|||||||
@@ -234,6 +234,11 @@ async function prefsModal(){
|
|||||||
</div>
|
</div>
|
||||||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||||
subscribes you to every feed in one.</span></div>
|
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>
|
<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.
|
<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>
|
${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);
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
$('#gopml').onclick=opmlModal;
|
$('#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){
|
function settingsModal(f, newUrl?: string){
|
||||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||||
@@ -257,6 +272,11 @@ function settingsModal(f, newUrl?: string){
|
|||||||
<div class="field"><label>Keywords</label>
|
<div class="field"><label>Keywords</label>
|
||||||
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
||||||
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
<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>
|
<div class="field"><label>Max new downloads per scan</label>
|
||||||
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
<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
|
<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;
|
const max=$('#smax').value;
|
||||||
try{
|
try{
|
||||||
const patch: Record<string, unknown>={
|
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),
|
max_new_per_check:max===''?null:Number(max),
|
||||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
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.
|
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
||||||
if(S.me&&S.me.admin){
|
if(S.me&&S.me.admin){
|
||||||
patch.url=$('#surl').value.trim();
|
patch.url=$('#surl').value.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user