Implement complete event creation functionality with CalDAV backend

- Add CalDAV create_event method with proper iCalendar generation
- Add comprehensive backend API for event creation with validation
- Add event creation models and handlers with date/time parsing
- Add frontend service method for creating events via API
- Update frontend to call backend API instead of placeholder
- Fix CalDAV URL construction to avoid duplicate /dav.php paths
- Support all event fields: title, description, dates, location, all-day

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 22:29:06 -04:00
parent 5c966b2571
commit 3440403bed
6 changed files with 452 additions and 5 deletions

View File

@@ -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<String, CalDAVError> {
// 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<String, CalDAVError> {
let now = chrono::Utc::now();
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
let format_datetime = |dt: &DateTime<Utc>| -> String {
dt.format("%Y%m%dT%H%M%SZ").to_string()
};
let format_date = |dt: &DateTime<Utc>| -> 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

View File

@@ -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<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());
// 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<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))
}
}

View File

@@ -41,6 +41,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
.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(

View File

@@ -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<String>, // Optional - use first calendar if not specified
}
#[derive(Debug, Serialize)]
pub struct CreateEventResponse {
pub success: bool,
pub message: String,
pub event_href: Option<String>, // The created event's href/filename
}
// Error handling
#[derive(Debug)]
pub enum ApiError {

View File

@@ -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::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&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();
}
}
});
}
})
};

View File

@@ -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<String>
) -> 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,