diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index cb11bb0..31399e7 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, TimeZone}; -use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}}; +use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}, models_v2::{CreateEventRequestV2, CreateEventResponseV2, UpdateEventRequestV2, UpdateEventResponseV2, DeleteEventRequestV2, DeleteEventResponseV2, DeleteActionV2}}; use crate::calendar::{CalDAVClient, CalendarEvent}; #[derive(Deserialize)] @@ -374,6 +374,162 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h Ok(None) } +/// Delete event using v2 API with enum-based delete actions +pub async fn delete_event_v2( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + println!("🗑️ Delete event v2 request received: calendar_path='{}', event_href='{}', action={:?}", + request.calendar_path, request.event_href, request.delete_action); + + // 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); + + // Handle different delete actions + match request.delete_action { + DeleteActionV2::DeleteThis => { + // Add EXDATE to exclude this specific occurrence + if let Some(occurrence_date) = request.occurrence_date { + println!("🔄 Adding EXDATE for occurrence: {}", occurrence_date); + + // First, fetch the current event to get its data + match fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await { + Ok(Some(mut event)) => { + // Check if it has recurrence rule + if event.recurrence_rule.is_some() { + // Calculate the exact datetime for this occurrence by using the original event's time + let original_time = event.start.time(); + let occurrence_datetime = occurrence_date.date_naive().and_time(original_time); + let exception_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime); + + println!("🔄 Original event start: {}", event.start); + println!("🔄 Occurrence date: {}", occurrence_date); + println!("🔄 Calculated EXDATE: {}", exception_utc); + + // Add the exception date + event.exception_dates.push(exception_utc); + + // Update the event with the new EXDATE + client.update_event(&request.calendar_path, &event, &request.event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?; + + Ok(Json(DeleteEventResponseV2 { + success: true, + message: "Individual occurrence excluded from series successfully".to_string(), + })) + } else { + // Not a recurring event, just delete it completely + client.delete_event(&request.calendar_path, &request.event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; + + Ok(Json(DeleteEventResponseV2 { + success: true, + message: "Event deleted successfully".to_string(), + })) + } + }, + Ok(None) => Err(ApiError::NotFound("Event not found".to_string())), + Err(e) => Err(ApiError::Internal(format!("Failed to fetch event: {}", e))), + } + } else { + Err(ApiError::BadRequest("Occurrence date is required for 'delete_this' action".to_string())) + } + }, + DeleteActionV2::DeleteFollowing => { + // Modify RRULE to end before the selected occurrence + if let Some(occurrence_date) = request.occurrence_date { + println!("🔄 Modifying RRULE to end before: {}", occurrence_date); + + // First, fetch the current event to get its data + match fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await { + Ok(Some(mut event)) => { + // Check if it has recurrence rule + if let Some(ref rrule) = event.recurrence_rule { + // Calculate the datetime for the occurrence we want to stop before + let original_time = event.start.time(); + let occurrence_datetime = occurrence_date.date_naive().and_time(original_time); + let occurrence_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime); + + // UNTIL should be the last occurrence we want to keep (day before the selected occurrence) + let until_date = occurrence_utc - chrono::Duration::days(1); + let until_str = until_date.format("%Y%m%dT%H%M%SZ").to_string(); + + println!("🔄 Original event start: {}", event.start); + println!("🔄 Occurrence to stop before: {}", occurrence_utc); + println!("🔄 UNTIL date (last to keep): {}", until_date); + println!("🔄 UNTIL string: {}", until_str); + println!("🔄 Original RRULE: {}", rrule); + + // Modify the RRULE to add UNTIL clause + let new_rrule = if rrule.contains("UNTIL=") { + // Replace existing UNTIL + regex::Regex::new(r"UNTIL=[^;]+").unwrap().replace(rrule, &format!("UNTIL={}", until_str)).to_string() + } else { + // Add UNTIL clause + format!("{};UNTIL={}", rrule, until_str) + }; + + println!("🔄 New RRULE: {}", new_rrule); + event.recurrence_rule = Some(new_rrule); + + // Update the event with the modified RRULE + client.update_event(&request.calendar_path, &event, &request.event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update event with modified RRULE: {}", e)))?; + + Ok(Json(DeleteEventResponseV2 { + success: true, + message: "Following occurrences removed from series successfully".to_string(), + })) + } else { + // Not a recurring event, just delete it completely + client.delete_event(&request.calendar_path, &request.event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; + + Ok(Json(DeleteEventResponseV2 { + success: true, + message: "Event deleted successfully".to_string(), + })) + } + }, + Ok(None) => Err(ApiError::NotFound("Event not found".to_string())), + Err(e) => Err(ApiError::Internal(format!("Failed to fetch event: {}", e))), + } + } else { + Err(ApiError::BadRequest("Occurrence date is required for 'delete_following' action".to_string())) + } + }, + DeleteActionV2::DeleteSeries => { + // Delete the entire event/series (current default behavior) + client.delete_event(&request.calendar_path, &request.event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; + + Ok(Json(DeleteEventResponseV2 { + success: true, + message: "Event series deleted successfully".to_string(), + })) + } + } +} + pub async fn delete_event( State(state): State>, headers: HeaderMap, @@ -540,6 +696,142 @@ pub async fn delete_event( } } +/// Create event using v2 API with direct DateTime support (no string parsing) +pub async fn create_event_v2( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + println!("📝 Create event v2 request received: summary='{}', all_day={}, calendar_path={:?}", + request.summary, 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.summary.trim().is_empty() { + return Err(ApiError::BadRequest("Event summary is required".to_string())); + } + + if request.summary.len() > 200 { + return Err(ApiError::BadRequest("Event summary 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() + }; + + // Validate that end is after start + if let Some(end) = request.dtend { + if end <= request.dtstart { + 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()); + + // Convert V2 enums to calendar module enums + let status = match request.status.unwrap_or_default() { + crate::models_v2::EventStatusV2::Tentative => crate::calendar::EventStatus::Tentative, + crate::models_v2::EventStatusV2::Cancelled => crate::calendar::EventStatus::Cancelled, + crate::models_v2::EventStatusV2::Confirmed => crate::calendar::EventStatus::Confirmed, + }; + + let class = match request.class.unwrap_or_default() { + crate::models_v2::EventClassV2::Private => crate::calendar::EventClass::Private, + crate::models_v2::EventClassV2::Confidential => crate::calendar::EventClass::Confidential, + crate::models_v2::EventClassV2::Public => crate::calendar::EventClass::Public, + }; + + // Convert attendees from V2 to simple email list (for now) + let attendees: Vec = request.attendees.into_iter() + .map(|att| att.email) + .collect(); + + // Convert alarms to reminders + let reminders: Vec = request.alarms.into_iter() + .map(|alarm| crate::calendar::EventReminder { + minutes_before: -alarm.trigger_minutes, // Convert to positive minutes before + action: match alarm.action { + crate::models_v2::AlarmActionV2::Display => crate::calendar::ReminderAction::Display, + crate::models_v2::AlarmActionV2::Email => crate::calendar::ReminderAction::Email, + crate::models_v2::AlarmActionV2::Audio => crate::calendar::ReminderAction::Audio, + }, + description: alarm.description, + }) + .collect(); + + // Create the CalendarEvent struct - much simpler now! + let event = crate::calendar::CalendarEvent { + uid, + summary: Some(request.summary.clone()), + description: request.description, + start: request.dtstart, + end: request.dtend, + location: request.location, + status, + class, + priority: request.priority, + organizer: request.organizer, + attendees, + categories: request.categories, + created: Some(chrono::Utc::now()), + last_modified: Some(chrono::Utc::now()), + recurrence_rule: request.rrule, + exception_dates: Vec::new(), // No exception dates for new events + all_day: request.all_day, + reminders, + 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)))?; + + // Fetch the created event to get its details + let created_event = fetch_event_by_href(&client, &calendar_path, &event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch created event: {}", e)))?; + + let event_summary = created_event.map(|e| crate::models_v2::EventSummaryV2 { + uid: e.uid, + summary: e.summary, + dtstart: e.start, + dtend: e.end, + location: e.location, + all_day: e.all_day, + href: e.href, + etag: e.etag, + }); + + Ok(Json(CreateEventResponseV2 { + success: true, + message: "Event created successfully".to_string(), + event: event_summary, + })) +} + pub async fn create_event( State(state): State>, headers: HeaderMap, @@ -763,6 +1055,255 @@ pub async fn create_event( })) } +/// Update event using v2 API with direct DateTime support (no string parsing) +pub async fn update_event_v2( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + println!("🔄 Update event v2 request received: uid='{}', summary='{}', update_action={:?}", + request.uid, request.summary, request.update_action); + + // Extract and verify token + let token = extract_bearer_token(&headers)?; + let password = extract_password_header(&headers)?; + + // Validate request + if request.uid.trim().is_empty() { + return Err(ApiError::BadRequest("Event UID is required".to_string())); + } + + if request.summary.trim().is_empty() { + return Err(ApiError::BadRequest("Event summary is required".to_string())); + } + + if request.summary.len() > 200 { + return Err(ApiError::BadRequest("Event summary 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); + + // Find the event across all calendars (or in the specified calendar) + let calendar_paths = if let Some(path) = &request.calendar_path { + vec![path.clone()] + } else { + 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 update".to_string())); + } + + // Validate that end is after start + if let Some(end) = request.dtend { + if end <= request.dtstart { + return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string())); + } + } + + // Determine if this is a series update + let search_uid = request.uid.clone(); + let is_series_update = request.update_action.as_deref() == Some("update_series"); + + // Search for the event by UID across the specified calendars + let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, href) + for calendar_path in &calendar_paths { + // First try exact match + match client.fetch_event_by_uid(calendar_path, &search_uid).await { + Ok(Some(event)) => { + if let Some(href) = event.href.clone() { + found_event = Some((event, calendar_path.clone(), href)); + break; + } + }, + Ok(None) => { + // If exact match fails, try to find by base UID pattern for recurring events + println!("🔍 Exact match failed for '{}', searching by base UID pattern", search_uid); + match client.fetch_events(calendar_path).await { + Ok(events) => { + for event in events { + if let Some(href) = &event.href { + if event.uid.starts_with(&search_uid) && event.uid != search_uid { + println!("🎯 Found recurring event by pattern: '{}' matches '{}'", event.uid, search_uid); + found_event = Some((event.clone(), calendar_path.clone(), href.clone())); + break; + } + } + } + if found_event.is_some() { + break; + } + }, + Err(e) => { + eprintln!("Error fetching events from {}: {:?}", calendar_path, e); + continue; + } + } + }, + Err(e) => { + eprintln!("Failed to fetch event from calendar {}: {}", calendar_path, e); + continue; + } + } + } + + let (mut event, calendar_path, event_href) = found_event + .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", search_uid)))?; + + // Convert V2 enums to calendar module enums + let status = match request.status.unwrap_or_default() { + crate::models_v2::EventStatusV2::Tentative => crate::calendar::EventStatus::Tentative, + crate::models_v2::EventStatusV2::Cancelled => crate::calendar::EventStatus::Cancelled, + crate::models_v2::EventStatusV2::Confirmed => crate::calendar::EventStatus::Confirmed, + }; + + let class = match request.class.unwrap_or_default() { + crate::models_v2::EventClassV2::Private => crate::calendar::EventClass::Private, + crate::models_v2::EventClassV2::Confidential => crate::calendar::EventClass::Confidential, + crate::models_v2::EventClassV2::Public => crate::calendar::EventClass::Public, + }; + + // Convert attendees from V2 to simple email list (for now) + let attendees: Vec = request.attendees.into_iter() + .map(|att| att.email) + .collect(); + + // Convert alarms to reminders + let reminders: Vec = request.alarms.into_iter() + .map(|alarm| crate::calendar::EventReminder { + minutes_before: -alarm.trigger_minutes, + action: match alarm.action { + crate::models_v2::AlarmActionV2::Display => crate::calendar::ReminderAction::Display, + crate::models_v2::AlarmActionV2::Email => crate::calendar::ReminderAction::Email, + crate::models_v2::AlarmActionV2::Audio => crate::calendar::ReminderAction::Audio, + }, + description: alarm.description, + }) + .collect(); + + // Update the event fields with new data + event.summary = Some(request.summary.clone()); + event.description = request.description; + + // Handle date/time updates based on update type + if is_series_update { + // For series updates, only update the TIME, keep the original DATE + let original_start_date = event.start.date_naive(); + let original_end_date = event.end.map(|e| e.date_naive()).unwrap_or(original_start_date); + + let new_start_time = request.dtstart.time(); + let new_end_time = request.dtend.map(|dt| dt.time()).unwrap_or(new_start_time); + + // Combine original date with new time + let updated_start = original_start_date.and_time(new_start_time).and_utc(); + let updated_end = original_end_date.and_time(new_end_time).and_utc(); + + event.start = updated_start; + event.end = Some(updated_end); + } else { + // For regular updates, update both date and time + event.start = request.dtstart; + event.end = request.dtend; + } + + event.location = request.location; + event.status = status; + event.class = class; + event.priority = request.priority; + event.organizer = request.organizer; + event.attendees = attendees; + event.categories = request.categories; + event.last_modified = Some(chrono::Utc::now()); + event.all_day = request.all_day; + event.reminders = reminders; + + // Handle recurrence rule and UID for series updates + if is_series_update { + // For series updates, preserve existing recurrence rule and convert UID to base UID + let parts: Vec<&str> = request.uid.split('-').collect(); + if parts.len() > 1 { + let last_part = parts[parts.len() - 1]; + if last_part.chars().all(|c| c.is_numeric()) { + let base_uid = parts[0..parts.len()-1].join("-"); + event.uid = base_uid; + } + } + + // Handle exception dates + if let Some(exception_dates) = request.exception_dates { + let mut new_exception_dates = Vec::new(); + for date in exception_dates { + new_exception_dates.push(date); + } + + // Merge with existing exception dates (avoid duplicates) + for new_date in new_exception_dates { + if !event.exception_dates.contains(&new_date) { + event.exception_dates.push(new_date); + } + } + + println!("🔄 Updated exception dates: {} total", event.exception_dates.len()); + } + + // Handle UNTIL date modification for "This and Future Events" + if let Some(until_date) = request.until_date { + println!("🔄 Adding UNTIL clause to RRULE: {}", until_date); + + if let Some(ref rrule) = event.recurrence_rule { + // Remove existing UNTIL if present and add new one + let rrule_without_until = rrule.split(';') + .filter(|part| !part.starts_with("UNTIL=")) + .collect::>() + .join(";"); + + let until_formatted = until_date.format("%Y%m%dT%H%M%SZ").to_string(); + + event.recurrence_rule = Some(format!("{};UNTIL={}", rrule_without_until, until_formatted)); + println!("🔄 Modified RRULE: {}", event.recurrence_rule.as_ref().unwrap()); + + // Clear exception dates since we're using UNTIL instead + event.exception_dates.clear(); + println!("🔄 Cleared exception dates for UNTIL approach"); + } + } + } else { + // For regular updates, use the new recurrence rule + event.recurrence_rule = request.rrule; + } + + // Update the event on the CalDAV server + client.update_event(&calendar_path, &event, &event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?; + + // Fetch the updated event to return its details + let updated_event = fetch_event_by_href(&client, &calendar_path, &event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch updated event: {}", e)))?; + + let event_summary = updated_event.map(|e| crate::models_v2::EventSummaryV2 { + uid: e.uid, + summary: e.summary, + dtstart: e.start, + dtend: e.end, + location: e.location, + all_day: e.all_day, + href: e.href, + etag: e.etag, + }); + + Ok(Json(UpdateEventResponseV2 { + success: true, + message: "Event updated successfully".to_string(), + event: event_summary, + })) +} + pub async fn update_event( State(state): State>, headers: HeaderMap, diff --git a/backend/src/lib.rs b/backend/src/lib.rs index f7bc55d..36cc0e9 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -8,6 +8,7 @@ use std::sync::Arc; mod auth; mod models; +mod models_v2; mod handlers; mod calendar; mod config; @@ -44,6 +45,10 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/calendar/events/create", post(handlers::create_event)) .route("/api/calendar/events/update", post(handlers::update_event)) .route("/api/calendar/events/delete", post(handlers::delete_event)) + // V2 API routes with better type safety + .route("/api/v2/calendar/events/create", post(handlers::create_event_v2)) + .route("/api/v2/calendar/events/update", post(handlers::update_event_v2)) + .route("/api/v2/calendar/events/delete", post(handlers::delete_event_v2)) .route("/api/calendar/events/:uid", get(handlers::refresh_event)) .layer( CorsLayer::new() diff --git a/backend/src/models_v2.rs b/backend/src/models_v2.rs new file mode 100644 index 0000000..768f7cb --- /dev/null +++ b/backend/src/models_v2.rs @@ -0,0 +1,295 @@ +// Simplified RFC 5545-based API models +// Axum imports removed - not needed for model definitions +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +// ==================== CALENDAR REQUESTS ==================== + +#[derive(Debug, Deserialize)] +pub struct CreateCalendarRequestV2 { + pub name: String, + pub description: Option, + pub color: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteCalendarRequestV2 { + pub path: String, +} + +// ==================== EVENT REQUESTS ==================== + +// Simplified create event request using proper DateTime instead of string parsing +#[derive(Debug, Deserialize)] +pub struct CreateEventRequestV2 { + pub summary: String, // title -> summary (RFC 5545 term) + pub description: Option, // Optional in RFC 5545 + pub dtstart: DateTime, // Direct DateTime, no string parsing! + pub dtend: Option>, // Optional, alternative to duration + pub location: Option, + pub all_day: bool, + + // Status and classification + pub status: Option, // Use enum instead of string + pub class: Option, // Use enum instead of string + pub priority: Option, // 0-9 priority level + + // People + pub organizer: Option, // Organizer email + pub attendees: Vec, // Rich attendee objects + + // Categorization + pub categories: Vec, // Direct Vec instead of comma-separated + + // Recurrence (simplified for now) + pub rrule: Option, // Standard RRULE format + + // Reminders (simplified for now) + pub alarms: Vec, // Structured alarms + + // Calendar context + pub calendar_path: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateEventRequestV2 { + pub uid: String, // Event UID to identify which event to update + pub summary: String, + pub description: Option, + pub dtstart: DateTime, // Direct DateTime, no string parsing! + pub dtend: Option>, + pub location: Option, + pub all_day: bool, + + // Status and classification + pub status: Option, + pub class: Option, + pub priority: Option, + + // People + pub organizer: Option, + pub attendees: Vec, + + // Categorization + pub categories: Vec, + + // Recurrence + pub rrule: Option, + + // Reminders + pub alarms: Vec, + + // Context + pub calendar_path: Option, + pub update_action: Option, // "update_series" for recurring events + pub occurrence_date: Option>, // Specific occurrence + pub exception_dates: Option>>, // EXDATE + pub until_date: Option>, // RRULE UNTIL clause +} + +#[derive(Debug, Deserialize)] +pub struct DeleteEventRequestV2 { + pub calendar_path: String, + pub event_href: String, + pub delete_action: DeleteActionV2, // Use enum instead of string + pub occurrence_date: Option>, // Direct DateTime +} + +// ==================== SUPPORTING TYPES ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum EventStatusV2 { + Tentative, + Confirmed, + Cancelled, +} + +impl Default for EventStatusV2 { + fn default() -> Self { + EventStatusV2::Confirmed + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum EventClassV2 { + Public, + Private, + Confidential, +} + +impl Default for EventClassV2 { + fn default() -> Self { + EventClassV2::Public + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum DeleteActionV2 { + DeleteThis, // "delete_this" + DeleteFollowing, // "delete_following" + DeleteSeries, // "delete_series" +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AttendeeV2 { + pub email: String, // Calendar address + pub name: Option, // Common name (CN parameter) + pub role: Option, // Role (ROLE parameter) + pub status: Option, // Participation status (PARTSTAT parameter) + pub rsvp: Option, // RSVP expectation (RSVP parameter) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AttendeeRoleV2 { + Chair, + Required, // REQ-PARTICIPANT + Optional, // OPT-PARTICIPANT + NonParticipant, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ParticipationStatusV2 { + NeedsAction, + Accepted, + Declined, + Tentative, + Delegated, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AlarmV2 { + pub action: AlarmActionV2, // Action (AUDIO, DISPLAY, EMAIL) + pub trigger_minutes: i32, // Minutes before event (negative = before) + pub description: Option, // Description for display/email +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AlarmActionV2 { + Audio, + Display, + Email, +} + +// ==================== RESPONSES ==================== + +#[derive(Debug, Serialize)] +pub struct CreateEventResponseV2 { + pub success: bool, + pub message: String, + pub event: Option, // Return created event summary +} + +#[derive(Debug, Serialize)] +pub struct UpdateEventResponseV2 { + pub success: bool, + pub message: String, + pub event: Option, // Return updated event summary +} + +#[derive(Debug, Serialize)] +pub struct DeleteEventResponseV2 { + pub success: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EventSummaryV2 { + pub uid: String, + pub summary: Option, + pub dtstart: DateTime, + pub dtend: Option>, + pub location: Option, + pub all_day: bool, + pub href: Option, + pub etag: Option, +} + +// ==================== CONVERSION HELPERS ==================== + +// Convert from old request format to new for backward compatibility +impl From for CreateEventRequestV2 { + fn from(old: crate::models::CreateEventRequest) -> Self { + use chrono::{NaiveDate, NaiveTime, TimeZone, Utc}; + + // Parse the old string-based date/time format + let start_date = NaiveDate::parse_from_str(&old.start_date, "%Y-%m-%d") + .unwrap_or_else(|_| chrono::Utc::now().date_naive()); + let start_time = NaiveTime::parse_from_str(&old.start_time, "%H:%M") + .unwrap_or_else(|_| NaiveTime::from_hms_opt(0, 0, 0).unwrap()); + let dtstart = Utc.from_utc_datetime(&start_date.and_time(start_time)); + + let end_date = NaiveDate::parse_from_str(&old.end_date, "%Y-%m-%d") + .unwrap_or_else(|_| chrono::Utc::now().date_naive()); + let end_time = NaiveTime::parse_from_str(&old.end_time, "%H:%M") + .unwrap_or_else(|_| NaiveTime::from_hms_opt(1, 0, 0).unwrap()); + let dtend = Some(Utc.from_utc_datetime(&end_date.and_time(end_time))); + + // Parse comma-separated categories + let categories: Vec = if old.categories.trim().is_empty() { + Vec::new() + } else { + old.categories.split(',').map(|s| s.trim().to_string()).collect() + }; + + // Parse comma-separated attendees + let attendees: Vec = if old.attendees.trim().is_empty() { + Vec::new() + } else { + old.attendees.split(',').map(|email| AttendeeV2 { + email: email.trim().to_string(), + name: None, + role: Some(AttendeeRoleV2::Required), + status: Some(ParticipationStatusV2::NeedsAction), + rsvp: Some(true), + }).collect() + }; + + // Convert status string to enum + let status = match old.status.as_str() { + "tentative" => Some(EventStatusV2::Tentative), + "confirmed" => Some(EventStatusV2::Confirmed), + "cancelled" => Some(EventStatusV2::Cancelled), + _ => Some(EventStatusV2::Confirmed), + }; + + // Convert class string to enum + let class = match old.class.as_str() { + "public" => Some(EventClassV2::Public), + "private" => Some(EventClassV2::Private), + "confidential" => Some(EventClassV2::Confidential), + _ => Some(EventClassV2::Public), + }; + + // Create basic alarm if reminder specified + let alarms = if old.reminder == "none" { + Vec::new() + } else { + // Default to 15 minutes before for now + vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: 15, + description: Some("Event reminder".to_string()), + }] + }; + + Self { + summary: old.title, + description: if old.description.trim().is_empty() { None } else { Some(old.description) }, + dtstart, + dtend, + location: if old.location.trim().is_empty() { None } else { Some(old.location) }, + all_day: old.all_day, + status, + class, + priority: old.priority, + organizer: if old.organizer.trim().is_empty() { None } else { Some(old.organizer) }, + attendees, + categories, + rrule: None, // TODO: Convert recurrence string to RRULE + alarms, + calendar_path: old.calendar_path, + } + } +} + +// Error handling - ApiError is available through crate::models::ApiError in handlers \ No newline at end of file diff --git a/integration_plan.md b/integration_plan.md new file mode 100644 index 0000000..b481cb0 --- /dev/null +++ b/integration_plan.md @@ -0,0 +1,186 @@ +# RFC 5545 Integration Plan + +## Phase 1: Core Structure Replacement (High Impact, Low Risk) + +### 1.1 Replace Event Models +**Files to Update:** +- `backend/src/calendar.rs` - Replace `CalendarEvent` with `VEvent` +- `src/services/calendar_service.rs` - Replace `CalendarEvent` with `VEvent` +- Remove duplicate structures, use single source of truth + +**Benefits:** +- ✅ Eliminate duplicate event definitions +- ✅ Add missing DTSTAMP (RFC required) +- ✅ Add SEQUENCE for proper versioning +- ✅ Standardize on DateTime instead of string parsing + +### 1.2 Simplify Request/Response Models +**Files to Update:** +- `backend/src/models.rs` - Replace string-based fields + +**Current (Complex):** +```rust +pub start_date: String, // YYYY-MM-DD format +pub start_time: String, // HH:MM format +pub categories: String, // comma-separated +pub attendees: String, // comma-separated +``` + +**New (Simple):** +```rust +pub dtstart: DateTime, +pub categories: Vec, +pub attendees: Vec, +``` + +**Benefits:** +- ✅ Remove ~50 lines of manual string parsing in handlers +- ✅ Better type safety +- ✅ Automatic validation + +## Phase 2: Enhanced Functionality (Medium Impact, Medium Risk) + +### 2.1 Add Rich Attendee Support +**Current:** `Vec` (just emails) +**New:** `Vec` with roles, status, RSVP + +**Benefits:** +- ✅ Proper meeting invitations +- ✅ RSVP tracking +- ✅ Role-based permissions (Chair, Required, Optional) + +### 2.2 Structured Reminders/Alarms +**Current:** Simple reminder minutes +**New:** Full `VAlarm` component with actions, triggers + +**Benefits:** +- ✅ Multiple reminder types (email, display, audio) +- ✅ Complex trigger patterns +- ✅ Better CalDAV compatibility + +### 2.3 Geographic Location Support +**New Addition:** `GEO` property for latitude/longitude + +**Benefits:** +- ✅ Map integration possibilities +- ✅ Location-based reminders +- ✅ Travel time calculations + +## Phase 3: Advanced Components (High Impact, Higher Risk) + +### 3.1 Add VTODO Support +**New Component:** Task/To-Do management + +**Benefits:** +- ✅ Unified calendar + task system +- ✅ Due dates, completion tracking +- ✅ Priority management + +### 3.2 Add VJOURNAL Support +**New Component:** Journal/diary entries + +**Benefits:** +- ✅ Meeting notes integration +- ✅ Daily journaling +- ✅ Full calendar suite + +### 3.3 Add VFREEBUSY Support +**New Component:** Availability tracking + +**Benefits:** +- ✅ Meeting scheduling optimization +- ✅ Conflict detection +- ✅ Resource booking + +## Implementation Strategy + +### Immediate Actions (Can Start Now) +1. **Add compatibility layer** in existing `CalendarEvent` to support new fields +2. **Implement conversion functions** between old/new structures +3. **Update backend models** to use DateTime instead of string parsing + +### Quick Wins (1-2 hours each) +1. **Replace string date parsing** in `backend/src/handlers.rs` +2. **Add missing DTSTAMP** to all events (RFC compliance) +3. **Convert categories/attendees** from comma-separated strings to vectors + +### Medium Effort (3-5 hours each) +1. **Unified event structure** across frontend/backend +2. **Rich attendee management** with roles and status +3. **Structured alarm system** + +### Long Term (Future enhancements) +1. **Full VTODO implementation** +2. **VJOURNAL support** +3. **VFREEBUSY and scheduling** + +## Risk Mitigation + +### Backward Compatibility +- Keep existing API endpoints working +- Add conversion functions between old/new formats +- Gradual migration, not big-bang replacement + +### Testing Strategy +- Add tests for RFC 5545 compliance +- Test CalDAV interoperability +- Validate against multiple calendar clients + +### Rollback Plan +- Keep old structures as fallback +- Feature flags for new functionality +- Incremental deployment + +## Expected Benefits + +### Developer Experience +- **50% reduction** in date/time parsing code +- **Elimination** of string-based field parsing +- **Type safety** for all calendar operations +- **Standards compliance** reduces debugging + +### User Experience +- **Better CalDAV compatibility** with all clients +- **Rich attendee management** for meetings +- **Proper timezone handling** +- **Future-proof** for advanced features + +### Maintenance +- **Single source of truth** for event data +- **RFC 5545 compliance** eliminates compatibility issues +- **Cleaner codebase** with less duplication +- **Easier testing** with structured data + +## File Impact Analysis + +### High Impact Files (Need Updates) +``` +backend/src/models.rs - Replace request/response structs +backend/src/handlers.rs - Remove string parsing logic +backend/src/calendar.rs - Replace CalendarEvent +src/services/calendar_service.rs - Use unified structures +``` + +### Medium Impact Files (Minor Changes) +``` +src/components/create_event_modal.rs - Update form handling +src/components/event_modal.rs - Display enhancements +backend/src/lib.rs - Add new modules +``` + +### Low Impact Files (Minimal/No Changes) +``` +src/components/week_view.rs - Just use new event structure +src/components/month_view.rs - Just use new event structure +styles.css - No changes needed +``` + +## Next Steps + +1. **Review this plan** with team/stakeholders +2. **Create branch** for RFC 5545 integration +3. **Start with Phase 1.1** - Core structure replacement +4. **Implement conversion functions** for compatibility +5. **Update one handler at a time** to reduce risk + +The integration will significantly simplify the codebase while adding professional-grade calendar functionality! \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 57a7867..48a80da 100644 --- a/src/app.rs +++ b/src/app.rs @@ -328,72 +328,13 @@ pub fn App() -> Html { String::new() }; - // Convert local times to UTC for backend storage - let start_local = event_data.start_date.and_time(event_data.start_time); - let end_local = event_data.end_date.and_time(event_data.end_time); + // Use v2 API with structured data (no string conversion needed!) + let create_request = event_data.to_create_request_v2(); - let start_utc = start_local.and_local_timezone(chrono::Local).unwrap().to_utc(); - let end_utc = end_local.and_local_timezone(chrono::Local).unwrap().to_utc(); - - // Format UTC date and time strings for backend - let start_date = start_utc.format("%Y-%m-%d").to_string(); - let start_time = start_utc.format("%H:%M").to_string(); - let end_date = end_utc.format("%Y-%m-%d").to_string(); - let end_time = end_utc.format("%H:%M").to_string(); - - // Convert enums to strings for backend - let status_str = match event_data.status { - EventStatus::Tentative => "tentative", - EventStatus::Cancelled => "cancelled", - _ => "confirmed", - }.to_string(); - - let class_str = match event_data.class { - EventClass::Private => "private", - EventClass::Confidential => "confidential", - _ => "public", - }.to_string(); - - let reminder_str = match event_data.reminder { - ReminderType::Minutes15 => "15min", - ReminderType::Minutes30 => "30min", - ReminderType::Hour1 => "1hour", - ReminderType::Hours2 => "2hours", - ReminderType::Day1 => "1day", - ReminderType::Days2 => "2days", - ReminderType::Week1 => "1week", - _ => "none", - }.to_string(); - - let recurrence_str = match event_data.recurrence { - RecurrenceType::Daily => "daily", - RecurrenceType::Weekly => "weekly", - RecurrenceType::Monthly => "monthly", - RecurrenceType::Yearly => "yearly", - _ => "none", - }.to_string(); - - match calendar_service.create_event( + match calendar_service.create_event_v2( &token, &password, - event_data.title, - event_data.description, - start_date, - start_time, - end_date, - end_time, - event_data.location, - event_data.all_day, - status_str, - class_str, - event_data.priority, - event_data.organizer, - event_data.attendees, - event_data.categories, - reminder_str, - recurrence_str, - event_data.recurrence_days, - event_data.selected_calendar + create_request, ).await { Ok(_) => { web_sys::console::log_1(&"Event created successfully".into()); diff --git a/src/components/create_event_modal.rs b/src/components/create_event_modal.rs index 23b3064..9906833 100644 --- a/src/components/create_event_modal.rs +++ b/src/components/create_event_modal.rs @@ -1,7 +1,7 @@ use yew::prelude::*; use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement}; -use chrono::{NaiveDate, NaiveTime}; -use crate::services::calendar_service::{CalendarInfo, CalendarEvent}; +use chrono::{NaiveDate, NaiveTime, Utc, TimeZone}; +use crate::services::calendar_service::{CalendarInfo, CalendarEvent, CreateEventRequestV2, AttendeeV2, AlarmV2, AttendeeRoleV2, ParticipationStatusV2, AlarmActionV2}; #[derive(Properties, PartialEq)] pub struct CreateEventModalProps { @@ -187,6 +187,161 @@ impl EventCreationData { selected_calendar: event.calendar_path.clone(), } } + + /// Convert EventCreationData to CreateEventRequestV2 for the new v2 API + pub fn to_create_request_v2(&self) -> CreateEventRequestV2 { + // Combine date and time into UTC DateTime + let start_local = self.start_date.and_time(self.start_time); + let end_local = self.end_date.and_time(self.end_time); + + // Convert local time to UTC (assuming local timezone for now) + let start_utc = chrono::Local.from_local_datetime(&start_local) + .single() + .unwrap_or_else(|| chrono::Local.from_local_datetime(&start_local).earliest().unwrap()) + .with_timezone(&Utc); + let end_utc = chrono::Local.from_local_datetime(&end_local) + .single() + .unwrap_or_else(|| chrono::Local.from_local_datetime(&end_local).earliest().unwrap()) + .with_timezone(&Utc); + + // Convert status + let status = match self.status { + EventStatus::Tentative => Some(crate::services::calendar_service::EventStatus::Tentative), + EventStatus::Confirmed => Some(crate::services::calendar_service::EventStatus::Confirmed), + EventStatus::Cancelled => Some(crate::services::calendar_service::EventStatus::Cancelled), + }; + + // Convert class + let class = match self.class { + EventClass::Public => Some(crate::services::calendar_service::EventClass::Public), + EventClass::Private => Some(crate::services::calendar_service::EventClass::Private), + EventClass::Confidential => Some(crate::services::calendar_service::EventClass::Confidential), + }; + + // Convert attendees from comma-separated string to structured list + let attendees = if self.attendees.trim().is_empty() { + Vec::new() + } else { + self.attendees.split(',') + .map(|email| AttendeeV2 { + email: email.trim().to_string(), + name: None, + role: Some(AttendeeRoleV2::Required), + status: Some(ParticipationStatusV2::NeedsAction), + rsvp: Some(true), + }) + .collect() + }; + + // Convert categories from comma-separated string to vector + let categories = if self.categories.trim().is_empty() { + Vec::new() + } else { + self.categories.split(',') + .map(|cat| cat.trim().to_string()) + .filter(|cat| !cat.is_empty()) + .collect() + }; + + // Convert reminder to alarms + let alarms = match self.reminder { + ReminderType::Minutes15 => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -15, + description: Some("Event reminder".to_string()), + }], + ReminderType::Minutes30 => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -30, + description: Some("Event reminder".to_string()), + }], + ReminderType::Hour1 => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -60, + description: Some("Event reminder".to_string()), + }], + ReminderType::Hours2 => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -120, + description: Some("Event reminder".to_string()), + }], + ReminderType::Day1 => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -1440, + description: Some("Event reminder".to_string()), + }], + ReminderType::Days2 => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -2880, + description: Some("Event reminder".to_string()), + }], + ReminderType::Week1 => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -10080, + description: Some("Event reminder".to_string()), + }], + ReminderType::None => Vec::new(), + }; + + // Convert recurrence to RRULE string + let rrule = match self.recurrence { + RecurrenceType::Daily => Some("FREQ=DAILY".to_string()), + RecurrenceType::Weekly => { + let mut rrule = "FREQ=WEEKLY".to_string(); + + // Add BYDAY if specific days are selected + if self.recurrence_days.len() == 7 { + let selected_days: Vec<&str> = self.recurrence_days + .iter() + .enumerate() + .filter_map(|(i, &selected)| { + if selected { + Some(match i { + 0 => "SU", // Sunday + 1 => "MO", // Monday + 2 => "TU", // Tuesday + 3 => "WE", // Wednesday + 4 => "TH", // Thursday + 5 => "FR", // Friday + 6 => "SA", // Saturday + _ => return None, + }) + } else { + None + } + }) + .collect(); + + if !selected_days.is_empty() { + rrule.push_str(&format!(";BYDAY={}", selected_days.join(","))); + } + } + + Some(rrule) + }, + RecurrenceType::Monthly => Some("FREQ=MONTHLY".to_string()), + RecurrenceType::Yearly => Some("FREQ=YEARLY".to_string()), + RecurrenceType::None => None, + }; + + CreateEventRequestV2 { + summary: self.title.clone(), + description: if self.description.trim().is_empty() { None } else { Some(self.description.clone()) }, + dtstart: start_utc, + dtend: Some(end_utc), + location: if self.location.trim().is_empty() { None } else { Some(self.location.clone()) }, + all_day: self.all_day, + status, + class, + priority: self.priority, + organizer: if self.organizer.trim().is_empty() { None } else { Some(self.organizer.clone()) }, + attendees, + categories, + rrule, + alarms, + calendar_path: self.selected_calendar.clone(), + } + } } #[function_component(CreateEventModal)] diff --git a/src/main.rs b/src/main.rs index 9c8f73c..da1d4f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app; mod auth; mod components; +mod models; mod services; use app::App; diff --git a/src/models/ical.rs b/src/models/ical.rs new file mode 100644 index 0000000..0acce12 --- /dev/null +++ b/src/models/ical.rs @@ -0,0 +1,601 @@ +// RFC 5545 Compliant iCalendar Data Structures +// This file contains updated structures that fully comply with RFC 5545 iCalendar specification + +use chrono::{DateTime, Utc, NaiveDate, Duration}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// ==================== CALENDAR OBJECT (VCALENDAR) ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ICalendarObject { + // Required calendar properties + pub prodid: String, // Product identifier (PRODID) + pub version: String, // Version (typically "2.0") + + // Optional calendar properties + pub calscale: Option, // Calendar scale (CALSCALE) - default "GREGORIAN" + pub method: Option, // Method (METHOD) + + // Components + pub events: Vec, // VEVENT components + pub todos: Vec, // VTODO components + pub journals: Vec, // VJOURNAL components + pub freebusys: Vec, // VFREEBUSY components + pub timezones: Vec, // VTIMEZONE components +} + +// ==================== VEVENT COMPONENT ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VEvent { + // Required properties + pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED + pub uid: String, // Unique identifier (UID) - REQUIRED + pub dtstart: DateTime, // Start date-time (DTSTART) - REQUIRED + + // Optional properties (commonly used) + pub dtend: Option>, // End date-time (DTEND) + pub duration: Option, // Duration (DURATION) - alternative to DTEND + pub summary: Option, // Summary/title (SUMMARY) + pub description: Option, // Description (DESCRIPTION) + pub location: Option, // Location (LOCATION) + + // Classification and status + pub class: Option, // Classification (CLASS) + pub status: Option, // Status (STATUS) + pub transp: Option, // Time transparency (TRANSP) + pub priority: Option, // Priority 0-9 (PRIORITY) + + // People and organization + pub organizer: Option, // Organizer (ORGANIZER) + pub attendees: Vec, // Attendees (ATTENDEE) + pub contact: Option, // Contact information (CONTACT) + + // Categorization and relationships + pub categories: Vec, // Categories (CATEGORIES) + pub comment: Option, // Comment (COMMENT) + pub resources: Vec, // Resources (RESOURCES) + pub related_to: Option, // Related component (RELATED-TO) + pub url: Option, // URL (URL) + + // Geographical + pub geo: Option, // Geographic position (GEO) + + // Versioning and modification + pub sequence: Option, // Sequence number (SEQUENCE) + pub created: Option>, // Creation time (CREATED) + pub last_modified: Option>, // Last modified (LAST-MODIFIED) + + // Recurrence + pub rrule: Option, // Recurrence rule (RRULE) + pub rdate: Vec>, // Recurrence dates (RDATE) + pub exdate: Vec>, // Exception dates (EXDATE) + pub recurrence_id: Option>, // Recurrence ID (RECURRENCE-ID) + + // Alarms and attachments + pub alarms: Vec, // VALARM components + pub attachments: Vec, // Attachments (ATTACH) + + // CalDAV specific (for implementation) + pub etag: Option, // ETag for CalDAV + pub href: Option, // Href for CalDAV + pub calendar_path: Option, // Calendar path + pub all_day: bool, // All-day event flag +} + +// ==================== VTODO COMPONENT ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VTodo { + // Required properties + pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED + pub uid: String, // Unique identifier (UID) - REQUIRED + + // Optional date-time properties + pub dtstart: Option>, // Start date-time (DTSTART) + pub due: Option>, // Due date-time (DUE) + pub duration: Option, // Duration (DURATION) + pub completed: Option>, // Completion date-time (COMPLETED) + + // Descriptive properties + pub summary: Option, // Summary/title (SUMMARY) + pub description: Option, // Description (DESCRIPTION) + pub location: Option, // Location (LOCATION) + + // Status and completion + pub status: Option, // Status (STATUS) + pub percent_complete: Option, // Percent complete 0-100 (PERCENT-COMPLETE) + pub priority: Option, // Priority 0-9 (PRIORITY) + + // People and organization + pub organizer: Option, // Organizer (ORGANIZER) + pub attendees: Vec, // Attendees (ATTENDEE) + pub contact: Option, // Contact information (CONTACT) + + // Categorization and relationships + pub categories: Vec, // Categories (CATEGORIES) + pub comment: Option, // Comment (COMMENT) + pub resources: Vec, // Resources (RESOURCES) + pub related_to: Option, // Related component (RELATED-TO) + pub url: Option, // URL (URL) + + // Geographical + pub geo: Option, // Geographic position (GEO) + + // Versioning and modification + pub sequence: Option, // Sequence number (SEQUENCE) + pub created: Option>, // Creation time (CREATED) + pub last_modified: Option>, // Last modified (LAST-MODIFIED) + + // Recurrence + pub rrule: Option, // Recurrence rule (RRULE) + pub rdate: Vec>, // Recurrence dates (RDATE) + pub exdate: Vec>, // Exception dates (EXDATE) + pub recurrence_id: Option>, // Recurrence ID (RECURRENCE-ID) + + // Alarms and attachments + pub alarms: Vec, // VALARM components + pub attachments: Vec, // Attachments (ATTACH) +} + +// ==================== VJOURNAL COMPONENT ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VJournal { + // Required properties + pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED + pub uid: String, // Unique identifier (UID) - REQUIRED + + // Optional properties + pub dtstart: Option>, // Start date-time (DTSTART) + pub summary: Option, // Summary/title (SUMMARY) + pub description: Option, // Description (DESCRIPTION) + + // Classification and status + pub class: Option, // Classification (CLASS) + pub status: Option, // Status (STATUS) + + // People and organization + pub organizer: Option, // Organizer (ORGANIZER) + pub contact: Option, // Contact information (CONTACT) + + // Categorization and relationships + pub categories: Vec, // Categories (CATEGORIES) + pub comment: Option, // Comment (COMMENT) + pub related_to: Option, // Related component (RELATED-TO) + pub url: Option, // URL (URL) + + // Versioning and modification + pub sequence: Option, // Sequence number (SEQUENCE) + pub created: Option>, // Creation time (CREATED) + pub last_modified: Option>, // Last modified (LAST-MODIFIED) + + // Recurrence + pub rrule: Option, // Recurrence rule (RRULE) + pub rdate: Vec>, // Recurrence dates (RDATE) + pub exdate: Vec>, // Exception dates (EXDATE) + pub recurrence_id: Option>, // Recurrence ID (RECURRENCE-ID) + + // Attachments + pub attachments: Vec, // Attachments (ATTACH) +} + +// ==================== VFREEBUSY COMPONENT ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VFreeBusy { + // Required properties + pub dtstamp: DateTime, // Date-time stamp (DTSTAMP) - REQUIRED + pub uid: String, // Unique identifier (UID) - REQUIRED + + // Optional properties + pub dtstart: Option>, // Start date-time (DTSTART) + pub dtend: Option>, // End date-time (DTEND) + pub duration: Option, // Duration (DURATION) + + // Free/busy information + pub freebusy: Vec, // Free/busy periods (FREEBUSY) + + // People and organization + pub organizer: Option, // Organizer (ORGANIZER) + pub attendees: Vec, // Attendees (ATTENDEE) + pub contact: Option, // Contact information (CONTACT) + + // Additional properties + pub comment: Option, // Comment (COMMENT) + pub url: Option, // URL (URL) +} + +// ==================== VTIMEZONE COMPONENT ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VTimeZone { + // Required properties + pub tzid: String, // Time zone identifier (TZID) - REQUIRED + + // Optional properties + pub tzname: Option, // Time zone name (TZNAME) + pub tzurl: Option, // Time zone URL (TZURL) + + // Standard and daylight components + pub standard: Vec, // Standard time components + pub daylight: Vec, // Daylight time components + + // Last modified + pub last_modified: Option>, // Last modified (LAST-MODIFIED) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TimeZoneComponent { + pub dtstart: DateTime, // Start date-time (DTSTART) - REQUIRED + pub tzoffsetfrom: String, // UTC offset from (TZOFFSETFROM) - REQUIRED + pub tzoffsetto: String, // UTC offset to (TZOFFSETTO) - REQUIRED + + pub tzname: Option, // Time zone name (TZNAME) + pub comment: Option, // Comment (COMMENT) + + // Recurrence + pub rrule: Option, // Recurrence rule (RRULE) + pub rdate: Vec>, // Recurrence dates (RDATE) +} + +// ==================== VALARM COMPONENT ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VAlarm { + pub action: AlarmAction, // Action (ACTION) - REQUIRED + pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED + + // Optional properties (some required based on action) + pub description: Option, // Description (DESCRIPTION) + pub summary: Option, // Summary (SUMMARY) + pub duration: Option, // Duration (DURATION) + pub repeat: Option, // Repeat count (REPEAT) + pub attendees: Vec, // Attendees (ATTENDEE) - for EMAIL action + pub attachments: Vec, // Attachments (ATTACH) +} + +// ==================== SUPPORTING TYPES ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum EventClass { + Public, + Private, + Confidential, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum EventStatus { + Tentative, + Confirmed, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TodoStatus { + NeedsAction, + Completed, + InProcess, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum JournalStatus { + Draft, + Final, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TimeTransparency { + Opaque, // Time is not available (default) + Transparent, // Time is available despite event +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AlarmAction { + Audio, + Display, + Email, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AlarmTrigger { + Duration(Duration), // Relative to start/end + DateTime(DateTime), // Absolute time +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CalendarUser { + pub cal_address: String, // Calendar address (email) + pub cn: Option, // Common name (CN parameter) + pub dir: Option, // Directory entry (DIR parameter) + pub sent_by: Option, // Sent by (SENT-BY parameter) + pub language: Option, // Language (LANGUAGE parameter) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Attendee { + pub cal_address: String, // Calendar address (email) + pub cn: Option, // Common name (CN parameter) + pub role: Option, // Role (ROLE parameter) + pub partstat: Option, // Participation status (PARTSTAT parameter) + pub rsvp: Option, // RSVP expectation (RSVP parameter) + pub cutype: Option, // Calendar user type (CUTYPE parameter) + pub member: Vec, // Group/list membership (MEMBER parameter) + pub delegated_to: Vec, // Delegated to (DELEGATED-TO parameter) + pub delegated_from: Vec, // Delegated from (DELEGATED-FROM parameter) + pub sent_by: Option, // Sent by (SENT-BY parameter) + pub dir: Option, // Directory entry (DIR parameter) + pub language: Option, // Language (LANGUAGE parameter) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AttendeeRole { + Chair, + ReqParticipant, + OptParticipant, + NonParticipant, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ParticipationStatus { + NeedsAction, + Accepted, + Declined, + Tentative, + Delegated, + Completed, + InProcess, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum CalendarUserType { + Individual, + Group, + Resource, + Room, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GeographicPosition { + pub latitude: f64, // Latitude in decimal degrees + pub longitude: f64, // Longitude in decimal degrees +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Attachment { + pub data: AttachmentData, // Attachment data + pub fmttype: Option, // Format type (FMTTYPE parameter) + pub encoding: Option, // Encoding (ENCODING parameter) + pub filename: Option, // Filename (X-FILENAME parameter) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AttachmentData { + Uri(String), // URI reference + Binary(Vec), // Binary data +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FreeBusyTime { + pub period: (DateTime, DateTime), // Start and end time + pub fbtype: Option, // Free/busy type +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum FreeBusyType { + Free, + Busy, + BusyUnavailable, + BusyTentative, +} + +// ==================== COMPATIBILITY LAYER ==================== + +use crate::services::calendar_service::{CalendarEvent, EventReminder, ReminderAction}; + +// Conversion from new VEvent to existing CalendarEvent +impl From for CalendarEvent { + fn from(vevent: VEvent) -> Self { + Self { + uid: vevent.uid, + summary: vevent.summary, + description: vevent.description, + start: vevent.dtstart, + end: vevent.dtend, + location: vevent.location, + status: vevent.status.unwrap_or(EventStatus::Confirmed).into(), + class: vevent.class.unwrap_or(EventClass::Public).into(), + priority: vevent.priority, + organizer: vevent.organizer.map(|o| o.cal_address), + attendees: vevent.attendees.into_iter().map(|a| a.cal_address).collect(), + categories: vevent.categories, + created: vevent.created, + last_modified: vevent.last_modified, + recurrence_rule: vevent.rrule, + exception_dates: vevent.exdate, + all_day: vevent.all_day, + reminders: vevent.alarms.into_iter().map(|a| a.into()).collect(), + etag: vevent.etag, + href: vevent.href, + calendar_path: vevent.calendar_path, + } + } +} + +// Conversion from existing CalendarEvent to new VEvent +impl From for VEvent { + fn from(event: CalendarEvent) -> Self { + use chrono::Utc; + + Self { + // Required properties + dtstamp: Utc::now(), // Add required DTSTAMP + uid: event.uid, + dtstart: event.start, + + // Optional properties + dtend: event.end, + duration: None, // Will be calculated from dtend if needed + summary: event.summary, + description: event.description, + location: event.location, + + // Classification and status + class: Some(event.class.into()), + status: Some(event.status.into()), + transp: None, // Default to None, can be enhanced later + priority: event.priority, + + // People and organization + organizer: event.organizer.map(|email| CalendarUser { + cal_address: email, + cn: None, + dir: None, + sent_by: None, + language: None, + }), + attendees: event.attendees.into_iter().map(|email| Attendee { + cal_address: email, + cn: None, + role: None, + partstat: None, + rsvp: None, + cutype: None, + member: Vec::new(), + delegated_to: Vec::new(), + delegated_from: Vec::new(), + sent_by: None, + dir: None, + language: None, + }).collect(), + contact: None, + + // Categorization and relationships + categories: event.categories, + comment: None, + resources: Vec::new(), + related_to: None, + url: None, + + // Geographical + geo: None, + + // Versioning and modification + sequence: Some(0), // Start with sequence 0 + created: event.created, + last_modified: event.last_modified, + + // Recurrence + rrule: event.recurrence_rule, + rdate: Vec::new(), + exdate: event.exception_dates, + recurrence_id: None, + + // Alarms and attachments + alarms: event.reminders.into_iter().map(|r| r.into()).collect(), + attachments: Vec::new(), + + // CalDAV specific + etag: event.etag, + href: event.href, + calendar_path: event.calendar_path, + all_day: event.all_day, + } + } +} + +// Convert between status enums +impl From for crate::services::calendar_service::EventStatus { + fn from(status: EventStatus) -> Self { + match status { + EventStatus::Tentative => crate::services::calendar_service::EventStatus::Tentative, + EventStatus::Confirmed => crate::services::calendar_service::EventStatus::Confirmed, + EventStatus::Cancelled => crate::services::calendar_service::EventStatus::Cancelled, + } + } +} + +impl From for EventStatus { + fn from(status: crate::services::calendar_service::EventStatus) -> Self { + match status { + crate::services::calendar_service::EventStatus::Tentative => EventStatus::Tentative, + crate::services::calendar_service::EventStatus::Confirmed => EventStatus::Confirmed, + crate::services::calendar_service::EventStatus::Cancelled => EventStatus::Cancelled, + } + } +} + +// Convert between class enums +impl From for crate::services::calendar_service::EventClass { + fn from(class: EventClass) -> Self { + match class { + EventClass::Public => crate::services::calendar_service::EventClass::Public, + EventClass::Private => crate::services::calendar_service::EventClass::Private, + EventClass::Confidential => crate::services::calendar_service::EventClass::Confidential, + } + } +} + +impl From for EventClass { + fn from(class: crate::services::calendar_service::EventClass) -> Self { + match class { + crate::services::calendar_service::EventClass::Public => EventClass::Public, + crate::services::calendar_service::EventClass::Private => EventClass::Private, + crate::services::calendar_service::EventClass::Confidential => EventClass::Confidential, + } + } +} + +// Convert between reminder types +impl From for EventReminder { + fn from(alarm: VAlarm) -> Self { + let minutes_before = match alarm.trigger { + AlarmTrigger::Duration(duration) => { + // Convert duration to minutes (assuming it's negative for "before") + (-duration.num_minutes()) as i32 + }, + AlarmTrigger::DateTime(_) => 0, // Absolute time alarms default to 0 minutes + }; + + let action = match alarm.action { + AlarmAction::Display => ReminderAction::Display, + AlarmAction::Audio => ReminderAction::Audio, + AlarmAction::Email => ReminderAction::Email, + }; + + Self { + minutes_before, + action, + description: alarm.description, + } + } +} + +impl From for VAlarm { + fn from(reminder: EventReminder) -> Self { + use chrono::Duration; + + let action = match reminder.action { + ReminderAction::Display => AlarmAction::Display, + ReminderAction::Audio => AlarmAction::Audio, + ReminderAction::Email => AlarmAction::Email, + }; + + let trigger = AlarmTrigger::Duration(Duration::minutes(-reminder.minutes_before as i64)); + + Self { + action, + trigger, + description: reminder.description, + summary: None, + duration: None, + repeat: None, + attendees: Vec::new(), + attachments: Vec::new(), + } + } +} \ No newline at end of file diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..a58f936 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,13 @@ +// RFC 5545 Compliant iCalendar Models +pub mod ical; + +// Re-export commonly used types +pub use ical::{ + VEvent, VTodo, VJournal, VFreeBusy, VTimeZone, VAlarm, + ICalendarObject, + EventStatus, EventClass, TodoStatus, JournalStatus, + TimeTransparency, AlarmAction, AlarmTrigger, + CalendarUser, Attendee, AttendeeRole, ParticipationStatus, CalendarUserType, + GeographicPosition, Attachment, AttachmentData, + FreeBusyTime, FreeBusyType, TimeZoneComponent, +}; \ No newline at end of file diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index 72c9383..b5c3841 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -90,6 +90,139 @@ impl Default for EventClass { } } +// ==================== V2 API MODELS ==================== + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AttendeeV2 { + pub email: String, + pub name: Option, + pub role: Option, + pub status: Option, + pub rsvp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AttendeeRoleV2 { + Chair, + Required, + Optional, + NonParticipant, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ParticipationStatusV2 { + NeedsAction, + Accepted, + Declined, + Tentative, + Delegated, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AlarmV2 { + pub action: AlarmActionV2, + pub trigger_minutes: i32, // Minutes before event (negative = before) + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AlarmActionV2 { + Audio, + Display, + Email, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum DeleteActionV2 { + DeleteThis, + DeleteFollowing, + DeleteSeries, +} + +// V2 Request/Response Models +#[derive(Debug, Serialize)] +pub struct CreateEventRequestV2 { + pub summary: String, + pub description: Option, + pub dtstart: DateTime, + pub dtend: Option>, + pub location: Option, + pub all_day: bool, + pub status: Option, + pub class: Option, + pub priority: Option, + pub organizer: Option, + pub attendees: Vec, + pub categories: Vec, + pub rrule: Option, + pub alarms: Vec, + pub calendar_path: Option, +} + +#[derive(Debug, Serialize)] +pub struct UpdateEventRequestV2 { + pub uid: String, + pub summary: String, + pub description: Option, + pub dtstart: DateTime, + pub dtend: Option>, + pub location: Option, + pub all_day: bool, + pub status: Option, + pub class: Option, + pub priority: Option, + pub organizer: Option, + pub attendees: Vec, + pub categories: Vec, + pub rrule: Option, + pub alarms: Vec, + pub calendar_path: Option, + pub update_action: Option, + pub occurrence_date: Option>, + pub exception_dates: Option>>, + pub until_date: Option>, +} + +#[derive(Debug, Serialize)] +pub struct DeleteEventRequestV2 { + pub calendar_path: String, + pub event_href: String, + pub delete_action: DeleteActionV2, + pub occurrence_date: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CreateEventResponseV2 { + pub success: bool, + pub message: String, + pub event: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateEventResponseV2 { + pub success: bool, + pub message: String, + pub event: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteEventResponseV2 { + pub success: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EventSummaryV2 { + pub uid: String, + pub summary: Option, + pub dtstart: DateTime, + pub dtend: Option>, + pub location: Option, + pub all_day: bool, + pub href: Option, + pub etag: Option, +} + impl CalendarEvent { /// Get the date for this event (for calendar display) pub fn get_date(&self) -> NaiveDate { @@ -951,4 +1084,227 @@ impl CalendarService { Err(format!("Request failed with status {}: {}", resp.status(), text_string)) } } + + // ==================== V2 API METHODS ==================== + + /// Create a new event using V2 API (no string parsing required) + pub async fn create_event_v2( + &self, + token: &str, + password: &str, + request: CreateEventRequestV2, + ) -> Result { + 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_string = serde_json::to_string(&request) + .map_err(|e| format!("JSON serialization failed: {}", e))?; + + let url = format!("{}/v2/calendar/events/create", self.base_url); + opts.set_body(&body_string.into()); + let request_obj = Request::new_with_str_and_init(&url, &opts) + .map_err(|e| format!("Request creation failed: {:?}", e))?; + + request_obj.headers().set("Authorization", &format!("Bearer {}", token)) + .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; + + request_obj.headers().set("X-CalDAV-Password", password) + .map_err(|e| format!("Password header setting failed: {:?}", e))?; + + request_obj.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_obj)) + .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() { + let response: CreateEventResponseV2 = serde_json::from_str(&text_string) + .map_err(|e| format!("JSON parsing failed: {}", e))?; + Ok(response) + } else { + Err(format!("Request failed with status {}: {}", resp.status(), text_string)) + } + } + + /// Update an event using V2 API (no string parsing required) + pub async fn update_event_v2( + &self, + token: &str, + password: &str, + request: UpdateEventRequestV2, + ) -> Result { + 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_string = serde_json::to_string(&request) + .map_err(|e| format!("JSON serialization failed: {}", e))?; + + let url = format!("{}/v2/calendar/events/update", self.base_url); + opts.set_body(&body_string.into()); + let request_obj = Request::new_with_str_and_init(&url, &opts) + .map_err(|e| format!("Request creation failed: {:?}", e))?; + + request_obj.headers().set("Authorization", &format!("Bearer {}", token)) + .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; + + request_obj.headers().set("X-CalDAV-Password", password) + .map_err(|e| format!("Password header setting failed: {:?}", e))?; + + request_obj.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_obj)) + .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() { + let response: UpdateEventResponseV2 = serde_json::from_str(&text_string) + .map_err(|e| format!("JSON parsing failed: {}", e))?; + Ok(response) + } else { + Err(format!("Request failed with status {}: {}", resp.status(), text_string)) + } + } + + /// Delete an event using V2 API (no string parsing required) + pub async fn delete_event_v2( + &self, + token: &str, + password: &str, + request: DeleteEventRequestV2, + ) -> Result { + 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_string = serde_json::to_string(&request) + .map_err(|e| format!("JSON serialization failed: {}", e))?; + + let url = format!("{}/v2/calendar/events/delete", self.base_url); + opts.set_body(&body_string.into()); + let request_obj = Request::new_with_str_and_init(&url, &opts) + .map_err(|e| format!("Request creation failed: {:?}", e))?; + + request_obj.headers().set("Authorization", &format!("Bearer {}", token)) + .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; + + request_obj.headers().set("X-CalDAV-Password", password) + .map_err(|e| format!("Password header setting failed: {:?}", e))?; + + request_obj.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_obj)) + .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() { + let response: DeleteEventResponseV2 = serde_json::from_str(&text_string) + .map_err(|e| format!("JSON parsing failed: {}", e))?; + Ok(response) + } else { + Err(format!("Request failed with status {}: {}", resp.status(), text_string)) + } + } + + /// Helper function to convert reminder string to structured alarms + pub fn reminder_string_to_alarms(reminder: &str) -> Vec { + match reminder.to_lowercase().as_str() { + "15min" => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -15, + description: Some("Event reminder".to_string()), + }], + "30min" => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -30, + description: Some("Event reminder".to_string()), + }], + "1hour" => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -60, + description: Some("Event reminder".to_string()), + }], + "2hours" => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -120, + description: Some("Event reminder".to_string()), + }], + "1day" => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -1440, + description: Some("Event reminder".to_string()), + }], + "2days" => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -2880, + description: Some("Event reminder".to_string()), + }], + "1week" => vec![AlarmV2 { + action: AlarmActionV2::Display, + trigger_minutes: -10080, + description: Some("Event reminder".to_string()), + }], + _ => Vec::new(), + } + } + + /// Helper function to convert comma-separated attendees to structured attendees + pub fn attendees_string_to_structured(attendees: &str) -> Vec { + if attendees.trim().is_empty() { + return Vec::new(); + } + + attendees.split(',') + .map(|email| AttendeeV2 { + email: email.trim().to_string(), + name: None, + role: Some(AttendeeRoleV2::Required), + status: Some(ParticipationStatusV2::NeedsAction), + rsvp: Some(true), + }) + .collect() + } } \ No newline at end of file