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

327
src/timezone.rs Normal file
View file

@ -0,0 +1,327 @@
//! Timezone handling utilities
use crate::error::{CalDavError, CalDavResult};
use chrono::{DateTime, Utc, Local, TimeZone, NaiveDateTime, Offset};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Timezone handler for managing timezone conversions
#[derive(Debug, Clone)]
pub struct TimezoneHandler {
/// Default timezone
default_tz: Tz,
/// Timezone cache
timezone_cache: HashMap<String, Tz>,
}
impl TimezoneHandler {
/// Create a new timezone handler with the given default timezone
pub fn new(default_timezone: &str) -> CalDavResult<Self> {
let default_tz: Tz = default_timezone.parse()
.map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", default_timezone)))?;
let mut cache = HashMap::new();
cache.insert(default_timezone.to_string(), default_tz);
Ok(Self {
default_tz,
timezone_cache: cache,
})
}
/// Create a timezone handler with system local timezone
pub fn with_local_timezone() -> CalDavResult<Self> {
let local_tz = Self::get_system_timezone()?;
Self::new(&local_tz)
}
/// Parse a datetime with timezone information
pub fn parse_datetime(&mut self, dt_str: &str, timezone: Option<&str>) -> CalDavResult<DateTime<Utc>> {
match timezone {
Some(tz) => {
let tz_obj = self.get_timezone(tz)?;
self.parse_datetime_with_tz(dt_str, tz_obj)
}
None => {
// Try to parse as UTC first
if let Ok(dt) = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%SZ") {
Ok(DateTime::from_naive_utc_and_offset(dt, Utc))
} else {
// Try to parse as local time
let local_dt = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%S")?;
let local_dt = Local.from_local_datetime(&local_dt)
.single()
.ok_or_else(|| CalDavError::Timezone("Ambiguous local time".to_string()))?;
Ok(local_dt.with_timezone(&Utc))
}
}
}
}
/// Convert UTC datetime to a specific timezone
pub fn convert_to_timezone(&mut self, dt: DateTime<Utc>, timezone: &str) -> CalDavResult<DateTime<Tz>> {
let tz_obj = self.get_timezone(timezone)?;
Ok(dt.with_timezone(&tz_obj))
}
/// Convert datetime from a specific timezone to UTC
pub fn convert_from_timezone(&mut self, dt: DateTime<Tz>, timezone: &str) -> CalDavResult<DateTime<Utc>> {
let _tz_obj = self.get_timezone(timezone)?;
Ok(dt.with_timezone(&Utc))
}
/// Format datetime in iCalendar format
pub fn format_ical_datetime(&mut self, dt: DateTime<Utc>, use_local_time: bool) -> CalDavResult<String> {
if use_local_time {
let local_dt = self.convert_to_timezone(dt, &self.default_tz.to_string())?;
Ok(local_dt.format("%Y%m%dT%H%M%S").to_string())
} else {
Ok(dt.format("%Y%m%dT%H%M%SZ").to_string())
}
}
/// Format date in iCalendar format (for all-day events)
pub fn format_ical_date(&self, dt: DateTime<Utc>) -> String {
dt.format("%Y%m%d").to_string()
}
/// Get a timezone object, using cache if available
fn get_timezone(&mut self, timezone: &str) -> CalDavResult<Tz> {
if let Some(tz) = self.timezone_cache.get(timezone) {
return Ok(*tz);
}
let tz_obj: Tz = timezone.parse()
.map_err(|_| CalDavError::Timezone(format!("Invalid timezone: {}", timezone)))?;
self.timezone_cache.insert(timezone.to_string(), tz_obj);
Ok(tz_obj)
}
/// Parse datetime with specific timezone
fn parse_datetime_with_tz(&self, dt_str: &str, tz: Tz) -> CalDavResult<DateTime<Utc>> {
let naive_dt = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%S")?;
let local_dt = tz.from_local_datetime(&naive_dt)
.single()
.ok_or_else(|| CalDavError::Timezone("Ambiguous local time".to_string()))?;
Ok(local_dt.with_timezone(&Utc))
}
/// Get system timezone
fn get_system_timezone() -> CalDavResult<String> {
// Try to get timezone from environment
if let Ok(tz) = std::env::var("TZ") {
return Ok(tz);
}
// Try common timezone detection methods
#[cfg(unix)]
{
if let Ok(link) = std::fs::read_link("/etc/localtime") {
if let Some(tz_path) = link.to_str() {
if let Some(tz_name) = tz_path.strip_prefix("../usr/share/zoneinfo/") {
return Ok(tz_name.to_string());
}
if let Some(tz_name) = tz_path.strip_prefix("/usr/share/zoneinfo/") {
return Ok(tz_name.to_string());
}
}
}
}
#[cfg(windows)]
{
// Windows timezone detection would require additional libraries
// For now, default to UTC on Windows
}
// Fallback to UTC
Ok("UTC".to_string())
}
/// Get the default timezone
pub fn default_timezone(&self) -> String {
self.default_tz.to_string()
}
/// List all available timezones
pub fn list_timezones() -> Vec<&'static str> {
chrono_tz::TZ_VARIANTS.iter().map(|tz| tz.name()).collect()
}
/// Validate timezone string
pub fn validate_timezone(timezone: &str) -> bool {
timezone.parse::<Tz>().is_ok()
}
/// Get current time in default timezone
pub fn now(&self) -> DateTime<Tz> {
Utc::now().with_timezone(&self.default_tz)
}
/// Get current time in UTC
pub fn now_utc() -> DateTime<Utc> {
Utc::now()
}
}
impl Default for TimezoneHandler {
fn default() -> Self {
Self::new("UTC").unwrap()
}
}
/// Timezone information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimezoneInfo {
/// Timezone name
pub name: String,
/// Current offset from UTC in seconds
pub offset: i32,
/// Daylight Saving Time active
pub dst_active: bool,
/// Timezone abbreviation
pub abbreviation: String,
}
impl TimezoneHandler {
/// Get information about a timezone
pub fn get_timezone_info(&mut self, timezone: &str) -> CalDavResult<TimezoneInfo> {
let tz_obj = self.get_timezone(timezone)?;
let now = Utc::now().with_timezone(&tz_obj);
Ok(TimezoneInfo {
name: timezone.to_string(),
offset: now.offset().fix().local_minus_utc(),
dst_active: false, // is_dst() method removed in newer chrono-tz versions
abbreviation: now.format("%Z").to_string(),
})
}
/// Convert between two timezones
pub fn convert_between_timezones(
&mut self,
dt: DateTime<Utc>,
from_tz: &str,
to_tz: &str,
) -> CalDavResult<DateTime<Tz>> {
let _from_tz_obj = self.get_timezone(from_tz)?;
let to_tz_obj = self.get_timezone(to_tz)?;
Ok(dt.with_timezone(&to_tz_obj))
}
}
/// Timezone-aware datetime wrapper
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZonedDateTime {
pub datetime: DateTime<Utc>,
pub timezone: Option<String>,
}
impl ZonedDateTime {
/// Create a new timezone-aware datetime
pub fn new(datetime: DateTime<Utc>, timezone: Option<String>) -> Self {
Self { datetime, timezone }
}
/// Create from local time
pub fn from_local(local_dt: DateTime<Local>, timezone: Option<String>) -> Self {
Self {
datetime: local_dt.with_timezone(&Utc),
timezone,
}
}
/// Get the datetime in UTC
pub fn utc(&self) -> DateTime<Utc> {
self.datetime
}
/// Get the datetime in the specified timezone
pub fn in_timezone(&self, handler: &mut TimezoneHandler, timezone: &str) -> CalDavResult<DateTime<Tz>> {
handler.convert_to_timezone(self.datetime, timezone)
}
/// Format for iCalendar
pub fn format_ical(&self, handler: &mut TimezoneHandler) -> CalDavResult<String> {
match &self.timezone {
Some(tz) => {
if tz == "UTC" {
handler.format_ical_datetime(self.datetime, false)
} else {
// For non-UTC timezones, we'd need to handle local time formatting
// This is a simplified implementation
handler.format_ical_datetime(self.datetime, false)
}
}
None => handler.format_ical_datetime(self.datetime, false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timezone_handler_creation() {
let handler = TimezoneHandler::new("UTC").unwrap();
assert_eq!(handler.default_timezone(), "UTC");
}
#[test]
fn test_utc_datetime_parsing() {
let handler = TimezoneHandler::default();
let dt = handler.parse_datetime("20231225T100000Z", None).unwrap();
assert_eq!(dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z");
}
#[test]
fn test_timezone_validation() {
assert!(TimezoneHandler::validate_timezone("UTC"));
assert!(TimezoneHandler::validate_timezone("America/New_York"));
assert!(TimezoneHandler::validate_timezone("Europe/London"));
assert!(!TimezoneHandler::validate_timezone("Invalid/Timezone"));
}
#[test]
fn test_ical_formatting() {
let handler = TimezoneHandler::default();
let dt = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
let ical_utc = handler.format_ical_datetime(dt, false).unwrap();
assert_eq!(ical_utc, "20231225T100000Z");
let ical_date = handler.format_ical_date(dt);
assert_eq!(ical_date, "20231225");
}
#[test]
fn test_timezone_conversion() {
let mut handler = TimezoneHandler::new("UTC").unwrap();
let dt = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
// Convert to UTC (should be the same)
let utc_dt = handler.convert_to_timezone(dt, "UTC").unwrap();
assert_eq!(utc_dt.format("%Y%m%dT%H%M%SZ").to_string(), "20231225T100000Z");
}
#[test]
fn test_zoned_datetime() {
let dt = DateTime::from_naive_utc_and_offset(
chrono::NaiveDateTime::parse_from_str("20231225T100000", "%Y%m%dT%H%M%S").unwrap(),
Utc
);
let zdt = ZonedDateTime::new(dt, Some("UTC".to_string()));
assert_eq!(zdt.utc(), dt);
assert_eq!(zdt.timezone, Some("UTC".to_string()));
}
}