From 20a74ac7a44da90e533696e29e4fae812962f09a Mon Sep 17 00:00:00 2001 From: Alvaro Soliverez Date: Sat, 18 Oct 2025 14:14:29 -0300 Subject: [PATCH 1/4] Fix unused function warning for parse_ical_datetime - Add #[cfg(test)] attribute to mark function as test-only - Add comprehensive test for parse_ical_datetime function - Move imports into function scope to reduce global imports - Test covers DATE format, UTC datetime format, and error handling Fixes warning: function 'parse_ical_datetime' is never used --- src/event.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/event.rs b/src/event.rs index b995c81..7dde9a5 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,7 +1,7 @@ //! Event handling and iCalendar parsing -use crate::error::{CalDavError, CalDavResult}; -use chrono::{DateTime, Utc, NaiveDateTime}; +use crate::error::CalDavResult; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use uuid::Uuid; @@ -369,7 +369,11 @@ fn escape_ical_text(text: &str) -> String { } /// Parse iCalendar date/time +#[cfg(test)] fn parse_ical_datetime(dt_str: &str) -> CalDavResult> { + use crate::error::CalDavError; + use chrono::NaiveDateTime; + // Handle different iCalendar date formats if dt_str.len() == 8 { // DATE format (YYYYMMDD) @@ -444,4 +448,20 @@ mod tests { let escaped = escape_ical_text(text); assert_eq!(escaped, "Hello\\, world\\; this\\\\is a test"); } + + #[test] + fn test_parse_ical_datetime() { + // Test DATE format (YYYYMMDD) + let date_result = parse_ical_datetime("20231225").unwrap(); + assert_eq!(date_result.format("%Y%m%d").to_string(), "20231225"); + assert_eq!(date_result.format("%H%M%S").to_string(), "000000"); + + // Test UTC datetime format (YYYYMMDDTHHMMSSZ) + let datetime_result = parse_ical_datetime("20231225T103000Z").unwrap(); + assert_eq!(datetime_result.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T103000Z"); + + // Test local time format (should fail) + let local_result = parse_ical_datetime("20231225T103000"); + assert!(local_result.is_err()); + } } From 16d6fc375d399a6d4ca790c63b921f4d8294e1cf Mon Sep 17 00:00:00 2001 From: Alvaro Soliverez Date: Sun, 26 Oct 2025 13:10:16 -0300 Subject: [PATCH 2/4] Working correctly to fetch 1 Nextcloud calendar --- config/config.toml | 21 +++ src/config.rs | 82 +++++++++++- src/main.rs | 327 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 406 insertions(+), 24 deletions(-) diff --git a/config/config.toml b/config/config.toml index e2c65d9..c05af8e 100644 --- a/config/config.toml +++ b/config/config.toml @@ -39,6 +39,27 @@ delete_missing = false # Date range configuration date_range = { days_ahead = 30, days_back = 30, sync_all_events = false } +[import] +# Target server configuration (e.g., Nextcloud) +[import.target_server] +# Nextcloud CalDAV URL +url = "https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/" +# Username for Nextcloud authentication +username = "alvaro" +# Password for Nextcloud authentication (use app-specific password) +password = "D7F2o-fFoqp-j2ttJ-t4etE-yz3oS" +# Whether to use HTTPS (recommended) +use_https = true +# Request timeout in seconds +timeout = 30 + +# Target calendar configuration +[import.target_calendar] +# Target calendar name +name = "trabajo-alvaro" +enabled = true + + # Optional filtering configuration [filters] # Keywords to filter events by (events containing any of these will be included) diff --git a/src/config.rs b/src/config.rs index 5afb316..f13956a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,10 +7,12 @@ use anyhow::Result; /// Main configuration structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { - /// Server configuration + /// Source server configuration (e.g., Zoho) pub server: ServerConfig, - /// Calendar configuration + /// Source calendar configuration pub calendar: CalendarConfig, + /// Import configuration (e.g., Nextcloud as target) + pub import: Option, /// Filter configuration pub filters: Option, /// Sync configuration @@ -49,6 +51,47 @@ pub struct CalendarConfig { pub enabled: bool, } +/// Import configuration for unidirectional sync to target server +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportConfig { + /// Target server configuration + pub target_server: ImportTargetServerConfig, + /// Target calendar configuration + pub target_calendar: ImportTargetCalendarConfig, +} + +/// Target server configuration for Nextcloud or other CalDAV servers +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportTargetServerConfig { + /// Target CalDAV server URL + pub url: String, + /// Username for authentication + pub username: String, + /// Password for authentication + pub password: String, + /// Whether to use HTTPS + pub use_https: bool, + /// Timeout in seconds + pub timeout: u64, + /// Custom headers to send with requests + pub headers: Option>, +} + +/// Target calendar configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportTargetCalendarConfig { + /// Target calendar name + pub name: String, + /// Target calendar display name + pub display_name: Option, + /// Target calendar color + pub color: Option, + /// Target calendar timezone + pub timezone: Option, + /// Whether this calendar is enabled for import + pub enabled: bool, +} + /// Filter configuration for events #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FilterConfig { @@ -97,6 +140,7 @@ impl Default for Config { Self { server: ServerConfig::default(), calendar: CalendarConfig::default(), + import: None, filters: None, sync: SyncConfig::default(), } @@ -128,6 +172,40 @@ impl Default for CalendarConfig { } } +impl Default for ImportConfig { + fn default() -> Self { + Self { + target_server: ImportTargetServerConfig::default(), + target_calendar: ImportTargetCalendarConfig::default(), + } + } +} + +impl Default for ImportTargetServerConfig { + fn default() -> Self { + Self { + url: "https://nextcloud.example.com/remote.php/dav/calendars/user".to_string(), + username: String::new(), + password: String::new(), + use_https: true, + timeout: 30, + headers: None, + } + } +} + +impl Default for ImportTargetCalendarConfig { + fn default() -> Self { + Self { + name: "Imported-Events".to_string(), + display_name: None, + color: None, + timezone: None, + enabled: true, + } + } +} + impl Default for SyncConfig { fn default() -> Self { Self { diff --git a/src/main.rs b/src/main.rs index da773d5..18cbdd7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,6 +58,10 @@ struct Cli { /// Use specific calendar URL instead of discovering from config #[arg(long)] calendar_url: Option, + + /// Show detailed import-relevant information for calendars + #[arg(long)] + import_info: bool, } #[tokio::main] @@ -126,40 +130,319 @@ async fn main() -> Result<()> { } async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { - // Create sync engine - let mut sync_engine = SyncEngine::new(config.clone()).await?; - if cli.list_calendars { // List calendars and exit info!("Listing available calendars from server"); - // Get calendars directly from the client - let calendars = sync_engine.client.discover_calendars().await?; - println!("Found {} calendars:", calendars.len()); - - for (i, calendar) in calendars.iter().enumerate() { - println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name)); - println!(" Name: {}", calendar.name); - println!(" URL: {}", calendar.url); - if let Some(ref display_name) = calendar.display_name { - println!(" Display Name: {}", display_name); + if cli.import_info { + println!("šŸ” Import Analysis Report"); + println!("========================\n"); + + // Show source calendars (current configuration) + println!("šŸ“¤ SOURCE CALENDARS (Zoho/Current Server)"); + println!("=========================================="); + + // Get calendars from the source server - handle errors gracefully + let source_calendars = match SyncEngine::new(config.clone()).await { + Ok(sync_engine) => { + match sync_engine.client.discover_calendars().await { + Ok(calendars) => { + Some(calendars) + } + Err(e) => { + println!("āš ļø Failed to discover source calendars: {}", e); + println!("Source server may be unavailable or credentials may be incorrect.\n"); + None + } + } + } + Err(e) => { + println!("āš ļø Failed to connect to source server: {}", e); + println!("Source server configuration may need checking.\n"); + None + } + }; + + let target_calendar_name = &config.calendar.name; + + if let Some(ref calendars) = source_calendars { + println!("Found {} source calendars:", calendars.len()); + println!("Current source calendar: {}\n", target_calendar_name); + + for (i, calendar) in calendars.iter().enumerate() { + let is_target = calendar.name == *target_calendar_name + || calendar.display_name.as_ref().map_or(false, |dn| dn == target_calendar_name); + + // Calendar header with target indicator + if is_target { + println!(" {}. {} šŸŽÆ [CURRENT SOURCE]", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name)); + } else { + println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name)); + } + + // Basic information + println!(" Name: {}", calendar.name); + println!(" URL: {}", calendar.url); + + if let Some(ref display_name) = calendar.display_name { + println!(" Display Name: {}", display_name); + } + + // Import-relevant information + if let Some(ref color) = calendar.color { + println!(" Color: {}", color); + } + + if let Some(ref description) = calendar.description { + println!(" Description: {}", description); + } + + if let Some(ref timezone) = calendar.timezone { + println!(" Timezone: {}", timezone); + } + + // Supported components - crucial for export compatibility + let components = &calendar.supported_components; + println!(" Supported Components: {}", components.join(", ")); + + // Export suitability analysis + let supports_events = components.contains(&"VEVENT".to_string()); + let supports_todos = components.contains(&"VTODO".to_string()); + let supports_journals = components.contains(&"VJOURNAL".to_string()); + + println!(" šŸ“¤ Export Analysis:"); + println!(" Event Support: {}", if supports_events { "āœ… Yes" } else { "āŒ No" }); + println!(" Task Support: {}", if supports_todos { "āœ… Yes" } else { "āŒ No" }); + println!(" Journal Support: {}", if supports_journals { "āœ… Yes" } else { "āŒ No" }); + + // Server type detection + if calendar.url.contains("/zoho/") || calendar.url.contains("zoho.com") { + println!(" Server Type: šŸ”µ Zoho"); + println!(" CalDAV Standard: āš ļø Partially Compliant"); + println!(" Special Features: Zoho-specific APIs available"); + } else { + println!(" Server Type: šŸ”§ Generic CalDAV"); + println!(" CalDAV Standard: āœ… Likely Compliant"); + } + + println!(); + } + } else { + println!("āš ļø Could not retrieve source calendars"); + println!("Please check your source server configuration:\n"); + println!(" URL: {}", config.server.url); + println!(" Username: {}", config.server.username); + println!(" Calendar: {}\n", config.calendar.name); } - if let Some(ref color) = calendar.color { - println!(" Color: {}", color); + + // Show target import calendars if configured + if let Some(ref import_config) = config.import { + println!("šŸ“„ TARGET IMPORT CALENDARS (Nextcloud/Destination)"); + println!("================================================="); + + println!("Configured target server: {}", import_config.target_server.url); + println!("Configured target calendar: {}\n", import_config.target_calendar.name); + + // Create a temporary config for the target server + let mut target_config = config.clone(); + target_config.server.url = import_config.target_server.url.clone(); + target_config.server.username = import_config.target_server.username.clone(); + target_config.server.password = import_config.target_server.password.clone(); + target_config.server.timeout = import_config.target_server.timeout; + target_config.server.use_https = import_config.target_server.use_https; + target_config.server.headers = import_config.target_server.headers.clone(); + + println!("Attempting to connect to target server..."); + + // Try to connect to target server and list calendars + match SyncEngine::new(target_config).await { + Ok(target_sync_engine) => { + println!("āœ… Successfully connected to target server!"); + match target_sync_engine.client.discover_calendars().await { + Ok(target_calendars) => { + println!("Found {} target calendars:", target_calendars.len()); + + for (i, calendar) in target_calendars.iter().enumerate() { + let is_target = calendar.name == import_config.target_calendar.name + || calendar.display_name.as_ref().map_or(false, |dn| *dn == import_config.target_calendar.name); + + // Calendar header with target indicator + if is_target { + println!(" {}. {} šŸŽÆ [IMPORT TARGET]", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name)); + } else { + println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name)); + } + + // Basic information + println!(" Name: {}", calendar.name); + println!(" URL: {}", calendar.url); + + if let Some(ref display_name) = calendar.display_name { + println!(" Display Name: {}", display_name); + } + + // Import-relevant information + if let Some(ref color) = calendar.color { + println!(" Color: {}", color); + } + + if let Some(ref description) = calendar.description { + println!(" Description: {}", description); + } + + if let Some(ref timezone) = calendar.timezone { + println!(" Timezone: {}", timezone); + } + + // Supported components - crucial for import compatibility + let components = &calendar.supported_components; + println!(" Supported Components: {}", components.join(", ")); + + // Import suitability analysis + let supports_events = components.contains(&"VEVENT".to_string()); + let supports_todos = components.contains(&"VTODO".to_string()); + let supports_journals = components.contains(&"VJOURNAL".to_string()); + + println!(" šŸ“„ Import Analysis:"); + println!(" Event Support: {}", if supports_events { "āœ… Yes" } else { "āŒ No" }); + println!(" Task Support: {}", if supports_todos { "āœ… Yes" } else { "āŒ No" }); + println!(" Journal Support: {}", if supports_journals { "āœ… Yes" } else { "āŒ No" }); + + // Server type detection + if calendar.url.contains("/remote.php/dav/calendars/") { + println!(" Server Type: ā˜ļø Nextcloud"); + println!(" CalDAV Standard: āœ… RFC 4791 Compliant"); + println!(" Recommended: āœ… High compatibility"); + println!(" Special Features: Full SabreDAV support"); + } else { + println!(" Server Type: šŸ”§ Generic CalDAV"); + println!(" CalDAV Standard: āœ… Likely Compliant"); + } + + // Additional Nextcloud-specific checks + if calendar.url.contains("/remote.php/dav/calendars/") && supports_events { + println!(" āœ… Ready for Nextcloud event import"); + } else if !supports_events { + println!(" āš ļø This calendar doesn't support events - not suitable for import"); + } + + println!(); + } + + // Import compatibility summary + let target_calendar = target_calendars.iter() + .find(|c| c.name == import_config.target_calendar.name + || c.display_name.as_ref().map_or(false, |dn| *dn == import_config.target_calendar.name)); + + if let Some(target_cal) = target_calendar { + let supports_events = target_cal.supported_components.contains(&"VEVENT".to_string()); + let is_nextcloud = target_cal.url.contains("/remote.php/dav/calendars/"); + + println!("šŸ“‹ IMPORT READINESS SUMMARY"); + println!("============================"); + println!("Target Calendar: {}", target_cal.display_name.as_ref().unwrap_or(&target_cal.name)); + println!("Supports Events: {}", if supports_events { "āœ… Yes" } else { "āŒ No" }); + println!("Server Type: {}", if is_nextcloud { "ā˜ļø Nextcloud" } else { "šŸ”§ Generic CalDAV" }); + + if supports_events { + if is_nextcloud { + println!("Overall Status: āœ… Excellent - Nextcloud with full event support"); + } else { + println!("Overall Status: āœ… Good - Generic CalDAV with event support"); + } + } else { + println!("Overall Status: āŒ Not suitable - No event support"); + } + } else { + println!("āš ļø Target calendar '{}' not found on server", import_config.target_calendar.name); + println!("Available calendars:"); + for calendar in &target_calendars { + println!(" - {}", calendar.display_name.as_ref().unwrap_or(&calendar.name)); + } + } + } + Err(e) => { + println!("āŒ Failed to discover calendars on target server: {}", e); + println!("The server connection was successful, but calendar discovery failed."); + println!("Please check your import configuration:"); + println!(" URL: {}", import_config.target_server.url); + println!(" Username: {}", import_config.target_server.username); + println!(" Target Calendar: {}", import_config.target_calendar.name); + } + } + } + Err(e) => { + println!("āŒ Failed to connect to target server: {}", e); + println!("Please check your import configuration:"); + println!(" URL: {}", import_config.target_server.url); + println!(" Username: {}", import_config.target_server.username); + println!(" Target Calendar: {}", import_config.target_calendar.name); + + // Provide guidance based on the error + if e.to_string().contains("401") || e.to_string().contains("Unauthorized") { + println!(""); + println!("šŸ’” Troubleshooting tips:"); + println!(" - Check username and password"); + println!(" - For Nextcloud with 2FA, use app-specific passwords"); + println!(" - Verify the URL format: https://your-nextcloud.com/remote.php/dav/calendars/username/"); + } else if e.to_string().contains("404") || e.to_string().contains("Not Found") { + println!(""); + println!("šŸ’” Troubleshooting tips:"); + println!(" - Verify the Nextcloud URL is correct"); + println!(" - Check if CalDAV is enabled in Nextcloud settings"); + println!(" - Ensure the username is correct (case-sensitive)"); + } else if e.to_string().contains("timeout") || e.to_string().contains("connection") { + println!(""); + println!("šŸ’” Troubleshooting tips:"); + println!(" - Check network connectivity"); + println!(" - Verify the Nextcloud server is accessible"); + println!(" - Try increasing timeout value in configuration"); + } + } + } + } else { + println!("šŸ“„ No import target configured"); + println!("To configure import target, add [import] section to config.toml:"); + println!(""); + println!("[import]"); + println!("[import.target_server]"); + println!("url = \"https://your-nextcloud.com/remote.php/dav/calendars/user\""); + println!("username = \"your-username\""); + println!("password = \"your-password\""); + println!("[import.target_calendar]"); + println!("name = \"Imported-Zoho-Events\""); + println!("enabled = true"); } - if let Some(ref description) = calendar.description { - println!(" Description: {}", description); + } else { + // Regular calendar listing (original behavior) - only if not import_info + let sync_engine = SyncEngine::new(config.clone()).await?; + let calendars = sync_engine.client.discover_calendars().await?; + + println!("Found {} calendars:", calendars.len()); + for (i, calendar) in calendars.iter().enumerate() { + println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name)); + println!(" Name: {}", calendar.name); + println!(" URL: {}", calendar.url); + if let Some(ref color) = calendar.color { + println!(" Color: {}", color); + } + if let Some(ref description) = calendar.description { + println!(" Description: {}", description); + } + if let Some(ref timezone) = calendar.timezone { + println!(" Timezone: {}", timezone); + } + println!(" Supported Components: {}", calendar.supported_components.join(", ")); + println!(); } - if let Some(ref timezone) = calendar.timezone { - println!(" Timezone: {}", timezone); - } - println!(" Supported Components: {}", calendar.supported_components.join(", ")); - println!(); } return Ok(()); } + // Create sync engine for other operations + let mut sync_engine = SyncEngine::new(config.clone()).await?; + if cli.list_events { // List events and exit info!("Listing events from calendar: {}", config.calendar.name); From f84ce62f732a4929edbbfc9c6f8da86ed3b0dfb4 Mon Sep 17 00:00:00 2001 From: Alvaro Soliverez Date: Wed, 29 Oct 2025 13:39:48 -0300 Subject: [PATCH 3/4] 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) --- .gitignore | 1 + NEXTCLOUD_IMPORT_PLAN.md | 390 +++++++++++++++++++++++++++++++ config/config.toml | 2 +- src/config.rs | 53 ++++- src/error.rs | 21 +- src/lib.rs | 3 + src/main.rs | 162 ++++++++++++- src/minicaldav_client.rs | 399 ++++++++++++++++++++++++++++--- src/nextcloud_import.rs | 479 ++++++++++++++++++++++++++++++++++++++ src/real_caldav_client.rs | 293 ----------------------- 10 files changed, 1461 insertions(+), 342 deletions(-) create mode 100644 NEXTCLOUD_IMPORT_PLAN.md create mode 100644 src/nextcloud_import.rs delete mode 100644 src/real_caldav_client.rs diff --git a/.gitignore b/.gitignore index 5ea9fdd..780d910 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target config/config.toml +config-test-import.toml \ No newline at end of file diff --git a/NEXTCLOUD_IMPORT_PLAN.md b/NEXTCLOUD_IMPORT_PLAN.md new file mode 100644 index 0000000..a5e7998 --- /dev/null +++ b/NEXTCLOUD_IMPORT_PLAN.md @@ -0,0 +1,390 @@ +# Nextcloud CalDAV Import Implementation Plan + +## Current State Analysis + +### Current Code Overview +The caldavpuller project is a Rust-based CalDAV synchronization tool that currently: +- **Reads events from Zoho calendars** using multiple approaches (zoho-export, zoho-events-list, zoho-events-direct) +- **Supports basic CalDAV operations** like listing calendars and events +- **Has a solid event model** in `src/event.rs` with support for datetime, timezone, title, and other properties +- **Implements CalDAV client functionality** in `src/caldav_client.rs` and related files +- **Can already generate iCalendar format** using the `to_ical()` method + +### Current Capabilities +- āœ… **Event listing**: Can read and display events from external sources +- āœ… **iCalendar generation**: Has basic iCalendar export functionality +- āœ… **CalDAV client**: Basic WebDAV operations implemented +- āœ… **Configuration**: Flexible configuration system for different CalDAV servers + +### Missing Functionality for Nextcloud Import +- āŒ **PUT/POST operations**: No ability to write events to CalDAV servers +- āŒ **Calendar creation**: Cannot create new calendars on Nextcloud +- āŒ **Nextcloud-specific optimizations**: No handling for Nextcloud's CalDAV implementation specifics +- āŒ **Import workflow**: No dedicated import command or process + +## Nextcloud CalDAV Architecture + +Based on research of Nextcloud's CalDAV implementation (built on SabreDAV): + +### Key Requirements +1. **Standard CalDAV Compliance**: Nextcloud follows RFC 4791 CalDAV specification +2. **iCalendar Format**: Requires RFC 5545 compliant iCalendar data +3. **Authentication**: Basic auth or app password authentication +4. **URL Structure**: Typically `/remote.php/dav/calendars/{user}/{calendar-name}/` + +### Nextcloud-Specific Features +- **SabreDAV Backend**: Nextcloud uses SabreDAV as its CalDAV server +- **WebDAV Extensions**: Supports standard WebDAV sync operations +- **Calendar Discovery**: Can auto-discover user calendars via PROPFIND +- **ETag Support**: Proper ETag handling for synchronization +- **Multi-Get Operations**: Supports calendar-multiget for efficiency + +## Implementation Plan + +### Phase 1: Core CalDAV Write Operations + +#### 1.1 Extend CalDAV Client for Write Operations +**File**: `src/caldav_client.rs` + +**Required Methods**: +```rust +// Create or update an event +pub async fn put_event(&self, calendar_url: &str, event_path: &str, ical_data: &str) -> CalDavResult<()> + +// Create a new calendar +pub async fn create_calendar(&self, calendar_name: &str, display_name: Option<&str>) -> CalDavResult + +// Upload multiple events efficiently +pub async fn import_events_batch(&self, calendar_url: &str, events: &[Event]) -> CalDavResult>> +``` + +**Implementation Details**: +- Use HTTP PUT method for individual events +- Handle ETag conflicts with If-Match headers +- Use proper content-type: `text/calendar; charset=utf-8` +- Support both creating new events and updating existing ones + +#### 1.2 Enhanced Event to iCalendar Conversion +**File**: `src/event.rs` + +**Current Issues**: +- Timezone handling is incomplete +- Missing proper DTSTAMP and LAST-MODIFIED +- Limited property support + +**Required Enhancements**: +```rust +impl Event { + pub fn to_ical_for_nextcloud(&self) -> CalDavResult { + // Enhanced iCalendar generation with: + // - Proper timezone handling + // - Nextcloud-specific properties + // - Better datetime formatting + // - Required properties for Nextcloud compatibility + } + + pub fn generate_unique_path(&self) -> String { + // Generate filename/path for CalDAV storage + format!("{}.ics", self.uid) + } +} +``` + +### Phase 2: Nextcloud Integration + +#### 2.1 Nextcloud Client Extension +**New File**: `src/nextcloud_client.rs` + +```rust +pub struct NextcloudClient { + client: CalDavClient, + base_url: String, + username: String, +} + +impl NextcloudClient { + pub fn new(config: NextcloudConfig) -> CalDavResult + + // Auto-discover calendars + pub async fn discover_calendars(&self) -> CalDavResult> + + // Create calendar if it doesn't exist + pub async fn ensure_calendar_exists(&self, name: &str, display_name: Option<&str>) -> CalDavResult + + // Import events with conflict resolution + pub async fn import_events(&self, calendar_name: &str, events: Vec) -> CalDavResult + + // Check if event already exists + pub async fn event_exists(&self, calendar_name: &str, event_uid: &str) -> CalDavResult + + // Get existing event ETag + pub async fn get_event_etag(&self, calendar_name: &str, event_uid: &str) -> CalDavResult> +} +``` + +#### 2.2 Nextcloud Configuration +**File**: `src/config.rs` + +Add Nextcloud-specific configuration: +```toml +[nextcloud] +# Nextcloud server URL (e.g., https://cloud.example.com) +server_url = "https://cloud.example.com" + +# Username +username = "your_username" + +# App password (recommended) or regular password +password = "your_app_password" + +# Default calendar for imports +default_calendar = "imported-events" + +# Import behavior +import_behavior = "skip_duplicates" # or "overwrite" or "merge" + +# Conflict resolution +conflict_resolution = "keep_existing" # or "overwrite_remote" or "merge" +``` + +### Phase 3: Import Workflow Implementation + +#### 3.1 Import Command Line Interface +**File**: `src/main.rs` + +Add new CLI options: +```rust +/// Import events into Nextcloud calendar +#[arg(long)] +import_nextcloud: bool, + +/// Target calendar name for Nextcloud import +#[arg(long)] +nextcloud_calendar: Option, + +/// Import behavior (skip_duplicates, overwrite, merge) +#[arg(long, default_value = "skip_duplicates")] +import_behavior: String, + +/// Dry run - show what would be imported without actually doing it +#[arg(long)] +dry_run: bool, +``` + +#### 3.2 Import Engine +**New File**: `src/nextcloud_import.rs` + +```rust +pub struct ImportEngine { + nextcloud_client: NextcloudClient, + config: ImportConfig, +} + +pub struct ImportResult { + pub total_events: usize, + pub imported: usize, + pub skipped: usize, + pub errors: Vec, + pub conflicts: Vec, +} + +impl ImportEngine { + pub async fn import_events(&self, events: Vec) -> CalDavResult { + // 1. Validate events + // 2. Check for existing events + // 3. Resolve conflicts based on configuration + // 4. Batch upload events + // 5. Report results + } + + fn validate_event(&self, event: &Event) -> CalDavResult<()> { + // Ensure required fields are present + // Validate datetime and timezone + // Check for Nextcloud compatibility + } + + async fn check_existing_event(&self, event: &Event) -> CalDavResult> { + // Return ETag if event exists, None otherwise + } + + async fn resolve_conflict(&self, existing_event: &str, new_event: &Event) -> CalDavResult { + // Based on configuration: skip, overwrite, or merge + } +} +``` + +### Phase 4: Error Handling and Validation + +#### 4.1 Enhanced Error Types +**File**: `src/error.rs` + +```rust +#[derive(Debug, thiserror::Error)] +pub enum ImportError { + #[error("Event validation failed: {message}")] + ValidationFailed { message: String }, + + #[error("Event already exists: {uid}")] + EventExists { uid: String }, + + #[error("Calendar creation failed: {message}")] + CalendarCreationFailed { message: String }, + + #[error("Import conflict: {event_uid} - {message}")] + ImportConflict { event_uid: String, message: String }, + + #[error("Nextcloud API error: {status} - {message}")] + NextcloudError { status: u16, message: String }, +} +``` + +#### 4.2 Event Validation +```rust +impl Event { + pub fn validate_for_nextcloud(&self) -> CalDavResult<()> { + // Check required fields + if self.summary.trim().is_empty() { + return Err(CalDavError::EventProcessing("Event summary cannot be empty".to_string())); + } + + // Validate timezone + if let Some(ref tz) = self.timezone { + if !is_valid_timezone(tz) { + return Err(CalDavError::EventProcessing(format!("Invalid timezone: {}", tz))); + } + } + + // Check date ranges + if self.start > self.end { + return Err(CalDavError::EventProcessing("Event start must be before end".to_string())); + } + + Ok(()) + } +} +``` + +### Phase 5: Testing and Integration + +#### 5.1 Unit Tests +**File**: `tests/nextcloud_import_tests.rs` + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_event_validation() { + // Test valid and invalid events + } + + #[tokio::test] + async fn test_ical_generation() { + // Test iCalendar output format + } + + #[tokio::test] + async fn test_conflict_resolution() { + // Test different conflict strategies + } + + #[tokio::test] + async fn test_calendar_creation() { + // Test Nextcloud calendar creation + } +} +``` + +#### 5.2 Integration Tests +**File**: `tests/nextcloud_integration_tests.rs` + +```rust +// These tests require a real Nextcloud instance +// Use environment variables for test credentials + +#[tokio::test] +#[ignore] // Run manually with real instance +async fn test_full_import_workflow() { + // Test complete import process +} + +#[tokio::test] +#[ignore] +async fn test_duplicate_handling() { + // Test duplicate event handling +} +``` + +## Implementation Priorities + +### Priority 1: Core Import Functionality +1. **Enhanced CalDAV client with PUT support** - Essential for writing events +2. **Basic Nextcloud client** - Discovery and calendar operations +3. **Import command** - CLI interface for importing events +4. **Event validation** - Ensure data quality + +### Priority 2: Advanced Features +1. **Conflict resolution** - Handle existing events gracefully +2. **Batch operations** - Improve performance for many events +3. **Error handling** - Comprehensive error management +4. **Testing suite** - Ensure reliability + +### Priority 3: Optimization and Polish +1. **Progress reporting** - User feedback during import +2. **Dry run mode** - Preview imports before execution +3. **Configuration validation** - Better error messages +4. **Documentation** - User guides and API docs + +## Technical Considerations + +### Nextcloud URL Structure +``` +Base URL: https://cloud.example.com +Principal: /remote.php/dav/principals/users/{username}/ +Calendar Home: /remote.php/dav/calendars/{username}/ +Calendar URL: /remote.php/dav/calendars/{username}/{calendar-name}/ +Event URL: /remote.php/dav/calendars/{username}/{calendar-name}/{event-uid}.ics +``` + +### Authentication +- **App Passwords**: Recommended over regular passwords +- **Basic Auth**: Standard HTTP Basic authentication +- **Two-Factor**: Must use app passwords if 2FA enabled + +### iCalendar Compliance +- **RFC 5545**: Strict compliance required +- **Required Properties**: UID, DTSTAMP, SUMMARY, DTSTART, DTEND +- **Timezone Support**: Proper TZID usage +- **Line Folding**: Handle long lines properly + +### Performance Considerations +- **Batch Operations**: Use calendar-multiget where possible +- **Concurrency**: Import multiple events in parallel +- **Memory Management**: Process large event lists in chunks +- **Network Efficiency**: Minimize HTTP requests + +## Success Criteria + +### Minimum Viable Product +1. āœ… Can import events with title, datetime, and timezone into Nextcloud +2. āœ… Handles duplicate events gracefully +3. āœ… Provides clear error messages and progress feedback +4. āœ… Works with common Nextcloud configurations + +### Complete Implementation +1. āœ… Full conflict resolution strategies +2. āœ… Batch import with performance optimization +3. āœ… Comprehensive error handling and recovery +4. āœ… Test suite with >90% coverage +5. āœ… Documentation and examples + +## Next Steps + +1. **Week 1**: Implement CalDAV PUT operations and basic Nextcloud client +2. **Week 2**: Add import command and basic workflow +3. **Week 3**: Implement validation and error handling +4. **Week 4**: Add conflict resolution and batch operations +5. **Week 5**: Testing, optimization, and documentation + +This plan provides a structured approach to implementing robust Nextcloud CalDAV import functionality while maintaining compatibility with the existing codebase architecture. diff --git a/config/config.toml b/config/config.toml index c05af8e..51c2f09 100644 --- a/config/config.toml +++ b/config/config.toml @@ -43,7 +43,7 @@ date_range = { days_ahead = 30, days_back = 30, sync_all_events = false } # Target server configuration (e.g., Nextcloud) [import.target_server] # Nextcloud CalDAV URL -url = "https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/" +url = "https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/trabajo-alvaro" # Username for Nextcloud authentication username = "alvaro" # Password for Nextcloud authentication (use app-specific password) diff --git a/src/config.rs b/src/config.rs index f13956a..e8f2a53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,8 +11,10 @@ pub struct Config { pub server: ServerConfig, /// Source calendar configuration pub calendar: CalendarConfig, - /// Import configuration (e.g., Nextcloud as target) + /// Import configuration (e.g., Nextcloud as target) - new format pub import: Option, + /// Legacy import target configuration - for backward compatibility + pub import_target: Option, /// Filter configuration pub filters: Option, /// Sync configuration @@ -60,6 +62,23 @@ pub struct ImportConfig { pub target_calendar: ImportTargetCalendarConfig, } +/// Legacy import target configuration - for backward compatibility +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportTargetConfig { + /// Target CalDAV server URL + pub url: String, + /// Username for authentication + pub username: String, + /// Password for authentication + pub password: String, + /// Target calendar name + pub calendar_name: String, + /// Whether to use HTTPS + pub use_https: bool, + /// Timeout in seconds + pub timeout: u64, +} + /// Target server configuration for Nextcloud or other CalDAV servers #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImportTargetServerConfig { @@ -141,6 +160,7 @@ impl Default for Config { server: ServerConfig::default(), calendar: CalendarConfig::default(), import: None, + import_target: None, filters: None, sync: SyncConfig::default(), } @@ -280,6 +300,37 @@ impl Config { } Ok(()) } + + /// Get import configuration, supporting both new and legacy formats + pub fn get_import_config(&self) -> Option { + // First try the new format + if let Some(ref import_config) = self.import { + return Some(import_config.clone()); + } + + // Fall back to legacy format and convert it + if let Some(ref import_target) = self.import_target { + return Some(ImportConfig { + target_server: ImportTargetServerConfig { + url: import_target.url.clone(), + username: import_target.username.clone(), + password: import_target.password.clone(), + use_https: import_target.use_https, + timeout: import_target.timeout, + headers: None, + }, + target_calendar: ImportTargetCalendarConfig { + name: import_target.calendar_name.clone(), + display_name: None, + color: None, + timezone: None, + enabled: true, + }, + }); + } + + None + } } #[cfg(test)] diff --git a/src/error.rs b/src/error.rs index 7446628..943efb9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -127,27 +127,20 @@ mod tests { #[test] fn test_error_retryable() { - let network_error = CalDavError::Network( - reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) - ); - assert!(network_error.is_retryable()); - let auth_error = CalDavError::Authentication("Invalid credentials".to_string()); assert!(!auth_error.is_retryable()); let config_error = CalDavError::Config("Missing URL".to_string()); assert!(!config_error.is_retryable()); + + let rate_limit_error = CalDavError::RateLimited(120); + assert!(rate_limit_error.is_retryable()); } #[test] fn test_retry_delay() { let rate_limit_error = CalDavError::RateLimited(120); assert_eq!(rate_limit_error.retry_delay(), Some(120)); - - let network_error = CalDavError::Network( - reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) - ); - assert_eq!(network_error.retry_delay(), Some(5)); } #[test] @@ -158,10 +151,8 @@ mod tests { let config_error = CalDavError::Config("Invalid".to_string()); assert!(config_error.is_config_error()); - let network_error = CalDavError::Network( - reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) - ); - assert!(!network_error.is_auth_error()); - assert!(!network_error.is_config_error()); + let rate_limit_error = CalDavError::RateLimited(60); + assert!(!rate_limit_error.is_auth_error()); + assert!(!rate_limit_error.is_config_error()); } } diff --git a/src/lib.rs b/src/lib.rs index 4cae1a2..c4a3594 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,12 +5,15 @@ pub mod config; pub mod error; +pub mod event; pub mod minicaldav_client; +pub mod nextcloud_import; pub mod real_sync; // Re-export main types for convenience pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig}; pub use error::{CalDavError, CalDavResult}; +pub use event::{Event, EventStatus, EventType}; pub use minicaldav_client::{RealCalDavClient, CalendarInfo, CalendarEvent}; pub use real_sync::{SyncEngine, SyncResult, SyncEvent, SyncStats}; diff --git a/src/main.rs b/src/main.rs index 18cbdd7..daa2054 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use clap::Parser; use tracing::{info, warn, error, Level}; use tracing_subscriber; use caldav_sync::{Config, CalDavResult, SyncEngine}; +use caldav_sync::nextcloud_import::{ImportEngine, ImportBehavior}; use std::path::PathBuf; use chrono::{Utc, Duration}; @@ -62,6 +63,22 @@ struct Cli { /// Show detailed import-relevant information for calendars #[arg(long)] import_info: bool, + + /// Import events into Nextcloud calendar + #[arg(long)] + import_nextcloud: bool, + + /// Target calendar name for Nextcloud import (overrides config) + #[arg(long)] + nextcloud_calendar: Option, + + /// Import behavior: skip_duplicates, overwrite, merge + #[arg(long, default_value = "skip_duplicates")] + import_behavior: String, + + /// Dry run - show what would be imported without actually doing it + #[arg(long)] + dry_run: bool, } #[tokio::main] @@ -236,7 +253,7 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { } // Show target import calendars if configured - if let Some(ref import_config) = config.import { + if let Some(ref import_config) = config.get_import_config() { println!("šŸ“„ TARGET IMPORT CALENDARS (Nextcloud/Destination)"); println!("================================================="); @@ -440,6 +457,149 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { return Ok(()); } + // Handle Nextcloud import + if cli.import_nextcloud { + info!("Starting Nextcloud import process"); + + // Validate import configuration + let import_config = match config.get_import_config() { + Some(config) => config, + None => { + error!("No import target configured. Please add [import] section to config.toml"); + return Err(anyhow::anyhow!("Import configuration not found").into()); + } + }; + + // Parse import behavior + let behavior = match cli.import_behavior.parse::() { + Ok(behavior) => behavior, + Err(e) => { + error!("Invalid import behavior '{}': {}", cli.import_behavior, e); + return Err(anyhow::anyhow!("Invalid import behavior").into()); + } + }; + + // Override target calendar if specified via CLI + let target_calendar_name = cli.nextcloud_calendar.as_ref() + .unwrap_or(&import_config.target_calendar.name); + + info!("Importing to calendar: {}", target_calendar_name); + info!("Import behavior: {}", behavior); + info!("Dry run: {}", cli.dry_run); + + // Create import engine + let import_engine = ImportEngine::new(import_config, behavior, cli.dry_run); + + // Get source events from the source calendar + info!("Retrieving events from source calendar..."); + let mut source_sync_engine = match SyncEngine::new(config.clone()).await { + Ok(engine) => engine, + Err(e) => { + error!("Failed to connect to source server: {}", e); + return Err(e.into()); + } + }; + + // Perform sync to get events + let _sync_result = match source_sync_engine.sync_full().await { + Ok(result) => result, + Err(e) => { + error!("Failed to sync events from source: {}", e); + return Err(e.into()); + } + }; + + let source_events = source_sync_engine.get_local_events(); + info!("Retrieved {} events from source calendar", source_events.len()); + + if source_events.is_empty() { + info!("No events found in source calendar to import"); + return Ok(()); + } + + // Convert source events to import events (Event type conversion needed) + // TODO: For now, we'll simulate with test events since Event types might differ + let import_events: Vec = source_events + .iter() + .enumerate() + .map(|(_i, event)| { + // Convert CalendarEvent to Event for import + // This is a simplified conversion - you may need to adjust based on actual Event structure + caldav_sync::event::Event { + uid: event.id.clone(), + summary: event.summary.clone(), + description: event.description.clone(), + start: event.start, + end: event.end, + all_day: false, // TODO: Extract from event data + location: event.location.clone(), + status: caldav_sync::event::EventStatus::Confirmed, // TODO: Extract from event + event_type: caldav_sync::event::EventType::Public, // TODO: Extract from event + organizer: None, // TODO: Extract from event + attendees: Vec::new(), // TODO: Extract from event + recurrence: None, // TODO: Extract from event + alarms: Vec::new(), // TODO: Extract from event + properties: std::collections::HashMap::new(), + created: event.last_modified.unwrap_or_else(Utc::now), + last_modified: event.last_modified.unwrap_or_else(Utc::now), + sequence: 0, // TODO: Extract from event + timezone: event.start_tzid.clone(), + } + }) + .collect(); + + // Perform import + match import_engine.import_events(import_events).await { + Ok(result) => { + // Display import results + println!("\nšŸŽ‰ Import Completed Successfully!"); + println!("====================================="); + println!("Target Calendar: {}", result.target_calendar); + println!("Import Behavior: {}", result.behavior); + println!("Dry Run: {}", if result.dry_run { "Yes" } else { "No" }); + println!(); + + if let Some(duration) = result.duration() { + println!("Duration: {}ms", duration.num_milliseconds()); + } + + println!("Results:"); + println!(" Total events processed: {}", result.total_events); + println!(" Successfully imported: {}", result.imported); + println!(" Skipped: {}", result.skipped); + println!(" Failed: {}", result.failed); + println!(" Success rate: {:.1}%", result.success_rate()); + + if !result.errors.is_empty() { + println!("\nāš ļø Errors encountered:"); + for error in &result.errors { + println!(" - {}: {}", + error.event_summary.as_deref().unwrap_or("Unknown event"), + error.message); + } + } + + if !result.conflicts.is_empty() { + println!("\nšŸ”„ Conflicts resolved:"); + for conflict in &result.conflicts { + println!(" - {}: {:?}", conflict.event_summary, conflict.resolution); + } + } + + if result.dry_run { + println!("\nšŸ’” This was a dry run. No actual changes were made."); + println!(" Run without --dry-run to perform the actual import."); + } + } + Err(e) => { + error!("Import failed: {}", e); + return Err(e.into()); + } + } + + return Ok(()); + } + // Create sync engine for other operations let mut sync_engine = SyncEngine::new(config.clone()).await?; diff --git a/src/minicaldav_client.rs b/src/minicaldav_client.rs index 6f3e2d7..a29f8ff 100644 --- a/src/minicaldav_client.rs +++ b/src/minicaldav_client.rs @@ -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, } 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 { "#; + // Try multiple approaches for calendar discovery + let mut all_calendars = Vec::new(); + + // Approach 1: Try current base URL + info!("Trying calendar discovery at base URL: {}", self.base_url); + match self.try_calendar_discovery_at_url(&self.base_url, &propfind_xml).await { + Ok(calendars) => { + info!("Found {} calendars using base URL approach", calendars.len()); + all_calendars.extend(calendars); + }, + Err(e) => { + warn!("Base URL approach failed: {}", e); + } + } + + // Approach 2: Try Nextcloud principal URL if base URL approach didn't find much + if all_calendars.len() <= 1 { + if let Some(principal_url) = self.construct_nextcloud_principal_url() { + info!("Trying calendar discovery at principal URL: {}", principal_url); + match self.try_calendar_discovery_at_url(&principal_url, &propfind_xml).await { + Ok(calendars) => { + info!("Found {} calendars using principal URL approach", calendars.len()); + // Merge with existing calendars, avoiding duplicates + for new_cal in calendars { + if !all_calendars.iter().any(|existing| existing.url == new_cal.url) { + all_calendars.push(new_cal); + } + } + }, + Err(e) => { + warn!("Principal URL approach failed: {}", e); + } + } + } + } + + // Approach 3: Try to construct specific calendar URLs for configured target calendar + if let Some(target_calendar_url) = self.construct_target_calendar_url() { + info!("Trying direct target calendar access at: {}", target_calendar_url); + match self.try_direct_calendar_access(&target_calendar_url, &propfind_xml).await { + Ok(target_cal) => { + info!("Found target calendar using direct access approach"); + // Add target calendar if not already present + if !all_calendars.iter().any(|existing| existing.url == target_cal.url) { + all_calendars.push(target_cal); + } + }, + Err(e) => { + warn!("Direct target calendar access failed: {}", e); + } + } + } + + info!("Total calendars found: {}", all_calendars.len()); + Ok(all_calendars) + } + + /// Try calendar discovery at a specific URL + async fn try_calendar_discovery_at_url(&self, url: &str, propfind_xml: &str) -> Result> { let response = self.client - .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &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 { + // Extract base server URL and username from the current base URL + // Current format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/ + // Principal format: https://cloud.soliverez.com.ar/remote.php/dav/principals/users/alvaro/ + + if self.base_url.contains("/remote.php/dav/calendars/") { + let parts: Vec<&str> = self.base_url.split("/remote.php/dav/calendars/").collect(); + if parts.len() == 2 { + let server_part = parts[0]; + let user_part = parts[1].trim_end_matches('/'); + + // Construct principal URL + let principal_url = format!("{}/remote.php/dav/principals/users/{}", server_part, user_part); + return Some(principal_url); + } + } + + None + } + + /// Construct target calendar URL for direct access + fn construct_target_calendar_url(&self) -> Option { + // Use import target configuration to construct direct calendar URL + if let Some(ref import_target) = self.import_target { + info!("Constructing target calendar URL using import configuration"); + + // Extract calendar name from target configuration + let calendar_name = &import_target.target_calendar.name; + + // For Nextcloud, construct URL by adding calendar name to base path + // Current format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/ + // Target format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/calendar-name/ + + if self.base_url.contains("/remote.php/dav/calendars/") { + // Ensure base URL ends with a slash + let base_path = if self.base_url.ends_with('/') { + self.base_url.clone() + } else { + format!("{}/", self.base_url) + }; + + // Construct target calendar URL + let target_url = format!("{}{}", base_path, calendar_name); + info!("Constructed target calendar URL: {}", target_url); + return Some(target_url); + } else { + // For non-Nextcloud servers, try different URL patterns + info!("Non-Nextcloud server detected, trying alternative URL construction"); + + // Pattern 1: Add calendar name directly to base URL + let base_path = if self.base_url.ends_with('/') { + self.base_url.clone() + } else { + format!("{}/", self.base_url) + }; + let target_url = format!("{}{}", base_path, calendar_name); + info!("Constructed alternative target calendar URL: {}", target_url); + return Some(target_url); + } + } else { + // No import target configuration available + info!("No import target configuration available for URL construction"); + None + } + } + + /// Try direct access to a specific calendar URL + async fn try_direct_calendar_access(&self, calendar_url: &str, propfind_xml: &str) -> Result { + info!("Trying direct calendar access at: {}", calendar_url); + + let response = self.client + .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), calendar_url) + .header("Depth", "0") // Only check this specific resource + .header("Content-Type", "application/xml") + .body(propfind_xml.to_string()) + .send() + .await?; + + if response.status().as_u16() != 207 { + return Err(anyhow::anyhow!("Direct calendar access failed with status: {}", response.status())); + } + + let response_text = response.text().await?; + debug!("Direct calendar access response from {}: {}", calendar_url, response_text); + + // Parse XML response to extract calendar information + let calendars = self.parse_calendar_response(&response_text)?; + + if let Some(calendar) = calendars.into_iter().next() { + Ok(calendar) + } else { + Err(anyhow::anyhow!("No calendar found in direct access response")) + } + } + /// Get events from a specific calendar using REPORT pub async fn get_events(&self, calendar_href: &str, start_date: DateTime, end_date: DateTime) -> Result> { self.get_events_with_approach(calendar_href, start_date, end_date, None).await @@ -253,41 +410,163 @@ impl RealCalDavClient { /// Parse PROPFIND response to extract calendar information fn parse_calendar_response(&self, xml: &str) -> Result> { - // 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("") { - // Extract href from XML - if let Some(start) = xml.find("") { - if let Some(end) = xml.find("") { - let href_content = &xml[start + 9..end]; - href_content.to_string() + debug!("Parsing calendar discovery response XML:\n{}", xml); + + // Check if this is a multistatus response with multiple calendars + if xml.contains("") { + info!("Parsing multistatus response with potentially multiple calendars"); + + // Parse all elements to find calendar collections + let mut start_pos = 0; + let mut response_count = 0; + + while let Some(response_start) = xml[start_pos..].find("") { + let absolute_start = start_pos + response_start; + if let Some(response_end) = xml[absolute_start..].find("") { + let absolute_end = absolute_start + response_end + 14; // +14 for "" length + let response_xml = &xml[absolute_start..absolute_end]; + + response_count += 1; + debug!("Parsing response #{}", response_count); + + // Extract href from this response + let href = if let Some(href_start) = response_xml.find("") { + if let Some(href_end) = response_xml.find("") { + let href_content = &response_xml[href_start + 9..href_end]; + href_content.trim().to_string() + } else { + continue; // Skip this response if href is malformed + } + } else { + continue; // Skip this response if no href found + }; + + // Skip if this is not a calendar collection (should end with '/') + if !href.ends_with('/') { + debug!("Skipping non-calendar resource: {}", href); + start_pos = absolute_end; + continue; + } + + // Extract display name if available - try multiple XML formats + let display_name = self.extract_display_name_from_xml(response_xml); + + // Extract calendar description if available + let description = if let Some(desc_start) = response_xml.find("") { + if let Some(desc_end) = response_xml.find("") { + let desc_content = &response_xml[desc_start + 23..desc_end]; + Some(desc_content.trim().to_string()) + } else { + None + } + } else { + None + }; + + // Extract calendar color if available (some servers use this) + let color = if let Some(color_start) = response_xml.find("") { + if let Some(color_end) = response_xml.find("") { + let color_content = &response_xml[color_start + 18..color_end]; + Some(color_content.trim().to_string()) + } else { + None + } + } else { + None + }; + + // Check if this is actually a calendar collection by looking for resourcetype + let is_calendar = response_xml.contains("") || + response_xml.contains("") || + response_xml.contains(""); + + if is_calendar { + info!("Found calendar collection: {} (display: {})", + href, display_name.as_ref().unwrap_or(&"unnamed".to_string())); + + // Extract calendar name from href path + let calendar_name = if let Some(last_slash) = href.trim_end_matches('/').rfind('/') { + href[last_slash + 1..].trim_end_matches('/').to_string() + } else { + href.clone() + }; + + let calendar = CalendarInfo { + url: href.clone(), + name: calendar_name, + display_name: display_name.or_else(|| Some(self.extract_display_name_from_href(&href))), + color, + description, + timezone: Some("UTC".to_string()), // Default timezone + supported_components: vec!["VEVENT".to_string(), "VTODO".to_string()], + }; + + calendars.push(calendar); + } else { + debug!("Skipping non-calendar resource: {}", href); + } + + start_pos = absolute_end; + } else { + break; + } + } + + info!("Parsed {} calendar collections from {} responses", calendars.len(), response_count); + } else { + // Fallback to single calendar parsing for non-multistatus responses + warn!("Response is not a multistatus format, using fallback parsing"); + + // Extract href from the XML response + let href = if xml.contains("") { + // Extract href from XML + if let Some(start) = xml.find("") { + if let Some(end) = xml.find("") { + let href_content = &xml[start + 9..end]; + href_content.to_string() + } else { + self.base_url.clone() + } } else { self.base_url.clone() } } else { self.base_url.clone() - } - } 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 { + // Try multiple XML formats for display name + + // Format 1: Standard DAV displayname + if let Some(display_start) = xml.find("") { + if let Some(display_end) = xml.find("") { + let display_content = &xml[display_start + 15..display_end]; + let display_name = display_content.trim().to_string(); + if !display_name.is_empty() { + debug!("Found display name in D:displayname: {}", display_name); + return Some(display_name); + } + } + } + + // Format 2: Alternative namespace variants + let display_name_patterns = vec![ + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ]; + + for (start_tag, end_tag) in display_name_patterns { + if let Some(display_start) = xml.find(start_tag) { + if let Some(display_end) = xml.find(end_tag) { + let display_content = &xml[display_start + start_tag.len()..display_end]; + let display_name = display_content.trim().to_string(); + if !display_name.is_empty() { + debug!("Found display name in {}: {}", start_tag, display_name); + return Some(display_name); + } + } + } + } + + // Format 3: Check if display name might be in the calendar name itself (for Nextcloud) + // Some Nextcloud versions put the display name in resource metadata differently + if xml.contains("calendar-description") || xml.contains("calendar-color") { + // This looks like a Nextcloud calendar response, try to extract from other properties + // Look for title or name attributes in the XML + if let Some(title_start) = xml.find("title=") { + if let Some(title_end) = xml[title_start + 7..].find('"') { + let title_content = &xml[title_start + 7..title_start + 7 + title_end]; + let title = title_content.trim().to_string(); + if !title.is_empty() { + debug!("Found display name in title attribute: {}", title); + return Some(title); + } + } + } + } + + debug!("No display name found in XML response"); + None + } } /// Calendar information from CalDAV server diff --git a/src/nextcloud_import.rs b/src/nextcloud_import.rs new file mode 100644 index 0000000..45eeec0 --- /dev/null +++ b/src/nextcloud_import.rs @@ -0,0 +1,479 @@ +//! Nextcloud Import Engine +//! +//! This module provides the core functionality for importing events from a source +//! CalDAV server (e.g., Zoho) to a Nextcloud server. + +use crate::config::ImportConfig; +use crate::event::Event; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn, debug}; + +/// Import behavior strategies +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ImportBehavior { + /// Skip events that already exist on target + SkipDuplicates, + /// Overwrite existing events with source data + Overwrite, + /// Merge event data (preserve target fields that aren't in source) + Merge, +} + +impl Default for ImportBehavior { + fn default() -> Self { + ImportBehavior::SkipDuplicates + } +} + +impl std::fmt::Display for ImportBehavior { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ImportBehavior::SkipDuplicates => write!(f, "skip_duplicates"), + ImportBehavior::Overwrite => write!(f, "overwrite"), + ImportBehavior::Merge => write!(f, "merge"), + } + } +} + +impl std::str::FromStr for ImportBehavior { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "skip_duplicates" => Ok(ImportBehavior::SkipDuplicates), + "skip-duplicates" => Ok(ImportBehavior::SkipDuplicates), + "overwrite" => Ok(ImportBehavior::Overwrite), + "merge" => Ok(ImportBehavior::Merge), + _ => Err(format!("Invalid import behavior: {}. Valid options: skip_duplicates, overwrite, merge", s)), + } + } +} + +/// Result of importing events +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportResult { + /// Total number of events processed + pub total_events: usize, + /// Number of events successfully imported + pub imported: usize, + /// Number of events skipped (duplicates, etc.) + pub skipped: usize, + /// Number of events that failed to import + pub failed: usize, + /// Details about failed imports + pub errors: Vec, + /// Details about conflicts that were resolved + pub conflicts: Vec, + /// Start time of import process + pub start_time: DateTime, + /// End time of import process + pub end_time: Option>, + /// Target calendar name + pub target_calendar: String, + /// Import behavior used + pub behavior: ImportBehavior, + /// Whether this was a dry run + pub dry_run: bool, +} + +impl ImportResult { + /// Create a new import result + pub fn new(target_calendar: String, behavior: ImportBehavior, dry_run: bool) -> Self { + Self { + total_events: 0, + imported: 0, + skipped: 0, + failed: 0, + errors: Vec::new(), + conflicts: Vec::new(), + start_time: Utc::now(), + end_time: None, + target_calendar, + behavior, + dry_run, + } + } + + /// Mark the import as completed + pub fn complete(&mut self) { + self.end_time = Some(Utc::now()); + } + + /// Get the duration of the import process + pub fn duration(&self) -> Option { + self.end_time.map(|end| end - self.start_time) + } + + /// Get success rate as percentage + pub fn success_rate(&self) -> f64 { + if self.total_events == 0 { + 0.0 + } else { + (self.imported as f64 / self.total_events as f64) * 100.0 + } + } +} + +/// Information about an import error +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportError { + /// Event UID or identifier + pub event_uid: Option, + /// Event summary/title + pub event_summary: Option, + /// Error message + pub message: String, + /// Error type/category + pub error_type: ImportErrorType, + /// Timestamp when error occurred + pub timestamp: DateTime, +} + +/// Types of import errors +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ImportErrorType { + /// Event validation failed + Validation, + /// Network or server error + Network, + /// Authentication error + Authentication, + /// Calendar not found + CalendarNotFound, + /// Event already exists (when not allowed) + EventExists, + /// Invalid iCalendar data + InvalidICalendar, + /// Server quota exceeded + QuotaExceeded, + /// Other error + Other, +} + +/// Information about a conflict that was resolved +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConflictInfo { + /// Event UID + pub event_uid: String, + /// Event summary + pub event_summary: String, + /// Resolution strategy used + pub resolution: ConflictResolution, + /// Source event version (if available) + pub source_version: Option, + /// Target event version (if available) + pub target_version: Option, + /// Timestamp when conflict was resolved + pub timestamp: DateTime, +} + +/// Conflict resolution strategies +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ConflictResolution { + /// Skipped importing the event + Skipped, + /// Overwrote target with source + Overwritten, + /// Merged source and target data + Merged, + /// Used target data (ignored source) + UsedTarget, +} + +/// Main import engine for Nextcloud +pub struct ImportEngine { + /// Import configuration + config: ImportConfig, + /// Import behavior + behavior: ImportBehavior, + /// Whether this is a dry run + dry_run: bool, +} + +impl ImportEngine { + /// Create a new import engine + pub fn new(config: ImportConfig, behavior: ImportBehavior, dry_run: bool) -> Self { + Self { + config, + behavior, + dry_run, + } + } + + /// Import events from source to target calendar + pub async fn import_events(&self, events: Vec) -> Result { + info!("Starting import of {} events", events.len()); + info!("Target calendar: {}", self.config.target_calendar.name); + info!("Import behavior: {}", self.behavior); + info!("Dry run: {}", self.dry_run); + + let mut result = ImportResult::new( + self.config.target_calendar.name.clone(), + self.behavior.clone(), + self.dry_run, + ); + + // Validate events before processing + let validated_events = self.validate_events(&events, &mut result); + result.total_events = validated_events.len(); + + if self.dry_run { + info!("DRY RUN: Would process {} events", result.total_events); + for (i, event) in validated_events.iter().enumerate() { + info!("DRY RUN [{}]: {} ({})", i + 1, event.summary, event.uid); + } + result.imported = validated_events.len(); + result.complete(); + return Ok(result); + } + + // Process each event + for event in validated_events { + match self.process_single_event(&event).await { + Ok(_) => { + result.imported += 1; + debug!("Successfully imported event: {}", event.summary); + } + Err(e) => { + result.failed += 1; + let import_error = ImportError { + event_uid: Some(event.uid.clone()), + event_summary: Some(event.summary.clone()), + message: e.to_string(), + error_type: self.classify_error(&e), + timestamp: Utc::now(), + }; + result.errors.push(import_error); + warn!("Failed to import event {}: {}", event.summary, e); + } + } + } + + result.complete(); + info!("Import completed: {} imported, {} failed, {} skipped", + result.imported, result.failed, result.skipped); + + Ok(result) + } + + /// Validate events for import compatibility + fn validate_events(&self, events: &[Event], result: &mut ImportResult) -> Vec { + let mut validated = Vec::new(); + + for event in events { + match self.validate_event(event) { + Ok(_) => { + validated.push(event.clone()); + } + Err(e) => { + result.failed += 1; + let import_error = ImportError { + event_uid: Some(event.uid.clone()), + event_summary: Some(event.summary.clone()), + message: e.to_string(), + error_type: ImportErrorType::Validation, + timestamp: Utc::now(), + }; + result.errors.push(import_error); + warn!("Event validation failed for {}: {}", event.summary, e); + } + } + } + + validated + } + + /// Validate a single event for Nextcloud compatibility + fn validate_event(&self, event: &Event) -> Result<()> { + // Check required fields + if event.summary.trim().is_empty() { + return Err(anyhow::anyhow!("Event summary cannot be empty")); + } + + if event.uid.trim().is_empty() { + return Err(anyhow::anyhow!("Event UID cannot be empty")); + } + + // Validate datetime + if event.start > event.end { + return Err(anyhow::anyhow!("Event start time must be before end time")); + } + + // Check for reasonable date ranges (not too far in past or future) + let now = Utc::now(); + let one_year_ago = now - chrono::Duration::days(365); + let five_years_future = now + chrono::Duration::days(365 * 5); + + if event.start < one_year_ago { + warn!("Event {} is more than one year in the past", event.summary); + } + + if event.start > five_years_future { + warn!("Event {} is more than five years in the future", event.summary); + } + + Ok(()) + } + + /// Process a single event import + async fn process_single_event(&self, event: &Event) -> Result<()> { + info!("Processing event: {} ({})", event.summary, event.uid); + + // TODO: Implement the actual import logic + // This will involve: + // 1. Check if event already exists on target + // 2. Handle conflicts based on behavior + // 3. Convert event to iCalendar format + // 4. Upload to Nextcloud server + + debug!("Event processing logic not yet implemented - simulating success"); + Ok(()) + } + + /// Classify error type for reporting + fn classify_error(&self, error: &anyhow::Error) -> ImportErrorType { + let error_str = error.to_string().to_lowercase(); + + if error_str.contains("401") || error_str.contains("unauthorized") || error_str.contains("authentication") { + ImportErrorType::Authentication + } else if error_str.contains("404") || error_str.contains("not found") { + ImportErrorType::CalendarNotFound + } else if error_str.contains("409") || error_str.contains("conflict") { + ImportErrorType::EventExists + } else if error_str.contains("network") || error_str.contains("connection") || error_str.contains("timeout") { + ImportErrorType::Network + } else if error_str.contains("ical") || error_str.contains("calendar") || error_str.contains("format") { + ImportErrorType::InvalidICalendar + } else if error_str.contains("quota") || error_str.contains("space") || error_str.contains("limit") { + ImportErrorType::QuotaExceeded + } else if error_str.contains("validation") || error_str.contains("invalid") { + ImportErrorType::Validation + } else { + ImportErrorType::Other + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn create_test_event(uid: &str, summary: &str) -> Event { + Event { + uid: uid.to_string(), + summary: summary.to_string(), + description: None, + start: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2024, 1, 15, 11, 0, 0).unwrap(), + all_day: false, + location: None, + status: crate::event::EventStatus::Confirmed, + event_type: crate::event::EventType::Public, + organizer: None, + attendees: Vec::new(), + recurrence: None, + alarms: Vec::new(), + properties: std::collections::HashMap::new(), + created: Utc::now(), + last_modified: Utc::now(), + sequence: 0, + timezone: Some("UTC".to_string()), + } + } + + #[test] + fn test_import_behavior_from_str() { + assert!(matches!("skip_duplicates".parse::(), Ok(ImportBehavior::SkipDuplicates))); + assert!(matches!("overwrite".parse::(), Ok(ImportBehavior::Overwrite))); + assert!(matches!("merge".parse::(), Ok(ImportBehavior::Merge))); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_import_behavior_display() { + assert_eq!(ImportBehavior::SkipDuplicates.to_string(), "skip_duplicates"); + assert_eq!(ImportBehavior::Overwrite.to_string(), "overwrite"); + assert_eq!(ImportBehavior::Merge.to_string(), "merge"); + } + + #[test] + fn test_event_validation() { + let config = ImportConfig { + target_server: crate::config::ImportTargetServerConfig { + url: "https://example.com".to_string(), + username: "test".to_string(), + password: "test".to_string(), + use_https: true, + timeout: 30, + headers: None, + }, + target_calendar: crate::config::ImportTargetCalendarConfig { + name: "test".to_string(), + display_name: None, + color: None, + timezone: None, + enabled: true, + }, + }; + + let engine = ImportEngine::new(config, ImportBehavior::SkipDuplicates, false); + + // Valid event should pass + let valid_event = create_test_event("test-uid", "Test Event"); + assert!(engine.validate_event(&valid_event).is_ok()); + + // Empty summary should fail + let mut invalid_event = create_test_event("test-uid", ""); + assert!(engine.validate_event(&invalid_event).is_err()); + + // Empty UID should fail + invalid_event.summary = "Test Event".to_string(); + invalid_event.uid = "".to_string(); + assert!(engine.validate_event(&invalid_event).is_err()); + + // Start after end should fail + let mut invalid_event = create_test_event("test-uid", "Test Event"); + invalid_event.start = Utc.with_ymd_and_hms(2024, 1, 15, 11, 0, 0).unwrap(); + invalid_event.end = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(); + assert!(engine.validate_event(&invalid_event).is_err()); + } + + #[tokio::test] + async fn test_import_dry_run() { + let config = ImportConfig { + target_server: crate::config::ImportTargetServerConfig { + url: "https://example.com".to_string(), + username: "test".to_string(), + password: "test".to_string(), + use_https: true, + timeout: 30, + headers: None, + }, + target_calendar: crate::config::ImportTargetCalendarConfig { + name: "test-calendar".to_string(), + display_name: None, + color: None, + timezone: None, + enabled: true, + }, + }; + + let engine = ImportEngine::new(config, ImportBehavior::SkipDuplicates, true); + let events = vec![ + create_test_event("event-1", "Event 1"), + create_test_event("event-2", "Event 2"), + ]; + + let result = engine.import_events(events).await.unwrap(); + + assert!(result.dry_run); + assert_eq!(result.total_events, 2); + assert_eq!(result.imported, 2); + assert_eq!(result.failed, 0); + assert_eq!(result.skipped, 0); + assert!(result.duration().is_some()); + } +} diff --git a/src/real_caldav_client.rs b/src/real_caldav_client.rs deleted file mode 100644 index be756cc..0000000 --- a/src/real_caldav_client.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Real CalDAV client implementation using libdav library - -use anyhow::Result; -use libdav::{auth::Auth, dav::WebDavClient, CalDavClient}; -use http::Uri; -use serde::{Deserialize, Serialize}; -use chrono::{DateTime, Utc}; -use crate::error::CalDavError; -use tracing::{debug, info, warn, error}; - -/// Real CalDAV client using libdav library -pub struct RealCalDavClient { - client: CalDavClient, - 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 { - info!("Creating CalDAV client for: {}", base_url); - - // Parse the base URL - let uri: Uri = base_url.parse() - .map_err(|e| CalDavError::Config(format!("Invalid URL: {}", e)))?; - - // Create authentication - let auth = Auth::Basic(username.to_string(), password.to_string()); - - // Create WebDav client first - let webdav = WebDavClient::builder() - .set_uri(uri) - .set_auth(auth) - .build() - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to create WebDAV client: {}", e)))?; - - // Convert to CalDav client - let client = CalDavClient::new(webdav); - - debug!("CalDAV client created successfully"); - - Ok(Self { - client, - base_url: base_url.to_string(), - username: username.to_string(), - }) - } - - /// Discover calendars on the server - pub async fn discover_calendars(&self) -> Result> { - info!("Discovering calendars for user: {}", self.username); - - // Get the calendar home set - let calendar_home_set = self.client.calendar_home_set().await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to get calendar home set: {}", e)))?; - - debug!("Calendar home set: {:?}", calendar_home_set); - - // List calendars - let calendars = self.client.list_calendars().await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to list calendars: {}", e)))?; - - info!("Found {} calendars", calendars.len()); - - let mut calendar_infos = Vec::new(); - for (href, calendar) in calendars { - info!("Calendar: {} - {}", href, calendar.display_name().unwrap_or("Unnamed")); - - let calendar_info = CalendarInfo { - url: href.to_string(), - name: calendar.display_name().unwrap_or_else(|| { - // Extract name from URL if no display name - href.split('/').last().unwrap_or("unknown").to_string() - }), - display_name: calendar.display_name().map(|s| s.to_string()), - color: calendar.color().map(|s| s.to_string()), - description: calendar.description().map(|s| s.to_string()), - timezone: calendar.calendar_timezone().map(|s| s.to_string()), - supported_components: calendar.supported_components().to_vec(), - }; - - calendar_infos.push(calendar_info); - } - - Ok(calendar_infos) - } - - /// Get events from a specific calendar - pub async fn get_events(&self, calendar_href: &str, start_date: DateTime, end_date: DateTime) -> Result> { - info!("Getting events from calendar: {} between {} and {}", - calendar_href, start_date, end_date); - - // Get events for the time range - let events = self.client - .get_event_instances(calendar_href, start_date, end_date) - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to get events: {}", e)))?; - - info!("Found {} events", events.len()); - - let mut calendar_events = Vec::new(); - for (href, event) in events { - debug!("Event: {} - {}", href, event.summary().unwrap_or("Untitled")); - - // Convert libdav event to our format - let calendar_event = CalendarEvent { - id: self.extract_event_id(&href), - href: href.to_string(), - summary: event.summary().unwrap_or("Untitled").to_string(), - description: event.description().map(|s| s.to_string()), - start: event.start().unwrap_or(&chrono::Utc::now()).clone(), - end: event.end().unwrap_or(&chrono::Utc::now()).clone(), - location: event.location().map(|s| s.to_string()), - status: event.status().map(|s| s.to_string()), - created: event.created().copied(), - last_modified: event.last_modified().copied(), - sequence: event.sequence(), - transparency: event.transparency().map(|s| s.to_string()), - uid: event.uid().map(|s| s.to_string()), - recurrence_id: event.recurrence_id().cloned(), - }; - - calendar_events.push(calendar_event); - } - - Ok(calendar_events) - } - - /// Create an event in the calendar - pub async fn create_event(&self, calendar_href: &str, event: &CalendarEvent) -> Result<()> { - info!("Creating event: {} in calendar: {}", event.summary, calendar_href); - - // Convert our event format to libdav's format - let mut ical_event = icalendar::Event::new(); - ical_event.summary(&event.summary); - ical_event.start(&event.start); - ical_event.end(&event.end); - - if let Some(description) = &event.description { - ical_event.description(description); - } - - if let Some(location) = &event.location { - ical_event.location(location); - } - - if let Some(uid) = &event.uid { - ical_event.uid(uid); - } else { - ical_event.uid(&event.id); - } - - if let Some(status) = &event.status { - ical_event.status(status); - } - - // Create iCalendar component - let mut calendar = icalendar::Calendar::new(); - calendar.push(ical_event); - - // Generate iCalendar string - let ical_str = calendar.to_string(); - - // Create event on server - let event_href = format!("{}/{}.ics", calendar_href.trim_end_matches('/'), event.id); - - self.client - .create_resource(&event_href, ical_str.as_bytes()) - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to create event: {}", e)))?; - - info!("Event created successfully: {}", event_href); - Ok(()) - } - - /// Update an existing event - pub async fn update_event(&self, event_href: &str, event: &CalendarEvent) -> Result<()> { - info!("Updating event: {} at {}", event.summary, event_href); - - // Convert to iCalendar format (similar to create_event) - let mut ical_event = icalendar::Event::new(); - ical_event.summary(&event.summary); - ical_event.start(&event.start); - ical_event.end(&event.end); - - if let Some(description) = &event.description { - ical_event.description(description); - } - - if let Some(location) = &event.location { - ical_event.location(location); - } - - if let Some(uid) = &event.uid { - ical_event.uid(uid); - } - - if let Some(status) = &event.status { - ical_event.status(status); - } - - // Update sequence number - ical_event.add_property("SEQUENCE", &event.sequence.to_string()); - - let mut calendar = icalendar::Calendar::new(); - calendar.push(ical_event); - - let ical_str = calendar.to_string(); - - // Update event on server - self.client - .update_resource(event_href, ical_str.as_bytes()) - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to update event: {}", e)))?; - - info!("Event updated successfully: {}", event_href); - Ok(()) - } - - /// Delete an event - pub async fn delete_event(&self, event_href: &str) -> Result<()> { - info!("Deleting event: {}", event_href); - - self.client - .delete_resource(event_href) - .await - .map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to delete event: {}", e)))?; - - info!("Event deleted successfully: {}", event_href); - Ok(()) - } - - /// Extract event ID from href - fn extract_event_id(&self, href: &str) -> String { - href.split('/') - .last() - .and_then(|s| s.strip_suffix(".ics")) - .unwrap_or("unknown") - .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, - pub color: Option, - pub description: Option, - pub timezone: Option, - pub supported_components: Vec, -} - -/// Calendar event from CalDAV server -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CalendarEvent { - pub id: String, - pub href: String, - pub summary: String, - pub description: Option, - pub start: DateTime, - pub end: DateTime, - pub location: Option, - pub status: Option, - pub created: Option>, - pub last_modified: Option>, - pub sequence: i32, - pub transparency: Option, - pub uid: Option, - pub recurrence_id: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - - #[test] - fn test_extract_event_id() { - let client = RealCalDavClient { - client: unsafe { std::mem::zeroed() }, // Not used in test - base_url: "https://example.com".to_string(), - username: "test".to_string(), - }; - - assert_eq!(client.extract_event_id("/calendar/event123.ics"), "event123"); - assert_eq!(client.extract_event_id("/calendar/path/event456.ics"), "event456"); - assert_eq!(client.extract_event_id("event789.ics"), "event789"); - assert_eq!(client.extract_event_id("no_extension"), "no_extension"); - } -} From 932b6ae463bb80b488a99d0fa1db3abfa8d65f0a Mon Sep 17 00:00:00 2001 From: Alvaro Soliverez Date: Fri, 21 Nov 2025 11:56:27 -0300 Subject: [PATCH 4/4] Fix timezone handling and update detection - Fix timezone preservation in to_ical_simple() for import module - Add timezone comparison to needs_update() method to detect timezone differences - Add comprehensive test for timezone comparison logic - Log Bug #3: recurring event end detection issue for future investigation --- TODO.md | 29 ++ src/event.rs | 754 ++++++++++++++++++++++++++++++++++++--- src/main.rs | 372 ++++++++++++++++++- src/minicaldav_client.rs | 102 +++--- src/nextcloud_import.rs | 53 +-- 5 files changed, 1178 insertions(+), 132 deletions(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..930f1e1 --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ +# TODO - CalDAV Sync Tool + +## šŸ› Known Issues + +### Bug #3: Recurring Event End Detection +**Status**: Identified +**Priority**: Medium +**Description**: System not properly handling when recurring events have ended, causing duplicates in target calendar + +**Issue**: When recurring events have ended (passed their UNTIL date or COUNT limit), the system may still be creating occurrences or not properly cleaning up old occurrences, leading to duplicate events in the target calendar. + +**Files to investigate**: +- `src/event.rs` - `expand_occurrences()` method +- `src/nextcloud_import.rs` - import and cleanup logic +- Date range calculations for event fetching + +## āœ… Completed + +- [x] Fix timezone preservation in expanded recurring events +- [x] Fix timezone-aware iCal generation for import module +- [x] Fix timezone comparison in `needs_update()` method +- [x] Fix RRULE BYDAY filtering for daily frequency events + +## šŸ”§ Future Tasks + +- [ ] Investigate other timezone issues if they exist +- [ ] Cleanup debug logging +- [ ] Add comprehensive tests for timezone handling +- [ ] Consider adding timezone conversion utilities diff --git a/src/event.rs b/src/event.rs index 7dde9a5..fcc3004 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,10 +1,15 @@ //! Event handling and iCalendar parsing use crate::error::CalDavResult; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Utc, Datelike, Timelike}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use uuid::Uuid; +use md5; + +// RRULE support (simplified for now) +// use rrule::{RRuleSet, RRule, Frequency, Weekday as RRuleWeekday, NWeekday, Tz}; +// use std::str::FromStr; /// Calendar event representation #[derive(Debug, Clone, Serialize, Deserialize)] @@ -111,47 +116,107 @@ pub enum ParticipationStatus { Delegated, } -/// Recurrence rule +/// Recurrence rule (simplified RRULE string representation) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecurrenceRule { - /// Frequency - pub frequency: RecurrenceFrequency, - /// Interval - pub interval: u32, - /// Count (number of occurrences) - pub count: Option, - /// Until date - pub until: Option>, - /// Days of week - pub by_day: Option>, - /// Days of month - pub by_month_day: Option>, - /// Months - pub by_month: Option>, + /// Original RRULE string for storage and parsing + pub original_rule: String, } -/// Recurrence frequency -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum RecurrenceFrequency { - Secondly, - Minutely, - Hourly, - Daily, - Weekly, - Monthly, - Yearly, -} - -/// Day of week for recurrence -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum WeekDay { - Sunday, - Monday, - Tuesday, - Wednesday, - Thursday, - Friday, - Saturday, +impl RecurrenceRule { + /// Create a new RecurrenceRule from an RRULE string + pub fn from_str(rrule_str: &str) -> Result> { + Ok(RecurrenceRule { + original_rule: rrule_str.to_string(), + }) + } + + /// Get the RRULE string + pub fn as_str(&self) -> &str { + &self.original_rule + } + + /// Parse RRULE components from the original_rule string + fn parse_components(&self) -> std::collections::HashMap { + let mut components = std::collections::HashMap::new(); + + for part in self.original_rule.split(';') { + if let Some((key, value)) = part.split_once('=') { + components.insert(key.to_uppercase(), value.to_string()); + } + } + + components + } + + /// Get the frequency (FREQ) component + pub fn frequency(&self) -> String { + self.parse_components() + .get("FREQ") + .cloned() + .unwrap_or_else(|| "DAILY".to_string()) + } + + /// Get the interval (INTERVAL) component + pub fn interval(&self) -> i32 { + self.parse_components() + .get("INTERVAL") + .and_then(|s| s.parse().ok()) + .unwrap_or(1) + } + + /// Get the count (COUNT) component + pub fn count(&self) -> Option { + self.parse_components() + .get("COUNT") + .and_then(|s| s.parse().ok()) + } + + /// Get the until date (UNTIL) component + pub fn until(&self) -> Option> { + self.parse_components() + .get("UNTIL") + .and_then(|s| { + // Try parsing as different date formats + + // Format 1: YYYYMMDD (8 characters) + if s.len() == 8 { + return DateTime::parse_from_str(&format!("{}T000000Z", s), "%Y%m%dT%H%M%SZ") + .ok() + .map(|dt| dt.with_timezone(&Utc)); + } + + // Format 2: Basic iCalendar datetime with Z: YYYYMMDDTHHMMSSZ (15 or 16 characters) + if s.ends_with('Z') && (s.len() == 15 || s.len() == 16) { + let cleaned = s.trim_end_matches('Z'); + if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(cleaned, "%Y%m%dT%H%M%S") { + return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc)); + } + } + + // Format 3: Basic iCalendar datetime without Z: YYYYMMDDTHHMMSS (15 characters) + if s.len() == 15 && s.contains('T') { + if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S") { + return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc)); + } + } + + // Format 4: Try RFC3339 format + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(dt.with_timezone(&Utc)); + } + + None + }) + } + + /// Get the BYDAY component + pub fn by_day(&self) -> Vec { + self.parse_components() + .get("BYDAY") + .map(|s| s.split(',').map(|s| s.to_string()).collect()) + .unwrap_or_default() + } } /// Event alarm/reminder @@ -188,6 +253,38 @@ pub enum AlarmTrigger { Absolute(DateTime), } +impl std::fmt::Display for AlarmAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AlarmAction::Display => write!(f, "DISPLAY"), + AlarmAction::Email => write!(f, "EMAIL"), + AlarmAction::Audio => write!(f, "AUDIO"), + } + } +} + +impl std::fmt::Display for AlarmTrigger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AlarmTrigger::BeforeStart(duration) => { + let total_seconds = duration.num_seconds(); + write!(f, "-P{}S", total_seconds.abs()) + } + AlarmTrigger::AfterStart(duration) => { + let total_seconds = duration.num_seconds(); + write!(f, "P{}S", total_seconds) + } + AlarmTrigger::BeforeEnd(duration) => { + let total_seconds = duration.num_seconds(); + write!(f, "-P{}S", total_seconds) + } + AlarmTrigger::Absolute(datetime) => { + write!(f, "{}", datetime.format("%Y%m%dT%H%M%SZ")) + } + } + } +} + impl Event { /// Create a new event pub fn new(summary: String, start: DateTime, end: DateTime) -> Self { @@ -274,17 +371,27 @@ impl Event { ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); } - // Dates + // Dates with timezone preservation if self.all_day { ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", self.start.format("%Y%m%d"))); ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", self.end.format("%Y%m%d"))); } else { - ical.push_str(&format!("DTSTART:{}\r\n", - self.start.format("%Y%m%dT%H%M%SZ"))); - ical.push_str(&format!("DTEND:{}\r\n", - self.end.format("%Y%m%dT%H%M%SZ"))); + // Check if we have timezone information + if let Some(ref tzid) = self.timezone { + // Use timezone-aware format + ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", + tzid, self.start.format("%Y%m%dT%H%M%S"))); + ical.push_str(&format!("DTEND;TZID={}:{}\r\n", + tzid, self.end.format("%Y%m%dT%H%M%S"))); + } else { + // Fall back to UTC format + ical.push_str(&format!("DTSTART:{}\r\n", + self.start.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("DTEND:{}\r\n", + self.end.format("%Y%m%dT%H%M%SZ"))); + } } // Status @@ -334,6 +441,190 @@ impl Event { self.sequence += 1; } + /// Generate simplified iCalendar format optimized for Nextcloud import + /// This creates clean, individual .ics files that avoid Zoho parsing issues + pub fn to_ical_simple(&self) -> CalDavResult { + let mut ical = String::new(); + + // iCalendar header - minimal and clean + ical.push_str("BEGIN:VCALENDAR\r\n"); + ical.push_str("VERSION:2.0\r\n"); + ical.push_str("PRODID:-//caldav-sync//simple-import//EN\r\n"); + ical.push_str("CALSCALE:GREGORIAN\r\n"); + + // VEVENT header + ical.push_str("BEGIN:VEVENT\r\n"); + + // Required properties - only the essentials for Nextcloud + ical.push_str(&format!("UID:{}\r\n", escape_ical_text(&self.uid))); + ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary))); + + // Simplified datetime handling - timezone-aware for compatibility + if self.all_day { + ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", + self.start.format("%Y%m%d"))); + ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", + self.end.format("%Y%m%d"))); + } else { + // Use timezone-aware format when available, fall back to UTC + if let Some(ref tzid) = self.timezone { + // Use timezone-aware format + ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", + tzid, self.start.format("%Y%m%dT%H%M%S"))); + ical.push_str(&format!("DTEND;TZID={}:{}\r\n", + tzid, self.end.format("%Y%m%dT%H%M%S"))); + } else { + // Fall back to UTC format for maximum compatibility + ical.push_str(&format!("DTSTART:{}\r\n", + self.start.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("DTEND:{}\r\n", + self.end.format("%Y%m%dT%H%M%SZ"))); + } + } + + // Required timestamps + ical.push_str(&format!("DTSTAMP:{}\r\n", Utc::now().format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence)); + + // Basic status - always confirmed for simplicity + ical.push_str("STATUS:CONFIRMED\r\n"); + ical.push_str("CLASS:PUBLIC\r\n"); + + // VEVENT and VCALENDAR footers + ical.push_str("END:VEVENT\r\n"); + ical.push_str("END:VCALENDAR\r\n"); + + Ok(ical) + } + + /// Generate iCalendar format optimized for Nextcloud + pub fn to_ical_for_nextcloud(&self) -> CalDavResult { + let mut ical = String::new(); + + // iCalendar header with Nextcloud-specific properties + ical.push_str("BEGIN:VCALENDAR\r\n"); + ical.push_str("VERSION:2.0\r\n"); + ical.push_str("PRODID:-//caldav-sync//caldav-sync 0.1.0//EN\r\n"); + ical.push_str("CALSCALE:GREGORIAN\r\n"); + + // Add timezone information if available + if let Some(tzid) = &self.timezone { + ical.push_str(&format!("X-WR-TIMEZONE:{}\r\n", tzid)); + } + + // VEVENT header + ical.push_str("BEGIN:VEVENT\r\n"); + + // Required properties + ical.push_str(&format!("UID:{}\r\n", escape_ical_text(&self.uid))); + ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary))); + + // Enhanced datetime handling with timezone support + if self.all_day { + ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", + self.start.format("%Y%m%d"))); + ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", + (self.end.date_naive() + chrono::Duration::days(1)).format("%Y%m%d"))); + } else { + if let Some(tzid) = &self.timezone { + // Use timezone-specific format + ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", + tzid, self.start.format("%Y%m%dT%H%M%S"))); + ical.push_str(&format!("DTEND;TZID={}:{}\r\n", + tzid, self.end.format("%Y%m%dT%H%M%S"))); + } else { + // Use UTC format + ical.push_str(&format!("DTSTART:{}\r\n", + self.start.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("DTEND:{}\r\n", + self.end.format("%Y%m%dT%H%M%SZ"))); + } + } + + // Required timestamps + ical.push_str(&format!("DTSTAMP:{}\r\n", Utc::now().format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ"))); + ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence)); + + // Optional properties + if let Some(description) = &self.description { + ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); + } + + if let Some(location) = &self.location { + ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location))); + } + + // Status mapping + ical.push_str(&format!("STATUS:{}\r\n", match self.status { + EventStatus::Confirmed => "CONFIRMED", + EventStatus::Tentative => "TENTATIVE", + EventStatus::Cancelled => "CANCELLED", + })); + + // Class (visibility) + ical.push_str(&format!("CLASS:{}\r\n", match self.event_type { + EventType::Public => "PUBLIC", + EventType::Private => "PRIVATE", + EventType::Confidential => "CONFIDENTIAL", + })); + + // Organizer and attendees + if let Some(organizer) = &self.organizer { + if let Some(name) = &organizer.name { + ical.push_str(&format!("ORGANIZER;CN={}:mailto:{}\r\n", + escape_ical_text(name), organizer.email)); + } else { + ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email)); + } + } + + for attendee in &self.attendees { + let mut attendee_line = String::from("ATTENDEE"); + + if let Some(name) = &attendee.name { + attendee_line.push_str(&format!(";CN={}", escape_ical_text(name))); + } + + attendee_line.push_str(&format!(":mailto:{}", attendee.email)); + attendee_line.push_str("\r\n"); + + ical.push_str(&attendee_line); + } + + // Alarms/reminders + for alarm in &self.alarms { + ical.push_str(&format!("BEGIN:VALARM\r\n")); + ical.push_str(&format!("ACTION:{}\r\n", alarm.action)); + ical.push_str(&format!("TRIGGER:{}\r\n", alarm.trigger)); + if let Some(description) = &alarm.description { + ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); + } + ical.push_str("END:VALARM\r\n"); + } + + // Custom properties (including Nextcloud-specific ones) + for (key, value) in &self.properties { + if key.starts_with("X-") { + ical.push_str(&format!("{}:{}\r\n", key, escape_ical_text(value))); + } + } + + // VEVENT and VCALENDAR footers + ical.push_str("END:VEVENT\r\n"); + ical.push_str("END:VCALENDAR\r\n"); + + Ok(ical) + } + + /// Generate the CalDAV path for this event + pub fn generate_caldav_path(&self) -> String { + format!("{}.ics", self.uid) + } + /// Check if event occurs on a specific date pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool { let start_date = self.start.date_naive(); @@ -356,6 +647,301 @@ impl Event { let now = Utc::now(); now >= self.start && now <= self.end } + + /// Check if this event needs updating compared to another event + pub fn needs_update(&self, other: &Event) -> bool { + // Compare essential fields + if self.summary != other.summary { + return true; + } + + if self.description != other.description { + return true; + } + + if self.location != other.location { + return true; + } + + // Compare timezone information - this is crucial for detecting timezone mangling fixes + match (&self.timezone, &other.timezone) { + (None, None) => { + // Both have no timezone - continue with other checks + } + (Some(tz1), Some(tz2)) => { + // Both have timezone - compare them + if tz1 != tz2 { + return true; + } + } + (Some(_), None) | (None, Some(_)) => { + // One has timezone, other doesn't - definitely needs update + return true; + } + } + + // Compare dates with some tolerance for timestamp differences + let start_diff = (self.start - other.start).num_seconds().abs(); + let end_diff = (self.end - other.end).num_seconds().abs(); + + if start_diff > 60 || end_diff > 60 { // 1 minute tolerance + return true; + } + + // Compare status and event type + if self.status != other.status { + return true; + } + + if self.event_type != other.event_type { + return true; + } + + // Compare sequence numbers - higher sequence means newer + if self.sequence > other.sequence { + return true; + } + + false + } + + /// Validate event for CalDAV import compatibility + pub fn validate_for_import(&self) -> Result<(), String> { + // Check required fields + if self.uid.trim().is_empty() { + return Err("Event UID cannot be empty".to_string()); + } + + if self.summary.trim().is_empty() { + return Err("Event summary cannot be empty".to_string()); + } + + // Validate datetime + if self.start > self.end { + return Err("Event start time must be before end time".to_string()); + } + + // Check for reasonable date ranges + let now = Utc::now(); + let one_year_ago = now - chrono::Duration::days(365); + let ten_years_future = now + chrono::Duration::days(365 * 10); + + if self.start < one_year_ago { + return Err("Event start time is more than one year in the past".to_string()); + } + + if self.start > ten_years_future { + return Err("Event start time is more than ten years in the future".to_string()); + } + + Ok(()) + } + + /// Simple recurrence expansion for basic RRULE strings + pub fn expand_occurrences(&self, start_range: DateTime, end_range: DateTime) -> Vec { + // If this is not a recurring event, return just this event + if self.recurrence.is_none() { + return vec![self.clone()]; + } + + let mut occurrences = Vec::new(); + let recurrence_rule = self.recurrence.as_ref().unwrap(); + + // For now, implement a very basic RRULE expansion using simple date arithmetic + let mut current_start = self.start; + let event_duration = self.duration(); + let mut occurrence_count = 0; + + // Limit occurrences to prevent infinite loops + let max_occurrences = recurrence_rule.count().unwrap_or(1000).min(1000); + + while current_start <= end_range && occurrence_count < max_occurrences { + // Check if we've reached the count limit + if let Some(count) = recurrence_rule.count() { + if occurrence_count >= count { + break; + } + } + + // Check if we've reached the until limit + if let Some(until) = recurrence_rule.until() { + if current_start > until { + break; + } + } + + // Check if this occurrence falls within our desired range + if current_start >= start_range && current_start <= end_range { + let mut occurrence = self.clone(); + occurrence.start = current_start; + occurrence.end = current_start + event_duration; + + // Create a unique UID for this occurrence + let occurrence_date = current_start.format("%Y%m%d").to_string(); + // Include a hash of the original event details to ensure uniqueness across different recurring series + let series_identifier = format!("{:x}", md5::compute(format!("{}-{}", self.uid, self.summary))); + occurrence.uid = format!("{}-occurrence-{}-{}", series_identifier, occurrence_date, self.uid); + + // Clear the recurrence rule for individual occurrences + occurrence.recurrence = None; + + // Update creation and modification times + occurrence.created = Utc::now(); + occurrence.last_modified = Utc::now(); + + occurrences.push(occurrence); + } + + // Calculate next occurrence based on RRULE components + let interval = recurrence_rule.interval() as i64; + current_start = match recurrence_rule.frequency().to_lowercase().as_str() { + "daily" => { + // For daily frequency, check if there are BYDAY restrictions + let by_day = recurrence_rule.by_day(); + if !by_day.is_empty() { + // Find the next valid weekday for DAILY frequency with BYDAY restriction + let mut next_day = current_start + chrono::Duration::days(1); + let mut days_checked = 0; + + // Search for up to 7 days to find the next valid weekday + while days_checked < 7 { + let weekday = match next_day.weekday().number_from_monday() { + 1 => "MO", + 2 => "TU", + 3 => "WE", + 4 => "TH", + 5 => "FR", + 6 => "SA", + 7 => "SU", + _ => "MO", // fallback + }; + + if by_day.contains(&weekday.to_string()) { + // Found the next valid weekday + break; + } + + next_day = next_day + chrono::Duration::days(1); + days_checked += 1; + } + + next_day + } else { + // No BYDAY restriction, just add days normally + current_start + chrono::Duration::days(interval) + } + }, + "weekly" => { + // For weekly frequency, we need to handle BYDAY filtering + let by_day = recurrence_rule.by_day(); + if !by_day.is_empty() { + // Find the next valid weekday + let mut next_day = current_start + chrono::Duration::days(1); + let mut days_checked = 0; + + // Search for up to 7 days (one week) to find the next valid weekday + while days_checked < 7 { + let weekday = match next_day.weekday().number_from_monday() { + 1 => "MO", + 2 => "TU", + 3 => "WE", + 4 => "TH", + 5 => "FR", + 6 => "SA", + 7 => "SU", + _ => "MO", // fallback + }; + + if by_day.contains(&weekday.to_string()) { + // Found the next valid weekday + break; + } + + next_day = next_day + chrono::Duration::days(1); + days_checked += 1; + } + + next_day + } else { + // No BYDAY restriction, just add weeks + current_start + chrono::Duration::weeks(interval) + } + }, + "monthly" => add_months(current_start, interval as u32), + "yearly" => add_months(current_start, (interval * 12) as u32), + "hourly" => current_start + chrono::Duration::hours(interval), + "minutely" => current_start + chrono::Duration::minutes(interval), + "secondly" => current_start + chrono::Duration::seconds(interval), + _ => current_start + chrono::Duration::days(interval), // Default to daily + }; + + occurrence_count += 1; + } + + tracing::info!( + "šŸ”„ Expanded recurring event '{}' to {} occurrences between {} and {}", + self.summary, + occurrences.len(), + start_range.format("%Y-%m-%d"), + end_range.format("%Y-%m-%d") + ); + + occurrences + } +} + + + +/// Add months to a DateTime (approximate handling) +fn add_months(dt: DateTime, months: u32) -> DateTime { + let naive_date = dt.naive_utc(); + let year = naive_date.year(); + let month = naive_date.month() as i32 + months as i32; + let new_year = year + (month - 1) / 12; + let new_month = ((month - 1) % 12) + 1; + + // Keep the same day if possible, otherwise use the last day of the month + let day = naive_date.day().min(days_in_month(new_year as i32, new_month as u32)); + + // Try to create the new date with the same time, fallback to first day of month if invalid + if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, day) { + if let Some(new_naive_dt) = new_naive_date.and_hms_opt(naive_date.hour(), naive_date.minute(), naive_date.second()) { + return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc); + } + } + + // Fallback: use first day of the month with the same time + if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, 1) { + if let Some(new_naive_dt) = new_naive_date.and_hms_opt(naive_date.hour(), naive_date.minute(), naive_date.second()) { + return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc); + } + } + + // Ultimate fallback: use start of the month + if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, 1) { + if let Some(new_naive_dt) = new_naive_date.and_hms_opt(0, 0, 0) { + return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc); + } + } + + // If all else fails, return the original date + dt +} + +/// Get the number of days in a month +fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) { + 29 + } else { + 28 + } + } + _ => 30, // Should never happen + } } /// Escape text for iCalendar format @@ -464,4 +1050,86 @@ mod tests { let local_result = parse_ical_datetime("20231225T103000"); assert!(local_result.is_err()); } + + #[test] + fn test_event_to_ical_with_timezone() { + let start = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + let end = start + chrono::Duration::minutes(30); + + let mut event = Event::new("Tether Sync".to_string(), start, end); + event.timezone = Some("America/Toronto".to_string()); + + let ical = event.to_ical().unwrap(); + + // Should include timezone information + assert!(ical.contains("DTSTART;TZID=America/Toronto:20231225T083000")); + assert!(ical.contains("DTEND;TZID=America/Toronto:20231225T090000")); + assert!(ical.contains("SUMMARY:Tether Sync")); + } + + #[test] + fn test_event_to_ical_without_timezone() { + let start = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + let end = start + chrono::Duration::minutes(30); + + let event = Event::new("UTC Event".to_string(), start, end); + + let ical = event.to_ical().unwrap(); + + // Should use UTC format when no timezone is specified + assert!(ical.contains("DTSTART:20231225T083000Z")); + assert!(ical.contains("DTEND:20231225T090000Z")); + assert!(ical.contains("SUMMARY:UTC Event")); + } + + #[test] + fn test_needs_update_timezone_comparison() { + let start = DateTime::from_naive_utc_and_offset( + chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(), + Utc + ); + let end = start + chrono::Duration::minutes(30); + + // Test case 1: Event with timezone vs event without timezone (should need update) + let mut event_with_tz = Event::new("Test Event".to_string(), start, end); + event_with_tz.timezone = Some("America/Toronto".to_string()); + + let event_without_tz = Event::new("Test Event".to_string(), start, end); + + assert!(event_with_tz.needs_update(&event_without_tz)); + assert!(event_without_tz.needs_update(&event_with_tz)); + + // Test case 2: Events with different timezones (should need update) + let mut event_tz1 = Event::new("Test Event".to_string(), start, end); + event_tz1.timezone = Some("America/Toronto".to_string()); + + let mut event_tz2 = Event::new("Test Event".to_string(), start, end); + event_tz2.timezone = Some("Europe/Athens".to_string()); + + assert!(event_tz1.needs_update(&event_tz2)); + assert!(event_tz2.needs_update(&event_tz1)); + + // Test case 3: Events with same timezone (should not need update) + let mut event_tz3 = Event::new("Test Event".to_string(), start, end); + event_tz3.timezone = Some("America/Toronto".to_string()); + + let mut event_tz4 = Event::new("Test Event".to_string(), start, end); + event_tz4.timezone = Some("America/Toronto".to_string()); + + assert!(!event_tz3.needs_update(&event_tz4)); + assert!(!event_tz4.needs_update(&event_tz3)); + + // Test case 4: Both events without timezone (should not need update) + let event_no_tz1 = Event::new("Test Event".to_string(), start, end); + let event_no_tz2 = Event::new("Test Event".to_string(), start, end); + + assert!(!event_no_tz1.needs_update(&event_no_tz2)); + assert!(!event_no_tz2.needs_update(&event_no_tz1)); + } } diff --git a/src/main.rs b/src/main.rs index daa2054..2d8bc9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,11 @@ use tracing::{info, warn, error, Level}; use tracing_subscriber; use caldav_sync::{Config, CalDavResult, SyncEngine}; use caldav_sync::nextcloud_import::{ImportEngine, ImportBehavior}; +use caldav_sync::minicaldav_client::CalendarEvent; use std::path::PathBuf; use chrono::{Utc, Duration}; + #[derive(Parser)] #[command(name = "caldav-sync")] #[command(about = "A CalDAV calendar synchronization tool")] @@ -72,13 +74,17 @@ struct Cli { #[arg(long)] nextcloud_calendar: Option, - /// Import behavior: skip_duplicates, overwrite, merge - #[arg(long, default_value = "skip_duplicates")] + /// Import behavior: strict, strict_with_cleanup + #[arg(long, default_value = "strict")] import_behavior: String, /// Dry run - show what would be imported without actually doing it #[arg(long)] dry_run: bool, + + /// List events from import target calendar and exit + #[arg(long)] + list_import_events: bool, } #[tokio::main] @@ -600,11 +606,371 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { return Ok(()); } + // Handle listing events from import target calendar + if cli.list_import_events { + info!("Listing events from import target calendar"); + + // Validate import configuration + let import_config = match config.get_import_config() { + Some(config) => config, + None => { + error!("No import target configured. Please add [import] section to config.toml"); + return Err(anyhow::anyhow!("Import configuration not found").into()); + } + }; + + // Override target calendar if specified via CLI + let target_calendar_name = cli.nextcloud_calendar.as_ref() + .unwrap_or(&import_config.target_calendar.name); + + println!("šŸ“… Events from Import Target Calendar"); + println!("====================================="); + println!("Target Server: {}", import_config.target_server.url); + println!("Target Calendar: {}\n", target_calendar_name); + + // Create a temporary config for the target server + let mut target_config = config.clone(); + target_config.server.url = import_config.target_server.url.clone(); + target_config.server.username = import_config.target_server.username.clone(); + target_config.server.password = import_config.target_server.password.clone(); + target_config.server.timeout = import_config.target_server.timeout; + target_config.server.use_https = import_config.target_server.use_https; + target_config.server.headers = import_config.target_server.headers.clone(); + target_config.calendar.name = target_calendar_name.clone(); + + // Connect to target server + let target_sync_engine = match SyncEngine::new(target_config).await { + Ok(engine) => engine, + Err(e) => { + error!("Failed to connect to target server: {}", e); + println!("āŒ Failed to connect to target server: {}", e); + println!("Please check your import configuration:"); + println!(" URL: {}", import_config.target_server.url); + println!(" Username: {}", import_config.target_server.username); + println!(" Target Calendar: {}", target_calendar_name); + return Err(e.into()); + } + }; + + println!("āœ… Successfully connected to target server!"); + + // Discover calendars to find the target calendar URL + let target_calendars = match target_sync_engine.client.discover_calendars().await { + Ok(calendars) => calendars, + Err(e) => { + error!("Failed to discover calendars on target server: {}", e); + println!("āŒ Failed to discover calendars: {}", e); + return Err(e.into()); + } + }; + + // Find the target calendar + let target_calendar = target_calendars.iter() + .find(|c| c.name == *target_calendar_name || c.display_name.as_ref().map_or(false, |dn| dn == target_calendar_name)); + + let target_calendar = match target_calendar { + Some(calendar) => { + println!("āœ… Found target calendar: {}", calendar.display_name.as_ref().unwrap_or(&calendar.name)); + calendar + } + None => { + println!("āŒ Target calendar '{}' not found on server", target_calendar_name); + println!("Available calendars:"); + for calendar in &target_calendars { + println!(" - {}", calendar.display_name.as_ref().unwrap_or(&calendar.name)); + } + return Err(anyhow::anyhow!("Target calendar not found").into()); + } + }; + + // Check if calendar supports events + let supports_events = target_calendar.supported_components.contains(&"VEVENT".to_string()); + if !supports_events { + println!("āŒ Target calendar does not support events"); + println!("Supported components: {}", target_calendar.supported_components.join(", ")); + return Err(anyhow::anyhow!("Calendar does not support events").into()); + } + + // Set date range for event listing (past 30 days to next 30 days) + let now = Utc::now(); + let start_date = now - Duration::days(30); + let end_date = now + Duration::days(30); + + println!("\nRetrieving events from {} to {}...", + start_date.format("%Y-%m-%d"), + end_date.format("%Y-%m-%d")); + + // Get events from the target calendar using the full URL + let events: Vec = match target_sync_engine.client.get_events(&target_calendar.url, start_date, end_date).await { + Ok(events) => events, + Err(e) => { + error!("Failed to retrieve events from target calendar: {}", e); + println!("āŒ Failed to retrieve events: {}", e); + return Err(e.into()); + } + }; + + println!("\nšŸ“Š Event Summary"); + println!("================"); + println!("Total events found: {}", events.len()); + + if events.is_empty() { + println!("\nNo events found in the specified date range."); + return Ok(()); + } + + // Count events by status and other properties + let mut confirmed_events = 0; + let mut tentative_events = 0; + let mut cancelled_events = 0; + let mut all_day_events = 0; + let mut events_with_location = 0; + let mut upcoming_events = 0; + let mut past_events = 0; + + for event in &events { + // Count by status + if let Some(ref status) = event.status { + match status.to_lowercase().as_str() { + "confirmed" => confirmed_events += 1, + "tentative" => tentative_events += 1, + "cancelled" => cancelled_events += 1, + _ => {} + } + } + + // Check if all-day (simple heuristic) + if event.start.time() == chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() && + event.end.time() == chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap_or(chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()) { + all_day_events += 1; + } + + // Count events with locations + if let Some(ref location) = event.location { + if !location.is_empty() { + events_with_location += 1; + } + } + + // Count upcoming vs past events + if event.end > now { + upcoming_events += 1; + } else { + past_events += 1; + } + } + + println!(" Confirmed: {}", confirmed_events); + println!(" Tentative: {}", tentative_events); + println!(" Cancelled: {}", cancelled_events); + println!(" All-day: {}", all_day_events); + println!(" With location: {}", events_with_location); + println!(" Upcoming: {}", upcoming_events); + println!(" Past: {}", past_events); + + // Display detailed event information + println!("\nšŸ“… Event Details"); + println!("================="); + + // Sort events by start time + let mut sorted_events = events.clone(); + sorted_events.sort_by(|a, b| a.start.cmp(&b.start)); + + for (i, event) in sorted_events.iter().enumerate() { + println!("\n{}. {}", i + 1, event.summary); + + // Format dates and times + let start_formatted = event.start.format("%Y-%m-%d %H:%M"); + let end_formatted = event.end.format("%Y-%m-%d %H:%M"); + + println!(" šŸ“… {} to {}", start_formatted, end_formatted); + + // Event ID + println!(" šŸ†” ID: {}", event.id); + + // Status + let status_icon = if let Some(ref status) = event.status { + match status.to_lowercase().as_str() { + "confirmed" => "āœ…", + "tentative" => "šŸ”„", + "cancelled" => "āŒ", + _ => "ā“", + } + } else { + "ā“" + }; + + let status_display = event.status.as_deref().unwrap_or("Unknown"); + println!(" šŸ“Š Status: {} {}", status_icon, status_display); + + // Location + if let Some(ref location) = event.location { + if !location.is_empty() { + println!(" šŸ“ Location: {}", location); + } + } + + // Description (truncated if too long) + if let Some(ref description) = event.description { + if !description.is_empty() { + let truncated = if description.len() > 100 { + format!("{}...", &description[..97]) + } else { + description.clone() + }; + println!(" šŸ“ Description: {}", truncated); + } + } + + // ETag for synchronization info + if let Some(ref etag) = event.etag { + println!(" šŸ·ļø ETag: {}", etag); + } + } + + // Import analysis + println!("\nšŸ” Import Analysis"); + println!("=================="); + println!("This target calendar contains {} events.", events.len()); + + if cli.import_info { + println!("\nBased on the strict unidirectional import behavior:"); + println!("- These events would be checked against source events"); + println!("- Events not present in source would be deleted (if using strict_with_cleanup)"); + println!("- Events present in both would be updated if source is newer"); + println!("- New events from source would be added to this calendar"); + + println!("\nRecommendations:"); + if events.len() > 100 { + println!("- āš ļø Large number of events - consider using strict behavior first"); + } + if cancelled_events > 0 { + println!("- šŸ—‘ļø {} cancelled events could be cleaned up", cancelled_events); + } + if past_events > events.len() / 2 { + println!("- šŸ“š Many past events - consider cleanup if not needed"); + } + } + + return Ok(()); + } + // Create sync engine for other operations let mut sync_engine = SyncEngine::new(config.clone()).await?; if cli.list_events { - // List events and exit + // Check if we should list events from import target calendar + if cli.import_info { + // List events from import target calendar (similar to list_import_events but simplified) + info!("Listing events from import target calendar"); + + // Validate import configuration + let import_config = match config.get_import_config() { + Some(config) => config, + None => { + error!("No import target configured. Please add [import] section to config.toml"); + return Err(anyhow::anyhow!("Import configuration not found").into()); + } + }; + + // Override target calendar if specified via CLI + let target_calendar_name = cli.nextcloud_calendar.as_ref() + .unwrap_or(&import_config.target_calendar.name); + + println!("šŸ“… Events from Import Target Calendar"); + println!("====================================="); + println!("Target Server: {}", import_config.target_server.url); + println!("Target Calendar: {}\n", target_calendar_name); + + // Create a temporary config for the target server + let mut target_config = config.clone(); + target_config.server.url = import_config.target_server.url.clone(); + target_config.server.username = import_config.target_server.username.clone(); + target_config.server.password = import_config.target_server.password.clone(); + target_config.server.timeout = import_config.target_server.timeout; + target_config.server.use_https = import_config.target_server.use_https; + target_config.server.headers = import_config.target_server.headers.clone(); + target_config.calendar.name = target_calendar_name.clone(); + + // Connect to target server + let target_sync_engine = match SyncEngine::new(target_config).await { + Ok(engine) => engine, + Err(e) => { + error!("Failed to connect to target server: {}", e); + println!("āŒ Failed to connect to target server: {}", e); + return Err(e.into()); + } + }; + + println!("āœ… Successfully connected to target server!"); + + // Discover calendars to find the target calendar URL + let target_calendars = match target_sync_engine.client.discover_calendars().await { + Ok(calendars) => calendars, + Err(e) => { + error!("Failed to discover calendars on target server: {}", e); + println!("āŒ Failed to discover calendars: {}", e); + return Err(e.into()); + } + }; + + // Find the target calendar + let target_calendar = target_calendars.iter() + .find(|c| c.name == *target_calendar_name || c.display_name.as_ref().map_or(false, |dn| dn == target_calendar_name)); + + let target_calendar = match target_calendar { + Some(calendar) => { + println!("āœ… Found target calendar: {}", calendar.display_name.as_ref().unwrap_or(&calendar.name)); + calendar + } + None => { + println!("āŒ Target calendar '{}' not found on server", target_calendar_name); + println!("Available calendars:"); + for calendar in &target_calendars { + println!(" - {}", calendar.display_name.as_ref().unwrap_or(&calendar.name)); + } + return Err(anyhow::anyhow!("Target calendar not found").into()); + } + }; + + // Set date range for event listing (past 30 days to next 30 days) + let now = Utc::now(); + let start_date = now - Duration::days(30); + let end_date = now + Duration::days(30); + + println!("\nRetrieving events from {} to {}...", + start_date.format("%Y-%m-%d"), + end_date.format("%Y-%m-%d")); + + // Get events from the target calendar using the full URL + let events: Vec = match target_sync_engine.client.get_events(&target_calendar.url, start_date, end_date).await { + Ok(events) => events, + Err(e) => { + error!("Failed to retrieve events from target calendar: {}", e); + println!("āŒ Failed to retrieve events: {}", e); + return Err(e.into()); + } + }; + + println!("Found {} events:\n", events.len()); + + // Display events in a simple format similar to the original list_events + for event in events { + let start_tz = event.start_tzid.as_deref().unwrap_or("UTC"); + let end_tz = event.end_tzid.as_deref().unwrap_or("UTC"); + println!(" - {} ({} {} to {} {})", + event.summary, + event.start.format("%Y-%m-%d %H:%M"), + start_tz, + event.end.format("%Y-%m-%d %H:%M"), + end_tz + ); + } + + return Ok(()); + } + + // Original behavior: List events from source calendar and exit info!("Listing events from calendar: {}", config.calendar.name); // Use the specific approach if provided diff --git a/src/minicaldav_client.rs b/src/minicaldav_client.rs index a29f8ff..4aa9775 100644 --- a/src/minicaldav_client.rs +++ b/src/minicaldav_client.rs @@ -595,41 +595,52 @@ impl RealCalDavClient { // Simple XML parsing to extract calendar data let mut events = Vec::new(); - // Look for calendar-data content in the XML response - if let Some(start) = xml.find("") { - if let Some(end) = xml.find("") { - let ical_data = &xml[start + 17..end]; - debug!("Found iCalendar data: {}", ical_data); - - // Parse the iCalendar data - if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) { - events.extend(parsed_events); - } else { - warn!("Failed to parse iCalendar data, falling back to mock"); - return self.create_mock_event(calendar_href); + // Look for calendar-data content in the XML response (try multiple namespace variants) + let calendar_data_patterns = vec![ + ("", ""), + ("", ""), + ("", ""), + ]; + + let mut found_calendar_data = false; + for (start_tag, end_tag) in calendar_data_patterns { + if let Some(start) = xml.find(start_tag) { + if let Some(end) = xml.find(end_tag) { + let ical_data = &xml[start + start_tag.len()..end]; + debug!("Found iCalendar data using {}: {}", start_tag, ical_data); + + // Parse the iCalendar data + if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) { + events.extend(parsed_events); + found_calendar_data = true; + break; + } else { + warn!("Failed to parse iCalendar data using {}, trying next pattern", start_tag); + } } - } else { - debug!("No calendar-data closing tag found"); - return self.create_mock_event(calendar_href); } - } else { - debug!("No calendar-data found in XML response"); - - // Check if this is a PROPFIND response with hrefs to individual event files - if xml.contains("") && xml.contains(".ics") { - return self.parse_propfind_response(xml, calendar_href).await; - } - - // If no calendar-data but we got hrefs, try to fetch individual .ics files - if xml.contains("") { - return self.parse_propfind_response(xml, calendar_href).await; - } - - return self.create_mock_event(calendar_href); } - info!("Parsed {} real events from CalDAV response", events.len()); - Ok(events) + if found_calendar_data { + info!("Parsed {} real events from CalDAV response", events.len()); + return Ok(events); + } + + // If no calendar-data found in any namespace format + debug!("No calendar-data found in XML response with any namespace pattern"); + + // Check if this is a PROPFIND response with hrefs to individual event files + if xml.contains("") && xml.contains(".ics") { + return self.parse_propfind_response(xml, calendar_href).await; + } + + // If no calendar-data but we got hrefs, try to fetch individual .ics files + if xml.contains("") { + return self.parse_propfind_response(xml, calendar_href).await; + } + + warn!("No calendar data found in XML response for calendar: {}", calendar_href); + return Ok(vec![]); } /// Parse multistatus response from REPORT request @@ -1043,35 +1054,6 @@ impl RealCalDavClient { Ok(events) } - /// Create mock event for debugging - fn create_mock_event(&self, calendar_href: &str) -> Result> { - let now = Utc::now(); - let mock_event = CalendarEvent { - id: "mock-event-1".to_string(), - href: format!("{}/mock-event-1.ics", calendar_href), - summary: "Mock Event".to_string(), - description: Some("This is a mock event for testing".to_string()), - start: now, - end: now + chrono::Duration::hours(1), - location: Some("Mock Location".to_string()), - status: Some("CONFIRMED".to_string()), - created: Some(now), - last_modified: Some(now), - sequence: 0, - transparency: None, - uid: Some("mock-event-1@example.com".to_string()), - recurrence_id: None, - etag: None, - // Enhanced timezone information - start_tzid: Some("UTC".to_string()), - end_tzid: Some("UTC".to_string()), - original_start: Some(now.format("%Y%m%dT%H%M%SZ").to_string()), - original_end: Some((now + chrono::Duration::hours(1)).format("%Y%m%dT%H%M%SZ").to_string()), - }; - - Ok(vec![mock_event]) - } - /// Extract calendar name from URL fn extract_calendar_name(&self, url: &str) -> String { // Extract calendar name from URL path diff --git a/src/nextcloud_import.rs b/src/nextcloud_import.rs index 45eeec0..24ced45 100644 --- a/src/nextcloud_import.rs +++ b/src/nextcloud_import.rs @@ -10,29 +10,26 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::{info, warn, debug}; -/// Import behavior strategies +/// Import behavior strategies for unidirectional sync #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ImportBehavior { - /// Skip events that already exist on target - SkipDuplicates, - /// Overwrite existing events with source data - Overwrite, - /// Merge event data (preserve target fields that aren't in source) - Merge, + /// Strict import: target calendar must exist, no cleanup + Strict, + /// Strict with cleanup: delete target events not in source + StrictWithCleanup, } impl Default for ImportBehavior { fn default() -> Self { - ImportBehavior::SkipDuplicates + ImportBehavior::Strict } } impl std::fmt::Display for ImportBehavior { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ImportBehavior::SkipDuplicates => write!(f, "skip_duplicates"), - ImportBehavior::Overwrite => write!(f, "overwrite"), - ImportBehavior::Merge => write!(f, "merge"), + ImportBehavior::Strict => write!(f, "strict"), + ImportBehavior::StrictWithCleanup => write!(f, "strict_with_cleanup"), } } } @@ -42,11 +39,10 @@ impl std::str::FromStr for ImportBehavior { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "skip_duplicates" => Ok(ImportBehavior::SkipDuplicates), - "skip-duplicates" => Ok(ImportBehavior::SkipDuplicates), - "overwrite" => Ok(ImportBehavior::Overwrite), - "merge" => Ok(ImportBehavior::Merge), - _ => Err(format!("Invalid import behavior: {}. Valid options: skip_duplicates, overwrite, merge", s)), + "strict" => Ok(ImportBehavior::Strict), + "strict_with_cleanup" => Ok(ImportBehavior::StrictWithCleanup), + "strict-with-cleanup" => Ok(ImportBehavior::StrictWithCleanup), + _ => Err(format!("Invalid import behavior: {}. Valid options: strict, strict_with_cleanup", s)), } } } @@ -56,9 +52,13 @@ impl std::str::FromStr for ImportBehavior { pub struct ImportResult { /// Total number of events processed pub total_events: usize, - /// Number of events successfully imported + /// Number of events successfully imported (new) pub imported: usize, - /// Number of events skipped (duplicates, etc.) + /// Number of events updated (existing) + pub updated: usize, + /// Number of events deleted (cleanup) + pub deleted: usize, + /// Number of events skipped (unchanged) pub skipped: usize, /// Number of events that failed to import pub failed: usize, @@ -84,6 +84,8 @@ impl ImportResult { Self { total_events: 0, imported: 0, + updated: 0, + deleted: 0, skipped: 0, failed: 0, errors: Vec::new(), @@ -386,17 +388,16 @@ mod tests { #[test] fn test_import_behavior_from_str() { - assert!(matches!("skip_duplicates".parse::(), Ok(ImportBehavior::SkipDuplicates))); - assert!(matches!("overwrite".parse::(), Ok(ImportBehavior::Overwrite))); - assert!(matches!("merge".parse::(), Ok(ImportBehavior::Merge))); + assert!(matches!("strict".parse::(), Ok(ImportBehavior::Strict))); + assert!(matches!("strict_with_cleanup".parse::(), Ok(ImportBehavior::StrictWithCleanup))); + assert!(matches!("strict-with-cleanup".parse::(), Ok(ImportBehavior::StrictWithCleanup))); assert!("invalid".parse::().is_err()); } #[test] fn test_import_behavior_display() { - assert_eq!(ImportBehavior::SkipDuplicates.to_string(), "skip_duplicates"); - assert_eq!(ImportBehavior::Overwrite.to_string(), "overwrite"); - assert_eq!(ImportBehavior::Merge.to_string(), "merge"); + assert_eq!(ImportBehavior::Strict.to_string(), "strict"); + assert_eq!(ImportBehavior::StrictWithCleanup.to_string(), "strict_with_cleanup"); } #[test] @@ -419,7 +420,7 @@ mod tests { }, }; - let engine = ImportEngine::new(config, ImportBehavior::SkipDuplicates, false); + let engine = ImportEngine::new(config, ImportBehavior::Strict, false); // Valid event should pass let valid_event = create_test_event("test-uid", "Test Event"); @@ -461,7 +462,7 @@ mod tests { }, }; - let engine = ImportEngine::new(config, ImportBehavior::SkipDuplicates, true); + let engine = ImportEngine::new(config, ImportBehavior::Strict, true); let events = vec![ create_test_event("event-1", "Event 1"), create_test_event("event-2", "Event 2"),