mirror of
https://github.com/NotAShelf/stash.git
synced 2026-05-18 21:17:35 +00:00
watch: allow smarter mimetype detection
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I082de33646e0260b3113eeb4f5a77c6e6a6a6964
This commit is contained in:
parent
1f0312b2f6
commit
340d02d09a
2 changed files with 165 additions and 15 deletions
|
|
@ -6,16 +6,155 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use smol::Timer;
|
use smol::Timer;
|
||||||
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
|
use wl_clipboard_rs::paste::{
|
||||||
|
ClipboardType,
|
||||||
|
MimeType,
|
||||||
|
Seat,
|
||||||
|
get_contents as wl_get_contents,
|
||||||
|
get_mime_types,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||||
|
|
||||||
|
/// Get clipboard contents with optional smart MIME type selection.
|
||||||
|
///
|
||||||
|
/// Provides intelligent clipboard content retrieval that can
|
||||||
|
/// prioritize specific MIME types based on user preferences or built-in
|
||||||
|
/// heuristics.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `clipboard` - The clipboard type to retrieve from (`Regular`, `Primary`,
|
||||||
|
/// etc.)
|
||||||
|
/// * `seat` - The Wayland seat identifier
|
||||||
|
/// * `preferred_types` - List of MIME types to prioritize in order. Supports
|
||||||
|
/// wildcards like `"image/*"` or `"text/*"`. Empty list enables default smart
|
||||||
|
/// detection.
|
||||||
|
/// * `smart_detection` - When true, enables intelligent MIME type selection.
|
||||||
|
/// When false, falls back to [`MimeType::Any`] behavior.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Returns a tuple containing:
|
||||||
|
/// - A [`Box<dyn Read>`] for reading the clipboard content
|
||||||
|
/// - A [`String`] representing the actual MIME type that was used
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns errors if:
|
||||||
|
///
|
||||||
|
/// - Clipboard access fails
|
||||||
|
/// - MIME type negotiation fails
|
||||||
|
/// - Content reading fails
|
||||||
|
fn get_contents(
|
||||||
|
clipboard: ClipboardType,
|
||||||
|
seat: Seat,
|
||||||
|
types_preferred: &[String],
|
||||||
|
detection_smart: bool,
|
||||||
|
) -> Result<(Box<dyn std::io::Read>, String), Box<dyn std::error::Error>> {
|
||||||
|
if !types_preferred.is_empty() && detection_smart {
|
||||||
|
match get_mime_types(clipboard, seat) {
|
||||||
|
Ok(types) => {
|
||||||
|
for preferred in types_preferred {
|
||||||
|
// Handle wildcards (e.g., "image/*")
|
||||||
|
if preferred.ends_with("/*") {
|
||||||
|
let prefix = &preferred[..preferred.len() - 2];
|
||||||
|
for mime_type in &types {
|
||||||
|
if mime_type.starts_with(prefix) {
|
||||||
|
let mime_str = mime_type.clone();
|
||||||
|
let (reader, _) = wl_get_contents(
|
||||||
|
clipboard,
|
||||||
|
seat,
|
||||||
|
MimeType::Specific(&mime_str),
|
||||||
|
)?;
|
||||||
|
return Ok((
|
||||||
|
Box::new(reader) as Box<dyn std::io::Read>,
|
||||||
|
mime_str,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Exact match
|
||||||
|
if types.contains(preferred) {
|
||||||
|
let (reader, _) = wl_get_contents(
|
||||||
|
clipboard,
|
||||||
|
seat,
|
||||||
|
MimeType::Specific(preferred),
|
||||||
|
)?;
|
||||||
|
return Ok((
|
||||||
|
Box::new(reader) as Box<dyn std::io::Read>,
|
||||||
|
preferred.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
// Fall back to regular behavior if mime type query fails
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if detection_smart {
|
||||||
|
// Default for "smart" detection:
|
||||||
|
// prioritize images > text/plain > other text > other
|
||||||
|
// It is as smart as I am, and to be honest, that's not very smart
|
||||||
|
match get_mime_types(clipboard, seat) {
|
||||||
|
Ok(types) => {
|
||||||
|
// Priority order: images > text/plain > other text > other
|
||||||
|
for mime_type in &types {
|
||||||
|
if mime_type.starts_with("image/") {
|
||||||
|
let mime_str = mime_type.clone();
|
||||||
|
let (reader, _) =
|
||||||
|
wl_get_contents(clipboard, seat, MimeType::Specific(&mime_str))?;
|
||||||
|
return Ok((Box::new(reader) as Box<dyn std::io::Read>, mime_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if types.contains("text/plain") {
|
||||||
|
let (reader, _) = wl_get_contents(clipboard, seat, MimeType::Text)?;
|
||||||
|
return Ok((
|
||||||
|
Box::new(reader) as Box<dyn std::io::Read>,
|
||||||
|
"text/plain".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for mime_type in &types {
|
||||||
|
if mime_type.starts_with("text/") {
|
||||||
|
let mime_str = mime_type.clone();
|
||||||
|
let (reader, _) =
|
||||||
|
wl_get_contents(clipboard, seat, MimeType::Specific(&mime_str))?;
|
||||||
|
return Ok((Box::new(reader) as Box<dyn std::io::Read>, mime_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to first available
|
||||||
|
if let Some(first_type) = types.iter().next() {
|
||||||
|
let mime_str = first_type.clone();
|
||||||
|
let (reader, _) =
|
||||||
|
wl_get_contents(clipboard, seat, MimeType::Specific(&mime_str))?;
|
||||||
|
return Ok((Box::new(reader) as Box<dyn std::io::Read>, mime_str));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
// Fall back to regular behavior if mime type query fails
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to `Any` if smart detection is disabled or fails
|
||||||
|
let (reader, _) = wl_get_contents(clipboard, seat, MimeType::Any)?;
|
||||||
|
Ok((
|
||||||
|
Box::new(reader) as Box<dyn std::io::Read>,
|
||||||
|
"application/octet-stream".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
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],
|
||||||
|
preferred_types: &[String],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,9 +164,10 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
max_dedupe_search: u64,
|
max_dedupe_search: u64,
|
||||||
max_items: u64,
|
max_items: u64,
|
||||||
excluded_apps: &[String],
|
excluded_apps: &[String],
|
||||||
|
preferred_types: &[String],
|
||||||
) {
|
) {
|
||||||
smol::block_on(async {
|
smol::block_on(async {
|
||||||
log::info!("Starting clipboard watch daemon");
|
log::info!("starting clipboard watch daemon");
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
@ -44,7 +184,8 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
if let Ok((mut reader, _)) = get_contents(
|
if let Ok((mut reader, _)) = get_contents(
|
||||||
ClipboardType::Regular,
|
ClipboardType::Regular,
|
||||||
Seat::Unspecified,
|
Seat::Unspecified,
|
||||||
wl_clipboard_rs::paste::MimeType::Any,
|
preferred_types,
|
||||||
|
true, // enable smart detection
|
||||||
) {
|
) {
|
||||||
buf.clear();
|
buf.clear();
|
||||||
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
||||||
|
|
@ -56,12 +197,13 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
match get_contents(
|
match get_contents(
|
||||||
ClipboardType::Regular,
|
ClipboardType::Regular,
|
||||||
Seat::Unspecified,
|
Seat::Unspecified,
|
||||||
wl_clipboard_rs::paste::MimeType::Any,
|
preferred_types,
|
||||||
|
true, // enable smart detection
|
||||||
) {
|
) {
|
||||||
Ok((mut reader, _mime_type)) => {
|
Ok((mut reader, _mime_type)) => {
|
||||||
buf.clear();
|
buf.clear();
|
||||||
if let Err(e) = reader.read_to_end(&mut buf) {
|
if let Err(e) = reader.read_to_end(&mut buf) {
|
||||||
log::error!("Failed to read clipboard contents: {e}");
|
log::error!("failed to read clipboard contents: {e}");
|
||||||
Timer::after(Duration::from_millis(500)).await;
|
Timer::after(Duration::from_millis(500)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -78,17 +220,17 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
Some(excluded_apps),
|
Some(excluded_apps),
|
||||||
) {
|
) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
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);
|
||||||
},
|
},
|
||||||
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");
|
||||||
last_hash = Some(current_hash);
|
last_hash = Some(current_hash);
|
||||||
},
|
},
|
||||||
Err(crate::db::StashError::Store(ref msg))
|
Err(crate::db::StashError::Store(ref msg))
|
||||||
if msg.contains("Excluded by app filter") =>
|
if msg.contains("excluded by app filter") =>
|
||||||
{
|
{
|
||||||
log::info!("Clipboard entry excluded by app filter");
|
log::info!("clipboard entry excluded by app filter");
|
||||||
last_hash = Some(current_hash);
|
last_hash = Some(current_hash);
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -102,7 +244,7 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_msg = e.to_string();
|
let error_msg = e.to_string();
|
||||||
if !error_msg.contains("empty") {
|
if !error_msg.contains("empty") {
|
||||||
log::error!("Failed to get clipboard contents: {e}");
|
log::error!("failed to get clipboard contents: {e}");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
src/main.rs
18
src/main.rs
|
|
@ -99,10 +99,11 @@ enum Command {
|
||||||
ask: bool,
|
ask: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Import clipboard data from stdin (default: TSV format)
|
/// Import clipboard data from stdin
|
||||||
Import {
|
Import {
|
||||||
/// Explicitly specify format: "tsv" (default)
|
/// Type of the imported data. Only TSV is supported for the time being,
|
||||||
#[arg(long, value_parser = ["tsv"])]
|
/// which is backwards compatible with Cliphist's export format.
|
||||||
|
#[arg(long, value_parser = ["tsv"], default_value = "tsv")]
|
||||||
r#type: Option<String>,
|
r#type: Option<String>,
|
||||||
|
|
||||||
/// Ask for confirmation before importing
|
/// Ask for confirmation before importing
|
||||||
|
|
@ -111,7 +112,12 @@ 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 {
|
||||||
|
/// Comma-separated list of MIME types to prioritize (e.g.,
|
||||||
|
/// "image/*,text/plain")
|
||||||
|
#[arg(long, value_delimiter = ',')]
|
||||||
|
types: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report_error<T>(
|
fn report_error<T>(
|
||||||
|
|
@ -334,7 +340,8 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(Command::Watch) => {
|
|
||||||
|
Some(Command::Watch { types }) => {
|
||||||
db.watch(
|
db.watch(
|
||||||
cli.max_dedupe_search,
|
cli.max_dedupe_search,
|
||||||
cli.max_items,
|
cli.max_items,
|
||||||
|
|
@ -342,6 +349,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
&cli.excluded_apps,
|
&cli.excluded_apps,
|
||||||
#[cfg(not(feature = "use-toplevel"))]
|
#[cfg(not(feature = "use-toplevel"))]
|
||||||
&[],
|
&[],
|
||||||
|
&types,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue