- 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>
486 lines
17 KiB
Rust
486 lines
17 KiB
Rust
use axum::{
|
|
extract::{State, Query, Path},
|
|
http::HeaderMap,
|
|
response::Json,
|
|
};
|
|
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, CreateEventRequest, CreateEventResponse}};
|
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
|
|
|
#[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 the first calendar
|
|
let calendar_path = &calendar_paths[0];
|
|
let events = client.fetch_events(calendar_path)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch events: {}", e)))?;
|
|
|
|
// Filter events by month if specified
|
|
let filtered_events = if let (Some(year), Some(month)) = (params.year, params.month) {
|
|
events.into_iter().filter(|event| {
|
|
let event_date = event.start.date_naive();
|
|
event_date.year() == year && event_date.month() == month
|
|
}).collect()
|
|
} else {
|
|
events
|
|
};
|
|
|
|
Ok(Json(filtered_events))
|
|
}
|
|
|
|
pub async fn refresh_event(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(uid): Path<String>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
|
// Extract and verify token
|
|
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 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(None)); // No calendars found
|
|
}
|
|
|
|
// Fetch the specific event by UID from the first calendar
|
|
let calendar_path = &calendar_paths[0];
|
|
let event = client.fetch_event_by_uid(calendar_path, &uid)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?;
|
|
|
|
Ok(Json(event))
|
|
}
|
|
|
|
pub async fn login(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(request): Json<CalDAVLoginRequest>,
|
|
) -> Result<Json<AuthResponse>, ApiError> {
|
|
println!("🔐 Login attempt:");
|
|
println!(" Server URL: {}", request.server_url);
|
|
println!(" Username: {}", request.username);
|
|
println!(" Password length: {}", request.password.len());
|
|
|
|
let response = state.auth_service.login(request).await?;
|
|
Ok(Json(response))
|
|
}
|
|
|
|
pub async fn verify_token(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let token = extract_bearer_token(&headers)?;
|
|
let claims = state.auth_service.verify_token(&token)?;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"valid": true,
|
|
"username": claims.username,
|
|
"server_url": claims.server_url
|
|
})))
|
|
}
|
|
|
|
pub async fn get_user_info(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<UserInfo>, ApiError> {
|
|
// Extract and verify token
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
let claims = state.auth_service.verify_token(&token)?;
|
|
|
|
// 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)))?;
|
|
|
|
// Convert paths to CalendarInfo structs with display names, filtering out generic collections
|
|
let calendars: Vec<CalendarInfo> = calendar_paths.into_iter()
|
|
.filter_map(|path| {
|
|
let display_name = extract_calendar_name(&path);
|
|
// Skip generic collection names
|
|
if display_name.eq_ignore_ascii_case("calendar") ||
|
|
display_name.eq_ignore_ascii_case("calendars") ||
|
|
display_name.eq_ignore_ascii_case("collection") {
|
|
None
|
|
} else {
|
|
Some(CalendarInfo {
|
|
path: path.clone(),
|
|
display_name,
|
|
color: generate_calendar_color(&path),
|
|
})
|
|
}
|
|
}).collect();
|
|
|
|
Ok(Json(UserInfo {
|
|
username: claims.username,
|
|
server_url: claims.server_url,
|
|
calendars,
|
|
}))
|
|
}
|
|
|
|
// Helper function to generate a consistent color for a calendar based on its path
|
|
fn generate_calendar_color(path: &str) -> String {
|
|
// Predefined set of attractive, accessible colors for calendars
|
|
let colors = [
|
|
"#3B82F6", // Blue
|
|
"#10B981", // Emerald
|
|
"#F59E0B", // Amber
|
|
"#EF4444", // Red
|
|
"#8B5CF6", // Violet
|
|
"#06B6D4", // Cyan
|
|
"#84CC16", // Lime
|
|
"#F97316", // Orange
|
|
"#EC4899", // Pink
|
|
"#6366F1", // Indigo
|
|
"#14B8A6", // Teal
|
|
"#F3B806", // Yellow
|
|
"#8B5A2B", // Brown
|
|
"#6B7280", // Gray
|
|
"#DC2626", // Red-600
|
|
"#7C3AED", // Violet-600
|
|
];
|
|
|
|
// Create a simple hash from the path to ensure consistent color assignment
|
|
let mut hash: u32 = 0;
|
|
for byte in path.bytes() {
|
|
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
|
}
|
|
|
|
// Use the hash to select a color from our palette
|
|
let color_index = (hash as usize) % colors.len();
|
|
colors[color_index].to_string()
|
|
}
|
|
|
|
// Helper function to extract a readable calendar name from path
|
|
fn extract_calendar_name(path: &str) -> String {
|
|
// Extract the last meaningful part of the path
|
|
// e.g., "/calendars/user/personal/" -> "personal"
|
|
// or "/calendars/user/work-calendar/" -> "work-calendar"
|
|
let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
|
|
|
|
if let Some(last_part) = parts.last() {
|
|
if !last_part.is_empty() && *last_part != "calendars" {
|
|
// Convert kebab-case or snake_case to title case
|
|
last_part
|
|
.replace('-', " ")
|
|
.replace('_', " ")
|
|
.split_whitespace()
|
|
.map(|word| {
|
|
let mut chars = word.chars();
|
|
match chars.next() {
|
|
None => String::new(),
|
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
} else if parts.len() > 1 {
|
|
// If the last part is empty or "calendars", try the second to last
|
|
extract_calendar_name(&parts[..parts.len()-1].join("/"))
|
|
} else {
|
|
"Calendar".to_string()
|
|
}
|
|
} else {
|
|
"Calendar".to_string()
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
|
if let Some(auth_header) = headers.get("authorization") {
|
|
let auth_str = auth_header
|
|
.to_str()
|
|
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
|
|
|
|
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
|
Ok(token.to_string())
|
|
} else {
|
|
Err(ApiError::Unauthorized("Authorization header must start with 'Bearer '".to_string()))
|
|
}
|
|
} else {
|
|
Err(ApiError::Unauthorized("Authorization header required".to_string()))
|
|
}
|
|
}
|
|
|
|
fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
|
if let Some(password_header) = headers.get("x-caldav-password") {
|
|
let password = password_header
|
|
.to_str()
|
|
.map_err(|_| ApiError::BadRequest("Invalid password header".to_string()))?;
|
|
Ok(password.to_string())
|
|
} else {
|
|
Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string()))
|
|
}
|
|
}
|
|
|
|
pub async fn create_calendar(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<CreateCalendarRequest>,
|
|
) -> Result<Json<CreateCalendarResponse>, 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(),
|
|
}))
|
|
}
|
|
|
|
pub async fn delete_calendar(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<DeleteCalendarRequest>,
|
|
) -> Result<Json<DeleteCalendarResponse>, ApiError> {
|
|
println!("🗑️ Delete calendar request received: path='{}'", request.path);
|
|
|
|
// Extract and verify token
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
|
|
// Validate request
|
|
if request.path.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Calendar path is required".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);
|
|
|
|
// Delete the calendar
|
|
client.delete_calendar(&request.path)
|
|
.await
|
|
.map_err(|e| ApiError::Internal(format!("Failed to delete calendar: {}", e)))?;
|
|
|
|
Ok(Json(DeleteCalendarResponse {
|
|
success: true,
|
|
message: "Calendar deleted successfully".to_string(),
|
|
}))
|
|
}
|
|
|
|
pub async fn delete_event(
|
|
State(state): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
Json(request): Json<DeleteEventRequest>,
|
|
) -> Result<Json<DeleteEventResponse>, ApiError> {
|
|
println!("🗑️ Delete event request received: calendar_path='{}', event_href='{}'", request.calendar_path, request.event_href);
|
|
|
|
// Extract and verify token
|
|
let token = extract_bearer_token(&headers)?;
|
|
let password = extract_password_header(&headers)?;
|
|
|
|
// Validate request
|
|
if request.calendar_path.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
|
}
|
|
if request.event_href.trim().is_empty() {
|
|
return Err(ApiError::BadRequest("Event href is required".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);
|
|
|
|
// Delete the 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(),
|
|
}))
|
|
}
|
|
|
|
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))
|
|
}
|
|
} |