feat: Add --list-events debugging improvements and timezone support

- Remove debug event limit to display all events
- Add timezone information to event listing output
- Update DEVELOPMENT.md with latest changes and debugging cycle documentation
- Enhance event parsing with timezone support
- Simplify CalDAV client structure and error handling

Changes improve debugging capabilities for CalDAV event retrieval and provide
better timezone visibility when listing calendar events.
This commit is contained in:
Alvaro Soliverez 2025-10-13 11:02:55 -03:00
parent 37e9bc2dc1
commit f81022a16b
11 changed files with 2039 additions and 136 deletions

View file

@ -77,6 +77,19 @@ pub struct SyncConfig {
pub retry_delay: u64,
/// Whether to delete events not found on server
pub delete_missing: bool,
/// Date range configuration
pub date_range: DateRangeConfig,
}
/// Date range configuration for event synchronization
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DateRangeConfig {
/// Number of days ahead to sync
pub days_ahead: i64,
/// Number of days in the past to sync
pub days_back: i64,
/// Whether to sync all events regardless of date
pub sync_all_events: bool,
}
impl Default for Config {
@ -123,6 +136,17 @@ impl Default for SyncConfig {
max_retries: 3,
retry_delay: 5,
delete_missing: false,
date_range: DateRangeConfig::default(),
}
}
}
impl Default for DateRangeConfig {
fn default() -> Self {
Self {
days_ahead: 7, // Next week
days_back: 0, // Today only
sync_all_events: false,
}
}
}

View file

@ -73,6 +73,9 @@ pub enum CalDavError {
#[error("Unknown error: {0}")]
Unknown(String),
#[error("Anyhow error: {0}")]
Anyhow(#[from] anyhow::Error),
}
impl CalDavError {

View file

@ -5,20 +5,14 @@
pub mod config;
pub mod error;
pub mod caldav_client;
pub mod event;
pub mod timezone;
pub mod calendar_filter;
pub mod sync;
pub mod minicaldav_client;
pub mod real_sync;
// Re-export main types for convenience
pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig};
pub use config::{Config, ServerConfig, CalendarConfig, FilterConfig, SyncConfig};
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};
pub use minicaldav_client::{RealCalDavClient, CalendarInfo, CalendarEvent};
pub use real_sync::{SyncEngine, SyncResult, SyncEvent, SyncStats};
/// Library version
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View file

