mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-18 00:28:12 +00:00
treewide: format with rustfmt
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I6a6a69642c2865f41a4b141ddf39a198a3fc2e09
This commit is contained in:
parent
404990f928
commit
6a5cd9b95d
10 changed files with 1191 additions and 1132 deletions
|
|
@ -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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
883
src/db/mod.rs
883
src/db/mod.rs
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
534
src/main.rs
534
src/main.rs
|
|
@ -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!();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue