diff --git a/Cargo.lock b/Cargo.lock index b5e5799..04362c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,15 +148,6 @@ 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" @@ -1869,7 +1860,6 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", - "camino", "chrono", "chrono-tz", "clap", @@ -1878,7 +1868,6 @@ dependencies = [ "reqwest", "rrule", "serde", - "serde_json", "tokio", "toml", "tracing", diff --git a/Cargo.toml b/Cargo.toml index b986352..4271d6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ 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"] } @@ -15,7 +14,6 @@ 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 c45cb81..e182d7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,32 @@ -use anyhow::{Context as _, Result, bail}; -use camino::Utf8PathBuf; -use chrono::{DateTime, Utc}; +use anyhow::{Context as _, Result, anyhow, bail}; +use base64::Engine as _; +use chrono::{DateTime, TimeZone as _, Utc}; use clap::Parser as _; +use icalendar::{Component as _, EventLike as _}; use serde::Deserialize; -use std::{collections::BTreeSet, io::Write as _, time::Duration}; -use url::Url; +use std::{ + collections::BTreeSet, io::Write as _, path::PathBuf, str::FromStr as _, time::Duration, +}; #[cfg(test)] mod tests; -mod wac_campfire; -mod wac_ical; +#[derive(Clone, Deserialize)] +struct ConfigIcal { + /// Disk location to cache the ics file for debugging + file_path: PathBuf, -#[derive(Clone, Default, Deserialize)] -struct Downloadable { - /// URL to scrape to download the JSON - download_url: Option, + /// Magical ID we pass to Google to deep-link to Google Calendar events + google_id: Option, - /// Disk location to cache the JSON file for debugging - file_path: Utf8PathBuf, -} - -#[derive(Clone, Default, Deserialize)] -struct CalendarUi { /// 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)] @@ -51,27 +50,26 @@ struct ConfigOutput { #[derive(Deserialize)] struct Config { - campfires: Vec, - icals: Vec, + icals: Vec, output: ConfigOutput, } #[derive(clap::Parser)] struct CliAuto { #[arg(long)] - config: Utf8PathBuf, + config: PathBuf, } #[derive(clap::Parser)] -struct CliDebugOutput { +struct CliIcsDebug { #[arg(long)] - config: Utf8PathBuf, + config: PathBuf, } #[derive(clap::Subcommand)] enum Commands { Auto(CliAuto), - DebugOutput(CliDebugOutput), + IcsDebug(CliIcsDebug), } #[derive(clap::Parser)] @@ -120,10 +118,6 @@ 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, } @@ -142,93 +136,309 @@ 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, +struct EventInstance<'a> { dtstart: DatePerhapsTime, - location: Option, - /// Used internally to handle recurrence exceptions in ics - /// - /// Not implemented for Campfire - recurrence_id: Option, - summary: Option, - uid: Option, + ev: &'a icalendar::Event, +} + +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 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 EventInstance { - fn filter(&self, config_output: &ConfigOutput) -> bool { - if let Some(uid) = &self.uid - && config_output.hide_uids.contains(uid) +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 event_instances<'a>( + params: &Parameters, + ev: &'a 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 false; + return Ok(vec![]); } - if let Some(summary) = &self.summary - && config_output.hide_summaries.contains(summary) - { - return false; + vec![dtstart_normalized] + }; + + let instances = dates + .into_iter() + .map(|dtstart| EventInstance { dtstart, ev }) + .collect(); + Ok(instances) +} + +struct ICal { + /// The parsed ics file + cal: icalendar::Calendar, +} + +/// 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 ICal { + fn read_from_str(s: &str) -> Result { + let cal = s.parse().map_err(|s| anyhow!("parse error {s}"))?; + let cal = Self { cal }; + Ok(cal) + } + + fn read_from_config(config: &ConfigIcal) -> Result { + let s = std::fs::read_to_string(&config.file_path)?; + Self::read_from_str(&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 event_instances(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 }); + } } - true + + // 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.ev.get_recurrence_id().is_some() { + // This is a recurrence exception, exceptions never delete themselves + return true; + } + + let Some(uid) = ev.ev.get_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 { - campfires: Vec, - icals: Vec, + icals: Vec<(ICal, ConfigIcal)>, } fn read_data_from_disk(config: &Config) -> Result { - 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::, _>>()?, - }) + let mut data = Data::default(); + for config in &config.icals { + let cal = ICal::read_from_config(config)?; + data.icals.push((cal, config.clone())); + } + + Ok(data) } 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 campfire in &data.campfires { - for ev in campfire - .event_instances(¶ms)? - .into_iter() - .filter(|x| x.filter(config_output)) - { - instances.push(ev); + for (ical, config) in &data.icals { + for ei in ical.event_instances(¶ms)? { + if let Some(uid) = ei.ev.get_uid() + && config_output.hide_uids.contains(uid) + { + continue; + } + if let Some(summary) = ei.ev.get_summary() + && config_output.hide_summaries.contains(summary) + { + continue; + } + let ei = EventWithUrl::from_ei(config, ei)?; + instances.push(ei); } } - 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); + instances.sort_by_key(|ei| ei.dtstart); Ok(instances) } +// FIXME: Don't print to stdout / stderr fn output_html( config: &ConfigOutput, - instances: &[EventInstance], + instances: &[EventWithUrl], now: DateTime, ) -> Result<()> { let today = now.date_naive(); @@ -237,6 +447,11 @@ 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(); @@ -260,6 +475,8 @@ fn output_html( } } if last_date_printed != Some(date) { + // println!("{date}"); + // FIXME: De-dupe if !day_list.is_empty() { html_list.push(maud::html! { @@ -289,36 +506,35 @@ fn output_html( .map(|t| t.format("%l:%M %P").to_string()) .unwrap_or_else(|| "All day".to_string()); - let summary = ei - .summary - .as_deref() - .unwrap_or("Data error BXH45NAR - No summary in event"); + // println!(" {time} - {summary}"); 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_ui.html_url { - maud::html! { a href=(html_url) { (ei.calendar_ui.short_name) } } + let calendar_link = if let Some(html_url) = &ei.calendar.html_url { + maud::html! { a href=(html_url) { (ei.calendar.short_name) } } } else { - maud::html! { (ei.calendar_ui.short_name)} + maud::html! { (ei.calendar.short_name)} }; // This is where the main stuff happens - tracing::debug!(uid = ei.uid, summary = ei.summary); + tracing::debug!(uid = ei.ev.get_uid(), summary = ei.ev.get_summary()); day_list.push(maud::html! { li { details { summary { (time) " - " (summary) } ul { li { (calendar_link) " calendar" } - @if let Some(location) = &ei.location { + @if let Some(location) = location { li { "Location: " (location) } } } @@ -412,25 +628,20 @@ async fn do_everything(cli: &CliAuto) -> Result<()> { let client = reqwest::Client::builder() .user_agent(APP_USER_AGENT) .build()?; - 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 { + for ical in &config.icals { + let Some(ics_url) = &ical.ics_url else { continue; }; - tracing::info!(url = download_url.to_string(), "requesting..."); - let resp = client.get(download_url.clone()).send().await?; + tracing::info!(url = ics_url.to_string(), "requesting..."); + let resp = client.get(ics_url.clone()).send().await?; if resp.status() != 200 { bail!("Bad status {}", resp.status()); } let bytes = resp.bytes().await?; - let temp_path = dl.file_path.with_extension(".wac_temp"); + let temp_path = ical.file_path.with_extension(".ics.temp"); std::fs::write(&temp_path, &bytes)?; - std::fs::rename(&temp_path, &dl.file_path)?; + std::fs::rename(&temp_path, &ical.file_path)?; } let data = read_data_from_disk(&config)?; @@ -459,7 +670,7 @@ fn main_auto(cli: CliAuto) -> Result<()> { } } -fn main_debug_output(cli: CliDebugOutput) -> Result<()> { +fn main_ics_debug(cli: CliIcsDebug) -> Result<()> { tracing_subscriber::fmt::init(); tracing::info!("Started tracing"); let config = std::fs::read_to_string(&cli.config)?; @@ -480,6 +691,6 @@ fn main() -> Result<()> { match cli.command { Commands::Auto(x) => main_auto(x), - Commands::DebugOutput(x) => main_debug_output(x), + Commands::IcsDebug(x) => main_ics_debug(x), } } diff --git a/src/tests.rs b/src/tests.rs index 0bc0b8b..8e27b52 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,5 +1,4 @@ use super::*; -use chrono::TimeZone as _; fn chicago_time( year: i32, @@ -20,49 +19,9 @@ 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<()> { - use wac_ical::{Calendar, Config}; - // Blank lines added for clarity let s = r#" BEGIN:VCALENDAR @@ -105,10 +64,10 @@ END:VEVENT END:VCALENDAR "#; - let cal = Calendar::read_from_str(Config::default(), s)?; + let ical = ICal::read_from_str(s)?; let now = dt_from_ts(1755000000); let params = Parameters::new(now)?; - let instances = cal.event_instances(¶ms)?; + let instances = ical.event_instances(¶ms)?; assert_eq!(instances.len(), 1); let event = &instances[0]; @@ -117,14 +76,12 @@ END:VCALENDAR all_day: false, }; assert_eq!(event.dtstart, expected_time); - assert_eq!(event.summary.as_deref(), Some("zero roman mummy hatch")); + assert_eq!(event.ev.get_summary(), Some("zero roman mummy hatch")); Ok(()) } #[test] fn hand_written() -> Result<()> { - use wac_ical::{Calendar, Config}; - let s = r#" BEGIN:VCALENDAR @@ -145,7 +102,7 @@ END:VEVENT END:VCALENDAR "#; - let ical = Calendar::read_from_str(Config::default(), s)?; + let ical = ICal::read_from_str(s)?; let params = Parameters { ignore_before: chicago_time(2025, 1, 1, 0, 0, 0), output_start: chicago_time(2025, 7, 1, 0, 0, 0), @@ -175,8 +132,6 @@ 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 @@ -233,7 +188,7 @@ END:VEVENT END:VCALENDAR "#; - let ical = Calendar::read_from_str(Config::default(), s)?; + let ical = ICal::read_from_str(s)?; let params = Parameters { ignore_before: chicago_time(2025, 1, 1, 0, 0, 0), output_start: chicago_time(2025, 7, 1, 0, 0, 0), @@ -265,7 +220,7 @@ END:VCALENDAR ); for instance in &instances { - assert_eq!(instance.summary.as_deref(), Some("coil perm brush zippy")); + assert_eq!(instance.ev.get_summary(), Some("coil perm brush zippy")); } assert_eq!(instances.len(), 3); diff --git a/src/wac_campfire.rs b/src/wac_campfire.rs deleted file mode 100644 index 19cb589..0000000 --- a/src/wac_campfire.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! 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 deleted file mode 100644 index 20649ed..0000000 --- a/src/wac_ical.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! 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}; -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 - }; - - 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, - summary: ev.get_summary().map(|s| s.to_string()), - uid, - url, - }) - }) - .collect(); - 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 { - 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) - } - - 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) - } -}