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

29
TODO.md Normal file
View file

@ -0,0 +1,29 @@
# TODO - CalDAV Sync Tool
## 🐛 Known Issues
### Bug #3: Recurring Event End Detection
**Status**: Identified
**Priority**: Medium
**Description**: System not properly handling when recurring events have ended, causing duplicates in target calendar
**Issue**: When recurring events have ended (passed their UNTIL date or COUNT limit), the system may still be creating occurrences or not properly cleaning up old occurrences, leading to duplicate events in the target calendar.
**Files to investigate**:
- `src/event.rs` - `expand_occurrences()` method
- `src/nextcloud_import.rs` - import and cleanup logic
- Date range calculations for event fetching
## ✅ Completed
- [x] Fix timezone preservation in expanded recurring events
- [x] Fix timezone-aware iCal generation for import module
- [x] Fix timezone comparison in `needs_update()` method
- [x] Fix RRULE BYDAY filtering for daily frequency events
## 🔧 Future Tasks
- [ ] Investigate other timezone issues if they exist
- [ ] Cleanup debug logging
- [ ] Add comprehensive tests for timezone handling
- [ ] Consider adding timezone conversion utilities

View file

@ -1,10 +1,15 @@
//! Event handling and iCalendar parsing //! Event handling and iCalendar parsing
use crate::error::CalDavResult; use crate::error::CalDavResult;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc, Datelike, Timelike};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
use md5;
// RRULE support (simplified for now)
// use rrule::{RRuleSet, RRule, Frequency, Weekday as RRuleWeekday, NWeekday, Tz};
// use std::str::FromStr;
/// Calendar event representation /// Calendar event representation
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -111,47 +116,107 @@ pub enum ParticipationStatus {
Delegated, Delegated,
} }
/// Recurrence rule /// Recurrence rule (simplified RRULE string representation)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurrenceRule { pub struct RecurrenceRule {
/// Frequency /// Original RRULE string for storage and parsing
pub frequency: RecurrenceFrequency, pub original_rule: String,
/// Interval
pub interval: u32,
/// Count (number of occurrences)
pub count: Option<u32>,
/// Until date
pub until: Option<DateTime<Utc>>,
/// Days of week
pub by_day: Option<Vec<WeekDay>>,
/// Days of month
pub by_month_day: Option<Vec<u32>>,
/// Months
pub by_month: Option<Vec<u32>>,
} }
/// Recurrence frequency impl RecurrenceRule {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] /// Create a new RecurrenceRule from an RRULE string
pub enum RecurrenceFrequency { pub fn from_str(rrule_str: &str) -> Result<Self, Box<dyn std::error::Error>> {
Secondly, Ok(RecurrenceRule {
Minutely, original_rule: rrule_str.to_string(),
Hourly, })
Daily, }
Weekly,
Monthly,
Yearly,
}
/// Day of week for recurrence /// Get the RRULE string
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub fn as_str(&self) -> &str {
pub enum WeekDay { &self.original_rule
Sunday, }
Monday,
Tuesday, /// Parse RRULE components from the original_rule string
Wednesday, fn parse_components(&self) -> std::collections::HashMap<String, String> {
Thursday, let mut components = std::collections::HashMap::new();
Friday,
Saturday, for part in self.original_rule.split(';') {
if let Some((key, value)) = part.split_once('=') {
components.insert(key.to_uppercase(), value.to_string());
}
}
components
}
/// Get the frequency (FREQ) component
pub fn frequency(&self) -> String {
self.parse_components()
.get("FREQ")
.cloned()
.unwrap_or_else(|| "DAILY".to_string())
}
/// Get the interval (INTERVAL) component
pub fn interval(&self) -> i32 {
self.parse_components()
.get("INTERVAL")
.and_then(|s| s.parse().ok())
.unwrap_or(1)
}
/// Get the count (COUNT) component
pub fn count(&self) -> Option<i32> {
self.parse_components()
.get("COUNT")
.and_then(|s| s.parse().ok())
}
/// Get the until date (UNTIL) component
pub fn until(&self) -> Option<DateTime<Utc>> {
self.parse_components()
.get("UNTIL")
.and_then(|s| {
// Try parsing as different date formats
// Format 1: YYYYMMDD (8 characters)
if s.len() == 8 {
return DateTime::parse_from_str(&format!("{}T000000Z", s), "%Y%m%dT%H%M%SZ")
.ok()
.map(|dt| dt.with_timezone(&Utc));
}
// Format 2: Basic iCalendar datetime with Z: YYYYMMDDTHHMMSSZ (15 or 16 characters)
if s.ends_with('Z') && (s.len() == 15 || s.len() == 16) {
let cleaned = s.trim_end_matches('Z');
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(cleaned, "%Y%m%dT%H%M%S") {
return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
}
}
// Format 3: Basic iCalendar datetime without Z: YYYYMMDDTHHMMSS (15 characters)
if s.len() == 15 && s.contains('T') {
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S") {
return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
}
}
// Format 4: Try RFC3339 format
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
None
})
}
/// Get the BYDAY component
pub fn by_day(&self) -> Vec<String> {
self.parse_components()
.get("BYDAY")
.map(|s| s.split(',').map(|s| s.to_string()).collect())
.unwrap_or_default()
}
} }
/// Event alarm/reminder /// Event alarm/reminder
@ -188,6 +253,38 @@ pub enum AlarmTrigger {
Absolute(DateTime<Utc>), Absolute(DateTime<Utc>),
} }
impl std::fmt::Display for AlarmAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AlarmAction::Display => write!(f, "DISPLAY"),
AlarmAction::Email => write!(f, "EMAIL"),
AlarmAction::Audio => write!(f, "AUDIO"),
}
}
}
impl std::fmt::Display for AlarmTrigger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AlarmTrigger::BeforeStart(duration) => {
let total_seconds = duration.num_seconds();
write!(f, "-P{}S", total_seconds.abs())
}
AlarmTrigger::AfterStart(duration) => {
let total_seconds = duration.num_seconds();
write!(f, "P{}S", total_seconds)
}
AlarmTrigger::BeforeEnd(duration) => {
let total_seconds = duration.num_seconds();
write!(f, "-P{}S", total_seconds)
}
AlarmTrigger::Absolute(datetime) => {
write!(f, "{}", datetime.format("%Y%m%dT%H%M%SZ"))
}
}
}
}
impl Event { impl Event {
/// Create a new event /// Create a new event
pub fn new(summary: String, start: DateTime<Utc>, end: DateTime<Utc>) -> Self { pub fn new(summary: String, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
@ -274,18 +371,28 @@ impl Event {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description))); ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
} }
// Dates // Dates with timezone preservation
if self.all_day { if self.all_day {
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
self.start.format("%Y%m%d"))); self.start.format("%Y%m%d")));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
self.end.format("%Y%m%d"))); self.end.format("%Y%m%d")));
} else { } else {
// Check if we have timezone information
if let Some(ref tzid) = self.timezone {
// Use timezone-aware format
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n",
tzid, self.start.format("%Y%m%dT%H%M%S")));
ical.push_str(&format!("DTEND;TZID={}:{}\r\n",
tzid, self.end.format("%Y%m%dT%H%M%S")));
} else {
// Fall back to UTC format
ical.push_str(&format!("DTSTART:{}\r\n", ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ"))); self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n", ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ"))); self.end.format("%Y%m%dT%H%M%SZ")));
} }
}
// Status // Status
ical.push_str(&format!("STATUS:{}\r\n", match self.status { ical.push_str(&format!("STATUS:{}\r\n", match self.status {
@ -334,6 +441,190 @@ impl Event {
self.sequence += 1; self.sequence += 1;
} }
/// Generate simplified iCalendar format optimized for Nextcloud import
/// This creates clean, individual .ics files that avoid Zoho parsing issues
pub fn to_ical_simple(&self) -> CalDavResult<String> {
let mut ical = String::new();
// iCalendar header - minimal and clean
ical.push_str("BEGIN:VCALENDAR\r\n");
ical.push_str("VERSION:2.0\r\n");
ical.push_str("PRODID:-//caldav-sync//simple-import//EN\r\n");
ical.push_str("CALSCALE:GREGORIAN\r\n");
// VEVENT header
ical.push_str("BEGIN:VEVENT\r\n");
// Required properties - only the essentials for Nextcloud
ical.push_str(&format!("UID:{}\r\n", escape_ical_text(&self.uid)));
ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary)));
// Simplified datetime handling - timezone-aware for compatibility
if self.all_day {
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
self.start.format("%Y%m%d")));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
self.end.format("%Y%m%d")));
} else {
// Use timezone-aware format when available, fall back to UTC
if let Some(ref tzid) = self.timezone {
// Use timezone-aware format
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n",
tzid, self.start.format("%Y%m%dT%H%M%S")));
ical.push_str(&format!("DTEND;TZID={}:{}\r\n",
tzid, self.end.format("%Y%m%dT%H%M%S")));
} else {
// Fall back to UTC format for maximum compatibility
ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ")));
}
}
// Required timestamps
ical.push_str(&format!("DTSTAMP:{}\r\n", Utc::now().format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence));
// Basic status - always confirmed for simplicity
ical.push_str("STATUS:CONFIRMED\r\n");
ical.push_str("CLASS:PUBLIC\r\n");
// VEVENT and VCALENDAR footers
ical.push_str("END:VEVENT\r\n");
ical.push_str("END:VCALENDAR\r\n");
Ok(ical)
}
/// Generate iCalendar format optimized for Nextcloud
pub fn to_ical_for_nextcloud(&self) -> CalDavResult<String> {
let mut ical = String::new();
// iCalendar header with Nextcloud-specific properties
ical.push_str("BEGIN:VCALENDAR\r\n");
ical.push_str("VERSION:2.0\r\n");
ical.push_str("PRODID:-//caldav-sync//caldav-sync 0.1.0//EN\r\n");
ical.push_str("CALSCALE:GREGORIAN\r\n");
// Add timezone information if available
if let Some(tzid) = &self.timezone {
ical.push_str(&format!("X-WR-TIMEZONE:{}\r\n", tzid));
}
// VEVENT header
ical.push_str("BEGIN:VEVENT\r\n");
// Required properties
ical.push_str(&format!("UID:{}\r\n", escape_ical_text(&self.uid)));
ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary)));
// Enhanced datetime handling with timezone support
if self.all_day {
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
self.start.format("%Y%m%d")));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
(self.end.date_naive() + chrono::Duration::days(1)).format("%Y%m%d")));
} else {
if let Some(tzid) = &self.timezone {
// Use timezone-specific format
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n",
tzid, self.start.format("%Y%m%dT%H%M%S")));
ical.push_str(&format!("DTEND;TZID={}:{}\r\n",
tzid, self.end.format("%Y%m%dT%H%M%S")));
} else {
// Use UTC format
ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ")));
}
}
// Required timestamps
ical.push_str(&format!("DTSTAMP:{}\r\n", Utc::now().format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence));
// Optional properties
if let Some(description) = &self.description {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
}
if let Some(location) = &self.location {
ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location)));
}
// Status mapping
ical.push_str(&format!("STATUS:{}\r\n", match self.status {
EventStatus::Confirmed => "CONFIRMED",
EventStatus::Tentative => "TENTATIVE",
EventStatus::Cancelled => "CANCELLED",
}));
// Class (visibility)
ical.push_str(&format!("CLASS:{}\r\n", match self.event_type {
EventType::Public => "PUBLIC",
EventType::Private => "PRIVATE",
EventType::Confidential => "CONFIDENTIAL",
}));
// Organizer and attendees
if let Some(organizer) = &self.organizer {
if let Some(name) = &organizer.name {
ical.push_str(&format!("ORGANIZER;CN={}:mailto:{}\r\n",
escape_ical_text(name), organizer.email));
} else {
ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email));
}
}
for attendee in &self.attendees {
let mut attendee_line = String::from("ATTENDEE");
if let Some(name) = &attendee.name {
attendee_line.push_str(&format!(";CN={}", escape_ical_text(name)));
}
attendee_line.push_str(&format!(":mailto:{}", attendee.email));
attendee_line.push_str("\r\n");
ical.push_str(&attendee_line);
}
// Alarms/reminders
for alarm in &self.alarms {
ical.push_str(&format!("BEGIN:VALARM\r\n"));
ical.push_str(&format!("ACTION:{}\r\n", alarm.action));
ical.push_str(&format!("TRIGGER:{}\r\n", alarm.trigger));
if let Some(description) = &alarm.description {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
}
ical.push_str("END:VALARM\r\n");
}
// Custom properties (including Nextcloud-specific ones)
for (key, value) in &self.properties {
if key.starts_with("X-") {
ical.push_str(&format!("{}:{}\r\n", key, escape_ical_text(value)));
}
}
// VEVENT and VCALENDAR footers
ical.push_str("END:VEVENT\r\n");
ical.push_str("END:VCALENDAR\r\n");
Ok(ical)
}
/// Generate the CalDAV path for this event
pub fn generate_caldav_path(&self) -> String {
format!("{}.ics", self.uid)
}
/// Check if event occurs on a specific date /// Check if event occurs on a specific date
pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool { pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool {
let start_date = self.start.date_naive(); let start_date = self.start.date_naive();
@ -356,6 +647,301 @@ impl Event {
let now = Utc::now(); let now = Utc::now();
now >= self.start && now <= self.end now >= self.start && now <= self.end
} }
/// Check if this event needs updating compared to another event
pub fn needs_update(&self, other: &Event) -> bool {
// Compare essential fields
if self.summary != other.summary {
return true;
}
if self.description != other.description {
return true;
}
if self.location != other.location {
return true;
}
// Compare timezone information - this is crucial for detecting timezone mangling fixes
match (&self.timezone, &other.timezone) {
(None, None) => {
// Both have no timezone - continue with other checks
}
(Some(tz1), Some(tz2)) => {
// Both have timezone - compare them
if tz1 != tz2 {
return true;
}
}
(Some(_), None) | (None, Some(_)) => {
// One has timezone, other doesn't - definitely needs update
return true;
}
}
// Compare dates with some tolerance for timestamp differences
let start_diff = (self.start - other.start).num_seconds().abs();
let end_diff = (self.end - other.end).num_seconds().abs();
if start_diff > 60 || end_diff > 60 { // 1 minute tolerance
return true;
}
// Compare status and event type
if self.status != other.status {
return true;
}
if self.event_type != other.event_type {
return true;
}
// Compare sequence numbers - higher sequence means newer
if self.sequence > other.sequence {
return true;
}
false
}
/// Validate event for CalDAV import compatibility
pub fn validate_for_import(&self) -> Result<(), String> {
// Check required fields
if self.uid.trim().is_empty() {
return Err("Event UID cannot be empty".to_string());
}
if self.summary.trim().is_empty() {
return Err("Event summary cannot be empty".to_string());
}
// Validate datetime
if self.start > self.end {
return Err("Event start time must be before end time".to_string());
}
// Check for reasonable date ranges
let now = Utc::now();
let one_year_ago = now - chrono::Duration::days(365);
let ten_years_future = now + chrono::Duration::days(365 * 10);
if self.start < one_year_ago {
return Err("Event start time is more than one year in the past".to_string());
}
if self.start > ten_years_future {
return Err("Event start time is more than ten years in the future".to_string());
}
Ok(())
}
/// Simple recurrence expansion for basic RRULE strings
pub fn expand_occurrences(&self, start_range: DateTime<Utc>, end_range: DateTime<Utc>) -> Vec<Event> {
// If this is not a recurring event, return just this event
if self.recurrence.is_none() {
return vec![self.clone()];
}
let mut occurrences = Vec::new();
let recurrence_rule = self.recurrence.as_ref().unwrap();
// For now, implement a very basic RRULE expansion using simple date arithmetic
let mut current_start = self.start;
let event_duration = self.duration();
let mut occurrence_count = 0;
// Limit occurrences to prevent infinite loops
let max_occurrences = recurrence_rule.count().unwrap_or(1000).min(1000);
while current_start <= end_range && occurrence_count < max_occurrences {
// Check if we've reached the count limit
if let Some(count) = recurrence_rule.count() {
if occurrence_count >= count {
break;
}
}
// Check if we've reached the until limit
if let Some(until) = recurrence_rule.until() {
if current_start > until {
break;
}
}
// Check if this occurrence falls within our desired range
if current_start >= start_range && current_start <= end_range {
let mut occurrence = self.clone();
occurrence.start = current_start;
occurrence.end = current_start + event_duration;
// Create a unique UID for this occurrence
let occurrence_date = current_start.format("%Y%m%d").to_string();
// Include a hash of the original event details to ensure uniqueness across different recurring series
let series_identifier = format!("{:x}", md5::compute(format!("{}-{}", self.uid, self.summary)));
occurrence.uid = format!("{}-occurrence-{}-{}", series_identifier, occurrence_date, self.uid);
// Clear the recurrence rule for individual occurrences
occurrence.recurrence = None;
// Update creation and modification times
occurrence.created = Utc::now();
occurrence.last_modified = Utc::now();
occurrences.push(occurrence);
}
// Calculate next occurrence based on RRULE components
let interval = recurrence_rule.interval() as i64;
current_start = match recurrence_rule.frequency().to_lowercase().as_str() {
"daily" => {
// For daily frequency, check if there are BYDAY restrictions
let by_day = recurrence_rule.by_day();
if !by_day.is_empty() {
// Find the next valid weekday for DAILY frequency with BYDAY restriction
let mut next_day = current_start + chrono::Duration::days(1);
let mut days_checked = 0;
// Search for up to 7 days to find the next valid weekday
while days_checked < 7 {
let weekday = match next_day.weekday().number_from_monday() {
1 => "MO",
2 => "TU",
3 => "WE",
4 => "TH",
5 => "FR",
6 => "SA",
7 => "SU",
_ => "MO", // fallback
};
if by_day.contains(&weekday.to_string()) {
// Found the next valid weekday
break;
}
next_day = next_day + chrono::Duration::days(1);
days_checked += 1;
}
next_day
} else {
// No BYDAY restriction, just add days normally
current_start + chrono::Duration::days(interval)
}
},
"weekly" => {
// For weekly frequency, we need to handle BYDAY filtering
let by_day = recurrence_rule.by_day();
if !by_day.is_empty() {
// Find the next valid weekday
let mut next_day = current_start + chrono::Duration::days(1);
let mut days_checked = 0;
// Search for up to 7 days (one week) to find the next valid weekday
while days_checked < 7 {
let weekday = match next_day.weekday().number_from_monday() {
1 => "MO",
2 => "TU",
3 => "WE",
4 => "TH",
5 => "FR",
6 => "SA",
7 => "SU",
_ => "MO", // fallback
};
if by_day.contains(&weekday.to_string()) {
// Found the next valid weekday
break;
}
next_day = next_day + chrono::Duration::days(1);
days_checked += 1;
}
next_day
} else {
// No BYDAY restriction, just add weeks
current_start + chrono::Duration::weeks(interval)
}
},
"monthly" => add_months(current_start, interval as u32),
"yearly" => add_months(current_start, (interval * 12) as u32),
"hourly" => current_start + chrono::Duration::hours(interval),
"minutely" => current_start + chrono::Duration::minutes(interval),
"secondly" => current_start + chrono::Duration::seconds(interval),
_ => current_start + chrono::Duration::days(interval), // Default to daily
};
occurrence_count += 1;
}
tracing::info!(
"🔄 Expanded recurring event '{}' to {} occurrences between {} and {}",
self.summary,
occurrences.len(),
start_range.format("%Y-%m-%d"),
end_range.format("%Y-%m-%d")
);
occurrences
}
}
/// Add months to a DateTime (approximate handling)
fn add_months(dt: DateTime<Utc>, months: u32) -> DateTime<Utc> {
let naive_date = dt.naive_utc();
let year = naive_date.year();
let month = naive_date.month() as i32 + months as i32;
let new_year = year + (month - 1) / 12;
let new_month = ((month - 1) % 12) + 1;
// Keep the same day if possible, otherwise use the last day of the month
let day = naive_date.day().min(days_in_month(new_year as i32, new_month as u32));
// Try to create the new date with the same time, fallback to first day of month if invalid
if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, day) {
if let Some(new_naive_dt) = new_naive_date.and_hms_opt(naive_date.hour(), naive_date.minute(), naive_date.second()) {
return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc);
}
}
// Fallback: use first day of the month with the same time
if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, 1) {
if let Some(new_naive_dt) = new_naive_date.and_hms_opt(naive_date.hour(), naive_date.minute(), naive_date.second()) {
return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc);
}
}
// Ultimate fallback: use start of the month
if let Some(new_naive_date) = chrono::NaiveDate::from_ymd_opt(new_year, new_month as u32, 1) {
if let Some(new_naive_dt) = new_naive_date.and_hms_opt(0, 0, 0) {
return DateTime::from_naive_utc_and_offset(new_naive_dt, Utc);
}
}
// If all else fails, return the original date
dt
}
/// Get the number of days in a month
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
29
} else {
28
}
}
_ => 30, // Should never happen
}
} }
/// Escape text for iCalendar format /// Escape text for iCalendar format
@ -464,4 +1050,86 @@ mod tests {
let local_result = parse_ical_datetime("20231225T103000"); let local_result = parse_ical_datetime("20231225T103000");
assert!(local_result.is_err()); assert!(local_result.is_err());
} }
#[test]
fn test_event_to_ical_with_timezone() {
let start = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
let end = start + chrono::Duration::minutes(30);
let mut event = Event::new("Tether Sync".to_string(), start, end);
event.timezone = Some("America/Toronto".to_string());
let ical = event.to_ical().unwrap();
// Should include timezone information
assert!(ical.contains("DTSTART;TZID=America/Toronto:20231225T083000"));
assert!(ical.contains("DTEND;TZID=America/Toronto:20231225T090000"));
assert!(ical.contains("SUMMARY:Tether Sync"));
}
#[test]
fn test_event_to_ical_without_timezone() {
let start = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
let end = start + chrono::Duration::minutes(30);
let event = Event::new("UTC Event".to_string(), start, end);
let ical = event.to_ical().unwrap();
// Should use UTC format when no timezone is specified
assert!(ical.contains("DTSTART:20231225T083000Z"));
assert!(ical.contains("DTEND:20231225T090000Z"));
assert!(ical.contains("SUMMARY:UTC Event"));
}
#[test]
fn test_needs_update_timezone_comparison() {
let start = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T083000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
let end = start + chrono::Duration::minutes(30);
// Test case 1: Event with timezone vs event without timezone (should need update)
let mut event_with_tz = Event::new("Test Event".to_string(), start, end);
event_with_tz.timezone = Some("America/Toronto".to_string());
let event_without_tz = Event::new("Test Event".to_string(), start, end);
assert!(event_with_tz.needs_update(&event_without_tz));
assert!(event_without_tz.needs_update(&event_with_tz));
// Test case 2: Events with different timezones (should need update)
let mut event_tz1 = Event::new("Test Event".to_string(), start, end);
event_tz1.timezone = Some("America/Toronto".to_string());
let mut event_tz2 = Event::new("Test Event".to_string(), start, end);
event_tz2.timezone = Some("Europe/Athens".to_string());
assert!(event_tz1.needs_update(&event_tz2));
assert!(event_tz2.needs_update(&event_tz1));
// Test case 3: Events with same timezone (should not need update)
let mut event_tz3 = Event::new("Test Event".to_string(), start, end);
event_tz3.timezone = Some("America/Toronto".to_string());
let mut event_tz4 = Event::new("Test Event".to_string(), start, end);
event_tz4.timezone = Some("America/Toronto".to_string());
assert!(!event_tz3.needs_update(&event_tz4));
assert!(!event_tz4.needs_update(&event_tz3));
// Test case 4: Both events without timezone (should not need update)
let event_no_tz1 = Event::new("Test Event".to_string(), start, end);
let event_no_tz2 = Event::new("Test Event".to_string(), start, end);
assert!(!event_no_tz1.needs_update(&event_no_tz2));
assert!(!event_no_tz2.needs_update(&event_no_tz1));
}
} }