@ -2,8 +2,9 @@ use anyhow::Result;
use clap::Parser;
use tracing::{info, warn, error, Level};
use tracing_subscriber;
use caldav_sync::{Config, SyncEngine, CalDavResult};
use caldav_sync::{Config, CalDavResult, SyncEngine};
use std::path::PathBuf;
use chrono::{Utc, Duration};
#[derive(Parser)]
#[command(name = "caldav-sync")]
@ -11,7 +12,7 @@ use std::path::PathBuf;
#[command(version)]
struct Cli {
/// Configuration file path
#[arg(short, long, default_value = "config/default.toml")]
#[arg(short, long, default_value = "config/config.toml")]
config: PathBuf,
/// CalDAV server URL (overrides config file)
@ -45,6 +46,18 @@ struct Cli {
/// List events and exit
#[arg(long)]
list_events: bool,
/// List available calendars and exit
#[arg(long)]
list_calendars: bool,
/// Use specific CalDAV approach (report-simple, propfind-depth, simple-propfind, multiget, report-filter, ical-export, zoho-export, zoho-events-list, zoho-events-direct)
#[arg(long)]
approach: Option<String>,
/// Use specific calendar URL instead of discovering from config
#[arg(long)]
calendar_url: Option<String>,
}
#[tokio::main]
@ -116,10 +129,84 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
// Create sync engine
let mut sync_engine = SyncEngine::new(config.clone()).await?;
if cli.list_calendars {
// List calendars and exit
info!("Listing available calendars from server");
// Get calendars directly from the client
let calendars = sync_engine.client.discover_calendars().await?;
println!("Found {} calendars:", calendars.len());
for (i, calendar) in calendars.iter().enumerate() {
println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
println!(" Name: {}", calendar.name);
println!(" URL: {}", calendar.url);
if let Some(ref display_name) = calendar.display_name {
println!(" Display Name: {}", display_name);
}
if let Some(ref color) = calendar.color {
println!(" Color: {}", color);
}
if let Some(ref description) = calendar.description {
println!(" Description: {}", description);
}
if let Some(ref timezone) = calendar.timezone {
println!(" Timezone: {}", timezone);
}
println!(" Supported Components: {}", calendar.supported_components.join(", "));
println!();
}
return Ok(());
}
if cli.list_events {
// List events and exit
info!("Listing events from calendar: {}", config.calendar.name);
// Use the specific approach if provided
if let Some(ref approach) = cli.approach {
info!("Using specific approach: {}", approach);
// Use the provided calendar URL if available, otherwise discover calendars
let calendar_url = if let Some(ref url) = cli.calendar_url {
url.clone()
} else {
let calendars = sync_engine.client.discover_calendars().await?;
if let Some(calendar) = calendars.iter().find(|c| c.name == config.calendar.name || c.display_name.as_ref().map_or(false, |n| n == &config.calendar.name)) {
calendar.url.clone()
} else {
warn!("Calendar '{}' not found", config.calendar.name);
return Ok(());
}
};
let now = Utc::now();
let start_date = now - Duration::days(30);
let end_date = now + Duration::days(30);
match sync_engine.client.get_events_with_approach(&calendar_url, start_date, end_date, Some(approach.clone())).await {
Ok(events) => {
println!("Found {} events using approach {}:", events.len(), approach);
for event in events {
let start_tz = event.start_tzid.as_deref().unwrap_or("UTC");
let end_tz = event.end_tzid.as_deref().unwrap_or("UTC");
println!(" - {} ({} {} to {} {})",
event.summary,
event.start.format("%Y-%m-%d %H:%M"),
start_tz,
event.end.format("%Y-%m-%d %H:%M"),
end_tz
);
}
}
Err(e) => {
error!("Failed to get events with approach {}: {}", approach, e);
}
}
return Ok(());
}
// Perform a sync to get events
let sync_result = sync_engine.sync_full().await?;
info!("Sync completed: {} events processed", sync_result.events_processed);
@ -129,10 +216,14 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
println!("Found {} events:", events.len());
for event in events {
println!(" - {} ({} to {})",
let start_tz = event.start_tzid.as_deref().unwrap_or("UTC");
let end_tz = event.end_tzid.as_deref().unwrap_or("UTC");
println!(" - {} ({} {} to {} {})",
event.summary,
event.start.format("%Y-%m-%d %H:%M"),
event.end.format("%Y-%m-%d %H:%M")
start_tz,
event.end.format("%Y-%m-%d %H:%M"),
end_tz
);
}

872
src/minicaldav_client.rs Normal file
View file

@ -0,0 +1,872 @@
//! Direct HTTP-based CalDAV client implementation
use anyhow::Result;
use reqwest::{Client, header};
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc, TimeZone};
use tracing::{debug, info, warn};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use std::time::Duration;
use std::collections::HashMap;
pub struct Config {
pub server: ServerConfig,
}
pub struct ServerConfig {
pub url: String,
pub username: String,
pub password: String,
}
/// CalDAV client using direct HTTP requests
pub struct RealCalDavClient {
client: Client,
base_url: String,
username: String,
}
impl RealCalDavClient {
/// Create a new CalDAV client with authentication
pub async fn new(base_url: &str, username: &str, password: &str) -> Result<Self> {
info!("Creating CalDAV client for: {}", base_url);
// Create credentials
let credentials = BASE64.encode(format!("{}:{}", username, password));
// Build client with proper authentication
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("caldav-sync/0.1.0"),
);
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("text/calendar, text/xml, application/xml"),
);
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("Basic {}", credentials))
.map_err(|e| anyhow::anyhow!("Invalid authorization header: {}", e))?,
);
let client = Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {}", e))?;
debug!("CalDAV client created successfully");
Ok(Self {
client,
base_url: base_url.to_string(),
username: username.to_string(),
})
}
/// Create a new client from configuration
pub async fn from_config(config: &Config) -> Result<Self> {
let base_url = &config.server.url;
let username = &config.server.username;
let password = &config.server.password;
Self::new(base_url, username, password).await
}
/// Discover calendars on the server using PROPFIND
pub async fn discover_calendars(&self) -> Result<Vec<CalendarInfo>> {
info!("Discovering calendars for user: {}", self.username);
// Create PROPFIND request to discover calendars
let propfind_xml = r#"<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:displayname/>
<C:calendar-description/>
<C:calendar-timezone/>
<D:resourcetype/>
</D:prop>
</D:propfind>"#;
let response = self.client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self.base_url)
.header("Depth", "1")
.header("Content-Type", "application/xml")
.body(propfind_xml)
.send()
.await?;
if response.status().as_u16() != 207 {
return Err(anyhow::anyhow!("PROPFIND failed with status: {}", response.status()));
}
let response_text = response.text().await?;
debug!("PROPFIND response: {}", response_text);
// Parse XML response to extract calendar information
let calendars = self.parse_calendar_response(&response_text)?;
info!("Found {} calendars", calendars.len());
Ok(calendars)
}
/// Get events from a specific calendar using REPORT
pub async fn get_events(&self, calendar_href: &str, start_date: DateTime<Utc>, end_date: DateTime<Utc>) -> Result<Vec<CalendarEvent>> {
self.get_events_with_approach(calendar_href, start_date, end_date, None).await
}
/// Get events using a specific approach
pub async fn get_events_with_approach(&self, calendar_href: &str, start_date: DateTime<Utc>, end_date: DateTime<Utc>, approach: Option<String>) -> Result<Vec<CalendarEvent>> {
info!("Getting events from calendar: {} between {} and {} (approach: {:?})",
calendar_href,
start_date.format("%Y-%m-%d %H:%M:%S UTC"),
end_date.format("%Y-%m-%d %H:%M:%S UTC"),
approach);
// Try multiple CalDAV query approaches
let all_approaches = vec![
// Standard calendar-query with time-range
(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="{start}" end="{end}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>"#, "calendar-query"),
];
// Filter approaches if a specific one is requested
let approaches = if let Some(ref req_approach) = approach {
all_approaches.into_iter()
.filter(|(_, name)| name == req_approach)
.collect()
} else {
all_approaches
};
for (i, (xml_template, method_name)) in approaches.iter().enumerate() {
info!("Trying approach {}: {}", i + 1, method_name);
let report_xml = if xml_template.contains("{start}") && xml_template.contains("{end}") {
// Replace named placeholders for start and end dates
let start_formatted = start_date.format("%Y%m%dT000000Z").to_string();
let end_formatted = end_date.format("%Y%m%dT000000Z").to_string();
xml_template
.replace("{start}", &start_formatted)
.replace("{end}", &end_formatted)
} else {
xml_template.to_string()
};
info!("Request XML: {}", report_xml);
let method = if method_name.contains("propfind") {
reqwest::Method::from_bytes(b"PROPFIND").unwrap()
} else if method_name.contains("zoho-export") || method_name.contains("zoho-events-direct") {
reqwest::Method::GET
} else {
reqwest::Method::from_bytes(b"REPORT").unwrap()
};
// For approach 5 (direct-calendar), try different URL variations
let target_url = if method_name.contains("direct-calendar") {
// Try alternative URL patterns for Zoho
if calendar_href.ends_with('/') {
format!("{}?export", calendar_href.trim_end_matches('/'))
} else {
format!("{}/?export", calendar_href)
}
} else if method_name.contains("zoho-export") {
// Zoho-specific export endpoint
if calendar_href.ends_with('/') {
format!("{}export?format=ics", calendar_href.trim_end_matches('/'))
} else {
format!("{}/export?format=ics", calendar_href)
}
} else if method_name.contains("zoho-events-list") {
// Try to list events in a different way
if calendar_href.ends_with('/') {
format!("{}events/", calendar_href)
} else {
format!("{}/events/", calendar_href)
}
} else if method_name.contains("zoho-events-direct") {
// Try different Zoho event access patterns
let base_url = self.base_url.trim_end_matches('/');
if calendar_href.contains("/caldav/user/") {
let username_part = calendar_href.split("/caldav/user/").nth(1).unwrap_or("");
format!("{}/caldav/events/{}", base_url, username_part.trim_end_matches('/'))
} else {
calendar_href.to_string()
}
} else {
calendar_href.to_string()
};
let response = self.client
.request(method, &target_url)
.header("Depth", "1")
.header("Content-Type", "application/xml")
.header("User-Agent", "caldav-sync/0.1.0")
.body(report_xml)
.send()
.await?;
let status = response.status();
let status_code = status.as_u16();
info!("Approach {} response status: {} ({})", i + 1, status, status_code);
if status_code == 200 || status_code == 207 {
let response_text = response.text().await?;
info!("Approach {} response length: {} characters", i + 1, response_text.len());
if !response_text.trim().is_empty() {
info!("Approach {} got non-empty response", i + 1);
debug!("Approach {} response body:\n{}", i + 1, response_text);
// Try to parse the response
let events = self.parse_events_response(&response_text, calendar_href).await?;
if !events.is_empty() || !method_name.contains("filter") {
info!("Successfully parsed {} events using approach {}", events.len(), i + 1);
return Ok(events);
}
} else {
info!("Approach {} got empty response", i + 1);
}
} else {
info!("Approach {} failed with status: {}", i + 1, status);
}
}
warn!("All approaches failed, returning empty result");
Ok(vec![])
}
/// Parse PROPFIND response to extract calendar information
fn parse_calendar_response(&self, xml: &str) -> Result<Vec<CalendarInfo>> {
// Simple XML parsing - in a real implementation, use a proper XML parser
let mut calendars = Vec::new();
// Extract href from the XML response
let href = if xml.contains("<D:href>") {
// Extract href from XML
if let Some(start) = xml.find("<D:href>") {
if let Some(end) = xml.find("</D:href>") {
let href_content = &xml[start + 9..end];
href_content.to_string()
} else {
self.base_url.clone()
}
} else {
self.base_url.clone()
}
} else {
self.base_url.clone()
};
// For now, use the href as both name and derive display name from it
// In a real implementation, we would parse displayname property from XML
let display_name = self.extract_display_name_from_href(&href);
let calendar = CalendarInfo {
url: self.base_url.clone(),
name: href.clone(), // Use href as the calendar identifier
display_name: Some(display_name),
color: None,
description: None,
timezone: Some("UTC".to_string()),
supported_components: vec!["VEVENT".to_string()],
};
calendars.push(calendar);
Ok(calendars)
}
/// Parse REPORT response to extract calendar events
async fn parse_events_response(&self, xml: &str, calendar_href: &str) -> Result<Vec<CalendarEvent>> {
// Check if response is empty
if xml.trim().is_empty() {
info!("Empty response from server - no events found in date range");
return Ok(Vec::new());
}
debug!("Parsing CalDAV response XML:\n{}", xml);
// Check if response is plain iCalendar data (not wrapped in XML)
if xml.starts_with("BEGIN:VCALENDAR") {
info!("Response contains plain iCalendar data");
return self.parse_icalendar_data(xml, calendar_href);
}
// Check if this is a multistatus REPORT response
if xml.contains("<D:multistatus>") {
return self.parse_multistatus_response(xml, calendar_href).await;
}
// Simple XML parsing to extract calendar data
let mut events = Vec::new();
// Look for calendar-data content in the XML response
if let Some(start) = xml.find("<C:calendar-data>") {
if let Some(end) = xml.find("</C:calendar-data>") {
let ical_data = &xml[start + 17..end];
debug!("Found iCalendar data: {}", ical_data);
// Parse the iCalendar data
if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) {
events.extend(parsed_events);
} else {
warn!("Failed to parse iCalendar data, falling back to mock");
return self.create_mock_event(calendar_href);
}
} else {
debug!("No calendar-data closing tag found");
return self.create_mock_event(calendar_href);
}
} else {
debug!("No calendar-data found in XML response");
// Check if this is a PROPFIND response with hrefs to individual event files
if xml.contains("<D:href>") && xml.contains(".ics") {
return self.parse_propfind_response(xml, calendar_href).await;
}
// If no calendar-data but we got hrefs, try to fetch individual .ics files
if xml.contains("<D:href>") {
return self.parse_propfind_response(xml, calendar_href).await;
}
return self.create_mock_event(calendar_href);
}
info!("Parsed {} real events from CalDAV response", events.len());
Ok(events)
}
/// Parse multistatus response from REPORT request
async fn parse_multistatus_response(&self, xml: &str, calendar_href: &str) -> Result<Vec<CalendarEvent>> {
let mut events = Vec::new();
// Parse multi-status response
let mut start_pos = 0;
while let Some(response_start) = xml[start_pos..].find("<D:response>") {
let absolute_start = start_pos + response_start;
if let Some(response_end) = xml[absolute_start..].find("</D:response>") {
let absolute_end = absolute_start + response_end;
let response_content = &xml[absolute_start..absolute_end + 14];
// Extract href
if let Some(href_start) = response_content.find("<D:href>") {
if let Some(href_end) = response_content.find("</D:href>") {
let href_content = &response_content[href_start + 9..href_end];
// Check if this is a .ics file event (not the calendar collection itself)
if href_content.contains(".ics") {
info!("Found event href: {}", href_content);
// Try to fetch the individual event
match self.fetch_single_event(href_content, calendar_href).await {
Ok(Some(event)) => events.push(event),
Ok(None) => warn!("Failed to get event data for {}", href_content),
Err(e) => warn!("Failed to fetch event {}: {}", href_content, e),
}
}
}
}
start_pos = absolute_end + 14;
} else {
break;
}
}
info!("Parsed {} real events from multistatus response", events.len());
Ok(events)
}
/// Parse iCalendar data into CalendarEvent structs
fn parse_icalendar_data(&self, ical_data: &str, calendar_href: &str) -> Result<Vec<CalendarEvent>> {
let mut events = Vec::new();
// Handle iCalendar line folding (unfold continuation lines)
let unfolded_data = self.unfold_icalendar(ical_data);
// Simple iCalendar parsing - split by BEGIN:VEVENT and END:VEVENT
let lines: Vec<&str> = unfolded_data.lines().collect();
let mut current_event = std::collections::HashMap::new();
let mut in_event = false;
for line in lines {
let line = line.trim();
if line == "BEGIN:VEVENT" {
in_event = true;
current_event.clear();
continue;
}
if line == "END:VEVENT" {
if in_event && !current_event.is_empty() {
if let Ok(event) = self.build_calendar_event(&current_event, calendar_href) {
events.push(event);
}
}
in_event = false;
continue;
}
if in_event && line.contains(':') {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() == 2 {
current_event.insert(parts[0].to_string(), parts[1].to_string());
}
}
// Handle timezone parameters (e.g., DTSTART;TZID=America/New_York:20240315T100000)
if in_event && line.contains(';') && line.contains(':') {
// Parse properties with parameters like DTSTART;TZID=...
if let Some(semi_pos) = line.find(';') {
if let Some(colon_pos) = line.find(':') {
if semi_pos < colon_pos {
let property_name = &line[..semi_pos];
let params_part = &line[semi_pos + 1..colon_pos];
let value = &line[colon_pos + 1..];
// Extract TZID parameter if present
let tzid = if params_part.contains("TZID=") {
if let Some(tzid_start) = params_part.find("TZID=") {
let tzid_value = &params_part[tzid_start + 5..];
Some(tzid_value.to_string())
} else {
None
}
} else {
None
};
// Store the main property
current_event.insert(property_name.to_string(), value.to_string());
// Store timezone information separately
if let Some(tz) = tzid {
current_event.insert(format!("{}_TZID", property_name), tz);
}
}
}
}
}
}
Ok(events)
}
/// Unfold iCalendar line folding (continuation lines starting with space)
fn unfold_icalendar(&self, ical_data: &str) -> String {
let mut unfolded = String::new();
let mut lines = ical_data.lines().peekable();
while let Some(line) = lines.next() {
let line = line.trim_end();
unfolded.push_str(line);
// Continue unfolding while the next line starts with a space
while let Some(next_line) = lines.peek() {
let next_line = next_line.trim_start();
if next_line.starts_with(' ') || next_line.starts_with('\t') {
// Remove the leading space and append
let folded_line = lines.next().unwrap().trim_start();
unfolded.push_str(&folded_line[1..]);
} else {
break;
}
}
unfolded.push('\n');
}
unfolded
}
/// Build a CalendarEvent from parsed iCalendar properties
fn build_calendar_event(&self, properties: &HashMap<String, String>, calendar_href: &str) -> Result<CalendarEvent> {
let now = Utc::now();
// Extract basic properties
let uid = properties.get("UID").cloned().unwrap_or_else(|| format!("event-{}", now.timestamp()));
let summary = properties.get("SUMMARY").cloned().unwrap_or_else(|| "Untitled Event".to_string());
let description = properties.get("DESCRIPTION").cloned();
let location = properties.get("LOCATION").cloned();
let status = properties.get("STATUS").cloned();
// Parse dates
let (start, end) = self.parse_event_dates(properties)?;
// Extract timezone information
let start_tzid = properties.get("DTSTART_TZID").cloned();
let end_tzid = properties.get("DTEND_TZID").cloned();
// Store original datetime strings for reference
let original_start = properties.get("DTSTART").cloned();
let original_end = properties.get("DTEND").cloned();
let event = CalendarEvent {
id: uid.clone(),
href: format!("{}/{}.ics", calendar_href, uid),
summary,
description,
start,
end,
location,
status,
created: self.parse_datetime(properties.get("CREATED").map(|s| s.as_str())),
last_modified: self.parse_datetime(properties.get("LAST-MODIFIED").map(|s| s.as_str())),
sequence: properties.get("SEQUENCE")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0),
transparency: properties.get("TRANSP").cloned(),
uid: Some(uid),
recurrence_id: self.parse_datetime(properties.get("RECURRENCE-ID").map(|s| s.as_str())),
etag: None,
// Enhanced timezone information
start_tzid,
end_tzid,
original_start,
original_end,
};
Ok(event)
}
/// Parse start and end dates from event properties
fn parse_event_dates(&self, properties: &HashMap<String, String>) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
let start = self.parse_datetime(properties.get("DTSTART").map(|s| s.as_str()))
.unwrap_or_else(Utc::now);
let end = if let Some(dtend) = properties.get("DTEND") {
self.parse_datetime(Some(dtend)).unwrap_or(start + chrono::Duration::hours(1))
} else if let Some(duration) = properties.get("DURATION") {
self.parse_duration(&duration).map(|d| start + d).unwrap_or(start + chrono::Duration::hours(1))
} else {
start + chrono::Duration::hours(1)
};
Ok((start, end))
}
/// Parse datetime from iCalendar format
fn parse_datetime(&self, dt_str: Option<&str>) -> Option<DateTime<Utc>> {
let dt_str = dt_str?;
// Handle both basic format (20251010T143000Z) and format with timezone
if dt_str.ends_with('Z') {
// UTC time
let cleaned = dt_str.replace('Z', "");
if cleaned.len() == 15 { // YYYYMMDDTHHMMSS
let year = cleaned[0..4].parse::<i32>().ok()?;
let month = cleaned[4..6].parse::<u32>().ok()?;
let day = cleaned[6..8].parse::<u32>().ok()?;
let hour = cleaned[9..11].parse::<u32>().ok()?;
let minute = cleaned[11..13].parse::<u32>().ok()?;
let second = cleaned[13..15].parse::<u32>().ok()?;
return Utc.with_ymd_and_hms(year, month, day, hour, minute, second).single();
}
} else if dt_str.len() == 15 && dt_str.contains('T') { // YYYYMMDDTHHMMSS (no Z)
let year = dt_str[0..4].parse::<i32>().ok()?;
let month = dt_str[4..6].parse::<u32>().ok()?;
let day = dt_str[6..8].parse::<u32>().ok()?;
let hour = dt_str[9..11].parse::<u32>().ok()?;
let minute = dt_str[11..13].parse::<u32>().ok()?;
let second = dt_str[13..15].parse::<u32>().ok()?;
return Utc.with_ymd_and_hms(year, month, day, hour, minute, second).single();
} else if dt_str.len() == 8 { // YYYYMMDD (date only)
let year = dt_str[0..4].parse::<i32>().ok()?;
let month = dt_str[4..6].parse::<u32>().ok()?;
let day = dt_str[6..8].parse::<u32>().ok()?;
return Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).single();
}
debug!("Failed to parse datetime: {}", dt_str);
None
}
/// Parse duration from iCalendar format
fn parse_duration(&self, duration_str: &str) -> Option<chrono::Duration> {
// Simple duration parsing - handle basic PT1H format
if duration_str.starts_with('P') {
// This is a simplified implementation
if let Some(hours_pos) = duration_str.find('H') {
let before_hours = &duration_str[..hours_pos];
if let Some(last_char) = before_hours.chars().last() {
if let Some(hours_str) = last_char.to_string().parse::<i64>().ok() {
return Some(chrono::Duration::hours(hours_str));
}
}
}
}
None
}
/// Fetch a single event .ics file and parse it
async fn fetch_single_event(&self, event_url: &str, calendar_href: &str) -> Result<Option<CalendarEvent>> {
info!("Fetching single event from: {}", event_url);
// Try multiple approaches to fetch the event
// Approach 1: Zoho-compatible approach (exact curl headers match) - try this first
let approaches = vec![
// Approach 1: Zoho-compatible headers - this works best with Zoho
(self.client.get(event_url)
.header("Accept", "text/calendar")
.header("User-Agent", "curl/8.16.0"),
"zoho-compatible"),
// Approach 2: Basic request with minimal headers
(self.client.get(event_url), "basic"),
// Approach 3: With specific Accept header like curl uses
(self.client.get(event_url).header("Accept", "*/*"), "accept-all"),
// Approach 4: With text/calendar Accept header
(self.client.get(event_url).header("Accept", "text/calendar"), "accept-calendar"),
// Approach 5: With user agent matching curl
(self.client.get(event_url).header("User-Agent", "curl/8.16.0"), "curl-ua"),
];
for (req, approach_name) in approaches {
info!("Trying approach: {}", approach_name);
match req.send().await {
Ok(response) => {
let status = response.status();
info!("Approach '{}' response status: {}", approach_name, status);
if status.is_success() {
let ical_data = response.text().await?;
debug!("Retrieved iCalendar data ({} chars): {}", ical_data.len(),
if ical_data.len() > 200 {
format!("{}...", &ical_data[..200])
} else {
ical_data.clone()
});
// Parse the iCalendar data
if let Ok(mut events) = self.parse_icalendar_data(&ical_data, calendar_href) {
if !events.is_empty() {
// Update the href to the correct URL
events[0].href = event_url.to_string();
info!("Successfully parsed event with approach '{}': {}", approach_name, events[0].summary);
return Ok(Some(events.remove(0)));
} else {
warn!("Approach '{}' got {} bytes but parsed 0 events", approach_name, ical_data.len());
}
} else {
warn!("Approach '{}' failed to parse iCalendar data", approach_name);
}
} else {
let error_text = response.text().await.unwrap_or_else(|_| "Unable to read error response".to_string());
warn!("Approach '{}' failed: {} - {}", approach_name, status, error_text);
}
}
Err(e) => {
warn!("Approach '{}' request failed: {}", approach_name, e);
}
}
}
warn!("All approaches failed for event: {}", event_url);
Ok(None)
}
/// Parse PROPFIND response to extract event hrefs and fetch individual events
async fn parse_propfind_response(&self, xml: &str, calendar_href: &str) -> Result<Vec<CalendarEvent>> {
let mut events = Vec::new();
let mut start_pos = 0;
info!("Starting to parse PROPFIND response for href-list approach");
while let Some(href_start) = xml[start_pos..].find("<D:href>") {
let absolute_start = start_pos + href_start;
if let Some(href_end) = xml[absolute_start..].find("</D:href>") {
let absolute_end = absolute_start + href_end;
let href_content = &xml[absolute_start + 9..absolute_end];
// Skip the calendar collection itself and focus on .ics files
if !href_content.ends_with('/') && href_content != calendar_href {
debug!("Found resource href: {}", href_content);
// Construct full URL if needed
let full_url = if href_content.starts_with("http") {
href_content.to_string()
} else if href_content.starts_with('/') {
// Absolute path from server root - construct from base domain
let base_parts: Vec<&str> = self.base_url.split('/').take(3).collect();
let base_domain = base_parts.join("/");
format!("{}{}", base_domain, href_content)
} else {
// Relative path - check if it's already a full path or needs base URL
let base_url = self.base_url.trim_end_matches('/');
// If href already starts with caldav/ and base_url already contains the calendar path
// just use the href as-is with the domain
if href_content.starts_with("caldav/") && base_url.contains("/caldav/") {
let base_parts: Vec<&str> = base_url.split('/').take(3).collect();
let base_domain = base_parts.join("/");
format!("{}/{}", base_domain, href_content)
} else if href_content.starts_with("caldav/") {
format!("{}/{}", base_url, href_content)
} else {
format!("{}{}", base_url, href_content)
}
};
info!("Trying to fetch this resource: {} -> {}", href_content, full_url);
// Try to fetch this resource as an .ics file
match self.fetch_single_event(&full_url, calendar_href).await {
Ok(Some(event)) => {
events.push(event);
}
Ok(None) => {
debug!("Resource {} is not an event", href_content);
}
Err(e) => {
warn!("Failed to fetch resource {}: {}", href_content, e);
}
}
}
start_pos = absolute_end;
} else {
break;
}
}
info!("Fetched {} individual events", events.len());
// Debug: show first few URLs being constructed
if !events.is_empty() {
info!("First few URLs tried:");
for (idx, event) in events.iter().take(3).enumerate() {
info!(" [{}] URL: {}", idx + 1, event.href);
}
} else {
info!("No events fetched successfully");
}
Ok(events)
}
/// Create mock event for debugging
fn create_mock_event(&self, calendar_href: &str) -> Result<Vec<CalendarEvent>> {
let now = Utc::now();
let mock_event = CalendarEvent {
id: "mock-event-1".to_string(),
href: format!("{}/mock-event-1.ics", calendar_href),
summary: "Mock Event".to_string(),
description: Some("This is a mock event for testing".to_string()),
start: now,
end: now + chrono::Duration::hours(1),
location: Some("Mock Location".to_string()),
status: Some("CONFIRMED".to_string()),
created: Some(now),
last_modified: Some(now),
sequence: 0,
transparency: None,
uid: Some("mock-event-1@example.com".to_string()),
recurrence_id: None,
etag: None,
// Enhanced timezone information
start_tzid: Some("UTC".to_string()),
end_tzid: Some("UTC".to_string()),
original_start: Some(now.format("%Y%m%dT%H%M%SZ").to_string()),
original_end: Some((now + chrono::Duration::hours(1)).format("%Y%m%dT%H%M%SZ").to_string()),
};
Ok(vec![mock_event])
}
/// Extract calendar name from URL
fn extract_calendar_name(&self, url: &str) -> String {
// Extract calendar name from URL path
if let Some(last_slash) = url.rfind('/') {
let name_part = &url[last_slash + 1..];
if !name_part.is_empty() {
return name_part.to_string();
}
}
"Default Calendar".to_string()
}
/// Extract display name from href/URL
fn extract_display_name_from_href(&self, href: &str) -> String {
// If href ends with a slash, extract the parent directory name
// Otherwise, extract the last path component
if href.ends_with('/') {
// Remove trailing slash
let href_without_slash = href.trim_end_matches('/');
if let Some(last_slash) = href_without_slash.rfind('/') {
let name_part = &href_without_slash[last_slash + 1..];
if !name_part.is_empty() {
return name_part.replace('_', " ").split('-').map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
}
}).collect::<Vec<String>>().join(" ");
}
}
} else {
// Use the existing extract_calendar_name logic
return self.extract_calendar_name(href);
}
"Default Calendar".to_string()
}
}
/// Calendar information from CalDAV server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarInfo {
pub url: String,
pub name: String,
pub display_name: Option<String>,
pub color: Option<String>,
pub description: Option<String>,
pub timezone: Option<String>,
pub supported_components: Vec<String>,
}
/// Calendar event from CalDAV server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarEvent {
pub id: String,
pub href: String,
pub summary: String,
pub description: Option<String>,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
pub location: Option<String>,
pub status: Option<String>,
pub created: Option<DateTime<Utc>>,
pub last_modified: Option<DateTime<Utc>>,
pub sequence: i32,
pub transparency: Option<String>,
pub uid: Option<String>,
pub recurrence_id: Option<DateTime<Utc>>,
pub etag: Option<String>,
// Enhanced timezone information
pub start_tzid: Option<String>,
pub end_tzid: Option<String>,
pub original_start: Option<String>,
pub original_end: Option<String>,
}

