1use 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
35pub 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#[derive(Debug, Default)]
71struct DavResource {
72 href: String,
73 display_name: Option<String>,
74 is_calendar: bool,
75 components: Option<Vec<String>>,
77 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
86fn 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
121fn 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 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 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
188const RETRY_DELAYS: [Duration; 3] = [
191 Duration::from_secs(1),
192 Duration::from_secs(2),
193 Duration::from_secs(4),
194];
195
196fn 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
208fn 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 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 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#[derive(Debug, Clone)]
303pub struct CalendarInfo {
304 pub name: Option<String>,
306 pub url: String,
307 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
341async 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
364fn 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
397pub 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#[derive(Clone)]
426pub struct AppleCalendar {
427 dav: DavClient,
428 calendar_url: Url,
429 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 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 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 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 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 pub async fn with_exam_calendar(
499 mut self,
500 calendar_name: &str,
501 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
502 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 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 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 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 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 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 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 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 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 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 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 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 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
735pub(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 slug.truncate(100);
752
753 format!("{slug}-{:016x}.ics", fnv1a(uid))
754}
755
756fn 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
767pub(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
799pub(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
827pub(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 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
843struct RawProperty {
845 name: String,
846 parameters: Vec<(String, Vec<String>)>,
847 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 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
875fn 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
905pub(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
922pub(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
969pub(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
988fn 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 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 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 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 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 push_property(&mut calendar_object, "COLOR", &[], "tomato");
1141 }
1142
1143 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 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 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
1199pub(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 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 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 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
1296fn 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
1317fn 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
1335pub 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 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 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 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 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 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 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 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 assert!(calendar_object.contains("SUMMARY:Analysis 1\r\n"));
1621 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}