360 lines · 11.9 KB · Rust
RawDownload
//! Storage module for bookmarks and history persistence use anyhow::{Context, Result};use chrono::{DateTime, Utc};use rusqlite::{params, Connection};use std::path::PathBuf; /// Database connection wrapperpub struct Database {    conn: Connection,} /// Bookmark entry#[derive(Debug, Clone)]pub struct Bookmark {    pub id: i64,    pub url: String,    pub title: String,    pub tags: Vec<String>,    pub created_at: DateTime<Utc>,    pub updated_at: DateTime<Utc>,} /// History entry#[derive(Debug, Clone)]pub struct HistoryEntry {    pub id: i64,    pub url: String,    pub title: String,    pub visited_at: DateTime<Utc>,} impl Database {    /// Open or create database at default location    pub fn open() -> Result<Self> {        let path = Self::default_path()?;        Self::open_at(path)    }     /// Open or create database at specified path    pub fn open_at(path: PathBuf) -> Result<Self> {        // Ensure parent directory exists        if let Some(parent) = path.parent() {            std::fs::create_dir_all(parent)                .context("Failed to create database directory")?;        }         let conn = Connection::open(&path)            .context("Failed to open database")?;         let db = Self { conn };        db.init_schema()?;         Ok(db)    }     /// Get default database path    fn default_path() -> Result<PathBuf> {        let data_dir = dirs::data_dir()            .context("Could not find data directory")?;        Ok(data_dir.join("azul-browse").join("azul.db"))    }     /// Initialize database schema    fn init_schema(&self) -> Result<()> {        self.conn.execute_batch(            r#"            CREATE TABLE IF NOT EXISTS bookmarks (                id INTEGER PRIMARY KEY AUTOINCREMENT,                url TEXT UNIQUE NOT NULL,                title TEXT NOT NULL,                tags TEXT,                created_at TEXT DEFAULT CURRENT_TIMESTAMP,                updated_at TEXT DEFAULT CURRENT_TIMESTAMP            );             CREATE TABLE IF NOT EXISTS history (                id INTEGER PRIMARY KEY AUTOINCREMENT,                url TEXT NOT NULL,                title TEXT NOT NULL,                visited_at TEXT DEFAULT CURRENT_TIMESTAMP            );             CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url);            CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);            CREATE INDEX IF NOT EXISTS idx_history_visited ON history(visited_at);            "#        ).context("Failed to initialize schema")?;         Ok(())    }     // ═══════════════════════════════════════════════════════════════════════════    // BOOKMARKS    // ═══════════════════════════════════════════════════════════════════════════     /// Add a new bookmark (or update if URL exists)    pub fn add_bookmark(&self, url: &str, title: &str, tags: &[String]) -> Result<Bookmark> {        let tags_str = tags.join(",");         self.conn.execute(            r#"INSERT INTO bookmarks (url, title, tags)               VALUES (?1, ?2, ?3)               ON CONFLICT(url) DO UPDATE SET                 title = ?2, tags = ?3, updated_at = CURRENT_TIMESTAMP"#,            params![url, title, tags_str],        ).context("Failed to add bookmark")?;         let id = self.conn.last_insert_rowid();        let now = Utc::now();         Ok(Bookmark {            id,            url: url.to_string(),            title: title.to_string(),            tags: tags.to_vec(),            created_at: now,            updated_at: now,        })    }     /// Remove a bookmark by URL    pub fn remove_bookmark(&self, url: &str) -> Result<()> {        self.conn.execute(            "DELETE FROM bookmarks WHERE url = ?1",            params![url],        ).context("Failed to remove bookmark")?;         Ok(())    }     /// Check if a URL is bookmarked    pub fn is_bookmarked(&self, url: &str) -> Result<bool> {        let count: i64 = self.conn.query_row(            "SELECT COUNT(*) FROM bookmarks WHERE url = ?1",            params![url],            |row| row.get(0),        )?;         Ok(count > 0)    }     /// Get a bookmark by URL    pub fn get_bookmark(&self, url: &str) -> Result<Option<Bookmark>> {        let mut stmt = self.conn.prepare(            "SELECT id, url, title, tags, created_at, updated_at FROM bookmarks WHERE url = ?1"        )?;         let result = stmt.query_row(params![url], |row| {            let tags_str: Option<String> = row.get(3)?;            let tags = tags_str                .map(|s| s.split(',').filter(|t| !t.is_empty()).map(String::from).collect())                .unwrap_or_default();             let created_at_str: String = row.get(4)?;            let updated_at_str: String = row.get(5)?;             Ok(Bookmark {                id: row.get(0)?,                url: row.get(1)?,                title: row.get(2)?,                tags,                created_at: DateTime::parse_from_rfc3339(&created_at_str)                    .map(|dt| dt.with_timezone(&Utc))                    .unwrap_or_else(|_| Utc::now()),                updated_at: DateTime::parse_from_rfc3339(&updated_at_str)                    .map(|dt| dt.with_timezone(&Utc))                    .unwrap_or_else(|_| Utc::now()),            })        });         match result {            Ok(bookmark) => Ok(Some(bookmark)),            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),            Err(e) => Err(e.into()),        }    }     /// List bookmarks with optional search filter    pub fn list_bookmarks(&self, search: Option<&str>, limit: usize) -> Result<Vec<Bookmark>> {        let mut bookmarks = Vec::new();         let mapper = |row: &rusqlite::Row| -> rusqlite::Result<Bookmark> {            let tags_str: Option<String> = row.get(3)?;            let tags = tags_str                .map(|s| s.split(',').filter(|t| !t.is_empty()).map(String::from).collect())                .unwrap_or_default();             Ok(Bookmark {                id: row.get(0)?,                url: row.get(1)?,                title: row.get(2)?,                tags,                created_at: Utc::now(),                updated_at: Utc::now(),            })        };         if let Some(s) = search {            let search_val = format!("%{}%", s);            let mut stmt = self.conn.prepare(                r#"SELECT id, url, title, tags, created_at, updated_at FROM bookmarks                   WHERE title LIKE ?1 OR url LIKE ?1 OR tags LIKE ?1                   ORDER BY updated_at DESC LIMIT ?2"#,            )?;            let rows = stmt.query_map(params![search_val, limit as i64], mapper)?;            for row in rows {                bookmarks.push(row?);            }        } else {            let mut stmt = self.conn.prepare(                r#"SELECT id, url, title, tags, created_at, updated_at FROM bookmarks                   ORDER BY updated_at DESC LIMIT ?1"#,            )?;            let rows = stmt.query_map(params![limit as i64], mapper)?;            for row in rows {                bookmarks.push(row?);            }        }         Ok(bookmarks)    }     /// Count total bookmarks    pub fn count_bookmarks(&self) -> Result<usize> {        let count: i64 = self.conn.query_row(            "SELECT COUNT(*) FROM bookmarks",            [],            |row| row.get(0),        )?;        Ok(count as usize)    }     // ═══════════════════════════════════════════════════════════════════════════    // HISTORY    // ═══════════════════════════════════════════════════════════════════════════     /// Add a history entry    pub fn add_history(&self, url: &str, title: &str) -> Result<()> {        self.conn.execute(            "INSERT INTO history (url, title) VALUES (?1, ?2)",            params![url, title],        ).context("Failed to add history")?;         Ok(())    }     /// List history entries with optional search filter    pub fn list_history(&self, search: Option<&str>, limit: usize) -> Result<Vec<HistoryEntry>> {        let mut entries = Vec::new();         let mapper = |row: &rusqlite::Row| -> rusqlite::Result<HistoryEntry> {            Ok(HistoryEntry {                id: row.get(0)?,                url: row.get(1)?,                title: row.get(2)?,                visited_at: Utc::now(),            })        };         if let Some(s) = search {            let search_val = format!("%{}%", s);            let mut stmt = self.conn.prepare(                r#"SELECT id, url, title, visited_at FROM history                   WHERE title LIKE ?1 OR url LIKE ?1                   ORDER BY visited_at DESC LIMIT ?2"#,            )?;            let rows = stmt.query_map(params![search_val, limit as i64], mapper)?;            for row in rows {                entries.push(row?);            }        } else {            let mut stmt = self.conn.prepare(                r#"SELECT id, url, title, visited_at FROM history                   ORDER BY visited_at DESC LIMIT ?1"#,            )?;            let rows = stmt.query_map(params![limit as i64], mapper)?;            for row in rows {                entries.push(row?);            }        }         Ok(entries)    }     /// Clear all history    pub fn clear_history(&self) -> Result<()> {        self.conn.execute("DELETE FROM history", [])            .context("Failed to clear history")?;        Ok(())    }     /// Clear history older than specified days    pub fn clear_history_older_than(&self, days: i64) -> Result<()> {        self.conn.execute(            "DELETE FROM history WHERE visited_at < datetime('now', ?1)",            params![format!("-{} days", days)],        ).context("Failed to clear old history")?;        Ok(())    }     /// Count total history entries    pub fn count_history(&self) -> Result<usize> {        let count: i64 = self.conn.query_row(            "SELECT COUNT(*) FROM history",            [],            |row| row.get(0),        )?;        Ok(count as usize)    }} #[cfg(test)]mod tests {    use super::*;    use tempfile::{tempdir, TempDir};     // Return both so TempDir stays alive while Database is used    fn test_db() -> (Database, TempDir) {        let dir = tempdir().unwrap();        let path = dir.path().join("test.db");        let db = Database::open_at(path).unwrap();        (db, dir)    }     #[test]    fn test_add_and_get_bookmark() {        let (db, _dir) = test_db();         db.add_bookmark("https://example.com", "Example", &["test".to_string()]).unwrap();         assert!(db.is_bookmarked("https://example.com").unwrap());         let bookmark = db.get_bookmark("https://example.com").unwrap().unwrap();        assert_eq!(bookmark.url, "https://example.com");        assert_eq!(bookmark.title, "Example");    }     #[test]    fn test_remove_bookmark() {        let (db, _dir) = test_db();         db.add_bookmark("https://example.com", "Example", &[]).unwrap();        assert!(db.is_bookmarked("https://example.com").unwrap());         db.remove_bookmark("https://example.com").unwrap();        assert!(!db.is_bookmarked("https://example.com").unwrap());    }     #[test]    fn test_add_history() {        let (db, _dir) = test_db();         db.add_history("https://example.com", "Example").unwrap();         let entries = db.list_history(None, 10).unwrap();        assert_eq!(entries.len(), 1);        assert_eq!(entries[0].url, "https://example.com");    }}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t