Merge pull request #5 from NotAShelf/notashelf/push-zlynssnnvsqo

stash: add watch subcommand
This commit is contained in:
raf 2025-08-13 08:01:01 +03:00 committed by GitHub
commit 3262715cf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1030 additions and 1063 deletions

1383
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "stash" name = "stash"
version = "0.1.0" version = "0.2.2"
edition = "2024" edition = "2024"
authors = ["NotAShelf <raf@notashelf.dev>"] authors = ["NotAShelf <raf@notashelf.dev>"]
license = "MPL-2.0" license = "MPL-2.0"
@ -10,12 +10,22 @@ rust-version = "1.85"
[dependencies] [dependencies]
clap = { version = "4.5.44", features = ["derive"] } clap = { version = "4.5.44", features = ["derive"] }
sled = "0.34.7"
dirs = "6.0.0" dirs = "6.0.0"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
rmp-serde = "1.3.0" rmp-serde = "1.3.0"
image = "0.25.6" imagesize = "0.14"
log = "0.4.27" log = "0.4.27"
env_logger = "0.11.8" env_logger = "0.11.8"
clap-verbosity-flag = "3.0.3" clap-verbosity-flag = "3.0.3"
thiserror = "2.0.14" thiserror = "2.0.14"
wl-clipboard-rs = "0.9.2"
rusqlite = { version = "0.37.0", features = ["bundled"] }
smol = "2.0.2"
serde_json = "1.0.142"
base64 = "0.22.1"
[profile.release]
lto = true
opt-level = "z"
strip = true

View file

@ -7,7 +7,7 @@ line.
## Features ## Features
- Stores clipboard entries with automatic MIME detection - Stores clipboard entries with automatic MIME detection
- Fast persistent storage using sled - Fast persistent storage using SQLite
- List, search, decode, delete, and wipe clipboard history - List, search, decode, delete, and wipe clipboard history
- Backwards compatible with Cliphist TSV format - Backwards compatible with Cliphist TSV format
- Import clipboard history from TSV (e.g., from `cliphist list`) - Import clipboard history from TSV (e.g., from `cliphist list`)
@ -42,13 +42,13 @@ stash decode --input "1234"
### Delete entries matching a query ### Delete entries matching a query
```bash ```bash
stash delete-query --query "some text" stash delete --type query --arg "some text"
``` ```
### Delete multiple entries by ID (from a file or stdin) ### Delete multiple entries by ID (from a file or stdin)
```bash ```bash
stash delete < ids.txt stash delete --type id < ids.txt
``` ```
### Wipe all entries ### Wipe all entries
@ -57,6 +57,15 @@ stash delete < ids.txt
stash wipe stash wipe
``` ```
### Watch clipboard for changes and store automatically
```bash
stash watch
```
This runs a daemon that monitors the clipboard and stores new entries
automatically.
### Options ### Options
Some commands take additional flags to modify Stash's behavior. See each Some commands take additional flags to modify Stash's behavior. See each
@ -66,27 +75,66 @@ commands `--help` text for more details. The following are generally standard:
- `--max-items <N>`: Maximum number of entries to keep (oldest trimmed) - `--max-items <N>`: Maximum number of entries to keep (oldest trimmed)
- `--max-dedupe-search <N>`: Deduplication window size - `--max-dedupe-search <N>`: Deduplication window size
- `--preview-width <N>`: Text preview max width for `list` - `--preview-width <N>`: Text preview max width for `list`
- `--version`: Print the current version and exit
## Tips & Tricks ## Tips & Tricks
### Migrating from Cliphist ### Migrating from Cliphist
[Cliphist]: https://github.com/sentriz/cliphist Stash is designed to be a drop-in replacement for Cliphist, with only minor
improvements. If you are migrating from Cliphist, here are a few things you
should know.
Stash is designed to be backwards compatible with [Cliphist]. Though for - Most Cliphist commands have direct equivalents in Stash. For example,
brevity, I have elected to skip automatic database migration. Which means you `cliphist store` -> `stash store`, `cliphist list` -> `stash list`, etc.
must handle the migration yourself, with one simple command. - Cliphist uses `delete-query`; in Stash, you must use
`stash delete --type query --arg "your query"`.
- Both Cliphist and Stash support deleting by ID, including from stdin or a
file.
- Stash respects the `STASH_CLIPBOARD_STATE` environment variable for
sensitive/clear entries, just like Cliphist. The `STASH_` prefix is added for
granularity, you must update your scripts.
- You can export your Cliphist history to TSV and import it into Stash (see
below).
- Stash supports text and image previews, including dimensions and format.
- Stash adds a `watch` command to automatically store clipboard changes. This is
an alternative to `wl-paste --watch cliphist list`. You can avoid shelling out
and depending on `wl-paste` as Stash implements it through `wl-clipboard-rs`
crate.
### TSV Export and Import
Both Stash and Cliphist support TSV format for clipboard history. You can export
from Cliphist and import into Stash, or use Stash to export TSV for
interoperability.
**Export TSV from Cliphist:**
```bash ```bash
$ cliphist list --db ~/.cache/cliphist/db | stash --import-tsv cliphist list --db ~/.cache/cliphist/db > cliphist.tsv
# > Imported 750 records from TSV into sled database.
``` ```
Alternatively, you may first export from Cliphist and _then_ import the **Import TSV into Stash:**
database.
```bash ```bash
$ cliphist list --db ~/.cache/cliphist/db > cliphist.tsv stash --import < cliphist.tsv
$ stash --import-tsv < cliphist.tsv
# > Imported 750 records from TSV into sled database.
``` ```
**Export TSV from Stash:**
```bash
stash list > stash.tsv
```
**Import TSV into Cliphist:**
```bash
cliphist --import < stash.tsv
```
### More Tricks
- Use `stash list` to export your clipboard history in TSV format. This displays
your clipboard in the same format as `cliphist list`
- Use `stash import --type tsv` to import TSV clipboard history from Cliphist or
other tools.

