Skip to main content

scitadel_db/sqlite/
paper_state.rs

1//! Per-reader paper state (star / to-read / read). Thin CRUD over the
2//! `paper_state` table; upserts are idempotent on (paper_id, reader).
3
4use rusqlite::params;
5
6use crate::error::DbError;
7use crate::sqlite::Database;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct PaperState {
11    pub paper_id: String,
12    pub reader: String,
13    pub starred: bool,
14    pub to_read: bool,
15    /// ISO-8601 timestamp when the paper was marked read, or `None` if unread.
16    pub read_at: Option<String>,
17}
18
19#[derive(Clone)]
20pub struct SqlitePaperStateRepository {
21    db: Database,
22}
23
24impl SqlitePaperStateRepository {
25    pub fn new(db: Database) -> Self {
26        Self { db }
27    }
28
29    /// Load the state for one paper, or `None` if nothing is recorded yet.
30    pub fn get(&self, paper_id: &str, reader: &str) -> Result<Option<PaperState>, DbError> {
31        let conn = self.db.conn()?;
32        let mut stmt = conn.prepare(
33            "SELECT paper_id, reader, starred, to_read, read_at
34             FROM paper_state
35             WHERE paper_id = ?1 AND reader = ?2",
36        )?;
37        let mut rows = stmt.query(params![paper_id, reader])?;
38        if let Some(row) = rows.next()? {
39            Ok(Some(PaperState {
40                paper_id: row.get(0)?,
41                reader: row.get(1)?,
42                starred: row.get::<_, i64>(2)? != 0,
43                to_read: row.get::<_, i64>(3)? != 0,
44                read_at: row.get::<_, Option<String>>(4)?,
45            }))
46        } else {
47            Ok(None)
48        }
49    }
50
51    /// Upsert full state for a (paper, reader) pair.
52    pub fn set(&self, state: &PaperState) -> Result<(), DbError> {
53        let conn = self.db.conn()?;
54        let now = chrono::Utc::now().to_rfc3339();
55        conn.execute(
56            "INSERT INTO paper_state (paper_id, reader, starred, to_read, read_at, updated_at)
57             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
58             ON CONFLICT(paper_id, reader) DO UPDATE SET
59                 starred    = excluded.starred,
60                 to_read    = excluded.to_read,
61                 read_at    = excluded.read_at,
62                 updated_at = excluded.updated_at",
63            params![
64                state.paper_id,
65                state.reader,
66                i64::from(state.starred),
67                i64::from(state.to_read),
68                state.read_at,
69                now,
70            ],
71        )?;
72        Ok(())
73    }
74
75    /// Toggle `starred` and return the new value. Creates the row if needed.
76    pub fn toggle_starred(&self, paper_id: &str, reader: &str) -> Result<bool, DbError> {
77        let existing = self.get(paper_id, reader)?;
78        let new_state = match existing {
79            Some(mut s) => {
80                s.starred = !s.starred;
81                s
82            }
83            None => PaperState {
84                paper_id: paper_id.into(),
85                reader: reader.into(),
86                starred: true,
87                to_read: false,
88                read_at: None,
89            },
90        };
91        self.set(&new_state)?;
92        Ok(new_state.starred)
93    }
94
95    /// Load starred paper IDs for one reader as a `HashSet`.
96    pub fn starred_ids(&self, reader: &str) -> Result<std::collections::HashSet<String>, DbError> {
97        let conn = self.db.conn()?;
98        let mut stmt =
99            conn.prepare("SELECT paper_id FROM paper_state WHERE reader = ?1 AND starred = 1")?;
100        let rows = stmt.query_map(params![reader], |row| row.get::<_, String>(0))?;
101        let mut out = std::collections::HashSet::new();
102        for r in rows {
103            out.insert(r?);
104        }
105        Ok(out)
106    }
107
108    /// Paper IDs starred by `reader`, ordered by when the star was set
109    /// (most recent first). Drives the Queue tab (#48) — a cross-search
110    /// aggregator of starred papers. The partial index on
111    /// `paper_state(starred) WHERE starred = 1` keeps this cheap even
112    /// with thousands of rows.
113    pub fn starred_ids_ordered(&self, reader: &str) -> Result<Vec<String>, DbError> {
114        let conn = self.db.conn()?;
115        let mut stmt = conn.prepare(
116            "SELECT paper_id FROM paper_state
117             WHERE reader = ?1 AND starred = 1
118             ORDER BY updated_at DESC",
119        )?;
120        let rows = stmt.query_map(params![reader], |row| row.get::<_, String>(0))?;
121        Ok(rows.filter_map(Result::ok).collect())
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    fn fresh_db() -> Database {
130        let db = Database::open_in_memory().unwrap();
131        db.migrate().unwrap();
132        // Need a paper row to satisfy the FK.
133        let conn = db.conn().unwrap();
134        conn.execute(
135            "INSERT INTO papers (id, title, created_at, updated_at)
136             VALUES ('p1', 't', datetime('now'), datetime('now'))",
137            [],
138        )
139        .unwrap();
140        db
141    }
142
143    #[test]
144    fn toggle_starred_roundtrip() {
145        let db = fresh_db();
146        let repo = SqlitePaperStateRepository::new(db);
147        assert!(repo.toggle_starred("p1", "lars").unwrap());
148        assert!(!repo.toggle_starred("p1", "lars").unwrap());
149        assert!(repo.toggle_starred("p1", "lars").unwrap());
150        assert!(repo.get("p1", "lars").unwrap().is_some_and(|s| s.starred));
151    }
152
153    #[test]
154    fn different_readers_have_independent_state() {
155        let db = fresh_db();
156        let repo = SqlitePaperStateRepository::new(db);
157        repo.toggle_starred("p1", "lars").unwrap();
158        assert!(!repo.get("p1", "claude").unwrap().is_some_and(|s| s.starred));
159        assert!(repo.get("p1", "lars").unwrap().is_some_and(|s| s.starred));
160    }
161
162    #[test]
163    fn starred_ids_lists_only_starred() {
164        let db = fresh_db();
165        let repo = SqlitePaperStateRepository::new(db);
166        repo.toggle_starred("p1", "me").unwrap();
167        let ids = repo.starred_ids("me").unwrap();
168        assert!(ids.contains("p1"));
169    }
170
171    #[test]
172    fn starred_ids_ordered_returns_most_recent_first() {
173        let db = fresh_db();
174        // Add a second paper so we can observe ordering.
175        db.conn()
176            .unwrap()
177            .execute(
178                "INSERT INTO papers (id, title, created_at, updated_at)
179                 VALUES ('p2', 't2', datetime('now'), datetime('now'))",
180                [],
181            )
182            .unwrap();
183        let repo = SqlitePaperStateRepository::new(db);
184
185        // Star p1 first, then p2; the Queue tab (#48) expects the most
186        // recently starred to surface at the top of the list.
187        repo.toggle_starred("p1", "lars").unwrap();
188        std::thread::sleep(std::time::Duration::from_millis(10));
189        repo.toggle_starred("p2", "lars").unwrap();
190
191        let ordered = repo.starred_ids_ordered("lars").unwrap();
192        assert_eq!(ordered.len(), 2);
193        assert_eq!(ordered[0], "p2", "most recent first");
194        assert_eq!(ordered[1], "p1");
195    }
196
197    #[test]
198    fn starred_ids_ordered_drops_unstarred() {
199        let db = fresh_db();
200        let repo = SqlitePaperStateRepository::new(db);
201        repo.toggle_starred("p1", "lars").unwrap();
202        repo.toggle_starred("p1", "lars").unwrap(); // back to unstarred
203        assert!(repo.starred_ids_ordered("lars").unwrap().is_empty());
204    }
205}