Events were appearing 4 hours earlier than selected time due to incorrect timezone handling in backend. The issue was treating frontend local time as if it was already in UTC. - Fix parse_event_datetime() in events.rs to properly convert local time to UTC - Fix all datetime conversions in series.rs to use Local timezone conversion - Replace Utc.from_utc_datetime() with proper Local.from_local_datetime() - Add timezone conversion using with_timezone(&Utc) for accurate UTC storage Now when user selects 5:00 AM, it correctly stores as UTC equivalent and displays back at 5:00 AM local time. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
876 lines
32 KiB
Rust
876 lines
32 KiB
Rust
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::HeaderMap,
|
|
response::Json,
|
|
};
|
|
use chrono::Datelike;
|
|
use serde::Deserialize;
|
|
use std::sync::Arc;
|
|
|
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
|
use crate::{
|
|
models::{
|
|
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
|
|
UpdateEventRequest, UpdateEventResponse,
|
|
},
|
|
AppState,
|
|
};
|
|
use calendar_models::{
|
|
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
|
|
};
|
|
|
|
use super::auth::{extract_bearer_token, extract_password_header};
|
|
|
|
#[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) => {
|
|
eprintln!(
|
|
"Failed to fetch events from calendar {}: {}",
|
|
calendar_path, e
|
|
);
|
|
// Continue with other calendars instead of failing completely
|
|
}
|
|
}
|
|
}
|
|
|
|
// If year and month are specified, filter events
|
|
if let (Some(year), Some(month)) = (params.year, params.month) {
|
|
let target_date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
|
let month_start = target_date;
|
|
let month_end = if month == 12 {
|
|
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
|
} else {
|
|
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
|
} - chrono::Duration::days(1);
|
|
|
|
all_events.retain(|event| {
|
|
let event_date = event.dtstart.date_naive();
|
|
|
|
// For non-recurring events, check if the event date is within the month
|
|
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
|
|
let event_year = event.dtstart.year();
|
|
let event_month = event.dtstart.month();
|
|
return event_year == year && event_month == month;
|
|
}
|
|
|
|
// For recurring events, check if they could have instances in this month
|
|
// Include if:
|
|
// 1. The event starts before or during the requested month
|
|
// 2. The event doesn't have an UNTIL date, OR the UNTIL date is after the month start
|
|
if event_date > month_end {
|
|
// Event starts after the requested month
|
|
return false;
|
|
}
|
|
|
|
// Check UNTIL date in RRULE if present
|
|
if let Some(ref rrule) = event.rrule {
|
|
if let Some(until_pos) = rrule.find("UNTIL=") {
|
|
let until_part = &rrule[until_pos + 6..];
|
|
let until_end = until_part.find(';').unwrap_or(until_part.len());
|
|
let until_str = &until_part[..until_end];
|
|
|
|
// Try to parse UNTIL date (format: YYYYMMDDTHHMMSSZ or YYYYMMDD)
|
|
if until_str.len() >= 8 {
|
|
if let Ok(until_date) = chrono::NaiveDate::parse_from_str(&until_str[..8], "%Y%m%d") {
|
|
if until_date < month_start {
|
|
// Recurring event ended before the requested month
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Include the recurring event - the frontend will do proper expansion
|
|
true
|
|
});
|
|
}
|
|
|
|
println!("📅 Returning {} events", all_events.len());
|
|
Ok(Json(all_events))
|
|
}
|
|
|
|
pub async fn refresh_event(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(uid): Path<String>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
|
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
|
|
let calendar_paths = client
|
|
.discover_calendars()
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
|
|
|
// Search for the event by UID across all calendars
|
|
for calendar_path in &calendar_paths {
|
|
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
|
|
event.calendar_path = Some(calendar_path.clone());
|
|
return Ok(Json(Some(event)));
|
|
}
|
|
}
|
|
|
|
Ok(Json(None))
|
|
}
|
|
|
|
async fn fetch_event_by_href(
|
|
client: &CalDAVClient,
|
|
calendar_path: &str,
|
|
event_href: &str,
|
|
) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
|
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
|
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
|
let events = client.fetch_events(calendar_path).await?;
|
|
|
|
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
|
println!(
|
|
"🔍 Available events with hrefs: {:?}",
|
|
events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()
|
|
);
|
|
|
|
// First try to match by exact href
|
|
for event in &events {
|
|
if let Some(stored_href) = &event.href {
|
|
if stored_href == event_href {
|
|
println!("✅ Found matching event by exact href: {}", event.uid);
|
|
return Ok(Some(event.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: try to match by UID extracted from href filename
|
|
let filename = event_href.split('/').last().unwrap_or(event_href);
|
|
let uid_from_href = filename.trim_end_matches(".ics");
|
|
|
|
println!(
|
|
"🔍 Fallback: trying UID match. filename='{}', uid='{}'",
|
|
filename, uid_from_href
|
|
);
|
|
|
|
for event in events {
|
|
if event.uid == uid_from_href {
|
|
println!("✅ Found matching event by UID: {}", event.uid);
|
|
return Ok(Some(event));
|
|
}
|
|
}
|
|
|
|
println!("❌ No matching event found for href: {}", event_href);
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
pub async fn delete_event(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<DeleteEventRequest>,
|
|
) -> Result<Json<DeleteEventResponse>, ApiError> {
|
|
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);
|
|
|
|
// Handle different delete actions for recurring events
|
|
match request.delete_action.as_str() {
|
|
"delete_this" => {
|
|
if let Some(event) =
|
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
|
{
|
|
// Check if this is a recurring event
|
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
|
// Recurring event - add EXDATE for this occurrence
|
|
if let Some(occurrence_date) = &request.occurrence_date {
|
|
let exception_utc = if let Ok(date) =
|
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
|
{
|
|
// RFC3339 format (with time and timezone)
|
|
date.with_timezone(&chrono::Utc)
|
|
} else if let Ok(naive_date) =
|
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
|
{
|
|
// Simple date format (YYYY-MM-DD)
|
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
|
} else {
|
|
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
|
};
|
|
|
|
let mut updated_event = event;
|
|
updated_event.exdate.push(exception_utc);
|
|
|
|
println!(
|
|
"🔄 Adding EXDATE {} to recurring event {}",
|
|
exception_utc.format("%Y%m%dT%H%M%SZ"),
|
|
updated_event.uid
|
|
);
|
|
|
|
// Update the event with the new EXDATE
|
|
client
|
|
.update_event(
|
|
&request.calendar_path,
|
|
&updated_event,
|
|
&request.event_href,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!(
|
|
"Failed to update event with EXDATE: {}",
|
|
e
|
|
))
|
|
})?;
|
|
|
|
println!("✅ Successfully updated recurring event with EXDATE");
|
|
|
|
Ok(Json(DeleteEventResponse {
|
|
success: true,
|
|
message: "Single occurrence deleted successfully".to_string(),
|
|
}))
|
|
} else {
|
|
Err(ApiError::BadRequest("Occurrence date is required for single occurrence deletion of recurring events".to_string()))
|
|
}
|
|
} else {
|
|
// Non-recurring event - delete the entire event
|
|
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
|
|
|
client
|
|
.delete_event(&request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
|
})?;
|
|
|
|
println!("✅ Successfully deleted non-recurring event");
|
|
|
|
Ok(Json(DeleteEventResponse {
|
|
success: true,
|
|
message: "Event deleted successfully".to_string(),
|
|
}))
|
|
}
|
|
} else {
|
|
Err(ApiError::NotFound("Event not found".to_string()))
|
|
}
|
|
}
|
|
"delete_following" => {
|
|
// For "this and following" deletion, we need to:
|
|
// 1. Fetch the recurring event
|
|
// 2. Modify the RRULE to end before this occurrence
|
|
// 3. Update the event
|
|
|
|
if let Some(mut event) =
|
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
|
{
|
|
if let Some(occurrence_date) = &request.occurrence_date {
|
|
let until_date = if let Ok(date) =
|
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
|
{
|
|
// RFC3339 format (with time and timezone)
|
|
date.with_timezone(&chrono::Utc)
|
|
} else if let Ok(naive_date) =
|
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
|
{
|
|
// Simple date format (YYYY-MM-DD)
|
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
|
} else {
|
|
return Err(ApiError::BadRequest(format!(
|
|
"Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD",
|
|
occurrence_date
|
|
)));
|
|
};
|
|
|
|
// Modify the RRULE to add an UNTIL clause
|
|
if let Some(rrule) = &event.rrule {
|
|
// Remove existing UNTIL if present and add new one
|
|
let parts: Vec<&str> = rrule
|
|
.split(';')
|
|
.filter(|part| {
|
|
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
|
})
|
|
.collect();
|
|
|
|
let new_rrule = format!(
|
|
"{};UNTIL={}",
|
|
parts.join(";"),
|
|
until_date.format("%Y%m%dT%H%M%SZ")
|
|
);
|
|
event.rrule = 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: "This and following occurrences deleted successfully"
|
|
.to_string(),
|
|
}))
|
|
} else {
|
|
// No RRULE, just delete the single event
|
|
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(),
|
|
}))
|
|
}
|
|
} else {
|
|
Err(ApiError::BadRequest(
|
|
"Occurrence date is required for following deletion".to_string(),
|
|
))
|
|
}
|
|
} else {
|
|
Err(ApiError::NotFound("Event not found".to_string()))
|
|
}
|
|
}
|
|
"delete_series" | _ => {
|
|
// Delete the entire event/series
|
|
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(),
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
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 mut 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)))?;
|
|
|
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
|
// RFC-5545 uses exclusive end dates for all-day events
|
|
if request.all_day {
|
|
end_datetime = end_datetime + chrono::Duration::days(1);
|
|
}
|
|
|
|
// Validate that end is after start (allow equal times for all-day events)
|
|
if request.all_day {
|
|
if end_datetime < start_datetime {
|
|
return Err(ApiError::BadRequest(
|
|
"End date must be on or after start date for all-day events".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
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" => EventStatus::Tentative,
|
|
"cancelled" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
};
|
|
|
|
// Parse class
|
|
let class = match request.class.to_lowercase().as_str() {
|
|
"private" => EventClass::Private,
|
|
"confidential" => EventClass::Confidential,
|
|
_ => 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 alarms - convert from minutes string to EventReminder structs
|
|
let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
match request.reminder.parse::<i32>() {
|
|
Ok(minutes) => vec![crate::calendar::EventReminder {
|
|
minutes_before: minutes,
|
|
action: crate::calendar::ReminderAction::Display,
|
|
description: None,
|
|
}],
|
|
Err(_) => Vec::new(),
|
|
}
|
|
};
|
|
|
|
// Check if recurrence is already a full RRULE or just a simple type
|
|
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 {
|
|
// Legacy path: Parse recurrence with BYDAY support for weekly recurrence
|
|
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,
|
|
}
|
|
};
|
|
|
|
// Create the VEvent struct (RFC 5545 compliant)
|
|
let mut event = VEvent::new(uid, start_datetime);
|
|
event.dtend = Some(end_datetime);
|
|
event.summary = if request.title.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.title.clone())
|
|
};
|
|
event.description = if request.description.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.description)
|
|
};
|
|
event.location = if request.location.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.location)
|
|
};
|
|
event.status = Some(status);
|
|
event.class = Some(class);
|
|
event.priority = request.priority;
|
|
event.organizer = if request.organizer.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(CalendarUser {
|
|
cal_address: request.organizer,
|
|
common_name: None,
|
|
dir_entry_ref: None,
|
|
sent_by: None,
|
|
language: None,
|
|
})
|
|
};
|
|
event.attendees = attendees
|
|
.into_iter()
|
|
.map(|email| Attendee {
|
|
cal_address: email,
|
|
common_name: None,
|
|
role: None,
|
|
part_stat: None,
|
|
rsvp: None,
|
|
cu_type: None,
|
|
member: Vec::new(),
|
|
delegated_to: Vec::new(),
|
|
delegated_from: Vec::new(),
|
|
sent_by: None,
|
|
dir_entry_ref: None,
|
|
language: None,
|
|
})
|
|
.collect();
|
|
event.categories = categories;
|
|
event.rrule = rrule;
|
|
event.all_day = request.all_day;
|
|
event.alarms = alarms
|
|
.into_iter()
|
|
.map(|reminder| VAlarm {
|
|
action: AlarmAction::Display,
|
|
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
|
|
-reminder.minutes_before as i64,
|
|
)),
|
|
duration: None,
|
|
repeat: None,
|
|
description: reminder.description,
|
|
summary: None,
|
|
attendees: Vec::new(),
|
|
attach: Vec::new(),
|
|
})
|
|
.collect();
|
|
event.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)))?;
|
|
|
|
println!(
|
|
"✅ Event created successfully with UID: {} at href: {}",
|
|
event.uid, event_href
|
|
);
|
|
|
|
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)))?
|
|
};
|
|
|
|
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
|
|
|
|
for calendar_path in &calendar_paths {
|
|
match client.fetch_events(calendar_path).await {
|
|
Ok(events) => {
|
|
for event in events {
|
|
if event.uid == request.uid {
|
|
// Use the actual href from the event, or generate one if missing
|
|
let event_href = event
|
|
.href
|
|
.clone()
|
|
.unwrap_or_else(|| format!("{}.ics", event.uid));
|
|
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
|
found_event = Some((event, calendar_path.clone(), event_href));
|
|
break;
|
|
}
|
|
}
|
|
if found_event.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Failed to fetch events 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", request.uid)))?;
|
|
|
|
// 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 mut 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)))?;
|
|
|
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
|
// RFC-5545 uses exclusive end dates for all-day events
|
|
if request.all_day {
|
|
end_datetime = end_datetime + chrono::Duration::days(1);
|
|
}
|
|
|
|
// Validate that end is after start (allow equal times for all-day events)
|
|
if request.all_day {
|
|
if end_datetime < start_datetime {
|
|
return Err(ApiError::BadRequest(
|
|
"End date must be on or after start date for all-day events".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
if end_datetime <= start_datetime {
|
|
return Err(ApiError::BadRequest(
|
|
"End date/time must be after start date/time".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Update event properties
|
|
event.dtstart = start_datetime;
|
|
event.dtend = Some(end_datetime);
|
|
event.summary = if request.title.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.title)
|
|
};
|
|
event.description = if request.description.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.description)
|
|
};
|
|
event.location = if request.location.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(request.location)
|
|
};
|
|
event.all_day = request.all_day;
|
|
|
|
// Parse and update status
|
|
event.status = Some(match request.status.to_lowercase().as_str() {
|
|
"tentative" => EventStatus::Tentative,
|
|
"cancelled" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
});
|
|
|
|
// Parse and update class
|
|
event.class = Some(match request.class.to_lowercase().as_str() {
|
|
"private" => EventClass::Private,
|
|
"confidential" => EventClass::Confidential,
|
|
_ => EventClass::Public,
|
|
});
|
|
|
|
event.priority = request.priority;
|
|
|
|
// Update the event on the CalDAV server
|
|
println!(
|
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
|
event.uid, calendar_path, event_href
|
|
);
|
|
client
|
|
.update_event(&calendar_path, &event, &event_href)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
|
|
|
println!("✅ Successfully updated event {}", event.uid);
|
|
|
|
Ok(Json(UpdateEventResponse {
|
|
success: true,
|
|
message: "Event updated successfully".to_string(),
|
|
}))
|
|
}
|
|
|
|
fn parse_event_datetime(
|
|
date_str: &str,
|
|
time_str: &str,
|
|
all_day: bool,
|
|
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
|
use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
|
|
|
// 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);
|
|
|
|
// Treat the datetime as local time and convert to UTC
|
|
let local_datetime = Local.from_local_datetime(&datetime)
|
|
.single()
|
|
.ok_or_else(|| "Ambiguous local datetime".to_string())?;
|
|
|
|
Ok(local_datetime.with_timezone(&Utc))
|
|
}
|
|
}
|