Skip to main content

ics_watcher/
lib.rs

1//! A lightweight crate for monitoring iCalendar (ICS) files or links and detecting changes, additions, and removals.
2//! Provides an API to watch calendars and receive notifications through customizable callbacks.
3//!
4//! See [ICSWatcher] to get started.
5
6pub mod apple;
7pub use apple::{tum_apple_sync, AppleCalendar, CalendarInfo};
8
9pub mod migrate;
10pub use migrate::{migrate_google_to_apple, move_exams_to_exam_calendar, MigrationOptions};
11
12use std::{
13    collections::HashMap,
14    fs::{self, File},
15    future::Future,
16    io::BufReader,
17    path::Path,
18    pin::Pin,
19    sync::Arc,
20    time::Duration,
21};
22
23use chrono::{NaiveDateTime, Utc};
24
25use ical::{
26    parser::{
27        ical::component::{IcalCalendar, IcalEvent},
28        Component,
29    },
30    property::Property,
31    IcalParser,
32};
33
34use google_calendar3::{
35    api::{Event, EventDateTime},
36    hyper_rustls::{self, HttpsConnector},
37    hyper_util::{self, client::legacy::connect::HttpConnector},
38    yup_oauth2::{self, read_application_secret},
39    CalendarHub,
40};
41
42use once_cell::sync::Lazy;
43use regex::Regex;
44use sanitize_filename::sanitize;
45use tokio::time::sleep;
46
47fn rfc5545_to_std_duration(rfc_duration: &str) -> Duration {
48    let duration_str = rfc_duration.trim_start_matches('P').replace('T', "");
49
50    let mut total_secs = 0u64;
51    let mut number = String::new();
52
53    for c in duration_str.chars() {
54        match c {
55            'W' => {
56                let weeks = number.parse::<u64>().unwrap_or(0);
57                total_secs += weeks * 7 * 24 * 60 * 60;
58                number.clear();
59            }
60            'D' => {
61                let days = number.parse::<u64>().unwrap_or(0);
62                total_secs += days * 24 * 60 * 60;
63                number.clear();
64            }
65            'H' => {
66                let hours = number.parse::<u64>().unwrap_or(0);
67                total_secs += hours * 60 * 60;
68                number.clear();
69            }
70            'M' => {
71                let minutes = number.parse::<u64>().unwrap_or(0);
72                total_secs += minutes * 60;
73                number.clear();
74            }
75            'S' => {
76                let seconds = number.parse::<u64>().unwrap_or(0);
77                total_secs += seconds;
78                number.clear();
79            }
80            digit if digit.is_ascii_digit() => {
81                number.push(digit);
82            }
83            '-' => {}
84            _ => {}
85        }
86    }
87
88    Duration::from_secs(total_secs)
89}
90
91fn changed_properties(event1: &IcalEvent, event2: &IcalEvent) -> Option<Vec<String>> {
92    let props1 = &event1.properties;
93    let props2 = &event2.properties;
94
95    let mut changed_props = Vec::new();
96
97    // Check for modified and removed properties
98    for prop1 in props1.iter().filter(|p| p.name != "DTSTAMP") {
99        let matching_prop = props2
100            .iter()
101            .filter(|p| p.name != "DTSTAMP")
102            .find(|prop2| prop1.name == prop2.name);
103
104        match matching_prop {
105            Some(prop2) => {
106                // Check if existing property changed
107                if prop1.value != prop2.value
108                    || match (&prop1.params, &prop2.params) {
109                        (Some(params1), Some(params2)) => params1 != params2,
110                        (None, None) => false,
111                        _ => true,
112                    }
113                {
114                    changed_props.push(prop1.name.clone());
115                }
116            }
117            None => {
118                // Property was removed in event2
119                changed_props.push(prop1.name.clone());
120            }
121        }
122    }
123
124    // Check for new properties in event2
125    for prop2 in props2.iter().filter(|p| p.name != "DTSTAMP") {
126        if !props1.iter().any(|p| p.name == prop2.name) {
127            changed_props.push(prop2.name.clone());
128        }
129    }
130
131    if changed_props.is_empty() {
132        None
133    } else {
134        Some(changed_props)
135    }
136}
137
138/// A helper struct to save an [IcalEvent] with its uid
139#[derive(Debug, Clone)]
140pub struct EventData {
141    pub uid: String,
142    pub ical_data: IcalEvent,
143}
144
145/// A struct denoting a Property Change of a [key](`PropertyChange::key`) with both states in [from](`PropertyChange::from`) and [to](`PropertyChange::to`).
146///
147/// # Examples
148///
149/// ```
150/// // A description has been added with the contents "New Description"
151/// PropertyChange {
152///     key: "DESCRIPTION".to_string(),
153///     from: None,
154///     to: Some(Property {
155///         name: "DESCRIPTION".to_string(),
156///         params: None,
157///         value: Some("New Description".to_string())
158///     })
159/// }
160/// ```
161#[derive(Debug, Clone)]
162pub struct PropertyChange {
163    pub key: String,
164    pub from: Option<Property>,
165    pub to: Option<Property>,
166}
167
168/// Used to pass the events to the callbacks.
169///
170/// The types:
171/// - [`CalendarEvent::Setup`]: If the ICS Watcher is being initialized for the first time, all events that are found will be passed as [`CalendarEvent::Setup`]
172/// - [`CalendarEvent::Created`]: If the ICS Watcher has been running, any new events found will be passed as [`CalendarEvent::Created`]
173/// - [`CalendarEvent::Updated`]: Any events with different properties. The changed properties, along with both the before and after state will be passed in [`CalendarEvent::Updated::changed_properties`]
174/// - [`CalendarEvent::Deleted`]: If an event is not found anymore, it is being passed as [`CalendarEvent::Deleted`]
175#[derive(Debug, Clone)]
176pub enum CalendarEvent {
177    Setup(EventData),
178    Created(EventData),
179    Updated {
180        event: EventData,
181        changed_properties: Vec<PropertyChange>,
182    },
183    Deleted(EventData),
184}
185
186/// Handling change detection of a single calendar (as one ics file can contain multiple calendars)
187/// For usage details, see [ICSWatcher]
188#[derive(Debug)]
189pub struct CalendarChangeDetector {
190    pub name: Option<String>,
191    pub description: Option<String>,
192    pub ttl: Duration,
193    previous: HashMap<String, IcalEvent>,
194    initialized: bool,
195}
196
197impl CalendarChangeDetector {
198    pub fn new() -> Self {
199        CalendarChangeDetector {
200            name: None,
201            description: None,
202            ttl: rfc5545_to_std_duration("PT1H"),
203            previous: HashMap::new(),
204            initialized: false,
205        }
206    }
207
208    pub fn set_state(&mut self, state: HashMap<String, IcalEvent>) {
209        self.previous = state;
210        self.initialized = true;
211    }
212
213    pub fn compare(&mut self, calendar: IcalCalendar) -> Vec<CalendarEvent> {
214        self.name = calendar
215            .get_property("X-WR-CALNAME")
216            .and_then(|prop| prop.value.clone());
217
218        self.description = calendar
219            .get_property("X-WR-CALDESC")
220            .and_then(|prop| prop.value.clone());
221
222        self.ttl = calendar
223            .get_property("X-PUBLISHED-TTL")
224            .and_then(|prop| prop.value.as_ref())
225            .map(|value| value.as_str())
226            .and_then(|s| Some(rfc5545_to_std_duration(s)))
227            .unwrap_or_else(|| rfc5545_to_std_duration("PT1H"));
228
229        let mut new_previous = HashMap::new();
230        let mut result = Vec::with_capacity(calendar.events.len());
231
232        for event in calendar.events {
233            let event_uid_property = match event
234                .get_property("UID")
235                .and_then(|prop| prop.value.clone())
236            {
237                Some(uid) => uid,
238                None => {
239                    println!("Warning: An event is missing a UID, skipping");
240                    continue;
241                }
242            };
243            let event_uid = event_uid_property
244                + &event
245                    .get_property("RECURRENCE-ID")
246                    .map(|prop| match prop.value.clone() {
247                        Some(v) => v,
248                        None => String::from("R"),
249                    })
250                    .unwrap_or(String::from(""))
251                + &event
252                    .get_property("X-CO-RECURRINGID")
253                    .map(|prop| match prop.value.clone() {
254                        Some(v) => v,
255                        None => String::from("XR"),
256                    })
257                    .unwrap_or(String::from(""));
258
259            new_previous.insert(event_uid.clone(), event.clone());
260            if self.initialized {
261                if let Some(prev_event) = self.previous.get(&event_uid) {
262                    match changed_properties(prev_event, &event) {
263                        Some(properties) => {
264                            result.push(CalendarEvent::Updated {
265                                changed_properties: properties
266                                    .iter()
267                                    .map(|property| PropertyChange {
268                                        key: property.clone(),
269                                        from: self.previous[&event_uid]
270                                            .get_property(property)
271                                            .cloned(),
272                                        to: new_previous[&event_uid]
273                                            .get_property(property)
274                                            .cloned(),
275                                    })
276                                    .collect(),
277                                event: EventData {
278                                    uid: event_uid,
279                                    ical_data: event,
280                                },
281                            });
282                        }
283                        None => (),
284                    }
285                } else {
286                    result.push(CalendarEvent::Created(EventData {
287                        uid: event_uid,
288                        ical_data: event,
289                    }));
290                }
291            } else {
292                result.push(CalendarEvent::Setup(EventData {
293                    uid: event_uid,
294                    ical_data: event,
295                }));
296            }
297        }
298
299        for (uid, ical_data) in self.previous.drain() {
300            if !new_previous.contains_key(&uid) {
301                result.push(CalendarEvent::Deleted(EventData { uid, ical_data }));
302            }
303        }
304
305        self.previous = new_previous;
306        self.initialized = true;
307
308        result
309    }
310}
311
312pub type CalendarCallback = Box<
313    dyn Fn(
314        Option<String>,
315        Option<String>,
316        Vec<CalendarEvent>,
317    ) -> Pin<
318        Box<dyn Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send>,
319    >,
320>;
321/// Instantiate an [ICSWatcher] using [ICSWatcher::new] to watch for changes of an ics link.
322///
323/// Using this, you can also [create](`ICSWatcher::create_backup`) and [load](`ICSWatcher::load_backup`) backups.
324/// If you want to handle when the watcher updates, you can manually call the [`ICSWatcher::update`] method.
325///
326/// # Examples
327///
328/// ```
329/// let mut ics_watcher = ICSWatcher::new(
330///     "some url",
331///     vec![
332///         Box::new(|a, b, e| Box::pin(async move { log_events(a, b, e).await })),
333///     ],
334/// );
335
336/// // Try to load backup
337/// let _ = ics_watcher.load_backup("Your Calendar");
338/// // Run ics watcher infinitely and save backups as "Your Calendar"
339/// ics_watcher
340///     .run(Option::from("Your Calendar"))
341///     .await
342///     .expect("ICS Watcher crashed");
343/// ```
344pub struct ICSWatcher<'a> {
345    ics_link: &'a str,
346    pub callbacks: Vec<CalendarCallback>,
347    change_detector: CalendarChangeDetector,
348}
349
350impl<'a> ICSWatcher<'a> {
351    pub fn new(ics_link: &'a str, callbacks: Vec<CalendarCallback>) -> Self {
352        ICSWatcher {
353            ics_link,
354            callbacks,
355            change_detector: CalendarChangeDetector::new(),
356        }
357    }
358
359    pub fn restore_state(&mut self, state: HashMap<String, IcalEvent>) {
360        self.change_detector.set_state(state);
361    }
362
363    pub fn get_state(&self) -> &HashMap<String, IcalEvent> {
364        &self.change_detector.previous
365    }
366
367    pub fn get_calendar_name(&self) -> Option<String> {
368        self.change_detector.name.clone()
369    }
370
371    pub fn create_backup(&self, name: &str) {
372        let backup_file_path = Path::new(".backups").join(sanitize(name) + ".cbor");
373
374        fs::create_dir_all(".backups").expect("Failed to create .backups folder");
375        let backup_file = File::create(backup_file_path).expect("Failed to create backup file");
376        ciborium::ser::into_writer(self.get_state(), backup_file)
377            .expect("Failed to create and write backup");
378    }
379
380    pub fn load_backup(&mut self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
381        let state = read_backup(name).map_err(|error| error as Box<dyn std::error::Error>)?;
382        self.restore_state(state);
383
384        Ok(())
385    }
386
387    pub async fn update(&mut self) -> Result<(), Box<dyn std::error::Error>> {
388        let res = reqwest::get(self.ics_link).await?;
389        // If server doesn't return 200, return with error
390        if let Err(error) = res.error_for_status_ref() {
391            return Err(error.into());
392        }
393        let res_text = res.text().await?;
394        let buf = BufReader::new(res_text.as_bytes());
395        let calendar = IcalParser::new(buf).next().ok_or("No Calendar present")??;
396
397        let events = self.change_detector.compare(calendar);
398
399        if !events.is_empty() {
400            let futures: Vec<_> = self
401                .callbacks
402                .iter()
403                .map(|callback| {
404                    callback(
405                        self.change_detector.name.clone(),
406                        self.change_detector.description.clone(),
407                        events.clone(),
408                    )
409                })
410                .collect();
411
412            for future in futures {
413                match future.await {
414                    Ok(()) => (),
415                    Err(err) => eprintln!("Error in callback: {err:?}"),
416                }
417            }
418        }
419
420        Ok(())
421    }
422
423    pub async fn run(&mut self, backup: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
424        loop {
425            self.update().await?;
426            if let Some(path) = backup {
427                self.create_backup(path);
428            }
429            println!("Refreshing in {:?}", self.change_detector.ttl);
430            sleep(self.change_detector.ttl).await;
431        }
432    }
433}
434
435/// Reads the state saved by [`ICSWatcher::create_backup`] - the events of the calendar as the
436/// watcher saw them last, keyed by their uid.
437pub fn read_backup(
438    name: &str,
439) -> Result<HashMap<String, IcalEvent>, Box<dyn std::error::Error + Send + Sync>> {
440    let backup_file = File::open(Path::new(".backups").join(sanitize(name) + ".cbor"))?;
441
442    Ok(ciborium::de::from_reader(backup_file)?)
443}
444
445/// This is a callback which logs all events.
446///
447/// This can come in useful during debugging or when deploying to check the logs later on.
448///
449/// # Examples
450///
451/// ```
452/// let mut ics_watcher = ICSWatcher::new(
453///     "some url",
454///     vec![
455///         Box::new(|a, b, e| Box::pin(async move { log_events(a, b, e).await })),
456///     ],
457/// );
458///
459/// // Try to load backup
460/// let _ = ics_watcher.load_backup("Your Calendar");
461/// ics_watcher
462///     .run(Option::from("Your Calendar"))
463///     .await
464///     .expect("ICS Watcher crashed");
465/// ```
466pub async fn log_events(
467    name: Option<String>,
468    description: Option<String>,
469    events: Vec<CalendarEvent>,
470) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
471    println!(
472        "Captured changes of {}{}:",
473        name.as_deref().unwrap_or("Unnamed Calendar"),
474        description
475            .as_deref()
476            .and_then(|desc| Some(format!(" ({desc})")))
477            .unwrap_or(String::from(""))
478    );
479    for event in events {
480        match event {
481            CalendarEvent::Setup(EventData { uid, ical_data }) => {
482                println!("Setup {uid}: {ical_data:?}\n")
483            }
484            CalendarEvent::Created(EventData { uid, ical_data }) => {
485                println!("Created {uid}: {ical_data:?}\n")
486            }
487            CalendarEvent::Updated {
488                event,
489                changed_properties,
490            } => {
491                println!("Updated {}: {:?}\n", event.uid, changed_properties)
492            }
493            CalendarEvent::Deleted(EventData { uid, ical_data }) => {
494                println!("Deleted {uid}: {ical_data:?}\n")
495            }
496        }
497    }
498
499    Ok(())
500}
501
502static REPLACEMENTS: Lazy<Arc<Vec<(String, String)>>> = Lazy::new(|| {
503    let courses_json = match fs::read_to_string("replacements.json") {
504        Ok(content) => content,
505        Err(_) => return Arc::new(Vec::new()),
506    };
507
508    let raw_replacements: HashMap<String, String> = match serde_json::from_str(&courses_json) {
509        Ok(parsed) => parsed,
510        Err(_) => return Arc::new(Vec::new()),
511    };
512
513    let mut replacements: Vec<(String, String)> = raw_replacements.into_iter().collect();
514    replacements.sort_by(|(a_key, _), (b_key, _)| {
515        if a_key.len() != b_key.len() {
516            b_key.len().cmp(&a_key.len())
517        } else {
518            a_key.cmp(b_key)
519        }
520    });
521
522    Arc::new(replacements)
523});
524
525static LV_ID_REGEX: Lazy<Regex> =
526    Lazy::new(|| Regex::new(r"\[(([A-Z]{2})(\d{4})(?:,\s*[A-Z]{2}\d{4})*)\]|\((([A-Z]{2})(\d{4})(?:,\s*[A-Z]{2}\d{4})*)\)").unwrap());
527
528fn remove_lv_id(text: &str) -> String {
529    // Matches [AA1234] or (AA1234) where A is any uppercase letter and 1234 is any four digits
530    LV_ID_REGEX.replace_all(text, "").to_string()
531}
532
533pub(crate) fn replace_courses(input: &str) -> String {
534    let mut result = input.to_string();
535    for (from, to) in REPLACEMENTS.iter() {
536        result = result.replace(from, to);
537    }
538    remove_lv_id(result.as_str())
539}
540
541/// Unescapes the location of an ICS event. Locations are a single line, so line breaks
542/// become commas instead of leaving an `n` behind.
543pub(crate) fn unescape_location(location: &str) -> String {
544    location
545        .replace(r"\n", ", ")
546        .replace(r"\N", ", ")
547        .replace(r"\", "")
548}
549
550fn convert_to_non_digits(str: String) -> String {
551    str.chars()
552        .map(|c| match c {
553            '0' => '𝟎',
554            '1' => '𝟏',
555            '2' => '𝟐',
556            '3' => '𝟑',
557            '4' => '𝟒',
558            '5' => '𝟓',
559            '6' => '𝟔',
560            '7' => '𝟕',
561            '8' => '𝟖',
562            '9' => '𝟗',
563            other => other,
564        })
565        .collect::<String>()
566}
567
568/// Undoes [convert_to_non_digits]
569pub(crate) fn convert_to_digits(str: &str) -> String {
570    str.chars()
571        .map(|c| match c {
572            '𝟎' => '0',
573            '𝟏' => '1',
574            '𝟐' => '2',
575            '𝟑' => '3',
576            '𝟒' => '4',
577            '𝟓' => '5',
578            '𝟔' => '6',
579            '𝟕' => '7',
580            '𝟖' => '8',
581            '𝟗' => '9',
582            other => other,
583        })
584        .collect::<String>()
585}
586
587pub(crate) type GoogleCalendarHub = CalendarHub<HttpsConnector<HttpConnector>>;
588
589/// Connects to the Google Calendar API with the credentials in `.secrets`.
590///
591/// The first time, this opens the OAuth consent screen and caches the token afterwards.
592pub(crate) async fn google_hub(
593) -> Result<GoogleCalendarHub, Box<dyn std::error::Error + Send + Sync>> {
594    let secret: yup_oauth2::ApplicationSecret =
595        read_application_secret(Path::new(".secrets/client_secret.json"))
596            .await
597            .expect("Failed to read client secret");
598
599    let auth = yup_oauth2::InstalledFlowAuthenticator::builder(
600        secret,
601        yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect,
602    )
603    .persist_tokens_to_disk(".secrets/token_cache.json")
604    .build()
605    .await?;
606
607    auth.token(&["https://www.googleapis.com/auth/calendar"])
608        .await
609        .expect("Unable to get scope for calendar");
610
611    let client = hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
612        .build(
613            hyper_rustls::HttpsConnectorBuilder::new()
614                .with_native_roots()?
615                .https_or_http()
616                .enable_http1()
617                .build(),
618        );
619
620    Ok(CalendarHub::new(client, auth))
621}
622
623// TODO: Refactor create and update event
624async fn create_event(
625    hub: &CalendarHub<HttpsConnector<HttpConnector>>,
626    uid: String,
627    event: IcalEvent,
628    calendar_id: &str,
629) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
630    let mut google_event = Event::default();
631
632    let start = NaiveDateTime::parse_from_str(
633        &event
634            .get_property("DTSTART")
635            .ok_or("Required property DTSTART missing")?
636            .value
637            .clone()
638            .ok_or("Required property value DTSTART missing")?[0..15],
639        "%Y%m%dT%H%M%S",
640    )?
641    .and_utc();
642    let end = NaiveDateTime::parse_from_str(
643        &event
644            .get_property("DTEND")
645            .ok_or("Required property DTEND missing")?
646            .value
647            .clone()
648            .ok_or("Required property value DTEND missing")?[0..15],
649        "%Y%m%dT%H%M%S",
650    )?
651    .and_utc();
652
653    google_event.start = Some(EventDateTime {
654        date_time: Some(start),
655        date: None,
656        time_zone: None,
657    });
658    google_event.end = Some(EventDateTime {
659        date_time: Some(end),
660        date: None,
661        time_zone: None,
662    });
663
664    let room = event
665        .get_property("LOCATION")
666        .and_then(|loc| loc.value.clone())
667        .map(|s| unescape_location(&s))
668        .unwrap_or_else(|| "Kein Ort angegeben".to_string());
669
670    let i_cal_uid = convert_to_non_digits(uid.replace("@tum.de", "|").to_string());
671
672    // google_event.reminders would be useful for exams
673    if let Some(url) = event.get_property("URL").and_then(|url| url.value.clone()) {
674        google_event.source = Some(google_calendar3::api::EventSource {
675            title: Some("Link zur Lernveranstaltung".to_string()),
676            url: Some(url),
677        });
678    }
679
680    if let Some(status) = event
681        .get_property("STATUS")
682        .and_then(|status| status.value.clone())
683    {
684        google_event.status = Some(status.to_lowercase());
685    }
686
687    match event
688        .get_property("SUMMARY")
689        .and_then(|summary| summary.value.clone())
690    {
691        Some(summary) => {
692            google_event.summary = Some(replace_courses(summary.replace(r"\", "").as_str()));
693            if summary.contains("Prüfung") {
694                // Big important :o
695                google_event.color_id = Some(String::from("11"));
696            }
697        }
698        None => {
699            google_event.summary = Some("Kein Titel angegeben".to_string());
700        }
701    }
702
703    google_event.location = Some(convert_to_non_digits(room.clone()));
704
705    let link = format!("https://nav.tum.de/search?q={}", room.clone());
706    let description = event
707        .get_property("DESCRIPTION")
708        .and_then(|prop| prop.value.clone())
709        .map(|desc| desc.split(r"\;").skip(2).collect())
710        .unwrap_or(String::new())
711        .as_str()
712        .replace(r"\n", "<br>")
713        .replace(r"\", "")
714        .trim()
715        .to_string();
716
717    let original_description = if !description.is_empty() {
718        format!("{}<br>", description)
719    } else {
720        description
721    };
722
723    let location_link = format!("<a href=\"{}\">Wo ist das?</a><br>", link);
724    let online_only = room.to_lowercase().contains("online");
725    let on_moodle = room.to_lowercase().contains("moodle");
726
727    google_event.description = Some(format!(
728        "{}{}<br><hr><small>uid:{}</small>",
729        original_description,
730        if online_only {
731            if on_moodle {
732                "<a href=\"https://www.moodle.tum.de/my/\">Online auf Moodle</a><br>".into()
733            } else {
734                "Online<br>".into()
735            }
736        } else {
737            location_link
738        },
739        i_cal_uid
740    ));
741
742    let results = hub
743        .events()
744        .list(calendar_id)
745        .q(&format!("uid:{}", i_cal_uid))
746        .doit()
747        .await?;
748
749    if let Some(event_id) = results
750        .1
751        .items
752        .and_then(|items| items.first().cloned())
753        .and_then(|event| event.id)
754    {
755        hub.events()
756            .update(google_event, calendar_id, &event_id)
757            .doit()
758            .await?
759            .0
760    } else {
761        hub.events()
762            .insert(google_event, calendar_id)
763            .doit()
764            .await?
765            .0
766    };
767
768    Ok(())
769}
770
771async fn update_event(
772    hub: &CalendarHub<HttpsConnector<HttpConnector>>,
773    uid: String,
774    event: IcalEvent,
775    property_changes: Vec<PropertyChange>,
776    calendar_id: &str,
777) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
778    println!("Updating event {uid}: {property_changes:?}");
779
780    let i_cal_uid = convert_to_non_digits(uid.replace("@tum.de", "|").to_string());
781    let results = hub
782        .events()
783        .list(calendar_id)
784        .q(&format!("uid:{}", i_cal_uid))
785        .doit()
786        .await?;
787
788    let oringinal_event = results
789        .1
790        .items
791        .and_then(|items| items.first().cloned())
792        .ok_or("Updating not possible as event is not present anymore")?;
793    let event_id = oringinal_event
794        .id
795        .ok_or("Google didn't provide the event with an ID")?;
796    let mut google_event = Event::default();
797
798    if property_changes
799        .iter()
800        .any(|property_change| property_change.key == "DTSTART" || property_change.key == "DTEND")
801    {
802        let start = NaiveDateTime::parse_from_str(
803            &event
804                .get_property("DTSTART")
805                .ok_or("Required property DTSTART missing")?
806                .value
807                .clone()
808                .ok_or("Required property value DTSTART missing")?[0..15],
809            "%Y%m%dT%H%M%S",
810        )?
811        .and_utc();
812        let end = NaiveDateTime::parse_from_str(
813            &event
814                .get_property("DTEND")
815                .ok_or("Required property DTEND missing")?
816                .value
817                .clone()
818                .ok_or("Required property DTEND value missing")?[0..15],
819            "%Y%m%dT%H%M%S",
820        )?
821        .and_utc();
822
823        google_event.start = Some(EventDateTime {
824            date_time: Some(start),
825            date: None,
826            time_zone: None,
827        });
828        google_event.end = Some(EventDateTime {
829            date_time: Some(end),
830            date: None,
831            time_zone: None,
832        });
833    } else {
834        google_event.start = oringinal_event.start;
835        google_event.end = oringinal_event.end;
836    }
837
838    // google_event.reminders would be useful for exams
839    if let Some(url) = event.get_property("URL").and_then(|url| url.value.clone()) {
840        google_event.source = Some(google_calendar3::api::EventSource {
841            title: Some("Link zur Lernveranstaltung".to_string()),
842            url: Some(url),
843        });
844    }
845
846    if property_changes
847        .iter()
848        .any(|property_change| property_change.key == "STATUS")
849    {
850        if let Some(status) = event
851            .get_property("STATUS")
852            .and_then(|status| status.value.clone())
853        {
854            google_event.status = Some(status.to_lowercase());
855        }
856    } else {
857        google_event.status = oringinal_event.status;
858    }
859
860    if property_changes
861        .iter()
862        .any(|property_change| property_change.key == "SUMMARY")
863    {
864        match event
865            .get_property("SUMMARY")
866            .and_then(|summary| summary.value.clone())
867        {
868            Some(summary) => {
869                google_event.summary = Some(replace_courses(summary.replace(r"\", "").as_str()));
870                if summary.contains("Prüfung") {
871                    // 11 = Tomato (Google Calendar's Red)
872                    google_event.color_id = Some(String::from("11"));
873                }
874            }
875            None => {
876                google_event.summary = Some("Kein Titel angegeben".to_string());
877            }
878        }
879    } else {
880        if oringinal_event
881            .summary
882            .clone()
883            .is_some_and(|summary| summary.contains("Prüfung"))
884        {
885            // 11 = Tomato (Google Calendar's Red)
886            google_event.color_id = Some(String::from("11"));
887        }
888        google_event.summary = oringinal_event.summary;
889    }
890
891    // If room has changed, update all properties associated with the room
892    let room = event
893        .get_property("LOCATION")
894        .and_then(|loc| loc.value.clone())
895        .map(|s| unescape_location(&s))
896        .unwrap_or_else(|| "Kein Ort angegeben".to_string());
897    if property_changes
898        .iter()
899        .any(|property_change| property_change.key == "LOCATION")
900    {
901        google_event.location = Some(convert_to_non_digits(room.clone()));
902    } else {
903        google_event.location = oringinal_event.location;
904    }
905
906    let link = format!(
907        "https://nav.tum.de/search?q={}",
908        google_event.location.clone().unwrap_or_default()
909    );
910
911    let description = event
912        .get_property("DESCRIPTION")
913        .and_then(|prop| prop.value.clone())
914        .map(|desc| desc.split(r"\;").skip(2).collect())
915        .unwrap_or(String::new())
916        .as_str()
917        .replace(r"\n", "<br>")
918        .replace(r"\", "")
919        .trim()
920        .to_string();
921
922    let original_description = if !description.is_empty() {
923        format!("{}<br>", description)
924    } else {
925        description
926    };
927
928    if property_changes.iter().any(|property_change| {
929        property_change.key == "DESCRIPTION" || property_change.key == "LOCATION"
930    }) {
931        let location_link = format!("<a href=\"{}\">Wo ist das?</a><br>", link);
932        let online_only = google_event
933            .location
934            .clone()
935            .unwrap_or_default()
936            .to_lowercase()
937            .contains("online");
938        let on_moodle = google_event
939            .location
940            .clone()
941            .unwrap_or_default()
942            .to_lowercase()
943            .contains("moodle");
944        google_event.description = Some(format!(
945            "{}{}<br><hr><small>uid:{}</small>",
946            original_description,
947            if online_only {
948                if on_moodle {
949                    "<a href=\"https://www.moodle.tum.de/my/\">Online auf Moodle</a><br>".into()
950                } else {
951                    "Online<br>".into()
952                }
953            } else {
954                location_link
955            },
956            i_cal_uid
957        ));
958    } else {
959        google_event.description = oringinal_event.description;
960    }
961
962    hub.events()
963        .update(google_event, calendar_id, &event_id)
964        .doit()
965        .await?
966        .0;
967
968    Ok(())
969}
970
971async fn delete_event(
972    hub: &CalendarHub<HttpsConnector<HttpConnector>>,
973    uid: String,
974    calendar_id: &str,
975) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
976    let i_cal_uid = convert_to_non_digits(uid.replace("@tum.de", "|").to_string());
977    let results = hub
978        .events()
979        .list(calendar_id)
980        .q(&format!("uid:{}", i_cal_uid))
981        .doit()
982        .await?;
983
984    if let Some(event_id) = results
985        .1
986        .items
987        .and_then(|items| items.first().cloned())
988        .and_then(|event| event.id)
989    {
990        hub.events().delete(calendar_id, &event_id).doit().await?;
991    }
992
993    Ok(())
994}
995
996/// This is a callback which synchronizes your TUM Calendar to your Google Calender.
997///
998/// The event summaries will be shortened and the events themselves modifieable. As soon as you delete an event, it won't come back.
999/// If you modify an event, your changes will only be overwritten if they're changed in the TUM Calendar.
1000///
1001/// # Examples
1002///
1003/// ```
1004/// let mut ics_watcher = ICSWatcher::new(
1005///     tum_url.as_str(),
1006///     vec![
1007///         Box::new(move |a, b, e| {
1008///             let calendar_id = google_calendar_id.clone();
1009///             Box::pin(async move { tum_google_sync(&calendar_id, a, b, e).await })
1010///         }),
1011///     ],
1012/// );
1013
1014/// // Try to load backup
1015/// let _ = ics_watcher.load_backup("TUM Calendar");
1016/// ics_watcher
1017///     .run(Option::from("TUM Calendar"))
1018///     .await
1019///     .expect("ICS Watcher crashed");
1020/// ```
1021pub async fn tum_google_sync(
1022    calendar_id: &str,
1023    _: Option<String>,
1024    _: Option<String>,
1025    events: Vec<CalendarEvent>,
1026) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1027    let hub = google_hub().await?;
1028
1029    for event in events {
1030        let calendar_id = calendar_id;
1031
1032        let result = match event {
1033            CalendarEvent::Setup(EventData { uid, ical_data }) => {
1034                // Don't sync if event is a video transmission
1035                if ical_data
1036                    .get_property("DESCRIPTION")
1037                    .and_then(|prop| prop.value.clone())
1038                    .map(|desc| desc.contains("Videoübertragung aus"))
1039                    .unwrap_or(false)
1040                {
1041                    Err(format!(
1042                        "Skipping video transmission event {:?}",
1043                        ical_data.get_property("SUMMARY"),
1044                    )
1045                    .into())
1046                } else {
1047                    println!("Setting up event {uid}");
1048                    create_event(&hub, uid, ical_data, calendar_id).await
1049                }
1050            }
1051            CalendarEvent::Created(EventData { uid, ical_data }) => {
1052                // Don't sync if event is a video transmission
1053                if ical_data
1054                    .get_property("DESCRIPTION")
1055                    .and_then(|prop| prop.value.clone())
1056                    .map(|desc| desc.contains("Videoübertragung aus"))
1057                    .unwrap_or(false)
1058                {
1059                    // Skipping video transmission event
1060                    Ok(())
1061                } else {
1062                    println!("Creating event {uid}");
1063                    create_event(&hub, uid, ical_data, calendar_id).await
1064                }
1065            }
1066            CalendarEvent::Updated {
1067                event: EventData { uid, ical_data },
1068                changed_properties,
1069            } => {
1070                // The TUM Calendar seems to randomly serve english / german descriptions
1071                // This looks for differences other than the first two words in english / german
1072                if changed_properties.len() == 1
1073                    && changed_properties[0].key == "DESCRIPTION"
1074                    && changed_properties[0]
1075                        .from
1076                        .as_ref()
1077                        .and_then(|from| from.value.as_ref())
1078                        .zip(
1079                            changed_properties[0]
1080                                .to
1081                                .as_ref()
1082                                .and_then(|to| to.value.as_ref()),
1083                        )
1084                        .map_or(false, |(from, to)| {
1085                            from.split(";").skip(2).collect::<String>()
1086                                == to.split(";").skip(2).collect::<String>()
1087                        })
1088                {
1089                    // Update is a language-only update
1090                    Ok(())
1091                } else {
1092                    update_event(&hub, uid, ical_data, changed_properties, calendar_id).await
1093                }
1094            }
1095            CalendarEvent::Deleted(EventData { uid, ical_data }) => {
1096                println!("Deleting event {uid}");
1097                // If the event is in the far past, we assume it's just the calendar updating
1098                // for the next semester, which means we don't actually need to delete it
1099                let end_date = ical_data
1100                    .get_property("DTEND")
1101                    .and_then(|prop| prop.value.clone())
1102                    .and_then(|value| {
1103                        if value.len() >= 15 {
1104                            NaiveDateTime::parse_from_str(&value[0..15], "%Y%m%dT%H%M%S")
1105                                .map(|dt| dt.and_utc())
1106                                .ok()
1107                        } else {
1108                            None
1109                        }
1110                    });
1111
1112                match end_date {
1113                    Some(end) if end < Utc::now() - Duration::from_secs(60 * 60 * 24 * 7) => {
1114                        // Not deleting event as it is far back in the past
1115                        Ok(())
1116                    }
1117                    _ => delete_event(&hub, uid, calendar_id).await,
1118                }
1119            }
1120        };
1121
1122        match result {
1123            Ok(_) => (),
1124            Err(error) => eprintln!("Error on syncing event: {error:?}"),
1125        }
1126    }
1127
1128    Ok(())
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    use super::*;
1134
1135    #[test]
1136    fn cmp_no_properties() {
1137        let event1 = IcalEvent {
1138            properties: vec![],
1139            alarms: vec![],
1140        };
1141
1142        let event2 = IcalEvent {
1143            properties: vec![],
1144            alarms: vec![],
1145        };
1146
1147        let keys = changed_properties(&event1, &event2);
1148
1149        assert_eq!(keys, None);
1150    }
1151
1152    #[test]
1153    fn cmp_same_properties() {
1154        let prop1 = Property {
1155            name: String::from("prop1"),
1156            value: Some(String::from("prop1 value")),
1157            params: None,
1158        };
1159
1160        let prop2 = Property {
1161            name: String::from("prop2"),
1162            value: Some(String::from("prop2 value")),
1163            params: None,
1164        };
1165
1166        let event1 = IcalEvent {
1167            properties: vec![prop1.clone(), prop2.clone()],
1168            alarms: vec![],
1169        };
1170
1171        let event2 = IcalEvent {
1172            properties: vec![prop2.clone(), prop1.clone()],
1173            alarms: vec![],
1174        };
1175
1176        let keys = changed_properties(&event1, &event2);
1177
1178        assert_eq!(keys, None);
1179    }
1180
1181    #[test]
1182    fn cmp_different_properties() {
1183        let prop1 = Property {
1184            name: String::from("prop1"),
1185            value: Some(String::from("prop1 value")),
1186            params: None,
1187        };
1188
1189        let prop2 = Property {
1190            name: String::from("prop2"),
1191            value: Some(String::from("prop2 value")),
1192            params: None,
1193        };
1194
1195        let event1 = IcalEvent {
1196            properties: vec![prop1],
1197            alarms: vec![],
1198        };
1199
1200        let event2 = IcalEvent {
1201            properties: vec![prop2],
1202            alarms: vec![],
1203        };
1204
1205        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1206
1207        assert_eq!(keys.len(), 2);
1208        assert!(keys.contains(&String::from("prop1")) && keys.contains(&String::from("prop2")));
1209    }
1210
1211    #[test]
1212    fn cmp_added_property() {
1213        let prop1 = Property {
1214            name: String::from("prop1"),
1215            value: Some(String::from("prop1 value")),
1216            params: None,
1217        };
1218
1219        let event1 = IcalEvent {
1220            properties: vec![],
1221            alarms: vec![],
1222        };
1223
1224        let event2 = IcalEvent {
1225            properties: vec![prop1],
1226            alarms: vec![],
1227        };
1228
1229        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1230
1231        assert_eq!(keys.len(), 1);
1232        assert!(keys.contains(&String::from("prop1")));
1233    }
1234
1235    #[test]
1236    fn cmp_removed_property() {
1237        let prop1 = Property {
1238            name: String::from("prop1"),
1239            value: Some(String::from("prop1 value")),
1240            params: None,
1241        };
1242
1243        let event1 = IcalEvent {
1244            properties: vec![prop1],
1245            alarms: vec![],
1246        };
1247
1248        let event2 = IcalEvent {
1249            properties: vec![],
1250            alarms: vec![],
1251        };
1252
1253        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1254
1255        assert_eq!(keys.len(), 1);
1256        assert!(keys.contains(&String::from("prop1")));
1257    }
1258
1259    #[test]
1260    fn cmp_different_properties_no_value() {
1261        let prop1 = Property {
1262            name: String::from("prop1"),
1263            value: None,
1264            params: None,
1265        };
1266
1267        let prop2 = Property {
1268            name: String::from("prop2"),
1269            value: None,
1270            params: None,
1271        };
1272
1273        let event1 = IcalEvent {
1274            properties: vec![prop1],
1275            alarms: vec![],
1276        };
1277
1278        let event2 = IcalEvent {
1279            properties: vec![prop2],
1280            alarms: vec![],
1281        };
1282
1283        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1284
1285        assert_eq!(keys.len(), 2);
1286        assert!(keys.contains(&String::from("prop1")) && keys.contains(&String::from("prop2")));
1287    }
1288
1289    #[test]
1290    fn cmp_different_params() {
1291        let prop1 = Property {
1292            name: String::from("prop1"),
1293            value: None,
1294            params: Some(vec![(String::from("key"), vec![String::from("value")])]),
1295        };
1296
1297        let prop2 = Property {
1298            name: String::from("prop2"),
1299            value: None,
1300            params: Some(vec![(String::from("key"), vec![String::from("value")])]),
1301        };
1302
1303        let event1 = IcalEvent {
1304            properties: vec![prop1],
1305            alarms: vec![],
1306        };
1307
1308        let event2 = IcalEvent {
1309            properties: vec![prop2],
1310            alarms: vec![],
1311        };
1312
1313        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1314
1315        assert_eq!(keys.len(), 2);
1316        assert!(keys.contains(&String::from("prop1")) && keys.contains(&String::from("prop2")));
1317    }
1318
1319    #[test]
1320    fn cmp_same_params() {
1321        let prop1 = Property {
1322            name: String::from("prop1"),
1323            value: None,
1324            params: Some(vec![(String::from("key"), vec![String::from("value")])]),
1325        };
1326
1327        let prop2 = Property {
1328            name: String::from("prop1"),
1329            value: None,
1330            params: Some(vec![(String::from("key"), vec![String::from("value")])]),
1331        };
1332
1333        let event1 = IcalEvent {
1334            properties: vec![prop1],
1335            alarms: vec![],
1336        };
1337
1338        let event2 = IcalEvent {
1339            properties: vec![prop2],
1340            alarms: vec![],
1341        };
1342
1343        let keys = changed_properties(&event1, &event2);
1344
1345        assert_eq!(keys, None);
1346    }
1347
1348    #[test]
1349    fn cmp_different_param_keys() {
1350        let prop1 = Property {
1351            name: String::from("prop1"),
1352            value: None,
1353            params: Some(vec![(String::from("key"), vec![String::from("value")])]),
1354        };
1355
1356        let prop2 = Property {
1357            name: String::from("prop1"),
1358            value: None,
1359            params: Some(vec![(String::from("key2"), vec![String::from("value")])]),
1360        };
1361
1362        let event1 = IcalEvent {
1363            properties: vec![prop1],
1364            alarms: vec![],
1365        };
1366
1367        let event2 = IcalEvent {
1368            properties: vec![prop2],
1369            alarms: vec![],
1370        };
1371
1372        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1373
1374        assert_eq!(keys.len(), 1);
1375        assert!(keys.contains(&String::from("prop1")));
1376    }
1377
1378    #[test]
1379    fn cmp_different_param_values() {
1380        let prop1 = Property {
1381            name: String::from("prop1"),
1382            value: None,
1383            params: Some(vec![(String::from("key"), vec![String::from("value")])]),
1384        };
1385
1386        let prop2 = Property {
1387            name: String::from("prop1"),
1388            value: None,
1389            params: Some(vec![(String::from("key"), vec![String::from("value2")])]),
1390        };
1391
1392        let event1 = IcalEvent {
1393            properties: vec![prop1],
1394            alarms: vec![],
1395        };
1396
1397        let event2 = IcalEvent {
1398            properties: vec![prop2],
1399            alarms: vec![],
1400        };
1401
1402        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1403
1404        assert_eq!(keys.len(), 1);
1405        assert!(keys.contains(&String::from("prop1")));
1406    }
1407
1408    #[test]
1409    fn cmp_added_param() {
1410        let prop1 = Property {
1411            name: String::from("prop1"),
1412            value: None,
1413            params: None,
1414        };
1415
1416        let prop2 = Property {
1417            name: String::from("prop1"),
1418            value: None,
1419            params: Some(vec![(String::from("key"), vec![String::from("value2")])]),
1420        };
1421
1422        let event1 = IcalEvent {
1423            properties: vec![prop1],
1424            alarms: vec![],
1425        };
1426
1427        let event2 = IcalEvent {
1428            properties: vec![prop2],
1429            alarms: vec![],
1430        };
1431
1432        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1433
1434        assert_eq!(keys.len(), 1);
1435        assert!(keys.contains(&String::from("prop1")));
1436    }
1437
1438    #[test]
1439    fn cmp_removed_param() {
1440        let prop1 = Property {
1441            name: String::from("prop1"),
1442            value: None,
1443            params: Some(vec![(String::from("key"), vec![String::from("value2")])]),
1444        };
1445
1446        let prop2 = Property {
1447            name: String::from("prop1"),
1448            value: None,
1449            params: None,
1450        };
1451
1452        let event1 = IcalEvent {
1453            properties: vec![prop1],
1454            alarms: vec![],
1455        };
1456
1457        let event2 = IcalEvent {
1458            properties: vec![prop2],
1459            alarms: vec![],
1460        };
1461
1462        let keys = changed_properties(&event1, &event2).expect("Keys should be Some");
1463
1464        assert_eq!(keys.len(), 1);
1465        assert!(keys.contains(&String::from("prop1")));
1466    }
1467}