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:
@@ -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!");
|
||||
|
||||
Reference in New Issue
Block a user