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
+41
View File
@@ -14,6 +14,27 @@ pub fn windows_to_iana(windows_name: &str) -> Option<&'static str> {
None
}
pub fn resolve_to_iana(value: &str) -> Option<String> {
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!(
+8 -7
View File
@@ -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);
+4 -31
View File
@@ -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<String, Value> = 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());
}
}
+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]
+68 -42
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"));
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");
}
}