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