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:
commit
8362ebe44b
16 changed files with 6192 additions and 0 deletions
327
src/timezone.rs
Normal file
327
src/timezone.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue