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:
parent
16d6fc375d
commit
f84ce62f73
10 changed files with 1461 additions and 342 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue