//! iCalendar (.ics) parser for event import. //! //! Uses the `ical` crate for robust VEVENT parsing, then maps properties //! to GO's Event model. use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc}; use chrono_tz::Tz; use goingson_core::Recurrence; use ical::parser::ical::component::IcalEvent; use ical::property::Property; use serde::Serialize; /// A fully parsed iCalendar event. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ParsedEvent { pub title: String, pub description: String, pub start_time: DateTime, pub end_time: Option>, pub location: Option, pub recurrence: Recurrence, pub external_id: Option, } /// Parse an .ics file content into a list of events. pub fn parse_ics(content: &str) -> Result, String> { let reader = ical::IcalParser::new(content.as_bytes()); let mut events = Vec::new(); for calendar_result in reader { let calendar = calendar_result.map_err(|e| format!("Failed to parse iCalendar: {}", e))?; for ical_event in calendar.events { if let Some(parsed) = parse_vevent(&ical_event) { events.push(parsed); } } } Ok(events) } /// Parse a single VEVENT component. fn parse_vevent(event: &IcalEvent) -> Option { let title = get_property_value(&event.properties, "SUMMARY") .unwrap_or_default(); if title.is_empty() { return None; } let description = get_property_value(&event.properties, "DESCRIPTION") .unwrap_or_default(); let location = get_property_value(&event.properties, "LOCATION"); let uid = get_property_value(&event.properties, "UID"); // Parse start time let start_time = parse_datetime_property(&event.properties, "DTSTART")?; // Parse end time: DTEND takes precedence, then compute from DURATION let end_time = parse_datetime_property(&event.properties, "DTEND") .or_else(|| { get_property_value(&event.properties, "DURATION") .and_then(|d| parse_duration(&d)) .map(|dur| start_time + dur) }); // Parse recurrence let recurrence = get_property_value(&event.properties, "RRULE") .map(|rule| parse_rrule(&rule)) .unwrap_or(Recurrence::None); Some(ParsedEvent { title, description, start_time, end_time, location, recurrence, external_id: uid, }) } /// Get the value of a named property. fn get_property_value(properties: &[Property], name: &str) -> Option { properties .iter() .find(|p| p.name == name) .and_then(|p| p.value.clone()) .map(|v| unescape_ical(&v)) } /// Find a named property (for accessing params). fn find_property<'a>(properties: &'a [Property], name: &str) -> Option<&'a Property> { properties.iter().find(|p| p.name == name) } /// Parse a DTSTART or DTEND property, handling TZID, DATE, and DATE-TIME formats. fn parse_datetime_property(properties: &[Property], name: &str) -> Option> { let prop = find_property(properties, name)?; let value = prop.value.as_deref()?; // Check for VALUE=DATE (all-day event) let is_date_only = prop.params.as_ref().is_some_and(|params| { params.iter().any(|(k, v)| k == "VALUE" && v.iter().any(|val| val == "DATE")) }); if is_date_only || (value.len() == 8 && value.chars().all(|c| c.is_ascii_digit())) { // All-day: YYYYMMDD → midnight UTC return parse_date_to_utc(value); } // Check for TZID parameter let tzid = prop.params.as_ref().and_then(|params| { params .iter() .find(|(k, _)| k == "TZID") .and_then(|(_, v)| v.first()) .map(|s| s.as_str()) }); // Try parsing with timezone if let Some(tz_name) = tzid { if let Some(ndt) = parse_ical_datetime(value) { // Resolve IANA timezone and convert local time to UTC if let Ok(tz) = tz_name.parse::() { // .earliest() returns None for times in a DST spring-forward gap. // Fall back to .latest() which maps gap times to post-transition. if let Some(local_dt) = tz .from_local_datetime(&ndt) .earliest() .or_else(|| tz.from_local_datetime(&ndt).latest()) { return Some(local_dt.with_timezone(&Utc)); } } // Fall back to treating as UTC if timezone can't be resolved return Some(Utc.from_utc_datetime(&ndt)); } } // UTC (ends with Z) if let Some(clean) = value.strip_suffix('Z') { return parse_ical_datetime(clean).map(|ndt| Utc.from_utc_datetime(&ndt)); } // Floating time (no timezone) → treat as UTC parse_ical_datetime(value).map(|ndt| Utc.from_utc_datetime(&ndt)) } /// Parse an iCalendar datetime string (YYYYMMDDTHHMMSS). fn parse_ical_datetime(s: &str) -> Option { // Format: 20260415T100000 — all ASCII, so validate before slicing if s.len() < 15 || !s.is_ascii() { return None; } let t_pos = s.find('T')?; if t_pos != 8 { return None; } let date_part = &s[..8]; let time_part = s.get(9..15)?; let date = NaiveDate::parse_from_str(date_part, "%Y%m%d").ok()?; let hour: u32 = time_part.get(0..2)?.parse().ok()?; let min: u32 = time_part.get(2..4)?.parse().ok()?; let sec: u32 = time_part.get(4..6)?.parse().ok()?; Some(date.and_hms_opt(hour, min, sec)?) } /// Parse a DATE-only value to midnight UTC. fn parse_date_to_utc(s: &str) -> Option> { // Format: YYYYMMDD or YYYY-MM-DD let clean = s.replace('-', ""); if clean.len() == 8 { let date = NaiveDate::parse_from_str(&clean, "%Y%m%d").ok()?; Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?)) } else { None } } /// Parse an iCalendar DURATION value (e.g., "PT1H30M", "P1D"). fn parse_duration(s: &str) -> Option { let s = s.trim(); if !s.starts_with('P') { return None; } let s = &s[1..]; let mut total_seconds: i64 = 0; let mut in_time = false; let mut num_buf = String::new(); for ch in s.chars() { match ch { 'T' => in_time = true, '0'..='9' => num_buf.push(ch), 'D' if !in_time => { total_seconds += num_buf.parse::().unwrap_or(0) * 86400; num_buf.clear(); } 'W' if !in_time => { total_seconds += num_buf.parse::().unwrap_or(0) * 604800; num_buf.clear(); } 'H' if in_time => { total_seconds += num_buf.parse::().unwrap_or(0) * 3600; num_buf.clear(); } 'M' if in_time => { total_seconds += num_buf.parse::().unwrap_or(0) * 60; num_buf.clear(); } 'S' if in_time => { total_seconds += num_buf.parse::().unwrap_or(0); num_buf.clear(); } _ => {} } } Some(chrono::Duration::seconds(total_seconds)) } /// Parse an RRULE into a GO Recurrence. Only simple rules are mapped; /// complex rules (BYDAY with multiple days, INTERVAL>1, UNTIL, COUNT) → None. fn parse_rrule(rule: &str) -> Recurrence { let parts: std::collections::HashMap<&str, &str> = rule .split(';') .filter_map(|part| { let mut kv = part.splitn(2, '='); Some((kv.next()?, kv.next()?)) }) .collect(); let freq = match parts.get("FREQ") { Some(f) => f.to_uppercase(), None => return Recurrence::None, }; // Only map simple rules (INTERVAL=1 or absent) let interval: u32 = parts .get("INTERVAL") .and_then(|v| v.parse().ok()) .unwrap_or(1); if interval != 1 { return Recurrence::None; } // Complex rules with BYDAY having multiple days → skip if let Some(byday) = parts.get("BYDAY") { if byday.contains(',') { return Recurrence::None; } } // UNTIL or COUNT → still map the frequency (they just limit recurrence) match freq.as_str() { "DAILY" => Recurrence::Daily, "WEEKLY" => Recurrence::Weekly, "MONTHLY" => Recurrence::Monthly, _ => Recurrence::None, } } /// Unescape iCalendar text values. fn unescape_ical(s: &str) -> String { s.replace("\\n", "\n") .replace("\\N", "\n") .replace("\\,", ",") .replace("\\;", ";") .replace("\\\\", "\\") } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple_event() { let ics = "\ BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ UID:test-uid-123@example.com\r\n\ SUMMARY:Team Meeting\r\n\ DTSTART:20260415T100000Z\r\n\ DTEND:20260415T110000Z\r\n\ LOCATION:Conference Room A\r\n\ DESCRIPTION:Weekly standup\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; let events = parse_ics(ics).unwrap(); assert_eq!(events.len(), 1); let e = &events[0]; assert_eq!(e.title, "Team Meeting"); assert_eq!(e.description, "Weekly standup"); assert_eq!(e.location.as_deref(), Some("Conference Room A")); assert_eq!(e.external_id.as_deref(), Some("test-uid-123@example.com")); assert!(e.end_time.is_some()); assert_eq!(e.recurrence, Recurrence::None); } #[test] fn test_parse_all_day_event() { let ics = "\ BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ SUMMARY:Holiday\r\n\ DTSTART;VALUE=DATE:20260501\r\n\ DTEND;VALUE=DATE:20260502\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; let events = parse_ics(ics).unwrap(); assert_eq!(events.len(), 1); assert_eq!(events[0].title, "Holiday"); // All-day events start at midnight UTC assert_eq!(events[0].start_time.hour(), 0); } #[test] fn test_parse_recurring_daily() { let ics = "\ BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ SUMMARY:Daily Standup\r\n\ DTSTART:20260415T090000Z\r\n\ RRULE:FREQ=DAILY;INTERVAL=1\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; let events = parse_ics(ics).unwrap(); assert_eq!(events[0].recurrence, Recurrence::Daily); } #[test] fn test_parse_recurring_weekly() { let ics = "\ BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ SUMMARY:Weekly Review\r\n\ DTSTART:20260415T140000Z\r\n\ RRULE:FREQ=WEEKLY\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; let events = parse_ics(ics).unwrap(); assert_eq!(events[0].recurrence, Recurrence::Weekly); } #[test] fn test_complex_rrule_falls_back_to_none() { let ics = "\ BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ SUMMARY:MWF Meeting\r\n\ DTSTART:20260415T100000Z\r\n\ RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; let events = parse_ics(ics).unwrap(); assert_eq!(events[0].recurrence, Recurrence::None); } #[test] fn test_duration_instead_of_dtend() { let ics = "\ BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ SUMMARY:Quick Chat\r\n\ DTSTART:20260415T100000Z\r\n\ DURATION:PT30M\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; let events = parse_ics(ics).unwrap(); let e = &events[0]; assert!(e.end_time.is_some()); let duration = e.end_time.unwrap() - e.start_time; assert_eq!(duration.num_minutes(), 30); } #[test] fn test_skip_event_without_summary() { let ics = "\ BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ DTSTART:20260415T100000Z\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; let events = parse_ics(ics).unwrap(); assert_eq!(events.len(), 0); } #[test] fn test_multiple_events() { let ics = "\ BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ BEGIN:VEVENT\r\n\ SUMMARY:Event 1\r\n\ DTSTART:20260415T100000Z\r\n\ END:VEVENT\r\n\ BEGIN:VEVENT\r\n\ SUMMARY:Event 2\r\n\ DTSTART:20260416T140000Z\r\n\ END:VEVENT\r\n\ END:VCALENDAR\r\n"; let events = parse_ics(ics).unwrap(); assert_eq!(events.len(), 2); assert_eq!(events[0].title, "Event 1"); assert_eq!(events[1].title, "Event 2"); } #[test] fn test_parse_duration_values() { assert_eq!(parse_duration("PT1H").unwrap().num_seconds(), 3600); assert_eq!(parse_duration("PT30M").unwrap().num_seconds(), 1800); assert_eq!(parse_duration("PT1H30M").unwrap().num_seconds(), 5400); assert_eq!(parse_duration("P1D").unwrap().num_seconds(), 86400); assert_eq!(parse_duration("P1W").unwrap().num_seconds(), 604800); assert_eq!(parse_duration("P1DT2H30M").unwrap().num_seconds(), 86400 + 7200 + 1800); } #[test] fn test_unescape_ical() { assert_eq!(unescape_ical("Hello\\nWorld"), "Hello\nWorld"); assert_eq!(unescape_ical("A\\,B\\;C"), "A,B;C"); } } #[cfg(test)] use chrono::Timelike;