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
754
src/event.rs
754
src/event.rs
|
|
@ -1,10 +1,15 @@
|
|||
//! Event handling and iCalendar parsing
|
||||
|
||||
use crate::error::CalDavResult;
|
||||
use chrono::{DateTime, Utc};
|
||||
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)]
|
||||
|
|
@ -111,47 +116,107 @@ pub enum ParticipationStatus {
|
|||
Delegated,
|
||||
}
|
||||
|
||||
/// Recurrence rule
|
||||
/// Recurrence rule (simplified RRULE string representation)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecurrenceRule {
|
||||
/// Frequency
|
||||
pub frequency: RecurrenceFrequency,
|
||||
/// 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>>,
|
||||
/// Original RRULE string for storage and parsing
|
||||
pub original_rule: String,
|
||||
}
|
||||
|
||||
/// Recurrence frequency
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum RecurrenceFrequency {
|
||||
Secondly,
|
||||
Minutely,
|
||||
Hourly,
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
/// Day of week for recurrence
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum WeekDay {
|
||||
Sunday,
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
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
|
||||
|
|
@ -188,6 +253,38 @@ pub enum AlarmTrigger {
|
|||
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 {
|
||||
|
|
@ -274,17 +371,27 @@ impl Event {
|
|||
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
|
||||
}
|
||||
|
||||
// Dates
|
||||
// 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 {
|
||||
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")));
|
||||
// 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
|
||||
|
|
@ -334,6 +441,190 @@ impl Event {
|
|||
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();
|
||||
|
|
@ -356,6 +647,301 @@ impl Event {
|
|||
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
|
||||
|
|
@ -464,4 +1050,86 @@ mod tests {
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue