//! 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 wrapper pub struct Database { conn: Connection, } /// Bookmark entry #[derive(Debug, Clone)] pub struct Bookmark { pub id: i64, pub url: String, pub title: String, pub tags: Vec, pub created_at: DateTime, pub updated_at: DateTime, } /// History entry #[derive(Debug, Clone)] pub struct HistoryEntry { pub id: i64, pub url: String, pub title: String, pub visited_at: DateTime, } impl Database { /// Open or create database at default location pub fn open() -> Result { let path = Self::default_path()?; Self::open_at(path) } /// Open or create database at specified path pub fn open_at(path: PathBuf) -> Result { // 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 { 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 { 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 { 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> { 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 = 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> { let mut bookmarks = Vec::new(); let mapper = |row: &rusqlite::Row| -> rusqlite::Result { let tags_str: Option = 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 { 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> { let mut entries = Vec::new(); let mapper = |row: &rusqlite::Row| -> rusqlite::Result { 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 { 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"); } }