Per-user read state and subscriptions
Read, starred and position move to entry_state; subscriptions carry each person's keywords, auto-download, explicit and per-scan limit. The feed list and unread counts are per person, and the existing library is adopted by the admin on first start. The feed URL, folder and schedule stay shared and admin-only: one file serves everyone, so they describe the file rather than a preference. Scanning merges subscribers' wants -- anyone wanting an item is enough -- via merge_policy, which is pure and tested. Also: the test fixture wiped its data directory from every Playwright worker, deleting the database out from under the running daemon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
359
src/db.rs
359
src/db.rs
@@ -80,6 +80,31 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
created INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- What one person wants from a feed. The feed, its items and its files are shared; this
|
||||
-- is the part that is not. NULL in a column means: follow the feed's own setting.
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
feed_id TEXT NOT NULL,
|
||||
keywords TEXT,
|
||||
auto_download INTEGER,
|
||||
allow_explicit INTEGER,
|
||||
max_new_per_check INTEGER,
|
||||
created INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, feed_id)
|
||||
);
|
||||
|
||||
-- Read, starred and how far in. One row per person per item, created on first touch;
|
||||
-- an item nobody has touched has no row at all, which is what unread means.
|
||||
CREATE TABLE IF NOT EXISTS entry_state (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
feed_id TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
flagged INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, feed_id, guid)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -88,6 +113,16 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
);
|
||||
";
|
||||
|
||||
/// One person's wants for one feed. `None` in a field means the feed's own setting stands.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Sub {
|
||||
pub feed_id: String,
|
||||
pub keywords: Option<Vec<String>>,
|
||||
pub auto_download: Option<bool>,
|
||||
pub allow_explicit: Option<bool>,
|
||||
pub max_new_per_check: Option<i64>,
|
||||
}
|
||||
|
||||
/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives
|
||||
/// through the proxy.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -506,11 +541,14 @@ impl Db {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn unread_count(&self, feed_id: &str) -> Result<i64> {
|
||||
pub fn unread_count(&self, user_id: i64, feed_id: &str) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(
|
||||
"SELECT count(*) FROM entries WHERE feed_id = ?1 AND read = 0",
|
||||
[feed_id],
|
||||
"SELECT count(*) 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 coalesce(s.read, 0) = 0",
|
||||
rusqlite::params![feed_id, user_id],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
}
|
||||
@@ -574,8 +612,8 @@ impl Filter {
|
||||
fn sql(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "1=1",
|
||||
Self::Unread => "e.read = 0",
|
||||
Self::Flagged => "e.flagged = 1",
|
||||
Self::Unread => "coalesce(s.read, 0) = 0",
|
||||
Self::Flagged => "coalesce(s.flagged, 0) = 1",
|
||||
Self::Downloaded => {
|
||||
"EXISTS (SELECT 1 FROM enclosures x
|
||||
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
|
||||
@@ -602,6 +640,7 @@ impl Db {
|
||||
/// `search` matches title and description, case-insensitively.
|
||||
pub fn entries(
|
||||
&self,
|
||||
user_id: i64,
|
||||
feed_id: &str,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
@@ -613,9 +652,12 @@ impl Db {
|
||||
.map(|q| format!("%{}%", q.trim().to_lowercase()))
|
||||
.unwrap_or_default();
|
||||
let sql = format!(
|
||||
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description, e.read,
|
||||
e.flagged, e.image, e.duration, e.episode, e.season, e.position
|
||||
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
|
||||
coalesce(s.read, 0), coalesce(s.flagged, 0), e.image, e.duration,
|
||||
e.episode, e.season, coalesce(s.position, 0)
|
||||
FROM entries e
|
||||
LEFT JOIN entry_state s
|
||||
ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||
WHERE e.feed_id = ?1 AND {} AND {SEARCH}
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
|
||||
LIMIT ?4 OFFSET ?3",
|
||||
@@ -641,7 +683,7 @@ impl Db {
|
||||
})
|
||||
};
|
||||
let mut rows: Vec<EntryRow> = stmt
|
||||
.query_map(rusqlite::params![feed_id, like, offset, limit], map)?
|
||||
.query_map(rusqlite::params![feed_id, like, offset, limit, user_id], map)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
if rows.is_empty() {
|
||||
@@ -685,29 +727,210 @@ impl Db {
|
||||
}
|
||||
|
||||
/// How many entries match, so the UI knows whether there is another page.
|
||||
pub fn count_entries(&self, feed_id: &str, filter: Filter, search: Option<&str>) -> Result<i64> {
|
||||
pub fn count_entries(
|
||||
&self,
|
||||
user_id: i64,
|
||||
feed_id: &str,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let like = search
|
||||
.map(|q| format!("%{}%", q.trim().to_lowercase()))
|
||||
.unwrap_or_default();
|
||||
let sql = format!(
|
||||
"SELECT count(*) FROM entries e WHERE e.feed_id = ?1 AND {} AND {SEARCH}",
|
||||
"SELECT count(*) FROM entries e
|
||||
LEFT JOIN entry_state s
|
||||
ON s.user_id = ?3 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||
WHERE e.feed_id = ?1 AND {} AND {SEARCH}",
|
||||
filter.sql()
|
||||
);
|
||||
Ok(conn.query_row(&sql, rusqlite::params![feed_id, like], |r| r.get(0))?)
|
||||
Ok(conn.query_row(&sql, rusqlite::params![feed_id, like, user_id], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
/// Where playback got to, so it resumes there next time.
|
||||
pub fn set_position(&self, feed_id: &str, guid: &str, secs: i64) -> Result<()> {
|
||||
/// Where playback got to, so it resumes there next time -- for this listener only.
|
||||
pub fn set_position(&self, user_id: i64, feed_id: &str, guid: &str, secs: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE entries SET position = ?3 WHERE feed_id = ?1 AND guid = ?2",
|
||||
rusqlite::params![feed_id, guid, secs.max(0)],
|
||||
"INSERT INTO entry_state (user_id, feed_id, guid, position) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position",
|
||||
rusqlite::params![user_id, feed_id, guid, secs.max(0)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks every entry in a feed read, for the "mark all read" button.
|
||||
/// Moves a single-user library onto an account: everything read, starred or part-played
|
||||
/// becomes that person's, and they subscribe to every feed already in the catalogue.
|
||||
/// Runs once -- the moment there is a first account and no subscriptions yet.
|
||||
pub fn adopt_existing_library(&self, user_id: i64, catalogue: &[String]) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let already: i64 =
|
||||
conn.query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0))?;
|
||||
if already > 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let moved = conn.execute(
|
||||
"INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position)
|
||||
SELECT ?1, feed_id, guid, read, flagged, position FROM entries
|
||||
WHERE read = 1 OR flagged = 1 OR position > 0
|
||||
ON CONFLICT(user_id, feed_id, guid) DO NOTHING",
|
||||
[user_id],
|
||||
)?;
|
||||
for id in catalogue {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
|
||||
params![user_id, id, now()],
|
||||
)?;
|
||||
}
|
||||
// Feeds that exist only in the database (OPML children) count too.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created)
|
||||
SELECT ?1, id, ?2 FROM feeds",
|
||||
params![user_id, now()],
|
||||
)?;
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
// ---- subscriptions ----
|
||||
|
||||
/// What this person wants from a feed. Absent means they do not subscribe at all.
|
||||
pub fn subscription(&self, user_id: i64, feed_id: &str) -> Result<Option<Sub>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT keywords, auto_download, allow_explicit, max_new_per_check
|
||||
FROM subscriptions WHERE user_id = ?1 AND feed_id = ?2",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![user_id, feed_id])?;
|
||||
Ok(match rows.next()? {
|
||||
Some(r) => Some(Sub {
|
||||
feed_id: feed_id.to_string(),
|
||||
keywords: r
|
||||
.get::<_, Option<String>>(0)?
|
||||
.and_then(|j| serde_json::from_str(&j).ok()),
|
||||
auto_download: r.get::<_, Option<i64>>(1)?.map(|v| v != 0),
|
||||
allow_explicit: r.get::<_, Option<i64>>(2)?.map(|v| v != 0),
|
||||
max_new_per_check: r.get(3)?,
|
||||
}),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every feed this person subscribes to, with their settings.
|
||||
pub fn subscriptions_for(&self, user_id: i64) -> Result<Vec<Sub>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT feed_id, keywords, auto_download, allow_explicit, max_new_per_check
|
||||
FROM subscriptions WHERE user_id = ?1",
|
||||
)?;
|
||||
let out = stmt
|
||||
.query_map([user_id], |r| {
|
||||
Ok(Sub {
|
||||
feed_id: r.get(0)?,
|
||||
keywords: r
|
||||
.get::<_, Option<String>>(1)?
|
||||
.and_then(|j| serde_json::from_str(&j).ok()),
|
||||
auto_download: r.get::<_, Option<i64>>(2)?.map(|v| v != 0),
|
||||
allow_explicit: r.get::<_, Option<i64>>(3)?.map(|v| v != 0),
|
||||
max_new_per_check: r.get(4)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Everyone's settings for one feed. The scanner merges these into what it fetches
|
||||
/// and downloads, since one file serves the lot.
|
||||
pub fn subscribers(&self, feed_id: &str) -> Result<Vec<Sub>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT keywords, auto_download, allow_explicit, max_new_per_check
|
||||
FROM subscriptions WHERE feed_id = ?1",
|
||||
)?;
|
||||
let out = stmt
|
||||
.query_map([feed_id], |r| {
|
||||
Ok(Sub {
|
||||
feed_id: feed_id.to_string(),
|
||||
keywords: r
|
||||
.get::<_, Option<String>>(0)?
|
||||
.and_then(|j| serde_json::from_str(&j).ok()),
|
||||
auto_download: r.get::<_, Option<i64>>(1)?.map(|v| v != 0),
|
||||
allow_explicit: r.get::<_, Option<i64>>(2)?.map(|v| v != 0),
|
||||
max_new_per_check: r.get(3)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
|
||||
params![user_id, feed_id, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unsubscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM subscriptions WHERE user_id = ?1 AND feed_id = ?2",
|
||||
params![user_id, feed_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many people want this feed. Nobody means it stops being scanned.
|
||||
pub fn subscriber_count(&self, feed_id: &str) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(
|
||||
"SELECT count(*) FROM subscriptions WHERE feed_id = ?1",
|
||||
[feed_id],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Overwrites one person's settings for a feed. A None field means: follow the feed.
|
||||
pub fn set_subscription(&self, user_id: i64, sub: &Sub) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let kw = sub
|
||||
.keywords
|
||||
.as_ref()
|
||||
.map(|k| serde_json::to_string(k))
|
||||
.transpose()?;
|
||||
conn.execute(
|
||||
"INSERT INTO subscriptions
|
||||
(user_id, feed_id, keywords, auto_download, allow_explicit, max_new_per_check, created)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(user_id, feed_id) DO UPDATE SET
|
||||
keywords = excluded.keywords,
|
||||
auto_download = excluded.auto_download,
|
||||
allow_explicit = excluded.allow_explicit,
|
||||
max_new_per_check = excluded.max_new_per_check",
|
||||
params![
|
||||
user_id,
|
||||
sub.feed_id,
|
||||
kw,
|
||||
sub.auto_download.map(|v| v as i64),
|
||||
sub.allow_explicit.map(|v| v as i64),
|
||||
sub.max_new_per_check,
|
||||
now()
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Feeds with at least one subscriber. What the scanner walks.
|
||||
pub fn subscribed_feed_ids(&self) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT DISTINCT feed_id FROM subscriptions")?;
|
||||
let out = stmt
|
||||
.query_map([], |r| r.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- users and sessions ----
|
||||
|
||||
pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result<i64> {
|
||||
@@ -827,11 +1050,19 @@ impl Db {
|
||||
|
||||
/// Marks every entry of the given feeds read. Takes a list because an OPML subscription
|
||||
/// holds no entries itself -- marking it read means the feeds inside it.
|
||||
pub fn mark_all_read(&self, feed_ids: &[String]) -> Result<usize> {
|
||||
pub fn mark_all_read(&self, user_id: i64, feed_ids: &[String]) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut n = 0;
|
||||
for id in feed_ids {
|
||||
n += conn.execute("UPDATE entries SET read = 1 WHERE feed_id = ?1 AND read = 0", [id])?;
|
||||
n += conn.execute(
|
||||
"INSERT INTO entry_state (user_id, feed_id, guid, read)
|
||||
SELECT ?1, e.feed_id, e.guid, 1 FROM entries e
|
||||
LEFT JOIN entry_state s
|
||||
ON s.user_id = ?1 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||
WHERE e.feed_id = ?2 AND coalesce(s.read, 0) = 0
|
||||
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET read = 1",
|
||||
rusqlite::params![user_id, id],
|
||||
)?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -876,14 +1107,28 @@ impl Db {
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// `read` and `flagged` finally get a writer: retention orders by them.
|
||||
pub fn set_entry_flag(&self, feed_id: &str, guid: &str, field: EntryFlag, on: bool) -> Result<()> {
|
||||
/// Read and starred, per person. The row is created on first touch.
|
||||
pub fn set_entry_flag(
|
||||
&self,
|
||||
user_id: i64,
|
||||
feed_id: &str,
|
||||
guid: &str,
|
||||
field: EntryFlag,
|
||||
on: bool,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let sql = match field {
|
||||
EntryFlag::Read => "UPDATE entries SET read = ?3 WHERE feed_id = ?1 AND guid = ?2",
|
||||
EntryFlag::Flagged => "UPDATE entries SET flagged = ?3 WHERE feed_id = ?1 AND guid = ?2",
|
||||
let col = match field {
|
||||
EntryFlag::Read => "read",
|
||||
EntryFlag::Flagged => "flagged",
|
||||
};
|
||||
conn.execute(sql, rusqlite::params![feed_id, guid, on as i64])?;
|
||||
conn.execute(
|
||||
&format!(
|
||||
"INSERT INTO entry_state (user_id, feed_id, guid, {col})
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET {col} = excluded.{col}"
|
||||
),
|
||||
rusqlite::params![user_id, feed_id, guid, on as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1021,6 +1266,39 @@ pub fn now() -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_state_belongs_to_one_person() {
|
||||
let db = Db::memory().unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
|
||||
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
||||
('f','a','One',100),('f','b','Two',200);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.unread_count(1, "f").unwrap(), 2);
|
||||
assert_eq!(db.unread_count(2, "f").unwrap(), 2);
|
||||
|
||||
db.set_entry_flag(1, "f", "a", EntryFlag::Read, true).unwrap();
|
||||
assert_eq!(db.unread_count(1, "f").unwrap(), 1, "ray read one of them");
|
||||
assert_eq!(db.unread_count(2, "f").unwrap(), 2, "sam has read nothing");
|
||||
|
||||
// Starring and position are just as private.
|
||||
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap();
|
||||
db.set_position(2, "f", "b", 42).unwrap();
|
||||
let ray = db.entries(1, "f", Filter::All, None, 0, 50).unwrap();
|
||||
let sam = db.entries(2, "f", Filter::All, None, 0, 50).unwrap();
|
||||
let ray_b = ray.iter().find(|e| e.guid == "b").unwrap();
|
||||
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
|
||||
assert!(ray_b.flagged && ray_b.position == 0);
|
||||
assert!(!sam_b.flagged && sam_b.position == 42);
|
||||
|
||||
// Marking a whole feed read is likewise one person's business.
|
||||
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
|
||||
assert_eq!(db.unread_count(2, "f").unwrap(), 0);
|
||||
assert_eq!(db.unread_count(1, "f").unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_is_idempotent_and_summary_handles_unknown_feeds() {
|
||||
let db = Db::memory().unwrap();
|
||||
@@ -1040,32 +1318,37 @@ mod tests {
|
||||
// so plain filtering failed with "Wrong number of parameters passed to query".
|
||||
let db = Db::memory().unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO entries (feed_id, guid, title, description, first_seen, read, flagged) VALUES
|
||||
('f','a','Alpha dive','notes one',100,0,0),
|
||||
('f','b','Beta', 'notes two',200,1,0),
|
||||
('f','c','Gamma dive','notes three',300,1,1);
|
||||
"INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
|
||||
('f','a','Alpha dive','notes one',100),
|
||||
('f','b','Beta', 'notes two',200),
|
||||
('f','c','Gamma dive','notes three',300);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1,'f','b','u1','/tmp/b','done');",
|
||||
(1,'f','b','u1','/tmp/b','done');
|
||||
-- Read and starred belong to a person now, so say which one.
|
||||
INSERT INTO users (id, name, is_admin, created) VALUES (7,'reader',1,0);
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||
(7,'f','b',1,0),
|
||||
(7,'f','c',1,1);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged] {
|
||||
// Both paths must run without erroring, and agree with each other.
|
||||
let rows = db.entries("f", f, None, 0, 50).unwrap();
|
||||
let n = db.count_entries("f", f, None).unwrap();
|
||||
let rows = db.entries(7, "f", f, None, 0, 50).unwrap();
|
||||
let n = db.count_entries(7, "f", f, None).unwrap();
|
||||
assert_eq!(rows.len() as i64, n, "{f:?} count disagrees with the page");
|
||||
|
||||
let rows = db.entries("f", f, Some("dive"), 0, 50).unwrap();
|
||||
let n = db.count_entries("f", f, Some("dive")).unwrap();
|
||||
let rows = db.entries(7, "f", f, Some("dive"), 0, 50).unwrap();
|
||||
let n = db.count_entries(7, "f", f, Some("dive")).unwrap();
|
||||
assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees");
|
||||
}
|
||||
|
||||
assert_eq!(db.count_entries("f", Filter::All, None).unwrap(), 3);
|
||||
assert_eq!(db.count_entries("f", Filter::Unread, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries("f", Filter::Downloaded, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries("f", Filter::Flagged, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries("f", Filter::All, Some("dive")).unwrap(), 2);
|
||||
assert_eq!(db.count_entries("f", Filter::All, Some("NOTES two")).unwrap(), 1,
|
||||
assert_eq!(db.count_entries(7, "f", Filter::All, None).unwrap(), 3);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::Unread, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::Downloaded, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::Flagged, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::All, Some("dive")).unwrap(), 2);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::All, Some("NOTES two")).unwrap(), 1,
|
||||
"search is case-insensitive and covers the description");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user