Fix recurring event series modification via drag and drop operations
This commit resolves the "Failed to fetch" errors when updating recurring event series through drag operations by implementing proper request sequencing and fixing time parameter handling. Key fixes: - Eliminate HTTP request cancellation by sequencing operations properly - Add global mutex to prevent CalDAV HTTP race conditions - Implement complete RFC 5545-compliant series splitting for "this_and_future" - Fix frontend to pass dragged times instead of original times - Add comprehensive error handling and request timing logs - Backend now handles both UPDATE (add UNTIL) and CREATE (new series) in single request Technical changes: - Frontend: Remove concurrent CREATE request, pass dragged times to backend - Backend: Implement full this_and_future logic with sequential operations - CalDAV: Add mutex serialization and detailed error tracking - Series: Create new series with occurrence date + dragged times 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -242,19 +242,39 @@ pub async fn update_event_series(
|
||||
};
|
||||
|
||||
// Create CalDAV config from token and password
|
||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
||||
println!("🔄 Creating CalDAV config for series update...");
|
||||
let config = match state.auth_service.caldav_config_from_token(&token, &password) {
|
||||
Ok(config) => {
|
||||
println!("✅ CalDAV config created successfully");
|
||||
config
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Failed to create CalDAV config: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Use the parsed frequency for further processing (avoiding unused variable warning)
|
||||
let _freq_for_processing = recurrence_freq;
|
||||
|
||||
// Determine which calendar to search (or search all calendars)
|
||||
println!("🔍 Determining calendar paths...");
|
||||
let calendar_paths = if let Some(ref path) = request.calendar_path {
|
||||
println!("✅ Using specified calendar path: {}", path);
|
||||
vec![path.clone()]
|
||||
} else {
|
||||
client.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||
println!("🔍 Discovering all available calendars...");
|
||||
match client.discover_calendars().await {
|
||||
Ok(paths) => {
|
||||
println!("✅ Discovered {} calendar paths", paths.len());
|
||||
paths
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Failed to discover calendars: {}", e);
|
||||
return Err(ApiError::Internal(format!("Failed to discover calendars: {}", e)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
@@ -262,14 +282,25 @@ pub async fn update_event_series(
|
||||
}
|
||||
|
||||
// Find the series event across all specified calendars
|
||||
println!("🔍 Searching for series UID '{}' across {} calendar(s)...", request.series_uid, calendar_paths.len());
|
||||
let mut existing_event = None;
|
||||
let mut calendar_path = String::new();
|
||||
|
||||
for path in &calendar_paths {
|
||||
if let Ok(Some(event)) = client.fetch_event_by_uid(path, &request.series_uid).await {
|
||||
existing_event = Some(event);
|
||||
calendar_path = path.clone();
|
||||
break;
|
||||
println!("🔍 Searching calendar path: {}", path);
|
||||
match client.fetch_event_by_uid(path, &request.series_uid).await {
|
||||
Ok(Some(event)) => {
|
||||
println!("✅ Found series event in calendar: {}", path);
|
||||
existing_event = Some(event);
|
||||
calendar_path = path.clone();
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("❌ Series event not found in calendar: {}", path);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Error searching calendar {}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,60 +308,88 @@ pub async fn update_event_series(
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", request.series_uid)))?;
|
||||
|
||||
println!("📅 Found series event in calendar: {}", calendar_path);
|
||||
println!("📅 Event details: UID={}, summary={:?}, dtstart={}",
|
||||
existing_event.uid, existing_event.summary, existing_event.dtstart);
|
||||
|
||||
// Parse datetime components for the update
|
||||
// For recurring events, preserve the original series start date and only update the time
|
||||
// to prevent the entire series from shifting to a different date
|
||||
println!("🕒 Parsing datetime components...");
|
||||
let original_start_date = existing_event.dtstart.date_naive();
|
||||
let start_date = original_start_date; // Always use original series date
|
||||
|
||||
// For "this_and_future" updates, use the occurrence date for the new series
|
||||
// For other updates, preserve the original series start date
|
||||
let start_date = if request.update_scope == "this_and_future" && request.occurrence_date.is_some() {
|
||||
let occurrence_date_str = request.occurrence_date.as_ref().unwrap();
|
||||
let occurrence_date = chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string()))?;
|
||||
println!("🕒 Using occurrence date: {} for this_and_future update", occurrence_date);
|
||||
occurrence_date
|
||||
} else {
|
||||
println!("🕒 Using original start date: {} for series update", original_start_date);
|
||||
original_start_date
|
||||
};
|
||||
|
||||
// Log what we're doing for debugging
|
||||
println!("🕒 Parsing requested start date: {}", request.start_date);
|
||||
let requested_date = chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()))?;
|
||||
println!("📅 Preserving original series date {} (requested: {})", original_start_date, requested_date);
|
||||
|
||||
println!("🕒 Determining datetime format (all_day: {})...", request.all_day);
|
||||
let (start_datetime, end_datetime) = if request.all_day {
|
||||
println!("🕒 Processing all-day event...");
|
||||
let start_dt = start_date.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
||||
|
||||
// For all-day events, also preserve the original date pattern
|
||||
let end_date = if !request.end_date.is_empty() {
|
||||
println!("🕒 Calculating end date from original duration...");
|
||||
// Calculate the duration from the original event
|
||||
let original_duration_days = existing_event.dtend
|
||||
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
|
||||
.unwrap_or(0);
|
||||
println!("🕒 Original duration: {} days", original_duration_days);
|
||||
start_date + chrono::Duration::days(original_duration_days)
|
||||
} else {
|
||||
println!("🕒 Using same date for end date");
|
||||
start_date
|
||||
};
|
||||
|
||||
let end_dt = end_date.and_hms_opt(23, 59, 59)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||
|
||||
println!("🕒 All-day datetime range: {} to {}", start_dt, end_dt);
|
||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
||||
} else {
|
||||
println!("🕒 Processing timed event...");
|
||||
let start_time = if !request.start_time.is_empty() {
|
||||
println!("🕒 Parsing start time: {}", request.start_time);
|
||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))?
|
||||
} else {
|
||||
println!("🕒 Using existing event start time");
|
||||
existing_event.dtstart.time()
|
||||
};
|
||||
|
||||
let end_time = if !request.end_time.is_empty() {
|
||||
println!("🕒 Parsing end time: {}", request.end_time);
|
||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))?
|
||||
} else {
|
||||
println!("🕒 Calculating end time from existing event");
|
||||
existing_event.dtend.map(|dt| dt.time()).unwrap_or_else(|| {
|
||||
existing_event.dtstart.time() + chrono::Duration::hours(1)
|
||||
})
|
||||
};
|
||||
|
||||
println!("🕒 Calculated times: start={}, end={}", start_time, end_time);
|
||||
let start_dt = start_date.and_time(start_time);
|
||||
// For timed events, preserve the original date and only update times
|
||||
let end_dt = if !request.end_time.is_empty() {
|
||||
println!("🕒 Using new end time with preserved date");
|
||||
// Use the new end time with the preserved original date
|
||||
start_date.and_time(end_time)
|
||||
} else {
|
||||
println!("🕒 Calculating end time based on original duration");
|
||||
// Calculate end time based on original duration
|
||||
let original_duration = existing_event.dtend
|
||||
.map(|end| end - existing_event.dtstart)
|
||||
@@ -338,37 +397,57 @@ pub async fn update_event_series(
|
||||
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
||||
};
|
||||
|
||||
println!("🕒 Timed datetime range: {} to {}", start_dt, end_dt);
|
||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
||||
};
|
||||
|
||||
// Handle different update scopes
|
||||
println!("🎯 Handling update scope: '{}'", request.update_scope);
|
||||
let (updated_event, occurrences_affected) = match request.update_scope.as_str() {
|
||||
"all_in_series" => {
|
||||
println!("🎯 Processing all_in_series update...");
|
||||
// Update the entire series - modify the master event
|
||||
update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)?
|
||||
},
|
||||
"this_and_future" => {
|
||||
println!("🎯 Processing this_and_future update...");
|
||||
// Split the series: keep past occurrences, create new series from occurrence date
|
||||
update_this_and_future(&mut existing_event, &request, start_datetime, end_datetime)?
|
||||
update_this_and_future(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path).await?
|
||||
},
|
||||
"this_only" => {
|
||||
println!("🎯 Processing this_only update...");
|
||||
// Create exception for single occurrence, keep original series
|
||||
let event_href = existing_event.href.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("Event missing href for single occurrence update".to_string()))?
|
||||
.clone();
|
||||
println!("🎯 Using event href: {}", event_href);
|
||||
update_single_occurrence(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path, &event_href).await?
|
||||
},
|
||||
_ => unreachable!(), // Already validated above
|
||||
};
|
||||
|
||||
println!("✅ Update scope processing completed, {} occurrences affected", occurrences_affected);
|
||||
|
||||
// Update the event on the CalDAV server using the original event's href
|
||||
// Note: For "this_only" updates, the original series was already updated in update_single_occurrence
|
||||
if request.update_scope != "this_only" {
|
||||
println!("📤 Updating event on CalDAV server...");
|
||||
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!("📤 Using event href: {}", event_href);
|
||||
println!("📤 Calendar path: {}", calendar_path);
|
||||
|
||||
match client.update_event(&calendar_path, &updated_event, event_href).await {
|
||||
Ok(_) => {
|
||||
println!("✅ CalDAV update completed successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ CalDAV update failed: {}", e);
|
||||
return Err(ApiError::Internal(format!("Failed to update event series: {}", e)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("📤 Skipping CalDAV update (already handled in this_only scope)");
|
||||
}
|
||||
|
||||
println!("✅ Event series updated successfully with UID: {}", request.series_uid);
|
||||
@@ -602,43 +681,92 @@ fn update_entire_series(
|
||||
}
|
||||
|
||||
/// Update this occurrence and all future occurrences
|
||||
fn update_this_and_future(
|
||||
async fn update_this_and_future(
|
||||
existing_event: &mut VEvent,
|
||||
request: &UpdateEventSeriesRequest,
|
||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
||||
client: &CalDAVClient,
|
||||
calendar_path: &str,
|
||||
) -> Result<(VEvent, u32), ApiError> {
|
||||
// For now, treat this the same as update_entire_series
|
||||
// In a full implementation, this would:
|
||||
// Full implementation:
|
||||
// 1. Add UNTIL to the original series to stop at the occurrence date
|
||||
// 2. Create a new series starting from the occurrence date with updated properties
|
||||
|
||||
// For simplicity, we'll modify the original series with an UNTIL date if occurrence_date is provided
|
||||
if let Some(occurrence_date) = &request.occurrence_date {
|
||||
// Parse occurrence date and set as UNTIL for the original series
|
||||
match chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
||||
Ok(date) => {
|
||||
let until_datetime = date.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?;
|
||||
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
||||
|
||||
// Create modified RRULE with UNTIL clause
|
||||
let mut rrule = existing_event.rrule.clone().unwrap_or_else(|| "FREQ=WEEKLY".to_string());
|
||||
|
||||
// Remove existing UNTIL or COUNT if present
|
||||
let parts: Vec<&str> = rrule.split(';').filter(|part| {
|
||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||
}).collect();
|
||||
|
||||
rrule = format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ"));
|
||||
existing_event.rrule = Some(rrule);
|
||||
},
|
||||
Err(_) => return Err(ApiError::BadRequest("Invalid occurrence date format".to_string())),
|
||||
}
|
||||
}
|
||||
println!("🔄 this_and_future: occurrence_date = {:?}", request.occurrence_date);
|
||||
|
||||
// Then apply the same updates as all_in_series for the rest of the properties
|
||||
update_entire_series(existing_event, request, start_datetime, end_datetime)
|
||||
let occurrence_date = request.occurrence_date.as_ref()
|
||||
.ok_or_else(|| ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string()))?;
|
||||
|
||||
// Parse occurrence date
|
||||
let occurrence_date_parsed = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format".to_string()))?;
|
||||
|
||||
// Step 1: Add UNTIL to the original series to stop before the occurrence date
|
||||
let until_datetime = occurrence_date_parsed.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?;
|
||||
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
||||
|
||||
// Create modified RRULE with UNTIL clause for the original series
|
||||
let original_rrule = existing_event.rrule.clone().unwrap_or_else(|| "FREQ=WEEKLY".to_string());
|
||||
let parts: Vec<&str> = original_rrule.split(';').filter(|part| {
|
||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||
}).collect();
|
||||
|
||||
existing_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ")));
|
||||
println!("🔄 this_and_future: Updated original series RRULE: {:?}", existing_event.rrule);
|
||||
|
||||
// Step 2: Create a new series starting from the occurrence date with updated properties
|
||||
let new_series_uid = format!("series-{}", uuid::Uuid::new_v4());
|
||||
let mut new_series = existing_event.clone();
|
||||
|
||||
// Update the new series with new properties
|
||||
new_series.uid = new_series_uid.clone();
|
||||
new_series.dtstart = start_datetime;
|
||||
new_series.dtend = Some(end_datetime);
|
||||
new_series.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
||||
new_series.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) };
|
||||
new_series.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) };
|
||||
|
||||
new_series.status = Some(match request.status.to_lowercase().as_str() {
|
||||
"tentative" => EventStatus::Tentative,
|
||||
"cancelled" => EventStatus::Cancelled,
|
||||
_ => EventStatus::Confirmed,
|
||||
});
|
||||
|
||||
new_series.class = Some(match request.class.to_lowercase().as_str() {
|
||||
"private" => EventClass::Private,
|
||||
"confidential" => EventClass::Confidential,
|
||||
_ => EventClass::Public,
|
||||
});
|
||||
|
||||
new_series.priority = request.priority;
|
||||
|
||||
// Reset the RRULE for the new series (remove UNTIL)
|
||||
let new_rrule_parts: Vec<&str> = original_rrule.split(';').filter(|part| {
|
||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||
}).collect();
|
||||
new_series.rrule = Some(new_rrule_parts.join(";"));
|
||||
|
||||
// Update timestamps
|
||||
let now = chrono::Utc::now();
|
||||
new_series.dtstamp = now;
|
||||
new_series.created = Some(now);
|
||||
new_series.last_modified = Some(now);
|
||||
new_series.href = None; // Will be set when created
|
||||
|
||||
println!("🔄 this_and_future: Creating new series with UID: {}", new_series_uid);
|
||||
println!("🔄 this_and_future: New series RRULE: {:?}", new_series.rrule);
|
||||
|
||||
// Create the new series on CalDAV server
|
||||
client.create_event(calendar_path, &new_series)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create new series: {}", e)))?;
|
||||
|
||||
println!("✅ this_and_future: Created new series successfully");
|
||||
|
||||
// Return the original event (with UNTIL added) - it will be updated by the main handler
|
||||
Ok((existing_event.clone(), 2)) // 2 operations: updated original + created new series
|
||||
}
|
||||
|
||||
/// Update only a single occurrence (create an exception)
|
||||
|
||||
Reference in New Issue
Block a user