feat: Add comprehensive Nextcloud import functionality and fix compilation issues

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)
This commit is contained in:
Alvaro Soliverez 2025-10-29 13:39:48 -03:00
parent 16d6fc375d
commit f84ce62f73
10 changed files with 1461 additions and 342 deletions

View file

@ -9,6 +9,7 @@ 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,
@ -25,6 +26,7 @@ pub struct RealCalDavClient {
client: Client,
base_url: String,
username: String,
import_target: Option<ImportConfig>,
}
impl RealCalDavClient {
@ -63,6 +65,7 @@ impl RealCalDavClient {
client,
base_url: base_url.to_string(),
username: username.to_string(),
import_target: None,
})
}
@ -90,11 +93,70 @@ impl RealCalDavClient {
</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(), &self.base_url)
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), url)
.header("Depth", "1")
.header("Content-Type", "application/xml")
.body(propfind_xml)
.body(propfind_xml.to_string())
.send()
.await?;
@ -103,15 +165,110 @@ impl RealCalDavClient {
}
let response_text = response.text().await?;
debug!("PROPFIND response: {}", response_text);
debug!("PROPFIND response from {}: {}", url, response_text);
// Parse XML response to extract calendar information
let calendars = self.parse_calendar_response(&response_text)?;
info!("Found {} calendars", calendars.len());
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
@ -253,41 +410,163 @@ impl RealCalDavClient {
/// 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
// Enhanced XML parsing to extract multiple calendars from PROPFIND response
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()
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()
}
} 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);
}
// 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);
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)
}
@ -832,6 +1111,64 @@ impl RealCalDavClient {
"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