aimcal_core/
todo.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{fmt::Display, num::NonZeroU32, str::FromStr};
6
7use chrono::{DateTime, Local, Utc};
8use icalendar::Component;
9
10use crate::{Config, DateTimeAnchor, LooseDateTime, Priority, SortOrder};
11
12/// Trait representing a todo item.
13pub trait Todo {
14    /// The short identifier for the todo.
15    /// It will be `None` if the event does not have a short ID.
16    /// It is used for display purposes and may not be unique.
17    fn short_id(&self) -> Option<NonZeroU32> {
18        None
19    }
20
21    /// The unique identifier for the todo item.
22    fn uid(&self) -> &str;
23
24    /// The description of the todo item.
25    fn completed(&self) -> Option<DateTime<Local>>;
26
27    /// The description of the todo item, if available.
28    fn description(&self) -> Option<&str>;
29
30    /// The due date and time of the todo item, if available.
31    fn due(&self) -> Option<LooseDateTime>;
32
33    /// The percent complete, from 0 to 100.
34    fn percent_complete(&self) -> Option<u8>;
35
36    /// The priority from 1 to 9, where 1 is the highest priority.
37    fn priority(&self) -> Priority;
38
39    /// The status of the todo item.
40    fn status(&self) -> TodoStatus;
41
42    /// The summary of the todo item.
43    fn summary(&self) -> &str;
44}
45
46impl Todo for icalendar::Todo {
47    fn uid(&self) -> &str {
48        self.get_uid().unwrap_or_default()
49    }
50
51    fn completed(&self) -> Option<DateTime<Local>> {
52        self.get_completed().map(|dt| dt.with_timezone(&Local))
53    }
54
55    fn description(&self) -> Option<&str> {
56        self.get_description()
57    }
58
59    fn due(&self) -> Option<LooseDateTime> {
60        self.get_due().map(Into::into)
61    }
62
63    fn percent_complete(&self) -> Option<u8> {
64        self.get_percent_complete()
65    }
66
67    fn priority(&self) -> Priority {
68        match self.get_priority() {
69            Some(p) => Priority::from(u8::try_from(p.min(9)).unwrap_or_default()),
70            _ => Priority::default(),
71        }
72    }
73
74    fn status(&self) -> TodoStatus {
75        self.get_status().map(Into::into).unwrap_or_default()
76    }
77
78    fn summary(&self) -> &str {
79        self.get_summary().unwrap_or_default()
80    }
81}
82
83/// Darft for a todo item, used for creating new todos.
84#[derive(Debug)]
85pub struct TodoDraft {
86    /// The description of the todo item, if available.
87    pub description: Option<String>,
88
89    /// The due date and time of the todo item, if available.
90    pub due: Option<LooseDateTime>,
91
92    /// The percent complete, from 0 to 100, if available.
93    pub percent_complete: Option<u8>,
94
95    /// The priority of the todo item, if available.
96    pub priority: Option<Priority>,
97
98    /// The status of the todo item.
99    pub status: TodoStatus,
100
101    /// The summary of the todo item.
102    pub summary: String,
103}
104
105impl TodoDraft {
106    /// Creates a new empty patch.
107    pub(crate) fn default(config: &Config, now: &DateTime<Local>) -> Self {
108        Self {
109            description: None,
110            due: config.default_due.map(|d| d.resolve_since_datetime(now)),
111            percent_complete: None,
112            priority: Some(config.default_priority),
113            status: TodoStatus::default(),
114            summary: String::default(),
115        }
116    }
117
118    /// Converts the draft into a icalendar Todo component.
119    pub(crate) fn resolve<'a>(
120        &'a self,
121        config: &Config,
122        now: &'a DateTime<Local>,
123    ) -> ResolvedTodoDraft<'a> {
124        let due = self
125            .due
126            .or_else(|| config.default_due.map(|d| d.resolve_since_datetime(now)));
127
128        let percent_complete = self.percent_complete.map(|a| a.max(100));
129
130        let priority = self.priority.or(Some(config.default_priority));
131
132        ResolvedTodoDraft {
133            description: self.description.as_deref(),
134            due,
135            percent_complete,
136            priority,
137            status: self.status,
138            summary: &self.summary,
139
140            now,
141        }
142    }
143}
144
145#[derive(Debug, Clone, Copy)]
146pub struct ResolvedTodoDraft<'a> {
147    pub description: Option<&'a str>,
148    pub due: Option<LooseDateTime>,
149    pub percent_complete: Option<u8>,
150    pub priority: Option<Priority>,
151    pub status: TodoStatus,
152    pub summary: &'a str,
153
154    pub now: &'a DateTime<Local>,
155}
156
157impl ResolvedTodoDraft<'_> {
158    /// Converts the draft into a icalendar Todo component.
159    pub(crate) fn into_ics(self, uid: &str) -> icalendar::Todo {
160        let mut todo = icalendar::Todo::with_uid(uid);
161
162        if let Some(description) = self.description {
163            Component::description(&mut todo, description);
164        }
165
166        if let Some(due) = self.due {
167            icalendar::Todo::due(&mut todo, due);
168        }
169
170        if let Some(percent) = self.percent_complete {
171            icalendar::Todo::percent_complete(&mut todo, percent);
172        }
173
174        if let Some(priority) = self.priority {
175            Component::priority(&mut todo, priority.into());
176        }
177
178        icalendar::Todo::status(&mut todo, self.status.into());
179
180        Component::summary(&mut todo, self.summary);
181
182        // Set the creation time to now
183        Component::created(&mut todo, self.now.with_timezone(&Utc));
184        todo
185    }
186}
187
188/// Patch for a todo item, allowing partial updates.
189#[derive(Debug, Default, Clone)]
190pub struct TodoPatch {
191    /// The description of the todo item, if available.
192    pub description: Option<Option<String>>,
193
194    /// The due date and time of the todo item, if available.
195    pub due: Option<Option<LooseDateTime>>,
196
197    /// The percent complete, from 0 to 100.
198    pub percent_complete: Option<Option<u8>>,
199
200    /// The priority of the todo item, from 1 to 9, where 1 is the highest priority.
201    pub priority: Option<Priority>,
202
203    /// The status of the todo item, if available.
204    pub status: Option<TodoStatus>,
205
206    /// The summary of the todo item, if available.
207    pub summary: Option<String>,
208}
209
210impl TodoPatch {
211    /// Is this patch empty, meaning no fields are set
212    #[must_use]
213    pub fn is_empty(&self) -> bool {
214        self.description.is_none()
215            && self.due.is_none()
216            && self.percent_complete.is_none()
217            && self.priority.is_none()
218            && self.status.is_none()
219            && self.summary.is_none()
220    }
221
222    pub(crate) fn resolve<'a>(&'a self, now: &'a DateTime<Local>) -> ResolvedTodoPatch<'a> {
223        let percent_complete = match self.percent_complete {
224            Some(Some(v)) => Some(Some(v.min(100))),
225            _ => self.percent_complete,
226        };
227
228        ResolvedTodoPatch {
229            description: self.description.as_ref().map(|opt| opt.as_deref()),
230            due: self.due,
231            percent_complete,
232            priority: self.priority,
233            status: self.status,
234            summary: self.summary.as_deref(),
235            now,
236        }
237    }
238}
239
240#[derive(Debug, Clone, Copy)]
241pub struct ResolvedTodoPatch<'a> {
242    pub description: Option<Option<&'a str>>,
243    pub due: Option<Option<LooseDateTime>>,
244    pub percent_complete: Option<Option<u8>>,
245    pub priority: Option<Priority>,
246    pub status: Option<TodoStatus>,
247    pub summary: Option<&'a str>,
248
249    pub now: &'a DateTime<Local>,
250}
251
252impl ResolvedTodoPatch<'_> {
253    /// Applies the patch to a mutable todo item, modifying it in place.
254    pub fn apply_to<'b>(&self, t: &'b mut icalendar::Todo) -> &'b mut icalendar::Todo {
255        match self.description {
256            Some(Some(desc)) => t.description(desc),
257            Some(None) => t.remove_description(),
258            None => t,
259        };
260
261        match self.due {
262            Some(Some(due)) => t.due(due),
263            Some(None) => t.remove_due(),
264            None => t,
265        };
266
267        match self.percent_complete {
268            Some(Some(v)) => t.percent_complete(v),
269            Some(None) => t.remove_percent_complete(),
270            None => t,
271        };
272
273        if let Some(priority) = self.priority {
274            t.priority(priority.into());
275        }
276
277        if let Some(status) = self.status {
278            t.status(status.into());
279
280            match status {
281                TodoStatus::Completed => t.completed(self.now.with_timezone(&Utc)),
282                _ if t.get_completed().is_some() => t.remove_completed(),
283                _ => t,
284            };
285        }
286
287        if let Some(summary) = &self.summary {
288            t.summary(summary);
289        }
290
291        // Set the creation time to now if it is not already set
292        if t.get_created().is_none() {
293            Component::created(t, self.now.with_timezone(&Utc));
294        }
295        t
296    }
297}
298
299/// The status of a todo item, which can be one of several predefined states.
300#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
301#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
302pub enum TodoStatus {
303    /// The todo item needs action.
304    #[default]
305    NeedsAction,
306
307    /// The todo item has been completed.
308    Completed,
309
310    /// The todo item is currently in process.
311    InProcess,
312
313    /// The todo item has been cancelled.
314    Cancelled,
315}
316
317const STATUS_NEEDS_ACTION: &str = "NEEDS-ACTION";
318const STATUS_COMPLETED: &str = "COMPLETED";
319const STATUS_IN_PROCESS: &str = "IN-PROGRESS";
320const STATUS_CANCELLED: &str = "CANCELLED";
321
322impl AsRef<str> for TodoStatus {
323    fn as_ref(&self) -> &str {
324        match self {
325            TodoStatus::NeedsAction => STATUS_NEEDS_ACTION,
326            TodoStatus::Completed => STATUS_COMPLETED,
327            TodoStatus::InProcess => STATUS_IN_PROCESS,
328            TodoStatus::Cancelled => STATUS_CANCELLED,
329        }
330    }
331}
332
333impl Display for TodoStatus {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        self.as_ref().fmt(f)
336    }
337}
338
339impl FromStr for TodoStatus {
340    type Err = ();
341
342    fn from_str(value: &str) -> Result<Self, Self::Err> {
343        match value {
344            STATUS_NEEDS_ACTION => Ok(TodoStatus::NeedsAction),
345            STATUS_COMPLETED => Ok(TodoStatus::Completed),
346            STATUS_IN_PROCESS => Ok(TodoStatus::InProcess),
347            STATUS_CANCELLED => Ok(TodoStatus::Cancelled),
348            _ => Err(()),
349        }
350    }
351}
352
353impl From<TodoStatus> for icalendar::TodoStatus {
354    fn from(item: TodoStatus) -> icalendar::TodoStatus {
355        match item {
356            TodoStatus::NeedsAction => icalendar::TodoStatus::NeedsAction,
357            TodoStatus::Completed => icalendar::TodoStatus::Completed,
358            TodoStatus::InProcess => icalendar::TodoStatus::InProcess,
359            TodoStatus::Cancelled => icalendar::TodoStatus::Cancelled,
360        }
361    }
362}
363
364impl From<icalendar::TodoStatus> for TodoStatus {
365    fn from(status: icalendar::TodoStatus) -> Self {
366        match status {
367            icalendar::TodoStatus::NeedsAction => TodoStatus::NeedsAction,
368            icalendar::TodoStatus::Completed => TodoStatus::Completed,
369            icalendar::TodoStatus::InProcess => TodoStatus::InProcess,
370            icalendar::TodoStatus::Cancelled => TodoStatus::Cancelled,
371        }
372    }
373}
374
375/// Conditions for filtering todo items, such as current time, status, and due date.
376#[derive(Debug, Clone, Copy)]
377pub struct TodoConditions {
378    /// The status of the todo item to filter by, if any.
379    pub status: Option<TodoStatus>,
380
381    /// The priority of the todo item to filter by, if any.
382    pub due: Option<DateTimeAnchor>,
383}
384
385impl TodoConditions {
386    pub(crate) fn resolve(&self, now: &DateTime<Local>) -> ResolvedTodoConditions {
387        ResolvedTodoConditions {
388            status: self.status,
389            due: self.due.map(|a| a.resolve_at_end_of_day(now)),
390        }
391    }
392}
393
394#[derive(Debug, Clone, Copy)]
395pub struct ResolvedTodoConditions {
396    pub status: Option<TodoStatus>,
397    pub due: Option<DateTime<Local>>,
398}
399
400/// The default sort key for todo items, which is by due date.
401#[derive(Debug, Clone, Copy)]
402pub enum TodoSort {
403    /// Sort by the due date and time of the todo item.
404    Due(SortOrder),
405
406    /// Sort by the priority of the todo item.
407    Priority {
408        /// Sort order, either ascending or descending.
409        order: SortOrder,
410
411        /// Put items with no priority first or last. If none, use the default
412        none_first: Option<bool>,
413    },
414}
415
416impl TodoSort {
417    pub(crate) fn resolve(self, config: &Config) -> ResolvedTodoSort {
418        match self {
419            TodoSort::Due(order) => ResolvedTodoSort::Due(order),
420            TodoSort::Priority { order, none_first } => ResolvedTodoSort::Priority {
421                order,
422                none_first: none_first.unwrap_or(config.default_priority_none_fist),
423            },
424        }
425    }
426
427    pub(crate) fn resolve_vec(sort: &[TodoSort], config: &Config) -> Vec<ResolvedTodoSort> {
428        sort.iter().map(|s| s.resolve(config)).collect()
429    }
430}
431
432#[derive(Debug, Clone, Copy)]
433pub enum ResolvedTodoSort {
434    Due(SortOrder),
435    Priority { order: SortOrder, none_first: bool },
436}