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"); - } -}