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