From ee1c6ee29902501c16b19da0fa9dc1cd59235fd0 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Sat, 30 Aug 2025 18:40:48 -0400 Subject: [PATCH] Fix single event deletion functionality with proper recurring vs non-recurring handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit resolves multiple issues with event deletion: Backend fixes: - Fix CalDAV URL construction for DELETE requests (missing slash separator) - Improve event lookup by href with exact matching and fallback to UID extraction - Add support for both RFC3339 and simple YYYY-MM-DD date formats in occurrence parsing - Implement proper logic to distinguish recurring vs non-recurring events in delete_this action - For non-recurring events: delete entire event from CalDAV server - For recurring events: add EXDATE to exclude specific occurrences - Add comprehensive debug logging for troubleshooting deletion issues Frontend fixes: - Update callback signatures to support series endpoint parameters (7-parameter tuples) - Add update_series method to CalendarService for series-specific operations - Route single occurrence modifications through series endpoint with proper scoping - Fix all component prop definitions to use new callback signature - Update all emit calls to pass correct number of parameters The deletion process now works correctly: - Single events are completely removed from the calendar - Recurring event occurrences are properly excluded via EXDATE - Debug logging helps identify and resolve CalDAV communication issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/calendar.rs | 2 +- backend/src/handlers/events.rs | 146 ++++++++++++++-------- frontend/src/app.rs | 95 +++++++++----- frontend/src/components/calendar.rs | 6 +- frontend/src/components/route_handler.rs | 4 +- frontend/src/components/week_view.rs | 10 +- frontend/src/services/calendar_service.rs | 97 ++++++++++++++ 7 files changed, 267 insertions(+), 93 deletions(-) diff --git a/backend/src/calendar.rs b/backend/src/calendar.rs index b2cd62c..56cf83b 100644 --- a/backend/src/calendar.rs +++ b/backend/src/calendar.rs @@ -1015,7 +1015,7 @@ 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); diff --git a/backend/src/handlers/events.rs b/backend/src/handlers/events.rs index 9d3d450..5f3d316 100644 --- a/backend/src/handlers/events.rs +++ b/backend/src/handlers/events.rs @@ -106,14 +106,34 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h // 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?; - // Try to match by UID extracted from href - let uid_from_href = event_href.trim_end_matches(".ics"); + println!("🔍 fetch_event_by_href: looking for href='{}'", event_href); + 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 { + if stored_href == event_href { + println!("✅ Found matching event by exact href: {}", event.uid); + return Ok(Some(event.clone())); + } + } + } + + // 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); + 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) } @@ -132,34 +152,56 @@ pub async fn delete_event( // Handle different delete actions for recurring events match request.delete_action.as_str() { "delete_this" => { - // For single occurrence deletion, we need to: - // 1. Fetch the recurring event - // 2. Add an EXDATE for this occurrence - // 3. Update the event - - if let Some(mut event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await + 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(occurrence_date) = &request.occurrence_date { - // Parse the occurrence date and add it to EXDATE - if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) { - let exception_utc = date.with_timezone(&chrono::Utc); - event.exdate.push(exception_utc); + // 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) { + // 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") { + // 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); // Update the event with the new EXDATE - client.update_event(&request.calendar_path, &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)))?; + println!("✅ Successfully updated recurring event with EXDATE"); + Ok(Json(DeleteEventResponse { success: true, message: "Single occurrence deleted successfully".to_string(), })) } else { - Err(ApiError::BadRequest("Invalid occurrence date format".to_string())) + Err(ApiError::BadRequest("Occurrence date is required for single occurrence deletion of recurring events".to_string())) } } else { - Err(ApiError::BadRequest("Occurrence date is required for single occurrence deletion".to_string())) + // Non-recurring event - delete the entire event + println!("🗑️ Deleting non-recurring event: {}", event.uid); + + client.delete_event(&request.calendar_path, &request.event_href) + .await + .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(), + })) } } else { Err(ApiError::NotFound("Event not found".to_string())) @@ -175,41 +217,45 @@ pub async fn delete_event( .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? { if let Some(occurrence_date) = &request.occurrence_date { - if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) { - let until_date = date.with_timezone(&chrono::Utc); - - // 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")); - event.rrule = Some(new_rrule); - - // Update the event with the modified RRULE - 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)))?; - - Ok(Json(DeleteEventResponse { - success: true, - 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) - .await - .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; - - Ok(Json(DeleteEventResponse { - success: true, - message: "Event deleted successfully".to_string(), - })) - } + 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") { + // Simple date format (YYYY-MM-DD) + naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() } else { - Err(ApiError::BadRequest("Invalid occurrence date format".to_string())) + 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")); + event.rrule = Some(new_rrule); + + // Update the event with the modified RRULE + 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)))?; + + Ok(Json(DeleteEventResponse { + success: true, + 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) + .await + .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())) diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 918b2c4..57d40c3 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -2,7 +2,7 @@ use yew::prelude::*; use yew_router::prelude::*; 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, RecurringEditModal, RecurringEditAction}; +use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction}; use crate::services::{CalendarService, calendar_service::UserInfo}; use crate::models::ical::VEvent; use chrono::NaiveDate; @@ -374,7 +374,7 @@ 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): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option>)| { + 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"), @@ -437,36 +437,67 @@ pub fn App() -> Html { let recurrence_str = original_event.rrule.unwrap_or_default(); let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence - match 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 { + let result = if let Some(scope) = update_scope.as_ref() { + // Use series endpoint + calendar_service.update_series( + &token, + &password, + backend_uid, + original_event.summary.unwrap_or_default(), + original_event.description.unwrap_or_default(), + start_date.clone(), + start_time.clone(), + end_date.clone(), + end_time.clone(), + 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, + original_event.calendar_path, + scope.clone(), + occurrence_date, + ).await + } 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 diff --git a/frontend/src/components/calendar.rs b/frontend/src/components/calendar.rs index d10befb..62767fc 100644 --- a/frontend/src/components/calendar.rs +++ b/frontend/src/components/calendar.rs @@ -25,7 +25,7 @@ pub struct CalendarProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update_request: Option>)>>, + pub on_event_update_request: Option>, Option, Option)>>, #[prop_or_default] pub context_menus_open: bool, } @@ -195,9 +195,9 @@ pub fn Calendar(props: &CalendarProps) -> Html { // 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): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option>)| { + 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)); + callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date)); } }) }; diff --git a/frontend/src/components/route_handler.rs b/frontend/src/components/route_handler.rs index 278cee9..c64b5ce 100644 --- a/frontend/src/components/route_handler.rs +++ b/frontend/src/components/route_handler.rs @@ -28,7 +28,7 @@ pub struct RouteHandlerProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update_request: Option>)>>, + pub on_event_update_request: Option>, Option, Option)>>, #[prop_or_default] pub context_menus_open: bool, } @@ -106,7 +106,7 @@ pub struct CalendarViewProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update_request: Option>)>>, + pub on_event_update_request: Option>, Option, Option)>>, #[prop_or_default] pub context_menus_open: bool, } diff --git a/frontend/src/components/week_view.rs b/frontend/src/components/week_view.rs index 5c28cfb..08ad5da 100644 --- a/frontend/src/components/week_view.rs +++ b/frontend/src/components/week_view.rs @@ -25,7 +25,7 @@ pub struct WeekViewProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update: Option>)>>, + pub on_event_update: Option>, Option, Option)>>, #[prop_or_default] pub context_menus_open: bool, #[prop_or_default] @@ -221,7 +221,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { let original_end = original_series.dtend.unwrap_or(original_series.dtstart).with_timezone(&chrono::Local).naive_local(); // Send until_date to backend instead of modifying RRULE on frontend - update_callback.emit((original_series, original_start, original_end, true, Some(until_utc))); // preserve_rrule = true, backend will add UNTIL + update_callback.emit((original_series, original_start, original_end, true, Some(until_utc), Some("this_and_future".to_string()), None)); // preserve_rrule = true, backend will add UNTIL } // 2. Create new series starting from this occurrence with modified times @@ -465,7 +465,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { } else { // Regular event - proceed with update if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date + callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date } } }, @@ -501,7 +501,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { } else { // Regular event - proceed with update if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date + callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date } } }, @@ -532,7 +532,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { } else { // Regular event - proceed with update if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date + callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date } } } diff --git a/frontend/src/services/calendar_service.rs b/frontend/src/services/calendar_service.rs index aad6301..4921050 100644 --- a/frontend/src/services/calendar_service.rs +++ b/frontend/src/services/calendar_service.rs @@ -1106,5 +1106,102 @@ impl CalendarService { } } + pub async fn update_series( + &self, + token: &str, + password: &str, + series_uid: String, + title: String, + description: String, + start_date: String, + start_time: String, + end_date: String, + end_time: String, + location: String, + all_day: bool, + status: String, + class: String, + priority: Option, + organizer: String, + attendees: String, + categories: String, + reminder: String, + recurrence: String, + calendar_path: Option, + update_scope: String, + occurrence_date: Option, + ) -> Result<(), String> { + let window = web_sys::window().ok_or("No global window exists")?; + + let opts = RequestInit::new(); + opts.set_method("POST"); + opts.set_mode(RequestMode::Cors); + + let body = serde_json::json!({ + "series_uid": series_uid, + "title": title, + "description": description, + "start_date": start_date, + "start_time": start_time, + "end_date": end_date, + "end_time": end_time, + "location": location, + "all_day": all_day, + "status": status, + "class": class, + "priority": priority, + "organizer": organizer, + "attendees": attendees, + "categories": categories, + "reminder": reminder, + "recurrence": recurrence, + "recurrence_days": vec![false; 7], // Default - could be enhanced + "recurrence_interval": 1_u32, // Default interval + "recurrence_end_date": None as Option, // No end date by default + "recurrence_count": None as Option, // No count limit by default + "calendar_path": calendar_path, + "update_scope": update_scope, + "occurrence_date": occurrence_date + }); + + let url = format!("{}/calendar/events/series/update", self.base_url); + + let body_string = serde_json::to_string(&body) + .map_err(|e| format!("JSON serialization failed: {}", e))?; + opts.set_body(&body_string.into()); + let request = Request::new_with_str_and_init(&url, &opts) + .map_err(|e| format!("Request creation failed: {:?}", e))?; + + request.headers().set("Authorization", &format!("Bearer {}", token)) + .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; + + request.headers().set("X-CalDAV-Password", password) + .map_err(|e| format!("Password header setting failed: {:?}", e))?; + + request.headers().set("Content-Type", "application/json") + .map_err(|e| format!("Content-Type 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() + .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_string = text.as_string() + .ok_or("Response text is not a string")?; + + if resp.ok() { + Ok(()) + } else { + Err(format!("Request failed with status {}: {}", resp.status(), text_string)) + } + } + }