- 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
1135 lines
41 KiB
Rust
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));
|
|
}
|
|
}
|