186 lines
9.1 KiB
Rust
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())
|
|
}
|