Commit browse files
Message
Changed Files (7)
-
modified CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 2896f40..74248c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- Bang (`!`) and at-sign (`@`) shortcuts: `!w query` redirects to Wikipedia, `@w query` searches only Wikipedia. Shortcodes auto-generated from configured engines, overridable per-engine or via `[[bangs]]` config. +- `shortcode`, `bang_url`, `bang_enabled` per-engine override fields. +- Configurable standalone bangs via `[[bangs]]` in `config.toml`. +- Dynamic shortcuts help in the web UI (press `?`). +- JSON API returns `{ type: "bang", redirect_url }` for bang queries. + ## [0.4.2] - 2026-06-23 ### Added -
modified README.md
diff --git a/README.md b/README.md index 425d597..af93e65 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,67 @@ instance = "https://search.kael.ink" # url = "https://html.duckduckgo.com/html/?q={}" # selector = "div.results" # timeout_secs = 10.0 +# shortcode = "ddg" # optional: override auto-generated shortcode +# bang_enabled = true # optional: disable ! redirect but keep @ +# bang_url = "..." # optional: custom redirect URL (keeps shortcode) ``` +## Search shortcuts + +SearchHub supports two query prefixes that use **shortcodes** — compact aliases +auto-generated from your configured `[[engines]]`. + +| Prefix | Example | Behavior | +|--------|---------|----------| +| `!` | `!w Rust` | HTTP 302 redirect to the site's own search results page | +| `@` | `@w Rust` | Show search results from that engine only (bookmarks still shown) | + +### Auto-generated shortcodes + +Each engine type gets a sensible default shortcode: + +| Engine | Shortcode | Bang URL | +|--------|-----------|----------| +| Wikipedia (lang=en) | `w` | `https://en.wikipedia.org/w/index.php?search={}` | +| Wikipedia (lang=fr) | `wfr` | `https://fr.wikipedia.org/w/index.php?search={}` | +| MDN (locale=en-US) | `mdn` | `https://developer.mozilla.org/en-US/search?q={}` | +| MDN (locale=fr) | `mdnfr` | `https://developer.mozilla.org/fr/search?q={}` | +| crates.io | `crates` | `https://crates.io/search?q={}` | +| SearXNG | `sx` | `{instance}/search?q={}` | +| Generic | slugified name | the engine's own URL template | + +### Overriding shortcodes per engine + +Set `shortcode`, `bang_url`, or `bang_enabled` directly on the engine: + +```toml +[[engines]] +type = "wikipedia" +lang = "fr" +shortcode = "wikifr" # overrides "wfr" +bang_enabled = false # disable ! redirect (still searchable via @) +bang_url = "https://..." # custom redirect URL +``` + +### Custom bangs (standalone shortcuts — no @ support) + +```toml +[[bangs]] +trigger = "gh" +url = "https://github.com/search?q={}" +name = "GitHub" + +# Suppress an auto-generated shortcut +[[bangs]] +trigger = "crates" +enabled = false +``` + +### Collisions + +If two engines produce the same shortcode, SearchHub panics at startup with +a message naming both engines. Set `shortcode` on one of them to resolve it. + ## Run the web server as a systemd user service Keeps the web UI running in the background, starts automatically on login. -
modified src/config.rs
diff --git a/src/config.rs b/src/config.rs index 3961be4..b3d1ab0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use figment::Figment; use figment::providers::{Format, Toml}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -18,12 +19,25 @@ pub enum EngineConfig { url: Option<String>, #[serde(default)] timeout_secs: Option<f32>, + // Per-engine shortcut overrides (see `auto_shortcode()`, `bang_url()`, `is_bang_enabled()`) + #[serde(default)] + shortcode: Option<String>, + #[serde(default)] + bang_enabled: Option<bool>, + #[serde(default)] + bang_url: Option<String>, }, /// SearXNG meta-search engine SearXng { instance: String, #[serde(default)] timeout_secs: Option<f32>, + #[serde(default)] + shortcode: Option<String>, + #[serde(default)] + bang_enabled: Option<bool>, + #[serde(default)] + bang_url: Option<String>, }, /// Wikipedia search (language-specific) Wikipedia { @@ -32,6 +46,12 @@ pub enum EngineConfig { lang: Option<String>, #[serde(default)] timeout_secs: Option<f32>, + #[serde(default)] + shortcode: Option<String>, + #[serde(default)] + bang_enabled: Option<bool>, + #[serde(default)] + bang_url: Option<String>, }, /// MDN Web Docs search (language-specific) Mdn { @@ -40,6 +60,12 @@ pub enum EngineConfig { locale: Option<String>, #[serde(default)] timeout_secs: Option<f32>, + #[serde(default)] + shortcode: Option<String>, + #[serde(default)] + bang_enabled: Option<bool>, + #[serde(default)] + bang_url: Option<String>, }, /// Generic HTML-scraped search engine (configurable URL + CSS selector) Generic { @@ -51,6 +77,12 @@ pub enum EngineConfig { selector: String, #[serde(default)] timeout_secs: Option<f32>, + #[serde(default)] + shortcode: Option<String>, + #[serde(default)] + bang_enabled: Option<bool>, + #[serde(default)] + bang_url: Option<String>, }, } @@ -107,14 +139,14 @@ impl SearchEngine for EngineConfig { client: &reqwest::Client, ) -> Result<Vec<ResultEntry>, EngineError> { match self { - EngineConfig::CratesIo { url, timeout_secs } => { + EngineConfig::CratesIo { url, timeout_secs, .. } => { let engine = crate::search_engines::crates_io::CratesIo { timeout_secs: *timeout_secs, api_url: url.clone().unwrap_or_else(|| crate::search_engines::crates_io::DEFAULT_API_URL.into()), }; engine.fetch_results(query, client).await } - EngineConfig::SearXng { instance, timeout_secs } => { + EngineConfig::SearXng { instance, timeout_secs, .. } => { let engine = crate::search_engines::searxng::SearXng { instance: instance.clone(), url_tpl: format!("{}/search?format=json&q={{}}", instance.trim_end_matches('/')), @@ -122,21 +154,21 @@ impl SearchEngine for EngineConfig { }; engine.fetch_results(query, client).await } - EngineConfig::Wikipedia { lang, timeout_secs } => { + EngineConfig::Wikipedia { lang, timeout_secs, .. } => { let engine = crate::search_engines::wikipedia::Wikipedia { lang: lang.clone().unwrap_or_else(|| crate::search_engines::wikipedia::DEFAULT_LANG.into()), timeout_secs: *timeout_secs, }; engine.fetch_results(query, client).await } - EngineConfig::Mdn { locale, timeout_secs } => { + EngineConfig::Mdn { locale, timeout_secs, .. } => { let engine = crate::search_engines::mdn::Mdn { locale: locale.clone().unwrap_or_else(|| crate::search_engines::mdn::DEFAULT_LOCALE.into()), timeout_secs: *timeout_secs, }; engine.fetch_results(query, client).await } - EngineConfig::Generic { name, url, selector, timeout_secs } => { + EngineConfig::Generic { name, url, selector, timeout_secs, .. } => { let engine = crate::search_engines::generic::Generic { name: name.clone(), url: url.clone(), @@ -149,6 +181,118 @@ impl SearchEngine for EngineConfig { } } +/// A resolved shortcut linking a trigger string (!/@) to a bang URL and +/// optionally to a specific search engine. +#[derive(Debug, Clone, Serialize)] +pub struct Shortcut { + /// The trigger keyword (e.g. "w" for !w). + pub trigger: String, + /// URL template with `{}` placeholder for the query. + pub bang_url: String, + /// Human-readable display name. + pub name: String, + /// If `Some(i)`, this shortcut originated from `engines[i]` and `@` works. + pub engine_index: Option<usize>, +} + +/// User-defined override or addition to auto-generated shortcuts. +#[derive(Debug, Deserialize, Clone)] +pub struct BangOverride { + /// The trigger keyword (e.g. "w" for !w). + pub trigger: String, + /// Optional override URL template with `{}` placeholder. + pub url: Option<String>, + /// Optional display name override. + pub name: Option<String>, + /// Set to `false` to suppress an auto-generated shortcut. + #[serde(default)] + pub enabled: Option<bool>, +} + +impl EngineConfig { + /// Human-facing search-page URL for bang redirects (not the API URL). + pub fn bang_url(&self) -> String { + match self { + EngineConfig::Wikipedia { lang, .. } => { + let lang = lang.clone().unwrap_or_else(|| "en".into()); + format!("https://{lang}.wikipedia.org/w/index.php?search={{}}") + } + EngineConfig::Mdn { locale, .. } => { + let locale = locale.clone().unwrap_or_else(|| "en-US".into()); + format!("https://developer.mozilla.org/{locale}/search?q={{}}") + } + EngineConfig::CratesIo { .. } => "https://crates.io/search?q={}".into(), + EngineConfig::SearXng { instance, .. } => { + format!("{}/search?q={{}}", instance.trim_end_matches('/')) + } + EngineConfig::Generic { url, .. } => url.clone(), + } + } + + /// Auto-generated shortcode derived from engine type and locale. + pub fn auto_shortcode(&self) -> String { + match self { + EngineConfig::Wikipedia { lang, .. } => match lang.as_deref() { + None | Some("en") => "w".into(), + Some(l) => format!("w{l}"), + }, + EngineConfig::Mdn { locale, .. } => match locale.as_deref() { + None | Some("en-US") => "mdn".into(), + Some(l) => { + let short = l.split('-').next().unwrap_or(l); + format!("mdn{short}") + } + }, + EngineConfig::CratesIo { .. } => "crates".into(), + EngineConfig::SearXng { .. } => "sx".into(), + EngineConfig::Generic { name, .. } => name + .to_lowercase() + .replace(|c: char| !c.is_alphanumeric() && c != '-', "-") + .trim_matches('-') + .to_string(), + } + } + + /// Effective shortcode, respecting user override on the engine. + pub fn effective_shortcode(&self) -> String { + match self { + EngineConfig::CratesIo { shortcode, .. } + | EngineConfig::SearXng { shortcode, .. } + | EngineConfig::Wikipedia { shortcode, .. } + | EngineConfig::Mdn { shortcode, .. } + | EngineConfig::Generic { shortcode, .. } => { + shortcode.clone().unwrap_or_else(|| self.auto_shortcode()) + } + } + } + + /// Effective bang URL, respecting user override on the engine. + pub fn effective_bang_url(&self) -> String { + match self { + EngineConfig::CratesIo { bang_url, .. } + | EngineConfig::SearXng { bang_url, .. } + | EngineConfig::Wikipedia { bang_url, .. } + | EngineConfig::Mdn { bang_url, .. } + | EngineConfig::Generic { bang_url, .. } => { + bang_url.clone().unwrap_or_else(|| self.bang_url()) + } + } + } + + /// Whether `!` bang redirect is enabled for this engine. + pub fn is_bang_enabled(&self) -> bool { + match self { + EngineConfig::CratesIo { bang_enabled, .. } + | EngineConfig::SearXng { bang_enabled, .. } + | EngineConfig::Wikipedia { bang_enabled, .. } + | EngineConfig::Mdn { bang_enabled, .. } + | EngineConfig::Generic { bang_enabled, .. } => { + bang_enabled.unwrap_or(true) + } + } + } +} + /// Application configuration loaded from the TOML config file. /// /// # Example @@ -175,6 +319,9 @@ pub struct Config { /// Per-engine configuration. Each entry defines an enabled search engine. #[serde(default)] pub engines: Vec<EngineConfig>, + /// User-defined shortcut overrides and additions. + #[serde(default)] + pub bangs: Vec<BangOverride>, /// Default bookmark database path. #[serde(default)] pub db_path: Option<String>, @@ -238,6 +385,75 @@ impl Config { Config::default() } } + + /// Resolve all shortcuts by combining auto-generated engine shortcodes + /// with user-defined `[[bangs]]` overrides. + /// + /// Two-phase resolution: + /// 1. Walk engines, auto-generate or use per-engine `shortcode` override. + /// 2. Walk `[[bangs]]` entries: disable existing, add new, or merge + /// (replace url/name on an auto-generated entry). + /// + /// # Panics + /// + /// Panics if two engines produce the same shortcode. Set `shortcode` on + /// one of them to resolve the conflict. + pub fn resolve_shortcuts(&self) -> HashMap<String, Shortcut> { + let mut map: HashMap<String, Shortcut> = HashMap::new(); + + // Phase 1: auto-generate from configured engines + for (i, engine) in self.engines.iter().enumerate() { + if !engine.is_bang_enabled() { + continue; + } + let trigger = engine.effective_shortcode(); + let bang_url = engine.effective_bang_url(); + let name = engine.name().to_string(); + + if let Some(existing) = map.get(&trigger) { + panic!( + "Shortcode conflict: '{trigger}' is used by engine {existing_idx:?} \ + ({existing_name}) and engine #{i} ({name}). \ + Set `shortcode` on one of them to resolve.", + existing_idx = existing.engine_index, + existing_name = existing.name, + ); + } + + map.insert( + trigger.clone(), + Shortcut { + trigger, + bang_url, + name, + engine_index: Some(i), + }, + ); + } + + // Phase 2: apply user-defined [[bangs]] overrides/additions + for bang in &self.bangs { + let enabled = bang.enabled.unwrap_or(true); + if !enabled { + map.remove(&bang.trigger); + continue; + } + let entry = map.entry(bang.trigger.clone()).or_insert_with(|| Shortcut { + trigger: bang.trigger.clone(), + bang_url: String::new(), + name: String::new(), + engine_index: None, + }); + if let Some(url) = &bang.url { + entry.bang_url = url.clone(); + } + if let Some(name) = &bang.name { + entry.name = name.clone(); + } + } + + map + } } @@ -387,4 +603,244 @@ instance = "https://search.example.com" let cfg = Config::load_from(&file.path().to_path_buf()); assert_eq!(cfg.tagging_enabled, Some(true)); } + + // -- shortcut (bang/@) tests -- + + #[test] + fn auto_shortcode_wikipedia_defaults_to_w() { + let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.auto_shortcode(), "w"); + } + + #[test] + fn auto_shortcode_wikipedia_lang_appended() { + let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.auto_shortcode(), "wfr"); + } + + #[test] + fn auto_shortcode_wikipedia_en_is_w() { + let e = EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.auto_shortcode(), "w"); + } + + #[test] + fn auto_shortcode_mdn_defaults_to_mdn() { + let e = EngineConfig::Mdn { locale: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.auto_shortcode(), "mdn"); + } + + #[test] + fn auto_shortcode_mdn_locale_appended() { + let e = EngineConfig::Mdn { locale: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.auto_shortcode(), "mdnfr"); + } + + #[test] + fn auto_shortcode_crates_io() { + let e = EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.auto_shortcode(), "crates"); + } + + #[test] + fn auto_shortcode_searxng() { + let e = EngineConfig::SearXng { instance: "https://example.com".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.auto_shortcode(), "sx"); + } + + #[test] + fn auto_shortcode_generic_slugifies_name() { + let e = EngineConfig::Generic { name: "Stack Overflow".into(), url: "".into(), selector: "".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.auto_shortcode(), "stack-overflow"); + } + + #[test] + fn effective_shortcode_respects_override() { + let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: Some("wikifr".into()), bang_enabled: None, bang_url: None }; + assert_eq!(e.effective_shortcode(), "wikifr"); + } + + #[test] + fn bang_url_wikipedia() { + let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.bang_url(), "https://en.wikipedia.org/w/index.php?search={}"); + } + + #[test] + fn bang_url_wikipedia_fr() { + let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.bang_url(), "https://fr.wikipedia.org/w/index.php?search={}"); + } + + #[test] + fn bang_url_crates_io() { + let e = EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.bang_url(), "https://crates.io/search?q={}"); + } + + #[test] + fn bang_url_searxng() { + let e = EngineConfig::SearXng { instance: "https://search.example.com".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.bang_url(), "https://search.example.com/search?q={}"); + } + + #[test] + fn bang_url_mdn() { + let e = EngineConfig::Mdn { locale: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.bang_url(), "https://developer.mozilla.org/en-US/search?q={}"); + } + + #[test] + fn bang_url_generic_uses_provided_url() { + let e = EngineConfig::Generic { name: "Test".into(), url: "https://example.com/search?q={}".into(), selector: "".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert_eq!(e.bang_url(), "https://example.com/search?q={}"); + } + + #[test] + fn effective_bang_url_respects_override() { + let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: Some("https://custom.example.com/?q={}".into()) }; + assert_eq!(e.effective_bang_url(), "https://custom.example.com/?q={}"); + } + + #[test] + fn is_bang_enabled_defaults_true() { + let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; + assert!(e.is_bang_enabled()); + } + + #[test] + fn is_bang_enabled_false_when_set() { + let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: Some(false), bang_url: None }; + assert!(!e.is_bang_enabled()); + } + + #[test] + fn resolve_shortcuts_from_single_engine() { + let cfg = Config { + engines: vec![ + EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, + ], + ..Config::default() + }; + let sc = cfg.resolve_shortcuts(); + assert_eq!(sc.len(), 1); + assert!(sc.contains_key("crates")); + assert_eq!(sc["crates"].engine_index, Some(0)); + } + + #[test] + fn resolve_shortcuts_multiple_engines() { + let cfg = Config { + engines: vec![ + EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, + EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, + ], + ..Config::default() + }; + let sc = cfg.resolve_shortcuts(); + assert_eq!(sc.len(), 2); + assert!(sc.contains_key("w")); + assert!(sc.contains_key("crates")); + } + + #[test] + fn resolve_shortcuts_respects_engine_shortcode_override() { + let cfg = Config { + engines: vec![ + EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: Some("wiki".into()), bang_enabled: None, bang_url: None }, + ], + ..Config::default() + }; + let sc = cfg.resolve_shortcuts(); + assert!(sc.contains_key("wiki")); + assert!(!sc.contains_key("w")); + } + + #[test] + fn resolve_shortcuts_respects_engine_bang_disabled() { + let cfg = Config { + engines: vec![ + EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: Some(false), bang_url: None }, + ], + ..Config::default() + }; + let sc = cfg.resolve_shortcuts(); + assert!(sc.is_empty()); + } + + #[test] + fn resolve_shortcuts_user_bang_override_replaces_url() { + let cfg = Config { + engines: vec![ + EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, + ], + bangs: vec![ + BangOverride { trigger: "w".into(), url: Some("https://custom.example.com/?q={}".into()), name: None, enabled: None }, + ], + ..Config::default() + }; + let sc = cfg.resolve_shortcuts(); + assert_eq!(sc["w"].bang_url, "https://custom.example.com/?q={}"); + } + + #[test] + fn resolve_shortcuts_user_bang_disables_auto() { + let cfg = Config { + engines: vec![ + EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, + ], + bangs: vec![ + BangOverride { trigger: "w".into(), url: None, name: None, enabled: Some(false) }, + ], + ..Config::default() + }; + let sc = cfg.resolve_shortcuts(); + assert!(!sc.contains_key("w")); + } + + #[test] + fn resolve_shortcuts_user_bang_adds_new() { + let cfg = Config { + bangs: vec![ + BangOverride { trigger: "gh".into(), url: Some("https://github.com/search?q={}".into()), name: Some("GitHub".into()), enabled: None }, + ], + ..Config::default() + }; + let sc = cfg.resolve_shortcuts(); + assert!(sc.contains_key("gh")); + assert_eq!(sc["gh"].engine_index, None); + assert_eq!(sc["gh"].bang_url, "https://github.com/search?q={}"); + } + + #[test] + fn resolve_shortcuts_parses_bangs_from_config_file() { + let mut file = NamedTempFile::new().unwrap(); + write!(file, r#" +[[engines]] +type = "wikipedia" + +[[bangs]] +trigger = "gh" +url = "https://github.com/search?q={{}}" +name = "GitHub" +"#).unwrap(); + let cfg = Config::load_from(&file.path().to_path_buf()); + let sc = cfg.resolve_shortcuts(); + assert!(sc.contains_key("w")); + assert!(sc.contains_key("gh")); + assert_eq!(sc["gh"].engine_index, None); + } + + #[test] + #[should_panic(expected = "Shortcode conflict")] + fn resolve_shortcuts_panics_on_collision() { + let cfg = Config { + engines: vec![ + EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, + EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, + ], + ..Config::default() + }; + cfg.resolve_shortcuts(); + } } -
modified src/main.rs
diff --git a/src/main.rs b/src/main.rs index 8ba94f7..c7909aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -364,7 +364,9 @@ async fn main() { } }); - if let Err(e) = web::run_server(&db_path.to_string_lossy(), srv_cfg, engines).await { + // Panics if two engines share the same shortcode (config error). + let shortcuts = config.resolve_shortcuts(); + if let Err(e) = web::run_server(&db_path.to_string_lossy(), srv_cfg, engines, shortcuts).await { error!("Server error: {}", e); } } -
modified src/web/handlers.rs
diff --git a/src/web/handlers.rs b/src/web/handlers.rs index 299678a..f99e29f 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -1,7 +1,10 @@ -use crate::config::EngineConfig; +use crate::config::{EngineConfig, Shortcut}; +use crate::search_engines::utils::urlencode; use crate::search_engines::{EngineError, ResultEntry, SearchEngine}; use crate::storage; -use actix_web::{get, web, HttpRequest, HttpResponse, Responder}; +use actix_web::{get, http::header::LOCATION, web, HttpRequest, HttpResponse, Responder}; +use serde::Serialize; +use std::collections::HashMap; use std::sync::{Arc, RwLock}; use std::time::Instant; use tera::Tera; @@ -9,9 +12,138 @@ use tracing::{error, info}; use super::{ interleave, ApiBookmark, ApiExternal, DbPool, SearchApiResponse, SearchApiResult, SearchQuery, - ServerConfig, USER_AGENT, VERSION, + ServerConfig, ShortcutHelp, USER_AGENT, VERSION, }; +enum ShortcutKind { + Bang(String), + At(usize), +} + +/// Parse a !bang or @at shortcut from the start of a query. +fn parse_shortcut<'a>( + q: &'a str, + shortcuts: &HashMap<String, Shortcut>, +) -> Option<(ShortcutKind, &'a str)> { + if q.is_empty() { + return None; + } + // Split off the prefix (! or @) and extract the trigger word up to the first space. + // e.g. "!w Rust" -> prefix='!', trigger="w", rest="Rust" + let (prefix, body) = if let Some(body) = q.strip_prefix('!') { + ('!', body) + } else if let Some(body) = q.strip_prefix('@') { + ('@', body) + } else { + return None; + }; + let (trigger, rest) = if let Some(space) = body.find(' ') { + (&body[..space], &body[space + 1..]) + } else { + (body, "") // bare shortcut, no query text + }; + if trigger.is_empty() { + return None; + } + let sc = shortcuts.get(trigger)?; + match prefix { + '!' => Some((ShortcutKind::Bang(sc.bang_url.clone()), rest)), + '@' => Some((ShortcutKind::At(sc.engine_index?), rest)), // `@` requires a real engine + _ => unreachable!(), + } +} + +/// Build the sorted list of shortcuts for the help overlay in the web UI. +/// Entries with `engine_index: None` (standalone [[bangs]]) get `has_at: false`. +fn build_shortcuts_help(shortcuts: &HashMap<String, Shortcut>) -> Vec<ShortcutHelp> { + let mut help: Vec<ShortcutHelp> = shortcuts + .values() + .map(|sc| ShortcutHelp { + trigger: sc.trigger.clone(), + name: sc.name.clone(), + has_at: sc.engine_index.is_some(), + }) + .collect(); + help.sort_by(|a, b| a.trigger.cmp(&b.trigger)); + help +} + +fn run_external_engines( + q: &str, + user_agent: &str, + engines: &[EngineConfig], + engine_filter: Option<usize>, +) -> (Vec<Vec<ResultEntry>>, usize) { + let mut all_external: Vec<Vec<ResultEntry>> = Vec::new(); + let mut provider_count: usize = 0; + + let client = match reqwest::Client::builder() + .user_agent(user_agent) + .build() + .ok() + { + Some(c) => c, + None => return (all_external, 0), + }; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime"); + + let snapshot = engines.to_vec(); + let mut handles = Vec::new(); + + for i in 0..snapshot.len() { + if let Some(filter) = engine_filter { + if i != filter { + continue; + } + } + let engine_name = snapshot[i].name().to_string(); + let q_owned = q.to_string(); + let client = client.clone(); + let engines = snapshot.clone(); + + handles.push(rt.spawn(async move { + let t0 = Instant::now(); + let timeout_dur = engines[i].timeout(); + let result = tokio::time::timeout( + timeout_dur, + engines[i].fetch_results(&q_owned, &client), + ) + .await; + let elapsed = t0.elapsed(); + match result { + Ok(Ok(results)) => (engine_name, Ok(results), elapsed), + Ok(Err(e)) => (engine_name, Err(e), elapsed), + Err(_) => ( + engine_name, + Err(EngineError(format!("timed out after {timeout_dur:?}"))), + elapsed, + ), + } + })); + } + + for handle in handles { + if let Ok((name, result, elapsed)) = rt.block_on(handle) { + provider_count += 1; + match result { + Ok(results) => { + info!("external {} ({} results) [{:.2?}]", name, results.len(), elapsed); + all_external.push(results); + } + Err(e) => { + info!("external {} (error) [{:.2?}]: {}", name, elapsed, e); + } + } + } + } + + (all_external, provider_count) +} + #[get("/")] async fn index(templates: web::Data<Tera>, cfg: web::Data<ServerConfig>) -> impl Responder { info!("serving index page"); @@ -48,23 +180,44 @@ async fn search( db_pool: web::Data<DbPool>, engines: web::Data<Arc<RwLock<Vec<EngineConfig>>>>, cfg: web::Data<ServerConfig>, + shortcuts: web::Data<HashMap<String, Shortcut>>, ) -> impl Responder { let start = Instant::now(); - let q = query.q.as_deref().unwrap_or(""); + let q_orig = query.q.as_deref().unwrap_or(""); let page = query.page.unwrap_or(1).max(1); let page_size = cfg.page_size; + + // Check for !bang / @at prefix before doing any other work. + // '!' triggers an immediate HTTP redirect; '@' filters external search + // to a single engine but still shows local bookmark results. + let (q, engine_filter) = + match parse_shortcut(q_orig, &shortcuts) { + Some((ShortcutKind::Bang(url), rest)) => { + let redirect_url = url.replace("{}", &urlencode(rest)); + info!("bang redirect: {} -> {}", q_orig, redirect_url); + return HttpResponse::Found() + .append_header((LOCATION, redirect_url)) + .finish(); + } + Some((ShortcutKind::At(idx), rest)) => { + info!("at-filtered search: {} -> engine[{}]", q_orig, idx); + (rest.to_string(), Some(idx)) + } + None => (q_orig.to_string(), None), + }; + let has_query = !q.is_empty(); info!("search request: query=\"{}\" page={}", q, page); let total_results = if has_query { - storage::count_search_bookmarks(&db_pool.conn(), q).unwrap_or(0) + storage::count_search_bookmarks(&db_pool.conn(), &q).unwrap_or(0) } else { storage::count_bookmarks(&db_pool.conn()).unwrap_or(0) }; let total_pages = total_results.div_ceil(page_size); let bookmarks = if has_query { - storage::search_bookmarks(&db_pool.conn(), q, page, page_size).unwrap_or_default() + storage::search_bookmarks(&db_pool.conn(), &q, page, page_size).unwrap_or_default() } else { storage::list_bookmarks(&db_pool.conn(), page, page_size).unwrap_or_default() }; @@ -75,57 +228,12 @@ async fn search( .and_then(|v| v.to_str().ok()) .unwrap_or(USER_AGENT); - let mut all_external: Vec<Vec<ResultEntry>> = Vec::new(); - let mut provider_count: usize = 0; - if has_query { - let client = reqwest::Client::builder() - .user_agent(user_agent) - .build() - .ok(); - if let Some(client) = client { - let snapshot = engines.read().unwrap().clone(); - let mut handles = Vec::new(); - for i in 0..snapshot.len() { - let engine_name = snapshot[i].name().to_string(); - let q_owned = q.to_string(); - let client = client.clone(); - let engines = snapshot.clone(); - handles.push(tokio::spawn(async move { - let t0 = Instant::now(); - let timeout_dur = engines[i].timeout(); - let result = tokio::time::timeout( - timeout_dur, - engines[i].fetch_results(&q_owned, &client), - ) - .await; - let elapsed = t0.elapsed(); - match result { - Ok(Ok(results)) => (engine_name, Ok(results), elapsed), - Ok(Err(e)) => (engine_name, Err(e), elapsed), - Err(_) => ( - engine_name, - Err(EngineError(format!("timed out after {timeout_dur:?}"))), - elapsed, - ), - } - })); - } - for handle in handles { - if let Ok((name, result, elapsed)) = handle.await { - provider_count += 1; - match result { - Ok(results) => { - info!("external {} ({} results) [{:.2?}]", name, results.len(), elapsed); - all_external.push(results); - } - Err(e) => { - info!("external {} (error) [{:.2?}]: {}", name, elapsed, e); - } - } - } - } - } - } + let snapshot = engines.read().unwrap().clone(); + let (all_external, provider_count) = if has_query { + run_external_engines(&q, user_agent, &snapshot, engine_filter) + } else { + (Vec::new(), 0) + }; let external_results = interleave(&all_external); @@ -145,6 +253,8 @@ async fn search( external_engines.sort(); external_engines.dedup(); + let shortcuts_help = build_shortcuts_help(&shortcuts); + let mut ctx = tera::Context::new(); ctx.insert("bookmarks", &bookmarks); ctx.insert("query", &q); @@ -155,6 +265,7 @@ async fn search( ctx.insert("page_time_ms", &page_time_ms); ctx.insert("external_results", &external_results); ctx.insert("external_engines", &external_engines); + ctx.insert("shortcuts", &shortcuts_help); match templates.render("index.html", &ctx) { Ok(rendered) => HttpResponse::Ok().content_type("text/html").body(rendered), @@ -165,6 +276,15 @@ async fn search( } } +#[derive(Serialize)] +struct BangApiResponse { + #[serde(rename = "type")] + kind: &'static str, + trigger: String, + query: String, + redirect_url: String, +} + #[get("/api/search")] async fn api_search( req: HttpRequest, @@ -172,22 +292,45 @@ async fn api_search( cfg: web::Data<ServerConfig>, db_pool: web::Data<DbPool>, engines: web::Data<Arc<RwLock<Vec<EngineConfig>>>>, + shortcuts: web::Data<HashMap<String, Shortcut>>, ) -> impl Responder { let start = Instant::now(); - let q = query.q.as_deref().unwrap_or(""); + let q_orig = query.q.as_deref().unwrap_or(""); let page = query.page.unwrap_or(1).max(1); let page_size = query.page_size.unwrap_or(cfg.page_size).min(100); + + // Same shortcut logic as /search, but the API JSON response for bangs + // includes the redirect URL instead of performing the redirect server-side. + let (q, engine_filter) = + match parse_shortcut(q_orig, &shortcuts) { + Some((ShortcutKind::Bang(url), rest)) => { + let redirect_url = url.replace("{}", &urlencode(rest)); + info!("api bang redirect: {} -> {}", q_orig, redirect_url); + return HttpResponse::Ok().json(BangApiResponse { + kind: "bang", + trigger: q_orig.to_string(), + query: rest.to_string(), + redirect_url, + }); + } + Some((ShortcutKind::At(idx), rest)) => { + info!("api at-filtered search: {} -> engine[{}]", q_orig, idx); + (rest.to_string(), Some(idx)) + } + None => (q_orig.to_string(), None), + }; + let has_query = !q.is_empty(); let total_results = if has_query { - storage::count_search_bookmarks(&db_pool.conn(), q).unwrap_or(0) + storage::count_search_bookmarks(&db_pool.conn(), &q).unwrap_or(0) } else { storage::count_bookmarks(&db_pool.conn()).unwrap_or(0) }; let total_pages = total_results.div_ceil(page_size); let bookmarks = if has_query { - storage::search_bookmarks(&db_pool.conn(), q, page, page_size).unwrap_or_default() + storage::search_bookmarks(&db_pool.conn(), &q, page, page_size).unwrap_or_default() } else { storage::list_bookmarks(&db_pool.conn(), page, page_size).unwrap_or_default() }; @@ -198,57 +341,10 @@ async fn api_search( .and_then(|v| v.to_str().ok()) .unwrap_or(USER_AGENT); + let snapshot = engines.read().unwrap().clone(); let external_results = if has_query { - let client = reqwest::Client::builder() - .user_agent(user_agent) - .build() - .ok(); - if let Some(client) = client { - let snapshot = engines.read().unwrap().clone(); - let mut handles = Vec::new(); - for i in 0..snapshot.len() { - let engine_name = snapshot[i].name().to_string(); - let q_owned = q.to_string(); - let client = client.clone(); - let engines = snapshot.clone(); - handles.push(tokio::spawn(async move { - let t0 = Instant::now(); - let timeout_dur = engines[i].timeout(); - let result = tokio::time::timeout( - timeout_dur, - engines[i].fetch_results(&q_owned, &client), - ) - .await; - let elapsed = t0.elapsed(); - match result { - Ok(Ok(results)) => (engine_name, Ok(results), elapsed), - Ok(Err(e)) => (engine_name, Err(e), elapsed), - Err(_) => ( - engine_name, - Err(EngineError(format!("timed out after {timeout_dur:?}"))), - elapsed, - ), - } - })); - } - let mut all_external: Vec<Vec<ResultEntry>> = Vec::new(); - for handle in handles { - if let Ok((name, result, elapsed)) = handle.await { - match result { - Ok(results) => { - info!("external {} ({} results) [{:.2?}]", name, results.len(), elapsed); - all_external.push(results); - } - Err(e) => { - info!("external {} (error) [{:.2?}]: {}", name, elapsed, e); - } - } - } - } - interleave(&all_external) - } else { - Vec::new() - } + let (all_external, _) = run_external_engines(&q, user_agent, &snapshot, engine_filter); + interleave(&all_external) } else { Vec::new() }; -
modified src/web/mod.rs
diff --git a/src/web/mod.rs b/src/web/mod.rs index 9374c6f..c12755b 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,12 +1,13 @@ pub mod handlers; -use crate::config::EngineConfig; +use crate::config::{EngineConfig, Shortcut}; use crate::search_engines::ResultEntry; use crate::storage; use actix_web::{web, App, HttpServer}; use chrono::{DateTime, Utc}; use rusqlite::Connection; use serde::Serialize; +use std::collections::HashMap; use std::sync::{Arc, Mutex, RwLock}; use tera::Tera; use tracing::info; @@ -41,6 +42,15 @@ pub struct ServerConfig { pub workers: usize, } +/// Simplified shortcut info for rendering in Tera templates. +/// `has_at` is `false` for standalone [[bangs]] that have no engine backing. +#[derive(Serialize)] +pub struct ShortcutHelp { + pub trigger: String, + pub name: String, + pub has_at: bool, +} + #[derive(serde::Deserialize)] pub struct SearchQuery { pub q: Option<String>, @@ -91,10 +101,12 @@ pub async fn run_server( db_path: &str, cfg: ServerConfig, engines: Arc<RwLock<Vec<EngineConfig>>>, + shortcuts: HashMap<String, Shortcut>, ) -> std::io::Result<()> { let db_pool = web::Data::new(DbPool::new(db_path)); let engines = web::Data::new(engines); let cfg = web::Data::new(cfg); + let shortcuts = web::Data::new(shortcuts); let mut tera = Tera::default(); tera.add_raw_template("index.html", include_str!("../../templates/index.html")) .expect("Failed to parse index template"); @@ -117,6 +129,7 @@ pub async fn run_server( .app_data(db_pool.clone()) .app_data(engines.clone()) .app_data(cfg.clone()) + .app_data(shortcuts.clone()) .service(handlers::index) .service(handlers::search) .service(handlers::api_search) -
modified templates/index.html
diff --git a/templates/index.html b/templates/index.html index da100ad..00e8215 100644 --- a/templates/index.html +++ b/templates/index.html @@ -462,6 +462,15 @@ <div class="help-row"><span class="help-key">o</span><span class="help-desc">open result</span></div> <div class="help-row"><span class="help-key">?</span><span class="help-desc">toggle this help</span></div> <div class="help-row"><span class="help-key">esc</span><span class="help-desc">close / blur</span></div> +{% if shortcuts %} + <h2>search shortcuts</h2> +{% for sc in shortcuts %} + <div class="help-row"><span class="help-key">!{{ sc.trigger }} <i>query</i></span><span class="help-desc">redirect to {{ sc.name }}</span></div> +{% if sc.has_at %} + <div class="help-row"><span class="help-key">@{{ sc.trigger }} <i>query</i></span><span class="help-desc">search only {{ sc.name }}</span></div> +{% endif %} +{% endfor %} +{% endif %} </div> </div>