1pub 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 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 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 changed_props.push(prop1.name.clone());
120 }
121 }
122 }
123
124 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#[derive(Debug, Clone)]
140pub struct EventData {
141 pub uid: String,
142 pub ical_data: IcalEvent,
143}
144
145#[derive(Debug, Clone)]
162pub struct PropertyChange {
163 pub key: String,
164 pub from: Option<Property>,
165 pub to: Option<Property>,
166}
167
168#[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#[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>;
321pub 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 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
435pub 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
445pub 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 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
541pub(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
568pub(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
589pub(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
623async 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 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 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 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 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 google_event.color_id = Some(String::from("11"));
887 }
888 google_event.summary = oringinal_event.summary;
889 }
890
891 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
996pub 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 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 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 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 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 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 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 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}