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    comp: Option<Comp>,
232}
233impl SupportedCalendarComponentSet {
234    fn supports_events(&self) -> bool {
235        self.comp.as_ref().is_some_and(|v| v.name == "VEVENT")
236    }
237}
238
239#[derive(Debug, Deserialize)]
240struct Comp {
241    #[serde(rename = "@name", default)]
242    name: String,
243}
244
245fn parse_href(multi_status: Multistatus, base_url: Url) -> Result<Url, CalendarError> {
246    let props = multi_status
247        .responses
248        .into_iter()
249        .flat_map(|r| r.valid_props().into_iter())
250        .next();
251    match props.ok_or_else(|| CalendarError::Parsing("Property not found".into()))? {
252        PropValue::CurrentUserPrincipal(href) | PropValue::CalendarHomeSet(href) => base_url
253            .join(&href.href)
254            .map_err(|e| CalendarError::Parsing(e.to_string())),
255        _ => Err(CalendarError::Parsing("Invalid property".into())),
256    }
257}
258
259fn parse_calendars(
260    multi_status: Multistatus,
261    base_url: Url,
262) -> Result<Vec<Calendar>, CalendarError> {
263    let mut result = vec![];
264    for response in multi_status.responses {
265        let mut is_calendar = false;
266        let mut supports_events = false;
267        let mut name = None;
268        let href = response.href.clone();
269        for prop in response.valid_props() {
270            match prop {
271                PropValue::SupportedCalendarComponentSet(comp) => {
272                    supports_events = comp.supports_events();
273                }
274                PropValue::DisplayName(display_name) => name = Some(display_name),
275                PropValue::ResourceType(ty) => is_calendar = ty.is_calendar(),
276                _ => {}
277            }
278        }
279        if is_calendar && supports_events {
280            if let Some(name) = name {
281                result.push(Calendar {
282                    name,
283                    url: base_url
284                        .join(&href)
285                        .map_err(|_| CalendarError::Parsing("Malformed calendar url".into()))?,
286                });
287            }
288        }
289    }
290    Ok(result)
291}
292
293fn parse_events(multi_status: Multistatus) -> Result<Vec<Event>, CalendarError> {
294    let mut result = vec![];
295    for response in multi_status.responses {
296        for prop in response.valid_props() {
297            if let PropValue::CalendarData(data) = prop {
298                let calendar =
299                    icalendar::Calendar::from_str(&data).map_err(CalendarError::Parsing)?;
300                for component in calendar.components {
301                    if let icalendar::CalendarComponent::Event(event) = component {
302                        let start_at = event.get_start().and_then(|d| match d {
303                            icalendar::DatePerhapsTime::DateTime(dt) => dt.try_into_utc(),
304                            icalendar::DatePerhapsTime::Date(d) => d
305                                .and_hms_opt(0, 0, 0)
306                                .and_then(|d| d.and_local_timezone(Local).earliest())
307                                .map(|d| d.to_utc()),
308                        });
309                        let end_at = event.get_end().and_then(|d| match d {
310                            icalendar::DatePerhapsTime::DateTime(dt) => dt.try_into_utc(),
311                            icalendar::DatePerhapsTime::Date(d) => d
312                                .and_hms_opt(23, 59, 59)
313                                .and_then(|d| d.and_local_timezone(Local).earliest())
314                                .map(|d| d.to_utc()),
315                        });
316                        result.push(Event {
317                            uid: event.get_uid().map(Into::into),
318                            summary: event.get_summary().map(Into::into),
319                            description: event.get_description().map(Into::into),
320                            location: event.get_location().map(Into::into),
321                            url: event.get_url().map(Into::into),
322                            start_at,
323                            end_at,
324                        });
325                    }
326                }
327            }
328        }
329    }
330    Ok(result)
331}
332
333static CURRENT_USER_PRINCIPAL: &str = r#"<d:propfind xmlns:d="DAV:">
334          <d:prop>
335            <d:current-user-principal />
336          </d:prop>
337        </d:propfind>"#;
338
339static CALENDAR_HOME_SET: &str = r#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" >
340            <d:prop>
341                <c:calendar-home-set />
342            </d:prop>
343        </d:propfind>"#;
344
345static CALENDAR_REQUEST: &str = r#"<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" >
346            <d:prop>
347                <d:displayname />
348                <d:resourcetype />
349                <c:supported-calendar-component-set />
350            </d:prop>
351        </d:propfind>"#;
352
353pub fn calendar_events_request(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
354    const DATE_FORMAT: &str = "%Y%m%dT%H%M%SZ";
355    let start = start.format(DATE_FORMAT);
356    let end = end.format(DATE_FORMAT);
357    format!(
358        r#"<?xml version="1.0" encoding="UTF-8"?>
359        <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
360        <d:prop>
361            <c:calendar-data/>
362        </d:prop>
363        <c:filter>
364            <c:comp-filter name="VCALENDAR">
365                <c:comp-filter name="VEVENT">
366                    <c:time-range start="{start}" end="{end}" />
367                </c:comp-filter>
368            </c:comp-filter>
369        </c:filter>
370        </c:calendar-query>"#
371    )
372}