checkpoint
This commit is contained in:
parent
3b79aa21d2
commit
49f48acaf1
2 changed files with 109 additions and 75 deletions
174
src/main.rs
174
src/main.rs
|
@ -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,40 +235,50 @@ 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,
|
||||||
if uid.len() > 100 {
|
uid: Option<&str>,
|
||||||
// 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.
|
google_id: &str,
|
||||||
return Ok(None);
|
) -> Result<Option<String>> {
|
||||||
}
|
let uid = uid.context("No UID")?;
|
||||||
|
if uid.len() > 100 {
|
||||||
// Strip off the back part of the Google UID
|
// 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.
|
||||||
let idx = uid.find(['@', '_']).unwrap_or(uid.len());
|
return Ok(None);
|
||||||
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()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()))
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
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(¶ms)? {
|
for ei in ical.event_instances(config, ¶ms)? {
|
||||||
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
10
src/tests.rs
10
src/tests.rs
|
@ -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(¶ms)?;
|
let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?;
|
||||||
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(¶ms)?;
|
let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?;
|
||||||
|
|
||||||
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(¶ms)?;
|
let instances = ical.event_instances(&ConfigIcal::default(), ¶ms)?;
|
||||||
|
|
||||||
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);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue