watch: allow smarter mimetype detection

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I082de33646e0260b3113eeb4f5a77c6e6a6a6964
This commit is contained in:
raf 2025-10-28 13:10:54 +03:00
commit 340d02d09a
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
2 changed files with 165 additions and 15 deletions

View file

@ -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}");
} }
}, },
} }

View file

@ -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,
); );
}, },