1use std::{
18 collections::{HashMap, HashSet},
19 fs,
20 path::PathBuf,
21};
22
23use chrono::{DateTime, NaiveDate, Utc};
24use chrono_tz::Europe::Berlin;
25use futures::{stream, StreamExt};
26use google_calendar3::api::{Event, EventDateTime};
27use ical::parser::{ical::component::IcalEvent, Component};
28use once_cell::sync::Lazy;
29use regex::{Captures, Regex};
30use reqwest::Url;
31
32use crate::{
33 apple::{
34 escape_text, format_utc, is_exam, location_hint, object_name, parse_stored_event,
35 push_line, push_property, AppleCalendar, PRODID,
36 },
37 convert_to_digits, google_hub, read_backup, GoogleCalendarHub,
38};
39
40const BERLIN: &str = "Europe/Berlin";
41
42const BERLIN_TIMEZONE: &str = "BEGIN:VTIMEZONE\r\n\
45 TZID:Europe/Berlin\r\n\
46 BEGIN:DAYLIGHT\r\n\
47 TZOFFSETFROM:+0100\r\n\
48 TZOFFSETTO:+0200\r\n\
49 TZNAME:CEST\r\n\
50 DTSTART:19700329T020000\r\n\
51 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\n\
52 END:DAYLIGHT\r\n\
53 BEGIN:STANDARD\r\n\
54 TZOFFSETFROM:+0200\r\n\
55 TZOFFSETTO:+0100\r\n\
56 TZNAME:CET\r\n\
57 DTSTART:19701025T030000\r\n\
58 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\n\
59 END:STANDARD\r\n\
60 END:VTIMEZONE\r\n";
61
62static MARKER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"uid:([^\s<]+)").unwrap());
65static MARKER_TAG_REGEX: Lazy<Regex> =
66 Lazy::new(|| Regex::new(r"(?is)<small>\s*uid:[^<]*</small>").unwrap());
67static RULE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)<hr[^>]*>").unwrap());
68static ANCHOR_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?is)<a\s([^>]*)>(.*?)</a>").unwrap());
69static EXTRA_ATTRIBUTE_REGEX: Lazy<Regex> =
71 Lazy::new(|| Regex::new(r#"\s+(?:target|rel|class|style|data-[\w-]+)="[^"]*""#).unwrap());
72static TAG_REGEX: Lazy<Regex> =
73 Lazy::new(|| Regex::new(r"<(/?)([a-zA-Z][a-zA-Z0-9]*)([^>]*)>").unwrap());
74static HREF_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)href="([^"]*)""#).unwrap());
75static ENTITY_REGEX: Lazy<Regex> =
76 Lazy::new(|| Regex::new(r"&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);").unwrap());
77static LOST_BREAK_BEFORE_CONTACT_REGEX: Lazy<Regex> =
80 Lazy::new(|| Regex::new(r"n(Ansprechpartner)").unwrap());
81static LOST_BREAK_AFTER_PUNCTUATION_REGEX: Lazy<Regex> =
82 Lazy::new(|| Regex::new(r"(?m)(^|[,;:]\s?)n(\p{Lu})").unwrap());
83static LOST_BREAK_IN_WORD_REGEX: Lazy<Regex> =
85 Lazy::new(|| Regex::new(r"(\p{Ll})n(\p{Lu})").unwrap());
86static UNTOUCHED_DESCRIPTION_REGEX: Lazy<Regex> = Lazy::new(|| {
88 Regex::new(
89 r#"(?s)^.*?(?:<a href="https://nav\.tum\.de/search\?q=.*?">Wo ist das\?</a><br>|Online<br>|<a href="https://www\.moodle\.tum\.de/my/">Online auf Moodle</a><br>)<br><hr><small>uid:[^<]+</small>$"#,
90 )
91 .unwrap()
92});
93
94#[derive(Debug, Clone)]
96pub struct MigrationOptions {
97 pub apply: bool,
99 pub overwrite: bool,
101 pub dump_directory: Option<PathBuf>,
103 pub backup_name: String,
106}
107
108impl Default for MigrationOptions {
109 fn default() -> Self {
110 MigrationOptions {
111 apply: false,
112 overwrite: false,
113 dump_directory: None,
114 backup_name: String::from("TUM Calendar"),
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq)]
125enum Time {
126 Utc(DateTime<Utc>),
127 Local(DateTime<Utc>),
129 Date(NaiveDate),
130}
131
132impl Time {
133 fn from_google(value: &EventDateTime, local: bool) -> Option<Self> {
134 if let Some(date) = value.date {
135 return Some(Time::Date(date));
136 }
137
138 let date_time = value.date_time?;
139 Some(if local {
140 Time::Local(date_time)
141 } else {
142 Time::Utc(date_time)
143 })
144 }
145
146 fn push_to(&self, calendar_object: &mut String, name: &str) {
147 match self {
148 Time::Utc(date_time) => {
149 push_property(calendar_object, name, &[], &format_utc(*date_time))
150 }
151 Time::Local(date_time) => push_property(
152 calendar_object,
153 name,
154 &[(String::from("TZID"), vec![String::from(BERLIN)])],
155 &date_time
156 .with_timezone(&Berlin)
157 .format("%Y%m%dT%H%M%S")
158 .to_string(),
159 ),
160 Time::Date(date) => push_property(
161 calendar_object,
162 name,
163 &[(String::from("VALUE"), vec![String::from("DATE")])],
164 &date.format("%Y%m%d").to_string(),
165 ),
166 }
167 }
168}
169
170#[derive(Debug)]
172struct VEvent {
173 uid: String,
174 recurrence_id: Option<Time>,
175 start: Time,
176 end: Time,
177 recurrence: Vec<String>,
179 exdates: Vec<Time>,
180 summary: Option<String>,
181 location: Option<String>,
182 description: Option<String>,
183 url: Option<String>,
184 status: Option<String>,
185 categories: Option<String>,
186 color: Option<&'static str>,
187 transparent: bool,
188 created: Option<DateTime<Utc>>,
189 last_modified: Option<DateTime<Utc>>,
190 alarms: Vec<i32>,
192}
193
194impl VEvent {
195 fn from_google(uid: &str, event: &Event, local: bool) -> Option<Self> {
198 let start = Time::from_google(event.start.as_ref()?, local)?;
199
200 Some(VEvent {
201 uid: uid.to_string(),
202 recurrence_id: None,
203 start,
204 end: event
205 .end
206 .as_ref()
207 .and_then(|end| Time::from_google(end, local))
208 .unwrap_or(start),
209 recurrence: event.recurrence.clone().unwrap_or_default(),
210 exdates: Vec::new(),
211 summary: event
212 .summary
213 .clone()
214 .filter(|summary| !summary.trim().is_empty()),
215 location: event
216 .location
217 .clone()
218 .filter(|location| !location.trim().is_empty()),
219 description: None,
220 url: event.source.as_ref().and_then(|source| source.url.clone()),
221 status: event.status.as_deref().map(str::to_uppercase),
222 categories: None,
223 color: event.color_id.as_deref().and_then(color_name),
224 transparent: event.transparency.as_deref() == Some("transparent"),
225 created: event.created,
226 last_modified: event.updated,
227 alarms: event
228 .reminders
229 .as_ref()
230 .and_then(|reminders| reminders.overrides.as_ref())
231 .map(|overrides| {
232 overrides
233 .iter()
234 .filter_map(|reminder| reminder.minutes)
235 .collect()
236 })
237 .unwrap_or_default(),
238 })
239 }
240
241 fn uses_local_time(&self) -> bool {
242 [self.start, self.end]
243 .into_iter()
244 .chain(self.recurrence_id)
245 .chain(self.exdates.iter().copied())
246 .any(|time| matches!(time, Time::Local(_)))
247 || self.recurrence.iter().any(|line| line.contains("TZID="))
248 }
249
250 fn push_to(&self, calendar_object: &mut String) {
251 calendar_object.push_str("BEGIN:VEVENT\r\n");
252 push_property(calendar_object, "UID", &[], &escape_text(&self.uid));
253 push_property(calendar_object, "DTSTAMP", &[], &format_utc(Utc::now()));
254 if let Some(recurrence_id) = self.recurrence_id {
255 recurrence_id.push_to(calendar_object, "RECURRENCE-ID");
256 }
257 self.start.push_to(calendar_object, "DTSTART");
258 self.end.push_to(calendar_object, "DTEND");
259 for line in &self.recurrence {
260 push_line(calendar_object, line);
261 }
262 for exdate in &self.exdates {
263 exdate.push_to(calendar_object, "EXDATE");
264 }
265
266 let text_properties = [
267 ("SUMMARY", &self.summary),
268 ("LOCATION", &self.location),
269 ("DESCRIPTION", &self.description),
270 ("URL", &self.url),
271 ("STATUS", &self.status),
272 ("CATEGORIES", &self.categories),
273 ];
274 for (name, value) in text_properties {
275 if let Some(value) = value {
276 push_property(calendar_object, name, &[], &escape_text(value));
277 }
278 }
279
280 if let Some(color) = self.color {
281 push_property(calendar_object, "COLOR", &[], color);
283 }
284 if self.transparent {
285 push_property(calendar_object, "TRANSP", &[], "TRANSPARENT");
286 }
287 if let Some(created) = self.created {
288 push_property(calendar_object, "CREATED", &[], &format_utc(created));
289 }
290 push_property(calendar_object, "SEQUENCE", &[], "0");
291 push_property(
292 calendar_object,
293 "LAST-MODIFIED",
294 &[],
295 &format_utc(self.last_modified.unwrap_or_else(Utc::now)),
296 );
297
298 for minutes in &self.alarms {
299 calendar_object.push_str("BEGIN:VALARM\r\n");
300 push_property(calendar_object, "ACTION", &[], "DISPLAY");
301 push_property(
302 calendar_object,
303 "DESCRIPTION",
304 &[],
305 &escape_text(self.summary.as_deref().unwrap_or("Erinnerung")),
306 );
307 push_property(calendar_object, "TRIGGER", &[], &format!("-PT{minutes}M"));
308 calendar_object.push_str("END:VALARM\r\n");
309 }
310
311 calendar_object.push_str("END:VEVENT\r\n");
312 }
313}
314
315fn calendar_object(events: &[VEvent]) -> String {
316 let mut calendar_object = String::new();
317
318 calendar_object.push_str("BEGIN:VCALENDAR\r\n");
319 push_property(&mut calendar_object, "VERSION", &[], "2.0");
320 push_property(&mut calendar_object, "PRODID", &[], PRODID);
321 push_property(&mut calendar_object, "CALSCALE", &[], "GREGORIAN");
322 if events.iter().any(VEvent::uses_local_time) {
323 calendar_object.push_str(BERLIN_TIMEZONE);
324 }
325 for event in events {
326 event.push_to(&mut calendar_object);
327 }
328 calendar_object.push_str("END:VCALENDAR\r\n");
329
330 calendar_object
331}
332
333fn color_name(color_id: &str) -> Option<&'static str> {
335 Some(match color_id {
336 "1" => "mediumslateblue", "2" => "mediumseagreen", "3" => "darkorchid", "4" => "salmon", "5" => "gold", "6" => "orangered", "7" => "deepskyblue", "8" => "dimgray", "9" => "royalblue", "10" => "seagreen", "11" => "tomato", _ => return None,
348 })
349}
350
351fn decode_entities(text: &str) -> String {
356 ENTITY_REGEX
357 .replace_all(text, |captures: &Captures| {
358 let entity = &captures[1];
359 let decoded = match entity {
360 "nbsp" => Some(' '),
361 "amp" => Some('&'),
362 "lt" => Some('<'),
363 "gt" => Some('>'),
364 "quot" => Some('"'),
365 "apos" => Some('\''),
366 _ => entity
367 .strip_prefix('#')
368 .and_then(|number| match number.strip_prefix(['x', 'X']) {
369 Some(hex) => u32::from_str_radix(hex, 16).ok(),
370 None => number.parse().ok(),
371 })
372 .and_then(char::from_u32),
373 };
374 decoded
375 .map(String::from)
376 .unwrap_or_else(|| captures[0].to_string())
377 })
378 .replace('\u{a0}', " ")
379}
380
381fn tidy_lines(text: &str) -> String {
383 let mut lines: Vec<&str> = Vec::new();
384 for line in text.lines().map(str::trim) {
385 if line.is_empty() && lines.last().is_none_or(|last| last.is_empty()) {
386 continue;
387 }
388 lines.push(line);
389 }
390 while lines.last().is_some_and(|line| line.is_empty()) {
391 lines.pop();
392 }
393
394 lines.join("\n")
395}
396
397fn soft_line_break(text: &mut String) {
399 let current_line = text.rsplit('\n').next().unwrap_or_default().trim();
400 if !current_line.is_empty() && current_line != "β’" {
401 text.push('\n');
402 }
403}
404
405fn html_to_text(html: &str) -> String {
408 let html = html.replace("\r\n", "\n");
409 let mut text = String::new();
410 let mut links: Vec<(usize, Option<String>)> = Vec::new();
412 let mut position = 0;
413
414 for tag in TAG_REGEX.captures_iter(&html) {
415 let whole = tag.get(0).expect("the whole match always exists");
416 text.push_str(&decode_entities(&html[position..whole.start()]));
417 position = whole.end();
418
419 let closing = !tag[1].is_empty();
420 match tag[2].to_ascii_lowercase().as_str() {
421 "br" | "hr" => text.push('\n'),
422 "li" => {
423 soft_line_break(&mut text);
424 if !closing {
425 text.push_str("β’ ");
426 }
427 }
428 "p" | "div" | "ul" | "ol" | "table" | "tr" | "blockquote" | "h1" | "h2" | "h3"
429 | "h4" | "h5" | "h6" => soft_line_break(&mut text),
430 "a" if !closing => links.push((
431 text.len(),
432 HREF_REGEX
433 .captures(&tag[3])
434 .map(|href| decode_entities(&href[1])),
435 )),
436 "a" => {
437 if let Some((start, Some(href))) = links.pop() {
438 let label = text[start..].trim();
439 if !label.is_empty() && !href.is_empty() && label != href {
440 text.push_str(&format!(" ({href})"));
441 }
442 }
443 }
444 _ => (),
445 }
446 }
447 text.push_str(&decode_entities(&html[position..]));
448
449 tidy_lines(&text)
450}
451
452fn room_from_link(attributes: &str) -> Option<String> {
455 let attributes = EXTRA_ATTRIBUTE_REGEX.replace_all(attributes, "");
456 let href = attributes
457 .trim()
458 .strip_prefix("href=\"")?
459 .strip_suffix('"')?;
460
461 let link = Url::parse(&decode_entities(href)).ok()?;
462 let room = link.query_pairs().find(|(key, _)| key == "q")?.1;
463 let room = convert_to_digits(room.trim());
464
465 (!room.is_empty()).then_some(room)
466}
467
468fn synced_uid(event: &Event) -> Option<String> {
474 let marker = MARKER_REGEX.captures(event.description.as_deref()?)?;
475
476 Some(convert_to_digits(&marker[1]).replace('|', "@tum.de"))
477}
478
479fn convert_synced_location(location: &str) -> String {
482 let location = convert_to_digits(location)
483 .replace("\r\n", ", ")
484 .replace(['\n', '\r'], ", ")
485 .replace(r"\n", ", ");
486
487 let location = if location.to_lowercase().starts_with("online") {
490 LOST_BREAK_IN_WORD_REGEX
491 .replace_all(&location, "${1}, ${2}")
492 .into_owned()
493 } else {
494 location
495 };
496
497 location.trim().to_string()
498}
499
500fn convert_synced_description(
504 description: &str,
505 location: &str,
506) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
507 let html = MARKER_TAG_REGEX.replace_all(description, "");
508 let html = MARKER_REGEX.replace_all(&html, "");
509 let html = RULE_REGEX.replace_all(&html, "");
510
511 let mut linked_room = None;
512 let mut on_moodle = false;
513 let html = ANCHOR_REGEX.replace_all(&html, |captures: &Captures| {
514 match decode_entities(&captures[2]).trim() {
515 "Wo ist das?" => {
516 linked_room = room_from_link(&captures[1]);
517 String::new()
518 }
519 "Online auf Moodle" => {
520 on_moodle = true;
521 String::new()
522 }
523 _ => captures[0].to_string(),
524 }
525 });
526
527 let online = on_moodle || location.to_lowercase().contains("online");
528 let mut lines: Vec<String> = html_to_text(&html).lines().map(String::from).collect();
529
530 let unlinked = match lines.iter().rposition(|line| line == "Wo ist das?") {
532 Some(index) => {
533 lines.remove(index);
534 true
535 }
536 None => false,
537 };
538 if online {
539 if let Some(index) = lines
540 .iter()
541 .rposition(|line| line == "Online" || line == "Online auf Moodle")
542 {
543 lines.remove(index);
544 }
545 }
546
547 let mut text = lines.join("\n");
548 if text.contains("nAnsprechpartner") {
549 text = LOST_BREAK_BEFORE_CONTACT_REGEX
550 .replace_all(&text, "\n${1}")
551 .into_owned();
552 text = LOST_BREAK_AFTER_PUNCTUATION_REGEX
553 .replace_all(&text, "${1}\n${2}")
554 .into_owned();
555 }
556 let text = tidy_lines(&text);
557
558 let hint = match linked_room {
559 Some(room) if room.ends_with(',') && location.starts_with(&room) => {
561 Some(location_hint(location)?)
562 }
563 Some(room) => Some(location_hint(&room)?),
564 None if online || unlinked => Some(location_hint(location)?),
565 None => None,
566 };
567
568 let description = [Some(text), hint]
569 .into_iter()
570 .flatten()
571 .filter(|part| !part.is_empty())
572 .collect::<Vec<String>>()
573 .join("\n");
574
575 Ok((!description.is_empty()).then_some(description))
576}
577
578fn synced_event(
581 uid: &str,
582 event: &Event,
583) -> Result<Option<VEvent>, Box<dyn std::error::Error + Send + Sync>> {
584 let Some(mut vevent) = VEvent::from_google(uid, event, false) else {
585 return Ok(None);
586 };
587
588 let location = event
589 .location
590 .as_deref()
591 .map(convert_synced_location)
592 .unwrap_or_default();
593 vevent.description = match &event.description {
594 Some(description) => convert_synced_description(description, &location)?,
595 None => None,
596 };
597 vevent.location = (!location.is_empty()).then_some(location);
598
599 if vevent
600 .summary
601 .as_deref()
602 .is_some_and(|summary| summary.contains("PrΓΌfung"))
603 {
604 vevent.categories = Some(String::from("PrΓΌfung"));
606 vevent.color = Some("tomato");
607 }
608
609 Ok(Some(vevent))
610}
611
612fn hand_made_changes(event: &Event) -> Vec<&'static str> {
614 let mut changes = Vec::new();
615
616 if event
617 .description
618 .as_deref()
619 .is_some_and(|description| !UNTOUCHED_DESCRIPTION_REGEX.is_match(description))
620 {
621 changes.push("description");
622 }
623 if event.location.as_deref().is_some_and(|location| {
625 location.chars().any(|character| character.is_ascii_digit())
626 && !location.contains("Kein Ort")
627 }) {
628 changes.push("location");
629 }
630 if event
631 .reminders
632 .as_ref()
633 .and_then(|reminders| reminders.overrides.as_ref())
634 .is_some_and(|overrides| !overrides.is_empty())
635 {
636 changes.push("reminders");
637 }
638 let is_exam = event
640 .summary
641 .as_deref()
642 .is_some_and(|summary| summary.contains("PrΓΌfung"));
643 if event
644 .color_id
645 .as_deref()
646 .is_some_and(|color| color != "11" || !is_exam)
647 {
648 changes.push("color");
649 }
650
651 changes
652}
653
654#[derive(Debug, Default)]
657struct Series {
658 event: Option<Event>,
659 exceptions: Vec<Event>,
660}
661
662fn series_events(series: &Series) -> Result<Vec<VEvent>, String> {
665 let event = series
666 .event
667 .as_ref()
668 .ok_or("its recurring event is missing")?;
669 let uid = event
670 .i_cal_uid
671 .clone()
672 .or_else(|| event.id.clone())
673 .ok_or("it has no uid")?;
674 let start = event.start.as_ref().ok_or("it has no start")?;
675
676 let repeats_at_a_time = event.recurrence.is_some() && start.date.is_none();
679 let local = repeats_at_a_time && start.time_zone.as_deref() == Some(BERLIN);
680 if repeats_at_a_time && !local {
681 return Err(format!(
682 "it repeats in the time zone {:?}, which isn't supported",
683 start.time_zone
684 ));
685 }
686
687 let mut main = VEvent::from_google(&uid, event, local).ok_or("it has no start")?;
688 main.description = event
689 .description
690 .as_deref()
691 .map(html_to_text)
692 .filter(|description| !description.is_empty());
693
694 let mut occurrences = Vec::new();
695 for exception in &series.exceptions {
696 let original_start = exception
697 .original_start_time
698 .as_ref()
699 .and_then(|original_start| Time::from_google(original_start, local))
700 .ok_or("an occurrence has no original start")?;
701
702 if exception.status.as_deref() == Some("cancelled") {
703 main.exdates.push(original_start);
704 continue;
705 }
706
707 let mut occurrence =
708 VEvent::from_google(&uid, exception, local).ok_or("an occurrence has no start")?;
709 occurrence.description = exception
710 .description
711 .as_deref()
712 .map(html_to_text)
713 .filter(|description| !description.is_empty());
714 occurrence.recurrence.clear();
716 occurrence.recurrence_id = Some(original_start);
717 occurrences.push(occurrence);
718 }
719
720 occurrences.insert(0, main);
721 Ok(occurrences)
722}
723
724async fn fetch_events(
729 hub: &GoogleCalendarHub,
730 calendar_id: &str,
731) -> Result<Vec<Event>, Box<dyn std::error::Error + Send + Sync>> {
732 let mut events = Vec::new();
733 let mut page_token: Option<String> = None;
734
735 loop {
736 let mut call = hub
738 .events()
739 .list(calendar_id)
740 .single_events(false)
741 .show_deleted(false)
742 .max_results(2500);
743 if let Some(page_token) = &page_token {
744 call = call.page_token(page_token);
745 }
746
747 let (_, page) = call.doit().await?;
748 events.extend(page.items.unwrap_or_default());
749
750 page_token = page.next_page_token;
751 if page_token.is_none() {
752 return Ok(events);
753 }
754 }
755}
756
757fn label(event: &Event) -> String {
759 let date = event
760 .start
761 .as_ref()
762 .or(event.original_start_time.as_ref())
763 .and_then(|start| {
764 start.date.or_else(|| {
765 start
766 .date_time
767 .map(|date_time| date_time.with_timezone(&Berlin).date_naive())
768 })
769 })
770 .map(|date| date.to_string())
771 .unwrap_or_else(|| String::from("????-??-??"));
772
773 format!(
774 "{date} {}",
775 event.summary.as_deref().unwrap_or("(no title)")
776 )
777}
778
779fn feed_label(event: &IcalEvent) -> String {
780 let property = |name: &str| {
781 event
782 .get_property(name)
783 .and_then(|property| property.value.clone())
784 .unwrap_or_default()
785 };
786 let start = property("DTSTART");
787 let date = start
788 .get(0..8)
789 .and_then(|date| NaiveDate::parse_from_str(date, "%Y%m%d").ok())
790 .map(|date| date.to_string())
791 .unwrap_or(start);
792
793 format!("{date} {}", property("SUMMARY").replace('\\', ""))
794}
795
796struct Planned {
798 uid: String,
799 name: String,
800 label: String,
801 calendar_object: String,
802}
803
804async fn write(
807 apple_calendar: &AppleCalendar,
808 planned: &Planned,
809 overwrite: bool,
810) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
811 let url = apple_calendar.object_url(&planned.uid)?;
812
813 if overwrite {
814 apple_calendar
815 .put_object(&url, &planned.calendar_object, None)
816 .await
817 .map(|()| true)
818 } else {
819 apple_calendar
820 .create_object(&url, &planned.calendar_object)
821 .await
822 }
823}
824
825pub async fn migrate_google_to_apple(
833 google_calendar_id: &str,
834 apple_calendar: Option<&AppleCalendar>,
835 options: &MigrationOptions,
836) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
837 let hub = google_hub().await?;
838 let events = fetch_events(&hub, google_calendar_id).await?;
839 let event_count = events.len();
840
841 let mut synced: HashMap<String, Event> = HashMap::new();
843 let mut duplicates = 0;
844 let mut series: HashMap<String, Series> = HashMap::new();
845 for event in events {
846 if let Some(recurring_event_id) = event.recurring_event_id.clone() {
847 series
848 .entry(recurring_event_id)
849 .or_default()
850 .exceptions
851 .push(event);
852 } else if let Some(uid) = synced_uid(&event) {
853 if let Some(existing) = synced.get(&uid) {
855 duplicates += 1;
856 if existing.updated >= event.updated {
857 continue;
858 }
859 }
860 synced.insert(uid, event);
861 } else if let Some(id) = event.id.clone() {
862 series.entry(id).or_default().event = Some(event);
863 }
864 }
865
866 let feed = read_backup(&options.backup_name).ok();
867 let mut planned = Vec::new();
868 let mut problems = Vec::new();
869 let mut changes = Vec::new();
870
871 for (uid, event) in &synced {
872 let label = label(event);
873 let changed = hand_made_changes(event);
874 if !changed.is_empty() {
875 changes.push(format!("{label}: {}", changed.join(", ")));
876 }
877
878 match synced_event(uid, event) {
879 Ok(Some(vevent)) => planned.push(Planned {
880 uid: uid.clone(),
881 name: object_name(uid),
882 label,
883 calendar_object: calendar_object(&[vevent]),
884 }),
885 Ok(None) => problems.push(format!("{label}: it has no start")),
886 Err(error) => problems.push(format!("{label}: {error}")),
887 }
888 }
889
890 let mut recurring = 0;
891 let mut occurrences = 0;
892 for series in series.values() {
893 let label = series
894 .event
895 .as_ref()
896 .or(series.exceptions.first())
897 .map(label)
898 .unwrap_or_default();
899
900 match series_events(series) {
901 Ok(vevents) => {
902 if !vevents[0].recurrence.is_empty() {
903 recurring += 1;
904 occurrences += series.exceptions.len();
905 }
906 planned.push(Planned {
907 uid: vevents[0].uid.clone(),
908 name: object_name(&vevents[0].uid),
909 label,
910 calendar_object: calendar_object(&vevents),
911 });
912 }
913 Err(reason) => problems.push(format!("{label}: skipped, {reason}")),
914 }
915 }
916
917 planned.sort_by(|a, b| a.label.cmp(&b.label));
918 changes.sort();
919 problems.sort();
920
921 let mut names = HashSet::new();
922 if let Some(clash) = planned.iter().find(|planned| !names.insert(&planned.name)) {
923 return Err(format!("Two events would be stored as {}", clash.name).into());
924 }
925
926 println!("Google calendar: {event_count} events");
928 println!(
929 " {} created by the Google sync{}",
930 synced.len(),
931 match &feed {
932 Some(feed) => format!(
933 ", {} of them still in the ICS calendar",
934 synced.keys().filter(|uid| feed.contains_key(*uid)).count()
935 ),
936 None => String::new(),
937 }
938 );
939 if duplicates > 0 {
940 println!(" {duplicates} of them created twice, only the newer copy is migrated");
941 }
942 println!(
943 " {} added by hand, {recurring} of them recurring with {occurrences} moved or cancelled occurrences",
944 series.len()
945 );
946
947 if let Some(feed) = &feed {
948 let mut deleted: Vec<String> = feed
949 .iter()
950 .filter(|(uid, _)| !synced.contains_key(*uid))
951 .map(|(_, event)| {
952 let never_synced = event
953 .get_property("DESCRIPTION")
954 .and_then(|property| property.value.as_deref())
955 .is_some_and(|description| description.contains("VideoΓΌbertragung aus"));
956 if never_synced {
957 format!("{} (video transmission, never synced)", feed_label(event))
958 } else {
959 feed_label(event)
960 }
961 })
962 .collect();
963 deleted.sort();
964
965 println!(
966 "\nIn the ICS calendar, but not in Google - these won't be created ({}):",
967 deleted.len()
968 );
969 for label in &deleted {
970 println!(" {label}");
971 }
972 }
973
974 println!(
975 "\nChanges made in Google Calendar, which are carried over ({}):",
976 changes.len()
977 );
978 for change in &changes {
979 println!(" {change}");
980 }
981
982 if !problems.is_empty() {
983 println!("\nProblems ({}):", problems.len());
984 for problem in &problems {
985 println!(" {problem}");
986 }
987 }
988
989 if let Some(directory) = &options.dump_directory {
990 fs::create_dir_all(directory)?;
991 for planned in &planned {
992 fs::write(directory.join(&planned.name), &planned.calendar_object)?;
993 }
994 println!(
995 "\nSaved {} calendar objects to {}",
996 planned.len(),
997 directory.display()
998 );
999 }
1000
1001 let Some(apple_calendar) = apple_calendar else {
1002 println!(
1003 "\n{} calendar objects are ready - configure the Apple Calendar to write them.",
1004 planned.len()
1005 );
1006 return Ok(());
1007 };
1008
1009 let existing = apple_calendar.object_names().await?;
1010 let already_there = planned
1011 .iter()
1012 .filter(|planned| existing.contains(&planned.name))
1013 .count();
1014 let to_write: Vec<&Planned> = planned
1015 .iter()
1016 .filter(|planned| options.overwrite || !existing.contains(&planned.name))
1017 .collect();
1018
1019 println!(
1020 "\nApple Calendar: {} events in there already, {already_there} of them from this migration",
1021 existing.len()
1022 );
1023 println!(
1024 "{} calendar objects to {}, {} skipped as they exist already",
1025 to_write.len(),
1026 if options.overwrite {
1027 "write or replace"
1028 } else {
1029 "create"
1030 },
1031 planned.len() - to_write.len()
1032 );
1033
1034 if !options.apply {
1035 println!("\nNothing was written - run again with --apply to migrate.");
1036 return Ok(());
1037 }
1038
1039 let Some((first, rest)) = to_write.split_first() else {
1041 println!("\nNothing to write.");
1042 return Ok(());
1043 };
1044 write(apple_calendar, first, options.overwrite)
1045 .await
1046 .map_err(|error| format!("{}: {error}", first.label))?;
1047
1048 let results: Vec<_> = stream::iter(rest.iter().copied())
1049 .map(|planned| async move {
1050 (
1051 planned.label.as_str(),
1052 write(apple_calendar, planned, options.overwrite).await,
1053 )
1054 })
1055 .buffer_unordered(4)
1056 .collect()
1057 .await;
1058
1059 let mut written = 1;
1060 let mut skipped = 0;
1061 let mut failed = Vec::new();
1062 for (label, result) in results {
1063 match result {
1064 Ok(true) => written += 1,
1065 Ok(false) => skipped += 1,
1066 Err(error) => failed.push(format!("{label}: {error}")),
1067 }
1068 }
1069 failed.sort();
1070
1071 println!(
1072 "\nWrote {written} calendar objects, {skipped} skipped as they appeared in the meantime, {} failed",
1073 failed.len()
1074 );
1075 for failure in &failed {
1076 println!(" {failure}");
1077 }
1078
1079 if failed.is_empty() {
1080 Ok(())
1081 } else {
1082 Err(format!(
1083 "{} events couldn't be written - run the migration again to retry them",
1084 failed.len()
1085 )
1086 .into())
1087 }
1088}
1089
1090struct Exam {
1092 name: String,
1093 url: Url,
1094 etag: Option<String>,
1095 label: String,
1096}
1097
1098pub async fn move_exams_to_exam_calendar(
1105 calendar: &AppleCalendar,
1106 apply: bool,
1107) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1108 let calendar_urls = calendar.calendar_urls(false);
1109 let [calendar_url, exam_calendar_url] = calendar_urls.as_slice() else {
1110 return Err("No exam calendar configured".into());
1111 };
1112
1113 let names = calendar.object_names().await?;
1114 let fetched: Vec<_> = stream::iter(names)
1115 .map(|name| async move {
1116 let url = calendar_url.join(&name)?;
1117 let stored = calendar.fetch_object(&url).await?;
1118 Ok::<_, Box<dyn std::error::Error + Send + Sync>>((name, url, stored))
1119 })
1120 .buffer_unordered(4)
1121 .collect()
1122 .await;
1123
1124 let mut exams = Vec::new();
1125 let mut problems = Vec::new();
1126 let mut event_count = 0;
1127 for result in fetched {
1128 match result {
1129 Ok((name, url, Some((calendar_object, etag)))) => {
1130 event_count += 1;
1131 let Some(stored) = parse_stored_event(&calendar_object) else {
1132 problems.push(format!("{name}: couldn't be read"));
1133 continue;
1134 };
1135 if !is_exam(&stored.event) {
1136 continue;
1137 }
1138
1139 let property = |name: &str| {
1140 stored
1141 .event
1142 .get_property(name)
1143 .and_then(|property| property.value.clone())
1144 .unwrap_or_default()
1145 };
1146 let start = property("DTSTART");
1147 let date = start
1148 .get(0..8)
1149 .and_then(|date| NaiveDate::parse_from_str(date, "%Y%m%d").ok())
1150 .map(|date| date.to_string())
1151 .unwrap_or(start);
1152
1153 exams.push(Exam {
1154 label: format!("{date} {}", property("SUMMARY").replace('\\', "")),
1155 name,
1156 url,
1157 etag,
1158 });
1159 }
1160 Ok((_, _, None)) => (),
1162 Err(error) => problems.push(error.to_string()),
1163 }
1164 }
1165 exams.sort_by(|a, b| a.label.cmp(&b.label));
1166
1167 println!(
1168 "Main calendar: {event_count} events, {} of them exams",
1169 exams.len()
1170 );
1171 for exam in &exams {
1172 println!(" {}", exam.label);
1173 }
1174 if !problems.is_empty() {
1175 println!("\nProblems ({}):", problems.len());
1176 for problem in &problems {
1177 println!(" {problem}");
1178 }
1179 }
1180
1181 if !apply {
1182 println!("\nNothing was moved - run again with --apply to move the exams.");
1183 return Ok(());
1184 }
1185
1186 let results: Vec<_> = stream::iter(&exams)
1187 .map(|exam| async move {
1188 let result = async {
1189 calendar
1190 .move_object(
1191 &exam.url,
1192 &exam_calendar_url.join(&exam.name)?,
1193 exam.etag.as_deref(),
1194 )
1195 .await
1196 }
1197 .await;
1198 (exam.label.as_str(), result)
1199 })
1200 .buffer_unordered(4)
1201 .collect()
1202 .await;
1203
1204 let mut failed: Vec<String> = results
1205 .into_iter()
1206 .filter_map(|(label, result)| result.err().map(|error| format!("{label}: {error}")))
1207 .collect();
1208 failed.sort();
1209
1210 println!(
1211 "\nMoved {} exams, {} failed",
1212 exams.len() - failed.len(),
1213 failed.len()
1214 );
1215 for failure in &failed {
1216 println!(" {failure}");
1217 }
1218
1219 if failed.is_empty() {
1220 Ok(())
1221 } else {
1222 Err(format!(
1223 "{} exams couldn't be moved - run it again to retry them",
1224 failed.len()
1225 )
1226 .into())
1227 }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233 use google_calendar3::api::{EventReminder, EventReminders, EventSource};
1234
1235 fn at(value: &str) -> Option<EventDateTime> {
1236 Some(EventDateTime {
1237 date_time: Some(value.parse().expect("valid timestamp")),
1238 time_zone: Some(String::from(BERLIN)),
1239 date: None,
1240 })
1241 }
1242
1243 fn on(year: i32, month: u32, day: u32) -> Option<EventDateTime> {
1244 Some(EventDateTime {
1245 date: NaiveDate::from_ymd_opt(year, month, day),
1246 ..Default::default()
1247 })
1248 }
1249
1250 fn reminders(minutes: &[i32]) -> Option<EventReminders> {
1251 Some(EventReminders {
1252 use_default: Some(false),
1253 overrides: Some(
1254 minutes
1255 .iter()
1256 .map(|minutes| EventReminder {
1257 method: Some(String::from("popup")),
1258 minutes: Some(*minutes),
1259 })
1260 .collect(),
1261 ),
1262 })
1263 }
1264
1265 fn unfolded(calendar_object: &str) -> String {
1267 calendar_object.replace("\r\n ", "")
1268 }
1269
1270 const MARKER: &str = "<br><hr><small>uid:πππππππππ|XR</small>";
1271
1272 #[test]
1273 fn reads_the_uid_marker() {
1274 let event = Event {
1275 description: Some(format!("Vorlesung<br>Online<br>{MARKER}")),
1276 ..Default::default()
1277 };
1278 assert_eq!(synced_uid(&event).as_deref(), Some("891956005@tum.deXR"));
1279
1280 let event = Event {
1281 description: Some(String::from("<small>uid:πππππππππ|ππππππ</small>")),
1282 ..Default::default()
1283 };
1284 assert_eq!(
1285 synced_uid(&event).as_deref(),
1286 Some("891292590@tum.de606544")
1287 );
1288
1289 let event = Event {
1290 description: Some(String::from("Mittagessen")),
1291 ..Default::default()
1292 };
1293 assert_eq!(synced_uid(&event), None);
1294 }
1295
1296 #[test]
1297 fn turns_html_into_text() {
1298 let text = html_to_text(
1299 r#"<h4></h4><ul><li><h4><span><strong>Row:</strong> <span style="font-weight: normal;">A5</span></span></h4></li><li><span><strong>Seat: </strong>15</span> </li></ul><br> <br>Mehr auf <a href="https://example.com/info">der Info-Seite</a> & so"#,
1300 );
1301
1302 assert_eq!(
1303 text,
1304 "β’ Row: A5\nβ’ Seat: 15\n\nMehr auf der Info-Seite (https://example.com/info) & so"
1305 );
1306 }
1307
1308 #[test]
1309 fn keeps_plain_text() {
1310 assert_eq!(
1311 html_to_text("Zug um 11:43\nGleis 12 & 13"),
1312 "Zug um 11:43\nGleis 12 & 13"
1313 );
1314 }
1315
1316 #[test]
1317 fn keeps_a_note_before_the_link() {
1318 let room = "MW 0001, HΓΆrsaal (5510.EG.001)";
1319 let description = convert_synced_description(
1320 "<b>Sitzplatz</b>: H / 5\u{a0}<br>\u{a0}<br><a href=\"https://nav.tum.de/search?q=MW+ππππ,+HΓΆrsaal+(ππππ.EG.πππ)\" target=\"_blank\">Wo ist das?</a><br><hr /><small>uid:πππππππππ|XR</small>",
1321 room,
1322 )
1323 .unwrap();
1324
1325 assert_eq!(
1326 description,
1327 Some(format!(
1328 "Sitzplatz: H / 5\n{}",
1329 location_hint(room).unwrap()
1330 ))
1331 );
1332 }
1333
1334 #[test]
1335 fn keeps_a_note_after_the_link() {
1336 let room = r#"003, HΓΆrsaal 2, "Interims II" (5416.01.003)"#;
1338 let description = convert_synced_description(
1339 &format!("<a href=\"https://nav.tum.de/search?q={room}\">Wo ist das?</a><br>\u{a0}<br><b>Seat 96</b><br><hr /><small>uid:πππππππππ|XR</small>"),
1340 room,
1341 )
1342 .unwrap();
1343
1344 assert_eq!(
1345 description,
1346 Some(format!("Seat 96\n{}", location_hint(room).unwrap()))
1347 );
1348 }
1349
1350 #[test]
1351 fn repairs_line_breaks_older_versions_lost() {
1352 let description = convert_synced_description(
1353 "nAnsprechpartner: nJank Georg,nThoma Tobias<br>Online<br>(verschoben)\u{a0}<br><hr /><small>uid:πππππππππ|ππππππ</small>",
1354 "Online",
1355 )
1356 .unwrap();
1357
1358 assert_eq!(
1359 description.as_deref(),
1360 Some("Ansprechpartner:\nJank Georg,\nThoma Tobias\n(verschoben)\nOnline")
1361 );
1362 }
1363
1364 #[test]
1365 fn keeps_notes_in_the_location() {
1366 assert_eq!(
1367 convert_synced_location(
1368 "MW ππππ Empore Rudolf-Diesel-HΓΆrsaal (ππππ.ππ.πππ) Row: B\nSeat: 29"
1369 ),
1370 "MW 2001 Empore Rudolf-Diesel-HΓΆrsaal (5510.02.001) Row: B, Seat: 29"
1371 );
1372 assert_eq!(
1373 convert_synced_location(r"HΓΆrsaal 1 Row: B\nSeat: 29"),
1374 "HΓΆrsaal 1 Row: B, Seat: 29"
1375 );
1376 }
1377
1378 #[test]
1379 fn repairs_line_breaks_in_online_locations() {
1380 assert_eq!(
1381 convert_synced_location("Online: VideokonferenznZoom etc."),
1382 "Online: Videokonferenz, Zoom etc."
1383 );
1384 assert_eq!(convert_synced_location("SeminarnRaum"), "SeminarnRaum");
1386 }
1387
1388 #[test]
1389 fn completes_links_cut_off_at_a_quote() {
1390 let location = r#"0.001A, HΓΆrsaal 1A, "Zelt" (5539.EG.001A)"#;
1391 let description = convert_synced_description(
1392 "Seat 15<br><a href=\"https://nav.tum.de/search?q=0.001A,+HΓΆrsaal+1A,\" target=\"_blank\">Wo ist das?</a><br><hr /><small>uid:π|XR</small>",
1393 location,
1394 )
1395 .unwrap();
1396
1397 assert_eq!(
1398 description,
1399 Some(format!("Seat 15\n{}", location_hint(location).unwrap()))
1400 );
1401 }
1402
1403 #[test]
1404 fn tells_hand_made_changes_apart() {
1405 let untouched = Event {
1406 summary: Some(String::from("Analysis PrΓΌfung")),
1407 location: Some(String::from("ππππ.EG.πππ")),
1408 color_id: Some(String::from("11")),
1409 description: Some(format!(
1410 "<a href=\"https://nav.tum.de/search?q=102, HΓΆrsaal 2, \"Interims I\" (5620.01.102)\">Wo ist das?</a><br>{MARKER}"
1411 )),
1412 ..Default::default()
1413 };
1414 assert!(hand_made_changes(&untouched).is_empty());
1415
1416 let edited = Event {
1417 location: Some(String::from("ππππ.EG.πππ Platz 12")),
1418 color_id: Some(String::from("8")),
1419 description: Some(String::from(
1420 "Reihe 7<br><a href=\"https://nav.tum.de/search?q=x\" target=\"_blank\">Wo ist das?</a><br><hr /><small>uid:π|XR</small>",
1421 )),
1422 ..Default::default()
1423 };
1424 assert_eq!(
1425 hand_made_changes(&edited),
1426 vec!["description", "location", "color"]
1427 );
1428 }
1429
1430 #[test]
1431 fn builds_a_synced_exam() {
1432 let event = Event {
1433 summary: Some(String::from("GRNVS PrΓΌfung")),
1434 location: Some(String::from(
1435 "ππππ, Rudolf-MΓΆΓbauer-HΓΆrsaal (ππππ.EG.πππ) Platz F 7",
1436 )),
1437 description: Some(format!(
1438 "<a href=\"https://nav.tum.de/search?q=ππππ, Rudolf-MΓΆΓbauer-HΓΆrsaal (ππππ.EG.πππ)\">Wo ist das?</a><br>{MARKER}"
1439 )),
1440 start: at("2025-08-04T06:00:00Z"),
1441 end: at("2025-08-04T08:00:00Z"),
1442 color_id: Some(String::from("11")),
1443 reminders: reminders(&[1440]),
1444 source: Some(EventSource {
1445 title: Some(String::from("Link zur Lernveranstaltung")),
1446 url: Some(String::from("https://campus.tum.de/x")),
1447 }),
1448 status: Some(String::from("confirmed")),
1449 ..Default::default()
1450 };
1451
1452 let vevent = synced_event("891956005@tum.deXR", &event)
1453 .unwrap()
1454 .expect("the event has a start");
1455 let calendar_object = unfolded(&calendar_object(&[vevent]));
1456
1457 assert!(calendar_object.contains("UID:891956005@tum.deXR\r\n"));
1458 assert!(calendar_object.contains("DTSTART:20250804T060000Z\r\n"));
1459 assert!(calendar_object.contains("DTEND:20250804T080000Z\r\n"));
1460 assert!(calendar_object.contains("SUMMARY:GRNVS PrΓΌfung\r\n"));
1461 assert!(calendar_object
1462 .contains(r"LOCATION:2501\, Rudolf-MΓΆΓbauer-HΓΆrsaal (5101.EG.501) Platz F 7"));
1463 assert!(
1464 calendar_object.contains("DESCRIPTION:Wo ist das? https://nav.tum.de/search?q=2501")
1465 );
1466 assert!(calendar_object.contains("URL:https://campus.tum.de/x\r\n"));
1467 assert!(calendar_object.contains("STATUS:CONFIRMED\r\n"));
1468 assert!(calendar_object.contains("CATEGORIES:PrΓΌfung\r\n"));
1469 assert!(calendar_object.contains("COLOR:tomato\r\n"));
1470 assert!(calendar_object.contains(
1471 "BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:GRNVS PrΓΌfung\r\nTRIGGER:-PT1440M\r\nEND:VALARM\r\n"
1472 ));
1473 assert!(!calendar_object.contains("VTIMEZONE"));
1474 }
1475
1476 #[test]
1477 fn builds_a_recurring_event_with_its_exceptions() {
1478 let series = Series {
1479 event: Some(Event {
1480 id: Some(String::from("2bomka0k")),
1481 i_cal_uid: Some(String::from("2bomka0k@google.com")),
1482 summary: Some(String::from("MA HA Gruppe")),
1483 start: at("2025-01-14T17:00:00Z"),
1484 end: at("2025-01-14T19:00:00Z"),
1485 recurrence: Some(vec![String::from(
1486 "RRULE:FREQ=WEEKLY;UNTIL=20250210T225959Z;BYDAY=TU",
1487 )]),
1488 ..Default::default()
1489 }),
1490 exceptions: vec![
1491 Event {
1492 recurring_event_id: Some(String::from("2bomka0k")),
1493 status: Some(String::from("cancelled")),
1494 original_start_time: at("2025-01-28T17:00:00Z"),
1495 ..Default::default()
1496 },
1497 Event {
1498 recurring_event_id: Some(String::from("2bomka0k")),
1499 summary: Some(String::from("MA HA Gruppe (spΓ€ter)")),
1500 status: Some(String::from("confirmed")),
1501 original_start_time: at("2025-02-04T17:00:00Z"),
1502 start: at("2025-02-04T18:30:00Z"),
1503 end: at("2025-02-04T20:00:00Z"),
1504 ..Default::default()
1505 },
1506 ],
1507 };
1508
1509 let calendar_object = unfolded(&calendar_object(&series_events(&series).unwrap()));
1510
1511 assert!(calendar_object.contains("BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\n"));
1512 assert!(calendar_object.contains("DTSTART;TZID=Europe/Berlin:20250114T180000\r\n"));
1513 assert!(calendar_object.contains("RRULE:FREQ=WEEKLY;UNTIL=20250210T225959Z;BYDAY=TU\r\n"));
1514 assert_eq!(calendar_object.matches("RRULE:FREQ=WEEKLY").count(), 1);
1515 assert!(calendar_object.contains("EXDATE;TZID=Europe/Berlin:20250128T180000\r\n"));
1516 assert!(calendar_object.contains("RECURRENCE-ID;TZID=Europe/Berlin:20250204T180000\r\n"));
1517 assert!(calendar_object.contains("DTSTART;TZID=Europe/Berlin:20250204T193000\r\n"));
1518 assert!(calendar_object.contains("SUMMARY:MA HA Gruppe (spΓ€ter)\r\n"));
1519 assert_eq!(calendar_object.matches("BEGIN:VEVENT").count(), 2);
1520 assert_eq!(
1521 calendar_object.matches("UID:2bomka0k@google.com").count(),
1522 2
1523 );
1524 }
1525
1526 #[test]
1527 fn builds_all_day_events() {
1528 let series = Series {
1529 event: Some(Event {
1530 id: Some(String::from("c8qjed1p")),
1531 i_cal_uid: Some(String::from("c8qjed1p@google.com")),
1532 summary: Some(String::from("Klassentreffen")),
1533 start: on(2025, 12, 28),
1534 end: on(2025, 12, 29),
1535 color_id: Some(String::from("11")),
1536 reminders: reminders(&[9540]),
1537 ..Default::default()
1538 }),
1539 exceptions: Vec::new(),
1540 };
1541
1542 let calendar_object = unfolded(&calendar_object(&series_events(&series).unwrap()));
1543
1544 assert!(calendar_object.contains("DTSTART;VALUE=DATE:20251228\r\n"));
1545 assert!(calendar_object.contains("DTEND;VALUE=DATE:20251229\r\n"));
1546 assert!(calendar_object.contains("COLOR:tomato\r\n"));
1547 assert!(calendar_object.contains("TRIGGER:-PT9540M\r\n"));
1548 assert!(!calendar_object.contains("CATEGORIES"));
1549 assert!(!calendar_object.contains("VTIMEZONE"));
1550 }
1551
1552 #[test]
1553 fn refuses_recurring_events_in_other_time_zones() {
1554 let series = Series {
1555 event: Some(Event {
1556 id: Some(String::from("abc")),
1557 start: Some(EventDateTime {
1558 date_time: Some("2025-01-14T17:00:00Z".parse().unwrap()),
1559 time_zone: Some(String::from("America/New_York")),
1560 date: None,
1561 }),
1562 recurrence: Some(vec![String::from("RRULE:FREQ=DAILY;COUNT=3")]),
1563 ..Default::default()
1564 }),
1565 exceptions: Vec::new(),
1566 };
1567
1568 assert!(series_events(&series).is_err());
1569 }
1570}