Skip to main content

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