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:
parent
f84ce62f73
commit
932b6ae463
5 changed files with 1178 additions and 132 deletions
372
src/main.rs
372
src/main.rs
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue