Skip to main content

ics_watcher/
migrate.rs

1//! Moves a calendar [`tum_google_sync`](crate::tum_google_sync) kept in Google Calendar over to an
2//! Apple (iCloud) calendar, which [`tum_apple_sync`](crate::tum_apple_sync) keeps in sync from
3//! then on.
4//!
5//! - Events the Google sync created are recognized by the `uid:` marker at the end of their
6//!   description. They're stored under the same uid the Apple sync uses, so it keeps updating
7//!   them just like the Google sync did.
8//! - Whatever was changed by hand in Google Calendar comes along: notes in the description, a seat
9//!   number in the location, reminders and colors. Events deleted in Google stay deleted.
10//! - Events added to the calendar by hand are copied as well, recurring ones including their
11//!   moved and cancelled occurrences.
12//! - Nothing is ever deleted, neither in Google nor in the Apple Calendar. Events that already
13//!   exist in the Apple Calendar are skipped unless [`MigrationOptions::overwrite`] is set.
14//!
15//! See [migrate_google_to_apple] to run it.
16
17use 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
42/// Written into every calendar object with local times. Recurring events need them, otherwise
43/// they would move by an hour whenever daylight saving time starts or ends.
44const 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
62/// `uid:…` at the end of the description of every event the Google sync created, with the digits
63/// written as bold math digits (see [`convert_to_non_digits`](crate::convert_to_non_digits))
64static 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());
69/// Attributes Google Calendar adds to links whenever a description gets edited
70static 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());
77/// Older versions of the Google sync stripped the backslash of `\n` line breaks and left the
78/// `n` behind, e.g. `InfoeventnAnsprechpartner: nName,nName`
79static 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());
83/// Same for locations, e.g. `Online: VideokonferenznZoom etc.`
84static LOST_BREAK_IN_WORD_REGEX: Lazy<Regex> =
85    Lazy::new(|| Regex::new(r"(\p{Ll})n(\p{Lu})").unwrap());
86/// Exactly what the Google sync writes, to tell whether a description was edited by hand
87static 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/// Controls what [migrate_google_to_apple] does.
95#[derive(Debug, Clone)]
96pub struct MigrationOptions {
97    /// Without it, nothing is written - the migration only reports what it would do
98    pub apply: bool,
99    /// Replace events which already exist in the Apple Calendar instead of skipping them
100    pub overwrite: bool,
101    /// Additionally save every calendar object as an `.ics` file into this folder
102    pub dump_directory: Option<PathBuf>,
103    /// The backup of the [ICSWatcher](crate::ICSWatcher), which tells which events are still in
104    /// the ICS calendar
105    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// ---------------------------------------------------------------------------
120// Calendar objects
121// ---------------------------------------------------------------------------
122
123/// How a point in time gets written
124#[derive(Debug, Clone, Copy, PartialEq)]
125enum Time {
126    Utc(DateTime<Utc>),
127    /// Written in Europe/Berlin local time
128    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/// A single `VEVENT`. Text values are plain text, they get escaped while writing.
171#[derive(Debug)]
172struct VEvent {
173    uid: String,
174    recurrence_id: Option<Time>,
175    start: Time,
176    end: Time,
177    /// Complete `RRULE` / `EXDATE` / `RDATE` lines, the way Google hands them out
178    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    /// Minutes before the start
191    alarms: Vec<i32>,
192}
193
194impl VEvent {
195    /// Takes over everything that doesn't need any conversion. The description is left to the
196    /// caller.
197    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            // Apple Calendar shows RFC 7986 colors on supported clients
282            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
333/// Google's event colors as the closest CSS color, which is what RFC 7986 `COLOR` takes
334fn color_name(color_id: &str) -> Option<&'static str> {
335    Some(match color_id {
336        "1" => "mediumslateblue", // Lavender
337        "2" => "mediumseagreen",  // Sage
338        "3" => "darkorchid",      // Grape
339        "4" => "salmon",          // Flamingo
340        "5" => "gold",            // Banana
341        "6" => "orangered",       // Tangerine
342        "7" => "deepskyblue",     // Peacock
343        "8" => "dimgray",         // Graphite
344        "9" => "royalblue",       // Blueberry
345        "10" => "seagreen",       // Basil
346        "11" => "tomato",         // Tomato
347        _ => return None,
348    })
349}
350
351// ---------------------------------------------------------------------------
352// HTML descriptions
353// ---------------------------------------------------------------------------
354
355fn 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
381/// Trims all lines, drops empty ones at the start and the end and collapses runs of empty lines
382fn 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
397/// Starts a new line, unless the current one is still empty (or just a bullet)
398fn 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
405/// Turns the HTML Google Calendar keeps descriptions in into plain text. Plain text stays as
406/// it is.
407fn html_to_text(html: &str) -> String {
408    let html = html.replace("\r\n", "\n");
409    let mut text = String::new();
410    // Where the text of each open link starts, along with where the link points to
411    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
452/// The room a "Wo ist das?" link searches for. When a description gets edited, Google Calendar
453/// cuts links off at the first quote - what's left is still good enough for a search.
454fn 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
468// ---------------------------------------------------------------------------
469// Converting events
470// ---------------------------------------------------------------------------
471
472/// The uid the Google sync noted at the end of the description, if the event is one of its own.
473fn 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
479/// The Google sync wrote the digits of rooms as bold math digits. Locations are a single line,
480/// so line breaks typed into them become commas.
481fn 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    // Only online events had line breaks in their location, which older versions of the
488    // Google sync turned into a stray `n`
489    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
500/// Turns a description the Google sync wrote into the one the Apple sync writes: plain text,
501/// without the uid marker and with the "Wo ist das?" link as a line of its own at the end.
502/// Everything that was added by hand stays where it is.
503fn 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    // Descriptions which lost their HTML still have the text of the link
531    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        // Cut off at the quote in the room name - the location still has all of it
560        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
578/// Converts an event the Google sync created into what the Apple sync would have written,
579/// including everything that was changed by hand.
580fn 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        // Same as the Apple sync marks exams
605        vevent.categories = Some(String::from("PrΓΌfung"));
606        vevent.color = Some("tomato");
607    }
608
609    Ok(Some(vevent))
610}
611
612/// What was changed by hand in an event the Google sync created, for the report.
613fn 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    // The Google sync only ever wrote bold digits
624    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    // Exams are colored by the Google sync itself
639    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/// An event that was added by hand: a single one, or a recurring one along with its moved and
655/// cancelled occurrences.
656#[derive(Debug, Default)]
657struct Series {
658    event: Option<Event>,
659    exceptions: Vec<Event>,
660}
661
662/// Converts an event that was added by hand. The first `VEvent` is the event itself, the others
663/// are its moved occurrences.
664fn 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    // Recurring events are written in local time, so that they don't move by an hour whenever
677    // daylight saving time starts or ends
678    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        // Only the event itself carries the rule
715        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
724// ---------------------------------------------------------------------------
725// Migration
726// ---------------------------------------------------------------------------
727
728async 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        // Recurring events come as one event with its exceptions instead of every occurrence
737        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
757/// Date and title of an event, for the report
758fn 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
796/// A calendar object ready to be uploaded
797struct Planned {
798    uid: String,
799    name: String,
800    label: String,
801    calendar_object: String,
802}
803
804/// Uploads a calendar object. Returns whether it was written - without `overwrite`, existing
805/// events are left alone.
806async 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
825/// Copies the Google calendar that [`tum_google_sync`](crate::tum_google_sync) kept over to an
826/// Apple Calendar. See the [module documentation](self) for what it does exactly.
827///
828/// Run it from the folder the [ICSWatcher](crate::ICSWatcher) runs in, while the watcher is
829/// stopped: it needs the Google credentials in `.secrets` and the backup in `.backups`.
830/// Without an `apple_calendar` or without [`MigrationOptions::apply`], it only reports what it
831/// would do.
832pub 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    // Sort the events into the ones the Google sync created and the ones added by hand
842    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            // Should the Google sync ever have created an event twice, the newer one wins
854            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    // Report
927    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    // If the first event already gets rejected, all others would be as well
1040    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
1090/// An exam waiting to be moved into the exam calendar
1091struct Exam {
1092    name: String,
1093    url: Url,
1094    etag: Option<String>,
1095    label: String,
1096}
1097
1098/// Moves the exams that are in the main calendar over into the exam calendar (see
1099/// [`AppleCalendar::with_exam_calendar`]) - the ones [migrate_google_to_apple] put there, or
1100/// the ones created before there was an exam calendar.
1101///
1102/// The server moves every exam itself, and only if it didn't change in the meantime. Without
1103/// `apply`, it only reports what it would do.
1104pub 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            // Deleted in the meantime
1161            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    /// Undoes the line folding, so that assertions don't depend on where lines are split
1266    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>&nbsp;<span style="font-weight: normal;">A5</span></span></h4></li><li><span><strong>Seat:&nbsp;</strong>15</span>&nbsp;</li></ul><br>&nbsp;<br>Mehr auf <a href="https://example.com/info">der Info-Seite</a> &amp; 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        // The quotes in the room name end the link early in the HTML
1337        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        // Rooms are left alone
1385        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}