diff --git a/src/main.rs b/src/main.rs index d1a7907..67bcddc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use url::Url; #[cfg(test)] mod tests; -#[derive(Clone, Deserialize)] +#[derive(Clone, Default, Deserialize)] struct Downloadable { /// URL to scrape to download the JSON download_url: Option, @@ -22,6 +22,15 @@ struct Downloadable { file_path: Utf8PathBuf, } +#[derive(Clone, Default, Deserialize)] +struct CalendarUi { + /// A canonical webpage we can direct users to + html_url: Option, + + /// Very short name for putting on each event + short_name: String, +} + /// The Sierra Club uses a calendar software called Campfire, which luckily puts out nice JSON files /// /// It's also deterministic, which is lovely. You get the same JSON byte-for-byte unless the events changed @@ -30,15 +39,12 @@ struct ConfigCampfire { #[serde(flatten)] dl: Downloadable, - /// A canonical webpage we can direct users to - html_url: Option, - - /// Very short name for putting on each event - short_name: String, + #[serde(flatten)] + ui: CalendarUi, } /// Google Calendar has a public ics endpoint that we scrape for all upstream Google Calendars -#[derive(Clone, Deserialize)] +#[derive(Clone, Default, Deserialize)] struct ConfigIcal { #[serde(flatten)] dl: Downloadable, @@ -46,11 +52,8 @@ struct ConfigIcal { /// Magical ID we pass to Google to deep-link to Google Calendar events google_id: Option, - /// A canonical webpage we can direct users to - html_url: Option, - - /// Very short name for putting on each event - short_name: String, + #[serde(flatten)] + ui: CalendarUi, } #[derive(Deserialize)] @@ -232,40 +235,50 @@ fn recurring_dates( } /// An event that's been duplicated according to its recurrence rules, so we can sort by datetimes -struct EventInstance<'a> { +struct EventInstance { + calendar_ui: CalendarUi, dtstart: DatePerhapsTime, - ev: &'a icalendar::Event, + // ev: &'a icalendar::Event, + location: Option, + recurrence_id: Option, + summary: Option, + uid: Option, + url: Option, } -impl EventInstance<'_> { - fn google_url(&self, google_id: &str) -> Result> { - let uid = self.ev.get_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 = self - .dtstart - .dt - .with_timezone(&chrono_tz::UTC) - .format("%Y%m%dT%H%M%SZ") - .to_string(); - let eid_plain = if self.ev.properties().get("RRULE").is_some() { - // 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 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())) +} +/* +impl EventInstance { fn url(&self, google_id: Option<&str>) -> Result> { if let Some(url) = self.ev.get_url() { return Ok(Some(url.to_string())); @@ -295,11 +308,12 @@ impl<'a> EventWithUrl<'a> { }) } } - -fn event_instances<'a>( +*/ +fn ical_event_instances( + config_ical: &ConfigIcal, params: &Parameters, - ev: &'a icalendar::Event, -) -> Result>> { + ev: &icalendar::Event, +) -> Result> { let dates = if let Some(rrule) = ev.properties().get("RRULE") { recurring_dates(params, ev, rrule)?.collect() } else { @@ -316,9 +330,29 @@ fn event_instances<'a>( let instances = dates .into_iter() - .map(|dtstart| EventInstance { dtstart, ev }) + .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(); - Ok(instances) + instances } struct ICal { @@ -356,12 +390,16 @@ impl ICal { } /// Returns an unsorted list of event instances for this calendar - fn event_instances(&self, params: &Parameters) -> Result>> { + fn event_instances( + &self, + config_ical: &ConfigIcal, + params: &Parameters, + ) -> Result> { let mut instances = vec![]; let mut recurrence_exceptions = BTreeSet::new(); for ev in self.events() { - let eis = match event_instances(params, ev) + let eis = match ical_event_instances(config_ical, params, ev) .with_context(|| format!("Failed to process event with UID '{:?}'", ev.get_uid())) { Ok(x) => x, @@ -396,12 +434,12 @@ impl ICal { // There is probably a not-linear-time way to do this, but this should be fine. instances.retain(|ev| { - if ev.ev.get_recurrence_id().is_some() { + if ev.recurrence_id.is_some() { // This is a recurrence exception, exceptions never delete themselves return true; } - let Some(uid) = ev.ev.get_uid() else { + let Some(uid) = &ev.uid else { // If there's no UID, we can't apply recurrence exceptions return true; }; @@ -435,23 +473,23 @@ fn process_data<'a>( data: &'a Data, config_output: &'a ConfigOutput, now: DateTime, -) -> Result>> { +) -> Result> { let params = Parameters::new(now)?; let mut instances = vec![]; for (ical, config) in &data.icals { - for ei in ical.event_instances(¶ms)? { - if let Some(uid) = ei.ev.get_uid() + for ei in ical.event_instances(config, ¶ms)? { + if let Some(uid) = &ei.uid && config_output.hide_uids.contains(uid) { continue; } - if let Some(summary) = ei.ev.get_summary() + if let Some(summary) = &ei.summary && config_output.hide_summaries.contains(summary) { continue; } - let ei = EventWithUrl::from_ei(config, ei)?; + // let ei = EventWithUrl::from_ei(config, ei)?; instances.push(ei); } } @@ -463,7 +501,7 @@ fn process_data<'a>( // FIXME: Don't print to stdout / stderr fn output_html( config: &ConfigOutput, - instances: &[EventWithUrl], + instances: &[EventInstance], now: DateTime, ) -> Result<()> { let today = now.date_naive(); @@ -472,11 +510,6 @@ fn output_html( let mut html_list = vec![]; let mut day_list = vec![]; for ei in instances { - let summary = ei - .ev - .get_summary() - .unwrap_or("Data error BXH45NAR - No summary in event"); - let date = ei.dtstart.date_naive(); let past = date < today; let month = date.format("%B").to_string(); @@ -531,35 +564,36 @@ fn output_html( .map(|t| t.format("%l:%M %P").to_string()) .unwrap_or_else(|| "All day".to_string()); - // println!(" {time} - {summary}"); + let summary = ei + .summary + .as_deref() + .unwrap_or("Data error BXH45NAR - No summary in event"); let summary = if let Some(url) = &ei.url { maud::html! {a href=(url) {(summary)}} } else { maud::html! {(summary)} }; - let location = ei.ev.get_location(); - if past { day_list.push(maud::html! { li class="past" { (time) " - " (summary) } }); } else { - let calendar_link = if let Some(html_url) = &ei.calendar.html_url { - maud::html! { a href=(html_url) { (ei.calendar.short_name) } } + let calendar_link = if let Some(html_url) = &ei.calendar_ui.html_url { + maud::html! { a href=(html_url) { (ei.calendar_ui.short_name) } } } else { - maud::html! { (ei.calendar.short_name)} + maud::html! { (ei.calendar_ui.short_name)} }; // This is where the main stuff happens - tracing::debug!(uid = ei.ev.get_uid(), summary = ei.ev.get_summary()); + tracing::debug!(uid = ei.uid, summary = ei.summary); day_list.push(maud::html! { li { details { summary { (time) " - " (summary) } ul { li { (calendar_link) " calendar" } - @if let Some(location) = location { + @if let Some(location) = &ei.location { li { "Location: " (location) } } } diff --git a/src/tests.rs b/src/tests.rs index 8e27b52..6432539 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -67,7 +67,7 @@ END:VCALENDAR let ical = ICal::read_from_str(s)?; let now = dt_from_ts(1755000000); let params = Parameters::new(now)?; - let instances = ical.event_instances(¶ms)?; + let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?; assert_eq!(instances.len(), 1); let event = &instances[0]; @@ -76,7 +76,7 @@ END:VCALENDAR all_day: false, }; assert_eq!(event.dtstart, expected_time); - assert_eq!(event.ev.get_summary(), Some("zero roman mummy hatch")); + assert_eq!(event.summary.as_deref(), Some("zero roman mummy hatch")); Ok(()) } @@ -109,7 +109,7 @@ END:VCALENDAR output_stop: chicago_time(2025, 10, 1, 0, 0, 0), tz: chrono_tz::America::Chicago, }; - let instances = ical.event_instances(¶ms)?; + let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?; assert_eq!( [instances[0].dtstart, instances[1].dtstart,], @@ -195,7 +195,7 @@ END:VCALENDAR output_stop: chicago_time(2025, 10, 1, 0, 0, 0), tz: chrono_tz::America::Chicago, }; - let instances = ical.event_instances(¶ms)?; + let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?; assert_eq!( [ @@ -220,7 +220,7 @@ END:VCALENDAR ); for instance in &instances { - assert_eq!(instance.ev.get_summary(), Some("coil perm brush zippy")); + assert_eq!(instance.summary.as_deref(), Some("coil perm brush zippy")); } assert_eq!(instances.len(), 3);