koan_core/db/queries/
bookmarks.rs1use rusqlite::{Connection, params};
2
3use super::auth::resolve_user;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Bookmark {
11 pub track_id: i64,
12 pub position_ms: i64,
13 pub comment: Option<String>,
14 pub created_at: i64,
16 pub changed_at: i64,
17}
18
19fn now() -> i64 {
20 std::time::SystemTime::now()
21 .duration_since(std::time::UNIX_EPOCH)
22 .unwrap_or_default()
23 .as_secs() as i64
24}
25
26pub fn save_bookmark(
28 conn: &Connection,
29 user: i64,
30 track_id: i64,
31 position_ms: i64,
32 comment: Option<&str>,
33) -> rusqlite::Result<()> {
34 conn.prepare_cached(
35 "INSERT INTO bookmarks (user_id, track_id, position_ms, comment, created_at, changed_at)
36 VALUES (?1, ?2, ?3, ?4, ?5, ?5)
37 ON CONFLICT (user_id, track_id) DO UPDATE SET
38 position_ms = excluded.position_ms,
39 comment = excluded.comment,
40 changed_at = excluded.changed_at",
41 )?
42 .execute(params![
43 resolve_user(conn, user)?,
44 track_id,
45 position_ms.max(0),
46 comment,
47 now()
48 ])?;
49 Ok(())
50}
51
52pub fn delete_bookmark(conn: &Connection, user: i64, track_id: i64) -> rusqlite::Result<bool> {
54 let removed = conn
55 .prepare_cached("DELETE FROM bookmarks WHERE user_id = ?1 AND track_id = ?2")?
56 .execute(params![resolve_user(conn, user)?, track_id])?;
57 Ok(removed > 0)
58}
59
60pub fn bookmarks(conn: &Connection, user: i64) -> rusqlite::Result<Vec<Bookmark>> {
62 let mut stmt = conn.prepare_cached(
63 "SELECT track_id, position_ms, comment, created_at, changed_at FROM bookmarks
64 WHERE user_id = ?1 ORDER BY changed_at DESC, track_id",
65 )?;
66 let rows = stmt.query_map([resolve_user(conn, user)?], |r| {
67 Ok(Bookmark {
68 track_id: r.get(0)?,
69 position_ms: r.get(1)?,
70 comment: r.get(2)?,
71 created_at: r.get(3)?,
72 changed_at: r.get(4)?,
73 })
74 })?;
75 rows.collect()
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use crate::db::queries::LOCAL_USER;
82
83 fn conn() -> Connection {
85 let conn = Connection::open_in_memory().unwrap();
86 conn.pragma_update(None, "foreign_keys", "on").unwrap();
87 crate::db::schema::create_tables(&conn).unwrap();
88 conn.execute_batch("INSERT INTO tracks (id, title) VALUES (1, 'Side A'), (2, 'Side B');")
89 .unwrap();
90 conn
91 }
92
93 #[test]
94 fn save_replaces_and_delete_removes() {
95 let conn = &conn();
96 save_bookmark(conn, LOCAL_USER, 1, 60_000, Some("chapter 2")).unwrap();
97 save_bookmark(conn, LOCAL_USER, 1, 90_000, None).unwrap();
98 let saved = bookmarks(conn, LOCAL_USER).unwrap();
99 assert_eq!(saved.len(), 1);
100 assert_eq!(saved[0].position_ms, 90_000);
101 assert_eq!(saved[0].comment, None);
102 assert!(delete_bookmark(conn, LOCAL_USER, 1).unwrap());
103 assert!(!delete_bookmark(conn, LOCAL_USER, 1).unwrap());
104 assert!(bookmarks(conn, LOCAL_USER).unwrap().is_empty());
105 }
106
107 #[test]
108 fn a_bookmark_needs_its_track() {
109 let conn = &conn();
110 assert!(save_bookmark(conn, LOCAL_USER, 99, 0, None).is_err());
111 save_bookmark(conn, LOCAL_USER, 2, 1_000, None).unwrap();
112 conn.execute("DELETE FROM tracks WHERE id = 2", []).unwrap();
113 assert!(bookmarks(conn, LOCAL_USER).unwrap().is_empty());
114 }
115}