treewide: format with rustfmt

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69642c2865f41a4b141ddf39a198a3fc2e09
This commit is contained in:
raf 2025-08-20 09:40:20 +03:00
commit 6a5cd9b95d
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
10 changed files with 1191 additions and 1132 deletions

View file

@ -1,75 +1,78 @@
use crate::db::{ClipboardDb, SqliteClipboardDb};
use std::io::{Read, Write}; use std::io::{Read, Write};
use crate::db::StashError;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents}; use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents};
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait DecodeCommand { pub trait DecodeCommand {
fn decode( fn decode(
&self, &self,
in_: impl Read, in_: impl Read,
out: impl Write, out: impl Write,
input: Option<String>, input: Option<String>,
) -> Result<(), StashError>; ) -> Result<(), StashError>;
} }
impl DecodeCommand for SqliteClipboardDb { impl DecodeCommand for SqliteClipboardDb {
fn decode( fn decode(
&self, &self,
mut in_: impl Read, mut in_: impl Read,
mut out: impl Write, mut out: impl Write,
input: Option<String>, input: Option<String>,
) -> Result<(), StashError> { ) -> Result<(), StashError> {
let input_str = if let Some(s) = input { let input_str = if let Some(s) = input {
s 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 { } else {
let mut buf = String::new(); let _ = out.write_all(&buf);
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(());
} }
} else {
// Try decode as usual log::error!("Failed to get clipboard contents for relay");
match self.decode_entry(input_str.as_bytes(), &mut out, Some(input_str.clone())) { }
Ok(()) => { return 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(())
} }
// 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(())
}
} }

View file

@ -1,22 +1,22 @@
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
use std::io::Read; use std::io::Read;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait DeleteCommand { pub trait DeleteCommand {
fn delete(&self, input: impl Read) -> Result<usize, StashError>; fn delete(&self, input: impl Read) -> Result<usize, StashError>;
} }
impl DeleteCommand for SqliteClipboardDb { impl DeleteCommand for SqliteClipboardDb {
fn delete(&self, input: impl Read) -> Result<usize, StashError> { fn delete(&self, input: impl Read) -> Result<usize, StashError> {
match self.delete_entries(input) { match self.delete_entries(input) {
Ok(deleted) => { Ok(deleted) => {
log::info!("Deleted {deleted} entries"); log::info!("Deleted {deleted} entries");
Ok(deleted) Ok(deleted)
} },
Err(e) => { Err(e) => {
log::error!("Failed to delete entries: {e}"); log::error!("Failed to delete entries: {e}");
Err(e) Err(e)
} },
}
} }
}
} }

View file

@ -1,274 +1,286 @@
use std::io::Write; use std::io::Write;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait ListCommand { 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 { impl ListCommand for SqliteClipboardDb {
fn list(&self, out: impl Write, preview_width: u32) -> Result<(), StashError> { fn list(
self.list_entries(out, preview_width)?; &self,
log::info!("Listed clipboard entries"); out: impl Write,
Ok(()) preview_width: u32,
} ) -> Result<(), StashError> {
self.list_entries(out, preview_width)?;
log::info!("Listed clipboard entries");
Ok(())
}
} }
impl SqliteClipboardDb { impl SqliteClipboardDb {
/// Public TUI listing function for use in main.rs /// Public TUI listing function for use in main.rs
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> {
use crossterm::{ use std::io::stdout;
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;
// Query entries from DB use crossterm::{
let mut stmt = self event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
.conn execute,
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") terminal::{
.map_err(|e| StashError::ListDecode(e.to_string()))?; EnterAlternateScreen,
let mut rows = stmt LeaveAlternateScreen,
.query([]) disable_raw_mode,
.map_err(|e| StashError::ListDecode(e.to_string()))?; 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(); // Query entries from DB
let mut max_id_width = 2; let mut stmt = self
let mut max_mime_width = 8; .conn
while let Some(row) = rows .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
.next() .map_err(|e| StashError::ListDecode(e.to_string()))?;
.map_err(|e| StashError::ListDecode(e.to_string()))? let mut rows = stmt
{ .query([])
let id: u64 = row .map_err(|e| StashError::ListDecode(e.to_string()))?;
.get(0)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let contents: Vec<u8> = row
.get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let mime: Option<String> = 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 entries: Vec<(u64, String, String)> = Vec::new();
let mut stdout = stdout(); let mut max_id_width = 2;
execute!(stdout, EnterAlternateScreen, EnableMouseCapture) let mut max_mime_width = 8;
.map_err(|e| StashError::ListDecode(e.to_string()))?; while let Some(row) = rows
let backend = CrosstermBackend::new(stdout); .next()
let mut terminal = .map_err(|e| StashError::ListDecode(e.to_string()))?
Terminal::new(backend).map_err(|e| StashError::ListDecode(e.to_string()))?; {
let id: u64 = row
let mut state = ListState::default(); .get(0)
if !entries.is_empty() { .map_err(|e| StashError::ListDecode(e.to_string()))?;
state.select(Some(0)); let contents: Vec<u8> = row
} .get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let res = (|| -> Result<(), StashError> { let mime: Option<String> = row
loop { .get(2)
terminal .map_err(|e| StashError::ListDecode(e.to_string()))?;
.draw(|f| { let preview =
let area = f.area(); crate::db::preview_entry(&contents, mime.as_deref(), preview_width);
let block = Block::default() let mime_str = mime.as_deref().unwrap_or("").to_string();
.title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") let id_str = id.to_string();
.borders(Borders::ALL); max_id_width = max_id_width.max(id_str.width());
max_mime_width = max_mime_width.max(mime_str.width());
let border_width = 2; entries.push((id, preview, mime_str));
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<ListItem> = 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:<preview_col$}"),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{mime:>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:<preview_col$}")));
spans.push(Span::raw(" "));
spans.push(Span::raw(format!("{mime:>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
} }
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<ListItem> = 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:<preview_col$}"),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{mime:>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:<preview_col$}")));
spans.push(Span::raw(" "));
spans.push(Span::raw(format!("{mime:>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
}
} }

View file

@ -1,13 +1,11 @@
use crate::db::{ClipboardDb, SqliteClipboardDb}; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
use crate::db::StashError;
pub trait QueryCommand { pub trait QueryCommand {
fn query_delete(&self, query: &str) -> Result<usize, StashError>; fn query_delete(&self, query: &str) -> Result<usize, StashError>;
} }
impl QueryCommand for SqliteClipboardDb { impl QueryCommand for SqliteClipboardDb {
fn query_delete(&self, query: &str) -> Result<usize, StashError> { fn query_delete(&self, query: &str) -> Result<usize, StashError> {
<SqliteClipboardDb as ClipboardDb>::delete_query(self, query) <SqliteClipboardDb as ClipboardDb>::delete_query(self, query)
} }
} }

View file

@ -1,32 +1,32 @@
use crate::db::{ClipboardDb, SqliteClipboardDb};
use std::io::Read; use std::io::Read;
use crate::db::{ClipboardDb, SqliteClipboardDb};
pub trait StoreCommand { pub trait StoreCommand {
fn store( fn store(
&self, &self,
input: impl Read, input: impl Read,
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
state: Option<String>, state: Option<String>,
) -> Result<(), crate::db::StashError>; ) -> Result<(), crate::db::StashError>;
} }
impl StoreCommand for SqliteClipboardDb { impl StoreCommand for SqliteClipboardDb {
fn store( fn store(
&self, &self,
input: impl Read, input: impl Read,
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
state: Option<String>, state: Option<String>,
) -> Result<(), crate::db::StashError> { ) -> Result<(), crate::db::StashError> {
if let Some("sensitive" | "clear") = state.as_deref() { if let Some("sensitive" | "clear") = state.as_deref() {
self.delete_last()?; self.delete_last()?;
log::info!("Entry deleted"); log::info!("Entry deleted");
} else { } else {
self.store_entry(input, max_dedupe_search, max_items)?; self.store_entry(input, max_dedupe_search, max_items)?;
log::info!("Entry stored"); log::info!("Entry stored");
}
Ok(())
} }
Ok(())
}
} }

View file

@ -1,79 +1,80 @@
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; use std::{io::Read, time::Duration};
use smol::Timer; use smol::Timer;
use std::io::Read;
use std::time::Duration;
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb};
pub trait WatchCommand { 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 { impl WatchCommand for SqliteClipboardDb {
fn watch(&self, max_dedupe_search: u64, max_items: u64) { fn watch(&self, max_dedupe_search: u64, max_items: u64) {
smol::block_on(async { smol::block_on(async {
log::info!("Starting clipboard watch daemon"); log::info!("Starting clipboard watch daemon");
// Preallocate buffer for clipboard contents // Preallocate buffer for clipboard contents
let mut last_contents: Option<Vec<u8>> = None; let mut last_contents: Option<Vec<u8>> = None;
let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully
// Initialize with current clipboard to avoid duplicating on startup // Initialize with current clipboard to avoid duplicating on startup
if let Ok((mut reader, _)) = get_contents( if let Ok((mut reader, _)) = get_contents(
ClipboardType::Regular, ClipboardType::Regular,
Seat::Unspecified, Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any, wl_clipboard_rs::paste::MimeType::Any,
) { ) {
buf.clear(); buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_contents = Some(buf.clone()); 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 { // Only store if changed and not empty
match get_contents( if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) {
ClipboardType::Regular, last_contents = Some(std::mem::take(&mut buf));
Seat::Unspecified, let mime = Some(mime_type.to_string());
wl_clipboard_rs::paste::MimeType::Any, let entry = Entry {
) { contents: last_contents.as_ref().unwrap().clone(),
Ok((mut reader, mime_type)) => { mime,
buf.clear(); };
if let Err(e) = reader.read_to_end(&mut buf) { let id = self.next_sequence();
log::error!("Failed to read clipboard contents: {e}"); match self.store_entry(
Timer::after(Duration::from_millis(500)).await; &entry.contents[..],
continue; 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 // Drop clipboard contents after storing
if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { last_contents = None;
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;
} }
}); },
} 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;
}
});
}
} }

View file

@ -1,15 +1,13 @@
use crate::db::{ClipboardDb, SqliteClipboardDb}; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
use crate::db::StashError;
pub trait WipeCommand { pub trait WipeCommand {
fn wipe(&self) -> Result<(), StashError>; fn wipe(&self) -> Result<(), StashError>;
} }
impl WipeCommand for SqliteClipboardDb { impl WipeCommand for SqliteClipboardDb {
fn wipe(&self) -> Result<(), StashError> { fn wipe(&self) -> Result<(), StashError> {
self.wipe_db()?; self.wipe_db()?;
log::info!("Database wiped"); log::info!("Database wiped");
Ok(()) Ok(())
} }
} }

View file

@ -1,392 +1,419 @@
use std::env; use std::{
use std::fmt; env,
use std::fs; fmt,
use std::io::{BufRead, BufReader, Read, Write}; fs,
use std::str; io::{BufRead, BufReader, Read, Write},
str,
};
use base64::{Engine, engine::general_purpose::STANDARD};
use imagesize::{ImageSize, ImageType}; use imagesize::{ImageSize, ImageType};
use log::{error, info, warn}; use log::{error, info, warn};
use regex::Regex; use regex::Regex;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde_json::json; use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum StashError { pub enum StashError {
#[error("Input is empty or too large, skipping store.")] #[error("Input is empty or too large, skipping store.")]
EmptyOrTooLarge, EmptyOrTooLarge,
#[error("Input is all whitespace, skipping store.")] #[error("Input is all whitespace, skipping store.")]
AllWhitespace, AllWhitespace,
#[error("Failed to store entry: {0}")] #[error("Failed to store entry: {0}")]
Store(String), Store(String),
#[error("Error reading entry during deduplication: {0}")] #[error("Error reading entry during deduplication: {0}")]
DeduplicationRead(String), DeduplicationRead(String),
#[error("Error decoding entry during deduplication: {0}")] #[error("Error decoding entry during deduplication: {0}")]
DeduplicationDecode(String), DeduplicationDecode(String),
#[error("Failed to remove entry during deduplication: {0}")] #[error("Failed to remove entry during deduplication: {0}")]
DeduplicationRemove(String), DeduplicationRemove(String),
#[error("Failed to trim entry: {0}")] #[error("Failed to trim entry: {0}")]
Trim(String), Trim(String),
#[error("No entries to delete")] #[error("No entries to delete")]
NoEntriesToDelete, NoEntriesToDelete,
#[error("Failed to delete last entry: {0}")] #[error("Failed to delete last entry: {0}")]
DeleteLast(String), DeleteLast(String),
#[error("Failed to wipe database: {0}")] #[error("Failed to wipe database: {0}")]
Wipe(String), Wipe(String),
#[error("Failed to decode entry during list: {0}")] #[error("Failed to decode entry during list: {0}")]
ListDecode(String), ListDecode(String),
#[error("Failed to read input for decode: {0}")] #[error("Failed to read input for decode: {0}")]
DecodeRead(String), DecodeRead(String),
#[error("Failed to extract id for decode: {0}")] #[error("Failed to extract id for decode: {0}")]
DecodeExtractId(String), DecodeExtractId(String),
#[error("Failed to get entry for decode: {0}")] #[error("Failed to get entry for decode: {0}")]
DecodeGet(String), DecodeGet(String),
#[error("Failed to write decoded entry: {0}")] #[error("Failed to write decoded entry: {0}")]
DecodeWrite(String), DecodeWrite(String),
#[error("Failed to delete entry during query delete: {0}")] #[error("Failed to delete entry during query delete: {0}")]
QueryDelete(String), QueryDelete(String),
#[error("Failed to delete entry with id {0}: {1}")] #[error("Failed to delete entry with id {0}: {1}")]
DeleteEntry(u64, String), DeleteEntry(u64, String),
} }
pub trait ClipboardDb { pub trait ClipboardDb {
fn store_entry( fn store_entry(
&self, &self,
input: impl Read, input: impl Read,
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
) -> Result<u64, StashError>; ) -> Result<u64, StashError>;
fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError>; fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError>;
fn trim_db(&self, max: u64) -> Result<(), StashError>; fn trim_db(&self, max: u64) -> Result<(), StashError>;
fn delete_last(&self) -> Result<(), StashError>; fn delete_last(&self) -> Result<(), StashError>;
fn wipe_db(&self) -> Result<(), StashError>; fn wipe_db(&self) -> Result<(), StashError>;
fn list_entries(&self, out: impl Write, preview_width: u32) -> Result<usize, StashError>; fn list_entries(
fn decode_entry( &self,
&self, out: impl Write,
in_: impl Read, preview_width: u32,
out: impl Write, ) -> Result<usize, StashError>;
input: Option<String>, fn decode_entry(
) -> Result<(), StashError>; &self,
fn delete_query(&self, query: &str) -> Result<usize, StashError>; in_: impl Read,
fn delete_entries(&self, in_: impl Read) -> Result<usize, StashError>; out: impl Write,
fn next_sequence(&self) -> u64; input: Option<String>,
) -> Result<(), StashError>;
fn delete_query(&self, query: &str) -> Result<usize, StashError>;
fn delete_entries(&self, in_: impl Read) -> Result<usize, StashError>;
fn next_sequence(&self) -> u64;
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct Entry { pub struct Entry {
pub contents: Vec<u8>, pub contents: Vec<u8>,
pub mime: Option<String>, pub mime: Option<String>,
} }
impl fmt::Display for Entry { impl fmt::Display for Entry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let preview = preview_entry(&self.contents, self.mime.as_deref(), 100); let preview = preview_entry(&self.contents, self.mime.as_deref(), 100);
write!(f, "{preview}") write!(f, "{preview}")
} }
} }
pub struct SqliteClipboardDb { pub struct SqliteClipboardDb {
pub conn: Connection, pub conn: Connection,
} }
impl SqliteClipboardDb { impl SqliteClipboardDb {
pub fn new(conn: Connection) -> Result<Self, StashError> { pub fn new(conn: Connection) -> Result<Self, StashError> {
conn.execute_batch( conn
"CREATE TABLE IF NOT EXISTS clipboard ( .execute_batch(
"CREATE TABLE IF NOT EXISTS clipboard (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
contents BLOB NOT NULL, contents BLOB NOT NULL,
mime TEXT mime TEXT
);", );",
) )
.map_err(|e| StashError::Store(e.to_string()))?; .map_err(|e| StashError::Store(e.to_string()))?;
Ok(Self { conn }) Ok(Self { conn })
} }
} }
impl SqliteClipboardDb { impl SqliteClipboardDb {
pub fn list_json(&self) -> Result<String, StashError> { pub fn list_json(&self) -> Result<String, StashError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string()))?;
let mut rows = stmt let mut rows = stmt
.query([]) .query([])
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string()))?;
let mut entries = Vec::new(); let mut entries = Vec::new();
while let Some(row) = rows while let Some(row) = rows
.next() .next()
.map_err(|e| StashError::ListDecode(e.to_string()))? .map_err(|e| StashError::ListDecode(e.to_string()))?
{ {
let id: u64 = row let id: u64 = row
.get(0) .get(0)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string()))?;
let contents: Vec<u8> = row let contents: Vec<u8> = row
.get(1) .get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string()))?;
let mime: Option<String> = row let mime: Option<String> = row
.get(2) .get(2)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string()))?;
let contents_str = match mime.as_deref() { let contents_str = match mime.as_deref() {
Some(m) if m.starts_with("text/") || m == "application/json" => { Some(m) if m.starts_with("text/") || m == "application/json" => {
String::from_utf8_lossy(&contents).to_string() String::from_utf8_lossy(&contents).to_string()
} },
_ => STANDARD.encode(&contents), _ => STANDARD.encode(&contents),
}; };
entries.push(json!({ entries.push(json!({
"id": id, "id": id,
"contents": contents_str, "contents": contents_str,
"mime": mime, "mime": mime,
})); }));
}
serde_json::to_string_pretty(&entries).map_err(|e| StashError::ListDecode(e.to_string()))
} }
serde_json::to_string_pretty(&entries)
.map_err(|e| StashError::ListDecode(e.to_string()))
}
} }
impl ClipboardDb for SqliteClipboardDb { impl ClipboardDb for SqliteClipboardDb {
fn store_entry( fn store_entry(
&self, &self,
mut input: impl Read, mut input: impl Read,
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
) -> Result<u64, StashError> { ) -> Result<u64, StashError> {
let mut buf = Vec::new(); let mut buf = Vec::new();
if input.read_to_end(&mut buf).is_err() || buf.is_empty() || buf.len() > 5 * 1_000_000 { if input.read_to_end(&mut buf).is_err()
return Err(StashError::EmptyOrTooLarge); || buf.is_empty()
} || buf.len() > 5 * 1_000_000
if buf.iter().all(u8::is_ascii_whitespace) { {
return Err(StashError::AllWhitespace); return Err(StashError::EmptyOrTooLarge);
} }
if buf.iter().all(u8::is_ascii_whitespace) {
let mime = match detect_mime(&buf) { return Err(StashError::AllWhitespace);
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 deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError> { let mime = match detect_mime(&buf) {
let mut stmt = self None => {
.conn // If valid UTF-8, treat as text/plain
.prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") if std::str::from_utf8(&buf).is_ok() {
.map_err(|e| StashError::DeduplicationRead(e.to_string()))?; Some("text/plain".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<u8> = 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<u64> = 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 { } else {
Err(StashError::NoEntriesToDelete) None
} }
} },
other => other,
};
fn wipe_db(&self) -> Result<(), StashError> { // Try to load regex from systemd credential file, then env var
self.conn let regex = load_sensitive_regex();
.execute("DELETE FROM clipboard", []) if let Some(re) = regex {
.map_err(|e| StashError::Wipe(e.to_string()))?; // Only check text data
Ok(()) if let Ok(s) = std::str::from_utf8(&buf) {
} if re.is_match(s) {
warn!("Clipboard entry matches sensitive regex, skipping store.");
fn list_entries(&self, mut out: impl Write, preview_width: u32) -> Result<usize, StashError> { return Err(StashError::Store(
let mut stmt = self "Filtered by sensitive regex".to_string(),
.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<u8> = row
.get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let mime: Option<String> = 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.deduplicate(&buf, max_dedupe_search)?;
&self,
mut in_: impl Read,
mut out: impl Write,
input: Option<String>,
) -> 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<u8>, Option<String>) = 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<usize, StashError> { self
let mut stmt = self .conn
.conn .execute(
.prepare("SELECT id, contents FROM clipboard") "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
.map_err(|e| StashError::QueryDelete(e.to_string()))?; params![buf, mime],
let mut rows = stmt )
.query([]) .map_err(|e| StashError::Store(e.to_string()))?;
.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<u8> = 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<usize, StashError> { self.trim_db(max_items)?;
let reader = BufReader::new(in_); Ok(self.next_sequence())
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 { fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError> {
match self let mut stmt = self
.conn .conn
.query_row("SELECT MAX(id) FROM clipboard", [], |row| { .prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1")
row.get::<_, Option<u64>>(0) .map_err(|e| StashError::DeduplicationRead(e.to_string()))?;
}) { let mut rows = stmt
Ok(Some(max_id)) => max_id + 1, .query(params![i64::try_from(max).unwrap_or(i64::MAX)])
Ok(None) | Err(_) => 1, .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<u8> = 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<u64> = 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<usize, StashError> {
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<u8> = row
.get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let mime: Option<String> = 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<String>,
) -> 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<u8>, Option<String>) = 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<usize, StashError> {
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<u8> = 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<usize, StashError> {
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<u64>>(0)
}) {
Ok(Some(max_id)) => max_id + 1,
Ok(None) | Err(_) => 1,
}
}
} }
// Helper functions // Helper functions
@ -396,116 +423,116 @@ impl ClipboardDb for SqliteClipboardDb {
/// # Returns /// # Returns
/// `Some(Regex)` if present and valid, `None` otherwise. /// `Some(Regex)` if present and valid, `None` otherwise.
fn load_sensitive_regex() -> Option<Regex> { fn load_sensitive_regex() -> Option<Regex> {
if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") {
let file = format!("{regex_path}/clipboard_filter"); let file = format!("{regex_path}/clipboard_filter");
if let Ok(contents) = fs::read_to_string(&file) { if let Ok(contents) = fs::read_to_string(&file) {
if let Ok(re) = Regex::new(contents.trim()) { if let Ok(re) = Regex::new(contents.trim()) {
return Some(re); return Some(re);
} }
}
} }
}
// Fallback to an environment variable // Fallback to an environment variable
if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") {
if let Ok(re) = Regex::new(&pattern) { if let Ok(re) = Regex::new(&pattern) {
return Some(re); return Some(re);
}
} }
}
None None
} }
pub fn extract_id(input: &str) -> Result<u64, &'static str> { pub fn extract_id(input: &str) -> Result<u64, &'static str> {
let id_str = input.split('\t').next().unwrap_or(""); let id_str = input.split('\t').next().unwrap_or("");
id_str.parse().map_err(|_| "invalid id") id_str.parse().map_err(|_| "invalid id")
} }
pub fn detect_mime(data: &[u8]) -> Option<String> { pub fn detect_mime(data: &[u8]) -> Option<String> {
if let Ok(img_type) = imagesize::image_type(data) { if let Ok(img_type) = imagesize::image_type(data) {
Some( Some(
match img_type { match img_type {
ImageType::Png => "image/png", ImageType::Png => "image/png",
ImageType::Jpeg => "image/jpeg", ImageType::Jpeg => "image/jpeg",
ImageType::Gif => "image/gif", ImageType::Gif => "image/gif",
ImageType::Bmp => "image/bmp", ImageType::Bmp => "image/bmp",
ImageType::Tiff => "image/tiff", ImageType::Tiff => "image/tiff",
ImageType::Webp => "image/webp", ImageType::Webp => "image/webp",
ImageType::Aseprite => "image/x-aseprite", ImageType::Aseprite => "image/x-aseprite",
ImageType::Dds => "image/vnd.ms-dds", ImageType::Dds => "image/vnd.ms-dds",
ImageType::Exr => "image/aces", ImageType::Exr => "image/aces",
ImageType::Farbfeld => "image/farbfeld", ImageType::Farbfeld => "image/farbfeld",
ImageType::Hdr => "image/vnd.radiance", ImageType::Hdr => "image/vnd.radiance",
ImageType::Ico => "image/x-icon", ImageType::Ico => "image/x-icon",
ImageType::Ilbm => "image/ilbm", ImageType::Ilbm => "image/ilbm",
ImageType::Jxl => "image/jxl", ImageType::Jxl => "image/jxl",
ImageType::Ktx2 => "image/ktx2", ImageType::Ktx2 => "image/ktx2",
ImageType::Pnm => "image/x-portable-anymap", ImageType::Pnm => "image/x-portable-anymap",
ImageType::Psd => "image/vnd.adobe.photoshop", ImageType::Psd => "image/vnd.adobe.photoshop",
ImageType::Qoi => "image/qoi", ImageType::Qoi => "image/qoi",
ImageType::Tga => "image/x-tga", ImageType::Tga => "image/x-tga",
ImageType::Vtf => "image/x-vtf", ImageType::Vtf => "image/x-vtf",
ImageType::Heif(_) => "image/heif", ImageType::Heif(_) => "image/heif",
_ => "application/octet-stream", _ => "application/octet-stream",
} }
.to_string(), .to_string(),
) )
} else { } else {
None None
} }
} }
pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String {
if let Some(mime) = mime { if let Some(mime) = mime {
if mime.starts_with("image/") { if mime.starts_with("image/") {
if let Ok(ImageSize { if let Ok(ImageSize {
width: img_width, width: img_width,
height: img_height, height: img_height,
}) = imagesize::blob_size(data) }) = imagesize::blob_size(data)
{ {
return format!( return format!(
"[[ binary data {} {} {}x{} ]]", "[[ binary data {} {} {}x{} ]]",
size_str(data.len()), size_str(data.len()),
mime, mime,
img_width, img_width,
img_height img_height
); );
} }
} else if mime == "application/json" || mime.starts_with("text/") { } else if mime == "application/json" || mime.starts_with("text/") {
let s = match str::from_utf8(data) { let s = match str::from_utf8(data) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
error!("Failed to decode UTF-8 clipboard data: {e}"); error!("Failed to decode UTF-8 clipboard data: {e}");
"" ""
} },
}; };
let s = s.trim().replace(|c: char| c.is_whitespace(), " "); let s = s.trim().replace(|c: char| c.is_whitespace(), " ");
return truncate(&s, width as usize, ""); 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 { pub fn truncate(s: &str, max: usize, ellip: &str) -> String {
if s.chars().count() > max { if s.chars().count() > max {
s.chars().take(max).collect::<String>() + ellip s.chars().take(max).collect::<String>() + ellip
} else { } else {
s.to_string() s.to_string()
} }
} }
pub fn size_str(size: usize) -> String { pub fn size_str(size: usize) -> String {
let units = ["B", "KiB", "MiB"]; let units = ["B", "KiB", "MiB"];
let mut fsize = if let Ok(val) = u32::try_from(size) { let mut fsize = if let Ok(val) = u32::try_from(size) {
f64::from(val) f64::from(val)
} else { } else {
error!("Clipboard entry size too large for display: {size}"); error!("Clipboard entry size too large for display: {size}");
f64::from(u32::MAX) f64::from(u32::MAX)
}; };
let mut i = 0; let mut i = 0;
while fsize >= 1024.0 && i < units.len() - 1 { while fsize >= 1024.0 && i < units.len() - 1 {
fsize /= 1024.0; fsize /= 1024.0;
i += 1; i += 1;
} }
format!("{:.0} {}", fsize, units[i]) format!("{:.0} {}", fsize, units[i])
} }

View file

@ -1,43 +1,45 @@
use crate::db::{Entry, SqliteClipboardDb, detect_mime};
use log::{error, info};
use std::io::{self, BufRead}; use std::io::{self, BufRead};
use log::{error, info};
use crate::db::{Entry, SqliteClipboardDb, detect_mime};
pub trait ImportCommand { pub trait ImportCommand {
fn import_tsv(&self, input: impl io::Read); fn import_tsv(&self, input: impl io::Read);
} }
impl ImportCommand for SqliteClipboardDb { impl ImportCommand for SqliteClipboardDb {
fn import_tsv(&self, input: impl io::Read) { fn import_tsv(&self, input: impl io::Read) {
let reader = io::BufReader::new(input); let reader = io::BufReader::new(input);
let mut imported = 0; let mut imported = 0;
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
let mut parts = line.splitn(2, '\t'); let mut parts = line.splitn(2, '\t');
let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else {
error!("Malformed TSV line: {line:?}"); error!("Malformed TSV line: {line:?}");
continue; continue;
}; };
let Ok(_id) = id_str.parse::<u64>() else { let Ok(_id) = id_str.parse::<u64>() else {
error!("Failed to parse id from line: {id_str}"); error!("Failed to parse id from line: {id_str}");
continue; continue;
}; };
let entry = Entry { let entry = Entry {
contents: val.as_bytes().to_vec(), contents: val.as_bytes().to_vec(),
mime: detect_mime(val.as_bytes()), mime: detect_mime(val.as_bytes()),
}; };
match self.conn.execute( match self.conn.execute(
"INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
rusqlite::params![entry.contents, entry.mime], rusqlite::params![entry.contents, entry.mime],
) { ) {
Ok(_) => { Ok(_) => {
imported += 1; imported += 1;
info!("Imported entry from TSV"); info!("Imported entry from TSV");
} },
Err(e) => error!("Failed to insert entry: {e}"), Err(e) => error!("Failed to insert entry: {e}"),
} }
}
info!("Imported {imported} records from TSV into SQLite database.");
} }
info!("Imported {imported} records from TSV into SQLite database.");
}
} }

View file

@ -1,12 +1,11 @@
use std::{ use std::{
env, env,
io::{self}, io::{self},
path::PathBuf, path::PathBuf,
process, process,
}; };
use atty::Stream; use atty::Stream;
use clap::{CommandFactory, Parser, Subcommand}; use clap::{CommandFactory, Parser, Subcommand};
use inquire::Confirm; use inquire::Confirm;
@ -14,289 +13,308 @@ mod commands;
mod db; mod db;
mod import; mod import;
use crate::commands::decode::DecodeCommand; use crate::{
use crate::commands::delete::DeleteCommand; commands::{
use crate::commands::list::ListCommand; decode::DecodeCommand,
use crate::commands::query::QueryCommand; delete::DeleteCommand,
use crate::commands::store::StoreCommand; list::ListCommand,
use crate::commands::watch::WatchCommand; query::QueryCommand,
use crate::commands::wipe::WipeCommand; store::StoreCommand,
use crate::import::ImportCommand; watch::WatchCommand,
wipe::WipeCommand,
},
import::ImportCommand,
};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "stash")] #[command(name = "stash")]
#[command(about = "Wayland clipboard manager", version)] #[command(about = "Wayland clipboard manager", version)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Option<Command>, command: Option<Command>,
#[arg(long, default_value_t = 750)] #[arg(long, default_value_t = 750)]
max_items: u64, max_items: u64,
#[arg(long, default_value_t = 100)] #[arg(long, default_value_t = 100)]
max_dedupe_search: u64, max_dedupe_search: u64,
#[arg(long, default_value_t = 100)] #[arg(long, default_value_t = 100)]
preview_width: u32, preview_width: u32,
#[arg(long)] #[arg(long)]
db_path: Option<PathBuf>, db_path: Option<PathBuf>,
/// Ask for confirmation before destructive operations /// Ask for confirmation before destructive operations
#[arg(long)] #[arg(long)]
ask: bool, ask: bool,
#[command(flatten)] #[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity, verbosity: clap_verbosity_flag::Verbosity,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
enum Command { enum Command {
/// Store clipboard contents /// Store clipboard contents
Store, Store,
/// List clipboard history /// List clipboard history
List { List {
/// Output format: "tsv" (default) or "json" /// Output format: "tsv" (default) or "json"
#[arg(long, value_parser = ["tsv", "json"])] #[arg(long, value_parser = ["tsv", "json"])]
format: Option<String>, format: Option<String>,
}, },
/// Decode and output clipboard entry by id /// Decode and output clipboard entry by id
Decode { input: Option<String> }, Decode { input: Option<String> },
/// Delete clipboard entry by id (if numeric), or entries matching a query (if not). /// Delete clipboard entry by id (if numeric), or entries matching a query (if
/// Numeric arguments are treated as ids. Use --type to specify explicitly. /// not). Numeric arguments are treated as ids. Use --type to specify
Delete { /// explicitly.
/// Id or query string Delete {
arg: Option<String>, /// Id or query string
arg: Option<String>,
/// Explicitly specify type: "id" or "query" /// Explicitly specify type: "id" or "query"
#[arg(long, value_parser = ["id", "query"])] #[arg(long, value_parser = ["id", "query"])]
r#type: Option<String>, r#type: Option<String>,
/// Ask for confirmation before deleting /// Ask for confirmation before deleting
#[arg(long)] #[arg(long)]
ask: bool, ask: bool,
}, },
/// Wipe all clipboard history /// Wipe all clipboard history
Wipe { Wipe {
/// Ask for confirmation before wiping /// Ask for confirmation before wiping
#[arg(long)] #[arg(long)]
ask: bool, ask: bool,
}, },
/// Import clipboard data from stdin (default: TSV format) /// Import clipboard data from stdin (default: TSV format)
Import { Import {
/// Explicitly specify format: "tsv" (default) /// Explicitly specify format: "tsv" (default)
#[arg(long, value_parser = ["tsv"])] #[arg(long, value_parser = ["tsv"])]
r#type: Option<String>, r#type: Option<String>,
/// Ask for confirmation before importing /// Ask for confirmation before importing
#[arg(long)] #[arg(long)]
ask: bool, ask: bool,
}, },
/// Watch clipboard for changes and store automatically /// Watch clipboard for changes and store automatically
Watch, Watch,
} }
fn report_error<T>(result: Result<T, impl std::fmt::Display>, context: &str) -> Option<T> { fn report_error<T>(
match result { result: Result<T, impl std::fmt::Display>,
Ok(val) => Some(val), context: &str,
Err(e) => { ) -> Option<T> {
log::error!("{context}: {e}"); match result {
None Ok(val) => Some(val),
} Err(e) => {
} log::error!("{context}: {e}");
None
},
}
} }
#[allow(clippy::too_many_lines)] // whatever #[allow(clippy::too_many_lines)] // whatever
fn main() { fn main() {
smol::block_on(async { smol::block_on(async {
let cli = Cli::parse(); let cli = Cli::parse();
env_logger::Builder::new() env_logger::Builder::new()
.filter_level(cli.verbosity.into()) .filter_level(cli.verbosity.into())
.init(); .init();
let db_path = cli.db_path.unwrap_or_else(|| { let db_path = cli.db_path.unwrap_or_else(|| {
dirs::cache_dir() dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp")) .unwrap_or_else(|| PathBuf::from("/tmp"))
.join("stash") .join("stash")
.join("db") .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::<u64>() {
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::<u64>() {
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!();
}
}
}); });
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::<u64>() {
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::<u64>() {
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!();
},
}
});
} }