From d789425e478d1e39d27b834f56843e08e1a7b42f Mon Sep 17 00:00:00 2001 From: _ <_@_> Date: Thu, 14 Aug 2025 04:39:24 +0000 Subject: [PATCH] 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) + } }