Files
calendar/backend/src/handlers.rs
Connor Johnstone 81805289e4 Implement complete recurring event drag modification system
- Add recurring edit modal with three modification options:
  • "Only this event" - Creates exception for single occurrence
  • "This and future events" - Splits series from occurrence forward
  • "All occurrences in this series" - Updates entire series time

- Enhance backend update API to support series modifications:
  • Add update_action parameter for recurring event operations
  • Implement time-only updates that preserve original start dates
  • Convert timestamped occurrence UIDs to base UIDs for series updates
  • Preserve recurrence rules during series modifications

- Fix recurring event drag operations:
  • Show modal for recurring events instead of direct updates
  • Handle EXDATE creation for single occurrence modifications
  • Support series splitting with UNTIL clause modifications
  • Maintain proper UID management for different modification types

- Clean up debug logging and restore page refresh for data consistency

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 15:22:34 -04:00

1064 lines
42 KiB
Rust

use axum::{
extract::{State, Query, Path},
http::HeaderMap,
response::Json,
};
use serde::Deserialize;
use std::sync::Arc;
use chrono::{Datelike, TimeZone};
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}};
use crate::calendar::{CalDAVClient, CalendarEvent};
#[derive(Deserialize)]
pub struct CalendarQuery {
pub year: Option<i32>,
pub month: Option<u32>,
}
pub async fn get_calendar_events(
State(state): State<Arc<AppState>>,
Query(params): Query<CalendarQuery>,
headers: HeaderMap,
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
println!("🔑 API call with password length: {}", password.len());
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Ok(Json(vec![])); // No calendars found
}
// Fetch events from all calendars
let mut all_events = Vec::new();
for calendar_path in &calendar_paths {
match client.fetch_events(calendar_path).await {
Ok(mut events) => {
// Set calendar_path for each event to identify which calendar it belongs to
for event in &mut events {
event.calendar_path = Some(calendar_path.clone());
}
all_events.extend(events);
},
Err(e) => {
// Log the error but continue with other calendars
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
}
}
}
let events = all_events;
// Filter events by month if specified
let filtered_events = if let (Some(year), Some(month)) = (params.year, params.month) {
events.into_iter().filter(|event| {
let event_date = event.start.date_naive();
event_date.year() == year && event_date.month() == month
}).collect()
} else {
events
};
Ok(Json(filtered_events))
}
pub async fn refresh_event(
State(state): State<Arc<AppState>>,
Path(uid): Path<String>,
headers: HeaderMap,
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Ok(Json(None)); // No calendars found
}
// Search for the specific event by UID across all calendars
let mut found_event = None;
for calendar_path in &calendar_paths {
match client.fetch_event_by_uid(calendar_path, &uid).await {
Ok(Some(mut event)) => {
event.calendar_path = Some(calendar_path.clone());
found_event = Some(event);
break;
},
Ok(None) => continue, // Event not found in this calendar
Err(e) => {
eprintln!("Failed to fetch event from calendar {}: {}", calendar_path, e);
continue;
}
}
}
let event = found_event;
Ok(Json(event))
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(request): Json<CalDAVLoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
println!("🔐 Login attempt:");
println!(" Server URL: {}", request.server_url);
println!(" Username: {}", request.username);
println!(" Password length: {}", request.password.len());
let response = state.auth_service.login(request).await?;
Ok(Json(response))
}
pub async fn verify_token(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, ApiError> {
let token = extract_bearer_token(&headers)?;
let claims = state.auth_service.verify_token(&token)?;
Ok(Json(serde_json::json!({
"valid": true,
"username": claims.username,
"server_url": claims.server_url
})))
}
pub async fn get_user_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<UserInfo>, ApiError> {
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
let claims = state.auth_service.verify_token(&token)?;
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
// Convert paths to CalendarInfo structs with display names, filtering out generic collections
let calendars: Vec<CalendarInfo> = calendar_paths.into_iter()
.filter_map(|path| {
let display_name = extract_calendar_name(&path);
// Skip generic collection names
if display_name.eq_ignore_ascii_case("calendar") ||
display_name.eq_ignore_ascii_case("calendars") ||
display_name.eq_ignore_ascii_case("collection") {
None
} else {
Some(CalendarInfo {
path: path.clone(),
display_name,
color: generate_calendar_color(&path),
})
}
}).collect();
Ok(Json(UserInfo {
username: claims.username,
server_url: claims.server_url,
calendars,
}))
}
// Helper function to generate a consistent color for a calendar based on its path
fn generate_calendar_color(path: &str) -> String {
// Predefined set of attractive, accessible colors for calendars
let colors = [
"#3B82F6", // Blue
"#10B981", // Emerald
"#F59E0B", // Amber
"#EF4444", // Red
"#8B5CF6", // Violet
"#06B6D4", // Cyan
"#84CC16", // Lime
"#F97316", // Orange
"#EC4899", // Pink
"#6366F1", // Indigo
"#14B8A6", // Teal
"#F3B806", // Yellow
"#8B5A2B", // Brown
"#6B7280", // Gray
"#DC2626", // Red-600
"#7C3AED", // Violet-600
];
// Create a simple hash from the path to ensure consistent color assignment
let mut hash: u32 = 0;
for byte in path.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
}
// Use the hash to select a color from our palette
let color_index = (hash as usize) % colors.len();
colors[color_index].to_string()
}
// Helper function to extract a readable calendar name from path
fn extract_calendar_name(path: &str) -> String {
// Extract the last meaningful part of the path
// e.g., "/calendars/user/personal/" -> "personal"
// or "/calendars/user/work-calendar/" -> "work-calendar"
let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
if let Some(last_part) = parts.last() {
if !last_part.is_empty() && *last_part != "calendars" {
// Convert kebab-case or snake_case to title case
last_part
.replace('-', " ")
.replace('_', " ")
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
} else if parts.len() > 1 {
// If the last part is empty or "calendars", try the second to last
extract_calendar_name(&parts[..parts.len()-1].join("/"))
} else {
"Calendar".to_string()
}
} else {
"Calendar".to_string()
}
}
// Helper functions
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if let Some(token) = auth_str.strip_prefix("Bearer ") {
Ok(token.to_string())
} else {
Err(ApiError::Unauthorized("Authorization header must start with 'Bearer '".to_string()))
}
} else {
Err(ApiError::Unauthorized("Authorization header required".to_string()))
}
}
fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(password_header) = headers.get("x-caldav-password") {
let password = password_header
.to_str()
.map_err(|_| ApiError::BadRequest("Invalid password header".to_string()))?;
Ok(password.to_string())
} else {
Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string()))
}
}
pub async fn create_calendar(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<CreateCalendarRequest>,
) -> Result<Json<CreateCalendarResponse>, ApiError> {
println!("📝 Create calendar request received: name='{}', description={:?}, color={:?}",
request.name, request.description, request.color);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.name.trim().is_empty() {
return Err(ApiError::BadRequest("Calendar name is required".to_string()));
}
if request.name.len() > 100 {
return Err(ApiError::BadRequest("Calendar name too long (max 100 characters)".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Create the calendar
client.create_calendar(
&request.name,
request.description.as_deref(),
request.color.as_deref()
)
.await
.map_err(|e| ApiError::Internal(format!("Failed to create calendar: {}", e)))?;
Ok(Json(CreateCalendarResponse {
success: true,
message: "Calendar created successfully".to_string(),
}))
}
pub async fn delete_calendar(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<DeleteCalendarRequest>,
) -> Result<Json<DeleteCalendarResponse>, ApiError> {
println!("🗑️ Delete calendar request received: path='{}'", request.path);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.path.trim().is_empty() {
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Delete the calendar
client.delete_calendar(&request.path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete calendar: {}", e)))?;
Ok(Json(DeleteCalendarResponse {
success: true,
message: "Calendar deleted successfully".to_string(),
}))
}
/// 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='{}', action='{}'",
request.calendar_path, request.event_href, request.delete_action);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.calendar_path.trim().is_empty() {
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
}
if request.event_href.trim().is_empty() {
return Err(ApiError::BadRequest("Event href is required".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// 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 series deleted successfully".to_string(),
}))
}
}
}
pub async fn create_event(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<CreateEventRequest>,
) -> Result<Json<CreateEventResponse>, ApiError> {
println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
request.title, request.all_day, request.calendar_path);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.title.trim().is_empty() {
return Err(ApiError::BadRequest("Event title is required".to_string()));
}
if request.title.len() > 200 {
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Determine which calendar to use
let calendar_path = if let Some(path) = request.calendar_path {
path
} else {
// Use the first available calendar
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
}
calendar_paths[0].clone()
};
// Parse dates and times
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// Validate that end is after start
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
}
// Generate a unique UID for the event
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
// Parse status
let status = match request.status.to_lowercase().as_str() {
"tentative" => crate::calendar::EventStatus::Tentative,
"cancelled" => crate::calendar::EventStatus::Cancelled,
_ => crate::calendar::EventStatus::Confirmed,
};
// Parse class
let class = match request.class.to_lowercase().as_str() {
"private" => crate::calendar::EventClass::Private,
"confidential" => crate::calendar::EventClass::Confidential,
_ => crate::calendar::EventClass::Public,
};
// Parse attendees (comma-separated email list)
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
Vec::new()
} else {
request.attendees
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse categories (comma-separated list)
let categories: Vec<String> = if request.categories.trim().is_empty() {
Vec::new()
} else {
request.categories
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse reminders and convert to EventReminder structs
let reminders: Vec<crate::calendar::EventReminder> = match request.reminder.to_lowercase().as_str() {
"15min" => vec![crate::calendar::EventReminder {
minutes_before: 15,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"30min" => vec![crate::calendar::EventReminder {
minutes_before: 30,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1hour" => vec![crate::calendar::EventReminder {
minutes_before: 60,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2hours" => vec![crate::calendar::EventReminder {
minutes_before: 120,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1day" => vec![crate::calendar::EventReminder {
minutes_before: 1440, // 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2days" => vec![crate::calendar::EventReminder {
minutes_before: 2880, // 48 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1week" => vec![crate::calendar::EventReminder {
minutes_before: 10080, // 7 * 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
_ => Vec::new(),
};
// Parse recurrence with BYDAY support for weekly recurrence
let recurrence_rule = match request.recurrence.to_lowercase().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.push_str(&format!(";BYDAY={}", selected_days.join(",")));
}
}
Some(rrule)
},
"monthly" => Some("FREQ=MONTHLY".to_string()),
"yearly" => Some("FREQ=YEARLY".to_string()),
_ => None,
};
// Create the CalendarEvent struct
let event = crate::calendar::CalendarEvent {
uid,
summary: Some(request.title.clone()),
description: if request.description.trim().is_empty() {
None
} else {
Some(request.description.clone())
},
start: start_datetime,
end: Some(end_datetime),
location: if request.location.trim().is_empty() {
None
} else {
Some(request.location.clone())
},
status,
class,
priority: request.priority,
organizer: if request.organizer.trim().is_empty() {
None
} else {
Some(request.organizer.clone())
},
attendees,
categories,
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,
href: None,
calendar_path: Some(calendar_path.clone()),
};
// Create the event on the CalDAV server
let event_href = client.create_event(&calendar_path, &event)
.await
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
Ok(Json(CreateEventResponse {
success: true,
message: "Event created successfully".to_string(),
event_href: Some(event_href),
}))
}
pub async fn update_event(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<UpdateEventRequest>,
) -> Result<Json<UpdateEventResponse>, ApiError> {
// Handle update request
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.uid.trim().is_empty() {
return Err(ApiError::BadRequest("Event UID is required".to_string()));
}
if request.title.trim().is_empty() {
return Err(ApiError::BadRequest("Event title is required".to_string()));
}
if request.title.len() > 200 {
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Find the event across all calendars (or in the specified calendar)
let calendar_paths = if let Some(path) = &request.calendar_path {
vec![path.clone()]
} else {
client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
};
if calendar_paths.is_empty() {
return Err(ApiError::BadRequest("No calendars available for event update".to_string()));
}
// Determine if this is a series update
let search_uid = request.uid.clone();
let is_series_update = request.update_action.as_deref() == Some("update_series");
// Search for the event by UID across the specified calendars
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, href)
for calendar_path in &calendar_paths {
match client.fetch_event_by_uid(calendar_path, &search_uid).await {
Ok(Some(event)) => {
if let Some(href) = event.href.clone() {
found_event = Some((event, calendar_path.clone(), href));
break;
}
},
Ok(None) => continue, // Event not found in this calendar
Err(e) => {
eprintln!("Failed to fetch event from calendar {}: {}", calendar_path, e);
continue;
}
}
}
let (mut event, calendar_path, event_href) = found_event
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", search_uid)))?;
// Parse dates and times for the updated event
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// Validate that end is after start
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
}
// Parse status
let status = match request.status.to_lowercase().as_str() {
"tentative" => crate::calendar::EventStatus::Tentative,
"cancelled" => crate::calendar::EventStatus::Cancelled,
_ => crate::calendar::EventStatus::Confirmed,
};
// Parse class
let class = match request.class.to_lowercase().as_str() {
"private" => crate::calendar::EventClass::Private,
"confidential" => crate::calendar::EventClass::Confidential,
_ => crate::calendar::EventClass::Public,
};
// Parse attendees (comma-separated email list)
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
Vec::new()
} else {
request.attendees
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse categories (comma-separated list)
let categories: Vec<String> = if request.categories.trim().is_empty() {
Vec::new()
} else {
request.categories
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse reminders and convert to EventReminder structs
let reminders: Vec<crate::calendar::EventReminder> = match request.reminder.to_lowercase().as_str() {
"15min" => vec![crate::calendar::EventReminder {
minutes_before: 15,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"30min" => vec![crate::calendar::EventReminder {
minutes_before: 30,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1hour" => vec![crate::calendar::EventReminder {
minutes_before: 60,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2hours" => vec![crate::calendar::EventReminder {
minutes_before: 120,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1day" => vec![crate::calendar::EventReminder {
minutes_before: 1440, // 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2days" => vec![crate::calendar::EventReminder {
minutes_before: 2880, // 48 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1week" => vec![crate::calendar::EventReminder {
minutes_before: 10080, // 7 * 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
_ => Vec::new(),
};
// Parse recurrence with BYDAY support for weekly recurrence
let recurrence_rule = match request.recurrence.to_lowercase().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.push_str(&format!(";BYDAY={}", selected_days.join(",")));
}
}
Some(rrule)
},
"monthly" => Some("FREQ=MONTHLY".to_string()),
"yearly" => Some("FREQ=YEARLY".to_string()),
_ => None,
};
// Update the event fields with new data
event.summary = Some(request.title.clone());
event.description = if request.description.trim().is_empty() {
None
} else {
Some(request.description.clone())
};
// Handle date/time updates based on update type
if is_series_update {
// For series updates, only update the TIME, keep the original DATE
let original_start_date = event.start.date_naive();
let original_end_date = event.end.map(|e| e.date_naive()).unwrap_or(original_start_date);
let new_start_time = start_datetime.time();
let new_end_time = end_datetime.time();
// Combine original date with new time
let updated_start = original_start_date.and_time(new_start_time).and_utc();
let updated_end = original_end_date.and_time(new_end_time).and_utc();
// Preserve original date with new time
event.start = updated_start;
event.end = Some(updated_end);
} else {
// For regular updates, update both date and time
event.start = start_datetime;
event.end = Some(end_datetime);
}
event.location = if request.location.trim().is_empty() {
None
} else {
Some(request.location.clone())
};
event.status = status;
event.class = class;
event.priority = request.priority;
event.organizer = if request.organizer.trim().is_empty() {
None
} else {
Some(request.organizer.clone())
};
event.attendees = attendees;
event.categories = categories;
event.last_modified = Some(chrono::Utc::now());
event.all_day = request.all_day;
event.reminders = reminders;
// Handle recurrence rule and UID for series updates
if is_series_update {
// For series updates, preserve existing recurrence rule and convert UID to base UID
let parts: Vec<&str> = request.uid.split('-').collect();
if parts.len() > 1 {
let last_part = parts[parts.len() - 1];
if last_part.chars().all(|c| c.is_numeric()) {
let base_uid = parts[0..parts.len()-1].join("-");
event.uid = base_uid;
}
}
// Keep existing recurrence rule (don't overwrite with recurrence_rule variable)
// event.recurrence_rule stays as-is from the original event
} else {
// For regular updates, use the new recurrence rule
event.recurrence_rule = recurrence_rule;
}
// Update the event on the CalDAV server
client.update_event(&calendar_path, &event, &event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
Ok(Json(UpdateEventResponse {
success: true,
message: "Event updated successfully".to_string(),
}))
}
/// Parse date and time strings into a UTC DateTime
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
// Parse the date
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
if all_day {
// For all-day events, use midnight UTC
let datetime = date.and_hms_opt(0, 0, 0)
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
Ok(Utc.from_utc_datetime(&datetime))
} else {
// Parse the time
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
// Combine date and time
let datetime = NaiveDateTime::new(date, time);
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
Ok(Utc.from_utc_datetime(&datetime))
}
}