View file

@ -1,4 +1,4 @@
use crate::db::{ClipboardDb, SledClipboardDb}; use crate::db::{ClipboardDb, SqliteClipboardDb};
use std::io::{Read, Write}; use std::io::{Read, Write};
@ -13,7 +13,7 @@ pub trait DecodeCommand {
) -> Result<(), StashError>; ) -> Result<(), StashError>;
} }
impl DecodeCommand for SledClipboardDb { impl DecodeCommand for SqliteClipboardDb {
fn decode( fn decode(
&self, &self,
in_: impl Read, in_: impl Read,

View file

@ -1,4 +1,4 @@
use crate::db::{ClipboardDb, SledClipboardDb, StashError}; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
use std::io::Read; use std::io::Read;
@ -6,7 +6,7 @@ pub trait DeleteCommand {
fn delete(&self, input: impl Read) -> Result<usize, StashError>; fn delete(&self, input: impl Read) -> Result<usize, StashError>;
} }
impl DeleteCommand for SledClipboardDb { 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) => {

View file

@ -1,11 +1,11 @@
use crate::db::{ClipboardDb, SledClipboardDb}; use crate::db::{ClipboardDb, SqliteClipboardDb};
use std::io::Write; use std::io::Write;
pub trait ListCommand { pub trait ListCommand {
fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError>; fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError>;
} }
impl ListCommand for SledClipboardDb { impl ListCommand for SqliteClipboardDb {
fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError> { fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError> {
self.list_entries(out, preview_width)?; self.list_entries(out, preview_width)?;
log::info!("Listed clipboard entries"); log::info!("Listed clipboard entries");

View file

@ -3,4 +3,5 @@ pub mod delete;
pub mod list; pub mod list;
pub mod query; pub mod query;
pub mod store; pub mod store;
pub mod watch;
pub mod wipe; pub mod wipe;

View file

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

View file

@ -1,4 +1,4 @@
use crate::db::{ClipboardDb, SledClipboardDb}; use crate::db::{ClipboardDb, SqliteClipboardDb};
use std::io::Read; use std::io::Read;
@ -12,7 +12,7 @@ pub trait StoreCommand {
) -> Result<(), crate::db::StashError>; ) -> Result<(), crate::db::StashError>;
} }
impl StoreCommand for SledClipboardDb { impl StoreCommand for SqliteClipboardDb {
fn store( fn store(
&self, &self,
input: impl Read, input: impl Read,

79
src/commands/watch.rs Normal file
View file

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

View file

@ -1,4 +1,4 @@
use crate::db::{ClipboardDb, SledClipboardDb}; use crate::db::{ClipboardDb, SqliteClipboardDb};
use crate::db::StashError; use crate::db::StashError;
@ -6,7 +6,7 @@ pub trait WipeCommand {
fn wipe(&self) -> Result<(), StashError>; fn wipe(&self) -> Result<(), StashError>;
} }
impl WipeCommand for SledClipboardDb { 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");

View file

@ -2,21 +2,24 @@ use std::fmt;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
use std::str; use std::str;
use image::{GenericImageView, ImageFormat}; use imagesize::{ImageSize, ImageType};
use log::{error, info}; use log::{error, info};
use rmp_serde::{decode::from_read, encode::to_vec};
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sled::{Db, IVec};
use thiserror::Error; use thiserror::Error;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde_json::json;
#[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 serialize entry: {0}")]
Serialize(String),
#[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}")]
@ -41,10 +44,7 @@ pub enum StashError {
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("No entry found for id {0}")]
DecodeNoEntry(u64),
#[error("Failed to decode entry: {0}")]
DecodeDecode(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}")]
@ -89,11 +89,68 @@ impl fmt::Display for Entry {
} }
} }
pub struct SledClipboardDb { pub struct SqliteClipboardDb {
pub db: Db, pub conn: Connection,
} }
impl ClipboardDb for SledClipboardDb { impl SqliteClipboardDb {
pub fn new(conn: Connection) -> Result<Self, StashError> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS clipboard (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contents BLOB NOT NULL,
mime TEXT
);",
)
.map_err(|e| StashError::Store(e.to_string()))?;
Ok(Self { conn })
}
}
impl SqliteClipboardDb {
pub fn list_json(&self) -> Result<String, 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 entries = Vec::new();
while let Some(row) = rows
.next()
.map_err(|e| StashError::ListDecode(e.to_string()))?
{
let id: u64 = row
.get(0)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let contents: Vec<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 contents_str = match mime.as_deref() {
Some(m) if m.starts_with("text/") || m == "application/json" => {
String::from_utf8_lossy(&contents).to_string()
}
_ => STANDARD.encode(&contents),
};
entries.push(json!({
"id": id,
"contents": contents_str,
"mime": mime,
}));
}
Ok(serde_json::to_string_pretty(&entries)
.map_err(|e| StashError::ListDecode(e.to_string()))?)
}
}
impl ClipboardDb for SqliteClipboardDb {
fn store_entry( fn store_entry(
&self, &self,
mut input: impl Read, mut input: impl Read,
@ -108,102 +165,125 @@ impl ClipboardDb for SledClipboardDb {
return Err(StashError::AllWhitespace); return Err(StashError::AllWhitespace);
} }
let mime = detect_mime(&buf); let mime = match detect_mime(&buf) {
None => {
// If valid UTF-8, treat as text/plain
if std::str::from_utf8(&buf).is_ok() {
Some("text/plain".to_string())
} else {
None
}
}
other => other,
};
self.deduplicate(&buf, max_dedupe_search)?; self.deduplicate(&buf, max_dedupe_search)?;
let entry = Entry { self.conn
contents: buf.clone(), .execute(
mime, "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
}; params![buf, mime],
)
let id = self.next_sequence();
let enc = to_vec(&entry).map_err(|e| StashError::Serialize(e.to_string()))?;
self.db
.insert(u64_to_ivec(id), enc)
.map_err(|e| StashError::Store(e.to_string()))?; .map_err(|e| StashError::Store(e.to_string()))?;
self.trim_db(max_items)?; self.trim_db(max_items)?;
Ok(id) Ok(self.next_sequence())
} }
fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError> { fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError> {
let mut count = 0; let mut stmt = self
.conn
.prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1")
.map_err(|e| StashError::DeduplicationRead(e.to_string()))?;
let mut rows = stmt
.query(params![i64::try_from(max).unwrap_or(i64::MAX)])
.map_err(|e| StashError::DeduplicationRead(e.to_string()))?;
let mut deduped = 0; let mut deduped = 0;
for item in self while let Some(row) = rows
.db .next()
.iter() .map_err(|e| StashError::DeduplicationRead(e.to_string()))?
.rev()
.take(usize::try_from(max).unwrap_or(usize::MAX))
{ {
let (k, v) = match item { let id: u64 = row
Ok((k, v)) => (k, v), .get(0)
Err(e) => return Err(StashError::DeduplicationRead(e.to_string())), .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?;
}; let contents: Vec<u8> = row
let entry: Entry = match from_read(v.as_ref()) { .get(1)
Ok(e) => e, .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?;
Err(e) => return Err(StashError::DeduplicationDecode(e.to_string())), if contents == buf {
}; self.conn
if entry.contents == buf { .execute("DELETE FROM clipboard WHERE id = ?1", params![id])
self.db
.remove(k)
.map(|_| {
deduped += 1;
})
.map_err(|e| StashError::DeduplicationRemove(e.to_string()))?; .map_err(|e| StashError::DeduplicationRemove(e.to_string()))?;
} deduped += 1;
count += 1;
if count >= max {
break;
} }
} }
Ok(deduped) Ok(deduped)
} }
fn trim_db(&self, max: u64) -> Result<(), StashError> { fn trim_db(&self, max: u64) -> Result<(), StashError> {
let mut keys: Vec<_> = self let count: u64 = self
.db .conn
.iter() .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
.rev() .map_err(|e| StashError::Trim(e.to_string()))?;
.filter_map(|kv| match kv { if count > max {
Ok((k, _)) => Some(k), let to_delete = count - max;
Err(_e) => None, self.conn.execute(
}) "DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER BY id ASC LIMIT ?1)",
.collect(); params![i64::try_from(to_delete).unwrap_or(i64::MAX)],
if keys.len() as u64 > max { ).map_err(|e| StashError::Trim(e.to_string()))?;
for k in keys.drain(usize::try_from(max).unwrap_or(0)..) {
self.db
.remove(k)
.map_err(|e| StashError::Trim(e.to_string()))?;
}
} }
Ok(()) Ok(())
} }
fn delete_last(&self) -> Result<(), StashError> { fn delete_last(&self) -> Result<(), StashError> {
if let Some((k, _)) = self.db.iter().next_back().and_then(Result::ok) { let id: Option<u64> = self
self.db .conn
.remove(k) .query_row(
.map(|_| ()) "SELECT id FROM clipboard ORDER BY id DESC LIMIT 1",
.map_err(|e| StashError::DeleteLast(e.to_string())) [],
|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) Err(StashError::NoEntriesToDelete)
} }
} }
fn wipe_db(&self) -> Result<(), StashError> { fn wipe_db(&self) -> Result<(), StashError> {
self.db.clear().map_err(|e| StashError::Wipe(e.to_string())) 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> { 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; let mut listed = 0;
for (k, v) in self.db.iter().rev().filter_map(Result::ok) { while let Some(row) = rows
let id = ivec_to_u64(&k); .next()
let entry: Entry = match from_read(v.as_ref()) { .map_err(|e| StashError::ListDecode(e.to_string()))?
Ok(e) => e, {
Err(e) => return Err(StashError::ListDecode(e.to_string())), let id: u64 = row
}; .get(0)
let preview = preview_entry(&entry.contents, entry.mime.as_deref(), preview_width); .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() { if writeln!(out, "{id}\t{preview}").is_ok() {
listed += 1; listed += 1;
} }
@ -226,38 +306,44 @@ impl ClipboardDb for SledClipboardDb {
buf buf
}; };
let id = extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; let id = extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?;
let v = self let (contents, _mime): (Vec<u8>, Option<String>) = self
.db .conn
.get(u64_to_ivec(id)) .query_row(
.map_err(|e| StashError::DecodeGet(e.to_string()))? "SELECT contents, mime FROM clipboard WHERE id = ?1",
.ok_or(StashError::DecodeNoEntry(id))?; params![id],
let entry: Entry = |row| Ok((row.get(0)?, row.get(1)?)),
from_read(v.as_ref()).map_err(|e| StashError::DecodeDecode(e.to_string()))?; )
.map_err(|e| StashError::DecodeGet(e.to_string()))?;
out.write_all(&entry.contents) out.write_all(&contents)
.map_err(|e| StashError::DecodeWrite(e.to_string()))?; .map_err(|e| StashError::DecodeWrite(e.to_string()))?;
info!("Decoded entry with id {id}"); info!("Decoded entry with id {id}");
Ok(()) Ok(())
} }
fn delete_query(&self, query: &str) -> Result<usize, StashError> { 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; let mut deleted = 0;
for (k, v) in self.db.iter().filter_map(Result::ok) { while let Some(row) = rows
let entry: Entry = match from_read(v.as_ref()) { .next()
Ok(e) => e, .map_err(|e| StashError::QueryDelete(e.to_string()))?
Err(_) => continue, {
}; let id: u64 = row
if entry .get(0)
.contents .map_err(|e| StashError::QueryDelete(e.to_string()))?;
.windows(query.len()) let contents: Vec<u8> = row
.any(|w| w == query.as_bytes()) .get(1)
{ .map_err(|e| StashError::QueryDelete(e.to_string()))?;
self.db if contents.windows(query.len()).any(|w| w == query.as_bytes()) {
.remove(k) self.conn
.map(|_| { .execute("DELETE FROM clipboard WHERE id = ?1", params![id])
deleted += 1;
})
.map_err(|e| StashError::QueryDelete(e.to_string()))?; .map_err(|e| StashError::QueryDelete(e.to_string()))?;
deleted += 1;
} }
} }
Ok(deleted) Ok(deleted)
@ -268,25 +354,24 @@ impl ClipboardDb for SledClipboardDb {
let mut deleted = 0; let mut deleted = 0;
for line in reader.lines().map_while(Result::ok) { for line in reader.lines().map_while(Result::ok) {
if let Ok(id) = extract_id(&line) { if let Ok(id) = extract_id(&line) {
self.db self.conn
.remove(u64_to_ivec(id)) .execute("DELETE FROM clipboard WHERE id = ?1", params![id])
.map(|_| {
deleted += 1;
})
.map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; .map_err(|e| StashError::DeleteEntry(id, e.to_string()))?;
deleted += 1;
} }
} }
Ok(deleted) Ok(deleted)
} }
fn next_sequence(&self) -> u64 { fn next_sequence(&self) -> u64 {
let last = self match self
.db .conn
.iter() .query_row("SELECT MAX(id) FROM clipboard", [], |row| {
.next_back() row.get::<_, Option<u64>>(0)
.and_then(std::result::Result::ok) }) {
.map(|(k, _)| ivec_to_u64(&k)); Ok(Some(max_id)) => max_id + 1,
last.unwrap_or(0) + 1 Ok(None) | Err(_) => 1,
}
} }
} }
@ -296,38 +381,20 @@ pub fn extract_id(input: &str) -> Result<u64, &'static str> {
id_str.parse().map_err(|_| "invalid id") 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] = if let Ok(arr) = v.as_ref().try_into() {
arr
} else {
error!("Failed to convert IVec to u64: invalid length");
return 0;
};
u64::from_be_bytes(arr)
}
pub fn detect_mime(data: &[u8]) -> Option<String> { pub fn detect_mime(data: &[u8]) -> Option<String> {
if image::guess_format(data).is_ok() { if let Ok(img_type) = imagesize::image_type(data) {
match image::guess_format(data) { Some(
Ok(fmt) => Some( match img_type {
match fmt { ImageType::Png => "image/png",
ImageFormat::Png => "image/png", ImageType::Jpeg => "image/jpeg",
ImageFormat::Jpeg => "image/jpeg", ImageType::Gif => "image/gif",
ImageFormat::Gif => "image/gif", ImageType::Bmp => "image/bmp",
ImageFormat::Bmp => "image/bmp", ImageType::Tiff => "image/tiff",
ImageFormat::Tiff => "image/tiff", ImageType::Webp => "image/webp",
_ => "application/octet-stream", _ => "application/octet-stream",
} }
.to_string(), .to_string(),
), )
Err(_) => None,
}
} else if data.is_ascii() {
Some("text/plain".into())
} else { } else {
None None
} }
@ -336,18 +403,27 @@ pub fn detect_mime(data: &[u8]) -> Option<String> {
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(img) = image::load_from_memory(data) { if let Ok(ImageSize {
let (w, h) = img.dimensions(); width: img_width,
height: img_height,
}) = imagesize::blob_size(data)
{
return format!( return format!(
"[[ binary data {} {} {}x{} ]]", "[[ binary data {} {} {}x{} ]]",
size_str(data.len()), size_str(data.len()),
mime, mime,
w, img_width,
h img_height
); );
} }
} else if mime == "application/json" || mime.starts_with("text/") { } else if mime == "application/json" || mime.starts_with("text/") {
let s = str::from_utf8(data).unwrap_or(""); let s = match str::from_utf8(data) {
Ok(s) => s,
Err(e) => {
error!("Failed to decode UTF-8 clipboard data: {e}");
""
}
};
let s = s.trim().replace(|c: char| c.is_whitespace(), " "); let s = s.trim().replace(|c: char| c.is_whitespace(), " ");
return truncate(&s, width as usize, ""); return truncate(&s, width as usize, "");
} }
@ -366,7 +442,12 @@ pub fn truncate(s: &str, max: usize, ellip: &str) -> 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 = f64::from(u32::try_from(size).unwrap_or(u32::MAX)); let mut fsize = if let Ok(val) = u32::try_from(size) {
f64::from(val)
} else {
error!("Clipboard entry size too large for display: {size}");
f64::from(u32::MAX)
};
let mut i = 0; 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;

View file

@ -1,4 +1,4 @@
use crate::db::{Entry, SledClipboardDb, detect_mime, u64_to_ivec}; use crate::db::{Entry, SqliteClipboardDb, detect_mime};
use log::{error, info}; use log::{error, info};
use std::io::{self, BufRead}; use std::io::{self, BufRead};
@ -6,31 +6,27 @@ pub trait ImportCommand {
fn import_tsv(&self, input: impl io::Read); fn import_tsv(&self, input: impl io::Read);
} }
impl ImportCommand for SledClipboardDb { 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');
if let (Some(id_str), Some(val)) = (parts.next(), parts.next()) { if let (Some(id_str), Some(val)) = (parts.next(), parts.next()) {
if let Ok(id) = id_str.parse::<u64>() { if let Ok(_id) = id_str.parse::<u64>() {
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()),
}; };
let enc = match rmp_serde::encode::to_vec(&entry) { match self.conn.execute(
Ok(enc) => enc, "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
Err(e) => { rusqlite::params![entry.contents, entry.mime],
error!("Failed to encode entry for id {id}: {e}"); ) {
continue;
}
};
match self.db.insert(u64_to_ivec(id), enc) {
Ok(_) => { Ok(_) => {
imported += 1; imported += 1;
info!("Imported entry with id {id}"); info!("Imported entry from TSV");
} }
Err(e) => error!("Failed to insert entry with id {id}: {e}"), Err(e) => error!("Failed to insert entry: {e}"),
} }
} else { } else {
error!("Failed to parse id from line: {id_str}"); error!("Failed to parse id from line: {id_str}");
@ -39,6 +35,6 @@ impl ImportCommand for SledClipboardDb {
error!("Malformed TSV line: {line:?}"); error!("Malformed TSV line: {line:?}");
} }
} }
info!("Imported {imported} records from TSV into sled database."); info!("Imported {imported} records from TSV into SQLite database.");
} }
} }

