1#[cfg(feature = "advanced-queries")]
2use crate::database::conversions::naive_date_to_things_timestamp;
3#[cfg(feature = "advanced-queries")]
4use crate::models::TaskFilters;
5use crate::{
6 database::{mappers::map_task_row, ThingsDatabase},
7 error::{Result as ThingsResult, ThingsError},
8 models::{Task, TaskStatus, TaskType, ThingsId},
9};
10use chrono::{DateTime, NaiveDate, Utc};
11use sqlx::Row;
12use tracing::{debug, instrument};
13#[cfg(any(feature = "advanced-queries", feature = "batch-operations"))]
14use uuid::Uuid;
15
16impl ThingsDatabase {
17 #[instrument]
45 pub async fn get_all_tasks(&self) -> ThingsResult<Vec<Task>> {
46 let rows = sqlx::query(
47 r"
48 SELECT
49 uuid, title, status, type,
50 start_date, due_date,
51 project_uuid, area_uuid,
52 notes, tags,
53 created, modified
54 FROM TMTask
55 ORDER BY created DESC
56 ",
57 )
58 .fetch_all(&self.pool)
59 .await
60 .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks: {e}")))?;
61
62 let mut tasks = Vec::new();
63 for row in rows {
64 let task = Task {
65 uuid: ThingsId::from_trusted(row.get::<String, _>("uuid")),
66 title: row.get("title"),
67 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
68 task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
69 start_date: row
70 .get::<Option<String>, _>("start_date")
71 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
72 deadline: row
73 .get::<Option<String>, _>("due_date")
74 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
75 project_uuid: row
76 .get::<Option<String>, _>("project_uuid")
77 .map(ThingsId::from_trusted),
78 area_uuid: row
79 .get::<Option<String>, _>("area_uuid")
80 .map(ThingsId::from_trusted),
81 parent_uuid: None, notes: row.get("notes"),
83 tags: row
84 .get::<Option<String>, _>("tags")
85 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
86 .unwrap_or_default(),
87 children: Vec::new(), created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
89 .ok()
90 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
91 modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
92 .ok()
93 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
94 stop_date: None, };
96 tasks.push(task);
97 }
98
99 debug!("Fetched {} tasks", tasks.len());
100 Ok(tasks)
101 }
102
103 #[instrument]
109 pub async fn get_tasks_by_status(&self, status: TaskStatus) -> ThingsResult<Vec<Task>> {
110 let status_value = status as i32;
111 let rows = sqlx::query(
112 r"
113 SELECT
114 uuid, title, status, type,
115 start_date, due_date,
116 project_uuid, area_uuid,
117 notes, tags,
118 created, modified
119 FROM TMTask
120 WHERE status = ?
121 ORDER BY created DESC
122 ",
123 )
124 .bind(status_value)
125 .fetch_all(&self.pool)
126 .await
127 .map_err(|e| ThingsError::unknown(format!("Failed to fetch tasks by status: {e}")))?;
128
129 let mut tasks = Vec::new();
130 for row in rows {
131 let task = Task {
132 uuid: ThingsId::from_trusted(row.get::<String, _>("uuid")),
133 title: row.get("title"),
134 status: TaskStatus::from_i32(row.get("status")).unwrap_or(TaskStatus::Incomplete),
135 task_type: TaskType::from_i32(row.get("type")).unwrap_or(TaskType::Todo),
136 start_date: row
137 .get::<Option<String>, _>("start_date")
138 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
139 deadline: row
140 .get::<Option<String>, _>("due_date")
141 .and_then(|s| NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
142 project_uuid: row
143 .get::<Option<String>, _>("project_uuid")
144 .map(ThingsId::from_trusted),
145 area_uuid: row
146 .get::<Option<String>, _>("area_uuid")
147 .map(ThingsId::from_trusted),
148 parent_uuid: None, notes: row.get("notes"),
150 tags: row
151 .get::<Option<String>, _>("tags")
152 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
153 .unwrap_or_default(),
154 children: Vec::new(), created: DateTime::parse_from_rfc3339(&row.get::<String, _>("created"))
156 .ok()
157 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
158 modified: DateTime::parse_from_rfc3339(&row.get::<String, _>("modified"))
159 .ok()
160 .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc)),
161 stop_date: None, };
163 tasks.push(task);
164 }
165
166 debug!("Fetched {} tasks with status {:?}", tasks.len(), status);
167 Ok(tasks)
168 }
169
170 #[instrument]
176 pub async fn search_tasks(&self, query: &str) -> ThingsResult<Vec<Task>> {
177 let search_pattern = format!("%{query}%");
178 let rows = sqlx::query(
179 r"
180 SELECT
181 uuid, title, status, type,
182 startDate, deadline, stopDate,
183 project, area, heading,
184 notes,
185 (SELECT GROUP_CONCAT(tg.title, char(31))
186 FROM TMTaskTag tt
187 JOIN TMTag tg ON tg.uuid = tt.tags
188 WHERE tt.tasks = TMTask.uuid) AS tags_csv,
189 creationDate, userModificationDate
190 FROM TMTask
191 WHERE (title LIKE ? OR notes LIKE ?) AND type IN (0, 2) AND trashed = 0
192 ORDER BY creationDate DESC
193 ",
194 )
195 .bind(&search_pattern)
196 .bind(&search_pattern)
197 .fetch_all(&self.pool)
198 .await
199 .map_err(|e| ThingsError::unknown(format!("Failed to search tasks: {e}")))?;
200
201 let tasks = rows
202 .iter()
203 .map(map_task_row)
204 .collect::<ThingsResult<Vec<Task>>>()?;
205
206 debug!("Found {} tasks matching query: {}", tasks.len(), query);
207 Ok(tasks)
208 }
209
210 #[cfg(any(feature = "advanced-queries", feature = "batch-operations"))]
228 pub async fn query_tasks(&self, filters: &TaskFilters) -> ThingsResult<Vec<Task>> {
229 self.query_tasks_inner(filters, None).await
230 }
231
232 #[cfg(any(feature = "advanced-queries", feature = "batch-operations"))]
242 pub(crate) async fn query_tasks_inner(
243 &self,
244 filters: &TaskFilters,
245 after: Option<(i64, Uuid)>,
246 ) -> ThingsResult<Vec<Task>> {
247 const COLS: &str = "uuid, title, type, status, notes, startDate, deadline, stopDate, \
248 creationDate, userModificationDate, project, area, heading, \
249 (SELECT GROUP_CONCAT(tg.title, char(31)) \
250 FROM TMTaskTag tt \
251 JOIN TMTag tg ON tg.uuid = tt.tags \
252 WHERE tt.tasks = TMTask.uuid) AS tags_csv";
253
254 let trashed_val = i32::from(matches!(filters.status, Some(TaskStatus::Trashed)));
257 let mut conditions: Vec<String> = vec![format!("trashed = {trashed_val}")];
258
259 if let Some(status) = filters.status {
260 let n = match status {
261 TaskStatus::Incomplete => Some(0),
262 TaskStatus::Canceled => Some(2),
263 TaskStatus::Completed => Some(3),
264 TaskStatus::Trashed => None, };
266 if let Some(n) = n {
267 conditions.push(format!("status = {n}"));
268 }
269 }
270
271 if let Some(task_type) = filters.task_type {
272 let n = match task_type {
273 TaskType::Todo => 0,
274 TaskType::Project => 1,
275 TaskType::Heading => 2,
276 TaskType::Area => 3,
277 };
278 conditions.push(format!("type = {n}"));
279 }
280
281 if let Some(ref uuid) = filters.project_uuid {
282 conditions.push(format!("project = '{uuid}'"));
283 }
284
285 if let Some(ref uuid) = filters.area_uuid {
286 conditions.push(format!("area = '{uuid}'"));
287 }
288
289 if let Some(from) = filters.start_date_from {
290 conditions.push(format!(
291 "startDate >= {}",
292 naive_date_to_things_timestamp(from)
293 ));
294 }
295 if let Some(to) = filters.start_date_to {
296 conditions.push(format!(
297 "startDate <= {}",
298 naive_date_to_things_timestamp(to)
299 ));
300 }
301
302 if let Some(from) = filters.deadline_from {
303 conditions.push(format!(
304 "deadline >= {}",
305 naive_date_to_things_timestamp(from)
306 ));
307 }
308 if let Some(to) = filters.deadline_to {
309 conditions.push(format!(
310 "deadline <= {}",
311 naive_date_to_things_timestamp(to)
312 ));
313 }
314
315 if let Some((after_seconds, _)) = after {
316 conditions.push(format!(
323 "(CAST(creationDate AS INTEGER) < {after_seconds} \
324 OR (CAST(creationDate AS INTEGER) = {after_seconds} AND uuid < ?))"
325 ));
326 }
327
328 let where_clause = conditions.join(" AND ");
329 let mut sql = format!(
334 "SELECT {COLS} FROM TMTask WHERE {where_clause} \
335 ORDER BY CAST(creationDate AS INTEGER) DESC, uuid DESC"
336 );
337
338 let has_post_filters =
341 filters.tags.as_ref().is_some_and(|t| !t.is_empty()) || filters.search_query.is_some();
342
343 if !has_post_filters {
344 match (filters.limit, filters.offset) {
345 (Some(limit), Some(offset)) => {
346 sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
347 }
348 (Some(limit), None) => {
349 sql.push_str(&format!(" LIMIT {limit}"));
350 }
351 (None, Some(offset)) => {
352 sql.push_str(&format!(" LIMIT -1 OFFSET {offset}"));
354 }
355 (None, None) => {}
356 }
357 }
358
359 let rows = if let Some((_, after_uuid)) = after {
360 sqlx::query(&sql)
361 .bind(after_uuid.to_string())
362 .fetch_all(&self.pool)
363 .await
364 } else {
365 sqlx::query(&sql).fetch_all(&self.pool).await
366 }
367 .map_err(|e| ThingsError::unknown(format!("Failed to query tasks: {e}")))?;
368
369 let mut tasks = rows
370 .iter()
371 .map(map_task_row)
372 .collect::<ThingsResult<Vec<Task>>>()?;
373
374 if let Some(ref filter_tags) = filters.tags {
375 if !filter_tags.is_empty() {
376 tasks.retain(|task| filter_tags.iter().all(|f| task.tags.contains(f)));
377 }
378 }
379
380 if let Some(ref q) = filters.search_query {
381 let q_lower = q.to_lowercase();
382 tasks.retain(|task| {
383 task.title.to_lowercase().contains(&q_lower)
384 || task
385 .notes
386 .as_deref()
387 .unwrap_or("")
388 .to_lowercase()
389 .contains(&q_lower)
390 });
391 }
392
393 if has_post_filters {
394 let offset = filters.offset.unwrap_or(0);
395 tasks = tasks.into_iter().skip(offset).collect();
396 if let Some(limit) = filters.limit {
397 tasks.truncate(limit);
398 }
399 }
400
401 Ok(tasks)
402 }
403
404 #[allow(clippy::too_many_arguments)]
423 #[instrument(skip(self))]
424 pub async fn search_logbook(
425 &self,
426 search_text: Option<String>,
427 from_date: Option<NaiveDate>,
428 to_date: Option<NaiveDate>,
429 project_uuid: Option<ThingsId>,
430 area_uuid: Option<ThingsId>,
431 tags: Option<Vec<String>>,
432 limit: Option<u32>,
433 offset: Option<u32>,
434 ) -> ThingsResult<Vec<Task>> {
435 let result_limit = limit.unwrap_or(50).min(500);
437 let result_offset = offset.unwrap_or(0);
438
439 let rows = if let Some(ref text) = search_text {
443 let pattern = format!("%{text}%");
444 let mut q = String::from(
445 "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv, creationDate, userModificationDate FROM TMTask WHERE status = 3 AND trashed = 0 AND type = 0",
446 );
447 q.push_str(" AND (title LIKE ? OR notes LIKE ?)");
448
449 if let Some(date) = from_date {
450 let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
452 let timestamp = date_time.timestamp() as f64;
453 q.push_str(&format!(" AND stopDate >= {}", timestamp));
454 }
455
456 if let Some(date) = to_date {
457 let end_date = date + chrono::Duration::days(1);
459 let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
460 let timestamp = date_time.timestamp() as f64;
461 q.push_str(&format!(" AND stopDate < {}", timestamp));
462 }
463
464 if let Some(ref id) = project_uuid {
465 q.push_str(&format!(" AND project = '{}'", id));
466 }
467
468 if let Some(ref id) = area_uuid {
469 q.push_str(&format!(" AND area = '{}'", id));
470 }
471
472 q.push_str(&format!(
473 " ORDER BY stopDate DESC LIMIT {result_limit} OFFSET {result_offset}"
474 ));
475
476 sqlx::query(&q)
477 .bind(&pattern)
478 .bind(&pattern)
479 .fetch_all(&self.pool)
480 .await
481 .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
482 } else {
483 let mut q = String::from(
484 "SELECT uuid, title, status, type, startDate, deadline, stopDate, project, area, heading, notes, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv, creationDate, userModificationDate FROM TMTask WHERE status = 3 AND trashed = 0 AND type = 0",
485 );
486
487 if let Some(date) = from_date {
488 let date_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
490 let timestamp = date_time.timestamp() as f64;
491 q.push_str(&format!(" AND stopDate >= {}", timestamp));
492 }
493
494 if let Some(date) = to_date {
495 let end_date = date + chrono::Duration::days(1);
497 let date_time = end_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
498 let timestamp = date_time.timestamp() as f64;
499 q.push_str(&format!(" AND stopDate < {}", timestamp));
500 }
501
502 if let Some(ref id) = project_uuid {
503 q.push_str(&format!(" AND project = '{}'", id));
504 }
505
506 if let Some(ref id) = area_uuid {
507 q.push_str(&format!(" AND area = '{}'", id));
508 }
509
510 q.push_str(&format!(
511 " ORDER BY stopDate DESC LIMIT {result_limit} OFFSET {result_offset}"
512 ));
513
514 sqlx::query(&q)
515 .fetch_all(&self.pool)
516 .await
517 .map_err(|e| ThingsError::unknown(format!("Failed to search logbook: {e}")))?
518 };
519
520 let mut tasks = rows
522 .iter()
523 .map(map_task_row)
524 .collect::<ThingsResult<Vec<Task>>>()?;
525
526 if let Some(ref filter_tags) = tags {
527 if !filter_tags.is_empty() {
528 tasks.retain(|task| {
529 filter_tags
531 .iter()
532 .all(|filter_tag| task.tags.contains(filter_tag))
533 });
534 }
535 }
536
537 debug!("Found {} completed tasks in logbook", tasks.len());
538 Ok(tasks)
539 }
540
541 #[instrument(skip(self))]
547 pub async fn get_inbox(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
548 let query = if let Some(limit) = limit {
549 format!("SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv FROM TMTask WHERE type IN (0, 2) AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC LIMIT {limit}")
550 } else {
551 "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv FROM TMTask WHERE type IN (0, 2) AND status = 0 AND project IS NULL AND trashed = 0 ORDER BY creationDate DESC"
552 .to_string()
553 };
554
555 let rows = sqlx::query(&query)
556 .fetch_all(&self.pool)
557 .await
558 .map_err(|e| ThingsError::unknown(format!("Failed to fetch inbox tasks: {e}")))?;
559
560 let tasks = rows
561 .iter()
562 .map(map_task_row)
563 .collect::<ThingsResult<Vec<Task>>>()?;
564
565 Ok(tasks)
566 }
567
568 #[instrument(skip(self))]
578 pub async fn get_today(&self, limit: Option<usize>) -> ThingsResult<Vec<Task>> {
579 let query = if let Some(limit) = limit {
582 format!(
583 "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC LIMIT {limit}"
584 )
585 } else {
586 "SELECT uuid, title, type, status, notes, startDate, deadline, stopDate, creationDate, userModificationDate, project, area, heading, (SELECT GROUP_CONCAT(tg.title, char(31)) FROM TMTaskTag tt JOIN TMTag tg ON tg.uuid = tt.tags WHERE tt.tasks = TMTask.uuid) AS tags_csv FROM TMTask WHERE status = 0 AND todayIndex IS NOT NULL AND todayIndex != 0 AND trashed = 0 ORDER BY todayIndex ASC".to_string()
587 };
588
589 let rows = sqlx::query(&query)
590 .fetch_all(&self.pool)
591 .await
592 .map_err(|e| ThingsError::unknown(format!("Failed to fetch today's tasks: {e}")))?;
593
594 let tasks = rows
595 .iter()
596 .map(map_task_row)
597 .collect::<ThingsResult<Vec<Task>>>()?;
598
599 Ok(tasks)
600 }
601
602 #[instrument(skip(self))]
608 pub async fn get_task_by_uuid(&self, id: &ThingsId) -> ThingsResult<Option<Task>> {
609 let row = sqlx::query(
610 r"
611 SELECT
612 uuid, title, status, type,
613 startDate, deadline, stopDate,
614 project, area, heading,
615 notes, (SELECT GROUP_CONCAT(tg.title, char(31))
616 FROM TMTaskTag tt
617 JOIN TMTag tg ON tg.uuid = tt.tags
618 WHERE tt.tasks = TMTask.uuid) AS tags_csv,
619 creationDate, userModificationDate,
620 trashed
621 FROM TMTask
622 WHERE uuid = ?
623 ",
624 )
625 .bind(id.as_str())
626 .fetch_optional(&self.pool)
627 .await
628 .map_err(|e| ThingsError::unknown(format!("Failed to fetch task: {e}")))?;
629
630 if let Some(row) = row {
631 let trashed: i64 = row.get("trashed");
633 if trashed == 1 {
634 return Ok(None); }
636
637 let task = map_task_row(&row)?;
639 Ok(Some(task))
640 } else {
641 Ok(None)
642 }
643 }
644}