Major additions: - New NextcloudImportEngine with import behaviors (SkipDuplicates, Overwrite, Merge) - Complete import workflow with result tracking and conflict resolution - Support for dry-run mode and detailed progress reporting - Import command integration in CLI with --import-events flag Configuration improvements: - Added ImportConfig struct for structured import settings - Backward compatibility with legacy ImportTargetConfig - Enhanced get_import_config() method supporting both formats CalDAV client enhancements: - Improved XML parsing for multiple calendar display name formats - Better fallback handling for calendar discovery - Enhanced error handling and debugging capabilities Bug fixes: - Fixed test compilation errors in error.rs (reqwest::Error type conversion) - Resolved unused variable warning in main.rs - All tests now pass (16/16) Documentation: - Added comprehensive NEXTCLOUD_IMPORT_PLAN.md with implementation roadmap - Updated library exports to include new modules Files changed: - src/nextcloud_import.rs: New import engine implementation - src/config.rs: Enhanced configuration with import support - src/main.rs: Added import command and CLI integration - src/minicaldav_client.rs: Improved calendar discovery and XML parsing - src/error.rs: Fixed test compilation issues - src/lib.rs: Updated module exports - Deleted: src/real_caldav_client.rs (removed unused file)
1209 lines
No EOL
53 KiB
Rust
1209 lines
No EOL
53 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;
|
|
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<ImportConfig>,
|
|
}
|
|
|
|
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(),
|
|
import_target: None,
|
|
})
|
|
}
|
|
|
|
/// 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>"#;
|
|
|
|
// 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<Vec<CalendarInfo>> {
|
|
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<String> {
|
|
// 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<String> {
|
|
// 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<CalendarInfo> {
|
|
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<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>> {
|
|
// 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("<D:multistatus>") {
|
|
info!("Parsing multistatus response with potentially multiple calendars");
|
|
|
|
// Parse all <D:response> elements to find calendar collections
|
|
let mut start_pos = 0;
|
|
let mut response_count = 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 + 14; // +14 for "</D:response>" 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("<D:href>") {
|
|
if let Some(href_end) = response_xml.find("</D:href>") {
|
|
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("<C:calendar-description>") {
|
|
if let Some(desc_end) = response_xml.find("</C:calendar-description>") {
|
|
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("<C:calendar-color>") {
|
|
if let Some(color_end) = response_xml.find("</C:calendar-color>") {
|
|
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("<C:calendar/>") ||
|
|
response_xml.contains("<C:calendar></C:calendar>") ||
|
|
response_xml.contains("<C:calendar />");
|
|
|
|
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("<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
|
|
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<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()
|
|
}
|
|
|
|
/// Extract display name from XML response, trying multiple formats
|
|
fn extract_display_name_from_xml(&self, xml: &str) -> Option<String> {
|
|
// Try multiple XML formats for display name
|
|
|
|
// Format 1: Standard DAV displayname
|
|
if let Some(display_start) = xml.find("<D:displayname>") {
|
|
if let Some(display_end) = xml.find("</D:displayname>") {
|
|
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![
|
|
("<displayname>", "</displayname>"),
|
|
("<cal:displayname>", "</cal:displayname>"),
|
|
("<c:displayname>", "</c:displayname>"),
|
|
("<C:displayname>", "</C:displayname>"),
|
|
];
|
|
|
|
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<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>,
|
|
} |