Fix MS Exchange EWS: add support for version negotiation and other enhancements (closes #6)
This commit is contained in:
+126
-45
@@ -27,7 +27,8 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
let days: Vec<Value> = days_of_week
|
let days: Vec<Value> = days_of_week
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|d| day_token(d).map(|t| json!({"day": t})))
|
.filter_map(|d| expand_day(d))
|
||||||
|
.flat_map(|slice| slice.iter().map(|t| json!({"day": t})))
|
||||||
.collect();
|
.collect();
|
||||||
if !days.is_empty() {
|
if !days.is_empty() {
|
||||||
rule.insert("byDay".to_owned(), Value::Array(days));
|
rule.insert("byDay".to_owned(), Value::Array(days));
|
||||||
@@ -55,23 +56,7 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
|
|||||||
if *interval > 1 {
|
if *interval > 1 {
|
||||||
rule.insert("interval".to_owned(), Value::from(*interval));
|
rule.insert("interval".to_owned(), Value::from(*interval));
|
||||||
}
|
}
|
||||||
let nth = nth_of_period(day_of_week_index);
|
insert_relative_by_day(&mut rule, days_of_week, day_of_week_index);
|
||||||
let days: Vec<Value> = days_of_week
|
|
||||||
.iter()
|
|
||||||
.filter_map(|d| {
|
|
||||||
day_token(d).map(|t| {
|
|
||||||
let mut o = Map::new();
|
|
||||||
o.insert("day".to_owned(), Value::String(t.to_owned()));
|
|
||||||
if let Some(n) = nth {
|
|
||||||
o.insert("nthOfPeriod".to_owned(), Value::from(n));
|
|
||||||
}
|
|
||||||
Value::Object(o)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if !days.is_empty() {
|
|
||||||
rule.insert("byDay".to_owned(), Value::Array(days));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
RecurrencePattern::AbsoluteYearly {
|
RecurrencePattern::AbsoluteYearly {
|
||||||
month,
|
month,
|
||||||
@@ -101,23 +86,7 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
|
|||||||
Value::Array(vec![Value::String(n.to_string())]),
|
Value::Array(vec![Value::String(n.to_string())]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let nth = nth_of_period(day_of_week_index);
|
insert_relative_by_day(&mut rule, days_of_week, day_of_week_index);
|
||||||
let days: Vec<Value> = days_of_week
|
|
||||||
.iter()
|
|
||||||
.filter_map(|d| {
|
|
||||||
day_token(d).map(|t| {
|
|
||||||
let mut o = Map::new();
|
|
||||||
o.insert("day".to_owned(), Value::String(t.to_owned()));
|
|
||||||
if let Some(n) = nth {
|
|
||||||
o.insert("nthOfPeriod".to_owned(), Value::from(n));
|
|
||||||
}
|
|
||||||
Value::Object(o)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if !days.is_empty() {
|
|
||||||
rule.insert("byDay".to_owned(), Value::Array(days));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match raw.range.as_ref() {
|
match raw.range.as_ref() {
|
||||||
@@ -140,16 +109,60 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
|
|||||||
Some(Value::Object(rule))
|
Some(Value::Object(rule))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn day_token(d: &str) -> Option<&'static str> {
|
fn expand_day(d: &str) -> Option<&'static [&'static str]> {
|
||||||
match d.to_ascii_lowercase().as_str() {
|
Some(match d.to_ascii_lowercase().as_str() {
|
||||||
"monday" | "mo" => Some("mo"),
|
"monday" | "mo" => &["mo"],
|
||||||
"tuesday" | "tu" => Some("tu"),
|
"tuesday" | "tu" => &["tu"],
|
||||||
"wednesday" | "we" => Some("we"),
|
"wednesday" | "we" => &["we"],
|
||||||
"thursday" | "th" => Some("th"),
|
"thursday" | "th" => &["th"],
|
||||||
"friday" | "fr" => Some("fr"),
|
"friday" | "fr" => &["fr"],
|
||||||
"saturday" | "sa" => Some("sa"),
|
"saturday" | "sa" => &["sa"],
|
||||||
"sunday" | "su" => Some("su"),
|
"sunday" | "su" => &["su"],
|
||||||
_ => None,
|
"day" => &["mo", "tu", "we", "th", "fr", "sa", "su"],
|
||||||
|
"weekday" => &["mo", "tu", "we", "th", "fr"],
|
||||||
|
"weekendday" => &["sa", "su"],
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_relative_by_day(rule: &mut Map<String, Value>, days_of_week: &[String], index: &str) {
|
||||||
|
let nth = nth_of_period(index);
|
||||||
|
let mut days: Vec<&'static str> = Vec::new();
|
||||||
|
let mut is_set = false;
|
||||||
|
for d in days_of_week {
|
||||||
|
if let Some(slice) = expand_day(d) {
|
||||||
|
if slice.len() > 1 {
|
||||||
|
is_set = true;
|
||||||
|
}
|
||||||
|
for &t in slice {
|
||||||
|
if !days.contains(&t) {
|
||||||
|
days.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if days.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if is_set {
|
||||||
|
let by_day: Vec<Value> = days.iter().map(|t| json!({"day": t})).collect();
|
||||||
|
rule.insert("byDay".to_owned(), Value::Array(by_day));
|
||||||
|
if let Some(n) = nth {
|
||||||
|
rule.insert("bySetPosition".to_owned(), json!([n]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let by_day: Vec<Value> = days
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
let mut o = Map::new();
|
||||||
|
o.insert("day".to_owned(), Value::String((*t).to_owned()));
|
||||||
|
if let Some(n) = nth {
|
||||||
|
o.insert("nthOfPeriod".to_owned(), Value::from(n));
|
||||||
|
}
|
||||||
|
Value::Object(o)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
rule.insert("byDay".to_owned(), Value::Array(by_day));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +299,74 @@ mod tests {
|
|||||||
assert!(rule.get("count").is_none());
|
assert!(rule.get("count").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_monthly_first_weekday_expands_with_set_position() {
|
||||||
|
let raw = RawRecurrence {
|
||||||
|
pattern: Some(RecurrencePattern::RelativeMonthly {
|
||||||
|
interval: 1,
|
||||||
|
day_of_week_index: "First".to_owned(),
|
||||||
|
days_of_week: vec!["Weekday".to_owned()],
|
||||||
|
}),
|
||||||
|
range: Some(RecurrenceRange::NoEnd {
|
||||||
|
start_date: "2025-01-01".to_owned(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let rule = to_jscalendar_rule(&raw).unwrap();
|
||||||
|
let by_day = rule["byDay"].as_array().unwrap();
|
||||||
|
let days: Vec<&str> = by_day.iter().map(|d| d["day"].as_str().unwrap()).collect();
|
||||||
|
assert_eq!(days, ["mo", "tu", "we", "th", "fr"]);
|
||||||
|
assert!(
|
||||||
|
by_day.iter().all(|d| d.get("nthOfPeriod").is_none()),
|
||||||
|
"set-based byDay must not carry per-day nthOfPeriod"
|
||||||
|
);
|
||||||
|
assert_eq!(rule["bySetPosition"], json!([1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_monthly_last_weekendday_uses_negative_set_position() {
|
||||||
|
let raw = RawRecurrence {
|
||||||
|
pattern: Some(RecurrencePattern::RelativeMonthly {
|
||||||
|
interval: 1,
|
||||||
|
day_of_week_index: "Last".to_owned(),
|
||||||
|
days_of_week: vec!["WeekendDay".to_owned()],
|
||||||
|
}),
|
||||||
|
range: Some(RecurrenceRange::NoEnd {
|
||||||
|
start_date: "2025-01-01".to_owned(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let rule = to_jscalendar_rule(&raw).unwrap();
|
||||||
|
let days: Vec<&str> = rule["byDay"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|d| d["day"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(days, ["sa", "su"]);
|
||||||
|
assert_eq!(rule["bySetPosition"], json!([-1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weekly_every_weekday_expands() {
|
||||||
|
let raw = RawRecurrence {
|
||||||
|
pattern: Some(RecurrencePattern::Weekly {
|
||||||
|
interval: 1,
|
||||||
|
days_of_week: vec!["Weekday".to_owned()],
|
||||||
|
}),
|
||||||
|
range: Some(RecurrenceRange::NoEnd {
|
||||||
|
start_date: "2025-01-06".to_owned(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let rule = to_jscalendar_rule(&raw).unwrap();
|
||||||
|
let days: Vec<&str> = rule["byDay"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|d| d["day"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(days, ["mo", "tu", "we", "th", "fr"]);
|
||||||
|
assert!(rule.get("bySetPosition").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn weekly_two_days() {
|
fn weekly_two_days() {
|
||||||
let raw = RawRecurrence {
|
let raw = RawRecurrence {
|
||||||
|
|||||||
+41
-6
@@ -99,7 +99,12 @@ pub fn find_item_body(
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync_folder_items_body(folder: &FolderId, sync_state: &str, max_changes: u32) -> String {
|
pub fn sync_folder_items_body(
|
||||||
|
folder: &FolderId,
|
||||||
|
sync_state: &str,
|
||||||
|
max_changes: u32,
|
||||||
|
version: ServerVersion,
|
||||||
|
) -> String {
|
||||||
let mut out = String::with_capacity(512);
|
let mut out = String::with_capacity(512);
|
||||||
out.push_str("<m:SyncFolderItems><m:ItemShape>");
|
out.push_str("<m:SyncFolderItems><m:ItemShape>");
|
||||||
out.push_str("<t:BaseShape>IdOnly</t:BaseShape>");
|
out.push_str("<t:BaseShape>IdOnly</t:BaseShape>");
|
||||||
@@ -112,7 +117,9 @@ pub fn sync_folder_items_body(folder: &FolderId, sync_state: &str, max_changes:
|
|||||||
out.push_str("<m:MaxChangesReturned>");
|
out.push_str("<m:MaxChangesReturned>");
|
||||||
out.push_str(&max_changes.to_string());
|
out.push_str(&max_changes.to_string());
|
||||||
out.push_str("</m:MaxChangesReturned>");
|
out.push_str("</m:MaxChangesReturned>");
|
||||||
out.push_str("<m:SyncScope>NormalItems</m:SyncScope>");
|
if version >= ServerVersion::Exchange2010 {
|
||||||
|
out.push_str("<m:SyncScope>NormalItems</m:SyncScope>");
|
||||||
|
}
|
||||||
out.push_str("</m:SyncFolderItems>");
|
out.push_str("</m:SyncFolderItems>");
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -153,6 +160,10 @@ pub fn get_item_body(shape: ItemShape, ids: &[ItemId], version: ServerVersion) -
|
|||||||
out.push_str(f);
|
out.push_str(f);
|
||||||
out.push_str("\"/>");
|
out.push_str("\"/>");
|
||||||
}
|
}
|
||||||
|
if version >= ServerVersion::Exchange2010 {
|
||||||
|
out.push_str("<t:FieldURI FieldURI=\"calendar:StartTimeZone\"/>");
|
||||||
|
out.push_str("<t:FieldURI FieldURI=\"calendar:EndTimeZone\"/>");
|
||||||
|
}
|
||||||
out.push_str("</t:AdditionalProperties>");
|
out.push_str("</t:AdditionalProperties>");
|
||||||
}
|
}
|
||||||
ItemShape::Contact => {
|
ItemShape::Contact => {
|
||||||
@@ -190,8 +201,6 @@ const CALENDAR_FIELDS: &[&str] = &[
|
|||||||
"calendar:Recurrence",
|
"calendar:Recurrence",
|
||||||
"calendar:ModifiedOccurrences",
|
"calendar:ModifiedOccurrences",
|
||||||
"calendar:DeletedOccurrences",
|
"calendar:DeletedOccurrences",
|
||||||
"calendar:StartTimeZone",
|
|
||||||
"calendar:EndTimeZone",
|
|
||||||
"calendar:Duration",
|
"calendar:Duration",
|
||||||
"item:Subject",
|
"item:Subject",
|
||||||
"item:Body",
|
"item:Body",
|
||||||
@@ -349,17 +358,43 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sync_folder_items_body_carries_state_and_max() {
|
fn sync_folder_items_body_carries_state_and_max() {
|
||||||
let folder = FolderId::new("FID", "FCK");
|
let folder = FolderId::new("FID", "FCK");
|
||||||
let body = sync_folder_items_body(&folder, "STATE", 512);
|
let body = sync_folder_items_body(&folder, "STATE", 512, ServerVersion::Exchange2013Sp1);
|
||||||
assert!(body.contains("<m:SyncFolderId><t:FolderId Id=\"FID\" ChangeKey=\"FCK\"/>"));
|
assert!(body.contains("<m:SyncFolderId><t:FolderId Id=\"FID\" ChangeKey=\"FCK\"/>"));
|
||||||
assert!(body.contains("<m:SyncState>STATE</m:SyncState>"));
|
assert!(body.contains("<m:SyncState>STATE</m:SyncState>"));
|
||||||
assert!(body.contains("<m:MaxChangesReturned>512</m:MaxChangesReturned>"));
|
assert!(body.contains("<m:MaxChangesReturned>512</m:MaxChangesReturned>"));
|
||||||
assert!(body.contains("<m:SyncScope>NormalItems</m:SyncScope>"));
|
assert!(body.contains("<m:SyncScope>NormalItems</m:SyncScope>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_scope_omitted_below_exchange_2010() {
|
||||||
|
let folder = FolderId::new("FID", "");
|
||||||
|
let modern = sync_folder_items_body(&folder, "", 100, ServerVersion::Exchange2010);
|
||||||
|
assert!(modern.contains("<m:SyncScope>NormalItems</m:SyncScope>"));
|
||||||
|
let legacy = sync_folder_items_body(&folder, "", 100, ServerVersion::Exchange2007);
|
||||||
|
assert!(
|
||||||
|
!legacy.contains("SyncScope"),
|
||||||
|
"SyncScope is an Exchange 2010 addition"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calendar_timezone_fields_gated_on_exchange_2010() {
|
||||||
|
let modern =
|
||||||
|
get_item_body(ItemShape::CalendarItem, &[ItemId::new("X", "")], ServerVersion::Exchange2010);
|
||||||
|
assert!(modern.contains("calendar:StartTimeZone"));
|
||||||
|
assert!(modern.contains("calendar:EndTimeZone"));
|
||||||
|
let legacy =
|
||||||
|
get_item_body(ItemShape::CalendarItem, &[ItemId::new("X", "")], ServerVersion::Exchange2007);
|
||||||
|
assert!(
|
||||||
|
!legacy.contains("TimeZone"),
|
||||||
|
"StartTimeZone/EndTimeZone are Exchange 2010 additions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_folder_items_empty_state_round_trips() {
|
fn sync_folder_items_empty_state_round_trips() {
|
||||||
let folder = FolderId::new("FID", "");
|
let folder = FolderId::new("FID", "");
|
||||||
let body = sync_folder_items_body(&folder, "", 100);
|
let body = sync_folder_items_body(&folder, "", 100, ServerVersion::Exchange2013Sp1);
|
||||||
assert!(body.contains("<m:SyncState></m:SyncState>"));
|
assert!(body.contains("<m:SyncState></m:SyncState>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,9 +93,10 @@ fn try_sync_folder_items(
|
|||||||
let mut deletions: Vec<String> = Vec::new();
|
let mut deletions: Vec<String> = Vec::new();
|
||||||
let mut iters = 0;
|
let mut iters = 0;
|
||||||
let mut batch = ctx.sync_batch.clamp(SYNC_BATCH_MIN, SYNC_BATCH_MAX);
|
let mut batch = ctx.sync_batch.clamp(SYNC_BATCH_MIN, SYNC_BATCH_MAX);
|
||||||
|
let version = ctx.client.server_version();
|
||||||
loop {
|
loop {
|
||||||
let retries_before = ctx.client.retries_observed();
|
let retries_before = ctx.client.retries_observed();
|
||||||
let body = sync_folder_items_body(folder, &sync_state, batch);
|
let body = sync_folder_items_body(folder, &sync_state, batch, version);
|
||||||
let resp = match ctx.client.call(ctx.url, "SyncFolderItems", &body) {
|
let resp = match ctx.client.call(ctx.url, "SyncFolderItems", &body) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(EwsError::SoapFault {
|
Err(EwsError::SoapFault {
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ fn get_item_batches_chunk_the_id_list() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sync_folder_items_request_body_carries_state_and_max() {
|
fn sync_folder_items_request_body_carries_state_and_max() {
|
||||||
let folder = FolderId::new("FID", "FCK");
|
let folder = FolderId::new("FID", "FCK");
|
||||||
let body = sync_folder_items_body(&folder, "OPAQUE", 512);
|
let body = sync_folder_items_body(&folder, "OPAQUE", 512, ServerVersion::Exchange2013Sp1);
|
||||||
assert!(body.contains("<m:SyncState>OPAQUE</m:SyncState>"));
|
assert!(body.contains("<m:SyncState>OPAQUE</m:SyncState>"));
|
||||||
assert!(body.contains("<m:MaxChangesReturned>512</m:MaxChangesReturned>"));
|
assert!(body.contains("<m:MaxChangesReturned>512</m:MaxChangesReturned>"));
|
||||||
assert!(body.contains("<m:SyncScope>NormalItems</m:SyncScope>"));
|
assert!(body.contains("<m:SyncScope>NormalItems</m:SyncScope>"));
|
||||||
|
|||||||
Reference in New Issue
Block a user