diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 0cf8841..7f128e7 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -2,16 +2,16 @@ use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; -use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError}; -use crate::config::CalDAVConfig; use crate::calendar::CalDAVClient; +use crate::config::CalDAVConfig; +use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest}; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub username: String, pub server_url: String, - pub exp: i64, // Expiration time - pub iat: i64, // Issued at + pub exp: i64, // Expiration time + pub iat: i64, // Issued at } #[derive(Clone)] @@ -33,22 +33,25 @@ impl AuthService { // Create CalDAV config with provided credentials let caldav_config = CalDAVConfig::new( request.server_url.clone(), - request.username.clone(), - request.password.clone() + request.username.clone(), + request.password.clone(), ); println!("šŸ“ Created CalDAV config"); // Test authentication against CalDAV server let caldav_client = CalDAVClient::new(caldav_config.clone()); println!("šŸ”— Created CalDAV client, attempting to discover calendars..."); - + // Try to discover calendars as an authentication test match caldav_client.discover_calendars().await { Ok(calendars) => { - println!("āœ… Authentication successful! Found {} calendars", calendars.len()); + println!( + "āœ… Authentication successful! Found {} calendars", + calendars.len() + ); // Authentication successful, generate JWT token let token = self.generate_token(&request.username, &request.server_url)?; - + Ok(AuthResponse { token, username: request.username, @@ -58,7 +61,9 @@ impl AuthService { Err(err) => { println!("āŒ Authentication failed: {:?}", err); // Authentication failed - Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string())) + Err(ApiError::Unauthorized( + "Invalid CalDAV credentials or server unavailable".to_string(), + )) } } } @@ -69,13 +74,17 @@ impl AuthService { } /// Create CalDAV config from token - pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result { + pub fn caldav_config_from_token( + &self, + token: &str, + password: &str, + ) -> Result { let claims = self.verify_token(token)?; - + Ok(CalDAVConfig::new( claims.server_url, - claims.username, - password.to_string() + claims.username, + password.to_string(), )) } @@ -93,8 +102,11 @@ impl AuthService { } // Basic URL validation - if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") { - return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string())); + if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") + { + return Err(ApiError::BadRequest( + "Server URL must start with http:// or https://".to_string(), + )); } Ok(()) @@ -131,4 +143,4 @@ impl AuthService { Ok(token_data.claims) } -} \ No newline at end of file +} diff --git a/backend/src/calendar.rs b/backend/src/calendar.rs index 6c6cecc..c157697 100644 --- a/backend/src/calendar.rs +++ b/backend/src/calendar.rs @@ -1,9 +1,9 @@ +use calendar_models::{CalendarUser, EventClass, EventStatus, VAlarm, VEvent}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; -use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, VAlarm}; // Global mutex to serialize CalDAV HTTP requests to prevent race conditions lazy_static::lazy_static! { @@ -18,64 +18,64 @@ pub type CalendarEvent = VEvent; pub struct OldCalendarEvent { /// Unique identifier for the event (UID field in iCal) pub uid: String, - + /// Summary/title of the event pub summary: Option, - + /// Detailed description of the event pub description: Option, - + /// Start date and time of the event pub start: DateTime, - + /// End date and time of the event pub end: Option>, - + /// Location where the event takes place pub location: Option, - + /// Event status (TENTATIVE, CONFIRMED, CANCELLED) pub status: EventStatus, - + /// Event classification (PUBLIC, PRIVATE, CONFIDENTIAL) pub class: EventClass, - + /// Event priority (0-9, where 0 is undefined, 1 is highest, 9 is lowest) pub priority: Option, - + /// Organizer of the event pub organizer: Option, - + /// List of attendees pub attendees: Vec, - + /// Categories/tags for the event pub categories: Vec, - + /// Date and time when the event was created pub created: Option>, - + /// Date and time when the event was last modified pub last_modified: Option>, - + /// Recurrence rule (RRULE) pub recurrence_rule: Option, - + /// Exception dates - dates to exclude from recurrence (EXDATE) pub exdate: Vec>, - + /// All-day event flag pub all_day: bool, - + /// Reminders/alarms for this event pub reminders: Vec, - + /// ETag from CalDAV server for conflict detection pub etag: Option, - + /// URL/href of this event on the CalDAV server pub href: Option, - + /// Calendar path this event belongs to pub calendar_path: Option, } @@ -87,10 +87,10 @@ pub struct OldCalendarEvent { pub struct EventReminder { /// How long before the event to trigger the reminder (in minutes) pub minutes_before: i32, - + /// Type of reminder action pub action: ReminderAction, - + /// Optional description for the reminder pub description: Option, } @@ -117,7 +117,7 @@ impl CalDAVClient { .timeout(std::time::Duration::from_secs(60)) // 60 second global timeout .build() .expect("Failed to create HTTP client"); - + Self { config, http_client, @@ -125,10 +125,13 @@ impl CalDAVClient { } /// Fetch calendar events from the CalDAV server - /// + /// /// This method performs a REPORT request to get calendar data and parses /// the returned iCalendar format into CalendarEvent structs. - pub async fn fetch_events(&self, calendar_path: &str) -> Result, CalDAVError> { + pub async fn fetch_events( + &self, + calendar_path: &str, + ) -> Result, CalDAVError> { // CalDAV REPORT request to get calendar events let report_body = r#" @@ -149,7 +152,11 @@ impl CalDAVClient { // Extract the base URL (scheme + host + port) from server_url let server_url = &self.config.server_url; // Find the first '/' after "https://" or "http://" - let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 }; + let scheme_end = if server_url.starts_with("https://") { + 8 + } else { + 7 + }; if let Some(path_start) = server_url[scheme_end..].find('/') { let base_url = &server_url[..scheme_end + path_start]; format!("{}{}", base_url, calendar_path) @@ -162,8 +169,9 @@ impl CalDAVClient { let basic_auth = self.config.get_basic_auth(); println!("šŸ”‘ REPORT Basic Auth: Basic {}", basic_auth); println!("🌐 REPORT URL: {}", url); - - let response = self.http_client + + let response = self + .http_client .request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url) .header("Authorization", format!("Basic {}", basic_auth)) .header("Content-Type", "application/xml") @@ -183,13 +191,17 @@ impl CalDAVClient { } /// Parse CalDAV XML response containing calendar data - fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result, CalDAVError> { + fn parse_calendar_response( + &self, + xml_response: &str, + calendar_path: &str, + ) -> Result, CalDAVError> { let mut events = Vec::new(); - + // Extract calendar data from XML response // This is a simplified parser - in production, you'd want a proper XML parser let calendar_data_sections = self.extract_calendar_data(xml_response); - + for calendar_data in calendar_data_sections { if let Ok(parsed_events) = self.parse_ical_data(&calendar_data.data) { for mut event in parsed_events { @@ -205,30 +217,40 @@ impl CalDAVClient { } /// Fetch a single calendar event by UID from the CalDAV server - pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result, CalDAVError> { + pub async fn fetch_event_by_uid( + &self, + calendar_path: &str, + uid: &str, + ) -> Result, CalDAVError> { // First fetch all events and find the one with matching UID let events = self.fetch_events(calendar_path).await?; - + // Find event with matching UID let event = events.into_iter().find(|e| e.uid == uid); - + Ok(event) } /// Extract calendar data sections from CalDAV XML response fn extract_calendar_data(&self, xml_response: &str) -> Vec { let mut sections = Vec::new(); - + // Simple regex-based extraction (in production, use a proper XML parser) // Look for blocks containing calendar data for response_block in xml_response.split("").skip(1) { if let Some(end_pos) = response_block.find("") { let response_content = &response_block[..end_pos]; - - let href = self.extract_xml_content(response_content, "href").unwrap_or_default(); - let etag = self.extract_xml_content(response_content, "getetag").unwrap_or_default(); - - if let Some(calendar_data) = self.extract_xml_content(response_content, "cal:calendar-data") { + + let href = self + .extract_xml_content(response_content, "href") + .unwrap_or_default(); + let etag = self + .extract_xml_content(response_content, "getetag") + .unwrap_or_default(); + + if let Some(calendar_data) = + self.extract_xml_content(response_content, "cal:calendar-data") + { sections.push(CalendarDataSection { href: if href.is_empty() { None } else { Some(href) }, etag: if etag.is_empty() { None } else { Some(etag) }, @@ -237,7 +259,7 @@ impl CalDAVClient { } } } - + sections } @@ -245,14 +267,30 @@ impl CalDAVClient { fn extract_xml_content(&self, xml: &str, tag: &str) -> Option { // Handle both with and without namespace prefixes let patterns = [ - format!("(?s)<{}>(.*?)", tag, tag), // content - format!("(?s)<{}>(.*?)", tag, tag.split(':').last().unwrap_or(tag)), // content - format!("(?s)<.*:{}>(.*?)", tag.split(':').last().unwrap_or(tag), tag), // content - format!("(?s)<.*:{}>(.*?)", tag.split(':').last().unwrap_or(tag), tag.split(':').last().unwrap_or(tag)), // content - format!("(?s)<{}[^>]*>(.*?)", tag, tag), // content - format!("(?s)<{}[^>]*>(.*?)", tag, tag.split(':').last().unwrap_or(tag)), + format!("(?s)<{}>(.*?)", tag, tag), // content + format!( + "(?s)<{}>(.*?)", + tag, + tag.split(':').last().unwrap_or(tag) + ), // content + format!( + "(?s)<.*:{}>(.*?)", + tag.split(':').last().unwrap_or(tag), + tag + ), // content + format!( + "(?s)<.*:{}>(.*?)", + tag.split(':').last().unwrap_or(tag), + tag.split(':').last().unwrap_or(tag) + ), // content + format!("(?s)<{}[^>]*>(.*?)", tag, tag), // content + format!( + "(?s)<{}[^>]*>(.*?)", + tag, + tag.split(':').last().unwrap_or(tag) + ), ]; - + for pattern in &patterns { if let Ok(re) = regex::Regex::new(pattern) { if let Some(captures) = re.captures(xml) { @@ -262,46 +300,54 @@ impl CalDAVClient { } } } - + None } /// Parse iCalendar data into CalendarEvent structs fn parse_ical_data(&self, ical_data: &str) -> Result, CalDAVError> { let mut events = Vec::new(); - + // Parse the iCal data using the ical crate let reader = ical::IcalParser::new(ical_data.as_bytes()); - + for calendar in reader { let calendar = calendar.map_err(|e| CalDAVError::ParseError(e.to_string()))?; - + for event in calendar.events { if let Ok(calendar_event) = self.parse_ical_event(event) { events.push(calendar_event); } } } - + Ok(events) } /// Parse a single iCal event into a CalendarEvent struct - fn parse_ical_event(&self, event: ical::parser::ical::component::IcalEvent) -> Result { + fn parse_ical_event( + &self, + event: ical::parser::ical::component::IcalEvent, + ) -> Result { let mut properties: HashMap = HashMap::new(); - + // Extract all properties from the event for property in &event.properties { - properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); + properties.insert( + property.name.to_uppercase(), + property.value.clone().unwrap_or_default(), + ); } // Required UID field - let uid = properties.get("UID") + let uid = properties + .get("UID") .ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))? .clone(); // Parse start time (required) - let start = properties.get("DTSTART") + let start = properties + .get("DTSTART") .ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?; let start = self.parse_datetime(start, properties.get("DTSTART"))?; @@ -316,12 +362,14 @@ impl CalDAVClient { }; // Determine if it's an all-day event - let all_day = properties.get("DTSTART") + let all_day = properties + .get("DTSTART") .map(|s| !s.contains("T")) .unwrap_or(false); // Parse status - let status = properties.get("STATUS") + let status = properties + .get("STATUS") .map(|s| match s.to_uppercase().as_str() { "TENTATIVE" => EventStatus::Tentative, "CANCELLED" => EventStatus::Cancelled, @@ -330,7 +378,8 @@ impl CalDAVClient { .unwrap_or(EventStatus::Confirmed); // Parse classification - let class = properties.get("CLASS") + let class = properties + .get("CLASS") .map(|s| match s.to_uppercase().as_str() { "PRIVATE" => EventClass::Private, "CONFIDENTIAL" => EventClass::Confidential, @@ -339,20 +388,24 @@ impl CalDAVClient { .unwrap_or(EventClass::Public); // Parse priority - let priority = properties.get("PRIORITY") + let priority = properties + .get("PRIORITY") .and_then(|s| s.parse::().ok()) .filter(|&p| p <= 9); // Parse categories - let categories = properties.get("CATEGORIES") + let categories = properties + .get("CATEGORIES") .map(|s| s.split(',').map(|c| c.trim().to_string()).collect()) .unwrap_or_default(); // Parse dates - let created = properties.get("CREATED") + let created = properties + .get("CREATED") .and_then(|s| self.parse_datetime(s, None).ok()); - - let last_modified = properties.get("LAST-MODIFIED") + + let last_modified = properties + .get("LAST-MODIFIED") .and_then(|s| self.parse_datetime(s, None).ok()); // Parse exception dates (EXDATE) @@ -360,7 +413,7 @@ impl CalDAVClient { // Create VEvent with required fields let mut vevent = VEvent::new(uid, start); - + // Set optional fields vevent.dtend = end; vevent.summary = properties.get("SUMMARY").cloned(); @@ -369,7 +422,7 @@ impl CalDAVClient { vevent.status = Some(status); vevent.class = Some(class); vevent.priority = priority; - + // Convert organizer string to CalendarUser if let Some(organizer_str) = properties.get("ORGANIZER") { vevent.organizer = Some(CalendarUser { @@ -380,59 +433,72 @@ impl CalDAVClient { language: None, }); } - + // TODO: Parse attendees properly vevent.attendees = Vec::new(); - + vevent.categories = categories; vevent.created = created; vevent.last_modified = last_modified; vevent.rrule = properties.get("RRULE").cloned(); vevent.exdate = exdate; vevent.all_day = all_day; - + // Parse alarms vevent.alarms = self.parse_valarms(&event)?; - + // CalDAV specific fields (set by caller) vevent.etag = None; vevent.href = None; vevent.calendar_path = None; - + Ok(vevent) } /// Parse VALARM components from an iCal event - fn parse_valarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result, CalDAVError> { + fn parse_valarms( + &self, + event: &ical::parser::ical::component::IcalEvent, + ) -> Result, CalDAVError> { let mut alarms = Vec::new(); - + for alarm in &event.alarms { if let Ok(valarm) = self.parse_single_valarm(alarm) { alarms.push(valarm); } } - + Ok(alarms) } /// Parse a single VALARM component into a VAlarm - fn parse_single_valarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result { + fn parse_single_valarm( + &self, + alarm: &ical::parser::ical::component::IcalAlarm, + ) -> Result { let mut properties: HashMap = HashMap::new(); - + // Extract all properties from the alarm for property in &alarm.properties { - properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); + properties.insert( + property.name.to_uppercase(), + property.value.clone().unwrap_or_default(), + ); } - + // Parse ACTION (required) let action = match properties.get("ACTION").map(|s| s.to_uppercase()) { - Some(ref action_str) if action_str == "DISPLAY" => calendar_models::AlarmAction::Display, + Some(ref action_str) if action_str == "DISPLAY" => { + calendar_models::AlarmAction::Display + } Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email, Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio, - Some(ref action_str) if action_str == "PROCEDURE" => calendar_models::AlarmAction::Procedure, + Some(ref action_str) if action_str == "PROCEDURE" => { + calendar_models::AlarmAction::Procedure + } _ => calendar_models::AlarmAction::Display, // Default }; - + // Parse TRIGGER (required) let trigger = if let Some(trigger_str) = properties.get("TRIGGER") { if let Some(minutes) = self.parse_trigger_duration(trigger_str) { @@ -445,10 +511,10 @@ impl CalDAVClient { // Default to 15 minutes before calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-15)) }; - + // Get description let description = properties.get("DESCRIPTION").cloned(); - + Ok(VAlarm { action, trigger, @@ -465,18 +531,18 @@ impl CalDAVClient { fn parse_trigger_duration(&self, trigger: &str) -> Option { // Basic parsing of ISO 8601 duration or relative time // Examples: "-PT15M" (15 minutes before), "-P1D" (1 day before) - + if trigger.starts_with("-PT") && trigger.ends_with("M") { // Parse "-PT15M" format (minutes) - let minutes_str = &trigger[3..trigger.len()-1]; + let minutes_str = &trigger[3..trigger.len() - 1]; minutes_str.parse::().ok() } else if trigger.starts_with("-PT") && trigger.ends_with("H") { // Parse "-PT1H" format (hours) - let hours_str = &trigger[3..trigger.len()-1]; + let hours_str = &trigger[3..trigger.len() - 1]; hours_str.parse::().ok().map(|h| h * 60) } else if trigger.starts_with("-P") && trigger.ends_with("D") { // Parse "-P1D" format (days) - let days_str = &trigger[2..trigger.len()-1]; + let days_str = &trigger[2..trigger.len() - 1]; days_str.parse::().ok().map(|d| d * 24 * 60) } else { // Try to parse as raw minutes @@ -491,17 +557,14 @@ impl CalDAVClient { println!("Using configured calendar path: {}", calendar_path); return Ok(vec![calendar_path.clone()]); } - + println!("No calendar path configured, discovering calendars..."); // Try different common CalDAV discovery paths // Note: paths should be relative to the server URL base let user_calendar_path = format!("/calendars/{}/", self.config.username); - - let discovery_paths = vec![ - "/calendars/", - user_calendar_path.as_str(), - ]; + + let discovery_paths = vec!["/calendars/", user_calendar_path.as_str()]; let mut all_calendars = Vec::new(); @@ -533,9 +596,13 @@ impl CalDAVClient { let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path); - let response = self.http_client + let response = self + .http_client .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) - .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .header( + "Authorization", + format!("Basic {}", self.config.get_basic_auth()), + ) .header("Content-Type", "application/xml") .header("Depth", "2") // Deeper search to find actual calendars .header("User-Agent", "calendar-app/0.1.0") @@ -545,39 +612,50 @@ impl CalDAVClient { .map_err(CalDAVError::RequestError)?; if response.status().as_u16() != 207 { - println!("āŒ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16()); + println!( + "āŒ Discovery PROPFIND failed for {}: HTTP {}", + path, + response.status().as_u16() + ); return Err(CalDAVError::ServerError(response.status().as_u16())); } let body = response.text().await.map_err(CalDAVError::RequestError)?; println!("Discovery response for {}: {}", path, body); - + let mut calendar_paths = Vec::new(); - + // Extract calendar collection URLs from the response for response_block in body.split("").skip(1) { if let Some(end_pos) = response_block.find("") { let response_content = &response_block[..end_pos]; - + // Extract href first if let Some(href) = self.extract_xml_content(response_content, "href") { println!("šŸ” Checking resource: {}", href); - + // Check if this is a calendar collection by looking for supported-calendar-component-set // This indicates it's an actual calendar that can contain events - let has_supported_components = response_content.contains("supported-calendar-component-set") && - (response_content.contains("VEVENT") || response_content.contains("VTODO")); - let has_calendar_resourcetype = response_content.contains(") -> Result, CalDAVError> { + fn parse_datetime( + &self, + datetime_str: &str, + _original_property: Option<&String>, + ) -> Result, CalDAVError> { use chrono::TimeZone; - + // Handle different iCal datetime formats let cleaned = datetime_str.replace("TZID=", "").trim().to_string(); - + // Try different parsing formats let formats = [ - "%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z - "%Y%m%dT%H%M%S", // Local format: 20231225T120000 - "%Y%m%d", // Date only: 20231225 + "%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z + "%Y%m%dT%H%M%S", // Local format: 20231225T120000 + "%Y%m%d", // Date only: 20231225 ]; - + for format in &formats { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) { return Ok(Utc.from_utc_datetime(&dt)); @@ -616,14 +698,17 @@ impl CalDAVClient { return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap())); } } - - Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str))) + + Err(CalDAVError::ParseError(format!( + "Unable to parse datetime: {}", + datetime_str + ))) } /// Parse EXDATE properties from an iCal event fn parse_exdate(&self, event: &ical::parser::ical::component::IcalEvent) -> Vec> { let mut exdate = Vec::new(); - + // Look for EXDATE properties for property in &event.properties { if property.name.to_uppercase() == "EXDATE" { @@ -638,35 +723,50 @@ impl CalDAVClient { } } } - + exdate } /// Create a new calendar on the CalDAV server using MKCALENDAR - pub async fn create_calendar(&self, name: &str, description: Option<&str>, color: Option<&str>) -> Result<(), CalDAVError> { + pub async fn create_calendar( + &self, + name: &str, + description: Option<&str>, + color: Option<&str>, + ) -> Result<(), CalDAVError> { // Sanitize calendar name for URL path let calendar_id = name .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect::() .to_lowercase(); - + let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id); - let full_url = format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path); - + let full_url = format!( + "{}{}", + self.config.server_url.trim_end_matches('/'), + calendar_path + ); + // Build color property if provided let color_property = if let Some(color) = color { - format!(r#"{}"#, color) + format!( + r#"{}"#, + color + ) } else { String::new() }; - + let description_property = if let Some(desc) = description { - format!(r#"{}"#, desc) + format!( + r#"{}"#, + desc + ) } else { String::new() }; - + // Create the MKCALENDAR request body let mkcalendar_body = format!( r#" @@ -684,21 +784,28 @@ impl CalDAVClient { "#, name, color_property, description_property ); - + println!("Creating calendar at: {}", full_url); println!("MKCALENDAR body: {}", mkcalendar_body); - - let response = self.http_client - .request(reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url) + + let response = self + .http_client + .request( + reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), + &full_url, + ) .header("Content-Type", "application/xml; charset=utf-8") - .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .header( + "Authorization", + format!("Basic {}", self.config.get_basic_auth()), + ) .body(mkcalendar_body) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Calendar creation response status: {}", response.status()); - + if response.status().is_success() { println!("āœ… Calendar created successfully at {}", calendar_path); Ok(()) @@ -721,20 +828,28 @@ impl CalDAVClient { } else { calendar_path }; - format!("{}{}", self.config.server_url.trim_end_matches('/'), clean_path) + format!( + "{}{}", + self.config.server_url.trim_end_matches('/'), + clean_path + ) }; - + println!("Deleting calendar at: {}", full_url); - - let response = self.http_client + + let response = self + .http_client .delete(&full_url) - .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .header( + "Authorization", + format!("Basic {}", self.config.get_basic_auth()), + ) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Calendar deletion response status: {}", response.status()); - + if response.status().is_success() || response.status().as_u16() == 204 { println!("āœ… Calendar deleted successfully at {}", calendar_path); Ok(()) @@ -747,24 +862,28 @@ impl CalDAVClient { } /// Create a new event in a CalDAV calendar - pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result { + pub async fn create_event( + &self, + calendar_path: &str, + event: &CalendarEvent, + ) -> Result { // Generate a unique filename for the event (using UID + .ics extension) let event_filename = format!("{}.ics", event.uid); - + // Construct the full URL for the event let full_url = if calendar_path.starts_with("http") { format!("{}/{}", calendar_path.trim_end_matches('/'), event_filename) } else { // Handle URL construction more carefully let server_url = self.config.server_url.trim_end_matches('/'); - + // Remove /dav.php from the end of server URL if present let base_url = if server_url.ends_with("/dav.php") { server_url.trim_end_matches("/dav.php") } else { server_url }; - + // Calendar path should start with /dav.php, if not add it let clean_calendar_path = if calendar_path.starts_with("/dav.php") { calendar_path.trim_end_matches('/') @@ -772,27 +891,31 @@ impl CalDAVClient { // This shouldn't happen in our case, but handle it &format!("/dav.php{}", calendar_path.trim_end_matches('/')) }; - + format!("{}{}/{}", base_url, clean_calendar_path, event_filename) }; - + println!("šŸ“ Creating event with calendar_path: {}", calendar_path); println!("šŸ“ Server URL: {}", self.config.server_url); println!("šŸ“ Constructed URL: {}", full_url); - + // Generate iCalendar data for the event let ical_data = self.generate_ical_event(event)?; - + println!("Creating event at: {}", full_url); println!("iCal data: {}", ical_data); - + println!("šŸ“” Acquiring CalDAV HTTP lock for CREATE request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("šŸ“” Lock acquired, sending CREATE request to CalDAV server..."); - - let response = self.http_client + + let response = self + .http_client .put(&full_url) - .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .header( + "Authorization", + format!("Basic {}", self.config.get_basic_auth()), + ) .header("Content-Type", "text/calendar; charset=utf-8") .header("User-Agent", "calendar-app/0.1.0") .body(ical_data) @@ -801,7 +924,7 @@ impl CalDAVClient { .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Event creation response status: {}", response.status()); - + if response.status().is_success() || response.status().as_u16() == 201 { println!("āœ… Event created successfully at {}", event_filename); Ok(event_filename) @@ -814,13 +937,22 @@ impl CalDAVClient { } /// Update an existing event on the CalDAV server - pub async fn update_event(&self, calendar_path: &str, event: &CalendarEvent, event_href: &str) -> Result<(), CalDAVError> { + pub async fn update_event( + &self, + calendar_path: &str, + event: &CalendarEvent, + event_href: &str, + ) -> Result<(), CalDAVError> { // Construct the full URL for the event let full_url = if event_href.starts_with("http") { event_href.to_string() } else if event_href.starts_with("/dav.php") { // Event href is already a full path, combine with base server URL (without /dav.php) - let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php"); + let base_url = self + .config + .server_url + .trim_end_matches('/') + .trim_end_matches("/dav.php"); format!("{}{}", base_url, event_href) } else { // Event href is just a filename, combine with calendar path @@ -829,26 +961,35 @@ impl CalDAVClient { } else { calendar_path }; - format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href) + format!( + "{}/dav.php{}/{}", + self.config.server_url.trim_end_matches('/'), + clean_path, + event_href + ) }; - + println!("šŸ“ Updating event at: {}", full_url); - + // Generate iCalendar data for the event let ical_data = self.generate_ical_event(event)?; - + println!("šŸ“ Updated iCal data: {}", ical_data); println!("šŸ“ Event has {} exception dates", event.exdate.len()); - + println!("šŸ“” Acquiring CalDAV HTTP lock for PUT request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("šŸ“” Lock acquired, sending PUT request to CalDAV server..."); println!("šŸ”— PUT URL: {}", full_url); println!("šŸ” Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8"); - - let response = self.http_client + + let response = self + .http_client .put(&full_url) - .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .header( + "Authorization", + format!("Basic {}", self.config.get_basic_auth()), + ) .header("Content-Type", "text/calendar; charset=utf-8") .header("User-Agent", "calendar-app/0.1.0") .timeout(std::time::Duration::from_secs(30)) @@ -861,8 +1002,11 @@ impl CalDAVClient { })?; println!("Event update response status: {}", response.status()); - - if response.status().is_success() || response.status().as_u16() == 201 || response.status().as_u16() == 204 { + + if response.status().is_success() + || response.status().as_u16() == 201 + || response.status().as_u16() == 204 + { println!("āœ… Event updated successfully"); Ok(()) } else { @@ -876,30 +1020,30 @@ impl CalDAVClient { /// Generate iCalendar data for a CalendarEvent fn generate_ical_event(&self, event: &CalendarEvent) -> Result { let now = chrono::Utc::now(); - + // Format datetime for iCal (YYYYMMDDTHHMMSSZ format) - let format_datetime = |dt: &DateTime| -> String { - dt.format("%Y%m%dT%H%M%SZ").to_string() - }; - - let format_date = |dt: &DateTime| -> String { - dt.format("%Y%m%d").to_string() - }; - + let format_datetime = + |dt: &DateTime| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() }; + + let format_date = |dt: &DateTime| -> String { dt.format("%Y%m%d").to_string() }; + // Start building the iCal event let mut ical = String::new(); ical.push_str("BEGIN:VCALENDAR\r\n"); ical.push_str("VERSION:2.0\r\n"); ical.push_str("PRODID:-//calendar-app//calendar-app//EN\r\n"); ical.push_str("BEGIN:VEVENT\r\n"); - + // Required fields ical.push_str(&format!("UID:{}\r\n", event.uid)); ical.push_str(&format!("DTSTAMP:{}\r\n", format_datetime(&now))); - + // Start and end times if event.all_day { - ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.dtstart))); + ical.push_str(&format!( + "DTSTART;VALUE=DATE:{}\r\n", + format_date(&event.dtstart) + )); if let Some(end) = &event.dtend { ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end))); } @@ -909,30 +1053,33 @@ impl CalDAVClient { ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end))); } } - + // Optional fields if let Some(summary) = &event.summary { ical.push_str(&format!("SUMMARY:{}\r\n", self.escape_ical_text(summary))); } - + if let Some(description) = &event.description { - ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description))); + ical.push_str(&format!( + "DESCRIPTION:{}\r\n", + self.escape_ical_text(description) + )); } - + if let Some(location) = &event.location { ical.push_str(&format!("LOCATION:{}\r\n", self.escape_ical_text(location))); } - + // Status if let Some(status) = &event.status { let status_str = match status { EventStatus::Tentative => "TENTATIVE", - EventStatus::Confirmed => "CONFIRMED", + EventStatus::Confirmed => "CONFIRMED", EventStatus::Cancelled => "CANCELLED", }; ical.push_str(&format!("STATUS:{}\r\n", status_str)); } - + // Classification if let Some(class) = &event.class { let class_str = match class { @@ -942,37 +1089,40 @@ impl CalDAVClient { }; ical.push_str(&format!("CLASS:{}\r\n", class_str)); } - + // Priority if let Some(priority) = event.priority { ical.push_str(&format!("PRIORITY:{}\r\n", priority)); } - + // Categories if !event.categories.is_empty() { let categories = event.categories.join(","); - ical.push_str(&format!("CATEGORIES:{}\r\n", self.escape_ical_text(&categories))); + ical.push_str(&format!( + "CATEGORIES:{}\r\n", + self.escape_ical_text(&categories) + )); } - + // Creation and modification times if let Some(created) = &event.created { ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created))); } - + ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now))); - + // Add alarms/reminders for alarm in &event.alarms { ical.push_str("BEGIN:VALARM\r\n"); - + let action = match alarm.action { calendar_models::AlarmAction::Display => "DISPLAY", - calendar_models::AlarmAction::Email => "EMAIL", + calendar_models::AlarmAction::Email => "EMAIL", calendar_models::AlarmAction::Audio => "AUDIO", calendar_models::AlarmAction::Procedure => "PROCEDURE", }; ical.push_str(&format!("ACTION:{}\r\n", action)); - + // Handle trigger match &alarm.trigger { calendar_models::AlarmTrigger::Duration(duration) => { @@ -987,36 +1137,45 @@ impl CalDAVClient { ical.push_str(&format!("TRIGGER:{}\r\n", format_datetime(dt))); } } - + if let Some(description) = &alarm.description { - ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description))); + ical.push_str(&format!( + "DESCRIPTION:{}\r\n", + self.escape_ical_text(description) + )); } else if let Some(summary) = &event.summary { - ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(summary))); + ical.push_str(&format!( + "DESCRIPTION:{}\r\n", + self.escape_ical_text(summary) + )); } - + ical.push_str("END:VALARM\r\n"); } - + // Recurrence rule if let Some(rrule) = &event.rrule { ical.push_str(&format!("RRULE:{}\r\n", rrule)); } - + // Exception dates (EXDATE) for exception_date in &event.exdate { if event.all_day { - ical.push_str(&format!("EXDATE;VALUE=DATE:{}\r\n", format_date(exception_date))); + ical.push_str(&format!( + "EXDATE;VALUE=DATE:{}\r\n", + format_date(exception_date) + )); } else { ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date))); } } - + ical.push_str("END:VEVENT\r\n"); ical.push_str("END:VCALENDAR\r\n"); - + Ok(ical) } - + /// Escape text for iCalendar format (RFC 5545) fn escape_ical_text(&self, text: &str) -> String { text.replace('\\', "\\\\") @@ -1027,13 +1186,21 @@ impl CalDAVClient { } /// Delete an event from a CalDAV calendar - pub async fn delete_event(&self, calendar_path: &str, event_href: &str) -> Result<(), CalDAVError> { + pub async fn delete_event( + &self, + calendar_path: &str, + event_href: &str, + ) -> Result<(), CalDAVError> { // Construct the full URL for the event let full_url = if event_href.starts_with("http") { event_href.to_string() } else if event_href.starts_with("/dav.php") { // Event href is already a full path, combine with base server URL (without /dav.php) - let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php"); + let base_url = self + .config + .server_url + .trim_end_matches('/') + .trim_end_matches("/dav.php"); format!("{}{}", base_url, event_href) } else { // Event href is just a filename, combine with calendar path @@ -1042,24 +1209,33 @@ impl CalDAVClient { } else { calendar_path }; - format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href) + format!( + "{}/dav.php{}/{}", + self.config.server_url.trim_end_matches('/'), + clean_path, + event_href + ) }; - + println!("Deleting event at: {}", full_url); - + println!("šŸ“” Acquiring CalDAV HTTP lock for DELETE request..."); let _lock = CALDAV_HTTP_MUTEX.lock().await; println!("šŸ“” Lock acquired, sending DELETE request to CalDAV server..."); - - let response = self.http_client + + let response = self + .http_client .delete(&full_url) - .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .header( + "Authorization", + format!("Basic {}", self.config.get_basic_auth()), + ) .send() .await .map_err(|e| CalDAVError::ParseError(e.to_string()))?; println!("Event deletion response status: {}", response.status()); - + if response.status().is_success() || response.status().as_u16() == 204 { println!("āœ… Event deleted successfully at {}", event_href); Ok(()) @@ -1085,10 +1261,10 @@ struct CalendarDataSection { pub enum CalDAVError { #[error("HTTP request failed: {0}")] RequestError(#[from] reqwest::Error), - + #[error("CalDAV server returned error: {0}")] ServerError(u16), - + #[error("Failed to parse calendar data: {0}")] ParseError(String), } @@ -1099,36 +1275,39 @@ mod tests { use crate::config::CalDAVConfig; /// Integration test that fetches real calendar events from the Baikal server - /// + /// /// This test requires a valid .env file and a calendar with some events #[tokio::test] async fn test_fetch_calendar_events() { - let config = CalDAVConfig::from_env() - .expect("Failed to load CalDAV config from environment"); - + let config = CalDAVConfig::new( + "https://example.com".to_string(), + "test_user".to_string(), + "test_password".to_string(), + ); + let client = CalDAVClient::new(config); - + // First discover available calendars using PROPFIND println!("Discovering calendars..."); let discovery_result = client.discover_calendars().await; - + match discovery_result { Ok(calendar_paths) => { println!("Found {} calendar collection(s)", calendar_paths.len()); - + if calendar_paths.is_empty() { println!("No calendars found - this might be normal for a new server"); return; } - + // Try the first available calendar let calendar_path = &calendar_paths[0]; println!("Trying to fetch events from: {}", calendar_path); - + match client.fetch_events(calendar_path).await { Ok(events) => { println!("Successfully fetched {} calendar events", events.len()); - + for (i, event) in events.iter().take(3).enumerate() { println!("\n--- Event {} ---", i + 1); println!("UID: {}", event.uid); @@ -1142,14 +1321,17 @@ mod tests { println!("ETag: {:?}", event.etag); println!("HREF: {:?}", event.href); } - + // Validate that events have required fields for event in &events { assert!(!event.uid.is_empty(), "Event UID should not be empty"); // All events should have a start time - assert!(event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time"); + assert!( + event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), + "Event should have valid start time" + ); } - + println!("\nāœ“ Calendar event fetching test passed!"); } Err(e) => { @@ -1192,15 +1374,15 @@ END:VCALENDAR"#; username: "test".to_string(), password: "test".to_string(), calendar_path: None, - tasks_path: None, }; - + let client = CalDAVClient::new(config); - let events = client.parse_ical_data(sample_ical) + let events = client + .parse_ical_data(sample_ical) .expect("Should be able to parse sample iCal data"); assert_eq!(events.len(), 1); - + let event = &events[0]; assert_eq!(event.uid, "test-event-123@example.com"); assert_eq!(event.summary, Some("Test Event".to_string())); @@ -1211,7 +1393,7 @@ END:VCALENDAR"#; assert_eq!(event.priority, Some(5)); assert_eq!(event.categories, vec!["work", "important"]); assert!(!event.all_day); - + println!("āœ“ iCal parsing test passed!"); } @@ -1223,26 +1405,28 @@ END:VCALENDAR"#; username: "test".to_string(), password: "test".to_string(), calendar_path: None, - tasks_path: None, }; - + let client = CalDAVClient::new(config); - + // Test UTC format - let dt1 = client.parse_datetime("20231225T120000Z", None) + let dt1 = client + .parse_datetime("20231225T120000Z", None) .expect("Should parse UTC datetime"); println!("Parsed UTC datetime: {}", dt1); - + // Test date-only format (should be treated as all-day) - let dt2 = client.parse_datetime("20231225", None) + let dt2 = client + .parse_datetime("20231225", None) .expect("Should parse date-only"); println!("Parsed date-only: {}", dt2); - + // Test local format - let dt3 = client.parse_datetime("20231225T120000", None) + let dt3 = client + .parse_datetime("20231225T120000", None) .expect("Should parse local datetime"); println!("Parsed local datetime: {}", dt3); - + println!("āœ“ Datetime parsing test passed!"); } @@ -1252,12 +1436,11 @@ END:VCALENDAR"#; // Test status parsing - these don't have defaults, so let's test creation let status = EventStatus::Confirmed; assert_eq!(status, EventStatus::Confirmed); - + // Test class parsing let class = EventClass::Public; assert_eq!(class, EventClass::Public); - + println!("āœ“ Event enum tests passed!"); } - } diff --git a/backend/src/config.rs b/backend/src/config.rs index 2154c15..a383780 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -1,30 +1,30 @@ +use base64::prelude::*; use serde::{Deserialize, Serialize}; use std::env; -use base64::prelude::*; /// Configuration for CalDAV server connection and authentication. -/// +/// /// This struct holds all the necessary information to connect to a CalDAV server, /// including server URL, credentials, and optional collection paths. -/// +/// /// # Security Note -/// +/// /// The password field contains sensitive information and should be handled carefully. /// This struct implements `Debug` but in production, consider implementing a custom /// `Debug` that masks the password field. -/// +/// /// # Example -/// +/// /// ```rust /// # use calendar_backend::config::CalDAVConfig; /// let config = CalDAVConfig { /// server_url: "https://caldav.example.com".to_string(), -/// username: "user@example.com".to_string(), +/// username: "user@example.com".to_string(), /// password: "password".to_string(), /// calendar_path: None, /// tasks_path: None, /// }; -/// +/// /// // Use the configuration for HTTP requests /// let auth_header = format!("Basic {}", config.get_basic_auth()); /// ``` @@ -32,17 +32,17 @@ use base64::prelude::*; pub struct CalDAVConfig { /// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/") pub server_url: String, - + /// Username for authentication with the CalDAV server pub username: String, - + /// Password for authentication with the CalDAV server - /// + /// /// **Security Note**: This contains sensitive information pub password: String, - + /// Optional path to the calendar collection on the server - /// + /// /// If not provided, the client will discover available calendars /// through CalDAV PROPFIND requests pub calendar_path: Option, @@ -50,20 +50,20 @@ pub struct CalDAVConfig { impl CalDAVConfig { /// Creates a new CalDAVConfig with the given credentials. - /// + /// /// # Arguments - /// + /// /// * `server_url` - The base URL of the CalDAV server /// * `username` - Username for authentication /// * `password` - Password for authentication - /// + /// /// # Example - /// + /// /// ```rust /// # use calendar_backend::config::CalDAVConfig; /// let config = CalDAVConfig::new( /// "https://caldav.example.com".to_string(), - /// "user@example.com".to_string(), + /// "user@example.com".to_string(), /// "password".to_string() /// ); /// ``` @@ -77,21 +77,21 @@ impl CalDAVConfig { } /// Generates a Base64-encoded string for HTTP Basic Authentication. - /// + /// /// This method combines the username and password in the format /// `username:password` and encodes it using Base64, which is the /// standard format for the `Authorization: Basic` HTTP header. - /// + /// /// # Returns - /// + /// /// A Base64-encoded string that can be used directly in the /// `Authorization` header: `Authorization: Basic ` - /// + /// /// # Example - /// + /// /// ```rust /// # use calendar_backend::config::CalDAVConfig; - /// + /// /// let config = CalDAVConfig { /// server_url: "https://example.com".to_string(), /// username: "user".to_string(), @@ -99,7 +99,7 @@ impl CalDAVConfig { /// calendar_path: None, /// tasks_path: None, /// }; - /// + /// /// let auth_value = config.get_basic_auth(); /// let auth_header = format!("Basic {}", auth_value); /// ``` @@ -113,15 +113,15 @@ impl CalDAVConfig { #[derive(Debug, thiserror::Error)] pub enum ConfigError { /// A required environment variable is missing or cannot be read. - /// + /// /// This error occurs when calling `CalDAVConfig::from_env()` and one of the /// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`, /// or `CALDAV_PASSWORD`) is not set. #[error("Missing environment variable: {0}")] MissingVar(String), - + /// The configuration contains invalid or malformed values. - /// + /// /// This could include malformed URLs, invalid authentication credentials, /// or other configuration issues that prevent proper CalDAV operation. #[error("Invalid configuration: {0}")] @@ -139,7 +139,6 @@ mod tests { username: "testuser".to_string(), password: "testpass".to_string(), calendar_path: None, - tasks_path: None, }; let auth = config.get_basic_auth(); @@ -148,12 +147,12 @@ mod tests { } /// Integration test that authenticates with the actual Baikal CalDAV server - /// + /// /// This test requires a valid .env file with: /// - CALDAV_SERVER_URL /// - CALDAV_USERNAME /// - CALDAV_PASSWORD - /// + /// /// Run with: `cargo test test_baikal_auth` #[tokio::test] async fn test_baikal_auth() { @@ -161,7 +160,7 @@ mod tests { let config = CalDAVConfig::new( "https://example.com".to_string(), "test_user".to_string(), - "test_password".to_string() + "test_password".to_string(), ); println!("Testing authentication to: {}", config.server_url); @@ -172,7 +171,10 @@ mod tests { // Make a simple OPTIONS request to test authentication let response = client .request(reqwest::Method::OPTIONS, &config.server_url) - .header("Authorization", format!("Basic {}", config.get_basic_auth())) + .header( + "Authorization", + format!("Basic {}", config.get_basic_auth()), + ) .header("User-Agent", "calendar-app/0.1.0") .send() .await @@ -190,9 +192,9 @@ mod tests { // For Baikal/CalDAV servers, we should see DAV headers assert!( - response.headers().contains_key("dav") || - response.headers().contains_key("DAV") || - response.status().is_success(), + response.headers().contains_key("dav") + || response.headers().contains_key("DAV") + || response.status().is_success(), "Server doesn't appear to be a CalDAV server - missing DAV headers" ); @@ -200,17 +202,17 @@ mod tests { } /// Test making a PROPFIND request to discover calendars - /// + /// /// This test requires a valid .env file and makes an actual CalDAV PROPFIND request - /// + /// /// Run with: `cargo test test_propfind_calendars` #[tokio::test] async fn test_propfind_calendars() { - // Use test config - update these values to test with real server + // Use test config - update these values to test with real server let config = CalDAVConfig::new( "https://example.com".to_string(), "test_user".to_string(), - "test_password".to_string() + "test_password".to_string(), ); let client = reqwest::Client::new(); @@ -227,8 +229,14 @@ mod tests { "#; let response = client - .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url) - .header("Authorization", format!("Basic {}", config.get_basic_auth())) + .request( + reqwest::Method::from_bytes(b"PROPFIND").unwrap(), + &config.server_url, + ) + .header( + "Authorization", + format!("Basic {}", config.get_basic_auth()), + ) .header("Content-Type", "application/xml") .header("Depth", "1") .header("User-Agent", "calendar-app/0.1.0") @@ -239,7 +247,7 @@ mod tests { let status = response.status(); println!("PROPFIND Response status: {}", status); - + let body = response.text().await.expect("Failed to read response body"); println!("PROPFIND Response body: {}", body); @@ -251,8 +259,11 @@ mod tests { ); // The response should contain XML with calendar information - assert!(body.contains("calendar"), "Response should contain calendar information"); + assert!( + body.contains("calendar"), + "Response should contain calendar information" + ); println!("āœ“ PROPFIND calendars test passed!"); } -} \ No newline at end of file +} diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index a45f29a..a143e74 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -4,7 +4,7 @@ mod calendar; mod events; mod series; -pub use auth::{login, verify_token, get_user_info}; +pub use auth::{get_user_info, login, verify_token}; pub use calendar::{create_calendar, delete_calendar}; -pub use events::{get_calendar_events, refresh_event, create_event, update_event, delete_event}; -pub use series::{create_event_series, update_event_series, delete_event_series}; \ No newline at end of file +pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event}; +pub use series::{create_event_series, delete_event_series, update_event_series}; diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 425ba30..730bb22 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -1,33 +1,38 @@ -use axum::{ - extract::State, - http::HeaderMap, - response::Json, -}; +use axum::{extract::State, http::HeaderMap, response::Json}; use std::sync::Arc; -use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}}; use crate::calendar::CalDAVClient; use crate::config::CalDAVConfig; +use crate::{ + models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo}, + AppState, +}; pub fn extract_bearer_token(headers: &HeaderMap) -> Result { - let auth_header = headers.get("authorization") + let auth_header = headers + .get("authorization") .ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?; - - let auth_str = auth_header.to_str() + + let auth_str = auth_header + .to_str() .map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?; - + if let Some(token) = auth_str.strip_prefix("Bearer ") { Ok(token.to_string()) } else { - Err(ApiError::BadRequest("Authorization header must be Bearer token".to_string())) + Err(ApiError::BadRequest( + "Authorization header must be Bearer token".to_string(), + )) } } pub fn extract_password_header(headers: &HeaderMap) -> Result { - let password_header = headers.get("x-caldav-password") + let password_header = headers + .get("x-caldav-password") .ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?; - - password_header.to_str() + + password_header + .to_str() .map(|s| s.to_string()) .map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string())) } @@ -40,32 +45,37 @@ pub async fn login( println!(" Server URL: {}", request.server_url); println!(" Username: {}", request.username); println!(" Password length: {}", request.password.len()); - + // Basic validation if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() { - return Err(ApiError::BadRequest("Username, password, and server URL are required".to_string())); + return Err(ApiError::BadRequest( + "Username, password, and server URL are required".to_string(), + )); } - + println!("āœ… Input validation passed"); - + // Create a token using the auth service println!("šŸ“ Created CalDAV config"); - + // First verify the credentials are valid by attempting to discover calendars let config = CalDAVConfig::new( request.server_url.clone(), request.username.clone(), - request.password.clone() + request.password.clone(), ); let client = CalDAVClient::new(config); - client.discover_calendars() + client + .discover_calendars() .await .map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?; - - let token = state.auth_service.generate_token(&request.username, &request.server_url)?; - + + let token = state + .auth_service + .generate_token(&request.username, &request.server_url)?; + println!("šŸ”— Created CalDAV client, attempting to discover calendars..."); - + Ok(Json(AuthResponse { token, username: request.username, @@ -79,7 +89,7 @@ pub async fn verify_token( ) -> Result, ApiError> { let token = extract_bearer_token(&headers)?; let is_valid = state.auth_service.verify_token(&token).is_ok(); - + Ok(Json(serde_json::json!({ "valid": is_valid }))) } @@ -89,26 +99,33 @@ pub async fn get_user_info( ) -> Result, ApiError> { let token = extract_bearer_token(&headers)?; let password = extract_password_header(&headers)?; - + // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config.clone()); - + // Discover calendars - let calendar_paths = client.discover_calendars() + let calendar_paths = client + .discover_calendars() .await .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; - - println!("āœ… Authentication successful! Found {} calendars", calendar_paths.len()); - - let calendars: Vec = calendar_paths.iter().map(|path| { - CalendarInfo { + + println!( + "āœ… Authentication successful! Found {} calendars", + calendar_paths.len() + ); + + let calendars: Vec = calendar_paths + .iter() + .map(|path| CalendarInfo { path: path.clone(), display_name: extract_calendar_name(path), color: generate_calendar_color(path), - } - }).collect(); - + }) + .collect(); + Ok(Json(UserInfo { username: config.username, server_url: config.server_url, @@ -123,15 +140,14 @@ fn generate_calendar_color(path: &str) -> String { for byte in path.bytes() { hash = hash.wrapping_mul(31).wrapping_add(byte as u32); } - + // Define a set of pleasant colors let colors = [ - "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", - "#06B6D4", "#84CC16", "#F97316", "#EC4899", "#6366F1", - "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", - "#7C3AED", "#059669", "#D97706", "#BE185D", "#4F46E5" + "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316", + "#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED", + "#059669", "#D97706", "#BE185D", "#4F46E5", ]; - + colors[(hash as usize) % colors.len()].to_string() } @@ -154,4 +170,4 @@ fn extract_calendar_name(path: &str) -> String { }) .collect::>() .join(" ") -} \ No newline at end of file +} diff --git a/backend/src/handlers/calendar.rs b/backend/src/handlers/calendar.rs index d7f1972..f4a34f6 100644 --- a/backend/src/handlers/calendar.rs +++ b/backend/src/handlers/calendar.rs @@ -1,12 +1,14 @@ -use axum::{ - extract::State, - http::HeaderMap, - response::Json, -}; +use axum::{extract::State, http::HeaderMap, response::Json}; use std::sync::Arc; -use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}}; use crate::calendar::CalDAVClient; +use crate::{ + models::{ + ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, + DeleteCalendarResponse, + }, + AppState, +}; use super::auth::{extract_bearer_token, extract_password_header}; @@ -20,22 +22,36 @@ pub async fn create_calendar( // Validate request if request.name.trim().is_empty() { - return Err(ApiError::BadRequest("Calendar name is required".to_string())); + return Err(ApiError::BadRequest( + "Calendar name is required".to_string(), + )); } // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); // Create calendar on CalDAV server - match client.create_calendar(&request.name, request.description.as_deref(), request.color.as_deref()).await { + match client + .create_calendar( + &request.name, + request.description.as_deref(), + request.color.as_deref(), + ) + .await + { Ok(_) => Ok(Json(CreateCalendarResponse { success: true, message: "Calendar created successfully".to_string(), })), Err(e) => { eprintln!("Failed to create calendar: {}", e); - Err(ApiError::Internal(format!("Failed to create calendar: {}", e))) + Err(ApiError::Internal(format!( + "Failed to create calendar: {}", + e + ))) } } } @@ -50,11 +66,15 @@ pub async fn delete_calendar( // Validate request if request.path.trim().is_empty() { - return Err(ApiError::BadRequest("Calendar path is required".to_string())); + return Err(ApiError::BadRequest( + "Calendar path is required".to_string(), + )); } // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); // Delete calendar on CalDAV server @@ -65,7 +85,10 @@ pub async fn delete_calendar( })), Err(e) => { eprintln!("Failed to delete calendar: {}", e); - Err(ApiError::Internal(format!("Failed to delete calendar: {}", e))) + Err(ApiError::Internal(format!( + "Failed to delete calendar: {}", + e + ))) } } -} \ No newline at end of file +} diff --git a/backend/src/handlers/events.rs b/backend/src/handlers/events.rs index b9531e8..500f99f 100644 --- a/backend/src/handlers/events.rs +++ b/backend/src/handlers/events.rs @@ -1,15 +1,23 @@ use axum::{ - extract::{State, Query, Path}, + extract::{Path, Query, State}, http::HeaderMap, response::Json, }; +use chrono::Datelike; use serde::Deserialize; use std::sync::Arc; -use chrono::Datelike; -use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, Attendee, VAlarm, AlarmAction, AlarmTrigger}; -use crate::{AppState, models::{ApiError, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}}; use crate::calendar::{CalDAVClient, CalendarEvent}; +use crate::{ + models::{ + ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse, + UpdateEventRequest, UpdateEventResponse, + }, + AppState, +}; +use calendar_models::{ + AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent, +}; use super::auth::{extract_bearer_token, extract_password_header}; @@ -28,20 +36,23 @@ pub async fn get_calendar_events( let token = extract_bearer_token(&headers)?; let password = extract_password_header(&headers)?; println!("šŸ”‘ API call with password length: {}", password.len()); - + // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); - + // Discover calendars if needed - let calendar_paths = client.discover_calendars() + let calendar_paths = client + .discover_calendars() .await .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; - + if calendar_paths.is_empty() { return Ok(Json(vec![])); // No calendars found } - + // Fetch events from all calendars let mut all_events = Vec::new(); for calendar_path in &calendar_paths { @@ -54,12 +65,15 @@ pub async fn get_calendar_events( all_events.extend(events); } Err(e) => { - eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e); + eprintln!( + "Failed to fetch events from calendar {}: {}", + calendar_path, e + ); // Continue with other calendars instead of failing completely } } } - + // If year and month are specified, filter events if let (Some(year), Some(month)) = (params.year, params.month) { all_events.retain(|event| { @@ -68,7 +82,7 @@ pub async fn get_calendar_events( event_year == year && event_month == month }); } - + println!("šŸ“… Returning {} events", all_events.len()); Ok(Json(all_events)) } @@ -80,16 +94,19 @@ pub async fn refresh_event( ) -> Result>, ApiError> { let token = extract_bearer_token(&headers)?; let password = extract_password_header(&headers)?; - + // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); - + // Discover calendars - let calendar_paths = client.discover_calendars() + let calendar_paths = client + .discover_calendars() .await .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; - + // Search for the event by UID across all calendars for calendar_path in &calendar_paths { if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await { @@ -97,18 +114,25 @@ pub async fn refresh_event( return Ok(Json(Some(event))); } } - + Ok(Json(None)) } -async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_href: &str) -> Result, crate::calendar::CalDAVError> { +async fn fetch_event_by_href( + client: &CalDAVClient, + calendar_path: &str, + event_href: &str, +) -> Result, crate::calendar::CalDAVError> { // This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href // For now, we'll fetch all events and find the matching one by href (inefficient but functional) let events = client.fetch_events(calendar_path).await?; - + println!("šŸ” fetch_event_by_href: looking for href='{}'", event_href); - println!("šŸ” Available events with hrefs: {:?}", events.iter().map(|e| (&e.uid, &e.href)).collect::>()); - + println!( + "šŸ” Available events with hrefs: {:?}", + events.iter().map(|e| (&e.uid, &e.href)).collect::>() + ); + // First try to match by exact href for event in &events { if let Some(stored_href) = &event.href { @@ -118,22 +142,25 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h } } } - + // Fallback: try to match by UID extracted from href filename let filename = event_href.split('/').last().unwrap_or(event_href); let uid_from_href = filename.trim_end_matches(".ics"); - - println!("šŸ” Fallback: trying UID match. filename='{}', uid='{}'", filename, uid_from_href); - + + println!( + "šŸ” Fallback: trying UID match. filename='{}', uid='{}'", + filename, uid_from_href + ); + for event in events { if event.uid == uid_from_href { println!("āœ… Found matching event by UID: {}", event.uid); return Ok(Some(event)); } } - + println!("āŒ No matching event found for href: {}", event_href); - + Ok(None) } @@ -146,41 +173,63 @@ pub async fn delete_event( let password = extract_password_header(&headers)?; // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); // Handle different delete actions for recurring events match request.delete_action.as_str() { "delete_this" => { - if let Some(event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await - .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? { - + if let Some(event) = + fetch_event_by_href(&client, &request.calendar_path, &request.event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? + { // Check if this is a recurring event if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() { // Recurring event - add EXDATE for this occurrence if let Some(occurrence_date) = &request.occurrence_date { - let exception_utc = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) { + let exception_utc = if let Ok(date) = + chrono::DateTime::parse_from_rfc3339(occurrence_date) + { // RFC3339 format (with time and timezone) date.with_timezone(&chrono::Utc) - } else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") { + } else if let Ok(naive_date) = + chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") + { // Simple date format (YYYY-MM-DD) naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() } else { return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date))); }; - + let mut updated_event = event; updated_event.exdate.push(exception_utc); - - println!("šŸ”„ Adding EXDATE {} to recurring event {}", exception_utc.format("%Y%m%dT%H%M%SZ"), updated_event.uid); - + + println!( + "šŸ”„ Adding EXDATE {} to recurring event {}", + exception_utc.format("%Y%m%dT%H%M%SZ"), + updated_event.uid + ); + // Update the event with the new EXDATE - client.update_event(&request.calendar_path, &updated_event, &request.event_href) + client + .update_event( + &request.calendar_path, + &updated_event, + &request.event_href, + ) .await - .map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?; - + .map_err(|e| { + ApiError::Internal(format!( + "Failed to update event with EXDATE: {}", + e + )) + })?; + println!("āœ… Successfully updated recurring event with EXDATE"); - + Ok(Json(DeleteEventResponse { success: true, message: "Single occurrence deleted successfully".to_string(), @@ -191,13 +240,16 @@ pub async fn delete_event( } else { // Non-recurring event - delete the entire event println!("šŸ—‘ļø Deleting non-recurring event: {}", event.uid); - - client.delete_event(&request.calendar_path, &request.event_href) + + client + .delete_event(&request.calendar_path, &request.event_href) .await - .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; - + .map_err(|e| { + ApiError::Internal(format!("Failed to delete event: {}", e)) + })?; + println!("āœ… Successfully deleted non-recurring event"); - + Ok(Json(DeleteEventResponse { success: true, message: "Event deleted successfully".to_string(), @@ -206,70 +258,99 @@ pub async fn delete_event( } else { Err(ApiError::NotFound("Event not found".to_string())) } - }, + } "delete_following" => { // For "this and following" deletion, we need to: // 1. Fetch the recurring event // 2. Modify the RRULE to end before this occurrence // 3. Update the event - if let Some(mut event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await - .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? { - + if let Some(mut event) = + fetch_event_by_href(&client, &request.calendar_path, &request.event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? + { if let Some(occurrence_date) = &request.occurrence_date { - let until_date = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) { + let until_date = if let Ok(date) = + chrono::DateTime::parse_from_rfc3339(occurrence_date) + { // RFC3339 format (with time and timezone) date.with_timezone(&chrono::Utc) - } else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") { + } else if let Ok(naive_date) = + chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") + { // Simple date format (YYYY-MM-DD) naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() } else { - return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date))); + return Err(ApiError::BadRequest(format!( + "Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", + occurrence_date + ))); }; - + // Modify the RRULE to add an UNTIL clause if let Some(rrule) = &event.rrule { // Remove existing UNTIL if present and add new one - let parts: Vec<&str> = rrule.split(';').filter(|part| { - !part.starts_with("UNTIL=") && !part.starts_with("COUNT=") - }).collect(); - - let new_rrule = format!("{};UNTIL={}", parts.join(";"), until_date.format("%Y%m%dT%H%M%SZ")); + let parts: Vec<&str> = rrule + .split(';') + .filter(|part| { + !part.starts_with("UNTIL=") && !part.starts_with("COUNT=") + }) + .collect(); + + let new_rrule = format!( + "{};UNTIL={}", + parts.join(";"), + until_date.format("%Y%m%dT%H%M%SZ") + ); event.rrule = Some(new_rrule); - + // Update the event with the modified RRULE - client.update_event(&request.calendar_path, &event, &request.event_href) + client + .update_event(&request.calendar_path, &event, &request.event_href) .await - .map_err(|e| ApiError::Internal(format!("Failed to update event with modified RRULE: {}", e)))?; - + .map_err(|e| { + ApiError::Internal(format!( + "Failed to update event with modified RRULE: {}", + e + )) + })?; + Ok(Json(DeleteEventResponse { success: true, - message: "This and following occurrences deleted successfully".to_string(), + message: "This and following occurrences deleted successfully" + .to_string(), })) } else { // No RRULE, just delete the single event - client.delete_event(&request.calendar_path, &request.event_href) + client + .delete_event(&request.calendar_path, &request.event_href) .await - .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; - + .map_err(|e| { + ApiError::Internal(format!("Failed to delete event: {}", e)) + })?; + Ok(Json(DeleteEventResponse { success: true, message: "Event deleted successfully".to_string(), })) } } else { - Err(ApiError::BadRequest("Occurrence date is required for following deletion".to_string())) + Err(ApiError::BadRequest( + "Occurrence date is required for following deletion".to_string(), + )) } } else { Err(ApiError::NotFound("Event not found".to_string())) } - }, + } "delete_series" | _ => { // Delete the entire event/series - client.delete_event(&request.calendar_path, &request.event_href) + client + .delete_event(&request.calendar_path, &request.event_href) .await .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; - + Ok(Json(DeleteEventResponse { success: true, message: "Event deleted successfully".to_string(), @@ -283,9 +364,11 @@ pub async fn create_event( headers: HeaderMap, Json(request): Json, ) -> Result, ApiError> { - println!("šŸ“ Create event request received: title='{}', all_day={}, calendar_path={:?}", - request.title, request.all_day, request.calendar_path); - + println!( + "šŸ“ Create event request received: title='{}', all_day={}, calendar_path={:?}", + request.title, request.all_day, request.calendar_path + ); + // Extract and verify token let token = extract_bearer_token(&headers)?; let password = extract_password_header(&headers)?; @@ -294,13 +377,17 @@ pub async fn create_event( if request.title.trim().is_empty() { return Err(ApiError::BadRequest("Event title is required".to_string())); } - + if request.title.len() > 200 { - return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string())); + return Err(ApiError::BadRequest( + "Event title too long (max 200 characters)".to_string(), + )); } // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); // Determine which calendar to use @@ -308,31 +395,41 @@ pub async fn create_event( path } else { // Use the first available calendar - let calendar_paths = client.discover_calendars() + let calendar_paths = client + .discover_calendars() .await .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; - + if calendar_paths.is_empty() { - return Err(ApiError::BadRequest("No calendars available for event creation".to_string())); + return Err(ApiError::BadRequest( + "No calendars available for event creation".to_string(), + )); } - + calendar_paths[0].clone() }; // Parse dates and times - let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day) - .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; - + let start_datetime = + parse_event_datetime(&request.start_date, &request.start_time, request.all_day) + .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; + let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; // Validate that end is after start if end_datetime <= start_datetime { - return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string())); + return Err(ApiError::BadRequest( + "End date/time must be after start date/time".to_string(), + )); } // Generate a unique UID for the event - let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp()); + let uid = format!( + "{}-{}", + uuid::Uuid::new_v4(), + chrono::Utc::now().timestamp() + ); // Parse status let status = match request.status.to_lowercase().as_str() { @@ -352,7 +449,8 @@ pub async fn create_event( let attendees: Vec = if request.attendees.trim().is_empty() { Vec::new() } else { - request.attendees + request + .attendees .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -363,7 +461,8 @@ pub async fn create_event( let categories: Vec = if request.categories.trim().is_empty() { Vec::new() } else { - request.categories + request + .categories .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -399,10 +498,11 @@ pub async fn create_event( "WEEKLY" => { // Handle weekly recurrence with optional BYDAY parameter let mut rrule = "FREQ=WEEKLY".to_string(); - + // Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]) if request.recurrence_days.len() == 7 { - let selected_days: Vec<&str> = request.recurrence_days + let selected_days: Vec<&str> = request + .recurrence_days .iter() .enumerate() .filter_map(|(i, &selected)| { @@ -416,20 +516,20 @@ pub async fn create_event( 5 => "FR", // Friday 6 => "SA", // Saturday _ => return None, - }) - } else { - None - } - }) - .collect(); + }) + } else { + None + } + }) + .collect(); - if !selected_days.is_empty() { - rrule = format!("{};BYDAY={}", rrule, selected_days.join(",")); + if !selected_days.is_empty() { + rrule = format!("{};BYDAY={}", rrule, selected_days.join(",")); + } } - } - + Some(rrule) - }, + } "MONTHLY" => Some("FREQ=MONTHLY".to_string()), "YEARLY" => Some("FREQ=YEARLY".to_string()), _ => None, @@ -439,15 +539,27 @@ pub async fn create_event( // Create the VEvent struct (RFC 5545 compliant) let mut event = VEvent::new(uid, start_datetime); event.dtend = Some(end_datetime); - event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) }; - event.description = if request.description.trim().is_empty() { None } else { Some(request.description) }; - event.location = if request.location.trim().is_empty() { None } else { Some(request.location) }; + event.summary = if request.title.trim().is_empty() { + None + } else { + Some(request.title.clone()) + }; + event.description = if request.description.trim().is_empty() { + None + } else { + Some(request.description) + }; + event.location = if request.location.trim().is_empty() { + None + } else { + Some(request.location) + }; event.status = Some(status); event.class = Some(class); event.priority = request.priority; - event.organizer = if request.organizer.trim().is_empty() { - None - } else { + event.organizer = if request.organizer.trim().is_empty() { + None + } else { Some(CalendarUser { cal_address: request.organizer, common_name: None, @@ -456,41 +568,53 @@ pub async fn create_event( language: None, }) }; - event.attendees = attendees.into_iter().map(|email| Attendee { - cal_address: email, - common_name: None, - role: None, - part_stat: None, - rsvp: None, - cu_type: None, - member: Vec::new(), - delegated_to: Vec::new(), - delegated_from: Vec::new(), - sent_by: None, - dir_entry_ref: None, - language: None, - }).collect(); + event.attendees = attendees + .into_iter() + .map(|email| Attendee { + cal_address: email, + common_name: None, + role: None, + part_stat: None, + rsvp: None, + cu_type: None, + member: Vec::new(), + delegated_to: Vec::new(), + delegated_from: Vec::new(), + sent_by: None, + dir_entry_ref: None, + language: None, + }) + .collect(); event.categories = categories; event.rrule = rrule; event.all_day = request.all_day; - event.alarms = alarms.into_iter().map(|reminder| VAlarm { - action: AlarmAction::Display, - trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)), - duration: None, - repeat: None, - description: reminder.description, - summary: None, - attendees: Vec::new(), - attach: Vec::new(), - }).collect(); + event.alarms = alarms + .into_iter() + .map(|reminder| VAlarm { + action: AlarmAction::Display, + trigger: AlarmTrigger::Duration(chrono::Duration::minutes( + -reminder.minutes_before as i64, + )), + duration: None, + repeat: None, + description: reminder.description, + summary: None, + attendees: Vec::new(), + attach: Vec::new(), + }) + .collect(); event.calendar_path = Some(calendar_path.clone()); // Create the event on the CalDAV server - let event_href = client.create_event(&calendar_path, &event) + let event_href = client + .create_event(&calendar_path, &event) .await .map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?; - println!("āœ… Event created successfully with UID: {} at href: {}", event.uid, event_href); + println!( + "āœ… Event created successfully with UID: {} at href: {}", + event.uid, event_href + ); Ok(Json(CreateEventResponse { success: true, @@ -505,7 +629,7 @@ pub async fn update_event( Json(request): Json, ) -> Result, ApiError> { // Handle update request - + // Extract and verify token let token = extract_bearer_token(&headers)?; let password = extract_password_header(&headers)?; @@ -514,37 +638,45 @@ pub async fn update_event( if request.uid.trim().is_empty() { return Err(ApiError::BadRequest("Event UID is required".to_string())); } - + if request.title.trim().is_empty() { return Err(ApiError::BadRequest("Event title is required".to_string())); } - + if request.title.len() > 200 { - return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string())); + return Err(ApiError::BadRequest( + "Event title too long (max 200 characters)".to_string(), + )); } // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); // Find the event across all calendars (or in the specified calendar) let calendar_paths = if let Some(path) = &request.calendar_path { vec![path.clone()] } else { - client.discover_calendars() + client + .discover_calendars() .await .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))? }; let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href) - + for calendar_path in &calendar_paths { match client.fetch_events(calendar_path).await { Ok(events) => { for event in events { if event.uid == request.uid { // Use the actual href from the event, or generate one if missing - let event_href = event.href.clone().unwrap_or_else(|| format!("{}.ics", event.uid)); + let event_href = event + .href + .clone() + .unwrap_or_else(|| format!("{}.ics", event.uid)); println!("šŸ” Found event {} with href: {}", event.uid, event_href); found_event = Some((event, calendar_path.clone(), event_href)); break; @@ -553,9 +685,12 @@ pub async fn update_event( if found_event.is_some() { break; } - }, + } Err(e) => { - eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e); + eprintln!( + "Failed to fetch events from calendar {}: {}", + calendar_path, e + ); continue; } } @@ -565,23 +700,38 @@ pub async fn update_event( .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?; // Parse dates and times - let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day) - .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; - + let start_datetime = + parse_event_datetime(&request.start_date, &request.start_time, request.all_day) + .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; + let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; // Validate that end is after start if end_datetime <= start_datetime { - return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string())); + return Err(ApiError::BadRequest( + "End date/time must be after start date/time".to_string(), + )); } // Update event properties event.dtstart = start_datetime; event.dtend = Some(end_datetime); - event.summary = if request.title.trim().is_empty() { None } else { Some(request.title) }; - event.description = if request.description.trim().is_empty() { None } else { Some(request.description) }; - event.location = if request.location.trim().is_empty() { None } else { Some(request.location) }; + event.summary = if request.title.trim().is_empty() { + None + } else { + Some(request.title) + }; + event.description = if request.description.trim().is_empty() { + None + } else { + Some(request.description) + }; + event.location = if request.location.trim().is_empty() { + None + } else { + Some(request.location) + }; event.all_day = request.all_day; // Parse and update status @@ -601,11 +751,15 @@ pub async fn update_event( event.priority = request.priority; // Update the event on the CalDAV server - println!("šŸ“ Updating event {} at calendar_path: {}, event_href: {}", event.uid, calendar_path, event_href); - client.update_event(&calendar_path, &event, &event_href) + println!( + "šŸ“ Updating event {} at calendar_path: {}, event_href: {}", + event.uid, calendar_path, event_href + ); + client + .update_event(&calendar_path, &event, &event_href) .await .map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?; - + println!("āœ… Successfully updated event {}", event.uid); Ok(Json(UpdateEventResponse { @@ -614,27 +768,32 @@ pub async fn update_event( })) } -fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result, String> { - use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone}; - +fn parse_event_datetime( + date_str: &str, + time_str: &str, + all_day: bool, +) -> Result, String> { + use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; + // Parse the date let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") .map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?; - + if all_day { // For all-day events, use midnight UTC - let datetime = date.and_hms_opt(0, 0, 0) + let datetime = date + .and_hms_opt(0, 0, 0) .ok_or_else(|| "Failed to create midnight datetime".to_string())?; Ok(Utc.from_utc_datetime(&datetime)) } else { // Parse the time let time = NaiveTime::parse_from_str(time_str, "%H:%M") .map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?; - + // Combine date and time let datetime = NaiveDateTime::new(date, time); - + // Assume local time and convert to UTC (in a real app, you'd want timezone support) Ok(Utc.from_utc_datetime(&datetime)) } -} \ No newline at end of file +} diff --git a/backend/src/handlers/series.rs b/backend/src/handlers/series.rs index a69c595..945881b 100644 --- a/backend/src/handlers/series.rs +++ b/backend/src/handlers/series.rs @@ -1,14 +1,16 @@ -use axum::{ - extract::State, - http::HeaderMap, - response::Json, -}; -use std::sync::Arc; +use axum::{extract::State, http::HeaderMap, response::Json}; use chrono::TimeZone; +use std::sync::Arc; -use crate::{AppState, models::{ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse, DeleteEventSeriesRequest, DeleteEventSeriesResponse}}; use crate::calendar::CalDAVClient; -use calendar_models::{VEvent, EventStatus, EventClass}; +use crate::{ + models::{ + ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, DeleteEventSeriesRequest, + DeleteEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse, + }, + AppState, +}; +use calendar_models::{EventClass, EventStatus, VEvent}; use super::auth::{extract_bearer_token, extract_password_header}; @@ -18,9 +20,11 @@ pub async fn create_event_series( headers: HeaderMap, Json(request): Json, ) -> Result, ApiError> { - println!("šŸ“ Create event series request received: title='{}', recurrence='{}', all_day={}", - request.title, request.recurrence, request.all_day); - + println!( + "šŸ“ Create event series request received: title='{}', recurrence='{}', all_day={}", + request.title, request.recurrence, request.all_day + ); + // Extract and verify token let token = extract_bearer_token(&headers)?; let password = extract_password_header(&headers)?; @@ -29,13 +33,17 @@ pub async fn create_event_series( if request.title.trim().is_empty() { return Err(ApiError::BadRequest("Event title is required".to_string())); } - + if request.title.len() > 200 { - return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string())); + return Err(ApiError::BadRequest( + "Event title too long (max 200 characters)".to_string(), + )); } if request.recurrence == "none" { - return Err(ApiError::BadRequest("Use regular create endpoint for non-recurring events".to_string())); + return Err(ApiError::BadRequest( + "Use regular create endpoint for non-recurring events".to_string(), + )); } // Validate recurrence type - handle both simple strings and RRULE strings @@ -50,7 +58,9 @@ pub async fn create_event_series( } else if request.recurrence.contains("FREQ=YEARLY") { "yearly" } else { - return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string())); + return Err(ApiError::BadRequest( + "Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(), + )); } } else { // Handle simple strings @@ -58,14 +68,21 @@ pub async fn create_event_series( match lower.as_str() { "daily" => "daily", "weekly" => "weekly", - "monthly" => "monthly", + "monthly" => "monthly", "yearly" => "yearly", - _ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())), + _ => { + return Err(ApiError::BadRequest( + "Invalid recurrence type. Must be daily, weekly, monthly, or yearly" + .to_string(), + )) + } } }; // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); // Determine which calendar to use @@ -73,51 +90,64 @@ pub async fn create_event_series( path.clone() } else { // Use the first available calendar - let calendar_paths = client.discover_calendars() + let calendar_paths = client + .discover_calendars() .await .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; - + if calendar_paths.is_empty() { - return Err(ApiError::BadRequest("No calendars available for event creation".to_string())); + return Err(ApiError::BadRequest( + "No calendars available for event creation".to_string(), + )); } - + calendar_paths[0].clone() }; println!("šŸ“… Using calendar path: {}", calendar_path); // Parse datetime components - let start_date = chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d") - .map_err(|_| ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()))?; + let start_date = + chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| { + ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()) + })?; let (start_datetime, end_datetime) = if request.all_day { // For all-day events, use the dates as-is - let start_dt = start_date.and_hms_opt(0, 0, 0) + let start_dt = start_date + .and_hms_opt(0, 0, 0) .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; - + let end_date = if !request.end_date.is_empty() { - chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d") - .map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))? + chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| { + ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()) + })? } else { start_date }; - - let end_dt = end_date.and_hms_opt(23, 59, 59) + + let end_dt = end_date + .and_hms_opt(23, 59, 59) .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; - (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) + ( + chrono::Utc.from_utc_datetime(&start_dt), + chrono::Utc.from_utc_datetime(&end_dt), + ) } else { // Parse times for timed events let start_time = if !request.start_time.is_empty() { - chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M") - .map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))? + chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| { + ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()) + })? } else { chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM }; let end_time = if !request.end_time.is_empty() { - chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M") - .map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))? + chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| { + ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()) + })? } else { chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration }; @@ -125,13 +155,18 @@ pub async fn create_event_series( let start_dt = start_date.and_time(start_time); let end_dt = if !request.end_date.is_empty() { let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d") - .map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))?; + .map_err(|_| { + ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()) + })?; end_date.and_time(end_time) } else { start_date.and_time(end_time) }; - (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) + ( + chrono::Utc.from_utc_datetime(&start_dt), + chrono::Utc.from_utc_datetime(&end_dt), + ) }; // Generate a unique UID for the series @@ -140,24 +175,36 @@ pub async fn create_event_series( // Create the VEvent for the series let mut event = VEvent::new(uid.clone(), start_datetime); event.dtend = Some(end_datetime); - event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) }; - event.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) }; - event.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) }; - + event.summary = if request.title.trim().is_empty() { + None + } else { + Some(request.title.clone()) + }; + event.description = if request.description.trim().is_empty() { + None + } else { + Some(request.description.clone()) + }; + event.location = if request.location.trim().is_empty() { + None + } else { + Some(request.location.clone()) + }; + // Set event status event.status = Some(match request.status.to_lowercase().as_str() { "tentative" => EventStatus::Tentative, "cancelled" => EventStatus::Cancelled, _ => EventStatus::Confirmed, }); - + // Set event class event.class = Some(match request.class.to_lowercase().as_str() { "private" => EventClass::Private, "confidential" => EventClass::Confidential, _ => EventClass::Public, }); - + // Set priority event.priority = request.priority; @@ -171,13 +218,16 @@ pub async fn create_event_series( }; event.rrule = Some(rrule); - // Create the event on the CalDAV server - let event_href = client.create_event(&calendar_path, &event) + let event_href = client + .create_event(&calendar_path, &event) .await .map_err(|e| ApiError::Internal(format!("Failed to create event series: {}", e)))?; - println!("āœ… Event series created successfully with UID: {}, href: {}", uid, event_href); + println!( + "āœ… Event series created successfully with UID: {}, href: {}", + uid, event_href + ); Ok(Json(CreateEventSeriesResponse { success: true, @@ -194,9 +244,11 @@ pub async fn update_event_series( headers: HeaderMap, Json(request): Json, ) -> Result, ApiError> { - println!("šŸ”„ Update event series request received: series_uid='{}', update_scope='{}'", - request.series_uid, request.update_scope); - + println!( + "šŸ”„ Update event series request received: series_uid='{}', update_scope='{}'", + request.series_uid, request.update_scope + ); + // Extract and verify token let token = extract_bearer_token(&headers)?; let password = extract_password_header(&headers)?; @@ -205,19 +257,26 @@ pub async fn update_event_series( if request.series_uid.trim().is_empty() { return Err(ApiError::BadRequest("Series UID is required".to_string())); } - + if request.title.trim().is_empty() { return Err(ApiError::BadRequest("Event title is required".to_string())); } - + if request.title.len() > 200 { - return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string())); + return Err(ApiError::BadRequest( + "Event title too long (max 200 characters)".to_string(), + )); } // Validate update scope match request.update_scope.as_str() { - "this_only" | "this_and_future" | "all_in_series" => {}, - _ => return Err(ApiError::BadRequest("Invalid update_scope. Must be: this_only, this_and_future, or all_in_series".to_string())), + "this_only" | "this_and_future" | "all_in_series" => {} + _ => { + return Err(ApiError::BadRequest( + "Invalid update_scope. Must be: this_only, this_and_future, or all_in_series" + .to_string(), + )) + } } // Validate recurrence type - handle both simple strings and RRULE strings @@ -232,24 +291,33 @@ pub async fn update_event_series( } else if request.recurrence.contains("FREQ=YEARLY") { "yearly" } else { - return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string())); + return Err(ApiError::BadRequest( + "Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(), + )); } } else { // Handle simple strings let lower = request.recurrence.to_lowercase(); match lower.as_str() { "daily" => "daily", - "weekly" => "weekly", + "weekly" => "weekly", "monthly" => "monthly", "yearly" => "yearly", - _ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())), + _ => { + return Err(ApiError::BadRequest( + "Invalid recurrence type. Must be daily, weekly, monthly, or yearly" + .to_string(), + )) + } } }; // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); - + // Use the parsed frequency for further processing (avoiding unused variable warning) let _freq_for_processing = recurrence_freq; @@ -257,13 +325,16 @@ pub async fn update_event_series( let calendar_paths = if let Some(ref path) = request.calendar_path { vec![path.clone()] } else { - client.discover_calendars() + client + .discover_calendars() .await .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))? }; if calendar_paths.is_empty() { - return Err(ApiError::BadRequest("No calendars available for event update".to_string())); + return Err(ApiError::BadRequest( + "No calendars available for event update".to_string(), + )); } // Find the series event across all specified calendars @@ -278,60 +349,79 @@ pub async fn update_event_series( } } - let mut existing_event = existing_event - .ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", request.series_uid)))?; + let mut existing_event = existing_event.ok_or_else(|| { + ApiError::NotFound(format!( + "Event series with UID '{}' not found", + request.series_uid + )) + })?; println!("šŸ“… Found series event in calendar: {}", calendar_path); - println!("šŸ“… Event details: UID={}, summary={:?}, dtstart={}", - existing_event.uid, existing_event.summary, existing_event.dtstart); + println!( + "šŸ“… Event details: UID={}, summary={:?}, dtstart={}", + existing_event.uid, existing_event.summary, existing_event.dtstart + ); // Parse datetime components for the update let original_start_date = existing_event.dtstart.date_naive(); - + // For "this_and_future" and "this_only" updates, use the occurrence date for the modified event // For "all_in_series" updates, preserve the original series start date - let start_date = if (request.update_scope == "this_and_future" || request.update_scope == "this_only") && request.occurrence_date.is_some() { + let start_date = if (request.update_scope == "this_and_future" + || request.update_scope == "this_only") + && request.occurrence_date.is_some() + { let occurrence_date_str = request.occurrence_date.as_ref().unwrap(); - chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d") - .map_err(|_| ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string()))? + chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d").map_err(|_| { + ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string()) + })? } else { original_start_date }; let (start_datetime, end_datetime) = if request.all_day { - let start_dt = start_date.and_hms_opt(0, 0, 0) + let start_dt = start_date + .and_hms_opt(0, 0, 0) .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; - - // For all-day events, also preserve the original date pattern + + // For all-day events, also preserve the original date pattern let end_date = if !request.end_date.is_empty() { // Calculate the duration from the original event - let original_duration_days = existing_event.dtend + let original_duration_days = existing_event + .dtend .map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days()) .unwrap_or(0); start_date + chrono::Duration::days(original_duration_days) } else { start_date }; - - let end_dt = end_date.and_hms_opt(23, 59, 59) + + let end_dt = end_date + .and_hms_opt(23, 59, 59) .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; - (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) + ( + chrono::Utc.from_utc_datetime(&start_dt), + chrono::Utc.from_utc_datetime(&end_dt), + ) } else { let start_time = if !request.start_time.is_empty() { - chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M") - .map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))? + chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| { + ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()) + })? } else { existing_event.dtstart.time() }; let end_time = if !request.end_time.is_empty() { - chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M") - .map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))? + chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| { + ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()) + })? } else { - existing_event.dtend.map(|dt| dt.time()).unwrap_or_else(|| { - existing_event.dtstart.time() + chrono::Duration::hours(1) - }) + existing_event + .dtend + .map(|dt| dt.time()) + .unwrap_or_else(|| existing_event.dtstart.time() + chrono::Duration::hours(1)) }; let start_dt = start_date.and_time(start_time); @@ -340,13 +430,17 @@ pub async fn update_event_series( start_date.and_time(end_time) } else { // Calculate end time based on original duration - let original_duration = existing_event.dtend + let original_duration = existing_event + .dtend .map(|end| end - existing_event.dtstart) .unwrap_or_else(|| chrono::Duration::hours(1)); (chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() }; - (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) + ( + chrono::Utc.from_utc_datetime(&start_dt), + chrono::Utc.from_utc_datetime(&end_dt), + ) }; // Handle different update scopes @@ -354,39 +448,73 @@ pub async fn update_event_series( "all_in_series" => { // Update the entire series - modify the master event update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)? - }, + } "this_and_future" => { // Split the series: keep past occurrences, create new series from occurrence date - update_this_and_future(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path).await? - }, + update_this_and_future( + &mut existing_event, + &request, + start_datetime, + end_datetime, + &client, + &calendar_path, + ) + .await? + } "this_only" => { // Create exception for single occurrence, keep original series - let event_href = existing_event.href.as_ref() - .ok_or_else(|| ApiError::Internal("Event missing href for single occurrence update".to_string()))? + let event_href = existing_event + .href + .as_ref() + .ok_or_else(|| { + ApiError::Internal( + "Event missing href for single occurrence update".to_string(), + ) + })? .clone(); - update_single_occurrence(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path, &event_href).await? - }, + update_single_occurrence( + &mut existing_event, + &request, + start_datetime, + end_datetime, + &client, + &calendar_path, + &event_href, + ) + .await? + } _ => unreachable!(), // Already validated above }; // Update the event on the CalDAV server using the original event's href println!("šŸ“¤ Updating event on CalDAV server..."); - let event_href = existing_event.href.as_ref() + let event_href = existing_event + .href + .as_ref() .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?; println!("šŸ“¤ Using event href: {}", event_href); println!("šŸ“¤ Calendar path: {}", calendar_path); - - match client.update_event(&calendar_path, &updated_event, event_href).await { + + match client + .update_event(&calendar_path, &updated_event, event_href) + .await + { Ok(_) => { println!("āœ… CalDAV update completed successfully"); } Err(e) => { println!("āŒ CalDAV update failed: {}", e); - return Err(ApiError::Internal(format!("Failed to update event series: {}", e))); + return Err(ApiError::Internal(format!( + "Failed to update event series: {}", + e + ))); } } - println!("āœ… Event series updated successfully with UID: {}", request.series_uid); + println!( + "āœ… Event series updated successfully with UID: {}", + request.series_uid + ); Ok(Json(UpdateEventSeriesResponse { success: true, @@ -402,9 +530,11 @@ pub async fn delete_event_series( headers: HeaderMap, Json(request): Json, ) -> Result, ApiError> { - println!("šŸ—‘ļø Delete event series request received: series_uid='{}', delete_scope='{}'", - request.series_uid, request.delete_scope); - + println!( + "šŸ—‘ļø Delete event series request received: series_uid='{}', delete_scope='{}'", + request.series_uid, request.delete_scope + ); + // Extract and verify token let token = extract_bearer_token(&headers)?; let password = extract_password_header(&headers)?; @@ -413,9 +543,11 @@ pub async fn delete_event_series( if request.series_uid.trim().is_empty() { return Err(ApiError::BadRequest("Series UID is required".to_string())); } - + if request.calendar_path.trim().is_empty() { - return Err(ApiError::BadRequest("Calendar path is required".to_string())); + return Err(ApiError::BadRequest( + "Calendar path is required".to_string(), + )); } if request.event_href.trim().is_empty() { @@ -424,12 +556,19 @@ pub async fn delete_event_series( // Validate delete scope match request.delete_scope.as_str() { - "this_only" | "this_and_future" | "all_in_series" => {}, - _ => return Err(ApiError::BadRequest("Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series".to_string())), + "this_only" | "this_and_future" | "all_in_series" => {} + _ => { + return Err(ApiError::BadRequest( + "Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series" + .to_string(), + )) + } } // Create CalDAV config from token and password - let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let config = state + .auth_service + .caldav_config_from_token(&token, &password)?; let client = CalDAVClient::new(config); // Handle different deletion scopes @@ -437,19 +576,22 @@ pub async fn delete_event_series( "all_in_series" => { // Delete the entire series - simply delete the event delete_entire_series(&client, &request).await? - }, + } "this_and_future" => { // Modify RRULE to end before this occurrence delete_this_and_future(&client, &request).await? - }, + } "this_only" => { // Add EXDATE for single occurrence delete_single_occurrence(&client, &request).await? - }, + } _ => unreachable!(), // Already validated above }; - println!("āœ… Event series deletion completed with {} occurrences affected", occurrences_affected); + println!( + "āœ… Event series deletion completed with {} occurrences affected", + occurrences_affected + ); Ok(Json(DeleteEventSeriesResponse { success: true, @@ -460,29 +602,36 @@ pub async fn delete_event_series( // Helper functions - -fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) -> Result { +fn build_series_rrule_with_freq( + request: &CreateEventSeriesRequest, + freq: &str, +) -> Result { let mut rrule_parts = Vec::new(); - + // Add frequency match freq { "daily" => rrule_parts.push("FREQ=DAILY".to_string()), "weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()), "monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()), "yearly" => rrule_parts.push("FREQ=YEARLY".to_string()), - _ => return Err(ApiError::BadRequest("Invalid recurrence frequency".to_string())), + _ => { + return Err(ApiError::BadRequest( + "Invalid recurrence frequency".to_string(), + )) + } } - + // Add interval if specified and greater than 1 if let Some(interval) = request.recurrence_interval { if interval > 1 { rrule_parts.push(format!("INTERVAL={}", interval)); } } - + // Handle weekly recurrence with specific days (BYDAY) if freq == "weekly" && request.recurrence_days.len() == 7 { - let selected_days: Vec<&str> = request.recurrence_days + let selected_days: Vec<&str> = request + .recurrence_days .iter() .enumerate() .filter_map(|(i, &selected)| { @@ -507,25 +656,30 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) rrule_parts.push(format!("BYDAY={}", selected_days.join(","))); } } - + // Add end date if specified (UNTIL takes precedence over COUNT) if let Some(end_date) = &request.recurrence_end_date { // Parse the end date and convert to RRULE format (YYYYMMDDTHHMMSSZ) match chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") { Ok(date) => { - let end_datetime = date.and_hms_opt(23, 59, 59) + let end_datetime = date + .and_hms_opt(23, 59, 59) .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; let utc_end = chrono::Utc.from_utc_datetime(&end_datetime); rrule_parts.push(format!("UNTIL={}", utc_end.format("%Y%m%dT%H%M%SZ"))); - }, - Err(_) => return Err(ApiError::BadRequest("Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string())), + } + Err(_) => { + return Err(ApiError::BadRequest( + "Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string(), + )) + } } } else if let Some(count) = request.recurrence_count { if count > 0 { rrule_parts.push(format!("COUNT={}", count)); } } - + Ok(rrule_parts.join(";")) } @@ -538,38 +692,38 @@ fn update_entire_series( ) -> Result<(VEvent, u32), ApiError> { // Clone the existing event to preserve all metadata let mut updated_event = existing_event.clone(); - + // Update only the modified properties from the request updated_event.dtstart = start_datetime; updated_event.dtend = Some(end_datetime); - updated_event.summary = if request.title.trim().is_empty() { - existing_event.summary.clone() // Keep original if empty - } else { - Some(request.title.clone()) + updated_event.summary = if request.title.trim().is_empty() { + existing_event.summary.clone() // Keep original if empty + } else { + Some(request.title.clone()) }; - updated_event.description = if request.description.trim().is_empty() { - existing_event.description.clone() // Keep original if empty - } else { - Some(request.description.clone()) + updated_event.description = if request.description.trim().is_empty() { + existing_event.description.clone() // Keep original if empty + } else { + Some(request.description.clone()) }; - updated_event.location = if request.location.trim().is_empty() { - existing_event.location.clone() // Keep original if empty - } else { - Some(request.location.clone()) + updated_event.location = if request.location.trim().is_empty() { + existing_event.location.clone() // Keep original if empty + } else { + Some(request.location.clone()) }; - + updated_event.status = Some(match request.status.to_lowercase().as_str() { "tentative" => EventStatus::Tentative, "cancelled" => EventStatus::Cancelled, _ => EventStatus::Confirmed, }); - + updated_event.class = Some(match request.class.to_lowercase().as_str() { "private" => EventClass::Private, "confidential" => EventClass::Confidential, _ => EventClass::Public, }); - + updated_event.priority = request.priority; // Update timestamps @@ -589,14 +743,14 @@ fn update_entire_series( } /// Update this occurrence and all future occurrences (RFC 5545 compliant series splitting) -/// -/// This function implements the "this and future events" modification pattern for recurring +/// +/// This function implements the "this and future events" modification pattern for recurring /// event series by splitting the original series into two parts: /// /// ## Operation Overview: /// 1. **Terminate Original Series**: Adds an UNTIL clause to the original recurring event /// to stop generating occurrences before the target occurrence date. -/// 2. **Create New Series**: Creates a completely new recurring series starting from the +/// 2. **Create New Series**: Creates a completely new recurring series starting from the /// target occurrence date with the updated properties (new times, title, etc.). /// /// ## Example Scenario: @@ -616,7 +770,7 @@ fn update_entire_series( /// This function performs two sequential CalDAV operations: /// 1. CREATE new series on the CalDAV server /// 2. UPDATE original series (handled by caller) with UNTIL clause -/// +/// /// Operations are serialized using a global mutex to prevent race conditions. /// /// ## Parameters: @@ -641,73 +795,104 @@ async fn update_this_and_future( client: &CalDAVClient, calendar_path: &str, ) -> Result<(VEvent, u32), ApiError> { - // Clone the existing event to create the new series before modifying the RRULE of the // original, because we'd like to preserve the original UNTIL logic let mut new_series = existing_event.clone(); - let occurrence_date = request.occurrence_date.as_ref() - .ok_or_else(|| ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string()))?; - + let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| { + ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string()) + })?; + // Parse occurrence date let occurrence_date_parsed = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") .map_err(|_| ApiError::BadRequest("Invalid occurrence date format".to_string()))?; - + // Step 1: Add UNTIL to the original series to stop before the occurrence date - let until_datetime = occurrence_date_parsed.and_hms_opt(0, 0, 0) + let until_datetime = occurrence_date_parsed + .and_hms_opt(0, 0, 0) .ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?; let utc_until = chrono::Utc.from_utc_datetime(&until_datetime); - + // Create modified RRULE with UNTIL clause for the original series - let original_rrule = existing_event.rrule.clone().unwrap_or_else(|| "FREQ=WEEKLY".to_string()); - let parts: Vec<&str> = original_rrule.split(';').filter(|part| { - !part.starts_with("UNTIL=") && !part.starts_with("COUNT=") - }).collect(); - - existing_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ"))); - println!("šŸ”„ this_and_future: Updated original series RRULE: {:?}", existing_event.rrule); - + let original_rrule = existing_event + .rrule + .clone() + .unwrap_or_else(|| "FREQ=WEEKLY".to_string()); + let parts: Vec<&str> = original_rrule + .split(';') + .filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT=")) + .collect(); + + existing_event.rrule = Some(format!( + "{};UNTIL={}", + parts.join(";"), + utc_until.format("%Y%m%dT%H%M%SZ") + )); + println!( + "šŸ”„ this_and_future: Updated original series RRULE: {:?}", + existing_event.rrule + ); + // Step 2: Create a new series starting from the occurrence date with updated properties let new_series_uid = format!("series-{}", uuid::Uuid::new_v4()); - + // Update the new series with new properties new_series.uid = new_series_uid.clone(); new_series.dtstart = start_datetime; new_series.dtend = Some(end_datetime); - new_series.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) }; - new_series.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) }; - new_series.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) }; - + new_series.summary = if request.title.trim().is_empty() { + None + } else { + Some(request.title.clone()) + }; + new_series.description = if request.description.trim().is_empty() { + None + } else { + Some(request.description.clone()) + }; + new_series.location = if request.location.trim().is_empty() { + None + } else { + Some(request.location.clone()) + }; + new_series.status = Some(match request.status.to_lowercase().as_str() { "tentative" => EventStatus::Tentative, "cancelled" => EventStatus::Cancelled, _ => EventStatus::Confirmed, }); - + new_series.class = Some(match request.class.to_lowercase().as_str() { "private" => EventClass::Private, "confidential" => EventClass::Confidential, _ => EventClass::Public, }); - + new_series.priority = request.priority; - + // Update timestamps let now = chrono::Utc::now(); new_series.dtstamp = now; new_series.created = Some(now); new_series.last_modified = Some(now); new_series.href = None; // Will be set when created - - println!("šŸ”„ this_and_future: Creating new series with UID: {}", new_series_uid); - println!("šŸ”„ this_and_future: New series RRULE: {:?}", new_series.rrule); - + + println!( + "šŸ”„ this_and_future: Creating new series with UID: {}", + new_series_uid + ); + println!( + "šŸ”„ this_and_future: New series RRULE: {:?}", + new_series.rrule + ); + // Create the new series on CalDAV server - client.create_event(calendar_path, &new_series) + client + .create_event(calendar_path, &new_series) .await .map_err(|e| ApiError::Internal(format!("Failed to create new series: {}", e)))?; - + println!("āœ… this_and_future: Created new series successfully"); - + // Return the original event (with UNTIL added) - it will be updated by the main handler Ok((existing_event.clone(), 2)) // 2 operations: updated original + created new series } @@ -725,91 +910,109 @@ async fn update_single_occurrence( // For RFC 5545 compliant single occurrence updates, we need to: // 1. Add EXDATE to the original series to exclude this occurrence // 2. Create a new exception event with RECURRENCE-ID pointing to the original occurrence - + // First, add EXDATE to the original series - let occurrence_date = request.occurrence_date.as_ref() - .ok_or_else(|| ApiError::BadRequest("occurrence_date is required for single occurrence updates".to_string()))?; - + let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| { + ApiError::BadRequest( + "occurrence_date is required for single occurrence updates".to_string(), + ) + })?; + // Parse the occurrence date - let exception_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") - .map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?; - + let exception_date = + chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| { + ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()) + })?; + // Create the EXDATE datetime using the original event's time let original_time = existing_event.dtstart.time(); let exception_datetime = exception_date.and_time(original_time); let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime); - + // Add the exception date to the original series - println!("šŸ“ BEFORE adding EXDATE: existing_event.exdate = {:?}", existing_event.exdate); + println!( + "šŸ“ BEFORE adding EXDATE: existing_event.exdate = {:?}", + existing_event.exdate + ); existing_event.exdate.push(exception_utc); - println!("šŸ“ AFTER adding EXDATE: existing_event.exdate = {:?}", existing_event.exdate); - println!("🚫 Added EXDATE for single occurrence modification: {}", exception_utc.format("%Y-%m-%d %H:%M:%S")); - + println!( + "šŸ“ AFTER adding EXDATE: existing_event.exdate = {:?}", + existing_event.exdate + ); + println!( + "🚫 Added EXDATE for single occurrence modification: {}", + exception_utc.format("%Y-%m-%d %H:%M:%S") + ); + // Create exception event by cloning the existing event to preserve all metadata let mut exception_event = existing_event.clone(); - + // Give the exception event a unique UID (required for CalDAV) exception_event.uid = format!("exception-{}", uuid::Uuid::new_v4()); - + // Update the modified properties from the request exception_event.dtstart = start_datetime; exception_event.dtend = Some(end_datetime); - exception_event.summary = if request.title.trim().is_empty() { - existing_event.summary.clone() // Keep original if empty - } else { - Some(request.title.clone()) + exception_event.summary = if request.title.trim().is_empty() { + existing_event.summary.clone() // Keep original if empty + } else { + Some(request.title.clone()) }; - exception_event.description = if request.description.trim().is_empty() { - existing_event.description.clone() // Keep original if empty - } else { - Some(request.description.clone()) + exception_event.description = if request.description.trim().is_empty() { + existing_event.description.clone() // Keep original if empty + } else { + Some(request.description.clone()) }; - exception_event.location = if request.location.trim().is_empty() { - existing_event.location.clone() // Keep original if empty - } else { - Some(request.location.clone()) + exception_event.location = if request.location.trim().is_empty() { + existing_event.location.clone() // Keep original if empty + } else { + Some(request.location.clone()) }; - + exception_event.status = Some(match request.status.to_lowercase().as_str() { "tentative" => EventStatus::Tentative, "cancelled" => EventStatus::Cancelled, _ => EventStatus::Confirmed, }); - + exception_event.class = Some(match request.class.to_lowercase().as_str() { "private" => EventClass::Private, "confidential" => EventClass::Confidential, _ => EventClass::Public, }); - + exception_event.priority = request.priority; - + // Update timestamps for the exception event let now = chrono::Utc::now(); exception_event.dtstamp = now; exception_event.last_modified = Some(now); // Keep original created timestamp to preserve event history - + // Set RECURRENCE-ID to point to the original occurrence // exception_event.recurrence_id = Some(exception_utc); - + // Remove any recurrence rules from the exception (it's a single event) exception_event.rrule = None; exception_event.rdate.clear(); exception_event.exdate.clear(); - - // Set calendar path for the exception event + + // Set calendar path for the exception event exception_event.calendar_path = Some(calendar_path.to_string()); - - println!("✨ Created exception event with RECURRENCE-ID: {}", exception_utc.format("%Y-%m-%d %H:%M:%S")); - + + println!( + "✨ Created exception event with RECURRENCE-ID: {}", + exception_utc.format("%Y-%m-%d %H:%M:%S") + ); + // Create the exception event as a new event (original series will be updated by main handler) - client.create_event(calendar_path, &exception_event) + client + .create_event(calendar_path, &exception_event) .await .map_err(|e| ApiError::Internal(format!("Failed to create exception event: {}", e)))?; - + println!("āœ… Created exception event successfully"); - + // Return the original series (now with EXDATE) - main handler will update it on CalDAV Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception) } @@ -820,10 +1023,11 @@ async fn delete_entire_series( request: &DeleteEventSeriesRequest, ) -> Result { // Simply delete the entire event from the CalDAV server - client.delete_event(&request.calendar_path, &request.event_href) + client + .delete_event(&request.calendar_path, &request.event_href) .await .map_err(|e| ApiError::Internal(format!("Failed to delete event series: {}", e)))?; - + println!("šŸ—‘ļø Entire series deleted: {}", request.series_uid); Ok(1) // 1 series deleted (affects all occurrences) } @@ -835,44 +1039,63 @@ async fn delete_this_and_future( ) -> Result { // Fetch the existing event to modify its RRULE let event_uid = request.series_uid.clone(); - let existing_event = client.fetch_event_by_uid(&request.calendar_path, &event_uid) + let existing_event = client + .fetch_event_by_uid(&request.calendar_path, &event_uid) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))? - .ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)))?; - + .ok_or_else(|| { + ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)) + })?; + // If no occurrence_date is provided, delete the entire series let Some(occurrence_date) = &request.occurrence_date else { return delete_entire_series(client, request).await; }; - + // Parse occurrence date to set as UNTIL for the RRULE - let until_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") - .map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?; - + let until_date = + chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| { + ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()) + })?; + // Set UNTIL to the day before the occurrence to exclude it and all future occurrences - let until_datetime = until_date.pred_opt() - .ok_or_else(|| ApiError::BadRequest("Cannot delete from the first possible date".to_string()))? + let until_datetime = until_date + .pred_opt() + .ok_or_else(|| { + ApiError::BadRequest("Cannot delete from the first possible date".to_string()) + })? .and_hms_opt(23, 59, 59) .ok_or_else(|| ApiError::BadRequest("Invalid date calculation".to_string()))?; let utc_until = chrono::Utc.from_utc_datetime(&until_datetime); - + // Modify the existing event's RRULE let mut updated_event = existing_event; if let Some(rrule) = &updated_event.rrule { // Remove existing UNTIL or COUNT if present and add new UNTIL - let parts: Vec<&str> = rrule.split(';').filter(|part| { - !part.starts_with("UNTIL=") && !part.starts_with("COUNT=") - }).collect(); - - updated_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ"))); + let parts: Vec<&str> = rrule + .split(';') + .filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT=")) + .collect(); + + updated_event.rrule = Some(format!( + "{};UNTIL={}", + parts.join(";"), + utc_until.format("%Y%m%dT%H%M%SZ") + )); } - + // Update the event on the CalDAV server - client.update_event(&request.calendar_path, &updated_event, &request.event_href) + client + .update_event(&request.calendar_path, &updated_event, &request.event_href) .await - .map_err(|e| ApiError::Internal(format!("Failed to update event series for deletion: {}", e)))?; - - println!("šŸ—‘ļø Series modified with UNTIL for this_and_future deletion: {}", utc_until.format("%Y-%m-%d")); + .map_err(|e| { + ApiError::Internal(format!("Failed to update event series for deletion: {}", e)) + })?; + + println!( + "šŸ—‘ļø Series modified with UNTIL for this_and_future deletion: {}", + utc_until.format("%Y-%m-%d") + ); Ok(1) // 1 series modified } @@ -883,35 +1106,51 @@ async fn delete_single_occurrence( ) -> Result { // Fetch the existing event to add EXDATE let event_uid = request.series_uid.clone(); - let existing_event = client.fetch_event_by_uid(&request.calendar_path, &event_uid) + let existing_event = client + .fetch_event_by_uid(&request.calendar_path, &event_uid) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))? - .ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)))?; - + .ok_or_else(|| { + ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)) + })?; + // If no occurrence_date is provided, cannot delete single occurrence let Some(occurrence_date) = &request.occurrence_date else { - return Err(ApiError::BadRequest("occurrence_date is required for single occurrence deletion".to_string())); + return Err(ApiError::BadRequest( + "occurrence_date is required for single occurrence deletion".to_string(), + )); }; - + // Parse occurrence date - let exception_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") - .map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?; - + let exception_date = + chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| { + ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()) + })?; + // Create the EXDATE datetime (use the same time as the original event) let original_time = existing_event.dtstart.time(); let exception_datetime = exception_date.and_time(original_time); let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime); - + // Add the exception date to the event's EXDATE list let mut updated_event = existing_event; updated_event.exdate.push(exception_utc); - - println!("šŸ—‘ļø Added EXDATE for single occurrence deletion: {}", exception_utc.format("%Y%m%dT%H%M%SZ")); - + + println!( + "šŸ—‘ļø Added EXDATE for single occurrence deletion: {}", + exception_utc.format("%Y%m%dT%H%M%SZ") + ); + // Update the event on the CalDAV server - client.update_event(&request.calendar_path, &updated_event, &request.event_href) + client + .update_event(&request.calendar_path, &updated_event, &request.event_href) .await - .map_err(|e| ApiError::Internal(format!("Failed to update event series for single deletion: {}", e)))?; - + .map_err(|e| { + ApiError::Internal(format!( + "Failed to update event series for single deletion: {}", + e + )) + })?; + Ok(1) // 1 occurrence excluded } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index b69a5f5..a96e351 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -3,14 +3,14 @@ use axum::{ routing::{get, post}, Router, }; -use tower_http::cors::{CorsLayer, Any}; use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; pub mod auth; -pub mod models; -pub mod handlers; pub mod calendar; pub mod config; +pub mod handlers; +pub mod models; use auth::AuthService; @@ -22,13 +22,13 @@ pub struct AppState { pub async fn run_server() -> Result<(), Box> { // Initialize logging println!("šŸš€ Starting Calendar Backend Server"); - + // Create auth service let jwt_secret = std::env::var("JWT_SECRET") .unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()); - + let auth_service = AuthService::new(jwt_secret); - + let app_state = AppState { auth_service }; // Build our application with routes @@ -46,9 +46,18 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/calendar/events/delete", post(handlers::delete_event)) .route("/api/calendar/events/:uid", get(handlers::refresh_event)) // Event series-specific endpoints - .route("/api/calendar/events/series/create", post(handlers::create_event_series)) - .route("/api/calendar/events/series/update", post(handlers::update_event_series)) - .route("/api/calendar/events/series/delete", post(handlers::delete_event_series)) + .route( + "/api/calendar/events/series/create", + post(handlers::create_event_series), + ) + .route( + "/api/calendar/events/series/update", + post(handlers::update_event_series), + ) + .route( + "/api/calendar/events/series/delete", + post(handlers::delete_event_series), + ) .layer( CorsLayer::new() .allow_origin(Any) @@ -60,7 +69,7 @@ pub async fn run_server() -> Result<(), Box> { // Start server let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; println!("šŸ“” Server listening on http://0.0.0.0:3000"); - + axum::serve(listener, app).await?; Ok(()) @@ -76,4 +85,4 @@ async fn health_check() -> Json { "service": "calendar-backend", "version": "0.1.0" })) -} \ No newline at end of file +} diff --git a/backend/src/main.rs b/backend/src/main.rs index a3375fb..3fc1e66 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -4,4 +4,4 @@ use calendar_backend::*; #[tokio::main] async fn main() -> Result<(), Box> { run_server().await -} \ No newline at end of file +} diff --git a/backend/src/models.rs b/backend/src/models.rs index 853a352..4ce48fa 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -76,21 +76,21 @@ pub struct DeleteEventResponse { pub struct CreateEventRequest { pub title: String, pub description: String, - pub start_date: String, // YYYY-MM-DD format - pub start_time: String, // HH:MM format - pub end_date: String, // YYYY-MM-DD format - pub end_time: String, // HH:MM format + pub start_date: String, // YYYY-MM-DD format + pub start_time: String, // HH:MM format + pub end_date: String, // YYYY-MM-DD format + pub end_time: String, // HH:MM format pub location: String, pub all_day: bool, - pub status: String, // confirmed, tentative, cancelled - pub class: String, // public, private, confidential - pub priority: Option, // 0-9 priority level - pub organizer: String, // organizer email - pub attendees: String, // comma-separated attendee emails - pub categories: String, // comma-separated categories - pub reminder: String, // reminder type - pub recurrence: String, // recurrence type - pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence + pub status: String, // confirmed, tentative, cancelled + pub class: String, // public, private, confidential + pub priority: Option, // 0-9 priority level + pub organizer: String, // organizer email + pub attendees: String, // comma-separated attendee emails + pub categories: String, // comma-separated categories + pub reminder: String, // reminder type + pub recurrence: String, // recurrence type + pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub calendar_path: Option, // Optional - use first calendar if not specified } @@ -103,24 +103,24 @@ pub struct CreateEventResponse { #[derive(Debug, Deserialize)] pub struct UpdateEventRequest { - pub uid: String, // Event UID to identify which event to update + pub uid: String, // Event UID to identify which event to update pub title: String, pub description: String, - pub start_date: String, // YYYY-MM-DD format - pub start_time: String, // HH:MM format - pub end_date: String, // YYYY-MM-DD format - pub end_time: String, // HH:MM format + pub start_date: String, // YYYY-MM-DD format + pub start_time: String, // HH:MM format + pub end_date: String, // YYYY-MM-DD format + pub end_time: String, // HH:MM format pub location: String, pub all_day: bool, - pub status: String, // confirmed, tentative, cancelled - pub class: String, // public, private, confidential - pub priority: Option, // 0-9 priority level - pub organizer: String, // organizer email - pub attendees: String, // comma-separated attendee emails - pub categories: String, // comma-separated categories - pub reminder: String, // reminder type - pub recurrence: String, // recurrence type - pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence + pub status: String, // confirmed, tentative, cancelled + pub class: String, // public, private, confidential + pub priority: Option, // 0-9 priority level + pub organizer: String, // organizer email + pub attendees: String, // comma-separated attendee emails + pub categories: String, // comma-separated categories + pub reminder: String, // reminder type + pub recurrence: String, // recurrence type + pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub calendar_path: Option, // Optional - search all calendars if not specified pub update_action: Option, // "update_series" for recurring events #[serde(skip_serializing_if = "Option::is_none")] @@ -139,22 +139,22 @@ pub struct UpdateEventResponse { pub struct CreateEventSeriesRequest { pub title: String, pub description: String, - pub start_date: String, // YYYY-MM-DD format - pub start_time: String, // HH:MM format - pub end_date: String, // YYYY-MM-DD format - pub end_time: String, // HH:MM format + pub start_date: String, // YYYY-MM-DD format + pub start_time: String, // HH:MM format + pub end_date: String, // YYYY-MM-DD format + pub end_time: String, // HH:MM format pub location: String, pub all_day: bool, - pub status: String, // confirmed, tentative, cancelled - pub class: String, // public, private, confidential - pub priority: Option, // 0-9 priority level - pub organizer: String, // organizer email - pub attendees: String, // comma-separated attendee emails - pub categories: String, // comma-separated categories - pub reminder: String, // reminder type - + pub status: String, // confirmed, tentative, cancelled + pub class: String, // public, private, confidential + pub priority: Option, // 0-9 priority level + pub organizer: String, // organizer email + pub attendees: String, // comma-separated attendee emails + pub categories: String, // comma-separated categories + pub reminder: String, // reminder type + // Series-specific fields - pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) + pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub recurrence_interval: Option, // Every N days/weeks/months/years pub recurrence_end_date: Option, // When the series ends (YYYY-MM-DD) @@ -173,33 +173,33 @@ pub struct CreateEventSeriesResponse { #[derive(Debug, Deserialize)] pub struct UpdateEventSeriesRequest { - pub series_uid: String, // Series UID to identify which series to update + pub series_uid: String, // Series UID to identify which series to update pub title: String, pub description: String, - pub start_date: String, // YYYY-MM-DD format - pub start_time: String, // HH:MM format - pub end_date: String, // YYYY-MM-DD format - pub end_time: String, // HH:MM format + pub start_date: String, // YYYY-MM-DD format + pub start_time: String, // HH:MM format + pub end_date: String, // YYYY-MM-DD format + pub end_time: String, // HH:MM format pub location: String, pub all_day: bool, - pub status: String, // confirmed, tentative, cancelled - pub class: String, // public, private, confidential - pub priority: Option, // 0-9 priority level - pub organizer: String, // organizer email - pub attendees: String, // comma-separated attendee emails - pub categories: String, // comma-separated categories - pub reminder: String, // reminder type - + pub status: String, // confirmed, tentative, cancelled + pub class: String, // public, private, confidential + pub priority: Option, // 0-9 priority level + pub organizer: String, // organizer email + pub attendees: String, // comma-separated attendee emails + pub categories: String, // comma-separated categories + pub reminder: String, // reminder type + // Series-specific fields - pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) + pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub recurrence_interval: Option, // Every N days/weeks/months/years pub recurrence_end_date: Option, // When the series ends (YYYY-MM-DD) pub recurrence_count: Option, // Number of occurrences pub calendar_path: Option, // Optional - search all calendars if not specified - + // Update scope control - pub update_scope: String, // "this_only", "this_and_future", "all_in_series" + pub update_scope: String, // "this_only", "this_and_future", "all_in_series" pub occurrence_date: Option, // ISO date string for specific occurrence being updated pub changed_fields: Option>, // List of field names that were changed (for optimization) } @@ -214,12 +214,12 @@ pub struct UpdateEventSeriesResponse { #[derive(Debug, Deserialize)] pub struct DeleteEventSeriesRequest { - pub series_uid: String, // Series UID to identify which series to delete + pub series_uid: String, // Series UID to identify which series to delete pub calendar_path: String, pub event_href: String, - + // Delete scope control - pub delete_scope: String, // "this_only", "this_and_future", "all_in_series" + pub delete_scope: String, // "this_only", "this_and_future", "all_in_series" pub occurrence_date: Option, // ISO date string for specific occurrence being deleted } @@ -274,4 +274,4 @@ impl std::fmt::Display for ApiError { } } -impl std::error::Error for ApiError {} \ No newline at end of file +impl std::error::Error for ApiError {} diff --git a/backend/tests/integration_tests.rs b/backend/tests/integration_tests.rs index 44ad75f..1e27f2f 100644 --- a/backend/tests/integration_tests.rs +++ b/backend/tests/integration_tests.rs @@ -1,26 +1,26 @@ -use calendar_backend::AppState; -use calendar_backend::auth::AuthService; -use reqwest::Client; -use serde_json::json; -use std::time::Duration; -use tokio::time::sleep; use axum::{ response::Json, routing::{get, post}, Router, }; -use tower_http::cors::{CorsLayer, Any}; +use calendar_backend::auth::AuthService; +use calendar_backend::AppState; +use reqwest::Client; +use serde_json::json; use std::sync::Arc; +use std::time::Duration; +use tokio::time::sleep; +use tower_http::cors::{Any, CorsLayer}; /// Test utilities for integration testing mod test_utils { use super::*; - + pub struct TestServer { pub base_url: String, pub client: Client, } - + impl TestServer { pub async fn start() -> Self { // Create auth service @@ -33,19 +33,55 @@ mod test_utils { .route("/", get(root)) .route("/api/health", get(health_check)) .route("/api/auth/login", post(calendar_backend::handlers::login)) - .route("/api/auth/verify", get(calendar_backend::handlers::verify_token)) - .route("/api/user/info", get(calendar_backend::handlers::get_user_info)) - .route("/api/calendar/create", post(calendar_backend::handlers::create_calendar)) - .route("/api/calendar/delete", post(calendar_backend::handlers::delete_calendar)) - .route("/api/calendar/events", get(calendar_backend::handlers::get_calendar_events)) - .route("/api/calendar/events/create", post(calendar_backend::handlers::create_event)) - .route("/api/calendar/events/update", post(calendar_backend::handlers::update_event)) - .route("/api/calendar/events/delete", post(calendar_backend::handlers::delete_event)) - .route("/api/calendar/events/:uid", get(calendar_backend::handlers::refresh_event)) + .route( + "/api/auth/verify", + get(calendar_backend::handlers::verify_token), + ) + .route( + "/api/user/info", + get(calendar_backend::handlers::get_user_info), + ) + .route( + "/api/calendar/create", + post(calendar_backend::handlers::create_calendar), + ) + .route( + "/api/calendar/delete", + post(calendar_backend::handlers::delete_calendar), + ) + .route( + "/api/calendar/events", + get(calendar_backend::handlers::get_calendar_events), + ) + .route( + "/api/calendar/events/create", + post(calendar_backend::handlers::create_event), + ) + .route( + "/api/calendar/events/update", + post(calendar_backend::handlers::update_event), + ) + .route( + "/api/calendar/events/delete", + post(calendar_backend::handlers::delete_event), + ) + .route( + "/api/calendar/events/:uid", + get(calendar_backend::handlers::refresh_event), + ) // Event series-specific endpoints - .route("/api/calendar/events/series/create", post(calendar_backend::handlers::create_event_series)) - .route("/api/calendar/events/series/update", post(calendar_backend::handlers::update_event_series)) - .route("/api/calendar/events/series/delete", post(calendar_backend::handlers::delete_event_series)) + .route( + "/api/calendar/events/series/create", + post(calendar_backend::handlers::create_event_series), + ) + .route( + "/api/calendar/events/series/update", + post(calendar_backend::handlers::update_event_series), + ) + .route( + "/api/calendar/events/series/delete", + post(calendar_backend::handlers::delete_event_series), + ) .layer( CorsLayer::new() .allow_origin(Any) @@ -58,39 +94,47 @@ mod test_utils { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let base_url = format!("http://127.0.0.1:{}", addr.port()); - + tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - + // Wait for server to start sleep(Duration::from_millis(100)).await; - + let client = Client::new(); TestServer { base_url, client } } - + pub async fn login(&self) -> String { let login_payload = json!({ "username": "test".to_string(), "password": "test".to_string(), "server_url": "https://example.com".to_string() }); - - let response = self.client + + let response = self + .client .post(&format!("{}/api/auth/login", self.base_url)) .json(&login_payload) .send() .await .expect("Failed to send login request"); - - assert!(response.status().is_success(), "Login failed with status: {}", response.status()); - + + assert!( + response.status().is_success(), + "Login failed with status: {}", + response.status() + ); + let login_response: serde_json::Value = response.json().await.unwrap(); - login_response["token"].as_str().expect("Login response should contain token").to_string() + login_response["token"] + .as_str() + .expect("Login response should contain token") + .to_string() } } - + async fn root() -> &'static str { "Calendar Backend API v0.1.0" } @@ -106,26 +150,27 @@ mod test_utils { #[cfg(test)] mod tests { - use super::*; use super::test_utils::*; + use super::*; /// Test the health endpoint #[tokio::test] async fn test_health_endpoint() { let server = TestServer::start().await; - - let response = server.client + + let response = server + .client .get(&format!("{}/api/health", server.base_url)) .send() .await .unwrap(); - + assert_eq!(response.status(), 200); - + let health_response: serde_json::Value = response.json().await.unwrap(); assert_eq!(health_response["status"], "healthy"); assert_eq!(health_response["service"], "calendar-backend"); - + println!("āœ“ Health endpoint test passed"); } @@ -133,31 +178,42 @@ mod tests { #[tokio::test] async fn test_auth_login() { let server = TestServer::start().await; - + // Use test credentials let username = "test".to_string(); - let password = "test".to_string(); + let password = "test".to_string(); let server_url = "https://example.com".to_string(); - + let login_payload = json!({ "username": username, "password": password, "server_url": server_url }); - - let response = server.client + + let response = server + .client .post(&format!("{}/api/auth/login", server.base_url)) .json(&login_payload) .send() .await .unwrap(); - - assert!(response.status().is_success(), "Login failed with status: {}", response.status()); - + + assert!( + response.status().is_success(), + "Login failed with status: {}", + response.status() + ); + let login_response: serde_json::Value = response.json().await.unwrap(); - assert!(login_response["token"].is_string(), "Login response should contain a token"); - assert!(login_response["username"].is_string(), "Login response should contain username"); - + assert!( + login_response["token"].is_string(), + "Login response should contain a token" + ); + assert!( + login_response["username"].is_string(), + "Login response should contain username" + ); + println!("āœ“ Authentication login test passed"); } @@ -165,52 +221,57 @@ mod tests { #[tokio::test] async fn test_auth_verify() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - - let response = server.client + + let response = server + .client .get(&format!("{}/api/auth/verify", server.base_url)) .header("Authorization", format!("Bearer {}", token)) .send() .await .unwrap(); - + assert_eq!(response.status(), 200); - + let verify_response: serde_json::Value = response.json().await.unwrap(); assert!(verify_response["valid"].as_bool().unwrap_or(false)); - + println!("āœ“ Authentication verify test passed"); } /// Test user info endpoint - #[tokio::test] + #[tokio::test] async fn test_user_info() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - + // Load password from env for CalDAV requests dotenvy::dotenv().ok(); let password = "test".to_string(); - - let response = server.client + + let response = server + .client .get(&format!("{}/api/user/info", server.base_url)) .header("Authorization", format!("Bearer {}", token)) .header("X-CalDAV-Password", password) .send() .await .unwrap(); - + // Note: This might fail if CalDAV server discovery fails, which can happen if response.status().is_success() { let user_info: serde_json::Value = response.json().await.unwrap(); assert!(user_info["username"].is_string()); println!("āœ“ User info test passed"); } else { - println!("⚠ User info test skipped (CalDAV server issues): {}", response.status()); + println!( + "⚠ User info test skipped (CalDAV server issues): {}", + response.status() + ); } } @@ -218,48 +279,59 @@ mod tests { #[tokio::test] async fn test_get_calendar_events() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - - // Load password from env for CalDAV requests + + // Load password from env for CalDAV requests dotenvy::dotenv().ok(); let password = "test".to_string(); - - let response = server.client - .get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url)) + + let response = server + .client + .get(&format!( + "{}/api/calendar/events?year=2024&month=12", + server.base_url + )) .header("Authorization", format!("Bearer {}", token)) .header("X-CalDAV-Password", password) .send() .await .unwrap(); - - assert!(response.status().is_success(), "Get events failed with status: {}", response.status()); - + + assert!( + response.status().is_success(), + "Get events failed with status: {}", + response.status() + ); + let events: serde_json::Value = response.json().await.unwrap(); assert!(events.is_array()); - - println!("āœ“ Get calendar events test passed (found {} events)", events.as_array().unwrap().len()); + + println!( + "āœ“ Get calendar events test passed (found {} events)", + events.as_array().unwrap().len() + ); } /// Test event creation endpoint #[tokio::test] async fn test_create_event() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - + // Load password from env for CalDAV requests - dotenvy::dotenv().ok(); + dotenvy::dotenv().ok(); let password = "test".to_string(); - + let create_payload = json!({ "title": "Integration Test Event", "description": "Created by integration test", "start_date": "2024-12-25", "start_time": "10:00", - "end_date": "2024-12-25", + "end_date": "2024-12-25", "end_time": "11:00", "location": "Test Location", "all_day": false, @@ -273,8 +345,9 @@ mod tests { "recurrence": "none", "recurrence_days": [false, false, false, false, false, false, false] }); - - let response = server.client + + let response = server + .client .post(&format!("{}/api/calendar/events/create", server.base_url)) .header("Authorization", format!("Bearer {}", token)) .header("X-CalDAV-Password", password) @@ -282,10 +355,10 @@ mod tests { .send() .await .unwrap(); - + let status = response.status(); println!("Create event response status: {}", status); - + // Note: This might fail if CalDAV server is not accessible, which is expected in CI if status.is_success() { let create_response: serde_json::Value = response.json().await.unwrap(); @@ -300,47 +373,58 @@ mod tests { #[tokio::test] async fn test_refresh_event() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - + // Load password from env for CalDAV requests dotenvy::dotenv().ok(); let password = "test".to_string(); - + // Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure let test_uid = "test-event-uid"; - - let response = server.client - .get(&format!("{}/api/calendar/events/{}", server.base_url, test_uid)) + + let response = server + .client + .get(&format!( + "{}/api/calendar/events/{}", + server.base_url, test_uid + )) .header("Authorization", format!("Bearer {}", token)) .header("X-CalDAV-Password", password) .send() .await .unwrap(); - + // We expect either 200 (if event exists) or 404 (if not found) - both are valid responses - assert!(response.status() == 200 || response.status() == 404, - "Refresh event failed with unexpected status: {}", response.status()); - + assert!( + response.status() == 200 || response.status() == 404, + "Refresh event failed with unexpected status: {}", + response.status() + ); + println!("āœ“ Refresh event endpoint test passed"); } - + /// Test invalid authentication #[tokio::test] async fn test_invalid_auth() { let server = TestServer::start().await; - - let response = server.client + + let response = server + .client .get(&format!("{}/api/user/info", server.base_url)) .header("Authorization", "Bearer invalid-token") .send() .await .unwrap(); - + // Accept both 400 and 401 as valid responses for invalid tokens - assert!(response.status() == 401 || response.status() == 400, - "Expected 401 or 400 for invalid token, got {}", response.status()); + assert!( + response.status() == 401 || response.status() == 400, + "Expected 401 or 400 for invalid token, got {}", + response.status() + ); println!("āœ“ Invalid authentication test passed"); } @@ -348,13 +432,14 @@ mod tests { #[tokio::test] async fn test_missing_auth() { let server = TestServer::start().await; - - let response = server.client + + let response = server + .client .get(&format!("{}/api/user/info", server.base_url)) .send() .await .unwrap(); - + assert_eq!(response.status(), 401); println!("āœ“ Missing authentication test passed"); } @@ -365,20 +450,20 @@ mod tests { #[tokio::test] async fn test_create_event_series() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - + // Load password from env for CalDAV requests dotenvy::dotenv().ok(); let password = "test".to_string(); - + let create_payload = json!({ "title": "Integration Test Series", "description": "Created by integration test for series", "start_date": "2024-12-25", "start_time": "10:00", - "end_date": "2024-12-25", + "end_date": "2024-12-25", "end_time": "11:00", "location": "Test Series Location", "all_day": false, @@ -395,19 +480,23 @@ mod tests { "recurrence_count": 4, "calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery }); - - let response = server.client - .post(&format!("{}/api/calendar/events/series/create", server.base_url)) + + let response = server + .client + .post(&format!( + "{}/api/calendar/events/series/create", + server.base_url + )) .header("Authorization", format!("Bearer {}", token)) .header("X-CalDAV-Password", password) .json(&create_payload) .send() .await .unwrap(); - + let status = response.status(); println!("Create series response status: {}", status); - + // Note: This might fail if CalDAV server is not accessible, which is expected in CI if status.is_success() { let create_response: serde_json::Value = response.json().await.unwrap(); @@ -420,24 +509,24 @@ mod tests { } /// Test event series update endpoint - #[tokio::test] + #[tokio::test] async fn test_update_event_series() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - + // Load password from env for CalDAV requests dotenvy::dotenv().ok(); let password = "test".to_string(); - + let update_payload = json!({ "series_uid": "test-series-uid", "title": "Updated Series Title", "description": "Updated by integration test", "start_date": "2024-12-26", "start_time": "14:00", - "end_date": "2024-12-26", + "end_date": "2024-12-26", "end_time": "15:00", "location": "Updated Location", "all_day": false, @@ -455,27 +544,36 @@ mod tests { "update_scope": "all_in_series", "calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery }); - - let response = server.client - .post(&format!("{}/api/calendar/events/series/update", server.base_url)) + + let response = server + .client + .post(&format!( + "{}/api/calendar/events/series/update", + server.base_url + )) .header("Authorization", format!("Bearer {}", token)) .header("X-CalDAV-Password", password) .json(&update_payload) .send() .await .unwrap(); - + let status = response.status(); println!("Update series response status: {}", status); - + // Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI if status.is_success() { let update_response: serde_json::Value = response.json().await.unwrap(); assert!(update_response["success"].as_bool().unwrap_or(false)); - assert_eq!(update_response["series_uid"].as_str().unwrap(), "test-series-uid"); + assert_eq!( + update_response["series_uid"].as_str().unwrap(), + "test-series-uid" + ); println!("āœ“ Update event series test passed"); } else if status == 404 { - println!("⚠ Update event series test skipped (event not found - expected for test data)"); + println!( + "⚠ Update event series test skipped (event not found - expected for test data)" + ); } else { println!("⚠ Update event series test skipped (CalDAV server not accessible)"); } @@ -485,40 +583,46 @@ mod tests { #[tokio::test] async fn test_delete_event_series() { let server = TestServer::start().await; - - // First login to get a token + + // First login to get a token let token = server.login().await; - + // Load password from env for CalDAV requests dotenvy::dotenv().ok(); let password = "test".to_string(); - + let delete_payload = json!({ "series_uid": "test-series-to-delete", "calendar_path": "/calendars/test/default/", "event_href": "test-series.ics", "delete_scope": "all_in_series" }); - - let response = server.client - .post(&format!("{}/api/calendar/events/series/delete", server.base_url)) + + let response = server + .client + .post(&format!( + "{}/api/calendar/events/series/delete", + server.base_url + )) .header("Authorization", format!("Bearer {}", token)) .header("X-CalDAV-Password", password) .json(&delete_payload) .send() .await .unwrap(); - + let status = response.status(); println!("Delete series response status: {}", status); - + // Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI if status.is_success() { let delete_response: serde_json::Value = response.json().await.unwrap(); assert!(delete_response["success"].as_bool().unwrap_or(false)); println!("āœ“ Delete event series test passed"); } else if status == 404 { - println!("⚠ Delete event series test skipped (event not found - expected for test data)"); + println!( + "⚠ Delete event series test skipped (event not found - expected for test data)" + ); } else { println!("⚠ Delete event series test skipped (CalDAV server not accessible)"); } @@ -528,17 +632,17 @@ mod tests { #[tokio::test] async fn test_invalid_update_scope() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - + let invalid_payload = json!({ "series_uid": "test-series-uid", "title": "Test Title", "description": "Test", "start_date": "2024-12-25", "start_time": "10:00", - "end_date": "2024-12-25", + "end_date": "2024-12-25", "end_time": "11:00", "location": "Test", "all_day": false, @@ -552,16 +656,24 @@ mod tests { "recurrence_days": [false, false, false, false, false, false, false], "update_scope": "invalid_scope" // This should cause a 400 error }); - - let response = server.client - .post(&format!("{}/api/calendar/events/series/update", server.base_url)) + + let response = server + .client + .post(&format!( + "{}/api/calendar/events/series/update", + server.base_url + )) .header("Authorization", format!("Bearer {}", token)) .json(&invalid_payload) .send() .await .unwrap(); - - assert_eq!(response.status(), 400, "Expected 400 for invalid update scope"); + + assert_eq!( + response.status(), + 400, + "Expected 400 for invalid update scope" + ); println!("āœ“ Invalid update scope test passed"); } @@ -569,16 +681,16 @@ mod tests { #[tokio::test] async fn test_non_recurring_series_rejection() { let server = TestServer::start().await; - + // First login to get a token let token = server.login().await; - + let non_recurring_payload = json!({ "title": "Non-recurring Event", "description": "This should be rejected", "start_date": "2024-12-25", "start_time": "10:00", - "end_date": "2024-12-25", + "end_date": "2024-12-25", "end_time": "11:00", "location": "Test", "all_day": false, @@ -591,16 +703,24 @@ mod tests { "recurrence": "none", // This should cause rejection "recurrence_days": [false, false, false, false, false, false, false] }); - - let response = server.client - .post(&format!("{}/api/calendar/events/series/create", server.base_url)) + + let response = server + .client + .post(&format!( + "{}/api/calendar/events/series/create", + server.base_url + )) .header("Authorization", format!("Bearer {}", token)) .json(&non_recurring_payload) .send() .await .unwrap(); - - assert_eq!(response.status(), 400, "Expected 400 for non-recurring event in series endpoint"); + + assert_eq!( + response.status(), + 400, + "Expected 400 for non-recurring event in series endpoint" + ); println!("āœ“ Non-recurring series rejection test passed"); } -} \ No newline at end of file +} diff --git a/calendar-models/src/common.rs b/calendar-models/src/common.rs index c6d5940..81643ce 100644 --- a/calendar-models/src/common.rs +++ b/calendar-models/src/common.rs @@ -1,6 +1,6 @@ //! Common types and enums used across calendar components -use chrono::{DateTime, Utc, Duration}; +use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; // ==================== ENUMS AND COMMON TYPES ==================== @@ -22,7 +22,7 @@ pub enum EventClass { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum TimeTransparency { Opaque, // OPAQUE - time is not available - Transparent, // TRANSPARENT - time is available + Transparent, // TRANSPARENT - time is available } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -64,11 +64,11 @@ pub enum AlarmAction { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CalendarUser { - pub cal_address: String, // Calendar user address (usually email) - pub common_name: Option, // CN parameter - display name - pub dir_entry_ref: Option, // DIR parameter - directory entry - pub sent_by: Option, // SENT-BY parameter - pub language: Option, // LANGUAGE parameter + pub cal_address: String, // Calendar user address (usually email) + pub common_name: Option, // CN parameter - display name + pub dir_entry_ref: Option, // DIR parameter - directory entry + pub sent_by: Option, // SENT-BY parameter + pub language: Option, // LANGUAGE parameter } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -78,130 +78,130 @@ pub struct Attendee { pub role: Option, // ROLE parameter pub part_stat: Option, // PARTSTAT parameter pub rsvp: Option, // RSVP parameter - pub cu_type: Option, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN) - pub member: Vec, // MEMBER parameter - pub delegated_to: Vec, // DELEGATED-TO parameter - pub delegated_from: Vec, // DELEGATED-FROM parameter - pub sent_by: Option, // SENT-BY parameter - pub dir_entry_ref: Option, // DIR parameter - pub language: Option, // LANGUAGE parameter + pub cu_type: Option, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN) + pub member: Vec, // MEMBER parameter + pub delegated_to: Vec, // DELEGATED-TO parameter + pub delegated_from: Vec, // DELEGATED-FROM parameter + pub sent_by: Option, // SENT-BY parameter + pub dir_entry_ref: Option, // DIR parameter + pub language: Option, // LANGUAGE parameter } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct VAlarm { - pub action: AlarmAction, // Action (ACTION) - REQUIRED - pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED - pub duration: Option, // Duration (DURATION) - pub repeat: Option, // Repeat count (REPEAT) - pub description: Option, // Description for DISPLAY/EMAIL - pub summary: Option, // Summary for EMAIL - pub attendees: Vec, // Attendees for EMAIL - pub attach: Vec, // Attachments for AUDIO/EMAIL + pub action: AlarmAction, // Action (ACTION) - REQUIRED + pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED + pub duration: Option, // Duration (DURATION) + pub repeat: Option, // Repeat count (REPEAT) + pub description: Option, // Description for DISPLAY/EMAIL + pub summary: Option, // Summary for EMAIL + pub attendees: Vec, // Attendees for EMAIL + pub attach: Vec, // Attachments for AUDIO/EMAIL } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum AlarmTrigger { - DateTime(DateTime), // Absolute trigger time - Duration(Duration), // Duration relative to start/end + DateTime(DateTime), // Absolute trigger time + Duration(Duration), // Duration relative to start/end } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Attachment { - pub format_type: Option, // FMTTYPE parameter (MIME type) - pub encoding: Option, // ENCODING parameter - pub value: Option, // VALUE parameter (BINARY or URI) - pub uri: Option, // URI reference - pub binary_data: Option>, // Binary data (when ENCODING=BASE64) + pub format_type: Option, // FMTTYPE parameter (MIME type) + pub encoding: Option, // ENCODING parameter + pub value: Option, // VALUE parameter (BINARY or URI) + pub uri: Option, // URI reference + pub binary_data: Option>, // Binary data (when ENCODING=BASE64) } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct GeographicPosition { - pub latitude: f64, // Latitude in decimal degrees - pub longitude: f64, // Longitude in decimal degrees + pub latitude: f64, // Latitude in decimal degrees + pub longitude: f64, // Longitude in decimal degrees } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct VTimeZone { - pub tzid: String, // Time zone ID (TZID) - REQUIRED - pub last_modified: Option>, // Last modified (LAST-MODIFIED) - pub tzurl: Option, // Time zone URL (TZURL) + pub tzid: String, // Time zone ID (TZID) - REQUIRED + pub last_modified: Option>, // Last modified (LAST-MODIFIED) + pub tzurl: Option, // Time zone URL (TZURL) pub standard_components: Vec, // STANDARD components pub daylight_components: Vec, // DAYLIGHT components } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TimeZoneComponent { - pub dtstart: DateTime, // Start of this time zone definition - pub tzoffset_to: String, // UTC offset for this component - pub tzoffset_from: String, // UTC offset before this component - pub rrule: Option, // Recurrence rule - pub rdate: Vec>, // Recurrence dates - pub tzname: Vec, // Time zone names - pub comment: Vec, // Comments + pub dtstart: DateTime, // Start of this time zone definition + pub tzoffset_to: String, // UTC offset for this component + pub tzoffset_from: String, // UTC offset before this component + pub rrule: Option, // Recurrence rule + pub rdate: Vec>, // Recurrence dates + pub tzname: Vec, // Time zone names + pub comment: Vec, // Comments } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct VJournal { // Required properties - pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED - pub uid: String, // Unique identifier (UID) - REQUIRED - + pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED + pub uid: String, // Unique identifier (UID) - REQUIRED + // Optional properties - pub dtstart: Option>, // Start date-time (DTSTART) - pub summary: Option, // Summary/title (SUMMARY) - pub description: Option, // Description (DESCRIPTION) - + pub dtstart: Option>, // Start date-time (DTSTART) + pub summary: Option, // Summary/title (SUMMARY) + pub description: Option, // Description (DESCRIPTION) + // Classification and status - pub class: Option, // Classification (CLASS) - pub status: Option, // Status (STATUS) - + pub class: Option, // Classification (CLASS) + pub status: Option, // Status (STATUS) + // People and organization - pub organizer: Option, // Organizer (ORGANIZER) - pub attendees: Vec, // Attendees (ATTENDEE) - + pub organizer: Option, // Organizer (ORGANIZER) + pub attendees: Vec, // Attendees (ATTENDEE) + // Categorization - pub categories: Vec, // Categories (CATEGORIES) - + pub categories: Vec, // Categories (CATEGORIES) + // Versioning and modification - pub sequence: Option, // Sequence number (SEQUENCE) - pub created: Option>, // Creation time (CREATED) - pub last_modified: Option>, // Last modified (LAST-MODIFIED) - + pub sequence: Option, // Sequence number (SEQUENCE) + pub created: Option>, // Creation time (CREATED) + pub last_modified: Option>, // Last modified (LAST-MODIFIED) + // Recurrence - pub rrule: Option, // Recurrence rule (RRULE) - pub rdate: Vec>, // Recurrence dates (RDATE) - pub exdate: Vec>, // Exception dates (EXDATE) - pub recurrence_id: Option>, // Recurrence ID (RECURRENCE-ID) - + pub rrule: Option, // Recurrence rule (RRULE) + pub rdate: Vec>, // Recurrence dates (RDATE) + pub exdate: Vec>, // Exception dates (EXDATE) + pub recurrence_id: Option>, // Recurrence ID (RECURRENCE-ID) + // Attachments - pub attachments: Vec, // Attachments (ATTACH) + pub attachments: Vec, // Attachments (ATTACH) } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct VFreeBusy { // Required properties - pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED - pub uid: String, // Unique identifier (UID) - REQUIRED - + pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED + pub uid: String, // Unique identifier (UID) - REQUIRED + // Optional date-time properties - pub dtstart: Option>, // Start date-time (DTSTART) - pub dtend: Option>, // End date-time (DTEND) - + pub dtstart: Option>, // Start date-time (DTSTART) + pub dtend: Option>, // End date-time (DTEND) + // People - pub organizer: Option, // Organizer (ORGANIZER) - pub attendees: Vec, // Attendees (ATTENDEE) - + pub organizer: Option, // Organizer (ORGANIZER) + pub attendees: Vec, // Attendees (ATTENDEE) + // Free/busy time - pub freebusy: Vec, // Free/busy time periods - pub url: Option, // URL (URL) - pub comment: Vec, // Comments (COMMENT) - pub contact: Option, // Contact information (CONTACT) + pub freebusy: Vec, // Free/busy time periods + pub url: Option, // URL (URL) + pub comment: Vec, // Comments (COMMENT) + pub contact: Option, // Contact information (CONTACT) } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FreeBusyTime { - pub fb_type: FreeBusyType, // Free/busy type - pub periods: Vec, // Time periods + pub fb_type: FreeBusyType, // Free/busy type + pub periods: Vec, // Time periods } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -214,7 +214,7 @@ pub enum FreeBusyType { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Period { - pub start: DateTime, // Period start - pub end: Option>, // Period end - pub duration: Option, // Period duration (alternative to end) -} \ No newline at end of file + pub start: DateTime, // Period start + pub end: Option>, // Period end + pub duration: Option, // Period duration (alternative to end) +} diff --git a/calendar-models/src/lib.rs b/calendar-models/src/lib.rs index e8c9712..8e71325 100644 --- a/calendar-models/src/lib.rs +++ b/calendar-models/src/lib.rs @@ -1,10 +1,10 @@ //! RFC 5545 Compliant Calendar Models -//! +//! //! This crate provides shared data structures for calendar applications //! that comply with RFC 5545 (iCalendar) specification. -pub mod vevent; pub mod common; +pub mod vevent; +pub use common::*; pub use vevent::*; -pub use common::*; \ No newline at end of file diff --git a/calendar-models/src/vevent.rs b/calendar-models/src/vevent.rs index 6f50df2..48930c4 100644 --- a/calendar-models/src/vevent.rs +++ b/calendar-models/src/vevent.rs @@ -1,66 +1,66 @@ //! VEvent - RFC 5545 compliant calendar event structure -use chrono::{DateTime, Utc, Duration}; -use serde::{Deserialize, Serialize}; use crate::common::*; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; // ==================== VEVENT COMPONENT ==================== #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct VEvent { // Required properties - pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED - pub uid: String, // Unique identifier (UID) - REQUIRED - pub dtstart: DateTime, // Start date-time (DTSTART) - REQUIRED - + pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED + pub uid: String, // Unique identifier (UID) - REQUIRED + pub dtstart: DateTime, // Start date-time (DTSTART) - REQUIRED + // Optional properties (commonly used) - pub dtend: Option>, // End date-time (DTEND) - pub duration: Option, // Duration (DURATION) - alternative to DTEND - pub summary: Option, // Summary/title (SUMMARY) - pub description: Option, // Description (DESCRIPTION) - pub location: Option, // Location (LOCATION) - + pub dtend: Option>, // End date-time (DTEND) + pub duration: Option, // Duration (DURATION) - alternative to DTEND + pub summary: Option, // Summary/title (SUMMARY) + pub description: Option, // Description (DESCRIPTION) + pub location: Option, // Location (LOCATION) + // Classification and status - pub class: Option, // Classification (CLASS) - pub status: Option, // Status (STATUS) - pub transp: Option, // Time transparency (TRANSP) - pub priority: Option, // Priority 0-9 (PRIORITY) - + pub class: Option, // Classification (CLASS) + pub status: Option, // Status (STATUS) + pub transp: Option, // Time transparency (TRANSP) + pub priority: Option, // Priority 0-9 (PRIORITY) + // People and organization - pub organizer: Option, // Organizer (ORGANIZER) - pub attendees: Vec, // Attendees (ATTENDEE) - pub contact: Option, // Contact information (CONTACT) - + pub organizer: Option, // Organizer (ORGANIZER) + pub attendees: Vec, // Attendees (ATTENDEE) + pub contact: Option, // Contact information (CONTACT) + // Categorization and relationships - pub categories: Vec, // Categories (CATEGORIES) - pub comment: Option, // Comment (COMMENT) - pub resources: Vec, // Resources (RESOURCES) - pub related_to: Option, // Related component (RELATED-TO) - pub url: Option, // URL (URL) - + pub categories: Vec, // Categories (CATEGORIES) + pub comment: Option, // Comment (COMMENT) + pub resources: Vec, // Resources (RESOURCES) + pub related_to: Option, // Related component (RELATED-TO) + pub url: Option, // URL (URL) + // Geographical - pub geo: Option, // Geographic position (GEO) - + pub geo: Option, // Geographic position (GEO) + // Versioning and modification - pub sequence: Option, // Sequence number (SEQUENCE) - pub created: Option>, // Creation time (CREATED) - pub last_modified: Option>, // Last modified (LAST-MODIFIED) - + pub sequence: Option, // Sequence number (SEQUENCE) + pub created: Option>, // Creation time (CREATED) + pub last_modified: Option>, // Last modified (LAST-MODIFIED) + // Recurrence - pub rrule: Option, // Recurrence rule (RRULE) - pub rdate: Vec>, // Recurrence dates (RDATE) - pub exdate: Vec>, // Exception dates (EXDATE) - pub recurrence_id: Option>, // Recurrence ID (RECURRENCE-ID) - + pub rrule: Option, // Recurrence rule (RRULE) + pub rdate: Vec>, // Recurrence dates (RDATE) + pub exdate: Vec>, // Exception dates (EXDATE) + pub recurrence_id: Option>, // Recurrence ID (RECURRENCE-ID) + // Alarms and attachments - pub alarms: Vec, // VALARM components - pub attachments: Vec, // Attachments (ATTACH) - + pub alarms: Vec, // VALARM components + pub attachments: Vec, // Attachments (ATTACH) + // CalDAV specific (for implementation) - pub etag: Option, // ETag for CalDAV - pub href: Option, // Href for CalDAV - pub calendar_path: Option, // Calendar path - pub all_day: bool, // All-day event flag + pub etag: Option, // ETag for CalDAV + pub href: Option, // Href for CalDAV + pub calendar_path: Option, // Calendar path + pub all_day: bool, // All-day event flag } impl VEvent { @@ -129,7 +129,9 @@ impl VEvent { /// Helper method to get display title (summary or "Untitled Event") pub fn get_title(&self) -> String { - self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string()) + self.summary + .clone() + .unwrap_or_else(|| "Untitled Event".to_string()) } /// Helper method to get start date for UI compatibility @@ -151,7 +153,7 @@ impl VEvent { pub fn get_status_display(&self) -> &'static str { match &self.status { Some(EventStatus::Tentative) => "Tentative", - Some(EventStatus::Confirmed) => "Confirmed", + Some(EventStatus::Confirmed) => "Confirmed", Some(EventStatus::Cancelled) => "Cancelled", None => "Confirmed", // Default } @@ -180,4 +182,4 @@ impl VEvent { Some(p) => format!("Priority {}", p), } } -} \ No newline at end of file +} diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 95da19b..6b2924f 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,11 +1,15 @@ -use yew::prelude::*; -use yew_router::prelude::*; +use crate::components::{ + CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, + EditAction, EventClass, EventContextMenu, EventCreationData, EventStatus, RecurrenceType, + ReminderType, RouteHandler, Sidebar, Theme, ViewMode, +}; +use crate::models::ical::VEvent; +use crate::services::{calendar_service::UserInfo, CalendarService}; +use chrono::NaiveDate; use gloo_storage::{LocalStorage, Storage}; use web_sys::MouseEvent; -use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction, EditAction}; -use crate::services::{CalendarService, calendar_service::UserInfo}; -use crate::models::ical::VEvent; -use chrono::NaiveDate; +use yew::prelude::*; +use yew_router::prelude::*; fn get_theme_event_colors() -> Vec { if let Some(window) = web_sys::window() { @@ -25,21 +29,31 @@ fn get_theme_event_colors() -> Vec { } } } - + vec![ - "#3B82F6".to_string(), "#10B981".to_string(), "#F59E0B".to_string(), "#EF4444".to_string(), - "#8B5CF6".to_string(), "#06B6D4".to_string(), "#84CC16".to_string(), "#F97316".to_string(), - "#EC4899".to_string(), "#6366F1".to_string(), "#14B8A6".to_string(), "#F3B806".to_string(), - "#8B5A2B".to_string(), "#6B7280".to_string(), "#DC2626".to_string(), "#7C3AED".to_string() + "#3B82F6".to_string(), + "#10B981".to_string(), + "#F59E0B".to_string(), + "#EF4444".to_string(), + "#8B5CF6".to_string(), + "#06B6D4".to_string(), + "#84CC16".to_string(), + "#F97316".to_string(), + "#EC4899".to_string(), + "#6366F1".to_string(), + "#14B8A6".to_string(), + "#F3B806".to_string(), + "#8B5A2B".to_string(), + "#6B7280".to_string(), + "#DC2626".to_string(), + "#7C3AED".to_string(), ] } #[function_component] pub fn App() -> Html { - let auth_token = use_state(|| -> Option { - LocalStorage::get("auth_token").ok() - }); - + let auth_token = use_state(|| -> Option { LocalStorage::get("auth_token").ok() }); + let user_info = use_state(|| -> Option { None }); let color_picker_open = use_state(|| -> Option { None }); let create_modal_open = use_state(|| false); @@ -58,7 +72,7 @@ pub fn App() -> Html { let _recurring_edit_modal_open = use_state(|| false); let _recurring_edit_event = use_state(|| -> Option { None }); let _recurring_edit_data = use_state(|| -> Option { None }); - + // Calendar view state - load from localStorage if available let current_view = use_state(|| { // Try to load saved view mode from localStorage @@ -71,7 +85,7 @@ pub fn App() -> Html { ViewMode::Month // Default to month view } }); - + // Theme state - load from localStorage if available let current_theme = use_state(|| { // Try to load saved theme from localStorage @@ -81,7 +95,7 @@ pub fn App() -> Html { Theme::Default // Default theme } }); - + let available_colors = use_state(|| get_theme_event_colors()); let on_login = { @@ -110,7 +124,7 @@ pub fn App() -> Html { ViewMode::Week => "week", }; let _ = LocalStorage::set("calendar_view_mode", view_string); - + // Update state current_view.set(new_view); }) @@ -122,17 +136,17 @@ pub fn App() -> Html { Callback::from(move |new_theme: Theme| { // Save theme to localStorage let _ = LocalStorage::set("calendar_theme", new_theme.value()); - + // Apply theme to document root if let Some(document) = web_sys::window().and_then(|w| w.document()) { if let Some(root) = document.document_element() { let _ = root.set_attribute("data-theme", new_theme.value()); } } - + // Update state current_theme.set(new_theme); - + // Update available colors after theme change available_colors.set(get_theme_event_colors()); }) @@ -155,17 +169,21 @@ pub fn App() -> Html { { let user_info = user_info.clone(); let auth_token = auth_token.clone(); - + use_effect_with((*auth_token).clone(), move |token| { if let Some(token) = token { let user_info = user_info.clone(); let token = token.clone(); - + wasm_bindgen_futures::spawn_local(async move { let calendar_service = CalendarService::new(); - - let password = if let Ok(credentials_str) = LocalStorage::get::("caldav_credentials") { - if let Ok(credentials) = serde_json::from_str::(&credentials_str) { + + let password = if let Ok(credentials_str) = + LocalStorage::get::("caldav_credentials") + { + if let Ok(credentials) = + serde_json::from_str::(&credentials_str) + { credentials["password"].as_str().unwrap_or("").to_string() } else { String::new() @@ -173,12 +191,16 @@ pub fn App() -> Html { } else { String::new() }; - + if !password.is_empty() { match calendar_service.fetch_user_info(&token, &password).await { Ok(mut info) => { - if let Ok(saved_colors_json) = LocalStorage::get::("calendar_colors") { - if let Ok(saved_info) = serde_json::from_str::(&saved_colors_json) { + if let Ok(saved_colors_json) = + LocalStorage::get::("calendar_colors") + { + if let Ok(saved_info) = + serde_json::from_str::(&saved_colors_json) + { for saved_cal in &saved_info.calendars { for cal in &mut info.calendars { if cal.path == saved_cal.path { @@ -191,7 +213,9 @@ pub fn App() -> Html { user_info.set(Some(info)); } Err(err) => { - web_sys::console::log_1(&format!("Failed to fetch user info: {}", err).into()); + web_sys::console::log_1( + &format!("Failed to fetch user info: {}", err).into(), + ); } } } @@ -199,7 +223,7 @@ pub fn App() -> Html { } else { user_info.set(None); } - + || () }); } @@ -211,17 +235,17 @@ pub fn App() -> Html { let calendar_context_menu_open = calendar_context_menu_open.clone(); Callback::from(move |e: MouseEvent| { // Check if any context menu or color picker is open - let any_menu_open = color_picker_open.is_some() || - *context_menu_open || - *event_context_menu_open || - *calendar_context_menu_open; - + let any_menu_open = color_picker_open.is_some() + || *context_menu_open + || *event_context_menu_open + || *calendar_context_menu_open; + if any_menu_open { // Prevent the default action and stop event propagation e.prevent_default(); e.stop_propagation(); } - + // Close all open menus/pickers color_picker_open.set(None); context_menu_open.set(false); @@ -231,10 +255,10 @@ pub fn App() -> Html { }; // Compute if any context menu is open - let any_context_menu_open = color_picker_open.is_some() || - *context_menu_open || - *event_context_menu_open || - *calendar_context_menu_open; + let any_context_menu_open = color_picker_open.is_some() + || *context_menu_open + || *event_context_menu_open + || *calendar_context_menu_open; let on_color_change = { let user_info = user_info.clone(); @@ -248,7 +272,7 @@ pub fn App() -> Html { } } user_info.set(Some(info.clone())); - + if let Ok(json) = serde_json::to_string(&info) { let _ = LocalStorage::set("calendar_colors", json); } @@ -317,14 +341,18 @@ pub fn App() -> Html { Callback::from(move |event_data: EventCreationData| { web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into()); create_event_modal_open.set(false); - + if let Some(_token) = (*auth_token).clone() { wasm_bindgen_futures::spawn_local(async move { let _calendar_service = CalendarService::new(); - + // Get CalDAV password from storage - let _password = if let Ok(credentials_str) = LocalStorage::get::("caldav_credentials") { - if let Ok(credentials) = serde_json::from_str::(&credentials_str) { + let _password = if let Ok(credentials_str) = + LocalStorage::get::("caldav_credentials") + { + if let Ok(credentials) = + serde_json::from_str::(&credentials_str) + { credentials["password"].as_str().unwrap_or("").to_string() } else { String::new() @@ -332,30 +360,30 @@ pub fn App() -> Html { } else { String::new() }; - + let params = event_data.to_create_event_params(); - let create_result = _calendar_service.create_event( - &_token, - &_password, - params.0, // title - params.1, // description - params.2, // start_date - params.3, // start_time - params.4, // end_date - params.5, // end_time - params.6, // location - params.7, // all_day - params.8, // status - params.9, // class - params.10, // priority - params.11, // organizer - params.12, // attendees - params.13, // categories - params.14, // reminder - params.15, // recurrence - params.16, // recurrence_days - params.17 // calendar_path - ).await; + let create_result = _calendar_service + .create_event( + &_token, &_password, params.0, // title + params.1, // description + params.2, // start_date + params.3, // start_time + params.4, // end_date + params.5, // end_time + params.6, // location + params.7, // all_day + params.8, // status + params.9, // class + params.10, // priority + params.11, // organizer + params.12, // attendees + params.13, // categories + params.14, // reminder + params.15, // recurrence + params.16, // recurrence_days + params.17, // calendar_path + ) + .await; match create_result { Ok(_) => { web_sys::console::log_1(&"Event created successfully".into()); @@ -364,8 +392,13 @@ pub fn App() -> Html { web_sys::window().unwrap().location().reload().unwrap(); } Err(err) => { - web_sys::console::error_1(&format!("Failed to create event: {}", err).into()); - web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap(); + web_sys::console::error_1( + &format!("Failed to create event: {}", err).into(), + ); + web_sys::window() + .unwrap() + .alert_with_message(&format!("Failed to create event: {}", err)) + .unwrap(); } } }); @@ -375,161 +408,232 @@ pub fn App() -> Html { let on_event_update = { let auth_token = auth_token.clone(); - Callback::from(move |(original_event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option>, Option, Option)| { - web_sys::console::log_1(&format!("Updating event: {} to new times: {} - {}", - original_event.uid, - new_start.format("%Y-%m-%d %H:%M"), - new_end.format("%Y-%m-%d %H:%M")).into()); - - // Use the original UID for all updates - let backend_uid = original_event.uid.clone(); - - if let Some(token) = (*auth_token).clone() { - let original_event = original_event.clone(); - let backend_uid = backend_uid.clone(); - wasm_bindgen_futures::spawn_local(async move { - let calendar_service = CalendarService::new(); - - // Get CalDAV password from storage - let password = if let Ok(credentials_str) = LocalStorage::get::("caldav_credentials") { - if let Ok(credentials) = serde_json::from_str::(&credentials_str) { - credentials["password"].as_str().unwrap_or("").to_string() + Callback::from( + move |( + original_event, + new_start, + new_end, + preserve_rrule, + until_date, + update_scope, + occurrence_date, + ): ( + VEvent, + chrono::NaiveDateTime, + chrono::NaiveDateTime, + bool, + Option>, + Option, + Option, + )| { + web_sys::console::log_1( + &format!( + "Updating event: {} to new times: {} - {}", + original_event.uid, + new_start.format("%Y-%m-%d %H:%M"), + new_end.format("%Y-%m-%d %H:%M") + ) + .into(), + ); + + // Use the original UID for all updates + let backend_uid = original_event.uid.clone(); + + if let Some(token) = (*auth_token).clone() { + let original_event = original_event.clone(); + let backend_uid = backend_uid.clone(); + wasm_bindgen_futures::spawn_local(async move { + let calendar_service = CalendarService::new(); + + // Get CalDAV password from storage + let password = if let Ok(credentials_str) = + LocalStorage::get::("caldav_credentials") + { + if let Ok(credentials) = + serde_json::from_str::(&credentials_str) + { + credentials["password"].as_str().unwrap_or("").to_string() + } else { + String::new() + } } else { String::new() - } - } else { - String::new() - }; - - // Convert local times to UTC for backend storage - let start_utc = new_start.and_local_timezone(chrono::Local).unwrap().to_utc(); - let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc(); - - // Format UTC date and time strings for backend - let start_date = start_utc.format("%Y-%m-%d").to_string(); - let start_time = start_utc.format("%H:%M").to_string(); - let end_date = end_utc.format("%Y-%m-%d").to_string(); - let end_time = end_utc.format("%H:%M").to_string(); - - // Convert existing event data to string formats for the API - let status_str = match original_event.status { - Some(crate::models::ical::EventStatus::Tentative) => "TENTATIVE".to_string(), - Some(crate::models::ical::EventStatus::Confirmed) => "CONFIRMED".to_string(), - Some(crate::models::ical::EventStatus::Cancelled) => "CANCELLED".to_string(), - None => "CONFIRMED".to_string(), // Default status - }; - - let class_str = match original_event.class { - Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(), - Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(), - Some(crate::models::ical::EventClass::Confidential) => "CONFIDENTIAL".to_string(), - None => "PUBLIC".to_string(), // Default class - }; - - // Convert reminders to string format - let reminder_str = if !original_event.alarms.is_empty() { - // Convert from VAlarm to minutes before - "15".to_string() // TODO: Convert VAlarm trigger to minutes - } else { - "".to_string() - }; - - // Handle recurrence (keep existing) - let recurrence_str = original_event.rrule.unwrap_or_default(); - let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence - - // Determine if this is a recurring event that needs series endpoint - let has_recurrence = !recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE"; - - let result = if let Some(scope) = update_scope.as_ref() { - // Use series endpoint for recurring event operations - if !has_recurrence { - web_sys::console::log_1(&"āš ļø Warning: update_scope provided for non-recurring event, using regular endpoint instead".into()); - // Fall through to regular endpoint - None + }; + + // Convert local times to UTC for backend storage + let start_utc = new_start + .and_local_timezone(chrono::Local) + .unwrap() + .to_utc(); + let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc(); + + // Format UTC date and time strings for backend + let start_date = start_utc.format("%Y-%m-%d").to_string(); + let start_time = start_utc.format("%H:%M").to_string(); + let end_date = end_utc.format("%Y-%m-%d").to_string(); + let end_time = end_utc.format("%H:%M").to_string(); + + // Convert existing event data to string formats for the API + let status_str = match original_event.status { + Some(crate::models::ical::EventStatus::Tentative) => { + "TENTATIVE".to_string() + } + Some(crate::models::ical::EventStatus::Confirmed) => { + "CONFIRMED".to_string() + } + Some(crate::models::ical::EventStatus::Cancelled) => { + "CANCELLED".to_string() + } + None => "CONFIRMED".to_string(), // Default status + }; + + let class_str = match original_event.class { + Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(), + Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(), + Some(crate::models::ical::EventClass::Confidential) => { + "CONFIDENTIAL".to_string() + } + None => "PUBLIC".to_string(), // Default class + }; + + // Convert reminders to string format + let reminder_str = if !original_event.alarms.is_empty() { + // Convert from VAlarm to minutes before + "15".to_string() // TODO: Convert VAlarm trigger to minutes } else { - Some(calendar_service.update_series( - &token, - &password, - backend_uid.clone(), - original_event.summary.clone().unwrap_or_default(), - original_event.description.clone().unwrap_or_default(), - start_date.clone(), - start_time.clone(), - end_date.clone(), - end_time.clone(), - original_event.location.clone().unwrap_or_default(), - original_event.all_day, - status_str.clone(), - class_str.clone(), - original_event.priority, - original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(), - original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::>().join(","), - original_event.categories.join(","), - reminder_str.clone(), - recurrence_str.clone(), - original_event.calendar_path.clone(), - scope.clone(), - occurrence_date, - ).await) + "".to_string() + }; + + // Handle recurrence (keep existing) + let recurrence_str = original_event.rrule.unwrap_or_default(); + let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence + + // Determine if this is a recurring event that needs series endpoint + let has_recurrence = + !recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE"; + + let result = if let Some(scope) = update_scope.as_ref() { + // Use series endpoint for recurring event operations + if !has_recurrence { + web_sys::console::log_1(&"āš ļø Warning: update_scope provided for non-recurring event, using regular endpoint instead".into()); + // Fall through to regular endpoint + None + } else { + Some( + calendar_service + .update_series( + &token, + &password, + backend_uid.clone(), + original_event.summary.clone().unwrap_or_default(), + original_event.description.clone().unwrap_or_default(), + start_date.clone(), + start_time.clone(), + end_date.clone(), + end_time.clone(), + original_event.location.clone().unwrap_or_default(), + original_event.all_day, + status_str.clone(), + class_str.clone(), + original_event.priority, + original_event + .organizer + .as_ref() + .map(|o| o.cal_address.clone()) + .unwrap_or_default(), + original_event + .attendees + .iter() + .map(|a| a.cal_address.clone()) + .collect::>() + .join(","), + original_event.categories.join(","), + reminder_str.clone(), + recurrence_str.clone(), + original_event.calendar_path.clone(), + scope.clone(), + occurrence_date, + ) + .await, + ) + } + } else { + None + }; + + let result = if let Some(series_result) = result { + series_result + } else { + // Use regular endpoint + calendar_service + .update_event( + &token, + &password, + backend_uid, + original_event.summary.unwrap_or_default(), + original_event.description.unwrap_or_default(), + start_date, + start_time, + end_date, + end_time, + original_event.location.unwrap_or_default(), + original_event.all_day, + status_str, + class_str, + original_event.priority, + original_event + .organizer + .as_ref() + .map(|o| o.cal_address.clone()) + .unwrap_or_default(), + original_event + .attendees + .iter() + .map(|a| a.cal_address.clone()) + .collect::>() + .join(","), + original_event.categories.join(","), + reminder_str, + recurrence_str, + recurrence_days, + original_event.calendar_path, + original_event.exdate.clone(), + if preserve_rrule { + Some("update_series".to_string()) + } else { + Some("this_and_future".to_string()) + }, + until_date, + ) + .await + }; + + match result { + Ok(_) => { + web_sys::console::log_1(&"Event updated successfully".into()); + // Add small delay before reload to let any pending requests complete + wasm_bindgen_futures::spawn_local(async { + gloo_timers::future::sleep(std::time::Duration::from_millis( + 100, + )) + .await; + web_sys::window().unwrap().location().reload().unwrap(); + }); + } + Err(err) => { + web_sys::console::error_1( + &format!("Failed to update event: {}", err).into(), + ); + web_sys::window() + .unwrap() + .alert_with_message(&format!("Failed to update event: {}", err)) + .unwrap(); + } } - } else { - None - }; - - let result = if let Some(series_result) = result { - series_result - } else { - // Use regular endpoint - calendar_service.update_event( - &token, - &password, - backend_uid, - original_event.summary.unwrap_or_default(), - original_event.description.unwrap_or_default(), - start_date, - start_time, - end_date, - end_time, - original_event.location.unwrap_or_default(), - original_event.all_day, - status_str, - class_str, - original_event.priority, - original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(), - original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::>().join(","), - original_event.categories.join(","), - reminder_str, - recurrence_str, - recurrence_days, - original_event.calendar_path, - original_event.exdate.clone(), - if preserve_rrule { - Some("update_series".to_string()) - } else { - Some("this_and_future".to_string()) - }, - until_date - ).await - }; - - match result { - Ok(_) => { - web_sys::console::log_1(&"Event updated successfully".into()); - // Add small delay before reload to let any pending requests complete - wasm_bindgen_futures::spawn_local(async { - gloo_timers::future::sleep(std::time::Duration::from_millis(100)).await; - web_sys::window().unwrap().location().reload().unwrap(); - }); - } - Err(err) => { - web_sys::console::error_1(&format!("Failed to update event: {}", err).into()); - web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap(); - } - } - }); - } - }) + }); + } + }, + ) }; let refresh_calendars = { @@ -538,12 +642,16 @@ pub fn App() -> Html { Callback::from(move |_| { if let Some(token) = (*auth_token).clone() { let user_info = user_info.clone(); - + wasm_bindgen_futures::spawn_local(async move { let calendar_service = CalendarService::new(); - - let password = if let Ok(credentials_str) = LocalStorage::get::("caldav_credentials") { - if let Ok(credentials) = serde_json::from_str::(&credentials_str) { + + let password = if let Ok(credentials_str) = + LocalStorage::get::("caldav_credentials") + { + if let Ok(credentials) = + serde_json::from_str::(&credentials_str) + { credentials["password"].as_str().unwrap_or("").to_string() } else { String::new() @@ -551,11 +659,15 @@ pub fn App() -> Html { } else { String::new() }; - + match calendar_service.fetch_user_info(&token, &password).await { Ok(mut info) => { - if let Ok(saved_colors_json) = LocalStorage::get::("calendar_colors") { - if let Ok(saved_info) = serde_json::from_str::(&saved_colors_json) { + if let Ok(saved_colors_json) = + LocalStorage::get::("calendar_colors") + { + if let Ok(saved_info) = + serde_json::from_str::(&saved_colors_json) + { for saved_cal in &saved_info.calendars { for cal in &mut info.calendars { if cal.path == saved_cal.path { @@ -568,7 +680,9 @@ pub fn App() -> Html { user_info.set(Some(info)); } Err(err) => { - web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into()); + web_sys::console::log_1( + &format!("Failed to refresh calendars: {}", err).into(), + ); } } }); @@ -577,8 +691,10 @@ pub fn App() -> Html { }; // Debug logging - web_sys::console::log_1(&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into()); - + web_sys::console::log_1( + &format!("App rendering: auth_token = {:?}", auth_token.is_some()).into(), + ); + html! {
@@ -604,7 +720,7 @@ pub fn App() -> Html { on_theme_change={on_theme_change} />
- Html { } else { html! { } -} \ No newline at end of file +} diff --git a/frontend/src/auth.rs b/frontend/src/auth.rs index ba24dd8..a635464 100644 --- a/frontend/src/auth.rs +++ b/frontend/src/auth.rs @@ -34,14 +34,14 @@ impl AuthService { let base_url = option_env!("BACKEND_API_URL") .unwrap_or("http://localhost:3000/api") .to_string(); - + Self { base_url } } pub async fn login(&self, request: CalDAVLoginRequest) -> Result { self.post_json("/auth/login", &request).await } - + // Helper method for POST requests with JSON body async fn post_json Deserialize<'de>>( &self, @@ -49,9 +49,9 @@ impl AuthService { body: &T, ) -> Result { let window = web_sys::window().ok_or("No global window exists")?; - - let json_body = serde_json::to_string(body) - .map_err(|e| format!("JSON serialization failed: {}", e))?; + + let json_body = + serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?; let opts = RequestInit::new(); opts.set_method("POST"); @@ -62,23 +62,27 @@ impl AuthService { let request = Request::new_with_str_and_init(&url, &opts) .map_err(|e| format!("Request creation failed: {:?}", e))?; - request.headers().set("Content-Type", "application/json") + request + .headers() + .set("Content-Type", "application/json") .map_err(|e| format!("Header setting failed: {:?}", e))?; let resp_value = JsFuture::from(window.fetch_with_request(&request)) .await .map_err(|e| format!("Network request failed: {:?}", e))?; - let resp: Response = resp_value.dyn_into() + let resp: Response = resp_value + .dyn_into() .map_err(|e| format!("Response cast failed: {:?}", e))?; - let text = JsFuture::from(resp.text() - .map_err(|e| format!("Text extraction failed: {:?}", e))?) - .await - .map_err(|e| format!("Text promise failed: {:?}", e))?; + let text = JsFuture::from( + resp.text() + .map_err(|e| format!("Text extraction failed: {:?}", e))?, + ) + .await + .map_err(|e| format!("Text promise failed: {:?}", e))?; - let text_string = text.as_string() - .ok_or("Response text is not a string")?; + let text_string = text.as_string().ok_or("Response text is not a string")?; if resp.ok() { serde_json::from_str::(&text_string) @@ -92,4 +96,4 @@ impl AuthService { } } } -} \ No newline at end of file +} diff --git a/frontend/src/components/calendar.rs b/frontend/src/components/calendar.rs index 62767fc..b1a4209 100644 --- a/frontend/src/components/calendar.rs +++ b/frontend/src/components/calendar.rs @@ -1,19 +1,16 @@ -use yew::prelude::*; -use chrono::{Datelike, Local, NaiveDate, Duration}; +use crate::components::{ + CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, +}; +use crate::models::ical::VEvent; +use crate::services::{calendar_service::UserInfo, CalendarService}; +use chrono::{Datelike, Duration, Local, NaiveDate}; +use gloo_storage::{LocalStorage, Storage}; use std::collections::HashMap; use web_sys::MouseEvent; -use crate::services::calendar_service::UserInfo; -use crate::models::ical::VEvent; -use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData}; -use gloo_storage::{LocalStorage, Storage}; +use yew::prelude::*; #[derive(Properties, PartialEq)] pub struct CalendarProps { - #[prop_or_default] - pub events: HashMap>, - pub on_event_click: Callback, - #[prop_or_default] - pub refreshing_event_uid: Option, #[prop_or_default] pub user_info: Option, #[prop_or_default] @@ -25,7 +22,17 @@ pub struct CalendarProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update_request: Option>, Option, Option)>>, + pub on_event_update_request: Option< + Callback<( + VEvent, + chrono::NaiveDateTime, + chrono::NaiveDateTime, + bool, + Option>, + Option, + Option, + )>, + >, #[prop_or_default] pub context_menus_open: bool, } @@ -33,6 +40,12 @@ pub struct CalendarProps { #[function_component] pub fn Calendar(props: &CalendarProps) -> Html { let today = Local::now().date_naive(); + + // Event management state + let events = use_state(|| HashMap::>::new()); + let loading = use_state(|| true); + let error = use_state(|| None::); + let refreshing_event_uid = use_state(|| None::); // Track the currently selected date (the actual day the user has selected) let selected_date = use_state(|| { // Try to load saved selected date from localStorage @@ -55,20 +68,19 @@ pub fn Calendar(props: &CalendarProps) -> Html { } } }); - + // Track the display date (what to show in the view) - let current_date = use_state(|| { - match props.view { - ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date), - ViewMode::Week => *selected_date, - } + let current_date = use_state(|| match props.view { + ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date), + ViewMode::Week => *selected_date, }); let selected_event = use_state(|| None::); - + // State for create event modal let show_create_modal = use_state(|| false); - let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>); - + let create_event_data = + use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>); + // State for time increment snapping (15 or 30 minutes) let time_increment = use_state(|| { // Try to load saved time increment from localStorage @@ -82,7 +94,155 @@ pub fn Calendar(props: &CalendarProps) -> Html { 15 } }); - + + // Fetch events when current_date changes + { + let events = events.clone(); + let loading = loading.clone(); + let error = error.clone(); + let current_date = current_date.clone(); + + use_effect_with((*current_date, props.view.clone()), move |(date, _view)| { + let auth_token: Option = LocalStorage::get("auth_token").ok(); + let date = *date; // Clone the date to avoid lifetime issues + + if let Some(token) = auth_token { + let events = events.clone(); + let loading = loading.clone(); + let error = error.clone(); + + wasm_bindgen_futures::spawn_local(async move { + let calendar_service = CalendarService::new(); + + let password = if let Ok(credentials_str) = + LocalStorage::get::("caldav_credentials") + { + if let Ok(credentials) = + serde_json::from_str::(&credentials_str) + { + credentials["password"].as_str().unwrap_or("").to_string() + } else { + String::new() + } + } else { + String::new() + }; + + let current_year = date.year(); + let current_month = date.month(); + + match calendar_service + .fetch_events_for_month_vevent( + &token, + &password, + current_year, + current_month, + ) + .await + { + Ok(vevents) => { + let grouped_events = CalendarService::group_events_by_date(vevents); + events.set(grouped_events); + loading.set(false); + } + Err(err) => { + error.set(Some(format!("Failed to load events: {}", err))); + loading.set(false); + } + } + }); + } else { + loading.set(false); + error.set(Some("No authentication token found".to_string())); + } + + || () + }); + } + + // Handle event click to refresh individual events + let on_event_click = { + let events = events.clone(); + let refreshing_event_uid = refreshing_event_uid.clone(); + + Callback::from(move |event: VEvent| { + let auth_token: Option = LocalStorage::get("auth_token").ok(); + + if let Some(token) = auth_token { + let events = events.clone(); + let refreshing_event_uid = refreshing_event_uid.clone(); + let uid = event.uid.clone(); + + refreshing_event_uid.set(Some(uid.clone())); + + wasm_bindgen_futures::spawn_local(async move { + let calendar_service = CalendarService::new(); + + let password = if let Ok(credentials_str) = + LocalStorage::get::("caldav_credentials") + { + if let Ok(credentials) = + serde_json::from_str::(&credentials_str) + { + credentials["password"].as_str().unwrap_or("").to_string() + } else { + String::new() + } + } else { + String::new() + }; + + match calendar_service + .refresh_event(&token, &password, &uid) + .await + { + Ok(Some(refreshed_event)) => { + let refreshed_vevent = refreshed_event; + let mut updated_events = (*events).clone(); + + for (_, day_events) in updated_events.iter_mut() { + day_events.retain(|e| e.uid != uid); + } + + if refreshed_vevent.rrule.is_some() { + let new_occurrences = + CalendarService::expand_recurring_events(vec![ + refreshed_vevent.clone(), + ]); + + for occurrence in new_occurrences { + let date = occurrence.get_date(); + updated_events + .entry(date) + .or_insert_with(Vec::new) + .push(occurrence); + } + } else { + let date = refreshed_vevent.get_date(); + updated_events + .entry(date) + .or_insert_with(Vec::new) + .push(refreshed_vevent); + } + + events.set(updated_events); + } + Ok(None) => { + let mut updated_events = (*events).clone(); + for (_, day_events) in updated_events.iter_mut() { + day_events.retain(|e| e.uid != uid); + } + events.set(updated_events); + } + Err(_err) => {} + } + + refreshing_event_uid.set(None); + }); + } + }) + }; + // Handle view mode changes - adjust current_date format when switching between month/week { let current_date = current_date.clone(); @@ -98,7 +258,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { || {} }); } - + let on_prev = { let current_date = current_date.clone(); let selected_date = selected_date.clone(); @@ -110,19 +270,22 @@ pub fn Calendar(props: &CalendarProps) -> Html { let prev_month = *current_date - Duration::days(1); let first_of_prev = prev_month.with_day(1).unwrap(); (first_of_prev, first_of_prev) - }, + } ViewMode::Week => { // Go to previous week let prev_week = *selected_date - Duration::weeks(1); (prev_week, prev_week) - }, + } }; selected_date.set(new_selected); current_date.set(new_display); - let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); + let _ = LocalStorage::set( + "calendar_selected_date", + new_selected.format("%Y-%m-%d").to_string(), + ); }) }; - + let on_next = { let current_date = current_date.clone(); let selected_date = selected_date.clone(); @@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html { let next_month = if current_date.month() == 12 { NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap() } else { - NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap() + NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1) + .unwrap() }; (next_month, next_month) - }, + } ViewMode::Week => { // Go to next week let next_week = *selected_date + Duration::weeks(1); (next_week, next_week) - }, + } }; selected_date.set(new_selected); current_date.set(new_display); - let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); + let _ = LocalStorage::set( + "calendar_selected_date", + new_selected.format("%Y-%m-%d").to_string(), + ); }) }; @@ -160,15 +327,18 @@ pub fn Calendar(props: &CalendarProps) -> Html { ViewMode::Month => { let first_of_today = today.with_day(1).unwrap(); (today, first_of_today) // Select today, but display the month - }, + } ViewMode::Week => (today, today), // Select and display today }; selected_date.set(new_selected); current_date.set(new_display); - let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); + let _ = LocalStorage::set( + "calendar_selected_date", + new_selected.format("%Y-%m-%d").to_string(), + ); }) }; - + // Handle time increment toggle let on_time_increment_toggle = { let time_increment = time_increment.clone(); @@ -179,32 +349,68 @@ pub fn Calendar(props: &CalendarProps) -> Html { let _ = LocalStorage::set("calendar_time_increment", next); }) }; - + // Handle drag-to-create event let on_create_event = { let show_create_modal = show_create_modal.clone(); let create_event_data = create_event_data.clone(); - Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| { - // For drag-to-create, we don't need the temporary event approach - // Instead, just pass the local times directly via initial_time props - create_event_data.set(Some((start_datetime.date(), start_datetime.time(), end_datetime.time()))); - show_create_modal.set(true); - }) + Callback::from( + move |(_date, start_datetime, end_datetime): ( + NaiveDate, + chrono::NaiveDateTime, + chrono::NaiveDateTime, + )| { + // For drag-to-create, we don't need the temporary event approach + // Instead, just pass the local times directly via initial_time props + create_event_data.set(Some(( + start_datetime.date(), + start_datetime.time(), + end_datetime.time(), + ))); + show_create_modal.set(true); + }, + ) }; - + // Handle drag-to-move event let on_event_update = { let on_event_update_request = props.on_event_update_request.clone(); - Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option>, Option, Option)| { - if let Some(callback) = &on_event_update_request { - callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date)); - } - }) + Callback::from( + move |( + event, + new_start, + new_end, + preserve_rrule, + until_date, + update_scope, + occurrence_date, + ): ( + VEvent, + chrono::NaiveDateTime, + chrono::NaiveDateTime, + bool, + Option>, + Option, + Option, + )| { + if let Some(callback) = &on_event_update_request { + callback.emit(( + event, + new_start, + new_end, + preserve_rrule, + until_date, + update_scope, + occurrence_date, + )); + } + }, + ) }; html! {
Some("week-view"), _ => None })}> - Html { time_increment={Some(*time_increment)} on_time_increment_toggle={Some(on_time_increment_toggle)} /> - + { - match props.view { + if *loading { + html! { +
+

{"Loading calendar events..."}

+
+ } + } else if let Some(err) = (*error).clone() { + html! { +
+

{format!("Error: {}", err)}

+
+ } + } else { + match props.view { ViewMode::Month => { let on_day_select = { let selected_date = selected_date.clone(); @@ -224,14 +443,14 @@ pub fn Calendar(props: &CalendarProps) -> Html { let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string()); }) }; - + html! { Html { Html { time_increment={*time_increment} /> }, + } } } - + // Event details modal - Html { }) }} /> - + // Create event modal Html { Callback::from(move |event_data: EventCreationData| { show_create_modal.set(false); create_event_data.set(None); - + // Emit the create event request to parent if let Some(callback) = &on_create_event_request { callback.emit(event_data); @@ -313,4 +533,4 @@ pub fn Calendar(props: &CalendarProps) -> Html { />
} -} \ No newline at end of file +} diff --git a/frontend/src/components/calendar_context_menu.rs b/frontend/src/components/calendar_context_menu.rs index f1c13a5..7e9c080 100644 --- a/frontend/src/components/calendar_context_menu.rs +++ b/frontend/src/components/calendar_context_menu.rs @@ -1,5 +1,5 @@ -use yew::prelude::*; use web_sys::MouseEvent; +use yew::prelude::*; #[derive(Properties, PartialEq)] pub struct CalendarContextMenuProps { @@ -13,7 +13,7 @@ pub struct CalendarContextMenuProps { #[function_component(CalendarContextMenu)] pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html { let menu_ref = use_node_ref(); - + if !props.is_open { return html! {}; } @@ -33,9 +33,9 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html { }; html! { -
@@ -44,4 +44,4 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
} -} \ No newline at end of file +} diff --git a/frontend/src/components/calendar_header.rs b/frontend/src/components/calendar_header.rs index a99089f..a81c6e4 100644 --- a/frontend/src/components/calendar_header.rs +++ b/frontend/src/components/calendar_header.rs @@ -1,7 +1,7 @@ -use yew::prelude::*; -use chrono::{NaiveDate, Datelike}; use crate::components::ViewMode; +use chrono::{Datelike, NaiveDate}; use web_sys::MouseEvent; +use yew::prelude::*; #[derive(Properties, PartialEq)] pub struct CalendarHeaderProps { @@ -18,7 +18,11 @@ pub struct CalendarHeaderProps { #[function_component(CalendarHeader)] pub fn calendar_header(props: &CalendarHeaderProps) -> Html { - let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year()); + let title = format!( + "{} {}", + get_month_name(props.current_date.month()), + props.current_date.year() + ); html! {
@@ -48,7 +52,7 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html { fn get_month_name(month: u32) -> &'static str { match month { 1 => "January", - 2 => "February", + 2 => "February", 3 => "March", 4 => "April", 5 => "May", @@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str { 10 => "October", 11 => "November", 12 => "December", - _ => "Invalid" + _ => "Invalid", } -} \ No newline at end of file +} diff --git a/frontend/src/components/calendar_list_item.rs b/frontend/src/components/calendar_list_item.rs index d5540b2..6119bcb 100644 --- a/frontend/src/components/calendar_list_item.rs +++ b/frontend/src/components/calendar_list_item.rs @@ -1,13 +1,13 @@ -use yew::prelude::*; -use web_sys::MouseEvent; use crate::services::calendar_service::CalendarInfo; +use web_sys::MouseEvent; +use yew::prelude::*; #[derive(Properties, PartialEq)] pub struct CalendarListItemProps { pub calendar: CalendarInfo, pub color_picker_open: bool, pub on_color_change: Callback<(String, String)>, // (calendar_path, color) - pub on_color_picker_toggle: Callback, // calendar_path + pub on_color_picker_toggle: Callback, // calendar_path pub available_colors: Vec, pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) } @@ -34,7 +34,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { html! {
  • - { @@ -46,14 +46,14 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { let color_str = color.clone(); let cal_path = props.calendar.path.clone(); let on_color_change = props.on_color_change.clone(); - + let on_color_select = Callback::from(move |_: MouseEvent| { on_color_change.emit((cal_path.clone(), color_str.clone())); }); - + let is_selected = props.calendar.color == *color; let class_name = if is_selected { "color-option selected" } else { "color-option" }; - + html! {
    Html { {&props.calendar.display_name}
  • } -} \ No newline at end of file +} diff --git a/frontend/src/components/context_menu.rs b/frontend/src/components/context_menu.rs index 1fc9f8c..b0cd7dc 100644 --- a/frontend/src/components/context_menu.rs +++ b/frontend/src/components/context_menu.rs @@ -1,5 +1,5 @@ -use yew::prelude::*; use web_sys::MouseEvent; +use yew::prelude::*; #[derive(Properties, PartialEq)] pub struct ContextMenuProps { @@ -13,7 +13,7 @@ pub struct ContextMenuProps { #[function_component(ContextMenu)] pub fn context_menu(props: &ContextMenuProps) -> Html { let menu_ref = use_node_ref(); - + // Close menu when clicking outside (handled by parent component) if !props.is_open { @@ -35,9 +35,9 @@ pub fn context_menu(props: &ContextMenuProps) -> Html { }; html! { -
    @@ -45,4 +45,4 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
    } -} \ No newline at end of file +} diff --git a/frontend/src/components/create_calendar_modal.rs b/frontend/src/components/create_calendar_modal.rs index ef94b96..679532c 100644 --- a/frontend/src/components/create_calendar_modal.rs +++ b/frontend/src/components/create_calendar_modal.rs @@ -39,30 +39,32 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { let error_message = error_message.clone(); let is_creating = is_creating.clone(); let on_create = props.on_create.clone(); - + Callback::from(move |e: SubmitEvent| { e.prevent_default(); - + let name = (*calendar_name).trim(); if name.is_empty() { error_message.set(Some("Calendar name is required".to_string())); return; } - + if name.len() > 100 { - error_message.set(Some("Calendar name too long (max 100 characters)".to_string())); + error_message.set(Some( + "Calendar name too long (max 100 characters)".to_string(), + )); return; } - + error_message.set(None); is_creating.set(true); - + let desc = if (*description).trim().is_empty() { None } else { Some((*description).clone()) }; - + on_create.emit((name.to_string(), desc, (*selected_color).clone())); }) }; @@ -90,7 +92,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { {"Ɨ"}
    - +