From e49a7db2784587f88c7a0ce70695df3091439b02 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:18:56 +0200 Subject: [PATCH] Fix MS Exchange to JSCalendar conversion (closes #1 closes #2) --- src/exchange/tz.rs | 41 ++++++++++ src/exchange_ews/calendar_map.rs | 15 ++-- src/exchange_ews/contact_map.rs | 35 +-------- src/exchange_graph/calendar_map.rs | 121 ++++++++++++++++++++++------- src/exchange_graph/contact_map.rs | 112 ++++++++++++++++---------- 5 files changed, 215 insertions(+), 109 deletions(-) diff --git a/src/exchange/tz.rs b/src/exchange/tz.rs index 35b14b6..d8df894 100644 --- a/src/exchange/tz.rs +++ b/src/exchange/tz.rs @@ -14,6 +14,27 @@ pub fn windows_to_iana(windows_name: &str) -> Option<&'static str> { None } +pub fn resolve_to_iana(value: &str) -> Option { + use calcard::common::timezone::Tz; + use std::str::FromStr; + + let key = value.trim(); + if key.is_empty() { + return None; + } + if let Some(rest) = key.strip_prefix("tzone://Microsoft/") { + return rest + .eq_ignore_ascii_case("Utc") + .then(|| "Etc/UTC".to_owned()); + } + if let Some(iana) = windows_to_iana(key) { + return Some(iana.to_owned()); + } + Tz::from_str(key) + .ok() + .and_then(|tz| tz.name().map(|name| name.into_owned())) +} + const TABLE: &[(&str, &str)] = &[ ("UTC", "Etc/UTC"), ("Coordinated Universal Time", "Etc/UTC"), @@ -248,6 +269,26 @@ mod tests { ); } + #[test] + fn resolve_handles_windows_iana_offset_and_microsoft_tzone() { + assert_eq!( + resolve_to_iana("Romance Standard Time").as_deref(), + Some("Europe/Paris") + ); + assert_eq!( + resolve_to_iana("Europe/Paris").as_deref(), + Some("Europe/Paris") + ); + assert_eq!(resolve_to_iana("UTC").as_deref(), Some("Etc/UTC")); + assert_eq!( + resolve_to_iana("tzone://Microsoft/Utc").as_deref(), + Some("Etc/UTC") + ); + assert_eq!(resolve_to_iana("tzone://Microsoft/Custom"), None); + assert_eq!(resolve_to_iana("Made Up Zone"), None); + assert_eq!(resolve_to_iana(""), None); + } + #[test] fn coordinated_universal_time_alias_resolves_to_utc() { assert_eq!( diff --git a/src/exchange_ews/calendar_map.rs b/src/exchange_ews/calendar_map.rs index 72afffb..019f379 100644 --- a/src/exchange_ews/calendar_map.rs +++ b/src/exchange_ews/calendar_map.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value, json}; use crate::exchange_ews::parse::{CalendarItemRaw, RawAttendee, RawOccurrence}; use crate::exchange_ews::recurrence::to_jscalendar_rule; -use crate::exchange_ews::tz::windows_to_iana; +use crate::exchange_ews::tz::resolve_to_iana; pub struct EventValue { pub data: Value, @@ -73,7 +73,7 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue { let iana = raw .start_tz .as_deref() - .map(|tz| windows_to_iana(tz).unwrap_or(tz).to_owned()); + .map(|tz| resolve_to_iana(tz).unwrap_or_else(|| "Etc/UTC".to_owned())); if let Some(true) = raw.is_all_day_event { if let Some(start) = raw.start.as_ref() { let date_only = start.split('T').next().unwrap_or(start.as_str()); @@ -155,7 +155,7 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue { if let Some(rec) = raw.recurrence.as_ref() && let Some(rule) = to_jscalendar_rule(rec) { - event.insert("recurrenceRules".to_owned(), Value::Array(vec![rule])); + event.insert("recurrenceRule".to_owned(), rule); } let overrides = build_recurrence_overrides( &raw.modified_occurrences, @@ -429,7 +429,7 @@ mod tests { } #[test] - fn unknown_windows_timezone_passes_through_verbatim() { + fn unknown_windows_timezone_falls_back_to_utc() { let raw = CalendarItemRaw { uid: Some("uid-x".to_owned()), start: Some("2025-06-15T14:00:00Z".to_owned()), @@ -439,7 +439,7 @@ mod tests { }; let v = to_jscalendar(&raw).data; assert_eq!(v["start"], "2025-06-15T14:00:00"); - assert_eq!(v["timeZone"], "Made Up Time"); + assert_eq!(v["timeZone"], "Etc/UTC"); } #[test] @@ -514,8 +514,9 @@ mod tests { ..CalendarItemRaw::default() }; let v = to_jscalendar(&raw).data; - assert_eq!(v["recurrenceRules"][0]["frequency"], "daily"); - assert_eq!(v["recurrenceRules"][0]["count"], 3); + assert!(v.get("recurrenceRules").is_none()); + assert_eq!(v["recurrenceRule"]["frequency"], "daily"); + assert_eq!(v["recurrenceRule"]["count"], 3); let over = v["recurrenceOverrides"].as_object().unwrap(); assert!(over.contains_key("2025-06-16T14:00:00")); assert_eq!(over["2025-06-17T14:00:00"]["excluded"], true); diff --git a/src/exchange_ews/contact_map.rs b/src/exchange_ews/contact_map.rs index 1ffe4c8..0deaded 100644 --- a/src/exchange_ews/contact_map.rs +++ b/src/exchange_ews/contact_map.rs @@ -174,34 +174,6 @@ pub fn to_jscontact(raw: &ContactItemRaw) -> Value { Value::Object(map_singleton("1", json!({"@type": "Note", "note": notes}))), ); } - let mut related: Map = Map::new(); - if let Some(v) = raw.spouse.as_ref() { - related.insert( - v.clone(), - json!({"@type": "Relation", "relation": {"spouse": true}}), - ); - } - for child in &raw.children { - related.insert( - child.clone(), - json!({"@type": "Relation", "relation": {"child": true}}), - ); - } - if let Some(v) = raw.manager.as_ref() { - related.insert( - v.clone(), - json!({"@type": "Relation", "relation": {"x-manager": true}}), - ); - } - if let Some(v) = raw.assistant.as_ref() { - related.insert( - v.clone(), - json!({"@type": "Relation", "relation": {"x-assistant": true}}), - ); - } - if !related.is_empty() { - card.insert("relatedTo".to_owned(), Value::Object(related)); - } Value::Object(card) } @@ -440,15 +412,16 @@ mod tests { } #[test] - fn relations_use_x_prefix_for_manager_and_assistant() { + fn unresolvable_relations_are_dropped() { let raw = ContactItemRaw { display_name: Some("X".to_owned()), manager: Some("Boss".to_owned()), assistant: Some("Asst".to_owned()), + spouse: Some("Partner".to_owned()), + children: vec!["Kid".to_owned()], ..ContactItemRaw::default() }; let v = to_jscontact(&raw); - assert_eq!(v["relatedTo"]["Boss"]["relation"]["x-manager"], true); - assert_eq!(v["relatedTo"]["Asst"]["relation"]["x-assistant"], true); + assert!(v.get("relatedTo").is_none()); } } diff --git a/src/exchange_graph/calendar_map.rs b/src/exchange_graph/calendar_map.rs index 9cfdd0e..930b906 100644 --- a/src/exchange_graph/calendar_map.rs +++ b/src/exchange_graph/calendar_map.rs @@ -6,7 +6,7 @@ use serde_json::{Map, Value, json}; -use crate::exchange::tz::windows_to_iana; +use crate::exchange::tz::resolve_to_iana; use crate::exchange_graph::error::GraphError; use crate::exchange_graph::recurrence::convert_patterned_recurrence_rule; @@ -26,16 +26,21 @@ pub fn graph_calendar_color_to_hex(value: &str) -> Option<&'static str> { } pub fn windows_or_iana_to_iana(value: &str) -> Option { - if value.is_empty() { - return None; - } - if let Some(iana) = windows_to_iana(value) { - return Some(iana.to_owned()); - } - if value.contains('/') { - return Some(value.to_owned()); - } - None + resolve_to_iana(value) +} + +fn utc_naive_to_local(utc_naive: &str, iana: &str) -> Option { + use chrono::{NaiveDateTime, TimeZone}; + use chrono_tz::Tz; + + let tz: Tz = iana.parse().ok()?; + let naive = NaiveDateTime::parse_from_str(utc_naive, "%Y-%m-%dT%H:%M:%S").ok()?; + Some( + tz.from_utc_datetime(&naive) + .naive_local() + .format("%Y-%m-%dT%H:%M:%S") + .to_string(), + ) } #[derive(Debug, Clone)] @@ -93,7 +98,6 @@ pub fn convert_event( let mut card = Map::new(); card.insert("@type".to_owned(), Value::from("Event")); - card.insert("version".to_owned(), Value::from("2.0")); card.insert("uid".to_owned(), Value::from(uid.clone())); if let Some(subject) = graph_event.get("subject").and_then(Value::as_str) @@ -112,17 +116,12 @@ pub fn convert_event( let start_dt = extract_local_datetime(graph_event.get("start")).map(strip_fractional); let end_dt = extract_local_datetime(graph_event.get("end")).map(strip_fractional); - if let Some(start) = start_dt.as_deref() { - card.insert("start".to_owned(), Value::from(start.to_owned())); - } + let is_all_day = graph_event + .get("isAllDay") + .and_then(Value::as_bool) + .unwrap_or(false); - if let (Some(start), Some(end)) = (start_dt.as_deref(), end_dt.as_deref()) - && let Some(dur) = duration_iso8601(start, end) - { - card.insert("duration".to_owned(), Value::from(dur)); - } - - let event_tz = graph_event + let display_tz = graph_event .get("originalStartTimeZone") .and_then(Value::as_str) .and_then(windows_or_iana_to_iana) @@ -133,15 +132,28 @@ pub fn convert_event( .and_then(Value::as_str) .and_then(windows_or_iana_to_iana) }) - .or_else(|| fallback_calendar_tz.map(str::to_owned)); - if let Some(tz) = event_tz { - card.insert("timeZone".to_owned(), Value::from(tz)); + .or_else(|| fallback_calendar_tz.and_then(windows_or_iana_to_iana)) + .filter(|z| z != "Etc/UTC"); + + if let Some(start) = start_dt.as_deref() { + let value = match (is_all_day, display_tz.as_deref()) { + (false, Some(tz)) => utc_naive_to_local(start, tz).unwrap_or_else(|| start.to_owned()), + _ => start.to_owned(), + }; + card.insert("start".to_owned(), Value::from(value)); } - if let Some(all_day) = graph_event.get("isAllDay").and_then(Value::as_bool) - && all_day + if let (Some(start), Some(end)) = (start_dt.as_deref(), end_dt.as_deref()) + && let Some(dur) = duration_iso8601(start, end) { + card.insert("duration".to_owned(), Value::from(dur)); + } + + if is_all_day { card.insert("showWithoutTime".to_owned(), Value::Bool(true)); + } else { + let label = display_tz.unwrap_or_else(|| "Etc/UTC".to_owned()); + card.insert("timeZone".to_owned(), Value::from(label)); } if let Some(true) = graph_event.get("isCancelled").and_then(Value::as_bool) { @@ -559,7 +571,8 @@ mod tests { assert!(kws.contains_key("Red")); assert!(kws.contains_key("Internal")); assert!(conv.data.get("categories").is_none()); - assert_eq!(conv.data["version"], "2.0"); + assert!(conv.data.get("version").is_none()); + assert_eq!(conv.data["timeZone"], "Etc/UTC"); let alerts = conv.data["alerts"].as_object().unwrap(); assert_eq!(alerts["alert-1"]["trigger"]["offset"], "-PT15M"); } @@ -678,6 +691,58 @@ mod tests { v["start"]["timeZone"] = Value::from("UTC"); let conv = convert_event(&v, None).unwrap(); assert_eq!(conv.data["timeZone"], "America/Los_Angeles"); + assert_eq!(conv.data["start"], "2026-05-27T03:00:00"); + } + + #[test] + fn utc_times_are_converted_into_the_original_zone() { + let mut v = sample(); + v["originalStartTimeZone"] = Value::from("Europe/Paris"); + v["start"]["dateTime"] = Value::from("2025-03-12T07:30:00.0000000"); + v["start"]["timeZone"] = Value::from("UTC"); + v["end"]["dateTime"] = Value::from("2025-03-12T08:00:00.0000000"); + v["end"]["timeZone"] = Value::from("UTC"); + let conv = convert_event(&v, None).unwrap(); + assert_eq!(conv.data["start"], "2025-03-12T08:30:00"); + assert_eq!(conv.data["timeZone"], "Europe/Paris"); + assert_eq!(conv.data["duration"], "PT30M"); + } + + #[test] + fn summer_event_shifts_by_two_hours_in_paris() { + let mut v = sample(); + v["originalStartTimeZone"] = Value::from("Romance Standard Time"); + v["start"]["dateTime"] = Value::from("2025-07-15T06:30:00.0000000"); + v["start"]["timeZone"] = Value::from("UTC"); + v["end"]["dateTime"] = Value::from("2025-07-15T07:30:00.0000000"); + v["end"]["timeZone"] = Value::from("UTC"); + let conv = convert_event(&v, None).unwrap(); + assert_eq!(conv.data["start"], "2025-07-15T08:30:00"); + assert_eq!(conv.data["timeZone"], "Europe/Paris"); + } + + #[test] + fn unresolvable_microsoft_zone_falls_back_to_utc() { + let mut v = sample(); + v["originalStartTimeZone"] = Value::from("tzone://Microsoft/Custom"); + v["start"]["dateTime"] = Value::from("2025-03-12T07:30:00.0000000"); + v["start"]["timeZone"] = Value::from("UTC"); + let conv = convert_event(&v, None).unwrap(); + assert_eq!(conv.data["start"], "2025-03-12T07:30:00"); + assert_eq!(conv.data["timeZone"], "Etc/UTC"); + } + + #[test] + fn all_day_keeps_date_and_omits_timezone() { + let mut v = sample(); + v["isAllDay"] = Value::Bool(true); + v["originalStartTimeZone"] = Value::from("Europe/Paris"); + v["start"]["dateTime"] = Value::from("2025-03-12T00:00:00.0000000"); + v["start"]["timeZone"] = Value::from("UTC"); + let conv = convert_event(&v, None).unwrap(); + assert_eq!(conv.data["start"], "2025-03-12T00:00:00"); + assert_eq!(conv.data["showWithoutTime"], true); + assert!(conv.data.get("timeZone").is_none()); } #[test] diff --git a/src/exchange_graph/contact_map.rs b/src/exchange_graph/contact_map.rs index 947e2ce..4fa58ab 100644 --- a/src/exchange_graph/contact_map.rs +++ b/src/exchange_graph/contact_map.rs @@ -67,7 +67,7 @@ pub fn convert_contact(graph_contact: &Value) -> Result Result Result Result String { format!("vandelay-graph-{}", hash.to_hex()) } +fn normalise_utc_datetime(raw: &str) -> String { + let trailing_z = raw.ends_with('Z'); + let trimmed = raw.trim_end_matches('Z'); + let base = match trimmed.find('.') { + Some(dot) => &trimmed[..dot], + None => trimmed, + }; + if trailing_z { + format!("{base}Z") + } else { + base.to_owned() + } +} + fn parse_partial_date(raw: &str) -> Option { let date_part = raw.split('T').next().unwrap_or(raw); let mut iter = date_part.split('-'); @@ -368,7 +367,7 @@ fn push_phones(target: &mut Map, list: Option<&Vec>, conte } } -fn push_address(target: &mut Map, source: Option<&Value>, context: &str) { +fn push_address(target: &mut Map, source: Option<&Value>, context: Option<&str>) { let Some(source) = source else { return }; let Some(map) = source.as_object() else { return; @@ -396,7 +395,9 @@ fn push_address(target: &mut Map, source: Option<&Value>, context } let mut address = Map::new(); address.insert("@type".to_owned(), Value::from("Address")); - address.insert("contexts".to_owned(), json!({context: true})); + if let Some(context) = context { + address.insert("contexts".to_owned(), json!({context: true})); + } if let Some(s) = street { address.insert("full".to_owned(), Value::from(s.to_owned())); } @@ -486,6 +487,15 @@ mod tests { let conv = convert_contact(&c).unwrap(); let addresses = conv.data["addresses"].as_object().unwrap(); assert_eq!(addresses.len(), 3); + let has_other_context = addresses + .values() + .any(|a| a.get("contexts").and_then(|c| c.get("other")).is_some()); + assert!(!has_other_context); + let without_context = addresses + .values() + .filter(|a| a.get("contexts").is_none()) + .count(); + assert_eq!(without_context, 1); } #[test] @@ -569,7 +579,7 @@ mod tests { } #[test] - fn manager_and_children_become_related_to() { + fn related_contacts_are_dropped_without_resolvable_uids() { let c = json!({ "id": "X", "manager": "alice@x.com", @@ -577,10 +587,26 @@ mod tests { "children": ["Kid A", "Kid B"] }); let conv = convert_contact(&c).unwrap(); - let related = conv.data["relatedTo"].as_object().unwrap(); - assert!(related.contains_key("alice@x.com")); - assert!(related.contains_key("Bob")); - assert!(related.contains_key("Kid A")); - assert!(related.contains_key("Kid B")); + assert!(conv.data.get("relatedTo").is_none()); + } + + #[test] + fn nameless_contact_omits_name_object() { + let c = json!({"id": "X", "businessPhones": ["+1"]}); + let conv = convert_contact(&c).unwrap(); + assert!(conv.data.get("name").is_none()); + } + + #[test] + fn timestamps_strip_fractional_seconds() { + let c = json!({ + "id": "X", + "displayName": "Alice", + "createdDateTime": "2026-05-01T08:00:00.0000000Z", + "lastModifiedDateTime": "2026-05-02T08:00:00.123Z" + }); + let conv = convert_contact(&c).unwrap(); + assert_eq!(conv.data["created"], "2026-05-01T08:00:00Z"); + assert_eq!(conv.data["updated"], "2026-05-02T08:00:00Z"); } }