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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
50
src/app.rs
50
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::<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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user