use crate::config::Config; use crate::models::{ClipboardData, ClipboardEntry, Image}; use image::codecs::jpeg::JpegEncoder; use image::{ExtendedColorType, ImageEncoder}; use rusqlite::Connection; use std::error::Error; use std::fs; use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uuid::Uuid; pub struct Database { conn: Connection, dir_path: String, max_entries: usize, max_entry_size_bytes: usize, } impl Database { pub fn init(dir_path: &str, config: &Config) -> Result> { let base_path = Path::new(dir_path); fs::create_dir_all(base_path.join("images"))?; let conn = Connection::open(base_path.join("clipboard.db"))?; conn.execute_batch( "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", )?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); INSERT OR IGNORE INTO schema_version (version) SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);", )?; conn.execute( "CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, content TEXT NOT NULL, timestamp INTEGER NOT NULL )", [], )?; conn.execute_batch( "CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content); CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);", )?; conn.execute_batch( "DELETE FROM history WHERE id NOT IN ( SELECT MAX(id) FROM history GROUP BY content );", )?; Ok(Self { conn, dir_path: dir_path.to_string(), max_entries: config.max_entries, max_entry_size_bytes: config.max_entry_size_kb * 1024, }) } pub fn append(&self, entry: ClipboardEntry) -> Result<(), Box> { let ts = entry.timestamp.duration_since(UNIX_EPOCH)?.as_millis() as i64; let (kind, content) = match &entry.content { ClipboardData::Text(t) => { if t.trim().is_empty() { return Ok(()); } if t.len() > self.max_entry_size_bytes { return Ok(()); } ("text", t.clone()) } ClipboardData::Image(img) => { match &img.raw_pixels { Some(px) => { if px.len() > self.max_entry_size_bytes * 4 { eprintln!( "Image rejetée dans DB : {} Mo > limite {} Mo", px.len() / 1_048_576, (self.max_entry_size_bytes * 4) / 1_048_576 ); return Ok(()); } let path = img.file_path(&self.dir_path); let file = fs::File::create(&path)?; let rgb: Vec = px .chunks_exact(4) .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]]) .collect(); JpegEncoder::new_with_quality(file, 70).write_image( &rgb, img.width, img.height, ExtendedColorType::Rgb8, )?; } None => return Ok(()), } ("image", format!("{}.jpg", img.id)) } }; self.conn.execute( "INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)", (kind, &content, ts), )?; self.trim_to_max()?; Ok(()) } fn trim_to_max(&self) -> Result<(), Box> { if self.max_entries == 0 { return Ok(()); } let tx = self.conn.unchecked_transaction()?; let image_files: Vec = { let mut stmt = tx.prepare( "SELECT content FROM history WHERE type = 'image' AND id NOT IN ( SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 )", )?; stmt.query_map([self.max_entries as i64], |row| row.get(0))? .filter_map(|r| r.ok()) .collect() }; tx.execute( "DELETE FROM history WHERE id NOT IN ( SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1 )", [self.max_entries as i64], )?; tx.commit()?; for filename in image_files { let path = Path::new(&self.dir_path).join("images").join(&filename); if path.exists() { if let Err(e) = fs::remove_file(&path) { eprintln!("Impossible de supprimer l'image {filename} : {e}"); } } } Ok(()) } pub fn read_history(&self, limit: usize) -> Result, Box> { let mut stmt = self.conn.prepare( "SELECT type, content, timestamp FROM history ORDER BY timestamp DESC LIMIT ?1", )?; let rows = stmt.query_map([limit as i64], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, )) })?; let mut entries = Vec::new(); for row in rows { let (ty, content, ts_ms) = row?; let timestamp = UNIX_EPOCH + Duration::from_millis(ts_ms as u64); let data = if ty == "text" { ClipboardData::Text(content) } else { let id = Uuid::parse_str(content.trim_end_matches(".jpg"))?; ClipboardData::Image(Image { id, raw_pixels: None, width: 0, height: 0, }) }; entries.push(ClipboardEntry { content: data, timestamp, }); } Ok(entries) } pub fn delete_entry_by_content(&self, content: &str) -> Result<(), Box> { self.conn .execute("DELETE FROM history WHERE content = ?1", [content])?; Ok(()) } pub fn update_entry_content(&self, old: &str, new: &str) -> Result<(), Box> { let rows_affected = self.conn.execute( "UPDATE history SET content = ?1 WHERE content = ?2", [new, old], )?; if rows_affected == 0 { return Err(format!("Entrée introuvable : {old}").into()); } Ok(()) } pub fn delete_entries_older_than(&self, days: u64) -> Result> { let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64 - (days as i64 * 86_400_000); let tx = self.conn.unchecked_transaction()?; let image_files: Vec = { let mut stmt = tx.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?; stmt.query_map([cutoff_ms], |row| row.get(0))? .filter_map(|r| r.ok()) .collect() }; let count = tx.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?; tx.commit()?; for filename in image_files { let path = Path::new(&self.dir_path).join("images").join(&filename); if path.exists() { if let Err(e) = fs::remove_file(&path) { eprintln!("Impossible de supprimer l'image expirée {filename} : {e}"); } } } Ok(count) } pub fn clear_history(&self) -> Result<(), Box> { let images_dir = Path::new(&self.dir_path).join("images"); if images_dir.exists() { fs::remove_dir_all(&images_dir)?; } fs::create_dir_all(&images_dir)?; self.conn.execute("DELETE FROM history", [])?; Ok(()) } }