Fix timezone handling and update detection

- Fix timezone preservation in to_ical_simple() for import module
- Add timezone comparison to needs_update() method to detect timezone differences
- Add comprehensive test for timezone comparison logic
- Log Bug #3: recurring event end detection issue for future investigation
This commit is contained in:
Alvaro Soliverez 2025-11-21 11:56:27 -03:00
parent f84ce62f73
commit 932b6ae463
5 changed files with 1178 additions and 132 deletions

View file

@ -4,9 +4,11 @@ use tracing::{info, warn, error, Level};
use tracing_subscriber;
use caldav_sync::{Config, CalDavResult, SyncEngine};
use caldav_sync::nextcloud_import::{ImportEngine, ImportBehavior};
use caldav_sync::minicaldav_client::CalendarEvent;
use std::path::PathBuf;
use chrono::{Utc, Duration};
#[derive(Parser)]
#[command(name = "caldav-sync")]
#[command(about = "A CalDAV calendar synchronization tool")]
@ -72,13 +74,17 @@ struct Cli {
#[arg(long)]
nextcloud_calendar: Option<String>,
/// Import behavior: skip_duplicates, overwrite, merge
#[arg(long, default_value = "skip_duplicates")]
/// Import behavior: strict, strict_with_cleanup
#[arg(long, default_value = "strict")]
import_behavior: String,
/// Dry run - show what would be imported without actually doing it
#[arg(long)]
dry_run: bool,
/// List events from import target calendar and exit
#[arg(long)]
list_import_events: bool,
}
#[tokio::main]
@ -600,11 +606,371 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
return Ok(());
}
// Handle listing events from import target calendar
if cli.list_import_events {
info!("Listing events from import target calendar");
// Validate import configuration
let import_config = match config.get_import_config() {
Some(config) => config,
None => {
error!("No import target configured. Please add [import] section to config.toml");
return Err(anyhow::anyhow!("Import configuration not found").into());
}
};
// Override target calendar if specified via CLI
let target_calendar_name = cli.nextcloud_calendar.as_ref()
.unwrap_or(&import_config.target_calendar.name);
println!("📅 Events from Import Target Calendar");
println!("=====================================");
println!("Target Server: {}", import_config.target_server.url);
println!("Target Calendar: {}\n", target_calendar_name);
// Create a temporary config for the target server
let mut target_config = config.clone();
target_config.server.url = import_config.target_server.url.clone();
target_config.server.username = import_config.target_server.username.clone();
target_config.server.password = import_config.target_server.password.clone();
target_config.server.timeout = import_config.target_server.timeout;
target_config.server.use_https = import_config.target_server.use_https;
target_config.server.headers = import_config.target_server.headers.clone();
target_config.calendar.name = target_calendar_name.clone();
// Connect to target server
let target_sync_engine = match SyncEngine::new(target_config).await {
Ok(engine) => engine,
Err(e) => {
error!("Failed to connect to target server: {}", e);
println!("❌ Failed to connect to target server: {}", e);
println!("Please check your import configuration:");
println!(" URL: {}", import_config.target_server.url);
println!(" Username: {}", import_config.target_server.username);
println!(" Target Calendar: {}", target_calendar_name);
return Err(e.into());
}
};
println!("✅ Successfully connected to target server!");
// Discover calendars to find the target calendar URL
let target_calendars = match target_sync_engine.client.discover_calendars().await {
Ok(calendars) => calendars,
Err(e) => {
error!("Failed to discover calendars on target server: {}", e);
println!("❌ Failed to discover calendars: {}", e);
return Err(e.into());
}
};
// Find the target calendar
let target_calendar = target_calendars.iter()
.find(|c| c.name == *target_calendar_name || c.display_name.as_ref().map_or(false, |dn| dn == target_calendar_name));
let target_calendar = match target_calendar {
Some(calendar) => {
println!("✅ Found target calendar: {}", calendar.display_name.as_ref().unwrap_or(&calendar.name));
calendar
}
None => {
println!("❌ Target calendar '{}' not found on server", target_calendar_name);
println!("Available calendars:");
for calendar in &target_calendars {
println!(" - {}", calendar.display_name.as_ref().unwrap_or(&calendar.name));
}
return Err(anyhow::anyhow!("Target calendar not found").into());
}
};
// Check if calendar supports events
let supports_events = target_calendar.supported_components.contains(&"VEVENT".to_string());
if !supports_events {
println!("❌ Target calendar does not support events");
println!("Supported components: {}", target_calendar.supported_components.join(", "));
return Err(anyhow::anyhow!("Calendar does not support events").into());
}
// Set date range for event listing (past 30 days to next 30 days)
let now = Utc::now();
let start_date = now - Duration::days(30);
let end_date = now + Duration::days(30);
println!("\nRetrieving events from {} to {}...",
start_date.format("%Y-%m-%d"),
end_date.format("%Y-%m-%d"));
// Get events from the target calendar using the full URL
let events: Vec<CalendarEvent> = match target_sync_engine.client.get_events(&target_calendar.url, start_date, end_date).await {
Ok(events) => events,
Err(e) => {
error!("Failed to retrieve events from target calendar: {}", e);
println!("❌ Failed to retrieve events: {}", e);
return Err(e.into());
}
};
println!("\n📊 Event Summary");
println!("================");
println!("Total events found: {}", events.len());
if events.is_empty() {
println!("\nNo events found in the specified date range.");
return Ok(());
}
// Count events by status and other properties
let mut confirmed_events = 0;
let mut tentative_events = 0;
let mut cancelled_events = 0;
let mut all_day_events = 0;
let mut events_with_location = 0;
let mut upcoming_events = 0;
let mut past_events = 0;
for event in &events {
// Count by status
if let Some(ref status) = event.status {
match status.to_lowercase().as_str() {
"confirmed" => confirmed_events += 1,
"tentative" => tentative_events += 1,
"cancelled" => cancelled_events += 1,
_ => {}
}
}
// Check if all-day (simple heuristic)
if event.start.time() == chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap() &&
event.end.time() == chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap_or(chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()) {
all_day_events += 1;
}
// Count events with locations
if let Some(ref location) = event.location {
if !location.is_empty() {
events_with_location += 1;
}
}
// Count upcoming vs past events
if event.end > now {
upcoming_events += 1;
} else {
past_events += 1;
}
}
println!(" Confirmed: {}", confirmed_events);
println!(" Tentative: {}", tentative_events);
println!(" Cancelled: {}", cancelled_events);
println!(" All-day: {}", all_day_events);
println!(" With location: {}", events_with_location);
println!(" Upcoming: {}", upcoming_events);
println!(" Past: {}", past_events);
// Display detailed event information
println!("\n📅 Event Details");
println!("=================");
// Sort events by start time
let mut sorted_events = events.clone();
sorted_events.sort_by(|a, b| a.start.cmp(&b.start));
for (i, event) in sorted_events.iter().enumerate() {
println!("\n{}. {}", i + 1, event.summary);
// Format dates and times
let start_formatted = event.start.format("%Y-%m-%d %H:%M");
let end_formatted = event.end.format("%Y-%m-%d %H:%M");
println!(" 📅 {} to {}", start_formatted, end_formatted);
// Event ID
println!(" 🆔 ID: {}", event.id);
// Status
let status_icon = if let Some(ref status) = event.status {
match status.to_lowercase().as_str() {
"confirmed" => "",
"tentative" => "🔄",
"cancelled" => "",
_ => "",
}
} else {
""
};
let status_display = event.status.as_deref().unwrap_or("Unknown");
println!(" 📊 Status: {} {}", status_icon, status_display);
// Location
if let Some(ref location) = event.location {
if !location.is_empty() {
println!(" 📍 Location: {}", location);
}
}
// Description (truncated if too long)
if let Some(ref description) = event.description {
if !description.is_empty() {
let truncated = if description.len() > 100 {
format!("{}...", &description[..97])
} else {
description.clone()
};
println!(" 📝 Description: {}", truncated);
}
}
// ETag for synchronization info
if let Some(ref etag) = event.etag {
println!(" 🏷️ ETag: {}", etag);
}
}
// Import analysis
println!("\n🔍 Import Analysis");
println!("==================");
println!("This target calendar contains {} events.", events.len());
if cli.import_info {
println!("\nBased on the strict unidirectional import behavior:");
println!("- These events would be checked against source events");
println!("- Events not present in source would be deleted (if using strict_with_cleanup)");
println!("- Events present in both would be updated if source is newer");
println!("- New events from source would be added to this calendar");
println!("\nRecommendations:");
if events.len() > 100 {
println!("- ⚠️ Large number of events - consider using strict behavior first");
}
if cancelled_events > 0 {
println!("- 🗑️ {} cancelled events could be cleaned up", cancelled_events);
}
if past_events > events.len() / 2 {
println!("- 📚 Many past events - consider cleanup if not needed");
}
}
return Ok(());
}
// Create sync engine for other operations
let mut sync_engine = SyncEngine::new(config.clone()).await?;
if cli.list_events {
// List events and exit
// Check if we should list events from import target calendar
if cli.import_info {
// List events from import target calendar (similar to list_import_events but simplified)
info!("Listing events from import target calendar");
// Validate import configuration
let import_config = match config.get_import_config() {
Some(config) => config,
None => {
error!("No import target configured. Please add [import] section to config.toml");
return Err(anyhow::anyhow!("Import configuration not found").into());
}
};
// Override target calendar if specified via CLI
let target_calendar_name = cli.nextcloud_calendar.as_ref()
.unwrap_or(&import_config.target_calendar.name);
println!("📅 Events from Import Target Calendar");
println!("=====================================");
println!("Target Server: {}", import_config.target_server.url);
println!("Target Calendar: {}\n", target_calendar_name);
// Create a temporary config for the target server
let mut target_config = config.clone();
target_config.server.url = import_config.target_server.url.clone();
target_config.server.username = import_config.target_server.username.clone();
target_config.server.password = import_config.target_server.password.clone();
target_config.server.timeout = import_config.target_server.timeout;
target_config.server.use_https = import_config.target_server.use_https;
target_config.server.headers = import_config.target_server.headers.clone();
target_config.calendar.name = target_calendar_name.clone();
// Connect to target server
let target_sync_engine = match SyncEngine::new(target_config).await {
Ok(engine) => engine,
Err(e) => {
error!("Failed to connect to target server: {}", e);
println!("❌ Failed to connect to target server: {}", e);
return Err(e.into());
}
};
println!("✅ Successfully connected to target server!");
// Discover calendars to find the target calendar URL
let target_calendars = match target_sync_engine.client.discover_calendars().await {
Ok(calendars) => calendars,
Err(e) => {
error!("Failed to discover calendars on target server: {}", e);
println!("❌ Failed to discover calendars: {}", e);
return Err(e.into());
}
};
// Find the target calendar
let target_calendar = target_calendars.iter()
.find(|c| c.name == *target_calendar_name || c.display_name.as_ref().map_or(false, |dn| dn == target_calendar_name));
let target_calendar = match target_calendar {
Some(calendar) => {
println!("✅ Found target calendar: {}", calendar.display_name.as_ref().unwrap_or(&calendar.name));
calendar
}
None => {
println!("❌ Target calendar '{}' not found on server", target_calendar_name);
println!("Available calendars:");
for calendar in &target_calendars {
println!(" - {}", calendar.display_name.as_ref().unwrap_or(&calendar.name));
}
return Err(anyhow::anyhow!("Target calendar not found").into());
}
};
// Set date range for event listing (past 30 days to next 30 days)
let now = Utc::now();
let start_date = now - Duration::days(30);
let end_date = now + Duration::days(30);
println!("\nRetrieving events from {} to {}...",
start_date.format("%Y-%m-%d"),
end_date.format("%Y-%m-%d"));
// Get events from the target calendar using the full URL
let events: Vec<CalendarEvent> = match target_sync_engine.client.get_events(&target_calendar.url, start_date, end_date).await {
Ok(events) => events,
Err(e) => {
error!("Failed to retrieve events from target calendar: {}", e);
println!("❌ Failed to retrieve events: {}", e);
return Err(e.into());
}
};
println!("Found {} events:\n", events.len());
// Display events in a simple format similar to the original list_events
for event in events {
let start_tz = event.start_tzid.as_deref().unwrap_or("UTC");
let end_tz = event.end_tzid.as_deref().unwrap_or("UTC");
println!(" - {} ({} {} to {} {})",
event.summary,
event.start.format("%Y-%m-%d %H:%M"),
start_tz,
event.end.format("%Y-%m-%d %H:%M"),
end_tz
);
}
return Ok(());
}
// Original behavior: List events from source calendar and exit
info!("Listing events from calendar: {}", config.calendar.name);
// Use the specific approach if provided