Skip to main content

singularity_cli/commands/
task.rs

1use anyhow::Result;
2use chrono_tz::Tz;
3use clap::{Subcommand, ValueEnum};
4
5use crate::client::ApiClient;
6use crate::models::task::{
7    ChecklistItemListResponse, Task, TaskCreate, TaskListResponse, TaskUpdate, convert_date_filter,
8    resolve_date_keyword,
9};
10
11#[derive(Clone, ValueEnum)]
12pub enum DateKeyword {
13    Today,
14    Tomorrow,
15    Yesterday,
16    Week,
17    Month,
18}
19
20#[derive(Clone, ValueEnum)]
21pub enum Priority {
22    High,
23    Normal,
24    Low,
25}
26
27impl Priority {
28    fn as_i32(&self) -> i32 {
29        match self {
30            Priority::High => 0,
31            Priority::Normal => 1,
32            Priority::Low => 2,
33        }
34    }
35}
36
37#[derive(Clone, ValueEnum)]
38pub enum CheckedStatus {
39    Empty,
40    Checked,
41    Cancelled,
42}
43
44impl CheckedStatus {
45    fn as_i32(&self) -> i32 {
46        match self {
47            CheckedStatus::Empty => 0,
48            CheckedStatus::Checked => 1,
49            CheckedStatus::Cancelled => 2,
50        }
51    }
52}
53
54#[derive(Subcommand)]
55pub enum TaskCmd {
56    #[command(about = "List tasks with optional filters")]
57    List {
58        #[arg(value_enum, help = "Date shortcut: today, tomorrow, yesterday, week, month")]
59        date: Option<DateKeyword>,
60        #[arg(long, help = "Filter by project ID (P-<uuid>)")]
61        project_id: Option<String>,
62        #[arg(long, help = "Filter by parent task ID (T-<uuid>)")]
63        parent: Option<String>,
64        #[arg(
65            long,
66            help = "Filter tasks starting on or after this date (ISO 8601, inclusive)"
67        )]
68        start_from: Option<String>,
69        #[arg(
70            long,
71            help = "Filter tasks starting on or before this date (ISO 8601, inclusive)"
72        )]
73        start_to: Option<String>,
74        #[arg(long, help = "Maximum number of tasks to return (max 1000)")]
75        max_count: Option<u32>,
76        #[arg(long, help = "Number of tasks to skip for pagination")]
77        offset: Option<u32>,
78        #[arg(long, help = "Include soft-deleted tasks")]
79        include_removed: bool,
80        #[arg(long, help = "Include archived tasks")]
81        include_archived: bool,
82    },
83    #[command(about = "Get a single task by ID")]
84    Get {
85        #[arg(help = "Task ID (T-<uuid> format)")]
86        id: String,
87    },
88    #[command(about = "Create a new task")]
89    Create {
90        #[arg(help = "Task title")]
91        title: String,
92        #[arg(long, help = "Task description/notes")]
93        note: Option<String>,
94        #[arg(long, value_enum, help = "Task priority: high, normal, or low")]
95        priority: Option<Priority>,
96        #[arg(long, help = "Assign to project (P-<uuid>)")]
97        project_id: Option<String>,
98        #[arg(long, help = "Parent task ID for subtasks (T-<uuid>)")]
99        parent: Option<String>,
100        #[arg(long, help = "Task group ID (Q-<uuid>)")]
101        group: Option<String>,
102        #[arg(long, help = "Deadline date (ISO 8601)")]
103        deadline: Option<String>,
104        #[arg(long, help = "Start date (ISO 8601)")]
105        start: Option<String>,
106        #[arg(long, value_delimiter = ',', help = "Comma-separated tag IDs")]
107        tags: Option<Vec<String>>,
108    },
109    #[command(about = "Mark a task as completed")]
110    Done {
111        #[arg(help = "Task ID (T-<uuid> format)")]
112        id: String,
113    },
114    #[command(about = "Mark a task as cancelled")]
115    Cancel {
116        #[arg(help = "Task ID (T-<uuid> format)")]
117        id: String,
118    },
119    #[command(about = "Reopen a task (uncheck)")]
120    Reopen {
121        #[arg(help = "Task ID (T-<uuid> format)")]
122        id: String,
123    },
124    #[command(about = "Update an existing task (only specified fields are changed)")]
125    Update {
126        #[arg(help = "Task ID to update (T-<uuid> format)")]
127        id: String,
128        #[arg(long, help = "New task title")]
129        title: Option<String>,
130        #[arg(long, help = "New task description/notes")]
131        note: Option<String>,
132        #[arg(long, value_enum, help = "New priority: high, normal, or low")]
133        priority: Option<Priority>,
134        #[arg(
135            long,
136            value_enum,
137            help = "Completion status: empty, checked, or cancelled"
138        )]
139        checked: Option<CheckedStatus>,
140        #[arg(long, help = "Move to project (P-<uuid>)")]
141        project_id: Option<String>,
142        #[arg(long, help = "New parent task ID (T-<uuid>)")]
143        parent: Option<String>,
144        #[arg(long, help = "New task group ID (Q-<uuid>)")]
145        group: Option<String>,
146        #[arg(long, help = "New deadline date (ISO 8601)")]
147        deadline: Option<String>,
148        #[arg(long, help = "New start date (ISO 8601)")]
149        start: Option<String>,
150        #[arg(
151            long,
152            value_delimiter = ',',
153            help = "Replace tags with comma-separated tag IDs"
154        )]
155        tags: Option<Vec<String>>,
156    },
157    #[command(about = "Delete a task by ID (soft-delete)")]
158    Delete {
159        #[arg(help = "Task ID to delete (T-<uuid> format)")]
160        id: String,
161    },
162}
163
164pub fn run(client: &ApiClient, cmd: TaskCmd, json: bool, tz: Option<Tz>) -> Result<()> {
165    match cmd {
166        TaskCmd::List {
167            date,
168            project_id,
169            parent,
170            start_from,
171            start_to,
172            max_count,
173            offset,
174            include_removed,
175            include_archived,
176        } => {
177            if date.is_some() && (start_from.is_some() || start_to.is_some()) {
178                anyhow::bail!("cannot use date keyword with --start-from/--start-to");
179            }
180            let (resolved_from, resolved_to) = if let Some(ref keyword) = date {
181                let keyword_str = match keyword {
182                    DateKeyword::Today => "today",
183                    DateKeyword::Tomorrow => "tomorrow",
184                    DateKeyword::Yesterday => "yesterday",
185                    DateKeyword::Week => "week",
186                    DateKeyword::Month => "month",
187                };
188                let (f, t) = resolve_date_keyword(keyword_str, tz)?;
189                (Some(f), Some(t))
190            } else {
191                (start_from, start_to)
192            };
193            let mut query: Vec<(&str, String)> = Vec::new();
194            if let Some(ref v) = project_id {
195                query.push(("projectId", v.to_string()));
196            }
197            if let Some(ref v) = parent {
198                query.push(("parent", v.to_string()));
199            }
200            if let Some(ref v) = resolved_from {
201                query.push(("startDateFrom", convert_date_filter(v, false, tz)?));
202            }
203            if let Some(ref v) = resolved_to {
204                query.push(("startDateTo", convert_date_filter(v, true, tz)?));
205            }
206            if let Some(v) = max_count {
207                query.push(("maxCount", v.to_string()));
208            }
209            if let Some(v) = offset {
210                query.push(("offset", v.to_string()));
211            }
212            if include_removed {
213                query.push(("includeRemoved", "true".to_string()));
214            }
215            if include_archived {
216                query.push(("includeArchived", "true".to_string()));
217            }
218
219            if json {
220                let resp: serde_json::Value = client.get("/v2/task", &query)?;
221                println!("{}", serde_json::to_string_pretty(&resp)?);
222            } else {
223                let resp: TaskListResponse = client.get("/v2/task", &query)?;
224                if resp.tasks.is_empty() {
225                    println!("No tasks found.");
226                } else {
227                    for t in &resp.tasks {
228                        let checklist: ChecklistItemListResponse =
229                            client.get("/v2/checklist-item", &[("parent", t.id.clone())])?;
230                        println!("{}\n", t.display_list_item(&checklist.checklist_items, tz));
231                    }
232                }
233            }
234        }
235        TaskCmd::Get { id } => {
236            if json {
237                let resp: serde_json::Value = client.get(&format!("/v2/task/{}", id), &[])?;
238                println!("{}", serde_json::to_string_pretty(&resp)?);
239            } else {
240                let task: Task = client.get(&format!("/v2/task/{}", id), &[])?;
241                let checklist: ChecklistItemListResponse =
242                    client.get("/v2/checklist-item", &[("parent", task.id.clone())])?;
243                println!("{}", task.display_detail(&checklist.checklist_items, tz));
244            }
245        }
246        TaskCmd::Create {
247            title,
248            note,
249            priority,
250            project_id,
251            parent,
252            group,
253            deadline,
254            start,
255            tags,
256        } => {
257            let data = TaskCreate {
258                title,
259                note,
260                priority: priority.map(|p| p.as_i32()),
261                project_id,
262                parent,
263                group,
264                deadline,
265                start,
266                tags,
267                is_note: None,
268            };
269            if json {
270                let resp: serde_json::Value = client.post("/v2/task", &data)?;
271                println!("{}", serde_json::to_string_pretty(&resp)?);
272            } else {
273                let task: Task = client.post("/v2/task", &data)?;
274                println!("Created task {}", task.id);
275            }
276        }
277        TaskCmd::Update {
278            id,
279            title,
280            note,
281            priority,
282            checked,
283            project_id,
284            parent,
285            group,
286            deadline,
287            start,
288            tags,
289        } => {
290            let data = TaskUpdate {
291                title,
292                note,
293                priority: priority.map(|p| p.as_i32()),
294                checked: checked.map(|c| c.as_i32()),
295                project_id,
296                parent,
297                group,
298                deadline,
299                start,
300                tags,
301                is_note: None,
302            };
303            if json {
304                let resp: serde_json::Value = client.patch(&format!("/v2/task/{}", id), &data)?;
305                println!("{}", serde_json::to_string_pretty(&resp)?);
306            } else {
307                let task: Task = client.patch(&format!("/v2/task/{}", id), &data)?;
308                println!("Updated task {}", task.id);
309            }
310        }
311        TaskCmd::Delete { id } => {
312            client.delete(&format!("/v2/task/{}", id))?;
313            println!("Deleted task {}", id);
314        }
315        TaskCmd::Done { id } => {
316            let data = TaskUpdate {
317                checked: Some(CheckedStatus::Checked.as_i32()),
318                ..Default::default()
319            };
320            let task: Task = client.patch(&format!("/v2/task/{}", id), &data)?;
321            println!("Completed task {}", task.id);
322        }
323        TaskCmd::Cancel { id } => {
324            let data = TaskUpdate {
325                checked: Some(CheckedStatus::Cancelled.as_i32()),
326                ..Default::default()
327            };
328            let task: Task = client.patch(&format!("/v2/task/{}", id), &data)?;
329            println!("Cancelled task {}", task.id);
330        }
331        TaskCmd::Reopen { id } => {
332            let data = TaskUpdate {
333                checked: Some(CheckedStatus::Empty.as_i32()),
334                ..Default::default()
335            };
336            let task: Task = client.patch(&format!("/v2/task/{}", id), &data)?;
337            println!("Reopened task {}", task.id);
338        }
339    }
340    Ok(())
341}