diff --git a/src/db/mod.rs b/src/db/mod.rs index 8c98ddd..f2048cd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -80,6 +80,7 @@ pub trait ClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result; fn decode_entry( &self, @@ -93,7 +94,6 @@ pub trait ClipboardDb { &self, id: i64, ) -> Result<(i64, Vec, Option), StashError>; - fn next_sequence(&self) -> i64; } #[derive(Serialize, Deserialize)] @@ -296,6 +296,49 @@ impl SqliteClipboardDb { })?; } + // Add is_expired column if it doesn't exist (v5) + if schema_version < 5 { + let has_is_expired: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("is_expired")) + }, + ) + .unwrap_or(false); + + if !has_is_expired { + tx.execute( + "ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to add is_expired column: {e}").into(), + ) + })?; + } + + // Create index for is_expired (for filtering) + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \ + WHERE is_expired = 1", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create is_expired index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 5", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + tx.commit().map_err(|e| { StashError::Store( format!("Failed to commit migration transaction: {e}").into(), @@ -311,13 +354,17 @@ impl SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn list_json(&self) -> Result { + pub fn list_json(&self, include_expired: bool) -> Result { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -526,13 +573,18 @@ impl ClipboardDb for SqliteClipboardDb { &self, mut out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -680,17 +732,6 @@ impl ClipboardDb for SqliteClipboardDb { Ok((id, contents, mime)) } - - fn next_sequence(&self) -> i64 { - match self - .conn - .query_row("SELECT MAX(id) FROM clipboard", [], |row| { - row.get::<_, Option>(0) - }) { - Ok(Some(max_id)) => max_id + 1, - Ok(None) | Err(_) => 1, - } - } } impl SqliteClipboardDb { @@ -715,40 +756,6 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Trim(e.to_string().into())) } - /// Get expired entries (for --expired flag diagnostic output) - pub fn get_expired_entries( - &self, - ) -> Result, Option)>, StashError> { - let now = Self::now(); - let mut stmt = self - .conn - .prepare( - "SELECT id, contents, mime FROM clipboard WHERE expires_at IS NOT \ - NULL AND expires_at <= ?1 ORDER BY expires_at ASC", - ) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query([now]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut entries = Vec::new(); - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - let id: i64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - entries.push((id, contents, mime)); - } - Ok(entries) - } - /// Get the earliest expiration (timestamp, id) for heap initialization pub fn get_next_expiration(&self) -> Result, StashError> { match self.conn.query_row( @@ -778,6 +785,66 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Store(e.to_string().into()))?; Ok(()) } + + /// Optimize database using VACUUM + pub fn vacuum(&self) -> Result<(), StashError> { + self + .conn + .execute("VACUUM", []) + .map_err(|e| StashError::Store(e.to_string().into()))?; + Ok(()) + } + + /// Get database statistics + pub fn stats(&self) -> Result { + let total: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let expired: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE is_expired = 1", + [], + |row| row.get(0), + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let active = total - expired; + + let with_expiration: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \ + (is_expired IS NULL OR is_expired = 0)", + [], + |row| row.get(0), + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + // Get database file size + let page_count: i64 = self + .conn + .query_row("PRAGMA page_count", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let page_size: i64 = self + .conn + .query_row("PRAGMA page_size", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let size_bytes = page_count * page_size; + let size_mb = size_bytes as f64 / 1024.0 / 1024.0; + + Ok(format!( + "Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \ + {active}\nExpired: {expired}\nWith TTL: \ + {with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \ + ({size_bytes} bytes)\nPages: {page_count}\nPage size: \ + {page_size} bytes" + )) + } } /// Try to load a sensitive regex from systemd credential or env.