add parsing for Campfire
This commit is contained in:
parent
92c30167df
commit
d789425e47
6 changed files with 263 additions and 37 deletions
61
src/main.rs
61
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<ConfigCampfire>,
|
||||
campfires: Vec<wac_campfire::Config>,
|
||||
icals: Vec<wac_ical::Config>,
|
||||
output: ConfigOutput,
|
||||
}
|
||||
|
@ -131,6 +120,10 @@ impl Parameters {
|
|||
#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
|
||||
struct DatePerhapsTime {
|
||||
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,
|
||||
}
|
||||
|
||||
|
@ -154,6 +147,9 @@ struct EventInstance {
|
|||
calendar_ui: CalendarUi,
|
||||
dtstart: DatePerhapsTime,
|
||||
location: Option<String>,
|
||||
/// Used internally to handle recurrence exceptions in ics
|
||||
///
|
||||
/// Not implemented for Campfire
|
||||
recurrence_id: Option<DatePerhapsTime>,
|
||||
summary: Option<String>,
|
||||
uid: Option<String>,
|
||||
|
@ -178,17 +174,23 @@ impl EventInstance {
|
|||
|
||||
#[derive(Default)]
|
||||
struct Data {
|
||||
campfires: Vec<wac_campfire::Calendar>,
|
||||
icals: Vec<wac_ical::Calendar>,
|
||||
}
|
||||
|
||||
fn read_data_from_disk(config: &Config) -> Result<Data> {
|
||||
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::<Result<Vec<_>, _>>()?,
|
||||
icals: config
|
||||
.icals
|
||||
.iter()
|
||||
.map(|cfg| wac_ical::Calendar::read_from_config(cfg.clone()))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
42
src/tests.rs
42
src/tests.rs
|
@ -20,6 +20,44 @@ fn dt_from_ts(ts: i64) -> DateTime<chrono_tz::Tz> {
|
|||
.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];
|
||||
|
|
173
src/wac_campfire.rs
Normal file
173
src/wac_campfire.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -191,17 +191,6 @@ struct RecurrenceKey<'a> {
|
|||
}
|
||||
|
||||
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> {
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue