//! Full-text search commands. //! //! Provides unified search across tasks, emails, projects, and events //! using SQLite FTS5 full-text search with learnable filter syntax. //! //! # Filter Syntax //! //! The search query supports embedded filters: //! - `is:overdue`, `is:today`, `is:snoozed`, `is:waiting` //! - `priority:high`, `priority:medium`, `priority:low` //! - `type:task`, `type:email`, `type:event`, `type:project` //! - `in:ProjectName` - filter by project //! - `tag:work`, `-tag:personal` - include/exclude tags use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use uuid::Uuid; use goingson_core::{search_parser, ProjectId, SearchQuery, SearchResultItem, SearchResultType}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::ApiError; // ============ Types ============ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchInput { pub query: String, #[serde(rename = "type")] pub result_type: Option, pub project_id: Option, pub date_from: Option>, pub date_to: Option>, pub limit: Option, pub offset: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SearchResultResponse { pub id: Uuid, pub result_type: String, pub title: String, pub snippet: Option, pub project_id: Option, pub project_name: Option, pub rank: f64, } impl From for SearchResultResponse { fn from(item: SearchResultItem) -> Self { SearchResultResponse { id: item.id, result_type: match item.result_type { SearchResultType::Task => "task".to_string(), SearchResultType::Email => "email".to_string(), SearchResultType::Project => "project".to_string(), SearchResultType::Event => "event".to_string(), SearchResultType::Contact => "contact".to_string(), }, title: item.title, snippet: item.snippet, project_id: item.project_id, project_name: item.project_name, rank: item.rank, } } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SearchResultsResponse { pub results: Vec, pub query: String, pub total: usize, /// Active filters parsed from the query (for UI sync) pub active_filters: Vec, } // ============ Commands ============ /// Performs a full-text search across tasks, emails, projects, and events. /// /// Supports both free-text search and filter syntax embedded in the query. /// Filters are parsed and reflected in `active_filters` for UI synchronization. /// /// # Arguments /// /// * `input` - Search parameters: /// - `query`: Search text with optional embedded filters /// - `result_type`: Comma-separated type filter (task, email, project, event) /// - `project_id`: Filter to a specific project /// - `date_from`/`date_to`: Date range filter /// - `limit`/`offset`: Pagination (default limit: 50) /// /// # Errors /// /// Returns `DATABASE_ERROR` if the search query fails. #[tauri::command] #[instrument(skip_all)] pub async fn search(state: State<'_, Arc>, input: SearchInput) -> Result { // Parse the query to extract filters let parsed = search_parser::parse_search_query(&input.query); // Collect active filter labels for UI sync let mut active_filters: Vec = Vec::new(); for f in &parsed.is_filters { let label = match f { search_parser::IsFilter::Overdue => "is:overdue", search_parser::IsFilter::Today => "is:today", search_parser::IsFilter::Tomorrow => "is:tomorrow", search_parser::IsFilter::ThisWeek => "is:thisweek", search_parser::IsFilter::Snoozed => "is:snoozed", search_parser::IsFilter::Pending => "is:pending", search_parser::IsFilter::Started => "is:started", search_parser::IsFilter::Completed => "is:completed", search_parser::IsFilter::Waiting => "is:waiting", }; active_filters.push(label.to_string()); } if let Some(ref p) = parsed.priority { let label = match p { goingson_core::Priority::High => "priority:high", goingson_core::Priority::Medium => "priority:medium", goingson_core::Priority::Low => "priority:low", }; active_filters.push(label.to_string()); } if let Some(ref name) = parsed.project_name { active_filters.push(format!("in:{}", name)); } for t in &parsed.result_types { let label = match t { SearchResultType::Task => "type:task", SearchResultType::Email => "type:email", SearchResultType::Event => "type:event", SearchResultType::Project => "type:project", SearchResultType::Contact => "type:contact", }; active_filters.push(label.to_string()); } for tag in &parsed.tags_include { active_filters.push(format!("tag:{}", tag)); } for tag in &parsed.tags_exclude { active_filters.push(format!("-tag:{}", tag)); } // Parse explicit type filter from input (comma-separated) let explicit_types = input.result_type.as_ref().map(|t| { t.split(',') .filter_map(|s| match s.trim().to_lowercase().as_str() { "task" => Some(SearchResultType::Task), "email" => Some(SearchResultType::Email), "project" => Some(SearchResultType::Project), "event" => Some(SearchResultType::Event), "contact" => Some(SearchResultType::Contact), _ => None, }) .collect::>() }).filter(|v| !v.is_empty()); // Merge parsed types with explicit types let types = if !parsed.result_types.is_empty() { Some(parsed.result_types.clone()) } else { explicit_types }; // Build the search query let query = SearchQuery { query: parsed.text.clone(), types, project_id: input.project_id, project_name: parsed.project_name.clone(), date_from: parsed.date_from.or(input.date_from), date_to: parsed.date_to.or(input.date_to), limit: input.limit.or(Some(50)), offset: input.offset, is_filters: parsed.is_filters.clone(), priority: parsed.priority.clone(), tags_include: parsed.tags_include.clone(), tags_exclude: parsed.tags_exclude.clone(), }; let (results, total) = state.search.search(DESKTOP_USER_ID, query).await?; Ok(SearchResultsResponse { results: results.into_iter().map(SearchResultResponse::from).collect(), query: input.query, total, active_filters, }) }