//! Folder monitor — watches a directory for new files and emits //! `folder-monitor-new-files` with absolute paths to the renderer. //! //! Reflects the v1 `lib/folder-monitor.js` design: debounced notify events, //! extension include/exclude filter, skip-duplicates against a seen-set, //! optional recursive watch, initial scan on start. use notify::{EventKind, RecursiveMode, Watcher}; use notify_debouncer_full::{new_debouncer, DebouncedEvent, DebounceEventResult}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tauri::{AppHandle, Emitter}; use tokio::sync::mpsc; #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct FolderMonitorSettings { pub enabled: bool, pub folder_path: String, #[serde(default)] pub recursive: bool, #[serde(default = "default_filter_mode")] pub filter_mode: String, #[serde(default)] pub extensions: String, #[serde(default = "truthy")] pub skip_duplicates: bool, #[serde(default = "default_delay")] pub delay_sec: u64, } fn default_filter_mode() -> String { "include".into() } fn truthy() -> bool { true } fn default_delay() -> u64 { 3 } pub struct FolderMonitor { app: AppHandle, stop_tx: Mutex>>, current: Mutex>, seen: Arc>>, } impl FolderMonitor { pub fn new(app: AppHandle) -> Self { Self { app, stop_tx: Mutex::new(None), current: Mutex::new(None), seen: Arc::new(Mutex::new(HashSet::new())), } } pub fn is_running(&self) -> bool { self.stop_tx.lock().is_some() } pub fn current(&self) -> Option { self.current.lock().clone() } pub async fn start(&self, settings: FolderMonitorSettings) -> Result<(), String> { self.stop().await; if !settings.enabled || settings.folder_path.is_empty() { return Ok(()); } let root = PathBuf::from(&settings.folder_path); if !root.exists() { return Err(format!("Ordner existiert nicht: {}", root.display())); } let recursive = if settings.recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive }; let extensions = parse_extensions(&settings.extensions); let filter_include = settings.filter_mode == "include"; let skip_dup = settings.skip_duplicates; let delay = Duration::from_secs(settings.delay_sec.max(1)); let seen = self.seen.clone(); // Seed baseline so existing files don't get queued on startup. if skip_dup { let mut s = seen.lock(); s.clear(); walk_collect(&root, settings.recursive, &mut s); } let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1); let (event_tx, mut event_rx) = mpsc::channel::>(32); let app = self.app.clone(); tokio::spawn(async move { while let Some(paths) = event_rx.recv().await { if paths.is_empty() { continue; } let filtered: Vec = paths.into_iter() .filter(|p| path_matches(p, &extensions, filter_include)) .filter_map(|p| p.to_str().map(|s| s.to_string())) .collect(); if filtered.is_empty() { continue; } let _ = app.emit("folder-monitor-new-files", filtered); } }); let event_tx_cloned = event_tx.clone(); let seen_cloned = seen.clone(); tokio::task::spawn_blocking(move || { let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::(); let mut debouncer = match new_debouncer( delay, None, move |res: DebounceEventResult| { let _ = debounce_tx.send(res); }, ) { Ok(d) => d, Err(e) => { tracing::error!("folder-monitor debouncer: {e}"); return; } }; if let Err(e) = debouncer.watch(&root, recursive) { tracing::error!("folder-monitor watch: {e}"); return; } loop { match debounce_rx.recv_timeout(Duration::from_millis(250)) { Ok(Ok(events)) => { let mut new_paths = Vec::new(); for ev in events { if matches!(ev.event.kind, EventKind::Create(_) | EventKind::Modify(_)) { for p in &ev.paths { if p.is_file() { let mut s = seen_cloned.lock(); if skip_dup && !s.insert(p.clone()) { continue; } new_paths.push(p.clone()); } } } } if !new_paths.is_empty() { let _ = event_tx_cloned.blocking_send(new_paths); } } Ok(Err(_)) => {} Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { if stop_rx.try_recv().is_ok() { break; } } Err(_) => break, } } }); *self.stop_tx.lock() = Some(stop_tx); *self.current.lock() = Some(settings); Ok(()) } pub async fn stop(&self) { let tx = self.stop_tx.lock().take(); if let Some(tx) = tx { let _ = tx.send(()).await; } *self.current.lock() = None; } } fn parse_extensions(s: &str) -> Vec { s.split(',') .map(|x| x.trim().trim_start_matches('.').to_lowercase()) .filter(|x| !x.is_empty()) .collect() } fn path_matches(p: &Path, extensions: &[String], include: bool) -> bool { if extensions.is_empty() { return true; } let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).unwrap_or_default(); let listed = extensions.iter().any(|e| *e == ext); if include { listed } else { !listed } } fn walk_collect(dir: &Path, recursive: bool, out: &mut HashSet) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for entry in rd.flatten() { let p = entry.path(); if p.is_file() { out.insert(p); } else if p.is_dir() && recursive { walk_collect(&p, recursive, out); } } }