aimcal_core/
todo.rs

1// SPDX-FileCopyrightText: 2025 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, TimeZone, 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    /// Returns the unique identifier for the todo item.
22    fn uid(&self) -> &str;
23
24    /// Returns the description of the todo item.
25    fn completed(&self) -> Option<DateTime<Local>>;
26
27    /// Returns the description of the todo item, if available.
28    fn description(&self) -> Option<&str>;
29
30    /// Returns 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    /// Returns the status of the todo item.
40    fn status(&self) -> TodoStatus;
41
42    /// Returns 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        self.get_priority()
69            .map(|p| Priority::from(p as u8))
70            .unwrap_or_default()
71    }
72
73    fn status(&self) -> TodoStatus {
74        self.get_status().map(Into::into).unwrap_or_default()
75    }
76
77    fn summary(&self) -> &str {
78        self.get_summary().unwrap_or_default()
79    }
80}
81
82/// Darft for a todo item, used for creating new todos.
83#[derive(Debug)]
84pub struct TodoDraft {
85    /// The description of the todo item, if available.
86    pub description: Option<String>,
87
88    /// The due date and time of the todo item, if available.
89    pub due: Option<LooseDateTime>,
90
91    /// The percent complete, from 0 to 100, if available.
92    pub percent_complete: Option<u8>,
93
94    /// The priority of the todo item, if available.
95    pub priority: Option<Priority>,
96
97    /// The status of the todo item.
98    pub status: TodoStatus,
99
100    /// The summary of the todo item.
101    pub summary: String,
102}
103
104impl TodoDraft {
105    /// Creates a new empty patch.
106    pub(crate) fn default(config: &Config, now: DateTime<Local>) -> Self {
107        Self {
108            description: None,
109            due: config
110                .default_due
111                .map(|d| LooseDateTime::Local(d.datetime(now))),
112            percent_complete: None,
113            priority: Some(config.default_priority),
114            status: TodoStatus::default(),
115            summary: String::default(),
116        }
117    }
118
119    /// Converts the draft into a icalendar Todo component.
120    pub(crate) fn into_ics(
121        self,
122        config: &Config,
123        now: DateTime<Local>,
124        uid: &str,
125    ) -> icalendar::Todo {
126        let mut todo = icalendar::Todo::with_uid(uid);
127
128        if let Some(description) = self.description {
129            Component::description(&mut todo, &description);
130        }
131
132        if let Some(due) = self.due {
133            icalendar::Todo::due(&mut todo, due);
134        } else if let Some(duration) = config.default_due {
135            icalendar::Todo::due(&mut todo, LooseDateTime::Local(duration.datetime(now)));
136        }
137
138        if let Some(percent) = self.percent_complete {
139            icalendar::Todo::percent_complete(&mut todo, percent.max(100));
140        }
141
142        if let Some(priority) = self.priority {
143            Component::priority(&mut todo, priority.into());
144        } else {
145            Component::priority(&mut todo, config.default_priority.into());
146        }
147
148        icalendar::Todo::status(&mut todo, self.status.into());
149
150        Component::summary(&mut todo, &self.summary);
151
152        todo
153    }
154}
155
156/// Patch for a todo item, allowing partial updates.
157#[derive(Debug, Default, Clone)]
158pub struct TodoPatch {
159    /// The description of the todo item, if available.
160    pub description: Option<Option<String>>,
161
162    /// The due date and time of the todo item, if available.
163    pub due: Option<Option<LooseDateTime>>,
164
165    /// The percent complete, from 0 to 100.
166    pub percent_complete: Option<Option<u8>>,
167
168    /// The priority of the todo item, from 1 to 9, where 1 is the highest priority.
169    pub priority: Option<Priority>,
170
171    /// The status of the todo item, if available.
172    pub status: Option<TodoStatus>,
173
174    /// The summary of the todo item, if available.
175    pub summary: Option<String>,
176}
177
178impl TodoPatch {
179    /// Is this patch empty, meaning no fields are set
180    pub fn is_empty(&self) -> bool {
181        self.description.is_none()
182            && self.due.is_none()
183            && self.percent_complete.is_none()
184            && self.priority.is_none()
185            && self.status.is_none()
186            && self.summary.is_none()
187    }
188
189    /// Applies the patch to a mutable todo item, modifying it in place.
190    pub(crate) fn apply_to<'a, Tz: TimeZone>(
191        &self,
192        now: &DateTime<Tz>,
193        t: &'a mut icalendar::Todo,
194    ) -> &'a mut icalendar::Todo {
195        if let Some(description) = &self.description {
196            match description {
197                Some(desc) => t.description(desc),
198                None => t.remove_description(),
199            };
200        }
201
202        if let Some(due) = &self.due {
203            match due {
204                Some(d) => t.due(*d),
205                None => t.remove_due(),
206            };
207        }
208
209        if let Some(percent) = self.percent_complete {
210            t.percent_complete(percent.unwrap_or(0).max(100));
211        }
212
213        if let Some(priority) = self.priority {
214            t.priority(priority.into());
215        }
216
217        if let Some(status) = self.status {
218            t.status(status.into());
219
220            match status {
221                TodoStatus::Completed => t.completed(now.with_timezone(&Utc)),
222                _ if t.get_completed().is_some() => t.remove_completed(),
223                _ => t,
224            };
225        }
226
227        if let Some(summary) = &self.summary {
228            t.summary(summary);
229        }
230
231        t
232    }
233}
234
235/// The status of a todo item, which can be one of several predefined states.
236#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
237#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
238pub enum TodoStatus {
239    /// The todo item needs action.
240    #[default]
241    NeedsAction,
242
243    /// The todo item has been completed.
244    Completed,
245
246    /// The todo item is currently in process.
247    InProcess,
248
249    /// The todo item has been cancelled.
250    Cancelled,
251}
252
253const STATUS_NEEDS_ACTION: &str = "NEEDS-ACTION";
254const STATUS_COMPLETED: &str = "COMPLETED";
255const STATUS_IN_PROCESS: &str = "IN-PROGRESS";
256const STATUS_CANCELLED: &str = "CANCELLED";
257
258impl AsRef<str> for TodoStatus {
259    fn as_ref(&self) -> &str {
260        match self {
261            TodoStatus::NeedsAction => STATUS_NEEDS_ACTION,
262            TodoStatus::Completed => STATUS_COMPLETED,
263            TodoStatus::InProcess => STATUS_IN_PROCESS,
264            TodoStatus::Cancelled => STATUS_CANCELLED,
265        }
266    }
267}
268
269impl Display for TodoStatus {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        write!(f, "{}", self.as_ref())
272    }
273}
274
275impl FromStr for TodoStatus {
276    type Err = ();
277
278    fn from_str(value: &str) -> Result<Self, Self::Err> {
279        match value {
280            STATUS_NEEDS_ACTION => Ok(TodoStatus::NeedsAction),
281            STATUS_COMPLETED => Ok(TodoStatus::Completed),
282            STATUS_IN_PROCESS => Ok(TodoStatus::InProcess),
283            STATUS_CANCELLED => Ok(TodoStatus::Cancelled),
284            _ => Err(()),
285        }
286    }
287}
288
289impl From<TodoStatus> for icalendar::TodoStatus {
290    fn from(item: TodoStatus) -> icalendar::TodoStatus {
291        match item {
292            TodoStatus::NeedsAction => icalendar::TodoStatus::NeedsAction,
293            TodoStatus::Completed => icalendar::TodoStatus::Completed,
294            TodoStatus::InProcess => icalendar::TodoStatus::InProcess,
295            TodoStatus::Cancelled => icalendar::TodoStatus::Cancelled,
296        }
297    }
298}
299
300impl From<icalendar::TodoStatus> for TodoStatus {
301    fn from(status: icalendar::TodoStatus) -> Self {
302        match status {
303            icalendar::TodoStatus::NeedsAction => TodoStatus::NeedsAction,
304            icalendar::TodoStatus::Completed => TodoStatus::Completed,
305            icalendar::TodoStatus::InProcess => TodoStatus::InProcess,
306            icalendar::TodoStatus::Cancelled => TodoStatus::Cancelled,
307        }
308    }
309}
310
311/// Conditions for filtering todo items, such as current time, status, and due date.
312#[derive(Debug, Clone, Copy)]
313pub struct TodoConditions {
314    /// The status of the todo item to filter by, if any.
315    pub status: Option<TodoStatus>,
316
317    /// The priority of the todo item to filter by, if any.
318    pub due: Option<DateTimeAnchor>,
319}
320
321/// Conditions for filtering todo items, such as current time, status, and due date.
322#[derive(Debug, Clone, Copy)]
323pub(crate) struct ParsedTodoConditions {
324    /// The status of the todo item to filter by, if any.
325    pub status: Option<TodoStatus>,
326
327    /// The priority of the todo item to filter by, if any.
328    pub due: Option<DateTime<Local>>,
329}
330
331impl ParsedTodoConditions {
332    pub fn parse(now: &DateTime<Local>, conds: &TodoConditions) -> Self {
333        let status = conds.status;
334        let due = conds.due.map(|a| a.parse_as_end_of_day(now));
335        ParsedTodoConditions { status, due }
336    }
337}
338
339/// The default sort key for todo items, which is by due date.
340#[derive(Debug, Clone, Copy)]
341pub enum TodoSort {
342    /// Sort by the due date and time of the todo item.
343    Due(SortOrder),
344
345    /// Sort by the priority of the todo item.
346    Priority {
347        /// Sort order, either ascending or descending.
348        order: SortOrder,
349
350        /// Put items with no priority first or last. If none, use the default
351        none_first: Option<bool>,
352    },
353}
354
355#[derive(Debug, Clone, Copy)]
356pub(crate) enum ParsedTodoSort {
357    /// Sort by the due date and time of the todo item.
358    Due(SortOrder),
359
360    /// Sort by the priority of the todo item.
361    Priority {
362        /// Sort order, either ascending or descending.
363        order: SortOrder,
364
365        /// Put items with no priority first or last.
366        none_first: bool,
367    },
368}
369
370impl ParsedTodoSort {
371    pub fn parse(config: &Config, sort: TodoSort) -> Self {
372        match sort {
373            TodoSort::Due(order) => ParsedTodoSort::Due(order),
374            TodoSort::Priority { order, none_first } => ParsedTodoSort::Priority {
375                order,
376                none_first: none_first.unwrap_or(config.default_priority_none_fist),
377            },
378        }
379    }
380
381    pub fn parse_vec(config: &Config, sort: &[TodoSort]) -> Vec<Self> {
382        sort.iter()
383            .map(|s| ParsedTodoSort::parse(config, *s))
384            .collect()
385    }
386}