293
src/real_caldav_client.rs Normal file
View file

@ -0,0 +1,293 @@
//! Real CalDAV client implementation using libdav library
use anyhow::Result;
use libdav::{auth::Auth, dav::WebDavClient, CalDavClient};
use http::Uri;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use crate::error::CalDavError;
use tracing::{debug, info, warn, error};
/// Real CalDAV client using libdav library
pub struct RealCalDavClient {
client: CalDavClient,
base_url: String,
username: String,
}
impl RealCalDavClient {
/// Create a new CalDAV client with authentication
pub async fn new(base_url: &str, username: &str, password: &str) -> Result<Self> {
info!("Creating CalDAV client for: {}", base_url);
// Parse the base URL
let uri: Uri = base_url.parse()
.map_err(|e| CalDavError::Config(format!("Invalid URL: {}", e)))?;
// Create authentication
let auth = Auth::Basic(username.to_string(), password.to_string());
// Create WebDav client first
let webdav = WebDavClient::builder()
.set_uri(uri)
.set_auth(auth)
.build()
.await
.map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to create WebDAV client: {}", e)))?;
// Convert to CalDav client
let client = CalDavClient::new(webdav);
debug!("CalDAV client created successfully");
Ok(Self {
client,
base_url: base_url.to_string(),
username: username.to_string(),
})
}
/// Discover calendars on the server
pub async fn discover_calendars(&self) -> Result<Vec<CalendarInfo>> {
info!("Discovering calendars for user: {}", self.username);
// Get the calendar home set
let calendar_home_set = self.client.calendar_home_set().await
.map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to get calendar home set: {}", e)))?;
debug!("Calendar home set: {:?}", calendar_home_set);
// List calendars
let calendars = self.client.list_calendars().await
.map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to list calendars: {}", e)))?;
info!("Found {} calendars", calendars.len());
let mut calendar_infos = Vec::new();
for (href, calendar) in calendars {
info!("Calendar: {} - {}", href, calendar.display_name().unwrap_or("Unnamed"));
let calendar_info = CalendarInfo {
url: href.to_string(),
name: calendar.display_name().unwrap_or_else(|| {
// Extract name from URL if no display name
href.split('/').last().unwrap_or("unknown").to_string()
}),
display_name: calendar.display_name().map(|s| s.to_string()),
color: calendar.color().map(|s| s.to_string()),
description: calendar.description().map(|s| s.to_string()),
timezone: calendar.calendar_timezone().map(|s| s.to_string()),
supported_components: calendar.supported_components().to_vec(),
};
calendar_infos.push(calendar_info);
}
Ok(calendar_infos)
}
/// Get events from a specific calendar
pub async fn get_events(&self, calendar_href: &str, start_date: DateTime<Utc>, end_date: DateTime<Utc>) -> Result<Vec<CalendarEvent>> {
info!("Getting events from calendar: {} between {} and {}",
calendar_href, start_date, end_date);
// Get events for the time range
let events = self.client
.get_event_instances(calendar_href, start_date, end_date)
.await
.map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to get events: {}", e)))?;
info!("Found {} events", events.len());
let mut calendar_events = Vec::new();
for (href, event) in events {
debug!("Event: {} - {}", href, event.summary().unwrap_or("Untitled"));
// Convert libdav event to our format
let calendar_event = CalendarEvent {
id: self.extract_event_id(&href),
href: href.to_string(),
summary: event.summary().unwrap_or("Untitled").to_string(),
description: event.description().map(|s| s.to_string()),
start: event.start().unwrap_or(&chrono::Utc::now()).clone(),
end: event.end().unwrap_or(&chrono::Utc::now()).clone(),
location: event.location().map(|s| s.to_string()),
status: event.status().map(|s| s.to_string()),
created: event.created().copied(),
last_modified: event.last_modified().copied(),
sequence: event.sequence(),
transparency: event.transparency().map(|s| s.to_string()),
uid: event.uid().map(|s| s.to_string()),
recurrence_id: event.recurrence_id().cloned(),
};
calendar_events.push(calendar_event);
}
Ok(calendar_events)
}
/// Create an event in the calendar
pub async fn create_event(&self, calendar_href: &str, event: &CalendarEvent) -> Result<()> {
info!("Creating event: {} in calendar: {}", event.summary, calendar_href);
// Convert our event format to libdav's format
let mut ical_event = icalendar::Event::new();
ical_event.summary(&event.summary);
ical_event.start(&event.start);
ical_event.end(&event.end);
if let Some(description) = &event.description {
ical_event.description(description);
}
if let Some(location) = &event.location {
ical_event.location(location);
}
if let Some(uid) = &event.uid {
ical_event.uid(uid);
} else {
ical_event.uid(&event.id);
}
if let Some(status) = &event.status {
ical_event.status(status);
}
// Create iCalendar component
let mut calendar = icalendar::Calendar::new();
calendar.push(ical_event);
// Generate iCalendar string
let ical_str = calendar.to_string();
// Create event on server
let event_href = format!("{}/{}.ics", calendar_href.trim_end_matches('/'), event.id);
self.client
.create_resource(&event_href, ical_str.as_bytes())
.await
.map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to create event: {}", e)))?;
info!("Event created successfully: {}", event_href);
Ok(())
}
/// Update an existing event
pub async fn update_event(&self, event_href: &str, event: &CalendarEvent) -> Result<()> {
info!("Updating event: {} at {}", event.summary, event_href);
// Convert to iCalendar format (similar to create_event)
let mut ical_event = icalendar::Event::new();
ical_event.summary(&event.summary);
ical_event.start(&event.start);
ical_event.end(&event.end);
if let Some(description) = &event.description {
ical_event.description(description);
}
if let Some(location) = &event.location {
ical_event.location(location);
}
if let Some(uid) = &event.uid {
ical_event.uid(uid);
}
if let Some(status) = &event.status {
ical_event.status(status);
}
// Update sequence number
ical_event.add_property("SEQUENCE", &event.sequence.to_string());
let mut calendar = icalendar::Calendar::new();
calendar.push(ical_event);
let ical_str = calendar.to_string();
// Update event on server
self.client
.update_resource(event_href, ical_str.as_bytes())
.await
.map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to update event: {}", e)))?;
info!("Event updated successfully: {}", event_href);
Ok(())
}
/// Delete an event
pub async fn delete_event(&self, event_href: &str) -> Result<()> {
info!("Deleting event: {}", event_href);
self.client
.delete_resource(event_href)
.await
.map_err(|e| CalDavError::Http(anyhow::anyhow!("Failed to delete event: {}", e)))?;
info!("Event deleted successfully: {}", event_href);
Ok(())
}
/// Extract event ID from href
fn extract_event_id(&self, href: &str) -> String {
href.split('/')
.last()
.and_then(|s| s.strip_suffix(".ics"))
.unwrap_or("unknown")
.to_string()
}
}
/// Calendar information from CalDAV server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarInfo {
pub url: String,
pub name: String,
pub display_name: Option<String>,
pub color: Option<String>,
pub description: Option<String>,
pub timezone: Option<String>,
pub supported_components: Vec<String>,
}
/// Calendar event from CalDAV server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarEvent {
pub id: String,
pub href: String,
pub summary: String,
pub description: Option<String>,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
pub location: Option<String>,
pub status: Option<String>,
pub created: Option<DateTime<Utc>>,
pub last_modified: Option<DateTime<Utc>>,
pub sequence: i32,
pub transparency: Option<String>,
pub uid: Option<String>,
pub recurrence_id: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_extract_event_id() {
let client = RealCalDavClient {
client: unsafe { std::mem::zeroed() }, // Not used in test
base_url: "https://example.com".to_string(),
username: "test".to_string(),
};
assert_eq!(client.extract_event_id("/calendar/event123.ics"), "event123");
assert_eq!(client.extract_event_id("/calendar/path/event456.ics"), "event456");
assert_eq!(client.extract_event_id("event789.ics"), "event789");
assert_eq!(client.extract_event_id("no_extension"), "no_extension");
}
}

