Fix MS Exchange to JSCalendar conversion (closes #1 closes #2)

This commit is contained in:
Maurus Decimus
2026-06-04 12:18:56 +02:00
parent c89c5af37c
commit e49a7db278
5 changed files with 215 additions and 109 deletions
+93 -28
View File
@@ -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<String> {
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<String> {
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]
+69 -43
View File
@@ -67,7 +67,7 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
if !components.is_empty() {
name_object.insert("components".to_owned(), Value::Array(components));
}
if !name_object.is_empty() {
if name_object.contains_key("full") || name_object.contains_key("components") {
card.insert("name".to_owned(), Value::Object(name_object));
}
@@ -139,9 +139,17 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
}
let mut addresses = Map::new();
push_address(&mut addresses, graph_contact.get("homeAddress"), "private");
push_address(&mut addresses, graph_contact.get("businessAddress"), "work");
push_address(&mut addresses, graph_contact.get("otherAddress"), "other");
push_address(
&mut addresses,
graph_contact.get("homeAddress"),
Some("private"),
);
push_address(
&mut addresses,
graph_contact.get("businessAddress"),
Some("work"),
);
push_address(&mut addresses, graph_contact.get("otherAddress"), None);
if !addresses.is_empty() {
card.insert("addresses".to_owned(), Value::Object(addresses));
}
@@ -265,35 +273,6 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
card.insert("anniversaries".to_owned(), Value::Object(anniversaries));
}
let mut related = Map::new();
if let Some(m) = graph_contact.get("manager").and_then(Value::as_str)
&& !m.is_empty()
{
related.insert(
m.to_owned(),
json!({"@type": "Relation", "relation": {"co-worker": true}}),
);
}
if let Some(s) = graph_contact.get("spouseName").and_then(Value::as_str)
&& !s.is_empty()
{
related.insert(
s.to_owned(),
json!({"@type": "Relation", "relation": {"spouse": true}}),
);
}
if let Some(kids) = graph_contact.get("children").and_then(Value::as_array) {
for kid in kids.iter().filter_map(Value::as_str) {
related.insert(
kid.to_owned(),
json!({"@type": "Relation", "relation": {"child": true}}),
);
}
}
if !related.is_empty() {
card.insert("relatedTo".to_owned(), Value::Object(related));
}
if let Some(link) = graph_contact
.get("businessHomePage")
.and_then(Value::as_str)
@@ -308,13 +287,19 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
}
if let Some(created) = graph_contact.get("createdDateTime").and_then(Value::as_str) {
card.insert("created".to_owned(), Value::from(created.to_owned()));
card.insert(
"created".to_owned(),
Value::from(normalise_utc_datetime(created)),
);
}
if let Some(updated) = graph_contact
.get("lastModifiedDateTime")
.and_then(Value::as_str)
{
card.insert("updated".to_owned(), Value::from(updated.to_owned()));
card.insert(
"updated".to_owned(),
Value::from(normalise_utc_datetime(updated)),
);
}
Ok(ConvertedContact {
@@ -328,6 +313,20 @@ pub fn synthetic_uid(graph_id: &str) -> 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<Value> {
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<String, Value>, list: Option<&Vec<Value>>, conte
}
}
fn push_address(target: &mut Map<String, Value>, source: Option<&Value>, context: &str) {
fn push_address(target: &mut Map<String, Value>, 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<String, Value>, 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": "[email protected]",
@@ -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("[email protected]"));
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");
}
}