task_picker/sources/
caldav.rs

1use std::collections::HashMap;
2
3use anyhow::{anyhow, Result};
4use chrono::{format::ParseErrorKind, prelude::*};
5
6use serde::{Deserialize, Serialize};
7use ureq::Agent;
8use url::Url;
9
10use crate::tasks::Task;
11
12use super::CALDAV_ICON;
13
14#[derive(Serialize, Deserialize, Clone)]
15#[serde(default)]
16pub struct CalDavSource {
17    #[serde(skip)]
18    agent: ureq::Agent,
19    pub calendar_name: String,
20    pub username: String,
21    pub base_url: String,
22}
23
24impl Default for CalDavSource {
25    fn default() -> Self {
26        Self {
27            agent: Agent::new(),
28            calendar_name: String::default(),
29            username: String::default(),
30            base_url: String::default(),
31        }
32    }
33}
34
35const DATE_TIME_FORMAT: &str = "%Y%m%dT%H%M%S";
36const DATE_ONLY_FORMAT: &str = "%Y%m%d";
37const DATE_TIME_FORMAT_WITH_TZ: &str = "%Y%m%dT%H%M%S%#z";
38
39fn parse_caldav_date(data: &str) -> Result<DateTime<Utc>> {
40    match DateTime::parse_from_str(data, DATE_TIME_FORMAT_WITH_TZ) {
41        Ok(result) => {
42            let result_utc: DateTime<Utc> = DateTime::from(result);
43            Ok(result_utc)
44        }
45        Err(e) => {
46            if e.kind() == ParseErrorKind::TooShort {
47                // Try without a timezone and intepret it as local
48                let result_local =
49                    NaiveDateTime::parse_from_str(data, DATE_TIME_FORMAT).or_else(|e| {
50                        // This could be only a date without a time
51                        if e.kind() == ParseErrorKind::TooShort {
52                            let date = NaiveDate::parse_from_str(data, DATE_ONLY_FORMAT)?;
53                            let end_of_day =
54                                NaiveTime::from_hms_opt(23, 59, 59).unwrap_or_default();
55                            Ok(date.and_time(end_of_day))
56                        } else {
57                            Err(e)
58                        }
59                    })?;
60                match result_local.and_local_timezone(Local) {
61                    chrono::offset::LocalResult::Single(result_local) => Ok(result_local.into()),
62
63                    chrono::offset::LocalResult::Ambiguous(earliest, _) => Ok(earliest.into()),
64                    chrono::offset::LocalResult::None => {
65                        Err(anyhow!("The local time {:#?} does not exist", result_local))
66                    }
67                }
68            } else {
69                Err(anyhow::Error::from(e).context(format!("Could not parse CalDAV date '{data}'")))
70            }
71        }
72    }
73}
74
75impl CalDavSource {
76    pub fn query_tasks<S>(&self, secret: Option<S>) -> Result<Vec<Task>>
77    where
78        S: Into<String>,
79    {
80        let base_url = Url::parse(&self.base_url)?;
81        let secret = secret.ok_or_else(|| anyhow!("Missing passwort for CalDAV source"))?;
82        let credentials = minicaldav::Credentials::Basic(self.username.clone(), secret.into());
83        let calendars = minicaldav::get_calendars(self.agent.clone(), &credentials, &base_url)?;
84        let mut result = Vec::default();
85        for c in calendars {
86            if c.name().as_str() == self.calendar_name {
87                let (todos, _errors) = minicaldav::get_todos(self.agent.clone(), &credentials, &c)?;
88                for t in todos {
89                    let props: HashMap<String, String> = t
90                        .properties_todo()
91                        .into_iter()
92                        .map(|(k, v)| (k.to_string(), v.to_string()))
93                        .collect();
94                    let completed = props
95                        .get("STATUS")
96                        .filter(|s| s.as_str() == "COMPLETED")
97                        .is_some()
98                        || props.contains_key("COMPLETED");
99                    // Check start due date if this task is ready to be started on
100                    let start_due = props
101                        .get("DTSTART")
102                        .map(|raw| parse_caldav_date(raw))
103                        .transpose()?;
104                    let can_start = if let Some(start_due) = start_due {
105                        let start_due: DateTime<Local> = DateTime::from(start_due);
106                        Local::now().cmp(&start_due).is_ge()
107                    } else {
108                        true
109                    };
110                    if !completed && can_start {
111                        if let Some(title) = props.get("SUMMARY") {
112                            let title = unescape(title);
113                            let description: String = props
114                                .get("DESCRIPTION")
115                                .map(|s| unescape(s))
116                                .unwrap_or_default();
117                            let due = props
118                                .get("DUE")
119                                .map(|raw| parse_caldav_date(raw))
120                                .transpose()?;
121
122                            let created = props
123                                .get("CREATED")
124                                .map(|raw| parse_caldav_date(raw))
125                                .transpose()?;
126
127                            let task = Task {
128                                project: format!("{} {}", CALDAV_ICON, c.name()),
129                                title: title.clone(),
130                                description,
131                                due: due.map(DateTime::<Utc>::from),
132                                created: created.map(DateTime::<Utc>::from),
133                                id: props.get("UID").cloned(),
134                            };
135                            result.push(task);
136                        }
137                    }
138                }
139            }
140        }
141        Ok(result)
142    }
143}
144
145/// Unescape some known escaped characters in CalDAV.
146/// This always allocates a new string.
147fn unescape(val: &str) -> String {
148    let mut chars = val.chars().peekable();
149    let mut unescaped = String::new();
150
151    loop {
152        match chars.next() {
153            None => break,
154            Some(c) => {
155                let escaped_char = if c == '\\' {
156                    if let Some(escaped_char) = chars.peek() {
157                        let escaped_char = *escaped_char;
158                        match escaped_char {
159                            _ if escaped_char == '\\'
160                                || escaped_char == '"'
161                                || escaped_char == '\''
162                                || escaped_char == '`'
163                                || escaped_char == '$'
164                                || escaped_char == ',' =>
165                            {
166                                Some(escaped_char)
167                            }
168                            'n' => Some('\n'),
169                            'r' => Some('\r'),
170                            't' => Some('\t'),
171                            _ => None,
172                        }
173                    } else {
174                        None
175                    }
176                } else {
177                    None
178                };
179                if let Some(escaped_char) = escaped_char {
180                    unescaped.push(escaped_char);
181                    // skip the escaped character instead of outputting it again
182                    chars.next();
183                } else {
184                    unescaped.push(c);
185                };
186            }
187        }
188    }
189
190    unescaped
191}
192
193#[cfg(test)]
194mod tests;