Implement complete recurring event deletion with EXDATE and RRULE UNTIL support
Frontend Changes: - Add DeleteAction enum with DeleteThis, DeleteFollowing, DeleteSeries options - Update EventContextMenu to show different delete options for recurring events - Add exception_dates field to CalendarEvent struct - Fix occurrence generation to respect EXDATE exclusions - Add comprehensive RRULE parsing with UNTIL date support - Fix UNTIL date parsing to handle backend format (YYYYMMDDTHHMMSSZ) - Enhanced debugging for RRULE processing and occurrence generation Backend Changes: - Add exception_dates field to CalendarEvent struct with EXDATE parsing/generation - Implement update_event method for CalDAV client - Add fetch_event_by_href helper function - Update DeleteEventRequest model with delete_action and occurrence_date fields - Implement proper delete_this logic with EXDATE addition - Implement delete_following logic with RRULE UNTIL modification - Add comprehensive logging for delete operations CalDAV Integration: - Proper EXDATE generation in iCal format for excluded occurrences - RRULE modification with UNTIL clause for partial series deletion - Event updating via CalDAV PUT operations - Full iCal RFC 5545 compliance for recurring event modifications 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use chrono::Datelike;
|
||||
use chrono::{Datelike, TimeZone};
|
||||
|
||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse}};
|
||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||
@@ -350,12 +350,37 @@ pub async fn delete_calendar(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Helper function to fetch an event by its href
|
||||
async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_href: &str) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
||||
// Get all events from the calendar
|
||||
let events = client.fetch_events(calendar_path).await?;
|
||||
|
||||
// Find the event with matching href
|
||||
for event in events {
|
||||
if let Some(href) = &event.href {
|
||||
// Compare the href (handle both full URLs and relative paths)
|
||||
let href_matches = if event_href.starts_with("http") {
|
||||
href == event_href
|
||||
} else {
|
||||
href.ends_with(event_href) || href == event_href
|
||||
};
|
||||
|
||||
if href_matches {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn delete_event(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DeleteEventRequest>,
|
||||
) -> Result<Json<DeleteEventResponse>, ApiError> {
|
||||
println!("🗑️ Delete event request received: calendar_path='{}', event_href='{}'", request.calendar_path, request.event_href);
|
||||
println!("🗑️ Delete event request received: calendar_path='{}', event_href='{}', action='{}'",
|
||||
request.calendar_path, request.event_href, request.delete_action);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
@@ -373,15 +398,146 @@ pub async fn delete_event(
|
||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Delete the event
|
||||
client.delete_event(&request.calendar_path, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||
// Handle different delete actions
|
||||
match request.delete_action.as_str() {
|
||||
"delete_this" => {
|
||||
// Add EXDATE to exclude this specific occurrence
|
||||
if let Some(occurrence_date) = &request.occurrence_date {
|
||||
println!("🔄 Adding EXDATE for occurrence: {}", occurrence_date);
|
||||
|
||||
// First, fetch the current event to get its data
|
||||
match fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await {
|
||||
Ok(Some(mut event)) => {
|
||||
// Check if it has recurrence rule
|
||||
if event.recurrence_rule.is_some() {
|
||||
// Parse the occurrence date and calculate the correct EXDATE datetime
|
||||
if let Ok(occurrence_date_parsed) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
||||
// Calculate the exact datetime for this occurrence by using the original event's time
|
||||
let original_time = event.start.time();
|
||||
let occurrence_datetime = occurrence_date_parsed.and_time(original_time);
|
||||
let exception_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime);
|
||||
|
||||
println!("🔄 Original event start: {}", event.start);
|
||||
println!("🔄 Occurrence date: {}", occurrence_date);
|
||||
println!("🔄 Calculated EXDATE: {}", exception_utc);
|
||||
|
||||
// Add the exception date
|
||||
event.exception_dates.push(exception_utc);
|
||||
|
||||
// Update the event with the new EXDATE
|
||||
client.update_event(&request.calendar_path, &event, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?;
|
||||
|
||||
Ok(Json(DeleteEventResponse {
|
||||
success: true,
|
||||
message: "Individual occurrence excluded from series successfully".to_string(),
|
||||
}))
|
||||
} else {
|
||||
Err(ApiError::BadRequest("Invalid occurrence date format".to_string()))
|
||||
}
|
||||
} else {
|
||||
// Not a recurring event, just delete it completely
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
},
|
||||
Ok(None) => Err(ApiError::NotFound("Event not found".to_string())),
|
||||
Err(e) => Err(ApiError::Internal(format!("Failed to fetch event: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::BadRequest("Occurrence date is required for 'delete_this' action".to_string()))
|
||||
}
|
||||
},
|
||||
"delete_following" => {
|
||||
// Modify RRULE to end before the selected occurrence
|
||||
if let Some(occurrence_date) = &request.occurrence_date {
|
||||
println!("🔄 Modifying RRULE to end before: {}", occurrence_date);
|
||||
|
||||
// First, fetch the current event to get its data
|
||||
match fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await {
|
||||
Ok(Some(mut event)) => {
|
||||
// Check if it has recurrence rule
|
||||
if let Some(ref rrule) = event.recurrence_rule {
|
||||
// Parse the occurrence date and calculate the UNTIL date
|
||||
if let Ok(occurrence_date_parsed) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
||||
// Calculate the datetime for the occurrence we want to stop before
|
||||
let original_time = event.start.time();
|
||||
let occurrence_datetime = occurrence_date_parsed.and_time(original_time);
|
||||
let occurrence_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime);
|
||||
|
||||
// UNTIL should be the last occurrence we want to keep (day before the selected occurrence)
|
||||
let until_date = occurrence_utc - chrono::Duration::days(1);
|
||||
let until_str = until_date.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
|
||||
println!("🔄 Original event start: {}", event.start);
|
||||
println!("🔄 Occurrence to stop before: {}", occurrence_utc);
|
||||
println!("🔄 UNTIL date (last to keep): {}", until_date);
|
||||
println!("🔄 UNTIL string: {}", until_str);
|
||||
println!("🔄 Original RRULE: {}", rrule);
|
||||
|
||||
// Modify the RRULE to add UNTIL clause
|
||||
let new_rrule = if rrule.contains("UNTIL=") {
|
||||
// Replace existing UNTIL
|
||||
regex::Regex::new(r"UNTIL=[^;]+").unwrap().replace(rrule, &format!("UNTIL={}", until_str)).to_string()
|
||||
} else {
|
||||
// Add UNTIL clause
|
||||
format!("{};UNTIL={}", rrule, until_str)
|
||||
};
|
||||
|
||||
println!("🔄 New RRULE: {}", new_rrule);
|
||||
event.recurrence_rule = 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: "Following occurrences removed from series successfully".to_string(),
|
||||
}))
|
||||
} else {
|
||||
Err(ApiError::BadRequest("Invalid occurrence date format".to_string()))
|
||||
}
|
||||
} else {
|
||||
// Not a recurring event, just delete it completely
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
},
|
||||
Ok(None) => Err(ApiError::NotFound("Event not found".to_string())),
|
||||
Err(e) => Err(ApiError::Internal(format!("Failed to fetch event: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::BadRequest("Occurrence date is required for 'delete_following' action".to_string()))
|
||||
}
|
||||
},
|
||||
"delete_series" | _ => {
|
||||
// Delete the entire event/series (current default behavior)
|
||||
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(),
|
||||
}))
|
||||
Ok(Json(DeleteEventResponse {
|
||||
success: true,
|
||||
message: "Event series deleted successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_event(
|
||||
@@ -587,6 +743,7 @@ pub async fn create_event(
|
||||
created: Some(chrono::Utc::now()),
|
||||
last_modified: Some(chrono::Utc::now()),
|
||||
recurrence_rule,
|
||||
exception_dates: Vec::new(), // No exception dates for new events
|
||||
all_day: request.all_day,
|
||||
reminders,
|
||||
etag: None,
|
||||
|
||||
Reference in New Issue
Block a user