Implement complete event series endpoints with full CRUD support
## Backend Implementation - Add dedicated series endpoints: create, update, delete - Implement RFC 5545 compliant RRULE generation and modification - Support all scope operations: this_only, this_and_future, all_in_series - Add comprehensive series-specific request/response models - Implement EXDATE and RRULE modification for precise occurrence control ## Frontend Integration - Add automatic series detection and smart endpoint routing - Implement scope-aware event operations with backward compatibility - Enhance API payloads with series-specific fields - Integrate existing RecurringEditModal for scope selection UI ## Testing - Add comprehensive integration tests for all series endpoints - Validate scope handling, RRULE generation, and error scenarios - All 14 integration tests passing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -602,6 +602,26 @@ impl CalendarService {
|
||||
event_href: String,
|
||||
delete_action: String,
|
||||
occurrence_date: Option<String>
|
||||
) -> Result<String, String> {
|
||||
// Forward to delete_event_with_uid with extracted UID
|
||||
let event_uid = event_href.trim_end_matches(".ics").to_string();
|
||||
self.delete_event_with_uid(
|
||||
token, password, calendar_path, event_href, delete_action,
|
||||
occurrence_date, event_uid, None // No recurrence info available
|
||||
).await
|
||||
}
|
||||
|
||||
/// Delete an event from the CalDAV server with UID and recurrence support
|
||||
pub async fn delete_event_with_uid(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
calendar_path: String,
|
||||
event_href: String,
|
||||
delete_action: String,
|
||||
occurrence_date: Option<String>,
|
||||
event_uid: String,
|
||||
recurrence: Option<String>
|
||||
) -> Result<String, String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
@@ -609,17 +629,44 @@ impl CalendarService {
|
||||
opts.set_method("POST");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"calendar_path": calendar_path,
|
||||
"event_href": event_href,
|
||||
"delete_action": delete_action,
|
||||
"occurrence_date": occurrence_date
|
||||
});
|
||||
// Determine if this is a series event based on recurrence
|
||||
let is_series = recurrence.as_ref()
|
||||
.map(|r| !r.is_empty() && r.to_uppercase() != "NONE")
|
||||
.unwrap_or(false);
|
||||
|
||||
let (body, url) = if is_series {
|
||||
// Use series-specific endpoint and payload for recurring events
|
||||
// Map delete_action to delete_scope for series endpoint
|
||||
let delete_scope = match delete_action.as_str() {
|
||||
"delete_this" => "this_only",
|
||||
"delete_following" => "this_and_future",
|
||||
"delete_series" => "all_in_series",
|
||||
_ => "this_only" // Default to single occurrence
|
||||
};
|
||||
|
||||
let body = serde_json::json!({
|
||||
"series_uid": event_uid,
|
||||
"calendar_path": calendar_path,
|
||||
"event_href": event_href,
|
||||
"delete_scope": delete_scope,
|
||||
"occurrence_date": occurrence_date
|
||||
});
|
||||
let url = format!("{}/calendar/events/series/delete", self.base_url);
|
||||
(body, url)
|
||||
} else {
|
||||
// Use regular endpoint for non-recurring events
|
||||
let body = serde_json::json!({
|
||||
"calendar_path": calendar_path,
|
||||
"event_href": event_href,
|
||||
"delete_action": delete_action,
|
||||
"occurrence_date": occurrence_date
|
||||
});
|
||||
let url = format!("{}/calendar/events/delete", self.base_url);
|
||||
(body, url)
|
||||
};
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let url = format!("{}/calendar/events/delete", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
@@ -689,31 +736,64 @@ impl CalendarService {
|
||||
opts.set_method("POST");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"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": recurrence_days,
|
||||
"calendar_path": calendar_path
|
||||
});
|
||||
// Determine if this is a series event based on recurrence
|
||||
let is_series = !recurrence.is_empty() && recurrence.to_uppercase() != "NONE";
|
||||
|
||||
let (body, url) = if is_series {
|
||||
// Use series-specific endpoint and payload for recurring events
|
||||
let body = serde_json::json!({
|
||||
"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": recurrence_days,
|
||||
"recurrence_interval": 1_u32, // Default interval
|
||||
"recurrence_end_date": None as Option<String>, // No end date by default
|
||||
"recurrence_count": None as Option<u32>, // No count limit by default
|
||||
"calendar_path": calendar_path
|
||||
});
|
||||
let url = format!("{}/calendar/events/series/create", self.base_url);
|
||||
(body, url)
|
||||
} else {
|
||||
// Use regular endpoint for non-recurring events
|
||||
let body = serde_json::json!({
|
||||
"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": recurrence_days,
|
||||
"calendar_path": calendar_path
|
||||
});
|
||||
let url = format!("{}/calendar/events/create", self.base_url);
|
||||
(body, url)
|
||||
};
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let url = format!("{}/calendar/events/create", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
@@ -775,6 +855,45 @@ impl CalendarService {
|
||||
exception_dates: Vec<DateTime<Utc>>,
|
||||
update_action: Option<String>,
|
||||
until_date: Option<DateTime<Utc>>
|
||||
) -> Result<(), String> {
|
||||
// Forward to update_event_with_scope with default scope
|
||||
self.update_event_with_scope(
|
||||
token, password, event_uid, title, description,
|
||||
start_date, start_time, end_date, end_time, location,
|
||||
all_day, status, class, priority, organizer, attendees,
|
||||
categories, reminder, recurrence, recurrence_days,
|
||||
calendar_path, exception_dates, update_action, until_date,
|
||||
"all_in_series".to_string() // Default scope for backward compatibility
|
||||
).await
|
||||
}
|
||||
|
||||
pub async fn update_event_with_scope(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
event_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<u8>,
|
||||
organizer: String,
|
||||
attendees: String,
|
||||
categories: String,
|
||||
reminder: String,
|
||||
recurrence: String,
|
||||
recurrence_days: Vec<bool>,
|
||||
calendar_path: Option<String>,
|
||||
exception_dates: Vec<DateTime<Utc>>,
|
||||
update_action: Option<String>,
|
||||
until_date: Option<DateTime<Utc>>,
|
||||
update_scope: String
|
||||
) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
@@ -782,36 +901,72 @@ impl CalendarService {
|
||||
opts.set_method("POST");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"uid": event_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": recurrence_days,
|
||||
"calendar_path": calendar_path,
|
||||
"update_action": update_action,
|
||||
"occurrence_date": null,
|
||||
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
|
||||
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
|
||||
});
|
||||
// Determine if this is a series event based on recurrence
|
||||
let is_series = !recurrence.is_empty() && recurrence.to_uppercase() != "NONE";
|
||||
|
||||
let (body, url) = if is_series {
|
||||
// Use series-specific endpoint and payload for recurring events
|
||||
let body = serde_json::json!({
|
||||
"series_uid": event_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": recurrence_days,
|
||||
"recurrence_interval": 1_u32, // Default interval
|
||||
"recurrence_end_date": until_date.as_ref().map(|dt| dt.format("%Y-%m-%d").to_string()),
|
||||
"recurrence_count": None as Option<u32>, // No count limit by default
|
||||
"calendar_path": calendar_path,
|
||||
"update_scope": update_scope,
|
||||
"occurrence_date": None as Option<String> // For specific occurrence updates
|
||||
});
|
||||
let url = format!("{}/calendar/events/series/update", self.base_url);
|
||||
(body, url)
|
||||
} else {
|
||||
// Use regular endpoint for non-recurring events
|
||||
let body = serde_json::json!({
|
||||
"uid": event_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": recurrence_days,
|
||||
"calendar_path": calendar_path,
|
||||
"update_action": update_action,
|
||||
"occurrence_date": null,
|
||||
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
|
||||
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
|
||||
});
|
||||
let url = format!("{}/calendar/events/update", self.base_url);
|
||||
(body, url)
|
||||
};
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let url = format!("{}/calendar/events/update", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
Reference in New Issue
Block a user