mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-13 06:23:47 +00:00
modularize codebase
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I6a6a69648ea836bfeb539450db14a666c623412e
This commit is contained in:
parent
478c020579
commit
b0820a1940
10 changed files with 445 additions and 252 deletions
12
src/commands/decode.rs
Normal file
12
src/commands/decode.rs
Normal file
|
|
@ -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<String>);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodeCommand for SledClipboardDb {
|
||||||
|
fn decode(&self, in_: impl Read, out: impl Write, input: Option<String>) {
|
||||||
|
self.decode_entry(in_, out, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/commands/delete.rs
Normal file
12
src/commands/delete.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/commands/list.rs
Normal file
12
src/commands/list.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/commands/mod.rs
Normal file
6
src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod decode;
|
||||||
|
pub mod delete;
|
||||||
|
pub mod list;
|
||||||
|
pub mod query;
|
||||||
|
pub mod store;
|
||||||
|
pub mod wipe;
|
||||||
11
src/commands/query.rs
Normal file
11
src/commands/query.rs
Normal file
|
|
@ -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) {
|
||||||
|
<SledClipboardDb as ClipboardDb>::delete_query(self, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/commands/store.rs
Normal file
31
src/commands/store.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreCommand for SledClipboardDb {
|
||||||
|
fn store(
|
||||||
|
&self,
|
||||||
|
input: impl Read,
|
||||||
|
max_dedupe_search: u64,
|
||||||
|
max_items: u64,
|
||||||
|
state: Option<String>,
|
||||||
|
) {
|
||||||
|
match state.as_deref() {
|
||||||
|
Some("sensitive") | Some("clear") => {
|
||||||
|
self.delete_last();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.store_entry(input, max_dedupe_search, max_items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/commands/wipe.rs
Normal file
11
src/commands/wipe.rs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/db/mod.rs
Normal file
235
src/db/mod.rs
Normal file
|
|
@ -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<String>);
|
||||||
|
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<u8>,
|
||||||
|
pub mime: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>) {
|
||||||
|
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<u64, &'static str> {
|
||||||
|
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<String> {
|
||||||
|
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::<String>() + 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])
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
use crate::{Entry, detect_mime, u64_to_ivec};
|
use crate::db::{Entry, SledClipboardDb, detect_mime, u64_to_ivec};
|
||||||
use rmp_serde::encode::to_vec;
|
|
||||||
use sled::Db;
|
|
||||||
use std::io::{self, BufRead};
|
use std::io::{self, BufRead};
|
||||||
|
|
||||||
pub fn import_tsv(db: &Db, input: impl io::Read) {
|
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 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) {
|
||||||
|
|
@ -14,11 +17,12 @@ pub fn import_tsv(db: &Db, input: impl io::Read) {
|
||||||
contents: val.as_bytes().to_vec(),
|
contents: val.as_bytes().to_vec(),
|
||||||
mime: detect_mime(val.as_bytes()),
|
mime: detect_mime(val.as_bytes()),
|
||||||
};
|
};
|
||||||
let enc = to_vec(&entry).unwrap();
|
let enc = rmp_serde::encode::to_vec(&entry).unwrap();
|
||||||
db.insert(u64_to_ivec(id), enc).unwrap();
|
self.db.insert(u64_to_ivec(id), enc).unwrap();
|
||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eprintln!("Imported {imported} records from TSV into sled database.");
|
eprintln!("Imported {imported} records from TSV into sled database.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
339
src/main.rs
339
src/main.rs
|
|
@ -1,63 +1,82 @@
|
||||||
use std::{
|
use std::{
|
||||||
env, fmt,
|
env,
|
||||||
io::{self, BufRead, BufReader, Read, Write},
|
io::{self},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
process, str,
|
process,
|
||||||
};
|
};
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
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;
|
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)]
|
#[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>,
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
import_tsv: bool,
|
import_tsv: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Command {
|
enum Command {
|
||||||
|
/// Store clipboard contents
|
||||||
Store,
|
Store,
|
||||||
|
|
||||||
|
/// List clipboard history
|
||||||
List,
|
List,
|
||||||
|
|
||||||
|
/// Decode and output clipboard entry by id
|
||||||
Decode { input: Option<String> },
|
Decode { input: Option<String> },
|
||||||
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<String>,
|
||||||
|
|
||||||
|
/// Explicitly specify type: "id" or "query"
|
||||||
|
#[arg(long, value_parser = ["id", "query"])]
|
||||||
|
r#type: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Wipe all clipboard history
|
||||||
Wipe,
|
Wipe,
|
||||||
Import,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
/// Import clipboard data from stdin (default: TSV format)
|
||||||
pub struct Entry {
|
Import {
|
||||||
pub contents: Vec<u8>,
|
/// Explicitly specify format: "tsv" (default)
|
||||||
pub mime: Option<String>,
|
#[arg(long, value_parser = ["tsv"])]
|
||||||
}
|
r#type: Option<String>,
|
||||||
|
},
|
||||||
impl fmt::Display for Entry {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let preview = preview_entry(&self.contents, self.mime.as_deref(), 100);
|
|
||||||
write!(f, "{preview}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
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"))
|
||||||
|
|
@ -65,240 +84,80 @@ fn main() {
|
||||||
.join("db")
|
.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}");
|
eprintln!("Failed to open database: {e}");
|
||||||
process::exit(1);
|
process::exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let db = db::SledClipboardDb { db: sled_db };
|
||||||
|
|
||||||
if cli.import_tsv {
|
if cli.import_tsv {
|
||||||
import::import_tsv(&db, io::stdin());
|
db.import_tsv(io::stdin());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(Command::Store) => {
|
Some(Command::Store) => {
|
||||||
let state = env::var("CLIPBOARD_STATE").unwrap_or_default();
|
let state = env::var("STASH_CLIPBOARD_STATE").ok();
|
||||||
match state.as_str() {
|
db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state);
|
||||||
"sensitive" | "clear" => {
|
}
|
||||||
delete_last(&db);
|
|
||||||
|
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::<u64>() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
db.delete(Cursor::new(format!("{id}\n")));
|
||||||
|
} else {
|
||||||
|
eprintln!("Argument is not a valid id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(Some(s), Some("query")) => {
|
||||||
|
db.query_delete(&s);
|
||||||
|
}
|
||||||
|
|
||||||
|
(Some(s), None) => {
|
||||||
|
if let Ok(id) = s.parse::<u64>() {
|
||||||
|
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) => {
|
||||||
|
db.wipe();
|
||||||
|
}
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
store_entry(&db, io::stdin(), cli.max_dedupe_search, cli.max_items);
|
eprintln!("Unsupported import format: {format}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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(Command::Wipe) => {
|
|
||||||
wipe_db(&db);
|
|
||||||
}
|
|
||||||
Some(Command::Import) => {
|
|
||||||
eprintln!("Use --import-tsv to import TSV clipboard data");
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
eprintln!("No subcommand provided");
|
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<String>) {
|
|
||||||
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<u64, &'static str> {
|
|
||||||
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<String> {
|
|
||||||
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::<String>() + 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])
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue