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