1use crate::ids::{ProjectId, TaskId, WorkspaceId};
2use anyhow::Result;
3use chrono::Local;
4use serde::Serialize;
5use sqlx::{QueryBuilder, Row, Sqlite, SqliteConnection};
6use std::collections::{HashMap, HashSet};
7
8use crate::db::task_from_row;
9use crate::projects::resolve_existing_project_in_workspace;
10use crate::refs::DisplayRefContext;
11use crate::task_enrichment::{epic_parents_for_tasks, labels_for_tasks};
12use crate::types::Task;
13
14use super::hydration::{TaskHydration, build_task_list_items};
15use super::{
16 MetadataFilter, SortDirection, TaskFilters, TaskIdFilter, TaskListItem, TaskQueryMode,
17 TaskSort, validate_search_limit,
18};
19
20mod parser;
21
22const SQLITE_BIND_CHUNK_SIZE: usize = 900;
23
24const REF_WEIGHT: i64 = 1_000;
25const TITLE_WEIGHT: i64 = 420;
26const LABEL_WEIGHT: i64 = 240;
27const PROJECT_WEIGHT: i64 = 220;
28const STATUS_WEIGHT: i64 = 160;
29const PRIORITY_WEIGHT: i64 = 150;
30const DESCRIPTION_WEIGHT: i64 = 100;
31const ATTACHMENT_WEIGHT: i64 = 90;
32const NOTE_WEIGHT: i64 = 80;
33const FIELD_MATCH_BONUS: i64 = 35_000;
34const EXTRA_FIELD_BONUS: i64 = 18_000;
35const FIELD_SCORE_DIVISOR: i64 = 5;
36const PRIORITY_BOOST_CAP: i64 = 18_000;
37const RECENCY_BOOST_CAP: i64 = 12_000;
38
39#[derive(Debug, Clone)]
40pub struct TaskSearchQuery {
41 pub text: String,
42 pub project: Option<String>,
43 pub metadata: Vec<MetadataFilter>,
44 pub has_metadata: Vec<String>,
45 pub missing_metadata: Vec<String>,
46 pub include_deleted: bool,
47 pub limit: usize,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum SearchMatchedField {
53 Ref,
54 Title,
55 Label,
56 Project,
57 Status,
58 Priority,
59 Description,
60 Attachment,
61 Note,
62}
63
64impl SearchMatchedField {
65 pub fn as_str(self) -> &'static str {
66 match self {
67 Self::Ref => "ref",
68 Self::Title => "title",
69 Self::Label => "label",
70 Self::Project => "project",
71 Self::Status => "status",
72 Self::Priority => "priority",
73 Self::Description => "description",
74 Self::Attachment => "attachment",
75 Self::Note => "note",
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
81pub struct TaskSearchResult {
82 pub item: TaskListItem,
83 pub score: i64,
84 pub matched_field: SearchMatchedField,
85 pub snippet: Option<String>,
86}
87
88#[derive(Debug, Clone)]
89pub struct TaskSearchResultSet {
90 pub items: Vec<TaskSearchResult>,
91}
92
93#[derive(Debug, Clone)]
94pub struct TaskSearchPreviewResult {
95 pub task_id: TaskId,
96 pub display_ref: String,
97 pub title: String,
98 pub project_key: String,
99 pub status: String,
100 pub priority: String,
101 pub created_at: String,
102 pub labels: Vec<String>,
103 pub deleted: bool,
104 pub is_epic: bool,
105 pub epic_parent_display_ref: Option<String>,
106 pub score: i64,
107 pub matched_field: SearchMatchedField,
108 pub snippet: Option<String>,
109}
110
111#[derive(Debug, Clone)]
112pub struct TaskSearchPreviewResultSet {
113 pub items: Vec<TaskSearchPreviewResult>,
114 pub total_matches: usize,
115}
116
117struct ScoredSearchResults {
118 items: Vec<ScoredDocument>,
119}
120
121struct SearchDocument {
122 task: Task,
123 display_ref: String,
124 project_name: String,
125 labels_text: String,
126 notes_text: String,
127 attachments_text: String,
128 series: Option<DocumentSeries>,
129}
130
131struct DocumentSeries {
135 id: String,
136 display_ref: String,
137}
138
139struct ScoredDocument {
140 document: SearchDocument,
141 score: i64,
142 matched_field: SearchMatchedField,
143 snippet: Option<String>,
144}
145
146struct FieldEvidence {
147 score: i64,
148 matched_field: SearchMatchedField,
149 snippet: Option<String>,
150}
151
152pub async fn search_task_items_in_workspace(
153 conn: &mut SqliteConnection,
154 workspace_id: &WorkspaceId,
155 query: TaskSearchQuery,
156) -> Result<Vec<TaskSearchResult>> {
157 validate_search_limit(query.limit)?;
158 Ok(
159 search_task_item_set_with_presentation(conn, workspace_id, query, true)
160 .await?
161 .items,
162 )
163}
164
165pub async fn search_task_occurrence_items_in_workspace(
166 conn: &mut SqliteConnection,
167 workspace_id: &WorkspaceId,
168 query: TaskSearchQuery,
169) -> Result<Vec<TaskSearchResult>> {
170 validate_search_limit(query.limit)?;
171 Ok(
172 search_task_item_set_with_presentation(conn, workspace_id, query, false)
173 .await?
174 .items,
175 )
176}
177
178async fn search_task_item_set_with_presentation(
179 conn: &mut SqliteConnection,
180 workspace_id: &WorkspaceId,
181 query: TaskSearchQuery,
182 group_recurring: bool,
183) -> Result<TaskSearchResultSet> {
184 let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
185 let scored = scored_search_documents(conn, workspace_id, &query, &display_refs).await?;
186 let tasks = scored
187 .items
188 .iter()
189 .map(|scored| scored.document.task.clone())
190 .collect::<Vec<_>>();
191 let now_seconds = crate::queue::now_seconds();
192 let items = build_task_list_items(
193 conn,
194 workspace_id,
195 tasks,
196 now_seconds,
197 Local::now().date_naive(),
198 &display_refs,
199 TaskHydration::Detail,
200 )
201 .await?;
202 let by_id = items
203 .into_iter()
204 .map(|item| (item.task.id.clone(), item))
205 .collect::<HashMap<_, _>>();
206 let results = scored
207 .items
208 .into_iter()
209 .filter_map(|scored| {
210 by_id
211 .get(&scored.document.task.id)
212 .cloned()
213 .map(|item| TaskSearchResult {
214 item,
215 score: scored.score,
216 matched_field: scored.matched_field,
217 snippet: scored.snippet,
218 })
219 })
220 .collect::<Vec<_>>();
221 let metadata = results
222 .iter()
223 .map(|result| {
224 (
225 result.item.task.id.clone(),
226 (result.score, result.matched_field, result.snippet.clone()),
227 )
228 })
229 .collect::<HashMap<_, _>>();
230 let grouped = if group_recurring {
231 let at = crate::ids::now_utc();
232 super::recurrence::group_search_task_items(
233 conn,
234 workspace_id,
235 results.into_iter().map(|result| result.item).collect(),
236 at,
237 )
238 .await?
239 } else {
240 results.into_iter().map(|result| result.item).collect()
241 };
242 let items = grouped
243 .into_iter()
244 .filter_map(|item| {
245 metadata
246 .get(&item.task.id)
247 .map(|metadata| TaskSearchResult {
248 item,
249 score: metadata.0,
250 matched_field: metadata.1,
251 snippet: metadata.2.clone(),
252 })
253 })
254 .collect();
255 Ok(TaskSearchResultSet { items })
256}
257
258async fn scored_search_documents(
259 conn: &mut SqliteConnection,
260 workspace_id: &WorkspaceId,
261 query: &TaskSearchQuery,
262 display_refs: &DisplayRefContext,
263) -> Result<ScoredSearchResults> {
264 validate_search_limit(query.limit)?;
265 let limit = query.limit;
266 let parsed = parser::parse_task_search_query(&query.text);
267 if parsed.trimmed.is_empty() {
268 return Ok(ScoredSearchResults { items: Vec::new() });
269 }
270 let load_deleted = query.include_deleted || parsed.ref_query.is_some();
271 let project = if let Some(project) = query.project.as_deref() {
272 Some(resolve_existing_project_in_workspace(conn, workspace_id, project).await?)
273 } else {
274 None
275 };
276 let mut documents = load_candidate_search_documents(
277 conn,
278 workspace_id,
279 project.as_ref().map(|project| &project.id),
280 load_deleted,
281 &parsed,
282 display_refs,
283 )
284 .await?;
285 if !query.metadata.is_empty()
286 || !query.has_metadata.is_empty()
287 || !query.missing_metadata.is_empty()
288 {
289 let task_ids = documents
290 .iter()
291 .map(|document| document.task.id.clone())
292 .collect::<Vec<_>>();
293 let matching = super::tasks::list_task_summary_items_in_workspace(
294 conn,
295 workspace_id,
296 TaskFilters {
297 metadata: query.metadata.clone(),
298 has_metadata: query.has_metadata.clone(),
299 missing_metadata: query.missing_metadata.clone(),
300 include_deleted: true,
301 task_ids: TaskIdFilter::Only(task_ids),
302 expand_recurring: true,
303 ..TaskFilters::default()
304 },
305 TaskQueryMode::Flat,
306 TaskSort::Updated,
307 SortDirection::Desc,
308 None,
309 )
310 .await?
311 .into_iter()
312 .map(|item| item.task.id)
313 .collect::<HashSet<_>>();
314 documents.retain(|document| matching.contains(&document.task.id));
315 }
316
317 let now_seconds = crate::queue::now_seconds();
318 let mut scored = documents
319 .into_iter()
320 .filter_map(|document| {
321 let is_deleted = document.task.deleted;
322 let ref_strong_enough = parsed
323 .ref_query
324 .as_ref()
325 .is_some_and(|rq| ref_query_matches_display_or_full_id(&document, rq));
326 let scored = score_document(document, &parsed, now_seconds)?;
327 if is_deleted
328 && !query.include_deleted
329 && (scored.matched_field != SearchMatchedField::Ref || !ref_strong_enough)
330 {
331 return None;
332 }
333 Some(scored)
334 })
335 .collect::<Vec<_>>();
336 scored.sort_by(|a, b| {
337 b.score
338 .cmp(&a.score)
339 .then_with(|| b.document.task.updated_at.cmp(&a.document.task.updated_at))
340 .then_with(|| a.document.task.title.cmp(&b.document.task.title))
341 .then_with(|| a.document.task.id.cmp(&b.document.task.id))
342 });
343 scored.truncate(limit);
344 Ok(ScoredSearchResults { items: scored })
345}
346
347pub async fn search_task_preview_set_in_workspace(
348 conn: &mut SqliteConnection,
349 workspace_id: &WorkspaceId,
350 query: TaskSearchQuery,
351) -> Result<TaskSearchPreviewResultSet> {
352 validate_search_limit(query.limit)?;
353 let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
354 let mut scored = scored_search_documents(conn, workspace_id, &query, &display_refs).await?;
355 let task_ids = scored
356 .items
357 .iter()
358 .map(|scored| scored.document.task.id.clone())
359 .collect::<Vec<_>>();
360 let recurrence =
361 super::recurrence::task_recurrence_summaries(conn, workspace_id, &task_ids).await?;
362 let mut seen_series = std::collections::HashSet::new();
363 scored.items.retain(|scored| {
364 recurrence
365 .get(&scored.document.task.id)
366 .is_none_or(|summary| seen_series.insert(summary.series_id.clone()))
367 });
368 let total_matches = scored.items.len();
369 let mut labels_by_task = labels_for_tasks(conn, workspace_id, &task_ids).await?;
370 let mut epic_parents_by_task =
371 epic_parents_for_tasks(conn, workspace_id, &task_ids, &display_refs).await?;
372 let items = scored
373 .items
374 .into_iter()
375 .map(|scored| {
376 let task = scored.document.task;
377 TaskSearchPreviewResult {
378 task_id: task.id.clone(),
379 display_ref: scored.document.display_ref,
380 title: task.title,
381 project_key: task.project_key,
382 status: task.status.as_str().to_string(),
383 priority: task.priority.as_str().to_string(),
384 created_at: task.created_at,
385 labels: labels_by_task.remove(&task.id).unwrap_or_default(),
386 deleted: task.deleted,
387 is_epic: task.is_epic,
388 epic_parent_display_ref: epic_parents_by_task
389 .remove(&task.id)
390 .map(|parent| parent.display_ref),
391 score: scored.score,
392 matched_field: scored.matched_field,
393 snippet: scored.snippet,
394 }
395 })
396 .collect();
397 Ok(TaskSearchPreviewResultSet {
398 items,
399 total_matches,
400 })
401}
402
403async fn attachment_search_text_by_task(
404 conn: &mut SqliteConnection,
405 workspace_id: &str,
406 task_ids: &[String],
407) -> Result<HashMap<String, String>> {
408 let mut by_task: HashMap<String, Vec<String>> = HashMap::new();
409 if task_ids.is_empty() {
410 return Ok(HashMap::new());
411 }
412 for chunk in task_ids.chunks(SQLITE_BIND_CHUNK_SIZE) {
413 if chunk.is_empty() {
414 continue;
415 }
416 let mut query = QueryBuilder::<Sqlite>::new(
417 "SELECT task_id, filename, alt_text FROM task_attachments WHERE workspace_id = ",
418 );
419 query.push_bind(workspace_id);
420 query.push(" AND deleted = 0 AND task_id IN (");
421 {
422 let mut separated = query.separated(", ");
423 for task_id in chunk {
424 separated.push_bind(task_id);
425 }
426 }
427 query.push(") ORDER BY task_id, created_at, attachment_id");
428
429 for row in query.build().fetch_all(&mut *conn).await? {
430 let task_id: String = row.get("task_id");
431 if let Some(filename) = row.get::<Option<String>, _>("filename") {
432 by_task.entry(task_id.clone()).or_default().push(filename);
433 }
434 if let Some(alt_text) = row.get::<Option<String>, _>("alt_text") {
435 by_task.entry(task_id).or_default().push(alt_text);
436 }
437 }
438 }
439 Ok(by_task
440 .into_iter()
441 .map(|(task_id, parts)| (task_id, parts.join(" ")))
442 .collect())
443}
444
445async fn attach_attachment_search_text(
446 conn: &mut SqliteConnection,
447 workspace_id: &str,
448 documents: &mut [SearchDocument],
449) -> Result<()> {
450 let task_ids = documents
451 .iter()
452 .map(|document| document.task.id.to_string())
453 .collect::<Vec<_>>();
454 let mut attachments_by_task =
455 attachment_search_text_by_task(conn, workspace_id, &task_ids).await?;
456 for document in documents {
457 document.attachments_text = attachments_by_task
458 .remove(document.task.id.as_str())
459 .unwrap_or_default();
460 }
461 Ok(())
462}
463
464fn attachment_query_terms(parsed: &parser::ParsedTaskSearchQuery) -> Vec<String> {
465 let mut terms = Vec::new();
466 let trimmed = parsed.trimmed.trim().to_ascii_lowercase();
467 if !trimmed.is_empty() {
468 terms.push(trimmed);
469 }
470 for term in search_terms(parsed) {
471 let term = term.trim().to_ascii_lowercase();
472 if !term.is_empty() && !terms.iter().any(|existing| existing == &term) {
473 terms.push(term);
474 }
475 }
476 terms
477}
478
479async fn load_attachment_text_search_documents(
480 conn: &mut SqliteConnection,
481 workspace_id: &str,
482 project_id: Option<&ProjectId>,
483 include_deleted: bool,
484 parsed: &parser::ParsedTaskSearchQuery,
485 display_refs: &DisplayRefContext,
486) -> Result<Vec<SearchDocument>> {
487 let terms = attachment_query_terms(parsed);
488 if terms.is_empty() {
489 return Ok(Vec::new());
490 }
491 let mut query = QueryBuilder::<Sqlite>::new(
492 "SELECT DISTINCT t.id, t.workspace_id, t.title, t.description, t.project_id,
493 p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
494 t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic,
495 '' AS fts_labels, '' AS fts_notes
496 FROM task_attachments ta
497 JOIN tasks t ON t.workspace_id = ta.workspace_id AND t.id = ta.task_id
498 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
499 WHERE ta.workspace_id = ",
500 );
501 query.push_bind(workspace_id);
502 query.push(" AND ta.deleted = 0");
503 if let Some(project_id) = project_id {
504 query.push(" AND t.project_id = ");
505 query.push_bind(project_id);
506 }
507 query.push(" AND (");
508 let mut first = true;
509 for term in terms {
510 if !first {
511 query.push(" OR ");
512 }
513 first = false;
514 let pattern = format!("%{term}%");
515 query.push("(lower(COALESCE(ta.filename, '')) LIKE ");
516 query.push_bind(pattern.clone());
517 query.push(" OR lower(COALESCE(ta.alt_text, '')) LIKE ");
518 query.push_bind(pattern);
519 query.push(")");
520 }
521 query.push(") AND (");
522 query.push_bind(include_deleted);
523 query.push(" OR t.deleted = 0) AND ");
524 query.push(super::fragments::ordinary_task_clause("t"));
525 query.push(" ORDER BY t.updated_at DESC, t.id");
526
527 let rows = query.build().fetch_all(&mut *conn).await?;
528 search_documents_from_rows(rows, display_refs)
529}
530
531async fn load_candidate_search_documents(
532 conn: &mut SqliteConnection,
533 workspace_id: &WorkspaceId,
534 project_id: Option<&ProjectId>,
535 include_deleted: bool,
536 parsed: &parser::ParsedTaskSearchQuery,
537 display_refs: &DisplayRefContext,
538) -> Result<Vec<SearchDocument>> {
539 let mut documents = if let Some(fts_match) = parsed.fts_match.as_deref() {
540 load_fts_search_documents(
541 conn,
542 workspace_id,
543 project_id,
544 include_deleted,
545 fts_match,
546 display_refs,
547 )
548 .await?
549 } else {
550 Vec::new()
551 };
552 if let Some(ref_query) = &parsed.ref_query {
553 let ref_documents = load_ref_search_documents(
554 conn,
555 workspace_id,
556 project_id,
557 include_deleted,
558 ref_query,
559 display_refs,
560 )
561 .await?;
562 merge_search_documents(&mut documents, ref_documents);
563 let series_documents = load_series_ref_search_documents(
564 conn,
565 workspace_id,
566 project_id,
567 include_deleted,
568 ref_query,
569 display_refs,
570 )
571 .await?;
572 merge_search_documents(&mut documents, series_documents);
573 }
574 let attachment_documents = load_attachment_text_search_documents(
575 conn,
576 workspace_id.as_str(),
577 project_id,
578 include_deleted,
579 parsed,
580 display_refs,
581 )
582 .await?;
583 merge_search_documents(&mut documents, attachment_documents);
584 attach_attachment_search_text(conn, workspace_id.as_str(), &mut documents).await?;
585 if parsed.ref_query.is_some() {
586 attach_recurrence_series_refs(conn, workspace_id, &mut documents).await?;
587 }
588 Ok(documents)
589}
590
591async fn load_ref_search_documents(
592 conn: &mut SqliteConnection,
593 workspace_id: &WorkspaceId,
594 project_id: Option<&ProjectId>,
595 include_deleted: bool,
596 ref_query: &parser::ParsedRefSearchQuery,
597 display_refs: &DisplayRefContext,
598) -> Result<Vec<SearchDocument>> {
599 let rows = sqlx::query(
600 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
601 p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
602 t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic,
603 '' AS fts_labels, '' AS fts_notes
604 FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
605 WHERE t.workspace_id = ? AND (? OR t.deleted = 0)
606 AND (? OR t.project_id = ?) AND NOT EXISTS (
607 SELECT 1 FROM recurrence_occurrences ro
608 JOIN recurrence_series rs ON rs.workspace_id = ro.workspace_id AND rs.id = ro.series_id
609 WHERE ro.workspace_id = t.workspace_id AND ro.task_id = t.id
610 AND (ro.projection_state = 'archived'
611 OR (ro.projection_state = 'projected' AND rs.state = 'paused'))
612 ) AND t.id LIKE ? || '%'
613 ORDER BY t.updated_at DESC, t.id",
614 )
615 .bind(workspace_id)
616 .bind(include_deleted)
617 .bind(project_id.is_none())
618 .bind(project_id)
619 .bind(&ref_query.normalized_suffix)
620 .fetch_all(&mut *conn)
621 .await?;
622 search_documents_from_rows(rows, display_refs)
623}
624
625fn series_ref_suffix(ref_query: &parser::ParsedRefSearchQuery) -> Option<&str> {
628 match ref_query.normalized_prefix.as_deref() {
629 Some(prefix) if prefix != crate::recurrence::SERIES_REF_PREFIX => None,
630 _ => Some(&ref_query.normalized_suffix),
631 }
632}
633
634async fn load_series_ref_search_documents(
635 conn: &mut SqliteConnection,
636 workspace_id: &WorkspaceId,
637 project_id: Option<&ProjectId>,
638 include_deleted: bool,
639 ref_query: &parser::ParsedRefSearchQuery,
640 display_refs: &DisplayRefContext,
641) -> Result<Vec<SearchDocument>> {
642 let Some(suffix) = series_ref_suffix(ref_query) else {
643 return Ok(Vec::new());
644 };
645 let mut query = QueryBuilder::<Sqlite>::new(
646 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
647 p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
648 t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic,
649 '' AS fts_labels, '' AS fts_notes
650 FROM recurrence_occurrences ro
651 JOIN tasks t ON t.workspace_id = ro.workspace_id AND t.id = ro.task_id
652 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
653 WHERE ro.workspace_id = ",
654 );
655 query.push_bind(workspace_id);
656 query.push(" AND (");
657 query.push_bind(include_deleted);
658 query.push(" OR t.deleted = 0) AND (");
659 query.push_bind(project_id.is_none());
660 query.push(" OR t.project_id = ");
661 query.push_bind(project_id);
662 query.push(") AND ");
663 query.push(super::fragments::ordinary_task_clause("t"));
664 query.push(" AND ro.series_id GLOB ");
665 query.push_bind(format!("{suffix}*"));
666 query.push(" ORDER BY t.updated_at DESC, t.id");
667
668 let rows = query.build().fetch_all(&mut *conn).await?;
669 search_documents_from_rows(rows, display_refs)
670}
671
672async fn attach_recurrence_series_refs(
673 conn: &mut SqliteConnection,
674 workspace_id: &WorkspaceId,
675 documents: &mut [SearchDocument],
676) -> Result<()> {
677 let task_ids = documents
678 .iter()
679 .map(|document| document.task.id.clone())
680 .collect::<Vec<_>>();
681 let mut summaries =
682 super::recurrence::task_recurrence_summaries(conn, workspace_id, &task_ids).await?;
683 for document in documents {
684 document.series = summaries
685 .remove(&document.task.id)
686 .map(|summary| DocumentSeries {
687 id: summary.series_id.to_string(),
688 display_ref: summary.series_ref,
689 });
690 }
691 Ok(())
692}
693
694fn merge_search_documents(documents: &mut Vec<SearchDocument>, incoming: Vec<SearchDocument>) {
695 for document in incoming {
696 if !documents
697 .iter()
698 .any(|existing| existing.task.id == document.task.id)
699 {
700 documents.push(document);
701 }
702 }
703}
704
705async fn load_fts_search_documents(
706 conn: &mut SqliteConnection,
707 workspace_id: &WorkspaceId,
708 project_id: Option<&ProjectId>,
709 include_deleted: bool,
710 raw_fts_match: &str,
711 display_refs: &DisplayRefContext,
712) -> Result<Vec<SearchDocument>> {
713 let fts_match = workspace_scoped_fts_match(workspace_id, raw_fts_match);
714 let rows = sqlx::query(
715 "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
716 p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix,
717 t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic,
718 d.labels AS fts_labels, d.notes AS fts_notes
719 FROM task_search_fts f
720 JOIN task_search_documents d ON d.doc_id = f.rowid
721 JOIN tasks t ON t.workspace_id = d.workspace_id AND t.id = d.task_id
722 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
723 WHERE task_search_fts MATCH ? AND d.workspace_id = ? AND (? OR t.deleted = 0)
724 AND (? OR t.project_id = ?)
725 AND NOT EXISTS (
726 SELECT 1 FROM recurrence_occurrences ro
727 JOIN recurrence_series rs ON rs.workspace_id = ro.workspace_id AND rs.id = ro.series_id
728 WHERE ro.workspace_id = t.workspace_id AND ro.task_id = t.id
729 AND (ro.projection_state = 'archived'
730 OR (ro.projection_state = 'projected' AND rs.state = 'paused'))
731 )
732 ORDER BY t.updated_at DESC, t.id",
733 )
734 .bind(&fts_match)
735 .bind(workspace_id)
736 .bind(include_deleted)
737 .bind(project_id.is_none())
738 .bind(project_id)
739 .fetch_all(&mut *conn)
740 .await?;
741 search_documents_from_rows(rows, display_refs)
742}
743
744fn search_documents_from_rows(
745 rows: Vec<sqlx::sqlite::SqliteRow>,
746 display_refs: &DisplayRefContext,
747) -> Result<Vec<SearchDocument>> {
748 let mut tasks = Vec::with_capacity(rows.len());
749 let mut project_names = Vec::with_capacity(rows.len());
750 let mut labels_texts = Vec::with_capacity(rows.len());
751 let mut notes_texts = Vec::with_capacity(rows.len());
752 for row in rows {
753 project_names.push(row.get::<String, _>("project_name"));
754 labels_texts.push(row.get::<String, _>("fts_labels"));
755 notes_texts.push(row.get::<String, _>("fts_notes"));
756 tasks.push(task_from_row(&row)?);
757 }
758 Ok(tasks
759 .into_iter()
760 .zip(project_names)
761 .zip(labels_texts)
762 .zip(notes_texts)
763 .map(|(((task, project_name), labels_text), notes_text)| {
764 let display_ref = display_refs.display_ref(&task);
765 SearchDocument {
766 labels_text,
767 notes_text,
768 attachments_text: String::new(),
769 task,
770 display_ref,
771 project_name,
772 series: None,
773 }
774 })
775 .collect())
776}
777
778fn fts_phrase(value: &str) -> String {
779 format!("\"{}\"", value.replace('"', "\"\""))
780}
781
782fn workspace_scoped_fts_match(workspace_id: &WorkspaceId, fts_match: &str) -> String {
783 format!(
784 "workspace_token:{} {}",
785 fts_phrase(workspace_id.as_str()),
786 fts_match
787 )
788}
789
790fn score_document(
791 document: SearchDocument,
792 query: &parser::ParsedTaskSearchQuery,
793 now_seconds: i64,
794) -> Option<ScoredDocument> {
795 let project_text = format!(
796 "{} {} {}",
797 document.task.project_key, document.project_name, document.task.project_prefix
798 );
799 let mut evidence = Vec::new();
800 if let Some(ref_query) = &query.ref_query
801 && let Some(score) = score_ref_lane(&document, ref_query)
802 {
803 evidence.push(FieldEvidence {
804 score,
805 matched_field: SearchMatchedField::Ref,
806 snippet: None,
807 });
808 }
809 for (field, text, weight) in [
810 (
811 SearchMatchedField::Title,
812 document.task.title.as_str(),
813 TITLE_WEIGHT,
814 ),
815 (
816 SearchMatchedField::Label,
817 document.labels_text.as_str(),
818 LABEL_WEIGHT,
819 ),
820 (
821 SearchMatchedField::Project,
822 project_text.as_str(),
823 PROJECT_WEIGHT,
824 ),
825 (
826 SearchMatchedField::Status,
827 document.task.status.as_str(),
828 STATUS_WEIGHT,
829 ),
830 (
831 SearchMatchedField::Priority,
832 document.task.priority.as_str(),
833 PRIORITY_WEIGHT,
834 ),
835 (
836 SearchMatchedField::Description,
837 document.task.description.as_str(),
838 DESCRIPTION_WEIGHT,
839 ),
840 (
841 SearchMatchedField::Attachment,
842 document.attachments_text.as_str(),
843 ATTACHMENT_WEIGHT,
844 ),
845 (
846 SearchMatchedField::Note,
847 document.notes_text.as_str(),
848 NOTE_WEIGHT,
849 ),
850 ] {
851 if let Some((score, span)) = score_text_lane(text, query) {
852 let snippet = if matches!(
853 field,
854 SearchMatchedField::Description | SearchMatchedField::Note
855 ) {
856 snippet(text, span)
857 } else {
858 None
859 };
860 evidence.push(FieldEvidence {
861 score: score * weight,
862 matched_field: field,
863 snippet,
864 });
865 }
866 }
867 if evidence.is_empty() {
868 return None;
869 }
870 let best_index = evidence
871 .iter()
872 .enumerate()
873 .max_by(|(_, left), (_, right)| left.score.cmp(&right.score))
874 .map(|(index, _)| index)
875 .unwrap();
876 let best = evidence.swap_remove(best_index);
877 let extra_score = evidence
878 .iter()
879 .map(|item| item.score / FIELD_SCORE_DIVISOR)
880 .sum::<i64>();
881 let field_bonus = FIELD_MATCH_BONUS + evidence.len() as i64 * EXTRA_FIELD_BONUS;
882 let score = best.score
883 + extra_score
884 + field_bonus
885 + priority_boost(document.task.priority.as_str())
886 + recency_boost(document.task.updated_at.as_str(), now_seconds);
887 Some(ScoredDocument {
888 document,
889 score,
890 matched_field: best.matched_field,
891 snippet: best.snippet,
892 })
893}
894
895fn priority_boost(priority: &str) -> i64 {
896 match priority {
897 "urgent" => PRIORITY_BOOST_CAP,
898 "high" => 12_000,
899 "medium" => 6_000,
900 "low" => 2_000,
901 _ => 0,
902 }
903}
904
905fn recency_boost(updated_at: &str, now_seconds: i64) -> i64 {
906 let Some(updated_seconds) = crate::queue::unix_seconds(updated_at) else {
907 return 0;
908 };
909 let age_days = now_seconds.saturating_sub(updated_seconds).max(0) / 86_400;
910 let decay = age_days.saturating_mul(RECENCY_BOOST_CAP / 30);
911 RECENCY_BOOST_CAP.saturating_sub(decay)
912}
913
914fn score_text_lane(
915 text: &str,
916 query: &parser::ParsedTaskSearchQuery,
917) -> Option<(i64, std::ops::Range<usize>)> {
918 if query.phrases.is_empty() {
919 score_contiguous_text_lane(text, query.trimmed.as_str())
920 .or_else(|| score_term_coverage_lane(text, query))
921 } else {
922 score_parsed_contiguous_text_lane(text, query)
923 .or_else(|| score_term_coverage_lane(text, query))
924 }
925}
926
927fn score_parsed_contiguous_text_lane(
928 text: &str,
929 query: &parser::ParsedTaskSearchQuery,
930) -> Option<(i64, std::ops::Range<usize>)> {
931 search_terms(query)
932 .into_iter()
933 .filter_map(|term| score_contiguous_text_lane(text, term))
934 .max_by_key(|(score, _)| *score)
935}
936
937fn score_contiguous_text_lane(text: &str, query: &str) -> Option<(i64, std::ops::Range<usize>)> {
938 let normalized_text = text.to_ascii_lowercase();
939 let raw_query = query.trim();
940 let normalized_query = raw_query.to_ascii_lowercase();
941 let query = normalized_query.trim();
942 if query.is_empty() || normalized_text.is_empty() {
943 return None;
944 }
945 if let Some(index) = normalized_text.find(query) {
946 let boundary_bonus = if index == 0 || is_boundary(normalized_text.as_bytes()[index - 1]) {
947 200
948 } else {
949 0
950 };
951 let phrase_bonus = if index == 0 { 300 } else { 0 };
952 return Some((
953 1_000 + phrase_bonus + boundary_bonus - index as i64,
954 index..index + query.len(),
955 ));
956 }
957 token_match_span(&normalized_text, query).map(|span| {
958 let boundary_bonus =
959 if span.start == 0 || is_boundary(normalized_text.as_bytes()[span.start - 1]) {
960 120
961 } else {
962 0
963 };
964 let spread = span.end.saturating_sub(span.start + query.len()) as i64;
965 (700 + boundary_bonus - spread * 4 - span.start as i64, span)
966 })
967}
968
969fn score_term_coverage_lane(
970 text: &str,
971 query: &parser::ParsedTaskSearchQuery,
972) -> Option<(i64, std::ops::Range<usize>)> {
973 let terms = search_terms(query);
974 let normalized_text = text.to_ascii_lowercase();
975 if terms.len() < 2 || normalized_text.is_empty() {
976 return None;
977 }
978
979 let mut matched = 0_i64;
980 let mut start = usize::MAX;
981 let mut end = 0_usize;
982 for term in terms {
983 let normalized_term = term.to_ascii_lowercase();
984 if normalized_term.is_empty() {
985 continue;
986 }
987 if let Some(index) = normalized_text.find(&normalized_term) {
988 matched += 1;
989 start = start.min(index);
990 end = end.max(index + normalized_term.len());
991 }
992 }
993 if matched == 0 {
994 return None;
995 }
996
997 let boundary_bonus = if start == 0 || is_boundary(normalized_text.as_bytes()[start - 1]) {
998 120
999 } else {
1000 0
1001 };
1002 let spread = end.saturating_sub(start) as i64;
1003 Some((
1004 450 + matched * 160 + boundary_bonus - spread * 3 - start as i64,
1005 start..end,
1006 ))
1007}
1008
1009fn search_terms(query: &parser::ParsedTaskSearchQuery) -> Vec<&str> {
1010 query
1011 .phrases
1012 .iter()
1013 .map(String::as_str)
1014 .chain(query.tokens.iter().map(String::as_str))
1015 .chain(query.active_prefix.as_deref())
1016 .collect()
1017}
1018
1019fn score_ref_lane(
1020 document: &SearchDocument,
1021 ref_query: &parser::ParsedRefSearchQuery,
1022) -> Option<i64> {
1023 score_task_ref_lane(document, ref_query).max(score_series_ref_lane(document, ref_query))
1025}
1026
1027fn score_task_ref_lane(
1028 document: &SearchDocument,
1029 ref_query: &parser::ParsedRefSearchQuery,
1030) -> Option<i64> {
1031 if let Some(prefix) = ref_query.normalized_prefix.as_deref()
1032 && normalize_ref_query(&document.task.project_prefix) != prefix
1033 {
1034 return None;
1035 }
1036 score_ref_identity(&document.task.id, &document.display_ref, ref_query)
1037}
1038
1039fn score_series_ref_lane(
1040 document: &SearchDocument,
1041 ref_query: &parser::ParsedRefSearchQuery,
1042) -> Option<i64> {
1043 let suffix = series_ref_suffix(ref_query)?;
1044 let series = document.series.as_ref()?;
1045 normalize_ref_query(&series.id)
1046 .starts_with(suffix)
1047 .then(|| score_ref_identity(&series.id, &series.display_ref, ref_query))
1048 .flatten()
1049}
1050
1051fn score_ref_identity(
1052 id: &str,
1053 display_ref: &str,
1054 ref_query: &parser::ParsedRefSearchQuery,
1055) -> Option<i64> {
1056 let normalized_id = normalize_ref_query(id);
1057 if !normalized_id.starts_with(&ref_query.normalized_suffix) {
1058 return None;
1059 }
1060 let display_suffix_len = display_ref
1061 .rsplit_once('-')
1062 .map(|(_, suffix)| normalize_ref_query(suffix).len())
1063 .unwrap_or(0);
1064 let exact_bonus = if normalized_id == ref_query.normalized_suffix {
1065 700
1066 } else {
1067 0
1068 };
1069 let display_bonus = if ref_query.normalized_suffix.len() >= display_suffix_len {
1070 300
1071 } else {
1072 0
1073 };
1074 let prefix_bonus = if ref_query.normalized_prefix.is_some() {
1075 200
1076 } else {
1077 0
1078 };
1079 Some(
1080 (3_000
1081 + exact_bonus
1082 + display_bonus
1083 + prefix_bonus
1084 + ref_query.normalized_suffix.len() as i64)
1085 * REF_WEIGHT,
1086 )
1087}
1088
1089fn ref_query_matches_display_or_full_id(
1090 document: &SearchDocument,
1091 ref_query: &parser::ParsedRefSearchQuery,
1092) -> bool {
1093 let normalized_id = normalize_ref_query(&document.task.id);
1094 if normalized_id == ref_query.normalized_suffix {
1095 return true;
1096 }
1097 document
1098 .display_ref
1099 .rsplit_once('-')
1100 .map(|(_, suffix)| ref_query.normalized_suffix.len() >= normalize_ref_query(suffix).len())
1101 .unwrap_or(false)
1102}
1103
1104fn token_match_span(text: &str, query: &str) -> Option<std::ops::Range<usize>> {
1105 let tokens = query.split_whitespace().collect::<Vec<_>>();
1106 if tokens.len() < 2 {
1107 return None;
1108 }
1109 let mut start = usize::MAX;
1110 let mut end = 0;
1111 for token in tokens {
1112 let index = text.find(token)?;
1113 start = start.min(index);
1114 end = end.max(index + token.len());
1115 }
1116 Some(start..end)
1117}
1118
1119fn normalize_ref_query(input: &str) -> String {
1120 input
1121 .chars()
1122 .filter(|ch| ch.is_ascii_alphanumeric())
1123 .map(|ch| match ch.to_ascii_uppercase() {
1124 'O' => '0',
1125 'I' | 'L' => '1',
1126 ch => ch,
1127 })
1128 .collect()
1129}
1130
1131fn is_boundary(ch: u8) -> bool {
1132 !ch.is_ascii_alphanumeric()
1133}
1134
1135fn snippet(text: &str, span: std::ops::Range<usize>) -> Option<String> {
1136 if text.is_empty() {
1137 return None;
1138 }
1139 let start = char_boundary_at_or_before(text, span.start.saturating_sub(40));
1140 let end = char_boundary_at_or_after(text, (span.end + 80).min(text.len()));
1141 let mut value = text[start..end].replace('\n', " ");
1142 value = value.split_whitespace().collect::<Vec<_>>().join(" ");
1143 if start > 0 {
1144 value.insert_str(0, "...");
1145 }
1146 if end < text.len() {
1147 value.push_str("...");
1148 }
1149 Some(value)
1150}
1151
1152fn char_boundary_at_or_before(text: &str, mut index: usize) -> usize {
1153 index = index.min(text.len());
1154 while index > 0 && !text.is_char_boundary(index) {
1155 index -= 1;
1156 }
1157 index
1158}
1159
1160fn char_boundary_at_or_after(text: &str, mut index: usize) -> usize {
1161 index = index.min(text.len());
1162 while index < text.len() && !text.is_char_boundary(index) {
1163 index += 1;
1164 }
1165 index
1166}
1167
1168#[cfg(test)]
1169#[path = "search_tests.rs"]
1170mod tests;