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

@@ -1,4 +1,4 @@
use chrono::{DateTime, Utc, NaiveDate, Datelike, Weekday, Duration};
use chrono::{DateTime, Utc, NaiveDate, Datelike, Weekday, Duration, TimeZone};
use serde::{Deserialize, Serialize};
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
@@ -56,6 +56,7 @@ pub struct CalendarEvent {
pub created: Option<DateTime<Utc>>,
pub last_modified: Option<DateTime<Utc>>,
pub recurrence_rule: Option<String>,
pub exception_dates: Vec<DateTime<Utc>>,
pub all_day: bool,
pub reminders: Vec<EventReminder>,
pub etag: Option<String>,
@@ -267,8 +268,26 @@ impl CalendarService {
for event in events {
if let Some(ref rrule) = event.recurrence_rule {
web_sys::console::log_1(&format!("📅 Processing recurring event '{}' with RRULE: {}",
event.summary.as_deref().unwrap_or("Untitled"),
rrule
).into());
// Log if event has exception dates
if !event.exception_dates.is_empty() {
web_sys::console::log_1(&format!("📅 Event '{}' has {} exception dates: {:?}",
event.summary.as_deref().unwrap_or("Untitled"),
event.exception_dates.len(),
event.exception_dates
).into());
}
// Generate occurrences for recurring events
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
web_sys::console::log_1(&format!("📅 Generated {} occurrences for event '{}'",
occurrences.len(),
event.summary.as_deref().unwrap_or("Untitled")
).into());
expanded_events.extend(occurrences);
} else {
// Non-recurring event - add as-is
@@ -290,6 +309,8 @@ impl CalendarService {
// Parse RRULE components
let rrule_upper = rrule.to_uppercase();
web_sys::console::log_1(&format!("🔄 Parsing RRULE: {}", rrule_upper).into());
let components: HashMap<String, String> = rrule_upper
.split(';')
.filter_map(|part| {
@@ -316,25 +337,75 @@ impl CalendarService {
.unwrap_or(100)
.min(365); // Cap at 365 occurrences for performance
// Get UNTIL date if specified
let until_date = components.get("UNTIL")
.and_then(|until_str| {
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format
// Try different parsing approaches for UTC dates
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(until_str.trim_end_matches('Z'), "%Y%m%dT%H%M%S") {
Some(chrono::Utc.from_utc_datetime(&dt))
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
Some(dt.with_timezone(&chrono::Utc))
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
// Handle date-only UNTIL
Some(chrono::Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).unwrap()))
} else {
web_sys::console::log_1(&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into());
None
}
});
if let Some(until) = until_date {
web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into());
}
let start_date = base_event.start.date_naive();
let mut current_date = start_date;
let mut occurrence_count = 0;
// Generate occurrences based on frequency
while current_date <= end_range && occurrence_count < count {
if current_date >= start_range {
// Create occurrence event
let mut occurrence_event = base_event.clone();
// Adjust dates
let days_diff = current_date.signed_duration_since(start_date).num_days();
occurrence_event.start = base_event.start + Duration::days(days_diff);
if let Some(end) = base_event.end {
occurrence_event.end = Some(end + Duration::days(days_diff));
// Check UNTIL constraint - stop if current occurrence is after UNTIL date
if let Some(until) = until_date {
let current_datetime = base_event.start + Duration::days(current_date.signed_duration_since(start_date).num_days());
if current_datetime > until {
web_sys::console::log_1(&format!("🛑 Stopping at {} due to UNTIL {}", current_datetime, until).into());
break;
}
}
if current_date >= start_range {
// Calculate the occurrence datetime
let days_diff = current_date.signed_duration_since(start_date).num_days();
let occurrence_datetime = base_event.start + Duration::days(days_diff);
occurrences.push(occurrence_event);
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exception_dates.iter().any(|exception_date| {
// Compare dates ignoring sub-second precision
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
// Check if dates match (within a minute to handle minor time differences)
let diff = occurrence_naive - exception_naive;
let matches = diff.num_seconds().abs() < 60;
if matches {
web_sys::console::log_1(&format!("🚫 Excluding occurrence {} due to EXDATE {}", occurrence_naive, exception_naive).into());
}
matches
});
if !is_exception {
// Create occurrence event
let mut occurrence_event = base_event.clone();
occurrence_event.start = occurrence_datetime;
if let Some(end) = base_event.end {
occurrence_event.end = Some(end + Duration::days(days_diff));
}
occurrences.push(occurrence_event);
}
}
// Calculate next occurrence date
@@ -534,8 +605,10 @@ impl CalendarService {
token: &str,
password: &str,
calendar_path: String,
event_href: String
) -> Result<(), String> {
event_href: String,
delete_action: String,
occurrence_date: Option<String>
) -> Result<String, String> {
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
@@ -544,7 +617,9 @@ impl CalendarService {
let body = serde_json::json!({
"calendar_path": calendar_path,
"event_href": event_href
"event_href": event_href,
"delete_action": delete_action,
"occurrence_date": occurrence_date
});
let body_string = serde_json::to_string(&body)
@@ -580,7 +655,11 @@ impl CalendarService {
.ok_or("Response text is not a string")?;
if resp.ok() {
Ok(())
// Parse the response to get the message
let response: serde_json::Value = serde_json::from_str(&text_string)
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
let message = response["message"].as_str().unwrap_or("Event deleted successfully").to_string();
Ok(message)
} else {
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
}