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:
Connor Johnstone
2025-08-29 09:18:35 -04:00
parent e1578ed11c
commit 1b57adab98
7 changed files with 440 additions and 40 deletions

View File

@@ -50,6 +50,9 @@ pub struct CalendarEvent {
/// Recurrence rule (RRULE)
pub recurrence_rule: Option<String>,
/// Exception dates - dates to exclude from recurrence (EXDATE)
pub exception_dates: Vec<DateTime<Utc>>,
/// All-day event flag
pub all_day: bool,
@@ -361,6 +364,9 @@ impl CalDAVClient {
let last_modified = properties.get("LAST-MODIFIED")
.and_then(|s| self.parse_datetime(s, None).ok());
// Parse exception dates (EXDATE)
let exception_dates = self.parse_exception_dates(&event);
Ok(CalendarEvent {
uid,
summary: properties.get("SUMMARY").cloned(),
@@ -377,6 +383,7 @@ impl CalDAVClient {
created,
last_modified,
recurrence_rule: properties.get("RRULE").cloned(),
exception_dates,
all_day,
reminders: self.parse_alarms(&event)?,
etag: None, // Set by caller
@@ -591,6 +598,28 @@ impl CalDAVClient {
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
}
/// Parse EXDATE properties from an iCal event
fn parse_exception_dates(&self, event: &ical::parser::ical::component::IcalEvent) -> Vec<DateTime<Utc>> {
let mut exception_dates = Vec::new();
// Look for EXDATE properties
for property in &event.properties {
if property.name.to_uppercase() == "EXDATE" {
if let Some(value) = &property.value {
// EXDATE can contain multiple comma-separated dates
for date_str in value.split(',') {
// Try to parse the date (the parse_datetime method will handle different formats)
if let Ok(date) = self.parse_datetime(date_str.trim(), None) {
exception_dates.push(date);
}
}
}
}
}
exception_dates
}
/// 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> {
// Sanitize calendar name for URL path
@@ -758,6 +787,56 @@ 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> {
// 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");
format!("{}{}", base_url, event_href)
} else {
// Event href is just a filename, combine with calendar path
let clean_path = if calendar_path.starts_with("/dav.php") {
calendar_path.trim_start_matches("/dav.php")
} else {
calendar_path
};
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.exception_dates.len());
let response = self.http_client
.put(&full_url)
.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)
.send()
.await
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
println!("Event update response status: {}", response.status());
if response.status().is_success() || response.status().as_u16() == 201 || response.status().as_u16() == 204 {
println!("✅ Event updated successfully");
Ok(())
} else {
let status = response.status();
let error_body = response.text().await.unwrap_or_default();
println!("❌ Event update failed: {} - {}", status, error_body);
Err(CalDAVError::ServerError(status.as_u16()))
}
}
/// Generate iCalendar data for a CalendarEvent
fn generate_ical_event(&self, event: &CalendarEvent) -> Result<String, CalDAVError> {
let now = chrono::Utc::now();
@@ -871,6 +950,15 @@ impl CalDAVClient {
ical.push_str(&format!("RRULE:{}\r\n", rrule));
}
// Exception dates (EXDATE)
for exception_date in &event.exception_dates {
if event.all_day {
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");

View File

@@ -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,

View File

@@ -62,6 +62,8 @@ pub struct DeleteCalendarResponse {
pub struct DeleteEventRequest {
pub calendar_path: String,
pub event_href: String,
pub delete_action: String, // "delete_this", "delete_following", or "delete_series"
pub occurrence_date: Option<String>, // ISO date string for the specific occurrence
}
#[derive(Debug, Serialize)]