Skip to main content

kasl/db/
tasks.rs

1//! Task table access: CRUD plus the filtered queries behind `TaskFilter`.
2//!
3//! ```rust,no_run
4//! # fn main() -> anyhow::Result<()> {
5//! use kasl::db::tasks::Tasks;
6//! use kasl::libs::task::Task;
7//!
8//! let mut tasks = Tasks::new()?;
9//! let task = Task::new("Review code", "Check PR #123", Some(75));
10//! tasks.insert(&task)?;
11//! # Ok(())
12//! # }
13//! ```
14
15use super::db::Db;
16use crate::libs::messages::Message;
17use crate::libs::task::{Task, TaskFilter};
18use crate::msg_error_anyhow;
19use anyhow::Result;
20use rusqlite::{Connection, Statement, ToSql, params};
21use std::vec;
22
23const SCHEMA_TASKS: &str = "CREATE TABLE IF NOT EXISTS tasks (
24    id INTEGER NOT NULL PRIMARY KEY,
25    task_id INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0,
26    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
27    name TEXT NOT NULL,
28    comment TEXT,
29    completeness INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 100,
30    excluded_from_search BOOLEAN NOT NULL ON CONFLICT REPLACE DEFAULT FALSE
31);";
32
33const INSERT_TASK: &str = "INSERT INTO tasks (task_id, timestamp, name, comment, completeness, excluded_from_search) VALUES
34    (?, datetime(CURRENT_TIMESTAMP, 'localtime'), ?, ?, ?, ?) RETURNING id";
35const UPDATE_TASK_ID: &str = "UPDATE tasks SET task_id = ? WHERE id = ?";
36const SELECT_TASKS: &str = "SELECT * FROM tasks";
37const WHERE_DATE: &str = "WHERE date(timestamp) = date(?1)";
38const WHERE_ID_IN: &str = "WHERE id IN";
39
40// Incomplete = the latest completion state per task_id over the last 15 days,
41// still under 100%, and not already re-listed today.
42const WHERE_INCOMPLETE: &str = "WHERE
43  completeness < 100 AND
44  task_id NOT IN (SELECT task_id FROM tasks WHERE DATE(timestamp) = DATE('now')) AND
45  (task_id, completeness) IN (SELECT task_id, MAX(completeness) FROM tasks
46  WHERE DATE(timestamp) BETWEEN datetime(CURRENT_TIMESTAMP, 'localtime', '-15 day') AND datetime(CURRENT_TIMESTAMP, 'localtime', '-1 day')
47  GROUP BY task_id)
48  GROUP BY task_id";
49
50const WHERE_TAG: &str = "WHERE id IN (SELECT task_id FROM task_tags tt JOIN tags t ON tt.tag_id = t.id WHERE t.name = ?1)";
51const WHERE_TAGS: &str = "WHERE id IN (SELECT task_id FROM task_tags tt JOIN tags t ON tt.tag_id = t.id WHERE t.name IN";
52const DELETE_TASK: &str = "DELETE FROM tasks WHERE id = ?";
53const DELETE_TASKS_BY_IDS: &str = "DELETE FROM tasks WHERE id IN";
54const SELECT_COUNT_BY_ID: &str = "SELECT COUNT(*) FROM tasks WHERE id = ?";
55const UPDATE_TASK: &str = "UPDATE tasks SET name = ?, comment = ?, completeness = ? WHERE id = ?";
56
57/// Task table access; remembers the last inserted id for chaining.
58#[derive(Debug)]
59pub struct Tasks {
60    pub conn: Connection,
61
62    /// Id of the most recently inserted task, for `update_id`/`get` chaining.
63    pub id: Option<i32>,
64}
65
66impl Tasks {
67    /// Opens the database and ensures the tasks table exists.
68    ///
69    /// ```rust,no_run
70    /// # fn main() -> anyhow::Result<()> {
71    /// use kasl::db::tasks::Tasks;
72    ///
73    /// let mut tasks = Tasks::new()?;
74    /// # Ok(())
75    /// # }
76    /// ```
77    pub fn new() -> Result<Self> {
78        let db = Db::new()?;
79        db.conn.execute(SCHEMA_TASKS, [])?;
80        Ok(Self { conn: db.conn, id: None })
81    }
82
83    /// Inserts the task, storing the assigned id for chaining.
84    ///
85    /// ```rust,no_run
86    /// # fn main() -> anyhow::Result<()> {
87    /// use kasl::db::tasks::Tasks;
88    /// use kasl::libs::task::Task;
89    ///
90    /// let mut tasks = Tasks::new()?;
91    /// let task = Task::new("Code review", "Review PR #123", Some(50));
92    /// tasks.insert(&task)?
93    ///      .update_id()?; // Method chaining
94    /// # Ok(())
95    /// # }
96    /// ```
97    pub fn insert(&mut self, task: &Task) -> Result<&mut Self> {
98        self.id = Some(self.conn.query_row(
99            INSERT_TASK,
100            params![task.task_id, task.name, task.comment, task.completeness, task.excluded_from_search],
101            |row| row.get(0),
102        )?);
103
104        Ok(self)
105    }
106
107    /// Points the just-inserted task's `task_id` at its own id - the
108    /// convention for a standalone task that groups its own history.
109    ///
110    /// ```rust,no_run
111    /// # use kasl::db::tasks::Tasks;
112    /// use kasl::libs::task::Task;
113    ///
114    /// # fn main() -> anyhow::Result<()> {
115    /// let mut tasks = Tasks::new()?;
116    /// let subtask = Task::new("Subtask", "Part of larger task", Some(0));
117    /// tasks.insert(&subtask)?
118    ///      .update_id()?;
119    /// # Ok(())
120    /// # }
121    /// ```
122    pub fn update_id(&mut self) -> Result<&mut Self> {
123        self.conn.execute(UPDATE_TASK_ID, params![self.id, self.id])?;
124        Ok(self)
125    }
126
127    /// Fetches the most recently inserted task.
128    ///
129    /// ```rust,no_run
130    /// # use kasl::db::tasks::Tasks;
131    /// use kasl::libs::task::Task;
132    ///
133    /// # fn main() -> anyhow::Result<()> {
134    /// let mut tasks = Tasks::new()?;
135    /// let task = Task::new("New task", "Description", Some(100));
136    /// let inserted_tasks = tasks.insert(&task)?
137    ///                          .get()?;
138    /// # Ok(())
139    /// # }
140    /// ```
141    pub fn get(&mut self) -> Result<Vec<Task>> {
142        let id = self.id.ok_or_else(|| msg_error_anyhow!(Message::NoIdSet))?;
143        self.fetch(TaskFilter::ByIds(vec![id]))
144    }
145
146    /// Runs the query for the given filter and attaches each task's tags.
147    ///
148    /// ```rust,no_run
149    /// # fn main() -> anyhow::Result<()> {
150    /// use kasl::db::tasks::Tasks;
151    /// use kasl::libs::task::TaskFilter;
152    /// use chrono::Local;
153    ///
154    /// let mut tasks = Tasks::new()?;
155    ///
156    /// let all_tasks = tasks.fetch(TaskFilter::All)?;
157    /// let today = tasks.fetch(TaskFilter::Date(Local::now().date_naive()))?;
158    /// let urgent = tasks.fetch(TaskFilter::ByTag("urgent".to_string()))?;
159    /// let incomplete = tasks.fetch(TaskFilter::Incomplete)?;
160    /// # Ok(())
161    /// # }
162    /// ```
163    pub fn fetch(&mut self, filter: TaskFilter) -> Result<Vec<Task>> {
164        let (mut stmt, params): (Statement, Vec<Box<dyn ToSql>>) = match filter {
165            TaskFilter::All => (self.conn.prepare(SELECT_TASKS)?, vec![]),
166            TaskFilter::Date(date) => (self.conn.prepare(&format!("{} {}", SELECT_TASKS, WHERE_DATE))?, vec![Box::new(date)]),
167            TaskFilter::Incomplete => (self.conn.prepare(&format!("{} {}", SELECT_TASKS, WHERE_INCOMPLETE))?, vec![]),
168            TaskFilter::ByIds(ids) => {
169                let ids_params: Vec<Box<dyn ToSql>> = ids.clone().into_iter().map(|id| Box::new(id) as Box<dyn ToSql>).collect();
170                (self.conn.prepare(&Self::query_by_ids(&ids))?, ids_params)
171            }
172            TaskFilter::ByTag(tag_name) => (self.conn.prepare(&format!("{} {}", SELECT_TASKS, WHERE_TAG))?, vec![Box::new(tag_name)]),
173            TaskFilter::ByTags(tag_names) => {
174                let placeholders = vec!["?"; tag_names.len()].join(", ");
175                let query = format!("{} {} ({}))", SELECT_TASKS, WHERE_TAGS, placeholders);
176                let params: Vec<Box<dyn ToSql>> = tag_names.into_iter().map(|name| Box::new(name) as Box<dyn ToSql>).collect();
177                (self.conn.prepare(&query)?, params)
178            }
179        };
180
181        let params_refs: Vec<&dyn ToSql> = params.iter().map(|p| &**p).collect();
182        let task_iter = stmt.query_map(&params_refs[..], |row| {
183            Ok(Task {
184                id: row.get(0)?,
185                task_id: row.get(1)?,
186                timestamp: row.get(2)?,
187                name: row.get(3)?,
188                comment: row.get(4)?,
189                completeness: row.get(5)?,
190                excluded_from_search: row.get(6)?,
191                tags: vec![], // Tags will be populated in the next step
192            })
193        })?;
194
195        let mut tasks = Vec::new();
196        for task_result in task_iter {
197            tasks.push(task_result?);
198        }
199
200        // Enrich tasks with tag information
201        let mut tags_db = crate::db::tags::Tags::new()?;
202        for task in &mut tasks {
203            if let Some(task_id) = task.id {
204                task.tags = tags_db.get_tags_by_task(task_id)?;
205            }
206        }
207
208        Ok(tasks)
209    }
210
211    /// Builds `SELECT ... WHERE id IN (?, ?, ...)` with one placeholder per id.
212    fn query_by_ids(ids: &[i32]) -> String {
213        format!("{} {} ({})", SELECT_TASKS, WHERE_ID_IN, vec!["?"; ids.len()].join(", "))
214    }
215
216    /// Deletes one task; returns the number of rows removed.
217    ///
218    /// ```rust,no_run
219    /// # use kasl::db::tasks::Tasks;
220    /// # fn main() -> anyhow::Result<()> {
221    /// let mut tasks = Tasks::new()?;
222    /// let task_id = 1;
223    /// let deleted_count = tasks.delete(task_id)?;
224    /// if deleted_count > 0 {
225    ///     println!("Task deleted successfully");
226    /// }
227    /// # Ok(())
228    /// # }
229    /// ```
230    pub fn delete(&mut self, id: i32) -> Result<usize> {
231        let affected = self.conn.execute(DELETE_TASK, params![id])?;
232        Ok(affected)
233    }
234
235    /// Deletes several tasks in one statement; unknown ids are simply not
236    /// counted, and an empty slice is a no-op.
237    ///
238    /// ```rust,no_run
239    /// # use kasl::db::tasks::Tasks;
240    /// # fn main() -> anyhow::Result<()> {
241    /// let mut tasks = Tasks::new()?;
242    /// let ids_to_delete = vec![101, 102, 103];
243    /// let deleted_count = tasks.delete_many(&ids_to_delete)?;
244    /// println!("Deleted {} tasks", deleted_count);
245    /// # Ok(())
246    /// # }
247    /// ```
248    pub fn delete_many(&mut self, ids: &[i32]) -> Result<usize> {
249        if ids.is_empty() {
250            return Ok(0);
251        }
252
253        let placeholders = vec!["?"; ids.len()].join(", ");
254        let query = format!("{} ({})", DELETE_TASKS_BY_IDS, placeholders);
255
256        let params: Vec<Box<dyn ToSql>> = ids.iter().map(|id| Box::new(*id) as Box<dyn ToSql>).collect();
257        let params_refs: Vec<&dyn ToSql> = params.iter().map(|p| &**p).collect();
258
259        let affected = self.conn.execute(&query, &params_refs[..])?;
260        Ok(affected)
261    }
262
263    /// True when a task with this id exists.
264    ///
265    /// ```rust,no_run
266    /// # use kasl::db::tasks::Tasks;
267    /// # fn main() -> anyhow::Result<()> {
268    /// let mut tasks = Tasks::new()?;
269    /// let task_id = 1;
270    /// if tasks.exists(task_id)? {
271    ///     println!("Task exists and can be updated");
272    /// } else {
273    ///     println!("Task not found");
274    /// }
275    /// # Ok(())
276    /// # }
277    /// ```
278    pub fn exists(&mut self, id: i32) -> Result<bool> {
279        let count: i32 = self.conn.query_row(SELECT_COUNT_BY_ID, params![id], |row| row.get(0))?;
280        Ok(count > 0)
281    }
282
283    /// Updates name, comment and completeness; identity fields and tag
284    /// links stay as they are. Errors when the task has no id or no longer
285    /// exists.
286    ///
287    /// ```rust,no_run
288    /// # use kasl::db::tasks::Tasks;
289    /// # fn main() -> anyhow::Result<()> {
290    /// let mut tasks = Tasks::new()?;
291    /// let task_id = 1;
292    /// let mut task = tasks.get_by_id(task_id)?.unwrap();
293    /// task.name = "Updated task name".to_string();
294    /// task.completeness = Some(75);
295    /// tasks.update(&task)?;
296    /// # Ok(())
297    /// # }
298    /// ```
299    pub fn update(&mut self, task: &Task) -> Result<()> {
300        let id = task.id.ok_or_else(|| msg_error_anyhow!(Message::NoIdSet))?;
301
302        let affected = self.conn.execute(UPDATE_TASK, params![task.name, task.comment, task.completeness, id])?;
303
304        if affected == 0 {
305            return Err(msg_error_anyhow!(Message::TaskUpdateFailed));
306        }
307
308        Ok(())
309    }
310
311    /// Fetches one task by id, tags included.
312    ///
313    /// ```rust,no_run
314    /// # use kasl::db::tasks::Tasks;
315    /// # fn main() -> anyhow::Result<()> {
316    /// let mut tasks = Tasks::new()?;
317    /// if let Some(task) = tasks.get_by_id(42)? {
318    ///     println!("Found task: {}", task.name);
319    /// } else {
320    ///     println!("Task with ID 42 not found");
321    /// }
322    /// # Ok(())
323    /// # }
324    /// ```
325    pub fn get_by_id(&mut self, id: i32) -> Result<Option<Task>> {
326        let mut tasks = self.fetch(TaskFilter::ByIds(vec![id]))?;
327        Ok(tasks.pop())
328    }
329}