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::{borrow::Cow, fmt::Display, num::NonZeroU32, str::FromStr};
6
7use aimcal_ical as ical;
8use aimcal_ical::{
9    Completed, Description, DtStamp, Due, PercentComplete, Summary, TodoStatusValue, Uid, VTodo,
10};
11use jiff::{Zoned, civil, tz::TimeZone};
12
13use crate::{Config, DateTimeAnchor, LooseDateTime, Priority, SortOrder};
14
15/// Trait representing a todo item.
16pub trait Todo {
17    /// The short identifier for the todo.
18    /// It will be `None` if the event does not have a short ID.
19    /// It is used for display purposes and may not be unique.
20    fn short_id(&self) -> Option<NonZeroU32> {
21        None
22    }
23
24    /// The unique identifier for the todo item.
25    fn uid(&self) -> Cow<'_, str>;
26
27    /// The description of the todo item.
28    fn completed(&self) -> Option<Zoned>;
29
30    /// The description of the todo item, if available.
31    fn description(&self) -> Option<Cow<'_, str>>;
32
33    /// The due date and time of the todo item, if available.
34    fn due(&self) -> Option<LooseDateTime>;
35
36    /// The percent complete, from 0 to 100.
37    fn percent_complete(&self) -> Option<u8>;
38
39    /// The priority from 1 to 9, where 1 is the highest priority.
40    fn priority(&self) -> Priority;
41
42    /// The status of the todo item.
43    fn status(&self) -> TodoStatus;
44
45    /// The summary of the todo item.
46    fn summary(&self) -> Cow<'_, str>;
47}
48
49impl Todo for VTodo<String> {
50    fn uid(&self) -> Cow<'_, str> {
51        self.uid.content.to_string().into()
52    }
53
54    fn completed(&self) -> Option<Zoned> {
55        #[allow(clippy::cast_possible_wrap)]
56        self.completed.as_ref().and_then(|c| match &**c {
57            ical::DateTime::Utc { date, time, .. } => {
58                let civil_dt = civil::DateTime::new(
59                    date.year,
60                    date.month,
61                    date.day,
62                    time.hour as i8,
63                    time.minute as i8,
64                    time.second as i8,
65                    0,
66                )
67                .unwrap();
68                Some(civil_dt.to_zoned(TimeZone::UTC).unwrap())
69            }
70            ical::DateTime::Floating { date, time, .. } => {
71                let civil_dt = civil::DateTime::new(
72                    date.year,
73                    date.month,
74                    date.day,
75                    time.hour as i8,
76                    time.minute as i8,
77                    time.second as i8,
78                    0,
79                )
80                .unwrap();
81                // Try to interpret in system timezone
82                match civil_dt.to_zoned(TimeZone::system()) {
83                    Ok(zoned) => Some(zoned),
84                    Err(_) => {
85                        tracing::warn!("invalid local time, using UTC");
86                        Some(civil_dt.to_zoned(TimeZone::UTC).unwrap())
87                    }
88                }
89            }
90            ical::DateTime::Zoned {
91                date, time, tz_id, ..
92            } => {
93                let civil_dt = civil::DateTime::new(
94                    date.year,
95                    date.month,
96                    date.day,
97                    time.hour as i8,
98                    time.minute as i8,
99                    time.second as i8,
100                    0,
101                )
102                .unwrap();
103                TimeZone::get(tz_id.as_str())
104                    .ok()
105                    .and_then(|tz| civil_dt.to_zoned(tz).ok())
106            }
107            ical::DateTime::Date { .. } => None,
108        })
109    }
110
111    fn description(&self) -> Option<Cow<'_, str>> {
112        self.description
113            .as_ref()
114            .map(|a| a.content.to_string().into()) // PERF: avoid allocation
115    }
116
117    fn due(&self) -> Option<LooseDateTime> {
118        self.due.as_ref().map(|d| d.inner.clone().into())
119    }
120
121    fn percent_complete(&self) -> Option<u8> {
122        self.percent_complete.as_ref().map(|p| p.value)
123    }
124
125    fn priority(&self) -> Priority {
126        match self.priority.as_ref() {
127            Some(p) => p.value.into(),
128            None => Priority::default(),
129        }
130    }
131
132    fn status(&self) -> TodoStatus {
133        self.status
134            .as_ref()
135            .map(|s| s.value.into())
136            .unwrap_or_default()
137    }
138
139    fn summary(&self) -> Cow<'_, str> {
140        self.summary
141            .as_ref()
142            .map_or_else(|| "".into(), |s| s.content.to_string().into()) // PERF: avoid allocation
143    }
144}
145
146/// Darft for a todo item, used for creating new todos.
147#[derive(Debug)]
148pub struct TodoDraft {
149    /// The description of the todo item, if available.
150    pub description: Option<String>,
151
152    /// The due date and time of the todo item, if available.
153    pub due: Option<LooseDateTime>,
154
155    /// The percent complete, from 0 to 100, if available.
156    pub percent_complete: Option<u8>,
157
158    /// The priority of the todo item, if available.
159    pub priority: Option<Priority>,
160
161    /// The status of the todo item.
162    pub status: TodoStatus,
163
164    /// The summary of the todo item.
165    pub summary: String,
166}
167
168impl TodoDraft {
169    /// Creates a new empty patch.
170    pub(crate) fn default(config: &Config, now: &Zoned) -> Result<Self, String> {
171        Ok(Self {
172            description: None,
173            due: config
174                .default_due
175                .as_ref()
176                .map(|d| d.clone().resolve_since_zoned(now))
177                .transpose()?,
178            percent_complete: None,
179            priority: Some(config.default_priority),
180            status: TodoStatus::default(),
181            summary: String::default(),
182        })
183    }
184
185    /// Converts the draft into a icalendar Todo component.
186    pub(crate) fn resolve<'a>(&'a self, config: &Config, now: &'a Zoned) -> ResolvedTodoDraft<'a> {
187        let due = self.due.clone().or_else(|| {
188            config
189                .default_due
190                .as_ref()
191                .map(|d| d.clone().resolve_since_zoned(now))
192                .and_then(Result::ok)
193        });
194
195        let percent_complete = self.percent_complete.map(|a| a.max(100));
196
197        let priority = self.priority.or(Some(config.default_priority));
198
199        ResolvedTodoDraft {
200            description: self.description.as_deref(),
201            due,
202            percent_complete,
203            priority,
204            status: self.status,
205            summary: &self.summary,
206
207            now,
208        }
209    }
210}
211
212#[derive(Debug, Clone)]
213pub struct ResolvedTodoDraft<'a> {
214    pub description: Option<&'a str>,
215    pub due: Option<LooseDateTime>,
216    pub percent_complete: Option<u8>,
217    pub priority: Option<Priority>,
218    pub status: TodoStatus,
219    pub summary: &'a str,
220
221    pub now: &'a Zoned,
222}
223
224impl ResolvedTodoDraft<'_> {
225    /// Converts the draft into an aimcal-ical `VTodo` component.
226    pub(crate) fn into_ics(self, uid: &str) -> VTodo<String> {
227        VTodo {
228            uid: Uid::new(uid.to_string()),
229            dt_stamp: DtStamp::new(ical::DateTime::from(LooseDateTime::Local(self.now.clone()))),
230            dt_start: None,
231            due: self.due.map(|d| Due::new(d.into())),
232            completed: None,
233            duration: None,
234            summary: Some(Summary::new(self.summary.to_string())),
235            description: self.description.map(|d| Description::new(d.to_string())),
236            status: Some(ical::TodoStatus::new(self.status.into())),
237            percent_complete: self
238                .percent_complete
239                .map(|p| PercentComplete::new(p.min(100))),
240            priority: self
241                .priority
242                .map(|p| ical::Priority::new(Into::<u8>::into(p))),
243            location: None,
244            geo: None,
245            url: None,
246            organizer: None,
247            attendees: Vec::new(),
248            last_modified: None,
249            sequence: None,
250            classification: None,
251            resources: None,
252            categories: None,
253            rrule: None,
254            rdates: Vec::new(),
255            ex_dates: Vec::new(),
256            x_properties: Vec::new(),
257            retained_properties: Vec::new(),
258            alarms: Vec::new(),
259        }
260    }
261}
262
263/// Patch for a todo item, allowing partial updates.
264#[derive(Debug, Default, Clone)]
265pub struct TodoPatch {
266    /// The description of the todo item, if available.
267    pub description: Option<Option<String>>,
268
269    /// The due date and time of the todo item, if available.
270    pub due: Option<Option<LooseDateTime>>,
271
272    /// The percent complete, from 0 to 100.
273    pub percent_complete: Option<Option<u8>>,
274
275    /// The priority of the todo item, from 1 to 9, where 1 is the highest priority.
276    pub priority: Option<Priority>,
277
278    /// The status of the todo item, if available.
279    pub status: Option<TodoStatus>,
280
281    /// The summary of the todo item, if available.
282    pub summary: Option<String>,
283}
284
285impl TodoPatch {
286    /// Is this patch empty, meaning no fields are set
287    #[must_use]
288    pub fn is_empty(&self) -> bool {
289        self.description.is_none()
290            && self.due.is_none()
291            && self.percent_complete.is_none()
292            && self.priority.is_none()
293            && self.status.is_none()
294            && self.summary.is_none()
295    }
296
297    pub(crate) fn resolve<'a>(&'a self, now: &'a Zoned) -> ResolvedTodoPatch<'a> {
298        let percent_complete = match self.percent_complete {
299            Some(Some(v)) => Some(Some(v.min(100))),
300            _ => self.percent_complete,
301        };
302
303        ResolvedTodoPatch {
304            description: self.description.as_ref().map(|opt| opt.as_deref()),
305            due: self.due.clone(),
306            percent_complete,
307            priority: self.priority,
308            status: self.status,
309            summary: self.summary.as_deref(),
310            now,
311        }
312    }
313}
314
315#[derive(Debug, Clone)]
316pub struct ResolvedTodoPatch<'a> {
317    pub description: Option<Option<&'a str>>,
318    pub due: Option<Option<LooseDateTime>>,
319    pub percent_complete: Option<Option<u8>>,
320    pub priority: Option<Priority>,
321    pub status: Option<TodoStatus>,
322    pub summary: Option<&'a str>,
323
324    pub now: &'a Zoned,
325}
326
327impl ResolvedTodoPatch<'_> {
328    /// Applies the patch to a mutable todo item, modifying it in place.
329    pub fn apply_to<'a>(&self, t: &'a mut VTodo<String>) -> &'a mut VTodo<String> {
330        if let Some(Some(desc)) = &self.description {
331            t.description = Some(Description::new((*desc).to_string()));
332        } else if self.description.is_some() {
333            t.description = None;
334        }
335
336        if let Some(Some(ref due)) = self.due {
337            t.due = Some(Due::new(due.clone().into()));
338        } else if self.due.is_some() {
339            t.due = None;
340        }
341
342        if let Some(Some(v)) = self.percent_complete {
343            t.percent_complete = Some(PercentComplete::new(v.min(100)));
344        } else if self.percent_complete.is_some() {
345            t.percent_complete = None;
346        }
347
348        if let Some(priority) = self.priority {
349            t.priority = Some(ical::Priority::new(Into::<u8>::into(priority)));
350        }
351
352        if let Some(status) = self.status {
353            t.status = Some(ical::TodoStatus::new(status.into()));
354
355            // Handle COMPLETED property
356            if status == TodoStatus::Completed && t.completed.is_none() {
357                t.completed = Some(Completed::new(ical::DateTime::from(LooseDateTime::Local(
358                    self.now.clone(),
359                ))));
360            } else if status != TodoStatus::Completed {
361                t.completed = None;
362            }
363        }
364
365        if let Some(summary) = &self.summary {
366            t.summary = Some(Summary::new((*summary).to_string()));
367        }
368
369        // Set the creation time to now if it is not already set
370        if t.dt_stamp.inner.date().year == 1970 {
371            t.dt_stamp = DtStamp::new(ical::DateTime::from(LooseDateTime::Local(self.now.clone())));
372        }
373
374        t
375    }
376}
377
378/// The status of a todo item, which can be one of several predefined states.
379#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
380#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
381pub enum TodoStatus {
382    /// The todo item needs action.
383    #[default]
384    NeedsAction,
385
386    /// The todo item has been completed.
387    Completed,
388
389    /// The todo item is currently in process.
390    InProcess,
391
392    /// The todo item has been cancelled.
393    Cancelled,
394}
395
396const STATUS_NEEDS_ACTION: &str = "NEEDS-ACTION";
397const STATUS_COMPLETED: &str = "COMPLETED";
398const STATUS_IN_PROCESS: &str = "IN-PROGRESS";
399const STATUS_CANCELLED: &str = "CANCELLED";
400
401impl AsRef<str> for TodoStatus {
402    fn as_ref(&self) -> &str {
403        match self {
404            TodoStatus::NeedsAction => STATUS_NEEDS_ACTION,
405            TodoStatus::Completed => STATUS_COMPLETED,
406            TodoStatus::InProcess => STATUS_IN_PROCESS,
407            TodoStatus::Cancelled => STATUS_CANCELLED,
408        }
409    }
410}
411
412impl Display for TodoStatus {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        self.as_ref().fmt(f)
415    }
416}
417
418impl FromStr for TodoStatus {
419    type Err = ();
420
421    fn from_str(value: &str) -> Result<Self, Self::Err> {
422        match value {
423            STATUS_NEEDS_ACTION => Ok(TodoStatus::NeedsAction),
424            STATUS_COMPLETED => Ok(TodoStatus::Completed),
425            STATUS_IN_PROCESS => Ok(TodoStatus::InProcess),
426            STATUS_CANCELLED => Ok(TodoStatus::Cancelled),
427            _ => Err(()),
428        }
429    }
430}
431
432impl From<TodoStatusValue> for TodoStatus {
433    fn from(value: TodoStatusValue) -> Self {
434        match value {
435            TodoStatusValue::NeedsAction => TodoStatus::NeedsAction,
436            TodoStatusValue::Completed => TodoStatus::Completed,
437            TodoStatusValue::InProcess => TodoStatus::InProcess,
438            TodoStatusValue::Cancelled => TodoStatus::Cancelled,
439        }
440    }
441}
442
443impl From<TodoStatus> for TodoStatusValue {
444    fn from(value: TodoStatus) -> Self {
445        match value {
446            TodoStatus::NeedsAction => TodoStatusValue::NeedsAction,
447            TodoStatus::Completed => TodoStatusValue::Completed,
448            TodoStatus::InProcess => TodoStatusValue::InProcess,
449            TodoStatus::Cancelled => TodoStatusValue::Cancelled,
450        }
451    }
452}
453
454/// Conditions for filtering todo items, such as current time, status, and due date.
455#[derive(Debug, Clone)]
456pub struct TodoConditions {
457    /// The status of the todo item to filter by, if any.
458    pub status: Option<TodoStatus>,
459
460    /// The priority of the todo item to filter by, if any.
461    pub due: Option<DateTimeAnchor>,
462}
463
464impl TodoConditions {
465    pub(crate) fn resolve(&self, now: &Zoned) -> Result<ResolvedTodoConditions, String> {
466        Ok(ResolvedTodoConditions {
467            status: self.status,
468            due: self
469                .due
470                .as_ref()
471                .map(|a| a.resolve_at_end_of_day(now))
472                .transpose()?,
473        })
474    }
475}
476
477#[derive(Debug, Clone)]
478pub struct ResolvedTodoConditions {
479    pub status: Option<TodoStatus>,
480    pub due: Option<Zoned>,
481}
482
483/// The default sort key for todo items, which is by due date.
484#[derive(Debug, Clone, Copy)]
485pub enum TodoSort {
486    /// Sort by the due date and time of the todo item.
487    Due(SortOrder),
488
489    /// Sort by the priority of the todo item.
490    Priority {
491        /// Sort order, either ascending or descending.
492        order: SortOrder,
493
494        /// Put items with no priority first or last. If none, use the default
495        none_first: Option<bool>,
496    },
497}
498
499impl TodoSort {
500    pub(crate) fn resolve(self, config: &Config) -> ResolvedTodoSort {
501        match self {
502            TodoSort::Due(order) => ResolvedTodoSort::Due(order),
503            TodoSort::Priority { order, none_first } => ResolvedTodoSort::Priority {
504                order,
505                none_first: none_first.unwrap_or(config.default_priority_none_fist),
506            },
507        }
508    }
509
510    pub(crate) fn resolve_vec(sort: &[TodoSort], config: &Config) -> Vec<ResolvedTodoSort> {
511        sort.iter().map(|s| (*s).resolve(config)).collect()
512    }
513}
514
515#[derive(Debug, Clone, Copy)]
516pub enum ResolvedTodoSort {
517    Due(SortOrder),
518    Priority { order: SortOrder, none_first: bool },
519}