//! Structs and functions specific to gathering input from ics files, which is a popular format that Google Calendar happens to put out use super::{CalendarUi, DatePerhapsTime, Downloadable, EventInstance, Parameters, RecurrenceKey}; use anyhow::{Context as _, Result, anyhow}; use base64::Engine as _; use chrono::TimeZone as _; use icalendar::{Component as _, EventLike as _}; use serde::Deserialize; use std::{collections::BTreeSet, str::FromStr as _}; /// Google Calendar has a public ics endpoint that we scrape for all upstream Google Calendars #[derive(Clone, Default, Deserialize)] pub(crate) struct Config { #[serde(flatten)] pub(crate) dl: Downloadable, /// Magical ID we pass to Google to deep-link to Google Calendar events google_id: Option, #[serde(flatten)] pub(crate) ui: CalendarUi, } pub(crate) struct Calendar { /// The parsed ics file cal: icalendar::Calendar, /// The config used to load this calendar config: Config, } fn normalize_date_perhaps_time( x: &icalendar::DatePerhapsTime, tz: chrono_tz::Tz, ) -> Result { Ok(match x { icalendar::DatePerhapsTime::DateTime(x) => { let dt = x .try_into_utc() .context("Data error - Could not convert event datetime to UTC")? .with_timezone(&tz); DatePerhapsTime { dt, all_day: false } } icalendar::DatePerhapsTime::Date(date) => { let midnight = chrono::NaiveTime::default(); let dt = tz.from_local_datetime(&date.and_time(midnight)).single().context("DateTime doesn't map to a single unambiguous datetime when converting to our timezone")?; DatePerhapsTime { dt, all_day: true } } }) } fn recurring_dates_opt( params: &Parameters, ev: &icalendar::Event, rrule: &icalendar::Property, ) -> Result>> { let dtstart = ev .get_start() .context("Data error - Event has no DTSTART")?; let all_day = match &dtstart { icalendar::DatePerhapsTime::Date(_) => true, icalendar::DatePerhapsTime::DateTime(_) => false, }; let dtstart_norm = normalize_date_perhaps_time(&dtstart, params.tz)?; let rr = rrule::RRule::from_str(rrule.value()) .with_context(|| format!("RRule parse failed `{}`", rrule.value()))?; if let Some(until) = rr.get_until() && *until < params.output_start { // This skips over some bad data in our test set where we fail to parse a recurring event that's already ended before our output window starts return Ok(None); } let rrule_tz = params.tz.into(); let rr = rr.build(dtstart_norm.dt.with_timezone(&rrule_tz))?; let dates = rr .after(params.output_start.with_timezone(&rrule_tz)) .before(params.output_stop.with_timezone(&rrule_tz)) .all(10) .dates .into_iter() .map(move |dtstart| DatePerhapsTime { dt: dtstart.with_timezone(¶ms.tz), all_day, }); Ok(Some(dates)) } fn recurring_dates( params: &Parameters, ev: &icalendar::Event, rrule: &icalendar::Property, ) -> Result> { Ok(recurring_dates_opt(params, ev, rrule)? .into_iter() .flatten()) } fn google_url( dtstart: DatePerhapsTime, has_rrule: bool, uid: Option<&str>, google_id: &str, ) -> Result> { let uid = uid.context("No UID")?; if uid.len() > 100 { // There's one event in one of our test Google calendars which originates from Microsoft Exchange and has a totally different UID format from any other event. I was not able to reverse it, so I'm skipping it for now. return Ok(None); } // Strip off the back part of the Google UID let idx = uid.find(['@', '_']).unwrap_or(uid.len()); let uid_2 = &uid[..idx]; let utc_dtstart = dtstart .dt .with_timezone(&chrono_tz::UTC) .format("%Y%m%dT%H%M%SZ") .to_string(); let eid_plain = if has_rrule { // Recurring events have an extra timestamp in their base64 to disambiguiate format!("{uid_2}_{utc_dtstart} {google_id}") } else { format!("{uid_2} {google_id}") }; let eid = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&eid_plain); let mut link = url::Url::parse("https://www.google.com/calendar/event").unwrap(); link.query_pairs_mut().append_pair("eid", &eid); Ok(Some(link.to_string())) } fn ical_event_instances( config_ical: &Config, params: &Parameters, ev: &icalendar::Event, ) -> Result> { let dates = if let Some(rrule) = ev.properties().get("RRULE") { recurring_dates(params, ev, rrule)?.collect() } else { // Event that occurs once let dtstart = ev.get_start().context("Data error - Event has no start")?; let dtstart_normalized = normalize_date_perhaps_time(&dtstart, params.tz)?; if dtstart_normalized.dt < params.output_start || dtstart_normalized.dt > params.output_stop { return Ok(vec![]); } vec![dtstart_normalized] }; let instances = dates .into_iter() .map(|dtstart| { let has_rrule = ev.properties().get("RRULE").is_some(); let uid = ev.get_uid().map(|s| s.to_string()); let url = if let Some(url) = ev.get_url() { Some(url.to_string()) } else if let Some(google_id) = &config_ical.google_id { google_url(dtstart, has_rrule, uid.as_deref(), google_id)? } else { None }; Ok::<_, anyhow::Error>(EventInstance { calendar_ui: config_ical.ui.clone(), dtstart, location: ev.get_location().map(|s| s.to_string()), recurrence_id: ev.get_recurrence_id(), summary: ev.get_summary().map(|s| s.to_string()), uid, url, }) }) .collect(); instances } impl Calendar { pub(crate) fn read_from_str(config: Config, s: &str) -> Result { let cal = s.parse().map_err(|s| anyhow!("parse error {s}"))?; let cal = Self { cal, config }; Ok(cal) } pub(crate) fn read_from_downloadable(config: Config) -> Result { let s = std::fs::read_to_string(&config.dl.file_path)?; Self::read_from_str(config, &s) } fn events(&self) -> impl Iterator { self.cal.components.iter().filter_map(|comp| { if let icalendar::CalendarComponent::Event(ev) = comp { Some(ev) } else { None } }) } /// Returns an unsorted list of event instances for this calendar pub(crate) fn event_instances(&self, params: &Parameters) -> Result> { let mut instances = vec![]; let mut recurrence_exceptions = BTreeSet::new(); for ev in self.events() { let eis = match ical_event_instances(&self.config, params, ev) .with_context(|| format!("Failed to process event with UID '{:?}'", ev.get_uid())) { Ok(x) => x, Err(e) => { if ev.get_last_modified().context("Event has no timestamp")? < params.ignore_before { tracing::warn!("Ignoring error from very old event {e:?}"); continue; } else { Err(e)? } } }; for ei in eis { instances.push(ei); } if let Some(recurrence_id) = ev.get_recurrence_id() { // This is a recurrence exception and we must handle it specially by later deleting the original event it replaces let recurrence_id = normalize_date_perhaps_time(&recurrence_id, params.tz) .context("We should be able to normalize recurrence IDs")?; let uid = ev .get_uid() .context("Every recurrence exception should have a UID")?; recurrence_exceptions.insert(RecurrenceKey { recurrence_id, uid }); } } // Find all recurring events that are replaced with recurrence exceptions and delete the originals. // There is probably a not-linear-time way to do this, but this should be fine. instances.retain(|ev| { if ev.recurrence_id.is_some() { // This is a recurrence exception, exceptions never delete themselves return true; } let Some(uid) = &ev.uid else { // If there's no UID, we can't apply recurrence exceptions return true; }; let key = RecurrenceKey { recurrence_id: ev.dtstart, uid, }; !recurrence_exceptions.contains(&key) }); Ok(instances) } }