960 lines
33 KiB
Rust
960 lines
33 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::address::{as_smtp_address, extract_smtp_address};
|
|
use crate::exchange::jscalendar::{drop_calendar_address_dependents, synthetic_attendee_address};
|
|
use crate::exchange::tz::resolve_to_iana;
|
|
use crate::exchange_graph::error::GraphError;
|
|
use crate::exchange_graph::recurrence::convert_patterned_recurrence_rule;
|
|
|
|
pub fn graph_calendar_color_to_hex(value: &str) -> Option<&'static str> {
|
|
match value {
|
|
"lightBlue" => Some("#A6CEE3"),
|
|
"lightGreen" => Some("#B2DF8A"),
|
|
"lightOrange" => Some("#FDBF6F"),
|
|
"lightGray" => Some("#CCCCCC"),
|
|
"lightYellow" => Some("#FFFF99"),
|
|
"lightTeal" => Some("#A0E7E5"),
|
|
"lightPink" => Some("#FB9A99"),
|
|
"lightBrown" => Some("#B15928"),
|
|
"lightRed" => Some("#FB9A99"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn windows_or_iana_to_iana(value: &str) -> Option<String> {
|
|
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)]
|
|
pub struct ConvertedEvent {
|
|
pub uid: String,
|
|
pub data: Value,
|
|
pub is_draft: bool,
|
|
pub use_default_alerts: bool,
|
|
pub series_master_id: Option<String>,
|
|
pub event_type: EventType,
|
|
pub original_start: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EventType {
|
|
SingleInstance,
|
|
SeriesMaster,
|
|
Occurrence,
|
|
Exception,
|
|
}
|
|
|
|
pub fn classify_event_type(value: &Value) -> EventType {
|
|
match value.get("type").and_then(Value::as_str).unwrap_or("") {
|
|
"seriesMaster" => EventType::SeriesMaster,
|
|
"occurrence" => EventType::Occurrence,
|
|
"exception" => EventType::Exception,
|
|
_ => EventType::SingleInstance,
|
|
}
|
|
}
|
|
|
|
pub fn convert_event(
|
|
graph_event: &Value,
|
|
fallback_calendar_tz: Option<&str>,
|
|
) -> Result<ConvertedEvent, GraphError> {
|
|
let uid = graph_event
|
|
.get("iCalUId")
|
|
.and_then(Value::as_str)
|
|
.or_else(|| graph_event.get("id").and_then(Value::as_str))
|
|
.ok_or_else(|| GraphError::Malformed("event has neither iCalUId nor id".to_owned()))?
|
|
.to_owned();
|
|
|
|
let event_type = classify_event_type(graph_event);
|
|
let series_master_id = graph_event
|
|
.get("seriesMasterId")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned);
|
|
let original_start = graph_event
|
|
.get("originalStart")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned);
|
|
let is_draft = graph_event
|
|
.get("isDraft")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
|
|
let mut card = Map::new();
|
|
card.insert("@type".to_owned(), Value::from("Event"));
|
|
card.insert("uid".to_owned(), Value::from(uid.clone()));
|
|
|
|
if let Some(subject) = graph_event.get("subject").and_then(Value::as_str)
|
|
&& !subject.is_empty()
|
|
{
|
|
card.insert("title".to_owned(), Value::from(subject.to_owned()));
|
|
}
|
|
|
|
if let Some(body) = graph_event.get("body").and_then(Value::as_object)
|
|
&& let Some(content) = body.get("content").and_then(Value::as_str)
|
|
&& !content.is_empty()
|
|
{
|
|
card.insert("description".to_owned(), Value::from(content.to_owned()));
|
|
}
|
|
|
|
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 is_all_day = graph_event
|
|
.get("isAllDay")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
|
|
let display_tz = graph_event
|
|
.get("originalStartTimeZone")
|
|
.and_then(Value::as_str)
|
|
.and_then(windows_or_iana_to_iana)
|
|
.or_else(|| {
|
|
graph_event
|
|
.get("start")
|
|
.and_then(|s| s.get("timeZone"))
|
|
.and_then(Value::as_str)
|
|
.and_then(windows_or_iana_to_iana)
|
|
})
|
|
.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(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) {
|
|
card.insert("status".to_owned(), Value::from("cancelled"));
|
|
} else {
|
|
card.insert("status".to_owned(), Value::from("confirmed"));
|
|
}
|
|
|
|
if let Some(sens) = graph_event.get("sensitivity").and_then(Value::as_str) {
|
|
card.insert("privacy".to_owned(), Value::from(privacy_for(sens)));
|
|
}
|
|
|
|
if let Some(imp) = graph_event.get("importance").and_then(Value::as_str) {
|
|
card.insert("priority".to_owned(), Value::from(priority_for(imp)));
|
|
}
|
|
|
|
if let Some(show) = graph_event.get("showAs").and_then(Value::as_str) {
|
|
card.insert(
|
|
"freeBusyStatus".to_owned(),
|
|
Value::from(free_busy_for(show)),
|
|
);
|
|
}
|
|
|
|
if let Some(cats) = graph_event.get("categories").and_then(Value::as_array)
|
|
&& !cats.is_empty()
|
|
{
|
|
let mut map = Map::new();
|
|
for cat in cats.iter().filter_map(Value::as_str) {
|
|
map.insert(cat.to_owned(), Value::Bool(true));
|
|
}
|
|
if !map.is_empty() {
|
|
card.insert("keywords".to_owned(), Value::Object(map));
|
|
}
|
|
}
|
|
|
|
if let Some(locs) = graph_event.get("locations").and_then(Value::as_array)
|
|
&& !locs.is_empty()
|
|
{
|
|
let mut locations = Map::new();
|
|
let width = pad_width(locs.len());
|
|
for (i, loc) in locs.iter().enumerate() {
|
|
let key = format!("loc-{:0width$}", i + 1, width = width);
|
|
let mut object = Map::new();
|
|
object.insert("@type".to_owned(), Value::from("Location"));
|
|
let display_name = loc
|
|
.get("displayName")
|
|
.and_then(Value::as_str)
|
|
.filter(|s| !s.is_empty());
|
|
let address_str = loc
|
|
.get("address")
|
|
.and_then(Value::as_object)
|
|
.map(|addr| {
|
|
["street", "city", "state", "postalCode", "countryOrRegion"]
|
|
.iter()
|
|
.filter_map(|f| addr.get(*f).and_then(Value::as_str))
|
|
.filter(|s| !s.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
})
|
|
.filter(|s| !s.is_empty());
|
|
let name = match (display_name, address_str.as_deref()) {
|
|
(Some(dn), Some(addr)) if dn != addr => Some(format!("{dn}, {addr}")),
|
|
(Some(dn), _) => Some(dn.to_owned()),
|
|
(None, Some(addr)) => Some(addr.to_owned()),
|
|
(None, None) => None,
|
|
};
|
|
if let Some(n) = name {
|
|
object.insert("name".to_owned(), Value::from(n));
|
|
}
|
|
locations.insert(key, Value::Object(object));
|
|
}
|
|
if !locations.is_empty() {
|
|
card.insert("locations".to_owned(), Value::Object(locations));
|
|
}
|
|
}
|
|
|
|
if let Some(recurrence) = graph_event.get("recurrence")
|
|
&& !recurrence.is_null()
|
|
{
|
|
let rule = convert_patterned_recurrence_rule(recurrence)?;
|
|
card.insert("recurrenceRule".to_owned(), Value::Object(rule));
|
|
}
|
|
|
|
let mut participants: Vec<(String, Value)> = Vec::new();
|
|
if let Some(org) = graph_event
|
|
.get("organizer")
|
|
.and_then(|o| o.get("emailAddress"))
|
|
{
|
|
if let Some((cal_addr, _)) = resolve_calendar_address(org) {
|
|
card.insert("organizerCalendarAddress".to_owned(), Value::from(cal_addr));
|
|
}
|
|
let p = build_participant(org, &["owner", "chair"], None, None);
|
|
participants.push(("organizer".to_owned(), p));
|
|
}
|
|
if let Some(attendees) = graph_event.get("attendees").and_then(Value::as_array) {
|
|
let width = pad_width(attendees.len());
|
|
for (i, att) in attendees.iter().enumerate() {
|
|
let key = format!("att-{:0width$}", i + 1, width = width);
|
|
let Some(email) = att.get("emailAddress") else {
|
|
continue;
|
|
};
|
|
let role = att
|
|
.get("type")
|
|
.and_then(Value::as_str)
|
|
.map(attendee_type_to_role)
|
|
.unwrap_or("required");
|
|
let status = att
|
|
.get("status")
|
|
.and_then(|s| s.get("response"))
|
|
.and_then(Value::as_str)
|
|
.and_then(attendee_response_to_status);
|
|
let p = build_participant(email, &[role], status, Some(true));
|
|
participants.push((key, p));
|
|
}
|
|
}
|
|
if !participants.is_empty() {
|
|
let mut map: Map<String, Value> = participants.into_iter().collect();
|
|
if !card.contains_key("organizerCalendarAddress") {
|
|
drop_calendar_address_dependents(&mut map);
|
|
}
|
|
card.insert("participants".to_owned(), Value::Object(map));
|
|
}
|
|
|
|
if let Some(created) = graph_event.get("createdDateTime").and_then(Value::as_str) {
|
|
card.insert(
|
|
"created".to_owned(),
|
|
Value::from(strip_fractional_utc(created)),
|
|
);
|
|
}
|
|
if let Some(updated) = graph_event
|
|
.get("lastModifiedDateTime")
|
|
.and_then(Value::as_str)
|
|
{
|
|
card.insert(
|
|
"updated".to_owned(),
|
|
Value::from(strip_fractional_utc(updated)),
|
|
);
|
|
}
|
|
|
|
let use_default_alerts = false;
|
|
if let Some(true) = graph_event.get("isReminderOn").and_then(Value::as_bool)
|
|
&& let Some(mins) = graph_event
|
|
.get("reminderMinutesBeforeStart")
|
|
.and_then(Value::as_i64)
|
|
{
|
|
let trigger = format!("-PT{}M", mins.unsigned_abs());
|
|
let alert = json!({
|
|
"@type": "Alert",
|
|
"trigger": {
|
|
"@type": "OffsetTrigger",
|
|
"relativeTo": "start",
|
|
"offset": trigger
|
|
}
|
|
});
|
|
let mut alerts = Map::new();
|
|
alerts.insert("alert-1".to_owned(), alert);
|
|
card.insert("alerts".to_owned(), Value::Object(alerts));
|
|
}
|
|
|
|
if let Some(cancels) = graph_event
|
|
.get("cancelledOccurrences")
|
|
.and_then(Value::as_array)
|
|
&& !cancels.is_empty()
|
|
{
|
|
let mut overrides = Map::new();
|
|
for entry in cancels.iter().filter_map(Value::as_str) {
|
|
overrides.insert(entry.to_owned(), json!({"excluded": true}));
|
|
}
|
|
if !overrides.is_empty() {
|
|
card.insert("recurrenceOverrides".to_owned(), Value::Object(overrides));
|
|
}
|
|
}
|
|
|
|
Ok(ConvertedEvent {
|
|
uid,
|
|
data: Value::Object(card),
|
|
is_draft,
|
|
use_default_alerts,
|
|
series_master_id,
|
|
event_type,
|
|
original_start,
|
|
})
|
|
}
|
|
|
|
fn extract_local_datetime(slot: Option<&Value>) -> Option<String> {
|
|
let slot = slot?;
|
|
let dt = slot.get("dateTime").and_then(Value::as_str)?;
|
|
Some(dt.to_owned())
|
|
}
|
|
|
|
fn non_empty(email: &Value, key: &str) -> Option<String> {
|
|
email
|
|
.get(key)
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_owned)
|
|
}
|
|
|
|
fn resolve_calendar_address(email: &Value) -> Option<(String, Option<String>)> {
|
|
if let Some(addr) = non_empty(email, "address") {
|
|
if let Some(smtp) = as_smtp_address(&addr).or_else(|| extract_smtp_address(&addr)) {
|
|
return Some((format!("mailto:{smtp}"), Some(smtp)));
|
|
}
|
|
return Some((synthetic_attendee_address(&addr), None));
|
|
}
|
|
non_empty(email, "name").map(|name| (synthetic_attendee_address(&name), None))
|
|
}
|
|
|
|
fn build_participant(
|
|
email: &Value,
|
|
roles: &[&str],
|
|
participation_status: Option<&'static str>,
|
|
expect_reply: Option<bool>,
|
|
) -> Value {
|
|
let mut map = Map::new();
|
|
map.insert("@type".to_owned(), Value::from("Participant"));
|
|
if let Some(name) = non_empty(email, "name") {
|
|
map.insert("name".to_owned(), Value::from(name));
|
|
}
|
|
if let Some((cal_addr, smtp)) = resolve_calendar_address(email) {
|
|
map.insert("calendarAddress".to_owned(), Value::from(cal_addr));
|
|
if let Some(addr) = smtp {
|
|
map.insert("email".to_owned(), Value::from(addr));
|
|
}
|
|
let role_map: Map<String, Value> = roles
|
|
.iter()
|
|
.map(|r| ((*r).to_owned(), Value::Bool(true)))
|
|
.collect();
|
|
map.insert("roles".to_owned(), Value::Object(role_map));
|
|
if let Some(status) = participation_status {
|
|
map.insert(
|
|
"participationStatus".to_owned(),
|
|
Value::from(status.to_owned()),
|
|
);
|
|
}
|
|
if let Some(b) = expect_reply {
|
|
map.insert("expectReply".to_owned(), Value::Bool(b));
|
|
}
|
|
}
|
|
Value::Object(map)
|
|
}
|
|
|
|
fn attendee_type_to_role(t: &str) -> &'static str {
|
|
match t {
|
|
"optional" => "optional",
|
|
"resource" => "informational",
|
|
_ => "required",
|
|
}
|
|
}
|
|
|
|
fn attendee_response_to_status(r: &str) -> Option<&'static str> {
|
|
match r {
|
|
"accepted" | "organizer" => Some("accepted"),
|
|
"declined" => Some("declined"),
|
|
"tentativelyAccepted" => Some("tentative"),
|
|
"notResponded" | "none" => Some("needs-action"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn pad_width(n: usize) -> usize {
|
|
let mut digits = 1;
|
|
let mut k = n;
|
|
while k >= 10 {
|
|
digits += 1;
|
|
k /= 10;
|
|
}
|
|
digits
|
|
}
|
|
|
|
fn privacy_for(value: &str) -> &'static str {
|
|
match value {
|
|
"private" => "private",
|
|
"confidential" => "secret",
|
|
_ => "public",
|
|
}
|
|
}
|
|
|
|
fn priority_for(value: &str) -> i64 {
|
|
match value {
|
|
"high" => 1,
|
|
"low" => 9,
|
|
_ => 5,
|
|
}
|
|
}
|
|
|
|
fn free_busy_for(value: &str) -> &'static str {
|
|
match value {
|
|
"free" => "free",
|
|
_ => "busy",
|
|
}
|
|
}
|
|
|
|
fn duration_iso8601(start: &str, end: &str) -> Option<String> {
|
|
let s = parse_naive_seconds(start)?;
|
|
let e = parse_naive_seconds(end)?;
|
|
if e <= s {
|
|
return None;
|
|
}
|
|
let total = e - s;
|
|
let hours = total / 3600;
|
|
let minutes = (total % 3600) / 60;
|
|
let seconds = total % 60;
|
|
let mut out = "PT".to_owned();
|
|
use std::fmt::Write;
|
|
if hours > 0 {
|
|
let _ = write!(out, "{hours}H");
|
|
}
|
|
if minutes > 0 || (hours > 0 && seconds > 0) {
|
|
let _ = write!(out, "{minutes}M");
|
|
}
|
|
if seconds > 0 || (hours == 0 && minutes == 0) {
|
|
let _ = write!(out, "{seconds}S");
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
fn strip_fractional(s: String) -> String {
|
|
if let Some(dot) = s.find('.') {
|
|
s[..dot].to_owned()
|
|
} else {
|
|
s
|
|
}
|
|
}
|
|
|
|
fn strip_fractional_utc(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_naive_seconds(s: &str) -> Option<i64> {
|
|
let (date, time) = s.split_once('T')?;
|
|
let mut date_parts = date.split('-');
|
|
let year: i64 = date_parts.next()?.parse().ok()?;
|
|
let month: i64 = date_parts.next()?.parse().ok()?;
|
|
let day: i64 = date_parts.next()?.parse().ok()?;
|
|
let time = time.split('.').next()?;
|
|
let time = time.trim_end_matches('Z');
|
|
let mut time_parts = time.split(':');
|
|
let hh: i64 = time_parts.next()?.parse().ok()?;
|
|
let mm: i64 = time_parts.next()?.parse().ok()?;
|
|
let ss: i64 = time_parts.next().unwrap_or("0").parse().ok()?;
|
|
Some(days_from_civil(year, month, day) * 86400 + hh * 3600 + mm * 60 + ss)
|
|
}
|
|
|
|
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
|
let y = if m <= 2 { y - 1 } else { y };
|
|
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
|
let yoe = (y - era * 400) as u64;
|
|
let m_adj: i64 = if m > 2 { m - 3 } else { m + 9 };
|
|
let doy = ((153 * m_adj + 2) / 5 + d - 1) as u64;
|
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
era * 146097 + (doe as i64) - 719468
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn sample() -> Value {
|
|
json!({
|
|
"id": "AAA",
|
|
"iCalUId": "uid-1",
|
|
"type": "singleInstance",
|
|
"subject": "Sync up",
|
|
"body": {"contentType": "text", "content": "Agenda"},
|
|
"start": {"dateTime": "2026-05-27T10:00:00.0000000", "timeZone": "UTC"},
|
|
"end": {"dateTime": "2026-05-27T11:00:00.0000000", "timeZone": "UTC"},
|
|
"isAllDay": false,
|
|
"isCancelled": false,
|
|
"sensitivity": "private",
|
|
"importance": "high",
|
|
"showAs": "busy",
|
|
"categories": ["Red", "Internal"],
|
|
"createdDateTime": "2026-05-01T08:00:00Z",
|
|
"lastModifiedDateTime": "2026-05-02T08:00:00Z",
|
|
"isReminderOn": true,
|
|
"reminderMinutesBeforeStart": 15,
|
|
"isDraft": false,
|
|
"organizer": {"emailAddress": {"name": "Alice", "address": "[email protected]"}},
|
|
"attendees": [
|
|
{"emailAddress": {"name": "Bob", "address": "[email protected]"}, "type": "required"}
|
|
]
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn classify_event_type_dispatch() {
|
|
assert_eq!(
|
|
classify_event_type(&json!({"type": "seriesMaster"})),
|
|
EventType::SeriesMaster
|
|
);
|
|
assert_eq!(
|
|
classify_event_type(&json!({"type": "occurrence"})),
|
|
EventType::Occurrence
|
|
);
|
|
assert_eq!(
|
|
classify_event_type(&json!({"type": "exception"})),
|
|
EventType::Exception
|
|
);
|
|
assert_eq!(classify_event_type(&json!({})), EventType::SingleInstance);
|
|
}
|
|
|
|
#[test]
|
|
fn single_instance_converts_to_event_value() {
|
|
let conv = convert_event(&sample(), Some("UTC")).unwrap();
|
|
assert_eq!(conv.uid, "uid-1");
|
|
assert_eq!(conv.data["@type"], "Event");
|
|
assert_eq!(conv.data["title"], "Sync up");
|
|
assert_eq!(conv.data["description"], "Agenda");
|
|
assert_eq!(conv.data["start"], "2026-05-27T10:00:00");
|
|
assert_eq!(conv.data["duration"], "PT1H");
|
|
assert_eq!(conv.data["status"], "confirmed");
|
|
assert_eq!(conv.data["privacy"], "private");
|
|
assert_eq!(conv.data["priority"], 1);
|
|
assert_eq!(conv.data["freeBusyStatus"], "busy");
|
|
let kws = conv.data["keywords"].as_object().unwrap();
|
|
assert!(kws.contains_key("Red"));
|
|
assert!(kws.contains_key("Internal"));
|
|
assert!(conv.data.get("categories").is_none());
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_event_status_is_cancelled() {
|
|
let mut v = sample();
|
|
v["isCancelled"] = Value::Bool(true);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
assert_eq!(conv.data["status"], "cancelled");
|
|
}
|
|
|
|
#[test]
|
|
fn all_day_sets_show_without_time() {
|
|
let mut v = sample();
|
|
v["isAllDay"] = Value::Bool(true);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
assert_eq!(conv.data["showWithoutTime"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn priority_mapping_covers_three_levels() {
|
|
let conv = convert_event(&sample(), None).unwrap();
|
|
assert_eq!(conv.data["priority"], 1);
|
|
let mut v = sample();
|
|
v["importance"] = Value::from("normal");
|
|
assert_eq!(convert_event(&v, None).unwrap().data["priority"], 5);
|
|
v["importance"] = Value::from("low");
|
|
assert_eq!(convert_event(&v, None).unwrap().data["priority"], 9);
|
|
}
|
|
|
|
#[test]
|
|
fn duration_handles_subhour() {
|
|
assert_eq!(
|
|
duration_iso8601("2026-05-27T10:00:00", "2026-05-27T10:30:00").as_deref(),
|
|
Some("PT30M")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duration_handles_multi_day() {
|
|
assert_eq!(
|
|
duration_iso8601("2026-05-27T10:00:00", "2026-05-28T11:30:45").as_deref(),
|
|
Some("PT25H30M45S")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn windows_tz_fallback_returns_iana() {
|
|
assert_eq!(
|
|
windows_or_iana_to_iana("Pacific Standard Time").as_deref(),
|
|
Some("America/Los_Angeles")
|
|
);
|
|
assert_eq!(
|
|
windows_or_iana_to_iana("America/New_York").as_deref(),
|
|
Some("America/New_York")
|
|
);
|
|
assert!(windows_or_iana_to_iana("Made Up Zone").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn organizer_and_attendees_become_participants() {
|
|
let conv = convert_event(&sample(), None).unwrap();
|
|
let participants = conv.data["participants"].as_object().unwrap();
|
|
assert!(participants.contains_key("organizer"));
|
|
assert_eq!(participants["organizer"]["email"], "[email protected]");
|
|
assert_eq!(
|
|
participants["organizer"]["calendarAddress"],
|
|
"mailto:[email protected]"
|
|
);
|
|
assert_eq!(participants["organizer"]["roles"]["owner"], true);
|
|
assert_eq!(participants["organizer"]["roles"]["chair"], true);
|
|
assert!(participants.contains_key("att-1"));
|
|
assert_eq!(participants["att-1"]["email"], "[email protected]");
|
|
assert_eq!(participants["att-1"]["roles"]["required"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn a_hybrid_x500_address_is_not_emitted_as_a_mailto() {
|
|
let mut v = sample();
|
|
v["attendees"] = json!([
|
|
{
|
|
"emailAddress": {
|
|
"name": "Kristina Morgental",
|
|
"address": "/o=ExchangeLabs/ou=Exchange Administrative Group \
|
|
(FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina"
|
|
},
|
|
"type": "required",
|
|
"status": {"response": "none"}
|
|
}
|
|
]);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
let p = &conv.data["participants"]["att-1"];
|
|
let addr = p["calendarAddress"].as_str().unwrap();
|
|
assert!(
|
|
addr.starts_with("urn:x-vandelay:attendee:"),
|
|
"a hybrid X500 reference must not become a malformed mailto, got {addr}"
|
|
);
|
|
assert!(
|
|
p.get("email").is_none(),
|
|
"an X500 reference is not an email address"
|
|
);
|
|
assert_eq!(p["name"], "Kristina Morgental", "the display name is kept");
|
|
}
|
|
|
|
#[test]
|
|
fn a_prefixed_or_bracketed_address_is_normalised() {
|
|
let mut v = sample();
|
|
v["attendees"] = json!([
|
|
{
|
|
"emailAddress": {"name": "Bob", "address": "SMTP:[email protected]"},
|
|
"type": "required",
|
|
"status": {"response": "none"}
|
|
},
|
|
{
|
|
"emailAddress": {"name": "Eve", "address": "Eve <[email protected]>"},
|
|
"type": "required",
|
|
"status": {"response": "none"}
|
|
}
|
|
]);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
assert_eq!(
|
|
conv.data["participants"]["att-1"]["calendarAddress"],
|
|
"mailto:[email protected]"
|
|
);
|
|
assert_eq!(
|
|
conv.data["participants"]["att-1"]["email"],
|
|
"[email protected]"
|
|
);
|
|
assert_eq!(
|
|
conv.data["participants"]["att-2"]["calendarAddress"],
|
|
"mailto:[email protected]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_only_attendee_gets_a_stable_synthetic_calendar_address() {
|
|
let mut v = sample();
|
|
v["attendees"] = json!([
|
|
{
|
|
"emailAddress": {"name": "Room 4"},
|
|
"type": "resource",
|
|
"status": {"response": "accepted"}
|
|
}
|
|
]);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
let p = &conv.data["participants"]["att-1"];
|
|
let addr = p["calendarAddress"].as_str().unwrap();
|
|
assert!(
|
|
addr.starts_with("urn:x-vandelay:attendee:"),
|
|
"a name-only attendee needs a synthetic calendarAddress, got {addr}"
|
|
);
|
|
assert!(
|
|
p.get("email").is_none(),
|
|
"there is no SMTP address to report as email"
|
|
);
|
|
assert_eq!(p["participationStatus"], "accepted");
|
|
assert_eq!(p["roles"]["informational"], true);
|
|
assert_eq!(
|
|
addr,
|
|
convert_event(&v, None).unwrap().data["participants"]["att-1"]["calendarAddress"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unidentifiable_attendee_keeps_no_calendar_address_dependent_property() {
|
|
let mut v = sample();
|
|
v["attendees"] = json!([
|
|
{"emailAddress": {}, "type": "required", "status": {"response": "accepted"}}
|
|
]);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
let p = &conv.data["participants"]["att-1"];
|
|
for key in [
|
|
"calendarAddress",
|
|
"email",
|
|
"roles",
|
|
"participationStatus",
|
|
"expectReply",
|
|
] {
|
|
assert!(
|
|
p.get(key).is_none(),
|
|
"{key} requires calendarAddress (jscalendarbis 3.4.6)"
|
|
);
|
|
}
|
|
assert_eq!(p["@type"], "Participant");
|
|
}
|
|
|
|
#[test]
|
|
fn an_organizer_without_an_address_still_yields_a_calendar_address() {
|
|
let mut v = sample();
|
|
v["organizer"] = json!({"emailAddress": {"name": "Alice Example"}});
|
|
let conv = convert_event(&v, None).unwrap();
|
|
let organizer_address = conv.data["organizerCalendarAddress"].as_str().unwrap();
|
|
assert!(organizer_address.starts_with("urn:x-vandelay:attendee:"));
|
|
assert_eq!(
|
|
conv.data["participants"]["organizer"]["calendarAddress"],
|
|
organizer_address
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn participants_drop_calendar_addresses_when_no_organizer_is_known() {
|
|
let mut v = sample();
|
|
v.as_object_mut().unwrap().remove("organizer");
|
|
let conv = convert_event(&v, None).unwrap();
|
|
assert!(
|
|
conv.data.get("organizerCalendarAddress").is_none(),
|
|
"no organizer is derivable from the source event"
|
|
);
|
|
let participants = conv.data["participants"].as_object().unwrap();
|
|
assert!(!participants.is_empty());
|
|
for (key, participant) in participants {
|
|
assert!(
|
|
participant.get("calendarAddress").is_none(),
|
|
"{key} may not carry a calendarAddress without organizerCalendarAddress"
|
|
);
|
|
assert!(participant.get("roles").is_none(), "{key} kept roles");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn optional_attendee_keeps_role_and_uses_status_when_present() {
|
|
let mut v = sample();
|
|
v["attendees"] = json!([
|
|
{
|
|
"emailAddress": {"name": "Carol", "address": "[email protected]"},
|
|
"type": "optional",
|
|
"status": {"response": "tentativelyAccepted"}
|
|
}
|
|
]);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
let p = &conv.data["participants"]["att-1"];
|
|
assert_eq!(p["roles"]["optional"], true);
|
|
assert!(p["roles"].get("required").is_none());
|
|
assert_eq!(p["participationStatus"], "tentative");
|
|
}
|
|
|
|
#[test]
|
|
fn many_attendees_zero_pad_keys_for_stable_sort() {
|
|
let mut v = sample();
|
|
let mut atts = Vec::new();
|
|
for i in 1..=11 {
|
|
atts.push(json!({
|
|
"emailAddress": {"address": format!("u{i}@x.com")},
|
|
"type": "required"
|
|
}));
|
|
}
|
|
v["attendees"] = Value::Array(atts);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
let map = conv.data["participants"].as_object().unwrap();
|
|
assert!(map.contains_key("att-01"));
|
|
assert!(map.contains_key("att-11"));
|
|
}
|
|
|
|
#[test]
|
|
fn original_start_time_zone_is_preserved() {
|
|
let mut v = sample();
|
|
v["originalStartTimeZone"] = Value::from("Pacific Standard Time");
|
|
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]
|
|
fn duration_h_and_s_no_minutes_includes_zero_m() {
|
|
assert_eq!(
|
|
duration_iso8601("2026-05-27T10:00:00", "2026-05-27T11:00:05").as_deref(),
|
|
Some("PT1H0M5S")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fractional_seconds_are_stripped_from_timestamps() {
|
|
let mut v = sample();
|
|
v["createdDateTime"] = Value::from("2026-05-01T08:00:00.0000000Z");
|
|
v["lastModifiedDateTime"] = Value::from("2026-05-02T08:00:00.123Z");
|
|
let conv = convert_event(&v, None).unwrap();
|
|
assert_eq!(conv.data["created"], "2026-05-01T08:00:00Z");
|
|
assert_eq!(conv.data["updated"], "2026-05-02T08:00:00Z");
|
|
}
|
|
|
|
#[test]
|
|
fn organizer_calendar_address_is_set_alongside_participants() {
|
|
let conv = convert_event(&sample(), None).unwrap();
|
|
assert_eq!(conv.data["organizerCalendarAddress"], "mailto:[email protected]");
|
|
}
|
|
|
|
#[test]
|
|
fn recurrence_emits_singular_recurrence_rule_object() {
|
|
let mut v = sample();
|
|
v["recurrence"] = json!({
|
|
"pattern": {"type": "daily", "interval": 1},
|
|
"range": {"type": "noEnd"}
|
|
});
|
|
let conv = convert_event(&v, None).unwrap();
|
|
assert!(conv.data.get("recurrenceRules").is_none());
|
|
let rule = &conv.data["recurrenceRule"];
|
|
assert!(rule.is_object());
|
|
assert_eq!(rule["@type"], "RecurrenceRule");
|
|
assert_eq!(rule["frequency"], "daily");
|
|
}
|
|
|
|
#[test]
|
|
fn location_with_address_only_uses_name_property() {
|
|
let mut v = sample();
|
|
v["locations"] = json!([{
|
|
"address": {"street": "1 Infinite Loop", "city": "Cupertino"}
|
|
}]);
|
|
let conv = convert_event(&v, None).unwrap();
|
|
let locs = conv.data["locations"].as_object().unwrap();
|
|
let only = locs.values().next().unwrap();
|
|
assert_eq!(only["@type"], "Location");
|
|
assert_eq!(only["name"], "1 Infinite Loop, Cupertino");
|
|
assert!(only.get("description").is_none());
|
|
}
|
|
}
|