Skip to main content

ics_watcher/
apple.rs

1//! Apple Calendar (iCloud) adapter.
2//!
3//! Apple doesn't offer a REST API for its calendars, so this adapter talks CalDAV
4//! ([RFC 4791](https://datatracker.ietf.org/doc/html/rfc4791)) to `caldav.icloud.com`.
5//! Authentication uses an Apple ID together with an
6//! [app-specific password](https://support.apple.com/en-us/102654) - your regular
7//! Apple ID password will not work.
8//!
9//! See [AppleCalendar] to connect to a calendar and [tum_apple_sync] for the
10//! counterpart of [`tum_google_sync`](crate::tum_google_sync).
11
12use std::{
13    collections::{HashMap, HashSet},
14    io::BufReader,
15    time::Duration,
16};
17
18use chrono::{DateTime, NaiveDateTime, Utc};
19
20use ical::{
21    parser::{ical::component::IcalEvent, Component},
22    property::Property,
23    IcalParser,
24};
25
26use quick_xml::{escape::unescape, events::Event as XmlEvent, Reader};
27use reqwest::{
28    header::{CONTENT_TYPE, ETAG, IF_MATCH, IF_NONE_MATCH},
29    Client, Method, RequestBuilder, Response, StatusCode, Url,
30};
31use tokio::time::sleep;
32
33use crate::{replace_courses, unescape_location, CalendarEvent, EventData, PropertyChange};
34
35/// The CalDAV entry point of iCloud, used by [`AppleCalendar::connect`].
36pub const ICLOUD_CALDAV_URL: &str = "https://caldav.icloud.com/";
37
38pub(crate) const PRODID: &str = "-//ics-watcher//Apple Calendar Adapter//EN";
39
40const CURRENT_USER_PRINCIPAL_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
41<d:propfind xmlns:d="DAV:">
42  <d:prop><d:current-user-principal/></d:prop>
43</d:propfind>"#;
44
45const CALENDAR_HOME_SET_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
46<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
47  <d:prop><c:calendar-home-set/></d:prop>
48</d:propfind>"#;
49
50const CALENDAR_LIST_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
51<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
52  <d:prop>
53    <d:resourcetype/>
54    <d:displayname/>
55    <c:supported-calendar-component-set/>
56  </d:prop>
57</d:propfind>"#;
58
59const OBJECT_LIST_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
60<d:propfind xmlns:d="DAV:">
61  <d:prop><d:getetag/></d:prop>
62</d:propfind>"#;
63
64// ---------------------------------------------------------------------------
65// CalDAV plumbing
66// ---------------------------------------------------------------------------
67
68/// A single `<response>` of a WebDAV `multistatus` document, reduced to the
69/// properties this adapter cares about.
70#[derive(Debug, Default)]
71struct DavResource {
72    href: String,
73    display_name: Option<String>,
74    is_calendar: bool,
75    /// `None` if the server didn't report a component set at all
76    components: Option<Vec<String>>,
77    /// Hrefs nested inside a property, keyed by the property name
78    /// (e.g. `current-user-principal`)
79    property_hrefs: HashMap<String, String>,
80}
81
82fn local_name(name: quick_xml::name::QName) -> String {
83    name.local_name().into_inner().to_lowercase()
84}
85
86/// Handles an opening (or self-closing) tag. `parents` does *not* contain `name` yet.
87fn open_element(
88    name: &str,
89    element: &quick_xml::events::BytesStart,
90    parents: &[String],
91    resources: &mut Vec<DavResource>,
92) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
93    match name {
94        "response" => resources.push(DavResource::default()),
95        "calendar" if parents.iter().any(|parent| parent == "resourcetype") => {
96            if let Some(resource) = resources.last_mut() {
97                resource.is_calendar = true;
98            }
99        }
100        "comp"
101            if parents
102                .iter()
103                .any(|parent| parent == "supported-calendar-component-set") =>
104        {
105            if let Some(resource) = resources.last_mut() {
106                let components = resource.components.get_or_insert_with(Vec::new);
107                for attribute in element.attributes() {
108                    let attribute = attribute?;
109                    if local_name(attribute.key) == "name" {
110                        components.push(unescape(&attribute.value)?.to_string());
111                    }
112                }
113            }
114        }
115        _ => (),
116    }
117
118    Ok(())
119}
120
121/// Handles a closing tag. `parents` no longer contains `name`.
122fn close_element(name: &str, text: &str, parents: &[String], resources: &mut [DavResource]) {
123    let Some(resource) = resources.last_mut() else {
124        return;
125    };
126
127    match name {
128        "href" if text.is_empty() => (),
129        "href" if parents.last().is_some_and(|parent| parent == "response") => {
130            resource.href = text.to_string();
131        }
132        "href" => {
133            // <prop><current-user-principal><href>…</href></…></prop>
134            if parents.len() >= 2 && parents[parents.len() - 2] == "prop" {
135                resource
136                    .property_hrefs
137                    .insert(parents[parents.len() - 1].clone(), text.to_string());
138            }
139        }
140        "displayname"
141            if !text.is_empty() && parents.last().is_some_and(|parent| parent == "prop") =>
142        {
143            resource.display_name = Some(text.to_string());
144        }
145        _ => (),
146    }
147}
148
149fn parse_multistatus(
150    xml: &str,
151) -> Result<Vec<DavResource>, Box<dyn std::error::Error + Send + Sync>> {
152    let mut reader = Reader::from_str(xml);
153    let mut resources: Vec<DavResource> = Vec::new();
154    let mut stack: Vec<String> = Vec::new();
155    let mut text = String::new();
156
157    loop {
158        match reader.read_event()? {
159            XmlEvent::Start(element) => {
160                let name = local_name(element.name());
161                open_element(&name, &element, &stack, &mut resources)?;
162                stack.push(name);
163                text.clear();
164            }
165            XmlEvent::Empty(element) => {
166                let name = local_name(element.name());
167                open_element(&name, &element, &stack, &mut resources)?;
168            }
169            XmlEvent::Text(content) => text.push_str(&content.xml10_content()),
170            // Entity references arrive as their own event and have to be resolved
171            XmlEvent::GeneralRef(reference) => {
172                text.push_str(&unescape(&format!("&{};", reference.as_ref()))?)
173            }
174            XmlEvent::End(_) => {
175                if let Some(name) = stack.pop() {
176                    close_element(&name, text.trim(), &stack, &mut resources);
177                }
178                text.clear();
179            }
180            XmlEvent::Eof => break,
181            _ => (),
182        }
183    }
184
185    Ok(resources)
186}
187
188/// How long to wait before each retry of a request that failed for a temporary reason. iCloud
189/// answers the odd request with a 500, which works when sent again a moment later.
190const RETRY_DELAYS: [Duration; 3] = [
191    Duration::from_secs(1),
192    Duration::from_secs(2),
193    Duration::from_secs(4),
194];
195
196/// Whether the server had a temporary problem - unlike a 4xx, which means that the request
197/// itself is wrong and sending it again won't help.
198fn is_transient_status(status: StatusCode) -> bool {
199    matches!(
200        status,
201        StatusCode::INTERNAL_SERVER_ERROR
202            | StatusCode::BAD_GATEWAY
203            | StatusCode::SERVICE_UNAVAILABLE
204            | StatusCode::GATEWAY_TIMEOUT
205    )
206}
207
208/// Whether the request didn't get through: the connection couldn't be established, broke off
209/// or timed out.
210fn is_transient_error(error: &reqwest::Error) -> bool {
211    error.is_connect() || error.is_timeout() || error.is_request()
212}
213
214#[derive(Clone)]
215struct DavClient {
216    http: Client,
217    username: String,
218    password: String,
219    /// See [RETRY_DELAYS]
220    retry_delays: &'static [Duration],
221}
222
223impl DavClient {
224    fn new(
225        username: &str,
226        password: &str,
227    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
228        Ok(DavClient {
229            http: Client::builder()
230                .user_agent(concat!("ics-watcher/", env!("CARGO_PKG_VERSION")))
231                .build()?,
232            username: username.to_string(),
233            password: password.to_string(),
234            retry_delays: &RETRY_DELAYS,
235        })
236    }
237
238    fn request(&self, method: Method, url: &Url) -> RequestBuilder {
239        self.http
240            .request(method, url.clone())
241            .basic_auth(&self.username, Some(&self.password))
242    }
243
244    /// Sends a request, and sends it again as long as it fails for a temporary reason (see
245    /// [is_transient_status] and [is_transient_error]), waiting a bit longer every time.
246    ///
247    /// A request can only be sent once, so `prepare` is called for every attempt to add the
248    /// headers and the body.
249    async fn send(
250        &self,
251        method: Method,
252        url: &Url,
253        prepare: impl Fn(RequestBuilder) -> RequestBuilder,
254    ) -> Result<Response, reqwest::Error> {
255        let mut delays = self.retry_delays.iter();
256
257        loop {
258            let result = prepare(self.request(method.clone(), url)).send().await;
259            let problem = match &result {
260                Ok(response) if is_transient_status(response.status()) => {
261                    Some(response.status().to_string())
262                }
263                Err(error) if is_transient_error(error) => Some(error.to_string()),
264                _ => None,
265            };
266
267            match (problem, delays.next()) {
268                (Some(problem), Some(delay)) => {
269                    eprintln!("{method} {url} failed ({problem}), retrying in {delay:?}");
270                    sleep(*delay).await;
271                }
272                _ => return result,
273            }
274        }
275    }
276
277    async fn propfind(
278        &self,
279        url: &Url,
280        depth: &str,
281        body: &'static str,
282    ) -> Result<Vec<DavResource>, Box<dyn std::error::Error + Send + Sync>> {
283        let response = self
284            .send(Method::from_bytes(b"PROPFIND")?, url, |request| {
285                request
286                    .header(CONTENT_TYPE, "application/xml; charset=utf-8")
287                    .header("Depth", depth)
288                    .body(body)
289            })
290            .await?;
291
292        let status = response.status();
293        if !status.is_success() {
294            return Err(format!("PROPFIND {url} failed with status {status}").into());
295        }
296
297        parse_multistatus(&response.text().await?)
298    }
299}
300
301/// A calendar collection as advertised by the CalDAV server.
302#[derive(Debug, Clone)]
303pub struct CalendarInfo {
304    /// The name as shown in the Apple Calendar app
305    pub name: Option<String>,
306    pub url: String,
307    /// `false` for collections which only hold reminders (`VTODO`)
308    pub supports_events: bool,
309}
310
311async fn discover_calendars(
312    dav: &DavClient,
313    server_url: &str,
314) -> Result<Vec<CalendarInfo>, Box<dyn std::error::Error + Send + Sync>> {
315    let server_url = Url::parse(server_url)?;
316
317    let principal_href = dav
318        .propfind(&server_url, "0", CURRENT_USER_PRINCIPAL_BODY)
319        .await?
320        .into_iter()
321        .find_map(|resource| {
322            resource
323                .property_hrefs
324                .get("current-user-principal")
325                .cloned()
326        })
327        .ok_or("CalDAV server didn't return a current-user-principal")?;
328    let principal_url = server_url.join(&principal_href)?;
329
330    let home_href = dav
331        .propfind(&principal_url, "0", CALENDAR_HOME_SET_BODY)
332        .await?
333        .into_iter()
334        .find_map(|resource| resource.property_hrefs.get("calendar-home-set").cloned())
335        .ok_or("CalDAV server didn't return a calendar-home-set")?;
336    let home_url = ensure_collection_url(principal_url.join(&home_href)?)?;
337
338    list_calendars(dav, &home_url).await
339}
340
341/// The calendars in a calendar home, the collection all calendars of an account live in.
342async fn list_calendars(
343    dav: &DavClient,
344    home_url: &Url,
345) -> Result<Vec<CalendarInfo>, Box<dyn std::error::Error + Send + Sync>> {
346    let mut calendars = Vec::new();
347    for resource in dav.propfind(home_url, "1", CALENDAR_LIST_BODY).await? {
348        if !resource.is_calendar {
349            continue;
350        }
351
352        calendars.push(CalendarInfo {
353            name: resource.display_name,
354            url: ensure_collection_url(home_url.join(&resource.href)?)?.to_string(),
355            supports_events: resource
356                .components
357                .is_none_or(|components| components.iter().any(|component| component == "VEVENT")),
358        });
359    }
360
361    Ok(calendars)
362}
363
364/// The URL of the calendar named `calendar_name`, or an error listing the ones there are.
365fn find_calendar(
366    calendars: &[CalendarInfo],
367    calendar_name: &str,
368) -> Result<Url, Box<dyn std::error::Error + Send + Sync>> {
369    let calendar = calendars
370        .iter()
371        .find(|calendar| {
372            calendar.supports_events && calendar.name.as_deref() == Some(calendar_name)
373        })
374        .ok_or_else(|| {
375            let available: Vec<&str> = calendars
376                .iter()
377                .filter(|calendar| calendar.supports_events)
378                .filter_map(|calendar| calendar.name.as_deref())
379                .collect();
380            format!(
381                "No calendar named {calendar_name:?} found. Available calendars: {}",
382                available.join(", ")
383            )
384        })?;
385
386    Ok(Url::parse(&calendar.url)?)
387}
388
389fn ensure_collection_url(url: Url) -> Result<Url, Box<dyn std::error::Error + Send + Sync>> {
390    if url.path().ends_with('/') {
391        Ok(url)
392    } else {
393        Ok(Url::parse(&format!("{url}/"))?)
394    }
395}
396
397/// Lists all calendars of an iCloud account, which is handy to find out the exact
398/// name to pass to [`AppleCalendar::connect`].
399///
400/// `app_password` has to be an [app-specific password](https://support.apple.com/en-us/102654).
401pub async fn list_icloud_calendars(
402    apple_id: &str,
403    app_password: &str,
404) -> Result<Vec<CalendarInfo>, Box<dyn std::error::Error + Send + Sync>> {
405    let dav = DavClient::new(apple_id, app_password)?;
406    discover_calendars(&dav, ICLOUD_CALDAV_URL).await
407}
408
409// ---------------------------------------------------------------------------
410// Calendar handle
411// ---------------------------------------------------------------------------
412
413/// A connected Apple Calendar, used by [tum_apple_sync].
414///
415/// Cloning is cheap (the underlying connection pool is shared), so the handle can
416/// be created once and moved into a callback.
417///
418/// # Examples
419///
420/// ```
421/// let apple_calendar = AppleCalendar::connect("me@icloud.com", "abcd-efgh-ijkl-mnop", "TUM")
422///     .await
423///     .expect("Failed to connect to the Apple Calendar");
424/// ```
425#[derive(Clone)]
426pub struct AppleCalendar {
427    dav: DavClient,
428    calendar_url: Url,
429    /// Where exams go instead, see [`AppleCalendar::with_exam_calendar`]
430    exam_calendar_url: Option<Url>,
431}
432
433impl std::fmt::Debug for AppleCalendar {
434    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        // Deliberately without the credentials
436        formatter
437            .debug_struct("AppleCalendar")
438            .field("username", &self.dav.username)
439            .field("calendar_url", &self.calendar_url.as_str())
440            .field(
441                "exam_calendar_url",
442                &self.exam_calendar_url.as_ref().map(Url::as_str),
443            )
444            .finish_non_exhaustive()
445    }
446}
447
448impl AppleCalendar {
449    /// Connects to the iCloud calendar named `calendar_name`.
450    ///
451    /// `app_password` has to be an [app-specific password](https://support.apple.com/en-us/102654),
452    /// your regular Apple ID password won't be accepted.
453    pub async fn connect(
454        apple_id: &str,
455        app_password: &str,
456        calendar_name: &str,
457    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
458        Self::connect_to(ICLOUD_CALDAV_URL, apple_id, app_password, calendar_name).await
459    }
460
461    /// Same as [`AppleCalendar::connect`], but for any other CalDAV server
462    /// (Fastmail, Nextcloud, Radicale, …).
463    pub async fn connect_to(
464        server_url: &str,
465        username: &str,
466        password: &str,
467        calendar_name: &str,
468    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
469        let dav = DavClient::new(username, password)?;
470        let calendars = discover_calendars(&dav, server_url).await?;
471
472        Ok(AppleCalendar {
473            calendar_url: find_calendar(&calendars, calendar_name)?,
474            dav,
475            exam_calendar_url: None,
476        })
477    }
478
479    /// Skips the discovery and uses the calendar collection at `calendar_url` directly.
480    pub fn from_calendar_url(
481        username: &str,
482        password: &str,
483        calendar_url: &str,
484    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
485        Ok(AppleCalendar {
486            dav: DavClient::new(username, password)?,
487            calendar_url: ensure_collection_url(Url::parse(calendar_url)?)?,
488            exam_calendar_url: None,
489        })
490    }
491
492    /// Puts exams (events with "Prüfung" in their title) into the calendar named
493    /// `calendar_name` instead, so that they can have a color of their own - Apple Calendar
494    /// only colors whole calendars, not single events. It has to belong to the same account.
495    ///
496    /// Exams which are in the main calendar already can be moved over with
497    /// [`move_exams_to_exam_calendar`](crate::move_exams_to_exam_calendar).
498    pub async fn with_exam_calendar(
499        mut self,
500        calendar_name: &str,
501    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
502        // All calendars of an account live side by side in its calendar home
503        let home_url = self.calendar_url.join("../")?;
504        let calendars = list_calendars(&self.dav, &home_url).await?;
505
506        self.exam_calendar_url = Some(find_calendar(&calendars, calendar_name)?);
507        Ok(self)
508    }
509
510    /// Same as [`AppleCalendar::with_exam_calendar`], but skips the discovery and uses the
511    /// calendar collection at `calendar_url` directly.
512    pub fn with_exam_calendar_url(
513        mut self,
514        calendar_url: &str,
515    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
516        self.exam_calendar_url = Some(ensure_collection_url(Url::parse(calendar_url)?)?);
517        Ok(self)
518    }
519
520    pub fn calendar_url(&self) -> &str {
521        self.calendar_url.as_str()
522    }
523
524    pub fn exam_calendar_url(&self) -> Option<&str> {
525        self.exam_calendar_url.as_ref().map(Url::as_str)
526    }
527
528    /// The URL an event is stored at in the main calendar. Derived from the uid, so the same
529    /// event always ends up at the same place - no lookup needed.
530    pub(crate) fn object_url(
531        &self,
532        uid: &str,
533    ) -> Result<Url, Box<dyn std::error::Error + Send + Sync>> {
534        Ok(self.calendar_url.join(&object_name(uid))?)
535    }
536
537    /// The calendars an event can be in: the one it belongs into first, then the other one,
538    /// if exams have a calendar of their own. Events can end up in the other one when they
539    /// got moved over by hand, or were created before there was an exam calendar.
540    pub(crate) fn calendar_urls(&self, exam: bool) -> Vec<&Url> {
541        match &self.exam_calendar_url {
542            Some(exam_calendar_url) if exam => vec![exam_calendar_url, &self.calendar_url],
543            Some(exam_calendar_url) => vec![&self.calendar_url, exam_calendar_url],
544            None => vec![&self.calendar_url],
545        }
546    }
547
548    /// The resource names of all calendar objects currently in the calendar.
549    pub(crate) async fn object_names(
550        &self,
551    ) -> Result<HashSet<String>, Box<dyn std::error::Error + Send + Sync>> {
552        let resources = self
553            .dav
554            .propfind(&self.calendar_url, "1", OBJECT_LIST_BODY)
555            .await?;
556
557        Ok(resources
558            .into_iter()
559            .filter_map(|resource| {
560                let url = self.calendar_url.join(&resource.href).ok()?;
561                // The calendar collection lists itself as well
562                if url.path() == self.calendar_url.path() {
563                    return None;
564                }
565                url.path_segments()?
566                    .next_back()
567                    .filter(|name| !name.is_empty())
568                    .map(|name| name.to_string())
569            })
570            .collect())
571    }
572
573    /// Returns the stored calendar object and its etag, or `None` if it doesn't exist
574    /// (anymore).
575    pub(crate) async fn fetch_object(
576        &self,
577        url: &Url,
578    ) -> Result<Option<(String, Option<String>)>, Box<dyn std::error::Error + Send + Sync>> {
579        let response = self.dav.send(Method::GET, url, |request| request).await?;
580
581        if response.status() == StatusCode::NOT_FOUND {
582            return Ok(None);
583        }
584        let status = response.status();
585        if !status.is_success() {
586            return Err(format!("GET {url} failed with status {status}").into());
587        }
588
589        let etag = response
590            .headers()
591            .get(ETAG)
592            .and_then(|etag| etag.to_str().ok())
593            .map(|etag| etag.to_string());
594
595        Ok(Some((response.text().await?, etag)))
596    }
597
598    /// Uploads a calendar object unless there already is one at `url`.
599    ///
600    /// Returns whether the object was created. When a server error stored the object after
601    /// all, the retry finds it already there and `false` is returned.
602    pub(crate) async fn create_object(
603        &self,
604        url: &Url,
605        calendar_object: &str,
606    ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
607        let response = self
608            .dav
609            .send(Method::PUT, url, |request| {
610                request
611                    .header(CONTENT_TYPE, "text/calendar; charset=utf-8")
612                    .header(IF_NONE_MATCH, "*")
613                    .body(calendar_object.to_string())
614            })
615            .await?;
616        let status = response.status();
617
618        if status == StatusCode::PRECONDITION_FAILED {
619            return Ok(false);
620        }
621        if !status.is_success() {
622            // The server usually explains why it rejected the event
623            let reason: String = response
624                .text()
625                .await
626                .unwrap_or_default()
627                .chars()
628                .take(300)
629                .collect();
630            return Err(format!("PUT {url} failed with status {status}: {reason}").into());
631        }
632
633        Ok(true)
634    }
635
636    pub(crate) async fn put_object(
637        &self,
638        url: &Url,
639        calendar_object: &str,
640        etag: Option<&str>,
641    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
642        let response = self
643            .dav
644            .send(Method::PUT, url, |request| {
645                let request = request
646                    .header(CONTENT_TYPE, "text/calendar; charset=utf-8")
647                    .body(calendar_object.to_string());
648
649                match etag {
650                    Some(etag) => request.header(IF_MATCH, etag),
651                    None => request,
652                }
653            })
654            .await?;
655        let status = response.status();
656
657        // Also happens when a server error stored the event after all and the retry no
658        // longer matches the etag - there's no telling the two apart
659        if status == StatusCode::PRECONDITION_FAILED {
660            return Err(format!(
661                "PUT {url} was rejected because the event changed on the server in the meantime"
662            )
663            .into());
664        }
665        if !status.is_success() {
666            return Err(format!("PUT {url} failed with status {status}").into());
667        }
668
669        Ok(())
670    }
671
672    async fn delete_object(
673        &self,
674        url: &Url,
675    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
676        let response = self
677            .dav
678            .send(Method::DELETE, url, |request| request)
679            .await?;
680        let status = response.status();
681
682        // Already gone - nothing to do
683        if status == StatusCode::NOT_FOUND {
684            return Ok(());
685        }
686        if !status.is_success() {
687            return Err(format!("DELETE {url} failed with status {status}").into());
688        }
689
690        Ok(())
691    }
692
693    /// Moves a calendar object into another calendar of the same account - with an `etag`,
694    /// only if it didn't change since. The server does the moving, as iCloud doesn't allow
695    /// an event in two calendars at once, so it can't be copied over first.
696    pub(crate) async fn move_object(
697        &self,
698        url: &Url,
699        target_url: &Url,
700        etag: Option<&str>,
701    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
702        let response = self
703            .dav
704            .send(Method::from_bytes(b"MOVE")?, url, |request| {
705                let request = request
706                    .header("Destination", target_url.as_str())
707                    .header("Overwrite", "F");
708
709                match etag {
710                    Some(etag) => request.header(IF_MATCH, etag),
711                    None => request,
712                }
713            })
714            .await?;
715        let status = response.status();
716
717        // A retry after a server error that moved it after all
718        if status == StatusCode::NOT_FOUND && self.fetch_object(target_url).await?.is_some() {
719            return Ok(());
720        }
721        if status == StatusCode::PRECONDITION_FAILED {
722            return Err(format!(
723                "MOVE {url} was rejected because the event changed in the meantime or is in {target_url} already"
724            )
725            .into());
726        }
727        if !status.is_success() {
728            return Err(format!("MOVE {url} to {target_url} failed with status {status}").into());
729        }
730
731        Ok(())
732    }
733}
734
735/// Turns a uid into a stable, filesystem- and URL-safe resource name.
736///
737/// The hash keeps the name unique even when sanitizing or truncating maps two
738/// different uids onto the same slug.
739pub(crate) fn object_name(uid: &str) -> String {
740    let mut slug: String = uid
741        .chars()
742        .map(|character| {
743            if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
744                character
745            } else {
746                '-'
747            }
748        })
749        .collect();
750    // Only ASCII at this point, so truncating on a byte index is safe
751    slug.truncate(100);
752
753    format!("{slug}-{:016x}.ics", fnv1a(uid))
754}
755
756/// FNV-1a, because `DefaultHasher` isn't guaranteed to be stable across releases
757/// and the resource name has to stay the same forever.
758fn fnv1a(value: &str) -> u64 {
759    let mut hash = 0xcbf2_9ce4_8422_2325u64;
760    for byte in value.as_bytes() {
761        hash ^= *byte as u64;
762        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
763    }
764    hash
765}
766
767// ---------------------------------------------------------------------------
768// iCalendar serialization
769// ---------------------------------------------------------------------------
770
771/// Escapes a value for a text property as described in
772/// [RFC 5545 3.3.11](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11)
773pub(crate) fn escape_text(value: &str) -> String {
774    let mut escaped = String::with_capacity(value.len());
775    for character in value.chars() {
776        match character {
777            '\\' => escaped.push_str(r"\\"),
778            ';' => escaped.push_str(r"\;"),
779            ',' => escaped.push_str(r"\,"),
780            '\n' => escaped.push_str(r"\n"),
781            '\r' => (),
782            other => escaped.push(other),
783        }
784    }
785    escaped
786}
787
788fn quote_parameter(value: &str) -> String {
789    if value
790        .chars()
791        .any(|character| matches!(character, ':' | ';' | ',' | ' '))
792    {
793        format!("\"{}\"", value.replace('"', ""))
794    } else {
795        value.to_string()
796    }
797}
798
799/// Appends a property as a content line, see [push_line].
800///
801/// `value` is expected to be escaped already.
802pub(crate) fn push_property(
803    calendar_object: &mut String,
804    name: &str,
805    parameters: &[(String, Vec<String>)],
806    value: &str,
807) {
808    let mut line = String::from(name);
809    for (key, values) in parameters {
810        line.push(';');
811        line.push_str(key);
812        line.push('=');
813        line.push_str(
814            &values
815                .iter()
816                .map(|value| quote_parameter(value))
817                .collect::<Vec<String>>()
818                .join(","),
819        );
820    }
821    line.push(':');
822    line.push_str(value);
823
824    push_line(calendar_object, &line);
825}
826
827/// Appends a content line, folded to 75 octets as required by
828/// [RFC 5545 3.1](https://datatracker.ietf.org/doc/html/rfc5545#section-3.1).
829pub(crate) fn push_line(calendar_object: &mut String, line: &str) {
830    let mut octets = 0;
831    for character in line.chars() {
832        if octets + character.len_utf8() > 75 {
833            // Continuation lines start with a single space
834            calendar_object.push_str("\r\n ");
835            octets = 1;
836        }
837        calendar_object.push(character);
838        octets += character.len_utf8();
839    }
840    calendar_object.push_str("\r\n");
841}
842
843/// A property we either build ourselves or carry over from the calendar unchanged.
844struct RawProperty {
845    name: String,
846    parameters: Vec<(String, Vec<String>)>,
847    /// Already escaped
848    value: String,
849}
850
851impl RawProperty {
852    fn new(name: &str, value: &str) -> Self {
853        RawProperty {
854            name: name.to_string(),
855            parameters: Vec::new(),
856            value: value.to_string(),
857        }
858    }
859
860    /// Takes a property over as-is - the ical parser hands out unescaped values,
861    /// so nothing has to be escaped again.
862    fn inherited(property: &Property) -> Self {
863        RawProperty {
864            name: property.name.clone(),
865            parameters: property.params.clone().unwrap_or_default(),
866            value: property.value.clone().unwrap_or_default(),
867        }
868    }
869
870    fn push_to(&self, calendar_object: &mut String) {
871        push_property(calendar_object, &self.name, &self.parameters, &self.value);
872    }
873}
874
875/// Copies whole components (`VTIMEZONE`) out of a calendar object verbatim, so that
876/// `TZID` parameters we carry over keep pointing at a definition.
877fn extract_components(calendar_object: &str, component: &str) -> Vec<String> {
878    let begin = format!("BEGIN:{component}");
879    let end = format!("END:{component}");
880
881    let mut components = Vec::new();
882    let mut current: Option<String> = None;
883
884    for line in calendar_object.lines() {
885        let line = line.trim_end_matches('\r');
886        if current.is_none() && line.eq_ignore_ascii_case(&begin) {
887            current = Some(String::new());
888        }
889
890        let is_end = line.eq_ignore_ascii_case(&end);
891        if let Some(buffer) = current.as_mut() {
892            buffer.push_str(line);
893            buffer.push_str("\r\n");
894        }
895        if is_end {
896            if let Some(buffer) = current.take() {
897                components.push(buffer);
898            }
899        }
900    }
901
902    components
903}
904
905/// The event as it currently sits in the Apple Calendar
906pub(crate) struct StoredEvent {
907    pub(crate) event: IcalEvent,
908    timezones: Vec<String>,
909}
910
911pub(crate) fn parse_stored_event(calendar_object: &str) -> Option<StoredEvent> {
912    let calendar = IcalParser::new(BufReader::new(calendar_object.as_bytes()))
913        .next()?
914        .ok()?;
915
916    Some(StoredEvent {
917        event: calendar.events.into_iter().next()?,
918        timezones: extract_components(calendar_object, "VTIMEZONE"),
919    })
920}
921
922// ---------------------------------------------------------------------------
923// Building the event
924// ---------------------------------------------------------------------------
925
926pub(crate) fn format_utc(date_time: DateTime<Utc>) -> String {
927    date_time.format("%Y%m%dT%H%M%SZ").to_string()
928}
929
930fn parse_ical_utc(
931    event: &IcalEvent,
932    name: &str,
933) -> Result<DateTime<Utc>, Box<dyn std::error::Error + Send + Sync>> {
934    let value = event
935        .get_property(name)
936        .and_then(|property| property.value.clone())
937        .ok_or(format!("Required property {name} missing"))?;
938
939    let date_time = value.get(0..15).ok_or(format!(
940        "Property {name} has an unsupported value {value:?}"
941    ))?;
942
943    Ok(NaiveDateTime::parse_from_str(date_time, "%Y%m%dT%H%M%S")?.and_utc())
944}
945
946fn build_description(
947    event: &IcalEvent,
948    room: &str,
949) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
950    let description = event
951        .get_property("DESCRIPTION")
952        .and_then(|property| property.value.clone())
953        .map(|description| description.split(r"\;").skip(2).collect::<String>())
954        .unwrap_or_default()
955        .replace(r"\n", "\n")
956        .replace(r"\", "")
957        .trim()
958        .to_string();
959
960    let location_hint = location_hint(room)?;
961
962    Ok(if description.is_empty() {
963        location_hint
964    } else {
965        format!("{description}\n{location_hint}")
966    })
967}
968
969/// The last line of every description: where to find the room, or that the event is online.
970pub(crate) fn location_hint(
971    room: &str,
972) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
973    let lowercase_room = room.to_lowercase();
974
975    Ok(if lowercase_room.contains("online") {
976        if lowercase_room.contains("moodle") {
977            String::from("Online auf Moodle: https://www.moodle.tum.de/my/")
978        } else {
979            String::from("Online")
980        }
981    } else {
982        let mut link = Url::parse("https://nav.tum.de/search")?;
983        link.query_pairs_mut().append_pair("q", room);
984        format!("Wo ist das? {link}")
985    })
986}
987
988/// Builds the calendar object that gets uploaded.
989///
990/// `stored` and `property_changes` are `None` while creating an event, in which case
991/// everything is taken from the ICS calendar. While updating, only the properties
992/// which actually changed in the ICS calendar are overwritten - everything else is
993/// carried over from the Apple Calendar so that manual edits survive.
994fn build_calendar_object(
995    uid: &str,
996    event: &IcalEvent,
997    stored: Option<&StoredEvent>,
998    property_changes: Option<&[PropertyChange]>,
999) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
1000    let stored_event = stored.map(|stored| &stored.event);
1001    let unchanged = |name: &str| {
1002        property_changes.is_some_and(|changes| {
1003            !changes
1004                .iter()
1005                .any(|property_change| property_change.key == name)
1006        })
1007    };
1008    let inherited = |name: &str| {
1009        if unchanged(name) {
1010            stored_event.and_then(|event| event.get_property(name))
1011        } else {
1012            None
1013        }
1014    };
1015
1016    // Times - both are carried over together, otherwise an event could end before it starts
1017    let stored_times = if unchanged("DTSTART") && unchanged("DTEND") {
1018        stored_event
1019            .and_then(|stored| stored.get_property("DTSTART"))
1020            .zip(stored_event.and_then(|stored| stored.get_property("DTEND")))
1021    } else {
1022        None
1023    };
1024    let (start, end) = match stored_times {
1025        Some((start, end)) => (RawProperty::inherited(start), RawProperty::inherited(end)),
1026        None => (
1027            RawProperty::new("DTSTART", &format_utc(parse_ical_utc(event, "DTSTART")?)),
1028            RawProperty::new("DTEND", &format_utc(parse_ical_utc(event, "DTEND")?)),
1029        ),
1030    };
1031
1032    let (summary, is_exam) = match inherited("SUMMARY") {
1033        Some(summary) => {
1034            let summary = RawProperty::inherited(summary);
1035            let is_exam = summary.value.contains("Prüfung");
1036            (summary, is_exam)
1037        }
1038        None => match event
1039            .get_property("SUMMARY")
1040            .and_then(|summary| summary.value.clone())
1041        {
1042            Some(summary) => (
1043                RawProperty::new(
1044                    "SUMMARY",
1045                    &escape_text(&replace_courses(summary.replace(r"\", "").as_str())),
1046                ),
1047                summary.contains("Prüfung"),
1048            ),
1049            None => (
1050                RawProperty::new("SUMMARY", &escape_text("Kein Titel angegeben")),
1051                false,
1052            ),
1053        },
1054    };
1055
1056    let room = event
1057        .get_property("LOCATION")
1058        .and_then(|location| location.value.clone())
1059        .map(|location| unescape_location(&location))
1060        .unwrap_or_else(|| "Kein Ort angegeben".to_string());
1061    let inherited_location = inherited("LOCATION");
1062    let location = match inherited_location {
1063        Some(location) => RawProperty::inherited(location),
1064        None => RawProperty::new("LOCATION", &escape_text(&room)),
1065    };
1066
1067    // The description embeds the room, so it also has to be rebuilt when the room changed
1068    let stored_description = if unchanged("DESCRIPTION") && unchanged("LOCATION") {
1069        stored_event.and_then(|stored| stored.get_property("DESCRIPTION"))
1070    } else {
1071        None
1072    };
1073    let description = match stored_description {
1074        Some(description) => RawProperty::inherited(description),
1075        None => RawProperty::new(
1076            "DESCRIPTION",
1077            &escape_text(&build_description(event, &room)?),
1078        ),
1079    };
1080
1081    let status = match inherited("STATUS") {
1082        Some(status) => Some(RawProperty::inherited(status)),
1083        None => event
1084            .get_property("STATUS")
1085            .and_then(|status| status.value.clone())
1086            .map(|status| RawProperty::new("STATUS", &escape_text(&status.to_uppercase()))),
1087    };
1088
1089    // Bumping the sequence tells the calendar clients that this is a newer revision
1090    let sequence = stored_event
1091        .and_then(|stored| stored.get_property("SEQUENCE"))
1092        .and_then(|sequence| sequence.value.clone())
1093        .and_then(|sequence| sequence.trim().parse::<u32>().ok())
1094        .map(|sequence| sequence.saturating_add(1))
1095        .unwrap_or(0);
1096
1097    let url = event.get_property("URL").and_then(|url| url.value.clone());
1098
1099    let now = format_utc(Utc::now());
1100    let mut calendar_object = String::new();
1101
1102    calendar_object.push_str("BEGIN:VCALENDAR\r\n");
1103    push_property(&mut calendar_object, "VERSION", &[], "2.0");
1104    push_property(&mut calendar_object, "PRODID", &[], PRODID);
1105    push_property(&mut calendar_object, "CALSCALE", &[], "GREGORIAN");
1106
1107    // Only needed if we carried over times which might reference a TZID
1108    if stored_times.is_some() {
1109        for timezone in stored
1110            .map(|stored| stored.timezones.as_slice())
1111            .unwrap_or(&[])
1112        {
1113            calendar_object.push_str(timezone);
1114        }
1115    }
1116
1117    calendar_object.push_str("BEGIN:VEVENT\r\n");
1118    push_property(&mut calendar_object, "UID", &[], &escape_text(uid));
1119    push_property(&mut calendar_object, "DTSTAMP", &[], &now);
1120    start.push_to(&mut calendar_object);
1121    end.push_to(&mut calendar_object);
1122    summary.push_to(&mut calendar_object);
1123    location.push_to(&mut calendar_object);
1124    description.push_to(&mut calendar_object);
1125
1126    if let Some(url) = &url {
1127        push_property(&mut calendar_object, "URL", &[], &escape_text(url));
1128    }
1129    if let Some(status) = status {
1130        status.push_to(&mut calendar_object);
1131    }
1132    if is_exam {
1133        push_property(
1134            &mut calendar_object,
1135            "CATEGORIES",
1136            &[],
1137            &escape_text("Prüfung"),
1138        );
1139        // Apple Calendar shows RFC 7986 colors on supported clients
1140        push_property(&mut calendar_object, "COLOR", &[], "tomato");
1141    }
1142
1143    // Whatever else the stored event has (colors, properties the calendar apps added, …) is
1144    // carried over, otherwise every update from the ICS calendar would wipe it
1145    if let Some(stored_event) = stored_event {
1146        let mut managed = vec![
1147            "UID",
1148            "DTSTAMP",
1149            "DTSTART",
1150            "DTEND",
1151            "DURATION",
1152            "SUMMARY",
1153            "LOCATION",
1154            "DESCRIPTION",
1155            "STATUS",
1156            "SEQUENCE",
1157            "LAST-MODIFIED",
1158        ];
1159        if url.is_some() {
1160            managed.push("URL");
1161        }
1162        if is_exam {
1163            managed.extend(["CATEGORIES", "COLOR"]);
1164        }
1165        if inherited_location.is_none() {
1166            // Apple's map pin would still point to the old room
1167            managed.push("X-APPLE-STRUCTURED-LOCATION");
1168        }
1169
1170        for property in stored_event.properties.iter().filter(|property| {
1171            !managed
1172                .iter()
1173                .any(|name| property.name.eq_ignore_ascii_case(name))
1174        }) {
1175            RawProperty::inherited(property).push_to(&mut calendar_object);
1176        }
1177    }
1178
1179    push_property(&mut calendar_object, "SEQUENCE", &[], &sequence.to_string());
1180    push_property(&mut calendar_object, "LAST-MODIFIED", &[], &now);
1181
1182    // Alarms are always set by hand, so they're kept as they are
1183    for alarm in stored_event
1184        .map(|stored_event| stored_event.alarms.as_slice())
1185        .unwrap_or_default()
1186    {
1187        calendar_object.push_str("BEGIN:VALARM\r\n");
1188        for property in &alarm.properties {
1189            RawProperty::inherited(property).push_to(&mut calendar_object);
1190        }
1191        calendar_object.push_str("END:VALARM\r\n");
1192    }
1193    calendar_object.push_str("END:VEVENT\r\n");
1194    calendar_object.push_str("END:VCALENDAR\r\n");
1195
1196    Ok(calendar_object)
1197}
1198
1199// ---------------------------------------------------------------------------
1200// Syncing
1201// ---------------------------------------------------------------------------
1202
1203/// Whether an event is an exam, which gets marked as one and goes into the exam calendar, if
1204/// there is one.
1205pub(crate) fn is_exam(event: &IcalEvent) -> bool {
1206    event
1207        .get_property("SUMMARY")
1208        .and_then(|summary| summary.value.as_deref())
1209        .is_some_and(|summary| summary.contains("Prüfung"))
1210}
1211
1212async fn create_event(
1213    calendar: &AppleCalendar,
1214    uid: String,
1215    event: IcalEvent,
1216) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1217    let url = calendar.calendar_urls(is_exam(&event))[0].join(&object_name(&uid))?;
1218    let calendar_object = build_calendar_object(&uid, &event, None, None)?;
1219
1220    calendar.put_object(&url, &calendar_object, None).await
1221}
1222
1223async fn update_event(
1224    calendar: &AppleCalendar,
1225    uid: String,
1226    event: IcalEvent,
1227    property_changes: Vec<PropertyChange>,
1228) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1229    println!("Updating event {uid}: {property_changes:?}");
1230
1231    let exam = is_exam(&event);
1232    let mut stored_at = None;
1233    for calendar_url in calendar.calendar_urls(exam) {
1234        let url = calendar_url.join(&object_name(&uid))?;
1235        if let Some((stored_object, etag)) = calendar.fetch_object(&url).await? {
1236            stored_at = Some((url, stored_object, etag));
1237            break;
1238        }
1239    }
1240    let Some((url, stored_object, etag)) = stored_at else {
1241        // The event was deleted in the Apple Calendar - don't bring it back
1242        println!("Event {uid} isn't in the Apple Calendar anymore, skipping update");
1243        return Ok(());
1244    };
1245
1246    let stored = parse_stored_event(&stored_object);
1247    let calendar_object =
1248        build_calendar_object(&uid, &event, stored.as_ref(), Some(&property_changes))?;
1249
1250    // An event that just became an exam, or stopped being one, moves over to the other
1251    // calendar. Otherwise it stays where it is, even if it was moved there by hand.
1252    let target_url = calendar.calendar_urls(exam)[0].join(&object_name(&uid))?;
1253    let was_exam = property_changes
1254        .iter()
1255        .find(|property_change| property_change.key == "SUMMARY")
1256        .map(|property_change| {
1257            property_change
1258                .from
1259                .as_ref()
1260                .and_then(|from| from.value.as_deref())
1261                .is_some_and(|summary| summary.contains("Prüfung"))
1262        });
1263    calendar
1264        .put_object(&url, &calendar_object, etag.as_deref())
1265        .await?;
1266
1267    if target_url != url && was_exam.is_some_and(|was_exam| was_exam != exam) {
1268        println!("Moving event {uid} to {target_url}");
1269        calendar.move_object(&url, &target_url, None).await?;
1270    }
1271
1272    Ok(())
1273}
1274
1275async fn delete_event(
1276    calendar: &AppleCalendar,
1277    uid: String,
1278) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1279    // The event could be in either calendar
1280    for calendar_url in calendar.calendar_urls(false) {
1281        calendar
1282            .delete_object(&calendar_url.join(&object_name(&uid))?)
1283            .await?;
1284    }
1285
1286    Ok(())
1287}
1288
1289fn is_video_transmission(event: &IcalEvent) -> bool {
1290    event
1291        .get_property("DESCRIPTION")
1292        .and_then(|property| property.value.clone())
1293        .is_some_and(|description| description.contains("Videoübertragung aus"))
1294}
1295
1296/// The TUM Calendar seems to randomly serve english / german descriptions.
1297/// This looks for differences other than the first two words in english / german.
1298fn is_language_only_update(property_changes: &[PropertyChange]) -> bool {
1299    property_changes.len() == 1
1300        && property_changes[0].key == "DESCRIPTION"
1301        && property_changes[0]
1302            .from
1303            .as_ref()
1304            .and_then(|from| from.value.as_ref())
1305            .zip(
1306                property_changes[0]
1307                    .to
1308                    .as_ref()
1309                    .and_then(|to| to.value.as_ref()),
1310            )
1311            .is_some_and(|(from, to)| {
1312                from.split(";").skip(2).collect::<String>()
1313                    == to.split(";").skip(2).collect::<String>()
1314            })
1315}
1316
1317/// If the event is in the far past, we assume it's just the calendar updating
1318/// for the next semester, which means we don't actually need to delete it
1319///
1320/// The cutoff is a week back, the same one [`tum_google_sync`](crate::tum_google_sync)
1321/// uses, so both adapters keep and delete the same events.
1322fn is_far_in_the_past(event: &IcalEvent) -> bool {
1323    event
1324        .get_property("DTEND")
1325        .and_then(|property| property.value.clone())
1326        .and_then(|value| {
1327            value
1328                .get(0..15)
1329                .and_then(|value| NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S").ok())
1330                .map(|date_time| date_time.and_utc())
1331        })
1332        .is_some_and(|end| end < Utc::now() - Duration::from_secs(60 * 60 * 24 * 7))
1333}
1334
1335/// This is a callback which synchronizes your TUM Calendar to your Apple (iCloud) Calendar.
1336///
1337/// It behaves like [`tum_google_sync`](crate::tum_google_sync): the event summaries will be
1338/// shortened and the events themselves modifieable. As soon as you delete an event, it won't
1339/// come back. If you modify an event, your changes will only be overwritten if they're changed
1340/// in the TUM Calendar.
1341///
1342/// # Examples
1343///
1344/// ```
1345/// let apple_calendar = AppleCalendar::connect(&apple_id, &app_password, "TUM")
1346///     .await
1347///     .expect("Failed to connect to the Apple Calendar");
1348///
1349/// let mut ics_watcher = ICSWatcher::new(
1350///     tum_url.as_str(),
1351///     vec![
1352///         Box::new(move |a, b, e| {
1353///             let calendar = apple_calendar.clone();
1354///             Box::pin(async move { tum_apple_sync(&calendar, a, b, e).await })
1355///         }),
1356///     ],
1357/// );
1358///
1359/// // Try to load backup
1360/// let _ = ics_watcher.load_backup("TUM Calendar");
1361/// ics_watcher
1362///     .run(Option::from("TUM Calendar"))
1363///     .await
1364///     .expect("ICS Watcher crashed");
1365/// ```
1366pub async fn tum_apple_sync(
1367    calendar: &AppleCalendar,
1368    _: Option<String>,
1369    _: Option<String>,
1370    events: Vec<CalendarEvent>,
1371) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1372    for event in events {
1373        let result = match event {
1374            CalendarEvent::Setup(EventData { uid, ical_data }) => {
1375                // Don't sync if event is a video transmission
1376                if is_video_transmission(&ical_data) {
1377                    println!("Skipping video transmission event {uid}");
1378                    Ok(())
1379                } else {
1380                    println!("Setting up event {uid}");
1381                    create_event(calendar, uid, ical_data).await
1382                }
1383            }
1384            CalendarEvent::Created(EventData { uid, ical_data }) => {
1385                // Don't sync if event is a video transmission
1386                if is_video_transmission(&ical_data) {
1387                    Ok(())
1388                } else {
1389                    println!("Creating event {uid}");
1390                    create_event(calendar, uid, ical_data).await
1391                }
1392            }
1393            CalendarEvent::Updated {
1394                event: EventData { uid, ical_data },
1395                changed_properties,
1396            } => {
1397                if is_language_only_update(&changed_properties) {
1398                    // Update is a language-only update
1399                    Ok(())
1400                } else {
1401                    update_event(calendar, uid, ical_data, changed_properties).await
1402                }
1403            }
1404            CalendarEvent::Deleted(EventData { uid, ical_data }) => {
1405                println!("Deleting event {uid}");
1406                if is_far_in_the_past(&ical_data) {
1407                    // Not deleting event as it is far back in the past
1408                    Ok(())
1409                } else {
1410                    delete_event(calendar, uid).await
1411                }
1412            }
1413        };
1414
1415        match result {
1416            Ok(_) => (),
1417            Err(error) => eprintln!("Error on syncing event: {error:?}"),
1418        }
1419    }
1420
1421    Ok(())
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427    use std::sync::{
1428        atomic::{AtomicUsize, Ordering},
1429        Arc, Mutex,
1430    };
1431    use tokio::{
1432        io::{AsyncReadExt, AsyncWriteExt},
1433        net::TcpListener,
1434    };
1435
1436    fn property(name: &str, value: &str) -> Property {
1437        Property {
1438            name: String::from(name),
1439            params: None,
1440            value: Some(String::from(value)),
1441        }
1442    }
1443
1444    /// Builds a calendar object and undoes the line folding, so that assertions
1445    /// don't depend on where a long line happens to be split
1446    fn build_unfolded(
1447        event: &IcalEvent,
1448        stored: Option<&StoredEvent>,
1449        property_changes: Option<&[PropertyChange]>,
1450    ) -> String {
1451        build_calendar_object("1234567@tum.de", event, stored, property_changes)
1452            .expect("calendar object should build")
1453            .replace("\r\n ", "")
1454    }
1455
1456    fn tum_event() -> IcalEvent {
1457        IcalEvent {
1458            properties: vec![
1459                property("UID", "1234567@tum.de"),
1460                property("DTSTART", "20250114T100000Z"),
1461                property("DTEND", "20250114T120000Z"),
1462                property("SUMMARY", "Analysis 1"),
1463                property("LOCATION", r"5608.EG.011\, Hörsaal"),
1464                property("DESCRIPTION", r"fix\;me\;Vorlesung\nZweite Zeile"),
1465            ],
1466            alarms: vec![],
1467        }
1468    }
1469
1470    #[test]
1471    fn escapes_text() {
1472        assert_eq!(escape_text("a,b;c\\d\ne"), String::from(r"a\,b\;c\\d\ne"));
1473    }
1474
1475    #[test]
1476    fn folds_long_lines() {
1477        let mut calendar_object = String::new();
1478        push_property(&mut calendar_object, "DESCRIPTION", &[], &"x".repeat(200));
1479
1480        for line in calendar_object.lines() {
1481            assert!(line.len() <= 75, "line too long: {}", line.len());
1482        }
1483        // Unfolding has to yield the original line again
1484        assert_eq!(
1485            calendar_object.replace("\r\n ", ""),
1486            format!("DESCRIPTION:{}\r\n", "x".repeat(200))
1487        );
1488    }
1489
1490    #[test]
1491    fn folds_without_splitting_characters() {
1492        let mut calendar_object = String::new();
1493        push_property(&mut calendar_object, "SUMMARY", &[], &"ü".repeat(100));
1494
1495        assert!(calendar_object.contains("\r\n "));
1496        assert_eq!(
1497            calendar_object.replace("\r\n ", ""),
1498            format!("SUMMARY:{}\r\n", "ü".repeat(100))
1499        );
1500    }
1501
1502    #[test]
1503    fn serializes_parameters() {
1504        let mut calendar_object = String::new();
1505        push_property(
1506            &mut calendar_object,
1507            "DTSTART",
1508            &[(String::from("TZID"), vec![String::from("Europe/Berlin")])],
1509            "20250114T100000",
1510        );
1511
1512        assert_eq!(
1513            calendar_object,
1514            "DTSTART;TZID=Europe/Berlin:20250114T100000\r\n"
1515        );
1516    }
1517
1518    #[test]
1519    fn object_names_are_stable_and_safe() {
1520        let name = object_name("1234567@tum.de");
1521
1522        assert_eq!(name, object_name("1234567@tum.de"));
1523        assert!(name.ends_with(".ics"));
1524        assert!(name
1525            .chars()
1526            .all(|character| character.is_ascii_alphanumeric()
1527                || matches!(character, '-' | '_' | '.')));
1528        assert_ne!(name, object_name("1234567-tum.de"));
1529    }
1530
1531    #[test]
1532    fn creates_calendar_object() {
1533        let calendar_object = build_unfolded(&tum_event(), None, None);
1534
1535        assert!(calendar_object.starts_with("BEGIN:VCALENDAR\r\n"));
1536        assert!(calendar_object.ends_with("END:VCALENDAR\r\n"));
1537        assert!(calendar_object.contains("UID:1234567@tum.de\r\n"));
1538        assert!(calendar_object.contains("DTSTART:20250114T100000Z\r\n"));
1539        assert!(calendar_object.contains("DTEND:20250114T120000Z\r\n"));
1540        assert!(calendar_object.contains(r"LOCATION:5608.EG.011\, Hörsaal"));
1541        assert!(calendar_object.contains("SEQUENCE:0\r\n"));
1542        // The first two \; separated segments are dropped, linebreaks are kept
1543        assert!(calendar_object.contains(r"DESCRIPTION:Vorlesung\nZweite Zeile\n"));
1544        assert!(calendar_object.contains("nav.tum.de"));
1545    }
1546
1547    #[test]
1548    fn marks_exams() {
1549        let mut event = tum_event();
1550        event
1551            .properties
1552            .push(property("SUMMARY", "Prüfung Analysis"));
1553        event
1554            .properties
1555            .retain(|p| p.value.as_deref() != Some("Analysis 1"));
1556
1557        let calendar_object = build_unfolded(&event, None, None);
1558
1559        assert!(calendar_object.contains(r"CATEGORIES:Prüfung"));
1560        assert!(calendar_object.contains("COLOR:tomato"));
1561    }
1562
1563    #[test]
1564    fn keeps_online_events_without_a_room_link() {
1565        let mut event = tum_event();
1566        event.properties.retain(|p| p.name != "LOCATION");
1567        event
1568            .properties
1569            .push(property("LOCATION", "Online: Moodle"));
1570
1571        let calendar_object = build_unfolded(&event, None, None);
1572
1573        assert!(calendar_object.contains("moodle.tum.de"));
1574        assert!(!calendar_object.contains("nav.tum.de"));
1575    }
1576
1577    #[test]
1578    fn turns_line_breaks_in_locations_into_commas() {
1579        let mut event = tum_event();
1580        event.properties.retain(|p| p.name != "LOCATION");
1581        event
1582            .properties
1583            .push(property("LOCATION", r"Online: Videokonferenz\nZoom etc."));
1584
1585        let calendar_object = build_unfolded(&event, None, None);
1586
1587        assert!(calendar_object.contains(r"LOCATION:Online: Videokonferenz\, Zoom etc."));
1588    }
1589
1590    #[test]
1591    fn update_keeps_unchanged_properties() {
1592        let stored = parse_stored_event(
1593            "BEGIN:VCALENDAR\r\n\
1594             VERSION:2.0\r\n\
1595             BEGIN:VTIMEZONE\r\n\
1596             TZID:Europe/Berlin\r\n\
1597             END:VTIMEZONE\r\n\
1598             BEGIN:VEVENT\r\n\
1599             UID:1234567@tum.de\r\n\
1600             DTSTART;TZID=Europe/Berlin:20250114T110000\r\n\
1601             DTEND;TZID=Europe/Berlin:20250114T130000\r\n\
1602             SUMMARY:Mein eigener Titel\r\n\
1603             LOCATION:Zuhause\r\n\
1604             DESCRIPTION:Meine Notiz\r\n\
1605             SEQUENCE:3\r\n\
1606             END:VEVENT\r\n\
1607             END:VCALENDAR\r\n",
1608        )
1609        .expect("stored event should parse");
1610
1611        let changes = vec![PropertyChange {
1612            key: String::from("SUMMARY"),
1613            from: Some(property("SUMMARY", "Analysis 1 alt")),
1614            to: Some(property("SUMMARY", "Analysis 1")),
1615        }];
1616
1617        let calendar_object = build_unfolded(&tum_event(), Some(&stored), Some(&changes));
1618
1619        // Changed in the ICS calendar -> overwritten
1620        assert!(calendar_object.contains("SUMMARY:Analysis 1\r\n"));
1621        // Edited by hand and untouched by the ICS calendar -> kept, including the timezone
1622        assert!(calendar_object.contains("DTSTART;TZID=Europe/Berlin:20250114T110000\r\n"));
1623        assert!(calendar_object.contains("DTEND;TZID=Europe/Berlin:20250114T130000\r\n"));
1624        assert!(calendar_object.contains("BEGIN:VTIMEZONE\r\n"));
1625        assert!(calendar_object.contains("LOCATION:Zuhause\r\n"));
1626        assert!(calendar_object.contains("DESCRIPTION:Meine Notiz\r\n"));
1627        assert!(calendar_object.contains("SEQUENCE:4\r\n"));
1628    }
1629
1630    #[test]
1631    fn update_rebuilds_description_when_the_room_changes() {
1632        let stored = parse_stored_event(
1633            "BEGIN:VCALENDAR\r\n\
1634             BEGIN:VEVENT\r\n\
1635             UID:1234567@tum.de\r\n\
1636             DTSTART:20250114T100000Z\r\n\
1637             DTEND:20250114T120000Z\r\n\
1638             SUMMARY:Analysis 1\r\n\
1639             LOCATION:Alter Raum\r\n\
1640             DESCRIPTION:Alte Beschreibung\r\n\
1641             END:VEVENT\r\n\
1642             END:VCALENDAR\r\n",
1643        )
1644        .expect("stored event should parse");
1645
1646        let changes = vec![PropertyChange {
1647            key: String::from("LOCATION"),
1648            from: Some(property("LOCATION", "Alter Raum")),
1649            to: Some(property("LOCATION", r"5608.EG.011\, Hörsaal")),
1650        }];
1651
1652        let calendar_object = build_unfolded(&tum_event(), Some(&stored), Some(&changes));
1653
1654        assert!(calendar_object.contains(r"LOCATION:5608.EG.011\, Hörsaal"));
1655        assert!(!calendar_object.contains("Alte Beschreibung"));
1656        assert!(calendar_object.contains("nav.tum.de"));
1657    }
1658
1659    fn stored_event_with_extras() -> StoredEvent {
1660        parse_stored_event(
1661            "BEGIN:VCALENDAR\r\n\
1662             BEGIN:VEVENT\r\n\
1663             UID:1234567@tum.de\r\n\
1664             DTSTART:20250114T100000Z\r\n\
1665             DTEND:20250114T120000Z\r\n\
1666             SUMMARY:Analysis 1 alt\r\n\
1667             LOCATION:Zuhause\r\n\
1668             DESCRIPTION:Meine Notiz\r\n\
1669             COLOR:seagreen\r\n\
1670             X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC\r\n\
1671             X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-TITLE=Zuhause:geo:48.1,11.5\r\n\
1672             BEGIN:VALARM\r\n\
1673             ACTION:DISPLAY\r\n\
1674             TRIGGER:-PT1H\r\n\
1675             END:VALARM\r\n\
1676             END:VEVENT\r\n\
1677             END:VCALENDAR\r\n",
1678        )
1679        .expect("stored event should parse")
1680    }
1681
1682    #[test]
1683    fn update_keeps_alarms_and_other_properties() {
1684        let changes = vec![PropertyChange {
1685            key: String::from("SUMMARY"),
1686            from: Some(property("SUMMARY", "Analysis 1 alt")),
1687            to: Some(property("SUMMARY", "Analysis 1")),
1688        }];
1689
1690        let calendar_object = build_unfolded(
1691            &tum_event(),
1692            Some(&stored_event_with_extras()),
1693            Some(&changes),
1694        );
1695
1696        assert!(calendar_object.contains("SUMMARY:Analysis 1\r\n"));
1697        assert_eq!(calendar_object.matches("SUMMARY:").count(), 1);
1698        assert!(calendar_object.contains("COLOR:seagreen\r\n"));
1699        assert!(calendar_object.contains("X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC\r\n"));
1700        assert!(calendar_object
1701            .contains("X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-TITLE=Zuhause:geo:48.1,11.5\r\n"));
1702        assert!(calendar_object
1703            .contains("BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT1H\r\nEND:VALARM\r\n"));
1704    }
1705
1706    #[test]
1707    fn update_drops_the_map_pin_when_the_room_changes() {
1708        let changes = vec![PropertyChange {
1709            key: String::from("LOCATION"),
1710            from: Some(property("LOCATION", "Alter Raum")),
1711            to: Some(property("LOCATION", r"5608.EG.011\, Hörsaal")),
1712        }];
1713
1714        let calendar_object = build_unfolded(
1715            &tum_event(),
1716            Some(&stored_event_with_extras()),
1717            Some(&changes),
1718        );
1719
1720        assert!(calendar_object.contains(r"LOCATION:5608.EG.011\, Hörsaal"));
1721        assert!(!calendar_object.contains("X-APPLE-STRUCTURED-LOCATION"));
1722        // Everything unrelated to the room stays
1723        assert!(calendar_object.contains("COLOR:seagreen\r\n"));
1724        assert!(calendar_object.contains("BEGIN:VALARM\r\n"));
1725    }
1726
1727    #[test]
1728    fn exams_keep_their_own_color_on_update() {
1729        let mut event = tum_event();
1730        event.properties.retain(|p| p.name != "SUMMARY");
1731        event
1732            .properties
1733            .push(property("SUMMARY", "Prüfung Analysis"));
1734        let changes = vec![PropertyChange {
1735            key: String::from("SUMMARY"),
1736            from: Some(property("SUMMARY", "Analysis 1 alt")),
1737            to: Some(property("SUMMARY", "Prüfung Analysis")),
1738        }];
1739
1740        let calendar_object =
1741            build_unfolded(&event, Some(&stored_event_with_extras()), Some(&changes));
1742
1743        assert!(calendar_object.contains("COLOR:tomato\r\n"));
1744        assert_eq!(calendar_object.matches("COLOR:").count(), 1);
1745    }
1746
1747    #[test]
1748    fn parses_a_multistatus() {
1749        let resources = parse_multistatus(
1750            r#"<?xml version="1.0" encoding="UTF-8"?>
1751            <multistatus xmlns="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
1752              <response>
1753                <href>/1234/calendars/home/</href>
1754                <propstat>
1755                  <prop><resourcetype><collection/></resourcetype></prop>
1756                  <status>HTTP/1.1 200 OK</status>
1757                </propstat>
1758              </response>
1759              <response>
1760                <href>/1234/calendars/tum/</href>
1761                <propstat>
1762                  <prop>
1763                    <resourcetype><collection/><cal:calendar/></resourcetype>
1764                    <displayname>TUM</displayname>
1765                    <cal:supported-calendar-component-set>
1766                      <cal:comp name="VEVENT"/>
1767                    </cal:supported-calendar-component-set>
1768                  </prop>
1769                  <status>HTTP/1.1 200 OK</status>
1770                </propstat>
1771              </response>
1772              <response>
1773                <href>/1234/calendars/tasks/</href>
1774                <propstat>
1775                  <prop>
1776                    <resourcetype><collection/><cal:calendar/></resourcetype>
1777                    <displayname>Erinnerungen</displayname>
1778                    <cal:supported-calendar-component-set>
1779                      <cal:comp name="VTODO"/>
1780                    </cal:supported-calendar-component-set>
1781                  </prop>
1782                  <status>HTTP/1.1 200 OK</status>
1783                </propstat>
1784              </response>
1785            </multistatus>"#,
1786        )
1787        .unwrap();
1788
1789        assert_eq!(resources.len(), 3);
1790        assert!(!resources[0].is_calendar);
1791
1792        assert!(resources[1].is_calendar);
1793        assert_eq!(resources[1].href, "/1234/calendars/tum/");
1794        assert_eq!(resources[1].display_name.as_deref(), Some("TUM"));
1795        assert_eq!(
1796            resources[1].components.as_deref(),
1797            Some([String::from("VEVENT")].as_slice())
1798        );
1799
1800        assert_eq!(
1801            resources[2].components.as_deref(),
1802            Some([String::from("VTODO")].as_slice())
1803        );
1804    }
1805
1806    #[test]
1807    fn parses_a_current_user_principal() {
1808        let resources = parse_multistatus(
1809            r#"<?xml version="1.0" encoding="UTF-8"?>
1810            <d:multistatus xmlns:d="DAV:">
1811              <d:response>
1812                <d:href>/</d:href>
1813                <d:propstat>
1814                  <d:prop>
1815                    <d:current-user-principal>
1816                      <d:href>/1234/principal/</d:href>
1817                    </d:current-user-principal>
1818                  </d:prop>
1819                  <d:status>HTTP/1.1 200 OK</d:status>
1820                </d:propstat>
1821              </d:response>
1822            </d:multistatus>"#,
1823        )
1824        .unwrap();
1825
1826        assert_eq!(resources.len(), 1);
1827        assert_eq!(resources[0].href, "/");
1828        assert_eq!(
1829            resources[0]
1830                .property_hrefs
1831                .get("current-user-principal")
1832                .map(String::as_str),
1833            Some("/1234/principal/")
1834        );
1835    }
1836
1837    const NO_DELAYS: [Duration; 3] = [Duration::ZERO; 3];
1838
1839    /// A tiny local server standing in for iCloud. `respond` gets the request line (e.g.
1840    /// `PUT /calendars/tum/x.ics`) and returns the status and body to answer with. Also
1841    /// returns the request lines the server got.
1842    async fn test_server(
1843        respond: impl Fn(&str) -> (u16, String) + Send + 'static,
1844    ) -> (Url, Arc<Mutex<Vec<String>>>) {
1845        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1846        let server_url =
1847            Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
1848        let requests = Arc::new(Mutex::new(Vec::new()));
1849
1850        let log = requests.clone();
1851        tokio::spawn(async move {
1852            loop {
1853                let (mut stream, _) = listener.accept().await.unwrap();
1854
1855                // Read the whole request first, otherwise sending the body could fail
1856                let mut request = Vec::new();
1857                let mut buffer = [0; 4096];
1858                loop {
1859                    let read = stream.read(&mut buffer).await.unwrap();
1860                    request.extend_from_slice(&buffer[..read]);
1861                    let Some(head_end) = request.windows(4).position(|end| end == b"\r\n\r\n")
1862                    else {
1863                        if read == 0 {
1864                            break;
1865                        }
1866                        continue;
1867                    };
1868                    let head = String::from_utf8_lossy(&request[..head_end]).to_lowercase();
1869                    let body_length = head
1870                        .lines()
1871                        .find_map(|line| line.strip_prefix("content-length:"))
1872                        .map_or(0, |length| length.trim().parse().unwrap());
1873                    if read == 0 || request.len() >= head_end + 4 + body_length {
1874                        break;
1875                    }
1876                }
1877
1878                // "PUT /calendars/tum/x.ics HTTP/1.1" without the version
1879                let request_line = String::from_utf8_lossy(&request)
1880                    .lines()
1881                    .next()
1882                    .and_then(|line| line.rsplit_once(' '))
1883                    .map(|(request_line, _)| request_line.to_string())
1884                    .unwrap_or_default();
1885                let (status, body) = respond(&request_line);
1886                log.lock().unwrap().push(request_line);
1887
1888                let response = format!(
1889                    "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1890                    body.len()
1891                );
1892                stream.write_all(response.as_bytes()).await.unwrap();
1893            }
1894        });
1895
1896        (server_url, requests)
1897    }
1898
1899    /// A calendar at `/calendars/tum/`, which doesn't wait before retrying
1900    fn calendar_on(server_url: &Url) -> AppleCalendar {
1901        AppleCalendar {
1902            dav: DavClient {
1903                retry_delays: &NO_DELAYS,
1904                ..DavClient::new("me@icloud.com", "abcd-efgh-ijkl-mnop").unwrap()
1905            },
1906            calendar_url: server_url.join("calendars/tum/").unwrap(),
1907            exam_calendar_url: None,
1908        }
1909    }
1910
1911    /// Same, with the exams going into `/calendars/exams/`
1912    fn calendar_with_exams_on(server_url: &Url) -> AppleCalendar {
1913        AppleCalendar {
1914            exam_calendar_url: Some(server_url.join("calendars/exams/").unwrap()),
1915            ..calendar_on(server_url)
1916        }
1917    }
1918
1919    /// A calendar on a server answering with `statuses` one after another, repeating the
1920    /// last one
1921    async fn calendar_answering(
1922        statuses: &'static [u16],
1923    ) -> (AppleCalendar, Arc<Mutex<Vec<String>>>) {
1924        let answered = AtomicUsize::new(0);
1925        let (server_url, requests) = test_server(move |_| {
1926            let index = answered.fetch_add(1, Ordering::SeqCst);
1927            (statuses[index.min(statuses.len() - 1)], String::new())
1928        })
1929        .await;
1930
1931        (calendar_on(&server_url), requests)
1932    }
1933
1934    #[test]
1935    fn tells_temporary_server_errors_apart() {
1936        for status in [500, 502, 503, 504] {
1937            assert!(
1938                is_transient_status(StatusCode::from_u16(status).unwrap()),
1939                "{status}"
1940            );
1941        }
1942        for status in [200, 201, 204, 400, 401, 403, 404, 412, 501] {
1943            assert!(
1944                !is_transient_status(StatusCode::from_u16(status).unwrap()),
1945                "{status}"
1946            );
1947        }
1948    }
1949
1950    #[tokio::test]
1951    async fn tells_connection_problems_apart() {
1952        // Nothing listens on the port anymore once the listener is gone
1953        let port = TcpListener::bind("127.0.0.1:0")
1954            .await
1955            .unwrap()
1956            .local_addr()
1957            .unwrap()
1958            .port();
1959        let error = Client::new()
1960            .get(format!("http://127.0.0.1:{port}/"))
1961            .send()
1962            .await
1963            .unwrap_err();
1964        assert!(is_transient_error(&error));
1965
1966        let error = Client::new().get("not a url").send().await.unwrap_err();
1967        assert!(!is_transient_error(&error));
1968    }
1969
1970    #[tokio::test]
1971    async fn retries_server_errors() {
1972        let (calendar, requests) = calendar_answering(&[500, 503, 201]).await;
1973        let url = calendar.object_url("1234567@tum.de").unwrap();
1974
1975        assert!(calendar
1976            .create_object(&url, "BEGIN:VCALENDAR")
1977            .await
1978            .unwrap());
1979        assert_eq!(requests.lock().unwrap().len(), 3);
1980    }
1981
1982    #[tokio::test]
1983    async fn gives_up_after_the_last_retry() {
1984        let (calendar, requests) = calendar_answering(&[502]).await;
1985        let url = calendar.object_url("1234567@tum.de").unwrap();
1986
1987        let error = calendar
1988            .put_object(&url, "BEGIN:VCALENDAR", None)
1989            .await
1990            .unwrap_err();
1991
1992        assert!(error.to_string().contains("502"));
1993        assert_eq!(requests.lock().unwrap().len(), 1 + NO_DELAYS.len());
1994    }
1995
1996    #[tokio::test]
1997    async fn does_not_retry_client_errors() {
1998        let (calendar, requests) = calendar_answering(&[404]).await;
1999        let url = calendar.object_url("1234567@tum.de").unwrap();
2000
2001        // Gone - which both of them are fine with
2002        assert!(calendar.fetch_object(&url).await.unwrap().is_none());
2003        calendar.delete_object(&url).await.unwrap();
2004
2005        assert_eq!(requests.lock().unwrap().len(), 2);
2006    }
2007
2008    #[tokio::test]
2009    async fn keeps_handling_conflicts_after_a_retry() {
2010        let (calendar, requests) = calendar_answering(&[500, 412]).await;
2011        let url = calendar.object_url("1234567@tum.de").unwrap();
2012
2013        // The first attempt was stored despite the error, so the retry finds the event
2014        assert!(!calendar
2015            .create_object(&url, "BEGIN:VCALENDAR")
2016            .await
2017            .unwrap());
2018        assert_eq!(requests.lock().unwrap().len(), 2);
2019
2020        let error = calendar
2021            .put_object(&url, "BEGIN:VCALENDAR", Some("\"etag\""))
2022            .await
2023            .unwrap_err();
2024        assert!(error.to_string().contains("changed on the server"));
2025        assert_eq!(requests.lock().unwrap().len(), 3);
2026    }
2027
2028    const UID: &str = "1234567@tum.de";
2029
2030    fn feed_event(summary: &str) -> IcalEvent {
2031        let mut event = tum_event();
2032        event.properties.retain(|p| p.name != "SUMMARY");
2033        event.properties.push(property("SUMMARY", summary));
2034        event
2035    }
2036
2037    fn updated(from: &str, to: &str) -> CalendarEvent {
2038        CalendarEvent::Updated {
2039            event: EventData {
2040                uid: String::from(UID),
2041                ical_data: feed_event(to),
2042            },
2043            changed_properties: vec![PropertyChange {
2044                key: String::from("SUMMARY"),
2045                from: Some(property("SUMMARY", from)),
2046                to: Some(property("SUMMARY", to)),
2047            }],
2048        }
2049    }
2050
2051    fn stored_lecture() -> String {
2052        String::from(
2053            "BEGIN:VCALENDAR\r\n\
2054             BEGIN:VEVENT\r\n\
2055             UID:1234567@tum.de\r\n\
2056             DTSTART:20250114T100000Z\r\n\
2057             DTEND:20250114T120000Z\r\n\
2058             SUMMARY:Analysis 1\r\n\
2059             END:VEVENT\r\n\
2060             END:VCALENDAR\r\n",
2061        )
2062    }
2063
2064    /// The request for the event in `calendar`, e.g. `PUT /calendars/tum/…`
2065    fn request(method: &str, calendar: &str) -> String {
2066        format!("{method} /calendars/{calendar}/{}", object_name(UID))
2067    }
2068
2069    #[test]
2070    fn finds_calendars_by_name() {
2071        let calendar = |name: &str, path: &str, supports_events| CalendarInfo {
2072            name: Some(String::from(name)),
2073            url: format!("https://caldav.example.com/1/calendars/{path}/"),
2074            supports_events,
2075        };
2076        let calendars = vec![
2077            calendar("TUM", "tum", true),
2078            calendar("TUM Prüfungen", "exams", true),
2079            calendar("Erinnerungen", "tasks", false),
2080        ];
2081
2082        assert_eq!(
2083            find_calendar(&calendars, "TUM Prüfungen").unwrap().as_str(),
2084            "https://caldav.example.com/1/calendars/exams/"
2085        );
2086        let error = find_calendar(&calendars, "Erinnerungen").unwrap_err();
2087        assert!(error.to_string().contains("TUM, TUM Prüfungen"));
2088    }
2089
2090    #[tokio::test]
2091    async fn puts_new_exams_into_the_exam_calendar() {
2092        let (server_url, requests) = test_server(|_| (201, String::new())).await;
2093        let calendar = calendar_with_exams_on(&server_url);
2094
2095        for summary in ["Analysis 1 Prüfung", "Analysis 1"] {
2096            let created = CalendarEvent::Created(EventData {
2097                uid: String::from(UID),
2098                ical_data: feed_event(summary),
2099            });
2100            tum_apple_sync(&calendar, None, None, vec![created])
2101                .await
2102                .unwrap();
2103        }
2104
2105        assert_eq!(
2106            *requests.lock().unwrap(),
2107            vec![request("PUT", "exams"), request("PUT", "tum")]
2108        );
2109    }
2110
2111    #[tokio::test]
2112    async fn updates_events_where_they_are() {
2113        // The lecture was moved into the exam calendar by hand
2114        let stored_at = request("GET", "exams");
2115        let (server_url, requests) = test_server(move |request| {
2116            if request == stored_at {
2117                (200, stored_lecture())
2118            } else if request.starts_with("GET") {
2119                (404, String::new())
2120            } else {
2121                (204, String::new())
2122            }
2123        })
2124        .await;
2125        let calendar = calendar_with_exams_on(&server_url);
2126
2127        tum_apple_sync(
2128            &calendar,
2129            None,
2130            None,
2131            vec![updated("Analysis 1 alt", "Analysis 1")],
2132        )
2133        .await
2134        .unwrap();
2135
2136        assert_eq!(
2137            *requests.lock().unwrap(),
2138            vec![
2139                request("GET", "tum"),
2140                request("GET", "exams"),
2141                request("PUT", "exams")
2142            ]
2143        );
2144    }
2145
2146    #[tokio::test]
2147    async fn moves_events_that_became_an_exam() {
2148        let stored_at = request("GET", "tum");
2149        let (server_url, requests) = test_server(move |request| {
2150            if request == stored_at {
2151                (200, stored_lecture())
2152            } else if request.starts_with("GET") {
2153                (404, String::new())
2154            } else {
2155                (204, String::new())
2156            }
2157        })
2158        .await;
2159        let calendar = calendar_with_exams_on(&server_url);
2160
2161        tum_apple_sync(
2162            &calendar,
2163            None,
2164            None,
2165            vec![updated("Analysis 1", "Analysis 1 Prüfung")],
2166        )
2167        .await
2168        .unwrap();
2169
2170        assert_eq!(
2171            *requests.lock().unwrap(),
2172            // Updated where it is, then moved over - iCloud doesn't allow it in both at once
2173            vec![
2174                request("GET", "exams"),
2175                request("GET", "tum"),
2176                request("PUT", "tum"),
2177                request("MOVE", "tum")
2178            ]
2179        );
2180    }
2181
2182    #[tokio::test]
2183    async fn notices_a_move_that_went_through_despite_an_error() {
2184        let answered = AtomicUsize::new(0);
2185        let (server_url, requests) = test_server(move |request| {
2186            if request.starts_with("MOVE") {
2187                // Moved, but answered with an error - so the retry doesn't find it anymore
2188                if answered.fetch_add(1, Ordering::SeqCst) == 0 {
2189                    (500, String::new())
2190                } else {
2191                    (404, String::new())
2192                }
2193            } else {
2194                (200, stored_lecture())
2195            }
2196        })
2197        .await;
2198        let calendar = calendar_with_exams_on(&server_url);
2199        let calendar_urls = calendar.calendar_urls(false);
2200        let url = calendar_urls[0].join(&object_name(UID)).unwrap();
2201        let target_url = calendar_urls[1].join(&object_name(UID)).unwrap();
2202
2203        calendar.move_object(&url, &target_url, None).await.unwrap();
2204
2205        assert_eq!(
2206            *requests.lock().unwrap(),
2207            vec![
2208                request("MOVE", "tum"),
2209                request("MOVE", "tum"),
2210                request("GET", "exams")
2211            ]
2212        );
2213    }
2214
2215    #[tokio::test]
2216    async fn deletes_events_from_both_calendars() {
2217        let (server_url, requests) = test_server(|_| (404, String::new())).await;
2218        let calendar = calendar_with_exams_on(&server_url);
2219
2220        // Recent enough to actually get deleted
2221        let mut event = feed_event("Analysis 1");
2222        event.properties.retain(|p| p.name != "DTEND");
2223        event
2224            .properties
2225            .push(property("DTEND", &format_utc(Utc::now())));
2226        let deleted = CalendarEvent::Deleted(EventData {
2227            uid: String::from(UID),
2228            ical_data: event,
2229        });
2230        tum_apple_sync(&calendar, None, None, vec![deleted])
2231            .await
2232            .unwrap();
2233
2234        assert_eq!(
2235            *requests.lock().unwrap(),
2236            vec![request("DELETE", "tum"), request("DELETE", "exams")]
2237        );
2238    }
2239
2240    #[tokio::test]
2241    async fn moves_existing_exams_over() {
2242        let (server_url, requests) = test_server(|request| match request {
2243            "PROPFIND /calendars/tum/" => (
2244                207,
2245                String::from(
2246                    "<multistatus xmlns=\"DAV:\">\
2247                     <response><href>/calendars/tum/</href></response>\
2248                     <response><href>/calendars/tum/exam.ics</href></response>\
2249                     <response><href>/calendars/tum/lecture.ics</href></response>\
2250                     </multistatus>",
2251                ),
2252            ),
2253            "GET /calendars/tum/exam.ics" => (
2254                200,
2255                stored_lecture().replace("SUMMARY:Analysis 1", "SUMMARY:Analysis 1 Prüfung"),
2256            ),
2257            "GET /calendars/tum/lecture.ics" => (200, stored_lecture()),
2258            _ => (204, String::new()),
2259        })
2260        .await;
2261        let calendar = calendar_with_exams_on(&server_url);
2262
2263        crate::move_exams_to_exam_calendar(&calendar, true)
2264            .await
2265            .unwrap();
2266
2267        let mut requests = requests.lock().unwrap().clone();
2268        requests.sort();
2269        assert_eq!(
2270            requests,
2271            vec![
2272                "GET /calendars/tum/exam.ics",
2273                "GET /calendars/tum/lecture.ics",
2274                "MOVE /calendars/tum/exam.ics",
2275                "PROPFIND /calendars/tum/",
2276            ]
2277        );
2278    }
2279}