caldavpuller/src/event.rs
Alvaro Soliverez 932b6ae463 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
2025-11-21 11:56:27 -03:00

1135 lines
41 KiB
Rust

//! Event handling and iCalendar parsing
use crate::error::CalDavResult;
use chrono::{DateTime, Utc, Datelike, Timelike};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
/// Unique identifier
pub uid: String,
/// Event summary/title
pub summary: String,
/// Event description
pub description: Option<String>,
/// Start time
pub start: DateTime<Utc>,
/// End time
pub end: DateTime<Utc>,
/// All-day event flag
pub all_day: bool,
/// Event location
pub location: Option<String>,
/// Event status
pub status: EventStatus,
/// Event type
pub event_type: EventType,
/// Organizer
pub organizer: Option<Organizer>,
/// Attendees
pub attendees: Vec<Attendee>,
/// Recurrence rule
pub recurrence: Option<RecurrenceRule>,
/// Alarm/reminders
pub alarms: Vec<Alarm>,
/// Custom properties
pub properties: HashMap<String, String>,
/// Creation timestamp
pub created: DateTime<Utc>,
/// Last modification timestamp
pub last_modified: DateTime<Utc>,
/// Sequence number for updates
pub sequence: i32,
/// Timezone identifier
pub timezone: Option<String>,
}
/// Event status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventStatus {
Confirmed,
Tentative,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
/// Event type/classification
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventType {
Public,
Private,
Confidential,
}
impl Default for EventType {
fn default() -> Self {
EventType::Public
}
}
/// Event organizer
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Organizer {
/// Email address
pub email: String,
/// Display name
pub name: Option<String>,
/// Sent-by parameter
pub sent_by: Option<String>,
}
/// Event attendee
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attendee {
/// Email address
pub email: String,
/// Display name
pub name: Option<String>,
/// Participation status
pub status: ParticipationStatus,
/// Whether required
pub required: bool,
/// RSVP requested
pub rsvp: bool,
}
/// Participation status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ParticipationStatus {
NeedsAction,
Accepted,
Declined,
Tentative,
Delegated,
}
/// Recurrence rule (simplified RRULE string representation)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurrenceRule {
/// Original RRULE string for storage and parsing
pub original_rule: String,
}
impl RecurrenceRule {
/// Create a new RecurrenceRule from an RRULE string
pub fn from_str(rrule_str: &str) -> Result<Self, Box<dyn std::error::Error>> {
Ok(RecurrenceRule {
original_rule: rrule_str.to_string(),
})
}
/// Get the RRULE string
pub fn as_str(&self) -> &str {
&self.original_rule
}
/// Parse RRULE components from the original_rule string
fn parse_components(&self) -> std::collections::HashMap<String, String> {
let mut components = std::collections::HashMap::new();
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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alarm {
/// Action type
pub action: AlarmAction,
/// Trigger time
pub trigger: AlarmTrigger,
/// Description
pub description: Option<String>,
/// Summary
pub summary: Option<String>,
}
/// Alarm action
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AlarmAction {
Display,
Email,
Audio,
}
/// Alarm trigger
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AlarmTrigger {
/// Duration before start
BeforeStart(chrono::Duration),
/// Duration after start
AfterStart(chrono::Duration),
/// Duration before end
BeforeEnd(chrono::Duration),
/// Absolute time
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 {
/// Create a new event
pub fn new(summary: String, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
let now = Utc::now();
Self {
uid: Uuid::new_v4().to_string(),
summary,
description: None,
start,
end,
all_day: false,
location: None,
status: EventStatus::default(),
event_type: EventType::default(),
organizer: None,
attendees: Vec::new(),
recurrence: None,
alarms: Vec::new(),
properties: HashMap::new(),
created: now,
last_modified: now,
sequence: 0,
timezone: None,
}
}
/// Create an all-day event
pub fn new_all_day(summary: String, date: chrono::NaiveDate) -> Self {
let start = date.and_hms_opt(0, 0, 0).unwrap();
let end = date.and_hms_opt(23, 59, 59).unwrap();
let start_utc = DateTime::from_naive_utc_and_offset(start, Utc);
let end_utc = DateTime::from_naive_utc_and_offset(end, Utc);
Self {
uid: Uuid::new_v4().to_string(),
summary,
description: None,
start: start_utc,
end: end_utc,
all_day: true,
location: None,
status: EventStatus::default(),
event_type: EventType::default(),
organizer: None,
attendees: Vec::new(),
recurrence: None,
alarms: Vec::new(),
properties: HashMap::new(),
created: Utc::now(),
last_modified: Utc::now(),
sequence: 0,
timezone: None,
}
}
/// Parse event from iCalendar data
pub fn from_ical(_ical_data: &str) -> CalDavResult<Self> {
// This is a simplified iCalendar parser
// In a real implementation, you'd use a proper iCalendar parsing library
let event = Self::new("placeholder".to_string(), Utc::now(), Utc::now());
// Placeholder implementation
// TODO: Implement proper iCalendar parsing
Ok(event)
}
/// Convert event to iCalendar format
pub fn to_ical(&self) -> CalDavResult<String> {
let mut ical = String::new();
// iCalendar header
ical.push_str("BEGIN:VCALENDAR\r\n");
ical.push_str("VERSION:2.0\r\n");
ical.push_str("PRODID:-//CalDAV Sync//EN\r\n");
ical.push_str("BEGIN:VEVENT\r\n");
// Basic properties
ical.push_str(&format!("UID:{}\r\n", self.uid));
ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary)));
if let Some(description) = &self.description {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
}
// Dates with timezone preservation
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 {
// 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",
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
ical.push_str(&format!("STATUS:{}\r\n", match self.status {
EventStatus::Confirmed => "CONFIRMED",
EventStatus::Tentative => "TENTATIVE",
EventStatus::Cancelled => "CANCELLED",
}));
// Class
ical.push_str(&format!("CLASS:{}\r\n", match self.event_type {
EventType::Public => "PUBLIC",
EventType::Private => "PRIVATE",
EventType::Confidential => "CONFIDENTIAL",
}));
// Timestamps
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));
// Location
if let Some(location) = &self.location {
ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location)));
}
// Organizer
if let Some(organizer) = &self.organizer {
ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email));
}
// Attendees
for attendee in &self.attendees {
ical.push_str(&format!("ATTENDEE:mailto:{}\r\n", attendee.email));
}
// iCalendar footer
ical.push_str("END:VEVENT\r\n");
ical.push_str("END:VCALENDAR\r\n");
Ok(ical)
}
/// Update the event's last modified timestamp
pub fn touch(&mut self) {
self.last_modified = Utc::now();
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
pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool {
let start_date = self.start.date_naive();
let end_date = self.end.date_naive();
if self.all_day {
start_date <= date && end_date >= date
} else {
start_date <= date && end_date >= date
}
}
/// Get event duration
pub fn duration(&self) -> chrono::Duration {
self.end.signed_duration_since(self.start)
}
/// Check if event is currently in progress
pub fn is_in_progress(&self) -> bool {
let now = Utc::now();
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
fn escape_ical_text(text: &str) -> String {
text
.replace('\\', "\\\\")
.replace(',', "\\,")
.replace(';', "\\;")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
/// Parse iCalendar date/time
#[cfg(test)]
fn parse_ical_datetime(dt_str: &str) -> CalDavResult<DateTime<Utc>> {
use crate::error::CalDavError;
use chrono::NaiveDateTime;
// Handle different iCalendar date formats
if dt_str.len() == 8 {
// DATE format (YYYYMMDD)
let naive_date = chrono::NaiveDate::parse_from_str(dt_str, "%Y%m%d")?;
let naive_datetime = naive_date.and_hms_opt(0, 0, 0).unwrap();
Ok(DateTime::from_naive_utc_and_offset(naive_datetime, Utc))
} else if dt_str.ends_with('Z') {
// UTC datetime format (YYYYMMDDTHHMMSSZ)
let dt_without_z = &dt_str[..dt_str.len()-1];
let naive_dt = NaiveDateTime::parse_from_str(dt_without_z, "%Y%m%dT%H%M%S")?;
Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc))
} else {
// Local time format - this would need timezone handling
Err(CalDavError::EventProcessing(
"Local time parsing not implemented".to_string()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_creation() {
let start = Utc::now();
let end = start + chrono::Duration::hours(1);
let event = Event::new("Test Event".to_string(), start, end);
assert_eq!(event.summary, "Test Event");
assert_eq!(event.start, start);
assert_eq!(event.end, end);
assert!(!event.all_day);
assert_eq!(event.status, EventStatus::Confirmed);
}
#[test]
fn test_all_day_event() {
let date = chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap();
let event = Event::new_all_day("Christmas".to_string(), date);
assert_eq!(event.summary, "Christmas");
assert!(event.all_day);
assert!(event.occurs_on(date));
}
#[test]
fn test_event_to_ical() {
let event = Event::new(
"Meeting".to_string(),
DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
Utc
),
DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T110000", "%Y%m%dT%H%M%S").unwrap(),
Utc
),
);
let ical = event.to_ical().unwrap();
assert!(ical.contains("SUMMARY:Meeting"));
assert!(ical.contains("DTSTART:20231225T100000Z"));
assert!(ical.contains("DTEND:20231225T110000Z"));
assert!(ical.contains("BEGIN:VCALENDAR"));
assert!(ical.contains("END:VCALENDAR"));
}
#[test]
fn test_ical_text_escaping() {
let text = "Hello, world; this\\is a test";
let escaped = escape_ical_text(text);
assert_eq!(escaped, "Hello\\, world\\; this\\\\is a test");
}
#[test]
fn test_parse_ical_datetime() {
// Test DATE format (YYYYMMDD)
let date_result = parse_ical_datetime("20231225").unwrap();
assert_eq!(date_result.format("%Y%m%d").to_string(), "20231225");
assert_eq!(date_result.format("%H%M%S").to_string(), "000000");
// Test UTC datetime format (YYYYMMDDTHHMMSSZ)
let datetime_result = parse_ical_datetime("20231225T103000Z").unwrap();
assert_eq!(datetime_result.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T103000Z");
// Test local time format (should fail)
let local_result = parse_ical_datetime("20231225T103000");
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));
}
}