613 lines
20 KiB
Rust
613 lines
20 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*/
|
|
|
|
use serde_json::{Map, Value, json};
|
|
|
|
use crate::exchange_graph::error::GraphError;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ConvertedContact {
|
|
pub uid: String,
|
|
pub data: Value,
|
|
}
|
|
|
|
pub fn convert_contact(graph_contact: &Value) -> Result<ConvertedContact, GraphError> {
|
|
let graph_id = graph_contact
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| GraphError::Malformed("contact has no id".to_owned()))?;
|
|
let uid = synthetic_uid(graph_id);
|
|
|
|
let mut card = Map::new();
|
|
card.insert("@type".to_owned(), Value::from("Card"));
|
|
card.insert("version".to_owned(), Value::from("1.0"));
|
|
card.insert("uid".to_owned(), Value::from(uid.clone()));
|
|
card.insert("kind".to_owned(), Value::from("individual"));
|
|
|
|
let mut name_object = Map::new();
|
|
name_object.insert("@type".to_owned(), Value::from("Name"));
|
|
if let Some(display) = graph_contact.get("displayName").and_then(Value::as_str)
|
|
&& !display.is_empty()
|
|
{
|
|
name_object.insert("full".to_owned(), Value::from(display.to_owned()));
|
|
}
|
|
let name_slots = [
|
|
("title", graph_contact.get("title").and_then(Value::as_str)),
|
|
(
|
|
"given",
|
|
graph_contact.get("givenName").and_then(Value::as_str),
|
|
),
|
|
(
|
|
"given2",
|
|
graph_contact.get("middleName").and_then(Value::as_str),
|
|
),
|
|
(
|
|
"surname",
|
|
graph_contact.get("surname").and_then(Value::as_str),
|
|
),
|
|
(
|
|
"generation",
|
|
graph_contact.get("generation").and_then(Value::as_str),
|
|
),
|
|
];
|
|
let mut components: Vec<Value> = Vec::new();
|
|
let mut slot_index: std::collections::HashMap<&'static str, usize> =
|
|
std::collections::HashMap::new();
|
|
for (kind, value) in name_slots {
|
|
let Some(v) = value else { continue };
|
|
if v.is_empty() {
|
|
continue;
|
|
}
|
|
slot_index.insert(kind, components.len());
|
|
components.push(json!({"@type": "NameComponent", "kind": kind, "value": v}));
|
|
}
|
|
if !components.is_empty() {
|
|
name_object.insert("components".to_owned(), Value::Array(components));
|
|
}
|
|
if name_object.contains_key("full") || name_object.contains_key("components") {
|
|
card.insert("name".to_owned(), Value::Object(name_object));
|
|
}
|
|
|
|
if let Some(nick) = graph_contact.get("nickName").and_then(Value::as_str)
|
|
&& !nick.is_empty()
|
|
{
|
|
let mut nicks = Map::new();
|
|
nicks.insert(
|
|
"nick-1".to_owned(),
|
|
json!({"@type": "Nickname", "name": nick}),
|
|
);
|
|
card.insert("nicknames".to_owned(), Value::Object(nicks));
|
|
}
|
|
|
|
let mut emails = Map::new();
|
|
if let Some(addrs) = graph_contact
|
|
.get("emailAddresses")
|
|
.and_then(Value::as_array)
|
|
{
|
|
for (i, e) in addrs.iter().enumerate() {
|
|
let Some(addr) = e.get("address").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
if addr.is_empty() {
|
|
continue;
|
|
}
|
|
let key = format!("email-{}", i + 1);
|
|
let mut entry = Map::new();
|
|
entry.insert("@type".to_owned(), Value::from("EmailAddress"));
|
|
entry.insert("address".to_owned(), Value::from(addr.to_owned()));
|
|
if i == 0 {
|
|
entry.insert("pref".to_owned(), Value::from(1));
|
|
}
|
|
emails.insert(key, Value::Object(entry));
|
|
}
|
|
}
|
|
if !emails.is_empty() {
|
|
card.insert("emails".to_owned(), Value::Object(emails));
|
|
}
|
|
|
|
let mut phones = Map::new();
|
|
push_phones(
|
|
&mut phones,
|
|
graph_contact
|
|
.get("businessPhones")
|
|
.and_then(Value::as_array),
|
|
"work",
|
|
);
|
|
push_phones(
|
|
&mut phones,
|
|
graph_contact.get("homePhones").and_then(Value::as_array),
|
|
"private",
|
|
);
|
|
if let Some(mobile) = graph_contact.get("mobilePhone").and_then(Value::as_str)
|
|
&& !mobile.is_empty()
|
|
{
|
|
let key = format!("phone-{}", phones.len() + 1);
|
|
phones.insert(
|
|
key,
|
|
json!({
|
|
"@type": "Phone",
|
|
"number": mobile,
|
|
"features": {"mobile": true, "voice": true}
|
|
}),
|
|
);
|
|
}
|
|
if !phones.is_empty() {
|
|
card.insert("phones".to_owned(), Value::Object(phones));
|
|
}
|
|
|
|
let mut addresses = Map::new();
|
|
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));
|
|
}
|
|
|
|
let mut organizations = Map::new();
|
|
let has_company = graph_contact
|
|
.get("companyName")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|s| !s.is_empty());
|
|
let has_dept = graph_contact
|
|
.get("department")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|s| !s.is_empty());
|
|
let has_office = graph_contact
|
|
.get("officeLocation")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|s| !s.is_empty());
|
|
let has_title = graph_contact
|
|
.get("jobTitle")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|s| !s.is_empty());
|
|
if has_company || has_dept || has_office {
|
|
let mut org = Map::new();
|
|
org.insert("@type".to_owned(), Value::from("Organization"));
|
|
if let Some(name) = graph_contact.get("companyName").and_then(Value::as_str)
|
|
&& !name.is_empty()
|
|
{
|
|
org.insert("name".to_owned(), Value::from(name.to_owned()));
|
|
}
|
|
let mut units: Vec<Value> = Vec::new();
|
|
if let Some(dept) = graph_contact.get("department").and_then(Value::as_str)
|
|
&& !dept.is_empty()
|
|
{
|
|
units.push(json!({"@type": "OrgUnit", "name": dept}));
|
|
}
|
|
if let Some(office) = graph_contact.get("officeLocation").and_then(Value::as_str)
|
|
&& !office.is_empty()
|
|
{
|
|
units.push(json!({"@type": "OrgUnit", "name": office}));
|
|
}
|
|
if !units.is_empty() {
|
|
org.insert("units".to_owned(), Value::Array(units));
|
|
}
|
|
organizations.insert("org-1".to_owned(), Value::Object(org));
|
|
}
|
|
if has_title
|
|
&& let Some(title) = graph_contact.get("jobTitle").and_then(Value::as_str)
|
|
&& !title.is_empty()
|
|
{
|
|
let mut titles = Map::new();
|
|
let mut title_obj = Map::new();
|
|
title_obj.insert("@type".to_owned(), Value::from("Title"));
|
|
title_obj.insert("name".to_owned(), Value::from(title.to_owned()));
|
|
if organizations.contains_key("org-1") {
|
|
title_obj.insert("organizationId".to_owned(), Value::from("org-1"));
|
|
}
|
|
titles.insert("title-1".to_owned(), Value::Object(title_obj));
|
|
card.insert("titles".to_owned(), Value::Object(titles));
|
|
}
|
|
let has_org_one = organizations.contains_key("org-1");
|
|
if !organizations.is_empty() {
|
|
card.insert("organizations".to_owned(), Value::Object(organizations));
|
|
}
|
|
|
|
if let Some(notes) = graph_contact.get("personalNotes").and_then(Value::as_str)
|
|
&& !notes.is_empty()
|
|
{
|
|
let mut map = Map::new();
|
|
map.insert("note-1".to_owned(), json!({"@type": "Note", "note": notes}));
|
|
card.insert("notes".to_owned(), Value::Object(map));
|
|
}
|
|
|
|
let mut localizations = Map::new();
|
|
let mut ja = Map::new();
|
|
if let Some(name) = graph_contact.get("yomiCompanyName").and_then(Value::as_str)
|
|
&& !name.is_empty()
|
|
&& has_org_one
|
|
{
|
|
ja.insert(
|
|
"organizations/org-1/name".to_owned(),
|
|
Value::from(name.to_owned()),
|
|
);
|
|
}
|
|
if let Some(name) = graph_contact.get("yomiGivenName").and_then(Value::as_str)
|
|
&& !name.is_empty()
|
|
&& let Some(idx) = slot_index.get("given")
|
|
{
|
|
ja.insert(
|
|
format!("name/components/{idx}/value"),
|
|
Value::from(name.to_owned()),
|
|
);
|
|
}
|
|
if let Some(name) = graph_contact.get("yomiSurname").and_then(Value::as_str)
|
|
&& !name.is_empty()
|
|
&& let Some(idx) = slot_index.get("surname")
|
|
{
|
|
ja.insert(
|
|
format!("name/components/{idx}/value"),
|
|
Value::from(name.to_owned()),
|
|
);
|
|
}
|
|
if !ja.is_empty() {
|
|
localizations.insert("ja".to_owned(), Value::Object(ja));
|
|
}
|
|
if !localizations.is_empty() {
|
|
card.insert("localizations".to_owned(), Value::Object(localizations));
|
|
}
|
|
|
|
if let Some(birthday) = graph_contact.get("birthday").and_then(Value::as_str)
|
|
&& let Some(date) = parse_partial_date(birthday)
|
|
{
|
|
let mut anniversaries = Map::new();
|
|
anniversaries.insert(
|
|
"anniversary-1".to_owned(),
|
|
json!({
|
|
"@type": "Anniversary",
|
|
"kind": "birth",
|
|
"date": date,
|
|
}),
|
|
);
|
|
card.insert("anniversaries".to_owned(), Value::Object(anniversaries));
|
|
}
|
|
|
|
if let Some(link) = graph_contact
|
|
.get("businessHomePage")
|
|
.and_then(Value::as_str)
|
|
&& !link.is_empty()
|
|
{
|
|
let mut links = Map::new();
|
|
links.insert(
|
|
"link-1".to_owned(),
|
|
json!({"@type": "Link", "uri": link, "contexts": {"work": true}}),
|
|
);
|
|
card.insert("links".to_owned(), Value::Object(links));
|
|
}
|
|
|
|
if let Some(created) = graph_contact.get("createdDateTime").and_then(Value::as_str) {
|
|
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(normalise_utc_datetime(updated)),
|
|
);
|
|
}
|
|
|
|
Ok(ConvertedContact {
|
|
uid,
|
|
data: Value::Object(card),
|
|
})
|
|
}
|
|
|
|
pub fn synthetic_uid(graph_id: &str) -> String {
|
|
let hash = blake3::hash(graph_id.as_bytes());
|
|
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('-');
|
|
let year: u64 = iter.next()?.parse().ok()?;
|
|
let mut obj = Map::new();
|
|
obj.insert("@type".to_owned(), Value::from("PartialDate"));
|
|
obj.insert("year".to_owned(), Value::from(year));
|
|
if let Some(month_str) = iter.next()
|
|
&& let Ok(month) = month_str.parse::<u64>()
|
|
&& (1..=12).contains(&month)
|
|
{
|
|
obj.insert("month".to_owned(), Value::from(month));
|
|
if let Some(day_str) = iter.next()
|
|
&& let Ok(day) = day_str.parse::<u64>()
|
|
&& (1..=31).contains(&day)
|
|
{
|
|
obj.insert("day".to_owned(), Value::from(day));
|
|
}
|
|
}
|
|
Some(Value::Object(obj))
|
|
}
|
|
|
|
fn push_phones(target: &mut Map<String, Value>, list: Option<&Vec<Value>>, context: &str) {
|
|
let Some(list) = list else { return };
|
|
for n in list.iter().filter_map(Value::as_str) {
|
|
if n.is_empty() {
|
|
continue;
|
|
}
|
|
let key = format!("phone-{}", target.len() + 1);
|
|
target.insert(
|
|
key,
|
|
json!({
|
|
"@type": "Phone",
|
|
"number": n,
|
|
"contexts": {context: true}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
};
|
|
let street = map
|
|
.get("street")
|
|
.and_then(Value::as_str)
|
|
.filter(|s| !s.is_empty());
|
|
let component_fields: Vec<(&str, &str)> = [
|
|
("city", "locality"),
|
|
("state", "region"),
|
|
("postalCode", "postcode"),
|
|
("countryOrRegion", "country"),
|
|
]
|
|
.into_iter()
|
|
.filter_map(|(graph_key, jsc_kind)| {
|
|
map.get(graph_key)
|
|
.and_then(Value::as_str)
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| (jsc_kind, s))
|
|
})
|
|
.collect();
|
|
if street.is_none() && component_fields.is_empty() {
|
|
return;
|
|
}
|
|
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()));
|
|
}
|
|
if !component_fields.is_empty() {
|
|
let components: Vec<Value> = component_fields
|
|
.iter()
|
|
.map(|(kind, value)| json!({"@type": "AddressComponent", "kind": kind, "value": value}))
|
|
.collect();
|
|
address.insert("components".to_owned(), Value::Array(components));
|
|
}
|
|
let key = format!("addr-{}", target.len() + 1);
|
|
target.insert(key, Value::Object(address));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn minimal_contact_yields_card_with_synthetic_uid() {
|
|
let c = json!({"id": "GRAPH-1", "displayName": "Alice"});
|
|
let conv = convert_contact(&c).unwrap();
|
|
assert!(conv.uid.starts_with("vandelay-graph-"));
|
|
assert_eq!(conv.data["@type"], "Card");
|
|
assert_eq!(conv.data["kind"], "individual");
|
|
assert_eq!(conv.data["name"]["full"], "Alice");
|
|
}
|
|
|
|
#[test]
|
|
fn synthetic_uid_is_stable_per_graph_id() {
|
|
let a = synthetic_uid("AAA");
|
|
let b = synthetic_uid("AAA");
|
|
let c = synthetic_uid("BBB");
|
|
assert_eq!(a, b);
|
|
assert_ne!(a, c);
|
|
}
|
|
|
|
#[test]
|
|
fn emails_map_with_first_as_pref() {
|
|
let c = json!({
|
|
"id": "X",
|
|
"emailAddresses": [
|
|
{"address": "[email protected]", "name": "Alice"},
|
|
{"address": "[email protected]", "name": "Alice B"}
|
|
]
|
|
});
|
|
let conv = convert_contact(&c).unwrap();
|
|
let emails = conv.data["emails"].as_object().unwrap();
|
|
assert_eq!(emails["email-1"]["address"], "[email protected]");
|
|
assert_eq!(emails["email-1"]["pref"], 1);
|
|
assert_eq!(emails["email-2"]["address"], "[email protected]");
|
|
assert!(emails["email-2"].get("pref").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn phones_carry_context_per_source_field() {
|
|
let c = json!({
|
|
"id": "X",
|
|
"businessPhones": ["+1-555-2000"],
|
|
"homePhones": ["+1-555-1000"],
|
|
"mobilePhone": "+1-555-3000"
|
|
});
|
|
let conv = convert_contact(&c).unwrap();
|
|
let phones = conv.data["phones"].as_object().unwrap();
|
|
assert_eq!(phones.len(), 3);
|
|
let has_work = phones
|
|
.values()
|
|
.any(|v| v["contexts"]["work"] == Value::Bool(true));
|
|
let has_private = phones
|
|
.values()
|
|
.any(|v| v["contexts"]["private"] == Value::Bool(true));
|
|
let has_mobile = phones
|
|
.values()
|
|
.any(|v| v["features"]["mobile"] == Value::Bool(true));
|
|
assert!(has_work && has_private && has_mobile);
|
|
}
|
|
|
|
#[test]
|
|
fn addresses_map_three_contexts() {
|
|
let c = json!({
|
|
"id": "X",
|
|
"homeAddress": {"city": "Home City"},
|
|
"businessAddress": {"city": "Work City"},
|
|
"otherAddress": {"city": "Other City"}
|
|
});
|
|
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]
|
|
fn org_and_title_pair_into_separate_jscontact_objects() {
|
|
let c = json!({
|
|
"id": "X",
|
|
"companyName": "Acme",
|
|
"department": "Engineering",
|
|
"officeLocation": "Building 9",
|
|
"jobTitle": "Architect"
|
|
});
|
|
let conv = convert_contact(&c).unwrap();
|
|
let orgs = conv.data["organizations"].as_object().unwrap();
|
|
assert!(orgs.contains_key("org-1"));
|
|
assert_eq!(orgs["org-1"]["name"], "Acme");
|
|
let titles = conv.data["titles"].as_object().unwrap();
|
|
assert_eq!(titles["title-1"]["name"], "Architect");
|
|
assert_eq!(titles["title-1"]["organizationId"], "org-1");
|
|
}
|
|
|
|
#[test]
|
|
fn yomi_fields_populate_japanese_localization() {
|
|
let c = json!({
|
|
"id": "X",
|
|
"displayName": "Yamada Taro",
|
|
"givenName": "Taro",
|
|
"surname": "Yamada",
|
|
"companyName": "Acme",
|
|
"yomiCompanyName": "アクメ",
|
|
"yomiGivenName": "タロウ",
|
|
"yomiSurname": "ヤマダ"
|
|
});
|
|
let conv = convert_contact(&c).unwrap();
|
|
let ja = conv.data["localizations"]["ja"].as_object().unwrap();
|
|
assert_eq!(ja["organizations/org-1/name"], "アクメ");
|
|
}
|
|
|
|
#[test]
|
|
fn birthday_emits_partialdate_object() {
|
|
let c = json!({"id": "X", "birthday": "1980-04-12T00:00:00Z"});
|
|
let conv = convert_contact(&c).unwrap();
|
|
let ann = conv.data["anniversaries"].as_object().unwrap();
|
|
let date = &ann["anniversary-1"]["date"];
|
|
assert_eq!(date["@type"], "PartialDate");
|
|
assert_eq!(date["year"], 1980);
|
|
assert_eq!(date["month"], 4);
|
|
assert_eq!(date["day"], 12);
|
|
assert_eq!(ann["anniversary-1"]["kind"], "birth");
|
|
}
|
|
|
|
#[test]
|
|
fn mobile_phone_uses_features_not_contexts() {
|
|
let c = json!({"id": "X", "mobilePhone": "+1-555-3000"});
|
|
let conv = convert_contact(&c).unwrap();
|
|
let phones = conv.data["phones"].as_object().unwrap();
|
|
let phone = phones.values().next().unwrap();
|
|
assert_eq!(phone["features"]["mobile"], true);
|
|
assert!(phone.get("contexts").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn generation_maps_to_generation_kind() {
|
|
let c = json!({"id": "X", "generation": "Jr."});
|
|
let conv = convert_contact(&c).unwrap();
|
|
let comps = conv.data["name"]["components"].as_array().unwrap();
|
|
assert!(
|
|
comps
|
|
.iter()
|
|
.any(|c| c["kind"] == "generation" && c["value"] == "Jr.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_jobtitle_does_not_emit_empty_organization() {
|
|
let c = json!({"id": "X", "jobTitle": "Architect"});
|
|
let conv = convert_contact(&c).unwrap();
|
|
assert!(conv.data.get("organizations").is_none());
|
|
let titles = conv.data["titles"].as_object().unwrap();
|
|
assert_eq!(titles["title-1"]["name"], "Architect");
|
|
assert!(titles["title-1"].get("organizationId").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn related_contacts_are_dropped_without_resolvable_uids() {
|
|
let c = json!({
|
|
"id": "X",
|
|
"manager": "[email protected]",
|
|
"spouseName": "Bob",
|
|
"children": ["Kid A", "Kid B"]
|
|
});
|
|
let conv = convert_contact(&c).unwrap();
|
|
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");
|
|
}
|
|
}
|