diff --git a/src/commands/decode.rs b/src/commands/decode.rs index 4cff4a2..e6df237 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -1,75 +1,78 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; - use std::io::{Read, Write}; -use crate::db::StashError; use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents}; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; + pub trait DecodeCommand { - fn decode( - &self, - in_: impl Read, - out: impl Write, - input: Option, - ) -> Result<(), StashError>; + fn decode( + &self, + in_: impl Read, + out: impl Write, + input: Option, + ) -> Result<(), StashError>; } impl DecodeCommand for SqliteClipboardDb { - fn decode( - &self, - mut in_: impl Read, - mut out: impl Write, - input: Option, - ) -> Result<(), StashError> { - let input_str = if let Some(s) = input { - s + fn decode( + &self, + mut in_: impl Read, + mut out: impl Write, + input: Option, + ) -> Result<(), StashError> { + let input_str = if let Some(s) = input { + s + } else { + let mut buf = String::new(); + if let Err(e) = in_.read_to_string(&mut buf) { + log::error!("Failed to read stdin for decode: {e}"); + } + buf + }; + + // If input is empty or whitespace, treat as error and trigger fallback + if input_str.trim().is_empty() { + log::info!("No input provided to decode; relaying clipboard to stdout"); + if let Ok((mut reader, _mime)) = + get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) + { + let mut buf = Vec::new(); + if let Err(err) = reader.read_to_end(&mut buf) { + log::error!("Failed to read clipboard for relay: {err}"); } else { - let mut buf = String::new(); - if let Err(e) = in_.read_to_string(&mut buf) { - log::error!("Failed to read stdin for decode: {e}"); - } - buf - }; - - // If input is empty or whitespace, treat as error and trigger fallback - if input_str.trim().is_empty() { - log::info!("No input provided to decode; relaying clipboard to stdout"); - if let Ok((mut reader, _mime)) = - get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) - { - let mut buf = Vec::new(); - if let Err(err) = reader.read_to_end(&mut buf) { - log::error!("Failed to read clipboard for relay: {err}"); - } else { - let _ = out.write_all(&buf); - } - } else { - log::error!("Failed to get clipboard contents for relay"); - } - return Ok(()); + let _ = out.write_all(&buf); } - - // Try decode as usual - match self.decode_entry(input_str.as_bytes(), &mut out, Some(input_str.clone())) { - Ok(()) => { - log::info!("Entry decoded"); - } - Err(e) => { - log::error!("Failed to decode entry: {e}"); - if let Ok((mut reader, _mime)) = - get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) - { - let mut buf = Vec::new(); - if let Err(err) = reader.read_to_end(&mut buf) { - log::error!("Failed to read clipboard for relay: {err}"); - } else { - let _ = out.write_all(&buf); - } - } else { - log::error!("Failed to get clipboard contents for relay"); - } - } - } - Ok(()) + } else { + log::error!("Failed to get clipboard contents for relay"); + } + return Ok(()); } + + // Try decode as usual + match self.decode_entry( + input_str.as_bytes(), + &mut out, + Some(input_str.clone()), + ) { + Ok(()) => { + log::info!("Entry decoded"); + }, + Err(e) => { + log::error!("Failed to decode entry: {e}"); + if let Ok((mut reader, _mime)) = + get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) + { + let mut buf = Vec::new(); + if let Err(err) = reader.read_to_end(&mut buf) { + log::error!("Failed to read clipboard for relay: {err}"); + } else { + let _ = out.write_all(&buf); + } + } else { + log::error!("Failed to get clipboard contents for relay"); + } + }, + } + Ok(()) + } } diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 64fd1cf..e7e2c92 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -1,22 +1,22 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; - use std::io::Read; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; + pub trait DeleteCommand { - fn delete(&self, input: impl Read) -> Result; + fn delete(&self, input: impl Read) -> Result; } impl DeleteCommand for SqliteClipboardDb { - fn delete(&self, input: impl Read) -> Result { - match self.delete_entries(input) { - Ok(deleted) => { - log::info!("Deleted {deleted} entries"); - Ok(deleted) - } - Err(e) => { - log::error!("Failed to delete entries: {e}"); - Err(e) - } - } + fn delete(&self, input: impl Read) -> Result { + match self.delete_entries(input) { + Ok(deleted) => { + log::info!("Deleted {deleted} entries"); + Ok(deleted) + }, + Err(e) => { + log::error!("Failed to delete entries: {e}"); + Err(e) + }, } + } } diff --git a/src/commands/list.rs b/src/commands/list.rs index c926a5a..1464956 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,274 +1,286 @@ use std::io::Write; -use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; use unicode_segmentation::UnicodeSegmentation; 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) + -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { - fn list(&self, out: impl Write, preview_width: u32) -> Result<(), StashError> { - self.list_entries(out, preview_width)?; - log::info!("Listed clipboard entries"); - Ok(()) - } + fn list( + &self, + out: impl Write, + preview_width: u32, + ) -> Result<(), StashError> { + self.list_entries(out, preview_width)?; + log::info!("Listed clipboard entries"); + Ok(()) + } } impl SqliteClipboardDb { - /// Public TUI listing function for use in main.rs - #[allow(clippy::too_many_lines)] - pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { - use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, - execute, - terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, - }, - }; - use ratatui::{ - Terminal, - backend::CrosstermBackend, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState}, - }; - use std::io::stdout; + /// Public TUI listing function for use in main.rs + #[allow(clippy::too_many_lines)] + pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { + use std::io::stdout; - // Query entries from DB - let mut stmt = self - .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{ + EnterAlternateScreen, + LeaveAlternateScreen, + disable_raw_mode, + enable_raw_mode, + }, + }; + use ratatui::{ + Terminal, + backend::CrosstermBackend, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, + }; - let mut entries: Vec<(u64, String, String)> = Vec::new(); - let mut max_id_width = 2; - let mut max_mime_width = 8; - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let preview = crate::db::preview_entry(&contents, mime.as_deref(), preview_width); - let mime_str = mime.as_deref().unwrap_or("").to_string(); - let id_str = id.to_string(); - max_id_width = max_id_width.max(id_str.width()); - max_mime_width = max_mime_width.max(mime_str.width()); - entries.push((id, preview, mime_str)); - } + // Query entries from DB + let mut stmt = self + .conn + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string()))?; - enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut stdout = stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = - Terminal::new(backend).map_err(|e| StashError::ListDecode(e.to_string()))?; - - let mut state = ListState::default(); - if !entries.is_empty() { - state.select(Some(0)); - } - - let res = (|| -> Result<(), StashError> { - loop { - terminal - .draw(|f| { - let area = f.area(); - let block = Block::default() - .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") - .borders(Borders::ALL); - - let border_width = 2; - let highlight_symbol = ">"; - let highlight_width = 1; - let content_width = area.width as usize - border_width; - - // Minimum widths for columns - let min_id_width = 2; - let min_mime_width = 6; - let min_preview_width = 4; - let spaces = 3; // [id][ ][preview][ ][mime] - - // Dynamically allocate widths - let mut id_col = max_id_width.max(min_id_width); - let mut mime_col = max_mime_width.max(min_mime_width); - let mut preview_col = content_width - .saturating_sub(highlight_width) - .saturating_sub(id_col) - .saturating_sub(mime_col) - .saturating_sub(spaces); - - // If not enough space, shrink columns - if preview_col < min_preview_width { - let needed = min_preview_width - preview_col; - if mime_col > min_mime_width { - let reduce = mime_col - min_mime_width; - let take = reduce.min(needed); - mime_col -= take; - preview_col += take; - } - } - if preview_col < min_preview_width { - let needed = min_preview_width - preview_col; - if id_col > min_id_width { - let reduce = id_col - min_id_width; - let take = reduce.min(needed); - id_col -= take; - preview_col += take; - } - } - if preview_col < min_preview_width { - preview_col = min_preview_width; - } - - let selected = state.selected(); - - let list_items: Vec = entries - .iter() - .enumerate() - .map(|(i, entry)| { - // Truncate preview by grapheme clusters and display width - let mut preview = String::new(); - let mut width = 0; - for g in entry.1.graphemes(true) { - let g_width = UnicodeWidthStr::width(g); - if width + g_width > preview_col { - preview.push('…'); - break; - } - preview.push_str(g); - width += g_width; - } - // Truncate and pad mimetype - let mut mime = String::new(); - let mut mwidth = 0; - for g in entry.2.graphemes(true) { - let g_width = UnicodeWidthStr::width(g); - if mwidth + g_width > mime_col { - mime.push('…'); - break; - } - mime.push_str(g); - mwidth += g_width; - } - - // Compose the row as highlight + id + space + preview + space + mimetype - let mut spans = Vec::new(); - let (id, preview, mime) = entry; - if Some(i) == selected { - spans.push(Span::styled( - highlight_symbol, - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - format!("{id:>id_col$}"), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::raw(" ")); - spans.push(Span::styled( - format!("{preview:mime_col$}"), - Style::default().fg(Color::Green), - )); - } else { - spans.push(Span::raw(" ")); - spans.push(Span::raw(format!("{id:>id_col$}"))); - spans.push(Span::raw(" ")); - spans.push(Span::raw(format!("{preview:mime_col$}"))); - } - ListItem::new(Line::from(spans)) - }) - .collect(); - - let list = List::new(list_items) - .block(block) - .highlight_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(""); // handled manually - - f.render_stateful_widget(list, area, &mut state); - }) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - - if event::poll(std::time::Duration::from_millis(250)) - .map_err(|e| StashError::ListDecode(e.to_string()))? - { - if let Event::Key(key) = - event::read().map_err(|e| StashError::ListDecode(e.to_string()))? - { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, - KeyCode::Down | KeyCode::Char('j') => { - let i = match state.selected() { - Some(i) => { - if i >= entries.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - state.select(Some(i)); - } - KeyCode::Up | KeyCode::Char('k') => { - let i = match state.selected() { - Some(i) => { - if i == 0 { - entries.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - state.select(Some(i)); - } - _ => {} - } - } - } - } - Ok(()) - })(); - - disable_raw_mode().ok(); - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ) - .ok(); - terminal.show_cursor().ok(); - - res + let mut entries: Vec<(u64, String, String)> = Vec::new(); + let mut max_id_width = 2; + let mut max_mime_width = 8; + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let preview = + crate::db::preview_entry(&contents, mime.as_deref(), preview_width); + let mime_str = mime.as_deref().unwrap_or("").to_string(); + let id_str = id.to_string(); + max_id_width = max_id_width.max(id_str.width()); + max_mime_width = max_mime_width.max(mime_str.width()); + entries.push((id, preview, mime_str)); } + + enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + + let mut state = ListState::default(); + if !entries.is_empty() { + state.select(Some(0)); + } + + let res = (|| -> Result<(), StashError> { + loop { + terminal + .draw(|f| { + let area = f.area(); + let block = Block::default() + .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") + .borders(Borders::ALL); + + let border_width = 2; + let highlight_symbol = ">"; + let highlight_width = 1; + let content_width = area.width as usize - border_width; + + // Minimum widths for columns + let min_id_width = 2; + let min_mime_width = 6; + let min_preview_width = 4; + let spaces = 3; // [id][ ][preview][ ][mime] + + // Dynamically allocate widths + let mut id_col = max_id_width.max(min_id_width); + let mut mime_col = max_mime_width.max(min_mime_width); + let mut preview_col = content_width + .saturating_sub(highlight_width) + .saturating_sub(id_col) + .saturating_sub(mime_col) + .saturating_sub(spaces); + + // If not enough space, shrink columns + if preview_col < min_preview_width { + let needed = min_preview_width - preview_col; + if mime_col > min_mime_width { + let reduce = mime_col - min_mime_width; + let take = reduce.min(needed); + mime_col -= take; + preview_col += take; + } + } + if preview_col < min_preview_width { + let needed = min_preview_width - preview_col; + if id_col > min_id_width { + let reduce = id_col - min_id_width; + let take = reduce.min(needed); + id_col -= take; + preview_col += take; + } + } + if preview_col < min_preview_width { + preview_col = min_preview_width; + } + + let selected = state.selected(); + + let list_items: Vec = entries + .iter() + .enumerate() + .map(|(i, entry)| { + // Truncate preview by grapheme clusters and display width + let mut preview = String::new(); + let mut width = 0; + for g in entry.1.graphemes(true) { + let g_width = UnicodeWidthStr::width(g); + if width + g_width > preview_col { + preview.push('…'); + break; + } + preview.push_str(g); + width += g_width; + } + // Truncate and pad mimetype + let mut mime = String::new(); + let mut mwidth = 0; + for g in entry.2.graphemes(true) { + let g_width = UnicodeWidthStr::width(g); + if mwidth + g_width > mime_col { + mime.push('…'); + break; + } + mime.push_str(g); + mwidth += g_width; + } + + // Compose the row as highlight + id + space + preview + space + + // mimetype + let mut spans = Vec::new(); + let (id, preview, mime) = entry; + if Some(i) == selected { + spans.push(Span::styled( + highlight_symbol, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!("{id:>id_col$}"), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{preview:mime_col$}"), + Style::default().fg(Color::Green), + )); + } else { + spans.push(Span::raw(" ")); + spans.push(Span::raw(format!("{id:>id_col$}"))); + spans.push(Span::raw(" ")); + spans.push(Span::raw(format!("{preview:mime_col$}"))); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(list_items) + .block(block) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(""); // handled manually + + f.render_stateful_widget(list, area, &mut state); + }) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + + if event::poll(std::time::Duration::from_millis(250)) + .map_err(|e| StashError::ListDecode(e.to_string()))? + { + if let Event::Key(key) = + event::read().map_err(|e| StashError::ListDecode(e.to_string()))? + { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Down | KeyCode::Char('j') => { + let i = match state.selected() { + Some(i) => { + if i >= entries.len() - 1 { + 0 + } else { + i + 1 + } + }, + None => 0, + }; + state.select(Some(i)); + }, + KeyCode::Up | KeyCode::Char('k') => { + let i = match state.selected() { + Some(i) => { + if i == 0 { + entries.len() - 1 + } else { + i - 1 + } + }, + None => 0, + }; + state.select(Some(i)); + }, + _ => {}, + } + } + } + } + Ok(()) + })(); + + disable_raw_mode().ok(); + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .ok(); + terminal.show_cursor().ok(); + + res + } } diff --git a/src/commands/query.rs b/src/commands/query.rs index 6673648..c5b5851 100644 --- a/src/commands/query.rs +++ b/src/commands/query.rs @@ -1,13 +1,11 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; - -use crate::db::StashError; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait QueryCommand { - fn query_delete(&self, query: &str) -> Result; + fn query_delete(&self, query: &str) -> Result; } impl QueryCommand for SqliteClipboardDb { - fn query_delete(&self, query: &str) -> Result { - ::delete_query(self, query) - } + fn query_delete(&self, query: &str) -> Result { + ::delete_query(self, query) + } } diff --git a/src/commands/store.rs b/src/commands/store.rs index 4e2c769..6ddfb60 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -1,32 +1,32 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; - use std::io::Read; +use crate::db::{ClipboardDb, SqliteClipboardDb}; + pub trait StoreCommand { - fn store( - &self, - input: impl Read, - max_dedupe_search: u64, - max_items: u64, - state: Option, - ) -> Result<(), crate::db::StashError>; + fn store( + &self, + input: impl Read, + max_dedupe_search: u64, + max_items: u64, + state: Option, + ) -> Result<(), crate::db::StashError>; } impl StoreCommand for SqliteClipboardDb { - fn store( - &self, - input: impl Read, - max_dedupe_search: u64, - max_items: u64, - state: Option, - ) -> Result<(), crate::db::StashError> { - if let Some("sensitive" | "clear") = state.as_deref() { - self.delete_last()?; - log::info!("Entry deleted"); - } else { - self.store_entry(input, max_dedupe_search, max_items)?; - log::info!("Entry stored"); - } - Ok(()) + fn store( + &self, + input: impl Read, + max_dedupe_search: u64, + max_items: u64, + state: Option, + ) -> Result<(), crate::db::StashError> { + if let Some("sensitive" | "clear") = state.as_deref() { + self.delete_last()?; + log::info!("Entry deleted"); + } else { + self.store_entry(input, max_dedupe_search, max_items)?; + log::info!("Entry stored"); } + Ok(()) + } } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index dcf334d..01e922e 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,79 +1,80 @@ -use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; +use std::{io::Read, time::Duration}; + use smol::Timer; -use std::io::Read; -use std::time::Duration; use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; +use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; + pub trait WatchCommand { - fn watch(&self, max_dedupe_search: u64, max_items: u64); + fn watch(&self, max_dedupe_search: u64, max_items: u64); } impl WatchCommand for SqliteClipboardDb { - fn watch(&self, max_dedupe_search: u64, max_items: u64) { - smol::block_on(async { - log::info!("Starting clipboard watch daemon"); + fn watch(&self, max_dedupe_search: u64, max_items: u64) { + smol::block_on(async { + log::info!("Starting clipboard watch daemon"); - // Preallocate buffer for clipboard contents - let mut last_contents: Option> = None; - let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully + // Preallocate buffer for clipboard contents + let mut last_contents: Option> = None; + let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully - // Initialize with current clipboard to avoid duplicating on startup - if let Ok((mut reader, _)) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, - ) { - buf.clear(); - if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { - last_contents = Some(buf.clone()); - } + // Initialize with current clipboard to avoid duplicating on startup + if let Ok((mut reader, _)) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + buf.clear(); + if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + last_contents = Some(buf.clone()); + } + } + + loop { + match get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + Ok((mut reader, mime_type)) => { + buf.clear(); + if let Err(e) = reader.read_to_end(&mut buf) { + log::error!("Failed to read clipboard contents: {e}"); + Timer::after(Duration::from_millis(500)).await; + continue; } - loop { - match get_contents( - ClipboardType::Regular, - Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, - ) { - Ok((mut reader, mime_type)) => { - buf.clear(); - if let Err(e) = reader.read_to_end(&mut buf) { - log::error!("Failed to read clipboard contents: {e}"); - Timer::after(Duration::from_millis(500)).await; - continue; - } + // Only store if changed and not empty + if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { + last_contents = Some(std::mem::take(&mut buf)); + let mime = Some(mime_type.to_string()); + let entry = Entry { + contents: last_contents.as_ref().unwrap().clone(), + mime, + }; + let id = self.next_sequence(); + match self.store_entry( + &entry.contents[..], + max_dedupe_search, + max_items, + ) { + Ok(_) => log::info!("Stored new clipboard entry (id: {id})"), + Err(e) => log::error!("Failed to store clipboard entry: {e}"), + } - // Only store if changed and not empty - if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { - last_contents = Some(std::mem::take(&mut buf)); - let mime = Some(mime_type.to_string()); - let entry = Entry { - contents: last_contents.as_ref().unwrap().clone(), - mime, - }; - let id = self.next_sequence(); - match self.store_entry( - &entry.contents[..], - max_dedupe_search, - max_items, - ) { - Ok(_) => log::info!("Stored new clipboard entry (id: {id})"), - Err(e) => log::error!("Failed to store clipboard entry: {e}"), - } - - // Drop clipboard contents after storing - last_contents = None; - } - } - Err(e) => { - let error_msg = e.to_string(); - if !error_msg.contains("empty") { - log::error!("Failed to get clipboard contents: {e}"); - } - } - } - Timer::after(Duration::from_millis(500)).await; + // Drop clipboard contents after storing + last_contents = None; } - }); - } + }, + Err(e) => { + let error_msg = e.to_string(); + if !error_msg.contains("empty") { + log::error!("Failed to get clipboard contents: {e}"); + } + }, + } + Timer::after(Duration::from_millis(500)).await; + } + }); + } } diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs index d815527..c0bb9ee 100644 --- a/src/commands/wipe.rs +++ b/src/commands/wipe.rs @@ -1,15 +1,13 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb}; - -use crate::db::StashError; +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait WipeCommand { - fn wipe(&self) -> Result<(), StashError>; + fn wipe(&self) -> Result<(), StashError>; } impl WipeCommand for SqliteClipboardDb { - fn wipe(&self) -> Result<(), StashError> { - self.wipe_db()?; - log::info!("Database wiped"); - Ok(()) - } + fn wipe(&self) -> Result<(), StashError> { + self.wipe_db()?; + log::info!("Database wiped"); + Ok(()) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 6dbb357..6a7bf6b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,392 +1,419 @@ -use std::env; -use std::fmt; -use std::fs; -use std::io::{BufRead, BufReader, Read, Write}; -use std::str; +use std::{ + env, + fmt, + fs, + io::{BufRead, BufReader, Read, Write}, + str, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; use imagesize::{ImageSize, ImageType}; use log::{error, info, warn}; use regex::Regex; - use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD; use serde_json::json; +use thiserror::Error; #[derive(Error, Debug)] pub enum StashError { - #[error("Input is empty or too large, skipping store.")] - EmptyOrTooLarge, - #[error("Input is all whitespace, skipping store.")] - AllWhitespace, + #[error("Input is empty or too large, skipping store.")] + EmptyOrTooLarge, + #[error("Input is all whitespace, skipping store.")] + AllWhitespace, - #[error("Failed to store entry: {0}")] - Store(String), - #[error("Error reading entry during deduplication: {0}")] - DeduplicationRead(String), - #[error("Error decoding entry during deduplication: {0}")] - DeduplicationDecode(String), - #[error("Failed to remove entry during deduplication: {0}")] - DeduplicationRemove(String), - #[error("Failed to trim entry: {0}")] - Trim(String), - #[error("No entries to delete")] - NoEntriesToDelete, - #[error("Failed to delete last entry: {0}")] - DeleteLast(String), - #[error("Failed to wipe database: {0}")] - Wipe(String), - #[error("Failed to decode entry during list: {0}")] - ListDecode(String), - #[error("Failed to read input for decode: {0}")] - DecodeRead(String), - #[error("Failed to extract id for decode: {0}")] - DecodeExtractId(String), - #[error("Failed to get entry for decode: {0}")] - DecodeGet(String), + #[error("Failed to store entry: {0}")] + Store(String), + #[error("Error reading entry during deduplication: {0}")] + DeduplicationRead(String), + #[error("Error decoding entry during deduplication: {0}")] + DeduplicationDecode(String), + #[error("Failed to remove entry during deduplication: {0}")] + DeduplicationRemove(String), + #[error("Failed to trim entry: {0}")] + Trim(String), + #[error("No entries to delete")] + NoEntriesToDelete, + #[error("Failed to delete last entry: {0}")] + DeleteLast(String), + #[error("Failed to wipe database: {0}")] + Wipe(String), + #[error("Failed to decode entry during list: {0}")] + ListDecode(String), + #[error("Failed to read input for decode: {0}")] + DecodeRead(String), + #[error("Failed to extract id for decode: {0}")] + DecodeExtractId(String), + #[error("Failed to get entry for decode: {0}")] + DecodeGet(String), - #[error("Failed to write decoded entry: {0}")] - DecodeWrite(String), - #[error("Failed to delete entry during query delete: {0}")] - QueryDelete(String), - #[error("Failed to delete entry with id {0}: {1}")] - DeleteEntry(u64, String), + #[error("Failed to write decoded entry: {0}")] + DecodeWrite(String), + #[error("Failed to delete entry during query delete: {0}")] + QueryDelete(String), + #[error("Failed to delete entry with id {0}: {1}")] + DeleteEntry(u64, String), } pub trait ClipboardDb { - fn store_entry( - &self, - input: impl Read, - max_dedupe_search: u64, - max_items: u64, - ) -> Result; - fn deduplicate(&self, buf: &[u8], max: u64) -> Result; - fn trim_db(&self, max: u64) -> Result<(), StashError>; - fn delete_last(&self) -> Result<(), StashError>; - fn wipe_db(&self) -> Result<(), StashError>; - fn list_entries(&self, out: impl Write, preview_width: u32) -> Result; - fn decode_entry( - &self, - in_: impl Read, - out: impl Write, - input: Option, - ) -> Result<(), StashError>; - fn delete_query(&self, query: &str) -> Result; - fn delete_entries(&self, in_: impl Read) -> Result; - fn next_sequence(&self) -> u64; + fn store_entry( + &self, + input: impl Read, + max_dedupe_search: u64, + max_items: u64, + ) -> Result; + fn deduplicate(&self, buf: &[u8], max: u64) -> Result; + fn trim_db(&self, max: u64) -> Result<(), StashError>; + fn delete_last(&self) -> Result<(), StashError>; + fn wipe_db(&self) -> Result<(), StashError>; + fn list_entries( + &self, + out: impl Write, + preview_width: u32, + ) -> Result; + fn decode_entry( + &self, + in_: impl Read, + out: impl Write, + input: Option, + ) -> Result<(), StashError>; + fn delete_query(&self, query: &str) -> Result; + fn delete_entries(&self, in_: impl Read) -> Result; + fn next_sequence(&self) -> u64; } #[derive(Serialize, Deserialize)] pub struct Entry { - pub contents: Vec, - pub mime: Option, + pub contents: Vec, + pub mime: Option, } impl fmt::Display for Entry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let preview = preview_entry(&self.contents, self.mime.as_deref(), 100); - write!(f, "{preview}") - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let preview = preview_entry(&self.contents, self.mime.as_deref(), 100); + write!(f, "{preview}") + } } pub struct SqliteClipboardDb { - pub conn: Connection, + pub conn: Connection, } impl SqliteClipboardDb { - pub fn new(conn: Connection) -> Result { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS clipboard ( + pub fn new(conn: Connection) -> Result { + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard ( id INTEGER PRIMARY KEY AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT );", - ) - .map_err(|e| StashError::Store(e.to_string()))?; - Ok(Self { conn }) - } + ) + .map_err(|e| StashError::Store(e.to_string()))?; + Ok(Self { conn }) + } } impl SqliteClipboardDb { - pub fn list_json(&self) -> Result { - let mut stmt = self - .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + pub fn list_json(&self) -> Result { + let mut stmt = self + .conn + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut entries = Vec::new(); + let mut entries = Vec::new(); - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let contents_str = match mime.as_deref() { - Some(m) if m.starts_with("text/") || m == "application/json" => { - String::from_utf8_lossy(&contents).to_string() - } - _ => STANDARD.encode(&contents), - }; - entries.push(json!({ - "id": id, - "contents": contents_str, - "mime": mime, - })); - } - - serde_json::to_string_pretty(&entries).map_err(|e| StashError::ListDecode(e.to_string())) + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents_str = match mime.as_deref() { + Some(m) if m.starts_with("text/") || m == "application/json" => { + String::from_utf8_lossy(&contents).to_string() + }, + _ => STANDARD.encode(&contents), + }; + entries.push(json!({ + "id": id, + "contents": contents_str, + "mime": mime, + })); } + + serde_json::to_string_pretty(&entries) + .map_err(|e| StashError::ListDecode(e.to_string())) + } } impl ClipboardDb for SqliteClipboardDb { - fn store_entry( - &self, - mut input: impl Read, - max_dedupe_search: u64, - max_items: u64, - ) -> Result { - let mut buf = Vec::new(); - if input.read_to_end(&mut buf).is_err() || buf.is_empty() || buf.len() > 5 * 1_000_000 { - return Err(StashError::EmptyOrTooLarge); - } - if buf.iter().all(u8::is_ascii_whitespace) { - return Err(StashError::AllWhitespace); - } - - let mime = match detect_mime(&buf) { - None => { - // If valid UTF-8, treat as text/plain - if std::str::from_utf8(&buf).is_ok() { - Some("text/plain".to_string()) - } else { - None - } - } - other => other, - }; - - // Try to load regex from systemd credential file, then env var - let regex = load_sensitive_regex(); - if let Some(re) = regex { - // Only check text data - if let Ok(s) = std::str::from_utf8(&buf) { - if re.is_match(s) { - warn!("Clipboard entry matches sensitive regex, skipping store."); - return Err(StashError::Store("Filtered by sensitive regex".to_string())); - } - } - } - - self.deduplicate(&buf, max_dedupe_search)?; - - self.conn - .execute( - "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", - params![buf, mime], - ) - .map_err(|e| StashError::Store(e.to_string()))?; - - self.trim_db(max_items)?; - Ok(self.next_sequence()) + fn store_entry( + &self, + mut input: impl Read, + max_dedupe_search: u64, + max_items: u64, + ) -> Result { + let mut buf = Vec::new(); + if input.read_to_end(&mut buf).is_err() + || buf.is_empty() + || buf.len() > 5 * 1_000_000 + { + return Err(StashError::EmptyOrTooLarge); + } + if buf.iter().all(u8::is_ascii_whitespace) { + return Err(StashError::AllWhitespace); } - fn deduplicate(&self, buf: &[u8], max: u64) -> Result { - let mut stmt = self - .conn - .prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") - .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; - let mut rows = stmt - .query(params![i64::try_from(max).unwrap_or(i64::MAX)]) - .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; - let mut deduped = 0; - while let Some(row) = rows - .next() - .map_err(|e| StashError::DeduplicationRead(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; - if contents == buf { - self.conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeduplicationRemove(e.to_string()))?; - deduped += 1; - } - } - Ok(deduped) - } - - fn trim_db(&self, max: u64) -> Result<(), StashError> { - let count: u64 = self - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .map_err(|e| StashError::Trim(e.to_string()))?; - if count > max { - let to_delete = count - max; - self.conn.execute( - "DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER BY id ASC LIMIT ?1)", - params![i64::try_from(to_delete).unwrap_or(i64::MAX)], - ).map_err(|e| StashError::Trim(e.to_string()))?; - } - Ok(()) - } - - fn delete_last(&self) -> Result<(), StashError> { - let id: Option = self - .conn - .query_row( - "SELECT id FROM clipboard ORDER BY id DESC LIMIT 1", - [], - |row| row.get(0), - ) - .optional() - .map_err(|e| StashError::DeleteLast(e.to_string()))?; - if let Some(id) = id { - self.conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteLast(e.to_string()))?; - Ok(()) + let mime = match detect_mime(&buf) { + None => { + // If valid UTF-8, treat as text/plain + if std::str::from_utf8(&buf).is_ok() { + Some("text/plain".to_string()) } else { - Err(StashError::NoEntriesToDelete) + None } - } + }, + other => other, + }; - fn wipe_db(&self) -> Result<(), StashError> { - self.conn - .execute("DELETE FROM clipboard", []) - .map_err(|e| StashError::Wipe(e.to_string()))?; - Ok(()) - } - - fn list_entries(&self, mut out: impl Write, preview_width: u32) -> Result { - let mut stmt = self - .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mut listed = 0; - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; - let preview = preview_entry(&contents, mime.as_deref(), preview_width); - if writeln!(out, "{id}\t{preview}").is_ok() { - listed += 1; - } + // Try to load regex from systemd credential file, then env var + let regex = load_sensitive_regex(); + if let Some(re) = regex { + // Only check text data + if let Ok(s) = std::str::from_utf8(&buf) { + if re.is_match(s) { + warn!("Clipboard entry matches sensitive regex, skipping store."); + return Err(StashError::Store( + "Filtered by sensitive regex".to_string(), + )); } - Ok(listed) + } } - fn decode_entry( - &self, - mut in_: impl Read, - mut out: impl Write, - input: Option, - ) -> Result<(), StashError> { - let s = if let Some(input) = input { - input - } else { - let mut buf = String::new(); - in_.read_to_string(&mut buf) - .map_err(|e| StashError::DecodeRead(e.to_string()))?; - buf - }; - let id = extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; - let (contents, _mime): (Vec, Option) = self - .conn - .query_row( - "SELECT contents, mime FROM clipboard WHERE id = ?1", - params![id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|e| StashError::DecodeGet(e.to_string()))?; - out.write_all(&contents) - .map_err(|e| StashError::DecodeWrite(e.to_string()))?; - info!("Decoded entry with id {id}"); - Ok(()) - } + self.deduplicate(&buf, max_dedupe_search)?; - fn delete_query(&self, query: &str) -> Result { - let mut stmt = self - .conn - .prepare("SELECT id, contents FROM clipboard") - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - let mut rows = stmt - .query([]) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - let mut deleted = 0; - while let Some(row) = rows - .next() - .map_err(|e| StashError::QueryDelete(e.to_string()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - if contents.windows(query.len()).any(|w| w == query.as_bytes()) { - self.conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; - deleted += 1; - } - } - Ok(deleted) - } + self + .conn + .execute( + "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", + params![buf, mime], + ) + .map_err(|e| StashError::Store(e.to_string()))?; - fn delete_entries(&self, in_: impl Read) -> Result { - let reader = BufReader::new(in_); - let mut deleted = 0; - for line in reader.lines().map_while(Result::ok) { - if let Ok(id) = extract_id(&line) { - self.conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; - deleted += 1; - } - } - Ok(deleted) - } + self.trim_db(max_items)?; + Ok(self.next_sequence()) + } - fn next_sequence(&self) -> u64 { - 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, - } + fn deduplicate(&self, buf: &[u8], max: u64) -> Result { + let mut stmt = self + .conn + .prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") + .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; + let mut rows = stmt + .query(params![i64::try_from(max).unwrap_or(i64::MAX)]) + .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; + let mut deduped = 0; + while let Some(row) = rows + .next() + .map_err(|e| StashError::DeduplicationRead(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; + if contents == buf { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeduplicationRemove(e.to_string()))?; + deduped += 1; + } } + Ok(deduped) + } + + fn trim_db(&self, max: u64) -> Result<(), StashError> { + let count: u64 = self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .map_err(|e| StashError::Trim(e.to_string()))?; + if count > max { + let to_delete = count - max; + self + .conn + .execute( + "DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER \ + BY id ASC LIMIT ?1)", + params![i64::try_from(to_delete).unwrap_or(i64::MAX)], + ) + .map_err(|e| StashError::Trim(e.to_string()))?; + } + Ok(()) + } + + fn delete_last(&self) -> Result<(), StashError> { + let id: Option = self + .conn + .query_row( + "SELECT id FROM clipboard ORDER BY id DESC LIMIT 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| StashError::DeleteLast(e.to_string()))?; + if let Some(id) = id { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeleteLast(e.to_string()))?; + Ok(()) + } else { + Err(StashError::NoEntriesToDelete) + } + } + + fn wipe_db(&self) -> Result<(), StashError> { + self + .conn + .execute("DELETE FROM clipboard", []) + .map_err(|e| StashError::Wipe(e.to_string()))?; + Ok(()) + } + + fn list_entries( + &self, + mut out: impl Write, + preview_width: u32, + ) -> Result { + let mut stmt = self + .conn + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mut listed = 0; + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let preview = preview_entry(&contents, mime.as_deref(), preview_width); + if writeln!(out, "{id}\t{preview}").is_ok() { + listed += 1; + } + } + Ok(listed) + } + + fn decode_entry( + &self, + mut in_: impl Read, + mut out: impl Write, + input: Option, + ) -> Result<(), StashError> { + let s = if let Some(input) = input { + input + } else { + let mut buf = String::new(); + in_ + .read_to_string(&mut buf) + .map_err(|e| StashError::DecodeRead(e.to_string()))?; + buf + }; + let id = + extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; + let (contents, _mime): (Vec, Option) = self + .conn + .query_row( + "SELECT contents, mime FROM clipboard WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| StashError::DecodeGet(e.to_string()))?; + out + .write_all(&contents) + .map_err(|e| StashError::DecodeWrite(e.to_string()))?; + info!("Decoded entry with id {id}"); + Ok(()) + } + + fn delete_query(&self, query: &str) -> Result { + let mut stmt = self + .conn + .prepare("SELECT id, contents FROM clipboard") + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + let mut deleted = 0; + while let Some(row) = rows + .next() + .map_err(|e| StashError::QueryDelete(e.to_string()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + if contents.windows(query.len()).any(|w| w == query.as_bytes()) { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::QueryDelete(e.to_string()))?; + deleted += 1; + } + } + Ok(deleted) + } + + fn delete_entries(&self, in_: impl Read) -> Result { + let reader = BufReader::new(in_); + let mut deleted = 0; + for line in reader.lines().map_while(Result::ok) { + if let Ok(id) = extract_id(&line) { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; + deleted += 1; + } + } + Ok(deleted) + } + + fn next_sequence(&self) -> u64 { + 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, + } + } } // Helper functions @@ -396,116 +423,116 @@ impl ClipboardDb for SqliteClipboardDb { /// # Returns /// `Some(Regex)` if present and valid, `None` otherwise. fn load_sensitive_regex() -> Option { - if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { - let file = format!("{regex_path}/clipboard_filter"); - if let Ok(contents) = fs::read_to_string(&file) { - if let Ok(re) = Regex::new(contents.trim()) { - return Some(re); - } - } + if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{regex_path}/clipboard_filter"); + if let Ok(contents) = fs::read_to_string(&file) { + if let Ok(re) = Regex::new(contents.trim()) { + return Some(re); + } } + } - // Fallback to an environment variable - if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { - if let Ok(re) = Regex::new(&pattern) { - return Some(re); - } + // Fallback to an environment variable + if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { + if let Ok(re) = Regex::new(&pattern) { + return Some(re); } + } - None + None } pub fn extract_id(input: &str) -> Result { - let id_str = input.split('\t').next().unwrap_or(""); - id_str.parse().map_err(|_| "invalid id") + let id_str = input.split('\t').next().unwrap_or(""); + id_str.parse().map_err(|_| "invalid id") } pub fn detect_mime(data: &[u8]) -> Option { - if let Ok(img_type) = imagesize::image_type(data) { - Some( - match img_type { - ImageType::Png => "image/png", - ImageType::Jpeg => "image/jpeg", - ImageType::Gif => "image/gif", - ImageType::Bmp => "image/bmp", - ImageType::Tiff => "image/tiff", - ImageType::Webp => "image/webp", - ImageType::Aseprite => "image/x-aseprite", - ImageType::Dds => "image/vnd.ms-dds", - ImageType::Exr => "image/aces", - ImageType::Farbfeld => "image/farbfeld", - ImageType::Hdr => "image/vnd.radiance", - ImageType::Ico => "image/x-icon", - ImageType::Ilbm => "image/ilbm", - ImageType::Jxl => "image/jxl", - ImageType::Ktx2 => "image/ktx2", - ImageType::Pnm => "image/x-portable-anymap", - ImageType::Psd => "image/vnd.adobe.photoshop", - ImageType::Qoi => "image/qoi", - ImageType::Tga => "image/x-tga", - ImageType::Vtf => "image/x-vtf", - ImageType::Heif(_) => "image/heif", - _ => "application/octet-stream", - } - .to_string(), - ) - } else { - None - } + if let Ok(img_type) = imagesize::image_type(data) { + Some( + match img_type { + ImageType::Png => "image/png", + ImageType::Jpeg => "image/jpeg", + ImageType::Gif => "image/gif", + ImageType::Bmp => "image/bmp", + ImageType::Tiff => "image/tiff", + ImageType::Webp => "image/webp", + ImageType::Aseprite => "image/x-aseprite", + ImageType::Dds => "image/vnd.ms-dds", + ImageType::Exr => "image/aces", + ImageType::Farbfeld => "image/farbfeld", + ImageType::Hdr => "image/vnd.radiance", + ImageType::Ico => "image/x-icon", + ImageType::Ilbm => "image/ilbm", + ImageType::Jxl => "image/jxl", + ImageType::Ktx2 => "image/ktx2", + ImageType::Pnm => "image/x-portable-anymap", + ImageType::Psd => "image/vnd.adobe.photoshop", + ImageType::Qoi => "image/qoi", + ImageType::Tga => "image/x-tga", + ImageType::Vtf => "image/x-vtf", + ImageType::Heif(_) => "image/heif", + _ => "application/octet-stream", + } + .to_string(), + ) + } else { + None + } } pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { - if let Some(mime) = mime { - if mime.starts_with("image/") { - if let Ok(ImageSize { - width: img_width, - height: img_height, - }) = imagesize::blob_size(data) - { - return format!( - "[[ binary data {} {} {}x{} ]]", - size_str(data.len()), - mime, - img_width, - img_height - ); - } - } else if mime == "application/json" || mime.starts_with("text/") { - let s = match str::from_utf8(data) { - Ok(s) => s, - Err(e) => { - error!("Failed to decode UTF-8 clipboard data: {e}"); - "" - } - }; - let s = s.trim().replace(|c: char| c.is_whitespace(), " "); - return truncate(&s, width as usize, "…"); - } + if let Some(mime) = mime { + if mime.starts_with("image/") { + if let Ok(ImageSize { + width: img_width, + height: img_height, + }) = imagesize::blob_size(data) + { + return format!( + "[[ binary data {} {} {}x{} ]]", + size_str(data.len()), + mime, + img_width, + img_height + ); + } + } else if mime == "application/json" || mime.starts_with("text/") { + let s = match str::from_utf8(data) { + Ok(s) => s, + Err(e) => { + error!("Failed to decode UTF-8 clipboard data: {e}"); + "" + }, + }; + let s = s.trim().replace(|c: char| c.is_whitespace(), " "); + return truncate(&s, width as usize, "…"); } - let s = String::from_utf8_lossy(data); - truncate(s.trim(), width as usize, "…") + } + let s = String::from_utf8_lossy(data); + truncate(s.trim(), width as usize, "…") } pub fn truncate(s: &str, max: usize, ellip: &str) -> String { - if s.chars().count() > max { - s.chars().take(max).collect::() + ellip - } else { - s.to_string() - } + if s.chars().count() > max { + s.chars().take(max).collect::() + ellip + } else { + s.to_string() + } } pub fn size_str(size: usize) -> String { - let units = ["B", "KiB", "MiB"]; - let mut fsize = if let Ok(val) = u32::try_from(size) { - f64::from(val) - } else { - error!("Clipboard entry size too large for display: {size}"); - f64::from(u32::MAX) - }; - let mut i = 0; - while fsize >= 1024.0 && i < units.len() - 1 { - fsize /= 1024.0; - i += 1; - } - format!("{:.0} {}", fsize, units[i]) + let units = ["B", "KiB", "MiB"]; + let mut fsize = if let Ok(val) = u32::try_from(size) { + f64::from(val) + } else { + error!("Clipboard entry size too large for display: {size}"); + f64::from(u32::MAX) + }; + let mut i = 0; + while fsize >= 1024.0 && i < units.len() - 1 { + fsize /= 1024.0; + i += 1; + } + format!("{:.0} {}", fsize, units[i]) } diff --git a/src/import.rs b/src/import.rs index 9c4f421..7cb741f 100644 --- a/src/import.rs +++ b/src/import.rs @@ -1,43 +1,45 @@ -use crate::db::{Entry, SqliteClipboardDb, detect_mime}; -use log::{error, info}; use std::io::{self, BufRead}; +use log::{error, info}; + +use crate::db::{Entry, SqliteClipboardDb, detect_mime}; + pub trait ImportCommand { - fn import_tsv(&self, input: impl io::Read); + fn import_tsv(&self, input: impl io::Read); } impl ImportCommand for SqliteClipboardDb { - fn import_tsv(&self, input: impl io::Read) { - let reader = io::BufReader::new(input); - let mut imported = 0; - for line in reader.lines().map_while(Result::ok) { - let mut parts = line.splitn(2, '\t'); - let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { - error!("Malformed TSV line: {line:?}"); - continue; - }; + fn import_tsv(&self, input: impl io::Read) { + let reader = io::BufReader::new(input); + let mut imported = 0; + for line in reader.lines().map_while(Result::ok) { + let mut parts = line.splitn(2, '\t'); + let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { + error!("Malformed TSV line: {line:?}"); + continue; + }; - let Ok(_id) = id_str.parse::() else { - error!("Failed to parse id from line: {id_str}"); - continue; - }; + let Ok(_id) = id_str.parse::() else { + error!("Failed to parse id from line: {id_str}"); + continue; + }; - let entry = Entry { - contents: val.as_bytes().to_vec(), - mime: detect_mime(val.as_bytes()), - }; + let entry = Entry { + contents: val.as_bytes().to_vec(), + mime: detect_mime(val.as_bytes()), + }; - match self.conn.execute( - "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", - rusqlite::params![entry.contents, entry.mime], - ) { - Ok(_) => { - imported += 1; - info!("Imported entry from TSV"); - } - Err(e) => error!("Failed to insert entry: {e}"), - } - } - info!("Imported {imported} records from TSV into SQLite database."); + match self.conn.execute( + "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", + rusqlite::params![entry.contents, entry.mime], + ) { + Ok(_) => { + imported += 1; + info!("Imported entry from TSV"); + }, + Err(e) => error!("Failed to insert entry: {e}"), + } } + info!("Imported {imported} records from TSV into SQLite database."); + } } diff --git a/src/main.rs b/src/main.rs index 334afef..5b418c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,11 @@ use std::{ - env, - io::{self}, - path::PathBuf, - process, + env, + io::{self}, + path::PathBuf, + process, }; use atty::Stream; - use clap::{CommandFactory, Parser, Subcommand}; use inquire::Confirm; @@ -14,289 +13,308 @@ mod commands; mod db; mod import; -use crate::commands::decode::DecodeCommand; -use crate::commands::delete::DeleteCommand; -use crate::commands::list::ListCommand; -use crate::commands::query::QueryCommand; -use crate::commands::store::StoreCommand; -use crate::commands::watch::WatchCommand; -use crate::commands::wipe::WipeCommand; -use crate::import::ImportCommand; +use crate::{ + commands::{ + decode::DecodeCommand, + delete::DeleteCommand, + list::ListCommand, + query::QueryCommand, + store::StoreCommand, + watch::WatchCommand, + wipe::WipeCommand, + }, + import::ImportCommand, +}; #[derive(Parser)] #[command(name = "stash")] #[command(about = "Wayland clipboard manager", version)] struct Cli { - #[command(subcommand)] - command: Option, + #[command(subcommand)] + command: Option, - #[arg(long, default_value_t = 750)] - max_items: u64, + #[arg(long, default_value_t = 750)] + max_items: u64, - #[arg(long, default_value_t = 100)] - max_dedupe_search: u64, + #[arg(long, default_value_t = 100)] + max_dedupe_search: u64, - #[arg(long, default_value_t = 100)] - preview_width: u32, + #[arg(long, default_value_t = 100)] + preview_width: u32, - #[arg(long)] - db_path: Option, + #[arg(long)] + db_path: Option, - /// Ask for confirmation before destructive operations - #[arg(long)] - ask: bool, + /// Ask for confirmation before destructive operations + #[arg(long)] + ask: bool, - #[command(flatten)] - verbosity: clap_verbosity_flag::Verbosity, + #[command(flatten)] + verbosity: clap_verbosity_flag::Verbosity, } #[derive(Subcommand)] enum Command { - /// Store clipboard contents - Store, + /// Store clipboard contents + Store, - /// List clipboard history - List { - /// Output format: "tsv" (default) or "json" - #[arg(long, value_parser = ["tsv", "json"])] - format: Option, - }, + /// List clipboard history + List { + /// Output format: "tsv" (default) or "json" + #[arg(long, value_parser = ["tsv", "json"])] + format: Option, + }, - /// Decode and output clipboard entry by id - Decode { input: Option }, + /// Decode and output clipboard entry by id + Decode { input: Option }, - /// 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. - Delete { - /// Id or query string - arg: Option, + /// 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. + Delete { + /// Id or query string + arg: Option, - /// Explicitly specify type: "id" or "query" - #[arg(long, value_parser = ["id", "query"])] - r#type: Option, + /// Explicitly specify type: "id" or "query" + #[arg(long, value_parser = ["id", "query"])] + r#type: Option, - /// Ask for confirmation before deleting - #[arg(long)] - ask: bool, - }, + /// Ask for confirmation before deleting + #[arg(long)] + ask: bool, + }, - /// Wipe all clipboard history - Wipe { - /// Ask for confirmation before wiping - #[arg(long)] - ask: bool, - }, + /// Wipe all clipboard history + Wipe { + /// Ask for confirmation before wiping + #[arg(long)] + ask: bool, + }, - /// Import clipboard data from stdin (default: TSV format) - Import { - /// Explicitly specify format: "tsv" (default) - #[arg(long, value_parser = ["tsv"])] - r#type: Option, + /// Import clipboard data from stdin (default: TSV format) + Import { + /// Explicitly specify format: "tsv" (default) + #[arg(long, value_parser = ["tsv"])] + r#type: Option, - /// Ask for confirmation before importing - #[arg(long)] - ask: bool, - }, + /// Ask for confirmation before importing + #[arg(long)] + ask: bool, + }, - /// Watch clipboard for changes and store automatically - Watch, + /// Watch clipboard for changes and store automatically + Watch, } -fn report_error(result: Result, context: &str) -> Option { - match result { - Ok(val) => Some(val), - Err(e) => { - log::error!("{context}: {e}"); - None - } - } +fn report_error( + result: Result, + context: &str, +) -> Option { + match result { + Ok(val) => Some(val), + Err(e) => { + log::error!("{context}: {e}"); + None + }, + } } #[allow(clippy::too_many_lines)] // whatever fn main() { - smol::block_on(async { - let cli = Cli::parse(); - env_logger::Builder::new() - .filter_level(cli.verbosity.into()) - .init(); + smol::block_on(async { + let cli = Cli::parse(); + env_logger::Builder::new() + .filter_level(cli.verbosity.into()) + .init(); - let db_path = cli.db_path.unwrap_or_else(|| { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("stash") - .join("db") - }); - - if let Some(parent) = db_path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - log::error!("Failed to create database directory: {e}"); - process::exit(1); - } - } - - let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| { - log::error!("Failed to open SQLite database: {e}"); - process::exit(1); - }); - - let db = match db::SqliteClipboardDb::new(conn) { - Ok(db) => db, - Err(e) => { - log::error!("Failed to initialize SQLite database: {e}"); - process::exit(1); - } - }; - - match cli.command { - Some(Command::Store) => { - let state = env::var("STASH_CLIPBOARD_STATE").ok(); - report_error( - db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state), - "Failed to store entry", - ); - } - Some(Command::List { format }) => match format.as_deref() { - Some("tsv") => { - report_error( - db.list(io::stdout(), cli.preview_width), - "Failed to list entries", - ); - } - Some("json") => match db.list_json() { - Ok(json) => { - println!("{json}"); - } - Err(e) => { - log::error!("Failed to list entries as JSON: {e}"); - } - }, - Some(other) => { - log::error!("Unsupported format: {other}"); - } - None => { - if atty::is(Stream::Stdout) { - report_error( - db.list_tui(cli.preview_width), - "Failed to list entries in TUI", - ); - } else { - report_error( - db.list(io::stdout(), cli.preview_width), - "Failed to list entries", - ); - } - } - }, - Some(Command::Decode { input }) => { - report_error( - db.decode(io::stdin(), io::stdout(), input), - "Failed to decode entry", - ); - } - Some(Command::Delete { arg, r#type, ask }) => { - let mut should_proceed = true; - if ask { - should_proceed = - Confirm::new("Are you sure you want to delete clipboard entries?") - .with_default(false) - .prompt() - .unwrap_or(false); - - if !should_proceed { - log::info!("Aborted by user."); - } - } - if should_proceed { - match (arg, r#type.as_deref()) { - (Some(s), Some("id")) => { - if let Ok(id) = s.parse::() { - use std::io::Cursor; - report_error( - db.delete(Cursor::new(format!("{id}\n"))), - "Failed to delete entry by id", - ); - } else { - log::error!("Argument is not a valid id"); - } - } - (Some(s), Some("query")) => { - report_error(db.query_delete(&s), "Failed to delete entry by query"); - } - (Some(s), None) => { - if let Ok(id) = s.parse::() { - use std::io::Cursor; - report_error( - db.delete(Cursor::new(format!("{id}\n"))), - "Failed to delete entry by id", - ); - } else { - report_error( - db.query_delete(&s), - "Failed to delete entry by query", - ); - } - } - (None, _) => { - report_error( - db.delete(io::stdin()), - "Failed to delete entry from stdin", - ); - } - (_, Some(_)) => { - log::error!("Unknown type for --type. Use \"id\" or \"query\"."); - } - } - } - } - Some(Command::Wipe { ask }) => { - let mut should_proceed = true; - if ask { - should_proceed = - Confirm::new("Are you sure you want to wipe all clipboard history?") - .with_default(false) - .prompt() - .unwrap_or(false); - if !should_proceed { - log::info!("Aborted by user."); - } - } - if should_proceed { - report_error(db.wipe(), "Failed to wipe database"); - } - } - - Some(Command::Import { r#type, ask }) => { - let mut should_proceed = true; - if ask { - should_proceed = Confirm::new("Are you sure you want to import clipboard data? This may overwrite existing entries.") - .with_default(false) - .prompt() - .unwrap_or(false); - if !should_proceed { - log::info!("Aborted by user."); - } - } - if should_proceed { - let format = r#type.as_deref().unwrap_or("tsv"); - match format { - "tsv" => { - db.import_tsv(io::stdin()); - } - _ => { - log::error!("Unsupported import format: {format}"); - } - } - } - } - Some(Command::Watch) => { - db.watch(cli.max_dedupe_search, cli.max_items); - } - None => { - if let Err(e) = Cli::command().print_help() { - log::error!("Failed to print help: {e}"); - } - println!(); - } - } + let db_path = cli.db_path.unwrap_or_else(|| { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("stash") + .join("db") }); + + if let Some(parent) = db_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::error!("Failed to create database directory: {e}"); + process::exit(1); + } + } + + let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| { + log::error!("Failed to open SQLite database: {e}"); + process::exit(1); + }); + + let db = match db::SqliteClipboardDb::new(conn) { + Ok(db) => db, + Err(e) => { + log::error!("Failed to initialize SQLite database: {e}"); + process::exit(1); + }, + }; + + match cli.command { + Some(Command::Store) => { + let state = env::var("STASH_CLIPBOARD_STATE").ok(); + report_error( + db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state), + "Failed to store entry", + ); + }, + Some(Command::List { format }) => { + match format.as_deref() { + Some("tsv") => { + report_error( + db.list(io::stdout(), cli.preview_width), + "Failed to list entries", + ); + }, + Some("json") => { + match db.list_json() { + Ok(json) => { + println!("{json}"); + }, + Err(e) => { + log::error!("Failed to list entries as JSON: {e}"); + }, + } + }, + Some(other) => { + log::error!("Unsupported format: {other}"); + }, + None => { + if atty::is(Stream::Stdout) { + report_error( + db.list_tui(cli.preview_width), + "Failed to list entries in TUI", + ); + } else { + report_error( + db.list(io::stdout(), cli.preview_width), + "Failed to list entries", + ); + } + }, + } + }, + Some(Command::Decode { input }) => { + report_error( + db.decode(io::stdin(), io::stdout(), input), + "Failed to decode entry", + ); + }, + Some(Command::Delete { arg, r#type, ask }) => { + let mut should_proceed = true; + if ask { + should_proceed = + Confirm::new("Are you sure you want to delete clipboard entries?") + .with_default(false) + .prompt() + .unwrap_or(false); + + if !should_proceed { + log::info!("Aborted by user."); + } + } + if should_proceed { + match (arg, r#type.as_deref()) { + (Some(s), Some("id")) => { + if let Ok(id) = s.parse::() { + use std::io::Cursor; + report_error( + db.delete(Cursor::new(format!("{id}\n"))), + "Failed to delete entry by id", + ); + } else { + log::error!("Argument is not a valid id"); + } + }, + (Some(s), Some("query")) => { + report_error( + db.query_delete(&s), + "Failed to delete entry by query", + ); + }, + (Some(s), None) => { + if let Ok(id) = s.parse::() { + use std::io::Cursor; + report_error( + db.delete(Cursor::new(format!("{id}\n"))), + "Failed to delete entry by id", + ); + } else { + report_error( + db.query_delete(&s), + "Failed to delete entry by query", + ); + } + }, + (None, _) => { + report_error( + db.delete(io::stdin()), + "Failed to delete entry from stdin", + ); + }, + (_, Some(_)) => { + log::error!("Unknown type for --type. Use \"id\" or \"query\"."); + }, + } + } + }, + Some(Command::Wipe { ask }) => { + let mut should_proceed = true; + if ask { + should_proceed = Confirm::new( + "Are you sure you want to wipe all clipboard history?", + ) + .with_default(false) + .prompt() + .unwrap_or(false); + if !should_proceed { + log::info!("Aborted by user."); + } + } + if should_proceed { + report_error(db.wipe(), "Failed to wipe database"); + } + }, + + Some(Command::Import { r#type, ask }) => { + let mut should_proceed = true; + if ask { + should_proceed = Confirm::new( + "Are you sure you want to import clipboard data? This may \ + overwrite existing entries.", + ) + .with_default(false) + .prompt() + .unwrap_or(false); + if !should_proceed { + log::info!("Aborted by user."); + } + } + if should_proceed { + let format = r#type.as_deref().unwrap_or("tsv"); + match format { + "tsv" => { + db.import_tsv(io::stdin()); + }, + _ => { + log::error!("Unsupported import format: {format}"); + }, + } + } + }, + Some(Command::Watch) => { + db.watch(cli.max_dedupe_search, cli.max_items); + }, + None => { + if let Err(e) = Cli::command().print_help() { + log::error!("Failed to print help: {e}"); + } + println!(); + }, + } + }); }