use anyhow::{Context, Result}; use chrono::NaiveDateTime; use ical::parser::ical::component::IcalCalendar; use ical::property::Property; use std::fs::File; use std::io::BufReader; #[derive(Debug, Clone)] pub struct ParsedCalendarEvent { pub event_uid: Option, pub summary: String, pub description: Option, pub location: Option, pub start_time: i64, pub end_time: i64, pub all_day: bool, pub organizer: Option, pub attendees: Vec, } pub fn parse_ics_file(path: &str) -> Result> { let file = File::open(path).context("Failed to open .ics file")?; let reader = BufReader::new(file); let parser = ical::IcalParser::new(reader); let mut events = Vec::new(); for calendar_result in parser { let calendar: IcalCalendar = calendar_result.context("Failed to parse calendar")?; for event in calendar.events { // Extract properties let mut event_uid = None; let mut summary = None; let mut description = None; let mut location = None; let mut start_time = None; let mut end_time = None; let mut all_day = false; let mut organizer = None; let mut attendees = Vec::new(); for property in event.properties { match property.name.as_str() { "UID" => { event_uid = property.value; } "SUMMARY" => { summary = property.value; } "DESCRIPTION" => { description = property.value; } "LOCATION" => { location = property.value; } "DTSTART" => { if let Some(ref value) = property.value { start_time = parse_ical_datetime(value, &property)?; // Check if it's an all-day event (no time component) all_day = value.len() == 8; // YYYYMMDD format } } "DTEND" => { if let Some(ref value) = property.value { end_time = parse_ical_datetime(value, &property)?; } } "ORGANIZER" => { organizer = extract_email_from_mailto(property.value.as_deref()); } "ATTENDEE" => { if let Some(email) = extract_email_from_mailto(property.value.as_deref()) { attendees.push(email); } } _ => {} } } // Only include events with required fields if let (Some(summary_text), Some(start), Some(end)) = (summary, start_time, end_time) { events.push(ParsedCalendarEvent { event_uid, summary: summary_text, description, location, start_time: start, end_time: end, all_day, organizer, attendees, }); } } } Ok(events) } fn parse_ical_datetime(value: &str, property: &Property) -> Result> { // Check for TZID parameter let _tzid = property.params.as_ref().and_then(|params| { params .iter() .find(|(key, _)| key == "TZID") .and_then(|(_, values)| values.first()) .cloned() }); // iCal datetime formats: // - 20240815T140000Z (UTC) // - 20240815T140000 (local/TZID) // - 20240815 (all-day) let cleaned = value.replace("Z", "").replace("T", ""); // All-day event (YYYYMMDD) if cleaned.len() == 8 { let dt = NaiveDateTime::parse_from_str(&format!("{}000000", cleaned), "%Y%m%d%H%M%S") .context("Failed to parse all-day date")?; return Ok(Some(dt.and_utc().timestamp())); } // DateTime event (YYYYMMDDTHHMMSS) if cleaned.len() >= 14 { let dt = NaiveDateTime::parse_from_str(&cleaned[..14], "%Y%m%d%H%M%S") .context("Failed to parse datetime")?; // If original had 'Z', it's UTC let timestamp = if value.ends_with('Z') { dt.and_utc().timestamp() } else { // Treat as UTC for simplicity (proper TZID handling is complex) dt.and_utc().timestamp() }; return Ok(Some(timestamp)); } Ok(None) } fn extract_email_from_mailto(value: Option<&str>) -> Option { value.map(|v| { // ORGANIZER and ATTENDEE often have format: mailto:user@example.com if v.starts_with("mailto:") { v.trim_start_matches("mailto:").to_string() } else { v.to_string() } }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_ical_datetime() { let prop = Property { name: "DTSTART".to_string(), params: None, value: Some("20240815T140000Z".to_string()), }; let timestamp = parse_ical_datetime("20240815T140000Z", &prop).unwrap(); assert!(timestamp.is_some()); } #[test] fn test_extract_email() { assert_eq!( extract_email_from_mailto(Some("mailto:user@example.com")), Some("user@example.com".to_string()) ); assert_eq!( extract_email_from_mailto(Some("user@example.com")), Some("user@example.com".to_string()) ); } }