i3status_rs/blocks/calendar/
caldav.rs

1use std::{str::FromStr as _, time::Duration, vec};
2
3use chrono::{DateTime, Local, Utc};
4use icalendar::{Component as _, EventLike as _};
5use reqwest::{
6    self, ClientBuilder, Method, Url,
7    header::{CONTENT_TYPE, HeaderMap, HeaderValue},
8};
9use serde::Deserialize;
10
11use super::{
12    CalendarError,
13    auth::{Auth, Authorize},
14};
15
16#[derive(Clone, Debug)]
17pub struct Event {
18    pub uid: Option<String>,
19    pub summary: Option<String>,
20    pub description: Option<String>,
21    pub location: Option<String>,
22    pub url: Option<String>,
23    pub start_at: Option<DateTime<Utc>>,
24    pub end_at: Option<DateTime<Utc>>,
25}
26
27#[derive(Deserialize, Debug)]
28pub struct Calendar {
29    pub url: Url,
30    pub name: String,
31}
32
33pub struct Client {
34    url: Url,
35    client: reqwest::Client,
36    auth: Auth,
37}
38
39impl Client {
40    pub fn new(url: Url, auth: Auth) -> Self {
41        let mut headers = HeaderMap::new();
42        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/xml"));
43        Self {
44            url,
45            client: ClientBuilder::new()
46                .timeout(Duration::from_secs(10))
47                .default_headers(headers)
48                .build()
49                .expect("A valid http client"),
50            auth,
51        }
52    }
53    async fn propfind_request(
54        &mut self,
55        url: Url,
56        depth: usize,
57        body: String,
58    ) -> Result<Multistatus, CalendarError> {
59        let request = self
60            .client
61            .request(Method::from_str("PROPFIND").expect("A valid method"), url)
62            .body(body.clone())
63            .headers(self.auth.headers().await)
64            .header("Depth", depth)
65            .build()
66            .expect("A valid propfind request");
67        self.call(request).await
68    }
69
70    async fn report_request(
71        &mut self,
72        url: Url,
73        depth: usize,
74        body: String,
75    ) -> Result<Multistatus, CalendarError> {
76        let request = self
77            .client
78            .request(Method::from_str("REPORT").expect("A valid method"), url)
79            .body(body)
80            .headers(self.auth.headers().await)
81            .header("Depth", depth)
82            .build()
83            .expect("A valid report request");
84        self.call(request).await
85    }
86
87    async fn call(&mut self, request: reqwest::Request) -> Result<Multistatus, CalendarError> {
88        let mut retries = 0;
89        loop {
90            let result = self
91                .client
92                .execute(request.try_clone().expect("Request to be cloneable"))
93                .await?;
94            match result.error_for_status() {
95                Err(err) if retries == 0 => {
96                    self.auth.handle_error(err).await?;
97                    retries += 1;
98                }
99                Err(err) => return Err(CalendarError::Http(err)),
100                Ok(result) => return Ok(quick_xml::de::from_str(result.text().await?.as_str())?),
101            };
102        }
103    }
104
105    async fn user_principal_url(&mut self) -> Result<Url, CalendarError> {
106        let multi_status = self
107            .propfind_request(self.url.clone(), 1, CURRENT_USER_PRINCIPAL.into())
108            .await?;
109        parse_href(multi_status, self.url.clone())
110    }
111
112    async fn home_set_url(&mut self, user_principal_url: Url) -> Result<Url, CalendarError> {
113        let multi_status = self
114            .propfind_request(user_principal_url, 0, CALENDAR_HOME_SET.into())
115            .await?;
116        parse_href(multi_status, self.url.clone())
117    }
118
119    async fn calendars_query(&mut self, home_set_url: Url) -> Result<Vec<Calendar>, CalendarError> {
120        let multi_status = self
121            .propfind_request(home_set_url, 1, CALENDAR_REQUEST.into())
122            .await?;
123        parse_calendars(multi_status, self.url.clone())
124    }
125
126    pub async fn calendars(&mut self) -> Result<Vec<Calendar>, CalendarError> {
127        let user_principal_url = self.user_principal_url().await?;
128        let home_set_url = self.home_set_url(user_principal_url).await?;
129        self.calendars_query(home_set_url).await
130    }
131
132    pub async fn events(
133        &mut self,
134        calendar: &Calendar,
135        start: DateTime<Utc>,
136        end: DateTime<Utc>,
137    ) -> Result<Vec<Event>, CalendarError> {
138        let multi_status = self
139            .report_request(calendar.url.clone(), 1, calendar_events_request(start, end))
140            .await?;
141        parse_events(multi_status)
142    }
143
144    pub async fn authorize(&mut self) -> Result<Authorize, CalendarError> {
145        self.auth.authorize().await
146    }
147
148    pub async fn ask_user(&mut self, authorize: Authorize) -> Result<(), CalendarError> {
149        match authorize {
150            Authorize::Completed => Ok(()),
151            Authorize::AskUser(authorize_url) => self.auth.ask_user(authorize_url).await,
152        }
153    }
154}
155
156#[derive(Debug, Deserialize)]
157#[serde(rename = "multistatus")]
158struct Multistatus {
159    #[serde(rename = "response", default)]
160    responses: Vec<Response>,
161}
162
163#[derive(Debug, Deserialize)]
164struct Response {
165    href: String,
166    #[serde(rename = "propstat", default)]
167    propstats: Vec<Propstat>,
168}
169
170impl Response {
171    fn valid_props(self) -> Vec<PropValue> {
172        self.propstats
173            .into_iter()
174            .filter(|p| p.status.contains("200"))
175            .flat_map(|p| p.prop.values.into_iter())
176            .collect()
177    }
178}
179
180#[derive(Debug, Deserialize)]
181struct Propstat {
182    status: String,
183    prop: Prop,
184}
185
186#[derive(Debug, Deserialize)]
187struct Prop {
188    #[serde(rename = "$value")]
189    pub values: Vec<PropValue>,
190}
191
192#[derive(Debug, Deserialize)]
193#[serde(rename_all = "kebab-case")]
194enum PropValue {
195    CurrentUserPrincipal(HrefProperty),
196    CalendarHomeSet(HrefProperty),
197    SupportedCalendarComponentSet(SupportedCalendarComponentSet),
198    #[serde(rename = "displayname")]
199    DisplayName(String),
200    #[serde(rename = "resourcetype")]
201    ResourceType(ResourceTypes),
202    CalendarData(String),
203}
204
205#[derive(Debug, Deserialize)]
206pub struct HrefProperty {
207    href: String,
208}
209
210#[derive(Debug, Deserialize)]
211struct ResourceTypes {
212    #[serde(rename = "$value")]
213    pub values: Vec<ResourceType>,
214}
215
216impl ResourceTypes {
217    fn is_calendar(&self) -> bool {
218        self.values.contains(&ResourceType::Calendar)
219    }
220}
221#[derive(Debug, Deserialize, PartialEq)]
222#[serde(rename_all = "kebab-case")]
223enum ResourceType {
224    Calendar,
225    #[serde(other)]
226    Unsupported,
227}
228
229#[derive(Debug, Deserialize)]
230struct SupportedCalendarComponentSet {
231    #[serde(rename = "$value", default)]
232    pub values: Vec<Comp>,
233}
234impl SupportedCalendarComponentSet {
235    fn supports_events(&self) -> bool {
236        self.values.iter().any(|v| v.name == "VEVENT")
237    }
238}
239
240#[derive(Debug, Deserialize)]
241struct Comp {
242    #[serde(rename = "@name", default)]
243    name: String,
244}
245
246fn parse_href(multi_status: Multistatus, base_url: Url) -> Result<Url, CalendarError> {
247    let props = multi_status
248        .responses
249        .into_iter()
250        .flat_map(|r| r.valid_props().into_iter())
251        .next();
252    match props.ok_or_else(|| CalendarError::Parsing("Property not found".into()))? {
253        PropValue::CurrentUserPrincipal(href) | PropValue::CalendarHomeSet(href) => base_url
254            .join(&href.href)
255            .map_err(|e| CalendarError::Parsing(e.to_string())),
256        _ => Err(CalendarError::Parsing("Invalid property".into())),
257    }
258}
259
260fn parse_calendars(
261    multi_status: Multistatus,
262    base_url: Url,
263) -> Result<Vec<Calendar>, CalendarError> {
264    let mut result = vec![];
265    for response in multi_status.responses {
266        let mut is_calendar = false;
267        let mut supports_events = false;
268        let mut name = None;
269        let href = response.href.clone();
270        for prop in response.valid_props() {
271            match prop {
272                PropValue::SupportedCalendarComponentSet(comp) => {
273                    supports_events = comp.supports_events();
274                }
275                PropValue::DisplayName(display_name) => name = Some(display_name),
276                PropValue::ResourceType(ty) => is_calendar = ty.is_calendar(),
277                _ => {}
278            }
279        }
280        if is_calendar
281            && supports_events
282            && let Some(name) = name
283        {
284            result.push(Calendar {
285                name,
286                url: base_url
287                    .join(&href)
288                    .map_err(|_| CalendarError::Parsing("Malformed calendar url".into()))?,
289            });
290        }
291    }
292    Ok(result)
293}
294
295fn parse_events(multi_status: Multistatus) -> Result<Vec<Event>, CalendarError> {
296    let mut result = vec![];
297    for response in multi_status.responses {
298        for prop in response.valid_props() {
299            if let PropValue::CalendarData(data) = prop {
300                let calendar =
301                    icalendar::Calendar::from_str(&data).map_err(CalendarError::Parsing)?;
302                for component in calendar.components {
303                    if let icalendar::CalendarComponent::Event(event) = component {
304                        let start_at = event.get_start().and_then(|d| match d {
305                            icalendar::DatePerhapsTime::DateTime(dt) => dt.try_into_utc(),
306                            icalendar::DatePerhapsTime::Date(d) => d
307                                .and_hms_opt(0, 0, 0)
308                                .and_then(|d| d.and_local_timezone(Local).earliest())
309                                .map(|d| d.to_utc()),
310                        });
311                        let end_at = event.get_end().and_then(|d| match d {
312                            icalendar::DatePerhapsTime::DateTime(dt) => dt.try_into_utc(),
313                            icalendar::DatePerhapsTime::Date(d) => d
314                                .and_hms_opt(23, 59, 59)
315                                .and_then(|d| d.and_local_timezone(Local).earliest())
316                                .map(|d| d.to_utc()),
317                        });
318                        result.push(Event {
319                            uid: event.get_uid().map(Into::into),
320                            summary: event.get_summary().map(Into::into),
321                            description: event.get_description().map(Into::into),
322                            location: event.get_location().map(Into::into),
323                            url: event.get_url().map(Into::into),
324                            start_at,
325                            end_at,
326                        });
327                    }
328                }
329            }
330        }
331    }
332    Ok(result)
333}
334
335static CURRENT_USER_PRINCIPAL: &str = r#"<d:propfind xmlns:d="DAV:">
336          <d:prop>
337            <d:current-user-principal />
338          </d:prop>
339        </d:propfind>"#;
340
341static CALENDAR_HOME_SET: &str = r#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" >
342            <d:prop>
343                <c:calendar-home-set />
344            </d:prop>
345        </d:propfind>"#;
346
347static CALENDAR_REQUEST: &str = r#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" >
348            <d:prop>
349                <d:displayname />
350                <d:resourcetype />
351                <c:supported-calendar-component-set />
352            </d:prop>
353        </d:propfind>"#;
354
355pub fn calendar_events_request(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
356    const DATE_FORMAT: &str = "%Y%m%dT%H%M%SZ";
357    let start = start.format(DATE_FORMAT);
358    let end = end.format(DATE_FORMAT);
359    format!(
360        r#"<?xml version="1.0" encoding="UTF-8"?>
361        <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
362        <d:prop>
363            <c:calendar-data/>
364        </d:prop>
365        <c:filter>
366            <c:comp-filter name="VCALENDAR">
367                <c:comp-filter name="VEVENT">
368                    <c:time-range start="{start}" end="{end}" />
369                </c:comp-filter>
370            </c:comp-filter>
371        </c:filter>
372        </c:calendar-query>"#
373    )
374}