//! Direct HTTP-based CalDAV client implementation use anyhow::Result; use reqwest::{Client, header}; use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc, TimeZone}; use tracing::{debug, info, warn}; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use std::time::Duration; use std::collections::HashMap; use crate::config::{ImportConfig}; pub struct Config { pub server: ServerConfig, } pub struct ServerConfig { pub url: String, pub username: String, pub password: String, } /// CalDAV client using direct HTTP requests pub struct RealCalDavClient { client: Client, base_url: String, username: String, import_target: Option, } impl RealCalDavClient { /// Create a new CalDAV client with authentication pub async fn new(base_url: &str, username: &str, password: &str) -> Result { info!("Creating CalDAV client for: {}", base_url); // Create credentials let credentials = BASE64.encode(format!("{}:{}", username, password)); // Build client with proper authentication let mut headers = header::HeaderMap::new(); headers.insert( header::USER_AGENT, header::HeaderValue::from_static("caldav-sync/0.1.0"), ); headers.insert( header::ACCEPT, header::HeaderValue::from_static("text/calendar, text/xml, application/xml"), ); headers.insert( header::AUTHORIZATION, header::HeaderValue::from_str(&format!("Basic {}", credentials)) .map_err(|e| anyhow::anyhow!("Invalid authorization header: {}", e))?, ); let client = Client::builder() .default_headers(headers) .timeout(Duration::from_secs(30)) .build() .map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {}", e))?; debug!("CalDAV client created successfully"); Ok(Self { client, base_url: base_url.to_string(), username: username.to_string(), import_target: None, }) } /// Create a new client from configuration pub async fn from_config(config: &Config) -> Result { let base_url = &config.server.url; let username = &config.server.username; let password = &config.server.password; Self::new(base_url, username, password).await } /// Discover calendars on the server using PROPFIND pub async fn discover_calendars(&self) -> Result> { info!("Discovering calendars for user: {}", self.username); // Create PROPFIND request to discover calendars let propfind_xml = r#" "#; // Try multiple approaches for calendar discovery let mut all_calendars = Vec::new(); // Approach 1: Try current base URL info!("Trying calendar discovery at base URL: {}", self.base_url); match self.try_calendar_discovery_at_url(&self.base_url, &propfind_xml).await { Ok(calendars) => { info!("Found {} calendars using base URL approach", calendars.len()); all_calendars.extend(calendars); }, Err(e) => { warn!("Base URL approach failed: {}", e); } } // Approach 2: Try Nextcloud principal URL if base URL approach didn't find much if all_calendars.len() <= 1 { if let Some(principal_url) = self.construct_nextcloud_principal_url() { info!("Trying calendar discovery at principal URL: {}", principal_url); match self.try_calendar_discovery_at_url(&principal_url, &propfind_xml).await { Ok(calendars) => { info!("Found {} calendars using principal URL approach", calendars.len()); // Merge with existing calendars, avoiding duplicates for new_cal in calendars { if !all_calendars.iter().any(|existing| existing.url == new_cal.url) { all_calendars.push(new_cal); } } }, Err(e) => { warn!("Principal URL approach failed: {}", e); } } } } // Approach 3: Try to construct specific calendar URLs for configured target calendar if let Some(target_calendar_url) = self.construct_target_calendar_url() { info!("Trying direct target calendar access at: {}", target_calendar_url); match self.try_direct_calendar_access(&target_calendar_url, &propfind_xml).await { Ok(target_cal) => { info!("Found target calendar using direct access approach"); // Add target calendar if not already present if !all_calendars.iter().any(|existing| existing.url == target_cal.url) { all_calendars.push(target_cal); } }, Err(e) => { warn!("Direct target calendar access failed: {}", e); } } } info!("Total calendars found: {}", all_calendars.len()); Ok(all_calendars) } /// Try calendar discovery at a specific URL async fn try_calendar_discovery_at_url(&self, url: &str, propfind_xml: &str) -> Result> { let response = self.client .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), url) .header("Depth", "1") .header("Content-Type", "application/xml") .body(propfind_xml.to_string()) .send() .await?; if response.status().as_u16() != 207 { return Err(anyhow::anyhow!("PROPFIND failed with status: {}", response.status())); } let response_text = response.text().await?; debug!("PROPFIND response from {}: {}", url, response_text); // Parse XML response to extract calendar information let calendars = self.parse_calendar_response(&response_text)?; Ok(calendars) } /// Construct Nextcloud principal URL from base URL fn construct_nextcloud_principal_url(&self) -> Option { // Extract base server URL and username from the current base URL // Current format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/ // Principal format: https://cloud.soliverez.com.ar/remote.php/dav/principals/users/alvaro/ if self.base_url.contains("/remote.php/dav/calendars/") { let parts: Vec<&str> = self.base_url.split("/remote.php/dav/calendars/").collect(); if parts.len() == 2 { let server_part = parts[0]; let user_part = parts[1].trim_end_matches('/'); // Construct principal URL let principal_url = format!("{}/remote.php/dav/principals/users/{}", server_part, user_part); return Some(principal_url); } } None } /// Construct target calendar URL for direct access fn construct_target_calendar_url(&self) -> Option { // Use import target configuration to construct direct calendar URL if let Some(ref import_target) = self.import_target { info!("Constructing target calendar URL using import configuration"); // Extract calendar name from target configuration let calendar_name = &import_target.target_calendar.name; // For Nextcloud, construct URL by adding calendar name to base path // Current format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/ // Target format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/calendar-name/ if self.base_url.contains("/remote.php/dav/calendars/") { // Ensure base URL ends with a slash let base_path = if self.base_url.ends_with('/') { self.base_url.clone() } else { format!("{}/", self.base_url) }; // Construct target calendar URL let target_url = format!("{}{}", base_path, calendar_name); info!("Constructed target calendar URL: {}", target_url); return Some(target_url); } else { // For non-Nextcloud servers, try different URL patterns info!("Non-Nextcloud server detected, trying alternative URL construction"); // Pattern 1: Add calendar name directly to base URL let base_path = if self.base_url.ends_with('/') { self.base_url.clone() } else { format!("{}/", self.base_url) }; let target_url = format!("{}{}", base_path, calendar_name); info!("Constructed alternative target calendar URL: {}", target_url); return Some(target_url); } } else { // No import target configuration available info!("No import target configuration available for URL construction"); None } } /// Try direct access to a specific calendar URL async fn try_direct_calendar_access(&self, calendar_url: &str, propfind_xml: &str) -> Result { info!("Trying direct calendar access at: {}", calendar_url); let response = self.client .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), calendar_url) .header("Depth", "0") // Only check this specific resource .header("Content-Type", "application/xml") .body(propfind_xml.to_string()) .send() .await?; if response.status().as_u16() != 207 { return Err(anyhow::anyhow!("Direct calendar access failed with status: {}", response.status())); } let response_text = response.text().await?; debug!("Direct calendar access response from {}: {}", calendar_url, response_text); // Parse XML response to extract calendar information let calendars = self.parse_calendar_response(&response_text)?; if let Some(calendar) = calendars.into_iter().next() { Ok(calendar) } else { Err(anyhow::anyhow!("No calendar found in direct access response")) } } /// Get events from a specific calendar using REPORT pub async fn get_events(&self, calendar_href: &str, start_date: DateTime, end_date: DateTime) -> Result> { self.get_events_with_approach(calendar_href, start_date, end_date, None).await } /// Get events using a specific approach pub async fn get_events_with_approach(&self, calendar_href: &str, start_date: DateTime, end_date: DateTime, approach: Option) -> Result> { info!("Getting events from calendar: {} between {} and {} (approach: {:?})", calendar_href, start_date.format("%Y-%m-%d %H:%M:%S UTC"), end_date.format("%Y-%m-%d %H:%M:%S UTC"), approach); // Try multiple CalDAV query approaches let all_approaches = vec![ // Standard calendar-query with time-range (r#" "#, "calendar-query"), ]; // Filter approaches if a specific one is requested let approaches = if let Some(ref req_approach) = approach { all_approaches.into_iter() .filter(|(_, name)| name == req_approach) .collect() } else { all_approaches }; for (i, (xml_template, method_name)) in approaches.iter().enumerate() { info!("Trying approach {}: {}", i + 1, method_name); let report_xml = if xml_template.contains("{start}") && xml_template.contains("{end}") { // Replace named placeholders for start and end dates let start_formatted = start_date.format("%Y%m%dT000000Z").to_string(); let end_formatted = end_date.format("%Y%m%dT000000Z").to_string(); xml_template .replace("{start}", &start_formatted) .replace("{end}", &end_formatted) } else { xml_template.to_string() }; info!("Request XML: {}", report_xml); let method = if method_name.contains("propfind") { reqwest::Method::from_bytes(b"PROPFIND").unwrap() } else if method_name.contains("zoho-export") || method_name.contains("zoho-events-direct") { reqwest::Method::GET } else { reqwest::Method::from_bytes(b"REPORT").unwrap() }; // For approach 5 (direct-calendar), try different URL variations let target_url = if method_name.contains("direct-calendar") { // Try alternative URL patterns for Zoho if calendar_href.ends_with('/') { format!("{}?export", calendar_href.trim_end_matches('/')) } else { format!("{}/?export", calendar_href) } } else if method_name.contains("zoho-export") { // Zoho-specific export endpoint if calendar_href.ends_with('/') { format!("{}export?format=ics", calendar_href.trim_end_matches('/')) } else { format!("{}/export?format=ics", calendar_href) } } else if method_name.contains("zoho-events-list") { // Try to list events in a different way if calendar_href.ends_with('/') { format!("{}events/", calendar_href) } else { format!("{}/events/", calendar_href) } } else if method_name.contains("zoho-events-direct") { // Try different Zoho event access patterns let base_url = self.base_url.trim_end_matches('/'); if calendar_href.contains("/caldav/user/") { let username_part = calendar_href.split("/caldav/user/").nth(1).unwrap_or(""); format!("{}/caldav/events/{}", base_url, username_part.trim_end_matches('/')) } else { calendar_href.to_string() } } else { calendar_href.to_string() }; let response = self.client .request(method, &target_url) .header("Depth", "1") .header("Content-Type", "application/xml") .header("User-Agent", "caldav-sync/0.1.0") .body(report_xml) .send() .await?; let status = response.status(); let status_code = status.as_u16(); info!("Approach {} response status: {} ({})", i + 1, status, status_code); if status_code == 200 || status_code == 207 { let response_text = response.text().await?; info!("Approach {} response length: {} characters", i + 1, response_text.len()); if !response_text.trim().is_empty() { info!("Approach {} got non-empty response", i + 1); debug!("Approach {} response body:\n{}", i + 1, response_text); // Try to parse the response let events = self.parse_events_response(&response_text, calendar_href).await?; if !events.is_empty() || !method_name.contains("filter") { info!("Successfully parsed {} events using approach {}", events.len(), i + 1); return Ok(events); } } else { info!("Approach {} got empty response", i + 1); } } else { info!("Approach {} failed with status: {}", i + 1, status); } } warn!("All approaches failed, returning empty result"); Ok(vec![]) } /// Parse PROPFIND response to extract calendar information fn parse_calendar_response(&self, xml: &str) -> Result> { // Enhanced XML parsing to extract multiple calendars from PROPFIND response let mut calendars = Vec::new(); debug!("Parsing calendar discovery response XML:\n{}", xml); // Check if this is a multistatus response with multiple calendars if xml.contains("") { info!("Parsing multistatus response with potentially multiple calendars"); // Parse all elements to find calendar collections let mut start_pos = 0; let mut response_count = 0; while let Some(response_start) = xml[start_pos..].find("") { let absolute_start = start_pos + response_start; if let Some(response_end) = xml[absolute_start..].find("") { let absolute_end = absolute_start + response_end + 14; // +14 for "" length let response_xml = &xml[absolute_start..absolute_end]; response_count += 1; debug!("Parsing response #{}", response_count); // Extract href from this response let href = if let Some(href_start) = response_xml.find("") { if let Some(href_end) = response_xml.find("") { let href_content = &response_xml[href_start + 9..href_end]; href_content.trim().to_string() } else { continue; // Skip this response if href is malformed } } else { continue; // Skip this response if no href found }; // Skip if this is not a calendar collection (should end with '/') if !href.ends_with('/') { debug!("Skipping non-calendar resource: {}", href); start_pos = absolute_end; continue; } // Extract display name if available - try multiple XML formats let display_name = self.extract_display_name_from_xml(response_xml); // Extract calendar description if available let description = if let Some(desc_start) = response_xml.find("") { if let Some(desc_end) = response_xml.find("") { let desc_content = &response_xml[desc_start + 23..desc_end]; Some(desc_content.trim().to_string()) } else { None } } else { None }; // Extract calendar color if available (some servers use this) let color = if let Some(color_start) = response_xml.find("") { if let Some(color_end) = response_xml.find("") { let color_content = &response_xml[color_start + 18..color_end]; Some(color_content.trim().to_string()) } else { None } } else { None }; // Check if this is actually a calendar collection by looking for resourcetype let is_calendar = response_xml.contains("") || response_xml.contains("") || response_xml.contains(""); if is_calendar { info!("Found calendar collection: {} (display: {})", href, display_name.as_ref().unwrap_or(&"unnamed".to_string())); // Extract calendar name from href path let calendar_name = if let Some(last_slash) = href.trim_end_matches('/').rfind('/') { href[last_slash + 1..].trim_end_matches('/').to_string() } else { href.clone() }; let calendar = CalendarInfo { url: href.clone(), name: calendar_name, display_name: display_name.or_else(|| Some(self.extract_display_name_from_href(&href))), color, description, timezone: Some("UTC".to_string()), // Default timezone supported_components: vec!["VEVENT".to_string(), "VTODO".to_string()], }; calendars.push(calendar); } else { debug!("Skipping non-calendar resource: {}", href); } start_pos = absolute_end; } else { break; } } info!("Parsed {} calendar collections from {} responses", calendars.len(), response_count); } else { // Fallback to single calendar parsing for non-multistatus responses warn!("Response is not a multistatus format, using fallback parsing"); // Extract href from the XML response let href = if xml.contains("") { // Extract href from XML if let Some(start) = xml.find("") { if let Some(end) = xml.find("") { let href_content = &xml[start + 9..end]; href_content.to_string() } else { self.base_url.clone() } } else { self.base_url.clone() } } else { self.base_url.clone() }; // For now, use the href as both name and derive display name from it let display_name = self.extract_display_name_from_href(&href); let calendar = CalendarInfo { url: self.base_url.clone(), name: href.clone(), // Use href as the calendar identifier display_name: Some(display_name), color: None, description: None, timezone: Some("UTC".to_string()), supported_components: vec!["VEVENT".to_string()], }; calendars.push(calendar); } if calendars.is_empty() { warn!("No calendars found in response, creating fallback calendar"); // Create a fallback calendar based on base URL let calendar = CalendarInfo { url: self.base_url.clone(), name: "default".to_string(), display_name: Some("Default Calendar".to_string()), color: None, description: None, timezone: Some("UTC".to_string()), supported_components: vec!["VEVENT".to_string()], }; calendars.push(calendar); } Ok(calendars) } /// Parse REPORT response to extract calendar events async fn parse_events_response(&self, xml: &str, calendar_href: &str) -> Result> { // Check if response is empty if xml.trim().is_empty() { info!("Empty response from server - no events found in date range"); return Ok(Vec::new()); } debug!("Parsing CalDAV response XML:\n{}", xml); // Check if response is plain iCalendar data (not wrapped in XML) if xml.starts_with("BEGIN:VCALENDAR") { info!("Response contains plain iCalendar data"); return self.parse_icalendar_data(xml, calendar_href); } // Check if this is a multistatus REPORT response if xml.contains("") { return self.parse_multistatus_response(xml, calendar_href).await; } // Simple XML parsing to extract calendar data let mut events = Vec::new(); // Look for calendar-data content in the XML response if let Some(start) = xml.find("") { if let Some(end) = xml.find("") { let ical_data = &xml[start + 17..end]; debug!("Found iCalendar data: {}", ical_data); // Parse the iCalendar data if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) { events.extend(parsed_events); } else { warn!("Failed to parse iCalendar data, falling back to mock"); return self.create_mock_event(calendar_href); } } else { debug!("No calendar-data closing tag found"); return self.create_mock_event(calendar_href); } } else { debug!("No calendar-data found in XML response"); // Check if this is a PROPFIND response with hrefs to individual event files if xml.contains("") && xml.contains(".ics") { return self.parse_propfind_response(xml, calendar_href).await; } // If no calendar-data but we got hrefs, try to fetch individual .ics files if xml.contains("") { return self.parse_propfind_response(xml, calendar_href).await; } return self.create_mock_event(calendar_href); } info!("Parsed {} real events from CalDAV response", events.len()); Ok(events) } /// Parse multistatus response from REPORT request async fn parse_multistatus_response(&self, xml: &str, calendar_href: &str) -> Result> { let mut events = Vec::new(); // Parse multi-status response let mut start_pos = 0; while let Some(response_start) = xml[start_pos..].find("") { let absolute_start = start_pos + response_start; if let Some(response_end) = xml[absolute_start..].find("") { let absolute_end = absolute_start + response_end; let response_content = &xml[absolute_start..absolute_end + 14]; // Extract href if let Some(href_start) = response_content.find("") { if let Some(href_end) = response_content.find("") { let href_content = &response_content[href_start + 9..href_end]; // Check if this is a .ics file event (not the calendar collection itself) if href_content.contains(".ics") { info!("Found event href: {}", href_content); // Try to fetch the individual event match self.fetch_single_event(href_content, calendar_href).await { Ok(Some(event)) => events.push(event), Ok(None) => warn!("Failed to get event data for {}", href_content), Err(e) => warn!("Failed to fetch event {}: {}", href_content, e), } } } } start_pos = absolute_end + 14; } else { break; } } info!("Parsed {} real events from multistatus response", events.len()); Ok(events) } /// Parse iCalendar data into CalendarEvent structs fn parse_icalendar_data(&self, ical_data: &str, calendar_href: &str) -> Result> { let mut events = Vec::new(); // Handle iCalendar line folding (unfold continuation lines) let unfolded_data = self.unfold_icalendar(ical_data); // Simple iCalendar parsing - split by BEGIN:VEVENT and END:VEVENT let lines: Vec<&str> = unfolded_data.lines().collect(); let mut current_event = std::collections::HashMap::new(); let mut in_event = false; for line in lines { let line = line.trim(); if line == "BEGIN:VEVENT" { in_event = true; current_event.clear(); continue; } if line == "END:VEVENT" { if in_event && !current_event.is_empty() { if let Ok(event) = self.build_calendar_event(¤t_event, calendar_href) { events.push(event); } } in_event = false; continue; } if in_event && line.contains(':') { let parts: Vec<&str> = line.splitn(2, ':').collect(); if parts.len() == 2 { current_event.insert(parts[0].to_string(), parts[1].to_string()); } } // Handle timezone parameters (e.g., DTSTART;TZID=America/New_York:20240315T100000) if in_event && line.contains(';') && line.contains(':') { // Parse properties with parameters like DTSTART;TZID=... if let Some(semi_pos) = line.find(';') { if let Some(colon_pos) = line.find(':') { if semi_pos < colon_pos { let property_name = &line[..semi_pos]; let params_part = &line[semi_pos + 1..colon_pos]; let value = &line[colon_pos + 1..]; // Extract TZID parameter if present let tzid = if params_part.contains("TZID=") { if let Some(tzid_start) = params_part.find("TZID=") { let tzid_value = ¶ms_part[tzid_start + 5..]; Some(tzid_value.to_string()) } else { None } } else { None }; // Store the main property current_event.insert(property_name.to_string(), value.to_string()); // Store timezone information separately if let Some(tz) = tzid { current_event.insert(format!("{}_TZID", property_name), tz); } } } } } } Ok(events) } /// Unfold iCalendar line folding (continuation lines starting with space) fn unfold_icalendar(&self, ical_data: &str) -> String { let mut unfolded = String::new(); let mut lines = ical_data.lines().peekable(); while let Some(line) = lines.next() { let line = line.trim_end(); unfolded.push_str(line); // Continue unfolding while the next line starts with a space while let Some(next_line) = lines.peek() { let next_line = next_line.trim_start(); if next_line.starts_with(' ') || next_line.starts_with('\t') { // Remove the leading space and append let folded_line = lines.next().unwrap().trim_start(); unfolded.push_str(&folded_line[1..]); } else { break; } } unfolded.push('\n'); } unfolded } /// Build a CalendarEvent from parsed iCalendar properties fn build_calendar_event(&self, properties: &HashMap, calendar_href: &str) -> Result { let now = Utc::now(); // Extract basic properties let uid = properties.get("UID").cloned().unwrap_or_else(|| format!("event-{}", now.timestamp())); let summary = properties.get("SUMMARY").cloned().unwrap_or_else(|| "Untitled Event".to_string()); let description = properties.get("DESCRIPTION").cloned(); let location = properties.get("LOCATION").cloned(); let status = properties.get("STATUS").cloned(); // Parse dates let (start, end) = self.parse_event_dates(properties)?; // Extract timezone information let start_tzid = properties.get("DTSTART_TZID").cloned(); let end_tzid = properties.get("DTEND_TZID").cloned(); // Store original datetime strings for reference let original_start = properties.get("DTSTART").cloned(); let original_end = properties.get("DTEND").cloned(); let event = CalendarEvent { id: uid.clone(), href: format!("{}/{}.ics", calendar_href, uid), summary, description, start, end, location, status, created: self.parse_datetime(properties.get("CREATED").map(|s| s.as_str())), last_modified: self.parse_datetime(properties.get("LAST-MODIFIED").map(|s| s.as_str())), sequence: properties.get("SEQUENCE") .and_then(|s| s.parse::().ok()) .unwrap_or(0), transparency: properties.get("TRANSP").cloned(), uid: Some(uid), recurrence_id: self.parse_datetime(properties.get("RECURRENCE-ID").map(|s| s.as_str())), etag: None, // Enhanced timezone information start_tzid, end_tzid, original_start, original_end, }; Ok(event) } /// Parse start and end dates from event properties fn parse_event_dates(&self, properties: &HashMap) -> Result<(DateTime, DateTime)> { let start = self.parse_datetime(properties.get("DTSTART").map(|s| s.as_str())) .unwrap_or_else(Utc::now); let end = if let Some(dtend) = properties.get("DTEND") { self.parse_datetime(Some(dtend)).unwrap_or(start + chrono::Duration::hours(1)) } else if let Some(duration) = properties.get("DURATION") { self.parse_duration(&duration).map(|d| start + d).unwrap_or(start + chrono::Duration::hours(1)) } else { start + chrono::Duration::hours(1) }; Ok((start, end)) } /// Parse datetime from iCalendar format fn parse_datetime(&self, dt_str: Option<&str>) -> Option> { let dt_str = dt_str?; // Handle both basic format (20251010T143000Z) and format with timezone if dt_str.ends_with('Z') { // UTC time let cleaned = dt_str.replace('Z', ""); if cleaned.len() == 15 { // YYYYMMDDTHHMMSS let year = cleaned[0..4].parse::().ok()?; let month = cleaned[4..6].parse::().ok()?; let day = cleaned[6..8].parse::().ok()?; let hour = cleaned[9..11].parse::().ok()?; let minute = cleaned[11..13].parse::().ok()?; let second = cleaned[13..15].parse::().ok()?; return Utc.with_ymd_and_hms(year, month, day, hour, minute, second).single(); } } else if dt_str.len() == 15 && dt_str.contains('T') { // YYYYMMDDTHHMMSS (no Z) let year = dt_str[0..4].parse::().ok()?; let month = dt_str[4..6].parse::().ok()?; let day = dt_str[6..8].parse::().ok()?; let hour = dt_str[9..11].parse::().ok()?; let minute = dt_str[11..13].parse::().ok()?; let second = dt_str[13..15].parse::().ok()?; return Utc.with_ymd_and_hms(year, month, day, hour, minute, second).single(); } else if dt_str.len() == 8 { // YYYYMMDD (date only) let year = dt_str[0..4].parse::().ok()?; let month = dt_str[4..6].parse::().ok()?; let day = dt_str[6..8].parse::().ok()?; return Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).single(); } debug!("Failed to parse datetime: {}", dt_str); None } /// Parse duration from iCalendar format fn parse_duration(&self, duration_str: &str) -> Option { // Simple duration parsing - handle basic PT1H format if duration_str.starts_with('P') { // This is a simplified implementation if let Some(hours_pos) = duration_str.find('H') { let before_hours = &duration_str[..hours_pos]; if let Some(last_char) = before_hours.chars().last() { if let Some(hours_str) = last_char.to_string().parse::().ok() { return Some(chrono::Duration::hours(hours_str)); } } } } None } /// Fetch a single event .ics file and parse it async fn fetch_single_event(&self, event_url: &str, calendar_href: &str) -> Result> { info!("Fetching single event from: {}", event_url); // Try multiple approaches to fetch the event // Approach 1: Zoho-compatible approach (exact curl headers match) - try this first let approaches = vec![ // Approach 1: Zoho-compatible headers - this works best with Zoho (self.client.get(event_url) .header("Accept", "text/calendar") .header("User-Agent", "curl/8.16.0"), "zoho-compatible"), // Approach 2: Basic request with minimal headers (self.client.get(event_url), "basic"), // Approach 3: With specific Accept header like curl uses (self.client.get(event_url).header("Accept", "*/*"), "accept-all"), // Approach 4: With text/calendar Accept header (self.client.get(event_url).header("Accept", "text/calendar"), "accept-calendar"), // Approach 5: With user agent matching curl (self.client.get(event_url).header("User-Agent", "curl/8.16.0"), "curl-ua"), ]; for (req, approach_name) in approaches { info!("Trying approach: {}", approach_name); match req.send().await { Ok(response) => { let status = response.status(); info!("Approach '{}' response status: {}", approach_name, status); if status.is_success() { let ical_data = response.text().await?; debug!("Retrieved iCalendar data ({} chars): {}", ical_data.len(), if ical_data.len() > 200 { format!("{}...", &ical_data[..200]) } else { ical_data.clone() }); // Parse the iCalendar data if let Ok(mut events) = self.parse_icalendar_data(&ical_data, calendar_href) { if !events.is_empty() { // Update the href to the correct URL events[0].href = event_url.to_string(); info!("Successfully parsed event with approach '{}': {}", approach_name, events[0].summary); return Ok(Some(events.remove(0))); } else { warn!("Approach '{}' got {} bytes but parsed 0 events", approach_name, ical_data.len()); } } else { warn!("Approach '{}' failed to parse iCalendar data", approach_name); } } else { let error_text = response.text().await.unwrap_or_else(|_| "Unable to read error response".to_string()); warn!("Approach '{}' failed: {} - {}", approach_name, status, error_text); } } Err(e) => { warn!("Approach '{}' request failed: {}", approach_name, e); } } } warn!("All approaches failed for event: {}", event_url); Ok(None) } /// Parse PROPFIND response to extract event hrefs and fetch individual events async fn parse_propfind_response(&self, xml: &str, calendar_href: &str) -> Result> { let mut events = Vec::new(); let mut start_pos = 0; info!("Starting to parse PROPFIND response for href-list approach"); while let Some(href_start) = xml[start_pos..].find("") { let absolute_start = start_pos + href_start; if let Some(href_end) = xml[absolute_start..].find("") { let absolute_end = absolute_start + href_end; let href_content = &xml[absolute_start + 9..absolute_end]; // Skip the calendar collection itself and focus on .ics files if !href_content.ends_with('/') && href_content != calendar_href { debug!("Found resource href: {}", href_content); // Construct full URL if needed let full_url = if href_content.starts_with("http") { href_content.to_string() } else if href_content.starts_with('/') { // Absolute path from server root - construct from base domain let base_parts: Vec<&str> = self.base_url.split('/').take(3).collect(); let base_domain = base_parts.join("/"); format!("{}{}", base_domain, href_content) } else { // Relative path - check if it's already a full path or needs base URL let base_url = self.base_url.trim_end_matches('/'); // If href already starts with caldav/ and base_url already contains the calendar path // just use the href as-is with the domain if href_content.starts_with("caldav/") && base_url.contains("/caldav/") { let base_parts: Vec<&str> = base_url.split('/').take(3).collect(); let base_domain = base_parts.join("/"); format!("{}/{}", base_domain, href_content) } else if href_content.starts_with("caldav/") { format!("{}/{}", base_url, href_content) } else { format!("{}{}", base_url, href_content) } }; info!("Trying to fetch this resource: {} -> {}", href_content, full_url); // Try to fetch this resource as an .ics file match self.fetch_single_event(&full_url, calendar_href).await { Ok(Some(event)) => { events.push(event); } Ok(None) => { debug!("Resource {} is not an event", href_content); } Err(e) => { warn!("Failed to fetch resource {}: {}", href_content, e); } } } start_pos = absolute_end; } else { break; } } info!("Fetched {} individual events", events.len()); // Debug: show first few URLs being constructed if !events.is_empty() { info!("First few URLs tried:"); for (idx, event) in events.iter().take(3).enumerate() { info!(" [{}] URL: {}", idx + 1, event.href); } } else { info!("No events fetched successfully"); } Ok(events) } /// Create mock event for debugging fn create_mock_event(&self, calendar_href: &str) -> Result> { let now = Utc::now(); let mock_event = CalendarEvent { id: "mock-event-1".to_string(), href: format!("{}/mock-event-1.ics", calendar_href), summary: "Mock Event".to_string(), description: Some("This is a mock event for testing".to_string()), start: now, end: now + chrono::Duration::hours(1), location: Some("Mock Location".to_string()), status: Some("CONFIRMED".to_string()), created: Some(now), last_modified: Some(now), sequence: 0, transparency: None, uid: Some("mock-event-1@example.com".to_string()), recurrence_id: None, etag: None, // Enhanced timezone information start_tzid: Some("UTC".to_string()), end_tzid: Some("UTC".to_string()), original_start: Some(now.format("%Y%m%dT%H%M%SZ").to_string()), original_end: Some((now + chrono::Duration::hours(1)).format("%Y%m%dT%H%M%SZ").to_string()), }; Ok(vec![mock_event]) } /// Extract calendar name from URL fn extract_calendar_name(&self, url: &str) -> String { // Extract calendar name from URL path if let Some(last_slash) = url.rfind('/') { let name_part = &url[last_slash + 1..]; if !name_part.is_empty() { return name_part.to_string(); } } "Default Calendar".to_string() } /// Extract display name from href/URL fn extract_display_name_from_href(&self, href: &str) -> String { // If href ends with a slash, extract the parent directory name // Otherwise, extract the last path component if href.ends_with('/') { // Remove trailing slash let href_without_slash = href.trim_end_matches('/'); if let Some(last_slash) = href_without_slash.rfind('/') { let name_part = &href_without_slash[last_slash + 1..]; if !name_part.is_empty() { return name_part.replace('_', " ").split('-').map(|word| { let mut chars = word.chars(); match chars.next() { None => String::new(), Some(first) => first.to_uppercase().collect::() + &chars.as_str().to_lowercase(), } }).collect::>().join(" "); } } } else { // Use the existing extract_calendar_name logic return self.extract_calendar_name(href); } "Default Calendar".to_string() } /// Extract display name from XML response, trying multiple formats fn extract_display_name_from_xml(&self, xml: &str) -> Option { // Try multiple XML formats for display name // Format 1: Standard DAV displayname if let Some(display_start) = xml.find("") { if let Some(display_end) = xml.find("") { let display_content = &xml[display_start + 15..display_end]; let display_name = display_content.trim().to_string(); if !display_name.is_empty() { debug!("Found display name in D:displayname: {}", display_name); return Some(display_name); } } } // Format 2: Alternative namespace variants let display_name_patterns = vec![ ("", ""), ("", ""), ("", ""), ("", ""), ]; for (start_tag, end_tag) in display_name_patterns { if let Some(display_start) = xml.find(start_tag) { if let Some(display_end) = xml.find(end_tag) { let display_content = &xml[display_start + start_tag.len()..display_end]; let display_name = display_content.trim().to_string(); if !display_name.is_empty() { debug!("Found display name in {}: {}", start_tag, display_name); return Some(display_name); } } } } // Format 3: Check if display name might be in the calendar name itself (for Nextcloud) // Some Nextcloud versions put the display name in resource metadata differently if xml.contains("calendar-description") || xml.contains("calendar-color") { // This looks like a Nextcloud calendar response, try to extract from other properties // Look for title or name attributes in the XML if let Some(title_start) = xml.find("title=") { if let Some(title_end) = xml[title_start + 7..].find('"') { let title_content = &xml[title_start + 7..title_start + 7 + title_end]; let title = title_content.trim().to_string(); if !title.is_empty() { debug!("Found display name in title attribute: {}", title); return Some(title); } } } } debug!("No display name found in XML response"); None } } /// Calendar information from CalDAV server #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CalendarInfo { pub url: String, pub name: String, pub display_name: Option, pub color: Option, pub description: Option, pub timezone: Option, pub supported_components: Vec, } /// Calendar event from CalDAV server #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CalendarEvent { pub id: String, pub href: String, pub summary: String, pub description: Option, pub start: DateTime, pub end: DateTime, pub location: Option, pub status: Option, pub created: Option>, pub last_modified: Option>, pub sequence: i32, pub transparency: Option, pub uid: Option, pub recurrence_id: Option>, pub etag: Option, // Enhanced timezone information pub start_tzid: Option, pub end_tzid: Option, pub original_start: Option, pub original_end: Option, }