mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-12 22:17:41 +00:00
Merge pull request #5 from NotAShelf/notashelf/push-zlynssnnvsqo
stash: add watch subcommand
This commit is contained in:
commit
3262715cf0
14 changed files with 1030 additions and 1063 deletions
1383
Cargo.lock
generated
1383
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
16
Cargo.toml
16
Cargo.toml
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "stash"
|
||||
version = "0.1.0"
|
||||
version = "0.2.2"
|
||||
edition = "2024"
|
||||
authors = ["NotAShelf <raf@notashelf.dev>"]
|
||||
license = "MPL-2.0"
|
||||
|
|
@ -10,12 +10,22 @@ rust-version = "1.85"
|
|||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.44", features = ["derive"] }
|
||||
sled = "0.34.7"
|
||||
dirs = "6.0.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
rmp-serde = "1.3.0"
|
||||
image = "0.25.6"
|
||||
imagesize = "0.14"
|
||||
log = "0.4.27"
|
||||
env_logger = "0.11.8"
|
||||
clap-verbosity-flag = "3.0.3"
|
||||
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
|
||||
|
|
|
|||
76
README.md
76
README.md
|
|
@ -7,7 +7,7 @@ line.
|
|||
## Features
|
||||
|
||||
- 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
|
||||
- Backwards compatible with Cliphist TSV format
|
||||
- Import clipboard history from TSV (e.g., from `cliphist list`)
|
||||
|
|
@ -42,13 +42,13 @@ stash decode --input "1234"
|
|||
### Delete entries matching a query
|
||||
|
||||
```bash
|
||||
stash delete-query --query "some text"
|
||||
stash delete --type query --arg "some text"
|
||||
```
|
||||
|
||||
### Delete multiple entries by ID (from a file or stdin)
|
||||
|
||||
```bash
|
||||
stash delete < ids.txt
|
||||
stash delete --type id < ids.txt
|
||||
```
|
||||
|
||||
### Wipe all entries
|
||||
|
|
@ -57,6 +57,15 @@ stash delete < ids.txt
|
|||
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
|
||||
|
||||
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-dedupe-search <N>`: Deduplication window size
|
||||
- `--preview-width <N>`: Text preview max width for `list`
|
||||
- `--version`: Print the current version and exit
|
||||
|
||||
## Tips & Tricks
|
||||
|
||||
### 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
|
||||
brevity, I have elected to skip automatic database migration. Which means you
|
||||
must handle the migration yourself, with one simple command.
|
||||
- Most Cliphist commands have direct equivalents in Stash. For example,
|
||||
`cliphist store` -> `stash store`, `cliphist list` -> `stash list`, etc.
|
||||
- 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
|
||||
$ cliphist list --db ~/.cache/cliphist/db | stash --import-tsv
|
||||
# > Imported 750 records from TSV into sled database.
|
||||
cliphist list --db ~/.cache/cliphist/db > cliphist.tsv
|
||||
```
|
||||
|
||||
Alternatively, you may first export from Cliphist and _then_ import the
|
||||
database.
|
||||
**Import TSV into Stash:**
|
||||
|
||||
```bash
|
||||
$ cliphist list --db ~/.cache/cliphist/db > cliphist.tsv
|
||||
$ stash --import-tsv < cliphist.tsv
|
||||
# > Imported 750 records from TSV into sled database.
|
||||
stash --import < cliphist.tsv
|
||||
```
|
||||
|
||||
**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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::db::{ClipboardDb, SledClipboardDb};
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ pub trait DecodeCommand {
|
|||
) -> Result<(), StashError>;
|
||||
}
|
||||
|
||||
impl DecodeCommand for SledClipboardDb {
|
||||
impl DecodeCommand for SqliteClipboardDb {
|
||||
fn decode(
|
||||
&self,
|
||||
in_: impl Read,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::db::{ClipboardDb, SledClipboardDb, StashError};
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ pub trait DeleteCommand {
|
|||
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> {
|
||||
match self.delete_entries(input) {
|
||||
Ok(deleted) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use crate::db::{ClipboardDb, SledClipboardDb};
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
use std::io::Write;
|
||||
|
||||
pub trait ListCommand {
|
||||
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> {
|
||||
self.list_entries(out, preview_width)?;
|
||||
log::info!("Listed clipboard entries");
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ pub mod delete;
|
|||
pub mod list;
|
||||
pub mod query;
|
||||
pub mod store;
|
||||
pub mod watch;
|
||||
pub mod wipe;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::db::{ClipboardDb, SledClipboardDb};
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
use crate::db::StashError;
|
||||
|
||||
|
|
@ -6,8 +6,8 @@ pub trait QueryCommand {
|
|||
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> {
|
||||
<SledClipboardDb as ClipboardDb>::delete_query(self, query)
|
||||
<SqliteClipboardDb as ClipboardDb>::delete_query(self, query)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::db::{ClipboardDb, SledClipboardDb};
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ pub trait StoreCommand {
|
|||
) -> Result<(), crate::db::StashError>;
|
||||
}
|
||||
|
||||
impl StoreCommand for SledClipboardDb {
|
||||
impl StoreCommand for SqliteClipboardDb {
|
||||
fn store(
|
||||
&self,
|
||||
input: impl Read,
|
||||
|
|
|
|||
79
src/commands/watch.rs
Normal file
79
src/commands/watch.rs
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::db::{ClipboardDb, SledClipboardDb};
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
use crate::db::StashError;
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ pub trait WipeCommand {
|
|||
fn wipe(&self) -> Result<(), StashError>;
|
||||
}
|
||||
|
||||
impl WipeCommand for SledClipboardDb {
|
||||
impl WipeCommand for SqliteClipboardDb {
|
||||
fn wipe(&self) -> Result<(), StashError> {
|
||||
self.wipe_db()?;
|
||||
log::info!("Database wiped");
|
||||
|
|
|
|||
379
src/db/mod.rs
379
src/db/mod.rs
|
|
@ -2,21 +2,24 @@ use std::fmt;
|
|||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::str;
|
||||
|
||||
use image::{GenericImageView, ImageFormat};
|
||||
use imagesize::{ImageSize, ImageType};
|
||||
use log::{error, info};
|
||||
use rmp_serde::{decode::from_read, encode::to_vec};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sled::{Db, IVec};
|
||||
use thiserror::Error;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum StashError {
|
||||
#[error("Input is empty or too large, skipping store.")]
|
||||
EmptyOrTooLarge,
|
||||
#[error("Input is all whitespace, skipping store.")]
|
||||
AllWhitespace,
|
||||
#[error("Failed to serialize entry: {0}")]
|
||||
Serialize(String),
|
||||
|
||||
#[error("Failed to store entry: {0}")]
|
||||
Store(String),
|
||||
#[error("Error reading entry during deduplication: {0}")]
|
||||
|
|
@ -41,10 +44,7 @@ pub enum StashError {
|
|||
DecodeExtractId(String),
|
||||
#[error("Failed to get entry for decode: {0}")]
|
||||
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}")]
|
||||
DecodeWrite(String),
|
||||
#[error("Failed to delete entry during query delete: {0}")]
|
||||
|
|
@ -89,11 +89,68 @@ impl fmt::Display for Entry {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct SledClipboardDb {
|
||||
pub db: Db,
|
||||
pub struct SqliteClipboardDb {
|
||||
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(
|
||||
&self,
|
||||
mut input: impl Read,
|
||||
|
|
@ -108,102 +165,125 @@ impl ClipboardDb for SledClipboardDb {
|
|||
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)?;
|
||||
|
||||
let entry = Entry {
|
||||
contents: buf.clone(),
|
||||
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)
|
||||
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(id)
|
||||
Ok(self.next_sequence())
|
||||
}
|
||||
|
||||
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;
|
||||
for item in self
|
||||
.db
|
||||
.iter()
|
||||
.rev()
|
||||
.take(usize::try_from(max).unwrap_or(usize::MAX))
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|e| StashError::DeduplicationRead(e.to_string()))?
|
||||
{
|
||||
let (k, v) = match item {
|
||||
Ok((k, v)) => (k, v),
|
||||
Err(e) => return Err(StashError::DeduplicationRead(e.to_string())),
|
||||
};
|
||||
let entry: Entry = match from_read(v.as_ref()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return Err(StashError::DeduplicationDecode(e.to_string())),
|
||||
};
|
||||
if entry.contents == buf {
|
||||
self.db
|
||||
.remove(k)
|
||||
.map(|_| {
|
||||
deduped += 1;
|
||||
})
|
||||
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()))?;
|
||||
}
|
||||
count += 1;
|
||||
if count >= max {
|
||||
break;
|
||||
deduped += 1;
|
||||
}
|
||||
}
|
||||
Ok(deduped)
|
||||
}
|
||||
|
||||
fn trim_db(&self, max: u64) -> Result<(), StashError> {
|
||||
let mut keys: Vec<_> = self
|
||||
.db
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|kv| match kv {
|
||||
Ok((k, _)) => Some(k),
|
||||
Err(_e) => None,
|
||||
})
|
||||
.collect();
|
||||
if keys.len() as u64 > max {
|
||||
for k in keys.drain(usize::try_from(max).unwrap_or(0)..) {
|
||||
self.db
|
||||
.remove(k)
|
||||
.map_err(|e| StashError::Trim(e.to_string()))?;
|
||||
}
|
||||
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> {
|
||||
if let Some((k, _)) = self.db.iter().next_back().and_then(Result::ok) {
|
||||
self.db
|
||||
.remove(k)
|
||||
.map(|_| ())
|
||||
.map_err(|e| StashError::DeleteLast(e.to_string()))
|
||||
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.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> {
|
||||
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;
|
||||
for (k, v) in self.db.iter().rev().filter_map(Result::ok) {
|
||||
let id = ivec_to_u64(&k);
|
||||
let entry: Entry = match from_read(v.as_ref()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return Err(StashError::ListDecode(e.to_string())),
|
||||
};
|
||||
let preview = preview_entry(&entry.contents, entry.mime.as_deref(), preview_width);
|
||||
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;
|
||||
}
|
||||
|
|
@ -226,38 +306,44 @@ impl ClipboardDb for SledClipboardDb {
|
|||
buf
|
||||
};
|
||||
let id = extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?;
|
||||
let v = self
|
||||
.db
|
||||
.get(u64_to_ivec(id))
|
||||
.map_err(|e| StashError::DecodeGet(e.to_string()))?
|
||||
.ok_or(StashError::DecodeNoEntry(id))?;
|
||||
let entry: Entry =
|
||||
from_read(v.as_ref()).map_err(|e| StashError::DecodeDecode(e.to_string()))?;
|
||||
|
||||
out.write_all(&entry.contents)
|
||||
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;
|
||||
for (k, v) in self.db.iter().filter_map(Result::ok) {
|
||||
let entry: Entry = match from_read(v.as_ref()) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if entry
|
||||
.contents
|
||||
.windows(query.len())
|
||||
.any(|w| w == query.as_bytes())
|
||||
{
|
||||
self.db
|
||||
.remove(k)
|
||||
.map(|_| {
|
||||
deleted += 1;
|
||||
})
|
||||
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)
|
||||
|
|
@ -268,25 +354,24 @@ impl ClipboardDb for SledClipboardDb {
|
|||
let mut deleted = 0;
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if let Ok(id) = extract_id(&line) {
|
||||
self.db
|
||||
.remove(u64_to_ivec(id))
|
||||
.map(|_| {
|
||||
deleted += 1;
|
||||
})
|
||||
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 {
|
||||
let last = self
|
||||
.db
|
||||
.iter()
|
||||
.next_back()
|
||||
.and_then(std::result::Result::ok)
|
||||
.map(|(k, _)| ivec_to_u64(&k));
|
||||
last.unwrap_or(0) + 1
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -296,38 +381,20 @@ pub fn extract_id(input: &str) -> Result<u64, &'static str> {
|
|||
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> {
|
||||
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())
|
||||
if let Ok(img_type) = imagesize::image_type(data) {
|
||||
Some(
|
||||
match img_type {
|
||||
ImageType::Png => "image/png",
|
||||
ImageType::Jpeg => "image/jpeg",
|
||||
ImageType::Gif => "image/gif",
|
||||
ImageType::Bmp => "image/bmp",
|
||||
ImageType::Tiff => "image/tiff",
|
||||
ImageType::Webp => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
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 {
|
||||
if let Some(mime) = mime {
|
||||
if mime.starts_with("image/") {
|
||||
if let Ok(img) = image::load_from_memory(data) {
|
||||
let (w, h) = img.dimensions();
|
||||
if let Ok(ImageSize {
|
||||
width: img_width,
|
||||
height: img_height,
|
||||
}) = imagesize::blob_size(data)
|
||||
{
|
||||
return format!(
|
||||
"[[ binary data {} {} {}x{} ]]",
|
||||
size_str(data.len()),
|
||||
mime,
|
||||
w,
|
||||
h
|
||||
img_width,
|
||||
img_height
|
||||
);
|
||||
}
|
||||
} 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(), " ");
|
||||
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 {
|
||||
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;
|
||||
while fsize >= 1024.0 && i < units.len() - 1 {
|
||||
fsize /= 1024.0;
|
||||
|
|
|
|||
|
|
@ -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 std::io::{self, BufRead};
|
||||
|
||||
|
|
@ -6,31 +6,27 @@ pub trait ImportCommand {
|
|||
fn import_tsv(&self, input: impl io::Read);
|
||||
}
|
||||
|
||||
impl ImportCommand for SledClipboardDb {
|
||||
impl ImportCommand for SqliteClipboardDb {
|
||||
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::<u64>() {
|
||||
if let Ok(_id) = id_str.parse::<u64>() {
|
||||
let entry = Entry {
|
||||
contents: val.as_bytes().to_vec(),
|
||||
mime: detect_mime(val.as_bytes()),
|
||||
};
|
||||
let enc = match rmp_serde::encode::to_vec(&entry) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
error!("Failed to encode entry for id {id}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self.db.insert(u64_to_ivec(id), enc) {
|
||||
match self.conn.execute(
|
||||
"INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
|
||||
rusqlite::params![entry.contents, entry.mime],
|
||||
) {
|
||||
Ok(_) => {
|
||||
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 {
|
||||
error!("Failed to parse id from line: {id_str}");
|
||||
|
|
@ -39,6 +35,6 @@ impl ImportCommand for SledClipboardDb {
|
|||
error!("Malformed TSV line: {line:?}");
|
||||
}
|
||||
}
|
||||
info!("Imported {imported} records from TSV into sled database.");
|
||||
info!("Imported {imported} records from TSV into SQLite database.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
201
src/main.rs
201
src/main.rs
|
|
@ -5,7 +5,7 @@ use std::{
|
|||
process,
|
||||
};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
|
||||
mod commands;
|
||||
mod db;
|
||||
|
|
@ -16,6 +16,7 @@ use crate::commands::delete::DeleteCommand;
|
|||
use crate::commands::list::ListCommand;
|
||||
use crate::commands::query::QueryCommand;
|
||||
use crate::commands::store::StoreCommand;
|
||||
use crate::commands::watch::WatchCommand;
|
||||
use crate::commands::wipe::WipeCommand;
|
||||
use crate::import::ImportCommand;
|
||||
|
||||
|
|
@ -48,7 +49,11 @@ enum Command {
|
|||
Store,
|
||||
|
||||
/// 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 { input: Option<String> },
|
||||
|
|
@ -73,6 +78,9 @@ enum Command {
|
|||
#[arg(long, value_parser = ["tsv"])]
|
||||
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> {
|
||||
|
|
@ -86,96 +94,129 @@ fn report_error<T>(result: Result<T, impl std::fmt::Display>, context: &str) ->
|
|||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
env_logger::Builder::new()
|
||||
.filter_level(cli.verbosity.into())
|
||||
.init();
|
||||
smol::block_on(async {
|
||||
let cli = Cli::parse();
|
||||
env_logger::Builder::new()
|
||||
.filter_level(cli.verbosity.into())
|
||||
.init();
|
||||
|
||||
let db_path = cli.db_path.unwrap_or_else(|| {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("stash")
|
||||
.join("db")
|
||||
});
|
||||
let db_path = cli.db_path.unwrap_or_else(|| {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("stash")
|
||||
.join("db")
|
||||
});
|
||||
|
||||
let sled_db = sled::open(&db_path).unwrap_or_else(|e| {
|
||||
log::error!("Failed to open database: {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 = 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 {
|
||||
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) => {
|
||||
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 }) => 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");
|
||||
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 }) => {
|
||||
let format = format.as_deref().unwrap_or("tsv");
|
||||
match format {
|
||||
"tsv" => {
|
||||
report_error(
|
||||
db.list(io::stdout(), cli.preview_width),
|
||||
"Failed to list entries",
|
||||
);
|
||||
}
|
||||
"json" => {
|
||||
// Implement JSON output
|
||||
match db.list_json() {
|
||||
Ok(json) => {
|
||||
println!("{}", json);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to list entries as JSON: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::error!("Unsupported format: {}", format);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(s), Some("query")) => {
|
||||
report_error(db.query_delete(&s), "Failed to delete entry by query");
|
||||
Some(Command::Decode { input }) => {
|
||||
report_error(
|
||||
db.decode(io::stdin(), io::stdout(), input),
|
||||
"Failed to decode entry",
|
||||
);
|
||||
}
|
||||
(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 {
|
||||
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;
|
||||
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) => {
|
||||
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 }) => {
|
||||
// Default format is TSV (Cliphist compatible)
|
||||
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::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());
|
||||
}
|
||||
_ => {
|
||||
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");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue