task_picker/sources/
caldav.rs1use 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 let result_local =
49 NaiveDateTime::parse_from_str(data, DATE_TIME_FORMAT).or_else(|e| {
50 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 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
145fn 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 chars.next();
183 } else {
184 unescaped.push(c);
185 };
186 }
187 }
188 }
189
190 unescaped
191}
192
193#[cfg(test)]
194mod tests;