diff --git a/backend/src/calendar.rs b/backend/src/calendar.rs index 497e0a2..f739037 100644 --- a/backend/src/calendar.rs +++ b/backend/src/calendar.rs @@ -695,6 +695,192 @@ impl CalDAVClient { } } + /// Create a new event in a CalDAV calendar + pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result { + // Generate a unique filename for the event (using UID + .ics extension) + let event_filename = format!("{}.ics", event.uid); + + // Construct the full URL for the event + let full_url = if calendar_path.starts_with("http") { + format!("{}/{}", calendar_path.trim_end_matches('/'), event_filename) + } else { + // Handle URL construction more carefully + let server_url = self.config.server_url.trim_end_matches('/'); + + // Remove /dav.php from the end of server URL if present + let base_url = if server_url.ends_with("/dav.php") { + server_url.trim_end_matches("/dav.php") + } else { + server_url + }; + + // Calendar path should start with /dav.php, if not add it + let clean_calendar_path = if calendar_path.starts_with("/dav.php") { + calendar_path.trim_end_matches('/') + } else { + // This shouldn't happen in our case, but handle it + &format!("/dav.php{}", calendar_path.trim_end_matches('/')) + }; + + format!("{}{}/{}", base_url, clean_calendar_path, event_filename) + }; + + println!("📝 Creating event with calendar_path: {}", calendar_path); + println!("📝 Server URL: {}", self.config.server_url); + println!("📝 Constructed URL: {}", full_url); + + // Generate iCalendar data for the event + let ical_data = self.generate_ical_event(event)?; + + println!("Creating event at: {}", full_url); + println!("iCal data: {}", ical_data); + + let response = self.http_client + .put(&full_url) + .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .header("Content-Type", "text/calendar; charset=utf-8") + .header("User-Agent", "calendar-app/0.1.0") + .body(ical_data) + .send() + .await + .map_err(|e| CalDAVError::ParseError(e.to_string()))?; + + println!("Event creation response status: {}", response.status()); + + if response.status().is_success() || response.status().as_u16() == 201 { + println!("✅ Event created successfully at {}", event_filename); + Ok(event_filename) + } else { + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + println!("❌ Event creation failed: {} - {}", status, error_body); + Err(CalDAVError::ServerError(status.as_u16())) + } + } + + /// Generate iCalendar data for a CalendarEvent + fn generate_ical_event(&self, event: &CalendarEvent) -> Result { + let now = chrono::Utc::now(); + + // Format datetime for iCal (YYYYMMDDTHHMMSSZ format) + let format_datetime = |dt: &DateTime| -> String { + dt.format("%Y%m%dT%H%M%SZ").to_string() + }; + + let format_date = |dt: &DateTime| -> String { + dt.format("%Y%m%d").to_string() + }; + + // Start building the iCal event + let mut ical = String::new(); + ical.push_str("BEGIN:VCALENDAR\r\n"); + ical.push_str("VERSION:2.0\r\n"); + ical.push_str("PRODID:-//calendar-app//calendar-app//EN\r\n"); + ical.push_str("BEGIN:VEVENT\r\n"); + + // Required fields + ical.push_str(&format!("UID:{}\r\n", event.uid)); + ical.push_str(&format!("DTSTAMP:{}\r\n", format_datetime(&now))); + + // Start and end times + if event.all_day { + ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.start))); + if let Some(end) = &event.end { + ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end))); + } + } else { + ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.start))); + if let Some(end) = &event.end { + ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end))); + } + } + + // Optional fields + if let Some(summary) = &event.summary { + ical.push_str(&format!("SUMMARY:{}\r\n", self.escape_ical_text(summary))); + } + + if let Some(description) = &event.description { + ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description))); + } + + if let Some(location) = &event.location { + ical.push_str(&format!("LOCATION:{}\r\n", self.escape_ical_text(location))); + } + + // Status + let status_str = match event.status { + EventStatus::Tentative => "TENTATIVE", + EventStatus::Confirmed => "CONFIRMED", + EventStatus::Cancelled => "CANCELLED", + }; + ical.push_str(&format!("STATUS:{}\r\n", status_str)); + + // Classification + let class_str = match event.class { + EventClass::Public => "PUBLIC", + EventClass::Private => "PRIVATE", + EventClass::Confidential => "CONFIDENTIAL", + }; + ical.push_str(&format!("CLASS:{}\r\n", class_str)); + + // Priority + if let Some(priority) = event.priority { + ical.push_str(&format!("PRIORITY:{}\r\n", priority)); + } + + // Categories + if !event.categories.is_empty() { + let categories = event.categories.join(","); + ical.push_str(&format!("CATEGORIES:{}\r\n", self.escape_ical_text(&categories))); + } + + // Creation and modification times + if let Some(created) = &event.created { + ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created))); + } + + ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now))); + + // Add alarms/reminders + for reminder in &event.reminders { + ical.push_str("BEGIN:VALARM\r\n"); + + let action = match reminder.action { + ReminderAction::Display => "DISPLAY", + ReminderAction::Email => "EMAIL", + ReminderAction::Audio => "AUDIO", + }; + ical.push_str(&format!("ACTION:{}\r\n", action)); + + // Convert minutes to ISO 8601 duration format + let trigger = format!("-PT{}M", reminder.minutes_before); + ical.push_str(&format!("TRIGGER:{}\r\n", trigger)); + + if let Some(description) = &reminder.description { + ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description))); + } else if let Some(summary) = &event.summary { + ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(summary))); + } + + ical.push_str("END:VALARM\r\n"); + } + + ical.push_str("END:VEVENT\r\n"); + ical.push_str("END:VCALENDAR\r\n"); + + Ok(ical) + } + + /// Escape text for iCalendar format (RFC 5545) + fn escape_ical_text(&self, text: &str) -> String { + text.replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "") + .replace(',', "\\,") + .replace(';', "\\;") + } + /// Delete an event from a CalDAV calendar pub async fn delete_event(&self, calendar_path: &str, event_href: &str) -> Result<(), CalDAVError> { // Construct the full URL for the event diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 7d34aac..094c9c5 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use std::sync::Arc; use chrono::Datelike; -use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse}}; +use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse}}; use crate::calendar::{CalDAVClient, CalendarEvent}; #[derive(Deserialize)] @@ -357,4 +357,130 @@ pub async fn delete_event( success: true, message: "Event deleted successfully".to_string(), })) +} + +pub async fn create_event( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result, 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()); + + // 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: crate::calendar::EventStatus::Confirmed, + class: crate::calendar::EventClass::Public, + priority: None, + organizer: None, + attendees: Vec::new(), + categories: Vec::new(), + created: Some(chrono::Utc::now()), + last_modified: Some(chrono::Utc::now()), + recurrence_rule: None, + all_day: request.all_day, + reminders: Vec::new(), + 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), + })) +} + +/// Parse date and time strings into a UTC DateTime +fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result, 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)) + } } \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 5b31c2a..3594c06 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -41,6 +41,7 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/calendar/create", post(handlers::create_calendar)) .route("/api/calendar/delete", post(handlers::delete_calendar)) .route("/api/calendar/events", get(handlers::get_calendar_events)) + .route("/api/calendar/events/create", post(handlers::create_event)) .route("/api/calendar/events/delete", post(handlers::delete_event)) .route("/api/calendar/events/:uid", get(handlers::refresh_event)) .layer( diff --git a/backend/src/models.rs b/backend/src/models.rs index d62c039..82d5fd6 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -70,6 +70,26 @@ pub struct DeleteEventResponse { pub message: String, } +#[derive(Debug, Deserialize)] +pub struct CreateEventRequest { + pub title: String, + pub description: String, + pub start_date: String, // YYYY-MM-DD format + pub start_time: String, // HH:MM format + pub end_date: String, // YYYY-MM-DD format + pub end_time: String, // HH:MM format + pub location: String, + pub all_day: bool, + pub calendar_path: Option, // Optional - use first calendar if not specified +} + +#[derive(Debug, Serialize)] +pub struct CreateEventResponse { + pub success: bool, + pub message: String, + pub event_href: Option, // The created event's href/filename +} + // Error handling #[derive(Debug)] pub enum ApiError { diff --git a/src/app.rs b/src/app.rs index 70c9bb4..9a16ef8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -199,10 +199,52 @@ pub fn App() -> Html { Callback::from(move |event_data: EventCreationData| { web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into()); create_event_modal_open.set(false); - // TODO: Implement actual event creation API call - // For now, just close the modal and refresh - if (*auth_token).is_some() { - web_sys::window().unwrap().location().reload().unwrap(); + + if let Some(token) = (*auth_token).clone() { + wasm_bindgen_futures::spawn_local(async move { + let calendar_service = CalendarService::new(); + + // Get CalDAV password from storage + let password = if let Ok(credentials_str) = LocalStorage::get::("caldav_credentials") { + if let Ok(credentials) = serde_json::from_str::(&credentials_str) { + credentials["password"].as_str().unwrap_or("").to_string() + } else { + String::new() + } + } else { + String::new() + }; + + // Format date and time strings + let start_date = event_data.start_date.format("%Y-%m-%d").to_string(); + let start_time = event_data.start_time.format("%H:%M").to_string(); + let end_date = event_data.end_date.format("%Y-%m-%d").to_string(); + let end_time = event_data.end_time.format("%H:%M").to_string(); + + match calendar_service.create_event( + &token, + &password, + event_data.title, + event_data.description, + start_date, + start_time, + end_date, + end_time, + event_data.location, + event_data.all_day, + None // Let backend use first available calendar + ).await { + Ok(_) => { + web_sys::console::log_1(&"Event created successfully".into()); + // Refresh the page to show the new event + web_sys::window().unwrap().location().reload().unwrap(); + } + Err(err) => { + web_sys::console::error_1(&format!("Failed to create event: {}", err).into()); + web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap(); + } + } + }); } }) }; diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index b868e12..ad6ae6b 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -586,6 +586,78 @@ impl CalendarService { } } + /// Create a new event on the CalDAV server + pub async fn create_event( + &self, + token: &str, + password: &str, + title: String, + description: String, + start_date: String, + start_time: String, + end_date: String, + end_time: String, + location: String, + all_day: bool, + calendar_path: Option + ) -> Result<(), String> { + let window = web_sys::window().ok_or("No global window exists")?; + + let opts = RequestInit::new(); + opts.set_method("POST"); + opts.set_mode(RequestMode::Cors); + + let body = serde_json::json!({ + "title": title, + "description": description, + "start_date": start_date, + "start_time": start_time, + "end_date": end_date, + "end_time": end_time, + "location": location, + "all_day": all_day, + "calendar_path": calendar_path + }); + + let body_string = serde_json::to_string(&body) + .map_err(|e| format!("JSON serialization failed: {}", e))?; + + let url = format!("{}/calendar/events/create", self.base_url); + opts.set_body(&body_string.into()); + let request = Request::new_with_str_and_init(&url, &opts) + .map_err(|e| format!("Request creation failed: {:?}", e))?; + + request.headers().set("Authorization", &format!("Bearer {}", token)) + .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; + + request.headers().set("X-CalDAV-Password", password) + .map_err(|e| format!("Password header setting failed: {:?}", e))?; + + request.headers().set("Content-Type", "application/json") + .map_err(|e| format!("Content-Type header setting failed: {:?}", e))?; + + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| format!("Network request failed: {:?}", e))?; + + let resp: Response = resp_value.dyn_into() + .map_err(|e| format!("Response cast failed: {:?}", e))?; + + let text = JsFuture::from(resp.text() + .map_err(|e| format!("Text extraction failed: {:?}", e))?) + .await + .map_err(|e| format!("Text promise failed: {:?}", e))?; + + let text_string = text.as_string() + .ok_or("Response text is not a string")?; + + if resp.ok() { + Ok(()) + } else { + Err(format!("Request failed with status {}: {}", resp.status(), text_string)) + } + } + /// Delete a calendar from the CalDAV server pub async fn delete_calendar( &self,