diff --git a/Cargo.lock b/Cargo.lock index e975510..d855876 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,6 +580,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -608,6 +620,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1192,7 +1210,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1200,6 +1218,18 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "heck" @@ -1557,6 +1587,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1841,6 +1882,7 @@ dependencies = [ "log", "notify-rust", "reqwest", + "rusqlite", "serde", "serde_json", "signal-hook", @@ -2087,6 +2129,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2282,6 +2349,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/crates/owlry/Cargo.toml b/crates/owlry/Cargo.toml index fe0a3de..a88033d 100644 --- a/crates/owlry/Cargo.toml +++ b/crates/owlry/Cargo.toml @@ -60,6 +60,9 @@ signal-hook = "0.3" expr-solver-lib = "1" reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] } +# Optional providers (gated by cargo features below) +rusqlite = { version = "0.39", features = ["bundled"], optional = true } + # Async oneshot channel (background thread -> main loop) futures-channel = "0.3" @@ -78,6 +81,20 @@ dev-logging = [] # Optional providers (compiled in when enabled). # The AUR PKGBUILD builds with --features "full" to ship everything. +bookmarks = ["dep:rusqlite"] +clipboard = [] +emoji = [] +filesearch = [] +ssh = [] systemd = [] +websearch = [] -full = ["systemd"] +full = [ + "bookmarks", + "clipboard", + "emoji", + "filesearch", + "ssh", + "systemd", + "websearch", +] diff --git a/crates/owlry/src/config/mod.rs b/crates/owlry/src/config/mod.rs index f70622c..48d158a 100644 --- a/crates/owlry/src/config/mod.rs +++ b/crates/owlry/src/config/mod.rs @@ -174,6 +174,24 @@ pub struct ProvidersConfig { /// Enable systemd user units provider (alias: uuctl) #[serde(default = "default_true", alias = "uuctl")] pub systemd: bool, + /// Enable bookmarks provider (Firefox + Chromium) + #[serde(default = "default_true")] + pub bookmarks: bool, + /// Enable clipboard history provider (requires cliphist) + #[serde(default = "default_true")] + pub clipboard: bool, + /// Enable emoji picker provider + #[serde(default = "default_true")] + pub emoji: bool, + /// Enable filesystem search provider (uses fd or mlocate) + #[serde(default = "default_true")] + pub filesearch: bool, + /// Enable SSH host provider (parses ~/.ssh/config) + #[serde(default = "default_true")] + pub ssh: bool, + /// Enable web search provider (DuckDuckGo / configurable) + #[serde(default = "default_true")] + pub websearch: bool, /// Enable frecency-based result ranking #[serde(default = "default_true")] pub frecency: bool, @@ -196,6 +214,12 @@ impl Default for ProvidersConfig { converter: true, system: true, systemd: true, + bookmarks: true, + clipboard: true, + emoji: true, + filesearch: true, + ssh: true, + websearch: true, frecency: true, frecency_weight: 0.3, search_engine: "duckduckgo".to_string(), diff --git a/crates/owlry/src/providers/bookmarks.rs b/crates/owlry/src/providers/bookmarks.rs new file mode 100644 index 0000000..12e8908 --- /dev/null +++ b/crates/owlry/src/providers/bookmarks.rs @@ -0,0 +1,469 @@ +//! Browser bookmarks provider. +//! +//! Reads bookmarks from Firefox (`places.sqlite` via `rusqlite`) and +//! Chromium-family browsers (Chrome, Chromium, Brave, Edge — JSON +//! `Bookmarks` files). Items launch via `xdg-open `. +//! +//! This is a static provider: `refresh()` populates `self.items`, and the +//! core fuzzy-matches against the cached list. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; +use rusqlite::{Connection, OpenFlags}; +use serde::Deserialize; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const TYPE_ID: &str = "bookmarks"; +const ICON: &str = "user-bookmarks-symbolic"; + +pub struct BookmarksProvider { + items: Vec, +} + +impl Default for BookmarksProvider { + fn default() -> Self { + Self::new() + } +} + +impl BookmarksProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + /// Cache directory for downloaded Firefox favicons. + fn favicon_cache_dir() -> Option { + dirs::cache_dir().map(|d| d.join("owlry/favicons")) + } + + fn ensure_favicon_cache_dir() -> Option { + Self::favicon_cache_dir().and_then(|dir| { + fs::create_dir_all(&dir).ok()?; + Some(dir) + }) + } + + /// Hash a URL into a stable cache filename. Collisions are acceptable — + /// the worst case is a favicon shown for the wrong bookmark. + fn url_to_cache_filename(url: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut hasher); + format!("{:016x}.png", hasher.finish()) + } + + fn chromium_bookmark_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(config_dir) = dirs::config_dir() { + // Chrome + paths.push(config_dir.join("google-chrome/Default/Bookmarks")); + paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks")); + // Chromium + paths.push(config_dir.join("chromium/Default/Bookmarks")); + // Brave + paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks")); + // Edge + paths.push(config_dir.join("microsoft-edge/Default/Bookmarks")); + } + paths + } + + fn firefox_places_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + let firefox_dir = home.join(".mozilla/firefox"); + if firefox_dir.exists() + && let Ok(entries) = fs::read_dir(&firefox_dir) + { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let places = path.join("places.sqlite"); + if places.exists() { + paths.push(places); + } + } + } + } + } + paths + } + + /// Sibling `favicons.sqlite` next to a given `places.sqlite`, if present. + fn firefox_favicons_path(places_path: &Path) -> Option { + let favicons = places_path.parent()?.join("favicons.sqlite"); + if favicons.exists() { + Some(favicons) + } else { + None + } + } + + fn read_chrome_bookmarks(path: &Path, items: &mut Vec) { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return, + }; + + let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) { + Ok(b) => b, + Err(_) => return, + }; + + if let Some(roots) = bookmarks.roots { + if let Some(bar) = roots.bookmark_bar { + Self::process_chrome_folder(&bar, items); + } + if let Some(other) = roots.other { + Self::process_chrome_folder(&other, items); + } + if let Some(synced) = roots.synced { + Self::process_chrome_folder(&synced, items); + } + } + } + + fn process_chrome_folder(folder: &ChromeBookmarkNode, items: &mut Vec) { + if let Some(ref children) = folder.children { + for child in children { + match child.node_type.as_deref() { + Some("url") => { + if let Some(ref url) = child.url { + let name = child.name.clone().unwrap_or_else(|| url.clone()); + items.push(LaunchItem { + id: format!("bookmark:{}", url), + name, + description: Some(url.clone()), + icon: Some(ICON.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command: format!("xdg-open '{}'", url.replace('\'', "'\\''")), + terminal: false, + tags: vec!["bookmark".to_string(), "chrome".to_string()], + source: ItemSource::Core, + }); + } + } + Some("folder") => { + Self::process_chrome_folder(child, items); + } + _ => {} + } + } + } + } + + /// Read Firefox bookmarks via `rusqlite`. Copies the DB (+ WAL) into a + /// temp file first so we can open in read-only mode without contesting + /// the lock Firefox holds while running. + fn read_firefox_bookmarks(places_path: &Path, items: &mut Vec) { + let temp_dir = std::env::temp_dir(); + let pid = std::process::id(); + let temp_db = temp_dir.join(format!("owlry_places_{}.sqlite", pid)); + + if fs::copy(places_path, &temp_db).is_err() { + return; + } + + let wal_path = places_path.with_extension("sqlite-wal"); + if wal_path.exists() { + let temp_wal = temp_db.with_extension("sqlite-wal"); + let _ = fs::copy(&wal_path, &temp_wal); + } + + let favicons_path = Self::firefox_favicons_path(places_path); + let temp_favicons = temp_dir.join(format!("owlry_favicons_{}.sqlite", pid)); + if let Some(ref fp) = favicons_path { + let _ = fs::copy(fp, &temp_favicons); + let fav_wal = fp.with_extension("sqlite-wal"); + if fav_wal.exists() { + let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal")); + } + } + + let cache_dir = Self::ensure_favicon_cache_dir(); + let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref()); + + let _ = fs::remove_file(&temp_db); + let _ = fs::remove_file(temp_db.with_extension("sqlite-wal")); + let _ = fs::remove_file(&temp_favicons); + let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal")); + + for (title, url, favicon_path) in bookmarks { + let icon = favicon_path.unwrap_or_else(|| ICON.to_string()); + items.push(LaunchItem { + id: format!("bookmark:firefox:{}", url), + name: title, + description: Some(url.clone()), + icon: Some(icon), + provider: ProviderType::Plugin(TYPE_ID.into()), + command: format!("xdg-open '{}'", url.replace('\'', "'\\''")), + terminal: false, + tags: vec!["bookmark".to_string(), "firefox".to_string()], + source: ItemSource::Core, + }); + } + } + + /// Run the bookmark SELECT against `places.sqlite` and, if available, + /// fetch + cache favicons from `favicons.sqlite`. + fn fetch_firefox_bookmarks( + places_path: &Path, + favicons_path: &Path, + cache_dir: Option<&PathBuf>, + ) -> Vec<(String, String, Option)> { + let conn = match Connection::open_with_flags( + places_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + // type=1 means URL bookmarks (not folders, separators, etc.). We + // skip Firefox-internal `place:` and `about:` URLs. + let query = r#" + SELECT b.title, p.url + FROM moz_bookmarks b + JOIN moz_places p ON b.fk = p.id + WHERE b.type = 1 + AND p.url NOT LIKE 'place:%' + AND p.url NOT LIKE 'about:%' + AND b.title IS NOT NULL + AND b.title != '' + ORDER BY b.dateAdded DESC + LIMIT 500 + "#; + + let mut stmt = match conn.prepare(query) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + + let bookmarks: Vec<(String, String)> = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .ok() + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default(); + + let cache_dir = match cache_dir { + Some(c) => c, + None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), + }; + + let fav_conn = match Connection::open_with_flags( + favicons_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + Ok(c) => c, + Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(), + }; + + let mut results = Vec::new(); + for (title, url) in bookmarks { + let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir); + results.push((title, url, favicon_path)); + } + + results + } + + /// Look up a favicon for `page_url` in Firefox's `favicons.sqlite`, writing + /// the blob to the cache directory on first hit. Returns the cached path + /// (or None if no favicon / write failed). + fn get_favicon_for_url(conn: &Connection, page_url: &str, cache_dir: &Path) -> Option { + let cache_filename = Self::url_to_cache_filename(page_url); + let cache_path = cache_dir.join(&cache_filename); + if cache_path.exists() { + return Some(cache_path.to_string_lossy().to_string()); + } + + // Prefer the 32px-ish icon if multiple sizes exist. + let query = r#" + SELECT i.data + FROM moz_pages_w_icons p + JOIN moz_icons_to_pages ip ON p.id = ip.page_id + JOIN moz_icons i ON ip.icon_id = i.id + WHERE p.page_url = ? + AND i.data IS NOT NULL + ORDER BY ABS(i.width - 32) ASC + LIMIT 1 + "#; + + let data: Option> = conn.query_row(query, [page_url], |row| row.get(0)).ok(); + + let data = data?; + if data.is_empty() { + return None; + } + + let mut file = fs::File::create(&cache_path).ok()?; + file.write_all(&data).ok()?; + + Some(cache_path.to_string_lossy().to_string()) + } +} + +impl Provider for BookmarksProvider { + fn name(&self) -> &str { + "Bookmarks" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.items.clear(); + + for path in Self::chromium_bookmark_paths() { + if path.exists() { + Self::read_chrome_bookmarks(&path, &mut self.items); + } + } + + for path in Self::firefox_places_paths() { + Self::read_firefox_bookmarks(&path, &mut self.items); + } + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":bm") + } + + fn icon(&self) -> &str { + ICON + } + + fn tab_label(&self) -> Option<&str> { + Some("Bookmarks") + } + + fn search_noun(&self) -> Option<&str> { + Some("bookmarks") + } +} + +// Chrome's `Bookmarks` JSON layout. +#[derive(Debug, Deserialize)] +struct ChromeBookmarks { + roots: Option, +} + +#[derive(Debug, Deserialize)] +struct ChromeBookmarkRoots { + bookmark_bar: Option, + other: Option, + synced: Option, +} + +#[derive(Debug, Deserialize)] +struct ChromeBookmarkNode { + name: Option, + url: Option, + #[serde(rename = "type")] + node_type: Option, + children: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bookmarks_state_new() { + let p = BookmarksProvider::new(); + assert!(p.items().is_empty()); + } + + #[test] + fn test_chromium_paths() { + let paths = BookmarksProvider::chromium_bookmark_paths(); + // Should have at least some paths configured (5 known browsers). + assert!(!paths.is_empty()); + } + + #[test] + fn test_firefox_paths() { + // Path detection should not panic, even if Firefox isn't installed. + let paths = BookmarksProvider::firefox_places_paths(); + let _ = paths.len(); + } + + #[test] + fn test_parse_chrome_bookmarks() { + let json = r#"{ + "roots": { + "bookmark_bar": { + "type": "folder", + "children": [ + { + "type": "url", + "name": "Example", + "url": "https://example.com" + } + ] + } + } + }"#; + + let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap(); + assert!(bookmarks.roots.is_some()); + + let roots = bookmarks.roots.unwrap(); + assert!(roots.bookmark_bar.is_some()); + + let bar = roots.bookmark_bar.unwrap(); + assert!(bar.children.is_some()); + assert_eq!(bar.children.unwrap().len(), 1); + } + + #[test] + fn test_process_folder() { + let mut items = Vec::new(); + + let folder = ChromeBookmarkNode { + name: Some("Test Folder".to_string()), + url: None, + node_type: Some("folder".to_string()), + children: Some(vec![ChromeBookmarkNode { + name: Some("Test Bookmark".to_string()), + url: Some("https://test.com".to_string()), + node_type: Some("url".to_string()), + children: None, + }]), + }; + + BookmarksProvider::process_chrome_folder(&folder, &mut items); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "Test Bookmark"); + assert_eq!(items[0].provider, ProviderType::Plugin("bookmarks".into())); + assert_eq!(items[0].source, ItemSource::Core); + assert!(items[0].command.starts_with("xdg-open '")); + } + + #[test] + fn test_url_escaping() { + let url = "https://example.com/path?query='test'"; + let command = format!("xdg-open '{}'", url.replace('\'', "'\\''")); + assert!(command.contains("'\\''")); + } + + #[test] + fn provider_type_is_bookmarks_plugin() { + let p = BookmarksProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("bookmarks".into())); + } + + #[test] + fn provider_prefix_is_bm() { + let p = BookmarksProvider::new(); + assert_eq!(p.prefix(), Some(":bm")); + } +} diff --git a/crates/owlry/src/providers/clipboard.rs b/crates/owlry/src/providers/clipboard.rs new file mode 100644 index 0000000..3e54d3f --- /dev/null +++ b/crates/owlry/src/providers/clipboard.rs @@ -0,0 +1,249 @@ +//! Clipboard history provider. +//! +//! Static provider that surfaces `cliphist` entries as launch items. Selecting +//! an item runs `cliphist decode | wl-copy` to put the entry back on the +//! clipboard. Requires `cliphist` and `wl-clipboard` to be installed. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; +use std::process::Command; + +const TYPE_ID: &str = "clipboard"; +const PROVIDER_ICON: &str = "edit-paste"; +const DEFAULT_MAX_ENTRIES: usize = 50; + +pub struct ClipboardProvider { + items: Vec, + max_entries: usize, +} + +impl Default for ClipboardProvider { + fn default() -> Self { + Self::new() + } +} + +impl ClipboardProvider { + pub fn new() -> Self { + Self { + items: Vec::new(), + max_entries: DEFAULT_MAX_ENTRIES, + } + } + + fn has_cliphist() -> bool { + Command::new("which") + .arg("cliphist") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn parse_cliphist_output(output: &str, max_entries: usize) -> Vec { + let mut items = Vec::new(); + + for (idx, line) in output.lines().take(max_entries).enumerate() { + // cliphist format: "id\tpreview" + let parts: Vec<&str> = line.splitn(2, '\t').collect(); + + if parts.is_empty() { + continue; + } + + let clip_id = parts[0]; + let preview = if parts.len() > 1 { + // Truncate long previews (char-safe for UTF-8) + let p = parts[1]; + if p.chars().count() > 80 { + let truncated: String = p.chars().take(77).collect(); + format!("{}...", truncated) + } else { + p.to_string() + } + } else { + "[binary data]".to_string() + }; + + // Clean up preview - replace newlines/tabs with spaces, strip CR. + let preview_clean = preview + .replace('\n', " ") + .replace('\r', "") + .replace('\t', " "); + + // Command to paste this entry: echo "id" | cliphist decode | wl-copy + let command = format!( + "echo '{}' | cliphist decode | wl-copy", + clip_id.replace('\'', "'\\''") + ); + + items.push(LaunchItem { + id: format!("clipboard:{}", idx), + name: preview_clean, + description: Some("Copy to clipboard".to_string()), + icon: Some(PROVIDER_ICON.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command, + terminal: false, + tags: vec!["clipboard".to_string()], + source: ItemSource::Core, + }); + } + + items + } +} + +impl Provider for ClipboardProvider { + fn name(&self) -> &str { + "Clipboard" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.items.clear(); + + if !Self::has_cliphist() { + return; + } + + let output = match Command::new("cliphist").arg("list").output() { + Ok(o) if o.status.success() => o, + _ => return, + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + self.items = Self::parse_cliphist_output(&stdout, self.max_entries); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":clip") + } + + fn icon(&self) -> &str { + PROVIDER_ICON + } + + fn tab_label(&self) -> Option<&str> { + Some("Clipboard") + } + + fn search_noun(&self) -> Option<&str> { + Some("clipboard entries") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clipboard_provider_new() { + let p = ClipboardProvider::new(); + assert!(p.items.is_empty()); + assert_eq!(p.max_entries, DEFAULT_MAX_ENTRIES); + } + + #[test] + fn test_preview_truncation() { + // Long ASCII strings get truncated char-safely to 80 chars with "..." suffix. + let long_text = "a".repeat(100); + let truncated = if long_text.chars().count() > 80 { + let t: String = long_text.chars().take(77).collect(); + format!("{}...", t) + } else { + long_text.clone() + }; + assert_eq!(truncated.chars().count(), 80); + assert!(truncated.ends_with("...")); + } + + #[test] + fn test_preview_truncation_utf8() { + // Multi-byte UTF-8 chars must not split mid-codepoint. + let utf8_text = "├── ".repeat(30); + let truncated = if utf8_text.chars().count() > 80 { + let t: String = utf8_text.chars().take(77).collect(); + format!("{}...", t) + } else { + utf8_text.clone() + }; + assert_eq!(truncated.chars().count(), 80); + assert!(truncated.ends_with("...")); + } + + #[test] + fn test_preview_cleaning() { + let dirty = "line1\nline2\tcolumn\rend"; + let clean = dirty + .replace('\n', " ") + .replace('\r', "") + .replace('\t', " "); + assert_eq!(clean, "line1 line2 columnend"); + } + + #[test] + fn test_command_escaping() { + let clip_id = "test'id"; + let command = format!( + "echo '{}' | cliphist decode | wl-copy", + clip_id.replace('\'', "'\\''") + ); + assert!(command.contains("test'\\''id")); + } + + #[test] + fn test_has_cliphist_runs() { + // Just ensure it doesn't panic — cliphist may or may not be installed. + let _ = ClipboardProvider::has_cliphist(); + } + + #[test] + fn parse_cliphist_output_builds_items_with_plugin_provider_type() { + let output = "1\thello world\n2\tanother entry\n"; + let items = ClipboardProvider::parse_cliphist_output(output, DEFAULT_MAX_ENTRIES); + assert_eq!(items.len(), 2); + assert_eq!(items[0].name, "hello world"); + assert_eq!(items[0].provider, ProviderType::Plugin("clipboard".into())); + assert_eq!(items[0].source, ItemSource::Core); + assert!(items[0].command.contains("cliphist decode")); + assert!(items[0].command.contains("wl-copy")); + assert!(!items[0].terminal); + } + + #[test] + fn parse_cliphist_output_respects_max_entries() { + let output = "1\ta\n2\tb\n3\tc\n4\td\n"; + let items = ClipboardProvider::parse_cliphist_output(output, 2); + assert_eq!(items.len(), 2); + } + + #[test] + fn parse_cliphist_output_handles_missing_preview_as_binary() { + let output = "42\n"; + let items = ClipboardProvider::parse_cliphist_output(output, DEFAULT_MAX_ENTRIES); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "[binary data]"); + } + + #[test] + fn provider_type_is_clipboard_plugin() { + // CLI back-compat: `-m clip` / `:clip` resolve to this type_id. + let p = ClipboardProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("clipboard".into())); + } + + #[test] + fn provider_metadata_exposes_clip_prefix_and_labels() { + let p = ClipboardProvider::new(); + assert_eq!(p.prefix(), Some(":clip")); + assert_eq!(p.icon(), "edit-paste"); + assert_eq!(p.tab_label(), Some("Clipboard")); + assert_eq!(p.search_noun(), Some("clipboard entries")); + } +} diff --git a/crates/owlry/src/providers/emoji.rs b/crates/owlry/src/providers/emoji.rs new file mode 100644 index 0000000..ff22749 --- /dev/null +++ b/crates/owlry/src/providers/emoji.rs @@ -0,0 +1,518 @@ +//! Emoji provider. +//! +//! Static provider exposing a curated set of emojis. Selecting an item copies +//! the emoji glyph to the Wayland clipboard via `wl-copy`. Search matches the +//! human-readable name and the keyword bag. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; + +const TYPE_ID: &str = "emoji"; + +/// (emoji, name, space-separated keywords) +const EMOJIS: &[(&str, &str, &str)] = &[ + // Smileys & Emotion + ("😀", "grinning face", "smile happy"), + ("😃", "grinning face with big eyes", "smile happy"), + ("😄", "grinning face with smiling eyes", "smile happy laugh"), + ("😁", "beaming face with smiling eyes", "smile happy grin"), + ("😅", "grinning face with sweat", "smile nervous"), + ("🤣", "rolling on the floor laughing", "lol rofl funny"), + ("😂", "face with tears of joy", "laugh cry funny lol"), + ("🙂", "slightly smiling face", "smile"), + ("😊", "smiling face with smiling eyes", "blush happy"), + ("😇", "smiling face with halo", "angel innocent"), + ("🥰", "smiling face with hearts", "love adore"), + ("😍", "smiling face with heart-eyes", "love crush"), + ("🤩", "star-struck", "excited wow amazing"), + ("😘", "face blowing a kiss", "kiss love"), + ("😜", "winking face with tongue", "playful silly"), + ("🤪", "zany face", "crazy silly wild"), + ("😎", "smiling face with sunglasses", "cool"), + ("🤓", "nerd face", "geek glasses"), + ("🧐", "face with monocle", "thinking inspect"), + ("😏", "smirking face", "smug"), + ("😒", "unamused face", "meh annoyed"), + ("🙄", "face with rolling eyes", "whatever annoyed"), + ("😬", "grimacing face", "awkward nervous"), + ("😮‍💨", "face exhaling", "sigh relief"), + ("🤥", "lying face", "pinocchio lie"), + ("😌", "relieved face", "relaxed peaceful"), + ("😔", "pensive face", "sad thoughtful"), + ("😪", "sleepy face", "tired"), + ("🤤", "drooling face", "hungry yummy"), + ("😴", "sleeping face", "zzz tired"), + ("😷", "face with medical mask", "sick covid"), + ("🤒", "face with thermometer", "sick fever"), + ("🤕", "face with head-bandage", "hurt injured"), + ("🤢", "nauseated face", "sick gross"), + ("🤮", "face vomiting", "sick puke"), + ("🤧", "sneezing face", "achoo sick"), + ("🥵", "hot face", "sweating heat"), + ("🥶", "cold face", "freezing"), + ("😵", "face with crossed-out eyes", "dizzy dead"), + ("🤯", "exploding head", "mind blown wow"), + ("🤠", "cowboy hat face", "yeehaw western"), + ("🥳", "partying face", "celebration party"), + ("🥸", "disguised face", "incognito"), + ("🤡", "clown face", "circus"), + ("👻", "ghost", "halloween spooky"), + ("💀", "skull", "dead death"), + ("☠️", "skull and crossbones", "danger death"), + ("👽", "alien", "ufo extraterrestrial"), + ("🤖", "robot", "bot android"), + ("💩", "pile of poo", "poop"), + ("😈", "smiling face with horns", "devil evil"), + ("👿", "angry face with horns", "devil evil"), + // Gestures & People + ("👋", "waving hand", "hello hi bye wave"), + ("🤚", "raised back of hand", "stop"), + ("🖐️", "hand with fingers splayed", "five high"), + ("✋", "raised hand", "stop high five"), + ("🖖", "vulcan salute", "spock trek"), + ("👌", "ok hand", "okay perfect"), + ("🤌", "pinched fingers", "italian"), + ("🤏", "pinching hand", "small tiny"), + ("✌️", "victory hand", "peace two"), + ("🤞", "crossed fingers", "luck hope"), + ("🤟", "love-you gesture", "ily rock"), + ("🤘", "sign of the horns", "rock metal"), + ("🤙", "call me hand", "shaka hang loose"), + ("👈", "backhand index pointing left", "left point"), + ("👉", "backhand index pointing right", "right point"), + ("👆", "backhand index pointing up", "up point"), + ("👇", "backhand index pointing down", "down point"), + ("☝️", "index pointing up", "one point"), + ("👍", "thumbs up", "like yes good approve"), + ("👎", "thumbs down", "dislike no bad"), + ("✊", "raised fist", "power solidarity"), + ("👊", "oncoming fist", "punch bump"), + ("🤛", "left-facing fist", "fist bump"), + ("🤜", "right-facing fist", "fist bump"), + ("👏", "clapping hands", "applause bravo"), + ("🙌", "raising hands", "hooray celebrate"), + ("👐", "open hands", "hug"), + ("🤲", "palms up together", "prayer"), + ("🤝", "handshake", "agreement deal"), + ("🙏", "folded hands", "prayer please thanks"), + ("✍️", "writing hand", "write"), + ("💪", "flexed biceps", "strong muscle"), + ("🦾", "mechanical arm", "robot prosthetic"), + ("🦵", "leg", "kick"), + ("🦶", "foot", "kick"), + ("👂", "ear", "listen hear"), + ("👃", "nose", "smell"), + ("🧠", "brain", "smart think"), + ("👀", "eyes", "look see watch"), + ("👁️", "eye", "see look"), + ("👅", "tongue", "taste lick"), + ("👄", "mouth", "lips kiss"), + // Hearts & Love + ("❤️", "red heart", "love"), + ("🧡", "orange heart", "love"), + ("💛", "yellow heart", "love friendship"), + ("💚", "green heart", "love"), + ("💙", "blue heart", "love"), + ("💜", "purple heart", "love"), + ("🖤", "black heart", "love dark"), + ("🤍", "white heart", "love pure"), + ("🤎", "brown heart", "love"), + ("💔", "broken heart", "heartbreak sad"), + ("❤️‍🔥", "heart on fire", "passion love"), + ("❤️‍🩹", "mending heart", "healing recovery"), + ("💕", "two hearts", "love"), + ("💞", "revolving hearts", "love"), + ("💓", "beating heart", "love"), + ("💗", "growing heart", "love"), + ("💖", "sparkling heart", "love"), + ("💘", "heart with arrow", "love cupid"), + ("💝", "heart with ribbon", "love gift"), + ("💟", "heart decoration", "love"), + // Animals + ("🐶", "dog face", "puppy"), + ("🐱", "cat face", "kitty"), + ("🐭", "mouse face", ""), + ("🐹", "hamster", ""), + ("🐰", "rabbit face", "bunny"), + ("🦊", "fox", ""), + ("🐻", "bear", ""), + ("🐼", "panda", ""), + ("🐨", "koala", ""), + ("🐯", "tiger face", ""), + ("🦁", "lion", ""), + ("🐮", "cow face", ""), + ("🐷", "pig face", ""), + ("🐸", "frog", ""), + ("🐵", "monkey face", ""), + ("🦄", "unicorn", "magic"), + ("🐝", "bee", "honeybee"), + ("🦋", "butterfly", ""), + ("🐌", "snail", "slow"), + ("🐛", "bug", "caterpillar"), + ("🦀", "crab", ""), + ("🐙", "octopus", ""), + ("🐠", "tropical fish", ""), + ("🐟", "fish", ""), + ("🐬", "dolphin", ""), + ("🐳", "whale", ""), + ("🦈", "shark", ""), + ("🐊", "crocodile", "alligator"), + ("🐢", "turtle", ""), + ("🦎", "lizard", ""), + ("🐍", "snake", ""), + ("🦖", "t-rex", "dinosaur"), + ("🦕", "sauropod", "dinosaur"), + ("🐔", "chicken", ""), + ("🐧", "penguin", ""), + ("🦅", "eagle", "bird"), + ("🦆", "duck", ""), + ("🦉", "owl", ""), + // Food & Drink + ("🍎", "red apple", "fruit"), + ("🍐", "pear", "fruit"), + ("🍊", "orange", "tangerine fruit"), + ("🍋", "lemon", "fruit"), + ("🍌", "banana", "fruit"), + ("🍉", "watermelon", "fruit"), + ("🍇", "grapes", "fruit"), + ("🍓", "strawberry", "fruit"), + ("🍒", "cherries", "fruit"), + ("🍑", "peach", "fruit"), + ("🥭", "mango", "fruit"), + ("🍍", "pineapple", "fruit"), + ("🥥", "coconut", "fruit"), + ("🥝", "kiwi", "fruit"), + ("🍅", "tomato", "vegetable"), + ("🥑", "avocado", ""), + ("🥦", "broccoli", "vegetable"), + ("🥬", "leafy green", "vegetable salad"), + ("🥒", "cucumber", "vegetable"), + ("🌶️", "hot pepper", "spicy chili"), + ("🌽", "corn", ""), + ("🥕", "carrot", "vegetable"), + ("🧄", "garlic", ""), + ("🧅", "onion", ""), + ("🥔", "potato", ""), + ("🍞", "bread", ""), + ("🥐", "croissant", ""), + ("🥖", "baguette", "bread french"), + ("🥨", "pretzel", ""), + ("🧀", "cheese", ""), + ("🥚", "egg", ""), + ("🍳", "cooking", "frying pan egg"), + ("🥞", "pancakes", "breakfast"), + ("🧇", "waffle", "breakfast"), + ("🥓", "bacon", "breakfast"), + ("🍔", "hamburger", "burger"), + ("🍟", "french fries", ""), + ("🍕", "pizza", ""), + ("🌭", "hot dog", ""), + ("🥪", "sandwich", ""), + ("🌮", "taco", "mexican"), + ("🌯", "burrito", "mexican"), + ("🍜", "steaming bowl", "ramen noodles"), + ("🍝", "spaghetti", "pasta"), + ("🍣", "sushi", "japanese"), + ("🍱", "bento box", "japanese"), + ("🍩", "doughnut", "donut dessert"), + ("🍪", "cookie", "dessert"), + ("🎂", "birthday cake", "dessert"), + ("🍰", "shortcake", "dessert"), + ("🧁", "cupcake", "dessert"), + ("🍫", "chocolate bar", "dessert"), + ("🍬", "candy", "sweet"), + ("🍭", "lollipop", "candy sweet"), + ("🍦", "soft ice cream", "dessert"), + ("🍨", "ice cream", "dessert"), + ("☕", "hot beverage", "coffee tea"), + ("🍵", "teacup", "tea"), + ("🧃", "juice box", ""), + ("🥤", "cup with straw", "soda drink"), + ("🍺", "beer mug", "drink alcohol"), + ("🍻", "clinking beer mugs", "cheers drink"), + ("🥂", "clinking glasses", "champagne cheers"), + ("🍷", "wine glass", "drink alcohol"), + ("🥃", "tumbler glass", "whiskey drink"), + ("🍸", "cocktail glass", "martini drink"), + // Objects & Symbols + ("💻", "laptop", "computer"), + ("🖥️", "desktop computer", "pc"), + ("⌨️", "keyboard", ""), + ("🖱️", "computer mouse", ""), + ("💾", "floppy disk", "save"), + ("💿", "optical disk", "cd"), + ("📱", "mobile phone", "smartphone"), + ("☎️", "telephone", "phone"), + ("📧", "email", "mail"), + ("📨", "incoming envelope", "email"), + ("📩", "envelope with arrow", "email send"), + ("📝", "memo", "note write"), + ("📄", "page facing up", "document"), + ("📃", "page with curl", "document"), + ("📑", "bookmark tabs", ""), + ("📚", "books", "library read"), + ("📖", "open book", "read"), + ("🔗", "link", "chain url"), + ("📎", "paperclip", "attachment"), + ("🔒", "locked", "security"), + ("🔓", "unlocked", "security open"), + ("🔑", "key", "password"), + ("🔧", "wrench", "tool fix"), + ("🔨", "hammer", "tool"), + ("⚙️", "gear", "settings"), + ("🧲", "magnet", ""), + ("💡", "light bulb", "idea"), + ("🔦", "flashlight", ""), + ("🔋", "battery", "power"), + ("🔌", "electric plug", "power"), + ("💰", "money bag", ""), + ("💵", "dollar", "money cash"), + ("💳", "credit card", "payment"), + ("⏰", "alarm clock", "time"), + ("⏱️", "stopwatch", "timer"), + ("📅", "calendar", "date"), + ("📆", "tear-off calendar", "date"), + ("✅", "check mark", "done yes"), + ("❌", "cross mark", "no wrong delete"), + ("❓", "question mark", "help"), + ("❗", "exclamation mark", "important warning"), + ("⚠️", "warning", "caution alert"), + ("🚫", "prohibited", "no ban forbidden"), + ("⭕", "hollow circle", ""), + ("🔴", "red circle", ""), + ("🟠", "orange circle", ""), + ("🟡", "yellow circle", ""), + ("🟢", "green circle", ""), + ("🔵", "blue circle", ""), + ("🟣", "purple circle", ""), + ("⚫", "black circle", ""), + ("⚪", "white circle", ""), + ("🟤", "brown circle", ""), + ("⬛", "black square", ""), + ("⬜", "white square", ""), + ("🔶", "large orange diamond", ""), + ("🔷", "large blue diamond", ""), + ("⭐", "star", "favorite"), + ("🌟", "glowing star", "sparkle"), + ("✨", "sparkles", "magic shine"), + ("💫", "dizzy", "star"), + ("🔥", "fire", "hot lit"), + ("💧", "droplet", "water"), + ("🌊", "wave", "water ocean"), + ("🎵", "musical note", "music"), + ("🎶", "musical notes", "music"), + ("🎤", "microphone", "sing karaoke"), + ("🎧", "headphones", "music"), + ("🎮", "video game", "gaming controller"), + ("🕹️", "joystick", "gaming"), + ("🎯", "direct hit", "target bullseye"), + ("🏆", "trophy", "winner award"), + ("🥇", "1st place medal", "gold winner"), + ("🥈", "2nd place medal", "silver"), + ("🥉", "3rd place medal", "bronze"), + ("🎁", "wrapped gift", "present"), + ("🎈", "balloon", "party"), + ("🎉", "party popper", "celebration tada"), + ("🎊", "confetti ball", "celebration"), + // Arrows & Misc + ("➡️", "right arrow", ""), + ("⬅️", "left arrow", ""), + ("⬆️", "up arrow", ""), + ("⬇️", "down arrow", ""), + ("↗️", "up-right arrow", ""), + ("↘️", "down-right arrow", ""), + ("↙️", "down-left arrow", ""), + ("↖️", "up-left arrow", ""), + ("↕️", "up-down arrow", ""), + ("↔️", "left-right arrow", ""), + ("🔄", "counterclockwise arrows", "refresh reload"), + ("🔃", "clockwise arrows", "refresh reload"), + ("➕", "plus", "add"), + ("➖", "minus", "subtract"), + ("➗", "division", "divide"), + ("✖️", "multiply", "times"), + ("♾️", "infinity", "forever"), + ("💯", "hundred points", "100 perfect"), + ("🆗", "ok button", "okay"), + ("🆕", "new button", ""), + ("🆓", "free button", ""), + ("ℹ️", "information", "info"), + ("🅿️", "parking", ""), + ("🚀", "rocket", "launch startup"), + ("✈️", "airplane", "travel flight"), + ("🚗", "car", "automobile"), + ("🚕", "taxi", "cab"), + ("🚌", "bus", ""), + ("🚂", "locomotive", "train"), + ("🏠", "house", "home"), + ("🏢", "office building", "work"), + ("🏥", "hospital", ""), + ("🏫", "school", ""), + ("🏛️", "classical building", ""), + ("⛪", "church", ""), + ("🕌", "mosque", ""), + ("🕍", "synagogue", ""), + ("🗽", "statue of liberty", "usa america"), + ("🗼", "tokyo tower", "japan"), + ("🗾", "map of japan", ""), + ("🌍", "globe europe-africa", "earth world"), + ("🌎", "globe americas", "earth world"), + ("🌏", "globe asia-australia", "earth world"), + ("🌑", "new moon", ""), + ("🌕", "full moon", ""), + ("☀️", "sun", "sunny"), + ("🌙", "crescent moon", "night"), + ("☁️", "cloud", ""), + ("🌧️", "cloud with rain", "rainy"), + ("⛈️", "cloud with lightning", "storm thunder"), + ("🌈", "rainbow", ""), + ("❄️", "snowflake", "cold winter"), + ("☃️", "snowman", "winter"), + ("🎄", "christmas tree", "xmas holiday"), + ("🎃", "jack-o-lantern", "halloween pumpkin"), + ("🐚", "shell", "beach"), + ("🌸", "cherry blossom", "flower spring"), + ("🌺", "hibiscus", "flower"), + ("🌻", "sunflower", "flower"), + ("🌹", "rose", "flower love"), + ("🌷", "tulip", "flower"), + ("🌱", "seedling", "plant grow"), + ("🌲", "evergreen tree", ""), + ("🌳", "deciduous tree", ""), + ("🌴", "palm tree", "tropical"), + ("🌵", "cactus", "desert"), + ("🍀", "four leaf clover", "luck irish"), + ("🍁", "maple leaf", "fall autumn canada"), + ("🍂", "fallen leaf", "fall autumn"), +]; + +pub struct EmojiProvider { + items: Vec, +} + +impl Default for EmojiProvider { + fn default() -> Self { + Self::new() + } +} + +impl EmojiProvider { + pub fn new() -> Self { + Self { items: Vec::new() } + } + + fn build_items() -> Vec { + EMOJIS + .iter() + .map(|(emoji, name, keywords)| { + let mut tags = vec![name.to_string()]; + if !keywords.is_empty() { + tags.push(keywords.to_string()); + } + + LaunchItem { + id: format!("emoji:{}", emoji), + name: name.to_string(), + description: Some(format!("{} {}", emoji, keywords)), + icon: Some((*emoji).to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command: format!("printf '%s' '{}' | wl-copy", emoji), + terminal: false, + tags, + source: ItemSource::Core, + } + }) + .collect() + } +} + +impl Provider for EmojiProvider { + fn name(&self) -> &str { + "Emoji" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.items = Self::build_items(); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":emoji") + } + + fn icon(&self) -> &str { + "face-smile" + } + + fn tab_label(&self) -> Option<&str> { + Some("Emoji") + } + + fn search_noun(&self) -> Option<&str> { + Some("emoji") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_emoji_state_new() { + let mut provider = EmojiProvider::new(); + provider.refresh(); + assert!( + provider.items().len() > 100, + "Should have more than 100 emojis loaded after refresh" + ); + } + + #[test] + fn test_emoji_has_grinning_face() { + let mut provider = EmojiProvider::new(); + provider.refresh(); + + let grinning = provider + .items() + .iter() + .find(|i| i.name == "grinning face"); + assert!(grinning.is_some()); + + let item = grinning.unwrap(); + assert!(item.description.as_ref().unwrap().contains("😀")); + } + + #[test] + fn test_emoji_command_format() { + let mut provider = EmojiProvider::new(); + provider.refresh(); + + let item = &provider.items()[0]; + assert!(item.command.contains("wl-copy")); + assert!(item.command.contains("printf")); + } + + #[test] + fn test_emojis_have_keywords() { + let mut provider = EmojiProvider::new(); + provider.refresh(); + + let heart = provider.items().iter().find(|i| i.name == "red heart"); + assert!(heart.is_some()); + } + + #[test] + fn provider_type_is_emoji_plugin() { + let provider = EmojiProvider::new(); + assert_eq!( + provider.provider_type(), + ProviderType::Plugin("emoji".into()) + ); + } +} diff --git a/crates/owlry/src/providers/filesearch.rs b/crates/owlry/src/providers/filesearch.rs new file mode 100644 index 0000000..602ec36 --- /dev/null +++ b/crates/owlry/src/providers/filesearch.rs @@ -0,0 +1,267 @@ +//! File search provider. +//! +//! Dynamic provider that searches for files using `fd` (preferred) or +//! `locate`. Triggered by: +//! - `/ name` / `/name` (slash prefix) +//! - `file name` / `find name` (word prefix) +//! +//! External dependencies: +//! - `fd` (preferred) or `locate` + +use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType}; +use std::path::Path; +use std::process::Command; + +const TYPE_ID: &str = "filesearch"; +const MAX_RESULTS: usize = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SearchTool { + Fd, + Locate, + None, +} + +/// Dynamic file search provider — shells out to `fd` or `locate` per keystroke. +pub(crate) struct FileSearchProvider { + search_tool: SearchTool, + // TODO(v2.x): plumb via constructor (search roots, extra flags). + home: String, +} + +impl Default for FileSearchProvider { + fn default() -> Self { + Self::new() + } +} + +impl FileSearchProvider { + pub fn new() -> Self { + let search_tool = Self::detect_search_tool(); + // TODO(v2.x): plumb via constructor. + let home = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "/".to_string()); + + Self { search_tool, home } + } + + fn detect_search_tool() -> SearchTool { + // Prefer fd (faster, respects .gitignore). + if Self::command_exists("fd") { + return SearchTool::Fd; + } + // Fall back to locate (requires updatedb). + if Self::command_exists("locate") { + return SearchTool::Locate; + } + SearchTool::None + } + + fn command_exists(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// Extract the search term from the query. + fn extract_search_term(query: &str) -> Option<&str> { + let trimmed = query.trim(); + + if let Some(rest) = trimmed.strip_prefix("/ ") { + Some(rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix('/') { + Some(rest.trim()) + } else { + // Handle "file " and "find " prefixes (case-insensitive), or raw + // query in filter mode. + let lower = trimmed.to_lowercase(); + if lower.starts_with("file ") || lower.starts_with("find ") { + Some(trimmed[5..].trim()) + } else { + Some(trimmed) + } + } + } + + fn evaluate(&self, query: &str) -> Vec { + let search_term = match Self::extract_search_term(query) { + Some(t) if !t.is_empty() => t, + _ => return Vec::new(), + }; + + self.search_files(search_term) + } + + fn search_files(&self, pattern: &str) -> Vec { + match self.search_tool { + SearchTool::Fd => self.search_with_fd(pattern), + SearchTool::Locate => self.search_with_locate(pattern), + SearchTool::None => Vec::new(), + } + } + + fn search_with_fd(&self, pattern: &str) -> Vec { + let output = match Command::new("fd") + .args([ + "--max-results", + &MAX_RESULTS.to_string(), + "--type", + "f", // Files only + "--type", + "d", // And directories + pattern, + ]) + .current_dir(&self.home) + .output() + { + Ok(o) => o, + Err(_) => return Vec::new(), + }; + + self.parse_file_results(&String::from_utf8_lossy(&output.stdout)) + } + + fn search_with_locate(&self, pattern: &str) -> Vec { + let output = match Command::new("locate") + .args([ + "--limit", + &MAX_RESULTS.to_string(), + "--ignore-case", + pattern, + ]) + .output() + { + Ok(o) => o, + Err(_) => return Vec::new(), + }; + + self.parse_file_results(&String::from_utf8_lossy(&output.stdout)) + } + + fn parse_file_results(&self, output: &str) -> Vec { + output + .lines() + .filter(|line| !line.is_empty()) + .map(|path| { + let path = path.trim(); + let full_path = if path.starts_with('/') { + path.to_string() + } else { + format!("{}/{}", self.home, path) + }; + + let filename = Path::new(&full_path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| full_path.clone()); + + let is_dir = Path::new(&full_path).is_dir(); + let icon = if is_dir { "folder" } else { "text-x-generic" }; + + let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''")); + + LaunchItem { + id: format!("file:{}", full_path), + name: filename, + description: Some(full_path.clone()), + icon: Some(icon.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command, + terminal: false, + tags: vec!["file".to_string()], + source: ItemSource::Core, + } + }) + .collect() + } +} + +impl DynamicProvider for FileSearchProvider { + fn name(&self) -> &str { + "Files" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn priority(&self) -> u32 { + 8_000 + } + + fn query(&self, query: &str) -> Vec { + self.evaluate(query) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_search_term() { + assert_eq!( + FileSearchProvider::extract_search_term("/ config.toml"), + Some("config.toml") + ); + assert_eq!( + FileSearchProvider::extract_search_term("/config"), + Some("config") + ); + assert_eq!( + FileSearchProvider::extract_search_term("file bashrc"), + Some("bashrc") + ); + assert_eq!( + FileSearchProvider::extract_search_term("find readme"), + Some("readme") + ); + } + + #[test] + fn test_extract_search_term_empty() { + assert_eq!(FileSearchProvider::extract_search_term("/"), Some("")); + assert_eq!(FileSearchProvider::extract_search_term("/ "), Some("")); + } + + #[test] + fn test_command_exists() { + // 'which' should exist on any Unix system. + assert!(FileSearchProvider::command_exists("which")); + // This should not exist. + assert!(!FileSearchProvider::command_exists( + "nonexistent-command-12345" + )); + } + + #[test] + fn test_detect_search_tool() { + // Just ensure it doesn't panic. + let _ = FileSearchProvider::detect_search_tool(); + } + + #[test] + fn test_state_new() { + let provider = FileSearchProvider::new(); + assert!(!provider.home.is_empty()); + } + + #[test] + fn test_evaluate_empty() { + let provider = FileSearchProvider::new(); + let results = provider.evaluate("/"); + assert!(results.is_empty()); + + let results = provider.evaluate("/ "); + assert!(results.is_empty()); + } + + #[test] + fn provider_type_is_filesearch_plugin() { + let p = FileSearchProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("filesearch".into())); + } +} diff --git a/crates/owlry/src/providers/mod.rs b/crates/owlry/src/providers/mod.rs index aa9549c..257db2d 100644 --- a/crates/owlry/src/providers/mod.rs +++ b/crates/owlry/src/providers/mod.rs @@ -7,8 +7,20 @@ pub(crate) mod converter; pub(crate) mod system; // Optional feature-gated providers +#[cfg(feature = "bookmarks")] +pub(crate) mod bookmarks; +#[cfg(feature = "clipboard")] +pub(crate) mod clipboard; +#[cfg(feature = "emoji")] +pub(crate) mod emoji; +#[cfg(feature = "filesearch")] +pub(crate) mod filesearch; +#[cfg(feature = "ssh")] +pub(crate) mod ssh; #[cfg(feature = "systemd")] pub(crate) mod systemd; +#[cfg(feature = "websearch")] +pub(crate) mod websearch; // Re-exports for core providers pub use application::ApplicationProvider; @@ -243,45 +255,69 @@ impl ProviderManager { /// Only built-in / compiled-in providers are registered here. Future Lua-defined /// providers (Phase 3+) are added via [`Self::add_provider`] after construction. pub fn new_with_config(config: Arc>) -> Self { - let (calc_enabled, conv_enabled, sys_enabled, systemd_enabled) = match config.read() { - Ok(cfg) => ( - cfg.providers.calculator, - cfg.providers.converter, - cfg.providers.system, - cfg.providers.systemd, - ), + let cfg_snapshot = match config.read() { + Ok(cfg) => cfg.providers.clone(), Err(_) => { log::warn!("Config lock poisoned during provider init; using defaults"); - (true, true, true, true) + crate::config::ProvidersConfig::default() } }; - let _ = systemd_enabled; // referenced only behind cfg(feature) let mut core_providers: Vec> = vec![ Box::new(ApplicationProvider::new()), Box::new(CommandProvider::new()), ]; - if sys_enabled { + if cfg_snapshot.system { core_providers.push(Box::new(system::SystemProvider::new())); info!("Registered built-in system provider"); } + #[cfg(feature = "bookmarks")] + if cfg_snapshot.bookmarks { + core_providers.push(Box::new(bookmarks::BookmarksProvider::new())); + info!("Registered bookmarks provider"); + } + #[cfg(feature = "clipboard")] + if cfg_snapshot.clipboard { + core_providers.push(Box::new(clipboard::ClipboardProvider::new())); + info!("Registered clipboard provider"); + } + #[cfg(feature = "emoji")] + if cfg_snapshot.emoji { + core_providers.push(Box::new(emoji::EmojiProvider::new())); + info!("Registered emoji provider"); + } + #[cfg(feature = "ssh")] + if cfg_snapshot.ssh { + core_providers.push(Box::new(ssh::SshProvider::new())); + info!("Registered ssh provider"); + } #[cfg(feature = "systemd")] - if systemd_enabled { + if cfg_snapshot.systemd { core_providers.push(Box::new(systemd::SystemdProvider::new())); info!("Registered systemd provider (type_id: uuctl)"); } let mut builtin_dynamic: Vec> = Vec::new(); - if calc_enabled { + if cfg_snapshot.calculator { builtin_dynamic.push(Box::new(calculator::CalculatorProvider)); info!("Registered built-in calculator provider"); } - if conv_enabled { + if cfg_snapshot.converter { builtin_dynamic.push(Box::new(converter::ConverterProvider::new())); info!("Registered built-in converter provider"); } + #[cfg(feature = "filesearch")] + if cfg_snapshot.filesearch { + builtin_dynamic.push(Box::new(filesearch::FileSearchProvider::new())); + info!("Registered filesearch provider"); + } + #[cfg(feature = "websearch")] + if cfg_snapshot.websearch { + builtin_dynamic.push(Box::new(websearch::WebSearchProvider::new())); + info!("Registered websearch provider"); + } Self::new(core_providers, builtin_dynamic) } diff --git a/crates/owlry/src/providers/ssh.rs b/crates/owlry/src/providers/ssh.rs new file mode 100644 index 0000000..d2d11a4 --- /dev/null +++ b/crates/owlry/src/providers/ssh.rs @@ -0,0 +1,290 @@ +//! SSH hosts provider. +//! +//! Parses `~/.ssh/config` and exposes each non-wildcard `Host` entry as a +//! launchable item that opens an `ssh ` session in the user's terminal. + +use super::{ItemSource, LaunchItem, Provider, ProviderType}; +use std::fs; +use std::path::PathBuf; + +const TYPE_ID: &str = "ssh"; +const ICON: &str = "utilities-terminal"; + +pub struct SshProvider { + items: Vec, + terminal_command: String, +} + +impl Default for SshProvider { + fn default() -> Self { + Self::new() + } +} + +impl SshProvider { + pub fn new() -> Self { + Self { + items: Vec::new(), + terminal_command: Self::load_terminal_from_config(), + } + } + + fn load_terminal_from_config() -> String { + // Try [plugins.ssh] in config.toml + let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml")); + if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok()) + && let Ok(toml) = content.parse::() + && let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table()) + && let Some(ssh) = plugins.get("ssh").and_then(|v| v.as_table()) + && let Some(terminal) = ssh.get("terminal").and_then(|v| v.as_str()) + { + return terminal.to_string(); + } + + // Fall back to $TERMINAL env var + if let Ok(terminal) = std::env::var("TERMINAL") { + return terminal; + } + + // Last resort + "xdg-terminal-exec".to_string() + } + + fn ssh_config_path() -> Option { + dirs::home_dir().map(|h| h.join(".ssh").join("config")) + } + + fn parse_ssh_config(&mut self) { + self.items.clear(); + + let config_path = match Self::ssh_config_path() { + Some(p) => p, + None => return, + }; + + if !config_path.exists() { + return; + } + + let content = match fs::read_to_string(&config_path) { + Ok(c) => c, + Err(_) => return, + }; + + let mut current_host: Option = None; + let mut current_hostname: Option = None; + let mut current_user: Option = None; + let mut current_port: Option = None; + + for line in content.lines() { + let line = line.trim(); + + // Skip comments and empty lines + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Split on whitespace or '=' + let parts: Vec<&str> = line + .splitn(2, |c: char| c.is_whitespace() || c == '=') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + if parts.len() < 2 { + continue; + } + + let key = parts[0].to_lowercase(); + let value = parts[1]; + + match key.as_str() { + "host" => { + // Save previous host if exists + if let Some(host) = current_host.take() { + self.add_host_item( + &host, + current_hostname.take(), + current_user.take(), + current_port.take(), + ); + } + + // Skip wildcards and patterns + if !value.contains('*') && !value.contains('?') && value != "*" { + current_host = Some(value.to_string()); + } + current_hostname = None; + current_user = None; + current_port = None; + } + "hostname" => { + current_hostname = Some(value.to_string()); + } + "user" => { + current_user = Some(value.to_string()); + } + "port" => { + current_port = Some(value.to_string()); + } + _ => {} + } + } + + // Don't forget the last host + if let Some(host) = current_host.take() { + self.add_host_item(&host, current_hostname, current_user, current_port); + } + } + + fn add_host_item( + &mut self, + host: &str, + hostname: Option, + user: Option, + port: Option, + ) { + // Build description + let mut desc_parts = Vec::new(); + if let Some(ref h) = hostname { + desc_parts.push(h.clone()); + } + if let Some(ref u) = user { + desc_parts.push(format!("user: {}", u)); + } + if let Some(ref p) = port { + desc_parts.push(format!("port: {}", p)); + } + + let description = if desc_parts.is_empty() { + None + } else { + Some(desc_parts.join(", ")) + }; + + // Build SSH command - just use the host alias, SSH will resolve the rest + let ssh_command = format!("ssh {}", host); + + // Wrap in terminal + let command = format!("{} -e {}", self.terminal_command, ssh_command); + + self.items.push(LaunchItem { + id: format!("ssh:{}", host), + name: format!("SSH: {}", host), + description, + icon: Some(ICON.to_string()), + provider: ProviderType::Plugin(TYPE_ID.into()), + command, + terminal: false, + tags: vec!["ssh".to_string(), "remote".to_string()], + source: ItemSource::Core, + }); + } +} + +impl Provider for SshProvider { + fn name(&self) -> &str { + "SSH" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin(TYPE_ID.into()) + } + + fn refresh(&mut self) { + self.parse_ssh_config(); + } + + fn items(&self) -> &[LaunchItem] { + &self.items + } + + fn prefix(&self) -> Option<&str> { + Some(":ssh") + } + + fn icon(&self) -> &str { + ICON + } + + fn tab_label(&self) -> Option<&str> { + Some("SSH") + } + + fn search_noun(&self) -> Option<&str> { + Some("SSH hosts") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ssh_provider_new() { + let p = SshProvider::new(); + assert!(p.items.is_empty()); + } + + #[test] + fn test_parse_simple_config() { + let mut p = SshProvider::new(); + + // We can't easily test the full flow without mocking file paths, + // but we can test the add_host_item method + p.add_host_item( + "myserver", + Some("192.168.1.100".to_string()), + Some("admin".to_string()), + Some("2222".to_string()), + ); + + assert_eq!(p.items.len(), 1); + assert_eq!(p.items[0].name, "SSH: myserver"); + assert!(p.items[0].command.contains("ssh myserver")); + } + + #[test] + fn test_add_host_without_details() { + let mut p = SshProvider::new(); + p.add_host_item("simple-host", None, None, None); + + assert_eq!(p.items.len(), 1); + assert_eq!(p.items[0].name, "SSH: simple-host"); + assert!(p.items[0].description.is_none()); + } + + #[test] + fn test_add_host_with_partial_details() { + let mut p = SshProvider::new(); + p.add_host_item("partial", Some("example.com".to_string()), None, None); + + assert_eq!(p.items.len(), 1); + let desc = p.items[0].description.as_ref().unwrap(); + assert_eq!(desc, "example.com"); + } + + #[test] + fn test_items_have_icons() { + let mut p = SshProvider::new(); + p.add_host_item("test", None, None, None); + + assert!(p.items[0].icon.is_some()); + assert_eq!(p.items[0].icon.as_ref().unwrap(), ICON); + } + + #[test] + fn test_items_have_keywords() { + let mut p = SshProvider::new(); + p.add_host_item("test", None, None, None); + + assert!(!p.items[0].tags.is_empty()); + assert!(p.items[0].tags.iter().any(|t| t == "ssh")); + } + + #[test] + fn provider_type_is_ssh_plugin() { + let p = SshProvider::new(); + assert_eq!(p.provider_type(), ProviderType::Plugin("ssh".into())); + } +} diff --git a/crates/owlry/src/providers/websearch.rs b/crates/owlry/src/providers/websearch.rs new file mode 100644 index 0000000..d08fa4d --- /dev/null +++ b/crates/owlry/src/providers/websearch.rs @@ -0,0 +1,244 @@ +use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType}; + +/// Built-in web search provider. Opens a search URL in the user's browser via +/// `xdg-open`. +/// +/// Triggered by: +/// - `? query` / `?query` (explicit prefix) +/// - `web query` / `search query` (word prefixes) +/// +/// The CLI prefix routing for `:web` and `?` is handled by core; this provider +/// only needs to return matching items when `query()` is called. +pub(crate) struct WebSearchProvider { + /// URL template containing a `{query}` placeholder. + url_template: String, +} + +/// Common search engine URL templates. `{query}` is replaced with the +/// URL-encoded search term. +const SEARCH_ENGINES: &[(&str, &str)] = &[ + ("google", "https://www.google.com/search?q={query}"), + ("duckduckgo", "https://duckduckgo.com/?q={query}"), + ("bing", "https://www.bing.com/search?q={query}"), + ("startpage", "https://www.startpage.com/search?q={query}"), + ("searxng", "https://searx.be/search?q={query}"), + ("brave", "https://search.brave.com/search?q={query}"), + ("ecosia", "https://www.ecosia.org/search?q={query}"), +]; + +/// Default search engine when no configuration is provided. +const DEFAULT_ENGINE: &str = "duckduckgo"; + +const PROVIDER_ICON: &str = "web-browser"; + +impl Default for WebSearchProvider { + fn default() -> Self { + // TODO(v2.x): plumb search_engine via constructor argument from Lua + // config. The C-ABI host config lookup was removed; for now we + // hardcode the default engine. + Self::with_engine(DEFAULT_ENGINE) + } +} + +impl WebSearchProvider { + #[allow(dead_code)] + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn with_engine(engine_name: &str) -> Self { + let lower = engine_name.to_lowercase(); + let url_template = SEARCH_ENGINES + .iter() + .find(|(name, _)| *name == lower) + .map(|(_, url)| (*url).to_string()) + .unwrap_or_else(|| { + // Not a known engine; treat as custom URL template if it + // contains a {query} placeholder, else fall back to default. + if engine_name.contains("{query}") { + engine_name.to_string() + } else { + SEARCH_ENGINES + .iter() + .find(|(name, _)| *name == DEFAULT_ENGINE) + .map(|(_, url)| (*url).to_string()) + .expect("default engine must exist in SEARCH_ENGINES") + } + }); + + Self { url_template } + } + + /// Extract the search term from a raw query string. + fn extract_search_term(query: &str) -> Option<&str> { + let trimmed = query.trim(); + + if let Some(rest) = trimmed.strip_prefix("? ") { + Some(rest.trim()) + } else if let Some(rest) = trimmed.strip_prefix('?') { + Some(rest.trim()) + } else if trimmed.to_lowercase().starts_with("web ") { + Some(trimmed[4..].trim()) + } else if trimmed.to_lowercase().starts_with("search ") { + Some(trimmed[7..].trim()) + } else { + // Filter mode: accept the raw query. + Some(trimmed) + } + } + + /// URL-encode a search query using a small percent-encoding scheme suitable + /// for query parameters (spaces become `+`). + fn url_encode(query: &str) -> String { + query + .chars() + .map(|c| match c { + ' ' => "+".to_string(), + '&' => "%26".to_string(), + '=' => "%3D".to_string(), + '?' => "%3F".to_string(), + '#' => "%23".to_string(), + '+' => "%2B".to_string(), + '%' => "%25".to_string(), + c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(), + c => format!("%{:02X}", c as u32), + }) + .collect() + } + + /// Build the final search URL for a given search term. + fn build_search_url(&self, search_term: &str) -> String { + let encoded = Self::url_encode(search_term); + self.url_template.replace("{query}", &encoded) + } + + /// Evaluate a query and produce a single `LaunchItem` if the query yields a + /// non-empty search term. + fn evaluate(&self, query: &str) -> Option { + let search_term = Self::extract_search_term(query)?; + if search_term.is_empty() { + return None; + } + + let url = self.build_search_url(search_term); + let command = format!("xdg-open '{}'", url.replace('\'', "'\\''")); + + Some(LaunchItem { + id: format!("websearch:{}", search_term), + name: format!("Search: {}", search_term), + description: Some("Open in browser".to_string()), + icon: Some(PROVIDER_ICON.to_string()), + provider: ProviderType::Plugin("websearch".into()), + command, + terminal: false, + tags: vec!["web".into(), "search".into()], + source: ItemSource::Core, + }) + } +} + +impl DynamicProvider for WebSearchProvider { + fn name(&self) -> &str { + "Web Search" + } + + fn provider_type(&self) -> ProviderType { + ProviderType::Plugin("websearch".into()) + } + + fn priority(&self) -> u32 { + 9_000 + } + + fn query(&self, query: &str) -> Vec { + match self.evaluate(query) { + Some(item) => vec![item], + None => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_search_term() { + assert_eq!( + WebSearchProvider::extract_search_term("? rust programming"), + Some("rust programming") + ); + assert_eq!( + WebSearchProvider::extract_search_term("?rust"), + Some("rust") + ); + assert_eq!( + WebSearchProvider::extract_search_term("web rust docs"), + Some("rust docs") + ); + assert_eq!( + WebSearchProvider::extract_search_term("search how to rust"), + Some("how to rust") + ); + } + + #[test] + fn test_url_encode() { + assert_eq!(WebSearchProvider::url_encode("hello world"), "hello+world"); + assert_eq!(WebSearchProvider::url_encode("foo&bar"), "foo%26bar"); + assert_eq!(WebSearchProvider::url_encode("a=b"), "a%3Db"); + assert_eq!(WebSearchProvider::url_encode("test?query"), "test%3Fquery"); + } + + #[test] + fn test_build_search_url() { + let provider = WebSearchProvider::with_engine("duckduckgo"); + let url = provider.build_search_url("rust programming"); + assert_eq!(url, "https://duckduckgo.com/?q=rust+programming"); + } + + #[test] + fn test_build_search_url_google() { + let provider = WebSearchProvider::with_engine("google"); + let url = provider.build_search_url("rust"); + assert_eq!(url, "https://www.google.com/search?q=rust"); + } + + #[test] + fn test_evaluate() { + let provider = WebSearchProvider::new(); + let item = provider.evaluate("? rust docs").unwrap(); + assert_eq!(item.name, "Search: rust docs"); + assert!(item.command.contains("xdg-open")); + assert!(item.command.contains("duckduckgo")); + } + + #[test] + fn test_evaluate_empty() { + let provider = WebSearchProvider::new(); + assert!(provider.evaluate("?").is_none()); + assert!(provider.evaluate("? ").is_none()); + } + + #[test] + fn test_custom_url_template() { + let provider = WebSearchProvider::with_engine("https://custom.search/q={query}"); + let url = provider.build_search_url("test"); + assert_eq!(url, "https://custom.search/q=test"); + } + + #[test] + fn test_fallback_to_default() { + let provider = WebSearchProvider::with_engine("nonexistent"); + let url = provider.build_search_url("test"); + assert!(url.contains("duckduckgo")); + } + + #[test] + fn provider_type_is_websearch_plugin() { + assert_eq!( + WebSearchProvider::default().provider_type(), + ProviderType::Plugin("websearch".into()) + ); + } +} diff --git a/docs/RESTRUCTURE-V2.md b/docs/RESTRUCTURE-V2.md index 1c65885..560ae40 100644 --- a/docs/RESTRUCTURE-V2.md +++ b/docs/RESTRUCTURE-V2.md @@ -452,7 +452,9 @@ This section captures in-progress state. Update freely as work proceeds. - `2fc976b` — D15–D21 resolutions - `ae4a903` — C-ABI demolition: tasks #3/#4/#5 done in one commit - `1d20754` — TDD characterization pass (+36 tests) - - (next) — Workspace collapse: owlry-core merged into owlry (task #2) + - `0a4a090` — Workspace collapse: owlry-core merged into owlry (task #2) + - `eb8a65f` — systemd provider converted (issue #5 functional fix in v2) + - (next) — Remaining 6 plugins converted in parallel: bookmarks, clipboard, emoji, filesearch, ssh, websearch (tasks #6 + #7) - **Tasks done:** #1 inventory, #3 delete C-ABI, #4 delete Rune+Lua crates, #5 delete config_editor (scripts never lived in this repo) - **Tasks remaining (Phase 1):** #2 workspace collapse, #6 convert 8 plugins, #7 cargo features, #8 sys→power rename, #9 CLI subcommands, #10 auto-mode test, #11 final build+smoke - **Stray processes from inventory phase:**