- Remove debug event limit to display all events - Add timezone information to event listing output - Update DEVELOPMENT.md with latest changes and debugging cycle documentation - Enhance event parsing with timezone support - Simplify CalDAV client structure and error handling Changes improve debugging capabilities for CalDAV event retrieval and provide better timezone visibility when listing calendar events.
872 lines
No EOL
37 KiB
Rust
872 lines
No EOL
37 KiB
Rust
//! 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;
|
|
|
|
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,
|
|
}
|
|
|
|
impl RealCalDavClient {
|
|
/// Create a new CalDAV client with authentication
|
|
pub async fn new(base_url: &str, username: &str, password: &str) -> Result<Self> {
|
|
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(),
|
|
})
|
|
}
|
|
|
|
/// Create a new client from configuration
|
|
pub async fn from_config(config: &Config) -> Result<Self> {
|
|
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<Vec<CalendarInfo>> {
|
|
info!("Discovering calendars for user: {}", self.username);
|
|
|
|
// Create PROPFIND request to discover calendars
|
|
let propfind_xml = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:prop>
|
|
<D:displayname/>
|
|
<C:calendar-description/>
|
|
<C:calendar-timezone/>
|
|
<D:resourcetype/>
|
|
</D:prop>
|
|
</D:propfind>"#;
|
|
|
|
let response = self.client
|
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self.base_url)
|
|
.header("Depth", "1")
|
|
.header("Content-Type", "application/xml")
|
|
.body(propfind_xml)
|
|
.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: {}", response_text);
|
|
|
|
// Parse XML response to extract calendar information
|
|
let calendars = self.parse_calendar_response(&response_text)?;
|
|
|
|
info!("Found {} calendars", calendars.len());
|
|
Ok(calendars)
|
|
}
|
|
|
|
/// Get events from a specific calendar using REPORT
|
|
pub async fn get_events(&self, calendar_href: &str, start_date: DateTime<Utc>, end_date: DateTime<Utc>) -> Result<Vec<CalendarEvent>> {
|
|
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<Utc>, end_date: DateTime<Utc>, approach: Option<String>) -> Result<Vec<CalendarEvent>> {
|
|
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#"<?xml version="1.0" encoding="utf-8" ?>
|
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:prop>
|
|
<D:getetag/>
|
|
<C:calendar-data/>
|
|
</D:prop>
|
|
<C:filter>
|
|
<C:comp-filter name="VCALENDAR">
|
|
<C:comp-filter name="VEVENT">
|
|
<C:time-range start="{start}" end="{end}"/>
|
|
</C:comp-filter>
|
|
</C:comp-filter>
|
|
</C:filter>
|
|
</C:calendar-query>"#, "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<Vec<CalendarInfo>> {
|
|
// Simple XML parsing - in a real implementation, use a proper XML parser
|
|
let mut calendars = Vec::new();
|
|
|
|
// Extract href from the XML response
|
|
let href = if xml.contains("<D:href>") {
|
|
// Extract href from XML
|
|
if let Some(start) = xml.find("<D:href>") {
|
|
if let Some(end) = xml.find("</D:href>") {
|
|
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
|
|
// In a real implementation, we would parse displayname property from XML
|
|
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);
|
|
|
|
Ok(calendars)
|
|
}
|
|
|
|
/// Parse REPORT response to extract calendar events
|
|
async fn parse_events_response(&self, xml: &str, calendar_href: &str) -> Result<Vec<CalendarEvent>> {
|
|
// 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("<D:multistatus>") {
|
|
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("<C:calendar-data>") {
|
|
if let Some(end) = xml.find("</C:calendar-data>") {
|
|
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("<D:href>") && 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("<D:href>") {
|
|
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<Vec<CalendarEvent>> {
|
|
let mut events = Vec::new();
|
|
|
|
// Parse multi-status response
|
|
let mut start_pos = 0;
|
|
while let Some(response_start) = xml[start_pos..].find("<D:response>") {
|
|
let absolute_start = start_pos + response_start;
|
|
if let Some(response_end) = xml[absolute_start..].find("</D:response>") {
|
|
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("<D:href>") {
|
|
if let Some(href_end) = response_content.find("</D:href>") {
|
|
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<Vec<CalendarEvent>> {
|
|
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<String, String>, calendar_href: &str) -> Result<CalendarEvent> {
|
|
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::<i32>().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<String, String>) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
|
|
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<DateTime<Utc>> {
|
|
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::<i32>().ok()?;
|
|
let month = cleaned[4..6].parse::<u32>().ok()?;
|
|
let day = cleaned[6..8].parse::<u32>().ok()?;
|
|
let hour = cleaned[9..11].parse::<u32>().ok()?;
|
|
let minute = cleaned[11..13].parse::<u32>().ok()?;
|
|
let second = cleaned[13..15].parse::<u32>().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::<i32>().ok()?;
|
|
let month = dt_str[4..6].parse::<u32>().ok()?;
|
|
let day = dt_str[6..8].parse::<u32>().ok()?;
|
|
let hour = dt_str[9..11].parse::<u32>().ok()?;
|
|
let minute = dt_str[11..13].parse::<u32>().ok()?;
|
|
let second = dt_str[13..15].parse::<u32>().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::<i32>().ok()?;
|
|
let month = dt_str[4..6].parse::<u32>().ok()?;
|
|
let day = dt_str[6..8].parse::<u32>().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<chrono::Duration> {
|
|
// 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::<i64>().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<Option<CalendarEvent>> {
|
|
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<Vec<CalendarEvent>> {
|
|
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("<D:href>") {
|
|
let absolute_start = start_pos + href_start;
|
|
if let Some(href_end) = xml[absolute_start..].find("</D:href>") {
|
|
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<Vec<CalendarEvent>> {
|
|
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::<String>() + &chars.as_str().to_lowercase(),
|
|
}
|
|
}).collect::<Vec<String>>().join(" ");
|
|
}
|
|
}
|
|
} else {
|
|
// Use the existing extract_calendar_name logic
|
|
return self.extract_calendar_name(href);
|
|
}
|
|
|
|
"Default Calendar".to_string()
|
|
}
|
|
}
|
|
|
|
/// Calendar information from CalDAV server
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CalendarInfo {
|
|
pub url: String,
|
|
pub name: String,
|
|
pub display_name: Option<String>,
|
|
pub color: Option<String>,
|
|
pub description: Option<String>,
|
|
pub timezone: Option<String>,
|
|
pub supported_components: Vec<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
pub start: DateTime<Utc>,
|
|
pub end: DateTime<Utc>,
|
|
pub location: Option<String>,
|
|
pub status: Option<String>,
|
|
pub created: Option<DateTime<Utc>>,
|
|
pub last_modified: Option<DateTime<Utc>>,
|
|
pub sequence: i32,
|
|
pub transparency: Option<String>,
|
|
pub uid: Option<String>,
|
|
pub recurrence_id: Option<DateTime<Utc>>,
|
|
pub etag: Option<String>,
|
|
// Enhanced timezone information
|
|
pub start_tzid: Option<String>,
|
|
pub end_tzid: Option<String>,
|
|
pub original_start: Option<String>,
|
|
pub original_end: Option<String>,
|
|
} |