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:
Alvaro Soliverez 2025-10-04 11:57:44 -03:00
commit 8362ebe44b
16 changed files with 6192 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2588
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

74
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(())
}
}