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
29
TODO.md
Normal file
29
TODO.md
Normal 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
|
||||||
754
src/event.rs
754
src/event.rs
|
|
@ -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,
|
/// Get the RRULE string
|
||||||
Yearly,
|
pub fn as_str(&self) -> &str {
|
||||||
}
|
&self.original_rule
|
||||||
|
}
|
||||||
/// Day of week for recurrence
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
/// Parse RRULE components from the original_rule string
|
||||||
pub enum WeekDay {
|
fn parse_components(&self) -> std::collections::HashMap<String, String> {
|
||||||
Sunday,
|
let mut components = std::collections::HashMap::new();
|
||||||
Monday,
|
|
||||||
Tuesday,
|
for part in self.original_rule.split(';') {
|
||||||
Wednesday,
|
if let Some((key, value)) = part.split_once('=') {
|
||||||
Thursday,
|
components.insert(key.to_uppercase(), value.to_string());
|
||||||
Friday,
|
}
|
||||||
Saturday,
|
}
|
||||||
|
|
||||||
|
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,17 +371,27 @@ 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 {
|
||||||
ical.push_str(&format!("DTSTART:{}\r\n",
|
// Check if we have timezone information
|
||||||
self.start.format("%Y%m%dT%H%M%SZ")));
|
if let Some(ref tzid) = self.timezone {
|
||||||
ical.push_str(&format!("DTEND:{}\r\n",
|
// Use timezone-aware format
|
||||||
self.end.format("%Y%m%dT%H%M%SZ")));
|
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",
|
||||||
|
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")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status
|
// 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
372
src/main.rs
372
src/main.rs
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -595,41 +595,52 @@ 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>"),
|
||||||
|
];
|
||||||
// Parse the iCalendar data
|
|
||||||
if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) {
|
let mut found_calendar_data = false;
|
||||||
events.extend(parsed_events);
|
for (start_tag, end_tag) in calendar_data_patterns {
|
||||||
} else {
|
if let Some(start) = xml.find(start_tag) {
|
||||||
warn!("Failed to parse iCalendar data, falling back to mock");
|
if let Some(end) = xml.find(end_tag) {
|
||||||
return self.create_mock_event(calendar_href);
|
let ical_data = &xml[start + start_tag.len()..end];
|
||||||
|
debug!("Found iCalendar data using {}: {}", start_tag, ical_data);
|
||||||
|
|
||||||
|
// Parse the iCalendar data
|
||||||
|
if let Ok(parsed_events) = self.parse_icalendar_data(ical_data, calendar_href) {
|
||||||
|
events.extend(parsed_events);
|
||||||
|
found_calendar_data = true;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
warn!("Failed to parse iCalendar data using {}, trying next pattern", start_tag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
debug!("No calendar-data closing tag found");
|
|
||||||
return self.create_mock_event(calendar_href);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
debug!("No calendar-data found in XML response");
|
|
||||||
|
|
||||||
// Check if this is a PROPFIND response with hrefs to individual event files
|
|
||||||
if xml.contains("<D:href>") && xml.contains(".ics") {
|
|
||||||
return self.parse_propfind_response(xml, calendar_href).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no calendar-data but we got hrefs, try to fetch individual .ics files
|
|
||||||
if xml.contains("<D:href>") {
|
|
||||||
return self.parse_propfind_response(xml, calendar_href).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.create_mock_event(calendar_href);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Parsed {} real events from CalDAV response", events.len());
|
if found_calendar_data {
|
||||||
Ok(events)
|
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
|
||||||
|
if xml.contains("<D:href>") && xml.contains(".ics") {
|
||||||
|
return self.parse_propfind_response(xml, calendar_href).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no calendar-data but we got hrefs, try to fetch individual .ics files
|
||||||
|
if xml.contains("<D:href>") {
|
||||||
|
return self.parse_propfind_response(xml, calendar_href).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!("No calendar data found in XML response for calendar: {}", calendar_href);
|
||||||
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue