Fix timezone handling for drag-and-drop and recurring event updates
- Fix double timezone conversion in drag-and-drop that caused 4-hour time shifts - Frontend now sends local times instead of UTC to backend for proper conversion - Add missing timezone parameter to update_series method to fix recurring event updates - Update both event modal and drag-and-drop paths to include timezone information - Maintain RFC 5545 compliance with proper timezone conversion in backend 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -359,19 +359,20 @@ impl CalDAVClient {
|
||||
.clone();
|
||||
|
||||
// Parse start time (required)
|
||||
let start = properties
|
||||
let start_prop = properties
|
||||
.get("DTSTART")
|
||||
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
||||
let start = self.parse_datetime(start, full_properties.get("DTSTART"))?;
|
||||
let (start_naive, start_tzid) = self.parse_datetime_with_tz(start_prop, full_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, full_properties.get("DTEND"))?)
|
||||
let (end_naive, end_tzid) = if let Some(dtend) = properties.get("DTEND") {
|
||||
let (end_dt, end_tz) = self.parse_datetime_with_tz(dtend, full_properties.get("DTEND"))?;
|
||||
(Some(end_dt), end_tz)
|
||||
} else if let Some(_duration) = properties.get("DURATION") {
|
||||
// TODO: Parse duration and add to start time
|
||||
Some(start)
|
||||
(Some(start_naive), start_tzid.clone())
|
||||
} else {
|
||||
None
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Determine if it's an all-day event by checking for VALUE=DATE parameter
|
||||
@@ -411,23 +412,35 @@ impl CalDAVClient {
|
||||
.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());
|
||||
// Parse dates with timezone information
|
||||
let (created_naive, created_tzid) = if let Some(created_str) = properties.get("CREATED") {
|
||||
match self.parse_datetime_with_tz(created_str, None) {
|
||||
Ok((dt, tz)) => (Some(dt), tz),
|
||||
Err(_) => (None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let (last_modified_naive, last_modified_tzid) = if let Some(modified_str) = properties.get("LAST-MODIFIED") {
|
||||
match self.parse_datetime_with_tz(modified_str, None) {
|
||||
Ok((dt, tz)) => (Some(dt), tz),
|
||||
Err(_) => (None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Parse exception dates (EXDATE)
|
||||
let exdate = self.parse_exdate(&event);
|
||||
|
||||
// Create VEvent with required fields
|
||||
let mut vevent = VEvent::new(uid, start);
|
||||
// Create VEvent with parsed naive datetime and timezone info
|
||||
let mut vevent = VEvent::new(uid, start_naive);
|
||||
|
||||
// Set optional fields
|
||||
vevent.dtend = end;
|
||||
// Set optional fields with timezone information
|
||||
vevent.dtend = end_naive;
|
||||
vevent.dtstart_tzid = start_tzid;
|
||||
vevent.dtend_tzid = end_tzid;
|
||||
vevent.summary = properties.get("SUMMARY").cloned();
|
||||
vevent.description = properties.get("DESCRIPTION").cloned();
|
||||
vevent.location = properties.get("LOCATION").cloned();
|
||||
@@ -450,10 +463,13 @@ impl CalDAVClient {
|
||||
vevent.attendees = Vec::new();
|
||||
|
||||
vevent.categories = categories;
|
||||
vevent.created = created;
|
||||
vevent.last_modified = last_modified;
|
||||
vevent.created = created_naive;
|
||||
vevent.created_tzid = created_tzid;
|
||||
vevent.last_modified = last_modified_naive;
|
||||
vevent.last_modified_tzid = last_modified_tzid;
|
||||
vevent.rrule = properties.get("RRULE").cloned();
|
||||
vevent.exdate = exdate;
|
||||
vevent.exdate = exdate.into_iter().map(|dt| dt.naive_utc()).collect();
|
||||
vevent.exdate_tzid = None; // TODO: Parse timezone info for EXDATE
|
||||
vevent.all_day = all_day;
|
||||
|
||||
// Parse alarms
|
||||
@@ -704,6 +720,89 @@ impl CalDAVClient {
|
||||
Ok(calendar_paths)
|
||||
}
|
||||
|
||||
/// Parse iCal datetime format and return NaiveDateTime + timezone info
|
||||
/// According to RFC 5545: if no TZID parameter is provided, treat as UTC
|
||||
fn parse_datetime_with_tz(
|
||||
&self,
|
||||
datetime_str: &str,
|
||||
original_property: Option<&String>,
|
||||
) -> Result<(chrono::NaiveDateTime, Option<String>), CalDAVError> {
|
||||
// Extract timezone information from the original property if available
|
||||
let mut timezone_id: Option<String> = None;
|
||||
if let Some(prop) = original_property {
|
||||
// Look for TZID parameter in the property
|
||||
// Format: DTSTART;TZID=America/Denver:20231225T090000
|
||||
if let Some(tzid_start) = prop.find("TZID=") {
|
||||
let tzid_part = &prop[tzid_start + 5..];
|
||||
if let Some(tzid_end) = tzid_part.find(':') {
|
||||
timezone_id = Some(tzid_part[..tzid_end].to_string());
|
||||
} else if let Some(tzid_end) = tzid_part.find(';') {
|
||||
timezone_id = Some(tzid_part[..tzid_end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean the datetime string - remove any TZID prefix if present
|
||||
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
|
||||
|
||||
// Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000"
|
||||
let datetime_part = if let Some(colon_pos) = cleaned.find(':') {
|
||||
&cleaned[colon_pos + 1..]
|
||||
} else {
|
||||
&cleaned
|
||||
};
|
||||
|
||||
// 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 {
|
||||
// Try parsing as UTC format (with Z suffix)
|
||||
if datetime_part.ends_with('Z') {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") {
|
||||
// Z suffix means UTC, ignore any TZID parameter
|
||||
return Ok((dt, Some("UTC".to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
|
||||
// Convert to naive UTC time and return UTC timezone
|
||||
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||
}
|
||||
|
||||
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
|
||||
// Convert to naive UTC time and return UTC timezone
|
||||
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||
}
|
||||
|
||||
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
|
||||
// Z suffix means UTC
|
||||
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||
}
|
||||
|
||||
// Try parsing as naive datetime
|
||||
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
|
||||
// Per RFC 5545: if no TZID parameter is provided, treat as UTC
|
||||
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
|
||||
|
||||
// If it's UTC, the naive time is already correct
|
||||
// If it's a local timezone, we store the naive time and the timezone ID
|
||||
return Ok((naive_dt, Some(tz)));
|
||||
}
|
||||
}
|
||||
|
||||
Err(CalDAVError::ParseError(format!(
|
||||
"Could not parse datetime: {}",
|
||||
datetime_str
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parse iCal datetime format with timezone support
|
||||
fn parse_datetime(
|
||||
&self,
|
||||
@@ -1207,8 +1306,19 @@ impl CalDAVClient {
|
||||
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
||||
let format_datetime =
|
||||
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
|
||||
let format_datetime_naive =
|
||||
|dt: &chrono::NaiveDateTime| -> String { dt.format("%Y%m%dT%H%M%S").to_string() };
|
||||
|
||||
let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
||||
let _format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
||||
|
||||
// Format NaiveDateTime for iCal (local time without Z suffix)
|
||||
let format_naive_datetime = |dt: &chrono::NaiveDateTime| -> String {
|
||||
dt.format("%Y%m%dT%H%M%S").to_string()
|
||||
};
|
||||
|
||||
let format_naive_date = |dt: &chrono::NaiveDateTime| -> String {
|
||||
dt.format("%Y%m%d").to_string()
|
||||
};
|
||||
|
||||
// Start building the iCal event
|
||||
let mut ical = String::new();
|
||||
@@ -1225,15 +1335,77 @@ impl CalDAVClient {
|
||||
if event.all_day {
|
||||
ical.push_str(&format!(
|
||||
"DTSTART;VALUE=DATE:{}\r\n",
|
||||
format_date(&event.dtstart)
|
||||
format_naive_date(&event.dtstart)
|
||||
));
|
||||
if let Some(end) = &event.dtend {
|
||||
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
||||
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_naive_date(end)));
|
||||
}
|
||||
} else {
|
||||
ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.dtstart)));
|
||||
// Include timezone information for non-all-day events per RFC 5545
|
||||
if let Some(ref start_tzid) = event.dtstart_tzid {
|
||||
if start_tzid == "UTC" {
|
||||
// UTC events should use Z suffix format
|
||||
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&event.dtstart)));
|
||||
} else if start_tzid.starts_with('+') || start_tzid.starts_with('-') {
|
||||
// Timezone offset format (e.g., "+05:00", "-04:00")
|
||||
// Convert local time to UTC using the offset and use Z format
|
||||
if let Ok(offset_hours) = start_tzid[1..3].parse::<i32>() {
|
||||
let offset_minutes = start_tzid[4..6].parse::<i32>().unwrap_or(0);
|
||||
let total_offset_minutes = if start_tzid.starts_with('+') {
|
||||
offset_hours * 60 + offset_minutes
|
||||
} else {
|
||||
-(offset_hours * 60 + offset_minutes)
|
||||
};
|
||||
|
||||
// Convert local time to UTC by applying the inverse offset
|
||||
// If timezone is +04:00 (local ahead of UTC), subtract to get UTC
|
||||
// If timezone is -04:00 (local behind UTC), add to get UTC
|
||||
let utc_time = event.dtstart - chrono::Duration::minutes(total_offset_minutes as i64);
|
||||
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&utc_time)));
|
||||
} else {
|
||||
// Fallback to floating time if offset parsing fails
|
||||
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
|
||||
}
|
||||
} else {
|
||||
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
|
||||
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", start_tzid, format_naive_datetime(&event.dtstart)));
|
||||
}
|
||||
} else {
|
||||
// No timezone info - treat as floating local time per RFC 5545
|
||||
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
|
||||
}
|
||||
|
||||
if let Some(end) = &event.dtend {
|
||||
ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end)));
|
||||
if let Some(ref end_tzid) = event.dtend_tzid {
|
||||
if end_tzid == "UTC" {
|
||||
// UTC events should use Z suffix format
|
||||
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(end)));
|
||||
} else if end_tzid.starts_with('+') || end_tzid.starts_with('-') {
|
||||
// Timezone offset format (e.g., "+05:00", "-04:00")
|
||||
// Convert local time to UTC using the offset and use Z format
|
||||
if let Ok(offset_hours) = end_tzid[1..3].parse::<i32>() {
|
||||
let offset_minutes = end_tzid[4..6].parse::<i32>().unwrap_or(0);
|
||||
let total_offset_minutes = if end_tzid.starts_with('+') {
|
||||
offset_hours * 60 + offset_minutes
|
||||
} else {
|
||||
-(offset_hours * 60 + offset_minutes)
|
||||
};
|
||||
|
||||
// Convert local time to UTC by subtracting the offset
|
||||
let utc_time = *end - chrono::Duration::minutes(total_offset_minutes as i64);
|
||||
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(&utc_time)));
|
||||
} else {
|
||||
// Fallback to floating time if offset parsing fails
|
||||
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
|
||||
}
|
||||
} else {
|
||||
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
|
||||
ical.push_str(&format!("DTEND;TZID={}:{}\r\n", end_tzid, format_naive_datetime(end)));
|
||||
}
|
||||
} else {
|
||||
// No timezone info - treat as floating local time per RFC 5545
|
||||
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1289,7 +1461,18 @@ impl CalDAVClient {
|
||||
|
||||
// Creation and modification times
|
||||
if let Some(created) = &event.created {
|
||||
ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created)));
|
||||
if let Some(ref created_tzid) = event.created_tzid {
|
||||
if created_tzid == "UTC" {
|
||||
ical.push_str(&format!("CREATED:{}Z\r\n", format_datetime_naive(created)));
|
||||
} else {
|
||||
// Per RFC 5545, CREATED typically should be in UTC or floating time
|
||||
// Treat non-UTC as floating time
|
||||
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
|
||||
}
|
||||
} else {
|
||||
// No timezone info - output as floating time per RFC 5545
|
||||
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
|
||||
}
|
||||
}
|
||||
|
||||
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
|
||||
@@ -1346,10 +1529,10 @@ impl CalDAVClient {
|
||||
if event.all_day {
|
||||
ical.push_str(&format!(
|
||||
"EXDATE;VALUE=DATE:{}\r\n",
|
||||
format_date(exception_date)
|
||||
format_naive_date(exception_date)
|
||||
));
|
||||
} else {
|
||||
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
|
||||
ical.push_str(&format!("EXDATE:{}\r\n", format_naive_datetime(exception_date)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user