//! Event handling and iCalendar parsing use crate::error::CalDavResult; use chrono::{DateTime, Utc, Datelike, Timelike}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use uuid::Uuid; use md5; // RRULE support (simplified for now) // use rrule::{RRuleSet, RRule, Frequency, Weekday as RRuleWeekday, NWeekday, Tz}; // use std::str::FromStr; /// Calendar event representation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Event { /// Unique identifier pub uid: String, /// Event summary/title pub summary: String, /// Event description pub description: Option, /// Start time pub start: DateTime, /// End time pub end: DateTime, /// All-day event flag pub all_day: bool, /// Event location pub location: Option, /// Event status pub status: EventStatus, /// Event type pub event_type: EventType, /// Organizer pub organizer: Option, /// Attendees pub attendees: Vec, /// Recurrence rule pub recurrence: Option, /// Alarm/reminders pub alarms: Vec, /// Custom properties pub properties: HashMap, /// Creation timestamp pub created: DateTime, /// Last modification timestamp pub last_modified: DateTime, /// Sequence number for updates pub sequence: i32, /// Timezone identifier pub timezone: Option, } /// Event status #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum EventStatus { Confirmed, Tentative, Cancelled, } impl Default for EventStatus { fn default() -> Self { EventStatus::Confirmed } } /// Event type/classification #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum EventType { Public, Private, Confidential, } impl Default for EventType { fn default() -> Self { EventType::Public } } /// Event organizer #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Organizer { /// Email address pub email: String, /// Display name pub name: Option, /// Sent-by parameter pub sent_by: Option, } /// Event attendee #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Attendee { /// Email address pub email: String, /// Display name pub name: Option, /// Participation status pub status: ParticipationStatus, /// Whether required pub required: bool, /// RSVP requested pub rsvp: bool, } /// Participation status #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ParticipationStatus { NeedsAction, Accepted, Declined, Tentative, Delegated, } /// Recurrence rule (simplified RRULE string representation) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecurrenceRule { /// Original RRULE string for storage and parsing pub original_rule: String, } impl RecurrenceRule { /// Create a new RecurrenceRule from an RRULE string pub fn from_str(rrule_str: &str) -> Result> { Ok(RecurrenceRule { original_rule: rrule_str.to_string(), }) } /// Get the RRULE string pub fn as_str(&self) -> &str { &self.original_rule } /// Parse RRULE components from the original_rule string fn parse_components(&self) -> std::collections::HashMap { let mut components = std::collections::HashMap::new(); for part in self.original_rule.split(';') { if let Some((key, value)) = part.split_once('=') { components.insert(key.to_uppercase(), value.to_string()); } } components } /// Get the frequency (FREQ) component pub fn frequency(&self) -> String { self.parse_components() .get("FREQ") .cloned() .unwrap_or_else(|| "DAILY".to_string()) } /// Get the interval (INTERVAL) component pub fn interval(&self) -> i32 { self.parse_components() .get("INTERVAL") .and_then(|s| s.parse().ok()) .unwrap_or(1) } /// Get the count (COUNT) component pub fn count(&self) -> Option { self.parse_components() .get("COUNT") .and_then(|s| s.parse().ok()) } /// Get the until date (UNTIL) component pub fn until(&self) -> Option> { self.parse_components() .get("UNTIL") .and_then(|s| { // Try parsing as different date formats // Format 1: YYYYMMDD (8 characters) if s.len() == 8 { return DateTime::parse_from_str(&format!("{}T000000Z", s), "%Y%m%dT%H%M%SZ") .ok() .map(|dt| dt.with_timezone(&Utc)); } // Format 2: Basic iCalendar datetime with Z: YYYYMMDDTHHMMSSZ (15 or 16 characters) if s.ends_with('Z') && (s.len() == 15 || s.len() == 16) { let cleaned = s.trim_end_matches('Z'); if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(cleaned, "%Y%m%dT%H%M%S") { return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc)); } } // Format 3: Basic iCalendar datetime without Z: YYYYMMDDTHHMMSS (15 characters) if s.len() == 15 && s.contains('T') { if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S") { return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc)); } } // Format 4: Try RFC3339 format if let Ok(dt) = DateTime::parse_from_rfc3339(s) { return Some(dt.with_timezone(&Utc)); } None }) } /// Get the BYDAY component pub fn by_day(&self) -> Vec { self.parse_components() .get("BYDAY") .map(|s| s.split(',').map(|s| s.to_string()).collect()) .unwrap_or_default() } } /// Event alarm/reminder #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Alarm { /// Action type pub action: AlarmAction, /// Trigger time pub trigger: AlarmTrigger, /// Description pub description: Option, /// Summary pub summary: Option, } /// Alarm action #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum AlarmAction { Display, Email, Audio, } /// Alarm trigger #[derive(Debug, Clone, Serialize, Deserialize)] pub enum AlarmTrigger { /// Duration before start BeforeStart(chrono::Duration), /// Duration after start AfterStart(chrono::Duration), /// Duration before end BeforeEnd(chrono::Duration), /// Absolute time Absolute(DateTime), } impl std::fmt::Display for AlarmAction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AlarmAction::Display => write!(f, "DISPLAY"), AlarmAction::Email => write!(f, "EMAIL"), AlarmAction::Audio => write!(f, "AUDIO"), } } } impl std::fmt::Display for AlarmTrigger { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AlarmTrigger::BeforeStart(duration) => { let total_seconds = duration.num_seconds(); write!(f, "-P{}S", total_seconds.abs()) } AlarmTrigger::AfterStart(duration) => { let total_seconds = duration.num_seconds(); write!(f, "P{}S", total_seconds) } AlarmTrigger::BeforeEnd(duration) => { let total_seconds = duration.num_seconds(); write!(f, "-P{}S", total_seconds) } AlarmTrigger::Absolute(datetime) => { write!(f, "{}", datetime.format("%Y%m%dT%H%M%SZ")) } } } } impl Event { /// Create a new event pub fn new(summary: String, start: DateTime, end: DateTime) -> Self { let now = Utc::now(); Self { uid: Uuid::new_v4().to_string(), summary, description: None, start, end, all_day: false, location: None, status: EventStatus::default(), event_type: EventType::default(), organizer: None, attendees: Vec::new(), recurrence: None, alarms: Vec::new(), properties: HashMap::new(), created: now, last_modified: now, sequence: 0, timezone: None, } } /// Create an all-day event pub fn new_all_day(summary: String, date: chrono::NaiveDate) -> Self { let start = date.and_hms_opt(0, 0, 0).unwrap(); let end = date.and_hms_opt(23, 59, 59).unwrap(); let start_utc = DateTime::from_naive_utc_and_offset(start, Utc); let end_utc = DateTime::from_naive_utc_and_offset(end, Utc); Self { uid: Uuid::new_v4().to_string(), summary, description: None, start: start_utc, end: end_utc, all_day: true, location: None, status: EventStatus::default(), event_type: EventType::default(), organizer: None, attendees: Vec::new(), recurrence: None, alarms: Vec::new(), properties: HashMap::new(), created: Utc::now(), last_modified: Utc::now(), sequence: 0, timezone: None, } } /// Parse event from iCalendar data pub fn from_ical(_ical_data: &str) -> CalDavResult { // This is a simplified iCalendar parser // In a real implementation, you'd use a proper iCalendar parsing library let event = Self::new("placeholder".to_string(), Utc::now(), Utc::now()); // Placeholder implementation // TODO: Implement proper iCalendar parsing Ok(event) } /// Convert event to iCalendar format pub fn to_ical(&self) -> CalDavResult { let mut ical = String::new(); // iCalendar header ical.push_str("BEGIN:VCALENDAR\r\n"); ical.push_str("VERSION:2.0\r\n"); ical.push_str("PRODID:-//CalDAV Sync//EN\r\n"); ical.push_str("BEGIN:VEVENT\r\n"); // Basic properties ical.push_str(&format!("UID:{}\r\n", self.uid)); ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary))); if let Some(description) = &self.description { ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); } // Dates with timezone preservation if self.all_day { ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", self.start.format("%Y%m%d"))); ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", self.end.format("%Y%m%d"))); } else { // Check if we have timezone information if let Some(ref tzid) = self.timezone { // Use timezone-aware format ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", tzid, self.start.format("%Y%m%dT%H%M%S"))); ical.push_str(&format!("DTEND;TZID={}:{}\r\n", tzid, self.end.format("%Y%m%dT%H%M%S"))); } else { // Fall back to UTC format ical.push_str(&format!("DTSTART:{}\r\n", self.start.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("DTEND:{}\r\n", self.end.format("%Y%m%dT%H%M%SZ"))); } } // Status ical.push_str(&format!("STATUS:{}\r\n", match self.status { EventStatus::Confirmed => "CONFIRMED", EventStatus::Tentative => "TENTATIVE", EventStatus::Cancelled => "CANCELLED", })); // Class ical.push_str(&format!("CLASS:{}\r\n", match self.event_type { EventType::Public => "PUBLIC", EventType::Private => "PRIVATE", EventType::Confidential => "CONFIDENTIAL", })); // Timestamps ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence)); // Location if let Some(location) = &self.location { ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location))); } // Organizer if let Some(organizer) = &self.organizer { ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email)); } // Attendees for attendee in &self.attendees { ical.push_str(&format!("ATTENDEE:mailto:{}\r\n", attendee.email)); } // iCalendar footer ical.push_str("END:VEVENT\r\n"); ical.push_str("END:VCALENDAR\r\n"); Ok(ical) } /// Update the event's last modified timestamp pub fn touch(&mut self) { self.last_modified = Utc::now(); self.sequence += 1; } /// Generate simplified iCalendar format optimized for Nextcloud import /// This creates clean, individual .ics files that avoid Zoho parsing issues pub fn to_ical_simple(&self) -> CalDavResult { let mut ical = String::new(); // iCalendar header - minimal and clean ical.push_str("BEGIN:VCALENDAR\r\n"); ical.push_str("VERSION:2.0\r\n"); ical.push_str("PRODID:-//caldav-sync//simple-import//EN\r\n"); ical.push_str("CALSCALE:GREGORIAN\r\n"); // VEVENT header ical.push_str("BEGIN:VEVENT\r\n"); // Required properties - only the essentials for Nextcloud ical.push_str(&format!("UID:{}\r\n", escape_ical_text(&self.uid))); ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary))); // Simplified datetime handling - timezone-aware for compatibility if self.all_day { ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", self.start.format("%Y%m%d"))); ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", self.end.format("%Y%m%d"))); } else { // Use timezone-aware format when available, fall back to UTC if let Some(ref tzid) = self.timezone { // Use timezone-aware format ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", tzid, self.start.format("%Y%m%dT%H%M%S"))); ical.push_str(&format!("DTEND;TZID={}:{}\r\n", tzid, self.end.format("%Y%m%dT%H%M%S"))); } else { // Fall back to UTC format for maximum compatibility ical.push_str(&format!("DTSTART:{}\r\n", self.start.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("DTEND:{}\r\n", self.end.format("%Y%m%dT%H%M%SZ"))); } } // Required timestamps ical.push_str(&format!("DTSTAMP:{}\r\n", Utc::now().format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence)); // Basic status - always confirmed for simplicity ical.push_str("STATUS:CONFIRMED\r\n"); ical.push_str("CLASS:PUBLIC\r\n"); // VEVENT and VCALENDAR footers ical.push_str("END:VEVENT\r\n"); ical.push_str("END:VCALENDAR\r\n"); Ok(ical) } /// Generate iCalendar format optimized for Nextcloud pub fn to_ical_for_nextcloud(&self) -> CalDavResult { let mut ical = String::new(); // iCalendar header with Nextcloud-specific properties ical.push_str("BEGIN:VCALENDAR\r\n"); ical.push_str("VERSION:2.0\r\n"); ical.push_str("PRODID:-//caldav-sync//caldav-sync 0.1.0//EN\r\n"); ical.push_str("CALSCALE:GREGORIAN\r\n"); // Add timezone information if available if let Some(tzid) = &self.timezone { ical.push_str(&format!("X-WR-TIMEZONE:{}\r\n", tzid)); } // VEVENT header ical.push_str("BEGIN:VEVENT\r\n"); // Required properties ical.push_str(&format!("UID:{}\r\n", escape_ical_text(&self.uid))); ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary))); // Enhanced datetime handling with timezone support if self.all_day { ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", self.start.format("%Y%m%d"))); ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", (self.end.date_naive() + chrono::Duration::days(1)).format("%Y%m%d"))); } else { if let Some(tzid) = &self.timezone { // Use timezone-specific format ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", tzid, self.start.format("%Y%m%dT%H%M%S"))); ical.push_str(&format!("DTEND;TZID={}:{}\r\n", tzid, self.end.format("%Y%m%dT%H%M%S"))); } else { // Use UTC format ical.push_str(&format!("DTSTART:{}\r\n", self.start.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("DTEND:{}\r\n", self.end.format("%Y%m%dT%H%M%SZ"))); } } // Required timestamps ical.push_str(&format!("DTSTAMP:{}\r\n", Utc::now().format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ"))); ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence)); // Optional properties if let Some(description) = &self.description { ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); } if let Some(location) = &self.location { ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location))); } // Status mapping ical.push_str(&format!("STATUS:{}\r\n", match self.status { EventStatus::Confirmed => "CONFIRMED", EventStatus::Tentative => "TENTATIVE", EventStatus::Cancelled => "CANCELLED", })); // Class (visibility) ical.push_str(&format!("CLASS:{}\r\n", match self.event_type { EventType::Public => "PUBLIC", EventType::Private => "PRIVATE", EventType::Confidential => "CONFIDENTIAL", })); // Organizer and attendees if let Some(organizer) = &self.organizer { if let Some(name) = &organizer.name { ical.push_str(&format!("ORGANIZER;CN={}:mailto:{}\r\n", escape_ical_text(name), organizer.email)); } else { ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email)); } } for attendee in &self.attendees { let mut attendee_line = String::from("ATTENDEE"); if let Some(name) = &attendee.name { attendee_line.push_str(&format!(";CN={}", escape_ical_text(name))); } attendee_line.push_str(&format!(":mailto:{}", attendee.email)); attendee_line.push_str("\r\n"); ical.push_str(&attendee_line); } // Alarms/reminders for alarm in &self.alarms { ical.push_str(&format!("BEGIN:VALARM\r\n")); ical.push_str(&format!("ACTION:{}\r\n", alarm.action)); ical.push_str(&format!("TRIGGER:{}\r\n", alarm.trigger)); if let Some(description) = &alarm.description { ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); } ical.push_str("END:VALARM\r\n"); } // Custom properties (including Nextcloud-specific ones) for (key, value) in &self.properties { if key.starts_with("X-") { ical.push_str(&format!("{}:{}\r\n", key, escape_ical_text(value))); } } // VEVENT and VCALENDAR footers ical.push_str("END:VEVENT\r\n"); ical.push_str("END:VCALENDAR\r\n"); Ok(ical) } /// Generate the CalDAV path for this event pub fn generate_caldav_path(&self) -> String { format!("{}.ics", self.uid) } /// Check if event occurs on a specific date pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool { let start_date = self.start.date_naive(); let end_date = self.end.date_naive(); if self.all_day { start_date <= date && end_date >= date } else { start_date <= date && end_date >= date } } /// Get event duration pub fn duration(&self) -> chrono::Duration { self.end.signed_duration_since(self.start) } /// Check if event is currently in progress pub fn is_in_progress(&self) -> bool { let now = Utc::now(); now >= self.start && now <= self.end } /// Check if this event needs updating compared to another event pub fn needs_update(&self, other: &Event) -> bool { // Compare essential fields if self.summary != other.summary { return true; } if self.description != other.description { return true; } if self.location != other.location { return true; } // Compare timezone information - this is crucial for detecting timezone mangling fixes match (&self.timezone, &other.timezone) { (None, None) => { // Both have no timezone - continue with other checks } (Some(tz1), Some(tz2)) => { // Both have timezone - compare them if tz1 != tz2 { return true; } } (Some(_), None) | (None, Some(_)) => { // One has timezone, other doesn't - definitely needs update return true; } } // Compare dates with some tolerance for timestamp differences let start_diff = (self.start - other.start).num_seconds().abs(); let end_diff = (self.end - other.end).num_seconds().abs(); if start_diff > 60 || end_diff > 60 { // 1 minute tolerance return true; } // Compare status and event type if self.status != other.status { return true; } if self.event_type != other.event_type { return true; } // Compare sequence numbers - higher sequence means newer if self.sequence > other.sequence { return true; } false } /// Validate event for CalDAV import compatibility pub fn validate_for_import(&self) -> Result<(), String> { // Check required fields if self.uid.trim().is_empty() { return Err("Event UID cannot be empty".to_string()); } if self.summary.trim().is_empty() { return Err("Event summary cannot be empty".to_string()); } // Validate datetime if self.start > self.end { return Err("Event start time must be before end time".to_string()); } // Check for reasonable date ranges let now = Utc::now(); let one_year_ago = now - chrono::Duration::days(365); let ten_years_future = now + chrono::Duration::days(365 * 10); if self.start < one_year_ago { return Err("Event start time is more than one year in the past".to_string()); } if self.start > ten_years_future { return Err("Event start time is more than ten years in the future".to_string()); } Ok(()) } /// Simple recurrence expansion for basic RRULE strings pub fn expand_occurrences(&self, start_range: DateTime, end_range: DateTime) -> Vec { // If this is not a recurring event, return just this event if self.recurrence.is_none() { return vec![self.clone()]; } let mut occurrences = Vec::new(); let recurrence_rule = self.recurrence.as_ref().unwrap(); // For now, implement a very basic RRULE expansion using simple date arithmetic let mut current_start = self.start; let event_duration = self.duration(); let mut occurrence_count = 0; // Limit occurrences to prevent infinite loops let max_occurrences = recurrence_rule.count().unwrap_or(1000).min(1000); while current_start <= end_range && occurrence_count < max_occurrences { // Check if we've reached the count limit if let Some(count) = recurrence_rule.count() { if occurrence_count >= count { break; } } // Check if we've reached the until limit if let Some(until) = recurrence_rule.until() { if current_start > until { break; } } // Check if this occurrence falls within our desired range if current_start >= start_range && current_start <= end_range { let mut occurrence = self.clone(); occurrence.start = current_start; occurrence.end = current_start + event_duration; // Create a unique UID for this occurrence let occurrence_date = current_start.format("%Y%m%d").to_string(); // Include a hash of the original event details to ensure uniqueness across different recurring series let series_identifier = format!("{:x}", md5::compute(format!("{}-{}", self.uid, self.summary))); occurrence.uid = format!("{}-occurrence-{}-{}", series_identifier, occurrence_date, self.uid); // Clear the recurrence rule for individual occurrences occurrence.recurrence = None; // Update creation and modification times occurrence.created = Utc::now(); occurrence.last_modified = Utc::now(); occurrences.push(occurrence); } // Calculate next occurrence based on RRULE components let interval = recurrence_rule.interval() as i64; current_start = match recurrence_rule.frequency().to_lowercase().as_str() { "daily" => { // For daily frequency, check if there are BYDAY restrictions let by_day = recurrence_rule.by_day(); if !by_day.is_empty() { // Find the next valid weekday for DAILY frequency with BYDAY restriction let mut next_day = current_start + chrono::Duration::days(1); let mut days_checked = 0; // Search for up to 7 days to find the next valid weekday while days_checked < 7 { let weekday = match next_day.weekday().number_from_monday() { 1 => "MO", 2 => "TU", 3 => "WE", 4 => "TH", 5 => "FR", 6 => "SA", 7 => "SU", _ => "MO", // fallback }; if by_day.contains(&weekday.to_string()) { // Found the next valid weekday break; } next_day = next_day + chrono::Duration::days(1); days_checked += 1; } next_day } else { // No BYDAY restriction, just add days normally current_start + chrono::Duration::days(interval) } }, "weekly" => { // For weekly frequency, we need to handle BYDAY filtering let by_day = recurrence_rule.by_day(); if !by_day.is_empty() { // Find the next valid weekday let mut next_day = current_start + chrono::Duration::days(1); let mut days_checked = 0; // Search for up to 7 days (one week) to find the next valid weekday while days_checked < 7 { let weekday = match next_day.weekday().number_from_monday() { 1 => "MO", 2 => "TU", 3 => "WE", 4 => "TH", 5 => "FR", 6 => "SA", 7 => "SU", _ => "MO", // fallback }; if by_day.contains(&weekday.to_string()) { // Found the next valid weekday break; } next_day = next_day + chrono::Duration::days(1); days_checked += 1; } next_day } else { // No BYDAY restriction, just add weeks current_start + chrono::Duration::weeks(interval) } }, "monthly" => add_months(current_start, interval as u32), "yearly" => add_months(current_start, (interval * 12) as u32), "hourly" => current_start + chrono::Duration::hours(interval), "minutely" => current_start + chrono::Duration::minutes(interval), "secondly" => current_start + chrono::Duration::seconds(interval), _ => current_start + chrono::Duration::days(interval), // Default to daily }; occurrence_count += 1; } tracing::info!( "🔄 Expanded recurring event '{}' to {} occurrences between {} and {}", self.summary, occurrences.len(), start_range.format("%Y-%m-%d"), end_range.format("%Y-%m-%d") ); occurrences } } /// Add months to a DateTime (approximate handling) fn add_months(dt: DateTime, months: u32) -> DateTime { let naive_date = dt.naive_utc(); let year = naive_date.year(); let month = naive_date.month() as i32 + months as i32; let new_year = year + (month - 1) / 12; let new_month = ((month - 1) % 12) + 1; // Keep the same day if possible, otherwise use the last day of the month let day = naive_date.day().min(days_in_month(new_year as i32, new_month as u32)); // Try to create the new date with the same time, fallback to first day of month if invalid if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, day) { if let Some(new_naive_dt) = new_naive_date.and_hms_opt(naive_date.hour(), naive_date.minute(), naive_date.second()) { return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc); } } // Fallback: use first day of the month with the same time if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, 1) { if let Some(new_naive_dt) = new_naive_date.and_hms_opt(naive_date.hour(), naive_date.minute(), naive_date.second()) { return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc); } } // Ultimate fallback: use start of the month if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, 1) { if let Some(new_naive_dt) = new_naive_date.and_hms_opt(0, 0, 0) { return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc); } } // If all else fails, return the original date dt } /// Get the number of days in a month fn days_in_month(year: i32, month: u32) -> u32 { match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, 2 => { if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) { 29 } else { 28 } } _ => 30, // Should never happen } } /// Escape text for iCalendar format fn escape_ical_text(text: &str) -> String { text .replace('\\', "\\\\") .replace(',', "\\,") .replace(';', "\\;") .replace('\n', "\\n") .replace('\r', "\\r") } /// Parse iCalendar date/time #[cfg(test)] fn parse_ical_datetime(dt_str: &str) -> CalDavResult> { use crate::error::CalDavError; use chrono::NaiveDateTime; // Handle different iCalendar date formats if dt_str.len() == 8 { // DATE format (YYYYMMDD) let naive_date = chrono::NaiveDate::parse_from_str(dt_str, "%Y%m%d")?; let naive_datetime = naive_date.and_hms_opt(0, 0, 0).unwrap(); Ok(DateTime::from_naive_utc_and_offset(naive_datetime, Utc)) } else if dt_str.ends_with('Z') { // UTC datetime format (YYYYMMDDTHHMMSSZ) let dt_without_z = &dt_str[..dt_str.len()-1]; let naive_dt = NaiveDateTime::parse_from_str(dt_without_z, "%Y%m%dT%H%M%S")?; Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc)) } else { // Local time format - this would need timezone handling Err(CalDavError::EventProcessing( "Local time parsing not implemented".to_string() )) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_event_creation() { let start = Utc::now(); let end = start + chrono::Duration::hours(1); let event = Event::new("Test Event".to_string(), start, end); assert_eq!(event.summary, "Test Event"); assert_eq!(event.start, start); assert_eq!(event.end, end); assert!(!event.all_day); assert_eq!(event.status, EventStatus::Confirmed); } #[test] fn test_all_day_event() { let date = chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap(); let event = Event::new_all_day("Christmas".to_string(), date); assert_eq!(event.summary, "Christmas"); assert!(event.all_day); assert!(event.occurs_on(date)); } #[test] fn test_event_to_ical() { let event = Event::new( "Meeting".to_string(), DateTime::from_naive_utc_and_offset( chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(), Utc ), DateTime::from_naive_utc_and_offset( chrono::NaiveDateTime::parse_from_str("20231225T110000", "%Y%m%dT%H%M%S").unwrap(), Utc ), ); let ical = event.to_ical().unwrap(); assert!(ical.contains("SUMMARY:Meeting")); assert!(ical.contains("DTSTART:20231225T100000Z")); assert!(ical.contains("DTEND:20231225T110000Z")); assert!(ical.contains("BEGIN:VCALENDAR")); assert!(ical.contains("END:VCALENDAR")); } #[test] fn test_ical_text_escaping() { let text = "Hello, world; this\\is a test"; let escaped = escape_ical_text(text); assert_eq!(escaped, "Hello\\, world\\; this\\\\is a test"); } #[test] fn test_parse_ical_datetime() { // Test DATE format (YYYYMMDD) let date_result = parse_ical_datetime("20231225").unwrap(); assert_eq!(date_result.format("%Y%m%d").to_string(), "20231225"); assert_eq!(date_result.format("%H%M%S").to_string(), "000000"); // Test UTC datetime format (YYYYMMDDTHHMMSSZ) let datetime_result = parse_ical_datetime("20231225T103000Z").unwrap(); assert_eq!(datetime_result.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T103000Z"); // Test local time format (should fail) let local_result = parse_ical_datetime("20231225T103000"); assert!(local_result.is_err()); } #[test] fn test_event_to_ical_with_timezone() { let start = DateTime::from_naive_utc_and_offset( chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(), Utc ); let end = start + chrono::Duration::minutes(30); let mut event = Event::new("Tether Sync".to_string(), start, end); event.timezone = Some("America/Toronto".to_string()); let ical = event.to_ical().unwrap(); // Should include timezone information assert!(ical.contains("DTSTART;TZID=America/Toronto:20231225T083000")); assert!(ical.contains("DTEND;TZID=America/Toronto:20231225T090000")); assert!(ical.contains("SUMMARY:Tether Sync")); } #[test] fn test_event_to_ical_without_timezone() { let start = DateTime::from_naive_utc_and_offset( chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(), Utc ); let end = start + chrono::Duration::minutes(30); let event = Event::new("UTC Event".to_string(), start, end); let ical = event.to_ical().unwrap(); // Should use UTC format when no timezone is specified assert!(ical.contains("DTSTART:20231225T083000Z")); assert!(ical.contains("DTEND:20231225T090000Z")); assert!(ical.contains("SUMMARY:UTC Event")); } #[test] fn test_needs_update_timezone_comparison() { let start = DateTime::from_naive_utc_and_offset( chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(), Utc ); let end = start + chrono::Duration::minutes(30); // Test case 1: Event with timezone vs event without timezone (should need update) let mut event_with_tz = Event::new("Test Event".to_string(), start, end); event_with_tz.timezone = Some("America/Toronto".to_string()); let event_without_tz = Event::new("Test Event".to_string(), start, end); assert!(event_with_tz.needs_update(&event_without_tz)); assert!(event_without_tz.needs_update(&event_with_tz)); // Test case 2: Events with different timezones (should need update) let mut event_tz1 = Event::new("Test Event".to_string(), start, end); event_tz1.timezone = Some("America/Toronto".to_string()); let mut event_tz2 = Event::new("Test Event".to_string(), start, end); event_tz2.timezone = Some("Europe/Athens".to_string()); assert!(event_tz1.needs_update(&event_tz2)); assert!(event_tz2.needs_update(&event_tz1)); // Test case 3: Events with same timezone (should not need update) let mut event_tz3 = Event::new("Test Event".to_string(), start, end); event_tz3.timezone = Some("America/Toronto".to_string()); let mut event_tz4 = Event::new("Test Event".to_string(), start, end); event_tz4.timezone = Some("America/Toronto".to_string()); assert!(!event_tz3.needs_update(&event_tz4)); assert!(!event_tz4.needs_update(&event_tz3)); // Test case 4: Both events without timezone (should not need update) let event_no_tz1 = Event::new("Test Event".to_string(), start, end); let event_no_tz2 = Event::new("Test Event".to_string(), start, end); assert!(!event_no_tz1.needs_update(&event_no_tz2)); assert!(!event_no_tz2.needs_update(&event_no_tz1)); } }