checkpoint

This commit is contained in:
_ 2025-08-14 03:07:42 +00:00
parent 3b79aa21d2
commit 49f48acaf1
2 changed files with 109 additions and 75 deletions

View file

@ -13,7 +13,7 @@ use url::Url;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[derive(Clone, Deserialize)] #[derive(Clone, Default, Deserialize)]
struct Downloadable { struct Downloadable {
/// URL to scrape to download the JSON /// URL to scrape to download the JSON
download_url: Option<Url>, download_url: Option<Url>,
@ -22,6 +22,15 @@ struct Downloadable {
file_path: Utf8PathBuf, file_path: Utf8PathBuf,
} }
#[derive(Clone, Default, Deserialize)]
struct CalendarUi {
/// A canonical webpage we can direct users to
html_url: Option<Url>,
/// 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 /// 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 /// 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)] #[serde(flatten)]
dl: Downloadable, dl: Downloadable,
/// A canonical webpage we can direct users to #[serde(flatten)]
html_url: Option<Url>, ui: CalendarUi,
/// 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 /// Google Calendar has a public ics endpoint that we scrape for all upstream Google Calendars
#[derive(Clone, Deserialize)] #[derive(Clone, Default, Deserialize)]
struct ConfigIcal { struct ConfigIcal {
#[serde(flatten)] #[serde(flatten)]
dl: Downloadable, dl: Downloadable,
@ -46,11 +52,8 @@ struct ConfigIcal {
/// Magical ID we pass to Google to deep-link to Google Calendar events /// Magical ID we pass to Google to deep-link to Google Calendar events
google_id: Option<String>, google_id: Option<String>,
/// A canonical webpage we can direct users to #[serde(flatten)]
html_url: Option<Url>, ui: CalendarUi,
/// Very short name for putting on each event
short_name: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -232,14 +235,24 @@ fn recurring_dates(
} }
/// An event that's been duplicated according to its recurrence rules, so we can sort by datetimes /// 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, dtstart: DatePerhapsTime,
ev: &'a icalendar::Event, // ev: &'a icalendar::Event,
location: Option<String>,
recurrence_id: Option<icalendar::DatePerhapsTime>,
summary: Option<String>,
uid: Option<String>,
url: Option<String>,
} }
impl EventInstance<'_> { fn google_url(
fn google_url(&self, google_id: &str) -> Result<Option<String>> { dtstart: DatePerhapsTime,
let uid = self.ev.get_uid().context("No UID")?; has_rrule: bool,
uid: Option<&str>,
google_id: &str,
) -> Result<Option<String>> {
let uid = uid.context("No UID")?;
if uid.len() > 100 { 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. // 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); return Ok(None);
@ -248,13 +261,12 @@ impl EventInstance<'_> {
// Strip off the back part of the Google UID // Strip off the back part of the Google UID
let idx = uid.find(['@', '_']).unwrap_or(uid.len()); let idx = uid.find(['@', '_']).unwrap_or(uid.len());
let uid_2 = &uid[..idx]; let uid_2 = &uid[..idx];
let utc_dtstart = self let utc_dtstart = dtstart
.dtstart
.dt .dt
.with_timezone(&chrono_tz::UTC) .with_timezone(&chrono_tz::UTC)
.format("%Y%m%dT%H%M%SZ") .format("%Y%m%dT%H%M%SZ")
.to_string(); .to_string();
let eid_plain = if self.ev.properties().get("RRULE").is_some() { let eid_plain = if has_rrule {
// Recurring events have an extra timestamp in their base64 to disambiguiate // Recurring events have an extra timestamp in their base64 to disambiguiate
format!("{uid_2}_{utc_dtstart} {google_id}") format!("{uid_2}_{utc_dtstart} {google_id}")
} else { } else {
@ -265,7 +277,8 @@ impl EventInstance<'_> {
link.query_pairs_mut().append_pair("eid", &eid); link.query_pairs_mut().append_pair("eid", &eid);
Ok(Some(link.to_string())) Ok(Some(link.to_string()))
} }
/*
impl EventInstance {
fn url(&self, google_id: Option<&str>) -> Result<Option<String>> { fn url(&self, google_id: Option<&str>) -> Result<Option<String>> {
if let Some(url) = self.ev.get_url() { if let Some(url) = self.ev.get_url() {
return Ok(Some(url.to_string())); 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, params: &Parameters,
ev: &'a icalendar::Event, ev: &icalendar::Event,
) -> Result<Vec<EventInstance<'a>>> { ) -> Result<Vec<EventInstance>> {
let dates = if let Some(rrule) = ev.properties().get("RRULE") { let dates = if let Some(rrule) = ev.properties().get("RRULE") {
recurring_dates(params, ev, rrule)?.collect() recurring_dates(params, ev, rrule)?.collect()
} else { } else {
@ -316,9 +330,29 @@ fn event_instances<'a>(
let instances = dates let instances = dates
.into_iter() .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(); .collect();
Ok(instances) instances
} }
struct ICal { struct ICal {
@ -356,12 +390,16 @@ impl ICal {
} }
/// Returns an unsorted list of event instances for this calendar /// Returns an unsorted list of event instances for this calendar
fn event_instances(&self, params: &Parameters) -> Result<Vec<EventInstance<'_>>> { fn event_instances(
&self,
config_ical: &ConfigIcal,
params: &Parameters,
) -> Result<Vec<EventInstance>> {
let mut instances = vec![]; let mut instances = vec![];
let mut recurrence_exceptions = BTreeSet::new(); let mut recurrence_exceptions = BTreeSet::new();
for ev in self.events() { 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())) .with_context(|| format!("Failed to process event with UID '{:?}'", ev.get_uid()))
{ {
Ok(x) => x, 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. // There is probably a not-linear-time way to do this, but this should be fine.
instances.retain(|ev| { 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 // This is a recurrence exception, exceptions never delete themselves
return true; 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 // If there's no UID, we can't apply recurrence exceptions
return true; return true;
}; };
@ -435,23 +473,23 @@ fn process_data<'a>(
data: &'a Data, data: &'a Data,
config_output: &'a ConfigOutput, config_output: &'a ConfigOutput,
now: DateTime<chrono_tz::Tz>, now: DateTime<chrono_tz::Tz>,
) -> Result<Vec<EventWithUrl<'a>>> { ) -> Result<Vec<EventInstance>> {
let params = Parameters::new(now)?; let params = Parameters::new(now)?;
let mut instances = vec![]; let mut instances = vec![];
for (ical, config) in &data.icals { for (ical, config) in &data.icals {
for ei in ical.event_instances(&params)? { for ei in ical.event_instances(config, &params)? {
if let Some(uid) = ei.ev.get_uid() if let Some(uid) = &ei.uid
&& config_output.hide_uids.contains(uid) && config_output.hide_uids.contains(uid)
{ {
continue; continue;
} }
if let Some(summary) = ei.ev.get_summary() if let Some(summary) = &ei.summary
&& config_output.hide_summaries.contains(summary) && config_output.hide_summaries.contains(summary)
{ {
continue; continue;
} }
let ei = EventWithUrl::from_ei(config, ei)?; // let ei = EventWithUrl::from_ei(config, ei)?;
instances.push(ei); instances.push(ei);
} }
} }
@ -463,7 +501,7 @@ fn process_data<'a>(
// FIXME: Don't print to stdout / stderr // FIXME: Don't print to stdout / stderr
fn output_html( fn output_html(
config: &ConfigOutput, config: &ConfigOutput,
instances: &[EventWithUrl], instances: &[EventInstance],
now: DateTime<chrono_tz::Tz>, now: DateTime<chrono_tz::Tz>,
) -> Result<()> { ) -> Result<()> {
let today = now.date_naive(); let today = now.date_naive();
@ -472,11 +510,6 @@ fn output_html(
let mut html_list = vec![]; let mut html_list = vec![];
let mut day_list = vec![]; let mut day_list = vec![];
for ei in instances { 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 date = ei.dtstart.date_naive();
let past = date < today; let past = date < today;
let month = date.format("%B").to_string(); let month = date.format("%B").to_string();
@ -531,35 +564,36 @@ fn output_html(
.map(|t| t.format("%l:%M %P").to_string()) .map(|t| t.format("%l:%M %P").to_string())
.unwrap_or_else(|| "All day".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 { let summary = if let Some(url) = &ei.url {
maud::html! {a href=(url) {(summary)}} maud::html! {a href=(url) {(summary)}}
} else { } else {
maud::html! {(summary)} maud::html! {(summary)}
}; };
let location = ei.ev.get_location();
if past { if past {
day_list.push(maud::html! { day_list.push(maud::html! {
li class="past" { (time) " - " (summary) } li class="past" { (time) " - " (summary) }
}); });
} else { } else {
let calendar_link = if let Some(html_url) = &ei.calendar.html_url { let calendar_link = if let Some(html_url) = &ei.calendar_ui.html_url {
maud::html! { a href=(html_url) { (ei.calendar.short_name) } } maud::html! { a href=(html_url) { (ei.calendar_ui.short_name) } }
} else { } else {
maud::html! { (ei.calendar.short_name)} maud::html! { (ei.calendar_ui.short_name)}
}; };
// This is where the main stuff happens // 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! { day_list.push(maud::html! {
li { details { li { details {
summary { (time) " - " (summary) } summary { (time) " - " (summary) }
ul { ul {
li { (calendar_link) " calendar" } li { (calendar_link) " calendar" }
@if let Some(location) = location { @if let Some(location) = &ei.location {
li { "Location: " (location) } li { "Location: " (location) }
} }
} }

View file

@ -67,7 +67,7 @@ END:VCALENDAR
let ical = ICal::read_from_str(s)?; let ical = ICal::read_from_str(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 = ical.event_instances(&ConfigIcal::default(), &params)?;
assert_eq!(instances.len(), 1); assert_eq!(instances.len(), 1);
let event = &instances[0]; let event = &instances[0];
@ -76,7 +76,7 @@ END:VCALENDAR
all_day: false, all_day: false,
}; };
assert_eq!(event.dtstart, expected_time); 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(()) Ok(())
} }
@ -109,7 +109,7 @@ END:VCALENDAR
output_stop: chicago_time(2025, 10, 1, 0, 0, 0), output_stop: chicago_time(2025, 10, 1, 0, 0, 0),
tz: chrono_tz::America::Chicago, tz: chrono_tz::America::Chicago,
}; };
let instances = ical.event_instances(&params)?; let instances = ical.event_instances(&ConfigIcal::default(), &params)?;
assert_eq!( assert_eq!(
[instances[0].dtstart, instances[1].dtstart,], [instances[0].dtstart, instances[1].dtstart,],
@ -195,7 +195,7 @@ END:VCALENDAR
output_stop: chicago_time(2025, 10, 1, 0, 0, 0), output_stop: chicago_time(2025, 10, 1, 0, 0, 0),
tz: chrono_tz::America::Chicago, tz: chrono_tz::America::Chicago,
}; };
let instances = ical.event_instances(&params)?; let instances = ical.event_instances(&ConfigIcal::default(), &params)?;
assert_eq!( assert_eq!(
[ [
@ -220,7 +220,7 @@ END:VCALENDAR
); );
for instance in &instances { 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); assert_eq!(instances.len(), 3);