Fix singleton to series conversion with complete RRULE parameter support
All checks were successful
Build and Push Docker Image / docker (push) Successful in 2m16s
All checks were successful
Build and Push Docker Image / docker (push) Successful in 2m16s
- Add new context menu callback for singleton events to avoid series pipeline - Implement complete RRULE construction with INTERVAL, COUNT, and UNTIL parameters - Update frontend service methods to pass recurrence parameters correctly - Add missing recurrence fields to backend UpdateEventRequest model - Fix parameter ordering in frontend method calls - Ensure singleton→series conversion uses single event pipeline initially This resolves issues where converting singleton events to recurring series would not respect recurrence interval (every N days), count (N occurrences), or until date parameters. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -762,6 +762,8 @@ pub async fn update_event(
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||
|
||||
// Parse dates and times as local times (no UTC conversion)
|
||||
println!("🕐 UPDATE: Received start_date: '{}', start_time: '{}', timezone: '{}'",
|
||||
request.start_date, request.start_time, request.timezone);
|
||||
let start_datetime =
|
||||
parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||
@@ -828,6 +830,99 @@ pub async fn update_event(
|
||||
|
||||
event.priority = request.priority;
|
||||
|
||||
// Process recurrence information to set RRULE
|
||||
println!("🔄 Processing recurrence: '{}'", request.recurrence);
|
||||
println!("🔄 Recurrence days: {:?}", request.recurrence_days);
|
||||
println!("🔄 Recurrence interval: {:?}", request.recurrence_interval);
|
||||
println!("🔄 Recurrence count: {:?}", request.recurrence_count);
|
||||
println!("🔄 Recurrence end date: {:?}", request.recurrence_end_date);
|
||||
|
||||
let rrule = if request.recurrence.starts_with("FREQ=") {
|
||||
// Frontend sent a complete RRULE string, use it directly
|
||||
if request.recurrence.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.recurrence.clone())
|
||||
}
|
||||
} else {
|
||||
// Parse recurrence type and build RRULE with all parameters
|
||||
let base_rrule = match request.recurrence.to_uppercase().as_str() {
|
||||
"DAILY" => Some("FREQ=DAILY".to_string()),
|
||||
"WEEKLY" => {
|
||||
// Handle weekly recurrence with optional BYDAY parameter
|
||||
let mut rrule = "FREQ=WEEKLY".to_string();
|
||||
|
||||
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||
if request.recurrence_days.len() == 7 {
|
||||
let selected_days: Vec<&str> = request
|
||||
.recurrence_days
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &selected)| {
|
||||
if selected {
|
||||
Some(match i {
|
||||
0 => "SU", // Sunday
|
||||
1 => "MO", // Monday
|
||||
2 => "TU", // Tuesday
|
||||
3 => "WE", // Wednesday
|
||||
4 => "TH", // Thursday
|
||||
5 => "FR", // Friday
|
||||
6 => "SA", // Saturday
|
||||
_ => return None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !selected_days.is_empty() {
|
||||
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
Some(rrule)
|
||||
}
|
||||
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||
"NONE" | "" => None, // Clear any existing recurrence
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Add INTERVAL, COUNT, and UNTIL parameters if specified
|
||||
if let Some(mut rrule_string) = base_rrule {
|
||||
// Add INTERVAL parameter (every N days/weeks/months/years)
|
||||
if let Some(interval) = request.recurrence_interval {
|
||||
if interval > 1 {
|
||||
rrule_string = format!("{};INTERVAL={}", rrule_string, interval);
|
||||
}
|
||||
}
|
||||
|
||||
// Add COUNT or UNTIL parameter (but not both - COUNT takes precedence)
|
||||
if let Some(count) = request.recurrence_count {
|
||||
rrule_string = format!("{};COUNT={}", rrule_string, count);
|
||||
} else if let Some(end_date) = &request.recurrence_end_date {
|
||||
// Convert YYYY-MM-DD to YYYYMMDD format for UNTIL
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
|
||||
rrule_string = format!("{};UNTIL={}", rrule_string, date.format("%Y%m%d"));
|
||||
}
|
||||
}
|
||||
|
||||
Some(rrule_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
event.rrule = rrule.clone();
|
||||
println!("🔄 Set event RRULE to: {:?}", rrule);
|
||||
|
||||
if rrule.is_some() {
|
||||
println!("✨ Converting singleton event to recurring series with RRULE: {}", rrule.as_ref().unwrap());
|
||||
} else {
|
||||
println!("📝 Event remains non-recurring (no RRULE set)");
|
||||
}
|
||||
|
||||
// Update the event on the CalDAV server
|
||||
println!(
|
||||
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||
|
||||
@@ -229,6 +229,8 @@ pub async fn update_event_series(
|
||||
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
|
||||
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
|
||||
);
|
||||
println!("🕐 SERIES: Received start_date: '{}', start_time: '{}', timezone: '{}'",
|
||||
request.start_date, request.start_time, request.timezone);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
@@ -963,6 +965,8 @@ async fn update_single_occurrence(
|
||||
// Update the modified properties from the request
|
||||
exception_event.dtstart = start_datetime;
|
||||
exception_event.dtend = Some(end_datetime);
|
||||
exception_event.dtstart_tzid = Some(request.timezone.clone());
|
||||
exception_event.dtend_tzid = Some(request.timezone.clone());
|
||||
exception_event.summary = if request.title.trim().is_empty() {
|
||||
existing_event.summary.clone() // Keep original if empty
|
||||
} else {
|
||||
|
||||
@@ -147,6 +147,9 @@ pub struct UpdateEventRequest {
|
||||
pub reminder: String, // reminder type
|
||||
pub recurrence: String, // recurrence type
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||
pub update_action: Option<String>, // "update_series" for recurring events
|
||||
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||
|
||||
@@ -723,8 +723,12 @@ pub fn App() -> Html {
|
||||
crate::components::event_form::RecurrenceType::Monthly |
|
||||
crate::components::event_form::RecurrenceType::Yearly);
|
||||
|
||||
web_sys::console::log_1(&format!("🐛 FRONTEND DEBUG: is_recurring={}, edit_scope={:?}, original_uid={:?}",
|
||||
is_recurring, event_data_for_update.edit_scope, event_data_for_update.original_uid).into());
|
||||
|
||||
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
|
||||
// Use series update endpoint for recurring events
|
||||
// Only use series endpoint for existing recurring events being edited
|
||||
// Singleton→series conversion should use regular update_event endpoint
|
||||
let edit_action = event_data_for_update.edit_scope.unwrap();
|
||||
let scope = match edit_action {
|
||||
crate::components::EditAction::EditAll => "all_in_series".to_string(),
|
||||
@@ -754,12 +758,13 @@ pub fn App() -> Html {
|
||||
params.14, // reminder
|
||||
params.15, // recurrence
|
||||
params.16, // recurrence_days
|
||||
params.17, // recurrence_interval
|
||||
params.18, // recurrence_count
|
||||
params.19, // recurrence_until
|
||||
params.17, // calendar_path
|
||||
params.20, // calendar_path
|
||||
scope,
|
||||
event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date
|
||||
params.20, // timezone
|
||||
params.21, // timezone
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
@@ -786,11 +791,11 @@ pub fn App() -> Html {
|
||||
params.14, // reminder
|
||||
params.15, // recurrence
|
||||
params.16, // recurrence_days
|
||||
params.17, // calendar_path
|
||||
vec![], // exception_dates - empty for simple updates
|
||||
None, // update_action - None for regular updates
|
||||
None, // until_date - None for regular updates
|
||||
params.20, // timezone
|
||||
params.17, // recurrence_interval
|
||||
params.18, // recurrence_count
|
||||
params.19, // recurrence_until
|
||||
params.20, // calendar_path
|
||||
params.21, // timezone
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -874,10 +879,11 @@ pub fn App() -> Html {
|
||||
params.14, // reminder
|
||||
params.15, // recurrence
|
||||
params.16, // recurrence_days
|
||||
params.17, // recurrence_interval
|
||||
params.18, // recurrence_count
|
||||
params.19, // recurrence_until
|
||||
params.17, // calendar_path
|
||||
params.20, // timezone
|
||||
params.20, // calendar_path
|
||||
params.21, // timezone
|
||||
)
|
||||
.await;
|
||||
match create_result {
|
||||
@@ -1042,12 +1048,13 @@ pub fn App() -> Html {
|
||||
original_event.categories.join(","),
|
||||
reminder_str.clone(),
|
||||
recurrence_str.clone(),
|
||||
vec![false; 7],
|
||||
None,
|
||||
None,
|
||||
original_event.calendar_path.clone(),
|
||||
scope.clone(),
|
||||
occurrence_date,
|
||||
vec![false; 7], // recurrence_days
|
||||
1, // recurrence_interval - default for drag-and-drop
|
||||
None, // recurrence_count
|
||||
None, // recurrence_until
|
||||
original_event.calendar_path.clone(), // calendar_path
|
||||
scope.clone(), // update_scope
|
||||
occurrence_date, // occurrence_date
|
||||
{
|
||||
// Get timezone offset
|
||||
let date = js_sys::Date::new_0();
|
||||
@@ -1055,7 +1062,7 @@ pub fn App() -> Html {
|
||||
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
|
||||
let minutes = (timezone_offset as i32).abs() % 60;
|
||||
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
|
||||
},
|
||||
}, // timezone
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -1099,14 +1106,10 @@ pub fn App() -> Html {
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
recurrence_days,
|
||||
1, // recurrence_interval - default to 1 for drag-and-drop
|
||||
None, // recurrence_count - preserve existing
|
||||
None, // recurrence_until - preserve existing
|
||||
original_event.calendar_path,
|
||||
original_event.exdate.clone(),
|
||||
if preserve_rrule {
|
||||
Some("update_series".to_string())
|
||||
} else {
|
||||
Some("this_and_future".to_string())
|
||||
},
|
||||
until_date,
|
||||
{
|
||||
// Get timezone offset
|
||||
let date = js_sys::Date::new_0();
|
||||
@@ -1639,6 +1642,19 @@ pub fn App() -> Html {
|
||||
}
|
||||
}
|
||||
})}
|
||||
on_edit_singleton={Callback::from({
|
||||
let event_context_menu_event = event_context_menu_event.clone();
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
let create_event_modal_open = create_event_modal_open.clone();
|
||||
let event_edit_scope = event_edit_scope.clone();
|
||||
move |event: VEvent| {
|
||||
// For singleton events, open edit modal WITHOUT setting edit_scope
|
||||
event_context_menu_event.set(Some(event));
|
||||
event_edit_scope.set(None); // Explicitly set to None for singleton edits
|
||||
event_context_menu_open.set(false);
|
||||
create_event_modal_open.set(true);
|
||||
}
|
||||
})}
|
||||
on_view_details={Callback::from({
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
let view_event_modal_open = view_event_modal_open.clone();
|
||||
|
||||
@@ -291,8 +291,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
|
||||
|
||||
// Recurrence - Parse RRULE if present
|
||||
recurrence: if let Some(ref rrule_str) = event.rrule {
|
||||
web_sys::console::log_1(&format!("🐛 MODAL DEBUG: Event has RRULE: {}", rrule_str).into());
|
||||
parse_rrule_frequency(rrule_str)
|
||||
} else {
|
||||
web_sys::console::log_1(&"🐛 MODAL DEBUG: Event has no RRULE (singleton)".into());
|
||||
RecurrenceType::None
|
||||
},
|
||||
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
|
||||
@@ -337,7 +339,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
|
||||
},
|
||||
|
||||
// Edit tracking
|
||||
edit_scope: None, // Will be set by the modal after creation
|
||||
edit_scope: {
|
||||
web_sys::console::log_1(&"🐛 MODAL DEBUG: Setting edit_scope to None for vevent_to_creation_data".into());
|
||||
None // Will be set by the modal after creation
|
||||
},
|
||||
changed_fields: vec![],
|
||||
original_uid: Some(event.uid.clone()), // Preserve original UID for editing
|
||||
occurrence_date: Some(start_local.date()), // The occurrence date being edited
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct EventContextMenuProps {
|
||||
pub on_delete: Callback<DeleteAction>,
|
||||
pub on_view_details: Callback<VEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_edit_singleton: Callback<VEvent>, // New callback for editing singleton events without scope
|
||||
}
|
||||
|
||||
#[function_component(EventContextMenu)]
|
||||
@@ -109,6 +110,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let create_singleton_edit_callback = {
|
||||
let on_edit_singleton = props.on_edit_singleton.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
let event = props.event.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(event) = &event {
|
||||
on_edit_singleton.emit(event.clone());
|
||||
}
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let create_delete_callback = |action: DeleteAction| {
|
||||
let on_delete = props.on_delete.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
@@ -160,9 +173,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
// Regular single events - show edit option
|
||||
// Regular single events - show edit option without setting edit scope
|
||||
html! {
|
||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||
<div class="context-menu-item" onclick={create_singleton_edit_callback}>
|
||||
{"Edit Event"}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -148,9 +148,10 @@ impl EventCreationData {
|
||||
String, // reminder
|
||||
String, // recurrence
|
||||
Vec<bool>, // recurrence_days
|
||||
Option<String>, // calendar_path
|
||||
u32, // recurrence_interval
|
||||
Option<u32>, // recurrence_count
|
||||
Option<String>, // recurrence_until
|
||||
Option<String>, // calendar_path
|
||||
String, // timezone
|
||||
) {
|
||||
|
||||
@@ -197,9 +198,10 @@ impl EventCreationData {
|
||||
format!("{:?}", self.reminder),
|
||||
format!("{:?}", self.recurrence),
|
||||
self.recurrence_days.clone(),
|
||||
self.selected_calendar.clone(),
|
||||
self.recurrence_interval,
|
||||
self.recurrence_count,
|
||||
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||
self.selected_calendar.clone(),
|
||||
timezone,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ impl CalendarService {
|
||||
reminder: String,
|
||||
recurrence: String,
|
||||
recurrence_days: Vec<bool>,
|
||||
recurrence_interval: u32,
|
||||
recurrence_count: Option<u32>,
|
||||
recurrence_until: Option<String>,
|
||||
calendar_path: Option<String>,
|
||||
@@ -1332,7 +1333,7 @@ impl CalendarService {
|
||||
"reminder": reminder,
|
||||
"recurrence": recurrence,
|
||||
"recurrence_days": recurrence_days,
|
||||
"recurrence_interval": 1_u32, // Default interval
|
||||
"recurrence_interval": recurrence_interval,
|
||||
"recurrence_end_date": recurrence_until,
|
||||
"recurrence_count": recurrence_count,
|
||||
"calendar_path": calendar_path,
|
||||
@@ -1438,10 +1439,10 @@ impl CalendarService {
|
||||
reminder: String,
|
||||
recurrence: String,
|
||||
recurrence_days: Vec<bool>,
|
||||
recurrence_interval: u32,
|
||||
recurrence_count: Option<u32>,
|
||||
recurrence_until: Option<String>,
|
||||
calendar_path: Option<String>,
|
||||
exception_dates: Vec<chrono::NaiveDateTime>,
|
||||
update_action: Option<String>,
|
||||
until_date: Option<chrono::NaiveDateTime>,
|
||||
timezone: String,
|
||||
) -> Result<(), String> {
|
||||
// Forward to update_event_with_scope with default scope
|
||||
@@ -1466,10 +1467,10 @@ impl CalendarService {
|
||||
reminder,
|
||||
recurrence,
|
||||
recurrence_days,
|
||||
recurrence_interval,
|
||||
recurrence_count,
|
||||
recurrence_until,
|
||||
calendar_path,
|
||||
exception_dates,
|
||||
update_action,
|
||||
until_date,
|
||||
timezone,
|
||||
)
|
||||
.await
|
||||
@@ -1497,10 +1498,10 @@ impl CalendarService {
|
||||
reminder: String,
|
||||
recurrence: String,
|
||||
recurrence_days: Vec<bool>,
|
||||
recurrence_interval: u32,
|
||||
recurrence_count: Option<u32>,
|
||||
recurrence_until: Option<String>,
|
||||
calendar_path: Option<String>,
|
||||
exception_dates: Vec<chrono::NaiveDateTime>,
|
||||
update_action: Option<String>,
|
||||
until_date: Option<chrono::NaiveDateTime>,
|
||||
timezone: String,
|
||||
) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
@@ -1529,11 +1530,10 @@ impl CalendarService {
|
||||
"reminder": reminder,
|
||||
"recurrence": recurrence,
|
||||
"recurrence_days": recurrence_days,
|
||||
"recurrence_interval": recurrence_interval,
|
||||
"recurrence_count": recurrence_count,
|
||||
"recurrence_end_date": recurrence_until,
|
||||
"calendar_path": calendar_path,
|
||||
"update_action": update_action,
|
||||
"occurrence_date": null,
|
||||
"exception_dates": exception_dates.iter().map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::<Vec<String>>(),
|
||||
"until_date": until_date.as_ref().map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()),
|
||||
"timezone": timezone
|
||||
});
|
||||
let url = format!("{}/calendar/events/update", self.base_url);
|
||||
@@ -1735,6 +1735,7 @@ impl CalendarService {
|
||||
reminder: String,
|
||||
recurrence: String,
|
||||
recurrence_days: Vec<bool>,
|
||||
recurrence_interval: u32,
|
||||
recurrence_count: Option<u32>,
|
||||
recurrence_until: Option<String>,
|
||||
calendar_path: Option<String>,
|
||||
@@ -1767,7 +1768,7 @@ impl CalendarService {
|
||||
"reminder": reminder,
|
||||
"recurrence": recurrence,
|
||||
"recurrence_days": recurrence_days,
|
||||
"recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter
|
||||
"recurrence_interval": recurrence_interval,
|
||||
"recurrence_end_date": recurrence_until,
|
||||
"recurrence_count": recurrence_count,
|
||||
"calendar_path": calendar_path,
|
||||
|
||||
Reference in New Issue
Block a user