1use 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
15pub trait Todo {
17 fn short_id(&self) -> Option<NonZeroU32> {
21 None
22 }
23
24 fn uid(&self) -> Cow<'_, str>;
26
27 fn completed(&self) -> Option<Zoned>;
29
30 fn description(&self) -> Option<Cow<'_, str>>;
32
33 fn due(&self) -> Option<LooseDateTime>;
35
36 fn percent_complete(&self) -> Option<u8>;
38
39 fn priority(&self) -> Priority;
41
42 fn status(&self) -> TodoStatus;
44
45 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()) }
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()) }
91}
92
93#[derive(Debug)]
95pub struct TodoDraft {
96 pub calendar_id: Option<String>,
98 pub description: Option<String>,
100 pub due: Option<LooseDateTime>,
102 pub percent_complete: Option<u8>,
104 pub priority: Option<Priority>,
106 pub status: TodoStatus,
108 pub summary: String,
110}
111
112impl TodoDraft {
113 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 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 pub(crate) fn into_ics(self, uid: &str) -> VTodo<String> {
172 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#[derive(Debug, Default, Clone)]
213pub struct TodoPatch {
214 pub description: Option<Option<String>>,
216 pub due: Option<Option<LooseDateTime>>,
218 pub percent_complete: Option<Option<u8>>,
220 pub priority: Option<Priority>,
222 pub status: Option<TodoStatus>,
224 pub summary: Option<String>,
226}
227
228impl TodoPatch {
229 #[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 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 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 if t.dt_stamp.date().year == 1970 {
327 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
338#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
339pub enum TodoStatus {
340 #[default]
342 NeedsAction,
343 Completed,
345 InProcess,
347 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#[derive(Debug, Clone)]
411pub struct TodoConditions {
412 pub status: Option<TodoStatus>,
414
415 pub due: Option<DateTimeAnchor>,
417
418 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 pub calendar_id: Option<String>,
442}
443
444#[derive(Debug, Clone, Copy)]
446pub enum TodoSort {
447 Due(SortOrder),
449
450 Priority {
452 order: SortOrder,
454 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}