diff --git a/backend/src/calendar.rs b/backend/src/calendar.rs index 6e6fea7..44028a2 100644 --- a/backend/src/calendar.rs +++ b/backend/src/calendar.rs @@ -590,6 +590,74 @@ impl CalDAVClient { Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str))) } + + /// Create a new calendar on the CalDAV server using MKCALENDAR + pub async fn create_calendar(&self, name: &str, description: Option<&str>, color: Option<&str>) -> Result<(), CalDAVError> { + // Sanitize calendar name for URL path + let calendar_id = name + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .to_lowercase(); + + let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id); + let full_url = format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path); + + // Build color property if provided + let color_property = if let Some(color) = color { + format!(r#"{}"#, color) + } else { + String::new() + }; + + let description_property = if let Some(desc) = description { + format!(r#"{}"#, desc) + } else { + String::new() + }; + + // Create the MKCALENDAR request body + let mkcalendar_body = format!( + r#" + + + + {} + + + + {} + {} + + +"#, + name, color_property, description_property + ); + + println!("Creating calendar at: {}", full_url); + println!("MKCALENDAR body: {}", mkcalendar_body); + + let response = self.http_client + .request(reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url) + .header("Content-Type", "application/xml; charset=utf-8") + .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .body(mkcalendar_body) + .send() + .await + .map_err(|e| CalDAVError::ParseError(e.to_string()))?; + + println!("Calendar creation response status: {}", response.status()); + + if response.status().is_success() { + println!("✅ Calendar created successfully at {}", calendar_path); + Ok(()) + } else { + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + println!("❌ Calendar creation failed: {} - {}", status, error_body); + Err(CalDAVError::ServerError(status.as_u16())) + } + } } /// Helper struct for extracting calendar data from XML responses @@ -688,7 +756,9 @@ mod tests { } } - /// Test parsing a sample iCal event +} + +/// Test parsing a sample iCal event #[test] fn test_parse_ical_event() { let sample_ical = r#"BEGIN:VCALENDAR @@ -780,4 +850,3 @@ END:VCALENDAR"#; println!("✓ Event enum tests passed!"); } -} \ No newline at end of file diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index ad0dfd3..9a94aa3 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}}; +use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse}}; use crate::calendar::{CalDAVClient, CalendarEvent}; #[derive(Deserialize)] @@ -252,4 +252,44 @@ fn extract_password_header(headers: &HeaderMap) -> Result { } else { Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string())) } +} + +pub async fn create_calendar( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result, 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(), + })) } \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 3a5b337..17ba784 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -38,6 +38,7 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/auth/login", post(handlers::login)) .route("/api/auth/verify", get(handlers::verify_token)) .route("/api/user/info", get(handlers::get_user_info)) + .route("/api/calendar/create", post(handlers::create_calendar)) .route("/api/calendar/events", get(handlers::get_calendar_events)) .route("/api/calendar/events/:uid", get(handlers::refresh_event)) .layer( diff --git a/backend/src/models.rs b/backend/src/models.rs index 6252e68..11fdb8a 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -34,6 +34,19 @@ pub struct CalendarInfo { pub color: String, } +#[derive(Debug, Deserialize)] +pub struct CreateCalendarRequest { + pub name: String, + pub description: Option, + pub color: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateCalendarResponse { + pub success: bool, + pub message: String, +} + // Error handling #[derive(Debug)] pub enum ApiError { diff --git a/src/app.rs b/src/app.rs index 26d412b..5dc0d84 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,7 @@ use yew::prelude::*; use yew_router::prelude::*; use gloo_storage::{LocalStorage, Storage}; -use crate::components::{Login, Calendar}; +use crate::components::{Login, Calendar, CreateCalendarModal}; use crate::services::{CalendarService, CalendarEvent, UserInfo}; use std::collections::HashMap; use chrono::{Local, NaiveDate, Datelike}; @@ -24,6 +24,7 @@ pub fn App() -> Html { let user_info = use_state(|| -> Option { None }); let color_picker_open = use_state(|| -> Option { None }); // Store calendar path of open picker + let create_modal_open = use_state(|| false); // Available colors for calendar customization let available_colors = [ @@ -113,6 +114,11 @@ pub fn App() -> Html { }) }; + // Clone variables needed for the modal outside of the conditional blocks + let auth_token_for_modal = auth_token.clone(); + let user_info_for_modal = user_info.clone(); + let create_modal_open_for_modal = create_modal_open.clone(); + html! {
@@ -148,7 +154,7 @@ pub fn App() -> Html {
    { info.calendars.iter().map(|cal| { - let cal_clone = cal.clone(); + let _cal_clone = cal.clone(); let color_picker_open_clone = color_picker_open.clone(); let on_color_click = { @@ -229,6 +235,12 @@ pub fn App() -> Html { } } @@ -299,6 +311,74 @@ pub fn App() -> Html { } } } + + , Option)| { + if let Some(token) = (*auth_token).clone() { + let user_info = user_info.clone(); + let create_modal_open = create_modal_open.clone(); + + wasm_bindgen_futures::spawn_local(async move { + let calendar_service = CalendarService::new(); + + // Get password from stored credentials + 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() + }; + + match calendar_service.create_calendar(&token, &password, name, description, color).await { + Ok(_) => { + web_sys::console::log_1(&"Calendar created successfully!".into()); + // Refresh user info to show the new calendar + match calendar_service.fetch_user_info(&token, &password).await { + Ok(mut info) => { + // Load saved colors from local storage + if let Ok(saved_colors_json) = LocalStorage::get::("calendar_colors") { + if let Ok(saved_info) = serde_json::from_str::(&saved_colors_json) { + for saved_cal in &saved_info.calendars { + for cal in &mut info.calendars { + if cal.path == saved_cal.path { + cal.color = saved_cal.color.clone(); + } + } + } + } + } + user_info.set(Some(info)); + } + Err(err) => { + web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into()); + } + } + create_modal_open.set(false); + } + Err(err) => { + web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into()); + // TODO: Show error to user + create_modal_open.set(false); + } + } + }); + } + } + })} + available_colors={available_colors.iter().map(|c| c.to_string()).collect::>()} + />
} diff --git a/src/components/create_calendar_modal.rs b/src/components/create_calendar_modal.rs new file mode 100644 index 0000000..ef94b96 --- /dev/null +++ b/src/components/create_calendar_modal.rs @@ -0,0 +1,196 @@ +use yew::prelude::*; + +#[derive(Properties, PartialEq)] +pub struct CreateCalendarModalProps { + pub is_open: bool, + pub on_close: Callback<()>, + pub on_create: Callback<(String, Option, Option)>, // name, description, color + pub available_colors: Vec, +} + +#[function_component] +pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { + let calendar_name = use_state(|| String::new()); + let description = use_state(|| String::new()); + let selected_color = use_state(|| None::); + let error_message = use_state(|| None::); + let is_creating = use_state(|| false); + + let on_name_change = { + let calendar_name = calendar_name.clone(); + Callback::from(move |e: InputEvent| { + let input: web_sys::HtmlInputElement = e.target_unchecked_into(); + calendar_name.set(input.value()); + }) + }; + + let on_description_change = { + let description = description.clone(); + Callback::from(move |e: InputEvent| { + let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into(); + description.set(input.value()); + }) + }; + + let on_submit = { + let calendar_name = calendar_name.clone(); + let description = description.clone(); + let selected_color = selected_color.clone(); + let error_message = error_message.clone(); + let is_creating = is_creating.clone(); + let on_create = props.on_create.clone(); + + Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + + let name = (*calendar_name).trim(); + if name.is_empty() { + error_message.set(Some("Calendar name is required".to_string())); + return; + } + + if name.len() > 100 { + error_message.set(Some("Calendar name too long (max 100 characters)".to_string())); + return; + } + + error_message.set(None); + is_creating.set(true); + + let desc = if (*description).trim().is_empty() { + None + } else { + Some((*description).clone()) + }; + + on_create.emit((name.to_string(), desc, (*selected_color).clone())); + }) + }; + + let on_backdrop_click = { + let on_close = props.on_close.clone(); + Callback::from(move |e: MouseEvent| { + // Only close if clicking the backdrop, not the modal content + if e.target() == e.current_target() { + on_close.emit(()); + } + }) + }; + + if !props.is_open { + return html! {}; + } + + html! { +