@@ -14,6 +14,27 @@ pub fn windows_to_iana(windows_name: &str) -> Option<&'static str> {
|
|||||||
None
|
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)] = &[
|
const TABLE: &[(&str, &str)] = &[
|
||||||
("UTC", "Etc/UTC"),
|
("UTC", "Etc/UTC"),
|
||||||
("Coordinated Universal Time", "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]
|
#[test]
|
||||||
fn coordinated_universal_time_alias_resolves_to_utc() {
|
fn coordinated_universal_time_alias_resolves_to_utc() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
|
|||||||
|
|
||||||
use crate::exchange_ews::parse::{CalendarItemRaw, RawAttendee, RawOccurrence};
|
use crate::exchange_ews::parse::{CalendarItemRaw, RawAttendee, RawOccurrence};
|
||||||
use crate::exchange_ews::recurrence::to_jscalendar_rule;
|
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 struct EventValue {
|
||||||
pub data: Value,
|
pub data: Value,
|
||||||
@@ -73,7 +73,7 @@ pub fn to_jscalendar(raw: &CalendarItemRaw) -> EventValue {
|
|||||||
let iana = raw
|
let iana = raw
|
||||||
.start_tz
|
.start_tz
|
||||||
.as_deref()
|
.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(true) = raw.is_all_day_event {
|
||||||
if let Some(start) = raw.start.as_ref() {
|
if let Some(start) = raw.start.as_ref() {
|
||||||
let date_only = start.split('T').next().unwrap_or(start.as_str());
|
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()
|
if let Some(rec) = raw.recurrence.as_ref()
|
||||||
&& let Some(rule) = to_jscalendar_rule(rec)
|
&& 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(
|
let overrides = build_recurrence_overrides(
|
||||||
&raw.modified_occurrences,
|
&raw.modified_occurrences,
|
||||||
@@ -429,7 +429,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_windows_timezone_passes_through_verbatim() {
|
fn unknown_windows_timezone_falls_back_to_utc() {
|
||||||
let raw = CalendarItemRaw {
|
let raw = CalendarItemRaw {
|
||||||
uid: Some("uid-x".to_owned()),
|
uid: Some("uid-x".to_owned()),
|
||||||
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
start: Some("2025-06-15T14:00:00Z".to_owned()),
|
||||||
@@ -439,7 +439,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let v = to_jscalendar(&raw).data;
|
let v = to_jscalendar(&raw).data;
|
||||||
assert_eq!(v["start"], "2025-06-15T14:00:00");
|
assert_eq!(v["start"], "2025-06-15T14:00:00");
|
||||||
assert_eq!(v["timeZone"], "Made Up Time");
|
assert_eq!(v["timeZone"], "Etc/UTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -514,8 +514,9 @@ mod tests {
|
|||||||
..CalendarItemRaw::default()
|
..CalendarItemRaw::default()
|
||||||
};
|
};
|
||||||
let v = to_jscalendar(&raw).data;
|
let v = to_jscalendar(&raw).data;
|
||||||
assert_eq!(v["recurrenceRules"][0]["frequency"], "daily");
|
assert!(v.get("recurrenceRules").is_none());
|
||||||
assert_eq!(v["recurrenceRules"][0]["count"], 3);
|
assert_eq!(v["recurrenceRule"]["frequency"], "daily");
|
||||||
|
assert_eq!(v["recurrenceRule"]["count"], 3);
|
||||||
let over = v["recurrenceOverrides"].as_object().unwrap();
|
let over = v["recurrenceOverrides"].as_object().unwrap();
|
||||||
assert!(over.contains_key("2025-06-16T14:00:00"));
|
assert!(over.contains_key("2025-06-16T14:00:00"));
|
||||||
assert_eq!(over["2025-06-17T14:00:00"]["excluded"], true);
|
assert_eq!(over["2025-06-17T14:00:00"]["excluded"], true);
|
||||||
|
|||||||
@@ -174,34 +174,6 @@ pub fn to_jscontact(raw: &ContactItemRaw) -> Value {
|
|||||||
Value::Object(map_singleton("1", json!({"@type": "Note", "note": notes}))),
|
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)
|
Value::Object(card)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,15 +412,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn relations_use_x_prefix_for_manager_and_assistant() {
|
fn unresolvable_relations_are_dropped() {
|
||||||
let raw = ContactItemRaw {
|
let raw = ContactItemRaw {
|
||||||
display_name: Some("X".to_owned()),
|
display_name: Some("X".to_owned()),
|
||||||
manager: Some("Boss".to_owned()),
|
manager: Some("Boss".to_owned()),
|
||||||
assistant: Some("Asst".to_owned()),
|
assistant: Some("Asst".to_owned()),
|
||||||
|
spouse: Some("Partner".to_owned()),
|
||||||
|
children: vec!["Kid".to_owned()],
|
||||||
..ContactItemRaw::default()
|
..ContactItemRaw::default()
|
||||||
};
|
};
|
||||||
let v = to_jscontact(&raw);
|
let v = to_jscontact(&raw);
|
||||||
assert_eq!(v["relatedTo"]["Boss"]["relation"]["x-manager"], true);
|
assert!(v.get("relatedTo").is_none());
|
||||||
assert_eq!(v["relatedTo"]["Asst"]["relation"]["x-assistant"], true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use serde_json::{Map, Value, json};
|
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::error::GraphError;
|
||||||
use crate::exchange_graph::recurrence::convert_patterned_recurrence_rule;
|
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> {
|
pub fn windows_or_iana_to_iana(value: &str) -> Option<String> {
|
||||||
if value.is_empty() {
|
resolve_to_iana(value)
|
||||||
return None;
|
}
|
||||||
}
|
|
||||||
if let Some(iana) = windows_to_iana(value) {
|
fn utc_naive_to_local(utc_naive: &str, iana: &str) -> Option<String> {
|
||||||
return Some(iana.to_owned());
|
use chrono::{NaiveDateTime, TimeZone};
|
||||||
}
|
use chrono_tz::Tz;
|
||||||
if value.contains('/') {
|
|
||||||
return Some(value.to_owned());
|
let tz: Tz = iana.parse().ok()?;
|
||||||
}
|
let naive = NaiveDateTime::parse_from_str(utc_naive, "%Y-%m-%dT%H:%M:%S").ok()?;
|
||||||
None
|
Some(
|
||||||
|
tz.from_utc_datetime(&naive)
|
||||||
|
.naive_local()
|
||||||
|
.format("%Y-%m-%dT%H:%M:%S")
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -93,7 +98,6 @@ pub fn convert_event(
|
|||||||
|
|
||||||
let mut card = Map::new();
|
let mut card = Map::new();
|
||||||
card.insert("@type".to_owned(), Value::from("Event"));
|
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()));
|
card.insert("uid".to_owned(), Value::from(uid.clone()));
|
||||||
|
|
||||||
if let Some(subject) = graph_event.get("subject").and_then(Value::as_str)
|
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 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);
|
let end_dt = extract_local_datetime(graph_event.get("end")).map(strip_fractional);
|
||||||
|
|
||||||
if let Some(start) = start_dt.as_deref() {
|
let is_all_day = graph_event
|
||||||
card.insert("start".to_owned(), Value::from(start.to_owned()));
|
.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 display_tz = graph_event
|
||||||
&& let Some(dur) = duration_iso8601(start, end)
|
|
||||||
{
|
|
||||||
card.insert("duration".to_owned(), Value::from(dur));
|
|
||||||
}
|
|
||||||
|
|
||||||
let event_tz = graph_event
|
|
||||||
.get("originalStartTimeZone")
|
.get("originalStartTimeZone")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(windows_or_iana_to_iana)
|
.and_then(windows_or_iana_to_iana)
|
||||||
@@ -133,15 +132,28 @@ pub fn convert_event(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(windows_or_iana_to_iana)
|
.and_then(windows_or_iana_to_iana)
|
||||||
})
|
})
|
||||||
.or_else(|| fallback_calendar_tz.map(str::to_owned));
|
.or_else(|| fallback_calendar_tz.and_then(windows_or_iana_to_iana))
|
||||||
if let Some(tz) = event_tz {
|
.filter(|z| z != "Etc/UTC");
|
||||||
card.insert("timeZone".to_owned(), Value::from(tz));
|
|
||||||
|
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)
|
if let (Some(start), Some(end)) = (start_dt.as_deref(), end_dt.as_deref())
|
||||||
&& all_day
|
&& 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));
|
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) {
|
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("Red"));
|
||||||
assert!(kws.contains_key("Internal"));
|
assert!(kws.contains_key("Internal"));
|
||||||
assert!(conv.data.get("categories").is_none());
|
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();
|
let alerts = conv.data["alerts"].as_object().unwrap();
|
||||||
assert_eq!(alerts["alert-1"]["trigger"]["offset"], "-PT15M");
|
assert_eq!(alerts["alert-1"]["trigger"]["offset"], "-PT15M");
|
||||||
}
|
}
|
||||||
@@ -678,6 +691,58 @@ mod tests {
|
|||||||
v["start"]["timeZone"] = Value::from("UTC");
|
v["start"]["timeZone"] = Value::from("UTC");
|
||||||
let conv = convert_event(&v, None).unwrap();
|
let conv = convert_event(&v, None).unwrap();
|
||||||
assert_eq!(conv.data["timeZone"], "America/Los_Angeles");
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphE
|
|||||||
if !components.is_empty() {
|
if !components.is_empty() {
|
||||||
name_object.insert("components".to_owned(), Value::Array(components));
|
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));
|
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();
|
let mut addresses = Map::new();
|
||||||
push_address(&mut addresses, graph_contact.get("homeAddress"), "private");
|
push_address(
|
||||||
push_address(&mut addresses, graph_contact.get("businessAddress"), "work");
|
&mut addresses,
|
||||||
push_address(&mut addresses, graph_contact.get("otherAddress"), "other");
|
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() {
|
if !addresses.is_empty() {
|
||||||
card.insert("addresses".to_owned(), Value::Object(addresses));
|
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));
|
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
|
if let Some(link) = graph_contact
|
||||||
.get("businessHomePage")
|
.get("businessHomePage")
|
||||||
.and_then(Value::as_str)
|
.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) {
|
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
|
if let Some(updated) = graph_contact
|
||||||
.get("lastModifiedDateTime")
|
.get("lastModifiedDateTime")
|
||||||
.and_then(Value::as_str)
|
.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 {
|
Ok(ConvertedContact {
|
||||||
@@ -328,6 +313,20 @@ pub fn synthetic_uid(graph_id: &str) -> String {
|
|||||||
format!("vandelay-graph-{}", hash.to_hex())
|
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> {
|
fn parse_partial_date(raw: &str) -> Option<Value> {
|
||||||
let date_part = raw.split('T').next().unwrap_or(raw);
|
let date_part = raw.split('T').next().unwrap_or(raw);
|
||||||
let mut iter = date_part.split('-');
|
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(source) = source else { return };
|
||||||
let Some(map) = source.as_object() else {
|
let Some(map) = source.as_object() else {
|
||||||
return;
|
return;
|
||||||
@@ -396,7 +395,9 @@ fn push_address(target: &mut Map<String, Value>, source: Option<&Value>, context
|
|||||||
}
|
}
|
||||||
let mut address = Map::new();
|
let mut address = Map::new();
|
||||||
address.insert("@type".to_owned(), Value::from("Address"));
|
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 {
|
if let Some(s) = street {
|
||||||
address.insert("full".to_owned(), Value::from(s.to_owned()));
|
address.insert("full".to_owned(), Value::from(s.to_owned()));
|
||||||
}
|
}
|
||||||
@@ -486,6 +487,15 @@ mod tests {
|
|||||||
let conv = convert_contact(&c).unwrap();
|
let conv = convert_contact(&c).unwrap();
|
||||||
let addresses = conv.data["addresses"].as_object().unwrap();
|
let addresses = conv.data["addresses"].as_object().unwrap();
|
||||||
assert_eq!(addresses.len(), 3);
|
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]
|
#[test]
|
||||||
@@ -569,7 +579,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manager_and_children_become_related_to() {
|
fn related_contacts_are_dropped_without_resolvable_uids() {
|
||||||
let c = json!({
|
let c = json!({
|
||||||
"id": "X",
|
"id": "X",
|
||||||
"manager": "[email protected]",
|
"manager": "[email protected]",
|
||||||
@@ -577,10 +587,26 @@ mod tests {
|
|||||||
"children": ["Kid A", "Kid B"]
|
"children": ["Kid A", "Kid B"]
|
||||||
});
|
});
|
||||||
let conv = convert_contact(&c).unwrap();
|
let conv = convert_contact(&c).unwrap();
|
||||||
let related = conv.data["relatedTo"].as_object().unwrap();
|
assert!(conv.data.get("relatedTo").is_none());
|
||||||
assert!(related.contains_key("[email protected]"));
|
}
|
||||||
assert!(related.contains_key("Bob"));
|
|
||||||
assert!(related.contains_key("Kid A"));
|
#[test]
|
||||||
assert!(related.contains_key("Kid B"));
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user