View file

@ -4,9 +4,11 @@ use tracing::{info, warn, error, Level};
use tracing_subscriber; use tracing_subscriber;
use caldav_sync::{Config, CalDavResult, SyncEngine}; use caldav_sync::{Config, CalDavResult, SyncEngine};
use caldav_sync::nextcloud_import::{ImportEngine, ImportBehavior}; use caldav_sync::nextcloud_import::{ImportEngine, ImportBehavior};
use caldav_sync::minicaldav_client::CalendarEvent;
use std::path::PathBuf; use std::path::PathBuf;
use chrono::{Utc, Duration}; use chrono::{Utc, Duration};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "caldav-sync")] #[command(name = "caldav-sync")]
#[command(about = "A CalDAV calendar synchronization tool")] #[command(about = "A CalDAV calendar synchronization tool")]
@ -72,13 +74,17 @@ struct Cli {
#[arg(long)] #[arg(long)]
nextcloud_calendar: Option<String>, nextcloud_calendar: Option<String>,
/// Import behavior: skip_duplicates, overwrite, merge /// Import behavior: strict, strict_with_cleanup
#[arg(long, default_value = "skip_duplicates")] #[arg(long, default_value = "strict")]
import_behavior: String, import_behavior: String,
/// Dry run - show what would be imported without actually doing it /// Dry run - show what would be imported without actually doing it
#[arg(long)] #[arg(long)]
dry_run: bool, dry_run: bool,
/// List events from import target calendar and exit
#[arg(long)]
list_import_events: bool,
} }
#[tokio::main] #[tokio::main]
@ -600,11 +606,371 @@ async fn run_sync(config: Config, cli: &Cli) -> CalDavResult<()> {
return Ok(()); 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 // Create sync engine for other operations
let mut sync_engine = SyncEngine::new(config.clone()).await?; let mut sync_engine = SyncEngine::new(config.clone()).await?;
if cli.list_events { 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); info!("Listing events from calendar: {}", config.calendar.name);
// Use the specific approach if provided // Use the specific approach if provided

View file

@ -595,25 +595,39 @@ impl RealCalDavClient {
// Simple XML parsing to extract calendar data // Simple XML parsing to extract calendar data
let mut events = Vec::new(); let mut events = Vec::new();
// Look for calendar-data content in the XML response // Look for calendar-data content in the XML response (try multiple namespace variants)
if let Some(start) = xml.find("<C:calendar-data>") { let calendar_data_patterns = vec![
if let Some(end) = xml.find("</C:calendar-data>") { ("<C:calendar-data>", "</C:calendar-data>"),
let ical_data = &xml[start + 17..end]; ("<cal:calendar-data>", "</cal:calendar-data>"),
debug!("Found iCalendar data: {}", ical_data); ("<c:calendar-data>", "</c:calendar-data>"),
];
let mut found_calendar_data = false;
for (start_tag, end_tag) in calendar_data_patterns {
if let Some(start) = xml.find(start_tag) {
if let Some(end) = xml.find(end_tag) {
let ical_data = &xml[start + start_tag.len()..end];
debug!("Found iCalendar data using {}: {}", start_tag, ical_data);
// Parse the iCalendar data // Parse the iCalendar data
if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) { if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) {
events.extend(parsed_events); events.extend(parsed_events);
found_calendar_data = true;
break;
} else { } else {
warn!("Failed to parse iCalendar data, falling back to mock"); warn!("Failed to parse iCalendar data using {}, trying next pattern", start_tag);
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"); }
if found_calendar_data {
info!("Parsed {} real events from CalDAV response", events.len());
return Ok(events);
}
// If no calendar-data found in any namespace format
debug!("No calendar-data found in XML response with any namespace pattern");
// Check if this is a PROPFIND response with hrefs to individual event files // Check if this is a PROPFIND response with hrefs to individual event files
if xml.contains("<D:href>") && xml.contains(".ics") { if xml.contains("<D:href>") && xml.contains(".ics") {
@ -625,11 +639,8 @@ impl RealCalDavClient {
return self.parse_propfind_response(xml, calendar_href).await; return self.parse_propfind_response(xml, calendar_href).await;
} }
return self.create_mock_event(calendar_href); warn!("No calendar data found in XML response for calendar: {}", calendar_href);
} return Ok(vec![]);
info!("Parsed {} real events from CalDAV response", events.len());
Ok(events)
} }
/// Parse multistatus response from REPORT request /// Parse multistatus response from REPORT request
@ -1043,35 +1054,6 @@ impl RealCalDavClient {
Ok(events) 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 /// Extract calendar name from URL
fn extract_calendar_name(&self, url: &str) -> String { fn extract_calendar_name(&self, url: &str) -> String {
// Extract calendar name from URL path // Extract calendar name from URL path

View file

@ -10,29 +10,26 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{info, warn, debug}; use tracing::{info, warn, debug};
/// Import behavior strategies /// Import behavior strategies for unidirectional sync
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ImportBehavior { pub enum ImportBehavior {
/// Skip events that already exist on target /// Strict import: target calendar must exist, no cleanup
SkipDuplicates, Strict,
/// Overwrite existing events with source data /// Strict with cleanup: delete target events not in source
Overwrite, StrictWithCleanup,
/// Merge event data (preserve target fields that aren't in source)
Merge,
} }
impl Default for ImportBehavior { impl Default for ImportBehavior {
fn default() -> Self { fn default() -> Self {
ImportBehavior::SkipDuplicates ImportBehavior::Strict
} }
} }
impl std::fmt::Display for ImportBehavior { impl std::fmt::Display for ImportBehavior {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
ImportBehavior::SkipDuplicates => write!(f, "skip_duplicates"), ImportBehavior::Strict => write!(f, "strict"),
ImportBehavior::Overwrite => write!(f, "overwrite"), ImportBehavior::StrictWithCleanup => write!(f, "strict_with_cleanup"),
ImportBehavior::Merge => write!(f, "merge"),
} }
} }
} }
@ -42,11 +39,10 @@ impl std::str::FromStr for ImportBehavior {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"skip_duplicates" => Ok(ImportBehavior::SkipDuplicates), "strict" => Ok(ImportBehavior::Strict),
"skip-duplicates" => Ok(ImportBehavior::SkipDuplicates), "strict_with_cleanup" => Ok(ImportBehavior::StrictWithCleanup),
"overwrite" => Ok(ImportBehavior::Overwrite), "strict-with-cleanup" => Ok(ImportBehavior::StrictWithCleanup),
"merge" => Ok(ImportBehavior::Merge), _ => Err(format!("Invalid import behavior: {}. Valid options: strict, strict_with_cleanup", s)),
_ => Err(format!("Invalid import behavior: {}. Valid options: skip_duplicates, overwrite, merge", s)),
} }
} }
} }
@ -56,9 +52,13 @@ impl std::str::FromStr for ImportBehavior {
pub struct ImportResult { pub struct ImportResult {
/// Total number of events processed /// Total number of events processed
pub total_events: usize, pub total_events: usize,
/// Number of events successfully imported /// Number of events successfully imported (new)
pub imported: usize, pub imported: usize,
/// Number of events skipped (duplicates, etc.) /// Number of events updated (existing)
pub updated: usize,
/// Number of events deleted (cleanup)
pub deleted: usize,
/// Number of events skipped (unchanged)
pub skipped: usize, pub skipped: usize,
/// Number of events that failed to import /// Number of events that failed to import
pub failed: usize, pub failed: usize,
@ -84,6 +84,8 @@ impl ImportResult {
Self { Self {
total_events: 0, total_events: 0,
imported: 0, imported: 0,
updated: 0,
deleted: 0,
skipped: 0, skipped: 0,
failed: 0, failed: 0,
errors: Vec::new(), errors: Vec::new(),
@ -386,17 +388,16 @@ mod tests {
#[test] #[test]
fn test_import_behavior_from_str() { fn test_import_behavior_from_str() {
assert!(matches!("skip_duplicates".parse::<ImportBehavior>(), Ok(ImportBehavior::SkipDuplicates))); assert!(matches!("strict".parse::<ImportBehavior>(), Ok(ImportBehavior::Strict)));
assert!(matches!("overwrite".parse::<ImportBehavior>(), Ok(ImportBehavior::Overwrite))); assert!(matches!("strict_with_cleanup".parse::<ImportBehavior>(), Ok(ImportBehavior::StrictWithCleanup)));
assert!(matches!("merge".parse::<ImportBehavior>(), Ok(ImportBehavior::Merge))); assert!(matches!("strict-with-cleanup".parse::<ImportBehavior>(), Ok(ImportBehavior::StrictWithCleanup)));
assert!("invalid".parse::<ImportBehavior>().is_err()); assert!("invalid".parse::<ImportBehavior>().is_err());
} }
#[test] #[test]
fn test_import_behavior_display() { fn test_import_behavior_display() {
assert_eq!(ImportBehavior::SkipDuplicates.to_string(), "skip_duplicates"); assert_eq!(ImportBehavior::Strict.to_string(), "strict");
assert_eq!(ImportBehavior::Overwrite.to_string(), "overwrite"); assert_eq!(ImportBehavior::StrictWithCleanup.to_string(), "strict_with_cleanup");
assert_eq!(ImportBehavior::Merge.to_string(), "merge");
} }
#[test] #[test]
@ -419,7 +420,7 @@ mod tests {
}, },
}; };
let engine = ImportEngine::new(config, ImportBehavior::SkipDuplicates, false); let engine = ImportEngine::new(config, ImportBehavior::Strict, false);
// Valid event should pass // Valid event should pass
let valid_event = create_test_event("test-uid", "Test Event"); let valid_event = create_test_event("test-uid", "Test Event");
@ -461,7 +462,7 @@ mod tests {
}, },
}; };
let engine = ImportEngine::new(config, ImportBehavior::SkipDuplicates, true); let engine = ImportEngine::new(config, ImportBehavior::Strict, true);
let events = vec![ let events = vec![
create_test_event("event-1", "Event 1"), create_test_event("event-1", "Event 1"),
create_test_event("event-2", "Event 2"), create_test_event("event-2", "Event 2"),