mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-13 14:33:47 +00:00
Merge pull request #60 from NotAShelf/notashelf/push-wvyzsrrzyrum
add auto-expiry mechanism to `stash watch`
This commit is contained in:
commit
ded38723d4
7 changed files with 699 additions and 62 deletions
35
Cargo.lock
generated
35
Cargo.lock
generated
|
|
@ -1011,6 +1011,12 @@ version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humantime"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|
@ -1121,9 +1127,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.83"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|
@ -2128,6 +2134,7 @@ dependencies = [
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
"dirs",
|
"dirs",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"humantime",
|
||||||
"imagesize",
|
"imagesize",
|
||||||
"inquire",
|
"inquire",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -2561,18 +2568,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.2+wasi-0.2.9"
|
version = "1.0.1+wasi-0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen",
|
"wit-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.108"
|
version = "0.2.106"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
|
@ -2583,9 +2590,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.108"
|
version = "0.2.106"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
|
|
@ -2593,9 +2600,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.108"
|
version = "0.2.106"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|
@ -2606,9 +2613,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.108"
|
version = "0.2.106"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
@ -2924,9 +2931,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.46.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wl-clipboard-rs"
|
name = "wl-clipboard-rs"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ repository = "https://github.com/notashelf/stash"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "stash" # actual binary name for Nix, Cargo, etc.
|
name = "stash" # actual binary name for Nix, Cargo, etc.
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
@ -22,6 +22,7 @@ crossterm = "0.29.0"
|
||||||
ctrlc = "3.5.1"
|
ctrlc = "3.5.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
|
humantime = "2.3.0"
|
||||||
imagesize = "0.14.0"
|
imagesize = "0.14.0"
|
||||||
inquire = { version = "0.9.2", default-features = false, features = [
|
inquire = { version = "0.9.2", default-features = false, features = [
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
|
|
||||||
132
README.md
132
README.md
|
|
@ -45,8 +45,9 @@ with many features such as but not necessarily limited to:
|
||||||
- Import clipboard history from TSV (e.g., from `cliphist list`)
|
- Import clipboard history from TSV (e.g., from `cliphist list`)
|
||||||
- Image preview (shows dimensions and format)
|
- Image preview (shows dimensions and format)
|
||||||
- Text previews with customizable width
|
- Text previews with customizable width
|
||||||
- Deduplication and entry limit control
|
- De-duplication, whitespace prevention and entry limit control
|
||||||
- Automatic clipboard monitoring with `stash watch`
|
- Automatic clipboard monitoring with `stash watch`
|
||||||
|
- Configurable auto-expiry of old entries in watch mode as a safety buffer
|
||||||
- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`)
|
- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`)
|
||||||
- Sensitive clipboard filtering via regex (see below)
|
- Sensitive clipboard filtering via regex (see below)
|
||||||
- Sensitive clipboard filtering by application (see below)
|
- Sensitive clipboard filtering by application (see below)
|
||||||
|
|
@ -141,7 +142,7 @@ Commands:
|
||||||
list List clipboard history
|
list List clipboard history
|
||||||
decode Decode and output clipboard entry by id
|
decode Decode and output clipboard entry by id
|
||||||
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 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
|
||||||
wipe Wipe all clipboard history
|
db Database management operations
|
||||||
import Import clipboard data from stdin (default: TSV format)
|
import Import clipboard data from stdin (default: TSV format)
|
||||||
watch Start a process to watch clipboard for changes and store automatically
|
watch Start a process to watch clipboard for changes and store automatically
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
@ -154,7 +155,7 @@ Options:
|
||||||
--preview-width <PREVIEW_WIDTH>
|
--preview-width <PREVIEW_WIDTH>
|
||||||
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
|
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
|
||||||
--db-path <DB_PATH>
|
--db-path <DB_PATH>
|
||||||
Path to the `SQLite` clipboard database file
|
Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=]
|
||||||
--excluded-apps <EXCLUDED_APPS>
|
--excluded-apps <EXCLUDED_APPS>
|
||||||
Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=]
|
Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=]
|
||||||
--ask
|
--ask
|
||||||
|
|
@ -188,6 +189,11 @@ and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will
|
||||||
display entries in Cliphist-compatible TSV format in Bash scripts. You may also
|
display entries in Cliphist-compatible TSV format in Bash scripts. You may also
|
||||||
enforce the output format with `stash list --format <tsv | json>`.
|
enforce the output format with `stash list --format <tsv | json>`.
|
||||||
|
|
||||||
|
You may also view your clipboard _with the addition of expired entries_, i.e.,
|
||||||
|
entries that have reached their TTL and are marked as expired, using the
|
||||||
|
`--expired` flag as `stash list --expired`. Expired entries are not cleaned up
|
||||||
|
when using this flag, allowing you to inspect them before running cleanup.
|
||||||
|
|
||||||
### Decode an entry by ID
|
### Decode an entry by ID
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -219,10 +225,33 @@ stash delete --type id < ids.txt
|
||||||
|
|
||||||
### Wipe all entries
|
### Wipe all entries
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe`
|
||||||
|
> instead.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stash wipe
|
stash wipe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database management
|
||||||
|
|
||||||
|
Stash provides a `db` subcommand for database maintenance operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stash db wipe [--expired] [--ask]
|
||||||
|
stash db vacuum
|
||||||
|
stash db stats
|
||||||
|
```
|
||||||
|
|
||||||
|
- `stash db wipe`: Remove all entries from the database. Use `--expired` to only
|
||||||
|
wipe expired entries instead of all entries. Requires `--ask` confirmation by
|
||||||
|
default.
|
||||||
|
- `stash db vacuum`: Optimize the database using SQLite's VACUUM command,
|
||||||
|
reclaiming space and improving performance.
|
||||||
|
- `stash db stats`: Display database statistics including total/active/expired
|
||||||
|
entry counts, storage size, and page information. This is provided purely for
|
||||||
|
convenience and the rule of the cool.
|
||||||
|
|
||||||
### Watch clipboard for changes and store automatically
|
### Watch clipboard for changes and store automatically
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -235,13 +264,16 @@ automatically. This is designed as an alternative to shelling out to
|
||||||
premade Systemd service in `contrib/`. Packagers are encouraged to vendor the
|
premade Systemd service in `contrib/`. Packagers are encouraged to vendor the
|
||||||
service unless adding their own.
|
service unless adding their own.
|
||||||
|
|
||||||
> [!TIP]
|
#### Automatic Clipboard Clearing on Expiration
|
||||||
> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility
|
|
||||||
> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you
|
When `stash watch` is running and a clipboard entry expires, Stash will detect
|
||||||
> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs`
|
if the current clipboard still contains that expired content and automatically
|
||||||
> wrappers on your system. In other words, you can use
|
clear it. This prevents stale data from remaining in your clipboard after an
|
||||||
> `wl-paste --watch stash store` as an alternative to `stash watch` if
|
entry has expired from history.
|
||||||
> preferred.
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This behavior only applies when the watch daemon is actively running. Manual
|
||||||
|
> expiration or deletion of entries will not clear the clipboard.
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
|
|
@ -406,6 +438,86 @@ figured out something new, e.g. a neat shell trick, feel free to add it here!
|
||||||
the packagers. While building from source, you may link
|
the packagers. While building from source, you may link
|
||||||
`target/release/stash` manually.
|
`target/release/stash` manually.
|
||||||
|
|
||||||
|
### Entry Expiration
|
||||||
|
|
||||||
|
Stash supports time-to-live (TTL) for clipboard entries. When an entry's
|
||||||
|
expiration time is reached, it is marked as expired rather than immediately
|
||||||
|
deleted. This allows for inspection of expired entries and automatic clipboard
|
||||||
|
cleanup.
|
||||||
|
|
||||||
|
#### How Expiration Works
|
||||||
|
|
||||||
|
When `stash watch` is running with `--expire-after`, it monitors the clipboard
|
||||||
|
and processes expired entries periodically. Upon expiration:
|
||||||
|
|
||||||
|
1. The entry's `is_expired` flag is set to `1` in the database
|
||||||
|
2. If the current clipboard content matches the expired entry, Stash clears the
|
||||||
|
clipboard to prevent pasting stale data
|
||||||
|
3. Expired entries are excluded from normal list operations unless `--expired`
|
||||||
|
is specified
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> By default, entries do not expire. Use `stash watch --expire-after DURATION`
|
||||||
|
> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL).
|
||||||
|
|
||||||
|
#### Viewing Expired Entries
|
||||||
|
|
||||||
|
Use `stash list --expired` to include expired entries in the output. This is
|
||||||
|
useful for:
|
||||||
|
|
||||||
|
- Inspecting what has expired from your clipboard history
|
||||||
|
- Verifying that sensitive data has been properly expired
|
||||||
|
- Debugging expiration behavior
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View all entries including expired ones
|
||||||
|
stash list --expired
|
||||||
|
|
||||||
|
# View expired entries in JSON format
|
||||||
|
stash list --expired --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cleaning Up Expired Entries
|
||||||
|
|
||||||
|
The watch daemon automatically cleans up expired entries when it processes them.
|
||||||
|
For manual cleanup, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove all expired entries from the database
|
||||||
|
stash db wipe --expired
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If you have a large number of expired entries, consider running
|
||||||
|
> `stash db vacuum` afterward to reclaim disk space.
|
||||||
|
|
||||||
|
#### Automatic Clipboard Clearing
|
||||||
|
|
||||||
|
When `stash watch` is running and an entry expires, Stash checks if the current
|
||||||
|
clipboard still contains that expired content. If it matches, Stash clears the
|
||||||
|
clipboard automatically. This prevents accidentally pasting outdated content.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> This behavior only applies when the watch daemon is actively running. Manual
|
||||||
|
> expiration or deletion of entries will not clear the clipboard.
|
||||||
|
|
||||||
|
#### Database Maintenance
|
||||||
|
|
||||||
|
Stash uses SQLite for persistent storage. Over time, deleted entries and
|
||||||
|
fragmentation can affect performance. Use the `stash db` command to maintain
|
||||||
|
your database:
|
||||||
|
|
||||||
|
- **Check statistics**: `stash db stats` shows entry counts and storage usage.
|
||||||
|
Use this to monitor growth and decide when to clean up.
|
||||||
|
- **Remove expired entries**: `stash db wipe --expired` removes entries that
|
||||||
|
have reached their TTL. The daemon normally handles this, but this is useful
|
||||||
|
for manual cleanup.
|
||||||
|
- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to
|
||||||
|
reclaim space and defragment the database. This is safe to run periodically.
|
||||||
|
|
||||||
|
It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep
|
||||||
|
the database compact, especially after deleting many entries.
|
||||||
|
|
||||||
## Attributions
|
## Attributions
|
||||||
|
|
||||||
My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
|
My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@ use unicode_width::UnicodeWidthStr;
|
||||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||||
|
|
||||||
pub trait ListCommand {
|
pub trait ListCommand {
|
||||||
fn list(&self, out: impl Write, preview_width: u32)
|
fn list(
|
||||||
-> Result<(), StashError>;
|
&self,
|
||||||
|
out: impl Write,
|
||||||
|
preview_width: u32,
|
||||||
|
include_expired: bool,
|
||||||
|
) -> Result<(), StashError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListCommand for SqliteClipboardDb {
|
impl ListCommand for SqliteClipboardDb {
|
||||||
|
|
@ -15,14 +19,21 @@ impl ListCommand for SqliteClipboardDb {
|
||||||
&self,
|
&self,
|
||||||
out: impl Write,
|
out: impl Write,
|
||||||
preview_width: u32,
|
preview_width: u32,
|
||||||
|
include_expired: bool,
|
||||||
) -> Result<(), StashError> {
|
) -> Result<(), StashError> {
|
||||||
self.list_entries(out, preview_width).map(|_| ())
|
self
|
||||||
|
.list_entries(out, preview_width, include_expired)
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteClipboardDb {
|
impl SqliteClipboardDb {
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> {
|
pub fn list_tui(
|
||||||
|
&self,
|
||||||
|
preview_width: u32,
|
||||||
|
include_expired: bool,
|
||||||
|
) -> Result<(), StashError> {
|
||||||
use std::io::stdout;
|
use std::io::stdout;
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
|
|
@ -53,12 +64,16 @@ impl SqliteClipboardDb {
|
||||||
use wl_clipboard_rs::copy::{MimeType, Options, Source};
|
use wl_clipboard_rs::copy::{MimeType, Options, Source};
|
||||||
|
|
||||||
// Query entries from DB
|
// Query entries from DB
|
||||||
|
let query = if include_expired {
|
||||||
|
"SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \
|
||||||
|
id DESC"
|
||||||
|
} else {
|
||||||
|
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
|
||||||
|
is_expired = 0) ORDER BY last_accessed DESC, id DESC"
|
||||||
|
};
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare(
|
.prepare(query)
|
||||||
"SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \
|
|
||||||
DESC, id DESC",
|
|
||||||
)
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
let mut rows = stmt
|
let mut rows = stmt
|
||||||
.query([])
|
.query([])
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,105 @@
|
||||||
use std::{
|
use std::{
|
||||||
collections::hash_map::DefaultHasher,
|
collections::{BinaryHeap, hash_map::DefaultHasher},
|
||||||
hash::{Hash, Hasher},
|
hash::{Hash, Hasher},
|
||||||
io::Read,
|
io::Read,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use smol::Timer;
|
use smol::Timer;
|
||||||
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
|
use wl_clipboard_rs::{
|
||||||
|
copy::{MimeType as CopyMimeType, Options, Source},
|
||||||
|
paste::{ClipboardType, Seat, get_contents},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||||
|
|
||||||
|
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
|
||||||
|
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
|
||||||
|
/// Also see:
|
||||||
|
/// - <https://doc.rust-lang.org/std/cmp/struct.Reverse.html>
|
||||||
|
/// - <https://doc.rust-lang.org/std/primitive.f64.html#method.total_cmp>
|
||||||
|
/// - <https://docs.rs/ordered-float/latest/ordered_float/>
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Neg(f64);
|
||||||
|
|
||||||
|
impl Neg {
|
||||||
|
fn inner(&self) -> f64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::cmp::PartialEq for Neg {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.0 == other.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::cmp::Eq for Neg {}
|
||||||
|
|
||||||
|
impl std::cmp::PartialOrd for Neg {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::cmp::Ord for Neg {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
// Reverse ordering for min-heap behavior
|
||||||
|
other
|
||||||
|
.0
|
||||||
|
.partial_cmp(&self.0)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Min-heap for tracking entry expirations with sub-second precision.
|
||||||
|
/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ExpirationQueue {
|
||||||
|
heap: BinaryHeap<(Neg, i64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExpirationQueue {
|
||||||
|
/// Create a new empty expiration queue
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
heap: BinaryHeap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a new expiration into the queue
|
||||||
|
fn push(&mut self, expires_at: f64, id: i64) {
|
||||||
|
self.heap.push((Neg(expires_at), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peek at the next expiration timestamp without removing it
|
||||||
|
fn peek_next(&self) -> Option<f64> {
|
||||||
|
self.heap.peek().map(|(neg, _)| neg.inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove and return all entries that have expired by `now`
|
||||||
|
fn pop_expired(&mut self, now: f64) -> Vec<i64> {
|
||||||
|
let mut expired = Vec::new();
|
||||||
|
while let Some((neg_exp, id)) = self.heap.peek() {
|
||||||
|
let expires_at = neg_exp.inner();
|
||||||
|
if expires_at <= now {
|
||||||
|
expired.push(*id);
|
||||||
|
self.heap.pop();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub trait WatchCommand {
|
pub trait WatchCommand {
|
||||||
fn watch(
|
fn watch(
|
||||||
&self,
|
&self,
|
||||||
max_dedupe_search: u64,
|
max_dedupe_search: u64,
|
||||||
max_items: u64,
|
max_items: u64,
|
||||||
excluded_apps: &[String],
|
excluded_apps: &[String],
|
||||||
|
expire_after: Option<Duration>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,10 +109,46 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
max_dedupe_search: u64,
|
max_dedupe_search: u64,
|
||||||
max_items: u64,
|
max_items: u64,
|
||||||
excluded_apps: &[String],
|
excluded_apps: &[String],
|
||||||
|
expire_after: Option<Duration>,
|
||||||
) {
|
) {
|
||||||
smol::block_on(async {
|
smol::block_on(async {
|
||||||
log::info!("Starting clipboard watch daemon");
|
log::info!("Starting clipboard watch daemon");
|
||||||
|
|
||||||
|
// Build expiration queue from existing entries
|
||||||
|
let mut exp_queue = ExpirationQueue::new();
|
||||||
|
if let Ok(Some((expires_at, id))) = self.get_next_expiration() {
|
||||||
|
exp_queue.push(expires_at, id);
|
||||||
|
// Load remaining expirations (exclude already-marked expired entries)
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \
|
||||||
|
NULL AND (is_expired IS NULL OR is_expired = 0) ORDER BY \
|
||||||
|
expires_at ASC",
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
if let Some(ref mut stmt) = stmt {
|
||||||
|
let mut rows = stmt.query([]).ok();
|
||||||
|
if let Some(ref mut rows) = rows {
|
||||||
|
while let Ok(Some(row)) = rows.next() {
|
||||||
|
if let (Ok(exp), Ok(row_id)) =
|
||||||
|
(row.get::<_, f64>(0), row.get::<_, i64>(1))
|
||||||
|
{
|
||||||
|
// Skip first entry which is already added
|
||||||
|
if exp_queue
|
||||||
|
.heap
|
||||||
|
.iter()
|
||||||
|
.any(|(_, existing_id)| *existing_id == row_id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
exp_queue.push(exp, row_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// We use hashes for comparison instead of storing full contents
|
// We use hashes for comparison instead of storing full contents
|
||||||
let mut last_hash: Option<u64> = None;
|
let mut last_hash: Option<u64> = None;
|
||||||
let mut buf = Vec::with_capacity(4096);
|
let mut buf = Vec::with_capacity(4096);
|
||||||
|
|
@ -53,6 +173,83 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// Process any pending expirations
|
||||||
|
if let Some(next_exp) = exp_queue.peek_next() {
|
||||||
|
let now = SqliteClipboardDb::now();
|
||||||
|
if next_exp <= now {
|
||||||
|
// Expired entries to process
|
||||||
|
let expired_ids = exp_queue.pop_expired(now);
|
||||||
|
for id in expired_ids {
|
||||||
|
// Verify entry still exists and get its content_hash
|
||||||
|
let expired_hash: Option<i64> = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT content_hash FROM clipboard WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if let Some(stored_hash) = expired_hash {
|
||||||
|
// Mark as expired
|
||||||
|
self
|
||||||
|
.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE clipboard SET is_expired = 1 WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
log::info!("Entry {id} marked as expired");
|
||||||
|
|
||||||
|
// Check if this expired entry is currently in the clipboard
|
||||||
|
if let Ok((mut reader, _)) = get_contents(
|
||||||
|
ClipboardType::Regular,
|
||||||
|
Seat::Unspecified,
|
||||||
|
wl_clipboard_rs::paste::MimeType::Any,
|
||||||
|
) {
|
||||||
|
let mut current_buf = Vec::new();
|
||||||
|
if reader.read_to_end(&mut current_buf).is_ok()
|
||||||
|
&& !current_buf.is_empty()
|
||||||
|
{
|
||||||
|
let current_hash = hash_contents(¤t_buf);
|
||||||
|
// Compare as i64 (database stores as i64)
|
||||||
|
if current_hash as i64 == stored_hash {
|
||||||
|
// Clear the clipboard since expired content is still
|
||||||
|
// there
|
||||||
|
let mut opts = Options::new();
|
||||||
|
opts.clipboard(
|
||||||
|
wl_clipboard_rs::copy::ClipboardType::Regular,
|
||||||
|
);
|
||||||
|
if opts
|
||||||
|
.copy(
|
||||||
|
Source::Bytes(Vec::new().into()),
|
||||||
|
CopyMimeType::Autodetect,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
"Cleared clipboard containing expired entry {id}"
|
||||||
|
);
|
||||||
|
last_hash = None; // reset tracked hash
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to clear clipboard for expired entry {id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sleep *precisely* until next expiration
|
||||||
|
let sleep_duration = next_exp - now;
|
||||||
|
Timer::after(Duration::from_secs_f64(sleep_duration)).await;
|
||||||
|
continue; // skip normal poll, process expirations first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal clipboard polling
|
||||||
match get_contents(
|
match get_contents(
|
||||||
ClipboardType::Regular,
|
ClipboardType::Regular,
|
||||||
Seat::Unspecified,
|
Seat::Unspecified,
|
||||||
|
|
@ -70,16 +267,23 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
if !buf.is_empty() {
|
if !buf.is_empty() {
|
||||||
let current_hash = hash_contents(&buf);
|
let current_hash = hash_contents(&buf);
|
||||||
if last_hash != Some(current_hash) {
|
if last_hash != Some(current_hash) {
|
||||||
let id = self.next_sequence();
|
|
||||||
match self.store_entry(
|
match self.store_entry(
|
||||||
&buf[..],
|
&buf[..],
|
||||||
max_dedupe_search,
|
max_dedupe_search,
|
||||||
max_items,
|
max_items,
|
||||||
Some(excluded_apps),
|
Some(excluded_apps),
|
||||||
) {
|
) {
|
||||||
Ok(_) => {
|
Ok(id) => {
|
||||||
log::info!("Stored new clipboard entry (id: {id})");
|
log::info!("Stored new clipboard entry (id: {id})");
|
||||||
last_hash = Some(current_hash);
|
last_hash = Some(current_hash);
|
||||||
|
|
||||||
|
// Set expiration if configured
|
||||||
|
if let Some(duration) = expire_after {
|
||||||
|
let expires_at =
|
||||||
|
SqliteClipboardDb::now() + duration.as_secs_f64();
|
||||||
|
self.set_expiration(id, expires_at).ok();
|
||||||
|
exp_queue.push(expires_at, id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Err(crate::db::StashError::ExcludedByApp(_)) => {
|
Err(crate::db::StashError::ExcludedByApp(_)) => {
|
||||||
log::info!("Clipboard entry excluded by app filter");
|
log::info!("Clipboard entry excluded by app filter");
|
||||||
|
|
@ -106,7 +310,11 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
Timer::after(Duration::from_millis(500)).await;
|
|
||||||
|
// Normal poll interval (only if no expirations pending)
|
||||||
|
if exp_queue.peek_next().is_none() {
|
||||||
|
Timer::after(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
228
src/db/mod.rs
228
src/db/mod.rs
|
|
@ -80,6 +80,7 @@ pub trait ClipboardDb {
|
||||||
&self,
|
&self,
|
||||||
out: impl Write,
|
out: impl Write,
|
||||||
preview_width: u32,
|
preview_width: u32,
|
||||||
|
include_expired: bool,
|
||||||
) -> Result<usize, StashError>;
|
) -> Result<usize, StashError>;
|
||||||
fn decode_entry(
|
fn decode_entry(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -93,7 +94,6 @@ pub trait ClipboardDb {
|
||||||
&self,
|
&self,
|
||||||
id: i64,
|
id: i64,
|
||||||
) -> Result<(i64, Vec<u8>, Option<String>), StashError>;
|
) -> Result<(i64, Vec<u8>, Option<String>), StashError>;
|
||||||
fn next_sequence(&self) -> i64;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
|
|
@ -256,6 +256,89 @@ impl SqliteClipboardDb {
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add expires_at column if it doesn't exist (v4)
|
||||||
|
if schema_version < 4 {
|
||||||
|
let has_expires_at: bool = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND \
|
||||||
|
name='clipboard'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
let sql: String = row.get(0)?;
|
||||||
|
Ok(sql.to_lowercase().contains("expires_at"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_expires_at {
|
||||||
|
tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", [])
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to add expires_at column: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create partial index for expires_at (only index non-NULL values)
|
||||||
|
tx.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \
|
||||||
|
WHERE expires_at IS NOT NULL",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to create expires_at index: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.execute("PRAGMA user_version = 4", []).map_err(|e| {
|
||||||
|
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add is_expired column if it doesn't exist (v5)
|
||||||
|
if schema_version < 5 {
|
||||||
|
let has_is_expired: bool = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND \
|
||||||
|
name='clipboard'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
let sql: String = row.get(0)?;
|
||||||
|
Ok(sql.to_lowercase().contains("is_expired"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_is_expired {
|
||||||
|
tx.execute(
|
||||||
|
"ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to add is_expired column: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index for is_expired (for filtering)
|
||||||
|
tx.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \
|
||||||
|
WHERE is_expired = 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to create is_expired index: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.execute("PRAGMA user_version = 5", []).map_err(|e| {
|
||||||
|
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
tx.commit().map_err(|e| {
|
tx.commit().map_err(|e| {
|
||||||
StashError::Store(
|
StashError::Store(
|
||||||
format!("Failed to commit migration transaction: {e}").into(),
|
format!("Failed to commit migration transaction: {e}").into(),
|
||||||
|
|
@ -271,13 +354,17 @@ impl SqliteClipboardDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteClipboardDb {
|
impl SqliteClipboardDb {
|
||||||
pub fn list_json(&self) -> Result<String, StashError> {
|
pub fn list_json(&self, include_expired: bool) -> Result<String, StashError> {
|
||||||
|
let query = if include_expired {
|
||||||
|
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||||
|
COALESCE(last_accessed, 0) DESC, id DESC"
|
||||||
|
} else {
|
||||||
|
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
|
||||||
|
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC"
|
||||||
|
};
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare(
|
.prepare(query)
|
||||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
|
||||||
COALESCE(last_accessed, 0) DESC, id DESC",
|
|
||||||
)
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
let mut rows = stmt
|
let mut rows = stmt
|
||||||
.query([])
|
.query([])
|
||||||
|
|
@ -486,13 +573,18 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
&self,
|
&self,
|
||||||
mut out: impl Write,
|
mut out: impl Write,
|
||||||
preview_width: u32,
|
preview_width: u32,
|
||||||
|
include_expired: bool,
|
||||||
) -> Result<usize, StashError> {
|
) -> Result<usize, StashError> {
|
||||||
|
let query = if include_expired {
|
||||||
|
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||||
|
COALESCE(last_accessed, 0) DESC, id DESC"
|
||||||
|
} else {
|
||||||
|
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
|
||||||
|
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC"
|
||||||
|
};
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare(
|
.prepare(query)
|
||||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
|
||||||
COALESCE(last_accessed, 0) DESC, id DESC",
|
|
||||||
)
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
let mut rows = stmt
|
let mut rows = stmt
|
||||||
.query([])
|
.query([])
|
||||||
|
|
@ -640,17 +732,119 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
|
|
||||||
Ok((id, contents, mime))
|
Ok((id, contents, mime))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn next_sequence(&self) -> i64 {
|
impl SqliteClipboardDb {
|
||||||
match self
|
/// Get current Unix timestamp with sub-second precision
|
||||||
|
pub fn now() -> f64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs_f64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up all expired entries. Returns count deleted.
|
||||||
|
pub fn cleanup_expired(&self) -> Result<usize, StashError> {
|
||||||
|
let now = Self::now();
|
||||||
|
self
|
||||||
.conn
|
.conn
|
||||||
.query_row("SELECT MAX(id) FROM clipboard", [], |row| {
|
.execute(
|
||||||
row.get::<_, Option<i64>>(0)
|
"DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \
|
||||||
}) {
|
?1",
|
||||||
Ok(Some(max_id)) => max_id + 1,
|
[now],
|
||||||
Ok(None) | Err(_) => 1,
|
)
|
||||||
|
.map_err(|e| StashError::Trim(e.to_string().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the earliest expiration (timestamp, id) for heap initialization
|
||||||
|
pub fn get_next_expiration(&self) -> Result<Option<(f64, i64)>, StashError> {
|
||||||
|
match self.conn.query_row(
|
||||||
|
"SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \
|
||||||
|
ORDER BY expires_at ASC LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
) {
|
||||||
|
Ok(result) => Ok(Some(result)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(StashError::Store(e.to_string().into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set expiration timestamp for an entry
|
||||||
|
pub fn set_expiration(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
expires_at: f64,
|
||||||
|
) -> Result<(), StashError> {
|
||||||
|
self
|
||||||
|
.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE clipboard SET expires_at = ?2 WHERE id = ?1",
|
||||||
|
params![id, expires_at],
|
||||||
|
)
|
||||||
|
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimize database using VACUUM
|
||||||
|
pub fn vacuum(&self) -> Result<(), StashError> {
|
||||||
|
self
|
||||||
|
.conn
|
||||||
|
.execute("VACUUM", [])
|
||||||
|
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get database statistics
|
||||||
|
pub fn stats(&self) -> Result<String, StashError> {
|
||||||
|
let total: i64 = self
|
||||||
|
.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
||||||
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
|
||||||
|
let expired: i64 = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM clipboard WHERE is_expired = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
|
||||||
|
let active = total - expired;
|
||||||
|
|
||||||
|
let with_expiration: i64 = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \
|
||||||
|
(is_expired IS NULL OR is_expired = 0)",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
|
||||||
|
// Get database file size
|
||||||
|
let page_count: i64 = self
|
||||||
|
.conn
|
||||||
|
.query_row("PRAGMA page_count", [], |row| row.get(0))
|
||||||
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
|
||||||
|
let page_size: i64 = self
|
||||||
|
.conn
|
||||||
|
.query_row("PRAGMA page_size", [], |row| row.get(0))
|
||||||
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
|
||||||
|
let size_bytes = page_count * page_size;
|
||||||
|
let size_mb = size_bytes as f64 / 1024.0 / 1024.0;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \
|
||||||
|
{active}\nExpired: {expired}\nWith TTL: \
|
||||||
|
{with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \
|
||||||
|
({size_bytes} bytes)\nPages: {page_count}\nPage size: \
|
||||||
|
{page_size} bytes"
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to load a sensitive regex from systemd credential or env.
|
/// Try to load a sensitive regex from systemd credential or env.
|
||||||
|
|
|
||||||
114
src/main.rs
114
src/main.rs
|
|
@ -2,9 +2,11 @@ use std::{
|
||||||
env,
|
env,
|
||||||
io::{self, IsTerminal},
|
io::{self, IsTerminal},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use clap::{CommandFactory, Parser, Subcommand};
|
use clap::{CommandFactory, Parser, Subcommand};
|
||||||
|
use humantime::parse_duration;
|
||||||
use inquire::Confirm;
|
use inquire::Confirm;
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
|
|
@ -71,6 +73,10 @@ enum Command {
|
||||||
/// Output format: "tsv" (default) or "json"
|
/// Output format: "tsv" (default) or "json"
|
||||||
#[arg(long, value_parser = ["tsv", "json"])]
|
#[arg(long, value_parser = ["tsv", "json"])]
|
||||||
format: Option<String>,
|
format: Option<String>,
|
||||||
|
|
||||||
|
/// Show only expired entries (diagnostic, does not remove them)
|
||||||
|
#[arg(long)]
|
||||||
|
expired: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Decode and output clipboard entry by id
|
/// Decode and output clipboard entry by id
|
||||||
|
|
@ -93,12 +99,21 @@ enum Command {
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Wipe all clipboard history
|
/// Wipe all clipboard history
|
||||||
|
///
|
||||||
|
/// DEPRECATED: Use `stash db wipe` instead
|
||||||
|
#[command(hide = true)]
|
||||||
Wipe {
|
Wipe {
|
||||||
/// Ask for confirmation before wiping
|
/// Ask for confirmation before wiping
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
ask: bool,
|
ask: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Database management operations
|
||||||
|
Db {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: DbAction,
|
||||||
|
},
|
||||||
|
|
||||||
/// Import clipboard data from stdin (default: TSV format)
|
/// Import clipboard data from stdin (default: TSV format)
|
||||||
Import {
|
Import {
|
||||||
/// Explicitly specify format: "tsv" (default)
|
/// Explicitly specify format: "tsv" (default)
|
||||||
|
|
@ -111,7 +126,31 @@ enum Command {
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Start a process to watch clipboard for changes and store automatically.
|
/// Start a process to watch clipboard for changes and store automatically.
|
||||||
Watch,
|
Watch {
|
||||||
|
/// Expire new entries after duration (e.g., "3s", "500ms", "1h30m").
|
||||||
|
#[arg(long, value_parser = parse_duration)]
|
||||||
|
expire_after: Option<Duration>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum DbAction {
|
||||||
|
/// Wipe database entries
|
||||||
|
Wipe {
|
||||||
|
/// Only wipe expired entries instead of all entries
|
||||||
|
#[arg(long)]
|
||||||
|
expired: bool,
|
||||||
|
|
||||||
|
/// Ask for confirmation before wiping
|
||||||
|
#[arg(long)]
|
||||||
|
ask: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Optimize database using VACUUM
|
||||||
|
Vacuum,
|
||||||
|
|
||||||
|
/// Show database statistics
|
||||||
|
Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report_error<T>(
|
fn report_error<T>(
|
||||||
|
|
@ -186,16 +225,16 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
"failed to store entry",
|
"failed to store entry",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
Some(Command::List { format }) => {
|
Some(Command::List { format, expired }) => {
|
||||||
match format.as_deref() {
|
match format.as_deref() {
|
||||||
Some("tsv") => {
|
Some("tsv") => {
|
||||||
report_error(
|
report_error(
|
||||||
db.list(io::stdout(), cli.preview_width),
|
db.list(io::stdout(), cli.preview_width, expired),
|
||||||
"failed to list entries",
|
"failed to list entries",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
Some("json") => {
|
Some("json") => {
|
||||||
match db.list_json() {
|
match db.list_json(expired) {
|
||||||
Ok(json) => {
|
Ok(json) => {
|
||||||
println!("{json}");
|
println!("{json}");
|
||||||
},
|
},
|
||||||
|
|
@ -210,12 +249,12 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
None => {
|
None => {
|
||||||
if std::io::stdout().is_terminal() {
|
if std::io::stdout().is_terminal() {
|
||||||
report_error(
|
report_error(
|
||||||
db.list_tui(cli.preview_width),
|
db.list_tui(cli.preview_width, expired),
|
||||||
"failed to list entries in TUI",
|
"failed to list entries in TUI",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
report_error(
|
report_error(
|
||||||
db.list(io::stdout(), cli.preview_width),
|
db.list(io::stdout(), cli.preview_width, expired),
|
||||||
"failed to list entries",
|
"failed to list entries",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -287,6 +326,10 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(Command::Wipe { ask }) => {
|
Some(Command::Wipe { ask }) => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: The 'stash wipe' command is deprecated. Use 'stash db \
|
||||||
|
wipe' instead."
|
||||||
|
);
|
||||||
let mut should_proceed = true;
|
let mut should_proceed = true;
|
||||||
if ask {
|
if ask {
|
||||||
should_proceed = Confirm::new(
|
should_proceed = Confirm::new(
|
||||||
|
|
@ -304,6 +347,62 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Some(Command::Db { action }) => {
|
||||||
|
match action {
|
||||||
|
DbAction::Wipe { expired, ask } => {
|
||||||
|
let mut should_proceed = true;
|
||||||
|
if ask {
|
||||||
|
let message = if expired {
|
||||||
|
"Are you sure you want to wipe all expired clipboard entries?"
|
||||||
|
} else {
|
||||||
|
"Are you sure you want to wipe ALL clipboard history?"
|
||||||
|
};
|
||||||
|
should_proceed = Confirm::new(message)
|
||||||
|
.with_default(false)
|
||||||
|
.prompt()
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !should_proceed {
|
||||||
|
log::info!("db wipe command aborted by user.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if should_proceed {
|
||||||
|
if expired {
|
||||||
|
match db.cleanup_expired() {
|
||||||
|
Ok(count) => {
|
||||||
|
log::info!("Wiped {} expired entries", count);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("failed to wipe expired entries: {e}");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
report_error(db.wipe(), "failed to wipe database");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DbAction::Vacuum => {
|
||||||
|
match db.vacuum() {
|
||||||
|
Ok(()) => {
|
||||||
|
log::info!("Database optimized successfully");
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("failed to vacuum database: {e}");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DbAction::Stats => {
|
||||||
|
match db.stats() {
|
||||||
|
Ok(stats) => {
|
||||||
|
println!("{}", stats);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("failed to get database stats: {e}");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
Some(Command::Import { r#type, ask }) => {
|
Some(Command::Import { r#type, ask }) => {
|
||||||
let mut should_proceed = true;
|
let mut should_proceed = true;
|
||||||
if ask {
|
if ask {
|
||||||
|
|
@ -334,7 +433,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(Command::Watch) => {
|
Some(Command::Watch { expire_after }) => {
|
||||||
db.watch(
|
db.watch(
|
||||||
cli.max_dedupe_search,
|
cli.max_dedupe_search,
|
||||||
cli.max_items,
|
cli.max_items,
|
||||||
|
|
@ -342,6 +441,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
&cli.excluded_apps,
|
&cli.excluded_apps,
|
||||||
#[cfg(not(feature = "use-toplevel"))]
|
#[cfg(not(feature = "use-toplevel"))]
|
||||||
&[],
|
&[],
|
||||||
|
expire_after,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue