use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Represents a calendar event with all its properties #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CalendarEvent { /// 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, /// 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, } /// Event status enumeration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum EventStatus { Tentative, Confirmed, Cancelled, } impl Default for EventStatus { fn default() -> Self { EventStatus::Confirmed } } /// Event classification enumeration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum EventClass { Public, Private, Confidential, } impl Default for EventClass { fn default() -> Self { EventClass::Public } } /// 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 { Self { config, http_client: reqwest::Client::new(), } } /// 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 let all_day = properties.get("DTSTART") .map(|s| !s.contains("T")) .unwrap_or(false); // 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_default(); // 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_default(); // 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()); Ok(CalendarEvent { uid, summary: properties.get("SUMMARY").cloned(), description: properties.get("DESCRIPTION").cloned(), start, end, location: properties.get("LOCATION").cloned(), status, class, priority, organizer: properties.get("ORGANIZER").cloned(), attendees: Vec::new(), // TODO: Parse attendees categories, created, last_modified, recurrence_rule: properties.get("RRULE").cloned(), all_day, reminders: self.parse_alarms(&event)?, etag: None, // Set by caller href: None, // Set by caller calendar_path: None, // Set by caller }) } /// Parse VALARM components from an iCal event fn parse_alarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result, CalDAVError> { let mut reminders = Vec::new(); for alarm in &event.alarms { if let Ok(reminder) = self.parse_single_alarm(alarm) { reminders.push(reminder); } } Ok(reminders) } /// Parse a single VALARM component into an EventReminder fn parse_single_alarm(&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" => ReminderAction::Display, Some(ref action_str) if action_str == "EMAIL" => ReminderAction::Email, Some(ref action_str) if action_str == "AUDIO" => ReminderAction::Audio, _ => ReminderAction::Display, // Default }; // Parse TRIGGER (required) let minutes_before = if let Some(trigger) = properties.get("TRIGGER") { self.parse_trigger_duration(trigger).unwrap_or(15) // Default 15 minutes } else { 15 // Default 15 minutes }; // Get description let description = properties.get("DESCRIPTION").cloned(); Ok(EventReminder { minutes_before, action, description, }) } /// 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))) } /// 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())) } } } /// 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), #[error("Configuration error: {0}")] ConfigError(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::from_env() .expect("Failed to load CalDAV config from environment"); 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.start); println!("End: {:?}", event.end); 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.start > 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, tasks_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, EventStatus::Confirmed); assert_eq!(event.class, 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, tasks_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 assert_eq!(EventStatus::default(), EventStatus::Confirmed); // Test class parsing assert_eq!(EventClass::default(), EventClass::Public); println!("āœ“ Event enum tests passed!"); }