oxide_browser/
bookmarks.rs1use std::sync::{Arc, Mutex};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use anyhow::{Context, Result};
13
14#[derive(Clone, Debug)]
20pub struct Bookmark {
21 pub url: String,
23 pub title: String,
25 pub is_favorite: bool,
27 pub created_at_ms: u64,
29}
30
31impl Bookmark {
32 fn to_bytes(&self) -> Vec<u8> {
33 let fav_byte: u8 = if self.is_favorite { 1 } else { 0 };
34 let ts_bytes = self.created_at_ms.to_le_bytes();
35 let title_bytes = self.title.as_bytes();
36 let title_len = (title_bytes.len() as u32).to_le_bytes();
37
38 let mut buf = Vec::with_capacity(1 + 8 + 4 + title_bytes.len());
39 buf.push(fav_byte);
40 buf.extend_from_slice(&ts_bytes);
41 buf.extend_from_slice(&title_len);
42 buf.extend_from_slice(title_bytes);
43 buf
44 }
45
46 fn from_bytes(url: &str, data: &[u8]) -> Option<Self> {
47 if data.len() < 13 {
48 return None;
49 }
50 let is_favorite = data[0] != 0;
51 let created_at_ms = u64::from_le_bytes(data[1..9].try_into().ok()?);
52 let title_len = u32::from_le_bytes(data[9..13].try_into().ok()?) as usize;
53 if data.len() < 13 + title_len {
54 return None;
55 }
56 let title = String::from_utf8(data[13..13 + title_len].to_vec()).ok()?;
57 Some(Self {
58 url: url.to_string(),
59 title,
60 is_favorite,
61 created_at_ms,
62 })
63 }
64}
65
66#[derive(Clone)]
71pub struct BookmarkStore {
72 tree: sled::Tree,
73}
74
75impl BookmarkStore {
76 pub fn open(db: &sled::Db) -> Result<Self> {
78 let tree = db
79 .open_tree("bookmarks")
80 .context("failed to open bookmarks tree")?;
81 Ok(Self { tree })
82 }
83
84 pub fn add(&self, url: &str, title: &str) -> Result<()> {
88 let bm = Bookmark {
89 url: url.to_string(),
90 title: title.to_string(),
91 is_favorite: false,
92 created_at_ms: now_ms(),
93 };
94 self.tree
95 .insert(url.as_bytes(), bm.to_bytes())
96 .context("failed to insert bookmark")?;
97 Ok(())
98 }
99
100 pub fn remove(&self, url: &str) -> Result<()> {
102 self.tree
103 .remove(url.as_bytes())
104 .context("failed to remove bookmark")?;
105 Ok(())
106 }
107
108 pub fn contains(&self, url: &str) -> bool {
110 self.tree.contains_key(url.as_bytes()).unwrap_or(false)
111 }
112
113 pub fn toggle_favorite(&self, url: &str) -> Result<bool> {
118 if let Some(data) = self
119 .tree
120 .get(url.as_bytes())
121 .context("failed to read bookmark")?
122 {
123 if let Some(mut bm) = Bookmark::from_bytes(url, &data) {
124 bm.is_favorite = !bm.is_favorite;
125 let new_fav = bm.is_favorite;
126 self.tree
127 .insert(url.as_bytes(), bm.to_bytes())
128 .context("failed to update bookmark")?;
129 return Ok(new_fav);
130 }
131 }
132 Ok(false)
133 }
134
135 #[allow(dead_code)]
139 pub fn is_favorite(&self, url: &str) -> bool {
140 self.tree
141 .get(url.as_bytes())
142 .ok()
143 .flatten()
144 .and_then(|data| Bookmark::from_bytes(url, &data))
145 .map(|bm| bm.is_favorite)
146 .unwrap_or(false)
147 }
148
149 pub fn list_all(&self) -> Vec<Bookmark> {
151 let mut bookmarks = Vec::new();
152 for (key, val) in self.tree.iter().flatten() {
153 if let Ok(url) = String::from_utf8(key.to_vec()) {
154 if let Some(bm) = Bookmark::from_bytes(&url, &val) {
155 bookmarks.push(bm);
156 }
157 }
158 }
159 bookmarks.sort_by(|a, b| b.created_at_ms.cmp(&a.created_at_ms));
160 bookmarks
161 }
162
163 #[allow(dead_code)]
165 pub fn list_favorites(&self) -> Vec<Bookmark> {
166 self.list_all()
167 .into_iter()
168 .filter(|bm| bm.is_favorite)
169 .collect()
170 }
171}
172
173pub type SharedBookmarkStore = Arc<Mutex<Option<BookmarkStore>>>;
178
179pub fn new_shared() -> SharedBookmarkStore {
181 Arc::new(Mutex::new(None))
182}
183
184fn now_ms() -> u64 {
185 SystemTime::now()
186 .duration_since(UNIX_EPOCH)
187 .unwrap_or_default()
188 .as_millis() as u64
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use tempfile::tempdir;
195
196 fn test_store() -> BookmarkStore {
197 let dir = tempdir().unwrap();
198 let db = sled::open(dir.path()).unwrap();
199 BookmarkStore::open(&db).unwrap()
200 }
201
202 #[test]
203 fn add_and_list() {
204 let store = test_store();
205 store.add("https://a.com/app.wasm", "App A").unwrap();
206 store.add("https://b.com/app.wasm", "App B").unwrap();
207 let all = store.list_all();
208 assert_eq!(all.len(), 2);
209 assert!(store.contains("https://a.com/app.wasm"));
210 assert!(!store.contains("https://c.com/app.wasm"));
211 }
212
213 #[test]
214 fn remove_bookmark() {
215 let store = test_store();
216 store.add("https://a.com/app.wasm", "A").unwrap();
217 assert!(store.contains("https://a.com/app.wasm"));
218 store.remove("https://a.com/app.wasm").unwrap();
219 assert!(!store.contains("https://a.com/app.wasm"));
220 }
221
222 #[test]
223 fn toggle_favorite() {
224 let store = test_store();
225 store.add("https://a.com/app.wasm", "A").unwrap();
226 assert!(!store.is_favorite("https://a.com/app.wasm"));
227 store.toggle_favorite("https://a.com/app.wasm").unwrap();
228 assert!(store.is_favorite("https://a.com/app.wasm"));
229 store.toggle_favorite("https://a.com/app.wasm").unwrap();
230 assert!(!store.is_favorite("https://a.com/app.wasm"));
231 }
232
233 #[test]
234 fn list_favorites_only() {
235 let store = test_store();
236 store.add("https://a.com/app.wasm", "A").unwrap();
237 store.add("https://b.com/app.wasm", "B").unwrap();
238 store.toggle_favorite("https://a.com/app.wasm").unwrap();
239 let favs = store.list_favorites();
240 assert_eq!(favs.len(), 1);
241 assert_eq!(favs[0].url, "https://a.com/app.wasm");
242 }
243}