use chrono::{DateTime, Duration, TimeZone, Utc}; /** * Calendar Event Entity * * This module defines the CalendarEvent entity, which represents an event or * appointment in a calendar, following the iCalendar (RFC 5545) specification. * * Calendar events have properties like summary, description, location, start/end times, * and can include recurrence rules for repeating events. Each event belongs to a * specific calendar and stores its complete iCalendar representation. */ use uuid::Uuid; use crate::common::errors::{DomainError, ErrorKind, Result}; // Re-export entity errors from the centralized module pub use super::entity_errors::CalendarEventError; /** * CalendarEvent entity. * * Represents a calendar event or appointment that can be synced via CalDAV. * Follows the iCalendar format (RFC 5545) for compatibility with CalDAV clients. */ #[derive(Debug, Clone)] pub struct CalendarEvent { /// Unique identifier for the event id: Uuid, /// ID of the calendar this event belongs to calendar_id: Uuid, /// Short summary/title of the event summary: String, /// Detailed description of the event (optional) description: Option, /// Location of the event (optional) location: Option, /// Start time of the event start_time: DateTime, /// End time of the event end_time: DateTime, /// Whether this is an all-day event all_day: bool, /// Recurrence rule in iCalendar RRULE format (optional) rrule: Option, /// Unique identifier in iCalendar format (used for CalDAV sync) ical_uid: String, /// Complete iCalendar data (VEVENT component) ical_data: String, /// Time when the event was created created_at: DateTime, /// Time when the event was last modified updated_at: DateTime, } impl CalendarEvent { /** * Creates a new calendar event with the given properties. * * @param calendar_id ID of the calendar this event belongs to * @param summary Short summary/title of the event * @param description Detailed description of the event (optional) * @param location Location of the event (optional) * @param start_time Start time of the event * @param end_time End time of the event * @param all_day Whether this is an all-day event * @param rrule Recurrence rule in iCalendar RRULE format (optional) * @param ical_data Complete iCalendar data (VEVENT component) * @return Result containing the new CalendarEvent or a domain error */ #[allow(clippy::too_many_arguments)] pub fn new( calendar_id: Uuid, summary: String, description: Option, location: Option, start_time: DateTime, end_time: DateTime, all_day: bool, rrule: Option, ical_data: String, ) -> Result { // Validate inputs if summary.is_empty() { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Event summary cannot be empty", )); } if end_time < start_time { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "End time cannot be before start time", )); } // Validate RRULE if provided (basic validation) if let Some(ref rule) = rrule && !rule.starts_with("FREQ=") { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Recurrence rule must start with FREQ=", )); } // Validate iCalendar data (basic validation) if !ical_data.contains("BEGIN:VEVENT") || !ical_data.contains("END:VEVENT") { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "iCalendar data must contain a VEVENT component", )); } let now = Utc::now(); Ok(Self { id: Uuid::new_v4(), calendar_id, summary, description, location, start_time, end_time, all_day, rrule, ical_uid: Uuid::new_v4().to_string(), ical_data, created_at: now, updated_at: now, }) } /** * Creates a calendar event with specific ID and timestamps. * Typically used when reconstructing from storage. * * @param id Unique identifier for the event * @param calendar_id ID of the calendar this event belongs to * @param summary Short summary/title of the event * @param description Detailed description of the event (optional) * @param location Location of the event (optional) * @param start_time Start time of the event * @param end_time End time of the event * @param all_day Whether this is an all-day event * @param rrule Recurrence rule in iCalendar RRULE format (optional) * @param ical_uid Unique identifier in iCalendar format * @param ical_data Complete iCalendar data (VEVENT component) * @param created_at Time when the event was created * @param updated_at Time when the event was last modified * @return Result containing the new CalendarEvent or a domain error */ #[allow(clippy::too_many_arguments)] pub fn with_id( id: Uuid, calendar_id: Uuid, summary: String, description: Option, location: Option, start_time: DateTime, end_time: DateTime, all_day: bool, rrule: Option, ical_uid: String, ical_data: String, created_at: DateTime, updated_at: DateTime, ) -> Result { // Basic validation if summary.is_empty() { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Event summary cannot be empty", )); } if end_time < start_time { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "End time cannot be before start time", )); } Ok(Self { id, calendar_id, summary, description, location, start_time, end_time, all_day, rrule, ical_uid, ical_data, created_at, updated_at, }) } /** * Creates a calendar event from an iCalendar VEVENT component. * Parses the iCalendar data to extract event properties. * * @param calendar_id ID of the calendar this event belongs to * @param ical_data Complete iCalendar data (VEVENT component) * @return Result containing the new CalendarEvent or a domain error */ pub fn from_ical(calendar_id: Uuid, ical_data: String) -> Result { // This implementation would require a proper iCalendar parser // For brevity, we're using a simplified version here // Extract required fields from iCalendar data let summary = Self::extract_ical_property(&ical_data, "SUMMARY").ok_or_else(|| { DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Missing SUMMARY in iCalendar data", ) })?; let dtstart = Self::extract_ical_property(&ical_data, "DTSTART").ok_or_else(|| { DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Missing DTSTART in iCalendar data", ) })?; let dtend = Self::extract_ical_property(&ical_data, "DTEND").ok_or_else(|| { DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Missing DTEND in iCalendar data", ) })?; // Parse dates (simplified) let start_time = Self::parse_ical_datetime(&dtstart).map_err(|e| { DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", format!("Invalid DTSTART: {}", e), ) })?; let end_time = Self::parse_ical_datetime(&dtend).map_err(|e| { DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", format!("Invalid DTEND: {}", e), ) })?; // Determine if all-day event (simplified check) let all_day = dtstart.contains("VALUE=DATE") && !dtstart.contains("T"); // Extract optional fields let description = Self::extract_ical_property(&ical_data, "DESCRIPTION"); let location = Self::extract_ical_property(&ical_data, "LOCATION"); let rrule = Self::extract_ical_property(&ical_data, "RRULE"); // Extract UID or generate a new one let ical_uid = Self::extract_ical_property(&ical_data, "UID") .unwrap_or_else(|| Uuid::new_v4().to_string()); let now = Utc::now(); Ok(Self { id: Uuid::new_v4(), calendar_id, summary, description, location, start_time, end_time, all_day, rrule, ical_uid, ical_data, created_at: now, updated_at: now, }) } // Getters /// Returns the event's unique identifier pub fn id(&self) -> &Uuid { &self.id } /// Returns the ID of the calendar this event belongs to pub fn calendar_id(&self) -> &Uuid { &self.calendar_id } /// Returns the event's summary/title pub fn summary(&self) -> &str { &self.summary } /// Returns the event's description, if any pub fn description(&self) -> Option<&str> { self.description.as_deref() } /// Returns the event's location, if any pub fn location(&self) -> Option<&str> { self.location.as_deref() } /// Returns the event's start time pub fn start_time(&self) -> &DateTime { &self.start_time } /// Returns the event's end time pub fn end_time(&self) -> &DateTime { &self.end_time } /// Returns whether this is an all-day event pub fn all_day(&self) -> bool { self.all_day } /// Returns the event's recurrence rule, if any pub fn rrule(&self) -> Option<&str> { self.rrule.as_deref() } /// Returns the event's iCalendar UID pub fn ical_uid(&self) -> &str { &self.ical_uid } /// Returns the complete iCalendar data for the event pub fn ical_data(&self) -> &str { &self.ical_data } /// Returns the time when the event was created pub fn created_at(&self) -> &DateTime { &self.created_at } /// Returns the time when the event was last modified pub fn updated_at(&self) -> &DateTime { &self.updated_at } /// Returns the duration of the event pub fn duration(&self) -> Duration { self.end_time - self.start_time } // Setters and Mutators /** * Updates the event's summary/title. * * @param summary New summary/title for the event * @return Result indicating success or containing a domain error */ pub fn update_summary(&mut self, summary: String) -> Result<()> { if summary.is_empty() { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Event summary cannot be empty", )); } // Clone the summary before updating the struct let summary_clone = summary.clone(); self.summary = summary; self.updated_at = Utc::now(); // Update iCalendar data using the cloned value self.update_ical_property("SUMMARY", &summary_clone); Ok(()) } /** * Updates the event's description. * * @param description New description for the event */ pub fn update_description(&mut self, description: Option) { self.description = description.clone(); self.updated_at = Utc::now(); // Update iCalendar data match description { Some(desc) => self.update_ical_property("DESCRIPTION", &desc), None => self.remove_ical_property("DESCRIPTION"), } } /** * Updates the event's location. * * @param location New location for the event */ pub fn update_location(&mut self, location: Option) { self.location = location.clone(); self.updated_at = Utc::now(); // Update iCalendar data match location { Some(loc) => self.update_ical_property("LOCATION", &loc), None => self.remove_ical_property("LOCATION"), } } /** * Updates the event's start and end times. * * @param start_time New start time for the event * @param end_time New end time for the event * @return Result indicating success or containing a domain error */ pub fn update_time_range( &mut self, start_time: DateTime, end_time: DateTime, ) -> Result<()> { if end_time < start_time { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "End time cannot be before start time", )); } self.start_time = start_time; self.end_time = end_time; self.updated_at = Utc::now(); // Update iCalendar data let start_str = if self.all_day { format!("{}T000000Z", start_time.format("%Y%m%d")) } else { format!("{}", start_time.format("%Y%m%dT%H%M%SZ")) }; let end_str = if self.all_day { format!("{}T000000Z", end_time.format("%Y%m%d")) } else { format!("{}", end_time.format("%Y%m%dT%H%M%SZ")) }; self.update_ical_property("DTSTART", &start_str); self.update_ical_property("DTEND", &end_str); Ok(()) } /** * Updates whether this is an all-day event. * * @param all_day Whether this is an all-day event */ pub fn update_all_day(&mut self, all_day: bool) { self.all_day = all_day; self.updated_at = Utc::now(); // Update iCalendar data let start_str = if all_day { format!("VALUE=DATE:{}", self.start_time.format("%Y%m%d")) } else { format!("{}", self.start_time.format("%Y%m%dT%H%M%SZ")) }; let end_str = if all_day { format!("VALUE=DATE:{}", self.end_time.format("%Y%m%d")) } else { format!("{}", self.end_time.format("%Y%m%dT%H%M%SZ")) }; self.update_ical_property("DTSTART", &start_str); self.update_ical_property("DTEND", &end_str); } /** * Updates the event's recurrence rule. * * @param rrule New recurrence rule for the event * @return Result indicating success or containing a domain error */ pub fn update_rrule(&mut self, rrule: Option) -> Result<()> { // Validate RRULE if provided (basic validation) if let Some(ref rule) = rrule && !rule.starts_with("FREQ=") { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "Recurrence rule must start with FREQ=", )); } self.rrule = rrule.clone(); self.updated_at = Utc::now(); // Update iCalendar data match rrule { Some(rule) => self.update_ical_property("RRULE", &rule), None => self.remove_ical_property("RRULE"), } Ok(()) } /** * Updates the complete iCalendar data for the event. * Also updates the event properties based on the new iCalendar data. * * @param ical_data New iCalendar data for the event * @return Result indicating success or containing a domain error */ pub fn update_ical_data(&mut self, ical_data: String) -> Result<()> { // Validate iCalendar data (basic validation) if !ical_data.contains("BEGIN:VEVENT") || !ical_data.contains("END:VEVENT") { return Err(DomainError::new( ErrorKind::InvalidInput, "CalendarEvent", "iCalendar data must contain a VEVENT component", )); } // Extract and update properties from iCalendar data if let Some(summary) = Self::extract_ical_property(&ical_data, "SUMMARY") { self.summary = summary; } self.description = Self::extract_ical_property(&ical_data, "DESCRIPTION"); self.location = Self::extract_ical_property(&ical_data, "LOCATION"); if let Some(dtstart) = Self::extract_ical_property(&ical_data, "DTSTART") && let Ok(start_time) = Self::parse_ical_datetime(&dtstart) { self.start_time = start_time; } if let Some(dtend) = Self::extract_ical_property(&ical_data, "DTEND") && let Ok(end_time) = Self::parse_ical_datetime(&dtend) { self.end_time = end_time; } // Update all-day status based on DTSTART if let Some(dtstart) = Self::extract_ical_property(&ical_data, "DTSTART") { self.all_day = dtstart.contains("VALUE=DATE") && !dtstart.contains("T"); } self.rrule = Self::extract_ical_property(&ical_data, "RRULE"); if let Some(uid) = Self::extract_ical_property(&ical_data, "UID") { self.ical_uid = uid; } self.ical_data = ical_data; self.updated_at = Utc::now(); Ok(()) } /** * Checks if this event belongs to the specified calendar. * * @param calendar_id ID of the calendar to check against * @return true if the event belongs to the calendar, false otherwise */ pub fn belongs_to_calendar(&self, calendar_id: &Uuid) -> bool { self.calendar_id == *calendar_id } /** * Checks if this event occurs within the specified time range. * * @param start Start of the time range to check * @param end End of the time range to check * @return true if the event occurs within the range, false otherwise */ pub fn occurs_in_range(&self, start: &DateTime, end: &DateTime) -> bool { // Basic case: event directly overlaps with range if self.start_time <= *end && self.end_time >= *start { return true; } // If event has recurrence, check if any recurrence occurs in range // Note: A full implementation would need a proper recurrence rule parser if let Some(rrule) = &self.rrule { // Simplified check for demonstration // A real implementation would need to generate recurrence instances // and check if any fall within the range // For now, we'll just check if the recurrence hasn't ended // or if it ended after the start of our range if let Some(until_pos) = rrule.find("UNTIL=") { let until_start = until_pos + 6; // "UNTIL=" is 6 chars if let Some(until_end) = rrule[until_start..].find(';') { let until_str = &rrule[until_start..until_start + until_end]; if let Ok(until_date) = Self::parse_ical_datetime(until_str) { return until_date >= *start; } } else { // UNTIL is the last part of the rule let until_str = &rrule[until_start..]; if let Ok(until_date) = Self::parse_ical_datetime(until_str) { return until_date >= *start; } } } else { // No UNTIL specified, so recurrence continues indefinitely return true; } } false } // Helper methods for iCalendar operations /** * Extracts a property value from iCalendar data. * * @param ical_data The iCalendar data to search in * @param property_name The name of the property to extract * @return Option containing the property value if found */ fn extract_ical_property(ical_data: &str, property_name: &str) -> Option { // Find the property in the iCalendar data let search_str = format!("\n{}:", property_name); let search_str_alt = format!("\r\n{}:", property_name); let pos = ical_data .find(&search_str) .or_else(|| ical_data.find(&search_str_alt)); if let Some(pos) = pos { // Find the start of the value let value_start = pos + search_str.len(); // Find the end of the value (next line or end of string) let value_end = ical_data[value_start..] .find('\n') .map(|p| value_start + p) .unwrap_or_else(|| ical_data.len()); // Extract and return the value let value = ical_data[value_start..value_end].trim(); if !value.is_empty() { return Some(value.to_string()); } } None } /** * Parses an iCalendar datetime string into a DateTime object. * * @param datetime The iCalendar datetime string to parse * @return Result containing the parsed DateTime or an error */ fn parse_ical_datetime(datetime: &str) -> std::result::Result, String> { // Handle VALUE=DATE format if datetime.contains("VALUE=DATE") { let date_str = datetime.split(':').next_back().unwrap_or(""); if date_str.len() != 8 { return Err("Invalid date format".to_string()); } let year = date_str[0..4] .parse::() .map_err(|_| "Invalid year".to_string())?; let month = date_str[4..6] .parse::() .map_err(|_| "Invalid month".to_string())?; let day = date_str[6..8] .parse::() .map_err(|_| "Invalid day".to_string())?; return match chrono::NaiveDate::from_ymd_opt(year, month, day) { Some(date) => Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap())), None => Err("Invalid date components".to_string()), }; } // Handle standard UTC format (20230101T120000Z) let datetime_str = datetime.split(':').next_back().unwrap_or(datetime); if datetime_str.len() < 15 || !datetime_str.ends_with('Z') { return Err("Invalid datetime format".to_string()); } let year = datetime_str[0..4] .parse::() .map_err(|_| "Invalid year".to_string())?; let month = datetime_str[4..6] .parse::() .map_err(|_| "Invalid month".to_string())?; let day = datetime_str[6..8] .parse::() .map_err(|_| "Invalid day".to_string())?; let hour = datetime_str[9..11] .parse::() .map_err(|_| "Invalid hour".to_string())?; let minute = datetime_str[11..13] .parse::() .map_err(|_| "Invalid minute".to_string())?; let second = datetime_str[13..15] .parse::() .map_err(|_| "Invalid second".to_string())?; match chrono::NaiveDate::from_ymd_opt(year, month, day) { Some(date) => match date.and_hms_opt(hour, minute, second) { Some(datetime) => Ok(Utc.from_utc_datetime(&datetime)), None => Err("Invalid time components".to_string()), }, None => Err("Invalid date components".to_string()), } } /** * Updates an iCalendar property in the event's iCalendar data. * * @param property_name The name of the property to update * @param value The new value for the property */ fn update_ical_property(&mut self, property_name: &str, value: &str) { let search_str = format!("\n{}:", property_name); let search_str_alt = format!("\r\n{}:", property_name); // Check if property exists let pos = self .ical_data .find(&search_str) .or_else(|| self.ical_data.find(&search_str_alt)); if let Some(pos) = pos { // Find the start of the value let value_start = pos + search_str.len(); // Find the end of the value (next line or end of string) let value_end = self.ical_data[value_start..] .find('\n') .map(|p| value_start + p) .unwrap_or_else(|| self.ical_data.len()); // Replace the value let before = &self.ical_data[..value_start]; let after = &self.ical_data[value_end..]; self.ical_data = format!("{}{}{}", before, value, after); } else { // Property doesn't exist, add it before END:VEVENT let end_pos = self .ical_data .find("END:VEVENT") .unwrap_or(self.ical_data.len()); let before = &self.ical_data[..end_pos]; let after = &self.ical_data[end_pos..]; self.ical_data = format!("{}{}:{}\n{}", before, property_name, value, after); } } /** * Removes an iCalendar property from the event's iCalendar data. * * @param property_name The name of the property to remove */ fn remove_ical_property(&mut self, property_name: &str) { let search_str = format!("\n{}:", property_name); let search_str_alt = format!("\r\n{}:", property_name); // Check if property exists let pos = self .ical_data .find(&search_str) .or_else(|| self.ical_data.find(&search_str_alt)); if let Some(pos) = pos { // Find the end of the value (next line or end of string) let value_end = self.ical_data[pos + 1..] .find('\n') .map(|p| pos + 1 + p) .unwrap_or_else(|| self.ical_data.len()); // Remove the property let before = &self.ical_data[..pos]; let after = &self.ical_data[value_end..]; self.ical_data = format!("{}{}", before, after); } } }