use calendar_models::{CalendarUser, EventClass, EventStatus, VAlarm, VEvent}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; // Global mutex to serialize CalDAV HTTP requests to prevent race conditions lazy_static::lazy_static! { static ref CALDAV_HTTP_MUTEX: Arc> = Arc::new(Mutex::new(())); } /// Type alias for shared VEvent (for backward compatibility during migration) pub type CalendarEvent = VEvent; /// Old CalendarEvent struct definition (DEPRECATED - use VEvent instead) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct OldCalendarEvent { /// Unique identifier for the event (UID field in iCal) pub uid: String, /// Summary/title of the event pub summary: Option, /// Detailed description of the event pub description: Option, /// Start date and time of the event pub start: DateTime, /// End date and time of the event pub end: Option>, /// Location where the event takes place pub location: Option, /// Event status (TENTATIVE, CONFIRMED, CANCELLED) pub status: EventStatus, /// Event classification (PUBLIC, PRIVATE, CONFIDENTIAL) pub class: EventClass, /// Event priority (0-9, where 0 is undefined, 1 is highest, 9 is lowest) pub priority: Option, /// Organizer of the event pub organizer: Option, /// List of attendees pub attendees: Vec, /// Categories/tags for the event pub categories: Vec, /// Date and time when the event was created pub created: Option>, /// Date and time when the event was last modified pub last_modified: Option>, /// Recurrence rule (RRULE) pub recurrence_rule: Option, /// Exception dates - dates to exclude from recurrence (EXDATE) pub exdate: Vec>, /// All-day event flag pub all_day: bool, /// Reminders/alarms for this event pub reminders: Vec, /// ETag from CalDAV server for conflict detection pub etag: Option, /// URL/href of this event on the CalDAV server pub href: Option, /// Calendar path this event belongs to pub calendar_path: Option, } // EventStatus and EventClass are now imported from calendar_models /// Event reminder/alarm information #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EventReminder { /// How long before the event to trigger the reminder (in minutes) pub minutes_before: i32, /// Type of reminder action pub action: ReminderAction, /// Optional description for the reminder pub description: Option, } /// Reminder action types #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ReminderAction { Display, Email, Audio, } /// CalDAV client for fetching and parsing calendar events pub struct CalDAVClient { config: crate::config::CalDAVConfig, http_client: reqwest::Client, } impl CalDAVClient { /// Create a new CalDAV client with the given configuration pub fn new(config: crate::config::CalDAVConfig) -> Self { // Create HTTP client with global timeout to prevent hanging requests let http_client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) // 60 second global timeout .build() .expect("Failed to create HTTP client"); Self { config, http_client, } } /// Fetch calendar events from the CalDAV server /// /// This method performs a REPORT request to get calendar data and parses /// the returned iCalendar format into CalendarEvent structs. pub async fn fetch_events( &self, calendar_path: &str, ) -> Result, CalDAVError> { // CalDAV REPORT request to get calendar events let report_body = r#" "#; let url = if calendar_path.starts_with("http") { calendar_path.to_string() } else { // Extract the base URL (scheme + host + port) from server_url let server_url = &self.config.server_url; // Find the first '/' after "https://" or "http://" let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 }; if let Some(path_start) = server_url[scheme_end..].find('/') { let base_url = &server_url[..scheme_end + path_start]; format!("{}{}", base_url, calendar_path) } else { // No path in server_url, so just append the calendar_path format!("{}{}", server_url.trim_end_matches('/'), calendar_path) } }; let basic_auth = self.config.get_basic_auth(); println!("šŸ”‘ REPORT Basic Auth: Basic {}", basic_auth); println!("🌐 REPORT URL: {}", url); let response = self .http_client .request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url) .header("Authorization", format!("Basic {}", basic_auth)) .header("Content-Type", "application/xml") .header("Depth", "1") .header("User-Agent", "calendar-app/0.1.0") .body(report_body) .send() .await .map_err(CalDAVError::RequestError)?; if !response.status().is_success() && response.status().as_u16() != 207 { return Err(CalDAVError::ServerError(response.status().as_u16())); } let body = response.text().await.map_err(CalDAVError::RequestError)?; self.parse_calendar_response(&body, calendar_path) } /// Parse CalDAV XML response containing calendar data fn parse_calendar_response( &self, xml_response: &str, calendar_path: &str, ) -> Result, CalDAVError> { let mut events = Vec::new(); // Extract calendar data from XML response // This is a simplified parser - in production, you'd want a proper XML parser let calendar_data_sections = self.extract_calendar_data(xml_response); for calendar_data in calendar_data_sections { if let Ok(parsed_events) = self.parse_ical_data(&calendar_data.data) { for mut event in parsed_events { event.etag = calendar_data.etag.clone(); event.href = calendar_data.href.clone(); event.calendar_path = Some(calendar_path.to_string()); events.push(event); } } } Ok(events) } /// Fetch a single calendar event by UID from the CalDAV server pub async fn fetch_event_by_uid( &self, calendar_path: &str, uid: &str, ) -> Result, CalDAVError> { // First fetch all events and find the one with matching UID let events = self.fetch_events(calendar_path).await?; // Find event with matching UID let event = events.into_iter().find(|e| e.uid == uid); Ok(event) } /// Extract calendar data sections from CalDAV XML response fn extract_calendar_data(&self, xml_response: &str) -> Vec { let mut sections = Vec::new(); // Simple regex-based extraction (in production, use a proper XML parser) // Look for blocks containing calendar data for response_block in xml_response.split("").skip(1) { if let Some(end_pos) = response_block.find("") { let response_content = &response_block[..end_pos]; let href = self .extract_xml_content(response_content, "href") .unwrap_or_default(); let etag = self .extract_xml_content(response_content, "getetag") .unwrap_or_default(); if let Some(calendar_data) = self.extract_xml_content(response_content, "cal:calendar-data") { sections.push(CalendarDataSection { href: if href.is_empty() { None } else { Some(href) }, etag: if etag.is_empty() { None } else { Some(etag) }, data: calendar_data, }); } } } sections } /// Extract content from XML tags (simple implementation) fn extract_xml_content(&self, xml: &str, tag: &str) -> Option { // Handle both with and without namespace prefixes let patterns = [ format!("(?s)<{}>(.*?)", tag, tag), // content format!( "(?s)<{}>(.*?)", tag, tag.split(':').last().unwrap_or(tag) ), // content format!( "(?s)<.*:{}>(.*?)", tag.split(':').last().unwrap_or(tag), tag ), // content format!( "(?s)<.*:{}>(.*?)", tag.split(':').last().unwrap_or(tag), tag.split(':').last().unwrap_or(tag) ), // content format!("(?s)<{}[^>]*>(.*?)", tag, tag), // content format!( "(?s)<{}[^>]*>(.*?)", tag, tag.split(':').last().unwrap_or(tag) ), ]; for pattern in &patterns { if let Ok(re) = regex::Regex::new(pattern) { if let Some(captures) = re.captures(xml) { if let Some(content) = captures.get(1) { return Some(content.as_str().trim().to_string()); } } } } None } /// Parse iCalendar data into CalendarEvent structs fn parse_ical_data(&self, ical_data: &str) -> Result, CalDAVError> { let mut events = Vec::new(); // Parse the iCal data using the ical crate let reader = ical::IcalParser::new(ical_data.as_bytes()); for calendar in reader { let calendar = calendar.map_err(|e| CalDAVError::ParseError(e.to_string()))?; for event in calendar.events { if let Ok(calendar_event) = self.parse_ical_event(event) { events.push(calendar_event); } } } Ok(events) } /// Parse a single iCal event into a CalendarEvent struct fn parse_ical_event( &self, event: ical::parser::ical::component::IcalEvent, ) -> Result { let mut properties: HashMap = HashMap::new(); // Extract all properties from the event for property in &event.properties { properties.insert( property.name.to_uppercase(), property.value.clone().unwrap_or_default(), ); } // Required UID field let uid = properties .get("UID") .ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))? .clone(); // Parse start time (required) let start = properties .get("DTSTART") .ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?; let start = self.parse_datetime(start, properties.get("DTSTART"))?; // Parse end time (optional - use start time if not present) let end = if let Some(dtend) = properties.get("DTEND") { Some(self.parse_datetime(dtend, properties.get("DTEND"))?) } else if let Some(_duration) = properties.get("DURATION") { // TODO: Parse duration and add to start time Some(start) } else { None }; // Determine if it's an all-day event by checking for VALUE=DATE parameter let empty_string = String::new(); let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string); let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8); // Parse status let status = properties .get("STATUS") .map(|s| match s.to_uppercase().as_str() { "TENTATIVE" => EventStatus::Tentative, "CANCELLED" => EventStatus::Cancelled, _ => EventStatus::Confirmed, }) .unwrap_or(EventStatus::Confirmed); // Parse classification let class = properties .get("CLASS") .map(|s| match s.to_uppercase().as_str() { "PRIVATE" => EventClass::Private, "CONFIDENTIAL" => EventClass::Confidential, _ => EventClass::Public, }) .unwrap_or(EventClass::Public); // Parse priority let priority = properties .get("PRIORITY") .and_then(|s| s.parse::().ok()) .filter(|&p| p <= 9); // Parse categories let categories = properties .get("CATEGORIES") .map(|s| s.split(',').map(|c| c.trim().to_string()).collect()) .unwrap_or_default(); // Parse dates let created = properties .get("CREATED") .and_then(|s| self.parse_datetime(s, None).ok()); let last_modified = properties .get("LAST-MODIFIED") .and_then(|s| self.parse_datetime(s, None).ok()); // Parse exception dates (EXDATE) let exdate = self.parse_exdate(&event); // Create VEvent with required fields let mut vevent = VEvent::new(uid, start); // Set optional fields vevent.dtend = end; vevent.summary = properties.get("SUMMARY").cloned(); vevent.description = properties.get("DESCRIPTION").cloned(); vevent.location = properties.get("LOCATION").cloned(); vevent.status = Some(status); vevent.class = Some(class); vevent.priority = priority; // Convert organizer string to CalendarUser if let Some(organizer_str) = properties.get("ORGANIZER") { vevent.organizer = Some(CalendarUser { cal_address: organizer_str.clone(), common_name: None, dir_entry_ref: None, sent_by: None, language: None, }); } // TODO: Parse attendees properly vevent.attendees = Vec::new(); vevent.categories = categories; vevent.created = created; vevent.last_modified = last_modified; vevent.rrule = properties.get("RRULE").cloned(); vevent.exdate = exdate; vevent.all_day = all_day; // Parse alarms vevent.alarms = self.parse_valarms(&event)?; // CalDAV specific fields (set by caller) vevent.etag = None; vevent.href = None; vevent.calendar_path = None; Ok(vevent) } /// Parse VALARM components from an iCal event fn parse_valarms( &self, event: &ical::parser::ical::component::IcalEvent, ) -> Result, CalDAVError> { let mut alarms = Vec::new(); for alarm in &event.alarms { if let Ok(valarm) = self.parse_single_valarm(alarm) { alarms.push(valarm); } } Ok(alarms) } /// Parse a single VALARM component into a VAlarm fn parse_single_valarm( &self, alarm: &ical::parser::ical::component::IcalAlarm, ) -> Result { let mut properties: HashMap = HashMap::new(); // Extract all properties from the alarm for property in &alarm.properties { properties.insert( property.name.to_uppercase(), property.value.clone().unwrap_or_default(), ); } // Parse ACTION (required) let action = match properties.get("ACTION").map(|s| s.to_uppercase()) { Some(ref action_str) if action_str == "DISPLAY" => { calendar_models::AlarmAction::Display } Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email, Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio, Some(ref action_str) if action_str == "PROCEDURE" => { calendar_models::AlarmAction::Procedure } _ => calendar_models::AlarmAction::Display, // Default }; // Parse TRIGGER (required) let trigger = if let Some(trigger_str) = properties.get("TRIGGER") { if let Some(minutes) = self.parse_trigger_duration(trigger_str) { calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-minutes as i64)) } else { // Default to 15 minutes before calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-15)) } } else { // Default to 15 minutes before calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-15)) }; // Get description let description = properties.get("DESCRIPTION").cloned(); Ok(VAlarm { action, trigger, duration: None, repeat: None, description, summary: None, attendees: Vec::new(), attach: Vec::new(), }) } /// Parse a TRIGGER duration string into minutes before event fn parse_trigger_duration(&self, trigger: &str) -> Option { // Basic parsing of ISO 8601 duration or relative time // Examples: "-PT15M" (15 minutes before), "-P1D" (1 day before) if trigger.starts_with("-PT") && trigger.ends_with("M") { // Parse "-PT15M" format (minutes) let minutes_str = &trigger[3..trigger.len() - 1]; minutes_str.parse::().ok() } else if trigger.starts_with("-PT") && trigger.ends_with("H") { // Parse "-PT1H" format (hours) let hours_str = &trigger[3..trigger.len() - 1]; hours_str.parse::().ok().map(|h| h * 60) } else if trigger.starts_with("-P") && trigger.ends_with("D") { // Parse "-P1D" format (days) let days_str = &trigger[2..trigger.len() - 1]; days_str.parse::().ok().map(|d| d * 24 * 60) } else { // Try to parse as raw minutes trigger.parse::().ok().map(|m| m.abs()) } } /// Discover available calendar collections on the server pub async fn discover_calendars(&self) -> Result, CalDAVError> { // First, try to discover user calendars if we have a calendar path in config if let Some(calendar_path) = &self.config.calendar_path { println!("Using configured calendar path: {}", calendar_path); return Ok(vec![calendar_path.clone()]); } println!("No calendar path configured, discovering calendars..."); // Try different common CalDAV discovery paths // Note: paths should be relative to the server URL base let user_calendar_path = format!("/calendars/{}/", self.config.username); let discovery_paths = vec!["/calendars/", user_calendar_path.as_str()]; let mut all_calendars = Vec::new(); for path in discovery_paths { println!("Trying discovery path: {}", path); if let Ok(calendars) = self.discover_calendars_at_path(&path).await { println!("Found {} calendar(s) at {}", calendars.len(), path); all_calendars.extend(calendars); } } // Remove duplicates all_calendars.sort(); all_calendars.dedup(); Ok(all_calendars) } /// Discover calendars at a specific path async fn discover_calendars_at_path(&self, path: &str) -> Result, CalDAVError> { let propfind_body = r#" "#; let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path); let response = self .http_client .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .header("Content-Type", "application/xml") .header("Depth", "2") // Deeper search to find actual calendars .header("User-Agent", "calendar-app/0.1.0") .body(propfind_body) .send() .await .map_err(CalDAVError::RequestError)?; if response.status().as_u16() != 207 { println!( "āŒ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16() ); return Err(CalDAVError::ServerError(response.status().as_u16())); } let body = response.text().await.map_err(CalDAVError::RequestError)?; println!("Discovery response for {}: {}", path, body); let mut calendar_paths = Vec::new(); // Extract calendar collection URLs from the response for response_block in body.split("").skip(1) { if let Some(end_pos) = response_block.find("") { let response_content = &response_block[..end_pos]; // Extract href first if let Some(href) = self.extract_xml_content(response_content, "href") { println!("šŸ” Checking resource: {}", href); // Check if this is a calendar collection by looking for supported-calendar-component-set // This indicates it's an actual calendar that can contain events let has_supported_components = response_content .contains("supported-calendar-component-set") && (response_content.contains("VEVENT") || response_content.contains("VTODO")); let has_calendar_resourcetype = response_content.contains(", ) -> Result, CalDAVError> { use chrono::TimeZone; // Handle different iCal datetime formats let cleaned = datetime_str.replace("TZID=", "").trim().to_string(); // Try different parsing formats let formats = [ "%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z "%Y%m%dT%H%M%S", // Local format: 20231225T120000 "%Y%m%d", // Date only: 20231225 ]; for format in &formats { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) { return Ok(Utc.from_utc_datetime(&dt)); } if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) { return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap())); } } Err(CalDAVError::ParseError(format!( "Unable to parse datetime: {}", datetime_str ))) } /// Parse EXDATE properties from an iCal event fn parse_exdate(&self, event: &ical::parser::ical::component::IcalEvent) -> Vec> { let mut exdate = Vec::new(); // Look for EXDATE properties for property in &event.properties { if property.name.to_uppercase() == "EXDATE" { if let Some(value) = &property.value { // EXDATE can contain multiple comma-separated dates for date_str in value.split(',') { // Try to parse the date (the parse_datetime method will handle different formats) if let Ok(date) = self.parse_datetime(date_str.trim(), None) { exdate.push(date); } } } } } exdate } /// Create a new calendar on the CalDAV server using MKCALENDAR pub async fn create_calendar( &self, name: &str, description: Option<&str>, color: Option<&str>, ) -> Result<(), CalDAVError> { // Sanitize calendar name for URL path let calendar_id = name .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect::() .to_lowercase(); let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id); let full_url = format!( "{}{}", self.config.server_url.trim_end_matches('/'), calendar_path ); // Build color property if provided let color_property = if let Some(color) = color { format!( r#"{}"#, color ) } else { String::new() }; let description_property = if let Some(desc) = description { format!( r#"{}"#, desc ) } else { String::new() }; // Create the MKCALENDAR request body let mkcalendar_body = format!( r#" {} {} {} "#, name, color_property, description_property ); println!("Creating calendar at: {}", full_url); println!("MKCALENDAR body: {}", mkcalendar_body); let response = self .http_client .request( reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url, ) .header("Content-Type", "application/xml; charset=utf-8") .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .body(mkcalendar_body) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Calendar creation response status: {}", response.status()); if response.status().is_success() { println!("āœ… Calendar created successfully at {}", calendar_path); Ok(()) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("āŒ Calendar creation failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } /// Delete a calendar from the CalDAV server pub async fn delete_calendar(&self, calendar_path: &str) -> Result<(), CalDAVError> { let full_url = if calendar_path.starts_with("http") { calendar_path.to_string() } else { // Handle case where calendar_path already contains /dav.php let clean_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_start_matches("/dav.php") } else { calendar_path }; format!( "{}{}", self.config.server_url.trim_end_matches('/'), clean_path ) }; println!("Deleting calendar at: {}", full_url); let response = self .http_client .delete(&full_url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Calendar deletion response status: {}", response.status()); if response.status().is_success() || response.status().as_u16() == 204 { println!("āœ… Calendar deleted successfully at {}", calendar_path); Ok(()) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("āŒ Calendar deletion failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } /// Create a new event in a CalDAV calendar pub async fn create_event( &self, calendar_path: &str, event: &CalendarEvent, ) -> Result { // Generate a unique filename for the event (using UID + .ics extension) let event_filename = format!("{}.ics", event.uid); // Construct the full URL for the event let full_url = if calendar_path.starts_with("http") { format!("{}/{}", calendar_path.trim_end_matches('/'), event_filename) } else { // Handle URL construction more carefully let server_url = self.config.server_url.trim_end_matches('/'); // Remove /dav.php from the end of server URL if present let base_url = if server_url.ends_with("/dav.php") { server_url.trim_end_matches("/dav.php") } else { server_url }; // Calendar path should start with /dav.php, if not add it let clean_calendar_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_end_matches('/') } else { // This shouldn't happen in our case, but handle it &format!("/dav.php{}", calendar_path.trim_end_matches('/')) }; format!("{}{}/{}", base_url, clean_calendar_path, event_filename) }; println!("šŸ“ Creating event with calendar_path: {}", calendar_path); println!("šŸ“ Server URL: {}", self.config.server_url); println!("šŸ“ Constructed URL: {}", full_url); // Generate iCalendar data for the event let ical_data = self.generate_ical_event(event)?; println!("Creating event at: {}", full_url); println!("iCal data: {}", ical_data); println!("šŸ“” Acquiring CalDAV HTTP lock for CREATE request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("šŸ“” Lock acquired, sending CREATE request to CalDAV server..."); let response = self .http_client .put(&full_url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .header("Content-Type", "text/calendar; charset=utf-8") .header("User-Agent", "calendar-app/0.1.0") .body(ical_data) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Event creation response status: {}", response.status()); if response.status().is_success() || response.status().as_u16() == 201 { println!("āœ… Event created successfully at {}", event_filename); Ok(event_filename) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("āŒ Event creation failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } /// Update an existing event on the CalDAV server pub async fn update_event( &self, calendar_path: &str, event: &CalendarEvent, event_href: &str, ) -> Result<(), CalDAVError> { // Construct the full URL for the event let full_url = if event_href.starts_with("http") { event_href.to_string() } else if event_href.starts_with("/dav.php") { // Event href is already a full path, combine with base server URL (without /dav.php) let base_url = self .config .server_url .trim_end_matches('/') .trim_end_matches("/dav.php"); format!("{}{}", base_url, event_href) } else { // Event href is just a filename, combine with calendar path let clean_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_start_matches("/dav.php") } else { calendar_path }; format!( "{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href ) }; println!("šŸ“ Updating event at: {}", full_url); // Generate iCalendar data for the event let ical_data = self.generate_ical_event(event)?; println!("šŸ“ Updated iCal data: {}", ical_data); println!("šŸ“ Event has {} exception dates", event.exdate.len()); println!("šŸ“” Acquiring CalDAV HTTP lock for PUT request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("šŸ“” Lock acquired, sending PUT request to CalDAV server..."); println!("šŸ”— PUT URL: {}", full_url); println!("šŸ” Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8"); let response = self .http_client .put(&full_url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .header("Content-Type", "text/calendar; charset=utf-8") .header("User-Agent", "calendar-app/0.1.0") .timeout(std::time::Duration::from_secs(30)) .body(ical_data) .send() .await .map_err(|e| { println!("āŒ HTTP PUT request failed: {}", e); CalDAVError::ParseError(e.to_string()) })?; println!("Event update response status: {}", response.status()); if response.status().is_success() || response.status().as_u16() == 201 || response.status().as_u16() == 204 { println!("āœ… Event updated successfully"); Ok(()) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("āŒ Event update failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } /// Generate iCalendar data for a CalendarEvent fn generate_ical_event(&self, event: &CalendarEvent) -> Result { let now = chrono::Utc::now(); // Format datetime for iCal (YYYYMMDDTHHMMSSZ format) let format_datetime = |dt: &DateTime| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() }; let format_date = |dt: &DateTime| -> String { dt.format("%Y%m%d").to_string() }; // Start building the iCal event let mut ical = String::new(); ical.push_str("BEGIN:VCALENDAR\r\n"); ical.push_str("VERSION:2.0\r\n"); ical.push_str("PRODID:-//calendar-app//calendar-app//EN\r\n"); ical.push_str("BEGIN:VEVENT\r\n"); // Required fields ical.push_str(&format!("UID:{}\r\n", event.uid)); ical.push_str(&format!("DTSTAMP:{}\r\n", format_datetime(&now))); // Start and end times if event.all_day { ical.push_str(&format!( "DTSTART;VALUE=DATE:{}\r\n", format_date(&event.dtstart) )); if let Some(end) = &event.dtend { ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end))); } } else { ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.dtstart))); if let Some(end) = &event.dtend { ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end))); } } // Optional fields if let Some(summary) = &event.summary { ical.push_str(&format!("SUMMARY:{}\r\n", self.escape_ical_text(summary))); } if let Some(description) = &event.description { ical.push_str(&format!( "DESCRIPTION:{}\r\n", self.escape_ical_text(description) )); } if let Some(location) = &event.location { ical.push_str(&format!("LOCATION:{}\r\n", self.escape_ical_text(location))); } // Status if let Some(status) = &event.status { let status_str = match status { EventStatus::Tentative => "TENTATIVE", EventStatus::Confirmed => "CONFIRMED", EventStatus::Cancelled => "CANCELLED", }; ical.push_str(&format!("STATUS:{}\r\n", status_str)); } // Classification if let Some(class) = &event.class { let class_str = match class { EventClass::Public => "PUBLIC", EventClass::Private => "PRIVATE", EventClass::Confidential => "CONFIDENTIAL", }; ical.push_str(&format!("CLASS:{}\r\n", class_str)); } // Priority if let Some(priority) = event.priority { ical.push_str(&format!("PRIORITY:{}\r\n", priority)); } // Categories if !event.categories.is_empty() { let categories = event.categories.join(","); ical.push_str(&format!( "CATEGORIES:{}\r\n", self.escape_ical_text(&categories) )); } // Creation and modification times if let Some(created) = &event.created { ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created))); } ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now))); // Add alarms/reminders for alarm in &event.alarms { ical.push_str("BEGIN:VALARM\r\n"); let action = match alarm.action { calendar_models::AlarmAction::Display => "DISPLAY", calendar_models::AlarmAction::Email => "EMAIL", calendar_models::AlarmAction::Audio => "AUDIO", calendar_models::AlarmAction::Procedure => "PROCEDURE", }; ical.push_str(&format!("ACTION:{}\r\n", action)); // Handle trigger match &alarm.trigger { calendar_models::AlarmTrigger::Duration(duration) => { let minutes = duration.num_minutes(); if minutes < 0 { ical.push_str(&format!("TRIGGER:-PT{}M\r\n", -minutes)); } else { ical.push_str(&format!("TRIGGER:PT{}M\r\n", minutes)); } } calendar_models::AlarmTrigger::DateTime(dt) => { ical.push_str(&format!("TRIGGER:{}\r\n", format_datetime(dt))); } } if let Some(description) = &alarm.description { ical.push_str(&format!( "DESCRIPTION:{}\r\n", self.escape_ical_text(description) )); } else if let Some(summary) = &event.summary { ical.push_str(&format!( "DESCRIPTION:{}\r\n", self.escape_ical_text(summary) )); } ical.push_str("END:VALARM\r\n"); } // Recurrence rule if let Some(rrule) = &event.rrule { ical.push_str(&format!("RRULE:{}\r\n", rrule)); } // Exception dates (EXDATE) for exception_date in &event.exdate { if event.all_day { ical.push_str(&format!( "EXDATE;VALUE=DATE:{}\r\n", format_date(exception_date) )); } else { ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date))); } } ical.push_str("END:VEVENT\r\n"); ical.push_str("END:VCALENDAR\r\n"); Ok(ical) } /// Escape text for iCalendar format (RFC 5545) fn escape_ical_text(&self, text: &str) -> String { text.replace('\\', "\\\\") .replace('\n', "\\n") .replace('\r', "") .replace(',', "\\,") .replace(';', "\\;") } /// Delete an event from a CalDAV calendar pub async fn delete_event( &self, calendar_path: &str, event_href: &str, ) -> Result<(), CalDAVError> { // Construct the full URL for the event let full_url = if event_href.starts_with("http") { event_href.to_string() } else if event_href.starts_with("/dav.php") { // Event href is already a full path, combine with base server URL (without /dav.php) let base_url = self .config .server_url .trim_end_matches('/') .trim_end_matches("/dav.php"); format!("{}{}", base_url, event_href) } else { // Event href is just a filename, combine with calendar path let clean_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_start_matches("/dav.php") } else { calendar_path }; format!( "{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href ) }; println!("Deleting event at: {}", full_url); println!("šŸ“” Acquiring CalDAV HTTP lock for DELETE request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("šŸ“” Lock acquired, sending DELETE request to CalDAV server..."); let response = self .http_client .delete(&full_url) .header( "Authorization", format!("Basic {}", self.config.get_basic_auth()), ) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Event deletion response status: {}", response.status()); if response.status().is_success() || response.status().as_u16() == 204 { println!("āœ… Event deleted successfully at {}", event_href); Ok(()) } else { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); println!("āŒ Event deletion failed: {} - {}", status, error_body); Err(CalDAVError::ServerError(status.as_u16())) } } } /// Helper struct for extracting calendar data from XML responses #[derive(Debug)] struct CalendarDataSection { pub href: Option, pub etag: Option, pub data: String, } /// CalDAV-specific error types #[derive(Debug, thiserror::Error)] pub enum CalDAVError { #[error("HTTP request failed: {0}")] RequestError(#[from] reqwest::Error), #[error("CalDAV server returned error: {0}")] ServerError(u16), #[error("Failed to parse calendar data: {0}")] ParseError(String), } #[cfg(test)] mod tests { use super::*; use crate::config::CalDAVConfig; /// Integration test that fetches real calendar events from the Baikal server /// /// This test requires a valid .env file and a calendar with some events #[tokio::test] async fn test_fetch_calendar_events() { let config = CalDAVConfig::new( "https://example.com".to_string(), "test_user".to_string(), "test_password".to_string(), ); let client = CalDAVClient::new(config); // First discover available calendars using PROPFIND println!("Discovering calendars..."); let discovery_result = client.discover_calendars().await; match discovery_result { Ok(calendar_paths) => { println!("Found {} calendar collection(s)", calendar_paths.len()); if calendar_paths.is_empty() { println!("No calendars found - this might be normal for a new server"); return; } // Try the first available calendar let calendar_path = &calendar_paths[0]; println!("Trying to fetch events from: {}", calendar_path); match client.fetch_events(calendar_path).await { Ok(events) => { println!("Successfully fetched {} calendar events", events.len()); for (i, event) in events.iter().take(3).enumerate() { println!("\n--- Event {} ---", i + 1); println!("UID: {}", event.uid); println!("Summary: {:?}", event.summary); println!("Start: {}", event.dtstart); println!("End: {:?}", event.dtend); println!("All Day: {}", event.all_day); println!("Status: {:?}", event.status); println!("Location: {:?}", event.location); println!("Description: {:?}", event.description); println!("ETag: {:?}", event.etag); println!("HREF: {:?}", event.href); } // Validate that events have required fields for event in &events { assert!(!event.uid.is_empty(), "Event UID should not be empty"); // All events should have a start time assert!( event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time" ); } println!("\nāœ“ Calendar event fetching test passed!"); } Err(e) => { println!("Error fetching events from {}: {:?}", calendar_path, e); println!("This might be normal if the calendar is empty"); } } } Err(e) => { println!("Error discovering calendars: {:?}", e); println!("This might be normal if no calendars are set up yet"); } } } /// Test parsing a sample iCal event #[test] fn test_parse_ical_event() { let sample_ical = r#"BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//Test//EN BEGIN:VEVENT UID:test-event-123@example.com DTSTART:20231225T120000Z DTEND:20231225T130000Z SUMMARY:Test Event DESCRIPTION:This is a test event LOCATION:Test Location STATUS:CONFIRMED CLASS:PUBLIC PRIORITY:5 CREATED:20231220T100000Z LAST-MODIFIED:20231221T150000Z CATEGORIES:work,important END:VEVENT END:VCALENDAR"#; let config = CalDAVConfig { server_url: "https://example.com".to_string(), username: "test".to_string(), password: "test".to_string(), calendar_path: None, }; let client = CalDAVClient::new(config); let events = client .parse_ical_data(sample_ical) .expect("Should be able to parse sample iCal data"); assert_eq!(events.len(), 1); let event = &events[0]; assert_eq!(event.uid, "test-event-123@example.com"); assert_eq!(event.summary, Some("Test Event".to_string())); assert_eq!(event.description, Some("This is a test event".to_string())); assert_eq!(event.location, Some("Test Location".to_string())); assert_eq!(event.status, Some(EventStatus::Confirmed)); assert_eq!(event.class, Some(EventClass::Public)); assert_eq!(event.priority, Some(5)); assert_eq!(event.categories, vec!["work", "important"]); assert!(!event.all_day); println!("āœ“ iCal parsing test passed!"); } /// Test datetime parsing #[test] fn test_datetime_parsing() { let config = CalDAVConfig { server_url: "https://example.com".to_string(), username: "test".to_string(), password: "test".to_string(), calendar_path: None, }; let client = CalDAVClient::new(config); // Test UTC format let dt1 = client .parse_datetime("20231225T120000Z", None) .expect("Should parse UTC datetime"); println!("Parsed UTC datetime: {}", dt1); // Test date-only format (should be treated as all-day) let dt2 = client .parse_datetime("20231225", None) .expect("Should parse date-only"); println!("Parsed date-only: {}", dt2); // Test local format let dt3 = client .parse_datetime("20231225T120000", None) .expect("Should parse local datetime"); println!("Parsed local datetime: {}", dt3); println!("āœ“ Datetime parsing test passed!"); } /// Test event status parsing #[test] fn test_event_enums() { // Test status parsing - these don't have defaults, so let's test creation let status = EventStatus::Confirmed; assert_eq!(status, EventStatus::Confirmed); // Test class parsing let class = EventClass::Public; assert_eq!(class, EventClass::Public); println!("āœ“ Event enum tests passed!"); } }