Compare commits

..

4 commits

Author SHA1 Message Date
Alvaro Soliverez
932b6ae463 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
2025-11-21 11:56:27 -03:00
Alvaro Soliverez
f84ce62f73 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)
2025-10-29 13:39:48 -03:00
Alvaro Soliverez
16d6fc375d Working correctly to fetch 1 Nextcloud calendar 2025-10-26 13:10:16 -03:00
Alvaro Soliverez
20a74ac7a4 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
2025-10-18 14:14:52 -03:00
12 changed files with 3035 additions and 468 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target /target
config/config.toml config/config.toml
config-test-import.toml

390
NEXTCLOUD_IMPORT_PLAN.md Normal file
View file

@ -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<String>
// Upload multiple events efficiently
pub async fn import_events_batch(&self, calendar_url: &str, events: &[Event]) -> CalDavResult<Vec<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<String> {
// 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<Self>
// Auto-discover calendars
pub async fn discover_calendars(&self) -> CalDavResult<Vec<CalendarInfo>>
// Create calendar if it doesn't exist
pub async fn ensure_calendar_exists(&self, name: &str, display_name: Option<&str>) -> CalDavResult<String>
// Import events with conflict resolution
pub async fn import_events(&self, calendar_name: &str, events: Vec<Event>) -> CalDavResult<ImportResult>
// Check if event already exists
pub async fn event_exists(&self, calendar_name: &str, event_uid: &str) -> CalDavResult<bool>
// Get existing event ETag
pub async fn get_event_etag(&self, calendar_name: &str, event_uid: &str) -> CalDavResult<Option<String>>
}
```
#### 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<String>,
/// 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<ImportError>,
pub conflicts: Vec<ConflictInfo>,
}
impl ImportEngine {
pub async fn import_events(&self, events: Vec<Event>) -> CalDavResult<ImportResult> {
// 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<Option<String>> {
// Return ETag if event exists, None otherwise
}
async fn resolve_conflict(&self, existing_event: &str, new_event: &Event) -> CalDavResult<ConflictResolution> {
// 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.

29
TODO.md Normal file
View file

@ -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

View file

@ -39,6 +39,27 @@ delete_missing = false
# Date range configuration # Date range configuration
date_range = { days_ahead = 30, days_back = 30, sync_all_events = false } 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/trabajo-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 # Optional filtering configuration
[filters] [filters]
# Keywords to filter events by (events containing any of these will be included) # Keywords to filter events by (events containing any of these will be included)

View file

@ -7,10 +7,14 @@ use anyhow::Result;
/// Main configuration structure /// Main configuration structure
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
/// Server configuration /// Source server configuration (e.g., Zoho)
pub server: ServerConfig, pub server: ServerConfig,
/// Calendar configuration /// Source calendar configuration
pub calendar: CalendarConfig, pub calendar: CalendarConfig,
/// Import configuration (e.g., Nextcloud as target) - new format
pub import: Option<ImportConfig>,
/// Legacy import target configuration - for backward compatibility
pub import_target: Option<ImportTargetConfig>,
/// Filter configuration /// Filter configuration
pub filters: Option<FilterConfig>, pub filters: Option<FilterConfig>,
/// Sync configuration /// Sync configuration
@ -49,6 +53,64 @@ pub struct CalendarConfig {
pub enabled: bool, 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,
}
/// 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 {
/// 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<std::collections::HashMap<String, String>>,
}
/// 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<String>,
/// Target calendar color
pub color: Option<String>,
/// Target calendar timezone
pub timezone: Option<String>,
/// Whether this calendar is enabled for import
pub enabled: bool,
}
/// Filter configuration for events /// Filter configuration for events
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterConfig { pub struct FilterConfig {
@ -97,6 +159,8 @@ impl Default for Config {
Self { Self {
server: ServerConfig::default(), server: ServerConfig::default(),
calendar: CalendarConfig::default(), calendar: CalendarConfig::default(),
import: None,
import_target: None,
filters: None, filters: None,
sync: SyncConfig::default(), sync: SyncConfig::default(),
} }
@ -128,6 +192,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 { impl Default for SyncConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -202,6 +300,37 @@ impl Config {
} }
Ok(()) Ok(())
} }
/// Get import configuration, supporting both new and legacy formats
pub fn get_import_config(&self) -> Option<ImportConfig> {
// 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)] #[cfg(test)]

View file

@ -127,27 +127,20 @@ mod tests {
#[test] #[test]
fn test_error_retryable() { 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()); let auth_error = CalDavError::Authentication("Invalid credentials".to_string());
assert!(!auth_error.is_retryable()); assert!(!auth_error.is_retryable());
let config_error = CalDavError::Config("Missing URL".to_string()); let config_error = CalDavError::Config("Missing URL".to_string());
assert!(!config_error.is_retryable()); assert!(!config_error.is_retryable());
let rate_limit_error = CalDavError::RateLimited(120);
assert!(rate_limit_error.is_retryable());
} }
#[test] #[test]
fn test_retry_delay() { fn test_retry_delay() {
let rate_limit_error = CalDavError::RateLimited(120); let rate_limit_error = CalDavError::RateLimited(120);
assert_eq!(rate_limit_error.retry_delay(), Some(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] #[test]
@ -158,10 +151,8 @@ mod tests {
let config_error = CalDavError::Config("Invalid".to_string()); let config_error = CalDavError::Config("Invalid".to_string());
assert!(config_error.is_config_error()); assert!(config_error.is_config_error());
let network_error = CalDavError::Network( let rate_limit_error = CalDavError::RateLimited(60);
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test")) assert!(!rate_limit_error.is_auth_error());
); assert!(!rate_limit_error.is_config_error());
assert!(!network_error.is_auth_error());
assert!(!network_error.is_config_error());
} }
} }

View file

@ -1,10 +1,15 @@
//! Event handling and iCalendar parsing //! Event handling and iCalendar parsing
use crate::error::{CalDavError, CalDavResult}; use crate::error::CalDavResult;
use chrono::{DateTime, Utc, NaiveDateTime}; use chrono::{DateTime, Utc, Datelike, Timelike};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; 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 /// Calendar event representation
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -111,47 +116,107 @@ pub enum ParticipationStatus {
Delegated, Delegated,
} }
/// Recurrence rule /// Recurrence rule (simplified RRULE string representation)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurrenceRule { pub struct RecurrenceRule {
/// Frequency /// Original RRULE string for storage and parsing
pub frequency: RecurrenceFrequency, pub original_rule: String,
/// Interval
pub interval: u32,
/// Count (number of occurrences)
pub count: Option<u32>,
/// Until date
pub until: Option<DateTime<Utc>>,
/// Days of week
pub by_day: Option<Vec<WeekDay>>,
/// Days of month
pub by_month_day: Option<Vec<u32>>,
/// Months
pub by_month: Option<Vec<u32>>,
} }
/// Recurrence frequency impl RecurrenceRule {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] /// Create a new RecurrenceRule from an RRULE string
pub enum RecurrenceFrequency { pub fn from_str(rrule_str: &str) -> Result<Self, Box<dyn std::error::Error>> {
Secondly, Ok(RecurrenceRule {
Minutely, original_rule: rrule_str.to_string(),
Hourly, })
Daily,
Weekly,
Monthly,
Yearly,
} }
/// Day of week for recurrence /// Get the RRULE string
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub fn as_str(&self) -> &str {
pub enum WeekDay { &self.original_rule
Sunday, }
Monday,
Tuesday, /// Parse RRULE components from the original_rule string
Wednesday, fn parse_components(&self) -> std::collections::HashMap<String, String> {
Thursday, let mut components = std::collections::HashMap::new();
Friday,
Saturday, 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<i32> {
self.parse_components()
.get("COUNT")
.and_then(|s| s.parse().ok())
}
/// Get the until date (UNTIL) component
pub fn until(&self) -> Option<DateTime<Utc>> {
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<String> {
self.parse_components()
.get("BYDAY")
.map(|s| s.split(',').map(|s| s.to_string()).collect())
.unwrap_or_default()
}
} }
/// Event alarm/reminder /// Event alarm/reminder
@ -188,6 +253,38 @@ pub enum AlarmTrigger {
Absolute(DateTime<Utc>), Absolute(DateTime<Utc>),
} }
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 { impl Event {
/// Create a new event /// Create a new event
pub fn new(summary: String, start: DateTime<Utc>, end: DateTime<Utc>) -> Self { pub fn new(summary: String, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
@ -274,18 +371,28 @@ impl Event {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
} }
// Dates // Dates with timezone preservation
if self.all_day { if self.all_day {
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
self.start.format("%Y%m%d"))); self.start.format("%Y%m%d")));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
self.end.format("%Y%m%d"))); self.end.format("%Y%m%d")));
} else { } else {
// 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", ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ"))); self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n", ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ"))); self.end.format("%Y%m%dT%H%M%SZ")));
} }
}
// Status // Status
ical.push_str(&format!("STATUS:{}\r\n", match self.status { ical.push_str(&format!("STATUS:{}\r\n", match self.status {
@ -334,6 +441,190 @@ impl Event {
self.sequence += 1; 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<String> {
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<String> {
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 /// Check if event occurs on a specific date
pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool { pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool {
let start_date = self.start.date_naive(); let start_date = self.start.date_naive();
@ -356,6 +647,301 @@ impl Event {
let now = Utc::now(); let now = Utc::now();
now >= self.start && now <= self.end 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<Utc>, end_range: DateTime<Utc>) -> Vec<Event> {
// 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<Utc>, months: u32) -> DateTime<Utc> {
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 /// Escape text for iCalendar format
@ -369,7 +955,11 @@ fn escape_ical_text(text: &str) -> String {
} }
/// Parse iCalendar date/time /// Parse iCalendar date/time
#[cfg(test)]
fn parse_ical_datetime(dt_str: &str) -> CalDavResult<DateTime<Utc>> { fn parse_ical_datetime(dt_str: &str) -> CalDavResult<DateTime<Utc>> {
use crate::error::CalDavError;
use chrono::NaiveDateTime;
// Handle different iCalendar date formats // Handle different iCalendar date formats
if dt_str.len() == 8 { if dt_str.len() == 8 {
// DATE format (YYYYMMDD) // DATE format (YYYYMMDD)
@ -444,4 +1034,102 @@ mod tests {
let escaped = escape_ical_text(text); let escaped = escape_ical_text(text);
assert_eq!(escaped, "Hello\\, world\\; this\\\\is a test"); 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());
}
#[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));
}
} }

View file

@ -5,12 +5,15 @@
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod event;
pub mod minicaldav_client; pub mod minicaldav_client;
pub mod nextcloud_import;
pub mod real_sync; pub mod real_sync;
// Re-export main types for convenience // Re-export main types for convenience
pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig}; pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig};
pub use error::{CalDavError, CalDavResult}; pub use error::{CalDavError, CalDavResult};
pub use event::{Event, EventStatus, EventType};
pub use minicaldav_client::{RealCalDavClient, CalendarInfo, CalendarEvent}; pub use minicaldav_client::{RealCalDavClient, CalendarInfo, CalendarEvent};
pub use real_sync::{SyncEngine, SyncResult, SyncEvent, SyncStats}; pub use real_sync::{SyncEngine, SyncResult, SyncEvent, SyncStats};

View file

@ -3,9 +3,12 @@ use clap::Parser;
use tracing::{info, warn, error, Level}; use tracing::{info, warn, error, Level};
use tracing_subscriber; use tracing_subscriber;
use caldav_sync::{Config, CalDavResult, SyncEngine}; use caldav_sync::{Config, CalDavResult, SyncEngine};
use caldav_sync::nextcloud_import::{ImportEngine, ImportBehavior};
use caldav_sync::minicaldav_client::CalendarEvent;
use std::path::PathBuf; use std::path::PathBuf;
use chrono::{Utc, Duration}; use chrono::{Utc, Duration};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "caldav-sync")] #[command(name = "caldav-sync")]
#[command(about = "A CalDAV calendar synchronization tool")] #[command(about = "A CalDAV calendar synchronization tool")]
@ -58,6 +61,30 @@ struct Cli {
/// Use specific calendar URL instead of discovering from config /// Use specific calendar URL instead of discovering from config
#[arg(long)] #[arg(long)]
calendar_url: Option<String>, calendar_url: Option<String>,
/// 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<String>,
/// 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] #[tokio::main]
@ -126,24 +153,299 @@ async fn main() -> Result<()> {
} }
async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> { 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 { if cli.list_calendars {
// List calendars and exit // List calendars and exit
info!("Listing available calendars from server"); info!("Listing available calendars from server");
// Get calendars directly from the client if cli.import_info {
let calendars = sync_engine.client.discover_calendars().await?; println!("🔍 Import Analysis Report");
println!("Found {} calendars:", calendars.len()); 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);
}
// Show target import calendars if configured
if let Some(ref import_config) = config.get_import_config() {
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");
}
} 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() { for (i, calendar) in calendars.iter().enumerate() {
println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name)); println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
println!(" Name: {}", calendar.name); println!(" Name: {}", calendar.name);
println!(" URL: {}", calendar.url); println!(" URL: {}", calendar.url);
if let Some(ref display_name) = calendar.display_name {
println!(" Display Name: {}", display_name);
}
if let Some(ref color) = calendar.color { if let Some(ref color) = calendar.color {
println!(" Color: {}", color); println!(" Color: {}", color);
} }
@ -156,12 +458,519 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
println!(" Supported Components: {}", calendar.supported_components.join(", ")); println!(" Supported Components: {}", calendar.supported_components.join(", "));
println!(); println!();
} }
}
return Ok(()); 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::<ImportBehavior>() {
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<caldav_sync::event::Event> = 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(());
}
// 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<CalendarEvent> = 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 { 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<CalendarEvent> = 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); info!("Listing events from calendar: {}", config.calendar.name);
// Use the specific approach if provided // Use the specific approach if provided

View file

@ -9,6 +9,7 @@ use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine; use base64::Engine;
use std::time::Duration; use std::time::Duration;
use std::collections::HashMap; use std::collections::HashMap;
use crate::config::{ImportConfig};
pub struct Config { pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
@ -25,6 +26,7 @@ pub struct RealCalDavClient {
client: Client, client: Client,
base_url: String, base_url: String,
username: String, username: String,
import_target: Option<ImportConfig>,
} }
impl RealCalDavClient { impl RealCalDavClient {
@ -63,6 +65,7 @@ impl RealCalDavClient {
client, client,
base_url: base_url.to_string(), base_url: base_url.to_string(),
username: username.to_string(), username: username.to_string(),
import_target: None,
}) })
} }
@ -90,11 +93,70 @@ impl RealCalDavClient {
</D:prop> </D:prop>
</D:propfind>"#; </D:propfind>"#;
// Try multiple approaches for calendar discovery
let mut all_calendars = Vec::new();
// Approach 1: Try current base URL
info!("Trying calendar discovery at base URL: {}", self.base_url);
match self.try_calendar_discovery_at_url(&self.base_url, &propfind_xml).await {
Ok(calendars) => {
info!("Found {} calendars using base URL approach", calendars.len());
all_calendars.extend(calendars);
},
Err(e) => {
warn!("Base URL approach failed: {}", e);
}
}
// Approach 2: Try Nextcloud principal URL if base URL approach didn't find much
if all_calendars.len() <= 1 {
if let Some(principal_url) = self.construct_nextcloud_principal_url() {
info!("Trying calendar discovery at principal URL: {}", principal_url);
match self.try_calendar_discovery_at_url(&principal_url, &propfind_xml).await {
Ok(calendars) => {
info!("Found {} calendars using principal URL approach", calendars.len());
// Merge with existing calendars, avoiding duplicates
for new_cal in calendars {
if !all_calendars.iter().any(|existing| existing.url == new_cal.url) {
all_calendars.push(new_cal);
}
}
},
Err(e) => {
warn!("Principal URL approach failed: {}", e);
}
}
}
}
// Approach 3: Try to construct specific calendar URLs for configured target calendar
if let Some(target_calendar_url) = self.construct_target_calendar_url() {
info!("Trying direct target calendar access at: {}", target_calendar_url);
match self.try_direct_calendar_access(&target_calendar_url, &propfind_xml).await {
Ok(target_cal) => {
info!("Found target calendar using direct access approach");
// Add target calendar if not already present
if !all_calendars.iter().any(|existing| existing.url == target_cal.url) {
all_calendars.push(target_cal);
}
},
Err(e) => {
warn!("Direct target calendar access failed: {}", e);
}
}
}
info!("Total calendars found: {}", all_calendars.len());
Ok(all_calendars)
}
/// Try calendar discovery at a specific URL
async fn try_calendar_discovery_at_url(&self, url: &str, propfind_xml: &str) -> Result<Vec<CalendarInfo>> {
let response = self.client 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("Depth", "1")
.header("Content-Type", "application/xml") .header("Content-Type", "application/xml")
.body(propfind_xml) .body(propfind_xml.to_string())
.send() .send()
.await?; .await?;
@ -103,15 +165,110 @@ impl RealCalDavClient {
} }
let response_text = response.text().await?; 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 // Parse XML response to extract calendar information
let calendars = self.parse_calendar_response(&response_text)?; let calendars = self.parse_calendar_response(&response_text)?;
info!("Found {} calendars", calendars.len());
Ok(calendars) Ok(calendars)
} }
/// Construct Nextcloud principal URL from base URL
fn construct_nextcloud_principal_url(&self) -> Option<String> {
// Extract base server URL and username from the current base URL
// Current format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/
// Principal format: https://cloud.soliverez.com.ar/remote.php/dav/principals/users/alvaro/
if self.base_url.contains("/remote.php/dav/calendars/") {
let parts: Vec<&str> = self.base_url.split("/remote.php/dav/calendars/").collect();
if parts.len() == 2 {
let server_part = parts[0];
let user_part = parts[1].trim_end_matches('/');
// Construct principal URL
let principal_url = format!("{}/remote.php/dav/principals/users/{}", server_part, user_part);
return Some(principal_url);
}
}
None
}
/// Construct target calendar URL for direct access
fn construct_target_calendar_url(&self) -> Option<String> {
// Use import target configuration to construct direct calendar URL
if let Some(ref import_target) = self.import_target {
info!("Constructing target calendar URL using import configuration");
// Extract calendar name from target configuration
let calendar_name = &import_target.target_calendar.name;
// For Nextcloud, construct URL by adding calendar name to base path
// Current format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/
// Target format: https://cloud.soliverez.com.ar/remote.php/dav/calendars/alvaro/calendar-name/
if self.base_url.contains("/remote.php/dav/calendars/") {
// Ensure base URL ends with a slash
let base_path = if self.base_url.ends_with('/') {
self.base_url.clone()
} else {
format!("{}/", self.base_url)
};
// Construct target calendar URL
let target_url = format!("{}{}", base_path, calendar_name);
info!("Constructed target calendar URL: {}", target_url);
return Some(target_url);
} else {
// For non-Nextcloud servers, try different URL patterns
info!("Non-Nextcloud server detected, trying alternative URL construction");
// Pattern 1: Add calendar name directly to base URL
let base_path = if self.base_url.ends_with('/') {
self.base_url.clone()
} else {
format!("{}/", self.base_url)
};
let target_url = format!("{}{}", base_path, calendar_name);
info!("Constructed alternative target calendar URL: {}", target_url);
return Some(target_url);
}
} else {
// No import target configuration available
info!("No import target configuration available for URL construction");
None
}
}
/// Try direct access to a specific calendar URL
async fn try_direct_calendar_access(&self, calendar_url: &str, propfind_xml: &str) -> Result<CalendarInfo> {
info!("Trying direct calendar access at: {}", calendar_url);
let response = self.client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), calendar_url)
.header("Depth", "0") // Only check this specific resource
.header("Content-Type", "application/xml")
.body(propfind_xml.to_string())
.send()
.await?;
if response.status().as_u16() != 207 {
return Err(anyhow::anyhow!("Direct calendar access failed with status: {}", response.status()));
}
let response_text = response.text().await?;
debug!("Direct calendar access response from {}: {}", calendar_url, response_text);
// Parse XML response to extract calendar information
let calendars = self.parse_calendar_response(&response_text)?;
if let Some(calendar) = calendars.into_iter().next() {
Ok(calendar)
} else {
Err(anyhow::anyhow!("No calendar found in direct access response"))
}
}
/// Get events from a specific calendar using REPORT /// Get events from a specific calendar using REPORT
pub async fn get_events(&self, calendar_href: &str, start_date: DateTime<Utc>, end_date: DateTime<Utc>) -> Result<Vec<CalendarEvent>> { pub async fn get_events(&self, calendar_href: &str, start_date: DateTime<Utc>, end_date: DateTime<Utc>) -> Result<Vec<CalendarEvent>> {
self.get_events_with_approach(calendar_href, start_date, end_date, None).await self.get_events_with_approach(calendar_href, start_date, end_date, None).await
@ -253,9 +410,116 @@ impl RealCalDavClient {
/// Parse PROPFIND response to extract calendar information /// Parse PROPFIND response to extract calendar information
fn parse_calendar_response(&self, xml: &str) -> Result<Vec<CalendarInfo>> { fn parse_calendar_response(&self, xml: &str) -> Result<Vec<CalendarInfo>> {
// Simple XML parsing - in a real implementation, use a proper XML parser // Enhanced XML parsing to extract multiple calendars from PROPFIND response
let mut calendars = Vec::new(); let mut calendars = Vec::new();
debug!("Parsing calendar discovery response XML:\n{}", xml);
// Check if this is a multistatus response with multiple calendars
if xml.contains("<D:multistatus>") {
info!("Parsing multistatus response with potentially multiple calendars");
// Parse all <D:response> elements to find calendar collections
let mut start_pos = 0;
let mut response_count = 0;
while let Some(response_start) = xml[start_pos..].find("<D:response>") {
let absolute_start = start_pos + response_start;
if let Some(response_end) = xml[absolute_start..].find("</D:response>") {
let absolute_end = absolute_start + response_end + 14; // +14 for "</D:response>" length
let response_xml = &xml[absolute_start..absolute_end];
response_count += 1;
debug!("Parsing response #{}", response_count);
// Extract href from this response
let href = if let Some(href_start) = response_xml.find("<D:href>") {
if let Some(href_end) = response_xml.find("</D:href>") {
let href_content = &response_xml[href_start + 9..href_end];
href_content.trim().to_string()
} else {
continue; // Skip this response if href is malformed
}
} else {
continue; // Skip this response if no href found
};
// Skip if this is not a calendar collection (should end with '/')
if !href.ends_with('/') {
debug!("Skipping non-calendar resource: {}", href);
start_pos = absolute_end;
continue;
}
// Extract display name if available - try multiple XML formats
let display_name = self.extract_display_name_from_xml(response_xml);
// Extract calendar description if available
let description = if let Some(desc_start) = response_xml.find("<C:calendar-description>") {
if let Some(desc_end) = response_xml.find("</C:calendar-description>") {
let desc_content = &response_xml[desc_start + 23..desc_end];
Some(desc_content.trim().to_string())
} else {
None
}
} else {
None
};
// Extract calendar color if available (some servers use this)
let color = if let Some(color_start) = response_xml.find("<C:calendar-color>") {
if let Some(color_end) = response_xml.find("</C:calendar-color>") {
let color_content = &response_xml[color_start + 18..color_end];
Some(color_content.trim().to_string())
} else {
None
}
} else {
None
};
// Check if this is actually a calendar collection by looking for resourcetype
let is_calendar = response_xml.contains("<C:calendar/>") ||
response_xml.contains("<C:calendar></C:calendar>") ||
response_xml.contains("<C:calendar />");
if is_calendar {
info!("Found calendar collection: {} (display: {})",
href, display_name.as_ref().unwrap_or(&"unnamed".to_string()));
// Extract calendar name from href path
let calendar_name = if let Some(last_slash) = href.trim_end_matches('/').rfind('/') {
href[last_slash + 1..].trim_end_matches('/').to_string()
} else {
href.clone()
};
let calendar = CalendarInfo {
url: href.clone(),
name: calendar_name,
display_name: display_name.or_else(|| Some(self.extract_display_name_from_href(&href))),
color,
description,
timezone: Some("UTC".to_string()), // Default timezone
supported_components: vec!["VEVENT".to_string(), "VTODO".to_string()],
};
calendars.push(calendar);
} else {
debug!("Skipping non-calendar resource: {}", href);
}
start_pos = absolute_end;
} else {
break;
}
}
info!("Parsed {} calendar collections from {} responses", calendars.len(), response_count);
} else {
// Fallback to single calendar parsing for non-multistatus responses
warn!("Response is not a multistatus format, using fallback parsing");
// Extract href from the XML response // Extract href from the XML response
let href = if xml.contains("<D:href>") { let href = if xml.contains("<D:href>") {
// Extract href from XML // Extract href from XML
@ -274,7 +538,6 @@ impl RealCalDavClient {
}; };
// For now, use the href as both name and derive display name from it // 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 display_name = self.extract_display_name_from_href(&href);
let calendar = CalendarInfo { let calendar = CalendarInfo {
@ -288,6 +551,22 @@ impl RealCalDavClient {
}; };
calendars.push(calendar); 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) Ok(calendars)
} }
@ -316,25 +595,39 @@ impl RealCalDavClient {
// Simple XML parsing to extract calendar data // Simple XML parsing to extract calendar data
let mut events = Vec::new(); let mut events = Vec::new();
// Look for calendar-data content in the XML response // Look for calendar-data content in the XML response (try multiple namespace variants)
if let Some(start) = xml.find("<C:calendar-data>") { let calendar_data_patterns = vec![
if let Some(end) = xml.find("</C:calendar-data>") { ("<C:calendar-data>", "</C:calendar-data>"),
let ical_data = &xml[start + 17..end]; ("<cal:calendar-data>", "</cal:calendar-data>"),
debug!("Found iCalendar data: {}", ical_data); ("<c:calendar-data>", "</c:calendar-data>"),
];
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 // Parse the iCalendar data
if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) { if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) {
events.extend(parsed_events); events.extend(parsed_events);
found_calendar_data = true;
break;
} else { } else {
warn!("Failed to parse iCalendar data, falling back to mock"); warn!("Failed to parse iCalendar data using {}, trying next pattern", start_tag);
return self.create_mock_event(calendar_href);
} }
} else {
debug!("No calendar-data closing tag found");
return self.create_mock_event(calendar_href);
} }
} else { }
debug!("No calendar-data found in XML response"); }
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 // Check if this is a PROPFIND response with hrefs to individual event files
if xml.contains("<D:href>") && xml.contains(".ics") { if xml.contains("<D:href>") && xml.contains(".ics") {
@ -346,11 +639,8 @@ impl RealCalDavClient {
return self.parse_propfind_response(xml, calendar_href).await; return self.parse_propfind_response(xml, calendar_href).await;
} }
return self.create_mock_event(calendar_href); warn!("No calendar data found in XML response for calendar: {}", calendar_href);
} return Ok(vec![]);
info!("Parsed {} real events from CalDAV response", events.len());
Ok(events)
} }
/// Parse multistatus response from REPORT request /// Parse multistatus response from REPORT request
@ -764,35 +1054,6 @@ impl RealCalDavClient {
Ok(events) Ok(events)
} }
/// Create mock event for debugging
fn create_mock_event(&self, calendar_href: &str) -> Result<Vec<CalendarEvent>> {
let now = Utc::now();
let mock_event = CalendarEvent {
id: "mock-event-1".to_string(),
href: format!("{}/mock-event-1.ics", calendar_href),
summary: "Mock Event".to_string(),
description: Some("This is a mock event for testing".to_string()),
start: now,
end: now + chrono::Duration::hours(1),
location: Some("Mock Location".to_string()),
status: Some("CONFIRMED".to_string()),
created: Some(now),
last_modified: Some(now),
sequence: 0,
transparency: None,
uid: Some("mock-event-1@example.com".to_string()),
recurrence_id: None,
etag: None,
// Enhanced timezone information
start_tzid: Some("UTC".to_string()),
end_tzid: Some("UTC".to_string()),
original_start: Some(now.format("%Y%m%dT%H%M%SZ").to_string()),
original_end: Some((now + chrono::Duration::hours(1)).format("%Y%m%dT%H%M%SZ").to_string()),
};
Ok(vec![mock_event])
}
/// Extract calendar name from URL /// Extract calendar name from URL
fn extract_calendar_name(&self, url: &str) -> String { fn extract_calendar_name(&self, url: &str) -> String {
// Extract calendar name from URL path // Extract calendar name from URL path
@ -832,6 +1093,64 @@ impl RealCalDavClient {
"Default Calendar".to_string() "Default Calendar".to_string()
} }
/// Extract display name from XML response, trying multiple formats
fn extract_display_name_from_xml(&self, xml: &str) -> Option<String> {
// Try multiple XML formats for display name
// Format 1: Standard DAV displayname
if let Some(display_start) = xml.find("<D:displayname>") {
if let Some(display_end) = xml.find("</D:displayname>") {
let display_content = &xml[display_start + 15..display_end];
let display_name = display_content.trim().to_string();
if !display_name.is_empty() {
debug!("Found display name in D:displayname: {}", display_name);
return Some(display_name);
}
}
}
// Format 2: Alternative namespace variants
let display_name_patterns = vec![
("<displayname>", "</displayname>"),
("<cal:displayname>", "</cal:displayname>"),
("<c:displayname>", "</c:displayname>"),
("<C:displayname>", "</C:displayname>"),
];
for (start_tag, end_tag) in display_name_patterns {
if let Some(display_start) = xml.find(start_tag) {
if let Some(display_end) = xml.find(end_tag) {
let display_content = &xml[display_start + start_tag.len()..display_end];
let display_name = display_content.trim().to_string();
if !display_name.is_empty() {
debug!("Found display name in {}: {}", start_tag, display_name);
return Some(display_name);
}
}
}
}
// Format 3: Check if display name might be in the calendar name itself (for Nextcloud)
// Some Nextcloud versions put the display name in resource metadata differently
if xml.contains("calendar-description") || xml.contains("calendar-color") {
// This looks like a Nextcloud calendar response, try to extract from other properties
// Look for title or name attributes in the XML
if let Some(title_start) = xml.find("title=") {
if let Some(title_end) = xml[title_start + 7..].find('"') {
let title_content = &xml[title_start + 7..title_start + 7 + title_end];
let title = title_content.trim().to_string();
if !title.is_empty() {
debug!("Found display name in title attribute: {}", title);
return Some(title);
}
}
}
}
debug!("No display name found in XML response");
None
}
} }
/// Calendar information from CalDAV server /// Calendar information from CalDAV server

480
src/nextcloud_import.rs Normal file
View file

@ -0,0 +1,480 @@
//! 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 for unidirectional sync
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ImportBehavior {
/// 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::Strict
}
}
impl std::fmt::Display for ImportBehavior {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImportBehavior::Strict => write!(f, "strict"),
ImportBehavior::StrictWithCleanup => write!(f, "strict_with_cleanup"),
}
}
}
impl std::str::FromStr for ImportBehavior {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"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)),
}
}
}
/// 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 (new)
pub imported: usize,
/// 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,
/// Details about failed imports
pub errors: Vec<ImportError>,
/// Details about conflicts that were resolved
pub conflicts: Vec<ConflictInfo>,
/// Start time of import process
pub start_time: DateTime<Utc>,
/// End time of import process
pub end_time: Option<DateTime<Utc>>,
/// 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,
updated: 0,
deleted: 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<chrono::Duration> {
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<String>,
/// Event summary/title
pub event_summary: Option<String>,
/// Error message
pub message: String,
/// Error type/category
pub error_type: ImportErrorType,
/// Timestamp when error occurred
pub timestamp: DateTime<Utc>,
}
/// 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<String>,
/// Target event version (if available)
pub target_version: Option<String>,
/// Timestamp when conflict was resolved
pub timestamp: DateTime<Utc>,
}
/// 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<Event>) -> Result<ImportResult> {
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<Event> {
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!("strict".parse::<ImportBehavior>(), Ok(ImportBehavior::Strict)));
assert!(matches!("strict_with_cleanup".parse::<ImportBehavior>(), Ok(ImportBehavior::StrictWithCleanup)));
assert!(matches!("strict-with-cleanup".parse::<ImportBehavior>(), Ok(ImportBehavior::StrictWithCleanup)));
assert!("invalid".parse::<ImportBehavior>().is_err());
}
#[test]
fn test_import_behavior_display() {
assert_eq!(ImportBehavior::Strict.to_string(), "strict");
assert_eq!(ImportBehavior::StrictWithCleanup.to_string(), "strict_with_cleanup");
}
#[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::Strict, 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::Strict, 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());
}
}

View file

@ -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<Self> {
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<Vec<CalendarInfo>> {
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<Utc>, end_date: DateTime<Utc>) -> Result<Vec<CalendarEvent>> {
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<String>,
pub color: Option<String>,
pub description: Option<String>,
pub timezone: Option<String>,
pub supported_components: Vec<String>,
}
/// Calendar event from CalDAV server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarEvent {
pub id: String,
pub href: String,
pub summary: String,
pub description: Option<String>,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
pub location: Option<String>,
pub status: Option<String>,
pub created: Option<DateTime<Utc>>,
pub last_modified: Option<DateTime<Utc>>,
pub sequence: i32,
pub transparency: Option<String>,
pub uid: Option<String>,
pub recurrence_id: Option<DateTime<Utc>>,
}
#[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");
}
}