Initial commit: Complete CalDAV calendar synchronizer

- Rust-based CLI tool for Zoho to Nextcloud calendar sync
- Selective calendar import from Zoho to single Nextcloud calendar
- Timezone-aware event handling for next-week synchronization
- Comprehensive configuration system with TOML support
- CLI interface with debug, list, and sync operations
- Complete documentation and example configurations
This commit is contained in:
Alvaro Soliverez 2025-10-04 11:57:44 -03:00
commit 8362ebe44b
16 changed files with 6192 additions and 0 deletions

447
src/event.rs Normal file
View file

@ -0,0 +1,447 @@
//! Event handling and iCalendar parsing
use crate::error::{CalDavError, CalDavResult};
use chrono::{DateTime, Utc, NaiveDateTime};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
/// Calendar event representation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
/// Unique identifier
pub uid: String,
/// Event summary/title
pub summary: String,
/// Event description
pub description: Option<String>,
/// Start time
pub start: DateTime<Utc>,
/// End time
pub end: DateTime<Utc>,
/// All-day event flag
pub all_day: bool,
/// Event location
pub location: Option<String>,
/// Event status
pub status: EventStatus,
/// Event type
pub event_type: EventType,
/// Organizer
pub organizer: Option<Organizer>,
/// Attendees
pub attendees: Vec<Attendee>,
/// Recurrence rule
pub recurrence: Option<RecurrenceRule>,
/// Alarm/reminders
pub alarms: Vec<Alarm>,
/// Custom properties
pub properties: HashMap<String, String>,
/// Creation timestamp
pub created: DateTime<Utc>,
/// Last modification timestamp
pub last_modified: DateTime<Utc>,
/// Sequence number for updates
pub sequence: i32,
/// Timezone identifier
pub timezone: Option<String>,
}
/// Event status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventStatus {
Confirmed,
Tentative,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
/// Event type/classification
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventType {
Public,
Private,
Confidential,
}
impl Default for EventType {
fn default() -> Self {
EventType::Public
}
}
/// Event organizer
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Organizer {
/// Email address
pub email: String,
/// Display name
pub name: Option<String>,
/// Sent-by parameter
pub sent_by: Option<String>,
}
/// Event attendee
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attendee {
/// Email address
pub email: String,
/// Display name
pub name: Option<String>,
/// Participation status
pub status: ParticipationStatus,
/// Whether required
pub required: bool,
/// RSVP requested
pub rsvp: bool,
}
/// Participation status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ParticipationStatus {
NeedsAction,
Accepted,
Declined,
Tentative,
Delegated,
}
/// Recurrence rule
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurrenceRule {
/// Frequency
pub frequency: RecurrenceFrequency,
/// Interval
pub interval: u32,
/// Count (number of occurrences)
pub count: Option<u32>,
/// Until date
pub until: Option<DateTime<Utc>>,
/// Days of week
pub by_day: Option<Vec<WeekDay>>,
/// Days of month
pub by_month_day: Option<Vec<u32>>,
/// Months
pub by_month: Option<Vec<u32>>,
}
/// Recurrence frequency
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum RecurrenceFrequency {
Secondly,
Minutely,
Hourly,
Daily,
Weekly,
Monthly,
Yearly,
}
/// Day of week for recurrence
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum WeekDay {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
}
/// Event alarm/reminder
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alarm {
/// Action type
pub action: AlarmAction,
/// Trigger time
pub trigger: AlarmTrigger,
/// Description
pub description: Option<String>,
/// Summary
pub summary: Option<String>,
}
/// Alarm action
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AlarmAction {
Display,
Email,
Audio,
}
/// Alarm trigger
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AlarmTrigger {
/// Duration before start
BeforeStart(chrono::Duration),
/// Duration after start
AfterStart(chrono::Duration),
/// Duration before end
BeforeEnd(chrono::Duration),
/// Absolute time
Absolute(DateTime<Utc>),
}
impl Event {
/// Create a new event
pub fn new(summary: String, start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
let now = Utc::now();
Self {
uid: Uuid::new_v4().to_string(),
summary,
description: None,
start,
end,
all_day: false,
location: None,
status: EventStatus::default(),
event_type: EventType::default(),
organizer: None,
attendees: Vec::new(),
recurrence: None,
alarms: Vec::new(),
properties: HashMap::new(),
created: now,
last_modified: now,
sequence: 0,
timezone: None,
}
}
/// Create an all-day event
pub fn new_all_day(summary: String, date: chrono::NaiveDate) -> Self {
let start = date.and_hms_opt(0, 0, 0).unwrap();
let end = date.and_hms_opt(23, 59, 59).unwrap();
let start_utc = DateTime::from_naive_utc_and_offset(start, Utc);
let end_utc = DateTime::from_naive_utc_and_offset(end, Utc);
Self {
uid: Uuid::new_v4().to_string(),
summary,
description: None,
start: start_utc,
end: end_utc,
all_day: true,
location: None,
status: EventStatus::default(),
event_type: EventType::default(),
organizer: None,
attendees: Vec::new(),
recurrence: None,
alarms: Vec::new(),
properties: HashMap::new(),
created: Utc::now(),
last_modified: Utc::now(),
sequence: 0,
timezone: None,
}
}
/// Parse event from iCalendar data
pub fn from_ical(_ical_data: &str) -> CalDavResult<Self> {
// This is a simplified iCalendar parser
// In a real implementation, you'd use a proper iCalendar parsing library
let event = Self::new("placeholder".to_string(), Utc::now(), Utc::now());
// Placeholder implementation
// TODO: Implement proper iCalendar parsing
Ok(event)
}
/// Convert event to iCalendar format
pub fn to_ical(&self) -> CalDavResult<String> {
let mut ical = String::new();
// iCalendar header
ical.push_str("BEGIN:VCALENDAR\r\n");
ical.push_str("VERSION:2.0\r\n");
ical.push_str("PRODID:-//CalDAV Sync//EN\r\n");
ical.push_str("BEGIN:VEVENT\r\n");
// Basic properties
ical.push_str(&format!("UID:{}\r\n", self.uid));
ical.push_str(&format!("SUMMARY:{}\r\n", escape_ical_text(&self.summary)));
if let Some(description) = &self.description {
ical.push_str(&format!("DESCRIPTION:{}\r\n", escape_ical_text(description)));
}
// Dates
if self.all_day {
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n",
self.start.format("%Y%m%d")));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n",
self.end.format("%Y%m%d")));
} else {
ical.push_str(&format!("DTSTART:{}\r\n",
self.start.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("DTEND:{}\r\n",
self.end.format("%Y%m%dT%H%M%SZ")));
}
// Status
ical.push_str(&format!("STATUS:{}\r\n", match self.status {
EventStatus::Confirmed => "CONFIRMED",
EventStatus::Tentative => "TENTATIVE",
EventStatus::Cancelled => "CANCELLED",
}));
// Class
ical.push_str(&format!("CLASS:{}\r\n", match self.event_type {
EventType::Public => "PUBLIC",
EventType::Private => "PRIVATE",
EventType::Confidential => "CONFIDENTIAL",
}));
// Timestamps
ical.push_str(&format!("CREATED:{}\r\n", self.created.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", self.last_modified.format("%Y%m%dT%H%M%SZ")));
ical.push_str(&format!("SEQUENCE:{}\r\n", self.sequence));
// Location
if let Some(location) = &self.location {
ical.push_str(&format!("LOCATION:{}\r\n", escape_ical_text(location)));
}
// Organizer
if let Some(organizer) = &self.organizer {
ical.push_str(&format!("ORGANIZER:mailto:{}\r\n", organizer.email));
}
// Attendees
for attendee in &self.attendees {
ical.push_str(&format!("ATTENDEE:mailto:{}\r\n", attendee.email));
}
// iCalendar footer
ical.push_str("END:VEVENT\r\n");
ical.push_str("END:VCALENDAR\r\n");
Ok(ical)
}
/// Update the event's last modified timestamp
pub fn touch(&mut self) {
self.last_modified = Utc::now();
self.sequence += 1;
}
/// Check if event occurs on a specific date
pub fn occurs_on(&self, date: chrono::NaiveDate) -> bool {
let start_date = self.start.date_naive();
let end_date = self.end.date_naive();
if self.all_day {
start_date <= date && end_date >= date
} else {
start_date <= date && end_date >= date
}
}
/// Get event duration
pub fn duration(&self) -> chrono::Duration {
self.end.signed_duration_since(self.start)
}
/// Check if event is currently in progress
pub fn is_in_progress(&self) -> bool {
let now = Utc::now();
now >= self.start && now <= self.end
}
}
/// Escape text for iCalendar format
fn escape_ical_text(text: &str) -> String {
text
.replace('\\', "\\\\")
.replace(',', "\\,")
.replace(';', "\\;")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
/// Parse iCalendar date/time
fn parse_ical_datetime(dt_str: &str) -> CalDavResult<DateTime<Utc>> {
// Handle different iCalendar date formats
if dt_str.len() == 8 {
// DATE format (YYYYMMDD)
let naive_date = chrono::NaiveDate::parse_from_str(dt_str, "%Y%m%d")?;
let naive_datetime = naive_date.and_hms_opt(0, 0, 0).unwrap();
Ok(DateTime::from_naive_utc_and_offset(naive_datetime, Utc))
} else if dt_str.ends_with('Z') {
// UTC datetime format (YYYYMMDDTHHMMSSZ)
let dt_without_z = &dt_str[..dt_str.len()-1];
let naive_dt = NaiveDateTime::parse_from_str(dt_without_z, "%Y%m%dT%H%M%S")?;
Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc))
} else {
// Local time format - this would need timezone handling
Err(CalDavError::EventProcessing(
"Local time parsing not implemented".to_string()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_creation() {
let start = Utc::now();
let end = start + chrono::Duration::hours(1);
let event = Event::new("Test Event".to_string(), start, end);
assert_eq!(event.summary, "Test Event");
assert_eq!(event.start, start);
assert_eq!(event.end, end);
assert!(!event.all_day);
assert_eq!(event.status, EventStatus::Confirmed);
}
#[test]
fn test_all_day_event() {
let date = chrono::NaiveDate::from_ymd_opt(2023, 12, 25).unwrap();
let event = Event::new_all_day("Christmas".to_string(), date);
assert_eq!(event.summary, "Christmas");
assert!(event.all_day);
assert!(event.occurs_on(date));
}
#[test]
fn test_event_to_ical() {
let event = Event::new(
"Meeting".to_string(),
DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
Utc
),
DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T110000", "%Y%m%dT%H%M%S").unwrap(),
Utc
),
);
let ical = event.to_ical().unwrap();
assert!(ical.contains("SUMMARY:Meeting"));
assert!(ical.contains("DTSTART:20231225T100000Z"));
assert!(ical.contains("DTEND:20231225T110000Z"));
assert!(ical.contains("BEGIN:VCALENDAR"));
assert!(ical.contains("END:VCALENDAR"));
}
#[test]
fn test_ical_text_escaping() {
let text = "Hello, world; this\\is a test";
let escaped = escape_ical_text(text);
assert_eq!(escaped, "Hello\\, world\\; this\\\\is a test");
}
}