diff --git a/src/commands/decode.rs b/src/commands/decode.rs new file mode 100644 index 0000000..ff5427a --- /dev/null +++ b/src/commands/decode.rs @@ -0,0 +1,12 @@ +use crate::db::{ClipboardDb, SledClipboardDb}; +use std::io::{Read, Write}; + +pub trait DecodeCommand { + fn decode(&self, in_: impl Read, out: impl Write, input: Option); +} + +impl DecodeCommand for SledClipboardDb { + fn decode(&self, in_: impl Read, out: impl Write, input: Option) { + self.decode_entry(in_, out, input); + } +} diff --git a/src/commands/delete.rs b/src/commands/delete.rs new file mode 100644 index 0000000..b147717 --- /dev/null +++ b/src/commands/delete.rs @@ -0,0 +1,12 @@ +use crate::db::{ClipboardDb, SledClipboardDb}; +use std::io::Read; + +pub trait DeleteCommand { + fn delete(&self, input: impl Read); +} + +impl DeleteCommand for SledClipboardDb { + fn delete(&self, input: impl Read) { + self.delete_entries(input); + } +} diff --git a/src/commands/list.rs b/src/commands/list.rs new file mode 100644 index 0000000..2f4666c --- /dev/null +++ b/src/commands/list.rs @@ -0,0 +1,12 @@ +use crate::db::{ClipboardDb, SledClipboardDb}; +use std::io::Write; + +pub trait ListCommand { + fn list(&self, out: impl Write, preview_width: u32); +} + +impl ListCommand for SledClipboardDb { + fn list(&self, out: impl Write, preview_width: u32) { + self.list_entries(out, preview_width); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..0e3b925 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,6 @@ +pub mod decode; +pub mod delete; +pub mod list; +pub mod query; +pub mod store; +pub mod wipe; diff --git a/src/commands/query.rs b/src/commands/query.rs new file mode 100644 index 0000000..078b1e3 --- /dev/null +++ b/src/commands/query.rs @@ -0,0 +1,11 @@ +use crate::db::{ClipboardDb, SledClipboardDb}; + +pub trait QueryCommand { + fn query_delete(&self, query: &str); +} + +impl QueryCommand for SledClipboardDb { + fn query_delete(&self, query: &str) { + ::delete_query(self, query); + } +} diff --git a/src/commands/store.rs b/src/commands/store.rs new file mode 100644 index 0000000..858c084 --- /dev/null +++ b/src/commands/store.rs @@ -0,0 +1,31 @@ +use crate::db::{ClipboardDb, SledClipboardDb}; +use std::io::Read; + +pub trait StoreCommand { + fn store( + &self, + input: impl Read, + max_dedupe_search: u64, + max_items: u64, + state: Option, + ); +} + +impl StoreCommand for SledClipboardDb { + fn store( + &self, + input: impl Read, + max_dedupe_search: u64, + max_items: u64, + state: Option, + ) { + match state.as_deref() { + Some("sensitive") | Some("clear") => { + self.delete_last(); + } + _ => { + self.store_entry(input, max_dedupe_search, max_items); + } + } + } +} diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs new file mode 100644 index 0000000..ad05377 --- /dev/null +++ b/src/commands/wipe.rs @@ -0,0 +1,11 @@ +use crate::db::{ClipboardDb, SledClipboardDb}; + +pub trait WipeCommand { + fn wipe(&self); +} + +impl WipeCommand for SledClipboardDb { + fn wipe(&self) { + self.wipe_db(); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..70ea7f4 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,235 @@ +use std::fmt; +use std::io::{BufRead, BufReader, Read, Write}; +use std::str; + +use image::{GenericImageView, ImageFormat}; +use rmp_serde::{decode::from_read, encode::to_vec}; +use serde::{Deserialize, Serialize}; +use sled::{Db, IVec}; + +pub trait ClipboardDb { + fn store_entry(&self, input: impl Read, max_dedupe_search: u64, max_items: u64); + fn deduplicate(&self, buf: &[u8], max: u64); + fn trim_db(&self, max: u64); + fn delete_last(&self); + fn wipe_db(&self); + fn list_entries(&self, out: impl Write, preview_width: u32); + fn decode_entry(&self, in_: impl Read, out: impl Write, input: Option); + fn delete_query(&self, query: &str); + fn delete_entries(&self, in_: impl Read); + fn next_sequence(&self) -> u64; +} + +#[derive(Serialize, Deserialize)] +pub struct Entry { + pub contents: Vec, + pub mime: Option, +} + +impl fmt::Display for Entry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let preview = preview_entry(&self.contents, self.mime.as_deref(), 100); + write!(f, "{preview}") + } +} + +pub struct SledClipboardDb { + pub db: Db, +} + +impl ClipboardDb for SledClipboardDb { + fn store_entry(&self, mut input: impl Read, max_dedupe_search: u64, max_items: u64) { + let mut buf = Vec::new(); + if input.read_to_end(&mut buf).is_err() || buf.is_empty() || buf.len() > 5 * 1_000_000 { + return; + } + if buf.iter().all(|b| b.is_ascii_whitespace()) { + return; + } + + let mime = detect_mime(&buf); + + self.deduplicate(&buf, max_dedupe_search); + + let entry = Entry { + contents: buf.clone(), + mime, + }; + + let id = self.next_sequence(); + let enc = to_vec(&entry).unwrap(); + + self.db.insert(u64_to_ivec(id), enc).unwrap(); + self.trim_db(max_items); + } + + fn deduplicate(&self, buf: &[u8], max: u64) { + let mut count = 0; + for item in self.db.iter().rev().take(max as usize) { + let (k, v) = item.unwrap(); + let entry: Entry = from_read(v.as_ref()).unwrap(); + if entry.contents == buf { + self.db.remove(k).unwrap(); + } + count += 1; + if count >= max { + break; + } + } + } + + fn trim_db(&self, max: u64) { + let mut keys: Vec<_> = self.db.iter().rev().map(|kv| kv.unwrap().0).collect(); + if keys.len() as u64 > max { + for k in keys.drain((max as usize)..) { + self.db.remove(k).unwrap(); + } + } + } + + fn delete_last(&self) { + if let Some((k, _)) = self.db.iter().next_back().and_then(Result::ok) { + self.db.remove(k).unwrap(); + } + } + + fn wipe_db(&self) { + self.db.clear().unwrap(); + } + + fn list_entries(&self, mut out: impl Write, preview_width: u32) { + for (k, v) in self.db.iter().rev().filter_map(Result::ok) { + let id = ivec_to_u64(&k); + let entry: Entry = from_read(v.as_ref()).unwrap(); + let preview = preview_entry(&entry.contents, entry.mime.as_deref(), preview_width); + writeln!(out, "{id}\t{preview}").unwrap(); + } + } + + fn decode_entry(&self, mut in_: impl Read, mut out: impl Write, input: Option) { + let s = if let Some(input) = input { + input + } else { + let mut buf = String::new(); + in_.read_to_string(&mut buf).unwrap(); + buf + }; + + let id = extract_id(&s).unwrap(); + let v = self.db.get(u64_to_ivec(id)).unwrap().unwrap(); + let entry: Entry = from_read(v.as_ref()).unwrap(); + out.write_all(&entry.contents).unwrap(); + } + + fn delete_query(&self, query: &str) { + for (k, v) in self.db.iter().filter_map(Result::ok) { + let entry: Entry = from_read(v.as_ref()).unwrap(); + if entry + .contents + .windows(query.len()) + .any(|w| w == query.as_bytes()) + { + self.db.remove(k).unwrap(); + } + } + } + + fn delete_entries(&self, in_: impl Read) { + let reader = BufReader::new(in_); + for line in reader.lines().map_while(Result::ok) { + if let Ok(id) = extract_id(&line) { + self.db.remove(u64_to_ivec(id)).unwrap(); + } + } + } + + fn next_sequence(&self) -> u64 { + let last = self + .db + .iter() + .next_back() + .and_then(|r| r.ok()) + .map(|(k, _)| ivec_to_u64(&k)); + last.unwrap_or(0) + 1 + } +} + +// Helper functions +pub fn extract_id(input: &str) -> Result { + let id_str = input.split('\t').next().unwrap_or(""); + id_str.parse().map_err(|_| "invalid id") +} + +pub fn u64_to_ivec(v: u64) -> IVec { + IVec::from(&v.to_be_bytes()[..]) +} + +pub fn ivec_to_u64(v: &IVec) -> u64 { + let arr: [u8; 8] = v.as_ref().try_into().unwrap(); + u64::from_be_bytes(arr) +} + +pub fn detect_mime(data: &[u8]) -> Option { + if image::guess_format(data).is_ok() { + match image::guess_format(data) { + Ok(fmt) => Some( + match fmt { + ImageFormat::Png => "image/png", + ImageFormat::Jpeg => "image/jpeg", + ImageFormat::Gif => "image/gif", + ImageFormat::Bmp => "image/bmp", + ImageFormat::Tiff => "image/tiff", + _ => "application/octet-stream", + } + .to_string(), + ), + Err(_) => None, + } + } else if data.is_ascii() { + Some("text/plain".into()) + } else { + None + } +} + +pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { + if let Some(mime) = mime { + if mime.starts_with("image/") { + if let Ok(img) = image::load_from_memory(data) { + let (w, h) = img.dimensions(); + return format!( + "[[ binary data {} {} {}x{} ]]", + size_str(data.len()), + mime, + w, + h + ); + } + } else if mime == "application/json" || mime.starts_with("text/") { + let s = str::from_utf8(data).unwrap_or(""); + let s = s.trim().replace(|c: char| c.is_whitespace(), " "); + return truncate(&s, width as usize, "…"); + } + } + let s = String::from_utf8_lossy(data); + truncate(s.trim(), width as usize, "…") +} + +pub fn truncate(s: &str, max: usize, ellip: &str) -> String { + if s.chars().count() > max { + s.chars().take(max).collect::() + ellip + } else { + s.to_string() + } +} + +pub fn size_str(size: usize) -> String { + let units = ["B", "KiB", "MiB"]; + let mut fsize = size as f64; + let mut i = 0; + while fsize >= 1024.0 && i < units.len() - 1 { + fsize /= 1024.0; + i += 1; + } + format!("{:.0} {}", fsize, units[i]) +} diff --git a/src/import.rs b/src/import.rs index 903e867..1447c2b 100644 --- a/src/import.rs +++ b/src/import.rs @@ -1,24 +1,28 @@ -use crate::{Entry, detect_mime, u64_to_ivec}; -use rmp_serde::encode::to_vec; -use sled::Db; +use crate::db::{Entry, SledClipboardDb, detect_mime, u64_to_ivec}; use std::io::{self, BufRead}; -pub fn import_tsv(db: &Db, input: impl io::Read) { - let reader = io::BufReader::new(input); - let mut imported = 0; - for line in reader.lines().map_while(Result::ok) { - let mut parts = line.splitn(2, '\t'); - if let (Some(id_str), Some(val)) = (parts.next(), parts.next()) { - if let Ok(id) = id_str.parse::() { - let entry = Entry { - contents: val.as_bytes().to_vec(), - mime: detect_mime(val.as_bytes()), - }; - let enc = to_vec(&entry).unwrap(); - db.insert(u64_to_ivec(id), enc).unwrap(); - imported += 1; +pub trait ImportCommand { + fn import_tsv(&self, input: impl io::Read); +} + +impl ImportCommand for SledClipboardDb { + fn import_tsv(&self, input: impl io::Read) { + let reader = io::BufReader::new(input); + let mut imported = 0; + for line in reader.lines().map_while(Result::ok) { + let mut parts = line.splitn(2, '\t'); + if let (Some(id_str), Some(val)) = (parts.next(), parts.next()) { + if let Ok(id) = id_str.parse::() { + let entry = Entry { + contents: val.as_bytes().to_vec(), + mime: detect_mime(val.as_bytes()), + }; + let enc = rmp_serde::encode::to_vec(&entry).unwrap(); + self.db.insert(u64_to_ivec(id), enc).unwrap(); + imported += 1; + } } } + eprintln!("Imported {imported} records from TSV into sled database."); } - eprintln!("Imported {imported} records from TSV into sled database."); } diff --git a/src/main.rs b/src/main.rs index 2877354..a3c6c6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,63 +1,82 @@ use std::{ - env, fmt, - io::{self, BufRead, BufReader, Read, Write}, + env, + io::{self}, path::PathBuf, - process, str, + process, }; use clap::{Parser, Subcommand}; -use image::{GenericImageView, ImageFormat}; -use rmp_serde::{decode::from_read, encode::to_vec}; -use serde::{Deserialize, Serialize}; -use sled::{Db, IVec}; +mod commands; +mod db; mod import; +use crate::commands::decode::DecodeCommand; +use crate::commands::delete::DeleteCommand; +use crate::commands::list::ListCommand; +use crate::commands::query::QueryCommand; +use crate::commands::store::StoreCommand; +use crate::commands::wipe::WipeCommand; +use crate::import::ImportCommand; + #[derive(Parser)] #[command(name = "stash")] #[command(about = "Wayland clipboard manager", version)] struct Cli { #[command(subcommand)] command: Option, + #[arg(long, default_value_t = 750)] max_items: u64, + #[arg(long, default_value_t = 100)] max_dedupe_search: u64, + #[arg(long, default_value_t = 100)] preview_width: u32, + #[arg(long)] db_path: Option, + #[arg(long)] import_tsv: bool, } #[derive(Subcommand)] enum Command { + /// Store clipboard contents Store, + + /// List clipboard history List, + + /// Decode and output clipboard entry by id Decode { input: Option }, - DeleteQuery { query: String }, - Delete, + + /// Delete clipboard entry by id (if numeric), or entries matching a query (if not). + /// Numeric arguments are treated as ids. Use --type to specify explicitly. + Delete { + /// Id or query string + arg: Option, + + /// Explicitly specify type: "id" or "query" + #[arg(long, value_parser = ["id", "query"])] + r#type: Option, + }, + + /// Wipe all clipboard history Wipe, - Import, -} -#[derive(Serialize, Deserialize)] -pub struct Entry { - pub contents: Vec, - pub mime: Option, -} - -impl fmt::Display for Entry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let preview = preview_entry(&self.contents, self.mime.as_deref(), 100); - write!(f, "{preview}") - } + /// Import clipboard data from stdin (default: TSV format) + Import { + /// Explicitly specify format: "tsv" (default) + #[arg(long, value_parser = ["tsv"])] + r#type: Option, + }, } fn main() { let cli = Cli::parse(); - let db_path = cli.db_path.unwrap_or_else(|| { dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) @@ -65,240 +84,80 @@ fn main() { .join("db") }); - let db = sled::open(&db_path).unwrap_or_else(|e| { + let sled_db = sled::open(&db_path).unwrap_or_else(|e| { eprintln!("Failed to open database: {e}"); process::exit(1); }); + let db = db::SledClipboardDb { db: sled_db }; + if cli.import_tsv { - import::import_tsv(&db, io::stdin()); + db.import_tsv(io::stdin()); return; } match cli.command { Some(Command::Store) => { - let state = env::var("CLIPBOARD_STATE").unwrap_or_default(); - match state.as_str() { - "sensitive" | "clear" => { - delete_last(&db); - } - _ => { - store_entry(&db, io::stdin(), cli.max_dedupe_search, cli.max_items); + let state = env::var("STASH_CLIPBOARD_STATE").ok(); + db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state); + } + + Some(Command::List) => { + db.list(io::stdout(), cli.preview_width); + } + + Some(Command::Decode { input }) => { + db.decode(io::stdin(), io::stdout(), input); + } + + Some(Command::Delete { arg, r#type }) => match (arg, r#type.as_deref()) { + (Some(s), Some("id")) => { + if let Ok(id) = s.parse::() { + use std::io::Cursor; + db.delete(Cursor::new(format!("{id}\n"))); + } else { + eprintln!("Argument is not a valid id"); } } - } - Some(Command::List) => { - list_entries(&db, io::stdout(), cli.preview_width); - } - Some(Command::Decode { input }) => { - decode_entry(&db, io::stdin(), io::stdout(), input); - } - Some(Command::DeleteQuery { query }) => { - delete_query(&db, &query); - } - Some(Command::Delete) => { - delete_entries(&db, io::stdin()); - } + + (Some(s), Some("query")) => { + db.query_delete(&s); + } + + (Some(s), None) => { + if let Ok(id) = s.parse::() { + use std::io::Cursor; + db.delete(Cursor::new(format!("{id}\n"))); + } else { + db.query_delete(&s); + } + } + + (None, _) => { + db.delete(io::stdin()); + } + + (_, Some(_)) => { + eprintln!("Unknown type for --type. Use \"id\" or \"query\"."); + } + }, Some(Command::Wipe) => { - wipe_db(&db); + db.wipe(); } - Some(Command::Import) => { - eprintln!("Use --import-tsv to import TSV clipboard data"); + Some(Command::Import { r#type }) => { + // Default format is TSV (Cliphist compatible) + let format = r#type.as_deref().unwrap_or("tsv"); + match format { + "tsv" => { + db.import_tsv(io::stdin()); + } + _ => { + eprintln!("Unsupported import format: {format}"); + } + } } _ => { eprintln!("No subcommand provided"); } } } - -fn store_entry(db: &Db, mut input: impl Read, max_dedupe_search: u64, max_items: u64) { - let mut buf = Vec::new(); - if input.read_to_end(&mut buf).is_err() || buf.is_empty() || buf.len() > 5 * 1_000_000 { - return; - } - if buf.iter().all(|b| b.is_ascii_whitespace()) { - return; - } - - let mime = detect_mime(&buf); - - deduplicate(db, &buf, max_dedupe_search); - - let entry = Entry { - contents: buf.clone(), - mime, - }; - - let id = next_sequence(db); - let enc = to_vec(&entry).unwrap(); - - db.insert(u64_to_ivec(id), enc).unwrap(); - trim_db(db, max_items); -} - -fn deduplicate(db: &Db, buf: &[u8], max: u64) { - let mut count = 0; - for item in db.iter().rev().take(max as usize) { - let (k, v) = item.unwrap(); - let entry: Entry = from_read(v.as_ref()).unwrap(); - if entry.contents == buf { - db.remove(k).unwrap(); - } - count += 1; - if count >= max { - break; - } - } -} - -fn trim_db(db: &Db, max: u64) { - let mut keys: Vec<_> = db.iter().rev().map(|kv| kv.unwrap().0).collect(); - if keys.len() as u64 > max { - for k in keys.drain((max as usize)..) { - db.remove(k).unwrap(); - } - } -} - -fn delete_last(db: &Db) { - if let Some((k, _)) = db.iter().next_back().and_then(Result::ok) { - db.remove(k).unwrap(); - } -} - -fn wipe_db(db: &Db) { - db.clear().unwrap(); -} - -fn list_entries(db: &Db, mut out: impl Write, preview_width: u32) { - for (k, v) in db.iter().rev().filter_map(Result::ok) { - let id = ivec_to_u64(&k); - let entry: Entry = from_read(v.as_ref()).unwrap(); - let preview = preview_entry(&entry.contents, entry.mime.as_deref(), preview_width); - writeln!(out, "{id}\t{preview}").unwrap(); - } -} - -fn decode_entry(db: &Db, mut in_: impl Read, mut out: impl Write, input: Option) { - let s = if let Some(input) = input { - input - } else { - let mut buf = String::new(); - in_.read_to_string(&mut buf).unwrap(); - buf - }; - let id = extract_id(&s).unwrap(); - let v = db.get(u64_to_ivec(id)).unwrap().unwrap(); - let entry: Entry = from_read(v.as_ref()).unwrap(); - out.write_all(&entry.contents).unwrap(); -} - -fn delete_query(db: &Db, query: &str) { - for (k, v) in db.iter().filter_map(Result::ok) { - let entry: Entry = from_read(v.as_ref()).unwrap(); - if entry - .contents - .windows(query.len()) - .any(|w| w == query.as_bytes()) - { - db.remove(k).unwrap(); - } - } -} - -fn delete_entries(db: &Db, in_: impl Read) { - let reader = BufReader::new(in_); - for line in reader.lines().map_while(Result::ok) { - if let Ok(id) = extract_id(&line) { - db.remove(u64_to_ivec(id)).unwrap(); - } - } -} - -fn extract_id(input: &str) -> Result { - let id_str = input.split('\t').next().unwrap_or(""); - id_str.parse().map_err(|_| "invalid id") -} - -fn next_sequence(db: &Db) -> u64 { - let last = db - .iter() - .next_back() - .and_then(|r| r.ok()) - .map(|(k, _)| ivec_to_u64(&k)); - last.unwrap_or(0) + 1 -} - -fn u64_to_ivec(v: u64) -> IVec { - IVec::from(&v.to_be_bytes()[..]) -} - -fn ivec_to_u64(v: &IVec) -> u64 { - let arr: [u8; 8] = v.as_ref().try_into().unwrap(); - u64::from_be_bytes(arr) -} - -fn detect_mime(data: &[u8]) -> Option { - if image::guess_format(data).is_ok() { - match image::guess_format(data) { - Ok(fmt) => Some( - match fmt { - ImageFormat::Png => "image/png", - ImageFormat::Jpeg => "image/jpeg", - ImageFormat::Gif => "image/gif", - ImageFormat::Bmp => "image/bmp", - ImageFormat::Tiff => "image/tiff", - _ => "application/octet-stream", - } - .to_string(), - ), - Err(_) => None, - } - } else if data.is_ascii() { - Some("text/plain".into()) - } else { - None - } -} - -fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { - if let Some(mime) = mime { - if mime.starts_with("image/") { - if let Ok(img) = image::load_from_memory(data) { - let (w, h) = img.dimensions(); - return format!( - "[[ binary data {} {} {}x{} ]]", - size_str(data.len()), - mime, - w, - h - ); - } - } else if mime == "application/json" || mime.starts_with("text/") { - let s = str::from_utf8(data).unwrap_or(""); - let s = s.trim().replace(|c: char| c.is_whitespace(), " "); - return truncate(&s, width as usize, "…"); - } - } - let s = String::from_utf8_lossy(data); - truncate(s.trim(), width as usize, "…") -} - -fn truncate(s: &str, max: usize, ellip: &str) -> String { - if s.chars().count() > max { - s.chars().take(max).collect::() + ellip - } else { - s.to_string() - } -} - -fn size_str(size: usize) -> String { - let units = ["B", "KiB", "MiB"]; - let mut fsize = size as f64; - let mut i = 0; - while fsize >= 1024.0 && i < units.len() - 1 { - fsize /= 1024.0; - i += 1; - } - format!("{:.0} {}", fsize, units[i]) -}