- 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
327 lines
11 KiB
Rust
327 lines
11 KiB
Rust
//! 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()));
|
|
}
|
|
}
|