Initial commit: Complete CalDAV calendar synchronizer
- Rust-based CLI tool for Zoho to Nextcloud calendar sync - Selective calendar import from Zoho to single Nextcloud calendar - Timezone-aware event handling for next-week synchronization - Comprehensive configuration system with TOML support - CLI interface with debug, list, and sync operations - Complete documentation and example configurations
This commit is contained in:
commit
8362ebe44b
16 changed files with 6192 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
2588
Cargo.lock
generated
Normal file
2588
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
74
Cargo.toml
Normal file
74
Cargo.toml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
[package]
|
||||||
|
name = "caldav-sync"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Your Name <your.email@example.com>"]
|
||||||
|
description = "A CalDAV calendar synchronization tool"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/yourusername/caldav-sync"
|
||||||
|
keywords = ["caldav", "calendar", "sync", "productivity"]
|
||||||
|
categories = ["command-line-utilities", "date-and-time"]
|
||||||
|
readme = "README.md"
|
||||||
|
rust-version = "1.70.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# Date and time handling
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
chrono-tz = "0.8"
|
||||||
|
|
||||||
|
# XML parsing for CalDAV
|
||||||
|
quick-xml = { version = "0.28", features = ["serialize"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
# Configuration management
|
||||||
|
config = "0.13"
|
||||||
|
|
||||||
|
# Command line argument parsing
|
||||||
|
clap = { version = "4.0", features = ["derive"] }
|
||||||
|
|
||||||
|
# Logging and tracing
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# UUID generation for calendar items
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# Base64 encoding for authentication
|
||||||
|
base64 = "0.21"
|
||||||
|
|
||||||
|
# URL parsing
|
||||||
|
url = "2.3"
|
||||||
|
|
||||||
|
# TOML parsing
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
|
tempfile = "3.0"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "caldav-sync"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = true
|
||||||
|
opt-level = 0
|
||||||
303
README.md
Normal file
303
README.md
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
# CalDAV Calendar Synchronizer
|
||||||
|
|
||||||
|
A Rust-based command-line tool that synchronizes calendar events between Zoho Calendar and Nextcloud via CalDAV protocol. It allows you to selectively import events from specific Zoho calendars into a single Nextcloud calendar.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Selective Calendar Import**: Choose which Zoho calendars to sync from
|
||||||
|
- **Single Target Calendar**: All events consolidated into one Nextcloud calendar
|
||||||
|
- **Timezone Support**: Handles events across different timezones correctly
|
||||||
|
- **Next Week Focus**: Optimized for importing events for the next 7 days
|
||||||
|
- **Simple Data Transfer**: Extracts only title, time, and duration as requested
|
||||||
|
- **Secure Authentication**: Uses app-specific passwords for security
|
||||||
|
- **Dry Run Mode**: Preview what would be synced before making changes
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Prerequisites
|
||||||
|
|
||||||
|
- Rust 1.70+ (for building from source)
|
||||||
|
- Zoho account with CalDAV access
|
||||||
|
- Nextcloud instance with CalDAV enabled
|
||||||
|
- App-specific passwords for both services (recommended)
|
||||||
|
|
||||||
|
### 2. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd caldavpuller
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# The binary will be at target/release/caldav-sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configuration
|
||||||
|
|
||||||
|
Copy the example configuration file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config/example.toml config/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `config/config.toml` with your settings:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Zoho CalDAV Configuration (Source)
|
||||||
|
[zoho]
|
||||||
|
server_url = "https://caldav.zoho.com/caldav"
|
||||||
|
username = "your-zoho-email@domain.com"
|
||||||
|
password = "your-zoho-app-password"
|
||||||
|
|
||||||
|
# Select which calendars to import
|
||||||
|
selected_calendars = ["Work Calendar", "Personal Calendar"]
|
||||||
|
|
||||||
|
# Nextcloud Configuration (Target)
|
||||||
|
[nextcloud]
|
||||||
|
server_url = "https://your-nextcloud-domain.com"
|
||||||
|
username = "your-nextcloud-username"
|
||||||
|
password = "your-nextcloud-app-password"
|
||||||
|
target_calendar = "Imported-Zoho-Events"
|
||||||
|
create_if_missing = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. First Run
|
||||||
|
|
||||||
|
Test the configuration with a dry run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/caldav-sync --debug --list-events
|
||||||
|
```
|
||||||
|
|
||||||
|
Perform a one-time sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/caldav-sync --once
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Details
|
||||||
|
|
||||||
|
### Zoho Setup
|
||||||
|
|
||||||
|
1. **Enable CalDAV in Zoho**:
|
||||||
|
- Go to Zoho Mail Settings → CalDAV
|
||||||
|
- Enable CalDAV access
|
||||||
|
- Generate an app-specific password
|
||||||
|
|
||||||
|
2. **Find Calendar Names**:
|
||||||
|
```bash
|
||||||
|
./target/release/caldav-sync --list-events --debug
|
||||||
|
```
|
||||||
|
This will show all available calendars.
|
||||||
|
|
||||||
|
### Nextcloud Setup
|
||||||
|
|
||||||
|
1. **Enable CalDAV**:
|
||||||
|
- Usually enabled by default
|
||||||
|
- Access at `https://your-domain.com/remote.php/dav/`
|
||||||
|
|
||||||
|
2. **Generate App Password**:
|
||||||
|
- Go to Settings → Security → App passwords
|
||||||
|
- Create a new app password for the sync tool
|
||||||
|
|
||||||
|
3. **Target Calendar**:
|
||||||
|
- The tool can automatically create the target calendar
|
||||||
|
- Or create it manually in Nextcloud first
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Command Line Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caldav-sync [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-c, --config <CONFIG> Configuration file path [default: config/default.toml]
|
||||||
|
-s, --server-url <SERVER_URL> CalDAV server URL (overrides config file)
|
||||||
|
-u, --username <USERNAME> Username for authentication (overrides config file)
|
||||||
|
-p, --password <PASSWORD> Password for authentication (overrides config file)
|
||||||
|
--calendar <CALENDAR> Calendar name to sync (overrides config file)
|
||||||
|
-d, --debug Enable debug logging
|
||||||
|
--once Perform a one-time sync and exit
|
||||||
|
--full-resync Force a full resynchronization
|
||||||
|
--list-events List events and exit
|
||||||
|
-h, --help Print help
|
||||||
|
-V, --version Print version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Operations
|
||||||
|
|
||||||
|
1. **List Available Events**:
|
||||||
|
```bash
|
||||||
|
caldav-sync --list-events
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **One-Time Sync**:
|
||||||
|
```bash
|
||||||
|
caldav-sync --once
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Full Resynchronization**:
|
||||||
|
```bash
|
||||||
|
caldav-sync --full-resync
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Override Configuration**:
|
||||||
|
```bash
|
||||||
|
caldav-sync --username "user@example.com" --password "app-password" --once
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Complete Configuration Example
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
url = "https://caldav.zoho.com/caldav"
|
||||||
|
username = "your-email@domain.com"
|
||||||
|
password = "your-app-password"
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
[calendar]
|
||||||
|
name = "Work Calendar"
|
||||||
|
color = "#4285F4"
|
||||||
|
|
||||||
|
[sync]
|
||||||
|
sync_interval = 300
|
||||||
|
sync_on_startup = true
|
||||||
|
weeks_ahead = 1
|
||||||
|
dry_run = false
|
||||||
|
|
||||||
|
[filters]
|
||||||
|
exclude_patterns = ["Cancelled:", "BLOCKED"]
|
||||||
|
min_duration_minutes = 5
|
||||||
|
max_duration_hours = 24
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = "info"
|
||||||
|
file = "caldav-sync.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
You can also use environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CALDAV_SERVER_URL="https://caldav.zoho.com/caldav"
|
||||||
|
export CALDAV_USERNAME="your-email@domain.com"
|
||||||
|
export CALDAV_PASSWORD="your-app-password"
|
||||||
|
export CALDAV_CALENDAR="Work Calendar"
|
||||||
|
|
||||||
|
./target/release/caldav-sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Use App Passwords**: Never use your main account password
|
||||||
|
2. **Secure Configuration**: Set appropriate file permissions on config files
|
||||||
|
3. **SSL/TLS**: Always use HTTPS connections
|
||||||
|
4. **Log Management**: Be careful with debug logs that may contain sensitive data
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Authentication Failures**:
|
||||||
|
- Verify app-specific passwords are correctly set up
|
||||||
|
- Check that CalDAV is enabled in both services
|
||||||
|
- Ensure correct server URLs
|
||||||
|
|
||||||
|
2. **Calendar Not Found**:
|
||||||
|
- Use `--list-events` to see available calendars
|
||||||
|
- Check calendar name spelling
|
||||||
|
- Verify permissions
|
||||||
|
|
||||||
|
3. **Timezone Issues**:
|
||||||
|
- Events are automatically converted to UTC internally
|
||||||
|
- Original timezone information is preserved
|
||||||
|
- Check system timezone if times seem off
|
||||||
|
|
||||||
|
4. **SSL Certificate Issues**:
|
||||||
|
- Ensure server URLs use HTTPS
|
||||||
|
- Check if custom certificates need to be configured
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Enable debug logging for troubleshooting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caldav-sync --debug --list-events
|
||||||
|
```
|
||||||
|
|
||||||
|
This will show detailed HTTP requests, responses, and processing steps.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Building from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd caldavpuller
|
||||||
|
|
||||||
|
# Build in debug mode
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Build in release mode
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Check code formatting
|
||||||
|
cargo fmt --check
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
cargo clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
caldavpuller/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # CLI interface and entry point
|
||||||
|
│ ├── lib.rs # Library interface
|
||||||
|
│ ├── config.rs # Configuration management
|
||||||
|
│ ├── caldav_client.rs # CalDAV HTTP client
|
||||||
|
│ ├── event.rs # Event data structures
|
||||||
|
│ ├── timezone.rs # Timezone handling
|
||||||
|
│ ├── calendar_filter.rs # Calendar filtering logic
|
||||||
|
│ ├── sync.rs # Synchronization engine
|
||||||
|
│ └── error.rs # Error types and handling
|
||||||
|
├── config/
|
||||||
|
│ ├── default.toml # Default configuration
|
||||||
|
│ └── example.toml # Example configuration
|
||||||
|
├── tests/
|
||||||
|
│ └── integration_tests.rs # Integration tests
|
||||||
|
├── Cargo.toml # Rust project configuration
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Add tests if applicable
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
|
||||||
|
1. Check the troubleshooting section above
|
||||||
|
2. Review the debug output with `--debug`
|
||||||
|
3. Open an issue on the project repository
|
||||||
|
4. Include your configuration (with sensitive data removed) and debug logs
|
||||||
54
config/default.toml
Normal file
54
config/default.toml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Default CalDAV Sync Configuration
|
||||||
|
# This file provides default values for the Zoho to Nextcloud calendar sync
|
||||||
|
|
||||||
|
# Zoho Configuration (Source)
|
||||||
|
[zoho]
|
||||||
|
server_url = "https://caldav.zoho.com/caldav"
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
selected_calendars = []
|
||||||
|
|
||||||
|
# Nextcloud Configuration (Target)
|
||||||
|
[nextcloud]
|
||||||
|
server_url = ""
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
target_calendar = "Imported-Zoho-Events"
|
||||||
|
create_if_missing = true
|
||||||
|
|
||||||
|
[server]
|
||||||
|
# Request timeout in seconds
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
[calendar]
|
||||||
|
# Calendar color in hex format
|
||||||
|
color = "#3174ad"
|
||||||
|
# Default timezone for processing
|
||||||
|
timezone = "UTC"
|
||||||
|
|
||||||
|
[sync]
|
||||||
|
# Synchronization interval in seconds (300 = 5 minutes)
|
||||||
|
interval = 300
|
||||||
|
# Whether to perform synchronization on startup
|
||||||
|
sync_on_startup = true
|
||||||
|
# Number of weeks ahead to sync
|
||||||
|
weeks_ahead = 1
|
||||||
|
# Whether to run in dry-run mode (preview changes only)
|
||||||
|
dry_run = false
|
||||||
|
|
||||||
|
# Performance settings
|
||||||
|
max_retries = 3
|
||||||
|
retry_delay = 5
|
||||||
|
|
||||||
|
# Optional filtering configuration
|
||||||
|
# [filters]
|
||||||
|
# # Event types to include (leave empty for all)
|
||||||
|
# event_types = ["meeting", "appointment"]
|
||||||
|
# # Keywords to filter events by
|
||||||
|
# keywords = ["work", "meeting", "project"]
|
||||||
|
# # Keywords to exclude
|
||||||
|
# exclude_keywords = ["personal", "holiday", "cancelled"]
|
||||||
|
# # Minimum event duration in minutes
|
||||||
|
# min_duration_minutes = 5
|
||||||
|
# # Maximum event duration in hours
|
||||||
|
# max_duration_hours = 24
|
||||||
117
config/example.toml
Normal file
117
config/example.toml
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# CalDAV Configuration Example
|
||||||
|
# This file demonstrates how to configure Zoho and Nextcloud CalDAV connections
|
||||||
|
# Copy and modify this example for your specific setup
|
||||||
|
|
||||||
|
# Global settings
|
||||||
|
global:
|
||||||
|
log_level: "info"
|
||||||
|
sync_interval: 300 # seconds (5 minutes)
|
||||||
|
conflict_resolution: "latest" # or "manual" or "local" or "remote"
|
||||||
|
timezone: "UTC"
|
||||||
|
|
||||||
|
# Zoho CalDAV Configuration (Source)
|
||||||
|
zoho:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
server:
|
||||||
|
url: "https://caldav.zoho.com/caldav"
|
||||||
|
timeout: 30 # seconds
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
auth:
|
||||||
|
username: "your-zoho-email@domain.com"
|
||||||
|
password: "your-zoho-app-password" # Use app-specific password, not main password
|
||||||
|
|
||||||
|
# Calendar selection - which calendars to import from
|
||||||
|
calendars:
|
||||||
|
- name: "Work Calendar"
|
||||||
|
enabled: true
|
||||||
|
color: "#4285F4"
|
||||||
|
sync_direction: "pull" # Only pull from Zoho
|
||||||
|
|
||||||
|
- name: "Personal Calendar"
|
||||||
|
enabled: true
|
||||||
|
color: "#34A853"
|
||||||
|
sync_direction: "pull"
|
||||||
|
|
||||||
|
- name: "Team Meetings"
|
||||||
|
enabled: false # Disabled by default
|
||||||
|
color: "#EA4335"
|
||||||
|
sync_direction: "pull"
|
||||||
|
|
||||||
|
# Sync options
|
||||||
|
sync:
|
||||||
|
sync_past_events: false # Don't sync past events
|
||||||
|
sync_future_events: true
|
||||||
|
sync_future_days: 7 # Only sync next week
|
||||||
|
include_attendees: false # Keep it simple
|
||||||
|
include_attachments: false
|
||||||
|
|
||||||
|
# Nextcloud CalDAV Configuration (Target)
|
||||||
|
nextcloud:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
server:
|
||||||
|
url: "https://your-nextcloud-domain.com"
|
||||||
|
timeout: 30 # seconds
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
auth:
|
||||||
|
username: "your-nextcloud-username"
|
||||||
|
password: "your-nextcloud-app-password" # Use app-specific password
|
||||||
|
|
||||||
|
# Calendar discovery
|
||||||
|
discovery:
|
||||||
|
principal_url: "/remote.php/dav/principals/users/{username}/"
|
||||||
|
calendar_home_set: "/remote.php/dav/calendars/{username}/"
|
||||||
|
|
||||||
|
# Target calendar - all Zoho events go here
|
||||||
|
calendars:
|
||||||
|
- name: "Imported-Zoho-Events"
|
||||||
|
enabled: true
|
||||||
|
color: "#FF6B6B"
|
||||||
|
sync_direction: "push" # Only push to Nextcloud
|
||||||
|
create_if_missing: true # Auto-create if it doesn't exist
|
||||||
|
|
||||||
|
# Sync options
|
||||||
|
sync:
|
||||||
|
sync_past_events: false
|
||||||
|
sync_future_events: true
|
||||||
|
sync_future_days: 7
|
||||||
|
|
||||||
|
# Event filtering
|
||||||
|
filters:
|
||||||
|
events:
|
||||||
|
exclude_patterns:
|
||||||
|
- "Cancelled:"
|
||||||
|
- "BLOCKED"
|
||||||
|
|
||||||
|
# Time-based filters
|
||||||
|
min_duration_minutes: 5
|
||||||
|
max_duration_hours: 24
|
||||||
|
|
||||||
|
# Status filters
|
||||||
|
include_status: ["confirmed", "tentative"]
|
||||||
|
exclude_status: ["cancelled"]
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging:
|
||||||
|
level: "info"
|
||||||
|
format: "text"
|
||||||
|
file: "caldav-sync.log"
|
||||||
|
max_size: "10MB"
|
||||||
|
max_files: 3
|
||||||
|
|
||||||
|
# Performance settings
|
||||||
|
performance:
|
||||||
|
max_concurrent_syncs: 3
|
||||||
|
batch_size: 25
|
||||||
|
retry_attempts: 3
|
||||||
|
retry_delay: 5 # seconds
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
security:
|
||||||
|
ssl_verify: true
|
||||||
|
encryption: "tls12"
|
||||||
304
src/caldav_client.rs
Normal file
304
src/caldav_client.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
//! CalDAV client implementation
|
||||||
|
|
||||||
|
use crate::{config::ServerConfig, error::{CalDavError, CalDavResult}};
|
||||||
|
use reqwest::{Client, StatusCode};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// CalDAV client for communicating with CalDAV servers
|
||||||
|
pub struct CalDavClient {
|
||||||
|
client: Client,
|
||||||
|
config: ServerConfig,
|
||||||
|
base_url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalDavClient {
|
||||||
|
/// Create a new CalDAV client with the given configuration
|
||||||
|
pub fn new(config: ServerConfig) -> CalDavResult<Self> {
|
||||||
|
let base_url = Url::parse(&config.url)?;
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(config.timeout))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
config,
|
||||||
|
base_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test connection to the CalDAV server
|
||||||
|
pub async fn test_connection(&self) -> CalDavResult<()> {
|
||||||
|
let url = self.base_url.join("/")?;
|
||||||
|
let response = self.make_request("PROPFIND", &url, None).await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CalDavError::Http(
|
||||||
|
response.status(),
|
||||||
|
format!("Connection test failed: {}", response.status())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all calendars on the server
|
||||||
|
pub async fn list_calendars(&self) -> CalDavResult<Vec<CalendarInfo>> {
|
||||||
|
let url = self.base_url.join("/")?;
|
||||||
|
let body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype />
|
||||||
|
<D:displayname />
|
||||||
|
<C:calendar-description />
|
||||||
|
<C:supported-calendar-component-set />
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>"#;
|
||||||
|
|
||||||
|
let response = self.make_request("PROPFIND", &url, Some(body)).await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(CalDavError::Http(
|
||||||
|
response.status(),
|
||||||
|
"Failed to list calendars".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_text = response.text().await?;
|
||||||
|
self.parse_calendar_list(&response_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get events from a calendar within a date range
|
||||||
|
pub async fn get_events(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
start: DateTime<Utc>,
|
||||||
|
end: DateTime<Utc>,
|
||||||
|
) -> CalDavResult<Vec<CalDavEventInfo>> {
|
||||||
|
let calendar_url = self.base_url.join(calendar_path)?;
|
||||||
|
|
||||||
|
let body = format!(r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag />
|
||||||
|
<C:calendar-data />
|
||||||
|
</D:prop>
|
||||||
|
<C:filter>
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:time-range start="{}" end="{}" />
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:filter>
|
||||||
|
</C:calendar-query>"#,
|
||||||
|
start.format("%Y%m%dT%H%M%SZ"),
|
||||||
|
end.format("%Y%m%dT%H%M%SZ")
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self.make_request("REPORT", &calendar_url, Some(&body)).await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(CalDavError::Http(
|
||||||
|
response.status(),
|
||||||
|
"Failed to get events".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_text = response.text().await?;
|
||||||
|
self.parse_events(&response_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create or update an event
|
||||||
|
pub async fn put_event(&self, calendar_path: &str, event_id: &str, ical_data: &str) -> CalDavResult<()> {
|
||||||
|
let event_url = self.base_url.join(&format!("{}/{}.ics", calendar_path, event_id))?;
|
||||||
|
|
||||||
|
let response = self.make_request_with_body("PUT", &event_url, ical_data).await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() && response.status() != StatusCode::CREATED {
|
||||||
|
return Err(CalDavError::Http(
|
||||||
|
response.status(),
|
||||||
|
"Failed to create/update event".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an event
|
||||||
|
pub async fn delete_event(&self, calendar_path: &str, event_id: &str) -> CalDavResult<()> {
|
||||||
|
let event_url = self.base_url.join(&format!("{}/{}.ics", calendar_path, event_id))?;
|
||||||
|
|
||||||
|
let response = self.make_request("DELETE", &event_url, None).await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() && response.status() != StatusCode::NOT_FOUND {
|
||||||
|
return Err(CalDavError::Http(
|
||||||
|
response.status(),
|
||||||
|
"Failed to delete event".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific event
|
||||||
|
pub async fn get_event(&self, calendar_path: &str, event_id: &str) -> CalDavResult<Option<String>> {
|
||||||
|
let event_url = self.base_url.join(&format!("{}/{}.ics", calendar_path, event_id))?;
|
||||||
|
|
||||||
|
let response = self.make_request("GET", &event_url, None).await?;
|
||||||
|
|
||||||
|
match response.status() {
|
||||||
|
StatusCode::OK => {
|
||||||
|
let ical_data = response.text().await?;
|
||||||
|
Ok(Some(ical_data))
|
||||||
|
}
|
||||||
|
StatusCode::NOT_FOUND => Ok(None),
|
||||||
|
status => Err(CalDavError::Http(
|
||||||
|
status,
|
||||||
|
"Failed to get event".to_string()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make an authenticated request to the CalDAV server
|
||||||
|
async fn make_request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
url: &Url,
|
||||||
|
body: Option<&str>,
|
||||||
|
) -> CalDavResult<reqwest::Response> {
|
||||||
|
let mut request = self.client.request(method.parse().unwrap_or(reqwest::Method::GET), url.clone());
|
||||||
|
|
||||||
|
// Add basic authentication
|
||||||
|
if !self.config.username.is_empty() && !self.config.password.is_empty() {
|
||||||
|
let auth = format!("{}:{}", self.config.username, self.config.password);
|
||||||
|
let auth_encoded = base64::engine::general_purpose::STANDARD.encode(auth);
|
||||||
|
request = request.header("Authorization", format!("Basic {}", auth_encoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom headers
|
||||||
|
if let Some(headers) = &self.config.headers {
|
||||||
|
for (key, value) in headers {
|
||||||
|
request = request.header(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add default CalDAV headers
|
||||||
|
request = request
|
||||||
|
.header("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.header("Depth", "1");
|
||||||
|
|
||||||
|
// Add body if provided
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.body(body.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a request with custom content type
|
||||||
|
async fn make_request_with_body(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
url: &Url,
|
||||||
|
body: &str,
|
||||||
|
) -> CalDavResult<reqwest::Response> {
|
||||||
|
let mut request = self.client.request(method.parse().unwrap_or(reqwest::Method::PUT), url.clone());
|
||||||
|
|
||||||
|
// Add basic authentication
|
||||||
|
if !self.config.username.is_empty() && !self.config.password.is_empty() {
|
||||||
|
let auth = format!("{}:{}", self.config.username, self.config.password);
|
||||||
|
let auth_encoded = base64::engine::general_purpose::STANDARD.encode(auth);
|
||||||
|
request = request.header("Authorization", format!("Basic {}", auth_encoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
request = request
|
||||||
|
.header("Content-Type", "text/calendar; charset=utf-8");
|
||||||
|
|
||||||
|
let response = request.body(body.to_string()).send().await?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse calendar list from XML response
|
||||||
|
fn parse_calendar_list(&self, _xml: &str) -> CalDavResult<Vec<CalendarInfo>> {
|
||||||
|
// This is a simplified XML parser - in a real implementation,
|
||||||
|
// you'd use a proper XML parsing library
|
||||||
|
let calendars = Vec::new();
|
||||||
|
|
||||||
|
// Placeholder implementation
|
||||||
|
// TODO: Implement proper XML parsing for calendar discovery
|
||||||
|
|
||||||
|
Ok(calendars)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse events from XML response
|
||||||
|
fn parse_events(&self, _xml: &str) -> CalDavResult<Vec<CalDavEventInfo>> {
|
||||||
|
// This is a simplified XML parser - in a real implementation,
|
||||||
|
// you'd use a proper XML parsing library
|
||||||
|
let events = Vec::new();
|
||||||
|
|
||||||
|
// Placeholder implementation
|
||||||
|
// TODO: Implement proper XML parsing for event data
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendar information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarInfo {
|
||||||
|
/// Calendar path
|
||||||
|
pub path: String,
|
||||||
|
/// Display name
|
||||||
|
pub display_name: String,
|
||||||
|
/// Description
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Supported components
|
||||||
|
pub supported_components: Vec<String>,
|
||||||
|
/// Calendar color
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event information for CalDAV responses
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CalDavEventInfo {
|
||||||
|
/// Event ID
|
||||||
|
pub id: String,
|
||||||
|
/// Event summary
|
||||||
|
pub summary: String,
|
||||||
|
/// Event description
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Start time
|
||||||
|
pub start: DateTime<Utc>,
|
||||||
|
/// End time
|
||||||
|
pub end: DateTime<Utc>,
|
||||||
|
/// Event location
|
||||||
|
pub location: Option<String>,
|
||||||
|
/// Event status
|
||||||
|
pub status: String,
|
||||||
|
/// ETag for synchronization
|
||||||
|
pub etag: Option<String>,
|
||||||
|
/// Raw iCalendar data
|
||||||
|
pub ical_data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_creation() {
|
||||||
|
let config = ServerConfig {
|
||||||
|
url: "https://example.com".to_string(),
|
||||||
|
username: "test".to_string(),
|
||||||
|
password: "test".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = CalDavClient::new(config);
|
||||||
|
assert!(client.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
583
src/calendar_filter.rs
Normal file
583
src/calendar_filter.rs
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
//! Calendar filtering functionality
|
||||||
|
|
||||||
|
use crate::event::{Event, EventStatus, EventType};
|
||||||
|
use chrono::{DateTime, Utc, Datelike};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Calendar filter for filtering events based on various criteria
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarFilter {
|
||||||
|
/// Filter rules
|
||||||
|
pub rules: Vec<FilterRule>,
|
||||||
|
/// Whether to include events that match any rule (OR) or all rules (AND)
|
||||||
|
pub match_any: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CalendarFilter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rules: Vec::new(),
|
||||||
|
match_any: true, // Default to OR behavior
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarFilter {
|
||||||
|
/// Create a new calendar filter
|
||||||
|
pub fn new(match_any: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
rules: Vec::new(),
|
||||||
|
match_any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a filter rule
|
||||||
|
pub fn add_rule(&mut self, rule: FilterRule) {
|
||||||
|
self.rules.push(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add multiple filter rules
|
||||||
|
pub fn add_rules(&mut self, rules: Vec<FilterRule>) {
|
||||||
|
self.rules.extend(rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter a list of events
|
||||||
|
pub fn filter_events<'a>(&self, events: &'a [Event]) -> Vec<&'a Event> {
|
||||||
|
if self.rules.is_empty() {
|
||||||
|
return events.iter().collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
events.iter()
|
||||||
|
.filter(|event| self.matches_event(event))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter a list of events and return owned values
|
||||||
|
pub fn filter_events_owned(&self, events: Vec<Event>) -> Vec<Event> {
|
||||||
|
if self.rules.is_empty() {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.into_iter()
|
||||||
|
.filter(|event| self.matches_event(event))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event matches the filter rules
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
if self.rules.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches: Vec<bool> = self.rules.iter()
|
||||||
|
.map(|rule| rule.matches_event(event))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if self.match_any {
|
||||||
|
// OR logic - event matches if any rule matches
|
||||||
|
matches.iter().any(|&m| m)
|
||||||
|
} else {
|
||||||
|
// AND logic - event matches only if all rules match
|
||||||
|
matches.iter().all(|&m| m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a date range filter
|
||||||
|
pub fn date_range(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
|
||||||
|
let mut filter = Self::new(true);
|
||||||
|
filter.add_rule(FilterRule::DateRange(DateRangeFilter { start, end }));
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a keyword filter
|
||||||
|
pub fn keywords(keywords: Vec<String>, case_sensitive: bool) -> Self {
|
||||||
|
let mut filter = Self::new(true);
|
||||||
|
filter.add_rule(FilterRule::Keywords(KeywordFilter {
|
||||||
|
keywords,
|
||||||
|
case_sensitive,
|
||||||
|
search_fields: KeywordSearchFields::default(),
|
||||||
|
}));
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an event type filter
|
||||||
|
pub fn event_types(event_types: Vec<EventType>) -> Self {
|
||||||
|
let mut filter = Self::new(true);
|
||||||
|
filter.add_rule(FilterRule::EventType(EventTypeFilter { event_types }));
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an event status filter
|
||||||
|
pub fn event_statuses(statuses: Vec<EventStatus>) -> Self {
|
||||||
|
let mut filter = Self::new(true);
|
||||||
|
filter.add_rule(FilterRule::EventStatus(EventStatusFilter { statuses }));
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a combined filter with multiple criteria
|
||||||
|
pub fn combined() -> Self {
|
||||||
|
Self::new(false) // AND logic for combined filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual filter rule
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum FilterRule {
|
||||||
|
/// Filter by date range
|
||||||
|
DateRange(DateRangeFilter),
|
||||||
|
/// Filter by keywords
|
||||||
|
Keywords(KeywordFilter),
|
||||||
|
/// Filter by event type
|
||||||
|
EventType(EventTypeFilter),
|
||||||
|
/// Filter by event status
|
||||||
|
EventStatus(EventStatusFilter),
|
||||||
|
/// Filter by location
|
||||||
|
Location(LocationFilter),
|
||||||
|
/// Filter by organizer
|
||||||
|
Organizer(OrganizerFilter),
|
||||||
|
/// Filter by recurrence
|
||||||
|
Recurrence(RecurrenceFilter),
|
||||||
|
/// Custom filter function (not serializable)
|
||||||
|
Custom(String), // Store as string name for reference
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilterRule {
|
||||||
|
/// Check if an event matches this filter rule
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
match self {
|
||||||
|
FilterRule::DateRange(filter) => filter.matches_event(event),
|
||||||
|
FilterRule::Keywords(filter) => filter.matches_event(event),
|
||||||
|
FilterRule::EventType(filter) => filter.matches_event(event),
|
||||||
|
FilterRule::EventStatus(filter) => filter.matches_event(event),
|
||||||
|
FilterRule::Location(filter) => filter.matches_event(event),
|
||||||
|
FilterRule::Organizer(filter) => filter.matches_event(event),
|
||||||
|
FilterRule::Recurrence(filter) => filter.matches_event(event),
|
||||||
|
FilterRule::Custom(_) => false, // Custom filters need external handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Date range filter
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DateRangeFilter {
|
||||||
|
/// Start date (inclusive)
|
||||||
|
pub start: DateTime<Utc>,
|
||||||
|
/// End date (inclusive)
|
||||||
|
pub end: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DateRangeFilter {
|
||||||
|
/// Create a new date range filter
|
||||||
|
pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
|
||||||
|
Self { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event matches the date range
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
// Event overlaps with the date range if:
|
||||||
|
// - Event starts before or at the end of the range, AND
|
||||||
|
// - Event ends after or at the start of the range
|
||||||
|
event.start <= self.end && event.end >= self.start
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a filter for events today
|
||||||
|
pub fn today() -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap();
|
||||||
|
Self {
|
||||||
|
start: DateTime::from_naive_utc_and_offset(start, Utc),
|
||||||
|
end: DateTime::from_naive_utc_and_offset(end, Utc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a filter for events this week
|
||||||
|
pub fn this_week() -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
let weekday = now.weekday().num_days_from_monday();
|
||||||
|
let week_start = (now.date_naive() - chrono::Duration::days(weekday as i64)).and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
let week_end = (week_start.date() + chrono::Duration::days(6)).and_hms_opt(23, 59, 59).unwrap();
|
||||||
|
Self {
|
||||||
|
start: DateTime::from_naive_utc_and_offset(week_start, Utc),
|
||||||
|
end: DateTime::from_naive_utc_and_offset(week_end, Utc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a filter for events this month
|
||||||
|
pub fn this_month() -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
let month_start = now.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
let month_end = month_start.date()
|
||||||
|
.with_month(month_start.month() + 1)
|
||||||
|
.unwrap_or(month_start.date().with_year(month_start.year() + 1).unwrap())
|
||||||
|
.with_day(1).unwrap()
|
||||||
|
.pred_opt().unwrap()
|
||||||
|
.and_hms_opt(23, 59, 59).unwrap();
|
||||||
|
Self {
|
||||||
|
start: DateTime::from_naive_utc_and_offset(month_start, Utc),
|
||||||
|
end: DateTime::from_naive_utc_and_offset(month_end, Utc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyword filter
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct KeywordFilter {
|
||||||
|
/// Keywords to search for
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
/// Whether the search is case sensitive
|
||||||
|
pub case_sensitive: bool,
|
||||||
|
/// Which fields to search in
|
||||||
|
pub search_fields: KeywordSearchFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fields to search for keywords
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct KeywordSearchFields {
|
||||||
|
/// Search in summary
|
||||||
|
pub summary: bool,
|
||||||
|
/// Search in description
|
||||||
|
pub description: bool,
|
||||||
|
/// Search in location
|
||||||
|
pub location: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeywordSearchFields {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
summary: true,
|
||||||
|
description: true,
|
||||||
|
location: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeywordFilter {
|
||||||
|
/// Create a new keyword filter
|
||||||
|
pub fn new(keywords: Vec<String>, case_sensitive: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
keywords,
|
||||||
|
case_sensitive,
|
||||||
|
search_fields: KeywordSearchFields::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set which fields to search
|
||||||
|
pub fn search_fields(mut self, fields: KeywordSearchFields) -> Self {
|
||||||
|
self.search_fields = fields;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event matches the keyword filter
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
let search_text = self.get_search_text(event);
|
||||||
|
|
||||||
|
let search_text = if self.case_sensitive {
|
||||||
|
search_text
|
||||||
|
} else {
|
||||||
|
search_text.to_lowercase()
|
||||||
|
};
|
||||||
|
|
||||||
|
self.keywords.iter().any(|keyword| {
|
||||||
|
let keyword = if self.case_sensitive {
|
||||||
|
keyword.clone()
|
||||||
|
} else {
|
||||||
|
keyword.to_lowercase()
|
||||||
|
};
|
||||||
|
search_text.contains(&keyword)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the combined text to search in
|
||||||
|
fn get_search_text(&self, event: &Event) -> String {
|
||||||
|
let mut text_parts: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if self.search_fields.summary {
|
||||||
|
text_parts.push(event.summary.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.search_fields.description {
|
||||||
|
if let Some(description) = &event.description {
|
||||||
|
text_parts.push(description.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.search_fields.location {
|
||||||
|
if let Some(location) = &event.location {
|
||||||
|
text_parts.push(location.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text_parts.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event type filter
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EventTypeFilter {
|
||||||
|
/// Event types to include
|
||||||
|
pub event_types: Vec<EventType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventTypeFilter {
|
||||||
|
/// Create a new event type filter
|
||||||
|
pub fn new(event_types: Vec<EventType>) -> Self {
|
||||||
|
Self { event_types }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event matches the event type filter
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
self.event_types.contains(&event.event_type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event status filter
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EventStatusFilter {
|
||||||
|
/// Event statuses to include
|
||||||
|
pub statuses: Vec<EventStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventStatusFilter {
|
||||||
|
/// Create a new event status filter
|
||||||
|
pub fn new(statuses: Vec<EventStatus>) -> Self {
|
||||||
|
Self { statuses }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event matches the event status filter
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
self.statuses.contains(&event.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Location filter
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LocationFilter {
|
||||||
|
/// Locations to include (partial match)
|
||||||
|
pub locations: Vec<String>,
|
||||||
|
/// Whether the search is case sensitive
|
||||||
|
pub case_sensitive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocationFilter {
|
||||||
|
/// Create a new location filter
|
||||||
|
pub fn new(locations: Vec<String>, case_sensitive: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
locations,
|
||||||
|
case_sensitive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event matches the location filter
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
if let Some(location) = &event.location {
|
||||||
|
let location = if self.case_sensitive {
|
||||||
|
location.clone()
|
||||||
|
} else {
|
||||||
|
location.to_lowercase()
|
||||||
|
};
|
||||||
|
|
||||||
|
self.locations.iter().any(|filter_location| {
|
||||||
|
let filter_location = if self.case_sensitive {
|
||||||
|
filter_location.clone()
|
||||||
|
} else {
|
||||||
|
filter_location.to_lowercase()
|
||||||
|
};
|
||||||
|
location.contains(&filter_location)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Organizer filter
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrganizerFilter {
|
||||||
|
/// Organizer email addresses to include
|
||||||
|
pub organizers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrganizerFilter {
|
||||||
|
/// Create a new organizer filter
|
||||||
|
pub fn new(organizers: Vec<String>) -> Self {
|
||||||
|
Self { organizers }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event matches the organizer filter
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
if let Some(organizer) = &event.organizer {
|
||||||
|
self.organizers.contains(&organizer.email)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recurrence filter
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RecurrenceFilter {
|
||||||
|
/// Whether to include recurring events
|
||||||
|
pub include_recurring: bool,
|
||||||
|
/// Whether to include non-recurring events
|
||||||
|
pub include_non_recurring: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecurrenceFilter {
|
||||||
|
/// Create a new recurrence filter
|
||||||
|
pub fn new(include_recurring: bool, include_non_recurring: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
include_recurring,
|
||||||
|
include_non_recurring,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event matches the recurrence filter
|
||||||
|
pub fn matches_event(&self, event: &Event) -> bool {
|
||||||
|
let is_recurring = event.recurrence.is_some();
|
||||||
|
|
||||||
|
(is_recurring && self.include_recurring) ||
|
||||||
|
(!is_recurring && self.include_non_recurring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter builder for easy filter construction
|
||||||
|
pub struct FilterBuilder {
|
||||||
|
filter: CalendarFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilterBuilder {
|
||||||
|
/// Create a new filter builder
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
filter: CalendarFilter::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set match mode (any = OR, all = AND)
|
||||||
|
pub fn match_any(mut self, match_any: bool) -> Self {
|
||||||
|
self.filter.match_any = match_any;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add date range filter
|
||||||
|
pub fn date_range(mut self, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
|
||||||
|
self.filter.add_rule(FilterRule::DateRange(DateRangeFilter::new(start, end)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add keywords filter
|
||||||
|
pub fn keywords(mut self, keywords: Vec<String>) -> Self {
|
||||||
|
self.filter.add_rule(FilterRule::Keywords(KeywordFilter::new(keywords, false)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add case-sensitive keywords filter
|
||||||
|
pub fn keywords_case_sensitive(mut self, keywords: Vec<String>) -> Self {
|
||||||
|
self.filter.add_rule(FilterRule::Keywords(KeywordFilter::new(keywords, true)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add event type filter
|
||||||
|
pub fn event_types(mut self, event_types: Vec<EventType>) -> Self {
|
||||||
|
self.filter.add_rule(FilterRule::EventType(EventTypeFilter::new(event_types)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add event status filter
|
||||||
|
pub fn event_statuses(mut self, statuses: Vec<EventStatus>) -> Self {
|
||||||
|
self.filter.add_rule(FilterRule::EventStatus(EventStatusFilter::new(statuses)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the filter
|
||||||
|
pub fn build(self) -> CalendarFilter {
|
||||||
|
self.filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FilterBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::event::Event;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_date_range_filter() {
|
||||||
|
let start = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T000000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
let end = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T235959", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
|
||||||
|
let filter = DateRangeFilter::new(start, end);
|
||||||
|
|
||||||
|
// Event within range
|
||||||
|
let event_start = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
let event = Event::new("Test".to_string(), event_start, event_start + chrono::Duration::hours(1));
|
||||||
|
assert!(filter.matches_event(&event));
|
||||||
|
|
||||||
|
// Event outside range
|
||||||
|
let event_outside = Event::new(
|
||||||
|
"Test".to_string(),
|
||||||
|
start - chrono::Duration::days(1),
|
||||||
|
start - chrono::Duration::hours(23),
|
||||||
|
);
|
||||||
|
assert!(!filter.matches_event(&event_outside));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keyword_filter() {
|
||||||
|
let filter = KeywordFilter::new(vec!["meeting".to_string(), "important".to_string()], false);
|
||||||
|
|
||||||
|
let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(filter.matches_event(&event1));
|
||||||
|
|
||||||
|
let event2 = Event::new("Lunch".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(!filter.matches_event(&event2));
|
||||||
|
|
||||||
|
// Test case sensitivity
|
||||||
|
let case_filter = KeywordFilter::new(vec!["MEETING".to_string()], true);
|
||||||
|
assert!(!case_filter.matches_event(&event1)); // "Team Meeting" != "MEETING"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_filter() {
|
||||||
|
let mut filter = CalendarFilter::new(true); // OR logic
|
||||||
|
filter.add_rule(FilterRule::Keywords(KeywordFilter::new(vec!["meeting".to_string()], false)));
|
||||||
|
filter.add_rule(FilterRule::EventStatus(EventStatusFilter::new(vec![EventStatus::Cancelled])));
|
||||||
|
|
||||||
|
let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(filter.matches_event(&event1)); // Matches keyword
|
||||||
|
|
||||||
|
let mut event2 = Event::new("Holiday".to_string(), Utc::now(), Utc::now());
|
||||||
|
event2.status = EventStatus::Cancelled;
|
||||||
|
assert!(filter.matches_event(&event2)); // Matches status
|
||||||
|
|
||||||
|
let event3 = Event::new("Lunch".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(!filter.matches_event(&event3)); // Matches neither
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_builder() {
|
||||||
|
let filter = FilterBuilder::new()
|
||||||
|
.match_any(false) // AND logic
|
||||||
|
.keywords(vec!["meeting".to_string()])
|
||||||
|
.event_types(vec![EventType::Public])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let event = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(filter.matches_event(&event)); // Matches both conditions
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/config.rs
Normal file
204
src/config.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
//! Configuration management for CalDAV synchronizer
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Main configuration structure
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Server configuration
|
||||||
|
pub server: ServerConfig,
|
||||||
|
/// Calendar configuration
|
||||||
|
pub calendar: CalendarConfig,
|
||||||
|
/// Filter configuration
|
||||||
|
pub filters: Option<FilterConfig>,
|
||||||
|
/// Sync configuration
|
||||||
|
pub sync: SyncConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server connection configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// 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>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendar-specific configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarConfig {
|
||||||
|
/// Calendar name/path
|
||||||
|
pub name: String,
|
||||||
|
/// Calendar display name
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
/// Calendar color
|
||||||
|
pub color: Option<String>,
|
||||||
|
/// Calendar timezone
|
||||||
|
pub timezone: String,
|
||||||
|
/// Whether to sync this calendar
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter configuration for events
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FilterConfig {
|
||||||
|
/// Start date filter (ISO 8601)
|
||||||
|
pub start_date: Option<String>,
|
||||||
|
/// End date filter (ISO 8601)
|
||||||
|
pub end_date: Option<String>,
|
||||||
|
/// Event types to include
|
||||||
|
pub event_types: Option<Vec<String>>,
|
||||||
|
/// Keywords to filter by
|
||||||
|
pub keywords: Option<Vec<String>>,
|
||||||
|
/// Exclude keywords
|
||||||
|
pub exclude_keywords: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronization configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SyncConfig {
|
||||||
|
/// Sync interval in seconds
|
||||||
|
pub interval: u64,
|
||||||
|
/// Whether to sync on startup
|
||||||
|
pub sync_on_startup: bool,
|
||||||
|
/// Maximum number of retries
|
||||||
|
pub max_retries: u32,
|
||||||
|
/// Retry delay in seconds
|
||||||
|
pub retry_delay: u64,
|
||||||
|
/// Whether to delete events not found on server
|
||||||
|
pub delete_missing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
server: ServerConfig::default(),
|
||||||
|
calendar: CalendarConfig::default(),
|
||||||
|
filters: None,
|
||||||
|
sync: SyncConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
url: "https://caldav.example.com".to_string(),
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
use_https: true,
|
||||||
|
timeout: 30,
|
||||||
|
headers: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CalendarConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "calendar".to_string(),
|
||||||
|
display_name: None,
|
||||||
|
color: None,
|
||||||
|
timezone: "UTC".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SyncConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
interval: 300, // 5 minutes
|
||||||
|
sync_on_startup: true,
|
||||||
|
max_retries: 3,
|
||||||
|
retry_delay: 5,
|
||||||
|
delete_missing: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Load configuration from a TOML file
|
||||||
|
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let config: Config = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save configuration to a TOML file
|
||||||
|
pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configuration from environment variables
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let mut config = Config::default();
|
||||||
|
|
||||||
|
if let Ok(url) = std::env::var("CALDAV_URL") {
|
||||||
|
config.server.url = url;
|
||||||
|
}
|
||||||
|
if let Ok(username) = std::env::var("CALDAV_USERNAME") {
|
||||||
|
config.server.username = username;
|
||||||
|
}
|
||||||
|
if let Ok(password) = std::env::var("CALDAV_PASSWORD") {
|
||||||
|
config.server.password = password;
|
||||||
|
}
|
||||||
|
if let Ok(calendar) = std::env::var("CALDAV_CALENDAR") {
|
||||||
|
config.calendar.name = calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate configuration
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
if self.server.url.is_empty() {
|
||||||
|
anyhow::bail!("Server URL cannot be empty");
|
||||||
|
}
|
||||||
|
if self.server.username.is_empty() {
|
||||||
|
anyhow::bail!("Username cannot be empty");
|
||||||
|
}
|
||||||
|
if self.server.password.is_empty() {
|
||||||
|
anyhow::bail!("Password cannot be empty");
|
||||||
|
}
|
||||||
|
if self.calendar.name.is_empty() {
|
||||||
|
anyhow::bail!("Calendar name cannot be empty");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.server.url, "https://caldav.example.com");
|
||||||
|
assert_eq!(config.calendar.name, "calendar");
|
||||||
|
assert_eq!(config.sync.interval, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
assert!(config.validate().is_err()); // Empty username/password
|
||||||
|
|
||||||
|
config.server.username = "test".to_string();
|
||||||
|
config.server.password = "test".to_string();
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/error.rs
Normal file
164
src/error.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
//! Error handling for CalDAV synchronizer
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Result type for CalDAV operations
|
||||||
|
pub type CalDavResult<T> = Result<T, CalDavError>;
|
||||||
|
|
||||||
|
/// Main error type for CalDAV operations
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum CalDavError {
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
#[error("Authentication failed: {0}")]
|
||||||
|
Authentication(String),
|
||||||
|
|
||||||
|
#[error("Network error: {0}")]
|
||||||
|
Network(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("HTTP error: {0} - {1}")]
|
||||||
|
Http(reqwest::StatusCode, String),
|
||||||
|
|
||||||
|
#[error("XML parsing error: {0}")]
|
||||||
|
XmlParse(#[from] quick_xml::DeError),
|
||||||
|
|
||||||
|
#[error("Serialization error: {0}")]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Date/time error: {0}")]
|
||||||
|
DateTime(#[from] chrono::ParseError),
|
||||||
|
|
||||||
|
#[error("Timezone error: {0}")]
|
||||||
|
Timezone(String),
|
||||||
|
|
||||||
|
#[error("Event processing error: {0}")]
|
||||||
|
EventProcessing(String),
|
||||||
|
|
||||||
|
#[error("Calendar not found: {0}")]
|
||||||
|
CalendarNotFound(String),
|
||||||
|
|
||||||
|
#[error("Event not found: {0}")]
|
||||||
|
EventNotFound(String),
|
||||||
|
|
||||||
|
#[error("Synchronization error: {0}")]
|
||||||
|
Sync(String),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("URL parsing error: {0}")]
|
||||||
|
UrlParse(#[from] url::ParseError),
|
||||||
|
|
||||||
|
#[error("Base64 encoding error: {0}")]
|
||||||
|
Base64(#[from] base64::DecodeError),
|
||||||
|
|
||||||
|
#[error("UUID generation error: {0}")]
|
||||||
|
Uuid(#[from] uuid::Error),
|
||||||
|
|
||||||
|
#[error("Filter error: {0}")]
|
||||||
|
Filter(String),
|
||||||
|
|
||||||
|
#[error("Invalid response format: {0}")]
|
||||||
|
InvalidResponse(String),
|
||||||
|
|
||||||
|
#[error("Rate limited: retry after {0} seconds")]
|
||||||
|
RateLimited(u64),
|
||||||
|
|
||||||
|
#[error("Server error: {0}")]
|
||||||
|
ServerError(String),
|
||||||
|
|
||||||
|
#[error("Timeout error: operation timed out after {0} seconds")]
|
||||||
|
Timeout(u64),
|
||||||
|
|
||||||
|
#[error("Unknown error: {0}")]
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalDavError {
|
||||||
|
/// Check if this error is retryable
|
||||||
|
pub fn is_retryable(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
CalDavError::Network(_) => true,
|
||||||
|
CalDavError::Http(status, _) => {
|
||||||
|
matches!(status.as_u16(), 408 | 429 | 500..=599)
|
||||||
|
}
|
||||||
|
CalDavError::Timeout(_) => true,
|
||||||
|
CalDavError::RateLimited(_) => true,
|
||||||
|
CalDavError::ServerError(_) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get suggested retry delay in seconds
|
||||||
|
pub fn retry_delay(&self) -> Option<u64> {
|
||||||
|
match self {
|
||||||
|
CalDavError::RateLimited(seconds) => Some(*seconds),
|
||||||
|
CalDavError::Network(_) => Some(5),
|
||||||
|
CalDavError::Http(status, _) => {
|
||||||
|
match status.as_u16() {
|
||||||
|
429 => Some(60), // Rate limit
|
||||||
|
500..=599 => Some(30), // Server error
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CalDavError::Timeout(_) => Some(10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this error indicates authentication failure
|
||||||
|
pub fn is_auth_error(&self) -> bool {
|
||||||
|
matches!(self, CalDavError::Authentication(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this error indicates a configuration issue
|
||||||
|
pub fn is_config_error(&self) -> bool {
|
||||||
|
matches!(self, CalDavError::Config(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_retryable() {
|
||||||
|
let network_error = CalDavError::Network(
|
||||||
|
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test"))
|
||||||
|
);
|
||||||
|
assert!(network_error.is_retryable());
|
||||||
|
|
||||||
|
let auth_error = CalDavError::Authentication("Invalid credentials".to_string());
|
||||||
|
assert!(!auth_error.is_retryable());
|
||||||
|
|
||||||
|
let config_error = CalDavError::Config("Missing URL".to_string());
|
||||||
|
assert!(!config_error.is_retryable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retry_delay() {
|
||||||
|
let rate_limit_error = CalDavError::RateLimited(120);
|
||||||
|
assert_eq!(rate_limit_error.retry_delay(), Some(120));
|
||||||
|
|
||||||
|
let network_error = CalDavError::Network(
|
||||||
|
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test"))
|
||||||
|
);
|
||||||
|
assert_eq!(network_error.retry_delay(), Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_classification() {
|
||||||
|
let auth_error = CalDavError::Authentication("Invalid".to_string());
|
||||||
|
assert!(auth_error.is_auth_error());
|
||||||
|
|
||||||
|
let config_error = CalDavError::Config("Invalid".to_string());
|
||||||
|
assert!(config_error.is_config_error());
|
||||||
|
|
||||||
|
let network_error = CalDavError::Network(
|
||||||
|
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test"))
|
||||||
|
);
|
||||||
|
assert!(!network_error.is_auth_error());
|
||||||
|
assert!(!network_error.is_config_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
447
src/event.rs
Normal file
447
src/event.rs
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
//! Event handling and iCalendar parsing
|
||||||
|
|
||||||
|
use crate::error::{CalDavError, CalDavResult};
|
||||||
|
use chrono::{DateTime, Utc, NaiveDateTime};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Calendar event representation
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Event {
|
||||||
|
/// Unique identifier
|
||||||
|
pub uid: String,
|
||||||
|
/// Event summary/title
|
||||||
|
pub summary: String,
|
||||||
|
/// Event description
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Start time
|
||||||
|
pub start: DateTime<Utc>,
|
||||||
|
/// End time
|
||||||
|
pub end: DateTime<Utc>,
|
||||||
|
/// All-day event flag
|
||||||
|
pub all_day: bool,
|
||||||
|
/// Event location
|
||||||
|
pub location: Option<String>,
|
||||||
|
/// Event status
|
||||||
|
pub status: EventStatus,
|
||||||
|
/// Event type
|
||||||
|
pub event_type: EventType,
|
||||||
|
/// Organizer
|
||||||
|
pub organizer: Option<Organizer>,
|
||||||
|
/// Attendees
|
||||||
|
pub attendees: Vec<Attendee>,
|
||||||
|
/// Recurrence rule
|
||||||
|
pub recurrence: Option<RecurrenceRule>,
|
||||||
|
/// Alarm/reminders
|
||||||
|
pub alarms: Vec<Alarm>,
|
||||||
|
/// Custom properties
|
||||||
|
pub properties: HashMap<String, String>,
|
||||||
|
/// Creation timestamp
|
||||||
|
pub created: DateTime<Utc>,
|
||||||
|
/// Last modification timestamp
|
||||||
|
pub last_modified: DateTime<Utc>,
|
||||||
|
/// Sequence number for updates
|
||||||
|
pub sequence: i32,
|
||||||
|
/// Timezone identifier
|
||||||
|
pub timezone: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event status
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum EventStatus {
|
||||||
|
Confirmed,
|
||||||
|
Tentative,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventStatus {
|
||||||
|
fn default() -> Self {
|
||||||
|
EventStatus::Confirmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event type/classification
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum EventType {
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventType {
|
||||||
|
fn default() -> Self {
|
||||||
|
EventType::Public
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event organizer
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Organizer {
|
||||||
|
/// Email address
|
||||||
|
pub email: String,
|
||||||
|
/// Display name
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Sent-by parameter
|
||||||
|
pub sent_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event attendee
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Attendee {
|
||||||
|
/// Email address
|
||||||
|
pub email: String,
|
||||||
|
/// Display name
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Participation status
|
||||||
|
pub status: ParticipationStatus,
|
||||||
|
/// Whether required
|
||||||
|
pub required: bool,
|
||||||
|
/// RSVP requested
|
||||||
|
pub rsvp: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Participation status
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum ParticipationStatus {
|
||||||
|
NeedsAction,
|
||||||
|
Accepted,
|
||||||
|
Declined,
|
||||||
|
Tentative,
|
||||||
|
Delegated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recurrence rule
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RecurrenceRule {
|
||||||
|
/// Frequency
|
||||||
|
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>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recurrence frequency
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum RecurrenceFrequency {
|
||||||
|
Secondly,
|
||||||
|
Minutely,
|
||||||
|
Hourly,
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
Yearly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Day of week for recurrence
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum WeekDay {
|
||||||
|
Sunday,
|
||||||
|
Monday,
|
||||||
|
Tuesday,
|
||||||
|
Wednesday,
|
||||||
|
Thursday,
|
||||||
|
Friday,
|
||||||
|
Saturday,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event alarm/reminder
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Alarm {
|
||||||
|
/// Action type
|
||||||
|
pub action: AlarmAction,
|
||||||
|
/// Trigger time
|
||||||
|
pub trigger: AlarmTrigger,
|
||||||
|
/// Description
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Summary
|
||||||
|
pub summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alarm action
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AlarmAction {
|
||||||
|
Display,
|
||||||
|
Email,
|
||||||
|
Audio,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alarm trigger
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum AlarmTrigger {
|
||||||
|
/// Duration before start
|
||||||
|
BeforeStart(chrono::Duration),
|
||||||
|
/// Duration after start
|
||||||
|
AfterStart(chrono::Duration),
|
||||||
|
/// Duration before end
|
||||||
|
BeforeEnd(chrono::Duration),
|
||||||
|
/// Absolute time
|
||||||
|
Absolute(DateTime<Utc>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
/// Create a new event
|
||||||
|
pub fn new(summary: String, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
uid: Uuid::new_v4().to_string(),
|
||||||
|
summary,
|
||||||
|
description: None,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
all_day: false,
|
||||||
|
location: None,
|
||||||
|
status: EventStatus::default(),
|
||||||
|
event_type: EventType::default(),
|
||||||
|
organizer: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
recurrence: None,
|
||||||
|
alarms: Vec::new(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
created: now,
|
||||||
|
last_modified: now,
|
||||||
|
sequence: 0,
|
||||||
|
timezone: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an all-day event
|
||||||
|
pub fn new_all_day(summary: String, date: chrono::NaiveDate) -> Self {
|
||||||
|
let start = date.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
let end = date.and_hms_opt(23, 59, 59).unwrap();
|
||||||
|
let start_utc = DateTime::from_naive_utc_and_offset(start, Utc);
|
||||||
|
let end_utc = DateTime::from_naive_utc_and_offset(end, Utc);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
uid: Uuid::new_v4().to_string(),
|
||||||
|
summary,
|
||||||
|
description: None,
|
||||||
|
start: start_utc,
|
||||||
|
end: end_utc,
|
||||||
|
all_day: true,
|
||||||
|
location: None,
|
||||||
|
status: EventStatus::default(),
|
||||||
|
event_type: EventType::default(),
|
||||||
|
organizer: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
recurrence: None,
|
||||||
|
alarms: Vec::new(),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
created: Utc::now(),
|
||||||
|
last_modified: Utc::now(),
|
||||||
|
sequence: 0,
|
||||||
|
timezone: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse event from iCalendar data
|
||||||
|
pub fn from_ical(_ical_data: &str) -> CalDavResult<Self> {
|
||||||
|
// This is a simplified iCalendar parser
|
||||||
|
// In a real implementation, you'd use a proper iCalendar parsing library
|
||||||
|
|
||||||
|
let event = Self::new("placeholder".to_string(), Utc::now(), Utc::now());
|
||||||
|
|
||||||
|
// Placeholder implementation
|
||||||
|
// TODO: Implement proper iCalendar parsing
|
||||||
|
|
||||||
|
Ok(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert event to iCalendar format
|
||||||
|
pub fn to_ical(&self) -> CalDavResult<String> {
|
||||||
|
let mut ical = String::new();
|
||||||
|
|
||||||
|
// iCalendar header
|
||||||
|
ical.push_str("BEGIN:VCALENDAR\r\n");
|
||||||
|
ical.push_str("VERSION:2.0\r\n");
|
||||||
|
ical.push_str("PRODID:-//CalDAV Sync//EN\r\n");
|
||||||
|
ical.push_str("BEGIN:VEVENT\r\n");
|
||||||
|
|
||||||
|
// Basic properties
|
||||||
|
ical.push_str(&format!("UID:{}\r\n", self.uid));
|
||||||
|
ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary)));
|
||||||
|
|
||||||
|
if let Some(description) = &self.description {
|
||||||
|
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
if self.all_day {
|
||||||
|
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
|
||||||
|
self.start.format("%Y%m%d")));
|
||||||
|
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
|
||||||
|
self.end.format("%Y%m%d")));
|
||||||
|
} else {
|
||||||
|
ical.push_str(&format!("DTSTART:{}\r\n",
|
||||||
|
self.start.format("%Y%m%dT%H%M%SZ")));
|
||||||
|
ical.push_str(&format!("DTEND:{}\r\n",
|
||||||
|
self.end.format("%Y%m%dT%H%M%SZ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
ical.push_str(&format!("STATUS:{}\r\n", match self.status {
|
||||||
|
EventStatus::Confirmed => "CONFIRMED",
|
||||||
|
EventStatus::Tentative => "TENTATIVE",
|
||||||
|
EventStatus::Cancelled => "CANCELLED",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Class
|
||||||
|
ical.push_str(&format!("CLASS:{}\r\n", match self.event_type {
|
||||||
|
EventType::Public => "PUBLIC",
|
||||||
|
EventType::Private => "PRIVATE",
|
||||||
|
EventType::Confidential => "CONFIDENTIAL",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Location
|
||||||
|
if let Some(location) = &self.location {
|
||||||
|
ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organizer
|
||||||
|
if let Some(organizer) = &self.organizer {
|
||||||
|
ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendees
|
||||||
|
for attendee in &self.attendees {
|
||||||
|
ical.push_str(&format!("ATTENDEE:mailto:{}\r\n", attendee.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
// iCalendar footer
|
||||||
|
ical.push_str("END:VEVENT\r\n");
|
||||||
|
ical.push_str("END:VCALENDAR\r\n");
|
||||||
|
|
||||||
|
Ok(ical)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the event's last modified timestamp
|
||||||
|
pub fn touch(&mut self) {
|
||||||
|
self.last_modified = Utc::now();
|
||||||
|
self.sequence += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if event occurs on a specific date
|
||||||
|
pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool {
|
||||||
|
let start_date = self.start.date_naive();
|
||||||
|
let end_date = self.end.date_naive();
|
||||||
|
|
||||||
|
if self.all_day {
|
||||||
|
start_date <= date && end_date >= date
|
||||||
|
} else {
|
||||||
|
start_date <= date && end_date >= date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get event duration
|
||||||
|
pub fn duration(&self) -> chrono::Duration {
|
||||||
|
self.end.signed_duration_since(self.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if event is currently in progress
|
||||||
|
pub fn is_in_progress(&self) -> bool {
|
||||||
|
let now = Utc::now();
|
||||||
|
now >= self.start && now <= self.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape text for iCalendar format
|
||||||
|
fn escape_ical_text(text: &str) -> String {
|
||||||
|
text
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace(',', "\\,")
|
||||||
|
.replace(';', "\\;")
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
.replace('\r', "\\r")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse iCalendar date/time
|
||||||
|
fn parse_ical_datetime(dt_str: &str) -> CalDavResult<DateTime<Utc>> {
|
||||||
|
// Handle different iCalendar date formats
|
||||||
|
if dt_str.len() == 8 {
|
||||||
|
// DATE format (YYYYMMDD)
|
||||||
|
let naive_date = chrono::NaiveDate::parse_from_str(dt_str, "%Y%m%d")?;
|
||||||
|
let naive_datetime = naive_date.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
Ok(DateTime::from_naive_utc_and_offset(naive_datetime, Utc))
|
||||||
|
} else if dt_str.ends_with('Z') {
|
||||||
|
// UTC datetime format (YYYYMMDDTHHMMSSZ)
|
||||||
|
let dt_without_z = &dt_str[..dt_str.len()-1];
|
||||||
|
let naive_dt = NaiveDateTime::parse_from_str(dt_without_z, "%Y%m%dT%H%M%S")?;
|
||||||
|
Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc))
|
||||||
|
} else {
|
||||||
|
// Local time format - this would need timezone handling
|
||||||
|
Err(CalDavError::EventProcessing(
|
||||||
|
"Local time parsing not implemented".to_string()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_event_creation() {
|
||||||
|
let start = Utc::now();
|
||||||
|
let end = start + chrono::Duration::hours(1);
|
||||||
|
let event = Event::new("Test Event".to_string(), start, end);
|
||||||
|
|
||||||
|
assert_eq!(event.summary, "Test Event");
|
||||||
|
assert_eq!(event.start, start);
|
||||||
|
assert_eq!(event.end, end);
|
||||||
|
assert!(!event.all_day);
|
||||||
|
assert_eq!(event.status, EventStatus::Confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_day_event() {
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap();
|
||||||
|
let event = Event::new_all_day("Christmas".to_string(), date);
|
||||||
|
|
||||||
|
assert_eq!(event.summary, "Christmas");
|
||||||
|
assert!(event.all_day);
|
||||||
|
assert!(event.occurs_on(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_event_to_ical() {
|
||||||
|
let event = Event::new(
|
||||||
|
"Meeting".to_string(),
|
||||||
|
DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
),
|
||||||
|
DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T110000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ical = event.to_ical().unwrap();
|
||||||
|
assert!(ical.contains("SUMMARY:Meeting"));
|
||||||
|
assert!(ical.contains("DTSTART:20231225T100000Z"));
|
||||||
|
assert!(ical.contains("DTEND:20231225T110000Z"));
|
||||||
|
assert!(ical.contains("BEGIN:VCALENDAR"));
|
||||||
|
assert!(ical.contains("END:VCALENDAR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ical_text_escaping() {
|
||||||
|
let text = "Hello, world; this\\is a test";
|
||||||
|
let escaped = escape_ical_text(text);
|
||||||
|
assert_eq!(escaped, "Hello\\, world\\; this\\\\is a test");
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/lib.rs
Normal file
49
src/lib.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
//! CalDAV Calendar Synchronizer Library
|
||||||
|
//!
|
||||||
|
//! This library provides functionality for synchronizing calendars with CalDAV servers.
|
||||||
|
//! It includes support for event management, timezone handling, and calendar filtering.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod caldav_client;
|
||||||
|
pub mod event;
|
||||||
|
pub mod timezone;
|
||||||
|
pub mod calendar_filter;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
|
// Re-export main types for convenience
|
||||||
|
pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig};
|
||||||
|
pub use error::{CalDavError, CalDavResult};
|
||||||
|
pub use caldav_client::CalDavClient;
|
||||||
|
pub use event::{Event, EventStatus, EventType};
|
||||||
|
pub use timezone::TimezoneHandler;
|
||||||
|
pub use calendar_filter::{CalendarFilter, FilterRule};
|
||||||
|
pub use sync::{SyncEngine, SyncResult};
|
||||||
|
|
||||||
|
/// Library version
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
/// Initialize the library with default configuration
|
||||||
|
pub fn init() -> CalDavResult<()> {
|
||||||
|
// Initialize logging if not already set up
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::INFO)
|
||||||
|
.try_init();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_library_init() {
|
||||||
|
assert!(init().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version() {
|
||||||
|
assert!(!VERSION.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/main.rs
Normal file
174
src/main.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use tracing::{info, warn, error, Level};
|
||||||
|
use tracing_subscriber;
|
||||||
|
use caldav_sync::{Config, SyncEngine, CalDavResult};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "caldav-sync")]
|
||||||
|
#[command(about = "A CalDAV calendar synchronization tool")]
|
||||||
|
#[command(version)]
|
||||||
|
struct Cli {
|
||||||
|
/// Configuration file path
|
||||||
|
#[arg(short, long, default_value = "config/default.toml")]
|
||||||
|
config: PathBuf,
|
||||||
|
|
||||||
|
/// CalDAV server URL (overrides config file)
|
||||||
|
#[arg(short, long)]
|
||||||
|
server_url: Option<String>,
|
||||||
|
|
||||||
|
/// Username for authentication (overrides config file)
|
||||||
|
#[arg(short, long)]
|
||||||
|
username: Option<String>,
|
||||||
|
|
||||||
|
/// Password for authentication (overrides config file)
|
||||||
|
#[arg(short, long)]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Calendar name to sync (overrides config file)
|
||||||
|
#[arg(long)]
|
||||||
|
calendar: Option<String>,
|
||||||
|
|
||||||
|
/// Enable debug logging
|
||||||
|
#[arg(short, long)]
|
||||||
|
debug: bool,
|
||||||
|
|
||||||
|
/// Perform a one-time sync and exit
|
||||||
|
#[arg(long)]
|
||||||
|
once: bool,
|
||||||
|
|
||||||
|
/// Force a full resynchronization
|
||||||
|
#[arg(long)]
|
||||||
|
full_resync: bool,
|
||||||
|
|
||||||
|
/// List events and exit
|
||||||
|
#[arg(long)]
|
||||||
|
list_events: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Initialize logging
|
||||||
|
let log_level = if cli.debug { Level::DEBUG } else { Level::INFO };
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(log_level)
|
||||||
|
.with_target(false)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("Starting CalDAV synchronization tool v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let mut config = match Config::from_file(&cli.config) {
|
||||||
|
Ok(config) => {
|
||||||
|
info!("Loaded configuration from: {}", cli.config.display());
|
||||||
|
config
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to load config file: {}", e);
|
||||||
|
info!("Using default configuration and environment variables");
|
||||||
|
Config::from_env()?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override configuration with command line arguments
|
||||||
|
if let Some(ref server_url) = cli.server_url {
|
||||||
|
config.server.url = server_url.clone();
|
||||||
|
}
|
||||||
|
if let Some(ref username) = cli.username {
|
||||||
|
config.server.username = username.clone();
|
||||||
|
}
|
||||||
|
if let Some(ref password) = cli.password {
|
||||||
|
config.server.password = password.clone();
|
||||||
|
}
|
||||||
|
if let Some(ref calendar) = cli.calendar {
|
||||||
|
config.calendar.name = calendar.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate configuration
|
||||||
|
if let Err(e) = config.validate() {
|
||||||
|
error!("Configuration validation failed: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Server URL: {}", config.server.url);
|
||||||
|
info!("Username: {}", config.server.username);
|
||||||
|
info!("Calendar: {}", config.calendar.name);
|
||||||
|
|
||||||
|
// Initialize and run synchronization
|
||||||
|
match run_sync(config, &cli).await {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("CalDAV synchronization completed successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("CalDAV synchronization failed: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
|
||||||
|
// Create sync engine
|
||||||
|
let mut sync_engine = SyncEngine::new(config.clone()).await?;
|
||||||
|
|
||||||
|
if cli.list_events {
|
||||||
|
// List events and exit
|
||||||
|
info!("Listing events from calendar: {}", config.calendar.name);
|
||||||
|
|
||||||
|
// Perform a sync to get events
|
||||||
|
let sync_result = sync_engine.sync_full().await?;
|
||||||
|
info!("Sync completed: {} events processed", sync_result.events_processed);
|
||||||
|
|
||||||
|
// Get and display events
|
||||||
|
let events = sync_engine.get_local_events();
|
||||||
|
println!("Found {} events:", events.len());
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
println!(" - {} ({} to {})",
|
||||||
|
event.summary,
|
||||||
|
event.start.format("%Y-%m-%d %H:%M"),
|
||||||
|
event.end.format("%Y-%m-%d %H:%M")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.once || cli.full_resync {
|
||||||
|
// Perform one-time sync
|
||||||
|
if cli.full_resync {
|
||||||
|
info!("Performing full resynchronization");
|
||||||
|
let result = sync_engine.force_full_resync().await?;
|
||||||
|
info!("Full resync completed: {} events processed", result.events_processed);
|
||||||
|
} else {
|
||||||
|
info!("Performing one-time synchronization");
|
||||||
|
let result = sync_engine.sync_incremental().await?;
|
||||||
|
info!("Sync completed: {} events processed", result.events_processed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Start continuous synchronization
|
||||||
|
info!("Starting continuous synchronization");
|
||||||
|
|
||||||
|
if config.sync.sync_on_startup {
|
||||||
|
info!("Performing initial sync");
|
||||||
|
match sync_engine.sync_incremental().await {
|
||||||
|
Ok(result) => {
|
||||||
|
info!("Initial sync completed: {} events processed", result.events_processed);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Initial sync failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start auto-sync loop
|
||||||
|
sync_engine.start_auto_sync().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
518
src/sync.rs
Normal file
518
src/sync.rs
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
//! Synchronization engine for CalDAV calendars
|
||||||
|
|
||||||
|
use crate::{config::Config, caldav_client::CalDavClient, event::Event, calendar_filter::CalendarFilter, error::CalDavResult};
|
||||||
|
use chrono::{DateTime, Utc, Duration};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tracing::{info, warn, error, debug};
|
||||||
|
|
||||||
|
/// Synchronization engine for managing calendar synchronization
|
||||||
|
pub struct SyncEngine {
|
||||||
|
/// CalDAV client
|
||||||
|
client: CalDavClient,
|
||||||
|
/// Configuration
|
||||||
|
config: Config,
|
||||||
|
/// Local cache of events
|
||||||
|
local_events: HashMap<String, Event>,
|
||||||
|
/// Sync state
|
||||||
|
sync_state: SyncState,
|
||||||
|
/// Timezone handler
|
||||||
|
timezone_handler: crate::timezone::TimezoneHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronization state
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SyncState {
|
||||||
|
/// Last successful sync timestamp
|
||||||
|
pub last_sync: Option<DateTime<Utc>>,
|
||||||
|
/// Sync token for incremental syncs
|
||||||
|
pub sync_token: Option<String>,
|
||||||
|
/// Known event ETags
|
||||||
|
pub event_etags: HashMap<String, String>,
|
||||||
|
/// Sync statistics
|
||||||
|
pub stats: SyncStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronization statistics
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SyncStats {
|
||||||
|
/// Total events synchronized
|
||||||
|
pub total_events: u64,
|
||||||
|
/// Events created locally
|
||||||
|
pub local_created: u64,
|
||||||
|
/// Events updated locally
|
||||||
|
pub local_updated: u64,
|
||||||
|
/// Events deleted locally
|
||||||
|
pub local_deleted: u64,
|
||||||
|
/// Events created on server
|
||||||
|
pub server_created: u64,
|
||||||
|
/// Events updated on server
|
||||||
|
pub server_updated: u64,
|
||||||
|
/// Events deleted on server
|
||||||
|
pub server_deleted: u64,
|
||||||
|
/// Sync conflicts
|
||||||
|
pub conflicts: u64,
|
||||||
|
/// Last sync duration in milliseconds
|
||||||
|
pub last_sync_duration_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronization result
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SyncResult {
|
||||||
|
/// Success flag
|
||||||
|
pub success: bool,
|
||||||
|
/// Number of events processed
|
||||||
|
pub events_processed: usize,
|
||||||
|
/// Events created
|
||||||
|
pub events_created: usize,
|
||||||
|
/// Events updated
|
||||||
|
pub events_updated: usize,
|
||||||
|
/// Events deleted
|
||||||
|
pub events_deleted: usize,
|
||||||
|
/// Conflicts encountered
|
||||||
|
pub conflicts: usize,
|
||||||
|
/// Error message if any
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Sync duration in milliseconds
|
||||||
|
pub duration_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncEngine {
|
||||||
|
/// Create a new synchronization engine
|
||||||
|
pub async fn new(config: Config) -> CalDavResult<Self> {
|
||||||
|
let client = CalDavClient::new(config.server.clone())?;
|
||||||
|
let timezone_handler = crate::timezone::TimezoneHandler::new(&config.calendar.timezone)?;
|
||||||
|
|
||||||
|
let engine = Self {
|
||||||
|
client,
|
||||||
|
config,
|
||||||
|
local_events: HashMap::new(),
|
||||||
|
sync_state: SyncState {
|
||||||
|
last_sync: None,
|
||||||
|
sync_token: None,
|
||||||
|
event_etags: HashMap::new(),
|
||||||
|
stats: SyncStats::default(),
|
||||||
|
},
|
||||||
|
timezone_handler,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
engine.client.test_connection().await?;
|
||||||
|
|
||||||
|
Ok(engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a full synchronization
|
||||||
|
pub async fn sync_full(&mut self) -> CalDavResult<SyncResult> {
|
||||||
|
let start_time = Utc::now();
|
||||||
|
info!("Starting full calendar synchronization");
|
||||||
|
|
||||||
|
let mut result = SyncResult {
|
||||||
|
success: false,
|
||||||
|
events_processed: 0,
|
||||||
|
events_created: 0,
|
||||||
|
events_updated: 0,
|
||||||
|
events_deleted: 0,
|
||||||
|
conflicts: 0,
|
||||||
|
error: None,
|
||||||
|
duration_ms: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.do_sync_full(&mut result).await {
|
||||||
|
Ok(_) => {
|
||||||
|
result.success = true;
|
||||||
|
info!("Full synchronization completed successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
result.error = Some(e.to_string());
|
||||||
|
error!("Full synchronization failed: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.duration_ms = (Utc::now() - start_time).num_milliseconds() as u64;
|
||||||
|
self.sync_state.stats.last_sync_duration_ms = result.duration_ms;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform incremental synchronization
|
||||||
|
pub async fn sync_incremental(&mut self) -> CalDavResult<SyncResult> {
|
||||||
|
let start_time = Utc::now();
|
||||||
|
info!("Starting incremental calendar synchronization");
|
||||||
|
|
||||||
|
let mut result = SyncResult {
|
||||||
|
success: false,
|
||||||
|
events_processed: 0,
|
||||||
|
events_created: 0,
|
||||||
|
events_updated: 0,
|
||||||
|
events_deleted: 0,
|
||||||
|
conflicts: 0,
|
||||||
|
error: None,
|
||||||
|
duration_ms: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.sync_state.last_sync.is_none() {
|
||||||
|
// No previous sync, do full sync
|
||||||
|
return self.sync_full().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.do_sync_incremental(&mut result).await {
|
||||||
|
Ok(_) => {
|
||||||
|
result.success = true;
|
||||||
|
info!("Incremental synchronization completed successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
result.error = Some(e.to_string());
|
||||||
|
error!("Incremental synchronization failed: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.duration_ms = 0;
|
||||||
|
self.sync_state.stats.last_sync_duration_ms = result.duration_ms;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal full sync implementation
|
||||||
|
async fn do_sync_full(&mut self, result: &mut SyncResult) -> CalDavResult<()> {
|
||||||
|
// Get date range for sync (past 30 days to future 365 days)
|
||||||
|
let start = Utc::now() - Duration::days(30);
|
||||||
|
let end = Utc::now() + Duration::days(365);
|
||||||
|
|
||||||
|
// Fetch all events from server
|
||||||
|
let server_events = self.client.get_events(&self.config.calendar.name, start, end).await?;
|
||||||
|
|
||||||
|
debug!("Fetched {} events from server", server_events.len());
|
||||||
|
|
||||||
|
// Convert CalDavEventInfo to Event
|
||||||
|
let server_events: Vec<Event> = server_events.into_iter().map(|caldav_event| {
|
||||||
|
// Simple conversion - in a real implementation, you'd parse the iCalendar data
|
||||||
|
Event::new(caldav_event.summary, caldav_event.start, caldav_event.end)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Apply filters if configured
|
||||||
|
let filtered_events = if let Some(filter_config) = &self.config.filters {
|
||||||
|
let filter = self.create_filter_from_config(filter_config);
|
||||||
|
filter.filter_events_owned(server_events)
|
||||||
|
} else {
|
||||||
|
server_events
|
||||||
|
};
|
||||||
|
|
||||||
|
result.events_processed = filtered_events.len();
|
||||||
|
|
||||||
|
// Sync events
|
||||||
|
self.sync_events(&filtered_events, result).await?;
|
||||||
|
|
||||||
|
// Update sync state
|
||||||
|
self.sync_state.last_sync = Some(Utc::now());
|
||||||
|
self.sync_state.stats.total_events = filtered_events.len() as u64;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal incremental sync implementation
|
||||||
|
async fn do_sync_incremental(&mut self, result: &mut SyncResult) -> CalDavResult<()> {
|
||||||
|
// Get date range since last sync
|
||||||
|
let start = self.sync_state.last_sync.unwrap_or_else(|| Utc::now() - Duration::days(1));
|
||||||
|
let end = Utc::now() + Duration::days(30);
|
||||||
|
|
||||||
|
// Fetch events updated since last sync
|
||||||
|
let server_events = self.client.get_events(&self.config.calendar.name, start, end).await?;
|
||||||
|
|
||||||
|
debug!("Fetched {} updated events from server", server_events.len());
|
||||||
|
|
||||||
|
// Convert CalDavEventInfo to Event
|
||||||
|
let server_events: Vec<Event> = server_events.into_iter().map(|caldav_event| {
|
||||||
|
// Simple conversion - in a real implementation, you'd parse the iCalendar data
|
||||||
|
Event::new(caldav_event.summary, caldav_event.start, caldav_event.end)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
let filtered_events = if let Some(filter_config) = &self.config.filters {
|
||||||
|
let filter = self.create_filter_from_config(filter_config);
|
||||||
|
filter.filter_events_owned(server_events)
|
||||||
|
} else {
|
||||||
|
server_events
|
||||||
|
};
|
||||||
|
|
||||||
|
result.events_processed = filtered_events.len();
|
||||||
|
|
||||||
|
// Sync only changed events
|
||||||
|
self.sync_events_incremental(&filtered_events, result).await?;
|
||||||
|
|
||||||
|
// Update sync state
|
||||||
|
self.sync_state.last_sync = Some(Utc::now());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync events with local cache
|
||||||
|
async fn sync_events(&mut self, server_events: &[Event], result: &mut SyncResult) -> CalDavResult<()> {
|
||||||
|
let mut server_event_ids = HashSet::new();
|
||||||
|
let local_event_ids: HashSet<String> = self.local_events.keys().cloned().collect();
|
||||||
|
|
||||||
|
// Process server events
|
||||||
|
for server_event in server_events {
|
||||||
|
server_event_ids.insert(server_event.uid.clone());
|
||||||
|
|
||||||
|
match self.local_events.get(&server_event.uid) {
|
||||||
|
Some(local_event) => {
|
||||||
|
// Event exists locally, check for updates
|
||||||
|
if self.event_changed(local_event, server_event) {
|
||||||
|
self.update_local_event(server_event.clone());
|
||||||
|
result.events_updated += 1;
|
||||||
|
debug!("Updated event: {}", server_event.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// New event on server
|
||||||
|
self.add_local_event(server_event.clone());
|
||||||
|
result.events_created += 1;
|
||||||
|
debug!("Added new event: {}", server_event.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find events to delete (local events not on server)
|
||||||
|
if self.config.sync.delete_missing {
|
||||||
|
for event_id in &local_event_ids {
|
||||||
|
if !server_event_ids.contains(event_id) {
|
||||||
|
self.local_events.remove(event_id);
|
||||||
|
self.sync_state.event_etags.remove(event_id);
|
||||||
|
result.events_deleted += 1;
|
||||||
|
debug!("Deleted local event: {}", event_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync events incrementally
|
||||||
|
async fn sync_events_incremental(&mut self, server_events: &[Event], result: &mut SyncResult) -> CalDavResult<()> {
|
||||||
|
for server_event in server_events {
|
||||||
|
match self.local_events.get(&server_event.uid) {
|
||||||
|
Some(local_event) => {
|
||||||
|
if self.event_changed(local_event, server_event) {
|
||||||
|
self.update_local_event(server_event.clone());
|
||||||
|
result.events_updated += 1;
|
||||||
|
debug!("Updated event incrementally: {}", server_event.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.add_local_event(server_event.clone());
|
||||||
|
result.events_created += 1;
|
||||||
|
debug!("Added new event incrementally: {}", server_event.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an event has changed
|
||||||
|
fn event_changed(&self, local_event: &Event, server_event: &Event) -> bool {
|
||||||
|
// Simple comparison - in a real implementation, you'd compare ETags
|
||||||
|
local_event.last_modified != server_event.last_modified ||
|
||||||
|
local_event.sequence != server_event.sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add event to local cache
|
||||||
|
fn add_local_event(&mut self, event: Event) {
|
||||||
|
self.local_events.insert(event.uid.clone(), event.clone());
|
||||||
|
self.sync_state.stats.local_created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update event in local cache
|
||||||
|
fn update_local_event(&mut self, event: Event) {
|
||||||
|
self.local_events.insert(event.uid.clone(), event.clone());
|
||||||
|
self.sync_state.stats.local_updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create filter from configuration
|
||||||
|
fn create_filter_from_config(&self, filter_config: &crate::config::FilterConfig) -> CalendarFilter {
|
||||||
|
let mut filter = crate::calendar_filter::CalendarFilter::new(true);
|
||||||
|
|
||||||
|
// Add date range filter
|
||||||
|
if let (Some(start_str), Some(end_str)) = (&filter_config.start_date, &filter_config.end_date) {
|
||||||
|
if let (Ok(start), Ok(end)) = (
|
||||||
|
start_str.parse::<DateTime<Utc>>(),
|
||||||
|
end_str.parse::<DateTime<Utc>>()
|
||||||
|
) {
|
||||||
|
filter.add_rule(crate::calendar_filter::FilterRule::DateRange(
|
||||||
|
crate::calendar_filter::DateRangeFilter::new(start, end)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add keyword filter
|
||||||
|
if let Some(keywords) = &filter_config.keywords {
|
||||||
|
if !keywords.is_empty() {
|
||||||
|
filter.add_rule(crate::calendar_filter::FilterRule::Keywords(
|
||||||
|
crate::calendar_filter::KeywordFilter::new(keywords.clone(), false)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add exclude keywords filter (custom implementation needed)
|
||||||
|
if let Some(exclude_keywords) = &filter_config.exclude_keywords {
|
||||||
|
if !exclude_keywords.is_empty() {
|
||||||
|
// This would need a custom filter implementation
|
||||||
|
debug!("Exclude keywords filter not yet implemented: {:?}", exclude_keywords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all local events
|
||||||
|
pub fn get_local_events(&self) -> Vec<Event> {
|
||||||
|
self.local_events.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get local events filtered by criteria
|
||||||
|
pub fn get_local_events_filtered(&self, filter: &CalendarFilter) -> Vec<Event> {
|
||||||
|
let events: Vec<Event> = self.local_events.values().cloned().collect();
|
||||||
|
filter.filter_events_owned(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get synchronization state
|
||||||
|
pub fn get_sync_state(&self) -> &SyncState {
|
||||||
|
&self.sync_state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get synchronization statistics
|
||||||
|
pub fn get_sync_stats(&self) -> &SyncStats {
|
||||||
|
&self.sync_state.stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force a full resynchronization
|
||||||
|
pub async fn force_full_resync(&mut self) -> CalDavResult<SyncResult> {
|
||||||
|
info!("Forcing full resynchronization");
|
||||||
|
|
||||||
|
// Clear local cache and sync state
|
||||||
|
self.local_events.clear();
|
||||||
|
self.sync_state.event_etags.clear();
|
||||||
|
self.sync_state.sync_token = None;
|
||||||
|
|
||||||
|
// Perform full sync
|
||||||
|
self.sync_full().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start automatic synchronization loop
|
||||||
|
pub async fn start_auto_sync(&mut self) -> CalDavResult<()> {
|
||||||
|
info!("Starting automatic synchronization with interval: {} seconds", self.config.sync.interval);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Sync on startup if configured
|
||||||
|
if self.config.sync.sync_on_startup {
|
||||||
|
match self.sync_incremental().await {
|
||||||
|
Ok(result) => {
|
||||||
|
info!("Auto sync completed: {} events processed", result.events_processed);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Auto sync failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for next sync
|
||||||
|
sleep(tokio::time::Duration::from_secs(self.config.sync.interval)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new event on the server
|
||||||
|
pub async fn create_event(&mut self, mut event: Event) -> CalDavResult<()> {
|
||||||
|
// Generate UID if not present
|
||||||
|
if event.uid.is_empty() {
|
||||||
|
event.uid = uuid::Uuid::new_v4().to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to iCalendar format
|
||||||
|
let ical_data = event.to_ical()?;
|
||||||
|
|
||||||
|
// Upload to server
|
||||||
|
self.client.put_event(&self.config.calendar.name, &event.uid, &ical_data).await?;
|
||||||
|
|
||||||
|
// Add to local cache
|
||||||
|
self.add_local_event(event);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing event on the server
|
||||||
|
pub async fn update_event(&mut self, mut event: Event) -> CalDavResult<()> {
|
||||||
|
// Update modification timestamp
|
||||||
|
event.touch();
|
||||||
|
|
||||||
|
// Convert to iCalendar format
|
||||||
|
let ical_data = event.to_ical()?;
|
||||||
|
|
||||||
|
// Update on server
|
||||||
|
self.client.put_event(&self.config.calendar.name, &event.uid, &ical_data).await?;
|
||||||
|
|
||||||
|
// Update local cache
|
||||||
|
self.update_local_event(event);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an event from the server
|
||||||
|
pub async fn delete_event(&mut self, event_id: &str) -> CalDavResult<()> {
|
||||||
|
// Delete from server
|
||||||
|
self.client.delete_event(&self.config.calendar.name, event_id).await?;
|
||||||
|
|
||||||
|
// Remove from local cache
|
||||||
|
self.local_events.remove(event_id);
|
||||||
|
self.sync_state.event_etags.remove(event_id);
|
||||||
|
self.sync_state.stats.local_deleted += 1;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{Config, ServerConfig, CalendarConfig, SyncConfig};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_state_creation() {
|
||||||
|
let state = SyncState {
|
||||||
|
last_sync: None,
|
||||||
|
sync_token: None,
|
||||||
|
event_etags: HashMap::new(),
|
||||||
|
stats: SyncStats::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(state.last_sync.is_none());
|
||||||
|
assert!(state.sync_token.is_none());
|
||||||
|
assert_eq!(state.stats.total_events, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_result_creation() {
|
||||||
|
let result = SyncResult {
|
||||||
|
success: true,
|
||||||
|
events_processed: 10,
|
||||||
|
events_created: 2,
|
||||||
|
events_updated: 3,
|
||||||
|
events_deleted: 1,
|
||||||
|
conflicts: 0,
|
||||||
|
error: None,
|
||||||
|
duration_ms: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert_eq!(result.events_processed, 10);
|
||||||
|
assert_eq!(result.events_created, 2);
|
||||||
|
assert_eq!(result.events_updated, 3);
|
||||||
|
assert_eq!(result.events_deleted, 1);
|
||||||
|
assert_eq!(result.conflicts, 0);
|
||||||
|
assert!(result.error.is_none());
|
||||||
|
assert_eq!(result.duration_ms, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
327
src/timezone.rs
Normal file
327
src/timezone.rs
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
//! Timezone handling utilities
|
||||||
|
|
||||||
|
use crate::error::{CalDavError, CalDavResult};
|
||||||
|
use chrono::{DateTime, Utc, Local, TimeZone, NaiveDateTime, Offset};
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Timezone handler for managing timezone conversions
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TimezoneHandler {
|
||||||
|
/// Default timezone
|
||||||
|
default_tz: Tz,
|
||||||
|
/// Timezone cache
|
||||||
|
timezone_cache: HashMap<String, Tz>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimezoneHandler {
|
||||||
|
/// Create a new timezone handler with the given default timezone
|
||||||
|
pub fn new(default_timezone: &str) -> CalDavResult<Self> {
|
||||||
|
let default_tz: Tz = default_timezone.parse()
|
||||||
|
.map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", default_timezone)))?;
|
||||||
|
|
||||||
|
let mut cache = HashMap::new();
|
||||||
|
cache.insert(default_timezone.to_string(), default_tz);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
default_tz,
|
||||||
|
timezone_cache: cache,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a timezone handler with system local timezone
|
||||||
|
pub fn with_local_timezone() -> CalDavResult<Self> {
|
||||||
|
let local_tz = Self::get_system_timezone()?;
|
||||||
|
Self::new(&local_tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a datetime with timezone information
|
||||||
|
pub fn parse_datetime(&mut self, dt_str: &str, timezone: Option<&str>) -> CalDavResult<DateTime<Utc>> {
|
||||||
|
match timezone {
|
||||||
|
Some(tz) => {
|
||||||
|
let tz_obj = self.get_timezone(tz)?;
|
||||||
|
self.parse_datetime_with_tz(dt_str, tz_obj)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Try to parse as UTC first
|
||||||
|
if let Ok(dt) = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%SZ") {
|
||||||
|
Ok(DateTime::from_naive_utc_and_offset(dt, Utc))
|
||||||
|
} else {
|
||||||
|
// Try to parse as local time
|
||||||
|
let local_dt = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%S")?;
|
||||||
|
let local_dt = Local.from_local_datetime(&local_dt)
|
||||||
|
.single()
|
||||||
|
.ok_or_else(|| CalDavError::Timezone("Ambiguous local time".to_string()))?;
|
||||||
|
Ok(local_dt.with_timezone(&Utc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert UTC datetime to a specific timezone
|
||||||
|
pub fn convert_to_timezone(&mut self, dt: DateTime<Utc>, timezone: &str) -> CalDavResult<DateTime<Tz>> {
|
||||||
|
let tz_obj = self.get_timezone(timezone)?;
|
||||||
|
Ok(dt.with_timezone(&tz_obj))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert datetime from a specific timezone to UTC
|
||||||
|
pub fn convert_from_timezone(&mut self, dt: DateTime<Tz>, timezone: &str) -> CalDavResult<DateTime<Utc>> {
|
||||||
|
let _tz_obj = self.get_timezone(timezone)?;
|
||||||
|
Ok(dt.with_timezone(&Utc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format datetime in iCalendar format
|
||||||
|
pub fn format_ical_datetime(&mut self, dt: DateTime<Utc>, use_local_time: bool) -> CalDavResult<String> {
|
||||||
|
if use_local_time {
|
||||||
|
let local_dt = self.convert_to_timezone(dt, &self.default_tz.to_string())?;
|
||||||
|
Ok(local_dt.format("%Y%m%dT%H%M%S").to_string())
|
||||||
|
} else {
|
||||||
|
Ok(dt.format("%Y%m%dT%H%M%SZ").to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format date in iCalendar format (for all-day events)
|
||||||
|
pub fn format_ical_date(&self, dt: DateTime<Utc>) -> String {
|
||||||
|
dt.format("%Y%m%d").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a timezone object, using cache if available
|
||||||
|
fn get_timezone(&mut self, timezone: &str) -> CalDavResult<Tz> {
|
||||||
|
if let Some(tz) = self.timezone_cache.get(timezone) {
|
||||||
|
return Ok(*tz);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tz_obj: Tz = timezone.parse()
|
||||||
|
.map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", timezone)))?;
|
||||||
|
|
||||||
|
self.timezone_cache.insert(timezone.to_string(), tz_obj);
|
||||||
|
Ok(tz_obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse datetime with specific timezone
|
||||||
|
fn parse_datetime_with_tz(&self, dt_str: &str, tz: Tz) -> CalDavResult<DateTime<Utc>> {
|
||||||
|
let naive_dt = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%S")?;
|
||||||
|
let local_dt = tz.from_local_datetime(&naive_dt)
|
||||||
|
.single()
|
||||||
|
.ok_or_else(|| CalDavError::Timezone("Ambiguous local time".to_string()))?;
|
||||||
|
Ok(local_dt.with_timezone(&Utc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get system timezone
|
||||||
|
fn get_system_timezone() -> CalDavResult<String> {
|
||||||
|
// Try to get timezone from environment
|
||||||
|
if let Ok(tz) = std::env::var("TZ") {
|
||||||
|
return Ok(tz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try common timezone detection methods
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
if let Ok(link) = std::fs::read_link("/etc/localtime") {
|
||||||
|
if let Some(tz_path) = link.to_str() {
|
||||||
|
if let Some(tz_name) = tz_path.strip_prefix("../usr/share/zoneinfo/") {
|
||||||
|
return Ok(tz_name.to_string());
|
||||||
|
}
|
||||||
|
if let Some(tz_name) = tz_path.strip_prefix("/usr/share/zoneinfo/") {
|
||||||
|
return Ok(tz_name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// Windows timezone detection would require additional libraries
|
||||||
|
// For now, default to UTC on Windows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to UTC
|
||||||
|
Ok("UTC".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the default timezone
|
||||||
|
pub fn default_timezone(&self) -> String {
|
||||||
|
self.default_tz.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all available timezones
|
||||||
|
pub fn list_timezones() -> Vec<&'static str> {
|
||||||
|
chrono_tz::TZ_VARIANTS.iter().map(|tz| tz.name()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate timezone string
|
||||||
|
pub fn validate_timezone(timezone: &str) -> bool {
|
||||||
|
timezone.parse::<Tz>().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current time in default timezone
|
||||||
|
pub fn now(&self) -> DateTime<Tz> {
|
||||||
|
Utc::now().with_timezone(&self.default_tz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current time in UTC
|
||||||
|
pub fn now_utc() -> DateTime<Utc> {
|
||||||
|
Utc::now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TimezoneHandler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new("UTC").unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timezone information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TimezoneInfo {
|
||||||
|
/// Timezone name
|
||||||
|
pub name: String,
|
||||||
|
/// Current offset from UTC in seconds
|
||||||
|
pub offset: i32,
|
||||||
|
/// Daylight Saving Time active
|
||||||
|
pub dst_active: bool,
|
||||||
|
/// Timezone abbreviation
|
||||||
|
pub abbreviation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimezoneHandler {
|
||||||
|
/// Get information about a timezone
|
||||||
|
pub fn get_timezone_info(&mut self, timezone: &str) -> CalDavResult<TimezoneInfo> {
|
||||||
|
let tz_obj = self.get_timezone(timezone)?;
|
||||||
|
let now = Utc::now().with_timezone(&tz_obj);
|
||||||
|
|
||||||
|
Ok(TimezoneInfo {
|
||||||
|
name: timezone.to_string(),
|
||||||
|
offset: now.offset().fix().local_minus_utc(),
|
||||||
|
dst_active: false, // is_dst() method removed in newer chrono-tz versions
|
||||||
|
abbreviation: now.format("%Z").to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert between two timezones
|
||||||
|
pub fn convert_between_timezones(
|
||||||
|
&mut self,
|
||||||
|
dt: DateTime<Utc>,
|
||||||
|
from_tz: &str,
|
||||||
|
to_tz: &str,
|
||||||
|
) -> CalDavResult<DateTime<Tz>> {
|
||||||
|
let _from_tz_obj = self.get_timezone(from_tz)?;
|
||||||
|
let to_tz_obj = self.get_timezone(to_tz)?;
|
||||||
|
Ok(dt.with_timezone(&to_tz_obj))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timezone-aware datetime wrapper
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ZonedDateTime {
|
||||||
|
pub datetime: DateTime<Utc>,
|
||||||
|
pub timezone: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZonedDateTime {
|
||||||
|
/// Create a new timezone-aware datetime
|
||||||
|
pub fn new(datetime: DateTime<Utc>, timezone: Option<String>) -> Self {
|
||||||
|
Self { datetime, timezone }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from local time
|
||||||
|
pub fn from_local(local_dt: DateTime<Local>, timezone: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
datetime: local_dt.with_timezone(&Utc),
|
||||||
|
timezone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the datetime in UTC
|
||||||
|
pub fn utc(&self) -> DateTime<Utc> {
|
||||||
|
self.datetime
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the datetime in the specified timezone
|
||||||
|
pub fn in_timezone(&self, handler: &mut TimezoneHandler, timezone: &str) -> CalDavResult<DateTime<Tz>> {
|
||||||
|
handler.convert_to_timezone(self.datetime, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format for iCalendar
|
||||||
|
pub fn format_ical(&self, handler: &mut TimezoneHandler) -> CalDavResult<String> {
|
||||||
|
match &self.timezone {
|
||||||
|
Some(tz) => {
|
||||||
|
if tz == "UTC" {
|
||||||
|
handler.format_ical_datetime(self.datetime, false)
|
||||||
|
} else {
|
||||||
|
// For non-UTC timezones, we'd need to handle local time formatting
|
||||||
|
// This is a simplified implementation
|
||||||
|
handler.format_ical_datetime(self.datetime, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => handler.format_ical_datetime(self.datetime, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timezone_handler_creation() {
|
||||||
|
let handler = TimezoneHandler::new("UTC").unwrap();
|
||||||
|
assert_eq!(handler.default_timezone(), "UTC");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_utc_datetime_parsing() {
|
||||||
|
let handler = TimezoneHandler::default();
|
||||||
|
let dt = handler.parse_datetime("20231225T100000Z", None).unwrap();
|
||||||
|
assert_eq!(dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timezone_validation() {
|
||||||
|
assert!(TimezoneHandler::validate_timezone("UTC"));
|
||||||
|
assert!(TimezoneHandler::validate_timezone("America/New_York"));
|
||||||
|
assert!(TimezoneHandler::validate_timezone("Europe/London"));
|
||||||
|
assert!(!TimezoneHandler::validate_timezone("Invalid/Timezone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ical_formatting() {
|
||||||
|
let handler = TimezoneHandler::default();
|
||||||
|
let dt = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
|
||||||
|
let ical_utc = handler.format_ical_datetime(dt, false).unwrap();
|
||||||
|
assert_eq!(ical_utc, "20231225T100000Z");
|
||||||
|
|
||||||
|
let ical_date = handler.format_ical_date(dt);
|
||||||
|
assert_eq!(ical_date, "20231225");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timezone_conversion() {
|
||||||
|
let mut handler = TimezoneHandler::new("UTC").unwrap();
|
||||||
|
let dt = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to UTC (should be the same)
|
||||||
|
let utc_dt = handler.convert_to_timezone(dt, "UTC").unwrap();
|
||||||
|
assert_eq!(utc_dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zoned_datetime() {
|
||||||
|
let dt = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
let zdt = ZonedDateTime::new(dt, Some("UTC".to_string()));
|
||||||
|
|
||||||
|
assert_eq!(zdt.utc(), dt);
|
||||||
|
assert_eq!(zdt.timezone, Some("UTC".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
285
tests/integration_tests.rs
Normal file
285
tests/integration_tests.rs
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
use caldav_sync::{Config, CalDavResult};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod config_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() -> CalDavResult<()> {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.server.url, "https://caldav.example.com");
|
||||||
|
assert_eq!(config.calendar.name, "calendar");
|
||||||
|
assert_eq!(config.sync.interval, 300);
|
||||||
|
config.validate()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation() -> CalDavResult<()> {
|
||||||
|
let mut config = Config::default();
|
||||||
|
|
||||||
|
// Should fail with empty credentials
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
|
||||||
|
config.server.username = "test_user".to_string();
|
||||||
|
config.server.password = "test_pass".to_string();
|
||||||
|
|
||||||
|
// Should succeed now
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod error_tests {
|
||||||
|
use caldav_sync::{CalDavError, CalDavResult};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_retryable() {
|
||||||
|
let network_error = CalDavError::Network(
|
||||||
|
reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "test"))
|
||||||
|
);
|
||||||
|
assert!(network_error.is_retryable());
|
||||||
|
|
||||||
|
let auth_error = CalDavError::Authentication("Invalid credentials".to_string());
|
||||||
|
assert!(!auth_error.is_retryable());
|
||||||
|
|
||||||
|
let config_error = CalDavError::Config("Missing URL".to_string());
|
||||||
|
assert!(!config_error.is_retryable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_classification() {
|
||||||
|
let auth_error = CalDavError::Authentication("Invalid".to_string());
|
||||||
|
assert!(auth_error.is_auth_error());
|
||||||
|
|
||||||
|
let config_error = CalDavError::Config("Invalid".to_string());
|
||||||
|
assert!(config_error.is_config_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod event_tests {
|
||||||
|
use caldav_sync::event::{Event, EventStatus, EventType};
|
||||||
|
use chrono::{DateTime, Utc, NaiveDate};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_event_creation() {
|
||||||
|
let start = Utc::now();
|
||||||
|
let end = start + chrono::Duration::hours(1);
|
||||||
|
let event = Event::new("Test Event".to_string(), start, end);
|
||||||
|
|
||||||
|
assert_eq!(event.summary, "Test Event");
|
||||||
|
assert_eq!(event.start, start);
|
||||||
|
assert_eq!(event.end, end);
|
||||||
|
assert!(!event.all_day);
|
||||||
|
assert_eq!(event.status, EventStatus::Confirmed);
|
||||||
|
assert_eq!(event.event_type, EventType::Public);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_day_event() {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2023, 12, 25).unwrap();
|
||||||
|
let event = Event::new_all_day("Christmas".to_string(), date);
|
||||||
|
|
||||||
|
assert_eq!(event.summary, "Christmas");
|
||||||
|
assert!(event.all_day);
|
||||||
|
assert!(event.occurs_on(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_event_to_ical() -> caldav_sync::CalDavResult<()> {
|
||||||
|
let event = Event::new(
|
||||||
|
"Meeting".to_string(),
|
||||||
|
DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
),
|
||||||
|
DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T110000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ical = event.to_ical()?;
|
||||||
|
assert!(ical.contains("SUMMARY:Meeting"));
|
||||||
|
assert!(ical.contains("DTSTART:20231225T100000Z"));
|
||||||
|
assert!(ical.contains("DTEND:20231225T110000Z"));
|
||||||
|
assert!(ical.contains("BEGIN:VCALENDAR"));
|
||||||
|
assert!(ical.contains("END:VCALENDAR"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod timezone_tests {
|
||||||
|
use caldav_sync::timezone::TimezoneHandler;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timezone_handler_creation() -> CalDavResult<()> {
|
||||||
|
let handler = TimezoneHandler::new("UTC")?;
|
||||||
|
assert_eq!(handler.default_timezone(), "UTC");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timezone_validation() {
|
||||||
|
assert!(TimezoneHandler::validate_timezone("UTC"));
|
||||||
|
assert!(TimezoneHandler::validate_timezone("America/New_York"));
|
||||||
|
assert!(TimezoneHandler::validate_timezone("Europe/London"));
|
||||||
|
assert!(!TimezoneHandler::validate_timezone("Invalid/Timezone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ical_formatting() -> CalDavResult<()> {
|
||||||
|
let handler = TimezoneHandler::default();
|
||||||
|
let dt = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
|
||||||
|
let ical_utc = handler.format_ical_datetime(dt, false)?;
|
||||||
|
assert_eq!(ical_utc, "20231225T100000Z");
|
||||||
|
|
||||||
|
let ical_date = handler.format_ical_date(dt);
|
||||||
|
assert_eq!(ical_date, "20231225");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod filter_tests {
|
||||||
|
use caldav_sync::calendar_filter::{
|
||||||
|
CalendarFilter, FilterRule, DateRangeFilter, KeywordFilter,
|
||||||
|
EventTypeFilter, EventStatusFilter, FilterBuilder
|
||||||
|
};
|
||||||
|
use caldav_sync::event::{Event, EventStatus, EventType};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_date_range_filter() {
|
||||||
|
let start = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T000000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
let end = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T235959", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
|
||||||
|
let filter = DateRangeFilter::new(start, end);
|
||||||
|
|
||||||
|
let event_start = DateTime::from_naive_utc_and_offset(
|
||||||
|
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
|
||||||
|
Utc
|
||||||
|
);
|
||||||
|
let event = Event::new("Test".to_string(), event_start, event_start + chrono::Duration::hours(1));
|
||||||
|
assert!(filter.matches_event(&event));
|
||||||
|
|
||||||
|
let event_outside = Event::new(
|
||||||
|
"Test".to_string(),
|
||||||
|
start - chrono::Duration::days(1),
|
||||||
|
start - chrono::Duration::hours(23),
|
||||||
|
);
|
||||||
|
assert!(!filter_outside.matches_event(&event_outside));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keyword_filter() {
|
||||||
|
let filter = KeywordFilter::new(vec!["meeting".to_string(), "important".to_string()], false);
|
||||||
|
|
||||||
|
let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(filter.matches_event(&event1));
|
||||||
|
|
||||||
|
let event2 = Event::new("Lunch".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(!filter.matches_event(&event2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_filter() {
|
||||||
|
let mut filter = CalendarFilter::new(true); // OR logic
|
||||||
|
filter.add_rule(FilterRule::Keywords(KeywordFilter::new(vec!["meeting".to_string()], false)));
|
||||||
|
filter.add_rule(FilterRule::EventStatus(EventStatusFilter::new(vec![EventStatus::Cancelled])));
|
||||||
|
|
||||||
|
let event1 = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(filter.matches_event(&event1)); // Matches keyword
|
||||||
|
|
||||||
|
let mut event2 = Event::new("Holiday".to_string(), Utc::now(), Utc::now());
|
||||||
|
event2.status = EventStatus::Cancelled;
|
||||||
|
assert!(filter.matches_event(&event2)); // Matches status
|
||||||
|
|
||||||
|
let event3 = Event::new("Lunch".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(!filter.matches_event(&event3)); // Matches neither
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_builder() {
|
||||||
|
let filter = FilterBuilder::new()
|
||||||
|
.match_any(false) // AND logic
|
||||||
|
.keywords(vec!["meeting".to_string()])
|
||||||
|
.event_types(vec![EventType::Public])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let event = Event::new("Team Meeting".to_string(), Utc::now(), Utc::now());
|
||||||
|
assert!(filter.matches_event(&event)); // Matches both conditions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_library_initialization() -> CalDavResult<()> {
|
||||||
|
caldav_sync::init()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version() {
|
||||||
|
assert!(!caldav_sync::VERSION.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_workflow() -> CalDavResult<()> {
|
||||||
|
// Initialize library
|
||||||
|
caldav_sync::init()?;
|
||||||
|
|
||||||
|
// Create configuration
|
||||||
|
let config = Config::default();
|
||||||
|
|
||||||
|
// Validate configuration
|
||||||
|
config.validate()?;
|
||||||
|
|
||||||
|
// Create some test events
|
||||||
|
let event1 = caldav_sync::event::Event::new(
|
||||||
|
"Test Meeting".to_string(),
|
||||||
|
Utc::now(),
|
||||||
|
Utc::now() + chrono::Duration::hours(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
let event2 = caldav_sync::event::Event::new_all_day(
|
||||||
|
"Test Holiday".to_string(),
|
||||||
|
chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test event serialization
|
||||||
|
let ical1 = event1.to_ical()?;
|
||||||
|
let ical2 = event2.to_ical()?;
|
||||||
|
|
||||||
|
assert!(!ical1.is_empty());
|
||||||
|
assert!(!ical2.is_empty());
|
||||||
|
assert!(ical1.contains("SUMMARY:Test Meeting"));
|
||||||
|
assert!(ical2.contains("SUMMARY:Test Holiday"));
|
||||||
|
|
||||||
|
// Test filtering
|
||||||
|
let filter = caldav_sync::calendar_filter::FilterBuilder::new()
|
||||||
|
.keywords(vec!["test".to_string()])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assert!(filter.matches_event(&event1));
|
||||||
|
assert!(filter.matches_event(&event2));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue