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>, pub summary: String, pub all_day: bool, } pub async fn fetch_next_events( calendar_url: &str, max_events: usize, tz_str: &str, ) -> Result, Box> { 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 = Vec::new(); let tz: Option = 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 = None; let mut raw_dtstart: Option = None; let mut dt_params: Option)>> = 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::() { 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::>() { 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 = 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()) }