diff --git a/crates/aven-core/src/operations/recurrence.rs b/crates/aven-core/src/operations/recurrence.rs index b8eabec4..03585a7f 100644 --- a/crates/aven-core/src/operations/recurrence.rs +++ b/crates/aven-core/src/operations/recurrence.rs @@ -19,8 +19,8 @@ use crate::mutation::apply_field_value_in_workspace; use crate::projects::resolve_or_create_project_in_workspace; use crate::recurrence::{ RecurrenceDuePolicy, RecurrenceOutcome, RecurrenceProjectionState, RecurrenceSchedule, - RecurrenceSeriesId, RecurrenceSeriesState, derive_occurrence_identity, next_slot_after, - projection_slot_at, recurrence_series_display_ref, slot_cutoff, slot_values, + RecurrenceSeriesId, RecurrenceSeriesState, SERIES_REF_PREFIX, derive_occurrence_identity, + next_slot_after, projection_slot_at, recurrence_series_display_ref, slot_cutoff, slot_values, }; use crate::refs::get_task_in_workspace; use crate::task_fields::TaskField; @@ -33,7 +33,6 @@ use crate::workspaces::Workspace; mod tests; const RECONCILE_ATTEMPTS: usize = 3; -const SERIES_REF_PREFIX: &str = "RCR"; const SERIES_TEMPLATE_FIELDS: &[&str] = &[ "title", "description", diff --git a/crates/aven-core/src/query/recurrence_tests.rs b/crates/aven-core/src/query/recurrence_tests.rs index 81d69600..b9e503da 100644 --- a/crates/aven-core/src/query/recurrence_tests.rs +++ b/crates/aven-core/src/query/recurrence_tests.rs @@ -3,7 +3,9 @@ use sqlx::Connection; use super::*; use crate::operations::{CreateRecurrenceSeriesParams, RecurrenceSeriesDraft}; -use crate::query::{SortDirection, TaskFilters, TaskQueryMode, TaskSearchQuery, TaskSort}; +use crate::query::{ + SearchMatchedField, SortDirection, TaskFilters, TaskQueryMode, TaskSearchQuery, TaskSort, +}; use crate::recurrence::{ RecurrenceDuePolicy, RecurrenceOutcome, RecurrenceRule, RecurrenceSchedule, TimeZoneId, }; @@ -752,3 +754,49 @@ async fn recurrence_hydration_statement_shapes_stay_bounded() { assert!(items.iter().all(|item| item.recurrence.is_some())); assert!(conn.cached_statements_size() <= 16); } + +#[tokio::test] +async fn search_resolves_recurrence_series_refs_to_their_occurrences() { + let (_temp, database, workspace) = setup().await; + let created = create(&database, &workspace, "series ref fixture", 6).await; + let series_ref = created.series_ref.clone(); + let suffix = series_ref.split_once('-').unwrap().1.to_string(); + + let search = |text: String| { + let database = database.clone(); + let workspace_id = workspace.id.clone(); + async move { + let mut conn = database.acquire_writer().await.unwrap(); + super::super::search::search_task_items_in_workspace( + &mut conn, + &workspace_id, + TaskSearchQuery { + metadata: Vec::new(), + has_metadata: Vec::new(), + missing_metadata: Vec::new(), + text, + project: None, + include_deleted: false, + limit: 20, + }, + ) + .await + .unwrap() + } + }; + + // The series ref is what the UI shows for a recurring row, so searching it + // has to land on the occurrence the series currently projects. + let qualified = search(series_ref).await; + assert_eq!(qualified.len(), 1); + assert_eq!(qualified[0].item.task.id, created.task.id); + assert_eq!(qualified[0].matched_field, SearchMatchedField::Ref); + + let bare = search(suffix.clone()).await; + assert_eq!(bare.len(), 1); + assert_eq!(bare[0].item.task.id, created.task.id); + assert_eq!(bare[0].matched_field, SearchMatchedField::Ref); + + let wrong_prefix = search(format!("/APP-{suffix}")).await; + assert!(wrong_prefix.is_empty()); +} diff --git a/crates/aven-core/src/query/search.rs b/crates/aven-core/src/query/search.rs index fd57450c..7d567584 100644 --- a/crates/aven-core/src/query/search.rs +++ b/crates/aven-core/src/query/search.rs @@ -125,6 +125,15 @@ struct SearchDocument { labels_text: String, notes_text: String, attachments_text: String, + series: Option, +} + +/// Recurrence identity of an occurrence task. The ref lane reads it so a series +/// ref like `RCR-NP3S` — the ref the UI shows for a recurring row — resolves to +/// the occurrences that series projects. +struct DocumentSeries { + id: String, + display_ref: String, } struct ScoredDocument { @@ -551,6 +560,16 @@ async fn load_candidate_search_documents( ) .await?; merge_search_documents(&mut documents, ref_documents); + let series_documents = load_series_ref_search_documents( + conn, + workspace_id, + project_id, + include_deleted, + ref_query, + display_refs, + ) + .await?; + merge_search_documents(&mut documents, series_documents); } let attachment_documents = load_attachment_text_search_documents( conn, @@ -563,6 +582,9 @@ async fn load_candidate_search_documents( .await?; merge_search_documents(&mut documents, attachment_documents); attach_attachment_search_text(conn, workspace_id.as_str(), &mut documents).await?; + if parsed.ref_query.is_some() { + attach_recurrence_series_refs(conn, workspace_id, &mut documents).await?; + } Ok(documents) } @@ -600,6 +622,75 @@ async fn load_ref_search_documents( search_documents_from_rows(rows, display_refs) } +/// Suffix a series ref query addresses, or `None` when the query names a project +/// prefix and so cannot be a series ref. +fn series_ref_suffix(ref_query: &parser::ParsedRefSearchQuery) -> Option<&str> { + match ref_query.normalized_prefix.as_deref() { + Some(prefix) if prefix != crate::recurrence::SERIES_REF_PREFIX => None, + _ => Some(&ref_query.normalized_suffix), + } +} + +async fn load_series_ref_search_documents( + conn: &mut SqliteConnection, + workspace_id: &WorkspaceId, + project_id: Option<&ProjectId>, + include_deleted: bool, + ref_query: &parser::ParsedRefSearchQuery, + display_refs: &DisplayRefContext, +) -> Result> { + let Some(suffix) = series_ref_suffix(ref_query) else { + return Ok(Vec::new()); + }; + let mut query = QueryBuilder::::new( + "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id, + p.key AS project_key, p.name AS project_name, p.prefix AS project_prefix, + 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, + '' AS fts_labels, '' AS fts_notes + FROM recurrence_occurrences ro + JOIN tasks t ON t.workspace_id = ro.workspace_id AND t.id = ro.task_id + JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id + WHERE ro.workspace_id = ", + ); + query.push_bind(workspace_id); + query.push(" AND ("); + query.push_bind(include_deleted); + query.push(" OR t.deleted = 0) AND ("); + query.push_bind(project_id.is_none()); + query.push(" OR t.project_id = "); + query.push_bind(project_id); + query.push(") AND "); + query.push(super::fragments::ordinary_task_clause("t")); + query.push(" AND ro.series_id GLOB "); + query.push_bind(format!("{suffix}*")); + query.push(" ORDER BY t.updated_at DESC, t.id"); + + let rows = query.build().fetch_all(&mut *conn).await?; + search_documents_from_rows(rows, display_refs) +} + +async fn attach_recurrence_series_refs( + conn: &mut SqliteConnection, + workspace_id: &WorkspaceId, + documents: &mut [SearchDocument], +) -> Result<()> { + let task_ids = documents + .iter() + .map(|document| document.task.id.clone()) + .collect::>(); + let mut summaries = + super::recurrence::task_recurrence_summaries(conn, workspace_id, &task_ids).await?; + for document in documents { + document.series = summaries + .remove(&document.task.id) + .map(|summary| DocumentSeries { + id: summary.series_id.to_string(), + display_ref: summary.series_ref, + }); + } + Ok(()) +} + fn merge_search_documents(documents: &mut Vec, incoming: Vec) { for document in incoming { if !documents @@ -678,6 +769,7 @@ fn search_documents_from_rows( task, display_ref, project_name, + series: None, } }) .collect()) @@ -927,18 +1019,45 @@ fn search_terms(query: &parser::ParsedTaskSearchQuery) -> Vec<&str> { fn score_ref_lane( document: &SearchDocument, ref_query: &parser::ParsedRefSearchQuery, +) -> Option { + // Option ordering keeps whichever identity the query addresses more closely. + score_task_ref_lane(document, ref_query).max(score_series_ref_lane(document, ref_query)) +} + +fn score_task_ref_lane( + document: &SearchDocument, + ref_query: &parser::ParsedRefSearchQuery, ) -> Option { if let Some(prefix) = ref_query.normalized_prefix.as_deref() && normalize_ref_query(&document.task.project_prefix) != prefix { return None; } - let normalized_id = normalize_ref_query(&document.task.id); + score_ref_identity(&document.task.id, &document.display_ref, ref_query) +} + +fn score_series_ref_lane( + document: &SearchDocument, + ref_query: &parser::ParsedRefSearchQuery, +) -> Option { + let suffix = series_ref_suffix(ref_query)?; + let series = document.series.as_ref()?; + normalize_ref_query(&series.id) + .starts_with(suffix) + .then(|| score_ref_identity(&series.id, &series.display_ref, ref_query)) + .flatten() +} + +fn score_ref_identity( + id: &str, + display_ref: &str, + ref_query: &parser::ParsedRefSearchQuery, +) -> Option { + let normalized_id = normalize_ref_query(id); if !normalized_id.starts_with(&ref_query.normalized_suffix) { return None; } - let display_suffix_len = document - .display_ref + let display_suffix_len = display_ref .rsplit_once('-') .map(|(_, suffix)| normalize_ref_query(suffix).len()) .unwrap_or(0); diff --git a/crates/aven-core/src/recurrence.rs b/crates/aven-core/src/recurrence.rs index 45f25b85..c1cff6f1 100644 --- a/crates/aven-core/src/recurrence.rs +++ b/crates/aven-core/src/recurrence.rs @@ -49,6 +49,10 @@ impl RecurrenceSeriesId { } } +/// Prefix every recurrence series display ref carries, in place of the project +/// prefix a task ref uses. +pub(crate) const SERIES_REF_PREFIX: &str = "RCR"; + pub(crate) fn recurrence_series_display_ref( series_id: &RecurrenceSeriesId, ids: &[RecurrenceSeriesId], @@ -66,7 +70,7 @@ pub(crate) fn recurrence_series_display_ref( .max() .unwrap_or(0); let length = 4.max(shared.saturating_add(1)).min(id.len()); - format!("RCR-{}", &id[..length]) + format!("{SERIES_REF_PREFIX}-{}", &id[..length]) } impl Default for RecurrenceSeriesId {