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 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};
/// 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 {
fn watch(
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
preferred_types: &[String],
);
}
@ -25,9 +164,10 @@ impl WatchCommand for SqliteClipboardDb {
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
preferred_types: &[String],
) {
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
let mut last_hash: Option<u64> = None;
@ -44,7 +184,8 @@ impl WatchCommand for SqliteClipboardDb {
if let Ok((mut reader, _)) = get_contents(
ClipboardType::Regular,
Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any,
preferred_types,
true, // enable smart detection
) {
buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
@ -56,12 +197,13 @@ impl WatchCommand for SqliteClipboardDb {
match get_contents(
ClipboardType::Regular,
Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any,
preferred_types,
true, // enable smart detection
) {
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}");
log::error!("failed to read clipboard contents: {e}");
Timer::after(Duration::from_millis(500)).await;
continue;
}
@ -78,17 +220,17 @@ impl WatchCommand for SqliteClipboardDb {
Some(excluded_apps),
) {
Ok(_) => {
log::info!("Stored new clipboard entry (id: {id})");
log::info!("stored new clipboard entry (id: {id})");
last_hash = Some(current_hash);
},
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);
},
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);
},
Err(e) => {
@ -102,7 +244,7 @@ impl WatchCommand for SqliteClipboardDb {
Err(e) => {
let error_msg = e.to_string();
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,
},
/// Import clipboard data from stdin (default: TSV format)
/// Import clipboard data from stdin
Import {
/// Explicitly specify format: "tsv" (default)
#[arg(long, value_parser = ["tsv"])]
/// Type of the imported data. Only TSV is supported for the time being,
/// which is backwards compatible with Cliphist's export format.
#[arg(long, value_parser = ["tsv"], default_value = "tsv")]
r#type: Option<String>,
/// Ask for confirmation before importing
@ -111,7 +112,12 @@ enum Command {
},
/// 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>(
@ -334,7 +340,8 @@ fn main() -> color_eyre::eyre::Result<()> {
}
}
},
Some(Command::Watch) => {
Some(Command::Watch { types }) => {
db.watch(
cli.max_dedupe_search,
cli.max_items,
@ -342,6 +349,7 @@ fn main() -> color_eyre::eyre::Result<()> {
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
&types,
);
},