use rusqlite::{params, Connection, Result as SqlResult}; use sha2::{Digest, Sha256}; use std::path::PathBuf; #[derive(Debug, Clone)] pub struct ClipEntry { pub id: i64, pub timestamp: i64, pub mime_type: String, pub content: Option, pub image_path: Option, pub content_hash: String, } pub struct HistoryDb { conn: Connection, } impl HistoryDb { pub fn open() -> SqlResult { let dir = data_dir(); std::fs::create_dir_all(&dir).ok(); let conn = Connection::open(dir.join("history.db"))?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, mime_type TEXT NOT NULL, content TEXT, image_path TEXT, content_hash TEXT NOT NULL UNIQUE ); CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC);", )?; Ok(Self { conn }) } pub fn insert_text(&self, text: &str) -> SqlResult<()> { let hash = sha256_hex(text.as_bytes()); let ts = unix_now(); self.conn.execute( "INSERT INTO history (timestamp, mime_type, content, content_hash) VALUES (?1, 'text/plain', ?2, ?3) ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1", params![ts, text, hash], )?; self.trim(200, 50) } pub fn insert_image(&self, png_bytes: &[u8]) -> SqlResult<()> { let hash = sha256_hex(png_bytes); let images_dir = data_dir().join("images"); std::fs::create_dir_all(&images_dir).ok(); // Use first 16 hex chars for the filename (collision-safe for 50 images) let path = images_dir.join(format!("{}.png", &hash[..16])); if !path.exists() { std::fs::write(&path, png_bytes).ok(); } let path_str = path.to_string_lossy().to_string(); let ts = unix_now(); self.conn.execute( "INSERT INTO history (timestamp, mime_type, image_path, content_hash) VALUES (?1, 'image/png', ?2, ?3) ON CONFLICT(content_hash) DO UPDATE SET timestamp = ?1", params![ts, path_str, hash], )?; self.trim(200, 50) } pub fn list_entries(&self, limit: usize) -> SqlResult> { let mut stmt = self.conn.prepare( "SELECT id, timestamp, mime_type, content, image_path, content_hash FROM history ORDER BY timestamp DESC LIMIT ?1", )?; let rows = stmt.query_map([limit as i64], |row| { Ok(ClipEntry { id: row.get(0)?, timestamp: row.get(1)?, mime_type: row.get(2)?, content: row.get(3)?, image_path: row.get(4)?, content_hash: row.get(5)?, }) })? .collect::>>(); rows } pub fn delete_entry(&self, id: i64) -> SqlResult<()> { // Clean up image file if present let image_path: Option = self .conn .query_row( "SELECT image_path FROM history WHERE id = ?1", [id], |row| row.get(0), ) .ok() .flatten(); if let Some(p) = image_path { let _ = std::fs::remove_file(p); } self.conn.execute("DELETE FROM history WHERE id = ?1", [id])?; Ok(()) } fn trim(&self, max_text: usize, max_images: usize) -> SqlResult<()> { self.conn.execute( "DELETE FROM history WHERE mime_type = 'text/plain' AND id NOT IN ( SELECT id FROM history WHERE mime_type = 'text/plain' ORDER BY timestamp DESC LIMIT ?1 )", [max_text as i64], )?; // Collect old image paths before deleting rows let old_paths: Vec = { let mut stmt = self.conn.prepare( "SELECT image_path FROM history WHERE mime_type = 'image/png' AND image_path IS NOT NULL AND id NOT IN ( SELECT id FROM history WHERE mime_type = 'image/png' ORDER BY timestamp DESC LIMIT ?1 )", )?; let paths = stmt.query_map([max_images as i64], |row| row.get(0))? .collect::>>()?; paths }; for path in old_paths { let _ = std::fs::remove_file(path); } self.conn.execute( "DELETE FROM history WHERE mime_type = 'image/png' AND id NOT IN ( SELECT id FROM history WHERE mime_type = 'image/png' ORDER BY timestamp DESC LIMIT ?1 )", [max_images as i64], )?; Ok(()) } } pub fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); hex::encode(hasher.finalize()) } pub fn data_dir() -> PathBuf { dirs::data_local_dir() .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join("breadclip") } fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64 }