- Backend: Add GET /api/calendar/events/:uid endpoint for single event refresh - Backend: Implement fetch_event_by_uid method to retrieve updated events from CalDAV - Frontend: Add event click callback system to trigger refresh on interaction - Frontend: Display loading state with orange pulsing animation during refresh - Frontend: Smart event data updates without full calendar reload - Frontend: Graceful error handling with fallback to cached data - CSS: Add refreshing animation for visual feedback during updates Events now automatically refresh from CalDAV server when clicked, ensuring users always see the most current event data including changes made in other calendar applications. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
747 lines
27 KiB
Rust
747 lines
27 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
/// Represents a calendar event with all its properties
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct CalendarEvent {
|
|
/// Unique identifier for the event (UID field in iCal)
|
|
pub uid: String,
|
|
|
|
/// Summary/title of the event
|
|
pub summary: Option<String>,
|
|
|
|
/// Detailed description of the event
|
|
pub description: Option<String>,
|
|
|
|
/// Start date and time of the event
|
|
pub start: DateTime<Utc>,
|
|
|
|
/// End date and time of the event
|
|
pub end: Option<DateTime<Utc>>,
|
|
|
|
/// Location where the event takes place
|
|
pub location: Option<String>,
|
|
|
|
/// Event status (TENTATIVE, CONFIRMED, CANCELLED)
|
|
pub status: EventStatus,
|
|
|
|
/// Event classification (PUBLIC, PRIVATE, CONFIDENTIAL)
|
|
pub class: EventClass,
|
|
|
|
/// Event priority (0-9, where 0 is undefined, 1 is highest, 9 is lowest)
|
|
pub priority: Option<u8>,
|
|
|
|
/// Organizer of the event
|
|
pub organizer: Option<String>,
|
|
|
|
/// List of attendees
|
|
pub attendees: Vec<String>,
|
|
|
|
/// Categories/tags for the event
|
|
pub categories: Vec<String>,
|
|
|
|
/// Date and time when the event was created
|
|
pub created: Option<DateTime<Utc>>,
|
|
|
|
/// Date and time when the event was last modified
|
|
pub last_modified: Option<DateTime<Utc>>,
|
|
|
|
/// Recurrence rule (RRULE)
|
|
pub recurrence_rule: Option<String>,
|
|
|
|
/// All-day event flag
|
|
pub all_day: bool,
|
|
|
|
/// Reminders/alarms for this event
|
|
pub reminders: Vec<EventReminder>,
|
|
|
|
/// ETag from CalDAV server for conflict detection
|
|
pub etag: Option<String>,
|
|
|
|
/// URL/href of this event on the CalDAV server
|
|
pub href: 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
|
|
}
|
|
}
|
|
|
|
/// Event reminder/alarm information
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct EventReminder {
|
|
/// How long before the event to trigger the reminder (in minutes)
|
|
pub minutes_before: i32,
|
|
|
|
/// Type of reminder action
|
|
pub action: ReminderAction,
|
|
|
|
/// Optional description for the reminder
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
/// Reminder action types
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum ReminderAction {
|
|
Display,
|
|
Email,
|
|
Audio,
|
|
}
|
|
|
|
/// CalDAV client for fetching and parsing calendar events
|
|
pub struct CalDAVClient {
|
|
config: crate::config::CalDAVConfig,
|
|
http_client: reqwest::Client,
|
|
}
|
|
|
|
impl CalDAVClient {
|
|
/// Create a new CalDAV client with the given configuration
|
|
pub fn new(config: crate::config::CalDAVConfig) -> Self {
|
|
Self {
|
|
config,
|
|
http_client: reqwest::Client::new(),
|
|
}
|
|
}
|
|
|
|
/// Fetch calendar events from the CalDAV server
|
|
///
|
|
/// This method performs a REPORT request to get calendar data and parses
|
|
/// the returned iCalendar format into CalendarEvent structs.
|
|
pub async fn fetch_events(&self, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
|
// CalDAV REPORT request to get calendar events
|
|
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:prop>
|
|
<d:getetag/>
|
|
<c:calendar-data/>
|
|
</d:prop>
|
|
<c:filter>
|
|
<c:comp-filter name="VCALENDAR">
|
|
<c:comp-filter name="VEVENT"/>
|
|
</c:comp-filter>
|
|
</c:filter>
|
|
</c:calendar-query>"#;
|
|
|
|
let url = if calendar_path.starts_with("http") {
|
|
calendar_path.to_string()
|
|
} else {
|
|
format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path)
|
|
};
|
|
|
|
let response = self.http_client
|
|
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
|
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
|
.header("Content-Type", "application/xml")
|
|
.header("Depth", "1")
|
|
.header("User-Agent", "calendar-app/0.1.0")
|
|
.body(report_body)
|
|
.send()
|
|
.await
|
|
.map_err(CalDAVError::RequestError)?;
|
|
|
|
if !response.status().is_success() && response.status().as_u16() != 207 {
|
|
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
|
}
|
|
|
|
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
|
self.parse_calendar_response(&body)
|
|
}
|
|
|
|
/// Parse CalDAV XML response containing calendar data
|
|
fn parse_calendar_response(&self, xml_response: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
|
let mut events = Vec::new();
|
|
|
|
// Extract calendar data from XML response
|
|
// This is a simplified parser - in production, you'd want a proper XML parser
|
|
let calendar_data_sections = self.extract_calendar_data(xml_response);
|
|
|
|
for calendar_data in calendar_data_sections {
|
|
if let Ok(parsed_events) = self.parse_ical_data(&calendar_data.data) {
|
|
for mut event in parsed_events {
|
|
event.etag = calendar_data.etag.clone();
|
|
event.href = calendar_data.href.clone();
|
|
events.push(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(events)
|
|
}
|
|
|
|
/// Fetch a single calendar event by UID from the CalDAV server
|
|
pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<Option<CalendarEvent>, CalDAVError> {
|
|
// First fetch all events and find the one with matching UID
|
|
let events = self.fetch_events(calendar_path).await?;
|
|
|
|
// Find event with matching UID
|
|
let event = events.into_iter().find(|e| e.uid == uid);
|
|
|
|
Ok(event)
|
|
}
|
|
|
|
/// Extract calendar data sections from CalDAV XML response
|
|
fn extract_calendar_data(&self, xml_response: &str) -> Vec<CalendarDataSection> {
|
|
let mut sections = Vec::new();
|
|
|
|
// Simple regex-based extraction (in production, use a proper XML parser)
|
|
// Look for <d:response> blocks containing calendar data
|
|
for response_block in xml_response.split("<d:response>").skip(1) {
|
|
if let Some(end_pos) = response_block.find("</d:response>") {
|
|
let response_content = &response_block[..end_pos];
|
|
|
|
let href = self.extract_xml_content(response_content, "href").unwrap_or_default();
|
|
let etag = self.extract_xml_content(response_content, "getetag").unwrap_or_default();
|
|
|
|
if let Some(calendar_data) = self.extract_xml_content(response_content, "cal:calendar-data") {
|
|
sections.push(CalendarDataSection {
|
|
href: if href.is_empty() { None } else { Some(href) },
|
|
etag: if etag.is_empty() { None } else { Some(etag) },
|
|
data: calendar_data,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
sections
|
|
}
|
|
|
|
/// Extract content from XML tags (simple implementation)
|
|
fn extract_xml_content(&self, xml: &str, tag: &str) -> Option<String> {
|
|
// Handle both with and without namespace prefixes
|
|
let patterns = [
|
|
format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag>
|
|
format!("(?s)<{}>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)), // <tag>content</ns:tag>
|
|
format!("(?s)<.*:{}>(.*?)</{}>", tag.split(':').last().unwrap_or(tag), tag), // <ns:tag>content</tag>
|
|
format!("(?s)<.*:{}>(.*?)</.*:{}>", tag.split(':').last().unwrap_or(tag), tag.split(':').last().unwrap_or(tag)), // <ns:tag>content</ns:tag>
|
|
format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag>
|
|
format!("(?s)<{}[^>]*>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)),
|
|
];
|
|
|
|
for pattern in &patterns {
|
|
if let Ok(re) = regex::Regex::new(pattern) {
|
|
if let Some(captures) = re.captures(xml) {
|
|
if let Some(content) = captures.get(1) {
|
|
return Some(content.as_str().trim().to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Parse iCalendar data into CalendarEvent structs
|
|
fn parse_ical_data(&self, ical_data: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
|
let mut events = Vec::new();
|
|
|
|
// Parse the iCal data using the ical crate
|
|
let reader = ical::IcalParser::new(ical_data.as_bytes());
|
|
|
|
for calendar in reader {
|
|
let calendar = calendar.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
|
|
|
for event in calendar.events {
|
|
if let Ok(calendar_event) = self.parse_ical_event(event) {
|
|
events.push(calendar_event);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(events)
|
|
}
|
|
|
|
/// Parse a single iCal event into a CalendarEvent struct
|
|
fn parse_ical_event(&self, event: ical::parser::ical::component::IcalEvent) -> Result<CalendarEvent, CalDAVError> {
|
|
let mut properties: HashMap<String, String> = HashMap::new();
|
|
|
|
// Extract all properties from the event
|
|
for property in &event.properties {
|
|
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
|
}
|
|
|
|
// Required UID field
|
|
let uid = properties.get("UID")
|
|
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
|
.clone();
|
|
|
|
// Parse start time (required)
|
|
let start = properties.get("DTSTART")
|
|
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
|
let start = self.parse_datetime(start, properties.get("DTSTART"))?;
|
|
|
|
// Parse end time (optional - use start time if not present)
|
|
let end = if let Some(dtend) = properties.get("DTEND") {
|
|
Some(self.parse_datetime(dtend, properties.get("DTEND"))?)
|
|
} else if let Some(duration) = properties.get("DURATION") {
|
|
// TODO: Parse duration and add to start time
|
|
Some(start)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Determine if it's an all-day event
|
|
let all_day = properties.get("DTSTART")
|
|
.map(|s| !s.contains("T"))
|
|
.unwrap_or(false);
|
|
|
|
// Parse status
|
|
let status = properties.get("STATUS")
|
|
.map(|s| match s.to_uppercase().as_str() {
|
|
"TENTATIVE" => EventStatus::Tentative,
|
|
"CANCELLED" => EventStatus::Cancelled,
|
|
_ => EventStatus::Confirmed,
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
// Parse classification
|
|
let class = properties.get("CLASS")
|
|
.map(|s| match s.to_uppercase().as_str() {
|
|
"PRIVATE" => EventClass::Private,
|
|
"CONFIDENTIAL" => EventClass::Confidential,
|
|
_ => EventClass::Public,
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
// Parse priority
|
|
let priority = properties.get("PRIORITY")
|
|
.and_then(|s| s.parse::<u8>().ok())
|
|
.filter(|&p| p <= 9);
|
|
|
|
// Parse categories
|
|
let categories = properties.get("CATEGORIES")
|
|
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
|
.unwrap_or_default();
|
|
|
|
// Parse dates
|
|
let created = properties.get("CREATED")
|
|
.and_then(|s| self.parse_datetime(s, None).ok());
|
|
|
|
let last_modified = properties.get("LAST-MODIFIED")
|
|
.and_then(|s| self.parse_datetime(s, None).ok());
|
|
|
|
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(),
|
|
all_day,
|
|
reminders: self.parse_alarms(&event)?,
|
|
etag: None, // Set by caller
|
|
href: None, // Set by caller
|
|
})
|
|
}
|
|
|
|
/// 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();
|
|
|
|
for alarm in &event.alarms {
|
|
if let Ok(reminder) = self.parse_single_alarm(alarm) {
|
|
reminders.push(reminder);
|
|
}
|
|
}
|
|
|
|
Ok(reminders)
|
|
}
|
|
|
|
/// Parse a single VALARM component into an EventReminder
|
|
fn parse_single_alarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<EventReminder, CalDAVError> {
|
|
let mut properties: HashMap<String, String> = HashMap::new();
|
|
|
|
// Extract all properties from the alarm
|
|
for property in &alarm.properties {
|
|
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
|
}
|
|
|
|
// 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
|
|
};
|
|
|
|
// Parse TRIGGER (required)
|
|
let minutes_before = if let Some(trigger) = properties.get("TRIGGER") {
|
|
self.parse_trigger_duration(trigger).unwrap_or(15) // Default 15 minutes
|
|
} else {
|
|
15 // Default 15 minutes
|
|
};
|
|
|
|
// Get description
|
|
let description = properties.get("DESCRIPTION").cloned();
|
|
|
|
Ok(EventReminder {
|
|
minutes_before,
|
|
action,
|
|
description,
|
|
})
|
|
}
|
|
|
|
/// Parse a TRIGGER duration string into minutes before event
|
|
fn parse_trigger_duration(&self, trigger: &str) -> Option<i32> {
|
|
// Basic parsing of ISO 8601 duration or relative time
|
|
// Examples: "-PT15M" (15 minutes before), "-P1D" (1 day before)
|
|
|
|
if trigger.starts_with("-PT") && trigger.ends_with("M") {
|
|
// Parse "-PT15M" format (minutes)
|
|
let minutes_str = &trigger[3..trigger.len()-1];
|
|
minutes_str.parse::<i32>().ok()
|
|
} else if trigger.starts_with("-PT") && trigger.ends_with("H") {
|
|
// Parse "-PT1H" format (hours)
|
|
let hours_str = &trigger[3..trigger.len()-1];
|
|
hours_str.parse::<i32>().ok().map(|h| h * 60)
|
|
} else if trigger.starts_with("-P") && trigger.ends_with("D") {
|
|
// Parse "-P1D" format (days)
|
|
let days_str = &trigger[2..trigger.len()-1];
|
|
days_str.parse::<i32>().ok().map(|d| d * 24 * 60)
|
|
} else {
|
|
// Try to parse as raw minutes
|
|
trigger.parse::<i32>().ok().map(|m| m.abs())
|
|
}
|
|
}
|
|
|
|
/// Discover available calendar collections on the server
|
|
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
|
|
// First, try to discover user calendars if we have a calendar path in config
|
|
if let Some(calendar_path) = &self.config.calendar_path {
|
|
println!("Using configured calendar path: {}", calendar_path);
|
|
return Ok(vec![calendar_path.clone()]);
|
|
}
|
|
|
|
println!("No calendar path configured, discovering calendars...");
|
|
|
|
// Try different common CalDAV discovery paths
|
|
let user_calendar_path = format!("/calendars/{}/", self.config.username);
|
|
let user_dav_calendar_path = format!("/dav.php/calendars/{}/", self.config.username);
|
|
|
|
let discovery_paths = vec![
|
|
"/calendars/",
|
|
user_calendar_path.as_str(),
|
|
user_dav_calendar_path.as_str(),
|
|
"/dav.php/calendars/",
|
|
];
|
|
|
|
let mut all_calendars = Vec::new();
|
|
|
|
for path in discovery_paths {
|
|
println!("Trying discovery path: {}", path);
|
|
if let Ok(calendars) = self.discover_calendars_at_path(&path).await {
|
|
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
|
all_calendars.extend(calendars);
|
|
}
|
|
}
|
|
|
|
// Remove duplicates
|
|
all_calendars.sort();
|
|
all_calendars.dedup();
|
|
|
|
Ok(all_calendars)
|
|
}
|
|
|
|
/// Discover calendars at a specific path
|
|
async fn discover_calendars_at_path(&self, path: &str) -> Result<Vec<String>, CalDAVError> {
|
|
let propfind_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:prop>
|
|
<d:resourcetype />
|
|
<d:displayname />
|
|
<c:supported-calendar-component-set />
|
|
</d:prop>
|
|
</d:propfind>"#;
|
|
|
|
let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path);
|
|
|
|
let response = self.http_client
|
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
|
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
|
.header("Content-Type", "application/xml")
|
|
.header("Depth", "2") // Deeper search to find actual calendars
|
|
.header("User-Agent", "calendar-app/0.1.0")
|
|
.body(propfind_body)
|
|
.send()
|
|
.await
|
|
.map_err(CalDAVError::RequestError)?;
|
|
|
|
if response.status().as_u16() != 207 {
|
|
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
|
}
|
|
|
|
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
|
println!("Discovery response for {}: {}", path, body);
|
|
|
|
let mut calendar_paths = Vec::new();
|
|
|
|
// Extract calendar collection URLs from the response
|
|
for response_block in body.split("<d:response>").skip(1) {
|
|
if let Some(end_pos) = response_block.find("</d:response>") {
|
|
let response_content = &response_block[..end_pos];
|
|
|
|
// Look for actual calendar collections (not just containers)
|
|
if response_content.contains("<c:supported-calendar-component-set") ||
|
|
(response_content.contains("<d:collection/>") &&
|
|
response_content.contains("calendar")) {
|
|
if let Some(href) = self.extract_xml_content(response_content, "href") {
|
|
// Only include actual calendar paths, not container directories
|
|
if href.ends_with('/') && href.contains("calendar") && !href.ends_with("/calendars/") {
|
|
calendar_paths.push(href);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(calendar_paths)
|
|
}
|
|
|
|
/// Parse iCal datetime format
|
|
fn parse_datetime(&self, datetime_str: &str, _original_property: Option<&String>) -> Result<DateTime<Utc>, CalDAVError> {
|
|
use chrono::TimeZone;
|
|
|
|
// Handle different iCal datetime formats
|
|
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
|
|
|
|
// Try different parsing formats
|
|
let formats = [
|
|
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
|
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
|
"%Y%m%d", // Date only: 20231225
|
|
];
|
|
|
|
for format in &formats {
|
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) {
|
|
return Ok(Utc.from_utc_datetime(&dt));
|
|
}
|
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) {
|
|
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
|
|
}
|
|
}
|
|
|
|
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
|
|
}
|
|
}
|
|
|
|
/// Helper struct for extracting calendar data from XML responses
|
|
#[derive(Debug)]
|
|
struct CalendarDataSection {
|
|
pub href: Option<String>,
|
|
pub etag: Option<String>,
|
|
pub data: String,
|
|
}
|
|
|
|
/// CalDAV-specific error types
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum CalDAVError {
|
|
#[error("HTTP request failed: {0}")]
|
|
RequestError(#[from] reqwest::Error),
|
|
|
|
#[error("CalDAV server returned error: {0}")]
|
|
ServerError(u16),
|
|
|
|
#[error("Failed to parse calendar data: {0}")]
|
|
ParseError(String),
|
|
|
|
#[error("Configuration error: {0}")]
|
|
ConfigError(String),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::CalDAVConfig;
|
|
|
|
/// Integration test that fetches real calendar events from the Baikal server
|
|
///
|
|
/// This test requires a valid .env file and a calendar with some events
|
|
#[tokio::test]
|
|
async fn test_fetch_calendar_events() {
|
|
let config = CalDAVConfig::from_env()
|
|
.expect("Failed to load CalDAV config from environment");
|
|
|
|
let client = CalDAVClient::new(config);
|
|
|
|
// First discover available calendars using PROPFIND
|
|
println!("Discovering calendars...");
|
|
let discovery_result = client.discover_calendars().await;
|
|
|
|
match discovery_result {
|
|
Ok(calendar_paths) => {
|
|
println!("Found {} calendar collection(s)", calendar_paths.len());
|
|
|
|
if calendar_paths.is_empty() {
|
|
println!("No calendars found - this might be normal for a new server");
|
|
return;
|
|
}
|
|
|
|
// Try the first available calendar
|
|
let calendar_path = &calendar_paths[0];
|
|
println!("Trying to fetch events from: {}", calendar_path);
|
|
|
|
match client.fetch_events(calendar_path).await {
|
|
Ok(events) => {
|
|
println!("Successfully fetched {} calendar events", events.len());
|
|
|
|
for (i, event) in events.iter().take(3).enumerate() {
|
|
println!("\n--- Event {} ---", i + 1);
|
|
println!("UID: {}", event.uid);
|
|
println!("Summary: {:?}", event.summary);
|
|
println!("Start: {}", event.start);
|
|
println!("End: {:?}", event.end);
|
|
println!("All Day: {}", event.all_day);
|
|
println!("Status: {:?}", event.status);
|
|
println!("Location: {:?}", event.location);
|
|
println!("Description: {:?}", event.description);
|
|
println!("ETag: {:?}", event.etag);
|
|
println!("HREF: {:?}", event.href);
|
|
}
|
|
|
|
// Validate that events have required fields
|
|
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");
|
|
}
|
|
|
|
println!("\n✓ Calendar event fetching test passed!");
|
|
}
|
|
Err(e) => {
|
|
println!("Error fetching events from {}: {:?}", calendar_path, e);
|
|
println!("This might be normal if the calendar is empty");
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("Error discovering calendars: {:?}", e);
|
|
println!("This might be normal if no calendars are set up yet");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Test parsing a sample iCal event
|
|
#[test]
|
|
fn test_parse_ical_event() {
|
|
let sample_ical = r#"BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Test//Test//EN
|
|
BEGIN:VEVENT
|
|
UID:test-event-123@example.com
|
|
DTSTART:20231225T120000Z
|
|
DTEND:20231225T130000Z
|
|
SUMMARY:Test Event
|
|
DESCRIPTION:This is a test event
|
|
LOCATION:Test Location
|
|
STATUS:CONFIRMED
|
|
CLASS:PUBLIC
|
|
PRIORITY:5
|
|
CREATED:20231220T100000Z
|
|
LAST-MODIFIED:20231221T150000Z
|
|
CATEGORIES:work,important
|
|
END:VEVENT
|
|
END:VCALENDAR"#;
|
|
|
|
let config = CalDAVConfig {
|
|
server_url: "https://example.com".to_string(),
|
|
username: "test".to_string(),
|
|
password: "test".to_string(),
|
|
calendar_path: None,
|
|
tasks_path: None,
|
|
};
|
|
|
|
let client = CalDAVClient::new(config);
|
|
let events = client.parse_ical_data(sample_ical)
|
|
.expect("Should be able to parse sample iCal data");
|
|
|
|
assert_eq!(events.len(), 1);
|
|
|
|
let event = &events[0];
|
|
assert_eq!(event.uid, "test-event-123@example.com");
|
|
assert_eq!(event.summary, Some("Test Event".to_string()));
|
|
assert_eq!(event.description, Some("This is a test event".to_string()));
|
|
assert_eq!(event.location, Some("Test Location".to_string()));
|
|
assert_eq!(event.status, EventStatus::Confirmed);
|
|
assert_eq!(event.class, EventClass::Public);
|
|
assert_eq!(event.priority, Some(5));
|
|
assert_eq!(event.categories, vec!["work", "important"]);
|
|
assert!(!event.all_day);
|
|
|
|
println!("✓ iCal parsing test passed!");
|
|
}
|
|
|
|
/// Test datetime parsing
|
|
#[test]
|
|
fn test_datetime_parsing() {
|
|
let config = CalDAVConfig {
|
|
server_url: "https://example.com".to_string(),
|
|
username: "test".to_string(),
|
|
password: "test".to_string(),
|
|
calendar_path: None,
|
|
tasks_path: None,
|
|
};
|
|
|
|
let client = CalDAVClient::new(config);
|
|
|
|
// Test UTC format
|
|
let dt1 = client.parse_datetime("20231225T120000Z", None)
|
|
.expect("Should parse UTC datetime");
|
|
println!("Parsed UTC datetime: {}", dt1);
|
|
|
|
// Test date-only format (should be treated as all-day)
|
|
let dt2 = client.parse_datetime("20231225", None)
|
|
.expect("Should parse date-only");
|
|
println!("Parsed date-only: {}", dt2);
|
|
|
|
// Test local format
|
|
let dt3 = client.parse_datetime("20231225T120000", None)
|
|
.expect("Should parse local datetime");
|
|
println!("Parsed local datetime: {}", dt3);
|
|
|
|
println!("✓ Datetime parsing test passed!");
|
|
}
|
|
|
|
/// Test event status parsing
|
|
#[test]
|
|
fn test_event_enums() {
|
|
// Test status parsing
|
|
assert_eq!(EventStatus::default(), EventStatus::Confirmed);
|
|
|
|
// Test class parsing
|
|
assert_eq!(EventClass::default(), EventClass::Public);
|
|
|
|
println!("✓ Event enum tests passed!");
|
|
}
|
|
} |