add parsing for Campfire

This commit is contained in:
_ 2025-08-14 04:39:24 +00:00
parent 92c30167df
commit d789425e47
6 changed files with 263 additions and 37 deletions

1
Cargo.lock generated
View file

@ -1878,6 +1878,7 @@ dependencies = [
"reqwest", "reqwest",
"rrule", "rrule",
"serde", "serde",
"serde_json",
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",

View file

@ -15,6 +15,7 @@ maud = "0.27.0"
reqwest = "0.12.22" reqwest = "0.12.22"
rrule = "0.14.0" rrule = "0.14.0"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
tokio = { version = "1.47.1", features = ["rt-multi-thread", "time"] } tokio = { version = "1.47.1", features = ["rt-multi-thread", "time"] }
toml = "0.9.5" toml = "0.9.5"
tracing = "0.1.41" tracing = "0.1.41"

View file

@ -9,6 +9,7 @@ use url::Url;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod wac_campfire;
mod wac_ical; mod wac_ical;
#[derive(Clone, Default, Deserialize)] #[derive(Clone, Default, Deserialize)]
@ -29,18 +30,6 @@ struct CalendarUi {
short_name: String, 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)] #[derive(Deserialize)]
struct ConfigOutput { struct ConfigOutput {
/// Used as the OpenGraph description in meta tags /// Used as the OpenGraph description in meta tags
@ -62,7 +51,7 @@ struct ConfigOutput {
#[derive(Deserialize)] #[derive(Deserialize)]
struct Config { struct Config {
campfires: Vec<ConfigCampfire>, campfires: Vec<wac_campfire::Config>,
icals: Vec<wac_ical::Config>, icals: Vec<wac_ical::Config>,
output: ConfigOutput, output: ConfigOutput,
} }
@ -131,6 +120,10 @@ impl Parameters {
#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
struct DatePerhapsTime { struct DatePerhapsTime {
dt: DateTime<chrono_tz::Tz>, dt: DateTime<chrono_tz::Tz>,
/// 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, all_day: bool,
} }
@ -154,6 +147,9 @@ struct EventInstance {
calendar_ui: CalendarUi, calendar_ui: CalendarUi,
dtstart: DatePerhapsTime, dtstart: DatePerhapsTime,
location: Option<String>, location: Option<String>,
/// Used internally to handle recurrence exceptions in ics
///
/// Not implemented for Campfire
recurrence_id: Option<DatePerhapsTime>, recurrence_id: Option<DatePerhapsTime>,
summary: Option<String>, summary: Option<String>,
uid: Option<String>, uid: Option<String>,
@ -178,17 +174,23 @@ impl EventInstance {
#[derive(Default)] #[derive(Default)]
struct Data { struct Data {
campfires: Vec<wac_campfire::Calendar>,
icals: Vec<wac_ical::Calendar>, icals: Vec<wac_ical::Calendar>,
} }
fn read_data_from_disk(config: &Config) -> Result<Data> { fn read_data_from_disk(config: &Config) -> Result<Data> {
let mut data = Data::default(); Ok(Data {
for config_ical in &config.icals { campfires: config
let cal = wac_ical::Calendar::read_from_downloadable(config_ical.clone())?; .campfires
data.icals.push(cal); .iter()
} .map(|cfg| wac_campfire::Calendar::read_from_config(cfg.clone()))
.collect::<Result<Vec<_>, _>>()?,
Ok(data) icals: config
.icals
.iter()
.map(|cfg| wac_ical::Calendar::read_from_config(cfg.clone()))
.collect::<Result<Vec<_>, _>>()?,
})
} }
fn process_data<'a>( fn process_data<'a>(
@ -199,17 +201,28 @@ fn process_data<'a>(
let params = Parameters::new(now)?; let params = Parameters::new(now)?;
let mut instances = vec![]; let mut instances = vec![];
for ical in &data.icals {
for ei in ical for campfire in &data.campfires {
for ev in campfire
.event_instances(&params)? .event_instances(&params)?
.into_iter() .into_iter()
.filter(|x| x.filter(config_output)) .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(&params)?
.into_iter()
.filter(|x| x.filter(config_output))
{
instances.push(ev);
}
}
instances.sort_by_key(|ev| ev.dtstart);
Ok(instances) Ok(instances)
} }

View file

@ -20,6 +20,44 @@ fn dt_from_ts(ts: i64) -> DateTime<chrono_tz::Tz> {
.with_timezone(&chrono_tz::America::Chicago) .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(&params)?;
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 /// Expect that parsing a calendar works
#[test] #[test]
fn calendar_from_str() -> Result<()> { fn calendar_from_str() -> Result<()> {
@ -67,10 +105,10 @@ END:VEVENT
END:VCALENDAR 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 now = dt_from_ts(1755000000);
let params = Parameters::new(now)?; let params = Parameters::new(now)?;
let instances = ical.event_instances(&params)?; let instances = cal.event_instances(&params)?;
assert_eq!(instances.len(), 1); assert_eq!(instances.len(), 1);
let event = &instances[0]; let event = &instances[0];

173
src/wac_campfire.rs Normal file
View file

@ -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<String>,
#[serde(alias = "endTime")]
end_time: Option<String>,
#[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<Event>,
}
pub(crate) struct Calendar {
config: Config,
inner: CalendarInner,
}
fn parse_campfire_datetime(date: &str, time: &str, tz: &str) -> Result<DatePerhapsTime> {
// 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<Vec<EventInstance>> {
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<Self> {
let inner = serde_json::from_str(s)?;
Ok(Self { config, inner })
}
pub(crate) fn read_from_config(config: Config) -> Result<Self> {
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::Tz> {
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());
}
}
}

View file

@ -191,17 +191,6 @@ struct RecurrenceKey<'a> {
} }
impl Calendar { impl Calendar {
pub(crate) fn read_from_str(config: Config, s: &str) -> Result<Self> {
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<Self> {
let s = std::fs::read_to_string(&config.dl.file_path)?;
Self::read_from_str(config, &s)
}
fn events(&self) -> impl Iterator<Item = &icalendar::Event> { fn events(&self) -> impl Iterator<Item = &icalendar::Event> {
self.cal.components.iter().filter_map(|comp| { self.cal.components.iter().filter_map(|comp| {
if let icalendar::CalendarComponent::Event(ev) = comp { if let icalendar::CalendarComponent::Event(ev) = comp {
@ -271,4 +260,15 @@ impl Calendar {
Ok(instances) Ok(instances)
} }
pub(crate) fn read_from_str(config: Config, s: &str) -> Result<Self> {
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<Self> {
let s = std::fs::read_to_string(&config.dl.file_path)?;
Self::read_from_str(config, &s)
}
} }