Skip to main content

oxide_browser/
bookmarks.rs

1//! Persistent bookmarks for the Oxide browser.
2//!
3//! Entries are stored in a [`sled`] embedded database under a dedicated [`sled::Tree`]
4//! named `"bookmarks"`. Each record is keyed by URL; values hold the serialized
5//! title, favorite flag, and creation time. For UI code that may run on multiple
6//! threads, use [`SharedBookmarkStore`] and initialize the store when the
7//! database is available.
8
9use std::sync::{Arc, Mutex};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use anyhow::{Context, Result};
13
14/// A saved bookmark: canonical URL, display title, favorite flag, and creation time.
15///
16/// The URL is the primary key in [`BookmarkStore`]. New bookmarks from [`BookmarkStore::add`]
17/// start with [`Bookmark::is_favorite`] set to `false` and [`Bookmark::created_at_ms`] set
18/// to the current time in milliseconds since the UNIX epoch.
19#[derive(Clone, Debug)]
20pub struct Bookmark {
21    /// Canonical bookmark URL; also the sled key for this entry.
22    pub url: String,
23    /// User-visible title (may differ from the page title at save time).
24    pub title: String,
25    /// When `true`, this bookmark is included in favorite-only listings.
26    pub is_favorite: bool,
27    /// Creation instant as milliseconds since [`UNIX_EPOCH`].
28    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/// Persistent bookmark storage backed by a [`sled::Tree`] in an open [`sled::Db`].
67///
68/// The tree name is `"bookmarks"`. Keys are URL byte strings; values are an internal
69/// binary encoding of title, favorite bit, and timestamp (see [`Bookmark`]).
70#[derive(Clone)]
71pub struct BookmarkStore {
72    tree: sled::Tree,
73}
74
75impl BookmarkStore {
76    /// Opens the bookmarks tree in `db`, creating it if it does not exist.
77    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    /// Inserts a new bookmark for `url` with the given `title`, or overwrites the existing entry.
85    ///
86    /// The bookmark is stored as not favorited with a fresh [`Bookmark::created_at_ms`].
87    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    /// Removes the bookmark for `url`, if present.
101    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    /// Returns whether a bookmark exists for `url`.
109    pub fn contains(&self, url: &str) -> bool {
110        self.tree.contains_key(url.as_bytes()).unwrap_or(false)
111    }
112
113    /// Flips the favorite flag for the bookmark at `url` and returns the new value.
114    ///
115    /// If the URL is missing or the stored value cannot be decoded, returns `Ok(false)` without
116    /// changing storage.
117    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    /// Returns whether the bookmark at `url` is marked as a favorite.
136    ///
137    /// Missing or corrupt entries are treated as not favorited.
138    #[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    /// Returns every bookmark, ordered by [`Bookmark::created_at_ms`] descending (newest first).
150    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    /// Returns only bookmarks with [`Bookmark::is_favorite`] set, in the same order as [`Self::list_all`].
164    #[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
173/// Thread-safe handle to an optional [`BookmarkStore`]: [`Arc`] wrapped [`Mutex`] of [`Option`].
174///
175/// Use `None` before the sled database is opened; replace with `Some(store)` after
176/// [`BookmarkStore::open`]. Lock the mutex when reading or updating bookmarks from worker threads.
177pub type SharedBookmarkStore = Arc<Mutex<Option<BookmarkStore>>>;
178
179/// Creates a shared bookmark store initialized to `None` (no database opened yet).
180pub 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}