1use 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
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 #[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 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()) }
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()) }
144}
145
146#[derive(Debug)]
148pub struct TodoDraft {
149 pub description: Option<String>,
151
152 pub due: Option<LooseDateTime>,
154
155 pub percent_complete: Option<u8>,
157
158 pub priority: Option<Priority>,
160
161 pub status: TodoStatus,
163
164 pub summary: String,
166}
167
168impl TodoDraft {
169 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 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 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#[derive(Debug, Default, Clone)]
265pub struct TodoPatch {
266 pub description: Option<Option<String>>,
268
269 pub due: Option<Option<LooseDateTime>>,
271
272 pub percent_complete: Option<Option<u8>>,
274
275 pub priority: Option<Priority>,
277
278 pub status: Option<TodoStatus>,
280
281 pub summary: Option<String>,
283}
284
285impl TodoPatch {
286 #[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 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 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 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
380#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
381pub enum TodoStatus {
382 #[default]
384 NeedsAction,
385
386 Completed,
388
389 InProcess,
391
392 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#[derive(Debug, Clone)]
456pub struct TodoConditions {
457 pub status: Option<TodoStatus>,
459
460 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#[derive(Debug, Clone, Copy)]
485pub enum TodoSort {
486 Due(SortOrder),
488
489 Priority {
491 order: SortOrder,
493
494 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}