Compare commits
No commits in common. "5310f19383677df0d8ddcc566429e4c87f0240c4" and "294d95e80bf170a1d872770f852f7d4bcf319c39" have entirely different histories.
5310f19383
...
294d95e80b
6 changed files with 184 additions and 2276 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,2 @@
|
||||||
/output
|
|
||||||
/target
|
/target
|
||||||
/untracked
|
/untracked
|
||||||
|
|
1527
Cargo.lock
generated
1527
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
11
Cargo.toml
11
Cargo.toml
|
@ -5,17 +5,8 @@ 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 = { version = "0.10.4", features = ["serde"] }
|
chrono-tz = "0.10.4"
|
||||||
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"] }
|
|
||||||
|
|
27
README.md
27
README.md
|
@ -1,8 +1,21 @@
|
||||||
todo
|
Merges multiple ics files into one stream
|
||||||
|
|
||||||
- [ ] Rewrite README considering best practices
|
Try `cargo run -- ics-debug --tz America/New_York sample-data/nascar.ics sample-data/formula-1.ics`
|
||||||
- [ ] Add dynamic OpenGraph meta tags
|
|
||||||
- [ ] HTML templating for faster styling
|
Expected output is the next month or so of NASCAR and Formula 1 racing:
|
||||||
- [ ] Maybe put descriptions behind `details` tag
|
|
||||||
- [ ] Publish ICS to subscribe to?
|
```
|
||||||
- [ ] systemd unit or something
|
2025-08-03 00:00:00 EDT - NASCAR Cup - Iowa Corn 350
|
||||||
|
2025-08-03 00:00:00 EDT - F1 - Lenovo Hungarian Grand Prix
|
||||||
|
2025-08-10 00:00:00 EDT - NASCAR Cup - Go Bowling At The Glen (Watkins Glen)
|
||||||
|
2025-08-16 00:00:00 EDT - NASCAR Cup - Cook Out 400 (Richmond)
|
||||||
|
2025-08-23 00:00:00 EDT - NASCAR Cup - Coke Zero Sugar 400 (Daytona)
|
||||||
|
2025-08-31 00:00:00 EDT - NASCAR Cup -Playoff- Southern 500 (Darlington)
|
||||||
|
2025-08-31 00:00:00 EDT - F1 - Heineken Dutch Grand Prix
|
||||||
|
2025-09-07 00:00:00 EDT - NASCAR Cup -Playoff- Enjoy Illinois 300 (Gateway)
|
||||||
|
2025-09-07 00:00:00 EDT - F1 - Gran Premio d'Italia
|
||||||
|
2025-09-13 00:00:00 EDT - NASCAR Cup -Playoff- Bass Pro Shops Night Race (Bristol)
|
||||||
|
2025-09-21 00:00:00 EDT - F1 - Qatar Airways Azerbaijan Grand Prix
|
||||||
|
```
|
||||||
|
|
||||||
|
If the sample data is old, try the iCal links from <https://toomuchracing.com/calendar/>, e.g. `curl https://calendar.google.com/calendar/ical/fa9bjl6tu13dd10b066stoo5do%40group.calendar.google.com/public/basic.ics > sample-data/formula-1.ics`
|
||||||
|
|
686
src/main.rs
686
src/main.rs
|
@ -1,69 +1,27 @@
|
||||||
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 _, EventLike as _};
|
use icalendar::Component as _;
|
||||||
use serde::Deserialize;
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet, io::Write as _, path::PathBuf, str::FromStr as _, time::Duration,
|
convert::TryInto as _,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
str::FromStr,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
|
||||||
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: Option<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 {
|
|
||||||
/// Used as the OpenGraph description in meta tags
|
|
||||||
description: String,
|
|
||||||
|
|
||||||
/// Timezone to use for output (e.g. "Antarctica/South_Pole")
|
|
||||||
///
|
|
||||||
/// <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
|
|
||||||
timezone: chrono_tz::Tz,
|
|
||||||
|
|
||||||
/// Used as the page title and OpenGraph title in meta tags
|
|
||||||
title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Config {
|
|
||||||
icals: Vec<ConfigIcal>,
|
|
||||||
output: ConfigOutput,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
|
||||||
struct CliAuto {
|
|
||||||
#[arg(long)]
|
|
||||||
config: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
#[derive(clap::Parser)]
|
||||||
struct CliIcsDebug {
|
struct CliIcsDebug {
|
||||||
|
/// Timezone to use for output (e.g. "Antarctica/South_Pole")
|
||||||
|
///
|
||||||
|
/// <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
config: PathBuf,
|
tz: String,
|
||||||
|
|
||||||
|
ics_paths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(clap::Subcommand)]
|
#[derive(clap::Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
Auto(CliAuto),
|
|
||||||
IcsDebug(CliIcsDebug),
|
IcsDebug(CliIcsDebug),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,272 +32,162 @@ 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<chrono_tz::Tz>,
|
ignore_before: DateTime<rrule::Tz>,
|
||||||
|
|
||||||
/// Events before this time will not be shown
|
/// Events before this time will not be shown
|
||||||
output_start: DateTime<chrono_tz::Tz>,
|
output_start: DateTime<rrule::Tz>,
|
||||||
|
|
||||||
/// Events after this time will not be shown
|
/// Events after this time will not be shown
|
||||||
output_stop: DateTime<chrono_tz::Tz>,
|
output_stop: DateTime<rrule::Tz>,
|
||||||
|
|
||||||
tz: chrono_tz::Tz,
|
tz: chrono_tz::Tz,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Parameters {
|
fn _recurring_event(
|
||||||
fn new(now: DateTime<chrono_tz::Tz>) -> Result<Self> {
|
|
||||||
// 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")?;
|
|
||||||
|
|
||||||
Ok(Parameters {
|
|
||||||
ignore_before: now - Duration::from_secs(86_400 * 365 * 2),
|
|
||||||
output_start,
|
|
||||||
output_stop,
|
|
||||||
tz: now.timezone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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, Debug, 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<Option<impl Iterator<Item = DatePerhapsTime>>> {
|
) -> Result<Vec<DateTime<chrono_tz::Tz>>> {
|
||||||
|
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(¶ms.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 all_day = match &dtstart {
|
let dtstart = normalize_date_perhaps_time(dtstart, ¶ms.tz)?;
|
||||||
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() {
|
||||||
&& *until < params.output_start
|
if *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 rrule_tz = params.tz.into();
|
let dtstart = dtstart.with_timezone(&rrule::Tz::Tz(params.tz));
|
||||||
|
|
||||||
let rr = rr.build(dtstart_norm.dt.with_timezone(&rrule_tz))?;
|
let rr = rr.build(dtstart)?;
|
||||||
let dates = rr
|
let recurrences = rr
|
||||||
.after(params.output_start.with_timezone(&rrule_tz))
|
.after(params.output_start)
|
||||||
.before(params.output_stop.with_timezone(&rrule_tz))
|
.before(params.output_stop)
|
||||||
.all(10)
|
.all(10)
|
||||||
.dates
|
.dates;
|
||||||
.into_iter()
|
let mut instances = vec![];
|
||||||
.map(move |dtstart| DatePerhapsTime {
|
for dtstart in recurrences {
|
||||||
dt: dtstart.with_timezone(¶ms.tz),
|
let dtstart = dtstart.with_timezone(¶ms.tz);
|
||||||
all_day,
|
instances.push(dtstart);
|
||||||
});
|
}
|
||||||
Ok(Some(dates))
|
Ok(instances)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recurring_dates(
|
fn normalize_date_perhaps_time(
|
||||||
params: &Parameters,
|
x: icalendar::DatePerhapsTime,
|
||||||
ev: &icalendar::Event,
|
tz: &chrono_tz::Tz,
|
||||||
rrule: &icalendar::Property,
|
) -> Result<DateTime<chrono_tz::Tz>> {
|
||||||
) -> Result<impl Iterator<Item = DatePerhapsTime>> {
|
Ok(match x {
|
||||||
Ok(recurring_dates_opt(params, ev, rrule)?
|
icalendar::DatePerhapsTime::DateTime(x) => x
|
||||||
.into_iter()
|
.try_into_utc()
|
||||||
.flatten())
|
.context("Data error - Could not convert event datetime to UTC")?
|
||||||
}
|
.with_timezone(tz),
|
||||||
|
icalendar::DatePerhapsTime::Date(date) => {
|
||||||
/// An event that's been duplicated according to its recurrence rules, so we can sort by datetimes
|
let midnight = chrono::NaiveTime::default();
|
||||||
struct EventInstance<'a> {
|
match tz.from_local_datetime(&date.and_time(midnight)) {
|
||||||
dtstart: DatePerhapsTime,
|
chrono::offset::MappedLocalTime::Single(x) => x,
|
||||||
ev: &'a icalendar::Event,
|
_ => bail!(
|
||||||
}
|
"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<'a>(
|
fn event_instances(params: &Parameters, ev: &icalendar::Event) -> Result<Vec<EventInstance>> {
|
||||||
params: &Parameters,
|
let instances = if let Some(rrule) = ev.properties().get("RRULE") {
|
||||||
ev: &'a icalendar::Event,
|
recurring_event(params, ev, rrule)?
|
||||||
) -> 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_normalized = normalize_date_perhaps_time(&dtstart, params.tz)?;
|
let dtstart = normalize_date_perhaps_time(dtstart, ¶ms.tz)?;
|
||||||
if dtstart_normalized.dt < params.output_start || dtstart_normalized.dt > params.output_stop
|
if dtstart < params.output_start || dtstart > params.output_stop {
|
||||||
{
|
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
vec![dtstart_normalized]
|
vec![dtstart]
|
||||||
};
|
};
|
||||||
|
|
||||||
let instances = dates
|
let summary = ev
|
||||||
|
.get_summary()
|
||||||
|
.unwrap_or("Data error BXH45NAR - No summary in event");
|
||||||
|
|
||||||
|
let instances = instances
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|dtstart| EventInstance { dtstart, ev })
|
.map(|dtstart| EventInstance {
|
||||||
|
dtstart,
|
||||||
|
summary: summary.to_string(),
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(instances)
|
Ok(instances)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ICal {
|
fn read_ics(params: &Parameters, path: &Path) -> Result<Vec<EventInstance>> {
|
||||||
/// 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<Self> {
|
|
||||||
let cal = s.parse().map_err(|s| anyhow!("parse error {s}"))?;
|
|
||||||
let cal = Self { cal };
|
|
||||||
Ok(cal)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_from_config(config: &ConfigIcal) -> Result<Self> {
|
|
||||||
let s = std::fs::read_to_string(&config.file_path)?;
|
|
||||||
Self::read_from_str(&s)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an unsorted list of event instances for this calendar
|
|
||||||
fn event_instances(&self, params: &Parameters) -> Result<Vec<EventInstance<'_>>> {
|
|
||||||
let mut instances = vec![];
|
let mut instances = vec![];
|
||||||
let mut recurrence_exceptions = BTreeSet::new();
|
|
||||||
|
|
||||||
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")?
|
if ev.get_last_modified().context("Event has no timestamp")? < params.ignore_before
|
||||||
< params.ignore_before
|
|
||||||
{
|
{
|
||||||
tracing::warn!("Ignoring error from very old event {e:?}");
|
eprintln!("Ignoring error from very old event {e:?}");
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
Err(e)?
|
Err(e)?
|
||||||
|
@ -349,306 +197,39 @@ impl ICal {
|
||||||
for ei in eis {
|
for ei in eis {
|
||||||
instances.push(ei);
|
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.ev.get_recurrence_id().is_some() {
|
|
||||||
// This is a recurrence exception, exceptions never delete themselves
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let uid = ev
|
|
||||||
.ev
|
|
||||||
.get_uid()
|
|
||||||
.context(
|
|
||||||
"Every recurring event should have a UID so we can apply recurrence exceptions",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let key = RecurrenceKey {
|
|
||||||
recurrence_id: ev.dtstart,
|
|
||||||
uid,
|
|
||||||
};
|
|
||||||
!recurrence_exceptions.contains(&key)
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(instances)
|
Ok(instances)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
fn main_ics_debug(cli: CliIcsDebug) -> Result<()> {
|
||||||
struct Data {
|
let tz = cli.tz.parse().context("Couldn't parse timezone name")?;
|
||||||
icals: Vec<(ICal, ConfigIcal)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_data_from_disk(config: &Config) -> Result<Data> {
|
let now = Utc::now().with_timezone(&rrule::Tz::Tz(chrono_tz::UTC));
|
||||||
let mut data = Data::default();
|
let params = Parameters {
|
||||||
for config in &config.icals {
|
ignore_before: now - Duration::from_secs(86_400 * 365 * 2),
|
||||||
let cal = ICal::read_from_config(config)?;
|
output_start: now - Duration::from_secs(86_400 * 15),
|
||||||
data.icals.push((cal, config.clone()));
|
output_stop: now + Duration::from_secs(86_400 * 45),
|
||||||
}
|
tz,
|
||||||
|
};
|
||||||
Ok(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_data(data: &Data, now: DateTime<chrono_tz::Tz>) -> Result<Vec<EventWithUrl<'_>>> {
|
|
||||||
let params = Parameters::new(now)?;
|
|
||||||
|
|
||||||
let mut instances = vec![];
|
let mut instances = vec![];
|
||||||
for (ical, config) in &data.icals {
|
|
||||||
for ei in ical.event_instances(¶ms)? {
|
for path in cli.ics_paths {
|
||||||
let ei = EventWithUrl::from_ei(config, ei)?;
|
let eis =
|
||||||
|
read_ics(¶ms, &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(
|
|
||||||
config: &ConfigOutput,
|
|
||||||
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 {
|
|
||||||
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.short_name)}
|
|
||||||
};
|
|
||||||
|
|
||||||
day_list.push(maud::html! {
|
|
||||||
li { p { (time) " - " (summary) }
|
|
||||||
ul {
|
|
||||||
li { (calendar_link) " calendar" }
|
|
||||||
@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 description = &config.description;
|
|
||||||
let title = &config.title;
|
|
||||||
|
|
||||||
let s = maud::html! {
|
|
||||||
html lang="en" {
|
|
||||||
head {
|
|
||||||
meta http-equiv="Content-Type" content="text/html; charset=utf-8" {}
|
|
||||||
meta name="viewport" content="width=device-width, initial-scale=1" {}
|
|
||||||
(maud::PreEscaped(css))
|
|
||||||
|
|
||||||
meta property="og:locale" content="en" {}
|
|
||||||
meta property="og:type" content="website" {}
|
|
||||||
|
|
||||||
meta property="description" content=(description) {}
|
|
||||||
meta property="og:description" content=(description) {}
|
|
||||||
|
|
||||||
title { (title) }
|
|
||||||
met property="og:title" content=(title) {}
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
h1 { (title) }
|
|
||||||
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(&config.output, &instances, now)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seconds to sleep between auto cycles
|
|
||||||
const SLEEP_SECS: u64 = 9000;
|
|
||||||
|
|
||||||
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(SLEEP_SECS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(&config.output, &instances, now)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -656,7 +237,6 @@ 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
180
src/tests.rs
180
src/tests.rs
|
@ -1,180 +0,0 @@
|
||||||
use super::*;
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dt_from_ts(ts: i64) -> DateTime<chrono_tz::Tz> {
|
|
||||||
DateTime::from_timestamp(ts, 0)
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&chrono_tz::America::Chicago)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Expect that parsing a calendar works
|
|
||||||
#[test]
|
|
||||||
fn calendar_from_str() -> Result<()> {
|
|
||||||
// Blank lines added for clarity
|
|
||||||
let s = r#"
|
|
||||||
BEGIN:VCALENDAR
|
|
||||||
|
|
||||||
CALSCALE:GREGORIAN
|
|
||||||
X-WR-TIMEZONE:America/Chicago
|
|
||||||
|
|
||||||
BEGIN:VTIMEZONE
|
|
||||||
TZID:America/Chicago
|
|
||||||
X-LIC-LOCATION:America/Chicago
|
|
||||||
BEGIN:DAYLIGHT
|
|
||||||
TZOFFSETFROM:-0600
|
|
||||||
TZOFFSETTO:-0500
|
|
||||||
TZNAME:CDT
|
|
||||||
DTSTART:19700308T020000
|
|
||||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
|
||||||
END:DAYLIGHT
|
|
||||||
BEGIN:STANDARD
|
|
||||||
TZOFFSETFROM:-0500
|
|
||||||
TZOFFSETTO:-0600
|
|
||||||
TZNAME:CST
|
|
||||||
DTSTART:19701101T020000
|
|
||||||
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
|
||||||
END:STANDARD
|
|
||||||
END:VTIMEZONE
|
|
||||||
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART;TZID=America/Chicago:20250908T180000
|
|
||||||
DTEND;TZID=America/Chicago:20250908T200000
|
|
||||||
UID:Redacted
|
|
||||||
RECURRENCE-ID;TZID=America/Chicago:20250901T180000
|
|
||||||
CREATED:20241222T171032Z
|
|
||||||
LAST-MODIFIED:20250812T021726Z
|
|
||||||
SEQUENCE:1
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
SUMMARY:zero roman mummy hatch
|
|
||||||
TRANSP:OPAQUE
|
|
||||||
END:VEVENT
|
|
||||||
|
|
||||||
END:VCALENDAR
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let ical = ICal::read_from_str(s)?;
|
|
||||||
let now = dt_from_ts(1755000000);
|
|
||||||
let params = Parameters::new(now)?;
|
|
||||||
let instances = ical.event_instances(¶ms)?;
|
|
||||||
assert_eq!(instances.len(), 1);
|
|
||||||
|
|
||||||
let event = &instances[0];
|
|
||||||
let expected_time = DatePerhapsTime {
|
|
||||||
dt: chicago_time(2025, 9, 8, 18, 0, 0),
|
|
||||||
all_day: false,
|
|
||||||
};
|
|
||||||
assert_eq!(event.dtstart, expected_time);
|
|
||||||
assert_eq!(event.ev.get_summary(), Some("zero roman mummy hatch"));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Expect that recurrent exceptions work correctly and don't duplicate events
|
|
||||||
#[test]
|
|
||||||
fn recurrence_exceptions() -> Result<()> {
|
|
||||||
let s = r#"
|
|
||||||
BEGIN:VCALENDAR
|
|
||||||
|
|
||||||
CALSCALE:GREGORIAN
|
|
||||||
X-WR-TIMEZONE:America/Chicago
|
|
||||||
|
|
||||||
BEGIN:VTIMEZONE
|
|
||||||
TZID:America/Chicago
|
|
||||||
X-LIC-LOCATION:America/Chicago
|
|
||||||
BEGIN:DAYLIGHT
|
|
||||||
TZOFFSETFROM:-0600
|
|
||||||
TZOFFSETTO:-0500
|
|
||||||
TZNAME:CDT
|
|
||||||
DTSTART:19700308T020000
|
|
||||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
|
||||||
END:DAYLIGHT
|
|
||||||
BEGIN:STANDARD
|
|
||||||
TZOFFSETFROM:-0500
|
|
||||||
TZOFFSETTO:-0600
|
|
||||||
TZNAME:CST
|
|
||||||
DTSTART:19701101T020000
|
|
||||||
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
|
||||||
END:STANDARD
|
|
||||||
END:VTIMEZONE
|
|
||||||
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART;TZID=America/Chicago:20250708T180000
|
|
||||||
DTEND;TZID=America/Chicago:20250708T200000
|
|
||||||
RRULE:FREQ=MONTHLY;BYDAY=2TU
|
|
||||||
UID:jazz repay stout steam
|
|
||||||
CLASS:PUBLIC
|
|
||||||
CREATED:20250703T113806Z
|
|
||||||
LAST-MODIFIED:20250721T232331Z
|
|
||||||
SEQUENCE:1
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
SUMMARY:coil perm brush zippy
|
|
||||||
TRANSP:OPAQUE
|
|
||||||
END:VEVENT
|
|
||||||
|
|
||||||
BEGIN:VEVENT
|
|
||||||
DTSTART;TZID=America/Chicago:20250814T180000
|
|
||||||
DTEND;TZID=America/Chicago:20250814T200000
|
|
||||||
UID:jazz repay stout steam
|
|
||||||
RECURRENCE-ID;TZID=America/Chicago:20250812T180000
|
|
||||||
CLASS:PUBLIC
|
|
||||||
CREATED:20250703T113806Z
|
|
||||||
LAST-MODIFIED:20250721T232500Z
|
|
||||||
SEQUENCE:2
|
|
||||||
STATUS:CONFIRMED
|
|
||||||
SUMMARY:coil perm brush zippy
|
|
||||||
TRANSP:OPAQUE
|
|
||||||
END:VEVENT
|
|
||||||
|
|
||||||
END:VCALENDAR
|
|
||||||
"#;
|
|
||||||
|
|
||||||
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),
|
|
||||||
output_stop: chicago_time(2025, 10, 1, 0, 0, 0),
|
|
||||||
tz: chrono_tz::America::Chicago,
|
|
||||||
};
|
|
||||||
let instances = ical.event_instances(¶ms)?;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
[
|
|
||||||
instances[0].dtstart,
|
|
||||||
instances[1].dtstart,
|
|
||||||
instances[2].dtstart,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
DatePerhapsTime {
|
|
||||||
dt: chicago_time(2025, 7, 8, 18, 0, 0),
|
|
||||||
all_day: false,
|
|
||||||
},
|
|
||||||
DatePerhapsTime {
|
|
||||||
dt: chicago_time(2025, 9, 9, 18, 0, 0),
|
|
||||||
all_day: false,
|
|
||||||
},
|
|
||||||
DatePerhapsTime {
|
|
||||||
dt: chicago_time(2025, 8, 14, 18, 0, 0),
|
|
||||||
all_day: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
for instance in &instances {
|
|
||||||
assert_eq!(instance.ev.get_summary(), Some("coil perm brush zippy"));
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(instances.len(), 3);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue