trmnl-report/src/calendar.rs

186 lines
9.1 KiB
Rust

use chrono::{DateTime, FixedOffset, NaiveDate, Offset, TimeZone, Utc};
use ical::IcalParser;
use rrule::{RRule, RRuleSet, Unvalidated};
use std::error::Error;
pub struct CalendarEventSummary {
pub start: Option<DateTime<FixedOffset>>,
pub summary: String,
pub all_day: bool,
}
pub async fn fetch_next_events(
calendar_url: &str,
max_events: usize,
tz_str: &str,
) -> Result<Vec<CalendarEventSummary>, Box<dyn Error>> {
let resp = reqwest::get(calendar_url).await?;
let body = resp.bytes().await?;
let ical_str = String::from_utf8_lossy(&body);
let parser = IcalParser::new(ical_str.as_bytes());
let mut events: Vec<CalendarEventSummary> = Vec::new();
let tz: Option<chrono_tz::Tz> = tz_str.parse().ok();
for calendar in parser {
for evt in calendar?.events {
let mut summary = None;
let mut dtstart = None;
let mut all_day = false;
let mut rrule_str: Option<String> = None;
let mut raw_dtstart: Option<String> = None;
let mut dt_params: Option<Vec<(String, Vec<String>)>> = None;
for prop in &evt.properties {
match prop.name.as_str() {
"SUMMARY" => summary = prop.value.clone(),
"DTSTART" => {
raw_dtstart = prop.value.clone();
dt_params = prop.params.clone();
if let Some(val) = &prop.value {
// -------- Existing DTSTART parsing logic goes below ------
// All-day check
let is_all_day = prop
.params
.as_ref()
.and_then(|params| params.iter().find(|(k, _)| k == "VALUE"))
.and_then(|(_, v)| v.first())
.is_some_and(|v| v == "DATE");
if is_all_day {
if let Ok(date) = NaiveDate::parse_from_str(val, "%Y%m%d") {
if let Some(tz) = tz {
if let Some(dt) = tz
.from_local_datetime(
&date.and_hms_opt(0, 0, 0).unwrap(),
)
.single()
{
dtstart = Some(dt.with_timezone(&dt.offset().fix()));
}
}
all_day = true;
}
} else if let Some(params) = &prop.params {
// Check and handle TZID param!
if let Some((_, tz_vec)) = params.iter().find(|(k, _)| k == "TZID")
{
let tz_id = &tz_vec[0];
if let Ok(parsed_tz) = tz_id.parse::<chrono_tz::Tz>() {
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(
val,
"%Y%m%dT%H%M%S",
) {
let local_dt =
parsed_tz.from_local_datetime(&naive_dt).single();
if let Some(dt) = local_dt {
dtstart =
Some(dt.with_timezone(&dt.offset().fix()));
}
}
}
} else if let Ok(dt) = DateTime::parse_from_rfc3339(val) {
dtstart = Some(dt.with_timezone(dt.offset()));
} else if let Ok(dt) =
DateTime::parse_from_str(val, "%Y%m%dT%H%M%SZ")
{
dtstart = Some(dt.with_timezone(&Utc.fix()));
}
} else if val.ends_with('Z') && val.len() == 16 {
// e.g. "20250522T181500Z" not RFC3339, convert to RFC3339
let iso = format!(
"{}-{}-{}T{}:{}:{}Z",
&val[0..4],
&val[4..6],
&val[6..8],
&val[9..11],
&val[11..13],
&val[13..15]
);
if let Ok(dt) = DateTime::parse_from_rfc3339(&iso) {
dtstart = Some(dt.with_timezone(&Utc.fix()));
}
} else if let Ok(dt) = DateTime::parse_from_rfc3339(val) {
dtstart = Some(dt.with_timezone(dt.offset()));
} else if let Ok(dt) = DateTime::parse_from_str(val, "%Y%m%dT%H%M%S") {
// No Z/zone, treat as in configured tz
if let Some(tz) = tz {
if let Some(dt2) =
tz.from_local_datetime(&dt.naive_local()).single()
{
dtstart = Some(dt2.with_timezone(&dt2.offset().fix()));
}
}
} else if let Ok(date) = NaiveDate::parse_from_str(val, "%Y%m%d") {
// As a fallback, treat as all-day
if let Some(tz) = tz {
if let Some(dt2) = tz
.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
.single()
{
dtstart = Some(dt2.with_timezone(&dt2.offset().fix()));
}
}
all_day = true;
}
}
}
"RRULE" => rrule_str = prop.value.clone(),
_ => {}
}
}
// ----------- RRULE recurring event expansion block -----------
if let (Some(ref s), Some(dt), Some(ref rrule_val), Some(_val)) = (
summary.clone(),
dtstart,
rrule_str.as_ref(),
raw_dtstart.as_ref(),
) {
// dtstart is FixedOffset, convert to Utc for rrule
let dtstart_rrule = dt.with_timezone(&rrule::Tz::UTC);
if let Ok(unvalid) = rrule_val.parse::<RRule<Unvalidated>>() {
if let Ok(rrule) = unvalid.validate(dtstart_rrule) {
let set = RRuleSet::new(dtstart_rrule).rrule(rrule);
// Expand up to the next 20 future instances for each recurring event
let now = Utc::now();
let instances = set.all(1000);
let occur_iter = instances
.dates
.iter()
.filter(|t| **t > now)
.take(max_events);
for occ in occur_iter {
let occ_fixed: DateTime<FixedOffset> =
occ.with_timezone(&dt.offset().fix());
events.push(CalendarEventSummary {
start: Some(occ_fixed),
summary: s.clone(),
all_day,
});
}
} else if dtstart_rrule > Utc::now() {
eprintln!("[ERROR] Failed to validate RRULE: {:?}", rrule_val);
}
// Otherwise, ignore and continue
} else {
eprintln!("[ERROR] Failed to parse RRULE: {:?}", rrule_val);
}
continue;
}
// Non-recurring event
if let (Some(s), Some(dt)) = (summary.clone(), dtstart) {
events.push(CalendarEventSummary {
start: Some(dt),
summary: s,
all_day,
});
}
}
}
// Filter to only future events
let now = Utc::now();
events.retain(|e| e.start.map(|s| s > now).unwrap_or(false));
// Sort by time ascending, then take first max_events
events.sort_by_key(|e| e.start);
Ok(events.into_iter().take(max_events).collect())
}