- 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
1074 lines
49 KiB
Rust
1074 lines
49 KiB
Rust
use anyhow::Result;
|
|
use clap::Parser;
|
|
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")]
|
|
#[command(version)]
|
|
struct Cli {
|
|
/// Configuration file path
|
|
#[arg(short, long, default_value = "config/config.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,
|
|
|
|
/// 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>,
|
|
|
|
/// Show detailed import-relevant information for calendars
|
|
#[arg(long)]
|
|
import_info: bool,
|
|
|
|
/// Import events into Nextcloud calendar
|
|
#[arg(long)]
|
|
import_nextcloud: bool,
|
|
|
|
/// Target calendar name for Nextcloud import (overrides config)
|
|
#[arg(long)]
|
|
nextcloud_calendar: Option<String>,
|
|
|
|
/// 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]
|
|
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<()> {
|
|
if cli.list_calendars {
|
|
// List calendars and exit
|
|
info!("Listing available calendars from server");
|
|
|
|
if cli.import_info {
|
|
println!("🔍 Import Analysis Report");
|
|
println!("========================\n");
|
|
|
|
// Show source calendars (current configuration)
|
|
println!("📤 SOURCE CALENDARS (Zoho/Current Server)");
|
|
println!("==========================================");
|
|
|
|
// Get calendars from the source server - handle errors gracefully
|
|
let source_calendars = match SyncEngine::new(config.clone()).await {
|
|
Ok(sync_engine) => {
|
|
match sync_engine.client.discover_calendars().await {
|
|
Ok(calendars) => {
|
|
Some(calendars)
|
|
}
|
|
Err(e) => {
|
|
println!("⚠️ Failed to discover source calendars: {}", e);
|
|
println!("Source server may be unavailable or credentials may be incorrect.\n");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("⚠️ Failed to connect to source server: {}", e);
|
|
println!("Source server configuration may need checking.\n");
|
|
None
|
|
}
|
|
};
|
|
|
|
let target_calendar_name = &config.calendar.name;
|
|
|
|
if let Some(ref calendars) = source_calendars {
|
|
println!("Found {} source calendars:", calendars.len());
|
|
println!("Current source calendar: {}\n", target_calendar_name);
|
|
|
|
for (i, calendar) in calendars.iter().enumerate() {
|
|
let is_target = calendar.name == *target_calendar_name
|
|
|| calendar.display_name.as_ref().map_or(false, |dn| dn == target_calendar_name);
|
|
|
|
// Calendar header with target indicator
|
|
if is_target {
|
|
println!(" {}. {} 🎯 [CURRENT SOURCE]", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
|
|
} else {
|
|
println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
|
|
}
|
|
|
|
// Basic information
|
|
println!(" Name: {}", calendar.name);
|
|
println!(" URL: {}", calendar.url);
|
|
|
|
if let Some(ref display_name) = calendar.display_name {
|
|
println!(" Display Name: {}", display_name);
|
|
}
|
|
|
|
// Import-relevant information
|
|
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);
|
|
}
|
|
|
|
// Supported components - crucial for export compatibility
|
|
let components = &calendar.supported_components;
|
|
println!(" Supported Components: {}", components.join(", "));
|
|
|
|
// Export suitability analysis
|
|
let supports_events = components.contains(&"VEVENT".to_string());
|
|
let supports_todos = components.contains(&"VTODO".to_string());
|
|
let supports_journals = components.contains(&"VJOURNAL".to_string());
|
|
|
|
println!(" 📤 Export Analysis:");
|
|
println!(" Event Support: {}", if supports_events { "✅ Yes" } else { "❌ No" });
|
|
println!(" Task Support: {}", if supports_todos { "✅ Yes" } else { "❌ No" });
|
|
println!(" Journal Support: {}", if supports_journals { "✅ Yes" } else { "❌ No" });
|
|
|
|
// Server type detection
|
|
if calendar.url.contains("/zoho/") || calendar.url.contains("zoho.com") {
|
|
println!(" Server Type: 🔵 Zoho");
|
|
println!(" CalDAV Standard: ⚠️ Partially Compliant");
|
|
println!(" Special Features: Zoho-specific APIs available");
|
|
} else {
|
|
println!(" Server Type: 🔧 Generic CalDAV");
|
|
println!(" CalDAV Standard: ✅ Likely Compliant");
|
|
}
|
|
|
|
println!();
|
|
}
|
|
} else {
|
|
println!("⚠️ Could not retrieve source calendars");
|
|
println!("Please check your source server configuration:\n");
|
|
println!(" URL: {}", config.server.url);
|
|
println!(" Username: {}", config.server.username);
|
|
println!(" Calendar: {}\n", config.calendar.name);
|
|
}
|
|
|
|
// Show target import calendars if configured
|
|
if let Some(ref import_config) = config.get_import_config() {
|
|
println!("📥 TARGET IMPORT CALENDARS (Nextcloud/Destination)");
|
|
println!("=================================================");
|
|
|
|
println!("Configured target server: {}", import_config.target_server.url);
|
|
println!("Configured target calendar: {}\n", import_config.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();
|
|
|
|
println!("Attempting to connect to target server...");
|
|
|
|
// Try to connect to target server and list calendars
|
|
match SyncEngine::new(target_config).await {
|
|
Ok(target_sync_engine) => {
|
|
println!("✅ Successfully connected to target server!");
|
|
match target_sync_engine.client.discover_calendars().await {
|
|
Ok(target_calendars) => {
|
|
println!("Found {} target calendars:", target_calendars.len());
|
|
|
|
for (i, calendar) in target_calendars.iter().enumerate() {
|
|
let is_target = calendar.name == import_config.target_calendar.name
|
|
|| calendar.display_name.as_ref().map_or(false, |dn| *dn == import_config.target_calendar.name);
|
|
|
|
// Calendar header with target indicator
|
|
if is_target {
|
|
println!(" {}. {} 🎯 [IMPORT TARGET]", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
|
|
} else {
|
|
println!(" {}. {}", i + 1, calendar.display_name.as_ref().unwrap_or(&calendar.name));
|
|
}
|
|
|
|
// Basic information
|
|
println!(" Name: {}", calendar.name);
|
|
println!(" URL: {}", calendar.url);
|
|
|
|
if let Some(ref display_name) = calendar.display_name {
|
|
println!(" Display Name: {}", display_name);
|
|
}
|
|
|
|
// Import-relevant information
|
|
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);
|
|
}
|
|
|
|
// Supported components - crucial for import compatibility
|
|
let components = &calendar.supported_components;
|
|
println!(" Supported Components: {}", components.join(", "));
|
|
|
|
// Import suitability analysis
|
|
let supports_events = components.contains(&"VEVENT".to_string());
|
|
let supports_todos = components.contains(&"VTODO".to_string());
|
|
let supports_journals = components.contains(&"VJOURNAL".to_string());
|
|
|
|
println!(" 📥 Import Analysis:");
|
|
println!(" Event Support: {}", if supports_events { "✅ Yes" } else { "❌ No" });
|
|
println!(" Task Support: {}", if supports_todos { "✅ Yes" } else { "❌ No" });
|
|
println!(" Journal Support: {}", if supports_journals { "✅ Yes" } else { "❌ No" });
|
|
|
|
// Server type detection
|
|
if calendar.url.contains("/remote.php/dav/calendars/") {
|
|
println!(" Server Type: ☁️ Nextcloud");
|
|
println!(" CalDAV Standard: ✅ RFC 4791 Compliant");
|
|
println!(" Recommended: ✅ High compatibility");
|
|
println!(" Special Features: Full SabreDAV support");
|
|
} else {
|
|
println!(" Server Type: 🔧 Generic CalDAV");
|
|
println!(" CalDAV Standard: ✅ Likely Compliant");
|
|
}
|
|
|
|
// Additional Nextcloud-specific checks
|
|
if calendar.url.contains("/remote.php/dav/calendars/") && supports_events {
|
|
println!(" ✅ Ready for Nextcloud event import");
|
|
} else if !supports_events {
|
|
println!(" ⚠️ This calendar doesn't support events - not suitable for import");
|
|
}
|
|
|
|
println!();
|
|
}
|
|
|
|
// Import compatibility summary
|
|
let target_calendar = target_calendars.iter()
|
|
.find(|c| c.name == import_config.target_calendar.name
|
|
|| c.display_name.as_ref().map_or(false, |dn| *dn == import_config.target_calendar.name));
|
|
|
|
if let Some(target_cal) = target_calendar {
|
|
let supports_events = target_cal.supported_components.contains(&"VEVENT".to_string());
|
|
let is_nextcloud = target_cal.url.contains("/remote.php/dav/calendars/");
|
|
|
|
println!("📋 IMPORT READINESS SUMMARY");
|
|
println!("============================");
|
|
println!("Target Calendar: {}", target_cal.display_name.as_ref().unwrap_or(&target_cal.name));
|
|
println!("Supports Events: {}", if supports_events { "✅ Yes" } else { "❌ No" });
|
|
println!("Server Type: {}", if is_nextcloud { "☁️ Nextcloud" } else { "🔧 Generic CalDAV" });
|
|
|
|
if supports_events {
|
|
if is_nextcloud {
|
|
println!("Overall Status: ✅ Excellent - Nextcloud with full event support");
|
|
} else {
|
|
println!("Overall Status: ✅ Good - Generic CalDAV with event support");
|
|
}
|
|
} else {
|
|
println!("Overall Status: ❌ Not suitable - No event support");
|
|
}
|
|
} else {
|
|
println!("⚠️ Target calendar '{}' not found on server", import_config.target_calendar.name);
|
|
println!("Available calendars:");
|
|
for calendar in &target_calendars {
|
|
println!(" - {}", calendar.display_name.as_ref().unwrap_or(&calendar.name));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("❌ Failed to discover calendars on target server: {}", e);
|
|
println!("The server connection was successful, but calendar discovery failed.");
|
|
println!("Please check your import configuration:");
|
|
println!(" URL: {}", import_config.target_server.url);
|
|
println!(" Username: {}", import_config.target_server.username);
|
|
println!(" Target Calendar: {}", import_config.target_calendar.name);
|
|
}
|
|
}
|
|
}
|
|
Err(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: {}", import_config.target_calendar.name);
|
|
|
|
// Provide guidance based on the error
|
|
if e.to_string().contains("401") || e.to_string().contains("Unauthorized") {
|
|
println!("");
|
|
println!("💡 Troubleshooting tips:");
|
|
println!(" - Check username and password");
|
|
println!(" - For Nextcloud with 2FA, use app-specific passwords");
|
|
println!(" - Verify the URL format: https://your-nextcloud.com/remote.php/dav/calendars/username/");
|
|
} else if e.to_string().contains("404") || e.to_string().contains("Not Found") {
|
|
println!("");
|
|
println!("💡 Troubleshooting tips:");
|
|
println!(" - Verify the Nextcloud URL is correct");
|
|
println!(" - Check if CalDAV is enabled in Nextcloud settings");
|
|
println!(" - Ensure the username is correct (case-sensitive)");
|
|
} else if e.to_string().contains("timeout") || e.to_string().contains("connection") {
|
|
println!("");
|
|
println!("💡 Troubleshooting tips:");
|
|
println!(" - Check network connectivity");
|
|
println!(" - Verify the Nextcloud server is accessible");
|
|
println!(" - Try increasing timeout value in configuration");
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
println!("📥 No import target configured");
|
|
println!("To configure import target, add [import] section to config.toml:");
|
|
println!("");
|
|
println!("[import]");
|
|
println!("[import.target_server]");
|
|
println!("url = \"https://your-nextcloud.com/remote.php/dav/calendars/user\"");
|
|
println!("username = \"your-username\"");
|
|
println!("password = \"your-password\"");
|
|
println!("[import.target_calendar]");
|
|
println!("name = \"Imported-Zoho-Events\"");
|
|
println!("enabled = true");
|
|
}
|
|
} else {
|
|
// Regular calendar listing (original behavior) - only if not import_info
|
|
let sync_engine = SyncEngine::new(config.clone()).await?;
|
|
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 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(());
|
|
}
|
|
|
|
// Handle Nextcloud import
|
|
if cli.import_nextcloud {
|
|
info!("Starting Nextcloud import process");
|
|
|
|
// 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());
|
|
}
|
|
};
|
|
|
|
// Parse import behavior
|
|
let behavior = match cli.import_behavior.parse::<ImportBehavior>() {
|
|
Ok(behavior) => behavior,
|
|
Err(e) => {
|
|
error!("Invalid import behavior '{}': {}", cli.import_behavior, e);
|
|
return Err(anyhow::anyhow!("Invalid import behavior").into());
|
|
}
|
|
};
|
|
|
|
// Override target calendar if specified via CLI
|
|
let target_calendar_name = cli.nextcloud_calendar.as_ref()
|
|
.unwrap_or(&import_config.target_calendar.name);
|
|
|
|
info!("Importing to calendar: {}", target_calendar_name);
|
|
info!("Import behavior: {}", behavior);
|
|
info!("Dry run: {}", cli.dry_run);
|
|
|
|
// Create import engine
|
|
let import_engine = ImportEngine::new(import_config, behavior, cli.dry_run);
|
|
|
|
// Get source events from the source calendar
|
|
info!("Retrieving events from source calendar...");
|
|
let mut source_sync_engine = match SyncEngine::new(config.clone()).await {
|
|
Ok(engine) => engine,
|
|
Err(e) => {
|
|
error!("Failed to connect to source server: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
};
|
|
|
|
// Perform sync to get events
|
|
let _sync_result = match source_sync_engine.sync_full().await {
|
|
Ok(result) => result,
|
|
Err(e) => {
|
|
error!("Failed to sync events from source: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
};
|
|
|
|
let source_events = source_sync_engine.get_local_events();
|
|
info!("Retrieved {} events from source calendar", source_events.len());
|
|
|
|
if source_events.is_empty() {
|
|
info!("No events found in source calendar to import");
|
|
return Ok(());
|
|
}
|
|
|
|
// Convert source events to import events (Event type conversion needed)
|
|
// TODO: For now, we'll simulate with test events since Event types might differ
|
|
let import_events: Vec<caldav_sync::event::Event> = source_events
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(_i, event)| {
|
|
// Convert CalendarEvent to Event for import
|
|
// This is a simplified conversion - you may need to adjust based on actual Event structure
|
|
caldav_sync::event::Event {
|
|
uid: event.id.clone(),
|
|
summary: event.summary.clone(),
|
|
description: event.description.clone(),
|
|
start: event.start,
|
|
end: event.end,
|
|
all_day: false, // TODO: Extract from event data
|
|
location: event.location.clone(),
|
|
status: caldav_sync::event::EventStatus::Confirmed, // TODO: Extract from event
|
|
event_type: caldav_sync::event::EventType::Public, // TODO: Extract from event
|
|
organizer: None, // TODO: Extract from event
|
|
attendees: Vec::new(), // TODO: Extract from event
|
|
recurrence: None, // TODO: Extract from event
|
|
alarms: Vec::new(), // TODO: Extract from event
|
|
properties: std::collections::HashMap::new(),
|
|
created: event.last_modified.unwrap_or_else(Utc::now),
|
|
last_modified: event.last_modified.unwrap_or_else(Utc::now),
|
|
sequence: 0, // TODO: Extract from event
|
|
timezone: event.start_tzid.clone(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Perform import
|
|
match import_engine.import_events(import_events).await {
|
|
Ok(result) => {
|
|
// Display import results
|
|
println!("\n🎉 Import Completed Successfully!");
|
|
println!("=====================================");
|
|
println!("Target Calendar: {}", result.target_calendar);
|
|
println!("Import Behavior: {}", result.behavior);
|
|
println!("Dry Run: {}", if result.dry_run { "Yes" } else { "No" });
|
|
println!();
|
|
|
|
if let Some(duration) = result.duration() {
|
|
println!("Duration: {}ms", duration.num_milliseconds());
|
|
}
|
|
|
|
println!("Results:");
|
|
println!(" Total events processed: {}", result.total_events);
|
|
println!(" Successfully imported: {}", result.imported);
|
|
println!(" Skipped: {}", result.skipped);
|
|
println!(" Failed: {}", result.failed);
|
|
println!(" Success rate: {:.1}%", result.success_rate());
|
|
|
|
if !result.errors.is_empty() {
|
|
println!("\n⚠️ Errors encountered:");
|
|
for error in &result.errors {
|
|
println!(" - {}: {}",
|
|
error.event_summary.as_deref().unwrap_or("Unknown event"),
|
|
error.message);
|
|
}
|
|
}
|
|
|
|
if !result.conflicts.is_empty() {
|
|
println!("\n🔄 Conflicts resolved:");
|
|
for conflict in &result.conflicts {
|
|
println!(" - {}: {:?}", conflict.event_summary, conflict.resolution);
|
|
}
|
|
}
|
|
|
|
if result.dry_run {
|
|
println!("\n💡 This was a dry run. No actual changes were made.");
|
|
println!(" Run without --dry-run to perform the actual import.");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!("Import failed: {}", e);
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// 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
|
|
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);
|
|
|
|
// Get and display events
|
|
let events = sync_engine.get_local_events();
|
|
println!("Found {} events:", events.len());
|
|
|
|
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(());
|
|
}
|
|
|
|
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(())
|
|
}
|