From 3b79aa21d24fcdd4f8151b4c38aeaaa7eaa3c33c Mon Sep 17 00:00:00 2001 From: _ <_@_> Date: Thu, 14 Aug 2025 01:45:43 +0000 Subject: [PATCH 1/6] checkpoint --- Cargo.lock | 10 +++++++++ Cargo.toml | 1 + src/main.rs | 60 +++++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04362c2..e81b035 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "camino" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" +dependencies = [ + "serde", +] + [[package]] name = "cc" version = "1.2.32" @@ -1860,6 +1869,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", + "camino", "chrono", "chrono-tz", "clap", diff --git a/Cargo.toml b/Cargo.toml index 4271d6c..8628ae0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] anyhow = "1.0.98" base64 = "0.22.1" +camino = { version = "1.1.11", features = ["serde1"] } chrono = "0.4.41" chrono-tz = { version = "0.10.4", features = ["serde"] } clap = { version = "4.5.43", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index e182d7f..d1a7907 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use anyhow::{Context as _, Result, anyhow, bail}; use base64::Engine as _; +use camino::Utf8PathBuf; use chrono::{DateTime, TimeZone as _, Utc}; use clap::Parser as _; use icalendar::{Component as _, EventLike as _}; @@ -7,26 +8,49 @@ use serde::Deserialize; use std::{ collections::BTreeSet, io::Write as _, path::PathBuf, str::FromStr as _, time::Duration, }; +use url::Url; #[cfg(test)] mod tests; +#[derive(Clone, Deserialize)] +struct Downloadable { + /// URL to scrape to download the JSON + download_url: Option, + + /// Disk location to cache the JSON file for debugging + file_path: Utf8PathBuf, +} + +/// 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 +#[derive(Clone, Deserialize)] +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, +} + +/// Google Calendar has a public ics endpoint that we scrape for all upstream Google Calendars #[derive(Clone, Deserialize)] struct ConfigIcal { - /// Disk location to cache the ics file for debugging - file_path: PathBuf, + #[serde(flatten)] + dl: Downloadable, /// 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, + html_url: Option, /// Very short name for putting on each event short_name: String, - - /// URL to scrape to download the ics file - ics_url: Option, } #[derive(Deserialize)] @@ -50,6 +74,7 @@ struct ConfigOutput { #[derive(Deserialize)] struct Config { + campfires: Vec, icals: Vec, output: ConfigOutput, } @@ -315,8 +340,8 @@ impl ICal { Ok(cal) } - fn read_from_config(config: &ConfigIcal) -> Result { - let s = std::fs::read_to_string(&config.file_path)?; + fn read_from_downloadable(dl: &Downloadable) -> Result { + let s = std::fs::read_to_string(&dl.file_path)?; Self::read_from_str(&s) } @@ -399,7 +424,7 @@ struct Data { fn read_data_from_disk(config: &Config) -> Result { let mut data = Data::default(); for config in &config.icals { - let cal = ICal::read_from_config(config)?; + let cal = ICal::read_from_downloadable(&config.dl)?; data.icals.push((cal, config.clone())); } @@ -628,20 +653,25 @@ async fn do_everything(cli: &CliAuto) -> Result<()> { let client = reqwest::Client::builder() .user_agent(APP_USER_AGENT) .build()?; - for ical in &config.icals { - let Some(ics_url) = &ical.ics_url else { + for dl in config + .campfires + .iter() + .map(|cf| &cf.dl) + .chain(config.icals.iter().map(|ical| &ical.dl)) + { + let Some(download_url) = &dl.download_url else { continue; }; - tracing::info!(url = ics_url.to_string(), "requesting..."); - let resp = client.get(ics_url.clone()).send().await?; + tracing::info!(url = download_url.to_string(), "requesting..."); + let resp = client.get(download_url.clone()).send().await?; if resp.status() != 200 { bail!("Bad status {}", resp.status()); } let bytes = resp.bytes().await?; - let temp_path = ical.file_path.with_extension(".ics.temp"); + let temp_path = dl.file_path.with_extension(".ics.temp"); std::fs::write(&temp_path, &bytes)?; - std::fs::rename(&temp_path, &ical.file_path)?; + std::fs::rename(&temp_path, &dl.file_path)?; } let data = read_data_from_disk(&config)?; From 49f48acaf123c11829ebc794f350f8c831b72368 Mon Sep 17 00:00:00 2001 From: _ <_@_> Date: Thu, 14 Aug 2025 03:07:42 +0000 Subject: [PATCH 2/6] checkpoint --- src/main.rs | 174 ++++++++++++++++++++++++++++++--------------------- src/tests.rs | 10 +-- 2 files changed, 109 insertions(+), 75 deletions(-) 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); From d4d0adaacc1b1cde96d7a006ba9e6eeeb249e59f Mon Sep 17 00:00:00 2001 From: _ <_@_> Date: Thu, 14 Aug 2025 03:14:37 +0000 Subject: [PATCH 3/6] refactor --- src/main.rs | 68 +++++++++++++--------------------------------------- src/tests.rs | 12 +++++----- 2 files changed, 22 insertions(+), 58 deletions(-) diff --git a/src/main.rs b/src/main.rs index 67bcddc..b458a26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -277,38 +277,7 @@ fn google_url( 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())); - } - if let Some(google_id) = google_id { - return self.google_url(google_id); - } - Ok(None) - } -} -struct EventWithUrl<'a> { - calendar: &'a ConfigIcal, - dtstart: DatePerhapsTime, - ev: &'a icalendar::Event, - url: Option, -} - -impl<'a> EventWithUrl<'a> { - fn from_ei(calendar: &'a ConfigIcal, ei: EventInstance<'a>) -> Result> { - let url = ei.url(calendar.google_id.as_deref())?; - Ok(Self { - calendar, - dtstart: ei.dtstart, - ev: ei.ev, - url, - }) - } -} -*/ fn ical_event_instances( config_ical: &ConfigIcal, params: &Parameters, @@ -358,6 +327,9 @@ fn ical_event_instances( struct ICal { /// The parsed ics file cal: icalendar::Calendar, + + /// The config used to load this calendar + config: ConfigIcal, } /// Used to link recurrence exceptions to the original events they replace @@ -368,15 +340,15 @@ struct RecurrenceKey<'a> { } impl ICal { - fn read_from_str(s: &str) -> Result { + fn read_from_str(config: ConfigIcal, s: &str) -> Result { let cal = s.parse().map_err(|s| anyhow!("parse error {s}"))?; - let cal = Self { cal }; + let cal = Self { cal, config }; Ok(cal) } - fn read_from_downloadable(dl: &Downloadable) -> Result { - let s = std::fs::read_to_string(&dl.file_path)?; - Self::read_from_str(&s) + fn read_from_downloadable(config: ConfigIcal) -> Result { + let s = std::fs::read_to_string(&config.dl.file_path)?; + Self::read_from_str(config, &s) } fn events(&self) -> impl Iterator { @@ -390,16 +362,12 @@ impl ICal { } /// Returns an unsorted list of event instances for this calendar - fn event_instances( - &self, - config_ical: &ConfigIcal, - params: &Parameters, - ) -> Result> { + 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(config_ical, params, ev) + 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, @@ -456,14 +424,14 @@ impl ICal { #[derive(Default)] struct Data { - icals: Vec<(ICal, ConfigIcal)>, + icals: Vec, } fn read_data_from_disk(config: &Config) -> Result { let mut data = Data::default(); - for config in &config.icals { - let cal = ICal::read_from_downloadable(&config.dl)?; - data.icals.push((cal, config.clone())); + for config_ical in &config.icals { + let cal = ICal::read_from_downloadable(config_ical.clone())?; + data.icals.push(cal); } Ok(data) @@ -477,8 +445,8 @@ fn process_data<'a>( let params = Parameters::new(now)?; let mut instances = vec![]; - for (ical, config) in &data.icals { - for ei in ical.event_instances(config, ¶ms)? { + for ical in &data.icals { + for ei in ical.event_instances(¶ms)? { if let Some(uid) = &ei.uid && config_output.hide_uids.contains(uid) { @@ -489,7 +457,6 @@ fn process_data<'a>( { continue; } - // let ei = EventWithUrl::from_ei(config, ei)?; instances.push(ei); } } @@ -498,7 +465,6 @@ fn process_data<'a>( Ok(instances) } -// FIXME: Don't print to stdout / stderr fn output_html( config: &ConfigOutput, instances: &[EventInstance], @@ -533,8 +499,6 @@ fn output_html( } } if last_date_printed != Some(date) { - // println!("{date}"); - // FIXME: De-dupe if !day_list.is_empty() { html_list.push(maud::html! { diff --git a/src/tests.rs b/src/tests.rs index 6432539..3216a80 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -64,10 +64,10 @@ END:VEVENT END:VCALENDAR "#; - let ical = ICal::read_from_str(s)?; + let ical = ICal::read_from_str(ConfigIcal::default(), s)?; let now = dt_from_ts(1755000000); let params = Parameters::new(now)?; - let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?; + let instances = ical.event_instances(¶ms)?; assert_eq!(instances.len(), 1); let event = &instances[0]; @@ -102,14 +102,14 @@ END:VEVENT END:VCALENDAR "#; - let ical = ICal::read_from_str(s)?; + let ical = ICal::read_from_str(ConfigIcal::default(), s)?; let params = Parameters { ignore_before: chicago_time(2025, 1, 1, 0, 0, 0), output_start: chicago_time(2025, 7, 1, 0, 0, 0), output_stop: chicago_time(2025, 10, 1, 0, 0, 0), tz: chrono_tz::America::Chicago, }; - let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?; + let instances = ical.event_instances(¶ms)?; assert_eq!( [instances[0].dtstart, instances[1].dtstart,], @@ -188,14 +188,14 @@ END:VEVENT END:VCALENDAR "#; - let ical = ICal::read_from_str(s)?; + let ical = ICal::read_from_str(ConfigIcal::default(), s)?; let params = Parameters { ignore_before: chicago_time(2025, 1, 1, 0, 0, 0), output_start: chicago_time(2025, 7, 1, 0, 0, 0), output_stop: chicago_time(2025, 10, 1, 0, 0, 0), tz: chrono_tz::America::Chicago, }; - let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?; + let instances = ical.event_instances(¶ms)?; assert_eq!( [ From dfbf23ed6a7f5e921f0c3ffc0429dcb18398c895 Mon Sep 17 00:00:00 2001 From: _ <_@_> Date: Thu, 14 Aug 2025 03:25:39 +0000 Subject: [PATCH 4/6] refactor --- src/main.rs | 275 ++---------------------------------------------- src/tests.rs | 13 ++- src/wac_ical.rs | 261 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 268 deletions(-) create mode 100644 src/wac_ical.rs diff --git a/src/main.rs b/src/main.rs index b458a26..df3674b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,16 @@ -use anyhow::{Context as _, Result, anyhow, bail}; -use base64::Engine as _; +use anyhow::{Context as _, Result, bail}; use camino::Utf8PathBuf; -use chrono::{DateTime, TimeZone as _, Utc}; +use chrono::{DateTime, Utc}; use clap::Parser as _; -use icalendar::{Component as _, EventLike as _}; use serde::Deserialize; -use std::{ - collections::BTreeSet, io::Write as _, path::PathBuf, str::FromStr as _, time::Duration, -}; +use std::{collections::BTreeSet, io::Write as _, time::Duration}; use url::Url; #[cfg(test)] mod tests; +mod wac_ical; + #[derive(Clone, Default, Deserialize)] struct Downloadable { /// URL to scrape to download the JSON @@ -43,19 +41,6 @@ struct ConfigCampfire { ui: CalendarUi, } -/// Google Calendar has a public ics endpoint that we scrape for all upstream Google Calendars -#[derive(Clone, Default, Deserialize)] -struct ConfigIcal { - #[serde(flatten)] - dl: Downloadable, - - /// Magical ID we pass to Google to deep-link to Google Calendar events - google_id: Option, - - #[serde(flatten)] - ui: CalendarUi, -} - #[derive(Deserialize)] struct ConfigOutput { /// Used as the OpenGraph description in meta tags @@ -78,20 +63,20 @@ struct ConfigOutput { #[derive(Deserialize)] struct Config { campfires: Vec, - icals: Vec, + icals: Vec, output: ConfigOutput, } #[derive(clap::Parser)] struct CliAuto { #[arg(long)] - config: PathBuf, + config: Utf8PathBuf, } #[derive(clap::Parser)] struct CliIcsDebug { #[arg(long)] - config: PathBuf, + config: Utf8PathBuf, } #[derive(clap::Subcommand)] @@ -164,81 +149,10 @@ impl DatePerhapsTime { } } -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()) -} - /// An event that's been duplicated according to its recurrence rules, so we can sort by datetimes struct EventInstance { calendar_ui: CalendarUi, dtstart: DatePerhapsTime, - // ev: &'a icalendar::Event, location: Option, recurrence_id: Option, summary: Option, @@ -246,92 +160,6 @@ struct EventInstance { url: Option, } -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: &ConfigIcal, - 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 -} - -struct ICal { - /// The parsed ics file - cal: icalendar::Calendar, - - /// The config used to load this calendar - config: ConfigIcal, -} - /// Used to link recurrence exceptions to the original events they replace #[derive(Eq, Ord, PartialOrd, PartialEq)] struct RecurrenceKey<'a> { @@ -339,98 +167,15 @@ struct RecurrenceKey<'a> { uid: &'a str, } -impl ICal { - fn read_from_str(config: ConfigIcal, s: &str) -> Result { - let cal = s.parse().map_err(|s| anyhow!("parse error {s}"))?; - let cal = Self { cal, config }; - Ok(cal) - } - - fn read_from_downloadable(config: ConfigIcal) -> 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 - 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) - } -} - #[derive(Default)] struct Data { - icals: Vec, + icals: Vec, } fn read_data_from_disk(config: &Config) -> Result { let mut data = Data::default(); for config_ical in &config.icals { - let cal = ICal::read_from_downloadable(config_ical.clone())?; + let cal = wac_ical::Calendar::read_from_downloadable(config_ical.clone())?; data.icals.push(cal); } diff --git a/src/tests.rs b/src/tests.rs index 3216a80..32c3567 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use chrono::TimeZone as _; fn chicago_time( year: i32, @@ -22,6 +23,8 @@ fn dt_from_ts(ts: i64) -> DateTime { /// Expect that parsing a calendar works #[test] fn calendar_from_str() -> Result<()> { + use wac_ical::{Calendar, Config}; + // Blank lines added for clarity let s = r#" BEGIN:VCALENDAR @@ -64,7 +67,7 @@ END:VEVENT END:VCALENDAR "#; - let ical = ICal::read_from_str(ConfigIcal::default(), s)?; + let ical = Calendar::read_from_str(Config::default(), s)?; let now = dt_from_ts(1755000000); let params = Parameters::new(now)?; let instances = ical.event_instances(¶ms)?; @@ -82,6 +85,8 @@ END:VCALENDAR #[test] fn hand_written() -> Result<()> { + use wac_ical::{Calendar, Config}; + let s = r#" BEGIN:VCALENDAR @@ -102,7 +107,7 @@ END:VEVENT END:VCALENDAR "#; - let ical = ICal::read_from_str(ConfigIcal::default(), s)?; + let ical = Calendar::read_from_str(Config::default(), s)?; let params = Parameters { ignore_before: chicago_time(2025, 1, 1, 0, 0, 0), output_start: chicago_time(2025, 7, 1, 0, 0, 0), @@ -132,6 +137,8 @@ END:VCALENDAR /// Expect that recurrent exceptions work correctly and don't duplicate events #[test] fn recurrence_exceptions() -> Result<()> { + use wac_ical::{Calendar, Config}; + let s = r#" BEGIN:VCALENDAR @@ -188,7 +195,7 @@ END:VEVENT END:VCALENDAR "#; - let ical = ICal::read_from_str(ConfigIcal::default(), s)?; + let ical = Calendar::read_from_str(Config::default(), s)?; let params = Parameters { ignore_before: chicago_time(2025, 1, 1, 0, 0, 0), output_start: chicago_time(2025, 7, 1, 0, 0, 0), diff --git a/src/wac_ical.rs b/src/wac_ical.rs new file mode 100644 index 0000000..1913878 --- /dev/null +++ b/src/wac_ical.rs @@ -0,0 +1,261 @@ +//! 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) + } +} From 92c30167df2f61c89e400f67c9fb5fdccc8c5a30 Mon Sep 17 00:00:00 2001 From: _ <_@_> Date: Thu, 14 Aug 2025 03:33:12 +0000 Subject: [PATCH 5/6] refactored to make it more input-agnostic --- src/main.rs | 47 +++++++++++++++++++++++++---------------------- src/wac_ical.rs | 17 +++++++++++++++-- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index df3674b..d97701a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,7 +74,7 @@ struct CliAuto { } #[derive(clap::Parser)] -struct CliIcsDebug { +struct CliDebugOutput { #[arg(long)] config: Utf8PathBuf, } @@ -82,7 +82,7 @@ struct CliIcsDebug { #[derive(clap::Subcommand)] enum Commands { Auto(CliAuto), - IcsDebug(CliIcsDebug), + DebugOutput(CliDebugOutput), } #[derive(clap::Parser)] @@ -154,17 +154,26 @@ struct EventInstance { calendar_ui: CalendarUi, dtstart: DatePerhapsTime, location: Option, - recurrence_id: Option, + recurrence_id: Option, summary: Option, uid: Option, url: Option, } -/// Used to link recurrence exceptions to the original events they replace -#[derive(Eq, Ord, PartialOrd, PartialEq)] -struct RecurrenceKey<'a> { - recurrence_id: DatePerhapsTime, - uid: &'a str, +impl EventInstance { + fn filter(&self, config_output: &ConfigOutput) -> bool { + if let Some(uid) = &self.uid + && config_output.hide_uids.contains(uid) + { + return false; + } + if let Some(summary) = &self.summary + && config_output.hide_summaries.contains(summary) + { + return false; + } + true + } } #[derive(Default)] @@ -191,17 +200,11 @@ fn process_data<'a>( let mut instances = vec![]; for ical in &data.icals { - for ei in ical.event_instances(¶ms)? { - if let Some(uid) = &ei.uid - && config_output.hide_uids.contains(uid) - { - continue; - } - if let Some(summary) = &ei.summary - && config_output.hide_summaries.contains(summary) - { - continue; - } + for ei in ical + .event_instances(¶ms)? + .into_iter() + .filter(|x| x.filter(config_output)) + { instances.push(ei); } } @@ -412,7 +415,7 @@ async fn do_everything(cli: &CliAuto) -> Result<()> { } let bytes = resp.bytes().await?; - let temp_path = dl.file_path.with_extension(".ics.temp"); + let temp_path = dl.file_path.with_extension(".wac_temp"); std::fs::write(&temp_path, &bytes)?; std::fs::rename(&temp_path, &dl.file_path)?; } @@ -443,7 +446,7 @@ fn main_auto(cli: CliAuto) -> Result<()> { } } -fn main_ics_debug(cli: CliIcsDebug) -> Result<()> { +fn main_debug_output(cli: CliDebugOutput) -> Result<()> { tracing_subscriber::fmt::init(); tracing::info!("Started tracing"); let config = std::fs::read_to_string(&cli.config)?; @@ -464,6 +467,6 @@ fn main() -> Result<()> { match cli.command { Commands::Auto(x) => main_auto(x), - Commands::IcsDebug(x) => main_ics_debug(x), + Commands::DebugOutput(x) => main_debug_output(x), } } diff --git a/src/wac_ical.rs b/src/wac_ical.rs index 1913878..a7db026 100644 --- a/src/wac_ical.rs +++ b/src/wac_ical.rs @@ -1,6 +1,6 @@ //! 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 super::{CalendarUi, DatePerhapsTime, Downloadable, EventInstance, Parameters}; use anyhow::{Context as _, Result, anyhow}; use base64::Engine as _; use chrono::TimeZone as _; @@ -163,11 +163,17 @@ fn ical_event_instances( None }; + let recurrence_id = ev + .get_recurrence_id() + .as_ref() + .map(|x| normalize_date_perhaps_time(x, params.tz)) + .transpose()?; + 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(), + recurrence_id, summary: ev.get_summary().map(|s| s.to_string()), uid, url, @@ -177,6 +183,13 @@ fn ical_event_instances( instances } +/// Used to link recurrence exceptions to the original events they replace +#[derive(Eq, Ord, PartialOrd, PartialEq)] +struct RecurrenceKey<'a> { + recurrence_id: DatePerhapsTime, + uid: &'a str, +} + impl Calendar { pub(crate) fn read_from_str(config: Config, s: &str) -> Result { let cal = s.parse().map_err(|s| anyhow!("parse error {s}"))?; From d789425e478d1e39d27b834f56843e08e1a7b42f Mon Sep 17 00:00:00 2001 From: _ <_@_> Date: Thu, 14 Aug 2025 04:39:24 +0000 Subject: [PATCH 6/6] add parsing for Campfire --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 61 ++++++++++------ src/tests.rs | 42 ++++++++++- src/wac_campfire.rs | 173 ++++++++++++++++++++++++++++++++++++++++++++ src/wac_ical.rs | 22 +++--- 6 files changed, 263 insertions(+), 37 deletions(-) create mode 100644 src/wac_campfire.rs diff --git a/Cargo.lock b/Cargo.lock index e81b035..b5e5799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1878,6 +1878,7 @@ dependencies = [ "reqwest", "rrule", "serde", + "serde_json", "tokio", "toml", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 8628ae0..b986352 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ maud = "0.27.0" reqwest = "0.12.22" rrule = "0.14.0" serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.142" tokio = { version = "1.47.1", features = ["rt-multi-thread", "time"] } toml = "0.9.5" tracing = "0.1.41" diff --git a/src/main.rs b/src/main.rs index d97701a..c45cb81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use url::Url; #[cfg(test)] mod tests; +mod wac_campfire; mod wac_ical; #[derive(Clone, Default, Deserialize)] @@ -29,18 +30,6 @@ struct CalendarUi { 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 -#[derive(Clone, Deserialize)] -struct ConfigCampfire { - #[serde(flatten)] - dl: Downloadable, - - #[serde(flatten)] - ui: CalendarUi, -} - #[derive(Deserialize)] struct ConfigOutput { /// Used as the OpenGraph description in meta tags @@ -62,7 +51,7 @@ struct ConfigOutput { #[derive(Deserialize)] struct Config { - campfires: Vec, + campfires: Vec, icals: Vec, output: ConfigOutput, } @@ -131,6 +120,10 @@ impl Parameters { #[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)] struct DatePerhapsTime { dt: DateTime, + + /// True if the event has no specific time and takes all day on the given date + /// + /// Not implemented for Campfire because it hasn't shown up in the test data all_day: bool, } @@ -154,6 +147,9 @@ struct EventInstance { calendar_ui: CalendarUi, dtstart: DatePerhapsTime, location: Option, + /// Used internally to handle recurrence exceptions in ics + /// + /// Not implemented for Campfire recurrence_id: Option, summary: Option, uid: Option, @@ -178,17 +174,23 @@ impl EventInstance { #[derive(Default)] struct Data { + campfires: Vec, icals: Vec, } fn read_data_from_disk(config: &Config) -> Result { - let mut data = Data::default(); - for config_ical in &config.icals { - let cal = wac_ical::Calendar::read_from_downloadable(config_ical.clone())?; - data.icals.push(cal); - } - - Ok(data) + Ok(Data { + campfires: config + .campfires + .iter() + .map(|cfg| wac_campfire::Calendar::read_from_config(cfg.clone())) + .collect::, _>>()?, + icals: config + .icals + .iter() + .map(|cfg| wac_ical::Calendar::read_from_config(cfg.clone())) + .collect::, _>>()?, + }) } fn process_data<'a>( @@ -199,17 +201,28 @@ fn process_data<'a>( let params = Parameters::new(now)?; let mut instances = vec![]; - for ical in &data.icals { - for ei in ical + + for campfire in &data.campfires { + for ev in campfire .event_instances(¶ms)? .into_iter() .filter(|x| x.filter(config_output)) { - instances.push(ei); + instances.push(ev); } } - instances.sort_by_key(|ei| ei.dtstart); + for ical in &data.icals { + for ev in ical + .event_instances(¶ms)? + .into_iter() + .filter(|x| x.filter(config_output)) + { + instances.push(ev); + } + } + + instances.sort_by_key(|ev| ev.dtstart); Ok(instances) } diff --git a/src/tests.rs b/src/tests.rs index 32c3567..0bc0b8b 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -20,6 +20,44 @@ fn dt_from_ts(ts: i64) -> DateTime { .with_timezone(&chrono_tz::America::Chicago) } +#[test] +fn campfire() -> Result<()> { + use wac_campfire::{Calendar, Config}; + + let s = r#"{ + "message": "Success", + "eventList": [ + { + "urlToShare" : "https://example.com", + "timeZone" : "Central", + "startTime" : "7:00 AM", + "startDate" : "2025-09-13", + "location" : "Three Sisters Park, 17189 IL-29, Chillicothe", + "Id" : "701Po000011ncWKIAY", + "eventName" : "zero roman mummy hatch", + "endTime" : "12:00 PM", + "endDate" : "2025-09-13", + "description" : "Finally! It's just you, Marion, a division of one!" + } + ] + }"#; + + let cal = Calendar::read_from_str(Config::default(), s)?; + let now = dt_from_ts(1755000000); + let params = Parameters::new(now)?; + let instances = cal.event_instances(¶ms)?; + assert_eq!(instances.len(), 1); + + let event = &instances[0]; + let expected_time = DatePerhapsTime { + dt: chicago_time(2025, 9, 13, 7, 0, 0), + all_day: false, + }; + assert_eq!(event.dtstart, expected_time); + assert_eq!(event.summary.as_deref(), Some("zero roman mummy hatch")); + Ok(()) +} + /// Expect that parsing a calendar works #[test] fn calendar_from_str() -> Result<()> { @@ -67,10 +105,10 @@ END:VEVENT END:VCALENDAR "#; - let ical = Calendar::read_from_str(Config::default(), s)?; + let cal = Calendar::read_from_str(Config::default(), s)?; let now = dt_from_ts(1755000000); let params = Parameters::new(now)?; - let instances = ical.event_instances(¶ms)?; + let instances = cal.event_instances(¶ms)?; assert_eq!(instances.len(), 1); let event = &instances[0]; diff --git a/src/wac_campfire.rs b/src/wac_campfire.rs new file mode 100644 index 0000000..19cb589 --- /dev/null +++ b/src/wac_campfire.rs @@ -0,0 +1,173 @@ +//! Structs and functions specific to gathering input from Campfire, the special thing that Sierra Club uses for their events. +//! +//! Luckily it puts out JSON in a good format +//! +//! Note that recurring events aren't implemented for this cause I don't know how they work + +use super::{CalendarUi, DatePerhapsTime, Downloadable, EventInstance, Parameters}; +use anyhow::{Context as _, Result, bail}; +use serde::Deserialize; + +#[derive(Clone, Default, Deserialize)] +pub(crate) struct Config { + #[serde(flatten)] + pub(crate) dl: Downloadable, + + #[serde(flatten)] + pub(crate) ui: CalendarUi, +} + +#[derive(Deserialize)] +struct Event { + description: String, + #[serde(alias = "endDate")] + end_date: Option, + #[serde(alias = "endTime")] + end_time: Option, + #[serde(alias = "eventName")] + event_name: String, + location: String, + #[serde(alias = "Id")] + id: String, + #[serde(alias = "startDate")] + start_date: String, + #[serde(alias = "startTime")] + start_time: String, + #[serde(alias = "timeZone")] + time_zone: String, + #[serde(alias = "urlToShare")] + url_to_share: String, +} + +/// The bit that we deserialize directly from JSON +#[derive(Deserialize)] +struct CalendarInner { + #[serde(alias = "eventList")] + event_list: Vec, +} + +pub(crate) struct Calendar { + config: Config, + inner: CalendarInner, +} + +fn parse_campfire_datetime(date: &str, time: &str, tz: &str) -> Result { + // Campfire only uses American timezones apparently, because they don't follow tzdata. We'll compensate for that slightly here + + let tz = match tz { + "Central" => chrono_tz::US::Central, + "Eastern" => chrono_tz::US::Eastern, + "Mountain" => chrono_tz::US::Mountain, + "Pacific" => chrono_tz::US::Pacific, + _ => bail!("Can't recognize this timezone"), + }; + + let date = chrono::NaiveDate::parse_from_str(date, "%F").context("Couldn't parse date")?; + let time = + chrono::NaiveTime::parse_from_str(time, "%-I:%M %p").context("Couldn't parse time")?; + + let dt = date + .and_time(time) + .and_local_timezone(tz) + .single() + .context("Couldn't map timezones unambiguously")?; + Ok(DatePerhapsTime { dt, all_day: false }) +} + +impl Calendar { + pub(crate) fn event_instances(&self, params: &Parameters) -> Result> { + self.inner + .event_list + .iter() + .filter_map(|ev| { + let dtstart = + match parse_campfire_datetime(&ev.start_date, &ev.start_time, &ev.time_zone) + .context("Couldn't parse start time") + { + Ok(x) => x, + Err(e) => return Some(Err(e)), + }; + if dtstart.dt < params.output_start || dtstart.dt > params.output_stop { + return None; + } + + Some(Ok(EventInstance { + calendar_ui: self.config.ui.clone(), + dtstart, + location: Some(ev.location.clone()), + recurrence_id: None, + summary: Some(ev.event_name.clone()), + uid: Some(ev.id.clone()), + url: Some(ev.url_to_share.clone()), + })) + }) + .collect() + } + + pub(crate) fn read_from_str(config: Config, s: &str) -> Result { + let inner = serde_json::from_str(s)?; + Ok(Self { config, inner }) + } + + pub(crate) fn read_from_config(config: Config) -> Result { + let s = std::fs::read_to_string(&config.dl.file_path)?; + Self::read_from_str(config, &s) + } +} + +#[cfg(test)] +mod tests { + use chrono::{DateTime, TimeZone as _}; + + fn chicago_time( + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, + second: u32, + ) -> DateTime { + chrono_tz::America::Chicago + .with_ymd_and_hms(year, month, day, hour, minute, second) + .unwrap() + } + + #[test] + fn parse_campfire_datetime() { + for (date, time, tz, expected) in [ + ( + "2025-08-02", + "7:00 AM", + "Central", + chicago_time(2025, 8, 2, 7, 0, 0), + ), + ( + "2025-08-09", + "11:00 AM", + "Central", + chicago_time(2025, 8, 9, 11, 0, 0), + ), + ( + "2025-08-12", + "3:15 PM", + "Central", + chicago_time(2025, 8, 12, 15, 15, 0), + ), + ] { + assert_eq!( + super::parse_campfire_datetime(date, time, tz).unwrap().dt, + expected + ); + } + + // Negative cases + + for (date, time, tz) in [ + ("2025-08-02", "7:00 AM", "Alaska"), + ("2025-08-02", "", "Central"), + ("2025-08-02", "All day", "Central"), + ] { + assert!(super::parse_campfire_datetime(date, time, tz).is_err()); + } + } +} diff --git a/src/wac_ical.rs b/src/wac_ical.rs index a7db026..20649ed 100644 --- a/src/wac_ical.rs +++ b/src/wac_ical.rs @@ -191,17 +191,6 @@ struct RecurrenceKey<'a> { } 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 { @@ -271,4 +260,15 @@ impl Calendar { Ok(instances) } + + 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_config(config: Config) -> Result { + let s = std::fs::read_to_string(&config.dl.file_path)?; + Self::read_from_str(config, &s) + } }