From 79f287ed61da0707dff18714c28275c3c3e8a091 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Mon, 1 Sep 2025 18:31:51 -0400 Subject: [PATCH] Fix calendar event fetching to use visible date range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved event fetching logic from CalendarView to Calendar component to properly use the visible date range instead of hardcoded current month. The Calendar component already tracks the current visible date through navigation, so events now load correctly for August and other months when navigating. Changes: - Calendar component now manages its own events state and fetching - Event fetching responds to current_date changes from navigation - CalendarView simplified to just render Calendar component - Fixed cargo fmt/clippy formatting across codebase šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/auth.rs | 46 +- backend/src/calendar.rs | 679 +++++++++------ backend/src/config.rs | 101 ++- backend/src/handlers.rs | 6 +- backend/src/handlers/auth.rs | 106 ++- backend/src/handlers/calendar.rs | 51 +- backend/src/handlers/events.rs | 485 +++++++---- backend/src/handlers/series.rs | 735 ++++++++++------ backend/src/lib.rs | 31 +- backend/src/main.rs | 2 +- backend/src/models.rs | 120 +-- backend/tests/integration_tests.rs | 418 +++++---- calendar-models/src/common.rs | 170 ++-- calendar-models/src/lib.rs | 6 +- calendar-models/src/vevent.rs | 98 ++- frontend/src/app.rs | 678 +++++++++------ frontend/src/auth.rs | 32 +- frontend/src/components/calendar.rs | 344 ++++++-- .../src/components/calendar_context_menu.rs | 10 +- frontend/src/components/calendar_header.rs | 16 +- frontend/src/components/calendar_list_item.rs | 16 +- frontend/src/components/context_menu.rs | 10 +- .../src/components/create_calendar_modal.rs | 50 +- frontend/src/components/create_event_modal.rs | 604 +++++++------ frontend/src/components/event_context_menu.rs | 16 +- frontend/src/components/event_modal.rs | 37 +- frontend/src/components/login.rs | 39 +- frontend/src/components/mod.rs | 46 +- frontend/src/components/month_view.rs | 92 +- .../src/components/recurring_edit_modal.rs | 27 +- frontend/src/components/route_handler.rs | 228 +---- frontend/src/components/sidebar.rs | 19 +- frontend/src/components/week_view.rs | 364 ++++---- frontend/src/main.rs | 3 +- frontend/src/models/ical.rs | 2 +- frontend/src/models/mod.rs | 4 +- frontend/src/services/calendar_service.rs | 819 +++++++++++------- frontend/src/services/mod.rs | 2 +- 38 files changed, 3922 insertions(+), 2590 deletions(-) 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 { {"Ɨ"}
    - +