aimcal_core/
todo.rs

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