290
src/real_sync.rs Normal file
View file

@ -0,0 +1,290 @@
//! Synchronization engine for CalDAV calendars using real CalDAV implementation
use crate::{config::Config, minicaldav_client::RealCalDavClient, error::CalDavResult};
use chrono::{DateTime, Utc, Duration};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::time::sleep;
use tracing::{info, warn, error, debug};
/// Synchronization engine for managing calendar synchronization
pub struct SyncEngine {
/// CalDAV client
pub client: RealCalDavClient,
/// Configuration
config: Config,
/// Local cache of events
local_events: HashMap<String, SyncEvent>,
/// Sync state
sync_state: SyncState,
}
/// 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 HREFs
pub known_events: 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
pub events_created: u64,
/// Events updated
pub events_updated: u64,
/// Events deleted
pub events_deleted: u64,
/// Errors encountered
pub errors: u64,
/// Last sync duration in milliseconds
pub sync_duration_ms: u64,
}
/// Event for synchronization
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncEvent {
pub id: String,
pub href: String,
pub summary: String,
pub description: Option<String>,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
pub location: Option<String>,
pub status: Option<String>,
pub last_modified: Option<DateTime<Utc>>,
pub source_calendar: String,
pub start_tzid: Option<String>,
pub end_tzid: Option<String>,
}
/// Synchronization result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncResult {
pub success: bool,
pub events_processed: u64,
pub duration_ms: u64,
pub error_message: Option<String>,
pub stats: SyncStats,
}
impl SyncEngine {
/// Create a new sync engine
pub async fn new(config: Config) -> CalDavResult<Self> {
info!("Creating sync engine for: {}", config.server.url);
// Create CalDAV client
let client = RealCalDavClient::new(
&config.server.url,
&config.server.username,
&config.server.password,
).await?;
let sync_state = SyncState {
last_sync: None,
sync_token: None,
known_events: HashMap::new(),
stats: SyncStats::default(),
};
Ok(Self {
client,
config,
local_events: HashMap::new(),
sync_state,
})
}
/// Perform 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: true,
events_processed: 0,
duration_ms: 0,
error_message: None,
stats: SyncStats::default(),
};
// Discover calendars
match self.discover_and_sync_calendars().await {
Ok(events_count) => {
result.events_processed = events_count;
result.stats.events_created = events_count;
info!("Full sync completed: {} events processed", events_count);
}
Err(e) => {
error!("Full sync failed: {}", e);
result.success = false;
result.error_message = Some(e.to_string());
result.stats.errors = 1;
}
}
let duration = Utc::now() - start_time;
result.duration_ms = duration.num_milliseconds() as u64;
result.stats.sync_duration_ms = result.duration_ms;
// Update sync state
self.sync_state.last_sync = Some(Utc::now());
self.sync_state.stats = result.stats.clone();
Ok(result)
}
/// Perform incremental synchronization
pub async fn sync_incremental(&mut self) -> CalDavResult<SyncResult> {
let _start_time = Utc::now();
info!("Starting incremental calendar synchronization");
// For now, incremental sync is the same as full sync
// In a real implementation, we would use sync tokens or last modified timestamps
self.sync_full().await
}
/// Force a full resynchronization
pub async fn force_full_resync(&mut self) -> CalDavResult<SyncResult> {
info!("Forcing full resynchronization");
// Clear sync state
self.sync_state.sync_token = None;
self.sync_state.known_events.clear();
self.local_events.clear();
self.sync_full().await
}
/// Start automatic synchronization loop
pub async fn start_auto_sync(&mut self) -> CalDavResult<()> {
info!("Starting automatic synchronization loop");
loop {
if let Err(e) = self.sync_incremental().await {
error!("Auto sync failed: {}", e);
// Wait before retrying
sleep(tokio::time::Duration::from_secs(60)).await;
}
// Wait for next sync interval
let interval_secs = self.config.sync.interval;
debug!("Waiting {} seconds for next sync", interval_secs);
sleep(tokio::time::Duration::from_secs(interval_secs as u64)).await;
}
}
/// Get local events
pub fn get_local_events(&self) -> Vec<SyncEvent> {
self.local_events.values().cloned().collect()
}
/// Discover calendars and sync events
async fn discover_and_sync_calendars(&mut self) -> CalDavResult<u64> {
info!("Discovering calendars");
// Get calendar list
let calendars = self.client.discover_calendars().await?;
let mut total_events = 0u64;
let mut found_matching_calendar = false;
for calendar in calendars {
info!("Processing calendar: {}", calendar.name);
// Find calendar matching our configured calendar name
if calendar.name == self.config.calendar.name ||
calendar.display_name.as_ref().map_or(false, |n| n == &self.config.calendar.name) {
found_matching_calendar = true;
info!("Found matching calendar: {}", calendar.name);
// Calculate date range based on configuration
let now = Utc::now();
let (start_date, end_date) = if self.config.sync.date_range.sync_all_events {
// Sync all events regardless of date
// Use a very wide date range
let start_date = now - Duration::days(365 * 10); // 10 years ago
let end_date = now + Duration::days(365 * 10); // 10 years in future
info!("Syncing all events (wide date range: {} to {})",
start_date.format("%Y-%m-%d"), end_date.format("%Y-%m-%d"));
(start_date, end_date)
} else {
// Use configured date range
let days_back = self.config.sync.date_range.days_back;
let days_ahead = self.config.sync.date_range.days_ahead;
let start_date = now - Duration::days(days_back);
let end_date = now + Duration::days(days_ahead);
info!("Syncing events for date range: {} to {} ({} days back, {} days ahead)",
start_date.format("%Y-%m-%d"),
end_date.format("%Y-%m-%d"),
days_back, days_ahead);
(start_date, end_date)
};
// Get events for this calendar
match self.client.get_events(&calendar.url, start_date, end_date).await {
Ok(events) => {
info!("Found {} events in calendar: {}", events.len(), calendar.name);
// Process events
for event in events {
let sync_event = SyncEvent {
id: event.id.clone(),
href: event.href.clone(),
summary: event.summary.clone(),
description: event.description,
start: event.start,
end: event.end,
location: event.location,
status: event.status,
last_modified: event.last_modified,
source_calendar: calendar.name.clone(),
start_tzid: event.start_tzid,
end_tzid: event.end_tzid,
};
// Add to local cache
self.local_events.insert(event.id.clone(), sync_event);
total_events += 1;
}
}
Err(e) => {
warn!("Failed to get events from calendar {}: {}", calendar.name, e);
}
}
// For now, we only sync from one calendar as configured
break;
}
}
if !found_matching_calendar {
warn!("No calendars found matching: {}", self.config.calendar.name);
} else if total_events == 0 {
info!("No events found in matching calendar for the specified date range");
}
Ok(total_events)
}
}
impl Default for SyncState {
fn default() -> Self {
Self {
last_sync: None,
sync_token: None,
known_events: HashMap::new(),
stats: SyncStats::default(),
}
}
}