Implement shared RFC 5545 VEvent library with workspace restructuring

- Created calendar-models/ shared library with RFC 5545-compliant VEvent structures
- Migrated backend to use shared VEvent with proper field mappings (dtstart/dtend, rrule, exdate, etc.)
- Converted CalDAV client to parse into VEvent structures with structured types
- Updated all CRUD handlers to use VEvent with CalendarUser, Attendee, VAlarm types
- Restructured project as Cargo workspace with frontend/, backend/, calendar-models/
- Updated Trunk configuration for new directory structure
- Fixed all compilation errors and field references throughout codebase
- Updated documentation and build instructions for workspace structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-30 11:45:58 -04:00
parent 6887e0b389
commit 15f2d0c6d9
43 changed files with 1962 additions and 945 deletions

View File

@@ -8,6 +8,8 @@ name = "backend"
path = "src/main.rs"
[dependencies]
calendar-models = { workspace = true }
# Backend authentication dependencies
jsonwebtoken = "9.0"
tokio = { version = "1.0", features = ["full"] }

View File

@@ -1,10 +1,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, VAlarm};
/// Represents a calendar event with all its properties
/// Type alias for shared VEvent (for backward compatibility during migration)
pub type CalendarEvent = VEvent;
/// Old CalendarEvent struct definition (DEPRECATED - use VEvent instead)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CalendarEvent {
pub struct OldCalendarEvent {
/// Unique identifier for the event (UID field in iCal)
pub uid: String,
@@ -51,7 +55,7 @@ pub struct CalendarEvent {
pub recurrence_rule: Option<String>,
/// Exception dates - dates to exclude from recurrence (EXDATE)
pub exception_dates: Vec<DateTime<Utc>>,
pub exdate: Vec<DateTime<Utc>>,
/// All-day event flag
pub all_day: bool,
@@ -69,33 +73,7 @@ pub struct CalendarEvent {
pub calendar_path: Option<String>,
}
/// Event status enumeration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventStatus {
Tentative,
Confirmed,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
/// Event classification enumeration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventClass {
Public,
Private,
Confidential,
}
impl Default for EventClass {
fn default() -> Self {
EventClass::Public
}
}
// EventStatus and EventClass are now imported from calendar_models
/// Event reminder/alarm information
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -336,7 +314,7 @@ impl CalDAVClient {
"CANCELLED" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
})
.unwrap_or_default();
.unwrap_or(EventStatus::Confirmed);
// Parse classification
let class = properties.get("CLASS")
@@ -345,7 +323,7 @@ impl CalDAVClient {
"CONFIDENTIAL" => EventClass::Confidential,
_ => EventClass::Public,
})
.unwrap_or_default();
.unwrap_or(EventClass::Public);
// Parse priority
let priority = properties.get("PRIORITY")
@@ -365,48 +343,67 @@ impl CalDAVClient {
.and_then(|s| self.parse_datetime(s, None).ok());
// Parse exception dates (EXDATE)
let exception_dates = self.parse_exception_dates(&event);
let exdate = self.parse_exdate(&event);
Ok(CalendarEvent {
uid,
summary: properties.get("SUMMARY").cloned(),
description: properties.get("DESCRIPTION").cloned(),
start,
end,
location: properties.get("LOCATION").cloned(),
status,
class,
priority,
organizer: properties.get("ORGANIZER").cloned(),
attendees: Vec::new(), // TODO: Parse attendees
categories,
created,
last_modified,
recurrence_rule: properties.get("RRULE").cloned(),
exception_dates,
all_day,
reminders: self.parse_alarms(&event)?,
etag: None, // Set by caller
href: None, // Set by caller
calendar_path: None, // Set by caller
})
// Create VEvent with required fields
let mut vevent = VEvent::new(uid, start);
// Set optional fields
vevent.dtend = end;
vevent.summary = properties.get("SUMMARY").cloned();
vevent.description = properties.get("DESCRIPTION").cloned();
vevent.location = properties.get("LOCATION").cloned();
vevent.status = Some(status);
vevent.class = Some(class);
vevent.priority = priority;
// Convert organizer string to CalendarUser
if let Some(organizer_str) = properties.get("ORGANIZER") {
vevent.organizer = Some(CalendarUser {
cal_address: organizer_str.clone(),
common_name: None,
dir_entry_ref: None,
sent_by: None,
language: None,
});
}
// TODO: Parse attendees properly
vevent.attendees = Vec::new();
vevent.categories = categories;
vevent.created = created;
vevent.last_modified = last_modified;
vevent.rrule = properties.get("RRULE").cloned();
vevent.exdate = exdate;
vevent.all_day = all_day;
// Parse alarms
vevent.alarms = self.parse_valarms(&event)?;
// CalDAV specific fields (set by caller)
vevent.etag = None;
vevent.href = None;
vevent.calendar_path = None;
Ok(vevent)
}
/// Parse VALARM components from an iCal event
fn parse_alarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<EventReminder>, CalDAVError> {
let mut reminders = Vec::new();
fn parse_valarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<VAlarm>, CalDAVError> {
let mut alarms = Vec::new();
for alarm in &event.alarms {
if let Ok(reminder) = self.parse_single_alarm(alarm) {
reminders.push(reminder);
if let Ok(valarm) = self.parse_single_valarm(alarm) {
alarms.push(valarm);
}
}
Ok(reminders)
Ok(alarms)
}
/// Parse a single VALARM component into an EventReminder
fn parse_single_alarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<EventReminder, CalDAVError> {
/// Parse a single VALARM component into a VAlarm
fn parse_single_valarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<VAlarm, CalDAVError> {
let mut properties: HashMap<String, String> = HashMap::new();
// Extract all properties from the alarm
@@ -416,26 +413,38 @@ impl CalDAVClient {
// Parse ACTION (required)
let action = match properties.get("ACTION").map(|s| s.to_uppercase()) {
Some(ref action_str) if action_str == "DISPLAY" => ReminderAction::Display,
Some(ref action_str) if action_str == "EMAIL" => ReminderAction::Email,
Some(ref action_str) if action_str == "AUDIO" => ReminderAction::Audio,
_ => ReminderAction::Display, // Default
Some(ref action_str) if action_str == "DISPLAY" => calendar_models::AlarmAction::Display,
Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email,
Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio,
Some(ref action_str) if action_str == "PROCEDURE" => calendar_models::AlarmAction::Procedure,
_ => calendar_models::AlarmAction::Display, // Default
};
// Parse TRIGGER (required)
let minutes_before = if let Some(trigger) = properties.get("TRIGGER") {
self.parse_trigger_duration(trigger).unwrap_or(15) // Default 15 minutes
let trigger = if let Some(trigger_str) = properties.get("TRIGGER") {
if let Some(minutes) = self.parse_trigger_duration(trigger_str) {
calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-minutes as i64))
} else {
// Default to 15 minutes before
calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-15))
}
} else {
15 // Default 15 minutes
// Default to 15 minutes before
calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-15))
};
// Get description
let description = properties.get("DESCRIPTION").cloned();
Ok(EventReminder {
minutes_before,
Ok(VAlarm {
action,
trigger,
duration: None,
repeat: None,
description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
})
}
@@ -599,8 +608,8 @@ impl CalDAVClient {
}
/// Parse EXDATE properties from an iCal event
fn parse_exception_dates(&self, event: &ical::parser::ical::component::IcalEvent) -> Vec<DateTime<Utc>> {
let mut exception_dates = Vec::new();
fn parse_exdate(&self, event: &ical::parser::ical::component::IcalEvent) -> Vec<DateTime<Utc>> {
let mut exdate = Vec::new();
// Look for EXDATE properties
for property in &event.properties {
@@ -610,14 +619,14 @@ impl CalDAVClient {
for date_str in value.split(',') {
// Try to parse the date (the parse_datetime method will handle different formats)
if let Ok(date) = self.parse_datetime(date_str.trim(), None) {
exception_dates.push(date);
exdate.push(date);
}
}
}
}
}
exception_dates
exdate
}
/// Create a new calendar on the CalDAV server using MKCALENDAR
@@ -812,7 +821,7 @@ impl CalDAVClient {
let ical_data = self.generate_ical_event(event)?;
println!("📝 Updated iCal data: {}", ical_data);
println!("📝 Event has {} exception dates", event.exception_dates.len());
println!("📝 Event has {} exception dates", event.exdate.len());
let response = self.http_client
.put(&full_url)
@@ -863,13 +872,13 @@ impl CalDAVClient {
// 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!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.dtstart)));
if let Some(end) = &event.dtend {
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!("DTSTART:{}\r\n", format_datetime(&event.dtstart)));
if let Some(end) = &event.dtend {
ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end)));
}
}
@@ -888,20 +897,24 @@ impl CalDAVClient {
}
// 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));
if let Some(status) = &event.status {
let status_str = match 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));
if let Some(class) = &event.class {
let class_str = match 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 {
@@ -922,21 +935,33 @@ impl CalDAVClient {
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
// Add alarms/reminders
for reminder in &event.reminders {
for alarm in &event.alarms {
ical.push_str("BEGIN:VALARM\r\n");
let action = match reminder.action {
ReminderAction::Display => "DISPLAY",
ReminderAction::Email => "EMAIL",
ReminderAction::Audio => "AUDIO",
let action = match alarm.action {
calendar_models::AlarmAction::Display => "DISPLAY",
calendar_models::AlarmAction::Email => "EMAIL",
calendar_models::AlarmAction::Audio => "AUDIO",
calendar_models::AlarmAction::Procedure => "PROCEDURE",
};
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));
// Handle trigger
match &alarm.trigger {
calendar_models::AlarmTrigger::Duration(duration) => {
let minutes = duration.num_minutes();
if minutes < 0 {
ical.push_str(&format!("TRIGGER:-PT{}M\r\n", -minutes));
} else {
ical.push_str(&format!("TRIGGER:PT{}M\r\n", minutes));
}
}
calendar_models::AlarmTrigger::DateTime(dt) => {
ical.push_str(&format!("TRIGGER:{}\r\n", format_datetime(dt)));
}
}
if let Some(description) = &reminder.description {
if let Some(description) = &alarm.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)));
@@ -946,12 +971,12 @@ impl CalDAVClient {
}
// Recurrence rule
if let Some(rrule) = &event.recurrence_rule {
if let Some(rrule) = &event.rrule {
ical.push_str(&format!("RRULE:{}\r\n", rrule));
}
// Exception dates (EXDATE)
for exception_date in &event.exception_dates {
for exception_date in &event.exdate {
if event.all_day {
ical.push_str(&format!("EXDATE;VALUE=DATE:{}\r\n", format_date(exception_date)));
} else {
@@ -1080,8 +1105,8 @@ mod tests {
println!("\n--- Event {} ---", i + 1);
println!("UID: {}", event.uid);
println!("Summary: {:?}", event.summary);
println!("Start: {}", event.start);
println!("End: {:?}", event.end);
println!("Start: {}", event.dtstart);
println!("End: {:?}", event.dtend);
println!("All Day: {}", event.all_day);
println!("Status: {:?}", event.status);
println!("Location: {:?}", event.location);
@@ -1094,7 +1119,7 @@ mod tests {
for event in &events {
assert!(!event.uid.is_empty(), "Event UID should not be empty");
// All events should have a start time
assert!(event.start > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time");
assert!(event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time");
}
println!("\n✓ Calendar event fetching test passed!");

View File

@@ -7,6 +7,7 @@ use serde::Deserialize;
use std::sync::Arc;
use chrono::{Datelike, TimeZone};
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, Attendee, VAlarm, AlarmAction, AlarmTrigger};
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};
@@ -61,7 +62,7 @@ pub async fn get_calendar_events(
// 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();
let event_date = event.dtstart.date_naive();
event_date.year() == year && event_date.month() == month
}).collect()
} else {
@@ -410,18 +411,18 @@ pub async fn delete_event_v2(
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() {
if event.rrule.is_some() {
// Calculate the exact datetime for this occurrence by using the original event's time
let original_time = event.start.time();
let original_time = event.dtstart.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!("🔄 Original event start: {}", event.dtstart);
println!("🔄 Occurrence date: {}", occurrence_date);
println!("🔄 Calculated EXDATE: {}", exception_utc);
// Add the exception date
event.exception_dates.push(exception_utc);
event.exdate.push(exception_utc);
// Update the event with the new EXDATE
client.update_event(&request.calendar_path, &event, &request.event_href)
@@ -460,9 +461,9 @@ pub async fn delete_event_v2(
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 {
if let Some(ref rrule) = event.rrule {
// Calculate the datetime for the occurrence we want to stop before
let original_time = event.start.time();
let original_time = event.dtstart.time();
let occurrence_datetime = occurrence_date.date_naive().and_time(original_time);
let occurrence_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime);
@@ -470,7 +471,7 @@ pub async fn delete_event_v2(
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!("🔄 Original event start: {}", event.dtstart);
println!("🔄 Occurrence to stop before: {}", occurrence_utc);
println!("🔄 UNTIL date (last to keep): {}", until_date);
println!("🔄 UNTIL string: {}", until_str);
@@ -486,7 +487,7 @@ pub async fn delete_event_v2(
};
println!("🔄 New RRULE: {}", new_rrule);
event.recurrence_rule = Some(new_rrule);
event.rrule = Some(new_rrule);
// Update the event with the modified RRULE
client.update_event(&request.calendar_path, &event, &request.event_href)
@@ -565,20 +566,20 @@ pub async fn delete_event(
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() {
if event.rrule.is_some() {
// Parse the occurrence date and calculate the correct EXDATE datetime
if let Ok(occurrence_date_parsed) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
// Calculate the exact datetime for this occurrence by using the original event's time
let original_time = event.start.time();
let original_time = event.dtstart.time();
let occurrence_datetime = occurrence_date_parsed.and_time(original_time);
let exception_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime);
println!("🔄 Original event start: {}", event.start);
println!("🔄 Original event start: {}", event.dtstart);
println!("🔄 Occurrence date: {}", occurrence_date);
println!("🔄 Calculated EXDATE: {}", exception_utc);
// Add the exception date
event.exception_dates.push(exception_utc);
event.exdate.push(exception_utc);
// Update the event with the new EXDATE
client.update_event(&request.calendar_path, &event, &request.event_href)
@@ -620,11 +621,11 @@ pub async fn delete_event(
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 {
if let Some(ref rrule) = event.rrule {
// Parse the occurrence date and calculate the UNTIL date
if let Ok(occurrence_date_parsed) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
// Calculate the datetime for the occurrence we want to stop before
let original_time = event.start.time();
let original_time = event.dtstart.time();
let occurrence_datetime = occurrence_date_parsed.and_time(original_time);
let occurrence_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime);
@@ -633,7 +634,7 @@ pub async fn delete_event(
let until_str = until_date.format("%Y%m%dT%H%M%SZ").to_string();
println!("🔄 Original event start: {}", event.start);
println!("🔄 Original event start: {}", event.dtstart);
println!("🔄 Occurrence to stop before: {}", occurrence_utc);
println!("🔄 UNTIL date (last to keep): {}", until_date);
println!("🔄 UNTIL string: {}", until_str);
@@ -649,7 +650,7 @@ pub async fn delete_event(
};
println!("🔄 New RRULE: {}", new_rrule);
event.recurrence_rule = Some(new_rrule);
event.rrule = Some(new_rrule);
// Update the event with the modified RRULE
client.update_event(&request.calendar_path, &event, &request.event_href)
@@ -750,15 +751,15 @@ pub async fn create_event_v2(
// 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,
crate::models_v2::EventStatusV2::Tentative => EventStatus::Tentative,
crate::models_v2::EventStatusV2::Cancelled => EventStatus::Cancelled,
crate::models_v2::EventStatusV2::Confirmed => 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,
crate::models_v2::EventClassV2::Private => EventClass::Private,
crate::models_v2::EventClassV2::Confidential => EventClass::Confidential,
crate::models_v2::EventClassV2::Public => EventClass::Public,
};
// Convert attendees from V2 to simple email list (for now)
@@ -766,8 +767,8 @@ pub async fn create_event_v2(
.map(|att| att.email)
.collect();
// Convert alarms to reminders
let reminders: Vec<crate::calendar::EventReminder> = request.alarms.into_iter()
// Convert alarms to alarms
let alarms: Vec<crate::calendar::EventReminder> = request.alarms.into_iter()
.map(|alarm| crate::calendar::EventReminder {
minutes_before: -alarm.trigger_minutes, // Convert to positive minutes before
action: match alarm.action {
@@ -780,29 +781,56 @@ pub async fn create_event_v2(
.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 VEvent with required fields
let mut event = VEvent::new(uid, request.dtstart);
// Set optional fields
event.dtend = request.dtend;
event.summary = Some(request.summary.clone());
event.description = request.description;
event.location = request.location;
event.status = Some(status);
event.class = Some(class);
event.priority = request.priority;
event.organizer = request.organizer.map(|org| CalendarUser {
cal_address: org,
common_name: None,
dir_entry_ref: None,
sent_by: None,
language: None,
});
event.attendees = attendees.into_iter().map(|email| Attendee {
cal_address: email,
common_name: None,
role: None,
part_stat: None,
rsvp: None,
cu_type: None,
member: Vec::new(),
delegated_to: Vec::new(),
delegated_from: Vec::new(),
sent_by: None,
dir_entry_ref: None,
language: None,
}).collect();
event.categories = request.categories;
event.rrule = request.rrule;
event.all_day = request.all_day;
event.alarms = alarms.into_iter().map(|alarm| VAlarm {
action: match alarm.action {
crate::calendar::ReminderAction::Display => calendar_models::AlarmAction::Display,
crate::calendar::ReminderAction::Email => calendar_models::AlarmAction::Email,
crate::calendar::ReminderAction::Audio => calendar_models::AlarmAction::Audio,
},
trigger: calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-alarm.minutes_before as i64)),
duration: None,
repeat: None,
description: alarm.description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
}).collect();
event.calendar_path = Some(calendar_path.clone());
// Create the event on the CalDAV server
let event_href = client.create_event(&calendar_path, &event)
@@ -817,8 +845,8 @@ pub async fn create_event_v2(
let event_summary = created_event.map(|e| crate::models_v2::EventSummaryV2 {
uid: e.uid,
summary: e.summary,
dtstart: e.start,
dtend: e.end,
dtstart: e.dtstart,
dtend: e.dtend,
location: e.location,
all_day: e.all_day,
href: e.href,
@@ -890,16 +918,16 @@ pub async fn create_event(
// Parse status
let status = match request.status.to_lowercase().as_str() {
"tentative" => crate::calendar::EventStatus::Tentative,
"cancelled" => crate::calendar::EventStatus::Cancelled,
_ => crate::calendar::EventStatus::Confirmed,
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
};
// Parse class
let class = match request.class.to_lowercase().as_str() {
"private" => crate::calendar::EventClass::Private,
"confidential" => crate::calendar::EventClass::Confidential,
_ => crate::calendar::EventClass::Public,
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
};
// Parse attendees (comma-separated email list)
@@ -924,50 +952,24 @@ pub async fn create_event(
.collect()
};
// Parse reminders and convert to EventReminder structs
let reminders: Vec<crate::calendar::EventReminder> = match request.reminder.to_lowercase().as_str() {
"15min" => vec![crate::calendar::EventReminder {
minutes_before: 15,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"30min" => vec![crate::calendar::EventReminder {
minutes_before: 30,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1hour" => vec![crate::calendar::EventReminder {
minutes_before: 60,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2hours" => vec![crate::calendar::EventReminder {
minutes_before: 120,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1day" => vec![crate::calendar::EventReminder {
minutes_before: 1440, // 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2days" => vec![crate::calendar::EventReminder {
minutes_before: 2880, // 48 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1week" => vec![crate::calendar::EventReminder {
minutes_before: 10080, // 7 * 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
_ => Vec::new(),
// Parse alarms - convert from minutes string to EventReminder structs
let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() {
Vec::new()
} else {
match request.reminder.parse::<i32>() {
Ok(minutes) => vec![crate::calendar::EventReminder {
minutes_before: minutes,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
Err(_) => Vec::new(),
}
};
// Parse recurrence with BYDAY support for weekly recurrence
let recurrence_rule = match request.recurrence.to_lowercase().as_str() {
"daily" => Some("FREQ=DAILY".to_string()),
"weekly" => {
let rrule = match request.recurrence.to_uppercase().as_str() {
"DAILY" => Some("FREQ=DAILY".to_string()),
"WEEKLY" => {
// Handle weekly recurrence with optional BYDAY parameter
let mut rrule = "FREQ=WEEKLY".to_string();
@@ -993,61 +995,75 @@ pub async fn create_event(
}
})
.collect();
if !selected_days.is_empty() {
rrule.push_str(&format!(";BYDAY={}", selected_days.join(",")));
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
}
}
Some(rrule)
},
"monthly" => Some("FREQ=MONTHLY".to_string()),
"yearly" => Some("FREQ=YEARLY".to_string()),
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
"YEARLY" => Some("FREQ=YEARLY".to_string()),
_ => None,
};
// 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,
class,
priority: request.priority,
organizer: if request.organizer.trim().is_empty() {
None
} else {
Some(request.organizer.clone())
},
attendees,
categories,
created: Some(chrono::Utc::now()),
last_modified: Some(chrono::Utc::now()),
recurrence_rule,
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 VEvent struct (RFC 5545 compliant)
let mut event = VEvent::new(uid, start_datetime);
event.dtend = Some(end_datetime);
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
event.status = Some(status);
event.class = Some(class);
event.priority = request.priority;
event.organizer = if request.organizer.trim().is_empty() {
None
} else {
Some(CalendarUser {
cal_address: request.organizer,
common_name: None,
dir_entry_ref: None,
sent_by: None,
language: None,
})
};
event.attendees = attendees.into_iter().map(|email| Attendee {
cal_address: email,
common_name: None,
role: None,
part_stat: None,
rsvp: None,
cu_type: None,
member: Vec::new(),
delegated_to: Vec::new(),
delegated_from: Vec::new(),
sent_by: None,
dir_entry_ref: None,
language: None,
}).collect();
event.categories = categories;
event.rrule = rrule;
event.all_day = request.all_day;
event.alarms = alarms.into_iter().map(|reminder| VAlarm {
action: AlarmAction::Display,
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)),
duration: None,
repeat: None,
description: reminder.description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
}).collect();
event.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)))?;
println!("✅ Event created successfully with UID: {} at href: {}", event.uid, event_href);
Ok(Json(CreateEventResponse {
success: true,
message: "Event created successfully".to_string(),
@@ -1055,6 +1071,7 @@ 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<Arc<AppState>>,
@@ -1156,15 +1173,15 @@ pub async fn update_event_v2(
// 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,
crate::models_v2::EventStatusV2::Tentative => EventStatus::Tentative,
crate::models_v2::EventStatusV2::Cancelled => EventStatus::Cancelled,
crate::models_v2::EventStatusV2::Confirmed => 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,
crate::models_v2::EventClassV2::Private => EventClass::Private,
crate::models_v2::EventClassV2::Confidential => EventClass::Confidential,
crate::models_v2::EventClassV2::Public => EventClass::Public,
};
// Convert attendees from V2 to simple email list (for now)
@@ -1172,8 +1189,8 @@ pub async fn update_event_v2(
.map(|att| att.email)
.collect();
// Convert alarms to reminders
let reminders: Vec<crate::calendar::EventReminder> = request.alarms.into_iter()
// Convert alarms to alarms
let alarms: Vec<crate::calendar::EventReminder> = request.alarms.into_iter()
.map(|alarm| crate::calendar::EventReminder {
minutes_before: -alarm.trigger_minutes,
action: match alarm.action {
@@ -1192,8 +1209,8 @@ pub async fn update_event_v2(
// 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 original_start_date = event.dtstart.date_naive();
let original_end_date = event.dtend.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);
@@ -1202,24 +1219,56 @@ pub async fn update_event_v2(
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);
event.dtstart = updated_start;
event.dtend = Some(updated_end);
} else {
// For regular updates, update both date and time
event.start = request.dtstart;
event.end = request.dtend;
event.dtstart = request.dtstart;
event.dtend = request.dtend;
}
event.location = request.location;
event.status = status;
event.class = class;
event.status = Some(status);
event.class = Some(class);
event.priority = request.priority;
event.organizer = request.organizer;
event.attendees = attendees;
event.organizer = request.organizer.map(|org| CalendarUser {
cal_address: org,
common_name: None,
dir_entry_ref: None,
sent_by: None,
language: None,
});
event.attendees = attendees.into_iter().map(|email| Attendee {
cal_address: email,
common_name: None,
role: None,
part_stat: None,
rsvp: None,
cu_type: None,
member: Vec::new(),
delegated_to: Vec::new(),
delegated_from: Vec::new(),
sent_by: None,
dir_entry_ref: None,
language: None,
}).collect();
event.categories = request.categories;
event.last_modified = Some(chrono::Utc::now());
event.all_day = request.all_day;
event.reminders = reminders;
event.alarms = alarms.into_iter().map(|alarm| VAlarm {
action: match alarm.action {
crate::calendar::ReminderAction::Display => calendar_models::AlarmAction::Display,
crate::calendar::ReminderAction::Email => calendar_models::AlarmAction::Email,
crate::calendar::ReminderAction::Audio => calendar_models::AlarmAction::Audio,
},
trigger: calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-alarm.minutes_before as i64)),
duration: None,
repeat: None,
description: alarm.description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
}).collect();
// Handle recurrence rule and UID for series updates
if is_series_update {
@@ -1234,27 +1283,27 @@ pub async fn update_event_v2(
}
// 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);
if let Some(exdate) = request.exception_dates {
let mut new_exdate = Vec::new();
for date in exdate {
new_exdate.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);
for new_date in new_exdate {
if !event.exdate.contains(&new_date) {
event.exdate.push(new_date);
}
}
println!("🔄 Updated exception dates: {} total", event.exception_dates.len());
println!("🔄 Updated exception dates: {} total", event.exdate.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 {
if let Some(ref rrule) = event.rrule {
// Remove existing UNTIL if present and add new one
let rrule_without_until = rrule.split(';')
.filter(|part| !part.starts_with("UNTIL="))
@@ -1263,17 +1312,17 @@ pub async fn update_event_v2(
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());
event.rrule = Some(format!("{};UNTIL={}", rrule_without_until, until_formatted));
println!("🔄 Modified RRULE: {}", event.rrule.as_ref().unwrap());
// Clear exception dates since we're using UNTIL instead
event.exception_dates.clear();
event.exdate.clear();
println!("🔄 Cleared exception dates for UNTIL approach");
}
}
} else {
// For regular updates, use the new recurrence rule
event.recurrence_rule = request.rrule;
event.rrule = request.rrule;
}
// Update the event on the CalDAV server
@@ -1289,8 +1338,8 @@ pub async fn update_event_v2(
let event_summary = updated_event.map(|e| crate::models_v2::EventSummaryV2 {
uid: e.uid,
summary: e.summary,
dtstart: e.start,
dtend: e.end,
dtstart: e.dtstart,
dtend: e.dtend,
location: e.location,
all_day: e.all_day,
href: e.href,
@@ -1411,16 +1460,16 @@ pub async fn update_event(
// Parse status
let status = match request.status.to_lowercase().as_str() {
"tentative" => crate::calendar::EventStatus::Tentative,
"cancelled" => crate::calendar::EventStatus::Cancelled,
_ => crate::calendar::EventStatus::Confirmed,
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
};
// Parse class
let class = match request.class.to_lowercase().as_str() {
"private" => crate::calendar::EventClass::Private,
"confidential" => crate::calendar::EventClass::Confidential,
_ => crate::calendar::EventClass::Public,
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
};
// Parse attendees (comma-separated email list)
@@ -1445,8 +1494,8 @@ pub async fn update_event(
.collect()
};
// Parse reminders and convert to EventReminder structs
let reminders: Vec<crate::calendar::EventReminder> = match request.reminder.to_lowercase().as_str() {
// Parse alarms and convert to EventReminder structs
let alarms: Vec<crate::calendar::EventReminder> = match request.reminder.to_lowercase().as_str() {
"15min" => vec![crate::calendar::EventReminder {
minutes_before: 15,
action: crate::calendar::ReminderAction::Display,
@@ -1486,7 +1535,7 @@ pub async fn update_event(
};
// Parse recurrence with BYDAY support for weekly recurrence
let recurrence_rule = match request.recurrence.to_lowercase().as_str() {
let rrule = match request.recurrence.to_lowercase().as_str() {
"daily" => Some("FREQ=DAILY".to_string()),
"weekly" => {
// Handle weekly recurrence with optional BYDAY parameter
@@ -1537,8 +1586,8 @@ pub async fn update_event(
// 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 original_start_date = event.dtstart.date_naive();
let original_end_date = event.dtend.map(|e| e.date_naive()).unwrap_or(original_start_date);
let new_start_time = start_datetime.time();
let new_end_time = end_datetime.time();
@@ -1549,31 +1598,63 @@ pub async fn update_event(
// Preserve original date with new time
event.start = updated_start;
event.end = Some(updated_end);
event.dtstart = updated_start;
event.dtend = Some(updated_end);
} else {
// For regular updates, update both date and time
event.start = start_datetime;
event.end = Some(end_datetime);
event.dtstart = start_datetime;
event.dtend = Some(end_datetime);
}
event.location = if request.location.trim().is_empty() {
None
} else {
Some(request.location.clone())
};
event.status = status;
event.class = class;
event.status = Some(status);
event.class = Some(class);
event.priority = request.priority;
event.organizer = if request.organizer.trim().is_empty() {
None
} else {
Some(request.organizer.clone())
Some(CalendarUser {
cal_address: request.organizer.clone(),
common_name: None,
dir_entry_ref: None,
sent_by: None,
language: None,
})
};
event.attendees = attendees;
event.attendees = attendees.into_iter().map(|email| Attendee {
cal_address: email,
common_name: None,
role: None,
part_stat: None,
rsvp: None,
cu_type: None,
member: Vec::new(),
delegated_to: Vec::new(),
delegated_from: Vec::new(),
sent_by: None,
dir_entry_ref: None,
language: None,
}).collect();
event.categories = categories;
event.last_modified = Some(chrono::Utc::now());
event.all_day = request.all_day;
event.reminders = reminders;
event.alarms = alarms.into_iter().map(|alarm| VAlarm {
action: match alarm.action {
crate::calendar::ReminderAction::Display => calendar_models::AlarmAction::Display,
crate::calendar::ReminderAction::Email => calendar_models::AlarmAction::Email,
crate::calendar::ReminderAction::Audio => calendar_models::AlarmAction::Audio,
},
trigger: calendar_models::AlarmTrigger::Duration(chrono::Duration::minutes(-alarm.minutes_before as i64)),
duration: None,
repeat: None,
description: alarm.description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
}).collect();
// Handle recurrence rule and UID for series updates
if is_series_update {
@@ -1587,38 +1668,15 @@ pub async fn update_event(
}
}
// Keep existing recurrence rule (don't overwrite with recurrence_rule variable)
// event.recurrence_rule stays as-is from the original event
// Keep existing recurrence rule (don't overwrite with rrule variable)
// event.rrule stays as-is from the original event
// However, allow exception_dates to be updated - this is needed for "This and Future" events
if let Some(exception_dates_str) = &request.exception_dates {
// Parse the ISO datetime strings into DateTime<Utc>
let mut new_exception_dates = Vec::new();
for date_str in exception_dates_str {
if let Ok(parsed_date) = chrono::DateTime::parse_from_rfc3339(date_str) {
new_exception_dates.push(parsed_date.with_timezone(&chrono::Utc));
} else if let Ok(parsed_date) = chrono::DateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S UTC") {
new_exception_dates.push(parsed_date.with_timezone(&chrono::Utc));
} else {
eprintln!("Failed to parse exception date: {}", date_str);
}
}
// 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_str) = &request.until_date {
println!("🔄 Adding UNTIL clause to RRULE: {}", until_date_str);
if let Some(ref rrule) = event.recurrence_rule {
if let Some(ref rrule) = event.rrule {
// Remove existing UNTIL if present and add new one
let rrule_without_until = rrule.split(';')
.filter(|part| !part.starts_with("UNTIL="))
@@ -1630,18 +1688,18 @@ pub async fn update_event(
let until_utc = until_datetime.with_timezone(&chrono::Utc);
let until_formatted = until_utc.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());
event.rrule = Some(format!("{};UNTIL={}", rrule_without_until, until_formatted));
println!("🔄 Modified RRULE: {}", event.rrule.as_ref().unwrap());
// Clear exception dates since we're using UNTIL instead
event.exception_dates.clear();
event.exdate.clear();
println!("🔄 Cleared exception dates for UNTIL approach");
}
}
}
} else {
// For regular updates, use the new recurrence rule
event.recurrence_rule = recurrence_rule;
event.rrule = rrule;
}
// Update the event on the CalDAV server