Compare commits
No commits in common. "932b6ae463bb80b488a99d0fa1db3abfa8d65f0a" and "e8047fbba2ee8f9b0b30ba6941163be1f2a82edf" have entirely different histories.
932b6ae463
...
e8047fbba2
12 changed files with 468 additions and 3035 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,2 @@
|
||||||
/target
|
/target
|
||||||
config/config.toml
|
config/config.toml
|
||||||
config-test-import.toml
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
# 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
29
TODO.md
|
|
@ -1,29 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -39,27 +39,6 @@ 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)
|
||||||
|
|
|
||||||
133
src/config.rs
133
src/config.rs
|
|
@ -7,14 +7,10 @@ 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 {
|
||||||
/// Source server configuration (e.g., Zoho)
|
/// Server configuration
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
/// Source calendar configuration
|
/// 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
|
||||||
|
|
@ -53,64 +49,6 @@ 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 {
|
||||||
|
|
@ -159,8 +97,6 @@ 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(),
|
||||||
}
|
}
|
||||||
|
|
@ -192,40 +128,6 @@ 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 {
|
||||||
|
|
@ -300,37 +202,6 @@ 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)]
|
||||||
|
|
|
||||||
21
src/error.rs
21
src/error.rs
|
|
@ -127,20 +127,27 @@ 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]
|
||||||
|
|
@ -151,8 +158,10 @@ 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 rate_limit_error = CalDavError::RateLimited(60);
|
let network_error = CalDavError::Network(
|
||||||
assert!(!rate_limit_error.is_auth_error());
|
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test"))
|
||||||
assert!(!rate_limit_error.is_config_error());
|
);
|
||||||
|
assert!(!network_error.is_auth_error());
|
||||||
|
assert!(!network_error.is_config_error());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
764
src/event.rs
764
src/event.rs
|
|
@ -1,15 +1,10 @@
|
||||||
//! Event handling and iCalendar parsing
|
//! Event handling and iCalendar parsing
|
||||||
|
|
||||||
use crate::error::CalDavResult;
|
use crate::error::{CalDavError, CalDavResult};
|
||||||
use chrono::{DateTime, Utc, Datelike, Timelike};
|
use chrono::{DateTime, Utc, NaiveDateTime};
|
||||||
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)]
|
||||||
|
|
@ -116,107 +111,47 @@ pub enum ParticipationStatus {
|
||||||
Delegated,
|
Delegated,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recurrence rule (simplified RRULE string representation)
|
/// Recurrence rule
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RecurrenceRule {
|
pub struct RecurrenceRule {
|
||||||
/// Original RRULE string for storage and parsing
|
/// Frequency
|
||||||
pub original_rule: String,
|
pub frequency: RecurrenceFrequency,
|
||||||
|
/// 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>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RecurrenceRule {
|
/// Recurrence frequency
|
||||||
/// Create a new RecurrenceRule from an RRULE string
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub fn from_str(rrule_str: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
pub enum RecurrenceFrequency {
|
||||||
Ok(RecurrenceRule {
|
Secondly,
|
||||||
original_rule: rrule_str.to_string(),
|
Minutely,
|
||||||
})
|
Hourly,
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
Yearly,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the RRULE string
|
/// Day of week for recurrence
|
||||||
pub fn as_str(&self) -> &str {
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
&self.original_rule
|
pub enum WeekDay {
|
||||||
}
|
Sunday,
|
||||||
|
Monday,
|
||||||
/// Parse RRULE components from the original_rule string
|
Tuesday,
|
||||||
fn parse_components(&self) -> std::collections::HashMap<String, String> {
|
Wednesday,
|
||||||
let mut components = std::collections::HashMap::new();
|
Thursday,
|
||||||
|
Friday,
|
||||||
for part in self.original_rule.split(';') {
|
Saturday,
|
||||||
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
|
||||||
|
|
@ -253,38 +188,6 @@ 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 {
|
||||||
|
|
@ -371,28 +274,18 @@ 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 with timezone preservation
|
// Dates
|
||||||
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 {
|
||||||
|
|
@ -441,190 +334,6 @@ 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();
|
||||||
|
|
@ -647,301 +356,6 @@ 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
|
||||||
|
|
@ -955,11 +369,7 @@ 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)
|
||||||
|
|
@ -1034,102 +444,4 @@ 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,12 @@
|
||||||
|
|
||||||
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};
|
||||||
|
|
||||||
|
|
|
||||||
827
src/main.rs
827
src/main.rs
|
|
@ -3,12 +3,9 @@ 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")]
|
||||||
|
|
@ -61,30 +58,6 @@ 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]
|
||||||
|
|
@ -153,299 +126,24 @@ 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");
|
||||||
|
|
||||||
if cli.import_info {
|
// Get calendars directly from the client
|
||||||
println!("🔍 Import Analysis Report");
|
|
||||||
println!("========================\n");
|
|
||||||
|
|
||||||
// Show source calendars (current configuration)
|
|
||||||
println!("📤 SOURCE CALENDARS (Zoho/Current Server)");
|
|
||||||
println!("==========================================");
|
|
||||||
|
|
||||||
// Get calendars from the source server - handle errors gracefully
|
|
||||||
let source_calendars = match SyncEngine::new(config.clone()).await {
|
|
||||||
Ok(sync_engine) => {
|
|
||||||
match sync_engine.client.discover_calendars().await {
|
|
||||||
Ok(calendars) => {
|
|
||||||
Some(calendars)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("⚠️ Failed to discover source calendars: {}", e);
|
|
||||||
println!("Source server may be unavailable or credentials may be incorrect.\n");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("⚠️ Failed to connect to source server: {}", e);
|
|
||||||
println!("Source server configuration may need checking.\n");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let target_calendar_name = &config.calendar.name;
|
|
||||||
|
|
||||||
if let Some(ref calendars) = source_calendars {
|
|
||||||
println!("Found {} source calendars:", calendars.len());
|
|
||||||
println!("Current source calendar: {}\n", target_calendar_name);
|
|
||||||
|
|
||||||
for (i, calendar) in calendars.iter().enumerate() {
|
|
||||||
let is_target = calendar.name == *target_calendar_name
|
|
||||||
|| calendar.display_name.as_ref().map_or(false, |dn| dn == target_calendar_name);
|
|
||||||
|
|
||||||
// Calendar header with target indicator
|
|
||||||
if is_target {
|
|
||||||
println!(" {}. {} 🎯 [CURRENT SOURCE]", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
|
|
||||||
} else {
|
|
||||||
println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic information
|
|
||||||
println!(" Name: {}", calendar.name);
|
|
||||||
println!(" URL: {}", calendar.url);
|
|
||||||
|
|
||||||
if let Some(ref display_name) = calendar.display_name {
|
|
||||||
println!(" Display Name: {}", display_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import-relevant information
|
|
||||||
if let Some(ref color) = calendar.color {
|
|
||||||
println!(" Color: {}", color);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref description) = calendar.description {
|
|
||||||
println!(" Description: {}", description);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref timezone) = calendar.timezone {
|
|
||||||
println!(" Timezone: {}", timezone);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supported components - crucial for export compatibility
|
|
||||||
let components = &calendar.supported_components;
|
|
||||||
println!(" Supported Components: {}", components.join(", "));
|
|
||||||
|
|
||||||
// Export suitability analysis
|
|
||||||
let supports_events = components.contains(&"VEVENT".to_string());
|
|
||||||
let supports_todos = components.contains(&"VTODO".to_string());
|
|
||||||
let supports_journals = components.contains(&"VJOURNAL".to_string());
|
|
||||||
|
|
||||||
println!(" 📤 Export Analysis:");
|
|
||||||
println!(" Event Support: {}", if supports_events { "✅ Yes" } else { "❌ No" });
|
|
||||||
println!(" Task Support: {}", if supports_todos { "✅ Yes" } else { "❌ No" });
|
|
||||||
println!(" Journal Support: {}", if supports_journals { "✅ Yes" } else { "❌ No" });
|
|
||||||
|
|
||||||
// Server type detection
|
|
||||||
if calendar.url.contains("/zoho/") || calendar.url.contains("zoho.com") {
|
|
||||||
println!(" Server Type: 🔵 Zoho");
|
|
||||||
println!(" CalDAV Standard: ⚠️ Partially Compliant");
|
|
||||||
println!(" Special Features: Zoho-specific APIs available");
|
|
||||||
} else {
|
|
||||||
println!(" Server Type: 🔧 Generic CalDAV");
|
|
||||||
println!(" CalDAV Standard: ✅ Likely Compliant");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!("⚠️ Could not retrieve source calendars");
|
|
||||||
println!("Please check your source server configuration:\n");
|
|
||||||
println!(" URL: {}", config.server.url);
|
|
||||||
println!(" Username: {}", config.server.username);
|
|
||||||
println!(" Calendar: {}\n", config.calendar.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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?;
|
let calendars = sync_engine.client.discover_calendars().await?;
|
||||||
|
|
||||||
println!("Found {} calendars:", calendars.len());
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -458,519 +156,12 @@ 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 {
|
||||||
// Check if we should list events from import target calendar
|
// List events and exit
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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,
|
||||||
|
|
@ -26,7 +25,6 @@ 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 {
|
||||||
|
|
@ -65,7 +63,6 @@ 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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,70 +90,11 @@ 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(), url)
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self.base_url)
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.body(propfind_xml.to_string())
|
.body(propfind_xml)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -165,110 +103,15 @@ impl RealCalDavClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
let response_text = response.text().await?;
|
let response_text = response.text().await?;
|
||||||
debug!("PROPFIND response from {}: {}", url, response_text);
|
debug!("PROPFIND response: {}", 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
|
||||||
|
|
@ -410,116 +253,9 @@ 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>> {
|
||||||
// Enhanced XML parsing to extract multiple calendars from PROPFIND response
|
// Simple XML parsing - in a real implementation, use a proper XML parser
|
||||||
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
|
||||||
|
|
@ -538,6 +274,7 @@ 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 {
|
||||||
|
|
@ -551,22 +288,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -595,39 +316,25 @@ 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 (try multiple namespace variants)
|
// Look for calendar-data content in the XML response
|
||||||
let calendar_data_patterns = vec![
|
if let Some(start) = xml.find("<C:calendar-data>") {
|
||||||
("<C:calendar-data>", "</C:calendar-data>"),
|
if let Some(end) = xml.find("</C:calendar-data>") {
|
||||||
("<cal:calendar-data>", "</cal:calendar-data>"),
|
let ical_data = &xml[start + 17..end];
|
||||||
("<c:calendar-data>", "</c:calendar-data>"),
|
debug!("Found iCalendar data: {}", ical_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 using {}, trying next pattern", start_tag);
|
warn!("Failed to parse iCalendar data, falling back to mock");
|
||||||
|
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") {
|
||||||
|
|
@ -639,8 +346,11 @@ impl RealCalDavClient {
|
||||||
return self.parse_propfind_response(xml, calendar_href).await;
|
return self.parse_propfind_response(xml, calendar_href).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!("No calendar data found in XML response for calendar: {}", calendar_href);
|
return self.create_mock_event(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
|
||||||
|
|
@ -1054,6 +764,35 @@ 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
|
||||||
|
|
@ -1093,64 +832,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,480 +0,0 @@
|
||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
293
src/real_caldav_client.rs
Normal file
293
src/real_caldav_client.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
//! 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue