Initial commit
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
/*
|
||||
* 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::tz::windows_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> {
|
||||
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
|
||||
}
|
||||
|
||||
#[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("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)
|
||||
&& !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);
|
||||
|
||||
if let Some(start) = start_dt.as_deref() {
|
||||
card.insert("start".to_owned(), Value::from(start.to_owned()));
|
||||
}
|
||||
|
||||
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
|
||||
.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.map(str::to_owned));
|
||||
if let Some(tz) = event_tz {
|
||||
card.insert("timeZone".to_owned(), Value::from(tz));
|
||||
}
|
||||
|
||||
if let Some(all_day) = graph_event.get("isAllDay").and_then(Value::as_bool)
|
||||
&& all_day
|
||||
{
|
||||
card.insert("showWithoutTime".to_owned(), Value::Bool(true));
|
||||
}
|
||||
|
||||
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(addr) = org.get("address").and_then(Value::as_str)
|
||||
&& !addr.is_empty()
|
||||
{
|
||||
card.insert(
|
||||
"organizerCalendarAddress".to_owned(),
|
||||
Value::from(format!("mailto:{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 map: Map<String, Value> = participants.into_iter().collect();
|
||||
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 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(addr) = email.get("address").and_then(Value::as_str) {
|
||||
map.insert(
|
||||
"calendarAddress".to_owned(),
|
||||
Value::from(format!("mailto:{addr}")),
|
||||
);
|
||||
map.insert("email".to_owned(), Value::from(addr.to_owned()));
|
||||
}
|
||||
if let Some(name) = email.get("name").and_then(Value::as_str)
|
||||
&& !name.is_empty()
|
||||
{
|
||||
map.insert("name".to_owned(), Value::from(name.to_owned()));
|
||||
}
|
||||
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_eq!(conv.data["version"], "2.0");
|
||||
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 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");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user