Skip to main content

max / goingson

6.9 KB · 199 lines History Blame Raw
1 //! Full-text search commands.
2 //!
3 //! Provides unified search across tasks, emails, projects, and events
4 //! using SQLite FTS5 full-text search with learnable filter syntax.
5 //!
6 //! # Filter Syntax
7 //!
8 //! The search query supports embedded filters:
9 //! - `is:overdue`, `is:today`, `is:snoozed`, `is:waiting`
10 //! - `priority:high`, `priority:medium`, `priority:low`
11 //! - `type:task`, `type:email`, `type:event`, `type:project`
12 //! - `in:ProjectName` - filter by project
13 //! - `tag:work`, `-tag:personal` - include/exclude tags
14
15 use chrono::{DateTime, Utc};
16 use serde::{Deserialize, Serialize};
17 use std::sync::Arc;
18 use tauri::State;
19 use tracing::instrument;
20 use uuid::Uuid;
21
22 use goingson_core::{search_parser, ProjectId, SearchQuery, SearchResultItem, SearchResultType};
23
24 use crate::state::{AppState, DESKTOP_USER_ID};
25 use super::ApiError;
26
27 // ============ Types ============
28
29 #[derive(Debug, Deserialize)]
30 #[serde(rename_all = "camelCase")]
31 pub struct SearchInput {
32 pub query: String,
33 #[serde(rename = "type")]
34 pub result_type: Option<String>,
35 pub project_id: Option<ProjectId>,
36 pub date_from: Option<DateTime<Utc>>,
37 pub date_to: Option<DateTime<Utc>>,
38 pub limit: Option<i64>,
39 pub offset: Option<i64>,
40 }
41
42 #[derive(Debug, Serialize)]
43 #[serde(rename_all = "camelCase")]
44 pub struct SearchResultResponse {
45 pub id: Uuid,
46 pub result_type: String,
47 pub title: String,
48 pub snippet: Option<String>,
49 pub project_id: Option<ProjectId>,
50 pub project_name: Option<String>,
51 pub rank: f64,
52 }
53
54 impl From<SearchResultItem> for SearchResultResponse {
55 fn from(item: SearchResultItem) -> Self {
56 SearchResultResponse {
57 id: item.id,
58 result_type: match item.result_type {
59 SearchResultType::Task => "task".to_string(),
60 SearchResultType::Email => "email".to_string(),
61 SearchResultType::Project => "project".to_string(),
62 SearchResultType::Event => "event".to_string(),
63 SearchResultType::Contact => "contact".to_string(),
64 },
65 title: item.title,
66 snippet: item.snippet,
67 project_id: item.project_id,
68 project_name: item.project_name,
69 rank: item.rank,
70 }
71 }
72 }
73
74 #[derive(Debug, Serialize)]
75 #[serde(rename_all = "camelCase")]
76 pub struct SearchResultsResponse {
77 pub results: Vec<SearchResultResponse>,
78 pub query: String,
79 pub total: usize,
80 /// Active filters parsed from the query (for UI sync)
81 pub active_filters: Vec<String>,
82 }
83
84 // ============ Commands ============
85
86 /// Performs a full-text search across tasks, emails, projects, and events.
87 ///
88 /// Supports both free-text search and filter syntax embedded in the query.
89 /// Filters are parsed and reflected in `active_filters` for UI synchronization.
90 ///
91 /// # Arguments
92 ///
93 /// * `input` - Search parameters:
94 /// - `query`: Search text with optional embedded filters
95 /// - `result_type`: Comma-separated type filter (task, email, project, event)
96 /// - `project_id`: Filter to a specific project
97 /// - `date_from`/`date_to`: Date range filter
98 /// - `limit`/`offset`: Pagination (default limit: 50)
99 ///
100 /// # Errors
101 ///
102 /// Returns `DATABASE_ERROR` if the search query fails.
103 #[tauri::command]
104 #[instrument(skip_all)]
105 pub async fn search(state: State<'_, Arc<AppState>>, input: SearchInput) -> Result<SearchResultsResponse, ApiError> {
106 // Parse the query to extract filters
107 let parsed = search_parser::parse_search_query(&input.query);
108
109 // Collect active filter labels for UI sync
110 let mut active_filters: Vec<String> = Vec::new();
111 for f in &parsed.is_filters {
112 let label = match f {
113 search_parser::IsFilter::Overdue => "is:overdue",
114 search_parser::IsFilter::Today => "is:today",
115 search_parser::IsFilter::Tomorrow => "is:tomorrow",
116 search_parser::IsFilter::ThisWeek => "is:thisweek",
117 search_parser::IsFilter::Snoozed => "is:snoozed",
118 search_parser::IsFilter::Pending => "is:pending",
119 search_parser::IsFilter::Started => "is:started",
120 search_parser::IsFilter::Completed => "is:completed",
121 search_parser::IsFilter::Waiting => "is:waiting",
122 };
123 active_filters.push(label.to_string());
124 }
125 if let Some(ref p) = parsed.priority {
126 let label = match p {
127 goingson_core::Priority::High => "priority:high",
128 goingson_core::Priority::Medium => "priority:medium",
129 goingson_core::Priority::Low => "priority:low",
130 };
131 active_filters.push(label.to_string());
132 }
133 if let Some(ref name) = parsed.project_name {
134 active_filters.push(format!("in:{}", name));
135 }
136 for t in &parsed.result_types {
137 let label = match t {
138 SearchResultType::Task => "type:task",
139 SearchResultType::Email => "type:email",
140 SearchResultType::Event => "type:event",
141 SearchResultType::Project => "type:project",
142 SearchResultType::Contact => "type:contact",
143 };
144 active_filters.push(label.to_string());
145 }
146 for tag in &parsed.tags_include {
147 active_filters.push(format!("tag:{}", tag));
148 }
149 for tag in &parsed.tags_exclude {
150 active_filters.push(format!("-tag:{}", tag));
151 }
152
153 // Parse explicit type filter from input (comma-separated)
154 let explicit_types = input.result_type.as_ref().map(|t| {
155 t.split(',')
156 .filter_map(|s| match s.trim().to_lowercase().as_str() {
157 "task" => Some(SearchResultType::Task),
158 "email" => Some(SearchResultType::Email),
159 "project" => Some(SearchResultType::Project),
160 "event" => Some(SearchResultType::Event),
161 "contact" => Some(SearchResultType::Contact),
162 _ => None,
163 })
164 .collect::<Vec<_>>()
165 }).filter(|v| !v.is_empty());
166
167 // Merge parsed types with explicit types
168 let types = if !parsed.result_types.is_empty() {
169 Some(parsed.result_types.clone())
170 } else {
171 explicit_types
172 };
173
174 // Build the search query
175 let query = SearchQuery {
176 query: parsed.text.clone(),
177 types,
178 project_id: input.project_id,
179 project_name: parsed.project_name.clone(),
180 date_from: parsed.date_from.or(input.date_from),
181 date_to: parsed.date_to.or(input.date_to),
182 limit: input.limit.or(Some(50)),
183 offset: input.offset,
184 is_filters: parsed.is_filters.clone(),
185 priority: parsed.priority.clone(),
186 tags_include: parsed.tags_include.clone(),
187 tags_exclude: parsed.tags_exclude.clone(),
188 };
189
190 let (results, total) = state.search.search(DESKTOP_USER_ID, query).await?;
191
192 Ok(SearchResultsResponse {
193 results: results.into_iter().map(SearchResultResponse::from).collect(),
194 query: input.query,
195 total,
196 active_filters,
197 })
198 }
199