diff --git a/Cargo.lock b/Cargo.lock index d3b5980..26321a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "ident_case" version = "1.0.1" @@ -1121,9 +1127,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -2128,6 +2134,7 @@ dependencies = [ "ctrlc", "dirs", "env_logger", + "humantime", "imagesize", "inquire", "libc", @@ -2561,18 +2568,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -2583,9 +2590,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2593,9 +2600,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -2606,9 +2613,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] @@ -2924,9 +2931,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wl-clipboard-rs" diff --git a/Cargo.toml b/Cargo.toml index 94ec687..cd21891 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.90" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] @@ -22,6 +22,7 @@ crossterm = "0.29.0" ctrlc = "3.5.1" dirs = "6.0.0" env_logger = "0.11.8" +humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.2", default-features = false, features = [ "crossterm", diff --git a/README.md b/README.md index c3fd56c..72ea591 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ with many features such as but not necessarily limited to: - Import clipboard history from TSV (e.g., from `cliphist list`) - Image preview (shows dimensions and format) - Text previews with customizable width -- Deduplication and entry limit control +- De-duplication, whitespace prevention and entry limit control - Automatic clipboard monitoring with `stash watch` + - Configurable auto-expiry of old entries in watch mode as a safety buffer - Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`) - Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering by application (see below) @@ -141,7 +142,7 @@ Commands: list List clipboard history decode Decode and output clipboard entry by id delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly - wipe Wipe all clipboard history + db Database management operations import Import clipboard data from stdin (default: TSV format) watch Start a process to watch clipboard for changes and store automatically help Print this message or the help of the given subcommand(s) @@ -154,7 +155,7 @@ Options: --preview-width Maximum width (in characters) for clipboard entry previews in list output [default: 100] --db-path - Path to the `SQLite` clipboard database file + Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=] --excluded-apps Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=] --ask @@ -188,6 +189,11 @@ and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will display entries in Cliphist-compatible TSV format in Bash scripts. You may also enforce the output format with `stash list --format `. +You may also view your clipboard _with the addition of expired entries_, i.e., +entries that have reached their TTL and are marked as expired, using the +`--expired` flag as `stash list --expired`. Expired entries are not cleaned up +when using this flag, allowing you to inspect them before running cleanup. + ### Decode an entry by ID ```bash @@ -219,10 +225,33 @@ stash delete --type id < ids.txt ### Wipe all entries +> [!WARNING] +> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe` +> instead. + ```bash stash wipe ``` +### Database management + +Stash provides a `db` subcommand for database maintenance operations: + +```bash +stash db wipe [--expired] [--ask] +stash db vacuum +stash db stats +``` + +- `stash db wipe`: Remove all entries from the database. Use `--expired` to only + wipe expired entries instead of all entries. Requires `--ask` confirmation by + default. +- `stash db vacuum`: Optimize the database using SQLite's VACUUM command, + reclaiming space and improving performance. +- `stash db stats`: Display database statistics including total/active/expired + entry counts, storage size, and page information. This is provided purely for + convenience and the rule of the cool. + ### Watch clipboard for changes and store automatically ```bash @@ -235,13 +264,16 @@ automatically. This is designed as an alternative to shelling out to premade Systemd service in `contrib/`. Packagers are encouraged to vendor the service unless adding their own. -> [!TIP] -> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility -> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you -> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs` -> wrappers on your system. In other words, you can use -> `wl-paste --watch stash store` as an alternative to `stash watch` if -> preferred. +#### Automatic Clipboard Clearing on Expiration + +When `stash watch` is running and a clipboard entry expires, Stash will detect +if the current clipboard still contains that expired content and automatically +clear it. This prevents stale data from remaining in your clipboard after an +entry has expired from history. + +> [!NOTE] +> This behavior only applies when the watch daemon is actively running. Manual +> expiration or deletion of entries will not clear the clipboard. ### Options @@ -406,6 +438,86 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! the packagers. While building from source, you may link `target/release/stash` manually. +### Entry Expiration + +Stash supports time-to-live (TTL) for clipboard entries. When an entry's +expiration time is reached, it is marked as expired rather than immediately +deleted. This allows for inspection of expired entries and automatic clipboard +cleanup. + +#### How Expiration Works + +When `stash watch` is running with `--expire-after`, it monitors the clipboard +and processes expired entries periodically. Upon expiration: + +1. The entry's `is_expired` flag is set to `1` in the database +2. If the current clipboard content matches the expired entry, Stash clears the + clipboard to prevent pasting stale data +3. Expired entries are excluded from normal list operations unless `--expired` + is specified + +> [!NOTE] +> By default, entries do not expire. Use `stash watch --expire-after DURATION` +> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL). + +#### Viewing Expired Entries + +Use `stash list --expired` to include expired entries in the output. This is +useful for: + +- Inspecting what has expired from your clipboard history +- Verifying that sensitive data has been properly expired +- Debugging expiration behavior + +```bash +# View all entries including expired ones +stash list --expired + +# View expired entries in JSON format +stash list --expired --format json +``` + +#### Cleaning Up Expired Entries + +The watch daemon automatically cleans up expired entries when it processes them. +For manual cleanup, use: + +```bash +# Remove all expired entries from the database +stash db wipe --expired +``` + +> [!NOTE] +> If you have a large number of expired entries, consider running +> `stash db vacuum` afterward to reclaim disk space. + +#### Automatic Clipboard Clearing + +When `stash watch` is running and an entry expires, Stash checks if the current +clipboard still contains that expired content. If it matches, Stash clears the +clipboard automatically. This prevents accidentally pasting outdated content. + +> [!TIP] +> This behavior only applies when the watch daemon is actively running. Manual +> expiration or deletion of entries will not clear the clipboard. + +#### Database Maintenance + +Stash uses SQLite for persistent storage. Over time, deleted entries and +fragmentation can affect performance. Use the `stash db` command to maintain +your database: + +- **Check statistics**: `stash db stats` shows entry counts and storage usage. + Use this to monitor growth and decide when to clean up. +- **Remove expired entries**: `stash db wipe --expired` removes entries that + have reached their TTL. The daemon normally handles this, but this is useful + for manual cleanup. +- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to + reclaim space and defragment the database. This is safe to run periodically. + +It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep +the database compact, especially after deleting many entries. + ## Attributions My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the diff --git a/src/commands/list.rs b/src/commands/list.rs index 1afab2a..25903f3 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -6,8 +6,12 @@ use unicode_width::UnicodeWidthStr; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait ListCommand { - fn list(&self, out: impl Write, preview_width: u32) - -> Result<(), StashError>; + fn list( + &self, + out: impl Write, + preview_width: u32, + include_expired: bool, + ) -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { @@ -15,14 +19,21 @@ impl ListCommand for SqliteClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result<(), StashError> { - self.list_entries(out, preview_width).map(|_| ()) + self + .list_entries(out, preview_width, include_expired) + .map(|_| ()) } } impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] - pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { + pub fn list_tui( + &self, + preview_width: u32, + include_expired: bool, + ) -> Result<(), StashError> { use std::io::stdout; use crossterm::{ @@ -53,12 +64,16 @@ impl SqliteClipboardDb { use wl_clipboard_rs::copy::{MimeType, Options, Source}; // Query entries from DB + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \ + id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY last_accessed DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \ - DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index ce2acf7..54706bb 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,21 +1,105 @@ use std::{ - collections::hash_map::DefaultHasher, + collections::{BinaryHeap, hash_map::DefaultHasher}, hash::{Hash, Hasher}, io::Read, time::Duration, }; use smol::Timer; -use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; +use wl_clipboard_rs::{ + copy::{MimeType as CopyMimeType, Options, Source}, + paste::{ClipboardType, Seat, get_contents}, +}; use crate::db::{ClipboardDb, SqliteClipboardDb}; +/// Wrapper to provide [`Ord`] implementation for `f64` by negating values. +/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. +/// Also see: +/// - +/// - +/// - +#[derive(Debug, Clone, Copy)] +struct Neg(f64); + +impl Neg { + fn inner(&self) -> f64 { + self.0 + } +} + +impl std::cmp::PartialEq for Neg { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl std::cmp::Eq for Neg {} + +impl std::cmp::PartialOrd for Neg { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for Neg { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reverse ordering for min-heap behavior + other + .0 + .partial_cmp(&self.0) + .unwrap_or(std::cmp::Ordering::Equal) + } +} + +/// Min-heap for tracking entry expirations with sub-second precision. +/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior. +#[derive(Debug, Default)] +struct ExpirationQueue { + heap: BinaryHeap<(Neg, i64)>, +} + +impl ExpirationQueue { + /// Create a new empty expiration queue + fn new() -> Self { + Self { + heap: BinaryHeap::new(), + } + } + + /// Push a new expiration into the queue + fn push(&mut self, expires_at: f64, id: i64) { + self.heap.push((Neg(expires_at), id)); + } + + /// Peek at the next expiration timestamp without removing it + fn peek_next(&self) -> Option { + self.heap.peek().map(|(neg, _)| neg.inner()) + } + + /// Remove and return all entries that have expired by `now` + fn pop_expired(&mut self, now: f64) -> Vec { + let mut expired = Vec::new(); + while let Some((neg_exp, id)) = self.heap.peek() { + let expires_at = neg_exp.inner(); + if expires_at <= now { + expired.push(*id); + self.heap.pop(); + } else { + break; + } + } + expired + } +} + pub trait WatchCommand { fn watch( &self, max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], + expire_after: Option, ); } @@ -25,10 +109,46 @@ impl WatchCommand for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], + expire_after: Option, ) { smol::block_on(async { log::info!("Starting clipboard watch daemon"); + // Build expiration queue from existing entries + let mut exp_queue = ExpirationQueue::new(); + if let Ok(Some((expires_at, id))) = self.get_next_expiration() { + exp_queue.push(expires_at, id); + // Load remaining expirations (exclude already-marked expired entries) + let mut stmt = self + .conn + .prepare( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \ + NULL AND (is_expired IS NULL OR is_expired = 0) ORDER BY \ + expires_at ASC", + ) + .ok(); + if let Some(ref mut stmt) = stmt { + let mut rows = stmt.query([]).ok(); + if let Some(ref mut rows) = rows { + while let Ok(Some(row)) = rows.next() { + if let (Ok(exp), Ok(row_id)) = + (row.get::<_, f64>(0), row.get::<_, i64>(1)) + { + // Skip first entry which is already added + if exp_queue + .heap + .iter() + .any(|(_, existing_id)| *existing_id == row_id) + { + continue; + } + exp_queue.push(exp, row_id); + } + } + } + } + } + // We use hashes for comparison instead of storing full contents let mut last_hash: Option = None; let mut buf = Vec::with_capacity(4096); @@ -53,6 +173,83 @@ impl WatchCommand for SqliteClipboardDb { } loop { + // Process any pending expirations + if let Some(next_exp) = exp_queue.peek_next() { + let now = SqliteClipboardDb::now(); + if next_exp <= now { + // Expired entries to process + let expired_ids = exp_queue.pop_expired(now); + for id in expired_ids { + // Verify entry still exists and get its content_hash + let expired_hash: Option = self + .conn + .query_row( + "SELECT content_hash FROM clipboard WHERE id = ?1", + [id], + |row| row.get(0), + ) + .ok(); + + if let Some(stored_hash) = expired_hash { + // Mark as expired + self + .conn + .execute( + "UPDATE clipboard SET is_expired = 1 WHERE id = ?1", + [id], + ) + .ok(); + log::info!("Entry {id} marked as expired"); + + // Check if this expired entry is currently in the clipboard + if let Ok((mut reader, _)) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + let mut current_buf = Vec::new(); + if reader.read_to_end(&mut current_buf).is_ok() + && !current_buf.is_empty() + { + let current_hash = hash_contents(¤t_buf); + // Compare as i64 (database stores as i64) + if current_hash as i64 == stored_hash { + // Clear the clipboard since expired content is still + // there + let mut opts = Options::new(); + opts.clipboard( + wl_clipboard_rs::copy::ClipboardType::Regular, + ); + if opts + .copy( + Source::Bytes(Vec::new().into()), + CopyMimeType::Autodetect, + ) + .is_ok() + { + log::info!( + "Cleared clipboard containing expired entry {id}" + ); + last_hash = None; // reset tracked hash + } else { + log::warn!( + "Failed to clear clipboard for expired entry {id}" + ); + } + } + } + } + } + } + } else { + // Sleep *precisely* until next expiration + let sleep_duration = next_exp - now; + Timer::after(Duration::from_secs_f64(sleep_duration)).await; + continue; // skip normal poll, process expirations first + } + } + + // Normal clipboard polling match get_contents( ClipboardType::Regular, Seat::Unspecified, @@ -70,16 +267,23 @@ impl WatchCommand for SqliteClipboardDb { if !buf.is_empty() { let current_hash = hash_contents(&buf); if last_hash != Some(current_hash) { - let id = self.next_sequence(); match self.store_entry( &buf[..], max_dedupe_search, max_items, Some(excluded_apps), ) { - Ok(_) => { + Ok(id) => { log::info!("Stored new clipboard entry (id: {id})"); last_hash = Some(current_hash); + + // Set expiration if configured + if let Some(duration) = expire_after { + let expires_at = + SqliteClipboardDb::now() + duration.as_secs_f64(); + self.set_expiration(id, expires_at).ok(); + exp_queue.push(expires_at, id); + } }, Err(crate::db::StashError::ExcludedByApp(_)) => { log::info!("Clipboard entry excluded by app filter"); @@ -106,7 +310,11 @@ impl WatchCommand for SqliteClipboardDb { } }, } - Timer::after(Duration::from_millis(500)).await; + + // Normal poll interval (only if no expirations pending) + if exp_queue.peek_next().is_none() { + Timer::after(Duration::from_millis(500)).await; + } } }); } diff --git a/src/db/mod.rs b/src/db/mod.rs index 97e2bb3..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)] @@ -256,6 +256,89 @@ impl SqliteClipboardDb { })?; } + // Add expires_at column if it doesn't exist (v4) + if schema_version < 4 { + let has_expires_at: 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("expires_at")) + }, + ) + .unwrap_or(false); + + if !has_expires_at { + tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add expires_at column: {e}").into(), + ) + })?; + } + + // Create partial index for expires_at (only index non-NULL values) + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \ + WHERE expires_at IS NOT NULL", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create expires_at index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 4", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + + // 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(), @@ -271,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([]) @@ -486,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([]) @@ -640,17 +732,119 @@ impl ClipboardDb for SqliteClipboardDb { Ok((id, contents, mime)) } +} - fn next_sequence(&self) -> i64 { - match self +impl SqliteClipboardDb { + /// Get current Unix timestamp with sub-second precision + pub fn now() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64() + } + + /// Clean up all expired entries. Returns count deleted. + pub fn cleanup_expired(&self) -> Result { + let now = Self::now(); + 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, + .execute( + "DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \ + ?1", + [now], + ) + .map_err(|e| StashError::Trim(e.to_string().into())) + } + + /// Get the earliest expiration (timestamp, id) for heap initialization + pub fn get_next_expiration(&self) -> Result, StashError> { + match self.conn.query_row( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \ + ORDER BY expires_at ASC LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) { + Ok(result) => Ok(Some(result)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(StashError::Store(e.to_string().into())), } } + + /// Set expiration timestamp for an entry + pub fn set_expiration( + &self, + id: i64, + expires_at: f64, + ) -> Result<(), StashError> { + self + .conn + .execute( + "UPDATE clipboard SET expires_at = ?2 WHERE id = ?1", + params![id, expires_at], + ) + .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. diff --git a/src/main.rs b/src/main.rs index 28f9fb0..aca9838 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,11 @@ use std::{ env, io::{self, IsTerminal}, path::PathBuf, + time::Duration, }; use clap::{CommandFactory, Parser, Subcommand}; +use humantime::parse_duration; use inquire::Confirm; mod commands; @@ -71,6 +73,10 @@ enum Command { /// Output format: "tsv" (default) or "json" #[arg(long, value_parser = ["tsv", "json"])] format: Option, + + /// Show only expired entries (diagnostic, does not remove them) + #[arg(long)] + expired: bool, }, /// Decode and output clipboard entry by id @@ -93,12 +99,21 @@ enum Command { }, /// Wipe all clipboard history + /// + /// DEPRECATED: Use `stash db wipe` instead + #[command(hide = true)] Wipe { /// Ask for confirmation before wiping #[arg(long)] ask: bool, }, + /// Database management operations + Db { + #[command(subcommand)] + action: DbAction, + }, + /// Import clipboard data from stdin (default: TSV format) Import { /// Explicitly specify format: "tsv" (default) @@ -111,7 +126,31 @@ enum Command { }, /// Start a process to watch clipboard for changes and store automatically. - Watch, + Watch { + /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). + #[arg(long, value_parser = parse_duration)] + expire_after: Option, + }, +} + +#[derive(Subcommand)] +enum DbAction { + /// Wipe database entries + Wipe { + /// Only wipe expired entries instead of all entries + #[arg(long)] + expired: bool, + + /// Ask for confirmation before wiping + #[arg(long)] + ask: bool, + }, + + /// Optimize database using VACUUM + Vacuum, + + /// Show database statistics + Stats, } fn report_error( @@ -186,16 +225,16 @@ fn main() -> color_eyre::eyre::Result<()> { "failed to store entry", ); }, - Some(Command::List { format }) => { + Some(Command::List { format, expired }) => { match format.as_deref() { Some("tsv") => { report_error( - db.list(io::stdout(), cli.preview_width), + db.list(io::stdout(), cli.preview_width, expired), "failed to list entries", ); }, Some("json") => { - match db.list_json() { + match db.list_json(expired) { Ok(json) => { println!("{json}"); }, @@ -210,12 +249,12 @@ fn main() -> color_eyre::eyre::Result<()> { None => { if std::io::stdout().is_terminal() { report_error( - db.list_tui(cli.preview_width), + db.list_tui(cli.preview_width, expired), "failed to list entries in TUI", ); } else { report_error( - db.list(io::stdout(), cli.preview_width), + db.list(io::stdout(), cli.preview_width, expired), "failed to list entries", ); } @@ -287,6 +326,10 @@ fn main() -> color_eyre::eyre::Result<()> { } }, Some(Command::Wipe { ask }) => { + eprintln!( + "Warning: The 'stash wipe' command is deprecated. Use 'stash db \ + wipe' instead." + ); let mut should_proceed = true; if ask { should_proceed = Confirm::new( @@ -304,6 +347,62 @@ fn main() -> color_eyre::eyre::Result<()> { } }, + Some(Command::Db { action }) => { + match action { + DbAction::Wipe { expired, ask } => { + let mut should_proceed = true; + if ask { + let message = if expired { + "Are you sure you want to wipe all expired clipboard entries?" + } else { + "Are you sure you want to wipe ALL clipboard history?" + }; + should_proceed = Confirm::new(message) + .with_default(false) + .prompt() + .unwrap_or(false); + if !should_proceed { + log::info!("db wipe command aborted by user."); + } + } + if should_proceed { + if expired { + match db.cleanup_expired() { + Ok(count) => { + log::info!("Wiped {} expired entries", count); + }, + Err(e) => { + log::error!("failed to wipe expired entries: {e}"); + }, + } + } else { + report_error(db.wipe(), "failed to wipe database"); + } + } + }, + DbAction::Vacuum => { + match db.vacuum() { + Ok(()) => { + log::info!("Database optimized successfully"); + }, + Err(e) => { + log::error!("failed to vacuum database: {e}"); + }, + } + }, + DbAction::Stats => { + match db.stats() { + Ok(stats) => { + println!("{}", stats); + }, + Err(e) => { + log::error!("failed to get database stats: {e}"); + }, + } + }, + } + }, + Some(Command::Import { r#type, ask }) => { let mut should_proceed = true; if ask { @@ -334,7 +433,7 @@ fn main() -> color_eyre::eyre::Result<()> { } } }, - Some(Command::Watch) => { + Some(Command::Watch { expire_after }) => { db.watch( cli.max_dedupe_search, cli.max_items, @@ -342,6 +441,7 @@ fn main() -> color_eyre::eyre::Result<()> { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], + expire_after, ); },