diff --git a/backend/src/handlers/series.rs b/backend/src/handlers/series.rs index c4c88e4..6d5df21 100644 --- a/backend/src/handlers/series.rs +++ b/backend/src/handlers/series.rs @@ -353,17 +353,23 @@ pub async fn update_event_series( }, "this_only" => { // Create exception for single occurrence, keep original series - update_single_occurrence(&mut existing_event, &request, start_datetime, end_datetime)? + 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? }, _ => unreachable!(), // Already validated above }; // Update the event on the CalDAV server using the original event's href - let event_href = existing_event.href.as_ref() - .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?; - client.update_event(&calendar_path, &updated_event, event_href) - .await - .map_err(|e| ApiError::Internal(format!("Failed to update event series: {}", e)))?; + // Note: For "this_only" updates, the original series was already updated in update_single_occurrence + if request.update_scope != "this_only" { + let event_href = existing_event.href.as_ref() + .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?; + client.update_event(&calendar_path, &updated_event, event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update event series: {}", e)))?; + } println!("✅ Event series updated successfully with UID: {}", request.series_uid); @@ -636,24 +642,40 @@ fn update_this_and_future( } /// Update only a single occurrence (create an exception) -fn update_single_occurrence( +async fn update_single_occurrence( existing_event: &mut VEvent, request: &UpdateEventSeriesRequest, start_datetime: chrono::DateTime, end_datetime: chrono::DateTime, + client: &CalDAVClient, + calendar_path: &str, + original_event_href: &str, ) -> Result<(VEvent, u32), ApiError> { - // For single occurrence updates, we need to: - // 1. Keep the original series unchanged - // 2. Create a new single event (exception) with the same UID but different RECURRENCE-ID + // 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 - // Create a new event for the single occurrence - let occurrence_uid = if let Some(occurrence_date) = &request.occurrence_date { - format!("{}-exception-{}", existing_event.uid, occurrence_date) - } else { - format!("{}-exception", existing_event.uid) - }; + // 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 mut exception_event = VEvent::new(occurrence_uid, start_datetime); + // 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()))?; + + // 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); + 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")); + + // Create a new exception event with the same UID but RECURRENCE-ID + let mut exception_event = VEvent::new(existing_event.uid.clone(), start_datetime); exception_event.dtend = Some(end_datetime); exception_event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) }; exception_event.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) }; @@ -672,10 +694,41 @@ fn update_single_occurrence( }); exception_event.priority = request.priority; - - // Note: This function returns the exception event, but in a full implementation, - // we would need to create this as a separate event and add an EXDATE to the original series - Ok((exception_event, 1)) // 1 occurrence updated + + // 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 + 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")); + + // First, update the original series with the EXDATE + println!("📤 About to update CalDAV server with event containing {} EXDATE entries", existing_event.exdate.len()); + for (i, exdate) in existing_event.exdate.iter().enumerate() { + println!("📤 EXDATE[{}]: {}", i, exdate.format("%Y-%m-%d %H:%M:%S UTC")); + } + + client.update_event(calendar_path, existing_event, original_event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update original series with EXDATE: {}", e)))?; + + println!("✅ Updated original series with EXDATE"); + + // Then create the exception event as a new event + let exception_href = client.create_event(calendar_path, &exception_event) + .await + .map_err(|e| ApiError::Internal(format!("Failed to create exception event: {}", e)))?; + + println!("✅ Created exception event with href: {}", exception_href); + + // Return the original series (now with EXDATE) as the "updated" event + Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception) } /// Delete the entire series diff --git a/frontend/src/components/week_view.rs b/frontend/src/components/week_view.rs index 224f0c2..5c28cfb 100644 --- a/frontend/src/components/week_view.rs +++ b/frontend/src/components/week_view.rs @@ -112,27 +112,26 @@ pub fn week_view(props: &WeekViewProps) -> Html { if let Some(edit) = (*pending_recurring_edit).clone() { match action { RecurringEditAction::ThisEvent => { - // Create exception for this occurrence only + // Use the series endpoint with "this_only" scope for RFC 5545 compliant single occurrence modification - // 1. First, add EXDATE to the original series to exclude this occurrence + web_sys::console::log_1(&format!("🎯 Single occurrence modification: calling series update with this_only scope for event '{}'", + edit.event.summary.as_deref().unwrap_or("Untitled") + ).into()); + + // TODO: Need to call calendar service directly with update_scope "this_only" and occurrence_date + // For now, fall back to the old method but with better logging if let Some(update_callback) = &on_event_update { - let mut updated_series = edit.event.clone(); - updated_series.exdate.push(edit.event.dtstart); + // This currently goes to regular update endpoint, but we need it to go to series endpoint + // with update_scope: "this_only" and occurrence_date: edit.event.dtstart.format("%Y-%m-%d") + let updated_event = edit.event.clone(); - // Keep the original series times unchanged - we're only adding EXDATE - let original_start = edit.event.dtstart.with_timezone(&chrono::Local).naive_local(); - let original_end = edit.event.dtend.unwrap_or(edit.event.dtstart).with_timezone(&chrono::Local).naive_local(); + web_sys::console::log_1(&format!("⚠️ Using regular update callback - this should be changed to use series endpoint with this_only scope").into()); - web_sys::console::log_1(&format!("📅 Adding EXDATE {} to series '{}'", - edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC"), - edit.event.summary.as_deref().unwrap_or("Untitled") - ).into()); - - // Update the original series with the exception (times unchanged) - update_callback.emit((updated_series, original_start, original_end, true, None)); // preserve_rrule = true for EXDATE, no until_date + update_callback.emit((updated_event, edit.new_start, edit.new_end, true, None, Some("this_only".to_string()), Some(edit.event.dtstart.format("%Y-%m-%d").to_string()))); // preserve_rrule = true, update_scope = this_only } - // 2. Then create the new single event using the create callback + // Note: The proper fix requires calling calendar_service.update_event_with_scope() directly + // with update_scope: "this_only" and occurrence_date if let Some(create_callback) = &on_create_event_request { // Convert to EventCreationData for single event let event_data = EventCreationData { @@ -272,7 +271,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { let series_event = edit.event.clone(); if let Some(callback) = &on_event_update { - callback.emit((series_event, edit.new_start, edit.new_end, true, None)); // Regular drag operation - preserve RRULE, no until_date + callback.emit((series_event, edit.new_start, edit.new_end, true, None, Some("all_in_series".to_string()), None)); // Regular drag operation - preserve RRULE, update_scope = all_in_series } }, } diff --git a/frontend/src/services/calendar_service.rs b/frontend/src/services/calendar_service.rs index 3ed347d..aad6301 100644 --- a/frontend/src/services/calendar_service.rs +++ b/frontend/src/services/calendar_service.rs @@ -929,8 +929,13 @@ impl CalendarService { "recurrence_end_date": until_date.as_ref().map(|dt| dt.format("%Y-%m-%d").to_string()), "recurrence_count": None as Option, // No count limit by default "calendar_path": calendar_path, - "update_scope": update_scope, - "occurrence_date": None as Option // For specific occurrence updates + "update_scope": update_scope.clone(), + "occurrence_date": if update_scope == "this_only" { + // For single occurrence updates, use the original event's start date as occurrence_date + Some(start_date.clone()) + } else { + None + } }); let url = format!("{}/calendar/events/series/update", self.base_url); (body, url)