View file

@ -5,7 +5,7 @@ use std::{
process, process,
}; };
use clap::{Parser, Subcommand}; use clap::{CommandFactory, Parser, Subcommand};
mod commands; mod commands;
mod db; mod db;
@ -16,6 +16,7 @@ use crate::commands::delete::DeleteCommand;
use crate::commands::list::ListCommand; use crate::commands::list::ListCommand;
use crate::commands::query::QueryCommand; use crate::commands::query::QueryCommand;
use crate::commands::store::StoreCommand; use crate::commands::store::StoreCommand;
use crate::commands::watch::WatchCommand;
use crate::commands::wipe::WipeCommand; use crate::commands::wipe::WipeCommand;
use crate::import::ImportCommand; use crate::import::ImportCommand;
@ -48,7 +49,11 @@ enum Command {
Store, Store,
/// List clipboard history /// List clipboard history
List, List {
/// Output format: "tsv" (default) or "json"
#[arg(long, value_parser = ["tsv", "json"])]
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> },
@ -73,6 +78,9 @@ enum Command {
#[arg(long, value_parser = ["tsv"])] #[arg(long, value_parser = ["tsv"])]
r#type: Option<String>, r#type: Option<String>,
}, },
/// Watch clipboard for changes and store automatically
Watch,
} }
fn report_error<T>(result: Result<T, impl std::fmt::Display>, context: &str) -> Option<T> { fn report_error<T>(result: Result<T, impl std::fmt::Display>, context: &str) -> Option<T> {
@ -86,96 +94,129 @@ fn report_error<T>(result: Result<T, impl std::fmt::Display>, context: &str) ->
} }
fn main() { fn main() {
let cli = Cli::parse(); smol::block_on(async {
env_logger::Builder::new() let cli = Cli::parse();
.filter_level(cli.verbosity.into()) env_logger::Builder::new()
.init(); .filter_level(cli.verbosity.into())
.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")
}); });
let sled_db = sled::open(&db_path).unwrap_or_else(|e| { let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| {
log::error!("Failed to open database: {e}"); log::error!("Failed to open SQLite database: {e}");
process::exit(1); process::exit(1);
}); });
let db = db::SledClipboardDb { db: sled_db }; 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 { match cli.command {
Some(Command::Store) => { Some(Command::Store) => {
let state = env::var("STASH_CLIPBOARD_STATE").ok(); let state = env::var("STASH_CLIPBOARD_STATE").ok();
report_error( report_error(
db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state), db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state),
"Failed to store entry", "Failed to store entry",
); );
} }
Some(Command::List) => { Some(Command::List { format }) => {
report_error( let format = format.as_deref().unwrap_or("tsv");
db.list(io::stdout(), cli.preview_width), match format {
"Failed to list entries", "tsv" => {
); report_error(
} db.list(io::stdout(), cli.preview_width),
Some(Command::Decode { input }) => { "Failed to list entries",
report_error( );
db.decode(io::stdin(), io::stdout(), input), }
"Failed to decode entry", "json" => {
); // Implement JSON output
} match db.list_json() {
Some(Command::Delete { arg, r#type }) => match (arg, r#type.as_deref()) { Ok(json) => {
(Some(s), Some("id")) => { println!("{}", json);
if let Ok(id) = s.parse::<u64>() { }
use std::io::Cursor; Err(e) => {
report_error( log::error!("Failed to list entries as JSON: {}", e);
db.delete(Cursor::new(format!("{id}\n"))), }
"Failed to delete entry by id", }
); }
} else { _ => {
log::error!("Argument is not a valid id"); log::error!("Unsupported format: {}", format);
}
} }
} }
(Some(s), Some("query")) => { Some(Command::Decode { input }) => {
report_error(db.query_delete(&s), "Failed to delete entry by query"); report_error(
db.decode(io::stdin(), io::stdout(), input),
"Failed to decode entry",
);
} }
(Some(s), None) => { Some(Command::Delete { arg, r#type }) => match (arg, r#type.as_deref()) {
if let Ok(id) = s.parse::<u64>() { (Some(s), Some("id")) => {
use std::io::Cursor; if let Ok(id) = s.parse::<u64>() {
report_error( use std::io::Cursor;
db.delete(Cursor::new(format!("{id}\n"))), report_error(
"Failed to delete entry by id", db.delete(Cursor::new(format!("{id}\n"))),
); "Failed to delete entry by id",
} else { );
} 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"); 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) => {
report_error(db.wipe(), "Failed to wipe database");
} }
(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) => {
report_error(db.wipe(), "Failed to wipe database");
}
Some(Command::Import { r#type }) => { Some(Command::Import { r#type }) => {
// Default format is TSV (Cliphist compatible) // Default format is TSV (Cliphist compatible)
let format = r#type.as_deref().unwrap_or("tsv"); let format = r#type.as_deref().unwrap_or("tsv");
match format { match format {
"tsv" => { "tsv" => {
db.import_tsv(io::stdin()); db.import_tsv(io::stdin());
} }
_ => { _ => {
log::error!("Unsupported import format: {format}"); 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() {
eprintln!("Failed to print help: {e}");
}
println!();
}
} }
_ => { });
log::warn!("No subcommand provided");
}
}
} }