run fully automated

This commit is contained in:
_ 2025-08-11 22:27:25 +00:00
parent 294d95e80b
commit 7a2fba6804
4 changed files with 2002 additions and 172 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/output
/target /target
/untracked /untracked

1527
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,17 @@ edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0.98" anyhow = "1.0.98"
base64 = "0.22.1"
chrono = "0.4.41" chrono = "0.4.41"
chrono-tz = "0.10.4" chrono-tz = { version = "0.10.4", features = ["serde"] }
clap = { version = "4.5.43", features = ["derive"] } clap = { version = "4.5.43", features = ["derive"] }
icalendar = { version = "0.17.1", features = ["chrono-tz", "parser", "serde"] } icalendar = { version = "0.17.1", features = ["chrono-tz", "parser", "serde"] }
maud = "0.27.0"
reqwest = "0.12.22"
rrule = "0.14.0" rrule = "0.14.0"
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "time"] }
toml = "0.9.5"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
url = { version = "2.5.4", features = ["serde"] }

View file

@ -1,27 +1,58 @@
use anyhow::{Context as _, Result, anyhow, bail}; use anyhow::{Context as _, Result, anyhow, bail};
use base64::Engine as _;
use chrono::{DateTime, TimeZone as _, Utc}; use chrono::{DateTime, TimeZone as _, Utc};
use clap::Parser as _; use clap::Parser as _;
use icalendar::Component as _; use icalendar::{Component as _, EventLike as _};
use std::{ use serde::Deserialize;
convert::TryInto as _, use std::{io::Write as _, path::PathBuf, str::FromStr as _, time::Duration};
path::{Path, PathBuf},
str::FromStr,
time::Duration,
};
#[derive(clap::Parser)] #[derive(Clone, Deserialize)]
struct CliIcsDebug { struct ConfigIcal {
/// Disk location to cache the ics file for debugging
file_path: PathBuf,
/// Magical ID we pass to Google to deep-link to Google Calendar events
google_id: Option<String>,
/// A canonical webpage we can direct users to
html_url: url::Url,
/// Very short name for putting on each event
short_name: String,
/// URL to scrape to download the ics file
ics_url: url::Url,
}
#[derive(Deserialize)]
struct ConfigOutput {
/// Timezone to use for output (e.g. "Antarctica/South_Pole") /// Timezone to use for output (e.g. "Antarctica/South_Pole")
/// ///
/// <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones> /// <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
#[arg(long)] timezone: chrono_tz::Tz,
tz: String, }
ics_paths: Vec<PathBuf>, #[derive(Deserialize)]
struct Config {
icals: Vec<ConfigIcal>,
output: ConfigOutput,
}
#[derive(clap::Parser)]
struct CliAuto {
#[arg(long)]
config: PathBuf,
}
#[derive(clap::Parser)]
struct CliIcsDebug {
#[arg(long)]
config: PathBuf,
} }
#[derive(clap::Subcommand)] #[derive(clap::Subcommand)]
enum Commands { enum Commands {
Auto(CliAuto),
IcsDebug(CliIcsDebug), IcsDebug(CliIcsDebug),
} }
@ -32,161 +63,239 @@ struct Cli {
command: Commands, command: Commands,
} }
struct EventInstance {
dtstart: DateTime<chrono_tz::Tz>,
summary: String,
}
struct Parameters { struct Parameters {
/// Events before this time will be ignored if they cause an error /// Events before this time will be ignored if they cause an error
ignore_before: DateTime<rrule::Tz>, ignore_before: DateTime<chrono_tz::Tz>,
/// Events before this time will not be shown /// Events before this time will not be shown
output_start: DateTime<rrule::Tz>, output_start: DateTime<chrono_tz::Tz>,
/// Events after this time will not be shown /// Events after this time will not be shown
output_stop: DateTime<rrule::Tz>, output_stop: DateTime<chrono_tz::Tz>,
tz: chrono_tz::Tz, tz: chrono_tz::Tz,
} }
fn _recurring_event( /// Similar to `icalendar::DatePerhapsTime` but doesn't allow Floating, and naive dates are stored as local midnight with an "all day" flag
#[derive(Clone, Copy, Ord, PartialOrd, Eq, PartialEq)]
struct DatePerhapsTime {
dt: DateTime<chrono_tz::Tz>,
all_day: bool,
}
impl DatePerhapsTime {
fn date_naive(&self) -> chrono::NaiveDate {
self.dt.date_naive()
}
/// Returns None for all-day events
fn time(&self) -> Option<chrono::NaiveTime> {
if self.all_day {
None
} else {
Some(self.dt.time())
}
}
}
fn normalize_date_perhaps_time(
x: &icalendar::DatePerhapsTime,
tz: chrono_tz::Tz,
) -> Result<DatePerhapsTime> {
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, params: &Parameters,
ev: &icalendar::Event, ev: &icalendar::Event,
rrule: &icalendar::Property, rrule: &icalendar::Property,
) -> Result<Vec<DateTime<chrono_tz::Tz>>> { ) -> Result<Option<impl Iterator<Item = DatePerhapsTime>>> {
let dtstart = ev
.properties()
.get("DTSTART")
.context("Data error - Event has no DTSTART")?;
let dtstart_s: String = dtstart
.clone()
.try_into()
.context("Bug - Can't roundtrip DTSTART")?;
let rrule_s: String = rrule
.clone()
.try_into()
.context("Bug - Can't roundtrip RRULE")?;
let set_s = format!("{dtstart_s}{rrule_s}");
let rrule = rrule::RRuleSet::from_str(&set_s)
.with_context(|| format!("RRuleSet parse failed `{set_s}`"))?;
let recurrences = rrule
.after(params.output_start)
.before(params.output_stop)
.all(10)
.dates;
let mut instances = vec![];
for dtstart in recurrences {
let dtstart = dtstart.with_timezone(&params.tz);
instances.push(dtstart);
}
Ok(instances)
}
fn recurring_event(
params: &Parameters,
ev: &icalendar::Event,
rrule: &icalendar::Property,
) -> Result<Vec<DateTime<chrono_tz::Tz>>> {
let dtstart = ev let dtstart = ev
.get_start() .get_start()
.context("Data error - Event has no DTSTART")?; .context("Data error - Event has no DTSTART")?;
let dtstart = normalize_date_perhaps_time(dtstart, &params.tz)?; 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()) let rr = rrule::RRule::from_str(rrule.value())
.with_context(|| format!("RRule parse failed `{}`", rrule.value()))?; .with_context(|| format!("RRule parse failed `{}`", rrule.value()))?;
if let Some(until) = rr.get_until() { if let Some(until) = rr.get_until()
if *until < params.output_start { && *until < params.output_start
return Ok(vec![]); {
} // 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 dtstart = dtstart.with_timezone(&rrule::Tz::Tz(params.tz)); let rrule_tz = params.tz.into();
let rr = rr.build(dtstart)?; let rr = rr.build(dtstart_norm.dt.with_timezone(&rrule_tz))?;
let recurrences = rr let dates = rr
.after(params.output_start) .after(params.output_start.with_timezone(&rrule_tz))
.before(params.output_stop) .before(params.output_stop.with_timezone(&rrule_tz))
.all(10) .all(10)
.dates; .dates
let mut instances = vec![]; .into_iter()
for dtstart in recurrences { .map(move |dtstart| DatePerhapsTime {
let dtstart = dtstart.with_timezone(&params.tz); dt: dtstart.with_timezone(&params.tz),
instances.push(dtstart); all_day,
} });
Ok(instances) Ok(Some(dates))
} }
fn normalize_date_perhaps_time( fn recurring_dates(
x: icalendar::DatePerhapsTime, params: &Parameters,
tz: &chrono_tz::Tz, ev: &icalendar::Event,
) -> Result<DateTime<chrono_tz::Tz>> { rrule: &icalendar::Property,
Ok(match x { ) -> Result<impl Iterator<Item = DatePerhapsTime>> {
icalendar::DatePerhapsTime::DateTime(x) => x Ok(recurring_dates_opt(params, ev, rrule)?
.try_into_utc() .into_iter()
.context("Data error - Could not convert event datetime to UTC")? .flatten())
.with_timezone(tz), }
icalendar::DatePerhapsTime::Date(date) => {
let midnight = chrono::NaiveTime::default(); /// An event that's been duplicated according to its recurrence rules, so we can sort by datetimes
match tz.from_local_datetime(&date.and_time(midnight)) { struct EventInstance<'a> {
chrono::offset::MappedLocalTime::Single(x) => x, dtstart: DatePerhapsTime,
_ => bail!( ev: &'a icalendar::Event,
"Datetime doesn't map to a single unambiguous datetime when converting to our timezone" }
),
impl EventInstance<'_> {
fn google_url(&self, google_id: &str) -> Result<Option<String>> {
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<Option<String>> {
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<String>,
}
impl<'a> EventWithUrl<'a> {
fn from_ei(calendar: &'a ConfigIcal, ei: EventInstance<'a>) -> Result<EventWithUrl<'a>> {
let url = ei.url(calendar.google_id.as_deref())?;
Ok(Self {
calendar,
dtstart: ei.dtstart,
ev: ei.ev,
url,
}) })
} }
}
fn event_instances(params: &Parameters, ev: &icalendar::Event) -> Result<Vec<EventInstance>> { fn event_instances<'a>(
let instances = if let Some(rrule) = ev.properties().get("RRULE") { params: &Parameters,
recurring_event(params, ev, rrule)? ev: &'a icalendar::Event,
) -> Result<Vec<EventInstance<'a>>> {
let dates = if let Some(rrule) = ev.properties().get("RRULE") {
recurring_dates(params, ev, rrule)?.collect()
} else { } else {
// Event that occurs once // Event that occurs once
let dtstart = ev.get_start().context("Data error - Event has no start")?; let dtstart = ev.get_start().context("Data error - Event has no start")?;
let dtstart = normalize_date_perhaps_time(dtstart, &params.tz)?; let dtstart_normalized = normalize_date_perhaps_time(&dtstart, params.tz)?;
if dtstart < params.output_start || dtstart > params.output_stop { if dtstart_normalized.dt < params.output_start || dtstart_normalized.dt > params.output_stop
{
return Ok(vec![]); return Ok(vec![]);
} }
vec![dtstart] vec![dtstart_normalized]
}; };
let summary = ev let instances = dates
.get_summary()
.unwrap_or("Data error BXH45NAR - No summary in event");
let instances = instances
.into_iter() .into_iter()
.map(|dtstart| EventInstance { .map(|dtstart| EventInstance { dtstart, ev })
dtstart,
summary: summary.to_string(),
})
.collect(); .collect();
Ok(instances) Ok(instances)
} }
fn read_ics(params: &Parameters, path: &Path) -> Result<Vec<EventInstance>> { struct ICal {
/// The parsed ics file
cal: icalendar::Calendar,
/// The config used to download the ics file
config: ConfigIcal,
}
impl ICal {
fn read(config: ConfigIcal) -> Result<Self> {
let s = std::fs::read_to_string(&config.file_path)?;
let cal = s.parse().map_err(|s| anyhow!("parse error {s}"))?;
let cal = Self { cal, config };
Ok(cal)
}
fn events(&self) -> impl Iterator<Item = &icalendar::Event> {
self.cal.components.iter().filter_map(|comp| {
if let icalendar::CalendarComponent::Event(ev) = comp {
Some(ev)
} else {
None
}
})
}
fn event_instances(&self, params: &Parameters) -> Result<Vec<EventInstance<'_>>> {
let mut instances = vec![]; let mut instances = vec![];
for ev in self.events() {
let s = std::fs::read_to_string(path)?;
let cal: icalendar::Calendar = s.parse().map_err(|s| anyhow!("parse error {s}"))?;
for component in &cal.components {
let icalendar::CalendarComponent::Event(ev) = component else {
continue;
};
let eis = match event_instances(params, ev) let eis = match event_instances(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,
Err(e) => { Err(e) => {
if ev.get_last_modified().context("Event has no timestamp")? < params.ignore_before if ev.get_last_modified().context("Event has no timestamp")?
< params.ignore_before
{ {
// FIXME: Use tracing
eprintln!("Ignoring error from very old event {e:?}"); eprintln!("Ignoring error from very old event {e:?}");
continue; continue;
} else { } else {
@ -201,35 +310,250 @@ fn read_ics(params: &Parameters, path: &Path) -> Result<Vec<EventInstance>> {
Ok(instances) Ok(instances)
} }
}
fn main_ics_debug(cli: CliIcsDebug) -> Result<()> { #[derive(Default)]
let tz = cli.tz.parse().context("Couldn't parse timezone name")?; struct Data {
icals: Vec<ICal>,
}
fn read_data_from_disk(config: &Config) -> Result<Data> {
let mut data = Data::default();
for cfg in &config.icals {
let cal = ICal::read(cfg.clone())?;
data.icals.push(cal);
}
Ok(data)
}
fn process_data(data: &Data, now: DateTime<chrono_tz::Tz>) -> Result<Vec<EventWithUrl<'_>>> {
// Snap the cutoffs to midnight so we won't present half of a day
let midnight = chrono::NaiveTime::default();
let output_start = (now - Duration::from_secs(86_400 * 2))
.with_time(midnight)
.single()
.context("output_start doesn't map to a single time in our timezone")?;
let output_stop = (now + Duration::from_secs(86_400 * 45))
.with_time(midnight)
.single()
.context("output_stop doesn't map to a single time in our timezone")?;
let now = Utc::now().with_timezone(&rrule::Tz::Tz(chrono_tz::UTC));
let params = Parameters { let params = Parameters {
ignore_before: now - Duration::from_secs(86_400 * 365 * 2), ignore_before: now - Duration::from_secs(86_400 * 365 * 2),
output_start: now - Duration::from_secs(86_400 * 15), output_start,
output_stop: now + Duration::from_secs(86_400 * 45), output_stop,
tz, tz: now.timezone(),
}; };
let mut instances = vec![]; let mut instances = vec![];
for ical in &data.icals {
for path in cli.ics_paths { for ei in ical.event_instances(&params)? {
let eis = let ei = EventWithUrl::from_ei(&ical.config, ei)?;
read_ics(&params, &path).with_context(|| format!("Failed to parse file `{path:?}`"))?;
for ei in eis {
instances.push(ei); instances.push(ei);
} }
} }
instances.sort_by_key(|ei| ei.dtstart); instances.sort_by_key(|ei| ei.dtstart);
Ok(instances)
for instance in instances {
let EventInstance { dtstart, summary } = instance;
println!("{dtstart} - {summary}");
} }
// FIXME: Don't print to stdout / stderr
fn output_html(instances: &[EventWithUrl], now: DateTime<chrono_tz::Tz>) -> Result<()> {
let today = now.date_naive();
let mut last_month_printed: Option<String> = None;
let mut last_date_printed = None;
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();
match last_month_printed {
Some(ref x) if *x == month => {}
None | Some(_) => {
// FIXME: De-dupe
if !day_list.is_empty() {
html_list.push(maud::html! {
ul { @for entry in day_list {
(entry)
} }
});
day_list = vec![];
}
html_list.push(maud::html! {
h2 { (month) }
});
last_month_printed = Some(month);
}
}
if last_date_printed != Some(date) {
// println!("{date}");
// FIXME: De-dupe
if !day_list.is_empty() {
html_list.push(maud::html! {
ul { @for entry in day_list {
(entry)
} }
});
day_list = vec![];
}
if past {
html_list.push(maud::html! {
p class="past"{ s { (date.format("%-d %A")) } }
});
} else {
html_list.push(maud::html! {
h3 { (date.format("%-d %A")) }
hr{}
});
}
last_date_printed = Some(date);
}
let time = ei.dtstart.time();
let time = time
.map(|t| t.format("%l:%M %P").to_string())
.unwrap_or_else(|| "All day".to_string());
// 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 {
day_list.push(maud::html! {
li { p { (time) " - " (summary) }
ul {
li { a href=(ei.calendar.html_url) { (ei.calendar.short_name) } }
@if let Some(location) = location {
li { "Location: " (location) }
}
}
}
});
}
}
// FIXME: De-dupe
if !day_list.is_empty() {
html_list.push(maud::html! {
ul { @for entry in day_list {
(entry)
} }
});
}
std::fs::create_dir_all("output")?;
{
let temp_path = "output/index.html.tmp";
let final_path = "output/index.html";
let mut f = std::fs::File::create(temp_path)?;
let css = r#"
<style>
body {
font-family: sans-serif;
font-size: 14pt;
line-height: 1.6;
max-width: 700px;
}
.past {
color: #888;
}
</style>
"#;
let s = maud::html! {
(maud::PreEscaped(css))
h1 { "Wide-Angle Calendar" }
p { "Written at: " (now.to_rfc3339()) }
@for entry in html_list {
(entry)
}
}
.into_string();
f.write_all(s.as_bytes())?;
std::fs::rename(temp_path, final_path)?;
}
Ok(())
}
static APP_USER_AGENT: &str = concat!(
env!("CARGO_PKG_NAME"),
"_Z7FSRRA7/",
env!("CARGO_PKG_VERSION"),
);
async fn do_everything(cli: &CliAuto) -> Result<()> {
let config = std::fs::read_to_string(&cli.config)?;
let config: Config = toml::from_str(&config)?;
tracing::info!(?APP_USER_AGENT);
let client = reqwest::Client::builder()
.user_agent(APP_USER_AGENT)
.build()?;
for ical in &config.icals {
tracing::info!(url = ical.ics_url.to_string(), "requesting...");
let resp = client.get(ical.ics_url.clone()).send().await?;
if resp.status() != 200 {
bail!("Bad status {}", resp.status());
}
let bytes = resp.bytes().await?;
let temp_path = ical.file_path.with_extension(".ics.temp");
std::fs::write(&temp_path, &bytes)?;
std::fs::rename(&temp_path, &ical.file_path)?;
}
let data = read_data_from_disk(&config)?;
let tz = &config.output.timezone;
let now = Utc::now().with_timezone(tz);
let instances = process_data(&data, now)?;
output_html(&instances, now)?;
Ok(())
}
fn main_auto(cli: CliAuto) -> Result<()> {
tracing_subscriber::fmt::init();
loop {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
do_everything(&cli).await?;
Ok::<_, anyhow::Error>(())
})?;
rt.shutdown_timeout(Duration::from_secs(10));
tracing::info!("The service is eeping");
std::thread::sleep(Duration::from_secs(5823));
}
}
fn main_ics_debug(cli: CliIcsDebug) -> Result<()> {
let config = std::fs::read_to_string(&cli.config)?;
let config: Config = toml::from_str(&config)?;
let data = read_data_from_disk(&config)?;
let tz = &config.output.timezone;
let now = Utc::now().with_timezone(tz);
let instances = process_data(&data, now)?;
output_html(&instances, now)?;
Ok(()) Ok(())
} }
@ -237,6 +561,7 @@ fn main() -> Result<()> {
let cli = Cli::try_parse()?; let cli = Cli::try_parse()?;
match cli.command { match cli.command {
Commands::Auto(x) => main_auto(x),
Commands::IcsDebug(x) => main_ics_debug(x), Commands::IcsDebug(x) => main_ics_debug(x),
} }
} }