From aa25d3c2aa28d2b3aa540956e5fbefb35b6e01b5 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 02:51:28 +0200 Subject: [PATCH 01/18] Exchange zone definitions: read them and compute a midnight from them (232) StartTimeZone and EndTimeZone carry the zone's full definition. A walker reads it in both item parsers, onto its own boundary; the flag that took a self-closing start zone's place for the end zone's children goes. The new zone_definition module turns a definition into a clock (periods, yearly changes, eras from absolute transitions) and finds a day's midnight, the first hour after it where a change skips it. A fifth weekday reads as the month's last (238). Not wired into reading or writing yet. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/lib.rs | 1 + crates/adapter-ews/src/mapping.rs | 270 +++++- crates/adapter-ews/src/zone_definition.rs | 1017 +++++++++++++++++++++ 3 files changed, 1252 insertions(+), 36 deletions(-) create mode 100644 crates/adapter-ews/src/zone_definition.rs diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 9ec2c8f4..e1014864 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -37,6 +37,7 @@ pub mod mapping; pub mod soap; pub mod tasks; pub mod windows_tz; +pub mod zone_definition; use std::collections::HashMap; use std::path::PathBuf; diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 3a2d56fa..10743c2a 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -446,6 +446,16 @@ pub struct ParsedItem { /// drained again anyway (`api::ITEM_PARSER`). #[serde(default)] pub end_time_zone: Option, + /// The full definitions Exchange sends inside `` and + /// ``, read for a zone the CLDR table does not know (a + /// custom one such as `Customized Time Zone`, decision 232). `None` where + /// the element carried only its id. `#[serde(default)]` so older + /// persisted state loads; that state is drained again anyway + /// (`api::ITEM_PARSER` 5). + #[serde(default)] + pub start_zone_definition: Option, + #[serde(default)] + pub end_zone_definition: Option, /// `` on an occurrence or exception read by itself (the /// occurrence probe): the slot of the series it fills, which stays put when /// an exception is moved. `None` elsewhere. `#[serde(default)]` so older @@ -759,10 +769,10 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult = None; let mut recurrence_walker: Option = None; - // `` carries the master's Windows zone id (directly, or on - // a nested ``); track that we're inside it so the - // nested-form id isn't picked up from EndTimeZone. - let mut inside_start_timezone = false; + // `` / `` carry their Windows zone id and, + // inside, the zone's full definition: a walker reads that subtree, as the + // recurrence walker reads ``. `true` for the start zone. + let mut zone_walker: Option<(bool, crate::zone_definition::ZoneDefinitionWalker)> = None; let mut inside_deleted_occurrences = false; let mut inside_deleted_occurrence = false; // ModifiedOccurrences mirrors the DeletedOccurrences shape but @@ -775,7 +785,11 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult { + Ok(event @ (XmlEvent::Start(_) | XmlEvent::Empty(_))) => { + let empty = matches!(event, XmlEvent::Empty(_)); + let (XmlEvent::Start(e) | XmlEvent::Empty(e)) = event else { + unreachable!() + }; let local = e.local_name().as_ref().to_ascii_lowercase(); // Recurrence subtree: route to the shared walker // instead of the outer item state machine, so the @@ -785,6 +799,11 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult inside_change_kind = Some("create"), b"update" => inside_change_kind = Some("update"), @@ -917,7 +936,10 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult { - inside_start_timezone = true; + if !empty { + zone_walker = + Some((true, crate::zone_definition::ZoneDefinitionWalker::default())); + } // Simple form: . for a in e.attributes().flatten() { if a.key.as_ref().eq_ignore_ascii_case(b"Id") { @@ -928,8 +950,7 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult { for a in e.attributes().flatten() { if a.key.as_ref().eq_ignore_ascii_case(b"Id") { @@ -937,16 +958,9 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult - { - // Full-definition form: the id sits one level down. - for a in e.attributes().flatten() { - if a.key.as_ref().eq_ignore_ascii_case(b"Id") { - current.start_time_zone = - Some(String::from_utf8_lossy(&a.value).into_owned()); - } + if !empty { + zone_walker = + Some((false, crate::zone_definition::ZoneDefinitionWalker::default())); } } _ => {} @@ -982,6 +996,15 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult { inside_deleted_occurrences = false; @@ -992,7 +1015,6 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult { inside_modified_occurrences = false; } - b"starttimezone" => inside_start_timezone = false, b"occurrence" if inside_modified_occurrence => { inside_modified_occurrence = false; if let Some(o) = std::mem::take(&mut current_override).finish() { @@ -1046,6 +1068,10 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult EwsResult let mut current = ParsedItem::default(); let mut text_target: Option<&'static str> = None; let mut recurrence_walker: Option = None; - // `` carries the master's Windows zone id (directly, or on - // a nested ``); track that we're inside it so the - // nested-form id isn't picked up from EndTimeZone. - let mut inside_start_timezone = false; + // `` / `` carry their Windows zone id and, + // inside, the zone's full definition: a walker reads that subtree, as the + // recurrence walker reads ``. `true` for the start zone. + let mut zone_walker: Option<(bool, crate::zone_definition::ZoneDefinitionWalker)> = None; let mut inside_deleted_occurrences = false; let mut inside_deleted_occurrence = false; let mut inside_modified_occurrences = false; @@ -1265,7 +1291,11 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult loop { match reader.read_event_into(&mut buf) { - Ok(XmlEvent::Start(e)) | Ok(XmlEvent::Empty(e)) => { + Ok(event @ (XmlEvent::Start(_) | XmlEvent::Empty(_))) => { + let empty = matches!(event, XmlEvent::Empty(_)); + let (XmlEvent::Start(e) | XmlEvent::Empty(e)) = event else { + unreachable!() + }; let local = e.local_name().as_ref().to_ascii_lowercase(); // Recurrence subtree gets routed to the shared walker // — same logic as in `parse_sync_folder_items_response`. @@ -1273,6 +1303,11 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult walker.observe_start(local.as_slice()); continue; } + // A zone's definition subtree goes to its own walker. + if let Some((_, walker)) = zone_walker.as_mut() { + walker.observe_start(local.as_slice(), &e, empty); + continue; + } match local.as_slice() { b"calendaritem" => { inside_item = true; @@ -1401,7 +1436,10 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult text_target = Some("attendee_response"); } b"starttimezone" if inside_item => { - inside_start_timezone = true; + if !empty { + zone_walker = + Some((true, crate::zone_definition::ZoneDefinitionWalker::default())); + } for a in e.attributes().flatten() { if a.key.as_ref().eq_ignore_ascii_case(b"Id") { current.start_time_zone = @@ -1409,7 +1447,8 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult } } } - // As in the sync parser: the end zone's own id only. + // As in the sync parser: the end zone's own id and + // definition. b"endtimezone" if inside_item => { for a in e.attributes().flatten() { if a.key.as_ref().eq_ignore_ascii_case(b"Id") { @@ -1417,15 +1456,9 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult Some(String::from_utf8_lossy(&a.value).into_owned()); } } - } - b"timezonedefinition" - if inside_start_timezone && current.start_time_zone.is_none() => - { - for a in e.attributes().flatten() { - if a.key.as_ref().eq_ignore_ascii_case(b"Id") { - current.start_time_zone = - Some(String::from_utf8_lossy(&a.value).into_owned()); - } + if !empty { + zone_walker = + Some((false, crate::zone_definition::ZoneDefinitionWalker::default())); } } _ => {} @@ -1452,11 +1485,19 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult walker.observe_end_generic(); continue; } + if let Some((_, walker)) = zone_walker.as_mut() { + if walker.observe_end(local.as_slice()) { + if let Some((is_start, walker)) = zone_walker.take() { + finish_zone(&mut current, is_start, walker); + } + } + text_target = None; + continue; + } match local.as_slice() { b"deletedoccurrences" => inside_deleted_occurrences = false, b"deletedoccurrence" => inside_deleted_occurrence = false, b"modifiedoccurrences" => inside_modified_occurrences = false, - b"starttimezone" => inside_start_timezone = false, b"occurrence" if inside_modified_occurrence => { inside_modified_occurrence = false; if let Some(o) = std::mem::take(&mut current_override).finish() { @@ -1497,6 +1538,10 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult walker.observe_text(s); continue; } + if let Some((_, walker)) = zone_walker.as_mut() { + walker.observe_text(s); + continue; + } if text_target.is_none() { continue; } @@ -2430,6 +2475,25 @@ fn ews_all_day_boundary(when: DateTime) -> DateTime { Utc.from_utc_datetime(&day.and_hms_opt(0, 0, 0).unwrap()) } +/// Store a zone walker's result on the item: the definition, and the id a +/// nested `TimeZoneDefinition` named where the element itself carried none. +fn finish_zone( + item: &mut ParsedItem, + is_start: bool, + walker: crate::zone_definition::ZoneDefinitionWalker, +) { + let (definition, nested_id) = walker.finish(); + let (id, slot) = if is_start { + (&mut item.start_time_zone, &mut item.start_zone_definition) + } else { + (&mut item.end_time_zone, &mut item.end_zone_definition) + }; + *slot = definition; + if id.is_none() { + *id = nested_id; + } +} + /// Re-anchor an all-day boundary read from EWS at LOCAL midnight of the /// intended calendar day — the app-internal all-day convention. /// @@ -4274,6 +4338,8 @@ mod tests { item_type: None, start_time_zone: None, end_time_zone: None, + start_zone_definition: None, + end_zone_definition: None, original_start: None, recurrence: None, deleted_occurrence_starts: Vec::new(), @@ -6645,6 +6711,8 @@ mod tests { item_type: item_type.map(String::from), start_time_zone: None, end_time_zone: None, + start_zone_definition: None, + end_zone_definition: None, original_start: None, recurrence: None, deleted_occurrence_starts: Vec::new(), @@ -7375,6 +7443,136 @@ mod tests { ); } + /// A custom zone's definition, as an Exchange ActiveSync client leaves it + /// (`Customized Time Zone`, Name empty), with W. Europe's rules. + const CUSTOM_ZONE_BODY: &str = r#" + + + + + + + custom/dst + PT2H + 3 + Sunday + -1 + + + custom/std + PT3H + 10 + Sunday + -1 + + + + + 0 + "#; + + fn zone_item(start_zone: &str, end_zone: &str) -> String { + format!( + r#" + + Zone + 2026-10-11T22:00:00Z + 2026-10-12T22:00:00Z + true + Single + {start_zone} + {end_zone} + "# + ) + } + + fn in_sync_response(item: &str) -> ParsedItem { + let xml = format!( + r#" + + + NoError + S + true + {item} + + +"# + ); + match parse_sync_folder_items_response(&xml).unwrap().changes.remove(0) { + SyncChange::Create(item) => item, + other => panic!("expected Create, got {other:?}"), + } + } + + fn in_get_item_response(item: &str) -> ParsedItem { + let xml = format!( + r#" + + + {item} + + +"# + ); + parse_get_calendar_items_response(&xml).unwrap().remove(0) + } + + /// Both item parsers put a zone's definition on its own boundary, after a + /// self-closing zone on the other one: a flag that stayed set after a + /// self-closing start zone once took the end zone's children for it. + #[test] + fn a_zone_definition_lands_on_its_own_boundary() { + let id_only = r#""#; + let custom_end = + format!(r#"{CUSTOM_ZONE_BODY}"#); + let custom_start = + format!(r#"{CUSTOM_ZONE_BODY}"#); + let id_only_end = r#""#; + for parse in [in_sync_response, in_get_item_response] { + let item = parse(&zone_item(id_only, &custom_end)); + assert_eq!(item.start_time_zone.as_deref(), Some("W. Europe Standard Time")); + assert_eq!(item.start_zone_definition, None); + assert_eq!(item.end_time_zone.as_deref(), Some("Customized Time Zone")); + let def = item.end_zone_definition.expect("the end zone's definition"); + assert_eq!(def.periods.len(), 2); + assert_eq!(def.groups.len(), 1); + assert_eq!(def.groups[0].transitions.len(), 2); + assert_eq!(def.transitions.len(), 1); + let rules = crate::zone_definition::ZoneRules::try_from(&def).unwrap(); + assert_eq!( + rules.midnight("2026-10-12".parse().unwrap()), + Some("2026-10-11T22:00:00Z".parse().unwrap()) + ); + // The item's own fields after the zones are still read. + assert_eq!(item.subject, "Zone"); + + let item = parse(&zone_item(&custom_start, id_only_end)); + assert_eq!(item.start_time_zone.as_deref(), Some("Customized Time Zone")); + assert!(item.start_zone_definition.is_some()); + assert_eq!(item.end_time_zone.as_deref(), Some("W. Europe Standard Time")); + assert_eq!(item.end_zone_definition, None); + } + } + + /// A server that wraps the definition in a `TimeZoneDefinition` names the + /// id there; it still reaches the item. + #[test] + fn a_nested_definition_gives_its_id() { + let nested = format!( + r#"{CUSTOM_ZONE_BODY}"# + ); + for parse in [in_sync_response, in_get_item_response] { + let item = parse(&zone_item(&nested, "")); + assert_eq!(item.start_time_zone.as_deref(), Some("Customized Time Zone")); + assert!(item.start_zone_definition.is_some()); + } + } + /// An exception read on its own carries the slot it fills as its own /// `OriginalStart`; a master's `OriginalStart`s belong to its modified /// occurrences and must not leak onto the master. diff --git a/crates/adapter-ews/src/zone_definition.rs b/crates/adapter-ews/src/zone_definition.rs new file mode 100644 index 00000000..ca885c59 --- /dev/null +++ b/crates/adapter-ews/src/zone_definition.rs @@ -0,0 +1,1017 @@ +//! A time zone as Exchange defines it inside `StartTimeZone` / +//! `EndTimeZone` (`TimeZoneDefinitionType`), and the clock it describes. +//! +//! Most items carry an id the CLDR table maps to a tzdata zone; those are +//! read through [`crate::windows_tz`]. An item made by another client may +//! carry a definition of its own instead: `Customized Time Zone` (an +//! Exchange ActiveSync client such as the iPhone's calendar), an iCalendar +//! invitation's VTIMEZONE, or a registry-only id. Decision 232 reads such a +//! definition to find the item's midnights rather than refusing or guessing. +//! +//! The wire shape (Exchange 2010 and later): +//! +//! - `Periods/Period` with `Bias` (an `xs:duration`; UTC = local + Bias, so +//! W. Europe standard time is `-PT1H`) and an opaque `Id`. +//! - `TransitionsGroups/TransitionsGroup` (an `Id`), holding either one plain +//! `Transition` to a period (no clock change in that era) or +//! `RecurringDayTransition` / `RecurringDateTransition` elements to periods, +//! each with `TimeOffset` (the local wall time of the change, in the period +//! being left), `Month`, and `DayOfWeek` + `Occurrence` (1..4 from the +//! start of the month, -1..-4 from its end) or `Day`. +//! - `Transitions`: a plain `Transition` to the first group (or period), then +//! `AbsoluteDateTransition`s, each naming a group from its naive `DateTime` +//! on. +//! +//! [`ZoneDefinition`] keeps the text as Exchange sent it, so the cache holds +//! what was read; [`ZoneRules`] is the checked form the clock is computed +//! from, and every way a definition can fail to be one is a named +//! [`DefinitionError`]. + +use std::fmt; + +use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, Utc, Weekday}; +use serde::{Deserialize, Serialize}; + +/// A zone definition as Exchange sent it. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZoneDefinition { + #[serde(default)] + pub periods: Vec, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub transitions: Vec, +} + +impl ZoneDefinition { + /// Whether Exchange sent anything beyond the id. + pub fn is_empty(&self) -> bool { + self.periods.is_empty() && self.groups.is_empty() && self.transitions.is_empty() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeriodDef { + pub id: String, + pub bias: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GroupDef { + pub id: String, + #[serde(default)] + pub transitions: Vec, +} + +/// One transition element, its fields as text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransitionDef { + Plain { + to_kind: String, + to: String, + }, + RecurringDay { + to_kind: String, + to: String, + time_offset: String, + month: String, + day_of_week: String, + occurrence: String, + }, + RecurringDate { + to_kind: String, + to: String, + time_offset: String, + month: String, + day: String, + }, + Absolute { + to_kind: String, + to: String, + date_time: String, + }, +} + +/// Why a definition is not a clock Aperio can compute with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DefinitionError { + NoPeriods, + NoTransitions, + /// The first top-level transition is not a plain `Transition`. + FirstNotPlain, + /// A `Transition` after the first, at the top level. + PlainAfterFirst, + /// A recurring transition at the top level. + RecurringAtTop, + /// An absolute transition inside a group. + AbsoluteInGroup, + /// An `AbsoluteDateTransition` whose `DateTime` is not a naive date-time. + BadDateTime(String), + /// Absolute transitions not in strictly increasing order. + Unordered, + /// A `To` that names no period or group. + UnknownTarget(String), + /// A `To` whose `Kind` is neither `Period` nor `Group`, or a group target + /// inside a group. + BadTargetKind(String), + /// A group with no transitions, or mixing a plain transition with + /// recurring ones. + BadGroup(String), + BadBias(String), + BadTimeOffset(String), + BadMonth(String), + BadDayOfWeek(String), + BadOccurrence(String), + BadDay(String), +} + +impl fmt::Display for DefinitionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Machine tokens for the log, never shown to the user. + let (name, detail) = match self { + Self::NoPeriods => ("no-periods", None), + Self::NoTransitions => ("no-transitions", None), + Self::FirstNotPlain => ("first-not-plain", None), + Self::PlainAfterFirst => ("plain-after-first", None), + Self::RecurringAtTop => ("recurring-at-top", None), + Self::AbsoluteInGroup => ("absolute-in-group", None), + Self::BadDateTime(v) => ("bad-date-time", Some(v)), + Self::Unordered => ("unordered", None), + Self::UnknownTarget(v) => ("unknown-target", Some(v)), + Self::BadTargetKind(v) => ("bad-target-kind", Some(v)), + Self::BadGroup(v) => ("bad-group", Some(v)), + Self::BadBias(v) => ("bad-bias", Some(v)), + Self::BadTimeOffset(v) => ("bad-time-offset", Some(v)), + Self::BadMonth(v) => ("bad-month", Some(v)), + Self::BadDayOfWeek(v) => ("bad-day-of-week", Some(v)), + Self::BadOccurrence(v) => ("bad-occurrence", Some(v)), + Self::BadDay(v) => ("bad-day", Some(v)), + }; + match detail { + Some(v) => write!(f, "{name}:{v}"), + None => f.write_str(name), + } + } +} + +/// Walks one `StartTimeZone` / `EndTimeZone` element's children. The item +/// parsers hand it every event between the element's start and its end, as +/// they hand a `Recurrence` to the recurrence walker. +#[derive(Debug, Default)] +pub struct ZoneDefinitionWalker { + def: ZoneDefinition, + /// The id of a nested `TimeZoneDefinition`, for a server that wraps the + /// definition one level down. + nested_id: Option, + group: Option, + in_top_transitions: bool, + transition: Option, + text: Option<&'static str>, + depth: usize, +} + +#[derive(Debug, Default)] +struct TransitionBuilder { + kind: &'static str, + to_kind: String, + to: String, + time_offset: String, + month: String, + day_of_week: String, + occurrence: String, + day: String, + date_time: String, +} + +impl TransitionBuilder { + fn finish(self) -> Option { + let Self { + kind, + to_kind, + to, + time_offset, + month, + day_of_week, + occurrence, + day, + date_time, + } = self; + Some(match kind { + "transition" => TransitionDef::Plain { to_kind, to }, + "recurringdaytransition" => TransitionDef::RecurringDay { + to_kind, + to, + time_offset, + month, + day_of_week, + occurrence, + }, + "recurringdatetransition" => TransitionDef::RecurringDate { + to_kind, + to, + time_offset, + month, + day, + }, + "absolutedatetransition" => TransitionDef::Absolute { + to_kind, + to, + date_time, + }, + _ => return None, + }) + } +} + +fn attribute(e: &quick_xml::events::BytesStart<'_>, name: &[u8]) -> Option { + e.attributes() + .flatten() + .find(|a| a.key.local_name().as_ref().eq_ignore_ascii_case(name)) + .map(|a| String::from_utf8_lossy(&a.value).into_owned()) +} + +impl ZoneDefinitionWalker { + /// An element opened (`empty` for a self-closing one), its local name in + /// ASCII lower case. + pub fn observe_start(&mut self, local: &[u8], e: &quick_xml::events::BytesStart<'_>, empty: bool) { + if !empty { + self.depth += 1; + } + match local { + b"timezonedefinition" => self.nested_id = attribute(e, b"Id"), + b"period" => self.def.periods.push(PeriodDef { + id: attribute(e, b"Id").unwrap_or_default(), + bias: attribute(e, b"Bias").unwrap_or_default(), + }), + b"transitionsgroup" if !empty => { + self.group = Some(GroupDef { + id: attribute(e, b"Id").unwrap_or_default(), + transitions: Vec::new(), + }); + } + b"transitions" if !empty => self.in_top_transitions = true, + b"transition" | b"recurringdaytransition" | b"recurringdatetransition" + | b"absolutedatetransition" + if !empty => + { + let kind = match local { + b"transition" => "transition", + b"recurringdaytransition" => "recurringdaytransition", + b"recurringdatetransition" => "recurringdatetransition", + _ => "absolutedatetransition", + }; + self.transition = Some(TransitionBuilder { + kind, + ..Default::default() + }); + } + b"to" if self.transition.is_some() => { + if let Some(t) = self.transition.as_mut() { + t.to_kind = attribute(e, b"Kind").unwrap_or_default(); + } + self.text = (!empty).then_some("to"); + } + b"timeoffset" => self.text = Some("time_offset"), + b"month" => self.text = Some("month"), + b"dayofweek" => self.text = Some("day_of_week"), + b"occurrence" => self.text = Some("occurrence"), + b"day" => self.text = Some("day"), + b"datetime" => self.text = Some("date_time"), + _ => {} + } + if empty && local != b"to" { + // A self-closing text element carries no text. + if matches!( + local, + b"timeoffset" | b"month" | b"dayofweek" | b"occurrence" | b"day" | b"datetime" + ) { + self.text = None; + } + } + } + + pub fn observe_text(&mut self, text: &str) { + let (Some(target), Some(t)) = (self.text, self.transition.as_mut()) else { + return; + }; + let slot = match target { + "to" => &mut t.to, + "time_offset" => &mut t.time_offset, + "month" => &mut t.month, + "day_of_week" => &mut t.day_of_week, + "occurrence" => &mut t.occurrence, + "day" => &mut t.day, + "date_time" => &mut t.date_time, + _ => return, + }; + slot.push_str(text); + } + + /// An element closed. Returns `true` when it was the zone element itself, + /// so the caller takes the walker's result. + pub fn observe_end(&mut self, local: &[u8]) -> bool { + self.text = None; + if self.depth == 0 { + return true; + } + self.depth -= 1; + match local { + b"transition" | b"recurringdaytransition" | b"recurringdatetransition" + | b"absolutedatetransition" => { + if let Some(t) = self.transition.take().and_then(TransitionBuilder::finish) { + match self.group.as_mut() { + Some(group) => group.transitions.push(t), + None if self.in_top_transitions => self.def.transitions.push(t), + None => {} + } + } + } + b"transitionsgroup" => { + if let Some(group) = self.group.take() { + self.def.groups.push(group); + } + } + b"transitions" => self.in_top_transitions = false, + _ => {} + } + false + } + + /// The definition read, and the id a nested `TimeZoneDefinition` named. + pub fn finish(self) -> (Option, Option) { + let def = (!self.def.is_empty()).then_some(self.def); + (def, self.nested_id) + } +} + +/// When in the year a clock change happens. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum On { + /// The `nth` `weekday` of `month`; negative counts from the month's end. + NthWeekday { month: u32, weekday: Weekday, nth: i8 }, + Date { month: u32, day: u32 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Change { + on: On, + /// The local wall time of the change, in the period being left. + at: Duration, + /// The bias after the change, UTC = local + bias. + to: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Rule { + Fixed(Duration), + Yearly(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Era { + /// The naive local date-time this era starts at; `None` for the first. + from: Option, + rule: Rule, +} + +/// A checked definition: the clock it describes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ZoneRules { + eras: Vec, +} + +impl TryFrom<&ZoneDefinition> for ZoneRules { + type Error = DefinitionError; + + fn try_from(def: &ZoneDefinition) -> Result { + if def.periods.is_empty() { + return Err(DefinitionError::NoPeriods); + } + if def.transitions.is_empty() { + return Err(DefinitionError::NoTransitions); + } + let period_bias = |id: &str| -> Result { + let period = def + .periods + .iter() + .find(|p| p.id.trim() == id.trim()) + .ok_or_else(|| DefinitionError::UnknownTarget(id.trim().to_string()))?; + parse_duration(&period.bias).ok_or_else(|| DefinitionError::BadBias(period.bias.clone())) + }; + let group_rule = |id: &str| -> Result { + let group = def + .groups + .iter() + .find(|g| g.id.trim() == id.trim()) + .ok_or_else(|| DefinitionError::UnknownTarget(id.trim().to_string()))?; + match group.transitions.as_slice() { + [] => Err(DefinitionError::BadGroup(group.id.clone())), + [TransitionDef::Plain { to_kind, to }] => { + if !to_kind.trim().eq_ignore_ascii_case("Period") { + return Err(DefinitionError::BadTargetKind(to_kind.clone())); + } + Ok(Rule::Fixed(period_bias(to)?)) + } + transitions => { + let mut changes = Vec::with_capacity(transitions.len()); + for t in transitions { + changes.push(match t { + TransitionDef::Plain { .. } => { + return Err(DefinitionError::BadGroup(group.id.clone())) + } + TransitionDef::Absolute { .. } => { + return Err(DefinitionError::AbsoluteInGroup) + } + TransitionDef::RecurringDay { + to_kind, + to, + time_offset, + month, + day_of_week, + occurrence, + } => Change { + on: On::NthWeekday { + month: parse_month(month)?, + weekday: parse_weekday(day_of_week)?, + nth: parse_occurrence(occurrence)?, + }, + at: parse_time_offset(time_offset)?, + to: period_target(to_kind, to, &period_bias)?, + }, + TransitionDef::RecurringDate { + to_kind, + to, + time_offset, + month, + day, + } => { + let month_n = parse_month(month)?; + let day_n = day + .trim() + .parse::() + .ok() + .filter(|d| (1..=31).contains(d)) + .ok_or_else(|| DefinitionError::BadDay(day.clone()))?; + Change { + on: On::Date { + month: month_n, + day: day_n, + }, + at: parse_time_offset(time_offset)?, + to: period_target(to_kind, to, &period_bias)?, + } + } + }); + } + Ok(Rule::Yearly(changes)) + } + } + }; + let target_rule = |to_kind: &str, to: &str| -> Result { + match to_kind.trim() { + k if k.eq_ignore_ascii_case("Group") => group_rule(to), + k if k.eq_ignore_ascii_case("Period") => Ok(Rule::Fixed(period_bias(to)?)), + other => Err(DefinitionError::BadTargetKind(other.to_string())), + } + }; + + let mut eras = Vec::with_capacity(def.transitions.len()); + for (i, t) in def.transitions.iter().enumerate() { + match (i, t) { + (0, TransitionDef::Plain { to_kind, to }) => eras.push(Era { + from: None, + rule: target_rule(to_kind, to)?, + }), + (0, _) => return Err(DefinitionError::FirstNotPlain), + (_, TransitionDef::Plain { .. }) => return Err(DefinitionError::PlainAfterFirst), + ( + _, + TransitionDef::Absolute { + to_kind, + to, + date_time, + }, + ) => { + let from = parse_naive(date_time) + .ok_or_else(|| DefinitionError::BadDateTime(date_time.clone()))?; + if eras + .last() + .and_then(|e: &Era| e.from) + .is_some_and(|prev| from <= prev) + { + return Err(DefinitionError::Unordered); + } + eras.push(Era { + from: Some(from), + rule: target_rule(to_kind, to)?, + }); + } + (_, _) => return Err(DefinitionError::RecurringAtTop), + } + } + Ok(Self { eras }) + } +} + +fn period_target( + to_kind: &str, + to: &str, + period_bias: &impl Fn(&str) -> Result, +) -> Result { + if !to_kind.trim().eq_ignore_ascii_case("Period") { + return Err(DefinitionError::BadTargetKind(to_kind.to_string())); + } + period_bias(to) +} + +impl ZoneRules { + /// The era in force at a naive local (or, for a UTC instant, UTC) + /// date-time. Eras change at year starts in every published definition, + /// so the half-day between the two readings never decides one. + fn era_at(&self, at: NaiveDateTime) -> &Era { + self.eras + .iter() + .rev() + .find(|e| e.from.is_none_or(|from| from <= at)) + .unwrap_or(&self.eras[0]) + } + + /// One year's clock: the bias before the year's first change, then each + /// change as `(the UTC instant it happens, the bias after it)`, in order. + /// `None` when the changes do not follow each other. + fn year(changes: &[Change], year: i32) -> Option<(Duration, Vec<(NaiveDateTime, Duration)>)> { + let mut local: Vec<(NaiveDateTime, Duration)> = changes + .iter() + .map(|c| Some((change_day(c.on, year)?.and_hms_opt(0, 0, 0)? + c.at, c.to))) + .collect::>()?; + local.sort_by_key(|(when, _)| *when); + // Windows keeps one rule a year: the bias a year opens with is the + // one its last change leaves (Sydney is on daylight time on 1 Jan). + let opening = local.last()?.1; + let mut before = opening; + let mut utc = Vec::with_capacity(local.len()); + for (when, to) in local { + let instant = when + before; + if utc.last().is_some_and(|(prev, _)| instant <= *prev) { + return None; + } + utc.push((instant, to)); + before = to; + } + Some((opening, utc)) + } + + /// The bias in force at a UTC instant. + fn bias_at_utc(&self, utc: NaiveDateTime) -> Option { + match &self.era_at(utc).rule { + Rule::Fixed(bias) => Some(*bias), + Rule::Yearly(changes) => { + let (opening, steps) = Self::year(changes, utc.year())?; + Some( + steps + .iter() + .rev() + .find(|(at, _)| *at <= utc) + .map_or(opening, |(_, to)| *to), + ) + } + } + } + + /// The local wall time at a UTC instant. + pub fn wall(&self, when: DateTime) -> Option { + let utc = when.naive_utc(); + Some(utc - self.bias_at_utc(utc)?) + } + + /// The earliest UTC instant whose local wall time is `local`; `None` when + /// a clock change skips it. + pub fn instant_of(&self, local: NaiveDateTime) -> Option> { + let candidates: Vec = match &self.era_at(local).rule { + Rule::Fixed(bias) => vec![local + *bias], + Rule::Yearly(changes) => { + let (opening, steps) = Self::year(changes, local.year())?; + // Segment k runs from its change (or the year's start) to the + // next change; a reading `local + bias` counts when it falls + // inside the segment of that bias. + let mut out = Vec::new(); + let bounds: Vec<(Option, Duration)> = std::iter::once((None, opening)) + .chain(steps.iter().map(|(at, to)| (Some(*at), *to))) + .collect(); + for (k, (start, bias)) in bounds.iter().enumerate() { + let reading = local + *bias; + let after_start = start.is_none_or(|s| reading >= s); + let before_end = bounds.get(k + 1).is_none_or(|(next, _)| next.is_none_or(|n| reading < n)); + if after_start && before_end { + out.push(reading); + } + } + out + } + }; + candidates.into_iter().min().map(|utc| utc.and_utc()) + } + + /// The UTC instant of local midnight on `day`: the first one where the + /// clock reaches it twice, and the first hour after it where a clock + /// change skips it (as `all_day_anchor` places a skipped midnight). + pub fn midnight(&self, day: NaiveDate) -> Option> { + let midnight = day.and_hms_opt(0, 0, 0)?; + self.instant_of(midnight) + .or_else(|| self.instant_of(midnight + Duration::hours(1))) + } +} + +/// The day a change falls on in `year`. +fn change_day(on: On, year: i32) -> Option { + match on { + On::Date { month, day } => NaiveDate::from_ymd_opt(year, month, day), + On::NthWeekday { month, weekday, nth } if nth > 0 => { + NaiveDate::from_weekday_of_month_opt(year, month, weekday, nth as u8) + } + On::NthWeekday { month, weekday, nth } => { + // Counted from the month's end: -1 is its last such weekday. + let first_next = if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1)? + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1)? + }; + let last = first_next.pred_opt()?; + let back = (7 + last.weekday().num_days_from_monday() as i64 + - weekday.num_days_from_monday() as i64) + % 7; + let last_such = last - Duration::days(back); + Some(last_such - Duration::weeks((-nth - 1) as i64)) + } + } +} + +/// An `xs:duration` of days, hours, minutes and seconds (with a fraction): +/// `-PT1H`, `PT2H`, `-PT5H30M`, `PT23H59M59.999S`, `P1D`. Years and months +/// have no fixed length and are refused. +pub fn parse_duration(text: &str) -> Option { + let text = text.trim(); + let (negative, rest) = match text.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, text), + }; + let rest = rest.strip_prefix('P')?; + let (date_part, time_part) = match rest.split_once('T') { + Some((d, t)) => (d, Some(t)), + None => (rest, None), + }; + let mut millis: i64 = 0; + let mut any = false; + let mut take = |part: &str, units: &[(char, i64)], fraction_on: Option| -> Option<()> { + let mut number = String::new(); + let mut unit_at = 0usize; + for c in part.chars() { + if c.is_ascii_digit() || (Some(c) != None && c == '.' && fraction_on.is_some()) { + number.push(c); + continue; + } + let pos = units[unit_at..].iter().position(|(u, _)| *u == c)? + unit_at; + let (unit, scale) = units[pos]; + if number.is_empty() || (number.contains('.') && Some(unit) != fraction_on) { + return None; + } + let value: f64 = number.parse().ok()?; + millis = millis.checked_add((value * scale as f64).round() as i64)?; + any = true; + number.clear(); + unit_at = pos + 1; + } + number.is_empty().then_some(()) + }; + take(date_part, &[('D', 86_400_000)], None)?; + if let Some(time) = time_part { + if time.is_empty() { + return None; + } + take(time, &[('H', 3_600_000), ('M', 60_000), ('S', 1_000)], Some('S'))?; + } + if !any { + return None; + } + let d = Duration::milliseconds(millis); + Some(if negative { -d } else { d }) +} + +fn parse_time_offset(text: &str) -> Result { + parse_duration(text) + .filter(|d| *d >= Duration::zero() && *d < Duration::hours(24)) + .ok_or_else(|| DefinitionError::BadTimeOffset(text.to_string())) +} + +fn parse_month(text: &str) -> Result { + text.trim() + .parse::() + .ok() + .filter(|m| (1..=12).contains(m)) + .ok_or_else(|| DefinitionError::BadMonth(text.to_string())) +} + +fn parse_weekday(text: &str) -> Result { + match text.trim() { + "Sunday" => Ok(Weekday::Sun), + "Monday" => Ok(Weekday::Mon), + "Tuesday" => Ok(Weekday::Tue), + "Wednesday" => Ok(Weekday::Wed), + "Thursday" => Ok(Weekday::Thu), + "Friday" => Ok(Weekday::Fri), + "Saturday" => Ok(Weekday::Sat), + _ => Err(DefinitionError::BadDayOfWeek(text.to_string())), + } +} + +/// 1..4 from the month's start, -1..-4 from its end. A fifth weekday is the +/// month's last such weekday, as Windows reads it (decision 238). +fn parse_occurrence(text: &str) -> Result { + match text.trim().parse::() { + Ok(5) => Ok(-1), + Ok(n) if (1..=4).contains(&n) || (-4..=-1).contains(&n) => Ok(n), + _ => Err(DefinitionError::BadOccurrence(text.to_string())), + } +} + +/// A naive `DateTime` (`2007-01-01T00:00:00`, with or without a fraction); +/// a value with a zone is refused. +fn parse_naive(text: &str) -> Option { + let text = text.trim(); + if text.ends_with('Z') || text.get(10..).is_some_and(|t| t.contains('+') || t.contains('-')) { + return None; + } + NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f").ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn period(id: &str, bias: &str) -> PeriodDef { + PeriodDef { + id: id.into(), + bias: bias.into(), + } + } + + fn day_change(to: &str, offset: &str, month: &str, dow: &str, occ: &str) -> TransitionDef { + TransitionDef::RecurringDay { + to_kind: "Period".into(), + to: to.into(), + time_offset: offset.into(), + month: month.into(), + day_of_week: dow.into(), + occurrence: occ.into(), + } + } + + fn to_group(id: &str) -> TransitionDef { + TransitionDef::Plain { + to_kind: "Group".into(), + to: id.into(), + } + } + + /// W. Europe Standard Time as Exchange 2016's GetServerTimeZones gives it. + pub(crate) fn w_europe() -> ZoneDefinition { + let std = "trule:Microsoft/Registry/W. Europe Standard Time/1-Standard"; + let dst = "trule:Microsoft/Registry/W. Europe Standard Time/1-Daylight"; + ZoneDefinition { + periods: vec![period(std, "-PT1H"), period(dst, "-PT2H")], + groups: vec![GroupDef { + id: "0".into(), + transitions: vec![ + day_change(dst, "PT2H", "3", "Sunday", "-1"), + day_change(std, "PT3H", "10", "Sunday", "-1"), + ], + }], + transitions: vec![to_group("0")], + } + } + + fn utc(s: &str) -> DateTime { + s.parse().unwrap() + } + + fn day(s: &str) -> NaiveDate { + s.parse().unwrap() + } + + #[test] + fn w_europe_midnights_follow_the_clock_change() { + let rules = ZoneRules::try_from(&w_europe()).unwrap(); + assert_eq!(rules.midnight(day("2026-10-12")), Some(utc("2026-10-11T22:00:00Z"))); + assert_eq!(rules.midnight(day("2026-11-02")), Some(utc("2026-11-01T23:00:00Z"))); + // The days of the changes themselves. + assert_eq!(rules.midnight(day("2026-03-29")), Some(utc("2026-03-28T23:00:00Z"))); + assert_eq!(rules.midnight(day("2026-10-25")), Some(utc("2026-10-24T22:00:00Z"))); + assert_eq!(rules.midnight(day("2026-10-26")), Some(utc("2026-10-25T23:00:00Z"))); + } + + #[test] + fn the_order_of_a_groups_changes_does_not_matter() { + let mut def = w_europe(); + def.groups[0].transitions.reverse(); + let rules = ZoneRules::try_from(&def).unwrap(); + assert_eq!(rules.midnight(day("2026-10-12")), Some(utc("2026-10-11T22:00:00Z"))); + assert_eq!(rules.midnight(day("2026-11-02")), Some(utc("2026-11-01T23:00:00Z"))); + } + + #[test] + fn the_wall_clock_reads_back_the_midnight() { + let rules = ZoneRules::try_from(&w_europe()).unwrap(); + for d in ["2026-01-15", "2026-03-29", "2026-07-01", "2026-10-25", "2026-12-31"] { + let m = rules.midnight(day(d)).unwrap(); + assert_eq!(rules.wall(m), day(d).and_hms_opt(0, 0, 0), "{d}"); + } + } + + /// Sydney: daylight time opens the year, so the year's last change sets + /// the bias on 1 January. + #[test] + fn a_southern_zone_opens_the_year_on_daylight_time() { + let std = "aus/std"; + let dst = "aus/dst"; + let def = ZoneDefinition { + periods: vec![period(std, "-PT10H"), period(dst, "-PT11H")], + groups: vec![GroupDef { + id: "0".into(), + transitions: vec![ + day_change(std, "PT3H", "4", "Sunday", "1"), + day_change(dst, "PT2H", "10", "Sunday", "1"), + ], + }], + transitions: vec![to_group("0")], + }; + let rules = ZoneRules::try_from(&def).unwrap(); + assert_eq!(rules.midnight(day("2026-01-01")), Some(utc("2025-12-31T13:00:00Z"))); + assert_eq!(rules.midnight(day("2026-06-01")), Some(utc("2026-05-31T14:00:00Z"))); + } + + /// Chile changes at midnight: 23:59:59.999 on the Saturday, so Sunday's + /// midnight is skipped and the day starts at 01:00. + #[test] + fn a_skipped_midnight_starts_the_day_an_hour_later() { + let std = "chile/std"; + let dst = "chile/dst"; + let def = ZoneDefinition { + periods: vec![period(std, "PT4H"), period(dst, "PT3H")], + groups: vec![GroupDef { + id: "0".into(), + transitions: vec![ + day_change(std, "PT23H59M59.999S", "4", "Saturday", "1"), + day_change(dst, "PT23H59M59.999S", "9", "Saturday", "1"), + ], + }], + transitions: vec![to_group("0")], + }; + let rules = ZoneRules::try_from(&def).unwrap(); + assert_eq!(rules.midnight(day("2026-09-06")), Some(utc("2026-09-06T04:00:00Z"))); + } + + #[test] + fn a_fixed_zone_has_one_bias_all_year() { + let def = ZoneDefinition { + periods: vec![period("india", "-PT5H30M")], + groups: vec![GroupDef { + id: "0".into(), + transitions: vec![TransitionDef::Plain { + to_kind: "Period".into(), + to: "india".into(), + }], + }], + transitions: vec![to_group("0")], + }; + let rules = ZoneRules::try_from(&def).unwrap(); + assert_eq!(rules.midnight(day("2026-10-12")), Some(utc("2026-10-11T18:30:00Z"))); + let kiribati = ZoneDefinition { + periods: vec![period("line", "-PT14H")], + groups: vec![], + transitions: vec![TransitionDef::Plain { + to_kind: "Period".into(), + to: "line".into(), + }], + }; + let rules = ZoneRules::try_from(&kiribati).unwrap(); + assert_eq!(rules.midnight(day("2026-10-12")), Some(utc("2026-10-11T10:00:00Z"))); + } + + /// Microsoft's Eastern Standard Time sample: one rule until 2006, another + /// from 2007. + #[test] + fn an_absolute_transition_switches_the_rule() { + let p = |n: &str, b: &str| period(&format!("est/{n}"), b); + let def = ZoneDefinition { + periods: vec![ + p("2006-Standard", "PT5H"), + p("2006-Daylight", "PT4H"), + p("2007-Standard", "PT5H"), + p("2007-Daylight", "PT4H"), + ], + groups: vec![ + GroupDef { + id: "0".into(), + transitions: vec![ + day_change("est/2006-Daylight", "PT2H", "4", "Sunday", "1"), + day_change("est/2006-Standard", "PT2H", "10", "Sunday", "-1"), + ], + }, + GroupDef { + id: "1".into(), + transitions: vec![ + day_change("est/2007-Daylight", "PT2H", "3", "Sunday", "2"), + day_change("est/2007-Standard", "PT2H", "11", "Sunday", "1"), + ], + }, + ], + transitions: vec![ + to_group("0"), + TransitionDef::Absolute { + to_kind: "Group".into(), + to: "1".into(), + date_time: "2007-01-01T00:00:00".into(), + }, + ], + }; + let rules = ZoneRules::try_from(&def).unwrap(); + // 20 March 2006: before the April change, standard time. + assert_eq!(rules.midnight(day("2006-03-20")), Some(utc("2006-03-20T05:00:00Z"))); + // 20 March 2007: after the second-Sunday change, daylight time. + assert_eq!(rules.midnight(day("2007-03-20")), Some(utc("2007-03-20T04:00:00Z"))); + // 2 November 2006 (after the last Sunday of October): standard. + assert_eq!(rules.midnight(day("2006-11-02")), Some(utc("2006-11-02T05:00:00Z"))); + // 2 November 2007 (before the first Sunday of November): daylight. + assert_eq!(rules.midnight(day("2007-11-02")), Some(utc("2007-11-02T04:00:00Z"))); + } + + #[test] + fn a_fifth_weekday_reads_as_the_last() { + let mut def = w_europe(); + if let TransitionDef::RecurringDay { occurrence, .. } = &mut def.groups[0].transitions[1] { + *occurrence = "5".into(); + } + let rules = ZoneRules::try_from(&def).unwrap(); + assert_eq!(rules.midnight(day("2026-11-02")), Some(utc("2026-11-01T23:00:00Z"))); + } + + #[test] + fn a_broken_definition_names_what_is_wrong() { + let mut dangling = w_europe(); + dangling.transitions = vec![to_group("7")]; + assert_eq!( + ZoneRules::try_from(&dangling), + Err(DefinitionError::UnknownTarget("7".into())) + ); + + let mut zero = w_europe(); + if let TransitionDef::RecurringDay { occurrence, .. } = &mut zero.groups[0].transitions[0] { + *occurrence = "0".into(); + } + assert_eq!( + ZoneRules::try_from(&zero), + Err(DefinitionError::BadOccurrence("0".into())) + ); + + let mut weekday = w_europe(); + if let TransitionDef::RecurringDay { day_of_week, .. } = &mut weekday.groups[0].transitions[0] { + *day_of_week = "Weekday".into(); + } + assert_eq!( + ZoneRules::try_from(&weekday), + Err(DefinitionError::BadDayOfWeek("Weekday".into())) + ); + + let mut no_periods = w_europe(); + no_periods.periods.clear(); + assert_eq!(ZoneRules::try_from(&no_periods), Err(DefinitionError::NoPeriods)); + + let mut zoned = w_europe(); + zoned.transitions.push(TransitionDef::Absolute { + to_kind: "Group".into(), + to: "0".into(), + date_time: "2007-01-01T00:00:00Z".into(), + }); + assert_eq!( + ZoneRules::try_from(&zoned), + Err(DefinitionError::BadDateTime("2007-01-01T00:00:00Z".into())) + ); + } + + #[test] + fn durations_read_every_spelling() { + assert_eq!(parse_duration("-PT1H"), Some(Duration::hours(-1))); + assert_eq!(parse_duration("-PT60M"), Some(Duration::hours(-1))); + assert_eq!(parse_duration("PT5H"), Some(Duration::hours(5))); + assert_eq!(parse_duration("-PT5H30M"), Some(-Duration::minutes(330))); + assert_eq!( + parse_duration("PT23H59M59.999S"), + Some(Duration::milliseconds(86_399_999)) + ); + assert_eq!(parse_duration("P1D"), Some(Duration::days(1))); + assert_eq!(parse_duration("PT0S"), Some(Duration::zero())); + for bad in ["", "P", "PT", "1H", "P1Y", "P1M", "PT1.5H", "PTH", "PT1H2X"] { + assert_eq!(parse_duration(bad), None, "{bad}"); + } + } +} From 83c63b28caff14cc054d12b27e58b2be03d0955d Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 02:54:26 +0200 Subject: [PATCH 02/18] All-day items read their day in each boundary's own zone (233, 236, 239) DayZone names the clock an all-day boundary is a midnight in: a zone the CLDR table maps, an id that is tzdata's own name (Europe/Berlin, 236), or a zone Exchange defines in full (232). The end reads in the end zone (233). all_day_anchor_in takes the device clock, so tests pin one. ITEM_PARSER 5 drains every folder once to cache the definitions; READ_RULE 4 re-emits what was read before. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/api.rs | 18 +- crates/adapter-ews/src/mapping.rs | 289 ++++++++++++++++++---- crates/adapter-ews/src/windows_tz.rs | 5 +- crates/adapter-ews/src/zone_definition.rs | 2 +- 4 files changed, 258 insertions(+), 56 deletions(-) diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 09349246..70ffd41e 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -212,7 +212,10 @@ pub struct SyncedFolderState { /// (`inherited:{occurrence key}:{series key}`, decision 106), so the host /// can never take it for the occurrence's own copy. Rows handed out by /// parser 3 carry the occurrence's bare key for both. -pub const ITEM_PARSER: u32 = 4; +/// 5: items carry their zones' full definitions (`start_zone_definition`, +/// `end_zone_definition`, decision 232), which the all-day read uses for a +/// zone the CLDR table does not know (decision 239). +pub const ITEM_PARSER: u32 = 5; /// How many changes to ask for per `SyncFolderItems` request. /// Exchange Online caps at 512 per call; smaller is fine but means @@ -1100,7 +1103,7 @@ pub async fn delete_series_occurrence( let mut best: Option<(u32, i64)> = None; for index in candidate_indices(candidate) { if let Some(s) = occurrence_start(client, master_id, change_key, index).await? { - let slot = match day_zone { + let slot = match &day_zone { Some(zone) => crate::mapping::all_day_anchor(s, Some(zone)), None => s, }; @@ -2652,9 +2655,12 @@ mod tests { // The row as the read gives it: the local midnights of 15 and 16 January. let raw: chrono::DateTime = "2027-01-14T11:00:00Z".parse().unwrap(); - let start = crate::mapping::all_day_anchor(raw, Some(auckland)); + let start = crate::mapping::all_day_anchor(raw, Some(&crate::mapping::DayZone::Tz(auckland))); let end = - crate::mapping::all_day_anchor("2027-01-15T11:00:00Z".parse().unwrap(), Some(auckland)); + crate::mapping::all_day_anchor( + "2027-01-15T11:00:00Z".parse().unwrap(), + Some(&crate::mapping::DayZone::Tz(auckland)), + ); let edit = Event { keep_attendees: false, keep_fields: Vec::new(), @@ -3771,7 +3777,7 @@ mod tests { // The day as this device names it: its local midnight of July 20. let day = crate::mapping::all_day_anchor( "2026-07-20T10:00:00Z".parse().unwrap(), - Some(chrono_tz::Pacific::Honolulu), + Some(&crate::mapping::DayZone::Tz(chrono_tz::Pacific::Honolulu)), ); delete_series_occurrence(&client_for(&server), "MASTER", Some("CK"), day, false) .await @@ -3876,7 +3882,7 @@ mod tests { .await; // The day as this device names it: its local midnight of 15 January. - let day = crate::mapping::all_day_anchor(midnight(229), Some(auckland)); + let day = crate::mapping::all_day_anchor(midnight(229), Some(&crate::mapping::DayZone::Tz(auckland))); delete_series_occurrence(&client_for(&server), "MASTER", Some("CK"), day, false) .await .unwrap(); diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 10743c2a..5a0bc925 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -1801,15 +1801,15 @@ pub fn to_event(item: ParsedItem, calendar_id: &str) -> EwsResult { // All-day boundaries re-anchor at LOCAL midnight of their calendar day, // read in the item's own zone (the app-internal convention; see // all_day_anchor). - let day_zone = if item.is_all_day { - all_day_zone(&item) + let (day_zone, end_zone) = if item.is_all_day { + (all_day_zone(&item), all_day_end_zone(&item)) } else { - None + (None, None) }; let (start, end) = if item.is_all_day { ( - all_day_anchor(start, day_zone), - all_day_anchor(end, day_zone), + all_day_anchor(start, day_zone.as_ref()), + all_day_anchor(end, end_zone.as_ref()), ) } else { (start, end) @@ -1873,7 +1873,7 @@ pub fn to_event(item: ParsedItem, calendar_id: &str) -> EwsResult { // Anchor the exceptions the same way so they line up with the grid. let anchor = |dt: DateTime| { if item.is_all_day { - all_day_anchor(dt, day_zone) + all_day_anchor(dt, day_zone.as_ref()) } else { dt } @@ -2527,9 +2527,8 @@ pub fn override_event( // boundaries always agree — the master's flag decided this before, and a // series can hold an occurrence that is not all-day. if row.all_day { - let zone = all_day_zone(master_item); - row.start = all_day_anchor(ov.start, zone); - row.end = all_day_anchor(ov.end, zone); + row.start = all_day_anchor(ov.start, all_day_zone(master_item).as_ref()); + row.end = all_day_anchor(ov.end, all_day_end_zone(master_item).as_ref()); } else { row.start = ov.start; row.end = ov.end; @@ -2567,9 +2566,8 @@ fn inherited_override_event( row.id = encode_override_event_id(&master_ev.id, override_slot(master_item, ov)); row.recurrence = None; if master_item.is_all_day { - let zone = all_day_zone(master_item); - row.start = all_day_anchor(ov.start, zone); - row.end = all_day_anchor(ov.end, zone); + row.start = all_day_anchor(ov.start, all_day_zone(master_item).as_ref()); + row.end = all_day_anchor(ov.end, all_day_end_zone(master_item).as_ref()); } else { row.start = ov.start; row.end = ov.end; @@ -2601,7 +2599,7 @@ fn inherited_override_event( /// reminders honoured single changes too (decision 215). pub(crate) fn override_slot(master_item: &ParsedItem, ov: &ModifiedOccurrence) -> DateTime { if master_item.is_all_day { - all_day_anchor(ov.original_start, all_day_zone(master_item)) + all_day_anchor(ov.original_start, all_day_zone(master_item).as_ref()) } else { ov.original_start } @@ -2621,53 +2619,148 @@ pub(crate) fn names_override( override_slot(master_item, ov) == slot || ov.original_start == slot } -/// The zone an all-day item's instants are midnights in, as Exchange names -/// it: its start zone, read the way its series' zone is read -/// ([`crate::windows_tz::read_series_zone`]), and UTC for one made without a -/// zone. `None` where Exchange names none Aperio can read. -pub(crate) fn all_day_zone(item: &ParsedItem) -> Option { - match crate::windows_tz::read_series_zone( +/// The zone an all-day boundary's instants are midnights in, as Exchange +/// names it: a zone the CLDR table maps, or one whose id is tzdata's own name +/// (`Europe/Berlin`, decision 236), or one Exchange defines in full (a +/// custom zone such as `Customized Time Zone`, decision 232). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DayZone { + Tz(chrono_tz::Tz), + Rules(std::sync::Arc), +} + +impl DayZone { + /// The wall time at an instant; `None` where a definition cannot say. + pub(crate) fn wall(&self, when: DateTime) -> Option { + match self { + Self::Tz(tz) => Some(when.with_timezone(tz).naive_local()), + Self::Rules(rules) => rules.wall(when), + } + } + + /// The instant of midnight on `day`: the first one where the clock reaches + /// it twice, and the first hour after it where a change skips it. + pub(crate) fn midnight(&self, day: chrono::NaiveDate) -> Option> { + match self { + Self::Tz(tz) => local_midnight_in(day, tz), + Self::Rules(rules) => rules.midnight(day), + } + } +} + +/// Midnight on `day` in a chrono zone, the first hour after it where a +/// change skips it. +pub(crate) fn local_midnight_in(day: chrono::NaiveDate, zone: &Z) -> Option> { + let midnight = day.and_hms_opt(0, 0, 0)?; + zone.from_local_datetime(&midnight) + .earliest() + .or_else(|| { + zone.from_local_datetime(&(midnight + chrono::Duration::hours(1))) + .earliest() + }) + .map(|l| l.with_timezone(&Utc)) +} + +/// One boundary's zone from its id and definition: the CLDR table first (as +/// the series' zone is read, [`crate::windows_tz::read_windows_zone`]), then +/// an id that is tzdata's own name, then the definition. `None` where none of +/// them gives a clock. +fn boundary_zone( + id: Option<&str>, + definition: Option<&crate::zone_definition::ZoneDefinition>, +) -> Option { + use crate::windows_tz::{read_windows_zone, WindowsZoneRead}; + match id.map(read_windows_zone) { + Some(WindowsZoneRead::Zone(zone)) => return zone.parse().ok().map(DayZone::Tz), + Some(WindowsZoneRead::Utc) => return Some(DayZone::Tz(chrono_tz::UTC)), + Some(WindowsZoneRead::Unknown) | None => {} + } + if let Some(tz) = id.and_then(|id| id.trim().parse::().ok()) { + return Some(DayZone::Tz(tz)); + } + let definition = definition?; + match crate::zone_definition::ZoneRules::try_from(definition) { + Ok(rules) => Some(DayZone::Rules(std::sync::Arc::new(rules))), + Err(error) => { + tracing::warn!( + target: "adapter_ews::zones", + id = id.unwrap_or(""), + %error, + "an Exchange zone definition cannot be read", + ); + None + } + } +} + +/// Whether Exchange marks an item as made without a zone: the end zone +/// `tzone://Microsoft/Utc`, whatever the start zone says (decision 43b). +fn made_without_zone(item: &ParsedItem) -> bool { + item.end_time_zone + .as_deref() + .is_some_and(|end| end.trim().eq_ignore_ascii_case("tzone://Microsoft/Utc")) +} + +/// The zone an all-day item's START (and its slots) are midnights in: +/// [`boundary_zone`] of its start zone, and UTC for one made without a zone. +/// `None` where Exchange names none Aperio can read. +pub(crate) fn all_day_zone(item: &ParsedItem) -> Option { + if made_without_zone(item) { + return Some(DayZone::Tz(chrono_tz::UTC)); + } + boundary_zone( item.start_time_zone.as_deref(), - item.end_time_zone.as_deref(), - ) { - Some(crate::windows_tz::WindowsZoneRead::Zone(zone)) => zone.parse().ok(), - Some(crate::windows_tz::WindowsZoneRead::Utc) => Some(chrono_tz::UTC), - Some(crate::windows_tz::WindowsZoneRead::Unknown) | None => None, + item.start_zone_definition.as_ref(), + ) +} + +/// The zone an all-day item's END is a midnight in: its own end zone where +/// Exchange names one (an item can carry two, and Exchange rounds each +/// boundary in its own, decision 233), else the start's. +pub(crate) fn all_day_end_zone(item: &ParsedItem) -> Option { + if made_without_zone(item) { + return Some(DayZone::Tz(chrono_tz::UTC)); + } + if item.end_time_zone.is_none() && item.end_zone_definition.is_none() { + return all_day_zone(item); } + boundary_zone( + item.end_time_zone.as_deref(), + item.end_zone_definition.as_ref(), + ) } /// The local midnight of the day an all-day instant names. /// /// EWS hands back a plain instant that is midnight of the intended day in /// the item's zone (the mailbox's, or UTC for boundaries we wrote ourselves) -/// — mostly: after Aperio's own write of an Outlook item, which sends UTC -/// midnights without a zone, Exchange rounds in the item's old zone and -/// labels it UTC (live round 3). So the day is sampled 13:45 INTO it, in the -/// zone Exchange names ([`all_day_zone`], decision 217): exact for a midnight -/// that zone names, and the intended day for a label off by any offset in +/// — mostly: after a write by Aperio before PR 8a, which sent UTC midnights +/// without a zone, Exchange rounded in the item's old zone and labelled it +/// UTC (live round 3). So the day is sampled 13:45 INTO it, in the zone +/// Exchange names ([`all_day_zone`], decision 217): exact for a midnight that +/// zone names, and the intended day for a label off by any offset in /// (−10:15, +13:45] — New Zealand's summer, the Chatham Islands, Tonga and /// Samoa among them, which twelve hours read as the day before; the same /// window `cal_core`'s anchor repair reads days in. Where Exchange names no /// zone Aperio can read, the sample is taken in UTC, with the same window. /// DST edge: a device zone that skips midnight that day gets the first hour /// after it, where the views place the day too. -pub(crate) fn all_day_anchor(when: DateTime, zone: Option) -> DateTime { +pub(crate) fn all_day_anchor(when: DateTime, zone: Option<&DayZone>) -> DateTime { + all_day_anchor_in(when, zone, &Local) +} + +/// [`all_day_anchor`] on a given device clock, so tests can pin one. +pub(crate) fn all_day_anchor_in( + when: DateTime, + zone: Option<&DayZone>, + device: &D, +) -> DateTime { let into_the_day = chrono::Duration::minutes(13 * 60 + 45); - let day = match zone { - Some(tz) => (when.with_timezone(&tz) + into_the_day).date_naive(), - None => (when + into_the_day).date_naive(), - }; - let midnight = day.and_hms_opt(0, 0, 0).unwrap(); - Local - .from_local_datetime(&midnight) - .earliest() - .or_else(|| { - Local - .from_local_datetime(&(midnight + chrono::Duration::hours(1))) - .earliest() - }) - .map(|l| l.with_timezone(&Utc)) - .unwrap_or(when) + let wall = zone + .and_then(|zone| zone.wall(when)) + .unwrap_or_else(|| when.naive_utc()); + let day = (wall + into_the_day).date(); + local_midnight_in(day, device).unwrap_or(when) } fn push_set_string(out: &mut String, field_uri: &str, tag: &str, value: &str) { @@ -7972,6 +8065,106 @@ mod tests { /// its January slots at 11:00 UTC the day before; the 12-hour sample read /// them as that day before. Its start, its exceptions and the slot in its /// single changes' ids now name the day itself. + fn all_day_item(start_zone: Option<&str>, end_zone: Option<&str>) -> ParsedItem { + ParsedItem { + is_all_day: true, + start_time_zone: start_zone.map(str::to_string), + end_time_zone: end_zone.map(str::to_string), + ..Default::default() + } + } + + fn utc(s: &str) -> DateTime { + s.parse().unwrap() + } + + /// An id that is tzdata's own name (18 of Toni's items carry + /// `Europe/Berlin`) reads as that zone (decision 236). + #[test] + fn an_all_day_item_in_a_zone_named_by_tzdata_reads_its_day() { + let item = all_day_item(Some("Europe/Berlin"), Some("Europe/Berlin")); + let zone = all_day_zone(&item); + assert_eq!(zone, Some(DayZone::Tz(chrono_tz::Europe::Berlin))); + // Berlin's midnight of 12 October reads as that day on any device. + for device in [chrono_tz::Europe::Berlin, chrono_tz::Asia::Tokyo, chrono_tz::Pacific::Honolulu] { + let anchored = all_day_anchor_in(utc("2026-10-11T22:00:00Z"), zone.as_ref(), &device); + assert_eq!( + anchored, + local_midnight_in("2026-10-12".parse().unwrap(), &device).unwrap(), + "{device}" + ); + } + } + + /// A zone Exchange defines in full reads by its definition (decisions 232 + /// and 239): a custom +14 zone's midnight, which a sample in UTC reads as + /// the day before. + #[test] + fn an_all_day_item_in_a_custom_zone_reads_its_day_by_the_definition() { + use crate::zone_definition::{PeriodDef, TransitionDef, ZoneDefinition}; + let mut item = all_day_item(Some("Customized Time Zone"), None); + item.start_zone_definition = Some(ZoneDefinition { + periods: vec![PeriodDef { + id: "line".into(), + bias: "-PT14H".into(), + }], + groups: vec![], + transitions: vec![TransitionDef::Plain { + to_kind: "Period".into(), + to: "line".into(), + }], + }); + let zone = all_day_zone(&item); + assert!(matches!(zone, Some(DayZone::Rules(_)))); + // The end has no zone of its own and reads in the start's. + assert_eq!(all_day_end_zone(&item), zone); + let device = chrono_tz::Europe::Berlin; + let day = |d: &str| local_midnight_in(d.parse().unwrap(), &device).unwrap(); + // Midnight of 12 October at +14 is 10:00Z on the 11th. + assert_eq!( + all_day_anchor_in(utc("2026-10-11T10:00:00Z"), zone.as_ref(), &device), + day("2026-10-12") + ); + // Without the definition the sample in UTC names the 11th. + assert_eq!( + all_day_anchor_in(utc("2026-10-11T10:00:00Z"), None, &device), + day("2026-10-11") + ); + // W. Europe's rules as a custom definition read Berlin's midnights. + item.start_zone_definition = Some(crate::zone_definition::tests::w_europe()); + let zone = all_day_zone(&item); + assert_eq!( + all_day_anchor_in(utc("2026-10-24T22:00:00Z"), zone.as_ref(), &device), + day("2026-10-25") + ); + // An id-only custom zone has no clock. + item.start_zone_definition = None; + assert_eq!(all_day_zone(&item), None); + } + + /// Each boundary reads in its own zone (decision 233): an end Exchange + /// rounded in a zone far from the start's names its own day. + #[test] + fn an_all_day_end_reads_in_the_end_zone() { + let item = all_day_item(Some("Tokyo Standard Time"), Some("Hawaiian Standard Time")); + assert_eq!(all_day_zone(&item), Some(DayZone::Tz(chrono_tz::Asia::Tokyo))); + assert_eq!(all_day_end_zone(&item), Some(DayZone::Tz(chrono_tz::Pacific::Honolulu))); + let device = chrono_tz::Europe::Berlin; + // Honolulu's midnight of 20 October: read in Tokyo it is the 21st. + let end = utc("2026-10-20T10:00:00Z"); + assert_eq!( + all_day_anchor_in(end, all_day_end_zone(&item).as_ref(), &device), + local_midnight_in("2026-10-20".parse().unwrap(), &device).unwrap() + ); + assert_eq!( + all_day_anchor_in(end, all_day_zone(&item).as_ref(), &device), + local_midnight_in("2026-10-21".parse().unwrap(), &device).unwrap() + ); + // An item made without a zone reads both boundaries in UTC. + let made_without = all_day_item(Some("Greenwich Standard Time"), Some("tzone://Microsoft/Utc")); + assert_eq!(all_day_end_zone(&made_without), Some(DayZone::Tz(chrono_tz::UTC))); + } + #[test] fn an_all_day_series_is_read_in_its_own_zone() { let auckland = chrono_tz::Pacific::Auckland; @@ -8008,8 +8201,8 @@ mod tests { own: None, }; item.modified_occurrences = vec![ov.clone()]; - assert_eq!(all_day_zone(&item), Some(auckland)); - assert_eq!(all_day_anchor(slot, Some(auckland)), local_midnight); + assert_eq!(all_day_zone(&item), Some(DayZone::Tz(auckland))); + assert_eq!(all_day_anchor(slot, Some(&DayZone::Tz(auckland))), local_midnight); let master = to_event(item.clone(), "cal").unwrap(); assert_eq!( master.recurrence.as_ref().unwrap().exceptions, @@ -8021,7 +8214,7 @@ mod tests { // A series made without a zone reads its UTC midnights in UTC. item.start_time_zone = Some("Greenwich Standard Time".into()); item.end_time_zone = Some("tzone://Microsoft/Utc".into()); - assert_eq!(all_day_zone(&item), Some(chrono_tz::UTC)); + assert_eq!(all_day_zone(&item), Some(DayZone::Tz(chrono_tz::UTC))); // No zone Aperio can read: the day is sampled, as before. item.start_time_zone = None; item.end_time_zone = None; diff --git a/crates/adapter-ews/src/windows_tz.rs b/crates/adapter-ews/src/windows_tz.rs index dadeaaae..1353ae66 100644 --- a/crates/adapter-ews/src/windows_tz.rs +++ b/crates/adapter-ews/src/windows_tz.rs @@ -66,7 +66,10 @@ use windows_zones::{OTHER_CLOCK, TABLE_ID, UNMAPPED, WINDOWS_ZONES, ZONE_WINDOWS /// 3: a series' `EndDate` reads as its date whatever zone Exchange appends /// ("2026-11-05Z", "2026-11-05-05:00"). Before, such a rule was unreadable, /// and the series showed only at its master's start. -pub const READ_RULE: u32 = 3; +/// 4: an all-day boundary reads in its own zone (the end in the end zone, +/// decision 233), in a zone whose id is tzdata's own name (236), and in a +/// zone Exchange defines in full (232, 239). +pub const READ_RULE: u32 = 4; /// The end zone Exchange stores for a series created without a zone. const NO_ZONE_END: &str = "tzone://Microsoft/Utc"; diff --git a/crates/adapter-ews/src/zone_definition.rs b/crates/adapter-ews/src/zone_definition.rs index ca885c59..23be3cbe 100644 --- a/crates/adapter-ews/src/zone_definition.rs +++ b/crates/adapter-ews/src/zone_definition.rs @@ -745,7 +745,7 @@ fn parse_naive(text: &str) -> Option { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; fn period(id: &str, bias: &str) -> PeriodDef { From 653e37dfa4b91e2004e1cb14538dd33e7b0b17d2 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 04:05:46 +0200 Subject: [PATCH 03/18] Exchange writer: a series starts on its first day, an all-day day on the stored zone's midnight (date bug, 47a, 233-235, 237) rrule_to_ews_recurrence takes the series' first day instead of an instant: the device's day for an all-day series, the written zone's day on a create, the stored start zone's day on an update (234), UTC where none is known. Its StartDate, default weekday, day and month come from that day; read off the UTC date they named the day before east of UTC (R3-6). An update writes an all-day day as midnight in the zone Exchange stores each boundary in (47a, 233). read_before returns those zones with the copy; an exception takes its series' ids and definitions. With a zone Aperio cannot read the write is refused as day-zone-unreadable (237), carried as Forbidden; without the copy, the read's own error stands. The builders take the device clock, so tests pin one. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/api.rs | 134 ++++++-- crates/adapter-ews/src/lib.rs | 54 ++- crates/adapter-ews/src/mapping.rs | 380 +++++++++++++++++----- crates/adapter-ews/src/tasks.rs | 2 +- crates/adapter-ews/src/zone_definition.rs | 168 ++++++++-- crates/cal-core/src/write_refusal.rs | 8 + 6 files changed, 594 insertions(+), 152 deletions(-) diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 70ffd41e..3132187c 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -23,11 +23,11 @@ use reqwest::header::{HeaderValue, CONTENT_TYPE}; use crate::auth::{basic_auth_header, BasicCredentials}; use crate::error::{EwsError, EwsResult}; use crate::mapping::{ - decode_event_id, encode_event_id, event_to_update_field_xml_on, - new_event_to_calendar_item_xml_on, parse_find_folder_response, parse_find_item_response, - parse_first_item_id, parse_get_user_availability, parse_server_time_zones, - parse_sync_folder_items_counts, parse_sync_folder_items_response, split_calendar_id, - to_calendar, to_event, DecodedEventId, EventIdKind, ParsedItem, SyncChange, + decode_event_id, encode_event_id, new_event_to_calendar_item_xml_on, + parse_find_folder_response, parse_find_item_response, parse_first_item_id, + parse_get_user_availability, parse_server_time_zones, parse_sync_folder_items_counts, + parse_sync_folder_items_response, split_calendar_id, to_calendar, to_event, DecodedEventId, + EventIdKind, ParsedItem, SyncChange, }; use crate::soap::{ check_for_fault, create_calendar_item, delete_calendar_item, delete_occurrence_item, @@ -764,7 +764,8 @@ pub async fn update_event( // item are not facts about the occurrence, and a retry or a new sign-in // may well work. Only an answer that arrives WITHOUT the occurrence's copy // is the refusal. - let before = match read_before(client, &target, &event.calendar_id).await { + let mut read_error = None; + let copy = match read_before(client, &target, &event.calendar_id).await { Ok(None) if target.kind == EventIdKind::Exception => { tracing::warn!( target: "adapter_ews::write", @@ -795,11 +796,34 @@ pub async fn update_event( event_id = %event.id, "the current copy could not be read; writing every field not kept, and a kept rule with a moved slot", ); + read_error = Some(err); None } }; - let (set_xml, delete_xml) = - event_to_update_field_xml_on(event, before.as_ref(), server_zones, target.kind)?; + let (before, stored) = match copy { + Some(copy) => (Some(copy.event), copy.zones), + None => (None, crate::mapping::StoredZones::default()), + }; + // Without the copy the zones the item is stored in are unknown, and an + // all-day day is never written blind (decision 237): the user is told what + // kept the copy away — the read's own failure, or an item the server no + // longer has — rather than that its zone cannot be read. + let (set_xml, delete_xml) = match crate::mapping::event_to_update_field_xml_in( + event, + before.as_ref(), + &stored, + server_zones, + target.kind, + &chrono::Local, + ) { + Err(EwsError::Protocol(message)) if before.is_none() && day_zone_unreadable(&message) => { + return Err(read_error.unwrap_or_else(|| EwsError::Soap { + code: "ErrorItemNotFound".into(), + message: "the server answered without the item".into(), + })); + } + other => other?, + }; // Nothing to write. Asked of the BUILT XML, not of the diff: the diff can // report attendees changed while `keep_attendees` suppresses that block, // and an `UpdateItem` with an empty `` is a fault. This sits @@ -1214,7 +1238,12 @@ async fn resolve_override_target( kind: EventIdKind::Exception, item_id: ov.item_id.clone(), change_key: ov.change_key.clone(), - series_zone: Some((master.start_time_zone.clone(), master.end_time_zone.clone())), + series_zone: Some(SeriesZone { + start_id: master.start_time_zone.clone(), + end_id: master.end_time_zone.clone(), + start_definition: master.start_zone_definition.clone(), + end_definition: master.end_zone_definition.clone(), + }), }), // Refused, not widened. The occurrence is gone from the series — someone // deleted it, or the series was rewritten — and the only other thing @@ -1245,11 +1274,15 @@ async fn resolve_override_target( /// `Ok(None)` when the server answered without the item; a transport or parse /// failure is `Err`, and the caller decides what that means for the kind of /// item it is about to write. +/// +/// The copy comes with the zones Exchange stores its boundaries in: an all-day +/// day is written as midnight in them (decision 47a), and a series' rule +/// starts on its first day as the stored start zone reads it (234). async fn read_before( client: &EwsClient, target: &WriteTarget, calendar_id: &str, -) -> EwsResult> { +) -> EwsResult> { let ids = vec![(target.item_id.clone(), None)]; let body = match target.kind { EventIdKind::Exception => crate::soap::get_exception_items(&ids), @@ -1264,13 +1297,44 @@ async fn read_before( // the exception shape asks for no zone of its own: read here without one, // an all-day day the series' zone names otherwise would count as moved, // and a title-only edit would write the slot back (decision 217). - if let Some((start_zone, end_zone)) = &target.series_zone { + if let Some(series) = &target.series_zone { if item.start_time_zone.is_none() && item.end_time_zone.is_none() { - item.start_time_zone = start_zone.clone(); - item.end_time_zone = end_zone.clone(); + item.start_time_zone = series.start_id.clone(); + item.end_time_zone = series.end_id.clone(); + item.start_zone_definition = series.start_definition.clone(); + item.end_zone_definition = series.end_definition.clone(); } } - Ok(Some(crate::mapping::to_event(item, calendar_id)?)) + let zones = crate::mapping::StoredZones::of(&item); + Ok(Some(ServerCopy { + event: crate::mapping::to_event(item, calendar_id)?, + zones, + })) +} + +/// The provider's current copy of an item and the zones it is stored in. +#[derive(Debug, Clone)] +struct ServerCopy { + event: Event, + zones: crate::mapping::StoredZones, +} + +/// Whether a message is the refusal for a day in an unknown zone. +fn day_zone_unreadable(message: &str) -> bool { + matches!( + cal_core::WriteRefusal::parse(message), + Some((cal_core::WriteRefusal::DayZoneUnreadable, _)) + ) +} + +/// A series head's zones, as its exception is read and written in them: the +/// exception shape asks for no zone of its own. +#[derive(Debug, Clone, Default)] +struct SeriesZone { + start_id: Option, + end_id: Option, + start_definition: Option, + end_definition: Option, } /// Helper: the resolved (id, change_key) pair plus the kind we @@ -1280,9 +1344,10 @@ struct WriteTarget { kind: EventIdKind, item_id: String, change_key: Option, - /// For an exception, its series' StartTimeZone and EndTimeZone: the - /// zone its all-day day is read in, as its row is (`override_event`). - series_zone: Option<(Option, Option)>, + /// For an exception, its series' StartTimeZone and EndTimeZone, ids and + /// definitions: the zones its all-day day is read and written in, as its + /// row is read (`override_event`). + series_zone: Option, } /// Rename a calendar folder via `UpdateFolder` + `folder:DisplayName`. @@ -2427,6 +2492,8 @@ mod tests { NoError + + 2026-10-25T23:00:00Z @@ -2501,8 +2568,12 @@ mod tests { title: "Moved to Tuesday".into(), description: None, location: None, - start: "2026-10-26T23:00:00Z".parse().unwrap(), - end: "2026-10-27T23:00:00Z".parse().unwrap(), + // Tuesday 27 to Wednesday 28 October, as this device keeps an + // all-day day: its own midnights. + start: crate::mapping::local_midnight_in("2026-10-27".parse().unwrap(), &chrono::Local) + .unwrap(), + end: crate::mapping::local_midnight_in("2026-10-28".parse().unwrap(), &chrono::Local) + .unwrap(), all_day: true, recurrence: None, color_label: None, @@ -2568,6 +2639,14 @@ mod tests { 3, "Start, End and IsAllDayEvent, and nothing else: {update}" ); + // Decision 47a: the day goes out as midnights in the zone its series is + // stored in, W. Europe here (live round 3, R3-7b), on any device. + assert!( + update.contains("2026-10-26T23:00:00Z") + && update.contains("2026-10-27T23:00:00Z"), + "{update}" + ); + assert!(!update.contains("TimeZone"), "no zone is written: {update}"); assert_eq!(updated.id, override_id, "the override keeps its id"); assert_eq!(updated.etag.as_deref(), Some("ECK-V2")); } @@ -2655,12 +2734,12 @@ mod tests { // The row as the read gives it: the local midnights of 15 and 16 January. let raw: chrono::DateTime = "2027-01-14T11:00:00Z".parse().unwrap(); - let start = crate::mapping::all_day_anchor(raw, Some(&crate::mapping::DayZone::Tz(auckland))); - let end = - crate::mapping::all_day_anchor( - "2027-01-15T11:00:00Z".parse().unwrap(), - Some(&crate::mapping::DayZone::Tz(auckland)), - ); + let start = + crate::mapping::all_day_anchor(raw, Some(&crate::mapping::DayZone::Tz(auckland))); + let end = crate::mapping::all_day_anchor( + "2027-01-15T11:00:00Z".parse().unwrap(), + Some(&crate::mapping::DayZone::Tz(auckland)), + ); let edit = Event { keep_attendees: false, keep_fields: Vec::new(), @@ -3882,7 +3961,10 @@ mod tests { .await; // The day as this device names it: its local midnight of 15 January. - let day = crate::mapping::all_day_anchor(midnight(229), Some(&crate::mapping::DayZone::Tz(auckland))); + let day = crate::mapping::all_day_anchor( + midnight(229), + Some(&crate::mapping::DayZone::Tz(auckland)), + ); delete_series_occurrence(&client_for(&server), "MASTER", Some("CK"), day, false) .await .unwrap(); diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index e1014864..c7701f17 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -1440,6 +1440,17 @@ fn to_update_error(err: EwsError) -> CoreError { tracing::warn!(%code, %message, "Exchange refused the update"); CoreError::Forbidden(cal_core::WriteRefusal::ServerRefused.message(code)) } + // Nothing was sent: an all-day day in a zone Aperio cannot read + // (decision 237). Forbidden, so the phone keeps the message and a + // split knows the write never landed. + EwsError::Protocol(message) + if matches!( + cal_core::WriteRefusal::parse(&message), + Some((cal_core::WriteRefusal::DayZoneUnreadable, _)) + ) => + { + CoreError::Forbidden(message) + } other => to_core_error(other), } } @@ -2927,7 +2938,48 @@ mod server_zone_tests { #[tokio::test] async fn updating_an_all_day_series_neither_asks_for_nor_writes_a_zone() { let mut server = Server::new_async().await; - let (_mock, requests) = recording_server(&mut server, |_| ZONES.to_string()).await; + // The series as Exchange stores it, in W. Europe, a week earlier: the + // update moves its day, so the write goes out (decision 237 writes an + // all-day day only with the zones the copy names). + const STORED: &str = r#" + + + + NoError + + + All-day Berlin + 2026-10-11T22:00:00Z + 2026-10-12T22:00:00Z + true + Single + + + + + +"#; + let requests = Arc::new(StdMutex::new(Vec::::new())); + let seen = Arc::clone(&requests); + let _mock = server + .mock("POST", "/") + .with_status(200) + .with_body_from_request(move |request| { + let body = request.utf8_lossy_body().unwrap().into_owned(); + let answer = if body.contains("GetServerTimeZones") { + ZONES + } else if body.contains("") { + STORED + } else { + CREATED + }; + seen.lock().unwrap().push(body); + answer.as_bytes().to_vec() + }) + .create_async() + .await; let adapter = EwsAdapter::new(server.url(), alice()); let stamp: chrono::DateTime = "2026-09-15T00:00:00Z".parse().unwrap(); let series = Event { diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 5a0bc925..86a72578 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -937,8 +937,10 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult { if !empty { - zone_walker = - Some((true, crate::zone_definition::ZoneDefinitionWalker::default())); + zone_walker = Some(( + true, + crate::zone_definition::ZoneDefinitionWalker::default(), + )); } // Simple form: . for a in e.attributes().flatten() { @@ -959,8 +961,10 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult {} @@ -1437,8 +1441,10 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult } b"starttimezone" if inside_item => { if !empty { - zone_walker = - Some((true, crate::zone_definition::ZoneDefinitionWalker::default())); + zone_walker = Some(( + true, + crate::zone_definition::ZoneDefinitionWalker::default(), + )); } for a in e.attributes().flatten() { if a.key.as_ref().eq_ignore_ascii_case(b"Id") { @@ -1457,8 +1463,10 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult } } if !empty { - zone_walker = - Some((false, crate::zone_definition::ZoneDefinitionWalker::default())); + zone_walker = Some(( + false, + crate::zone_definition::ZoneDefinitionWalker::default(), + )); } } _ => {} @@ -2044,6 +2052,19 @@ pub fn new_event_to_calendar_item_xml_on( event: &NewEvent, server_zones: Option<&ServerTimeZones>, ) -> EwsResult { + new_event_to_calendar_item_xml_in(event, server_zones, &Local) +} + +/// [`new_event_to_calendar_item_xml_on`] on a given device clock, so tests can +/// pin one: an all-day boundary is the device's day. +pub(crate) fn new_event_to_calendar_item_xml_in( + event: &NewEvent, + server_zones: Option<&ServerTimeZones>, + device: &D, +) -> EwsResult { + // The zone is looked up once: it decides the rule's first day as well as + // what is written, and a zone Exchange cannot store is logged once. + let written = series_windows_zone(event.all_day, event.recurrence.as_ref(), server_zones); let mut out = String::new(); out.push_str(" \n"); out.push_str(&format!( @@ -2068,13 +2089,14 @@ pub fn new_event_to_calendar_item_xml_on( } else { out.push_str(" false\n"); } - // All-day events pin their boundaries to UTC midnight of the LOCAL - // calendar day (see ews_all_day_boundary); timed events write the - // instant verbatim. + // An all-day event goes out as UTC midnights of the device's days: Aperio + // creates it without a zone, so Exchange stores it in UTC + // ([`all_day_boundary`] in UTC). Timed events write the instant verbatim. let (wire_start, wire_end) = if event.all_day { + let utc = DayZone::Tz(chrono_tz::UTC); ( - ews_all_day_boundary(event.start), - ews_all_day_boundary(event.end), + all_day_boundary(event.start, Some(&utc), device)?, + all_day_boundary(event.end, Some(&utc), device)?, ) } else { (event.start, event.end) @@ -2103,7 +2125,9 @@ pub fn new_event_to_calendar_item_xml_on( // CalendarItem element order. out.push_str(&required_attendees_xml(&event.attendees)); if let Some(rec) = &event.recurrence { - let rec_xml = rrule_to_ews_recurrence(&rec.rrule, event.start)?; + let clock = written_clock(event.all_day, Some(rec), written); + let first_day = rule_first_day(event.start, event.all_day, clock.as_ref(), device); + let rec_xml = rrule_to_ews_recurrence(&rec.rrule, first_day)?; out.push_str(" "); out.push_str(&rec_xml); out.push('\n'); @@ -2112,9 +2136,7 @@ pub fn new_event_to_calendar_item_xml_on( // so it expands the series on that clock server-side. Per the EWS // CalendarItemType element order, StartTimeZone/EndTimeZone follow // . - if let Some(windows) = - series_windows_zone(event.all_day, event.recurrence.as_ref(), server_zones) - { + if let Some(windows) = written { out.push_str(&format!( " \n", escape_xml(windows) @@ -2230,6 +2252,32 @@ pub fn event_to_update_field_xml_on( before: Option<&Event>, server_zones: Option<&ServerTimeZones>, target: EventIdKind, +) -> EwsResult<(String, String)> { + event_to_update_field_xml_in( + event, + before, + &StoredZones::utc(), + server_zones, + target, + &Local, + ) +} + +/// [`event_to_update_field_xml_on`] for an item Exchange stores in `stored` +/// (read with `before`, `api::read_before`), on a given device clock. +/// +/// An all-day slot goes out as midnights in the stored zones (decision 47a, +/// each boundary in its own, 233), refused where one is unknown (237). A +/// series' rule starts on its first day as Exchange will read it: the +/// device's day for an all-day series, the stored start zone's day for a +/// timed one (234), UTC where none is known. +pub(crate) fn event_to_update_field_xml_in( + event: &Event, + before: Option<&Event>, + stored: &StoredZones, + server_zones: Option<&ServerTimeZones>, + target: EventIdKind, + device: &D, ) -> EwsResult<(String, String)> { let mut set = String::new(); let mut del = String::new(); @@ -2285,7 +2333,7 @@ pub fn event_to_update_field_xml_on( // Whether the start this update would put on the server is not the one // already there. Without a copy to compare with, it may be. let start_moves = before.is_none_or(|b| b.start != event.start); - // The slot is ONE fact, written as one group: `ews_all_day_boundary` + // The slot is ONE fact, written as one group: `all_day_boundary` // rewrites both boundaries from `all_day`, and Exchange validates a Start // against the End it has stored. Writing one of the three without the // others is how a whole update faults, or how a stored instant is silently @@ -2300,11 +2348,12 @@ pub fn event_to_update_field_xml_on( || touches(EventField::AllDay) || (touches(EventField::Recurrence) && event.recurrence.is_some() && start_moves); if slot_changed { - // Same all-day boundary pinning as the create path. + // An all-day boundary is midnight of its day in the zone Exchange + // stores it in (47a, 233); a timed one is its instant. let (wire_start, wire_end) = if event.all_day { ( - ews_all_day_boundary(event.start), - ews_all_day_boundary(event.end), + all_day_boundary(event.start, stored.start.as_ref(), device)?, + all_day_boundary(event.end, stored.end.as_ref(), device)?, ) } else { (event.start, event.end) @@ -2369,8 +2418,11 @@ pub fn event_to_update_field_xml_on( del.push_str(delete_item_field_xml("calendar:OptionalAttendees").as_str()); } // The rule Exchange stores is the BUILT one, and that depends on the start - // as well: the range's StartDate is `event.start`'s date, and a rule - // without BYDAY, BYMONTHDAY or BYMONTH takes those from the start too. The + // as well: the range's StartDate is the start's first day + // ([`rule_first_day`]), and a rule without BYDAY, BYMONTHDAY or BYMONTH + // takes those from that day too. Both sides are built on the same clock, + // the stored start zone (234), so a save that changes neither the start + // nor the rule writes neither. The // rrule text carries none of it, so a series dragged to another day // compares equal on the text alone and would keep its old StartDate on the // server. So the rule is asked twice: as text, which also catches a rule @@ -2394,15 +2446,17 @@ pub fn event_to_update_field_xml_on( Some(server) if !may(EventField::Recurrence) => server, _ => event, }; + let clock = stored.start.as_ref(); + let first_day = |ev: &Event| rule_first_day(ev.start, ev.all_day, clock, device); let built_rule = |ev: &Event| { ev.recurrence .as_ref() - .map(|rec| rrule_to_ews_recurrence(&rec.rrule, ev.start).ok()) + .map(|rec| rrule_to_ews_recurrence(&rec.rrule, first_day(ev)).ok()) }; let written_rule = rule_of .recurrence .as_ref() - .map(|rec| rrule_to_ews_recurrence(&rec.rrule, event.start).ok()); + .map(|rec| rrule_to_ews_recurrence(&rec.rrule, first_day(event)).ok()); let rule_follows_slot = slot_changed && start_moves && event.recurrence.is_some(); let rule_changed = touches(EventField::Recurrence) || ((may(EventField::Recurrence) || rule_follows_slot) @@ -2414,7 +2468,7 @@ pub fn event_to_update_field_xml_on( // pointless `DeleteItemField calendar:Recurrence` every time. if rule_changed { if let Some(rec) = &rule_of.recurrence { - let rec_xml = rrule_to_ews_recurrence(&rec.rrule, event.start)?; + let rec_xml = rrule_to_ews_recurrence(&rec.rrule, first_day(event))?; // Wrap the recurrence element in a SetItemField against // calendar:Recurrence. EWS expects the body's inner shape to // start with `` containing the recurrence. @@ -2464,17 +2518,6 @@ fn format_ews_datetime(ts: DateTime) -> String { ts.format("%Y-%m-%dT%H:%M:%SZ").to_string() } -/// All-day boundary for the wire: UTC midnight of the LOCAL calendar day. -/// Exchange normalises all-day Start/End to whole days in the request's -/// timezone context (UTC for us) — writing the raw boundary instant (a -/// local midnight, e.g. 22:00Z of the previous day for UTC+2) would pin -/// the event to the WRONG day. The internal end is already exclusive -/// (next day's midnight), which is the whole-day span EWS expects. -fn ews_all_day_boundary(when: DateTime) -> DateTime { - let day = when.with_timezone(&Local).date_naive(); - Utc.from_utc_datetime(&day.and_hms_opt(0, 0, 0).unwrap()) -} - /// Store a zone walker's result on the item: the definition, and the id a /// nested `TimeZoneDefinition` named where the element itself carried none. fn finish_zone( @@ -2650,7 +2693,10 @@ impl DayZone { /// Midnight on `day` in a chrono zone, the first hour after it where a /// change skips it. -pub(crate) fn local_midnight_in(day: chrono::NaiveDate, zone: &Z) -> Option> { +pub(crate) fn local_midnight_in( + day: chrono::NaiveDate, + zone: &Z, +) -> Option> { let midnight = day.and_hms_opt(0, 0, 0)?; zone.from_local_datetime(&midnight) .earliest() @@ -2730,6 +2776,92 @@ pub(crate) fn all_day_end_zone(item: &ParsedItem) -> Option { ) } +/// The zones Exchange stores an item's two boundaries in, read with its copy +/// at write time (`api::read_before`): [`all_day_zone`] and +/// [`all_day_end_zone`]. `None` where Exchange names none Aperio can read, or +/// where the copy could not be read. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct StoredZones { + pub(crate) start: Option, + pub(crate) end: Option, +} + +impl StoredZones { + pub(crate) fn of(item: &ParsedItem) -> Self { + Self { + start: all_day_zone(item), + end: all_day_end_zone(item), + } + } + + /// An item Exchange stores in UTC, as Aperio creates every item. + pub(crate) fn utc() -> Self { + Self { + start: Some(DayZone::Tz(chrono_tz::UTC)), + end: Some(DayZone::Tz(chrono_tz::UTC)), + } + } +} + +/// The calendar day an all-day boundary names: Aperio keeps it as the +/// device's midnight of that day. +pub(crate) fn device_day(when: DateTime, device: &D) -> chrono::NaiveDate { + when.with_timezone(device).date_naive() +} + +/// The day a series' rule starts on, on the clock Exchange repeats it on: an +/// all-day series on the device's days (48a); a timed one on `clock`, the +/// zone Exchange expands it in — the zone a create writes with it, the zone +/// the item is stored in on an update (decision 234: Exchange applies the +/// rule before a zone the same update writes), UTC where there is none. +pub(crate) fn rule_first_day( + start: DateTime, + all_day: bool, + clock: Option<&DayZone>, + device: &D, +) -> chrono::NaiveDate { + if all_day { + return device_day(start, device); + } + clock + .and_then(|zone| zone.wall(start)) + .map_or_else(|| start.date_naive(), |wall| wall.date()) +} + +/// The instant an all-day boundary is written as: midnight, in the zone +/// Exchange stores that boundary in, of the day the device names (decision +/// 47a; each boundary in its own zone, 233). Exchange rounds a boundary in +/// that zone (live round 3, R3-5b-u), so UTC midnights stretched an item in +/// any other zone over two days. Refused where the zone is unknown (237). +fn all_day_boundary( + when: DateTime, + zone: Option<&DayZone>, + device: &D, +) -> EwsResult> { + let zone = zone.ok_or_else(|| day_zone_refusal("no-zone"))?; + zone.midnight(device_day(when, device)) + .ok_or_else(|| day_zone_refusal("no-midnight")) +} + +fn day_zone_refusal(detail: &str) -> EwsError { + EwsError::Protocol(cal_core::WriteRefusal::DayZoneUnreadable.message(detail)) +} + +/// The zone a create writes a series with, as a clock: the tzdata zone behind +/// the Windows id [`series_windows_zone`] wrote. `None` where it wrote none. +fn written_clock( + all_day: bool, + recurrence: Option<&EventRecurrence>, + written: Option<&str>, +) -> Option { + written?; + let tzid = cal_core::written_series_zone(recurrence.and_then(|r| r.tzid.as_deref()), all_day); + cal_core::series_clock_zone(tzid) + .and_then(cal_core::canonical_zone) + .and_then(|zone| zone.parse::().ok()) + .map(DayZone::Tz) +} + /// The local midnight of the day an all-day instant names. /// /// EWS hands back a plain instant that is midnight of the intended day in @@ -2826,9 +2958,13 @@ fn delete_item_field_xml(field_uri: &str) -> String { // series round-trips EWS → RRULE → EWS without drift. /// Translate an RFC-5545 RRULE into an EWS `` block. -/// `start` is the master event's start date, used as the -/// recurrence's StartDate (EWS requires it on every range type). -pub fn rrule_to_ews_recurrence(rrule: &str, start: DateTime) -> EwsResult { +/// `first_day` is the day the series starts on, on the clock Exchange repeats +/// it on ([`rule_first_day`]): the range's StartDate (EWS requires one on +/// every range type), and the weekday, day of the month and month a rule +/// without BYDAY, BYMONTHDAY or BYMONTH takes. Read off a UTC instant, as +/// before PR 8a, it named the day before for an all-day series east of UTC, +/// and a series really started there (live round 3, R3-6). +pub fn rrule_to_ews_recurrence(rrule: &str, first_day: chrono::NaiveDate) -> EwsResult { let parts = parse_rrule(rrule); let freq = parts .get("FREQ") @@ -2850,7 +2986,7 @@ pub fn rrule_to_ews_recurrence(rrule: &str, start: DateTime) -> EwsResult=2 series with the mailbox's // own FirstDayOfWeek — which would drift from the RRULE we stored. @@ -2881,7 +3017,7 @@ pub fn rrule_to_ews_recurrence(rrule: &str, start: DateTime) -> EwsResult().ok()) .unwrap_or_else(|| { use chrono::Datelike; - start.day() as u8 + first_day.day() as u8 }); format!( "{interval}{day}", @@ -2893,7 +3029,7 @@ pub fn rrule_to_ews_recurrence(rrule: &str, start: DateTime) -> EwsResult().ok()) - .unwrap_or_else(|| start.month()); + .unwrap_or_else(|| first_day.month()); let month_name = month_number_to_name(month_num).ok_or_else(|| { EwsError::Protocol(format!("RRULE BYMONTH out of range: {month_num}")) })?; @@ -2908,7 +3044,7 @@ pub fn rrule_to_ews_recurrence(rrule: &str, start: DateTime) -> EwsResult().ok()) - .unwrap_or_else(|| start.day() as u8); + .unwrap_or_else(|| first_day.day() as u8); format!( "{day}{month_name}", ) @@ -2921,7 +3057,7 @@ pub fn rrule_to_ews_recurrence(rrule: &str, start: DateTime) -> EwsResult() @@ -3150,9 +3286,9 @@ fn ews_day_name(d: EwsDay) -> &'static str { } } -fn weekday_for(ts: DateTime) -> String { +fn weekday_for(day: chrono::NaiveDate) -> String { use chrono::Datelike; - match ts.weekday() { + match day.weekday() { chrono::Weekday::Mon => "Monday", chrono::Weekday::Tue => "Tuesday", chrono::Weekday::Wed => "Wednesday", @@ -5266,7 +5402,9 @@ mod tests { for (tag, when) in [("Start", event.start), ("End", event.end)] { let today = format!( "{}", - format_ews_datetime(ews_all_day_boundary(when)) + format_ews_datetime( + local_midnight_in(device_day(when, &Local), &chrono_tz::UTC).unwrap() + ) ); let prototype = format!("{}", format_ews_datetime(when)); assert_eq!(set.matches(&today).count(), 1, "{tag} in {set}"); @@ -5606,7 +5744,7 @@ mod tests { #[test] fn rrule_daily_translates_to_daily_recurrence() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=DAILY;INTERVAL=2", start).unwrap(); + let xml = rrule_to_ews_recurrence("FREQ=DAILY;INTERVAL=2", start.date_naive()).unwrap(); assert!(xml.contains("")); assert!(xml.contains("2")); assert!(xml.contains("")); @@ -5616,7 +5754,8 @@ mod tests { #[test] fn rrule_weekly_with_byday_translates_day_names() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=WEEKLY;BYDAY=MO,WE,FR", start).unwrap(); + let xml = + rrule_to_ews_recurrence("FREQ=WEEKLY;BYDAY=MO,WE,FR", start.date_naive()).unwrap(); assert!(xml.contains("")); assert!(xml.contains("Monday Wednesday Friday")); } @@ -5624,7 +5763,8 @@ mod tests { #[test] fn rrule_monthly_with_bymonthday_translates_to_absolute_monthly() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=MONTHLY;BYMONTHDAY=15", start).unwrap(); + let xml = + rrule_to_ews_recurrence("FREQ=MONTHLY;BYMONTHDAY=15", start.date_naive()).unwrap(); assert!(xml.contains("")); assert!(xml.contains("15")); } @@ -5632,7 +5772,9 @@ mod tests { #[test] fn rrule_yearly_with_bymonth_translates_to_absolute_yearly() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15", start).unwrap(); + let xml = + rrule_to_ews_recurrence("FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15", start.date_naive()) + .unwrap(); assert!(xml.contains("")); assert!(xml.contains("March")); assert!(xml.contains("15")); @@ -5641,7 +5783,7 @@ mod tests { #[test] fn rrule_count_translates_to_numbered_recurrence() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=DAILY;COUNT=5", start).unwrap(); + let xml = rrule_to_ews_recurrence("FREQ=DAILY;COUNT=5", start.date_naive()).unwrap(); assert!(xml.contains("")); assert!(xml.contains("5")); } @@ -5649,8 +5791,11 @@ mod tests { #[test] fn rrule_until_translates_to_end_date_recurrence() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = - rrule_to_ews_recurrence("FREQ=WEEKLY;BYDAY=TU;UNTIL=20260901T235959Z", start).unwrap(); + let xml = rrule_to_ews_recurrence( + "FREQ=WEEKLY;BYDAY=TU;UNTIL=20260901T235959Z", + start.date_naive(), + ) + .unwrap(); assert!(xml.contains("")); assert!(xml.contains("2026-09-01")); } @@ -5660,7 +5805,7 @@ mod tests { // "Second Wednesday of every month" → RelativeMonthly with // a single DaysOfWeek + DayOfWeekIndex=Second. let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=MONTHLY;BYDAY=2WE", start).unwrap(); + let xml = rrule_to_ews_recurrence("FREQ=MONTHLY;BYDAY=2WE", start.date_naive()).unwrap(); assert!(xml.contains("")); assert!(xml.contains("Wednesday")); assert!(xml.contains("Second")); @@ -5670,7 +5815,7 @@ mod tests { fn rrule_relative_monthly_last_maps_negative_ordinal() { // BYDAY=-1FR ("last Friday") → DayOfWeekIndex=Last. let start: DateTime = "2026-05-29T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=MONTHLY;BYDAY=-1FR", start).unwrap(); + let xml = rrule_to_ews_recurrence("FREQ=MONTHLY;BYDAY=-1FR", start.date_naive()).unwrap(); assert!(xml.contains("")); assert!(xml.contains("Friday")); assert!(xml.contains("Last")); @@ -5681,8 +5826,11 @@ mod tests { // "Last weekday of the month": multi-day BYDAY + BYSETPOS=-1 // collapses back into the EWS composite token `Weekday`. let start: DateTime = "2026-05-29T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", start) - .unwrap(); + let xml = rrule_to_ews_recurrence( + "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", + start.date_naive(), + ) + .unwrap(); assert!(xml.contains("")); assert!(xml.contains("Weekday")); assert!(xml.contains("Last")); @@ -5692,7 +5840,8 @@ mod tests { fn rrule_relative_yearly_translates_to_relative_yearly() { // "First Friday of March every year". let start: DateTime = "2026-03-06T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=YEARLY;BYMONTH=3;BYDAY=1FR", start).unwrap(); + let xml = + rrule_to_ews_recurrence("FREQ=YEARLY;BYMONTH=3;BYDAY=1FR", start.date_naive()).unwrap(); assert!(xml.contains("")); assert!(xml.contains("Friday")); assert!(xml.contains("First")); @@ -5705,7 +5854,7 @@ mod tests { // Wednesday monthly", parse the emitted XML back, and the // re-derived RRULE must equal the input. let start: DateTime = "2024-05-15T10:30:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=MONTHLY;BYDAY=3WE", start).unwrap(); + let xml = rrule_to_ews_recurrence("FREQ=MONTHLY;BYDAY=3WE", start.date_naive()).unwrap(); let reparsed = parse_ews_recurrence(&xml).unwrap(); assert_rrule_equivalent(&reparsed.to_rrule(), "FREQ=MONTHLY;BYDAY=3WE"); } @@ -5713,7 +5862,7 @@ mod tests { #[test] fn rrule_with_unknown_freq_rejected() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let err = rrule_to_ews_recurrence("FREQ=HOURLY", start).unwrap_err(); + let err = rrule_to_ews_recurrence("FREQ=HOURLY", start.date_naive()).unwrap_err(); match err { EwsError::Protocol(m) => assert!(m.contains("HOURLY")), other => panic!("expected Protocol, got {other:?}"), @@ -6070,7 +6219,16 @@ mod tests { .any(|f| block.contains(&format!(r#"FieldURI="{f}""#))); if rule_or_zone && keep.contains(&EventField::Recurrence) { let rule = before.recurrence.as_ref().map(|r| { - rrule_to_ews_recurrence(&r.rrule, edit.start).unwrap() + rrule_to_ews_recurrence( + &r.rrule, + rule_first_day( + edit.start, + edit.all_day, + Some(&DayZone::Tz(chrono_tz::UTC)), + &Local, + ), + ) + .unwrap() }); let zone = series_windows_zone( edit.all_day, @@ -6883,7 +7041,7 @@ mod tests { #[test] fn parse_daily_recurrence_roundtrips() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=DAILY;INTERVAL=3", start).unwrap(); + let xml = rrule_to_ews_recurrence("FREQ=DAILY;INTERVAL=3", start.date_naive()).unwrap(); let rec = parse_ews_recurrence(&xml).unwrap(); assert_eq!(rec.pattern, EwsRecurrencePattern::Daily { interval: 3 },); assert_eq!(rec.range, EwsRecurrenceRange::NoEnd); @@ -6893,7 +7051,9 @@ mod tests { #[test] fn parse_weekly_with_byday_roundtrips() { let start: DateTime = "2026-05-20T08:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10", start).unwrap(); + let xml = + rrule_to_ews_recurrence("FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10", start.date_naive()) + .unwrap(); let rec = parse_ews_recurrence(&xml).unwrap(); assert_eq!( rec.pattern, @@ -6912,8 +7072,11 @@ mod tests { let start: DateTime = "2026-07-05T09:00:00Z".parse().unwrap(); // WRITE: a WKST=SU rule pins the wire FirstDayOfWeek to Sunday. - let xml = rrule_to_ews_recurrence("FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,MO,TU;WKST=SU", start) - .unwrap(); + let xml = rrule_to_ews_recurrence( + "FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,MO,TU;WKST=SU", + start.date_naive(), + ) + .unwrap(); assert!( xml.contains("Sunday"), "expected Sunday first-day on the wire, got {xml}", @@ -6937,7 +7100,7 @@ mod tests { // A Monday-week rule: FirstDayOfWeek=Monday on the wire, but NO WKST in // the RRULE (the RFC-5545 default — keeps the common rule byte-identical). - let mon = rrule_to_ews_recurrence("FREQ=WEEKLY;BYDAY=TH", start).unwrap(); + let mon = rrule_to_ews_recurrence("FREQ=WEEKLY;BYDAY=TH", start.date_naive()).unwrap(); assert!(mon.contains("Monday")); assert!(!parse_ews_recurrence(&mon) .unwrap() @@ -6948,9 +7111,11 @@ mod tests { #[test] fn parse_absolute_monthly_with_enddate_roundtrips() { let start: DateTime = "2026-05-15T08:00:00Z".parse().unwrap(); - let xml = - rrule_to_ews_recurrence("FREQ=MONTHLY;BYMONTHDAY=15;UNTIL=20271231T000000Z", start) - .unwrap(); + let xml = rrule_to_ews_recurrence( + "FREQ=MONTHLY;BYMONTHDAY=15;UNTIL=20271231T000000Z", + start.date_naive(), + ) + .unwrap(); let rec = parse_ews_recurrence(&xml).unwrap(); assert_eq!( rec.pattern, @@ -6977,7 +7142,9 @@ mod tests { #[test] fn parse_absolute_yearly_roundtrips() { let start: DateTime = "2026-03-21T09:00:00Z".parse().unwrap(); - let xml = rrule_to_ews_recurrence("FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=21", start).unwrap(); + let xml = + rrule_to_ews_recurrence("FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=21", start.date_naive()) + .unwrap(); let rec = parse_ews_recurrence(&xml).unwrap(); assert_eq!( rec.pattern, @@ -7594,7 +7761,11 @@ mod tests { "# ); - match parse_sync_folder_items_response(&xml).unwrap().changes.remove(0) { + match parse_sync_folder_items_response(&xml) + .unwrap() + .changes + .remove(0) + { SyncChange::Create(item) => item, other => panic!("expected Create, got {other:?}"), } @@ -7621,14 +7792,19 @@ mod tests { #[test] fn a_zone_definition_lands_on_its_own_boundary() { let id_only = r#""#; - let custom_end = - format!(r#"{CUSTOM_ZONE_BODY}"#); - let custom_start = - format!(r#"{CUSTOM_ZONE_BODY}"#); + let custom_end = format!( + r#"{CUSTOM_ZONE_BODY}"# + ); + let custom_start = format!( + r#"{CUSTOM_ZONE_BODY}"# + ); let id_only_end = r#""#; for parse in [in_sync_response, in_get_item_response] { let item = parse(&zone_item(id_only, &custom_end)); - assert_eq!(item.start_time_zone.as_deref(), Some("W. Europe Standard Time")); + assert_eq!( + item.start_time_zone.as_deref(), + Some("W. Europe Standard Time") + ); assert_eq!(item.start_zone_definition, None); assert_eq!(item.end_time_zone.as_deref(), Some("Customized Time Zone")); let def = item.end_zone_definition.expect("the end zone's definition"); @@ -7645,9 +7821,15 @@ mod tests { assert_eq!(item.subject, "Zone"); let item = parse(&zone_item(&custom_start, id_only_end)); - assert_eq!(item.start_time_zone.as_deref(), Some("Customized Time Zone")); + assert_eq!( + item.start_time_zone.as_deref(), + Some("Customized Time Zone") + ); assert!(item.start_zone_definition.is_some()); - assert_eq!(item.end_time_zone.as_deref(), Some("W. Europe Standard Time")); + assert_eq!( + item.end_time_zone.as_deref(), + Some("W. Europe Standard Time") + ); assert_eq!(item.end_zone_definition, None); } } @@ -7661,7 +7843,10 @@ mod tests { ); for parse in [in_sync_response, in_get_item_response] { let item = parse(&zone_item(&nested, "")); - assert_eq!(item.start_time_zone.as_deref(), Some("Customized Time Zone")); + assert_eq!( + item.start_time_zone.as_deref(), + Some("Customized Time Zone") + ); assert!(item.start_zone_definition.is_some()); } } @@ -8086,7 +8271,11 @@ mod tests { let zone = all_day_zone(&item); assert_eq!(zone, Some(DayZone::Tz(chrono_tz::Europe::Berlin))); // Berlin's midnight of 12 October reads as that day on any device. - for device in [chrono_tz::Europe::Berlin, chrono_tz::Asia::Tokyo, chrono_tz::Pacific::Honolulu] { + for device in [ + chrono_tz::Europe::Berlin, + chrono_tz::Asia::Tokyo, + chrono_tz::Pacific::Honolulu, + ] { let anchored = all_day_anchor_in(utc("2026-10-11T22:00:00Z"), zone.as_ref(), &device); assert_eq!( anchored, @@ -8147,8 +8336,14 @@ mod tests { #[test] fn an_all_day_end_reads_in_the_end_zone() { let item = all_day_item(Some("Tokyo Standard Time"), Some("Hawaiian Standard Time")); - assert_eq!(all_day_zone(&item), Some(DayZone::Tz(chrono_tz::Asia::Tokyo))); - assert_eq!(all_day_end_zone(&item), Some(DayZone::Tz(chrono_tz::Pacific::Honolulu))); + assert_eq!( + all_day_zone(&item), + Some(DayZone::Tz(chrono_tz::Asia::Tokyo)) + ); + assert_eq!( + all_day_end_zone(&item), + Some(DayZone::Tz(chrono_tz::Pacific::Honolulu)) + ); let device = chrono_tz::Europe::Berlin; // Honolulu's midnight of 20 October: read in Tokyo it is the 21st. let end = utc("2026-10-20T10:00:00Z"); @@ -8161,8 +8356,14 @@ mod tests { local_midnight_in("2026-10-21".parse().unwrap(), &device).unwrap() ); // An item made without a zone reads both boundaries in UTC. - let made_without = all_day_item(Some("Greenwich Standard Time"), Some("tzone://Microsoft/Utc")); - assert_eq!(all_day_end_zone(&made_without), Some(DayZone::Tz(chrono_tz::UTC))); + let made_without = all_day_item( + Some("Greenwich Standard Time"), + Some("tzone://Microsoft/Utc"), + ); + assert_eq!( + all_day_end_zone(&made_without), + Some(DayZone::Tz(chrono_tz::UTC)) + ); } #[test] @@ -8202,7 +8403,10 @@ mod tests { }; item.modified_occurrences = vec![ov.clone()]; assert_eq!(all_day_zone(&item), Some(DayZone::Tz(auckland))); - assert_eq!(all_day_anchor(slot, Some(&DayZone::Tz(auckland))), local_midnight); + assert_eq!( + all_day_anchor(slot, Some(&DayZone::Tz(auckland))), + local_midnight + ); let master = to_event(item.clone(), "cal").unwrap(); assert_eq!( master.recurrence.as_ref().unwrap().exceptions, diff --git a/crates/adapter-ews/src/tasks.rs b/crates/adapter-ews/src/tasks.rs index ce17b58e..8fa343d9 100644 --- a/crates/adapter-ews/src/tasks.rs +++ b/crates/adapter-ews/src/tasks.rs @@ -983,7 +983,7 @@ pub fn new_task_to_task_item_xml(task: &NewTask) -> String { /// can't be expressed in EWS — the recurrence capability greys those out, /// so this only guards against a stray rule arriving from elsewhere. fn ews_recurrence_xml(rec: &cal_core::TaskRecurrence, start: DateTime) -> Option { - rrule_to_ews_recurrence(&task_recurrence_to_rrule(rec), start).ok() + rrule_to_ews_recurrence(&task_recurrence_to_rrule(rec), start.date_naive()).ok() } /// Build the `` element carrying the Aperio-Extras blob diff --git a/crates/adapter-ews/src/zone_definition.rs b/crates/adapter-ews/src/zone_definition.rs index 23be3cbe..23f26bfe 100644 --- a/crates/adapter-ews/src/zone_definition.rs +++ b/crates/adapter-ews/src/zone_definition.rs @@ -233,7 +233,12 @@ fn attribute(e: &quick_xml::events::BytesStart<'_>, name: &[u8]) -> Option, empty: bool) { + pub fn observe_start( + &mut self, + local: &[u8], + e: &quick_xml::events::BytesStart<'_>, + empty: bool, + ) { if !empty { self.depth += 1; } @@ -250,7 +255,9 @@ impl ZoneDefinitionWalker { }); } b"transitions" if !empty => self.in_top_transitions = true, - b"transition" | b"recurringdaytransition" | b"recurringdatetransition" + b"transition" + | b"recurringdaytransition" + | b"recurringdatetransition" | b"absolutedatetransition" if !empty => { @@ -316,7 +323,9 @@ impl ZoneDefinitionWalker { } self.depth -= 1; match local { - b"transition" | b"recurringdaytransition" | b"recurringdatetransition" + b"transition" + | b"recurringdaytransition" + | b"recurringdatetransition" | b"absolutedatetransition" => { if let Some(t) = self.transition.take().and_then(TransitionBuilder::finish) { match self.group.as_mut() { @@ -348,8 +357,15 @@ impl ZoneDefinitionWalker { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum On { /// The `nth` `weekday` of `month`; negative counts from the month's end. - NthWeekday { month: u32, weekday: Weekday, nth: i8 }, - Date { month: u32, day: u32 }, + NthWeekday { + month: u32, + weekday: Weekday, + nth: i8, + }, + Date { + month: u32, + day: u32, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -396,7 +412,8 @@ impl TryFrom<&ZoneDefinition> for ZoneRules { .iter() .find(|p| p.id.trim() == id.trim()) .ok_or_else(|| DefinitionError::UnknownTarget(id.trim().to_string()))?; - parse_duration(&period.bias).ok_or_else(|| DefinitionError::BadBias(period.bias.clone())) + parse_duration(&period.bias) + .ok_or_else(|| DefinitionError::BadBias(period.bias.clone())) }; let group_rule = |id: &str| -> Result { let group = def @@ -595,13 +612,16 @@ impl ZoneRules { // next change; a reading `local + bias` counts when it falls // inside the segment of that bias. let mut out = Vec::new(); - let bounds: Vec<(Option, Duration)> = std::iter::once((None, opening)) - .chain(steps.iter().map(|(at, to)| (Some(*at), *to))) - .collect(); + let bounds: Vec<(Option, Duration)> = + std::iter::once((None, opening)) + .chain(steps.iter().map(|(at, to)| (Some(*at), *to))) + .collect(); for (k, (start, bias)) in bounds.iter().enumerate() { let reading = local + *bias; let after_start = start.is_none_or(|s| reading >= s); - let before_end = bounds.get(k + 1).is_none_or(|(next, _)| next.is_none_or(|n| reading < n)); + let before_end = bounds + .get(k + 1) + .is_none_or(|(next, _)| next.is_none_or(|n| reading < n)); if after_start && before_end { out.push(reading); } @@ -626,10 +646,16 @@ impl ZoneRules { fn change_day(on: On, year: i32) -> Option { match on { On::Date { month, day } => NaiveDate::from_ymd_opt(year, month, day), - On::NthWeekday { month, weekday, nth } if nth > 0 => { - NaiveDate::from_weekday_of_month_opt(year, month, weekday, nth as u8) - } - On::NthWeekday { month, weekday, nth } => { + On::NthWeekday { + month, + weekday, + nth, + } if nth > 0 => NaiveDate::from_weekday_of_month_opt(year, month, weekday, nth as u8), + On::NthWeekday { + month, + weekday, + nth, + } => { // Counted from the month's end: -1 is its last such weekday. let first_next = if month == 12 { NaiveDate::from_ymd_opt(year + 1, 1, 1)? @@ -688,7 +714,11 @@ pub fn parse_duration(text: &str) -> Option { if time.is_empty() { return None; } - take(time, &[('H', 3_600_000), ('M', 60_000), ('S', 1_000)], Some('S'))?; + take( + time, + &[('H', 3_600_000), ('M', 60_000), ('S', 1_000)], + Some('S'), + )?; } if !any { return None; @@ -738,7 +768,11 @@ fn parse_occurrence(text: &str) -> Result { /// a value with a zone is refused. fn parse_naive(text: &str) -> Option { let text = text.trim(); - if text.ends_with('Z') || text.get(10..).is_some_and(|t| t.contains('+') || t.contains('-')) { + if text.ends_with('Z') + || text + .get(10..) + .is_some_and(|t| t.contains('+') || t.contains('-')) + { return None; } NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f").ok() @@ -801,12 +835,27 @@ pub(crate) mod tests { #[test] fn w_europe_midnights_follow_the_clock_change() { let rules = ZoneRules::try_from(&w_europe()).unwrap(); - assert_eq!(rules.midnight(day("2026-10-12")), Some(utc("2026-10-11T22:00:00Z"))); - assert_eq!(rules.midnight(day("2026-11-02")), Some(utc("2026-11-01T23:00:00Z"))); + assert_eq!( + rules.midnight(day("2026-10-12")), + Some(utc("2026-10-11T22:00:00Z")) + ); + assert_eq!( + rules.midnight(day("2026-11-02")), + Some(utc("2026-11-01T23:00:00Z")) + ); // The days of the changes themselves. - assert_eq!(rules.midnight(day("2026-03-29")), Some(utc("2026-03-28T23:00:00Z"))); - assert_eq!(rules.midnight(day("2026-10-25")), Some(utc("2026-10-24T22:00:00Z"))); - assert_eq!(rules.midnight(day("2026-10-26")), Some(utc("2026-10-25T23:00:00Z"))); + assert_eq!( + rules.midnight(day("2026-03-29")), + Some(utc("2026-03-28T23:00:00Z")) + ); + assert_eq!( + rules.midnight(day("2026-10-25")), + Some(utc("2026-10-24T22:00:00Z")) + ); + assert_eq!( + rules.midnight(day("2026-10-26")), + Some(utc("2026-10-25T23:00:00Z")) + ); } #[test] @@ -814,14 +863,26 @@ pub(crate) mod tests { let mut def = w_europe(); def.groups[0].transitions.reverse(); let rules = ZoneRules::try_from(&def).unwrap(); - assert_eq!(rules.midnight(day("2026-10-12")), Some(utc("2026-10-11T22:00:00Z"))); - assert_eq!(rules.midnight(day("2026-11-02")), Some(utc("2026-11-01T23:00:00Z"))); + assert_eq!( + rules.midnight(day("2026-10-12")), + Some(utc("2026-10-11T22:00:00Z")) + ); + assert_eq!( + rules.midnight(day("2026-11-02")), + Some(utc("2026-11-01T23:00:00Z")) + ); } #[test] fn the_wall_clock_reads_back_the_midnight() { let rules = ZoneRules::try_from(&w_europe()).unwrap(); - for d in ["2026-01-15", "2026-03-29", "2026-07-01", "2026-10-25", "2026-12-31"] { + for d in [ + "2026-01-15", + "2026-03-29", + "2026-07-01", + "2026-10-25", + "2026-12-31", + ] { let m = rules.midnight(day(d)).unwrap(); assert_eq!(rules.wall(m), day(d).and_hms_opt(0, 0, 0), "{d}"); } @@ -845,8 +906,14 @@ pub(crate) mod tests { transitions: vec![to_group("0")], }; let rules = ZoneRules::try_from(&def).unwrap(); - assert_eq!(rules.midnight(day("2026-01-01")), Some(utc("2025-12-31T13:00:00Z"))); - assert_eq!(rules.midnight(day("2026-06-01")), Some(utc("2026-05-31T14:00:00Z"))); + assert_eq!( + rules.midnight(day("2026-01-01")), + Some(utc("2025-12-31T13:00:00Z")) + ); + assert_eq!( + rules.midnight(day("2026-06-01")), + Some(utc("2026-05-31T14:00:00Z")) + ); } /// Chile changes at midnight: 23:59:59.999 on the Saturday, so Sunday's @@ -867,7 +934,10 @@ pub(crate) mod tests { transitions: vec![to_group("0")], }; let rules = ZoneRules::try_from(&def).unwrap(); - assert_eq!(rules.midnight(day("2026-09-06")), Some(utc("2026-09-06T04:00:00Z"))); + assert_eq!( + rules.midnight(day("2026-09-06")), + Some(utc("2026-09-06T04:00:00Z")) + ); } #[test] @@ -884,7 +954,10 @@ pub(crate) mod tests { transitions: vec![to_group("0")], }; let rules = ZoneRules::try_from(&def).unwrap(); - assert_eq!(rules.midnight(day("2026-10-12")), Some(utc("2026-10-11T18:30:00Z"))); + assert_eq!( + rules.midnight(day("2026-10-12")), + Some(utc("2026-10-11T18:30:00Z")) + ); let kiribati = ZoneDefinition { periods: vec![period("line", "-PT14H")], groups: vec![], @@ -894,7 +967,10 @@ pub(crate) mod tests { }], }; let rules = ZoneRules::try_from(&kiribati).unwrap(); - assert_eq!(rules.midnight(day("2026-10-12")), Some(utc("2026-10-11T10:00:00Z"))); + assert_eq!( + rules.midnight(day("2026-10-12")), + Some(utc("2026-10-11T10:00:00Z")) + ); } /// Microsoft's Eastern Standard Time sample: one rule until 2006, another @@ -936,13 +1012,25 @@ pub(crate) mod tests { }; let rules = ZoneRules::try_from(&def).unwrap(); // 20 March 2006: before the April change, standard time. - assert_eq!(rules.midnight(day("2006-03-20")), Some(utc("2006-03-20T05:00:00Z"))); + assert_eq!( + rules.midnight(day("2006-03-20")), + Some(utc("2006-03-20T05:00:00Z")) + ); // 20 March 2007: after the second-Sunday change, daylight time. - assert_eq!(rules.midnight(day("2007-03-20")), Some(utc("2007-03-20T04:00:00Z"))); + assert_eq!( + rules.midnight(day("2007-03-20")), + Some(utc("2007-03-20T04:00:00Z")) + ); // 2 November 2006 (after the last Sunday of October): standard. - assert_eq!(rules.midnight(day("2006-11-02")), Some(utc("2006-11-02T05:00:00Z"))); + assert_eq!( + rules.midnight(day("2006-11-02")), + Some(utc("2006-11-02T05:00:00Z")) + ); // 2 November 2007 (before the first Sunday of November): daylight. - assert_eq!(rules.midnight(day("2007-11-02")), Some(utc("2007-11-02T04:00:00Z"))); + assert_eq!( + rules.midnight(day("2007-11-02")), + Some(utc("2007-11-02T04:00:00Z")) + ); } #[test] @@ -952,7 +1040,10 @@ pub(crate) mod tests { *occurrence = "5".into(); } let rules = ZoneRules::try_from(&def).unwrap(); - assert_eq!(rules.midnight(day("2026-11-02")), Some(utc("2026-11-01T23:00:00Z"))); + assert_eq!( + rules.midnight(day("2026-11-02")), + Some(utc("2026-11-01T23:00:00Z")) + ); } #[test] @@ -974,7 +1065,9 @@ pub(crate) mod tests { ); let mut weekday = w_europe(); - if let TransitionDef::RecurringDay { day_of_week, .. } = &mut weekday.groups[0].transitions[0] { + if let TransitionDef::RecurringDay { day_of_week, .. } = + &mut weekday.groups[0].transitions[0] + { *day_of_week = "Weekday".into(); } assert_eq!( @@ -984,7 +1077,10 @@ pub(crate) mod tests { let mut no_periods = w_europe(); no_periods.periods.clear(); - assert_eq!(ZoneRules::try_from(&no_periods), Err(DefinitionError::NoPeriods)); + assert_eq!( + ZoneRules::try_from(&no_periods), + Err(DefinitionError::NoPeriods) + ); let mut zoned = w_europe(); zoned.transitions.push(TransitionDef::Absolute { diff --git a/crates/cal-core/src/write_refusal.rs b/crates/cal-core/src/write_refusal.rs index 4a50f1ff..df63ded5 100644 --- a/crates/cal-core/src/write_refusal.rs +++ b/crates/cal-core/src/write_refusal.rs @@ -52,6 +52,12 @@ pub enum WriteRefusal { /// is the same sentence whichever way the account was touched. The /// detail names the store and its state for the log. AccessNotGranted, + /// An all-day appointment's day could not be changed, because the provider + /// stores it in a time zone Aperio cannot read, so its midnights are + /// unknown (decision 237). Its title and the rest still save. The detail + /// is a machine token for the log: `no-zone` (no zone, or an id with no + /// definition), `no-midnight` (the zone has no midnight that day). + DayZoneUnreadable, } impl WriteRefusal { @@ -64,6 +70,7 @@ impl WriteRefusal { Self::OccurrenceNotWritable => "occurrence-not-writable", Self::UnsafeToWrite => "unsafe-to-write", Self::AccessNotGranted => "access-not-granted", + Self::DayZoneUnreadable => "day-zone-unreadable", } } @@ -106,6 +113,7 @@ impl WriteRefusal { Self::OccurrenceNotWritable, Self::UnsafeToWrite, Self::AccessNotGranted, + Self::DayZoneUnreadable, ] { let token = refusal.token(); let rest = match message.strip_prefix(token) { From 142604e205b9d270a64365955dfb7b5d8efb9a3a Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 04:14:50 +0200 Subject: [PATCH 04/18] Tests for the Exchange writer, and the day-zone-unreadable sentence on both surfaces Writer tests pin the device clock: an all-day series starts on its day in Berlin, Tokyo, Auckland, Honolulu and New York; a zoned series on its wall clock's day; a zoneless one, or one in a zone the server cannot store, on the UTC day; a rule-only edit carries the right StartDate and a title-only save writes no rule; an all-day day moves on the stored zones' midnights, each boundary in its own, a custom zone by its definition; an unknown zone is refused and a title still saves. Through the adapter: a custom zone's day moves, an id-only one is refused as Forbidden with nothing sent, and without the copy the read's own error stands. The new refusal gets its German and English sentences (237) and its place in eventWriteError.ts; the clippy finding in the duration parser is gone. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/lib.rs | 172 +++++++++ crates/adapter-ews/src/mapping.rs | 434 ++++++++++++++++++++++ crates/adapter-ews/src/zone_definition.rs | 2 +- crates/cal-core/bindings/WriteRefusal.ts | 2 +- locales/de/translation.json | 2 + locales/en/translation.json | 2 + shared/eventWriteError.ts | 2 + shared/generated/WriteRefusal.ts | 2 +- src/state/eventWriteError.test.ts | 19 + 9 files changed, 634 insertions(+), 3 deletions(-) diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index c7701f17..982c47e2 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -3023,4 +3023,176 @@ mod server_zone_tests { ["read", "no zone"], ); } + + /// An all-day single as Exchange stores it, its start zone given as + /// `zone_xml`, on Monday 12 October in W. Europe. + fn stored_single(zone_xml: &str) -> String { + format!( + r#" + + + + NoError + + + Custom + 2026-10-11T22:00:00Z + 2026-10-12T22:00:00Z + true + Single + {zone_xml} + + + +"# + ) + } + + /// A custom zone with W. Europe's rules, as an Exchange ActiveSync + /// client leaves it. + const CUSTOM_W_EUROPE: &str = r#" + + + + + + c/dstPT2H3Sunday-1 + c/stdPT3H10Sunday-1 + + 0 + "#; + + /// The same appointment moved to Tuesday 13 October, on this device's + /// midnights (an all-day day is kept that way). + fn moved_to_tuesday() -> Event { + let stamp: chrono::DateTime = "2026-10-01T00:00:00Z".parse().unwrap(); + let midnight = |d: &str| { + crate::mapping::local_midnight_in(d.parse().unwrap(), &chrono::Local).unwrap() + }; + Event { + keep_attendees: false, + keep_fields: Vec::new(), + clear_attendees: false, + organized_elsewhere: false, + id: "S:IID|CK".into(), + calendar_id: "FA|FCK".into(), + title: "Custom".into(), + description: None, + location: None, + start: midnight("2026-10-13"), + end: midnight("2026-10-14"), + all_day: true, + recurrence: None, + color_label: None, + color_hex: None, + reminders: Vec::new(), + sound: None, + attendees: Vec::new(), + send_invitations: false, + truncate_tail_overrides: false, + created_at: stamp, + updated_at: stamp, + etag: Some("CK".into()), + organizer: None, + attendee_responses: Vec::new(), + cancelled: false, + scheduling_silenced: false, + } + } + + async fn serve_stored(server: &mut Server, stored: String) -> Arc>> { + let requests = Arc::new(StdMutex::new(Vec::::new())); + let seen = Arc::clone(&requests); + server + .mock("POST", "/") + .with_status(200) + .with_body_from_request(move |request| { + let body = request.utf8_lossy_body().unwrap().into_owned(); + let answer = if body.contains("") { + stored.clone() + } else { + CREATED.to_string() + }; + seen.lock().unwrap().push(body); + answer.into_bytes() + }) + .create_async() + .await; + requests + } + + /// Decisions 232 and 47a through the adapter: a custom zone's day moves + /// on the midnights its definition gives, with no zone written. + #[tokio::test] + async fn an_all_day_day_in_a_custom_zone_moves_on_its_definition() { + let mut server = Server::new_async().await; + let requests = serve_stored(&mut server, stored_single(CUSTOM_W_EUROPE)).await; + let adapter = EwsAdapter::new(server.url(), alice()); + adapter + .update_event(moved_to_tuesday()) + .await + .expect("update"); + let requests = requests.lock().unwrap(); + let update = requests + .iter() + .find(|b| b.contains("UpdateItem")) + .expect("an UpdateItem"); + assert!( + update.contains("2026-10-12T22:00:00Z") + && update.contains("2026-10-13T22:00:00Z"), + "{update}" + ); + assert!(!update.contains("TimeZone"), "{update}"); + } + + /// Decision 237 through the adapter: a custom zone Exchange names by id + /// alone has no clock; the day is refused by name, carried as Forbidden + /// so the phone keeps the sentence, and nothing is sent. + #[tokio::test] + async fn an_all_day_day_in_an_unreadable_zone_is_refused_and_nothing_is_sent() { + let mut server = Server::new_async().await; + let requests = serve_stored( + &mut server, + stored_single(r#""#), + ) + .await; + let adapter = EwsAdapter::new(server.url(), alice()); + let err = adapter.update_event(moved_to_tuesday()).await.unwrap_err(); + let CoreError::Forbidden(message) = err else { + panic!("expected Forbidden, got {err:?}"); + }; + assert_eq!( + cal_core::WriteRefusal::parse(&message), + Some((cal_core::WriteRefusal::DayZoneUnreadable, "no-zone")) + ); + assert!( + !requests + .lock() + .unwrap() + .iter() + .any(|b| b.contains("UpdateItem")), + "nothing is sent" + ); + } + + /// Without the copy no stored zone is known, and an all-day day is not + /// written blind: the user hears what kept the copy away. + #[tokio::test] + async fn an_all_day_day_is_not_written_without_the_copy() { + let mut server = Server::new_async().await; + let requests = serve_stored(&mut server, CREATED.to_string()).await; + let adapter = EwsAdapter::new(server.url(), alice()); + let err = adapter.update_event(moved_to_tuesday()).await.unwrap_err(); + assert!(matches!(err, CoreError::NotFound(_)), "{err:?}"); + assert!( + !requests + .lock() + .unwrap() + .iter() + .any(|b| b.contains("UpdateItem")), + "nothing is sent" + ); + } } diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 86a72578..aafbd955 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -9204,4 +9204,438 @@ mod tests { assert!(body.contains(r#""#)); assert!(body.contains("Text")); } + + // ── PR 8a: the date bug and 47a ───────────────────────────────────── + // + // Every test here pins the device clock through the `_in` builders, so it + // reads the same on CI (UTC), on Toni's machine (Berlin) and anywhere else. + + fn midnight_on(day: &str, device: &D) -> DateTime { + local_midnight_in(day.parse().unwrap(), device).unwrap() + } + + /// The `` text of the first element named `tag` in `xml`. + fn element<'a>(xml: &'a str, tag: &str) -> &'a str { + let open = format!(""); + let start = xml + .find(&open) + .unwrap_or_else(|| panic!("no {tag} in {xml}")) + + open.len(); + let end = start + xml[start..].find(" NewEvent { + let mut ev = new_event_min("Monday"); + ev.all_day = true; + ev.start = midnight_on("2026-10-19", device); + ev.end = midnight_on("2026-10-20", device); + ev.recurrence = Some(EventRecurrence { + rrule: rrule.into(), + exceptions: Vec::new(), + tzid: None, + }); + ev + } + + /// An all-day series starts on the day it names on every device: read + /// off the UTC date, Monday 19 October named Sunday 18 east of UTC, and + /// the series started there (live round 3, R3-6). + #[test] + fn an_all_day_series_starts_on_the_day_it_names() { + use chrono_tz::{America, Asia, Europe, Pacific}; + for device in [ + Europe::Berlin, + Asia::Tokyo, + Pacific::Auckland, + Pacific::Honolulu, + America::New_York, + ] { + let create = |rrule: &str| { + new_event_to_calendar_item_xml_in(&all_day_new(&device, rrule), None, &device) + .unwrap() + }; + let daily = create("FREQ=DAILY;COUNT=4"); + assert_eq!(element(&daily, "StartDate"), "2026-10-19", "{device}"); + // The day goes out as UTC midnight: Aperio creates it without a zone. + assert_eq!(element(&daily, "Start"), "2026-10-19T00:00:00Z", "{device}"); + assert_eq!(element(&daily, "End"), "2026-10-20T00:00:00Z", "{device}"); + assert_eq!( + element(&create("FREQ=WEEKLY"), "DaysOfWeek"), + "Monday", + "{device}" + ); + assert_eq!( + element(&create("FREQ=MONTHLY"), "DayOfMonth"), + "19", + "{device}" + ); + let yearly = create("FREQ=YEARLY"); + assert_eq!(element(&yearly, "Month"), "October", "{device}"); + assert_eq!(element(&yearly, "DayOfMonth"), "19", "{device}"); + } + } + + /// A zoned timed series starts on its wall clock's day: Monday 00:30 in + /// Berlin is Sunday in UTC, Monday 20:00 in New York is Tuesday in UTC. + #[test] + fn a_zoned_series_starts_on_its_wall_clock_day() { + let device = chrono_tz::Europe::Berlin; + for (tzid, start) in [ + ("Europe/Berlin", "2026-10-18T22:30:00Z"), + ("America/New_York", "2026-10-20T00:00:00Z"), + ("Asia/Tokyo", "2026-10-18T23:00:00Z"), + ("Pacific/Honolulu", "2026-10-20T01:00:00Z"), + ] { + let mut ev = new_event_min("Weekly"); + ev.start = start.parse().unwrap(); + ev.end = ev.start + chrono::Duration::minutes(30); + ev.recurrence = Some(EventRecurrence { + rrule: "FREQ=WEEKLY".into(), + exceptions: Vec::new(), + tzid: Some(tzid.into()), + }); + let xml = new_event_to_calendar_item_xml_in(&ev, None, &device).unwrap(); + assert_eq!(element(&xml, "StartDate"), "2026-10-19", "{tzid}"); + assert_eq!(element(&xml, "DaysOfWeek"), "Monday", "{tzid}"); + assert!(xml.contains(" StoredZones { + StoredZones { + start: Some(DayZone::Tz(chrono_tz::Europe::Berlin)), + end: Some(DayZone::Tz(chrono_tz::Europe::Berlin)), + } + } + + fn all_day_master(device: &chrono_tz::Tz, rrule: &str) -> Event { + let mut ev = zoned_master(None); + ev.all_day = true; + ev.start = midnight_on("2026-10-19", device); + ev.end = midnight_on("2026-10-20", device); + ev.recurrence = Some(EventRecurrence { + rrule: rrule.into(), + exceptions: Vec::new(), + tzid: None, + }); + ev + } + + /// An Outlook all-day series whose rule alone changes starts on its day: + /// the start does not move, so no slot is written, but the rule carries + /// the right StartDate. + #[test] + fn a_rule_only_edit_of_an_all_day_series_starts_on_its_day() { + use chrono_tz::{Asia, Europe, Pacific}; + for device in [Europe::Berlin, Asia::Tokyo, Pacific::Auckland] { + let before = all_day_master(&device, "FREQ=WEEKLY;BYDAY=MO"); + let mut edit = before.clone(); + edit.recurrence.as_mut().unwrap().rrule = "FREQ=DAILY;COUNT=4".into(); + let (set, _) = event_to_update_field_xml_in( + &edit, + Some(&before), + &w_europe_zones(), + None, + EventIdKind::RecurringMaster, + &device, + ) + .unwrap(); + assert_eq!(element(&set, "StartDate"), "2026-10-19", "{device}: {set}"); + assert!( + !set.contains("calendar:Start\""), + "{device}: no slot: {set}" + ); + } + } + + /// A title-only save of an all-day series writes no rule: both sides of + /// the comparison build the rule on the same first day. + #[test] + fn a_title_only_save_of_an_all_day_series_writes_no_rule() { + use chrono_tz::{Asia, Europe, Pacific}; + for device in [ + Europe::Berlin, + Asia::Tokyo, + Pacific::Auckland, + Pacific::Honolulu, + ] { + for stored in [w_europe_zones(), StoredZones::utc()] { + let before = all_day_master(&device, "FREQ=WEEKLY"); + let mut edit = before.clone(); + edit.title = "Renamed".into(); + let (set, del) = event_to_update_field_xml_in( + &edit, + Some(&before), + &stored, + None, + EventIdKind::RecurringMaster, + &device, + ) + .unwrap(); + assert!(set.contains("item:Subject"), "{device}: {set}"); + assert!( + !set.contains("Recurrence") && !del.contains("Recurrence"), + "{device}: {set}" + ); + } + } + } + + fn moved_single(device: &chrono_tz::Tz) -> (Event, Event) { + let mut before = zoned_master(None); + before.recurrence = None; + before.all_day = true; + before.start = midnight_on("2026-10-19", device); + before.end = midnight_on("2026-10-20", device); + let mut edit = before.clone(); + edit.start = midnight_on("2026-10-20", device); + edit.end = midnight_on("2026-10-21", device); + (before, edit) + } + + fn slot_of(set: &str) -> (&str, &str) { + (element(set, "Start"), element(set, "End")) + } + + /// Decision 47a: an all-day day moves on the midnights of the zone + /// Exchange stores the item in, on any device; each boundary in its own + /// zone (233). Stored in UTC, the bytes are what Aperio always wrote. + #[test] + fn an_all_day_day_moves_on_the_stored_zones_midnights() { + use chrono_tz::{Asia, Europe, Pacific}; + let zones = |start: chrono_tz::Tz, end: chrono_tz::Tz| StoredZones { + start: Some(DayZone::Tz(start)), + end: Some(DayZone::Tz(end)), + }; + let cases = [ + // Measured good live (R3-3): W. Europe, Berlin device. + ( + Europe::Berlin, + zones(Europe::Berlin, Europe::Berlin), + "2026-10-19T22:00:00Z", + "2026-10-20T22:00:00Z", + ), + // The Tokyo item edited in Berlin. + ( + Europe::Berlin, + zones(Asia::Tokyo, Asia::Tokyo), + "2026-10-19T15:00:00Z", + "2026-10-20T15:00:00Z", + ), + // Devices far from the stored zone. + ( + Pacific::Auckland, + zones(Europe::Berlin, Europe::Berlin), + "2026-10-19T22:00:00Z", + "2026-10-20T22:00:00Z", + ), + ( + Pacific::Honolulu, + zones(Europe::Berlin, Europe::Berlin), + "2026-10-19T22:00:00Z", + "2026-10-20T22:00:00Z", + ), + // Two zones: each boundary on its own (233). + ( + Europe::Berlin, + zones(Asia::Tokyo, Pacific::Honolulu), + "2026-10-19T15:00:00Z", + "2026-10-21T10:00:00Z", + ), + // Stored in UTC: unchanged bytes. + ( + Europe::Berlin, + StoredZones::utc(), + "2026-10-20T00:00:00Z", + "2026-10-21T00:00:00Z", + ), + ]; + for (device, stored, start, end) in cases { + let (before, edit) = moved_single(&device); + let (set, _) = event_to_update_field_xml_in( + &edit, + Some(&before), + &stored, + None, + EventIdKind::Single, + &device, + ) + .unwrap(); + assert_eq!(slot_of(&set), (start, end), "{device} {stored:?}: {set}"); + assert!(!set.contains("TimeZone"), "no zone is written: {set}"); + } + } + + /// A zone Exchange defines in full moves the day as its named twin does. + #[test] + fn an_all_day_day_in_a_custom_zone_moves_on_its_definitions_midnights() { + let device = chrono_tz::Europe::Berlin; + let rules = + crate::zone_definition::ZoneRules::try_from(&crate::zone_definition::tests::w_europe()) + .unwrap(); + let custom = DayZone::Rules(std::sync::Arc::new(rules)); + let stored = StoredZones { + start: Some(custom.clone()), + end: Some(custom), + }; + let (before, edit) = moved_single(&device); + let (set, _) = event_to_update_field_xml_in( + &edit, + Some(&before), + &stored, + None, + EventIdKind::Single, + &device, + ) + .unwrap(); + assert_eq!( + slot_of(&set), + ("2026-10-19T22:00:00Z", "2026-10-20T22:00:00Z"), + "{set}" + ); + } + + /// Switching a timed appointment to all-day goes on the stored zone's + /// midnights too (decision 235). + #[test] + fn a_timed_appointment_made_all_day_lands_on_the_stored_zones_midnights() { + let device = chrono_tz::Europe::Berlin; + let mut before = zoned_master(None); + before.recurrence = None; + before.start = "2026-10-19T08:00:00Z".parse().unwrap(); + before.end = "2026-10-19T09:00:00Z".parse().unwrap(); + let mut edit = before.clone(); + edit.all_day = true; + edit.start = midnight_on("2026-10-19", &device); + edit.end = midnight_on("2026-10-20", &device); + let (set, _) = event_to_update_field_xml_in( + &edit, + Some(&before), + &w_europe_zones(), + None, + EventIdKind::Single, + &device, + ) + .unwrap(); + assert_eq!( + slot_of(&set), + ("2026-10-18T22:00:00Z", "2026-10-19T22:00:00Z"), + "{set}" + ); + } + + /// Decision 237: an all-day day in a zone Aperio cannot read is refused, + /// by name; a save that leaves the day alone still goes out. + #[test] + fn an_all_day_day_in_an_unknown_zone_is_refused_and_a_title_is_not() { + let device = chrono_tz::Europe::Berlin; + let unknown = StoredZones::default(); + let (before, edit) = moved_single(&device); + let err = event_to_update_field_xml_in( + &edit, + Some(&before), + &unknown, + None, + EventIdKind::Single, + &device, + ) + .unwrap_err(); + let EwsError::Protocol(message) = err else { + panic!("expected a refusal, got {err:?}"); + }; + assert_eq!( + cal_core::WriteRefusal::parse(&message), + Some((cal_core::WriteRefusal::DayZoneUnreadable, "no-zone")) + ); + let mut renamed = before.clone(); + renamed.title = "Renamed".into(); + let (set, _) = event_to_update_field_xml_in( + &renamed, + Some(&before), + &unknown, + None, + EventIdKind::Single, + &device, + ) + .unwrap(); + assert!( + set.contains("item:Subject") && !set.contains("calendar:Start"), + "{set}" + ); + } + + /// Decision 234: an update that makes a timed appointment a series and + /// names a new zone starts the rule on the day the STORED zone reads: + /// Exchange applies the rule before the zone the same update writes after + /// it. Aperio created the single in UTC; Monday 00:30 in Berlin is Sunday + /// there. (That zone, sent after the start, moves the wall clock as well — + /// live round 1, stages 9 and 12; the live test of 8a looks at it.) + #[test] + fn a_new_rule_with_a_new_zone_starts_on_the_stored_zones_day() { + let device = chrono_tz::Europe::Berlin; + let mut before = zoned_master(None); + before.recurrence = None; + before.start = "2026-10-18T22:30:00Z".parse().unwrap(); + before.end = "2026-10-18T23:00:00Z".parse().unwrap(); + let mut edit = before.clone(); + edit.recurrence = Some(EventRecurrence { + rrule: "FREQ=WEEKLY".into(), + exceptions: Vec::new(), + tzid: Some("Europe/Berlin".into()), + }); + let (set, _) = event_to_update_field_xml_in( + &edit, + Some(&before), + &StoredZones::utc(), + None, + EventIdKind::Single, + &device, + ) + .unwrap(); + assert_eq!(element(&set, "StartDate"), "2026-10-18", "{set}"); + assert!( + set.contains(r#""#), + "{set}" + ); + } } diff --git a/crates/adapter-ews/src/zone_definition.rs b/crates/adapter-ews/src/zone_definition.rs index 23f26bfe..828cadc2 100644 --- a/crates/adapter-ews/src/zone_definition.rs +++ b/crates/adapter-ews/src/zone_definition.rs @@ -692,7 +692,7 @@ pub fn parse_duration(text: &str) -> Option { let mut number = String::new(); let mut unit_at = 0usize; for c in part.chars() { - if c.is_ascii_digit() || (Some(c) != None && c == '.' && fraction_on.is_some()) { + if c.is_ascii_digit() || (c == '.' && fraction_on.is_some()) { number.push(c); continue; } diff --git a/crates/cal-core/bindings/WriteRefusal.ts b/crates/cal-core/bindings/WriteRefusal.ts index cf85c3dd..94d51eab 100644 --- a/crates/cal-core/bindings/WriteRefusal.ts +++ b/crates/cal-core/bindings/WriteRefusal.ts @@ -3,4 +3,4 @@ /** * The reason a write did not happen. */ -export type WriteRefusal = "reply-only-invitation" | "server-refused" | "identity-unknown" | "occurrence-not-writable" | "unsafe-to-write" | "access-not-granted"; +export type WriteRefusal = "reply-only-invitation" | "server-refused" | "identity-unknown" | "occurrence-not-writable" | "unsafe-to-write" | "access-not-granted" | "day-zone-unreadable"; diff --git a/locales/de/translation.json b/locales/de/translation.json index e1c7914c..ba0a6fd3 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -1695,6 +1695,7 @@ "occurrenceNotWritable": "Dieser eine Termin lässt sich in seiner Serie nicht einzeln ändern. Es wurde nichts geändert.", "unsafeToWrite": "Aperio kann diesen Termin nicht sicher ändern: Was auf dem Server noch mit ihm gespeichert ist, stünde auf dem Spiel. Es wurde nichts geändert.", "accessNotGranted": "Aperio hat keinen Zugriff auf die Kalender und Erinnerungen dieses Geräts. Erlaube ihn in der App „Einstellungen“ bei Aperio. Es wurde nichts geändert.", + "dayZoneUnreadable": "Exchange speichert diesen Termin in einer Zeitzone, die Aperio nicht lesen kann. Seinen Tag kannst du deshalb hier nicht ändern, den Titel und anderes schon. Es wurde nichts geändert.", "forbidden": "Der Anbieter erlaubt diese Änderung nicht ({{detail}}). Es wurde nichts geändert.", "changedOnServer": "Dieser Termin wurde auf dem Server geändert, seit du ihn geöffnet hast. Es wurde nichts geändert. Öffne ihn erneut, um den aktuellen Stand zu sehen.", "reason": { @@ -1704,6 +1705,7 @@ "occurrenceNotWritable": "dieser eine Termin lässt sich in seiner Serie nicht einzeln ändern", "unsafeToWrite": "Aperio kann ihn auf dem Server nicht sicher ändern", "accessNotGranted": "Aperio hat keinen Zugriff auf die Kalender und Erinnerungen dieses Geräts", + "dayZoneUnreadable": "Exchange speichert ihn in einer Zeitzone, die Aperio nicht lesen kann, deshalb lässt sich sein Tag hier nicht ändern", "forbidden": "der Anbieter erlaubt es nicht ({{detail}})", "changedOnServer": "sie wurde auf dem Server geändert, seit du sie geöffnet hast" } diff --git a/locales/en/translation.json b/locales/en/translation.json index 369cf0bf..b5a99e85 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -1695,6 +1695,7 @@ "occurrenceNotWritable": "This one occurrence cannot be changed on its own inside its series. Nothing was changed.", "unsafeToWrite": "Aperio cannot change this event safely: what else is stored with it on the server would be put at risk. Nothing was changed.", "accessNotGranted": "Aperio has no access to this device's calendars and reminders. Allow it for Aperio in the Settings app. Nothing was changed.", + "dayZoneUnreadable": "Exchange stores this appointment in a time zone Aperio cannot read, so its day cannot be changed here; its title and the rest can. Nothing was changed.", "forbidden": "The provider does not allow this change ({{detail}}). Nothing was changed.", "changedOnServer": "This event changed on the server since you opened it. Nothing was changed. Open it again to see the current version.", "reason": { @@ -1704,6 +1705,7 @@ "occurrenceNotWritable": "this occurrence cannot be changed on its own inside its series", "unsafeToWrite": "Aperio cannot change it safely on the server", "accessNotGranted": "Aperio has no access to this device's calendars and reminders", + "dayZoneUnreadable": "Exchange stores it in a time zone Aperio cannot read, so its day cannot be changed here", "forbidden": "the provider does not allow it ({{detail}})", "changedOnServer": "it changed on the server since you opened it" } diff --git a/shared/eventWriteError.ts b/shared/eventWriteError.ts index efacdf17..44dbc1f1 100644 --- a/shared/eventWriteError.ts +++ b/shared/eventWriteError.ts @@ -19,6 +19,7 @@ const REFUSAL_KEYS: Record = { 'occurrence-not-writable': 'dialogs.event.writeError.occurrenceNotWritable', 'unsafe-to-write': 'dialogs.event.writeError.unsafeToWrite', 'access-not-granted': 'dialogs.event.writeError.accessNotGranted', + 'day-zone-unreadable': 'dialogs.event.writeError.dayZoneUnreadable', }; const TOKENS = Object.keys(REFUSAL_KEYS) as WriteRefusal[]; @@ -32,6 +33,7 @@ const REFUSAL_REASON_KEYS: Record = { 'occurrence-not-writable': 'dialogs.event.writeError.reason.occurrenceNotWritable', 'unsafe-to-write': 'dialogs.event.writeError.reason.unsafeToWrite', 'access-not-granted': 'dialogs.event.writeError.reason.accessNotGranted', + 'day-zone-unreadable': 'dialogs.event.writeError.reason.dayZoneUnreadable', }; /** An error as the hosts hand it over: a code and a message. */ diff --git a/shared/generated/WriteRefusal.ts b/shared/generated/WriteRefusal.ts index cf85c3dd..94d51eab 100644 --- a/shared/generated/WriteRefusal.ts +++ b/shared/generated/WriteRefusal.ts @@ -3,4 +3,4 @@ /** * The reason a write did not happen. */ -export type WriteRefusal = "reply-only-invitation" | "server-refused" | "identity-unknown" | "occurrence-not-writable" | "unsafe-to-write" | "access-not-granted"; +export type WriteRefusal = "reply-only-invitation" | "server-refused" | "identity-unknown" | "occurrence-not-writable" | "unsafe-to-write" | "access-not-granted" | "day-zone-unreadable"; diff --git a/src/state/eventWriteError.test.ts b/src/state/eventWriteError.test.ts index d08db17b..917de9bc 100644 --- a/src/state/eventWriteError.test.ts +++ b/src/state/eventWriteError.test.ts @@ -198,6 +198,25 @@ describe('a device store the OS has not opened', () => { }); }); +describe('an all-day day in a time zone Aperio cannot read', () => { + it('reads as its own refusal, behind the wrapper too, and as nothing written', () => { + // Decision 237: Exchange stores the appointment in a zone with no clock + // Aperio can compute, so its day is not moved; the title still saves. + const desktop = command('forbidden', 'day-zone-unreadable: no-zone'); + const phone = new Error( + "Calling the 'updateEventJson' function has failed\n→ Caused by: day-zone-unreadable: no-zone", + ); + for (const err of [desktop, phone]) { + expect(eventWriteRefusal(err)?.refusal).toBe('day-zone-unreadable'); + expect(eventWriteErrorMessage(err, t)).toMatch(/Zeitzone, die Aperio nicht lesen kann/); + expect(eventWriteErrorMessage(err, t)).toMatch(/den Titel und anderes schon/); + expect(eventWriteErrorMessage(err, t)).not.toMatch(/no-zone/); + } + expect(eventWriteFailureReason(desktop, t)).toMatch(/Zeitzone, die Aperio nicht lesen kann/); + expect(writeNeverLanded(desktop)).toBe(true); + }); +}); + describe('any write on the phone, not only the event editor', () => { it('says a refusal as its sentence and anything else as before', async () => { const { writeErrorMessage } = await import('@aperio/shared'); From 0b14b8485e233c7a9adaad192993c7e8ab3076ee Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 04:17:17 +0200 Subject: [PATCH 05/18] Docs for PR 8a: the first-day rule, 47a on the stored zones, zone definitions, the refusal ews.md says how a series starts on its first day, how an all-day day is written on the stored zones' midnights, how zone definitions are read, and when the day is refused. DESIGN-series-time-zone.md marks 47a and the Exchange half of stage 5 built and lists what 8a leaves unmeasured. TODO flags the series written before 8a (231), Microsoft 365's date bug and the zone-and-rule update that moves a timed appointment. The troubleshooting guide (German and English) explains the new sentence. The round-3 generator expects StartDate 19 for R3-6 now. Co-Authored-By: Claude Opus 5.5 --- DESIGN-series-time-zone.md | 42 ++++++++++---- TODO.md | 20 ++++++- crates/adapter-ews/src/mapping.rs | 10 +++- .../content/docs/de/guides/troubleshooting.md | 19 +++++++ .../content/docs/developers/adapters/ews.md | 56 ++++++++++++++++--- .../content/docs/guides/troubleshooting.md | 19 +++++++ 6 files changed, 142 insertions(+), 24 deletions(-) diff --git a/DESIGN-series-time-zone.md b/DESIGN-series-time-zone.md index 64ccfab6..c4bc3667 100644 --- a/DESIGN-series-time-zone.md +++ b/DESIGN-series-time-zone.md @@ -121,11 +121,16 @@ keinen, dort ist die Wahl Tonis Sache. deshalb nur diesen Schutz. Die Regel steht einmal im Kern, und die Adapter fragen sie. Lesefehler, Update-Regel und Datumsfehler werden eigene Stufen nach der dritten Runde. -- **47a — Änderungen behalten die gespeicherte Zone.** Ändert Aperio einen - ganztägigen Exchange-Termin, der schon eine Zone trägt, etwa aus Outlook, - gehen Beginn und Ende auf Mitternacht in dieser Zone. Die Zone selbst bleibt - unberührt. Das passt zu 18a und dazu, dass Unberührtes wörtlich bleibt. Die - dritte Runde bestätigt es vor dem Bau. +- **47a — Änderungen behalten die gespeicherte Zone.** *(gebaut, PR 8a)* + Ändert Aperio einen ganztägigen Exchange-Termin, der schon eine Zone trägt, + etwa aus Outlook, gehen Beginn und Ende auf Mitternacht in dieser Zone. Die + Zone selbst bleibt unberührt. Das passt zu 18a und dazu, dass Unberührtes + wörtlich bleibt. Die dritte Runde hat es bestätigt (R3-1, R3-3, R3-7b). Mit + PR 8a kam dazu: jede Grenze in ihrer eigenen gespeicherten Zone (233), auch + in einer Zone, deren Kennung tzdatas eigener Name ist (236), oder einer, die + Exchange vollständig definiert (232, 239); eine Ausnahme in den Zonen ihrer + Serie; ist eine Zone nicht lesbar, wird der Tag nicht geschrieben und die + Person hört den Satz aus 237. Der Titel geht trotzdem durch. - **48a — ganztägige Serien wiederholen sich an Kalendertagen.** *(gebaut)* Aperio wiederholte ganztägige Serien in UTC. Östlich von UTC landete ein genannter Wochentag deshalb einen Tag zu spät, bei jedem Anbieter außer @@ -635,9 +640,12 @@ der Serie (21a), der Rest behält die Zone der ganzen Serie. „Exchange kann die Zeitzone {Stadt} nicht speichern; die Serie steht auf UTC.“ Kopieren oder Verschieben nach Exchange wird mit der Zone im Grund abgelehnt. -- **Exchange und Microsoft 365** leiten Start- und Enddatum und die Standard-Tage - heute aus dem UTC-Datum ab. Beide müssen sie auf der Uhr der Serie lesen, bevor - die Editoren Beginn und UNTIL auf diese Uhr stellen. +- **Exchange** liest seit PR 8a den ersten Tag einer Serie auf der Uhr, auf der + Exchange sie wiederholt (`rule_first_day`): ganztägig der Tag des Geräts, + beim Anlegen die geschriebene Zone, beim Ändern die gespeicherte Startzone + (234), sonst UTC. **Microsoft 365** leitet Start- und Enddatum und die + Standard-Tage weiter aus dem UTC-Datum ab und muss sie auf der Uhr der Serie + lesen, bevor die Editoren Beginn und UNTIL auf diese Uhr stellen. - **Handy-Kalender** speichern keine Regel; die Auswahl ist dort nicht da. ### Nach dem Speichern @@ -880,7 +888,11 @@ Handy im selben PR. eigenen. Die verschobene Ausnahme steht richtig nur am Dienstag. - Aperio zeigt die beschädigte Serie einen Tag zu spät (Lesefehler, 48a). 5. **Exchange und Microsoft 365 lesen Serien-Daten auf der Uhr der Serie** — `fix(ews, graph): read a series' dates on its own clock when writing`. - Danach messen, wie Microsoft 365 recurrenceTimeZone beim Zurücklesen behandelt. + Der Exchange-Teil ist mit PR 8a gebaut, zusammen mit 47a (57a). Offen ist + Microsoft 365; danach messen, wie es recurrenceTimeZone beim Zurücklesen + behandelt. Serien, die Aperio schon mit falschem Starttag geschrieben hat, + bleiben, wie sie sind (231): einmal verschieben und Wochentag oder Tag in + der Wiederholung neu wählen. 6. **Kalender melden, welche Zonen sie speichern** — `feat(plugin-core): calendars declare which series time zones they can store`. `time_zones`: alle, keine (Handy-Kalender) oder nur bestimmte (Exchange). Das Handy muss dafür auch die eingebauten Manifeste lesen; ob der Desktop sie @@ -1048,7 +1060,17 @@ Handy im selben PR. Ungeprüft bleibt, ob Exchange eigene Zonendefinitionen und Namen, die nur in der Windows-Registry stehen (Kamchatka, Mid-Atlantic), so zurückgibt, wie - Aperio sie liest: als keine Zone. Ungeprüft ist auch, ob Exchanges eigene + Aperio sie liest: als keine Zone. Seit PR 8a liest ein ganztägiger Termin + die Definition selbst, wenn Exchange sie mitschickt; ob Exchange 2019 sie in + `SyncFolderItems` und `GetItem` füllt, misst der Live-Test von 8a. + Ungeprüft aus PR 8a sind außerdem: ob ein Ändern, das Wiederholung und eine + neue Zone zusammen schreibt, den ersten Tag in der alten Zone liest (234; + die neue Zone nach dem Beginn verschiebt ohnehin die Uhrzeit, Stufen 9 und + 12); ob Exchange ein Ende in der Endzone rundet, wenn Beginn und Ende + verschiedene Zonen tragen (233, R3-2 legt es nahe); was Exchange an einem Tag + speichert, dessen Mitternacht die gespeicherte Zone überspringt; ob + `EndDate` inklusive ist (ein Schnitt „dieser und alle folgenden“ mit + Uhrzeit zeigte den Schnitttag dann doppelt). Ungeprüft ist auch, ob Exchanges eigene Zeitzonen-Regeln denen von Windows gleichen, an denen der Uhr-Wächter über die Standardzonen gemessen ist. - Ob der Desktop die eingebauten Manifeste für die Wiederholungs-Fähigkeiten diff --git a/TODO.md b/TODO.md index 8caf8bf7..ada4071e 100644 --- a/TODO.md +++ b/TODO.md @@ -2993,9 +2993,25 @@ Siehe DESIGN §4.2. zurück. Das gilt nur, falls Exchange `task:Recurrence` beim Auflisten überhaupt liefert, und das ist ungeprüft (`tasks.rs`). Abhilfe wäre, jede Aufgabenliste einmal neu zu lesen, etwa über eine Version im Aufgaben-Token. - 🚩 **Update-Regel für ganztägige Exchange-Termine** (47a) und + ↻ **Update-Regel für ganztägige Exchange-Termine** (47a) und **Datumsfehler** (Startdatum, Wochentag, Monatstag und Monat aus dem - UTC-Datum): eigene PRs nach Runde 3. + UTC-Datum): gebaut in PR 8a (2026-10-10, Entscheidungen 231-239). Eine + Serie beginnt an ihrem ersten Tag auf der Uhr, auf der Exchange sie + wiederholt; ein ganztägiger Tag geht beim Ändern auf Mitternacht in der + gespeicherten Zone jeder Grenze; eigene Zonen-Definitionen werden gelesen; + eine unlesbare Zone lehnt den Tag mit eigenem Satz ab (237). Live-Test vor + dem Zusammenführen mit einem Desktop-Build (235). + 🚩 **Serien mit falschem Starttag** (231): Was Aperio vor 8a geschrieben hat, + bleibt so. Einmal verschieben und Wochentag oder Tag in der Wiederholung + neu wählen; eine wöchentliche Serie ohne gewählten Wochentag trägt + „Sonntag“ in der Regel, eine zweiwöchentliche kann eine Woche verrutscht + sein. + 🚩 **Microsoft 365: derselbe Datumsfehler** (235): eigener PR. + 🚩 **Zone und Wiederholung in einem Ändern:** Wird ein Termin mit Uhrzeit + zur Serie mit neuer Zone, setzt Exchange die Zone nach dem Beginn und + behält dabei die Uhrzeit, der Termin wandert um den Versatz (Runde 1, + Stufen 9 und 12). Seit 8a beginnt die Regel am Tag der alten Zone (234); + der Live-Test sieht nach. 🚩 **Open-Source-Hinweise** in der Desktop- und der Handy-App (40a): CLDR und die eingebauten ICU4X-Bibliotheken nennen. 🚩 **Wenn der EWS-Adapter das Repository verlässt,** müssen `cldr/`, die diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index aafbd955..6f4f2e6d 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -5575,14 +5575,18 @@ mod tests { ..all_day_single("Aperio zone test R3-6 daily") }; let daily_xml = new_event_to_calendar_item_xml(&daily).unwrap(); + // Since PR 8a the series starts on the day it names. The builder in + // round 3 sent StartDate 2026-10-18 here, and Exchange started the + // series on Sunday 18 (R3-6, DESIGN-series-time-zone.md). assert!( - daily_xml.contains("2026-10-18"), + daily_xml.contains("2026-10-19"), "{daily_xml}" ); write( "R3-6-create-daily.xml", - "Step R3-6: a daily all-day series from Aperio's create builder, as main sends it: \ - StartDate 2026-10-18 for Monday 19 October. On which day does Exchange start it?", + "Step R3-6: a daily all-day series from Aperio's create builder: StartDate \ + 2026-10-19 for Monday 19 October (round 3 sent 2026-10-18). On which day \ + does Exchange start it?", envelope(&daily_xml), ); } diff --git a/web/src/content/docs/de/guides/troubleshooting.md b/web/src/content/docs/de/guides/troubleshooting.md index 3e3c49a6..f99545da 100644 --- a/web/src/content/docs/de/guides/troubleshooting.md +++ b/web/src/content/docs/de/guides/troubleshooting.md @@ -175,6 +175,25 @@ selbst: Für Aperio sieht sie genauso aus wie eine Serie, die absichtlich in UTC geführt wird. Um eine betroffene Serie zu reparieren, lege sie neu an oder stelle ihre Zeitzone in dem Programm wieder ein, aus dem sie stammt. +## Der Tag eines ganztägigen Exchange-Termins lässt sich nicht ändern + +Aperio sagt dann: „Exchange speichert diesen Termin in einer Zeitzone, die +Aperio nicht lesen kann. Seinen Tag kannst du deshalb hier nicht ändern, den +Titel und anderes schon. Es wurde nichts geändert.“ + +Exchange legt einen ganztägigen Termin auf die Mitternächte der Zeitzone, in +der es ihn speichert. Damit der Termin einen Tag lang bleibt, schreibt Aperio +einen neuen Tag deshalb als Mitternacht in genau dieser Zeitzone. Meist kennt +Aperio sie: eine Windows-Zeitzone wie „W. Europe Standard Time“, einen Namen +wie „Europe/Berlin“ oder eine Zeitzone, die Exchange vollständig mitschickt, +etwa bei Terminen aus dem Kalender eines iPhones („Customized Time Zone“). +Schickt Exchange nur einen Namen, den Aperio nicht kennt, ohne ihre Regeln, +rät Aperio nicht: Sonst stünde der Termin danach in Outlook über zwei Tage. + +Was du tun kannst: Ändere den Tag in Outlook. Alles andere, etwa Titel, Ort +oder Erinnerung, kannst du in Aperio weiter ändern, solange du den Tag dabei +nicht verschiebst. + ## Einen Fehler melden 1. Stelle in Einstellungen → Protokolle die Stufe auf **Debug**. diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index e9ad2d50..01502249 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -49,14 +49,17 @@ The endpoint is discovered or user-supplied. an inherited value is wrong, a guessed one would be worse. - **An all-day exception names its slot by its day.** EWS reports an all-day item's instants, an occurrence's `OriginalStart` among them, as - midnight in the item's own zone — mostly: after Aperio's own write of an - Outlook item (UTC midnights, no zone) Exchange keeps the old zone's - midnights and labels them UTC (live round 3). The read samples the day - 13:45 into it, in the zone Exchange names — the start zone, read as the - series' zone is, UTC for a series made without one (`all_day_zone`, - decision 217) — which is exact for a midnight that zone names, and the - intended day for a label off by any offset in (−10:15, +13:45], New - Zealand's summer included. It re-anchors the start, the series' + midnight in the item's own zone — mostly: after a write by Aperio before + PR 8a (UTC midnights, no zone) Exchange kept the old zone's midnights and + labelled them UTC (live round 3). The read samples the day 13:45 into it, + in the zone Exchange names for that boundary (`all_day_zone` for the + start and the slots, `all_day_end_zone` for the end, decisions 217 and + 233): an id the CLDR table maps, an id that is tzdata's own name + (`Europe/Berlin`, 236), or a zone Exchange defines in full (a custom one + such as `Customized Time Zone`, read by `zone_definition`, 232 and 239); + UTC for an item made without a zone. That is exact for a midnight the zone + names, and the intended day for a label off by any offset in + (−10:15, +13:45], New Zealand's summer included. It re-anchors the start, the series' exceptions and the override id's slot to this device's local midnight of that day (`all_day_anchor`, `override_slot`, decision 215). Where Exchange names no zone the adapter can read, the sample is taken in UTC @@ -151,7 +154,9 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in `Asia/Kolkata`). Exchange keeps one id for a group of cities on one clock, so a Vienna series reads back as Berlin. `UTC`, and an id the table does not know (custom definitions, registry-only ids), mean no zone; the series - repeats in UTC. + repeats in UTC. An all-day boundary reads in more zones than a series' + clock does: an id that is tzdata's own name and a full definition count + there (see the all-day quirk above). - **Ids the server knows.** A server refuses a save naming an id it does not know (`ErrorTimeZone`, and the whole save fails); Exchange 2019 does not know `Sao Tome Standard Time`. The adapter asks each server once @@ -166,6 +171,39 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in through the core's rule for zones (`series_clock_zone`, then `canonical_zone`): no zone, a UTC name or an unknown name writes no zone; any spelling of a zone writes that zone's id. +- **A series starts on its first day** (PR 8a). `rrule_to_ews_recurrence` + takes the day the series starts on, on the clock Exchange repeats it on + (`rule_first_day`): the device's day for an all-day series, the written + zone's day on a create, the stored start zone's day on an update (234: + Exchange applies the rule before a zone the same update writes), UTC where + none is known. The range's StartDate and the weekday, day of the month and + month of a rule without BYDAY, BYMONTHDAY or BYMONTH come from that day. + Read off the UTC date, as before, they named the day before east of UTC: a + daily all-day series created in Berlin for Monday 19 October started on + Sunday 18 (R3-6). A series already stored that way stays so (231); moving + it and choosing its weekday or day again in the repeat picker mends it. +- **An all-day day is written on the stored zone's midnights** (47a). An + update writes an all-day boundary as midnight, in the zone Exchange stores + that boundary in, of the day the device names (`all_day_boundary`; each + boundary in its own zone, 233). Exchange rounds a boundary in its stored + zone (R3-5b-u), so the UTC midnights Aperio wrote before stretched an + Outlook item over two days. `read_before` returns the zones with the copy + (`StoredZones`); an exception takes its series' ids and definitions. A + create still writes UTC midnights: Aperio creates without a zone, so + Exchange stores the item in UTC. Where a boundary's zone cannot be read, + or the copy could not be read, the day is not written: the update is + refused as `day-zone-unreadable` (237), carried as `Forbidden` so the + phone keeps the sentence, or the read's own error stands. A save that + leaves the day alone still goes out. +- **Zone definitions.** `StartTimeZone` and `EndTimeZone` carry the zone's + full definition (periods with their bias, yearly changes, eras from + absolute transitions). Both item parsers read it onto its own boundary + (`ParsedItem::start_zone_definition`, `end_zone_definition`, ITEM_PARSER + 5), and `zone_definition::ZoneRules` turns it into a clock: the wall time + at an instant, and a day's midnight (the first hour after it where a + change skips it). A fifth weekday reads as the month's last (238). Whether + Exchange 2019 fills these elements in `SyncFolderItems` and `GetItem` is + unmeasured; the live test of PR 8a reads one. - **Zones Exchange cannot store** are written without a zone: CLDR has no id for them (`Antarctica/Troll`), or the id runs another clock in the five years after the pinned release (`America/Scoresbysund`, `Antarctica/Casey`, diff --git a/web/src/content/docs/guides/troubleshooting.md b/web/src/content/docs/guides/troubleshooting.md index dc49637f..f10338ad 100644 --- a/web/src/content/docs/guides/troubleshooting.md +++ b/web/src/content/docs/guides/troubleshooting.md @@ -163,6 +163,25 @@ looks the same as a series that is meant to run in UTC. To fix an affected series, create it again, or set its time zone again in the program it came from. +## The day of an all-day Exchange appointment cannot be changed + +Aperio then says: "Exchange stores this appointment in a time zone Aperio +cannot read, so its day cannot be changed here; its title and the rest can. +Nothing was changed." + +Exchange places an all-day appointment on the midnights of the time zone it +stores it in. So that the appointment stays one day long, Aperio writes a new +day as midnight in exactly that time zone. Usually Aperio knows it: a Windows +time zone such as "W. Europe Standard Time", a name such as "Europe/Berlin", or +a time zone Exchange sends in full, as for appointments from an iPhone's +calendar ("Customized Time Zone"). When Exchange sends only a name Aperio does +not know, without its rules, Aperio does not guess: the appointment would +otherwise stretch over two days in Outlook. + +What you can do: change the day in Outlook. Everything else, such as the +title, the place or the reminder, you can still change in Aperio, as long as +you do not move the day with it. + ## Reporting a bug 1. In Settings → Logs, set the level to **Debug**. From cda087463d3f8b1c080a460d20d79d2e628c0069 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 04:35:14 +0200 Subject: [PATCH 06/18] Two more tests for PR 8a: a zoned series' rule-only edit, a custom-zone exception A zoned timed series whose rule alone changes starts on the day its stored zone reads (234). An exception of a series in a custom zone moves on the series' definition, which rides along to the exception's copy. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/api.rs | 138 ++++++++++++++++++++++++++++++ crates/adapter-ews/src/mapping.rs | 24 ++++++ 2 files changed, 162 insertions(+) diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 3132187c..5fa534f9 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -2651,6 +2651,144 @@ mod tests { assert_eq!(updated.etag.as_deref(), Some("ECK-V2")); } + /// Decisions 232 and 47a for an exception: its series is stored in a + /// custom zone Exchange defines in full, and the exception shape asks for + /// no zone, so the series' definition rides along (`WriteTarget`). Moved + /// from Monday 26 to Tuesday 27 October, the exception goes out on the + /// definition's midnights; without the definition its day is refused. + #[tokio::test] + async fn an_exception_of_a_custom_zone_series_moves_on_the_series_definition() { + use std::sync::{Arc, Mutex}; + let mut server = Server::new_async().await; + let series = r#" + + + + NoError + + + + + + + + + c/dstPT2H3Sunday-1 + c/stdPT3H10Sunday-1 + + 0 + + + + 2026-10-25T23:00:00Z + 2026-10-26T23:00:00Z + 2026-10-25T23:00:00Z + + + + +"#; + let occurrence = r#" + + + + NoError + + + Custom zone + 2026-10-25T23:00:00Z + 2026-10-26T23:00:00Z + true + 2026-10-25T23:00:00Z + + + +"#; + let updated = r#" + + + + NoError + + + +"#; + let requests = Arc::new(Mutex::new(Vec::::new())); + let seen = Arc::clone(&requests); + let _any = server + .mock("POST", "/") + .with_status(200) + .with_body_from_request(move |request| { + let body = request.utf8_lossy_body().unwrap().into_owned(); + let answer = if body.contains("UpdateItem") { + updated + } else if body.contains(r#"Id="EXC-ID""#) { + occurrence + } else { + series + }; + seen.lock().unwrap().push(body); + answer.as_bytes().to_vec() + }) + .create_async() + .await; + + let original_start: chrono::DateTime = "2026-10-25T23:00:00Z".parse().unwrap(); + let midnight = |d: &str| { + crate::mapping::local_midnight_in(d.parse().unwrap(), &chrono::Local).unwrap() + }; + let edit = Event { + keep_attendees: false, + keep_fields: Vec::new(), + clear_attendees: false, + organized_elsewhere: false, + id: crate::mapping::encode_override_event_id("M:MASTER-ID|MCK-V1", original_start), + calendar_id: "FOLDER-ID|FCK".into(), + title: "Custom zone".into(), + description: None, + location: None, + start: midnight("2026-10-27"), + end: midnight("2026-10-28"), + all_day: true, + recurrence: None, + color_label: None, + color_hex: None, + reminders: Vec::new(), + sound: None, + attendees: Vec::new(), + send_invitations: false, + truncate_tail_overrides: false, + created_at: "2026-09-18T00:00:00Z".parse().unwrap(), + updated_at: "2026-09-18T00:00:00Z".parse().unwrap(), + etag: Some("MCK-V1".into()), + organizer: None, + attendee_responses: Vec::new(), + cancelled: false, + scheduling_silenced: false, + }; + update_event(&client_for(&server), &edit, None) + .await + .unwrap(); + + let requests = requests.lock().unwrap(); + let update = requests + .iter() + .find(|b| b.contains("UpdateItem")) + .expect("an UpdateItem"); + assert!( + update.contains("2026-10-26T23:00:00Z") + && update.contains("2026-10-27T23:00:00Z"), + "{update}" + ); + assert!(!update.contains("TimeZone"), "{update}"); + } + /// Decision 217: an all-day exception's row is read in its series' zone, /// and so is the copy the update compares with — the exception shape asks /// for no zone of its own. Read without one, New Zealand's summer named the diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 6f4f2e6d..9d8e6c18 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -9392,6 +9392,30 @@ mod tests { } } + /// A zoned timed series whose rule alone changes starts on the day its + /// stored zone reads (decision 234): Monday 00:30 in Berlin is Sunday in + /// UTC. + #[test] + fn a_rule_only_edit_of_a_zoned_series_starts_on_the_stored_zones_day() { + let device = chrono_tz::Asia::Tokyo; + let mut before = zoned_master(Some("Europe/Berlin")); + before.start = "2026-10-18T22:30:00Z".parse().unwrap(); + before.end = "2026-10-18T23:00:00Z".parse().unwrap(); + let mut edit = before.clone(); + edit.recurrence.as_mut().unwrap().rrule = "FREQ=WEEKLY;COUNT=4".into(); + let (set, _) = event_to_update_field_xml_in( + &edit, + Some(&before), + &w_europe_zones(), + None, + EventIdKind::RecurringMaster, + &device, + ) + .unwrap(); + assert_eq!(element(&set, "StartDate"), "2026-10-19", "{set}"); + assert_eq!(element(&set, "DaysOfWeek"), "Monday", "{set}"); + } + /// A title-only save of an all-day series writes no rule: both sides of /// the comparison build the rule on the same first day. #[test] From 93456d9f852aa15d10318a42261a4bd08ff8f1d8 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 05:02:04 +0200 Subject: [PATCH 07/18] First check of #121: a flipped all-day flag, Windows' new-year marker, a bounded bias, and the wording A written slot takes a kept rule along whenever it moves the first day, also when only the all-day flag flips under the same instant: the two read the day on different clocks. A change at 1 January 00:00 opens the year on its bias instead of skipping its first hour (Moscow 2014). A bias beyond a day is refused instead of overflowing. The German sentence has Toni's semicolon again (237). 234 and 233 are named decisions, not measurements, and live step 9 looks at 234. The guides no longer name the iPhone as known good, the fixtures are marked synthetic, the 18 Europe/Berlin items are timed singles, the 217 test has its doc comment back, and TODO says old series carry the day before. Co-Authored-By: Claude Opus 5.5 --- TODO.md | 6 +- crates/adapter-ews/src/lib.rs | 4 +- crates/adapter-ews/src/mapping.rs | 80 ++++++++++++++---- crates/adapter-ews/src/zone_definition.rs | 84 +++++++++++++++++-- locales/de/translation.json | 2 +- src/state/eventWriteError.test.ts | 7 +- .../content/docs/de/guides/troubleshooting.md | 13 +-- .../content/docs/developers/adapters/ews.md | 14 ++-- .../content/docs/guides/troubleshooting.md | 10 +-- 9 files changed, 173 insertions(+), 47 deletions(-) diff --git a/TODO.md b/TODO.md index ada4071e..3dc8459f 100644 --- a/TODO.md +++ b/TODO.md @@ -3003,9 +3003,9 @@ Siehe DESIGN §4.2. dem Zusammenführen mit einem Desktop-Build (235). 🚩 **Serien mit falschem Starttag** (231): Was Aperio vor 8a geschrieben hat, bleibt so. Einmal verschieben und Wochentag oder Tag in der Wiederholung - neu wählen; eine wöchentliche Serie ohne gewählten Wochentag trägt - „Sonntag“ in der Regel, eine zweiwöchentliche kann eine Woche verrutscht - sein. + neu wählen; eine wöchentliche Serie ohne gewählten Wochentag trägt in der + Regel den Wochentag davor (für Montag: Sonntag), eine zweiwöchentliche kann + eine Woche verrutscht sein. 🚩 **Microsoft 365: derselbe Datumsfehler** (235): eigener PR. 🚩 **Zone und Wiederholung in einem Ändern:** Wird ein Termin mit Uhrzeit zur Serie mit neuer Zone, setzt Exchange die Zone nach dem Beginn und diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 982c47e2..6abb80de 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -3050,8 +3050,8 @@ mod server_zone_tests { ) } - /// A custom zone with W. Europe's rules, as an Exchange ActiveSync - /// client leaves it. + /// A custom zone with W. Europe's rules (synthetic: no definition has + /// been captured from a server yet). const CUSTOM_W_EUROPE: &str = r#" diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 9d8e6c18..0dade3ef 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2457,7 +2457,12 @@ pub(crate) fn event_to_update_field_xml_in( .recurrence .as_ref() .map(|rec| rrule_to_ews_recurrence(&rec.rrule, first_day(event)).ok()); - let rule_follows_slot = slot_changed && start_moves && event.recurrence.is_some(); + // A written slot takes a kept rule along when it moves the rule's first + // day: when the start moves, and also when only the all-day flag flips + // under the same instant, because the two read the day on different + // clocks (`rule_first_day`) — the device's day, or the stored zone's. + let first_day_moves = start_moves || before.is_some_and(|b| first_day(b) != first_day(event)); + let rule_follows_slot = slot_changed && first_day_moves && event.recurrence.is_some(); let rule_changed = touches(EventField::Recurrence) || ((may(EventField::Recurrence) || rule_follows_slot) && written_rule != before.and_then(built_rule)); @@ -2812,8 +2817,9 @@ pub(crate) fn device_day(when: DateTime, device: &D) -> chrono /// The day a series' rule starts on, on the clock Exchange repeats it on: an /// all-day series on the device's days (48a); a timed one on `clock`, the /// zone Exchange expands it in — the zone a create writes with it, the zone -/// the item is stored in on an update (decision 234: Exchange applies the -/// rule before a zone the same update writes), UTC where there is none. +/// the item is stored in on an update (decision 234, inferred from live round +/// 1, where Exchange applied an update's fields in order; not measured for a +/// rule and a zone), UTC where there is none. pub(crate) fn rule_first_day( start: DateTime, all_day: bool, @@ -2830,9 +2836,11 @@ pub(crate) fn rule_first_day( /// The instant an all-day boundary is written as: midnight, in the zone /// Exchange stores that boundary in, of the day the device names (decision -/// 47a; each boundary in its own zone, 233). Exchange rounds a boundary in -/// that zone (live round 3, R3-5b-u), so UTC midnights stretched an item in -/// any other zone over two days. Refused where the zone is unknown (237). +/// 47a; each boundary in its own zone, 233). Exchange rounds an item with one +/// stored zone in that zone (live round 3, R3-5b-u), so UTC midnights +/// stretched an item in any other zone over two days; that it rounds each +/// boundary in its own zone when the two differ is decision 233, which R3-2 +/// suggests and nobody has measured. Refused where the zone is unknown (237). fn all_day_boundary( when: DateTime, zone: Option<&DayZone>, @@ -7707,8 +7715,9 @@ mod tests { ); } - /// A custom zone's definition, as an Exchange ActiveSync client leaves it - /// (`Customized Time Zone`, Name empty), with W. Europe's rules. + /// A custom zone's definition with W. Europe's rules, under the id Toni's + /// mailbox shows for a custom zone (`Customized Time Zone`, Name empty). + /// Synthetic: no definition has been captured from a server yet. const CUSTOM_ZONE_BODY: &str = r#" @@ -7791,8 +7800,10 @@ mod tests { } /// Both item parsers put a zone's definition on its own boundary, after a - /// self-closing zone on the other one: a flag that stayed set after a - /// self-closing start zone once took the end zone's children for it. + /// self-closing zone on the other one. The flag the walker replaced stayed + /// set after a self-closing start zone; it fed only a nested definition's + /// id, so it never misread an item, but a definition read through it would + /// have landed on the start. #[test] fn a_zone_definition_lands_on_its_own_boundary() { let id_only = r#""#; @@ -8249,11 +8260,7 @@ mod tests { assert_eq!(override_slot(&item, &ov), orig); } - /// Decision 217: an all-day item's instants are midnights in its own zone, - /// and read in it the day is exact. An Auckland series begun in winter has - /// its January slots at 11:00 UTC the day before; the 12-hour sample read - /// them as that day before. Its start, its exceptions and the slot in its - /// single changes' ids now name the day itself. + /// An all-day item with these start and end zone ids. fn all_day_item(start_zone: Option<&str>, end_zone: Option<&str>) -> ParsedItem { ParsedItem { is_all_day: true, @@ -8267,8 +8274,9 @@ mod tests { s.parse().unwrap() } - /// An id that is tzdata's own name (18 of Toni's items carry - /// `Europe/Berlin`) reads as that zone (decision 236). + /// An id that is tzdata's own name reads as that zone (decision 236). 18 + /// of Toni's items carry `Europe/Berlin`, all timed singles in a snapshot + /// of 15 September; it counts for one made all-day. #[test] fn an_all_day_item_in_a_zone_named_by_tzdata_reads_its_day() { let item = all_day_item(Some("Europe/Berlin"), Some("Europe/Berlin")); @@ -8370,6 +8378,11 @@ mod tests { ); } + /// Decision 217: an all-day item's instants are midnights in its own zone, + /// and read in it the day is exact. An Auckland series begun in winter has + /// its January slots at 11:00 UTC the day before; the 12-hour sample read + /// them as that day before. Its start, its exceptions and the slot in its + /// single changes' ids now name the day itself. #[test] fn an_all_day_series_is_read_in_its_own_zone() { let auckland = chrono_tz::Pacific::Auckland; @@ -9449,6 +9462,39 @@ mod tests { } } + /// Flipping all-day under the same instant moves a series' first day + /// (device day against the stored zone's day), so a kept rule goes along + /// with the new day. Without it the kept StartDate named the other clock's + /// day: Sunday for an all-day series on Monday 2 November. + #[test] + fn flipping_all_day_takes_a_kept_rule_to_the_new_first_day() { + let device = chrono_tz::Europe::Berlin; + let monday = midnight_on("2026-11-02", &device); + let mut timed = zoned_master(None); + timed.recurrence.as_mut().unwrap().rrule = "FREQ=DAILY".into(); + timed.start = monday; + timed.end = monday + chrono::Duration::hours(1); + let mut all_day = timed.clone(); + all_day.all_day = true; + all_day.end = midnight_on("2026-11-03", &device); + for (before, mut edit, first_day) in [ + (timed.clone(), all_day.clone(), "2026-11-02"), + (all_day, timed, "2026-11-01"), + ] { + edit.keep_fields = vec![EventField::Recurrence, EventField::Title]; + let (set, _) = event_to_update_field_xml_in( + &edit, + Some(&before), + &StoredZones::utc(), + None, + EventIdKind::RecurringMaster, + &device, + ) + .unwrap(); + assert_eq!(element(&set, "StartDate"), first_day, "{set}"); + } + } + fn moved_single(device: &chrono_tz::Tz) -> (Event, Event) { let mut before = zoned_master(None); before.recurrence = None; diff --git a/crates/adapter-ews/src/zone_definition.rs b/crates/adapter-ews/src/zone_definition.rs index 828cadc2..a33ad5e5 100644 --- a/crates/adapter-ews/src/zone_definition.rs +++ b/crates/adapter-ews/src/zone_definition.rs @@ -3,10 +3,12 @@ //! //! Most items carry an id the CLDR table maps to a tzdata zone; those are //! read through [`crate::windows_tz`]. An item made by another client may -//! carry a definition of its own instead: `Customized Time Zone` (an -//! Exchange ActiveSync client such as the iPhone's calendar), an iCalendar -//! invitation's VTIMEZONE, or a registry-only id. Decision 232 reads such a -//! definition to find the item's midnights rather than refusing or guessing. +//! carry a zone of its own instead — `Customized Time Zone` in Toni's mailbox; +//! by the protocols, what an Exchange ActiveSync client or an iCalendar +//! invitation's VTIMEZONE leaves — or a registry-only id. Decision 232 reads +//! such a zone's definition, where Exchange sends one, to find the item's +//! midnights rather than refusing or guessing. Whether Exchange 2019 sends it +//! is unmeasured. //! //! The wire shape (Exchange 2010 and later): //! @@ -412,7 +414,10 @@ impl TryFrom<&ZoneDefinition> for ZoneRules { .iter() .find(|p| p.id.trim() == id.trim()) .ok_or_else(|| DefinitionError::UnknownTarget(id.trim().to_string()))?; + // A zone's offset stays within a day; a larger one would only + // overflow the arithmetic that reads the clock. parse_duration(&period.bias) + .filter(|bias| bias.num_hours().abs() <= 24) .ok_or_else(|| DefinitionError::BadBias(period.bias.clone())) }; let group_rule = |id: &str| -> Result { @@ -564,7 +569,15 @@ impl ZoneRules { local.sort_by_key(|(when, _)| *when); // Windows keeps one rule a year: the bias a year opens with is the // one its last change leaves (Sydney is on daylight time on 1 Jan). - let opening = local.last()?.1; + let mut opening = local.last()?.1; + // Except for Windows' marker: a change at 1 January 00:00 is no + // clock change but says the year OPENS on its bias (Moscow 2014, + // Brazil 2019). Read as a change, it skipped the year's first hour, + // and that day's midnight came out an hour late. + let year_start = NaiveDate::from_ymd_opt(year, 1, 1)?.and_hms_opt(0, 0, 0)?; + if local.first().is_some_and(|(when, _)| *when == year_start) { + opening = local.remove(0).1; + } let mut before = opening; let mut utc = Vec::with_capacity(local.len()); for (when, to) in local { @@ -1033,6 +1046,67 @@ pub(crate) mod tests { ); } + /// Windows marks a year that opens on daylight time with a change at + /// 1 January 00:00 (Russian Standard Time, 2014): no clock change, so + /// that midnight is there, and the year opens on daylight time. + #[test] + fn a_1_january_marker_opens_the_year_without_a_change() { + let def = ZoneDefinition { + periods: vec![ + period("moscow/fixed", "-PT4H"), + period("moscow/2014-daylight", "-PT4H"), + period("moscow/2014-standard", "-PT3H"), + ], + groups: vec![ + GroupDef { + id: "0".into(), + transitions: vec![TransitionDef::Plain { + to_kind: "Period".into(), + to: "moscow/fixed".into(), + }], + }, + GroupDef { + id: "1".into(), + transitions: vec![ + day_change("moscow/2014-daylight", "PT0S", "1", "Wednesday", "1"), + day_change("moscow/2014-standard", "PT2H", "10", "Sunday", "-1"), + ], + }, + ], + transitions: vec![ + to_group("0"), + TransitionDef::Absolute { + to_kind: "Group".into(), + to: "1".into(), + date_time: "2014-01-01T00:00:00".into(), + }, + ], + }; + let rules = ZoneRules::try_from(&def).unwrap(); + assert_eq!( + rules.midnight(day("2014-01-01")), + Some(utc("2013-12-31T20:00:00Z")) + ); + assert!(rules + .instant_of(day("2014-01-01").and_hms_opt(0, 30, 0).unwrap()) + .is_some()); + // After the October change, UTC+3. + assert_eq!( + rules.midnight(day("2014-11-01")), + Some(utc("2014-10-31T21:00:00Z")) + ); + } + + #[test] + fn a_bias_beyond_a_day_is_refused() { + let mut def = w_europe(); + def.periods[0].bias = "P100000000D".into(); + assert_eq!( + ZoneRules::try_from(&def), + Err(DefinitionError::BadBias("P100000000D".into())) + ); + } + #[test] fn a_fifth_weekday_reads_as_the_last() { let mut def = w_europe(); diff --git a/locales/de/translation.json b/locales/de/translation.json index ba0a6fd3..508b32dd 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -1695,7 +1695,7 @@ "occurrenceNotWritable": "Dieser eine Termin lässt sich in seiner Serie nicht einzeln ändern. Es wurde nichts geändert.", "unsafeToWrite": "Aperio kann diesen Termin nicht sicher ändern: Was auf dem Server noch mit ihm gespeichert ist, stünde auf dem Spiel. Es wurde nichts geändert.", "accessNotGranted": "Aperio hat keinen Zugriff auf die Kalender und Erinnerungen dieses Geräts. Erlaube ihn in der App „Einstellungen“ bei Aperio. Es wurde nichts geändert.", - "dayZoneUnreadable": "Exchange speichert diesen Termin in einer Zeitzone, die Aperio nicht lesen kann. Seinen Tag kannst du deshalb hier nicht ändern, den Titel und anderes schon. Es wurde nichts geändert.", + "dayZoneUnreadable": "Exchange speichert diesen Termin in einer Zeitzone, die Aperio nicht lesen kann. Seinen Tag kannst du deshalb hier nicht ändern; den Titel und anderes schon. Es wurde nichts geändert.", "forbidden": "Der Anbieter erlaubt diese Änderung nicht ({{detail}}). Es wurde nichts geändert.", "changedOnServer": "Dieser Termin wurde auf dem Server geändert, seit du ihn geöffnet hast. Es wurde nichts geändert. Öffne ihn erneut, um den aktuellen Stand zu sehen.", "reason": { diff --git a/src/state/eventWriteError.test.ts b/src/state/eventWriteError.test.ts index 917de9bc..5965247f 100644 --- a/src/state/eventWriteError.test.ts +++ b/src/state/eventWriteError.test.ts @@ -208,8 +208,11 @@ describe('an all-day day in a time zone Aperio cannot read', () => { ); for (const err of [desktop, phone]) { expect(eventWriteRefusal(err)?.refusal).toBe('day-zone-unreadable'); - expect(eventWriteErrorMessage(err, t)).toMatch(/Zeitzone, die Aperio nicht lesen kann/); - expect(eventWriteErrorMessage(err, t)).toMatch(/den Titel und anderes schon/); + expect(eventWriteErrorMessage(err, t)).toBe( + 'Exchange speichert diesen Termin in einer Zeitzone, die Aperio nicht lesen kann. ' + + 'Seinen Tag kannst du deshalb hier nicht ändern; den Titel und anderes schon. ' + + 'Es wurde nichts geändert.', + ); expect(eventWriteErrorMessage(err, t)).not.toMatch(/no-zone/); } expect(eventWriteFailureReason(desktop, t)).toMatch(/Zeitzone, die Aperio nicht lesen kann/); diff --git a/web/src/content/docs/de/guides/troubleshooting.md b/web/src/content/docs/de/guides/troubleshooting.md index f99545da..c85509fe 100644 --- a/web/src/content/docs/de/guides/troubleshooting.md +++ b/web/src/content/docs/de/guides/troubleshooting.md @@ -178,17 +178,18 @@ stelle ihre Zeitzone in dem Programm wieder ein, aus dem sie stammt. ## Der Tag eines ganztägigen Exchange-Termins lässt sich nicht ändern Aperio sagt dann: „Exchange speichert diesen Termin in einer Zeitzone, die -Aperio nicht lesen kann. Seinen Tag kannst du deshalb hier nicht ändern, den +Aperio nicht lesen kann. Seinen Tag kannst du deshalb hier nicht ändern; den Titel und anderes schon. Es wurde nichts geändert.“ Exchange legt einen ganztägigen Termin auf die Mitternächte der Zeitzone, in der es ihn speichert. Damit der Termin einen Tag lang bleibt, schreibt Aperio einen neuen Tag deshalb als Mitternacht in genau dieser Zeitzone. Meist kennt -Aperio sie: eine Windows-Zeitzone wie „W. Europe Standard Time“, einen Namen -wie „Europe/Berlin“ oder eine Zeitzone, die Exchange vollständig mitschickt, -etwa bei Terminen aus dem Kalender eines iPhones („Customized Time Zone“). -Schickt Exchange nur einen Namen, den Aperio nicht kennt, ohne ihre Regeln, -rät Aperio nicht: Sonst stünde der Termin danach in Outlook über zwei Tage. +Aperio sie: eine Windows-Zeitzone wie „W. Europe Standard Time“ oder einen +Namen wie „Europe/Berlin“. Eine eigene Zeitzone, etwa „Customized Time Zone“ +bei Terminen aus anderen Programmen, kann Aperio lesen, wenn Exchange ihre +Regeln mitschickt. Schickt Exchange nur einen Namen, den Aperio nicht kennt, +ohne ihre Regeln, rät Aperio nicht: Sonst stünde der Termin danach in Outlook +über zwei Tage. Was du tun kannst: Ändere den Tag in Outlook. Alles andere, etwa Titel, Ort oder Erinnerung, kannst du in Aperio weiter ändern, solange du den Tag dabei diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 01502249..aec1e3ca 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -174,9 +174,9 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in - **A series starts on its first day** (PR 8a). `rrule_to_ews_recurrence` takes the day the series starts on, on the clock Exchange repeats it on (`rule_first_day`): the device's day for an all-day series, the written - zone's day on a create, the stored start zone's day on an update (234: - Exchange applies the rule before a zone the same update writes), UTC where - none is known. The range's StartDate and the weekday, day of the month and + zone's day on a create, the stored start zone's day on an update (234, + inferred from live round 1, where Exchange applied an update's fields in + order; not measured for a rule and a zone), UTC where none is known. The range's StartDate and the weekday, day of the month and month of a rule without BYDAY, BYMONTHDAY or BYMONTH come from that day. Read off the UTC date, as before, they named the day before east of UTC: a daily all-day series created in Berlin for Monday 19 October started on @@ -185,9 +185,11 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in - **An all-day day is written on the stored zone's midnights** (47a). An update writes an all-day boundary as midnight, in the zone Exchange stores that boundary in, of the day the device names (`all_day_boundary`; each - boundary in its own zone, 233). Exchange rounds a boundary in its stored - zone (R3-5b-u), so the UTC midnights Aperio wrote before stretched an - Outlook item over two days. `read_before` returns the zones with the copy + boundary in its own zone, 233). Exchange rounds an item with one stored + zone in that zone (R3-5b-u), so the UTC midnights Aperio wrote before + stretched an Outlook item over two days; that it rounds each boundary in + its own zone when the two differ is the decision (233), which R3-2 + suggests and nobody has measured. `read_before` returns the zones with the copy (`StoredZones`); an exception takes its series' ids and definitions. A create still writes UTC midnights: Aperio creates without a zone, so Exchange stores the item in UTC. Where a boundary's zone cannot be read, diff --git a/web/src/content/docs/guides/troubleshooting.md b/web/src/content/docs/guides/troubleshooting.md index f10338ad..f656cac7 100644 --- a/web/src/content/docs/guides/troubleshooting.md +++ b/web/src/content/docs/guides/troubleshooting.md @@ -172,11 +172,11 @@ Nothing was changed." Exchange places an all-day appointment on the midnights of the time zone it stores it in. So that the appointment stays one day long, Aperio writes a new day as midnight in exactly that time zone. Usually Aperio knows it: a Windows -time zone such as "W. Europe Standard Time", a name such as "Europe/Berlin", or -a time zone Exchange sends in full, as for appointments from an iPhone's -calendar ("Customized Time Zone"). When Exchange sends only a name Aperio does -not know, without its rules, Aperio does not guess: the appointment would -otherwise stretch over two days in Outlook. +time zone such as "W. Europe Standard Time" or a name such as "Europe/Berlin". +A custom time zone, such as "Customized Time Zone" on appointments from other +programs, Aperio can read when Exchange sends its rules along. When Exchange +sends only a name Aperio does not know, without its rules, Aperio does not +guess: the appointment would otherwise stretch over two days in Outlook. What you can do: change the day in Outlook. Everything else, such as the title, the place or the reminder, you can still change in Aperio, as long as From cbfae1575e325b03498c000e0ad2141a58d4c194 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 05:38:49 +0200 Subject: [PATCH 08/18] Second check of #121: a withheld day without the copy, definitions for mapped ids, the live steps - An all-day day withheld because the copy could not be read reached the user as a network error, "may have reached the server", and a split would not take its new part back. It is now its own refusal, copy-unreadable, with the read's error as its detail, carried as Forbidden: nothing was sent. German and English sentences, the troubleshooting guides, and a test for HTTP 503 beside the answer without the item. - The parser kept a zone definition beside an id the table maps, so the cache carried two definitions for every Outlook item. It now keeps one only for an id that names no clock Aperio knows. - Round 3's generator steps are marked historical: what round 3's build sent, and the 47a prototype as Aperio's rule since 8a. Red proofs: copy-unreadable left out of the Forbidden mapping, and the definition kept for a mapped id, each fail their test. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/api.rs | 18 +++--- crates/adapter-ews/src/lib.rs | 59 +++++++++++++++++-- crates/adapter-ews/src/mapping.rs | 54 ++++++++++++++--- crates/cal-core/bindings/WriteRefusal.ts | 2 +- crates/cal-core/src/write_refusal.rs | 6 ++ locales/de/translation.json | 2 + locales/en/translation.json | 2 + shared/eventWriteError.ts | 2 + shared/generated/WriteRefusal.ts | 2 +- src/state/eventWriteError.test.ts | 18 ++++++ .../content/docs/de/guides/troubleshooting.md | 6 ++ .../content/docs/developers/adapters/ews.md | 9 +-- .../content/docs/guides/troubleshooting.md | 6 ++ 13 files changed, 159 insertions(+), 27 deletions(-) diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 5fa534f9..8f145597 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -805,9 +805,11 @@ pub async fn update_event( None => (None, crate::mapping::StoredZones::default()), }; // Without the copy the zones the item is stored in are unknown, and an - // all-day day is never written blind (decision 237): the user is told what - // kept the copy away — the read's own failure, or an item the server no - // longer has — rather than that its zone cannot be read. + // all-day day is never written blind (decision 237). Nothing is sent, and + // the error says so: `copy-unreadable`, with the read's own failure (or an + // item the server no longer has) as its detail. Returned as the read's + // error, a dropped connection read as a write that may have landed, and a + // split kept its new part next to the uncut series (decision 144). let (set_xml, delete_xml) = match crate::mapping::event_to_update_field_xml_in( event, before.as_ref(), @@ -817,10 +819,12 @@ pub async fn update_event( &chrono::Local, ) { Err(EwsError::Protocol(message)) if before.is_none() && day_zone_unreadable(&message) => { - return Err(read_error.unwrap_or_else(|| EwsError::Soap { - code: "ErrorItemNotFound".into(), - message: "the server answered without the item".into(), - })); + let why = read_error + .map(|err| err.to_string()) + .unwrap_or_else(|| "the server answered without the item".into()); + return Err(EwsError::Protocol( + cal_core::WriteRefusal::CopyUnreadable.message(&why), + )); } other => other?, }; diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 6abb80de..122d373b 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -1441,12 +1441,17 @@ fn to_update_error(err: EwsError) -> CoreError { CoreError::Forbidden(cal_core::WriteRefusal::ServerRefused.message(code)) } // Nothing was sent: an all-day day in a zone Aperio cannot read - // (decision 237). Forbidden, so the phone keeps the message and a - // split knows the write never landed. + // (decision 237), or one whose copy could not be read. Forbidden, so + // the phone keeps the message and a split knows the write never + // landed. EwsError::Protocol(message) if matches!( cal_core::WriteRefusal::parse(&message), - Some((cal_core::WriteRefusal::DayZoneUnreadable, _)) + Some(( + cal_core::WriteRefusal::DayZoneUnreadable + | cal_core::WriteRefusal::CopyUnreadable, + _ + )) ) => { CoreError::Forbidden(message) @@ -3178,14 +3183,26 @@ mod server_zone_tests { } /// Without the copy no stored zone is known, and an all-day day is not - /// written blind: the user hears what kept the copy away. + /// written blind. Nothing is sent, and the error says so as a refusal + /// (`copy-unreadable`), carried as Forbidden: a split then knows nothing + /// landed (decision 144). Its detail is what kept the copy away. #[tokio::test] async fn an_all_day_day_is_not_written_without_the_copy() { + // The server answers without the item. let mut server = Server::new_async().await; let requests = serve_stored(&mut server, CREATED.to_string()).await; let adapter = EwsAdapter::new(server.url(), alice()); let err = adapter.update_event(moved_to_tuesday()).await.unwrap_err(); - assert!(matches!(err, CoreError::NotFound(_)), "{err:?}"); + let CoreError::Forbidden(message) = err else { + panic!("expected Forbidden, got {err:?}"); + }; + assert_eq!( + cal_core::WriteRefusal::parse(&message), + Some(( + cal_core::WriteRefusal::CopyUnreadable, + "the server answered without the item" + )) + ); assert!( !requests .lock() @@ -3194,5 +3211,37 @@ mod server_zone_tests { .any(|b| b.contains("UpdateItem")), "nothing is sent" ); + + // The read itself fails: the server is down. + let mut server = Server::new_async().await; + let failed = Arc::new(StdMutex::new(Vec::::new())); + let seen = Arc::clone(&failed); + server + .mock("POST", "/") + .with_status(503) + .with_body_from_request(move |request| { + seen.lock() + .unwrap() + .push(request.utf8_lossy_body().unwrap().into_owned()); + b"Service Unavailable".to_vec() + }) + .create_async() + .await; + let adapter = EwsAdapter::new(server.url(), alice()); + let err = adapter.update_event(moved_to_tuesday()).await.unwrap_err(); + let CoreError::Forbidden(message) = err else { + panic!("expected Forbidden, got {err:?}"); + }; + let (refusal, why) = cal_core::WriteRefusal::parse(&message).expect("a refusal"); + assert_eq!(refusal, cal_core::WriteRefusal::CopyUnreadable); + assert!(why.contains("503"), "the read's own error: {why}"); + assert!( + !failed + .lock() + .unwrap() + .iter() + .any(|b| b.contains("UpdateItem")), + "nothing is sent" + ); } } diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 0dade3ef..b8107094 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2536,10 +2536,26 @@ fn finish_zone( } else { (&mut item.end_time_zone, &mut item.end_zone_definition) }; - *slot = definition; if id.is_none() { *id = nested_id; } + // Kept only where it is read: an id that names a clock by itself never + // needs it, and every item of a W. Europe mailbox would otherwise carry + // two definitions through the cache. + *slot = definition.filter(|_| !id_names_a_clock(id.as_deref())); +} + +/// Whether a zone id names a clock by itself — through the CLDR table, as the +/// end zone Exchange marks an item made without a zone with (43b), or as +/// tzdata's own name (236) — so a definition beside it is never read. +fn id_names_a_clock(id: Option<&str>) -> bool { + use crate::windows_tz::{read_windows_zone, WindowsZoneRead}; + let Some(id) = id.map(str::trim) else { + return false; + }; + id.eq_ignore_ascii_case("tzone://Microsoft/Utc") + || !matches!(read_windows_zone(id), WindowsZoneRead::Unknown) + || id.parse::().is_ok() } /// Re-anchor an all-day boundary read from EWS at LOCAL midnight of the @@ -5261,6 +5277,13 @@ mod tests { /// instead of the UTC midnights of the day; /// - the Tokyo items carry a zone on an all-day single; /// - R3-7b leaves out the Recurrence delete. + /// + /// Historical since PR 8a: the files reproduce round 3's requests. The + /// "today's rule" steps (R3-2, R3-4, R3-5b-u) are what round 3's build + /// sent, UTC midnights through `event_to_update_field_xml`, which reads + /// every item as stored in UTC; an 8a build writes an Outlook item's + /// stored-zone midnights instead. The 47a prototype (R3-1, R3-3, R3-7) is + /// Aperio's rule since 8a. #[test] #[ignore = "writes the live Exchange test requests of round 3; see the doc comment"] fn live_test_requests_round_3() { @@ -5433,7 +5456,7 @@ mod tests { write( "R3-1-update-s1-47a.xml", &format!( - "Step R3-1: NOT Aperio's rule yet (47a prototype). Aperio's update of Outlook's S1 \ + "Step R3-1: the 47a prototype, Aperio's rule since PR 8a. Aperio's update of Outlook's S1 \ series with a new title, Start and End moved to midnights of the stored zone. {REPLACE}" ), update(&on_stored_midnights(&s1, set), &del), @@ -5443,8 +5466,8 @@ mod tests { write( "R3-2-update-s2-today.xml", &format!( - "Step R3-2: Aperio's update of Outlook's S2 series with a new title, as this build \ - sends it (all-day series write no zone, 46a). {REPLACE}" + "Step R3-2: Aperio's update of Outlook's S2 series with a new title, as round 3's \ + build sent it, before PR 8a (all-day series write no zone, 46a). {REPLACE}" ), update(&set, &del), ); @@ -5457,7 +5480,7 @@ mod tests { write( "R3-3-update-t1-47a.xml", &format!( - "Step R3-3: NOT Aperio's rule yet (47a prototype). Aperio's update of Outlook's T1 \ + "Step R3-3: the 47a prototype, Aperio's rule since PR 8a. Aperio's update of Outlook's T1 \ single moved to Tuesday 20 October, Start and End on midnights of the stored zone. {REPLACE}" ), update(&on_stored_midnights(&t1, set), &del), @@ -5468,7 +5491,7 @@ mod tests { "R3-4-update-t2-today.xml", &format!( "Step R3-4: Aperio's update of Outlook's T2 single with a new title, for the planned item (no reminder, location or body) byte for byte as \ - main sends it. {REPLACE}" + round 3's main sent it, before PR 8a. {REPLACE}" ), update(&set, &del), ); @@ -5481,8 +5504,8 @@ mod tests { write( "R3-5b-u-update-tokyo-today.xml", &format!( - "Step R3-5b-u: Aperio's update of the Tokyo single R3-5b with a new title, as it sends \ - it today. In which zone does Exchange round its UTC midnights? {REPLACE}" + "Step R3-5b-u: Aperio's update of the Tokyo single R3-5b with a new title, as round \ + 3's build sent it, before PR 8a. In which zone does Exchange round its UTC midnights? {REPLACE}" ), update(&set, &del), ); @@ -5496,7 +5519,7 @@ mod tests { write( "R3-7-update-s3-exception-47a.xml", &format!( - "Step R3-7: NOT Aperio's rule yet (47a prototype). Aperio's override update of the S3 \ + "Step R3-7: the 47a prototype, Aperio's rule since PR 8a. Aperio's override update of the S3 \ exception moved to Tuesday 27 October, Start and End on midnights of the stored zone, \ with the Recurrence delete the override path sent until PR #77. {REPLACE}" ), @@ -7846,6 +7869,19 @@ mod tests { Some("W. Europe Standard Time") ); assert_eq!(item.end_zone_definition, None); + + // An id that names a clock by itself keeps no definition: it is + // never read, and the cache would carry it for every item. + for id in [ + "W. Europe Standard Time", + "Europe/Berlin", + "tzone://Microsoft/Utc", + ] { + let named = + format!(r#"{CUSTOM_ZONE_BODY}"#); + let item = parse(&zone_item(&named, "")); + assert_eq!(item.start_zone_definition, None, "{id}"); + } } } diff --git a/crates/cal-core/bindings/WriteRefusal.ts b/crates/cal-core/bindings/WriteRefusal.ts index 94d51eab..386f2d78 100644 --- a/crates/cal-core/bindings/WriteRefusal.ts +++ b/crates/cal-core/bindings/WriteRefusal.ts @@ -3,4 +3,4 @@ /** * The reason a write did not happen. */ -export type WriteRefusal = "reply-only-invitation" | "server-refused" | "identity-unknown" | "occurrence-not-writable" | "unsafe-to-write" | "access-not-granted" | "day-zone-unreadable"; +export type WriteRefusal = "reply-only-invitation" | "server-refused" | "identity-unknown" | "occurrence-not-writable" | "unsafe-to-write" | "access-not-granted" | "day-zone-unreadable" | "copy-unreadable"; diff --git a/crates/cal-core/src/write_refusal.rs b/crates/cal-core/src/write_refusal.rs index df63ded5..c0ca364f 100644 --- a/crates/cal-core/src/write_refusal.rs +++ b/crates/cal-core/src/write_refusal.rs @@ -58,6 +58,10 @@ pub enum WriteRefusal { /// is a machine token for the log: `no-zone` (no zone, or an id with no /// definition), `no-midnight` (the zone has no midnight that day). DayZoneUnreadable, + /// The provider's current copy of the item could not be read, and the + /// write needs it, so nothing was sent: a retry may well work. The detail + /// is the read's own error, for the log. + CopyUnreadable, } impl WriteRefusal { @@ -71,6 +75,7 @@ impl WriteRefusal { Self::UnsafeToWrite => "unsafe-to-write", Self::AccessNotGranted => "access-not-granted", Self::DayZoneUnreadable => "day-zone-unreadable", + Self::CopyUnreadable => "copy-unreadable", } } @@ -114,6 +119,7 @@ impl WriteRefusal { Self::UnsafeToWrite, Self::AccessNotGranted, Self::DayZoneUnreadable, + Self::CopyUnreadable, ] { let token = refusal.token(); let rest = match message.strip_prefix(token) { diff --git a/locales/de/translation.json b/locales/de/translation.json index 508b32dd..d63b0cd4 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -1696,6 +1696,7 @@ "unsafeToWrite": "Aperio kann diesen Termin nicht sicher ändern: Was auf dem Server noch mit ihm gespeichert ist, stünde auf dem Spiel. Es wurde nichts geändert.", "accessNotGranted": "Aperio hat keinen Zugriff auf die Kalender und Erinnerungen dieses Geräts. Erlaube ihn in der App „Einstellungen“ bei Aperio. Es wurde nichts geändert.", "dayZoneUnreadable": "Exchange speichert diesen Termin in einer Zeitzone, die Aperio nicht lesen kann. Seinen Tag kannst du deshalb hier nicht ändern; den Titel und anderes schon. Es wurde nichts geändert.", + "copyUnreadable": "Aperio konnte den aktuellen Stand dieses Termins nicht vom Server lesen und hat ihn deshalb nicht geändert. Versuche es gleich noch einmal.", "forbidden": "Der Anbieter erlaubt diese Änderung nicht ({{detail}}). Es wurde nichts geändert.", "changedOnServer": "Dieser Termin wurde auf dem Server geändert, seit du ihn geöffnet hast. Es wurde nichts geändert. Öffne ihn erneut, um den aktuellen Stand zu sehen.", "reason": { @@ -1706,6 +1707,7 @@ "unsafeToWrite": "Aperio kann ihn auf dem Server nicht sicher ändern", "accessNotGranted": "Aperio hat keinen Zugriff auf die Kalender und Erinnerungen dieses Geräts", "dayZoneUnreadable": "Exchange speichert ihn in einer Zeitzone, die Aperio nicht lesen kann, deshalb lässt sich sein Tag hier nicht ändern", + "copyUnreadable": "Aperio konnte seinen aktuellen Stand nicht vom Server lesen", "forbidden": "der Anbieter erlaubt es nicht ({{detail}})", "changedOnServer": "sie wurde auf dem Server geändert, seit du sie geöffnet hast" } diff --git a/locales/en/translation.json b/locales/en/translation.json index b5a99e85..10b5da67 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -1696,6 +1696,7 @@ "unsafeToWrite": "Aperio cannot change this event safely: what else is stored with it on the server would be put at risk. Nothing was changed.", "accessNotGranted": "Aperio has no access to this device's calendars and reminders. Allow it for Aperio in the Settings app. Nothing was changed.", "dayZoneUnreadable": "Exchange stores this appointment in a time zone Aperio cannot read, so its day cannot be changed here; its title and the rest can. Nothing was changed.", + "copyUnreadable": "Aperio could not read this appointment's current state from the server, so it did not change it. Try again in a moment.", "forbidden": "The provider does not allow this change ({{detail}}). Nothing was changed.", "changedOnServer": "This event changed on the server since you opened it. Nothing was changed. Open it again to see the current version.", "reason": { @@ -1706,6 +1707,7 @@ "unsafeToWrite": "Aperio cannot change it safely on the server", "accessNotGranted": "Aperio has no access to this device's calendars and reminders", "dayZoneUnreadable": "Exchange stores it in a time zone Aperio cannot read, so its day cannot be changed here", + "copyUnreadable": "Aperio could not read its current state from the server", "forbidden": "the provider does not allow it ({{detail}})", "changedOnServer": "it changed on the server since you opened it" } diff --git a/shared/eventWriteError.ts b/shared/eventWriteError.ts index 44dbc1f1..de492289 100644 --- a/shared/eventWriteError.ts +++ b/shared/eventWriteError.ts @@ -20,6 +20,7 @@ const REFUSAL_KEYS: Record = { 'unsafe-to-write': 'dialogs.event.writeError.unsafeToWrite', 'access-not-granted': 'dialogs.event.writeError.accessNotGranted', 'day-zone-unreadable': 'dialogs.event.writeError.dayZoneUnreadable', + 'copy-unreadable': 'dialogs.event.writeError.copyUnreadable', }; const TOKENS = Object.keys(REFUSAL_KEYS) as WriteRefusal[]; @@ -34,6 +35,7 @@ const REFUSAL_REASON_KEYS: Record = { 'unsafe-to-write': 'dialogs.event.writeError.reason.unsafeToWrite', 'access-not-granted': 'dialogs.event.writeError.reason.accessNotGranted', 'day-zone-unreadable': 'dialogs.event.writeError.reason.dayZoneUnreadable', + 'copy-unreadable': 'dialogs.event.writeError.reason.copyUnreadable', }; /** An error as the hosts hand it over: a code and a message. */ diff --git a/shared/generated/WriteRefusal.ts b/shared/generated/WriteRefusal.ts index 94d51eab..386f2d78 100644 --- a/shared/generated/WriteRefusal.ts +++ b/shared/generated/WriteRefusal.ts @@ -3,4 +3,4 @@ /** * The reason a write did not happen. */ -export type WriteRefusal = "reply-only-invitation" | "server-refused" | "identity-unknown" | "occurrence-not-writable" | "unsafe-to-write" | "access-not-granted" | "day-zone-unreadable"; +export type WriteRefusal = "reply-only-invitation" | "server-refused" | "identity-unknown" | "occurrence-not-writable" | "unsafe-to-write" | "access-not-granted" | "day-zone-unreadable" | "copy-unreadable"; diff --git a/src/state/eventWriteError.test.ts b/src/state/eventWriteError.test.ts index 5965247f..cca6a8ae 100644 --- a/src/state/eventWriteError.test.ts +++ b/src/state/eventWriteError.test.ts @@ -220,6 +220,24 @@ describe('an all-day day in a time zone Aperio cannot read', () => { }); }); +describe('a write that needs the current copy, when it cannot be read', () => { + it('says to try again, and counts as nothing written', () => { + // An all-day day is never written blind: without the copy nothing is sent, + // so a split can take its new part back (decision 144). + const desktop = command('forbidden', 'copy-unreadable: EWS HTTP 503: Service Unavailable'); + const phone = new Error( + "Calling the 'updateEventJson' function has failed\n→ Caused by: copy-unreadable: network down", + ); + for (const err of [desktop, phone]) { + expect(eventWriteRefusal(err)?.refusal).toBe('copy-unreadable'); + expect(eventWriteErrorMessage(err, t)).toMatch(/aktuellen Stand dieses Termins nicht/); + expect(eventWriteErrorMessage(err, t)).toMatch(/Versuche es gleich noch einmal/); + expect(eventWriteErrorMessage(err, t)).not.toMatch(/503|network/); + } + expect(writeNeverLanded(desktop)).toBe(true); + }); +}); + describe('any write on the phone, not only the event editor', () => { it('says a refusal as its sentence and anything else as before', async () => { const { writeErrorMessage } = await import('@aperio/shared'); diff --git a/web/src/content/docs/de/guides/troubleshooting.md b/web/src/content/docs/de/guides/troubleshooting.md index c85509fe..33ddd0cd 100644 --- a/web/src/content/docs/de/guides/troubleshooting.md +++ b/web/src/content/docs/de/guides/troubleshooting.md @@ -195,6 +195,12 @@ Was du tun kannst: Ändere den Tag in Outlook. Alles andere, etwa Titel, Ort oder Erinnerung, kannst du in Aperio weiter ändern, solange du den Tag dabei nicht verschiebst. +Ähnlich klingt: „Aperio konnte den aktuellen Stand dieses Termins nicht vom +Server lesen und hat ihn deshalb nicht geändert.“ Um die Zeitzone zu kennen, +liest Aperio den Termin vor dem Speichern vom Server. Gelingt das nicht, etwa +weil der Server gerade nicht antwortet, schickt Aperio nichts. Versuche es +nach einem Moment noch einmal. + ## Einen Fehler melden 1. Stelle in Einstellungen → Protokolle die Stufe auf **Debug**. diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index aec1e3ca..439757b8 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -193,10 +193,11 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in (`StoredZones`); an exception takes its series' ids and definitions. A create still writes UTC midnights: Aperio creates without a zone, so Exchange stores the item in UTC. Where a boundary's zone cannot be read, - or the copy could not be read, the day is not written: the update is - refused as `day-zone-unreadable` (237), carried as `Forbidden` so the - phone keeps the sentence, or the read's own error stands. A save that - leaves the day alone still goes out. + the day is not written: the update is refused as `day-zone-unreadable` + (237). Where the copy could not be read at all, it is refused as + `copy-unreadable`, with the read's error as its detail. Both are carried + as `Forbidden`, so the phone keeps the sentence and a split knows nothing + landed. A save that leaves the day alone still goes out. - **Zone definitions.** `StartTimeZone` and `EndTimeZone` carry the zone's full definition (periods with their bias, yearly changes, eras from absolute transitions). Both item parsers read it onto its own boundary diff --git a/web/src/content/docs/guides/troubleshooting.md b/web/src/content/docs/guides/troubleshooting.md index f656cac7..48cee14f 100644 --- a/web/src/content/docs/guides/troubleshooting.md +++ b/web/src/content/docs/guides/troubleshooting.md @@ -182,6 +182,12 @@ What you can do: change the day in Outlook. Everything else, such as the title, the place or the reminder, you can still change in Aperio, as long as you do not move the day with it. +A similar message is: "Aperio could not read this appointment's current state +from the server, so it did not change it." To know the time zone, +Aperio reads the appointment from the server before saving. When that fails, +for instance because the server is not answering right now, Aperio sends +nothing. Try again in a moment. + ## Reporting a bug 1. In Settings → Logs, set the level to **Debug**. From 02e46df85d94d04f102eec994cf60d765b5c9b11 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 06:08:50 +0200 Subject: [PATCH 09/18] Third check of #121: a blind series update's first day, sign-in and gone items, the generator and live step 6 - A zoned timed series updated without the copy still started its rule on the UTC date, while the same request wrote the series' zone: Monday 00:30 in Berlin went out as StartDate Sunday. Without the copy the rule now starts on the day of the zone the update writes with it, as a create does. - copy-unreadable hid a failed sign-in or a deleted item behind "try again". A read that failed on the sign-in or found the item gone keeps its own error (Authentication, NotFound), as the same edit of a timed appointment reports it. `names_sign_in_or_gone` shares its SOAP codes with `to_core_error`, and a test checks the two agree. - Round 3's generator says what still differs from round 3 (the rule's StartDate in R3-1 and R3-2) and names R3-7b as the measured 47a step. - The 234 test comment states a decision, not a measurement. - ews.md: the blind rule clock and the errors that stay themselves. Red proofs: the blind clock taken out, and every read error wrapped as copy-unreadable again, each fail their test. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/api.rs | 12 +- crates/adapter-ews/src/lib.rs | 151 ++++++++++++++++-- crates/adapter-ews/src/mapping.rs | 93 ++++++++--- .../content/docs/developers/adapters/ews.md | 9 +- 4 files changed, 228 insertions(+), 37 deletions(-) diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 8f145597..58f3fa5c 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -807,9 +807,12 @@ pub async fn update_event( // Without the copy the zones the item is stored in are unknown, and an // all-day day is never written blind (decision 237). Nothing is sent, and // the error says so: `copy-unreadable`, with the read's own failure (or an - // item the server no longer has) as its detail. Returned as the read's - // error, a dropped connection read as a write that may have landed, and a - // split kept its new part next to the uncut series (decision 144). + // answer without the item) as its detail. Returned as the read's error, a + // dropped connection read as a write that may have landed, and a split + // kept its new part next to the uncut series (decision 144). A failed + // sign-in or an item that is gone stays itself, as the same edit of a + // timed appointment reports it: "try again" would not help, and both + // already count as nothing written. let (set_xml, delete_xml) = match crate::mapping::event_to_update_field_xml_in( event, before.as_ref(), @@ -819,6 +822,9 @@ pub async fn update_event( &chrono::Local, ) { Err(EwsError::Protocol(message)) if before.is_none() && day_zone_unreadable(&message) => { + if let Some(err) = read_error.take_if(|err| crate::names_sign_in_or_gone(err)) { + return Err(err); + } let why = read_error .map(|err| err.to_string()) .unwrap_or_else(|| "the server answered without the item".into()); diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 122d373b..493e485e 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -1485,6 +1485,32 @@ fn refused_update_code(code: &str) -> Option<&str> { REFUSED_UPDATE_CODES.contains(&bare).then_some(bare) } +/// The SOAP codes with which Exchange says the sign-in or a permission failed. +const SIGN_IN_SOAP_CODES: &[&str] = &[ + "ErrorAccessDenied", + "ErrorInvalidAccessToken", + "ErrorPasswordExpired", + "ErrorADUnavailable", + "ErrorNoFreeBusyAccess", +]; + +/// The SOAP codes with which Exchange says the item or its folder is gone. +const GONE_SOAP_CODES: &[&str] = &["ErrorItemNotFound", "ErrorFolderNotFound"]; + +/// Whether an error says the sign-in failed or the item is gone: what +/// [`to_core_error`] names `Authentication` or `NotFound`. A retry mends +/// neither, so the error is reported as itself. +pub(crate) fn names_sign_in_or_gone(err: &EwsError) -> bool { + match err { + EwsError::Http { status, .. } => matches!(status, 401 | 403 | 404), + EwsError::Soap { code, .. } => { + SIGN_IN_SOAP_CODES.contains(&code.as_str()) || GONE_SOAP_CODES.contains(&code.as_str()) + } + EwsError::DiscoveryFailed(_) => true, + EwsError::Network(_) | EwsError::Protocol(_) | EwsError::Config(_) => false, + } +} + fn to_core_error(err: EwsError) -> CoreError { use EwsError::*; match err { @@ -1495,20 +1521,17 @@ fn to_core_error(err: EwsError) -> CoreError { 409 | 412 => CoreError::Conflict(message), _ => CoreError::Protocol(format!("EWS HTTP {status}: {message}")), }, - Soap { code, message } => match code.as_str() { - // EWS encodes auth + permission failures in the SOAP body - // even though the HTTP status is 200. Route the familiar - // codes into the matching cal-core variants so the UI can - // present "wrong password" specifically rather than a - // generic protocol error. - "ErrorAccessDenied" - | "ErrorInvalidAccessToken" - | "ErrorPasswordExpired" - | "ErrorADUnavailable" - | "ErrorNoFreeBusyAccess" => CoreError::Authentication(message), - "ErrorItemNotFound" | "ErrorFolderNotFound" => CoreError::NotFound(message), - _ => CoreError::Protocol(format!("EWS SOAP {code}: {message}")), - }, + // EWS encodes auth + permission failures in the SOAP body even though + // the HTTP status is 200. Route the familiar codes into the matching + // cal-core variants so the UI can present "wrong password" + // specifically rather than a generic protocol error. + Soap { code, message } if SIGN_IN_SOAP_CODES.contains(&code.as_str()) => { + CoreError::Authentication(message) + } + Soap { code, message } if GONE_SOAP_CODES.contains(&code.as_str()) => { + CoreError::NotFound(message) + } + Soap { code, message } => CoreError::Protocol(format!("EWS SOAP {code}: {message}")), Protocol(m) => CoreError::Protocol(m), Config(m) => CoreError::InvalidInput(m), DiscoveryFailed(m) => CoreError::NotFound(m), @@ -3244,4 +3267,104 @@ mod server_zone_tests { "nothing is sent" ); } + + /// A failed sign-in or an item that is gone keeps its own error when the + /// copy cannot be read: "try again" would not help. Both count as nothing + /// written, and nothing is sent. + #[tokio::test] + async fn a_failed_sign_in_or_a_gone_item_keeps_its_error_without_the_copy() { + // The item was deleted in Outlook. + let gone = r#" + + + + The specified object was not found in the store. + ErrorItemNotFound + + +"#; + let mut server = Server::new_async().await; + let requests = serve_stored(&mut server, gone.to_string()).await; + let adapter = EwsAdapter::new(server.url(), alice()); + let err = adapter.update_event(moved_to_tuesday()).await.unwrap_err(); + assert!(matches!(err, CoreError::NotFound(_)), "{err:?}"); + assert!( + !requests + .lock() + .unwrap() + .iter() + .any(|b| b.contains("UpdateItem")), + "nothing is sent" + ); + + // The password changed. + let mut server = Server::new_async().await; + let failed = Arc::new(StdMutex::new(Vec::::new())); + let seen = Arc::clone(&failed); + server + .mock("POST", "/") + .with_status(401) + .with_body_from_request(move |request| { + seen.lock() + .unwrap() + .push(request.utf8_lossy_body().unwrap().into_owned()); + b"Unauthorized".to_vec() + }) + .create_async() + .await; + let adapter = EwsAdapter::new(server.url(), alice()); + let err = adapter.update_event(moved_to_tuesday()).await.unwrap_err(); + assert!(matches!(err, CoreError::Authentication(_)), "{err:?}"); + assert!( + !failed + .lock() + .unwrap() + .iter() + .any(|b| b.contains("UpdateItem")), + "nothing is sent" + ); + } +} + +#[cfg(test)] +mod sign_in_or_gone_tests { + use super::*; + + /// `names_sign_in_or_gone` and `to_core_error` read the same codes: the + /// one says which errors stay themselves, the other what they become. + #[test] + fn names_sign_in_or_gone_agrees_with_to_core_error() { + let soap = |code: &str| EwsError::Soap { + code: code.into(), + message: "no".into(), + }; + let http = |status: u16| EwsError::Http { + status, + message: "no".into(), + }; + let mut errors: Vec = SIGN_IN_SOAP_CODES + .iter() + .chain(GONE_SOAP_CODES) + .chain(&["ErrorServerBusy", "ErrorInternalServerError"]) + .map(|code| soap(code)) + .collect(); + errors.extend([400, 401, 403, 404, 409, 429, 500, 503].map(http)); + errors.extend([ + EwsError::Network("reset".into()), + EwsError::Protocol("bad".into()), + EwsError::Config("bad".into()), + EwsError::DiscoveryFailed("x".into()), + ]); + for err in errors { + let label = format!("{err:?}"); + let named = names_sign_in_or_gone(&err); + let core = to_core_error(err); + assert_eq!( + named, + matches!(core, CoreError::Authentication(_) | CoreError::NotFound(_)), + "{label} -> {core:?}" + ); + } + } } diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index b8107094..5c532851 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2446,7 +2446,19 @@ pub(crate) fn event_to_update_field_xml_in( Some(server) if !may(EventField::Recurrence) => server, _ => event, }; - let clock = stored.start.as_ref(); + // Without the copy the stored zone is unknown. The zone this update writes + // beside the rule stands in for it, as on a create: it comes from the + // row's own zone, which Aperio read from the stored one, so the rule + // starts on the day the user sees instead of the UTC date. + let blind_clock = before + .is_none() + .then(|| { + let written = + series_windows_zone(event.all_day, rule_of.recurrence.as_ref(), server_zones); + written_clock(event.all_day, rule_of.recurrence.as_ref(), written) + }) + .flatten(); + let clock = stored.start.as_ref().or(blind_clock.as_ref()); let first_day = |ev: &Event| rule_first_day(ev.start, ev.all_day, clock, device); let built_rule = |ev: &Event| { ev.recurrence @@ -2835,7 +2847,8 @@ pub(crate) fn device_day(when: DateTime, device: &D) -> chrono /// zone Exchange expands it in — the zone a create writes with it, the zone /// the item is stored in on an update (decision 234, inferred from live round /// 1, where Exchange applied an update's fields in order; not measured for a -/// rule and a zone), UTC where there is none. +/// rule and a zone) or, when its copy could not be read, the zone the update +/// writes with it, UTC where there is none. pub(crate) fn rule_first_day( start: DateTime, all_day: bool, @@ -5278,12 +5291,16 @@ mod tests { /// - the Tokyo items carry a zone on an all-day single; /// - R3-7b leaves out the Recurrence delete. /// - /// Historical since PR 8a: the files reproduce round 3's requests. The - /// "today's rule" steps (R3-2, R3-4, R3-5b-u) are what round 3's build - /// sent, UTC midnights through `event_to_update_field_xml`, which reads - /// every item as stored in UTC; an 8a build writes an Outlook item's - /// stored-zone midnights instead. The 47a prototype (R3-1, R3-3, R3-7) is - /// Aperio's rule since 8a. + /// Historical since PR 8a: the files reproduce round 3's requests, built + /// with today's code. The "today's rule" steps (R3-2, R3-4, R3-5b-u) put + /// Start and End where round 3's build did, on UTC midnights through + /// `event_to_update_field_xml`, which reads every item as stored in UTC; + /// an 8a build writes an Outlook item's stored-zone midnights instead. One + /// field differs from round 3: the series' rule in R3-1 and R3-2 starts on + /// 8a's first day, the device's, StartDate 2026-10-19 on a Berlin device, + /// where round 3 sent 2026-10-18 (as R3-6 says). The 47a prototype as round 3 measured it (R3-1, R3-3, + /// R3-7b) is Aperio's rule since 8a; R3-7 also carries the Recurrence + /// delete Exchange refused. #[test] #[ignore = "writes the live Exchange test requests of round 3; see the doc comment"] fn live_test_requests_round_3() { @@ -5457,7 +5474,8 @@ mod tests { "R3-1-update-s1-47a.xml", &format!( "Step R3-1: the 47a prototype, Aperio's rule since PR 8a. Aperio's update of Outlook's S1 \ - series with a new title, Start and End moved to midnights of the stored zone. {REPLACE}" + series with a new title, Start and End moved to midnights of the stored zone. Its rule \ + starts on 2026-10-19 since PR 8a (round 3 sent 2026-10-18). {REPLACE}" ), update(&on_stored_midnights(&s1, set), &del), ); @@ -5467,7 +5485,8 @@ mod tests { "R3-2-update-s2-today.xml", &format!( "Step R3-2: Aperio's update of Outlook's S2 series with a new title, as round 3's \ - build sent it, before PR 8a (all-day series write no zone, 46a). {REPLACE}" + build sent it, before PR 8a (all-day series write no zone, 46a), but with the rule's \ + StartDate 2026-10-19 of PR 8a (round 3 sent 2026-10-18). {REPLACE}" ), update(&set, &del), ); @@ -5519,9 +5538,10 @@ mod tests { write( "R3-7-update-s3-exception-47a.xml", &format!( - "Step R3-7: the 47a prototype, Aperio's rule since PR 8a. Aperio's override update of the S3 \ + "Step R3-7: the 47a prototype. Aperio's override update of the S3 \ exception moved to Tuesday 27 October, Start and End on midnights of the stored zone, \ - with the Recurrence delete the override path sent until PR #77. {REPLACE}" + with the Recurrence delete the override path sent until PR #77; Exchange refused it \ + (ErrorInvalidPropertyDelete). {REPLACE}" ), update(&on_stored_midnights(&moved, set.clone()), &del), ); @@ -5541,7 +5561,8 @@ mod tests { write( "R3-7b-update-s3-exception-no-recurrence-delete.xml", &format!( - "Step R3-7b: NOT Aperio's request. R3-7 without the Recurrence delete, as the override path writes since PR #77; sent only if \ + "Step R3-7b: R3-7 without the Recurrence delete, as the override path writes since PR #77: \ + the 47a prototype round 3 measured, Aperio's rule since PR 8a; sent only if \ Exchange refuses R3-7. {REPLACE}" ), update(&on_stored_midnights(&moved, set), &del_without_rule), @@ -9465,6 +9486,40 @@ mod tests { assert_eq!(element(&set, "DaysOfWeek"), "Monday", "{set}"); } + /// Without the copy, a zoned timed series starts on the day of the zone + /// the update writes beside its rule, as a create does: Monday 00:30 in + /// Berlin, not the UTC Sunday, on a device in Honolulu where it is Sunday + /// too. A series without a zone keeps the UTC day. + #[test] + fn a_blind_update_of_a_zoned_series_starts_on_its_written_zones_day() { + let device = chrono_tz::Pacific::Honolulu; + for (tzid, day, weekday) in [ + (Some("Europe/Berlin"), "2026-10-19", "Monday"), + (None, "2026-10-18", "Sunday"), + ] { + let mut edit = zoned_master(tzid); + edit.start = "2026-10-18T22:30:00Z".parse().unwrap(); + edit.end = "2026-10-18T23:00:00Z".parse().unwrap(); + edit.recurrence.as_mut().unwrap().rrule = "FREQ=WEEKLY;COUNT=10".into(); + let (set, _) = event_to_update_field_xml_in( + &edit, + None, + &StoredZones::default(), + None, + EventIdKind::RecurringMaster, + &device, + ) + .unwrap(); + assert_eq!(element(&set, "StartDate"), day, "{tzid:?}: {set}"); + assert_eq!(element(&set, "DaysOfWeek"), weekday, "{tzid:?}: {set}"); + assert_eq!( + set.contains(""), + tzid.is_some(), + "{tzid:?}: {set}" + ); + } + } + /// A title-only save of an all-day series writes no rule: both sides of /// the comparison build the rule on the same first day. #[test] @@ -9715,11 +9770,13 @@ mod tests { } /// Decision 234: an update that makes a timed appointment a series and - /// names a new zone starts the rule on the day the STORED zone reads: - /// Exchange applies the rule before the zone the same update writes after - /// it. Aperio created the single in UTC; Monday 00:30 in Berlin is Sunday - /// there. (That zone, sent after the start, moves the wall clock as well — - /// live round 1, stages 9 and 12; the live test of 8a looks at it.) + /// names a new zone starts the rule on the day the STORED zone reads. The + /// decision assumes Exchange applies the rule before the zone the same + /// update writes after it, inferred from round 1's field order and not + /// measured for a rule and a zone (live step 9 of 8a looks). Aperio + /// created the single in UTC; Monday 00:30 in Berlin is Sunday there. + /// (That zone, sent after the start, moves the wall clock as well: live + /// round 1, stages 9 and 12.) #[test] fn a_new_rule_with_a_new_zone_starts_on_the_stored_zones_day() { let device = chrono_tz::Europe::Berlin; diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 439757b8..d2703f19 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -176,7 +176,9 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in (`rule_first_day`): the device's day for an all-day series, the written zone's day on a create, the stored start zone's day on an update (234, inferred from live round 1, where Exchange applied an update's fields in - order; not measured for a rule and a zone), UTC where none is known. The range's StartDate and the weekday, day of the month and + order; not measured for a rule and a zone), the zone the update writes + with the rule when the copy could not be read, UTC where none is known. + The range's StartDate and the weekday, day of the month and month of a rule without BYDAY, BYMONTHDAY or BYMONTH come from that day. Read off the UTC date, as before, they named the day before east of UTC: a daily all-day series created in Berlin for Monday 19 October started on @@ -197,7 +199,10 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in (237). Where the copy could not be read at all, it is refused as `copy-unreadable`, with the read's error as its detail. Both are carried as `Forbidden`, so the phone keeps the sentence and a split knows nothing - landed. A save that leaves the day alone still goes out. + landed. A read that failed on the sign-in or found the item gone keeps its + own error (`Authentication`, `NotFound`; `names_sign_in_or_gone`), as the + same edit of a timed appointment reports it: a retry mends neither. A + save that leaves the day alone still goes out. - **Zone definitions.** `StartTimeZone` and `EndTimeZone` carry the zone's full definition (periods with their bias, yearly changes, eras from absolute transitions). Both item parsers read it onto its own boundary From fbd25910a2c25c57c74f4180f0bdfccc2c354b51 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 06:33:10 +0200 Subject: [PATCH 10/18] Fourth check of #121: the log says what was sent, decision 233's wiring tested - When the copy could not be read, the adapter logged "writing every field not kept" before it knew whether the update would go out. It now logs the failed read, then the outcome: the all-day day withheld (with the reason) or the blind write that goes out. A day refused in a zone Aperio cannot read is logged as well. - Decision 233 through the production paths: an adapter test reads a Greenwich start and a W. Europe end from the server copy and writes each on its own zone's midnight; a read test takes a Tokyo start and a Honolulu end through to_event and both override_event paths. - The update builder's comment and DESIGN-series-time-zone.md name the rule clock of an update without the copy. Red proofs: the end zone taken from the start zone in StoredZones::of, in to_event, in an exception's own row and in an inherited row, each fail their test. Co-Authored-By: Claude Opus 5.5 --- DESIGN-series-time-zone.md | 3 +- crates/adapter-ews/src/api.rs | 38 ++++++++++++++---- crates/adapter-ews/src/lib.rs | 31 +++++++++++++++ crates/adapter-ews/src/mapping.rs | 65 ++++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 9 deletions(-) diff --git a/DESIGN-series-time-zone.md b/DESIGN-series-time-zone.md index c4bc3667..e107f8e8 100644 --- a/DESIGN-series-time-zone.md +++ b/DESIGN-series-time-zone.md @@ -643,7 +643,8 @@ der Serie (21a), der Rest behält die Zone der ganzen Serie. - **Exchange** liest seit PR 8a den ersten Tag einer Serie auf der Uhr, auf der Exchange sie wiederholt (`rule_first_day`): ganztägig der Tag des Geräts, beim Anlegen die geschriebene Zone, beim Ändern die gespeicherte Startzone - (234), sonst UTC. **Microsoft 365** leitet Start- und Enddatum und die + (234), ohne lesbaren Serverstand die Zone, die die Änderung mitschreibt, + sonst UTC. **Microsoft 365** leitet Start- und Enddatum und die Standard-Tage weiter aus dem UTC-Datum ab und muss sie auf der Uhr der Serie lesen, bevor die Editoren Beginn und UNTIL auf diese Uhr stellen. - **Handy-Kalender** speichern keine Regel; die Auswahl ist dort nicht da. diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 58f3fa5c..7727f696 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -730,7 +730,7 @@ pub async fn create_event( /// Update an existing calendar item with the supplied event payload. /// Fields with a value are set; some emptied ones become DeleteItemField /// blocks so EWS clears them server-side (see -/// [`crate::mapping::event_to_update_field_xml_on`]). +/// [`crate::mapping::event_to_update_field_xml_in`]). /// /// A plain occurrence id resolves to its series master (`GetItem` with /// `RecurringMasterItemId`), and the edit applies to the whole series. An @@ -789,12 +789,13 @@ pub async fn update_event( Err(err) => { // A single or a series head: the row is the user's own, so it is // written without a comparison — every field the host did not mark - // as left alone (decision 106). + // as left alone (decision 106). Whether it is written at all is + // known only once the update is built; that is logged below. tracing::warn!( target: "adapter_ews::write", ?err, event_id = %event.id, - "the current copy could not be read; writing every field not kept, and a kept rule with a moved slot", + "the current copy could not be read", ); read_error = Some(err); None @@ -821,19 +822,42 @@ pub async fn update_event( target.kind, &chrono::Local, ) { - Err(EwsError::Protocol(message)) if before.is_none() && day_zone_unreadable(&message) => { + Err(EwsError::Protocol(message)) if before.is_some() && day_zone_unreadable(&message) => { + tracing::warn!( + target: "adapter_ews::write", + event_id = %event.id, + %message, + "an all-day day in a zone Aperio cannot read; nothing is written", + ); + return Err(EwsError::Protocol(message)); + } + Err(EwsError::Protocol(message)) if day_zone_unreadable(&message) => { + let why = read_error.as_ref().map_or_else( + || "the server answered without the item".into(), + ToString::to_string, + ); + tracing::warn!( + target: "adapter_ews::write", + event_id = %event.id, + %why, + "an all-day day is not written without the current copy; nothing is written", + ); if let Some(err) = read_error.take_if(|err| crate::names_sign_in_or_gone(err)) { return Err(err); } - let why = read_error - .map(|err| err.to_string()) - .unwrap_or_else(|| "the server answered without the item".into()); return Err(EwsError::Protocol( cal_core::WriteRefusal::CopyUnreadable.message(&why), )); } other => other?, }; + if before.is_none() { + tracing::warn!( + target: "adapter_ews::write", + event_id = %event.id, + "writing without the current copy: every field not kept, and a kept rule with a moved slot", + ); + } // Nothing to write. Asked of the BUILT XML, not of the diff: the diff can // report attendees changed while `keep_attendees` suppresses that block, // and an `UpdateItem` with an empty `` is a fault. This sits diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 493e485e..23acfa5e 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -3175,6 +3175,37 @@ mod server_zone_tests { assert!(!update.contains("TimeZone"), "{update}"); } + /// Decision 233 through the adapter: a start and an end stored in + /// different zones each move on their own zone's midnight. Greenwich for + /// the start, W. Europe for the end: the shape Exchange gave R3-2's series. + #[tokio::test] + async fn each_all_day_boundary_moves_on_its_own_stored_zone() { + let mut server = Server::new_async().await; + let requests = serve_stored( + &mut server, + stored_single( + r#" + "#, + ), + ) + .await; + let adapter = EwsAdapter::new(server.url(), alice()); + adapter + .update_event(moved_to_tuesday()) + .await + .expect("update"); + let requests = requests.lock().unwrap(); + let update = requests + .iter() + .find(|b| b.contains("UpdateItem")) + .expect("an UpdateItem"); + assert!( + update.contains("2026-10-13T00:00:00Z") + && update.contains("2026-10-13T22:00:00Z"), + "{update}" + ); + } + /// Decision 237 through the adapter: a custom zone Exchange names by id /// alone has no clock; the day is refused by name, carried as Forbidden /// so the phone keeps the sentence, and nothing is sent. diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 5c532851..dc8fe82b 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2270,7 +2270,8 @@ pub fn event_to_update_field_xml_on( /// each boundary in its own, 233), refused where one is unknown (237). A /// series' rule starts on its first day as Exchange will read it: the /// device's day for an all-day series, the stored start zone's day for a -/// timed one (234), UTC where none is known. +/// timed one (234) or, without the copy, the day of the zone the update +/// writes beside the rule, UTC where none is known. pub(crate) fn event_to_update_field_xml_in( event: &Event, before: Option<&Event>, @@ -8435,6 +8436,68 @@ mod tests { ); } + /// Decision 233 through the read paths: a single's row and an exception's + /// row, inherited or its own, read the end in the end zone. Honolulu's + /// midnight of 20 October read in Tokyo, the start's zone, would be the + /// 21st. + #[test] + fn the_read_rows_take_the_end_from_the_end_zone() { + let local = |d: &str| local_midnight_in(d.parse().unwrap(), &Local).unwrap(); + let tokyo_19 = utc("2026-10-18T15:00:00Z"); + let honolulu_20 = utc("2026-10-20T10:00:00Z"); + let single = ParsedItem { + item_id: "S".into(), + subject: "Two zones".into(), + start: Some(tokyo_19), + end: Some(honolulu_20), + item_type: Some("Single".into()), + ..all_day_item(Some("Tokyo Standard Time"), Some("Hawaiian Standard Time")) + }; + let row = to_event(single.clone(), "cal").unwrap(); + assert_eq!( + (row.start, row.end), + (local("2026-10-19"), local("2026-10-20")) + ); + + let mut master = ParsedItem { + item_id: "M".into(), + is_recurring: true, + item_type: Some("RecurringMaster".into()), + ..single.clone() + }; + master.recurrence = Some(EwsRecurrence { + pattern: EwsRecurrencePattern::Daily { interval: 1 }, + range: EwsRecurrenceRange::NoEnd, + }); + let master_ev = to_event(master.clone(), "cal").unwrap(); + let inherited = ModifiedOccurrence { + item_id: "OCC".into(), + change_key: None, + start: tokyo_19, + end: honolulu_20, + original_start: tokyo_19, + cancelled: false, + own: None, + }; + let own = ModifiedOccurrence { + own: Some(Box::new(ParsedItem { + item_id: "OCC".into(), + item_type: Some("Exception".into()), + ..single + })), + ..inherited.clone() + }; + for ov in [inherited, own] { + let row = override_event(&master_ev, &master, &ov, "cal").unwrap(); + assert_eq!( + (row.start, row.end), + (local("2026-10-19"), local("2026-10-20")), + "own copy: {}", + ov.own.is_some() + ); + } + } + /// Decision 217: an all-day item's instants are midnights in its own zone, /// and read in it the day is exact. An Auckland series begun in winter has /// its January slots at 11:00 UTC the day before; the 12-hour sample read From bea6fc0ca3022748fc5c47d1b7e7990a01beea0f Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 06:54:08 +0200 Subject: [PATCH 11/18] Fifth check of #121: the blind-write log after the empty check, ews.md's generator note, German reasons - "writing without the current copy" is logged only when an update goes out: it now follows the empty-update check. - ews.md's Testing paragraph says round 3's generator is historical since PR 8a and which of its files are Aperio's rule now. - A test comment names all_day_boundary, which replaced ews_all_day_boundary. - The German reasons of day-zone-unreadable and copy-unreadable stand only in the split's sentences about "die alte Serie", so they take its gender ("sie", "ihr Tag", "ihren Stand"). The test asserts both whole. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/api.rs | 14 +++++++------- crates/adapter-ews/src/mapping.rs | 5 +++-- locales/de/translation.json | 4 ++-- src/state/eventWriteError.test.ts | 9 ++++++++- web/src/content/docs/developers/adapters/ews.md | 9 ++++++--- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 7727f696..7bc3d816 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -851,13 +851,6 @@ pub async fn update_event( } other => other?, }; - if before.is_none() { - tracing::warn!( - target: "adapter_ews::write", - event_id = %event.id, - "writing without the current copy: every field not kept, and a kept rule with a moved slot", - ); - } // Nothing to write. Asked of the BUILT XML, not of the diff: the diff can // report attendees changed while `keep_attendees` suppresses that block, // and an `UpdateItem` with an empty `` is a fault. This sits @@ -878,6 +871,13 @@ pub async fn update_event( ..event.clone() }); } + if before.is_none() { + tracing::warn!( + target: "adapter_ews::write", + event_id = %event.id, + "writing without the current copy: every field not kept, and a kept rule with a moved slot", + ); + } // Removed invitees count too: with every one removed (`clear_attendees`) // they may still get a cancellation (decision 74a). let notify = event.send_invitations && (!event.attendees.is_empty() || event.clear_attendees); diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index dc8fe82b..a860015c 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -6884,8 +6884,9 @@ mod tests { } /// The slot is one fact: turning an event into an all-day one writes both - /// boundaries with it, because `ews_all_day_boundary` rewrites them from - /// the flag and Exchange validates a Start against the End it has stored. + /// boundaries with it, because `all_day_boundary` rewrites them from the + /// flag, in the stored zones, and Exchange validates a Start against the + /// End it has stored. #[test] fn the_slot_is_written_as_one() { let before = saved_single("Trip"); diff --git a/locales/de/translation.json b/locales/de/translation.json index d63b0cd4..d10e7e0e 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -1706,8 +1706,8 @@ "occurrenceNotWritable": "dieser eine Termin lässt sich in seiner Serie nicht einzeln ändern", "unsafeToWrite": "Aperio kann ihn auf dem Server nicht sicher ändern", "accessNotGranted": "Aperio hat keinen Zugriff auf die Kalender und Erinnerungen dieses Geräts", - "dayZoneUnreadable": "Exchange speichert ihn in einer Zeitzone, die Aperio nicht lesen kann, deshalb lässt sich sein Tag hier nicht ändern", - "copyUnreadable": "Aperio konnte seinen aktuellen Stand nicht vom Server lesen", + "dayZoneUnreadable": "Exchange speichert sie in einer Zeitzone, die Aperio nicht lesen kann, deshalb lässt sich ihr Tag hier nicht ändern", + "copyUnreadable": "Aperio konnte ihren aktuellen Stand nicht vom Server lesen", "forbidden": "der Anbieter erlaubt es nicht ({{detail}})", "changedOnServer": "sie wurde auf dem Server geändert, seit du sie geöffnet hast" } diff --git a/src/state/eventWriteError.test.ts b/src/state/eventWriteError.test.ts index cca6a8ae..554b42e4 100644 --- a/src/state/eventWriteError.test.ts +++ b/src/state/eventWriteError.test.ts @@ -215,7 +215,11 @@ describe('an all-day day in a time zone Aperio cannot read', () => { ); expect(eventWriteErrorMessage(err, t)).not.toMatch(/no-zone/); } - expect(eventWriteFailureReason(desktop, t)).toMatch(/Zeitzone, die Aperio nicht lesen kann/); + // The reason stands only in the split's sentences, about "die alte Serie". + expect(eventWriteFailureReason(desktop, t)).toBe( + 'Exchange speichert sie in einer Zeitzone, die Aperio nicht lesen kann, ' + + 'deshalb lässt sich ihr Tag hier nicht ändern', + ); expect(writeNeverLanded(desktop)).toBe(true); }); }); @@ -234,6 +238,9 @@ describe('a write that needs the current copy, when it cannot be read', () => { expect(eventWriteErrorMessage(err, t)).toMatch(/Versuche es gleich noch einmal/); expect(eventWriteErrorMessage(err, t)).not.toMatch(/503|network/); } + expect(eventWriteFailureReason(desktop, t)).toBe( + 'Aperio konnte ihren aktuellen Stand nicht vom Server lesen', + ); expect(writeNeverLanded(desktop)).toBe(true); }); }); diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index d2703f19..4f095b6c 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -244,6 +244,9 @@ tests ask the mocked server for its zones once and drain an older parser's state again. Live testing needs an Exchange/365 mailbox that still exposes EWS. The ignored tests `live_test_requests` and `live_test_requests_round_3` write the requests of -the live zone tests as Aperio builds them, into the directory -`APERIO_LIVE_TEST_DIR` names; each file's header comment says which requests -are not Aperio's rule yet. +the live zone tests, into the directory `APERIO_LIVE_TEST_DIR` names; each +file's header comment says what it is. Round 3's files are historical since +PR 8a: its "today's rule" files (R3-2, R3-4, R3-5b-u) are round 3's requests +before 8a, rebuilt for an item read as stored in UTC, while an 8a build +writes an Outlook item's stored-zone midnights; its 47a prototype (R3-1, +R3-3, R3-7b) is Aperio's rule now. From 9b05c11ad94ce0b6e0b4d6550b3c2560c92b2cee Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 07:15:28 +0200 Subject: [PATCH 12/18] Sixth check of #121: the written zone looked up once, the round 3 generator's doc as history - The update builder looked the written series zone up twice on a blind update (once for the blind clock, once for the zone it writes), so a zone Exchange cannot store was logged twice. It is looked up once and used for both, as the create does. - The round 3 generator's doc opened with the old "NOT Aperio's rule" list; it is now history, and ews.md names the one field that differs from round 3 (the rule's StartDate in R3-1 and R3-2). Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/mapping.rs | 35 +++++++++---------- .../content/docs/developers/adapters/ews.md | 10 +++--- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index a860015c..b3fbb916 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2447,17 +2447,16 @@ pub(crate) fn event_to_update_field_xml_in( Some(server) if !may(EventField::Recurrence) => server, _ => event, }; + // The zone this update may write beside the rule, looked up once: a zone + // Exchange cannot store is logged once per write, as on a create. + let written = series_windows_zone(event.all_day, rule_of.recurrence.as_ref(), server_zones); // Without the copy the stored zone is unknown. The zone this update writes // beside the rule stands in for it, as on a create: it comes from the // row's own zone, which Aperio read from the stored one, so the rule // starts on the day the user sees instead of the UTC date. let blind_clock = before .is_none() - .then(|| { - let written = - series_windows_zone(event.all_day, rule_of.recurrence.as_ref(), server_zones); - written_clock(event.all_day, rule_of.recurrence.as_ref(), written) - }) + .then(|| written_clock(event.all_day, rule_of.recurrence.as_ref(), written)) .flatten(); let clock = stored.start.as_ref().or(blind_clock.as_ref()); let first_day = |ev: &Event| rule_first_day(ev.start, ev.all_day, clock, device); @@ -2509,10 +2508,7 @@ pub(crate) fn event_to_update_field_xml_in( // time-only gate would drop it on a rule-only change — weekly to daily // without moving the series. let zone_may_change = rule_changed || touches(EventField::AllDay) || slot_changed; - if let Some(windows) = - series_windows_zone(event.all_day, rule_of.recurrence.as_ref(), server_zones) - .filter(|_| zone_may_change) - { + if let Some(windows) = written.filter(|_| zone_may_change) { let win = escape_xml(windows); set.push_str(&format!( " \n \n \n \n \n \n \n \n \n \n \n \n", @@ -5285,23 +5281,24 @@ mod tests { /// The updates start from Aperio's own read of that planned stored shape (a /// SyncFolderItems row through `parse_sync_folder_items_response` and /// `to_event`), so they are what Aperio sends after reading those items as - /// the owner is asked to create them: no reminder, location or body. The - /// requests marked "NOT Aperio's rule" differ on purpose: - /// - the 47a prototype puts Start and End on midnights of the stored zone + /// the owner is asked to create them: no reminder, location or body. In + /// round 3, the requests marked "NOT Aperio's rule" differed on purpose: + /// - the 47a prototype put Start and End on midnights of the stored zone /// instead of the UTC midnights of the day; /// - the Tokyo items carry a zone on an all-day single; - /// - R3-7b leaves out the Recurrence delete. + /// - R3-7b left out the Recurrence delete. /// - /// Historical since PR 8a: the files reproduce round 3's requests, built - /// with today's code. The "today's rule" steps (R3-2, R3-4, R3-5b-u) put - /// Start and End where round 3's build did, on UTC midnights through + /// Only the Tokyo items (R3-5, R3-5b) are still marked so. Historical + /// since PR 8a: the files reproduce round 3's requests, built with + /// today's code. The "today's rule" steps (R3-2, R3-4, R3-5b-u) put Start + /// and End where round 3's build did, on UTC midnights through /// `event_to_update_field_xml`, which reads every item as stored in UTC; /// an 8a build writes an Outlook item's stored-zone midnights instead. One /// field differs from round 3: the series' rule in R3-1 and R3-2 starts on /// 8a's first day, the device's, StartDate 2026-10-19 on a Berlin device, - /// where round 3 sent 2026-10-18 (as R3-6 says). The 47a prototype as round 3 measured it (R3-1, R3-3, - /// R3-7b) is Aperio's rule since 8a; R3-7 also carries the Recurrence - /// delete Exchange refused. + /// where round 3 sent 2026-10-18 (as R3-6 says). The 47a prototype as + /// round 3 measured it (R3-1, R3-3, R3-7b) is Aperio's rule since 8a; + /// R3-7 also carries the Recurrence delete Exchange refused. #[test] #[ignore = "writes the live Exchange test requests of round 3; see the doc comment"] fn live_test_requests_round_3() { diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 4f095b6c..310c2413 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -246,7 +246,9 @@ needs an Exchange/365 mailbox that still exposes EWS. The ignored tests `live_test_requests` and `live_test_requests_round_3` write the requests of the live zone tests, into the directory `APERIO_LIVE_TEST_DIR` names; each file's header comment says what it is. Round 3's files are historical since -PR 8a: its "today's rule" files (R3-2, R3-4, R3-5b-u) are round 3's requests -before 8a, rebuilt for an item read as stored in UTC, while an 8a build -writes an Outlook item's stored-zone midnights; its 47a prototype (R3-1, -R3-3, R3-7b) is Aperio's rule now. +PR 8a: its "today's rule" files (R3-2, R3-4, R3-5b-u) put Start and End +where round 3's build did, rebuilt for an item read as stored in UTC, while +an 8a build writes an Outlook item's stored-zone midnights; its 47a +prototype (R3-1, R3-3, R3-7b) is Aperio's rule now. One field differs from +what round 3 sent: the series' rule in R3-1 and R3-2 starts on 8a's first +day (StartDate 2026-10-19 on a Berlin device; round 3 sent 2026-10-18). From ca398e0c611de72f5886b70e54683e78b905a3ac Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 07:35:53 +0200 Subject: [PATCH 13/18] Seventh check of #121: zone ids decoded, so an id holding '&' matches its target The zone walker kept attribute ids as written, entities and all, while both item parsers decode a target's text. A period or group id holding '&' then never matched its target, and a complete definition was refused as unreadable (237). Attribute values are now decoded (`attribute_text`), in the walker and for the StartTimeZone/EndTimeZone ids, so a zone id has one form whichever element names it. ITEM_PARSER 5, introduced by this PR, drains every folder once anyway. Red proofs: the walker's ids, and the zone elements' ids, read as written again; each fails the new test. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/mapping.rs | 49 +++++++++++++++++++++-- crates/adapter-ews/src/zone_definition.rs | 12 +++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index b3fbb916..c6192e30 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -946,7 +946,7 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult EwsResult EwsResult for a in e.attributes().flatten() { if a.key.as_ref().eq_ignore_ascii_case(b"Id") { current.start_time_zone = - Some(String::from_utf8_lossy(&a.value).into_owned()); + Some(crate::zone_definition::attribute_text(&a)); } } } @@ -1459,7 +1459,7 @@ pub fn parse_get_calendar_items_response(xml: &str) -> EwsResult for a in e.attributes().flatten() { if a.key.as_ref().eq_ignore_ascii_case(b"Id") { current.end_time_zone = - Some(String::from_utf8_lossy(&a.value).into_owned()); + Some(crate::zone_definition::attribute_text(&a)); } } if !empty { @@ -7905,6 +7905,47 @@ mod tests { } } + /// An id holding a character XML escapes reads the same in an attribute + /// and in a `` target, so the definition still makes a clock. The + /// zone's own id is decoded too. + #[test] + fn ids_with_escaped_characters_match_their_targets() { + let body = CUSTOM_ZONE_BODY + .replace("custom/", "Pacific (US & Canada)/") + .replace( + r#"TransitionsGroup Id="0""#, + r#"TransitionsGroup Id="g & 0""#, + ) + .replace( + r#"0"#, + r#"g & 0"#, + ); + assert!( + body.contains(r#"g & 0"#), + "{body}" + ); + let start = format!( + r#"{body}"# + ); + let end = r#""#; + for parse in [in_sync_response, in_get_item_response] { + let item = parse(&zone_item(&start, end)); + assert_eq!( + item.start_time_zone.as_deref(), + Some("Pacific Time (US & Canada)") + ); + let def = item + .start_zone_definition + .expect("the start zone's definition"); + assert_eq!(def.periods[0].id, "Pacific (US & Canada)/std"); + let rules = crate::zone_definition::ZoneRules::try_from(&def).unwrap(); + assert_eq!( + rules.midnight("2026-10-12".parse().unwrap()), + Some("2026-10-11T22:00:00Z".parse().unwrap()) + ); + } + } + /// A server that wraps the definition in a `TimeZoneDefinition` names the /// id there; it still reaches the item. #[test] diff --git a/crates/adapter-ews/src/zone_definition.rs b/crates/adapter-ews/src/zone_definition.rs index a33ad5e5..6045c6a3 100644 --- a/crates/adapter-ews/src/zone_definition.rs +++ b/crates/adapter-ews/src/zone_definition.rs @@ -229,7 +229,17 @@ fn attribute(e: &quick_xml::events::BytesStart<'_>, name: &[u8]) -> Option` target's text, so a period or group id holding `&` matches it +/// only decoded too. A value that does not decode is kept as written. +pub(crate) fn attribute_text(a: &quick_xml::events::attributes::Attribute<'_>) -> String { + a.unescape_value().map_or_else( + |_| String::from_utf8_lossy(&a.value).into_owned(), + |value| value.into_owned(), + ) } impl ZoneDefinitionWalker { From 8401281941290b86ed848e167698106b9dbbf237 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 17:11:57 +0200 Subject: [PATCH 14/18] Live round 6 of #121: record what Exchange did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 1 to 7 of the live test passed: the date bug is fixed, all-day days move on the stored zones' midnights, Exchange 2019 sends a custom zone's definition in GetItem and SyncFolderItems and the day moves on it, and a zone named Europe/Berlin reads as Berlin. Two results refute what the code assumed, both the same on main: - A timed "this and all following" split shows the cut day twice: the old series ends on the date of its UNTIL, and Exchange counts the EndDate in (235). Its own PR follows. - A single made weekly in one save lands on Sundays at 23:30 instead of Mondays at 00:30: the rule starts on the stored zone's day (234), and the new zone written after it keeps the stored wall clock. 234 is refuted; the next PR writes the zone first. DESIGN (stage 4, "Gemessen" and "Ungeprüft"), TODO, ews.md and the comments that cited the live steps say so now. Co-Authored-By: Claude Opus 5.5 --- DESIGN-series-time-zone.md | 56 +++++++++++++++---- TODO.md | 21 +++++-- crates/adapter-ews/src/api.rs | 3 +- crates/adapter-ews/src/mapping.rs | 23 ++++---- .../content/docs/developers/adapters/ews.md | 19 ++++--- 5 files changed, 87 insertions(+), 35 deletions(-) diff --git a/DESIGN-series-time-zone.md b/DESIGN-series-time-zone.md index e107f8e8..9cc166da 100644 --- a/DESIGN-series-time-zone.md +++ b/DESIGN-series-time-zone.md @@ -644,7 +644,10 @@ der Serie (21a), der Rest behält die Zone der ganzen Serie. Exchange sie wiederholt (`rule_first_day`): ganztägig der Tag des Geräts, beim Anlegen die geschriebene Zone, beim Ändern die gespeicherte Startzone (234), ohne lesbaren Serverstand die Zone, die die Änderung mitschreibt, - sonst UTC. **Microsoft 365** leitet Start- und Enddatum und die + sonst UTC. Wechselt ein Ändern dabei die Zone, stimmt das nicht: Runde 6 + hat gemessen, dass ein Einzeltermin, der so zur Serie wird, um den Versatz + wandert (234 widerlegt). Die Korrektur, die Zone vor Beginn, Ende und Regel + zu schreiben, ist der nächste PR. **Microsoft 365** leitet Start- und Enddatum und die Standard-Tage weiter aus dem UTC-Datum ab und muss sie auf der Uhr der Serie lesen, bevor die Editoren Beginn und UNTIL auf diese Uhr stellen. - **Handy-Kalender** speichern keine Regel; die Auswahl ist dort nicht da. @@ -888,8 +891,40 @@ Handy im selben PR. - Aperio zeigt eine Ausnahme unter dem Betreff der Serie, nicht unter ihrem eigenen. Die verschobene Ausnahme steht richtig nur am Dienstag. - Aperio zeigt die beschädigte Serie einen Tag zu spät (Lesefehler, 48a). + + Live-Test Runde 6 (10. Oktober 2026, derselbe Server, PR 8a). Toni hat die + Schritte 1 bis 5 mit einem Desktop-Build von 8a von Hand gemacht und in + Outlook nachgesehen. Die Schritte 6 bis 9 hat ein Testprogramm außerhalb + des Repositorys über Aperios eigenen Adapter gemacht und zurückgelesen, was + Exchange speichert. Gemeint war jeweils November 2026: + - Eine neue ganztägige Serie (täglich, 4 Mal, ab Montag, dem 2.) steht vom + 2. bis zum 5., eine zweiwöchentliche am 2., 16. und 30. Eine Serie mit + Uhrzeit um 00:30 ohne gewählten Wochentag steht montags. Der Datumsfehler + ist damit behoben. Eine ganztägige Serie ohne Zone zeigt Outlook mit der + Zone „(UTC+00:00) Monrovia, Reykjavik“, also Greenwich (43b). + - Ein ganztägiger Outlook-Termin, von Montag auf Dienstag verschoben, und + eine Ausnahme einer Outlook-Serie liegen danach einen Tag am neuen Tag. Eine + Regeländerung lässt eine Outlook-Serie auf ihren Tagen (47a). + - Exchange 2019 schickt die Definition einer eigenen Zone mit, in + `GetItem` und in `SyncFolderItems`. Ein ganztägiger Termin in einer eigenen + Zone UTC+03:00 (mit `CreateItem` und Definition angelegt, als „Customized + Time Zone“ gespeichert) wurde auf die Mitternacht dieser Zone verschoben, + einen Tag lang (232, 239). Ein Termin mit der Zone „Europe/Berlin“ ebenso + auf Berliner Mitternacht (236). Ein Outlook-Termin mit Uhrzeit, in Aperio + ganztägig gemacht, liegt einen Tag auf Berliner Mitternacht. + - Ein Schnitt „dieser und alle folgenden“ an einer täglichen Serie mit + Uhrzeit zeigt den Schnitttag doppelt. Aperio kürzt die alte Serie mit + `UNTIL` eine Sekunde vor dem Schnitt, das Enddatum ist dessen Datum, und + Exchange zählt das Enddatum mit. Ein vor dem Schnitt gelöschtes Vorkommen + bleibt gelöscht. Das war vor 8a genauso. + - Ein in Aperio angelegter Einzeltermin, Montag 00:30, in einem Speichern + wöchentlich gemacht, steht danach sonntags um 23:30. Die Regel beginnt + nach 234 am Tag der gespeicherten Zone (UTC: Sonntag), und die neue Zone + danach behält die gespeicherte Uhrzeit 23:30 (Stufen 9 und 12). 234 ist + damit widerlegt. Das war vor 8a genauso. 5. **Exchange und Microsoft 365 lesen Serien-Daten auf der Uhr der Serie** — `fix(ews, graph): read a series' dates on its own clock when writing`. - Der Exchange-Teil ist mit PR 8a gebaut, zusammen mit 47a (57a). Offen ist + Der Exchange-Teil ist mit PR 8a gebaut, zusammen mit 47a (57a), und in + Runde 6 gemessen. Offen ist Microsoft 365; danach messen, wie es recurrenceTimeZone beim Zurücklesen behandelt. Serien, die Aperio schon mit falschem Starttag geschrieben hat, bleiben, wie sie sind (231): einmal verschieben und Wochentag oder Tag in @@ -1062,16 +1097,13 @@ Handy im selben PR. Ungeprüft bleibt, ob Exchange eigene Zonendefinitionen und Namen, die nur in der Windows-Registry stehen (Kamchatka, Mid-Atlantic), so zurückgibt, wie Aperio sie liest: als keine Zone. Seit PR 8a liest ein ganztägiger Termin - die Definition selbst, wenn Exchange sie mitschickt; ob Exchange 2019 sie in - `SyncFolderItems` und `GetItem` füllt, misst der Live-Test von 8a. - Ungeprüft aus PR 8a sind außerdem: ob ein Ändern, das Wiederholung und eine - neue Zone zusammen schreibt, den ersten Tag in der alten Zone liest (234; - die neue Zone nach dem Beginn verschiebt ohnehin die Uhrzeit, Stufen 9 und - 12); ob Exchange ein Ende in der Endzone rundet, wenn Beginn und Ende - verschiedene Zonen tragen (233, R3-2 legt es nahe); was Exchange an einem Tag - speichert, dessen Mitternacht die gespeicherte Zone überspringt; ob - `EndDate` inklusive ist (ein Schnitt „dieser und alle folgenden“ mit - Uhrzeit zeigte den Schnitttag dann doppelt). Ungeprüft ist auch, ob Exchanges eigene + die Definition selbst; Exchange 2019 schickt sie in `SyncFolderItems` und + `GetItem` mit (Runde 6). Ungeprüft aus PR 8a sind außerdem: ob Exchange ein + Ende in der Endzone rundet, wenn Beginn und Ende verschiedene Zonen tragen + (233, R3-2 legt es nahe); was Exchange an einem Tag speichert, dessen + Mitternacht die gespeicherte Zone überspringt. Gemessen hat Runde 6, dass + `EndDate` inklusive ist und dass 234 nicht stimmt (siehe Stufe 4, + „Gemessen“). Ungeprüft ist auch, ob Exchanges eigene Zeitzonen-Regeln denen von Windows gleichen, an denen der Uhr-Wächter über die Standardzonen gemessen ist. - Ob der Desktop die eingebauten Manifeste für die Wiederholungs-Fähigkeiten diff --git a/TODO.md b/TODO.md index 3dc8459f..48a427d1 100644 --- a/TODO.md +++ b/TODO.md @@ -2999,8 +2999,16 @@ Siehe DESIGN §4.2. Serie beginnt an ihrem ersten Tag auf der Uhr, auf der Exchange sie wiederholt; ein ganztägiger Tag geht beim Ändern auf Mitternacht in der gespeicherten Zone jeder Grenze; eigene Zonen-Definitionen werden gelesen; - eine unlesbare Zone lehnt den Tag mit eigenem Satz ab (237). Live-Test vor - dem Zusammenführen mit einem Desktop-Build (235). + eine unlesbare Zone lehnt den Tag mit eigenem Satz ab (237). Live-Test + Runde 6 am 10.10.2026: Schritte 1 bis 7 bestanden, auch eine eigene Zone + und „Europe/Berlin“ (Einzelheiten in DESIGN, Stufe 4, „Gemessen“). + 🚩 **Schnitttag doppelt** (235, gemessen in Runde 6): Ein Schnitt „dieser + und alle folgenden“ an einer Exchange-Serie mit Uhrzeit zeigt den Schnitttag + in beiden Serien. Die alte Serie endet mit `UNTIL` eine Sekunde vor dem + Schnitt, das Enddatum ist dessen Datum, und Exchange zählt es mit. Das + Enddatum muss der Tag des letzten Vorkommens vor dem Schnitt sein. Eigener + PR, nach der Zonen-Reihenfolge und vor 8b. Ein vor dem Schnitt gelöschtes + Vorkommen bleibt gelöscht. 🚩 **Serien mit falschem Starttag** (231): Was Aperio vor 8a geschrieben hat, bleibt so. Einmal verschieben und Wochentag oder Tag in der Wiederholung neu wählen; eine wöchentliche Serie ohne gewählten Wochentag trägt in der @@ -3010,8 +3018,13 @@ Siehe DESIGN §4.2. 🚩 **Zone und Wiederholung in einem Ändern:** Wird ein Termin mit Uhrzeit zur Serie mit neuer Zone, setzt Exchange die Zone nach dem Beginn und behält dabei die Uhrzeit, der Termin wandert um den Versatz (Runde 1, - Stufen 9 und 12). Seit 8a beginnt die Regel am Tag der alten Zone (234); - der Live-Test sieht nach. + Stufen 9 und 12). Runde 6 hat es gemessen: Ein in Aperio angelegter + Einzeltermin am Montag um 00:30, wöchentlich gemacht, steht danach sonntags + um 23:30. Die Regel beginnt nach 234 am Tag der alten Zone (UTC: Sonntag); + 234 ist damit widerlegt. Nächster PR nach 8a: die Zone vor Beginn, Ende und + Regel schreiben und die Regel am Tag der neuen Zone beginnen lassen. Dass + die Zone zuerst die Zeitpunkte stehen lässt, ist nur an einer ganztägigen + Serie gemessen (Runde 2, B4); für eine mit Uhrzeit misst es der PR. 🚩 **Open-Source-Hinweise** in der Desktop- und der Handy-App (40a): CLDR und die eingebauten ICU4X-Bibliotheken nennen. 🚩 **Wenn der EWS-Adapter das Repository verlässt,** müssen `cldr/`, die diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 7bc3d816..93883509 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -1311,7 +1311,8 @@ async fn resolve_override_target( /// /// The copy comes with the zones Exchange stores its boundaries in: an all-day /// day is written as midnight in them (decision 47a), and a series' rule -/// starts on its first day as the stored start zone reads it (234). +/// starts on its first day as the stored start zone reads it (234; wrong +/// where the update also changes the zone, live round 6). async fn read_before( client: &EwsClient, target: &WriteTarget, diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index c6192e30..8ca1948c 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2842,10 +2842,12 @@ pub(crate) fn device_day(when: DateTime, device: &D) -> chrono /// The day a series' rule starts on, on the clock Exchange repeats it on: an /// all-day series on the device's days (48a); a timed one on `clock`, the /// zone Exchange expands it in — the zone a create writes with it, the zone -/// the item is stored in on an update (decision 234, inferred from live round -/// 1, where Exchange applied an update's fields in order; not measured for a -/// rule and a zone) or, when its copy could not be read, the zone the update -/// writes with it, UTC where there is none. +/// the item is stored in on an update (decision 234) or, when its copy could +/// not be read, the zone the update writes with it, UTC where there is none. +/// Where the update also changes the zone, 234 does not hold: live round 6 +/// found a single made weekly on the stored zone's day and moved by the new +/// zone, written after the rule, which kept the stored wall clock. The next +/// PR writes the zone first. pub(crate) fn rule_first_day( start: DateTime, all_day: bool, @@ -9872,13 +9874,12 @@ mod tests { } /// Decision 234: an update that makes a timed appointment a series and - /// names a new zone starts the rule on the day the STORED zone reads. The - /// decision assumes Exchange applies the rule before the zone the same - /// update writes after it, inferred from round 1's field order and not - /// measured for a rule and a zone (live step 9 of 8a looks). Aperio - /// created the single in UTC; Monday 00:30 in Berlin is Sunday there. - /// (That zone, sent after the start, moves the wall clock as well: live - /// round 1, stages 9 and 12.) + /// names a new zone starts the rule on the day the STORED zone reads. + /// Aperio created the single in UTC; Monday 00:30 in Berlin is Sunday + /// there. This pins what 8a writes, not what is right: live round 6 sent + /// exactly this and found the series on Sundays at 23:30, because the new + /// zone, written after the rule, kept the stored wall clock (as in round 1, + /// stages 9 and 12). The next PR writes the zone first and changes this. #[test] fn a_new_rule_with_a_new_zone_starts_on_the_stored_zones_day() { let device = chrono_tz::Europe::Berlin; diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 310c2413..112f71a4 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -174,10 +174,14 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in - **A series starts on its first day** (PR 8a). `rrule_to_ews_recurrence` takes the day the series starts on, on the clock Exchange repeats it on (`rule_first_day`): the device's day for an all-day series, the written - zone's day on a create, the stored start zone's day on an update (234, - inferred from live round 1, where Exchange applied an update's fields in - order; not measured for a rule and a zone), the zone the update writes - with the rule when the copy could not be read, UTC where none is known. + zone's day on a create, the stored start zone's day on an update (234), + the zone the update writes with the rule when the copy could not be read, + UTC where none is known. Where an update also changes the zone, 234 does + not hold: live round 6 turned a single created in Aperio, Monday 00:30 in + Berlin, into a weekly series and found it on Sundays at 23:30. The rule + started on the stored zone's (UTC) Sunday, and the new zone, written + after it, kept the stored wall clock (round 1, stages 9 and 12). The next + PR writes the zone before the start, the end and the rule. The range's StartDate and the weekday, day of the month and month of a rule without BYDAY, BYMONTHDAY or BYMONTH come from that day. Read off the UTC date, as before, they named the day before east of UTC: a @@ -209,9 +213,10 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in (`ParsedItem::start_zone_definition`, `end_zone_definition`, ITEM_PARSER 5), and `zone_definition::ZoneRules` turns it into a clock: the wall time at an instant, and a day's midnight (the first hour after it where a - change skips it). A fifth weekday reads as the month's last (238). Whether - Exchange 2019 fills these elements in `SyncFolderItems` and `GetItem` is - unmeasured; the live test of PR 8a reads one. + change skips it). A fifth weekday reads as the month's last (238). + Exchange 2019 fills these elements in `SyncFolderItems` and `GetItem`, and + a custom zone's day moved on its definition's midnight lands as one day + (live round 6, PR 8a). - **Zones Exchange cannot store** are written without a zone: CLDR has no id for them (`Antarctica/Troll`), or the id runs another clock in the five years after the pinned release (`America/Scoresbysund`, `Antarctica/Casey`, From 31fdd7abbf5a4ddbadc3cd0e9fd86b9809ee1d19 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 17:30:22 +0200 Subject: [PATCH 15/18] Review of the live results of #121: what step 9 measured, and its name - Step 9 does not refute 234: the update sent the rule and then the zone, without the start or the end, and Exchange applied them in that order, as 234 assumes. The zone without the start and the end kept the stored wall clock. The next PR writes the zone, then the start and the end even when unchanged, then the rule on the new zone's day, which replaces 234. - "Round 6" already names an iCloud round; this is "the 8a live test". - Step 6 used items the test program created through EWS; an iPhone or invitation item and the 237 refusal stay unmeasured. Step 7's item was created the way Outlook stores it, not by Outlook. - The repeat-only change of step 5 is the first-day rule, not 47a. - Measured as well: a W. Europe series reads back with offset dates (2026-11-04+01:00), and Exchange 2019 sends a custom zone's definition, which the zone_definition module doc now says. Co-Authored-By: Claude Opus 5.5 --- DESIGN-series-time-zone.md | 91 +++++++++++-------- TODO.md | 44 +++++---- crates/adapter-ews/src/api.rs | 4 +- crates/adapter-ews/src/mapping.rs | 20 ++-- crates/adapter-ews/src/zone_definition.rs | 4 +- .../content/docs/developers/adapters/ews.md | 27 +++--- 6 files changed, 110 insertions(+), 80 deletions(-) diff --git a/DESIGN-series-time-zone.md b/DESIGN-series-time-zone.md index 9cc166da..1a2dcf2b 100644 --- a/DESIGN-series-time-zone.md +++ b/DESIGN-series-time-zone.md @@ -644,10 +644,12 @@ der Serie (21a), der Rest behält die Zone der ganzen Serie. Exchange sie wiederholt (`rule_first_day`): ganztägig der Tag des Geräts, beim Anlegen die geschriebene Zone, beim Ändern die gespeicherte Startzone (234), ohne lesbaren Serverstand die Zone, die die Änderung mitschreibt, - sonst UTC. Wechselt ein Ändern dabei die Zone, stimmt das nicht: Runde 6 - hat gemessen, dass ein Einzeltermin, der so zur Serie wird, um den Versatz - wandert (234 widerlegt). Die Korrektur, die Zone vor Beginn, Ende und Regel - zu schreiben, ist der nächste PR. **Microsoft 365** leitet Start- und Enddatum und die + sonst UTC. Wechselt ein Ändern dabei die Zone, wandert der Termin trotzdem: + Der Live-Test 8a hat gemessen, dass ein Einzeltermin, der so zur Serie + wird, um den Versatz wandert, weil die neue Zone ohne Beginn und Ende die + gespeicherte Uhrzeit behält. Der nächste PR schreibt deshalb die Zone, dann + Beginn und Ende (auch unverändert), dann die Regel am Tag der neuen Zone; + das löst 234 ab. **Microsoft 365** leitet Start- und Enddatum und die Standard-Tage weiter aus dem UTC-Datum ab und muss sie auf der Uhr der Serie lesen, bevor die Editoren Beginn und UNTIL auf diese Uhr stellen. - **Handy-Kalender** speichern keine Regel; die Auswahl ist dort nicht da. @@ -892,39 +894,53 @@ Handy im selben PR. eigenen. Die verschobene Ausnahme steht richtig nur am Dienstag. - Aperio zeigt die beschädigte Serie einen Tag zu spät (Lesefehler, 48a). - Live-Test Runde 6 (10. Oktober 2026, derselbe Server, PR 8a). Toni hat die - Schritte 1 bis 5 mit einem Desktop-Build von 8a von Hand gemacht und in - Outlook nachgesehen. Die Schritte 6 bis 9 hat ein Testprogramm außerhalb - des Repositorys über Aperios eigenen Adapter gemacht und zurückgelesen, was - Exchange speichert. Gemeint war jeweils November 2026: + Live-Test 8a (10. Oktober 2026, derselbe Server). Toni hat die Schritte 1 + bis 5 mit einem Desktop-Build von PR 8a von Hand gemacht und in Outlook + nachgesehen. Die Schritte 6 bis 9 hat ein Testprogramm außerhalb des + Repositorys über Aperios eigenen Adapter gemacht; es hat seine Termine + selbst per EWS angelegt und zurückgelesen, was Exchange speichert und was + die Kalenderansicht zeigt. Gemeint war jeweils November 2026: - Eine neue ganztägige Serie (täglich, 4 Mal, ab Montag, dem 2.) steht vom 2. bis zum 5., eine zweiwöchentliche am 2., 16. und 30. Eine Serie mit Uhrzeit um 00:30 ohne gewählten Wochentag steht montags. Der Datumsfehler ist damit behoben. Eine ganztägige Serie ohne Zone zeigt Outlook mit der Zone „(UTC+00:00) Monrovia, Reykjavik“, also Greenwich (43b). - Ein ganztägiger Outlook-Termin, von Montag auf Dienstag verschoben, und - eine Ausnahme einer Outlook-Serie liegen danach einen Tag am neuen Tag. Eine - Regeländerung lässt eine Outlook-Serie auf ihren Tagen (47a). - - Exchange 2019 schickt die Definition einer eigenen Zone mit, in - `GetItem` und in `SyncFolderItems`. Ein ganztägiger Termin in einer eigenen - Zone UTC+03:00 (mit `CreateItem` und Definition angelegt, als „Customized - Time Zone“ gespeichert) wurde auf die Mitternacht dieser Zone verschoben, - einen Tag lang (232, 239). Ein Termin mit der Zone „Europe/Berlin“ ebenso - auf Berliner Mitternacht (236). Ein Outlook-Termin mit Uhrzeit, in Aperio - ganztägig gemacht, liegt einen Tag auf Berliner Mitternacht. + eine Ausnahme einer Outlook-Serie liegen danach einen Tag am neuen Tag + (47a). Eine reine Regeländerung lässt eine Outlook-Serie auf ihren Tagen: + Die Regel beginnt am Tag des Geräts (`rule_first_day`, der Datumsfehler). + - Exchange 2019 schickt die Definition einer eigenen Zone mit, in `GetItem` + und in `SyncFolderItems`. Gemessen ist das an einem Termin, den das + Programm selbst mit Definition angelegt hat (ganztägig, eigene Zone + UTC+03:00, Id „Customized Time Zone“, die Exchange so zurückgab): auf + Mittwoch verschoben, liegt er auf der Mitternacht dieser Zone, einen Tag + lang (232, 239). Ein Termin mit der Zone „Europe/Berlin“ ebenso auf + Berliner Mitternacht (236). Ein Termin vom iPhone oder aus einer + Einladung und die Ablehnung nach 237 sind nicht gemessen; Schritt 6 war + ohne Exchange im Apple-Kalender nicht machbar. + - Ein Termin mit Uhrzeit in der Zone W. Europe, wie Outlook ihn speichert + (vom Programm per EWS angelegt), in Aperio ganztägig gemacht, liegt einen + Tag auf Berliner Mitternacht. + - Exchange gibt Start- und Enddatum einer Serie in W. Europe ohne + `TimeZoneContext` mit Versatz zurück: `2026-11-04+01:00`. - Ein Schnitt „dieser und alle folgenden“ an einer täglichen Serie mit Uhrzeit zeigt den Schnitttag doppelt. Aperio kürzt die alte Serie mit `UNTIL` eine Sekunde vor dem Schnitt, das Enddatum ist dessen Datum, und - Exchange zählt das Enddatum mit. Ein vor dem Schnitt gelöschtes Vorkommen - bleibt gelöscht. Das war vor 8a genauso. + Exchange zählt das Enddatum mit (235). Ein vor dem Schnitt gelöschtes + Vorkommen bleibt gelöscht. Nach dem Schreibcode von main wäre es dort + genauso. - Ein in Aperio angelegter Einzeltermin, Montag 00:30, in einem Speichern - wöchentlich gemacht, steht danach sonntags um 23:30. Die Regel beginnt - nach 234 am Tag der gespeicherten Zone (UTC: Sonntag), und die neue Zone - danach behält die gespeicherte Uhrzeit 23:30 (Stufen 9 und 12). 234 ist - damit widerlegt. Das war vor 8a genauso. + wöchentlich gemacht, steht danach sonntags um 23:30. Die Änderung schrieb + die Regel (ab Sonntag, dem Tag der gespeicherten Zone UTC, 234) und danach + die Zone, aber weder Beginn noch Ende. Exchange hat die Felder in dieser + Reihenfolge angewandt, wie 234 annimmt, und die neue Zone hat die + gespeicherte Uhrzeit 23:30 behalten. Nach dem Schreibcode von main wäre + es dort genauso. Richtig wird es erst, wenn die Zone vor Beginn, Ende und + Regel geht und Beginn und Ende mitkommen; dann beginnt die Regel am Tag + der neuen Zone, und das löst 234 ab. 5. **Exchange und Microsoft 365 lesen Serien-Daten auf der Uhr der Serie** — `fix(ews, graph): read a series' dates on its own clock when writing`. - Der Exchange-Teil ist mit PR 8a gebaut, zusammen mit 47a (57a), und in - Runde 6 gemessen. Offen ist + Der Exchange-Teil ist mit PR 8a gebaut, zusammen mit 47a (57a), und im + Live-Test 8a gemessen. Offen ist Microsoft 365; danach messen, wie es recurrenceTimeZone beim Zurücklesen behandelt. Serien, die Aperio schon mit falschem Starttag geschrieben hat, bleiben, wie sie sind (231): einmal verschieben und Wochentag oder Tag in @@ -1094,16 +1110,19 @@ Handy im selben PR. - welche Namen Kerio Connect und Zimbra kennen und was sie mit einem unbekannten Namen tun. - Ungeprüft bleibt, ob Exchange eigene Zonendefinitionen und Namen, die nur in - der Windows-Registry stehen (Kamchatka, Mid-Atlantic), so zurückgibt, wie - Aperio sie liest: als keine Zone. Seit PR 8a liest ein ganztägiger Termin - die Definition selbst; Exchange 2019 schickt sie in `SyncFolderItems` und - `GetItem` mit (Runde 6). Ungeprüft aus PR 8a sind außerdem: ob Exchange ein - Ende in der Endzone rundet, wenn Beginn und Ende verschiedene Zonen tragen - (233, R3-2 legt es nahe); was Exchange an einem Tag speichert, dessen - Mitternacht die gespeicherte Zone überspringt. Gemessen hat Runde 6, dass - `EndDate` inklusive ist und dass 234 nicht stimmt (siehe Stufe 4, - „Gemessen“). Ungeprüft ist auch, ob Exchanges eigene + Ungeprüft bleibt, ob Exchange Namen, die nur in der Windows-Registry stehen + (Kamchatka, Mid-Atlantic), so zurückgibt, wie Aperio sie liest: als keine + Zone. Eine eigene Zonendefinition schickt Exchange 2019 in + `SyncFolderItems` und `GetItem` mit (Live-Test 8a, an einem per EWS + angelegten Termin); seit PR 8a liest ein ganztägiger Termin sie selbst, die + Uhr einer Serie behandelt sie weiter als keine Zone. Ungeprüft aus PR 8a sind + außerdem: ein Termin mit eigener Zone vom iPhone oder aus einer Einladung und + die Ablehnung nach 237 am echten Server; ob Exchange ein Ende in der Endzone + rundet, wenn Beginn und Ende verschiedene Zonen tragen (233, R3-2 legt es + nahe); was Exchange an einem Tag speichert, dessen Mitternacht die + gespeicherte Zone überspringt. Gemessen hat der Live-Test 8a, dass `EndDate` + inklusive ist (235) und dass eine neue Zone ohne Beginn und Ende die + gespeicherte Uhrzeit behält (siehe Stufe 4, „Gemessen“). Ungeprüft ist auch, ob Exchanges eigene Zeitzonen-Regeln denen von Windows gleichen, an denen der Uhr-Wächter über die Standardzonen gemessen ist. - Ob der Desktop die eingebauten Manifeste für die Wiederholungs-Fähigkeiten diff --git a/TODO.md b/TODO.md index 48a427d1..8f7db9f4 100644 --- a/TODO.md +++ b/TODO.md @@ -2966,14 +2966,11 @@ Siehe DESIGN §4.2. einer, die Aperio ohne Zone angelegt hat (Runde 2), und einer aus Outlook, deren Startzone Aperios Änderung auf Greenwich gesetzt hatte (Runde 3). Beide liegen bei Versatz 0; `Z` sagt dort nicht, ob es die UTC-Rechnung - der Anfrage oder die Zone der Serie meint. Dass das Enddatum ebenso eine - Zone trägt, ist gefolgert, nicht gemessen: gleicher Datentyp, und laut - Microsoft hängt EWS an zurückgegebene Werte eine Zone an. Ob Exchange bei - einer Serie in einer Zone abseits von UTC einen Versatz schreibt, ist - ungemessen. Aperio schickt keinen `TimeZoneContext`, und ohne ihn rechnet - EWS in UTC. Womöglich kommt `Z` also bei JEDER Serie, dann betraf der - Fehler jede Exchange-Serie mit Enddatum, auch die in Outlook angelegten. - Der Leser schnitt nur `T` und `+` ab. Bei `Z` und bei einem Versatz + der Anfrage oder die Zone der Serie meint. Der Live-Test 8a (2026-10-10) + hat für eine Serie in W. Europe ohne `TimeZoneContext` gemessen: Start- und + Enddatum kommen mit Versatz zurück, `2026-11-04+01:00`. `Z` kommt also + nicht bei jeder Serie; der Fehler traf Serien mit `Z` und mit einem Versatz + westlich von UTC. Der Leser schnitt nur `T` und `+` ab. Bei `Z` und bei einem Versatz westlich von UTC wurde daraus eine Regel wie `UNTIL=20261105ZT235959Z`, die beide Ausroller ablehnten: Die Serie erschien nur am Beginn ihres Masters, dem ersten Termin der Regel, auch wenn dieses Vorkommen geändert oder @@ -2999,10 +2996,14 @@ Siehe DESIGN §4.2. Serie beginnt an ihrem ersten Tag auf der Uhr, auf der Exchange sie wiederholt; ein ganztägiger Tag geht beim Ändern auf Mitternacht in der gespeicherten Zone jeder Grenze; eigene Zonen-Definitionen werden gelesen; - eine unlesbare Zone lehnt den Tag mit eigenem Satz ab (237). Live-Test - Runde 6 am 10.10.2026: Schritte 1 bis 7 bestanden, auch eine eigene Zone - und „Europe/Berlin“ (Einzelheiten in DESIGN, Stufe 4, „Gemessen“). - 🚩 **Schnitttag doppelt** (235, gemessen in Runde 6): Ein Schnitt „dieser + eine unlesbare Zone lehnt den Tag mit eigenem Satz ab (237). Live-Test 8a + am 10.10.2026: Schritte 1 bis 5 und 7 bestanden. Schritt 6 (ein Termin vom + iPhone) war nicht machbar; ein Testprogramm hat stattdessen eigene Termine + per EWS angelegt, eine eigene Zone und „Europe/Berlin“, beide bestanden. + Ungemessen bleiben ein Termin mit eigener Zone vom iPhone oder aus einer + Einladung und die Ablehnung nach 237 am echten Server (Einzelheiten in + DESIGN, Stufe 4, „Gemessen“). + 🚩 **Schnitttag doppelt** (235, gemessen im Live-Test 8a): Ein Schnitt „dieser und alle folgenden“ an einer Exchange-Serie mit Uhrzeit zeigt den Schnitttag in beiden Serien. Die alte Serie endet mit `UNTIL` eine Sekunde vor dem Schnitt, das Enddatum ist dessen Datum, und Exchange zählt es mit. Das @@ -3016,15 +3017,18 @@ Siehe DESIGN §4.2. eine Woche verrutscht sein. 🚩 **Microsoft 365: derselbe Datumsfehler** (235): eigener PR. 🚩 **Zone und Wiederholung in einem Ändern:** Wird ein Termin mit Uhrzeit - zur Serie mit neuer Zone, setzt Exchange die Zone nach dem Beginn und - behält dabei die Uhrzeit, der Termin wandert um den Versatz (Runde 1, - Stufen 9 und 12). Runde 6 hat es gemessen: Ein in Aperio angelegter + zur Serie mit neuer Zone, behält Exchange die gespeicherte Uhrzeit und gibt + ihr die neue Zone, der Termin wandert um den Versatz (Runde 1, Stufen 9 + und 12: eine Zone nach Beginn und Ende). Der Live-Test 8a hat es für eine + Änderung ohne Beginn und Ende gemessen: Ein in Aperio angelegter Einzeltermin am Montag um 00:30, wöchentlich gemacht, steht danach sonntags - um 23:30. Die Regel beginnt nach 234 am Tag der alten Zone (UTC: Sonntag); - 234 ist damit widerlegt. Nächster PR nach 8a: die Zone vor Beginn, Ende und - Regel schreiben und die Regel am Tag der neuen Zone beginnen lassen. Dass - die Zone zuerst die Zeitpunkte stehen lässt, ist nur an einer ganztägigen - Serie gemessen (Runde 2, B4); für eine mit Uhrzeit misst es der PR. + um 23:30. Die Änderung schrieb nur die Regel (ab dem Tag der alten Zone UTC: + Sonntag, 234) und danach die Zone. Exchange wandte die Felder in dieser + Reihenfolge an, wie 234 annimmt. Nächster PR nach 8a: die Zone schreiben, + dann Beginn und Ende, auch wenn sie sich nicht ändern, dann die Regel am Tag + der neuen Zone; das löst 234 ab. Dass die Zone zuerst die Zeitpunkte stehen + lässt, ist nur an einer ganztägigen Serie gemessen (Runde 2, B4); für eine + mit Uhrzeit misst es der PR. 🚩 **Open-Source-Hinweise** in der Desktop- und der Handy-App (40a): CLDR und die eingebauten ICU4X-Bibliotheken nennen. 🚩 **Wenn der EWS-Adapter das Repository verlässt,** müssen `cldr/`, die diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 93883509..5b163084 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -1311,8 +1311,8 @@ async fn resolve_override_target( /// /// The copy comes with the zones Exchange stores its boundaries in: an all-day /// day is written as midnight in them (decision 47a), and a series' rule -/// starts on its first day as the stored start zone reads it (234; wrong -/// where the update also changes the zone, live round 6). +/// starts on its first day as the stored start zone reads it (234; an update +/// that also changes the zone still moves the item, see `rule_first_day`). async fn read_before( client: &EwsClient, target: &WriteTarget, diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 8ca1948c..3c94fa82 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2844,10 +2844,11 @@ pub(crate) fn device_day(when: DateTime, device: &D) -> chrono /// zone Exchange expands it in — the zone a create writes with it, the zone /// the item is stored in on an update (decision 234) or, when its copy could /// not be read, the zone the update writes with it, UTC where there is none. -/// Where the update also changes the zone, 234 does not hold: live round 6 -/// found a single made weekly on the stored zone's day and moved by the new -/// zone, written after the rule, which kept the stored wall clock. The next -/// PR writes the zone first. +/// Where the update also changes the zone, the item still moves: the 8a live +/// test made a single weekly; the update sent the rule (on the stored zone's +/// day) and then the zone, without the start or the end, and the new zone +/// kept the stored wall clock. The next PR writes the zone, then the start +/// and the end, then the rule on the new zone's day, which replaces 234. pub(crate) fn rule_first_day( start: DateTime, all_day: bool, @@ -9876,10 +9877,13 @@ mod tests { /// Decision 234: an update that makes a timed appointment a series and /// names a new zone starts the rule on the day the STORED zone reads. /// Aperio created the single in UTC; Monday 00:30 in Berlin is Sunday - /// there. This pins what 8a writes, not what is right: live round 6 sent - /// exactly this and found the series on Sundays at 23:30, because the new - /// zone, written after the rule, kept the stored wall clock (as in round 1, - /// stages 9 and 12). The next PR writes the zone first and changes this. + /// there. This pins what 8a writes, not what is right: the 8a live test + /// sent a request of this shape (Monday 2 November 00:30, StartDate + /// 2026-11-01, Sunday; the rule, then the zone, no start or end) and found + /// the series on Sundays at 23:30, because the new zone kept the stored + /// wall clock (as the zone after the start did in round 1, stages 9 and + /// 12). The next PR writes the zone, then the start and the end, then the + /// rule on the new zone's day, and changes this. #[test] fn a_new_rule_with_a_new_zone_starts_on_the_stored_zones_day() { let device = chrono_tz::Europe::Berlin; diff --git a/crates/adapter-ews/src/zone_definition.rs b/crates/adapter-ews/src/zone_definition.rs index 6045c6a3..6f83266c 100644 --- a/crates/adapter-ews/src/zone_definition.rs +++ b/crates/adapter-ews/src/zone_definition.rs @@ -7,7 +7,9 @@ //! by the protocols, what an Exchange ActiveSync client or an iCalendar //! invitation's VTIMEZONE leaves — or a registry-only id. Decision 232 reads //! such a zone's definition, where Exchange sends one, to find the item's -//! midnights rather than refusing or guessing. Whether Exchange 2019 sends it +//! midnights rather than refusing or guessing. Exchange 2019 sends it in +//! `GetItem` and `SyncFolderItems` (the 8a live test, on an item created +//! through EWS with a definition); an item from an iPhone or an invitation //! is unmeasured. //! //! The wire shape (Exchange 2010 and later): diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 112f71a4..bd27a9f1 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -97,12 +97,9 @@ The endpoint is discovered or user-supplied. on an Outlook one whose start zone Aperio's update had set to Greenwich while its end zone stayed W. Europe (round 3). Both sit at offset 0, so `Z` cannot tell the request's UTC context from the series' own zone. - `EndDate` has the same `xs:date` type, and Microsoft documents that EWS - adds a zone to the values it returns, so it is taken to carry one too; - that is inferred, not measured. The schema also allows an offset (`+02:00`, - `-05:00`); whether Exchange writes one for a series in a zone off UTC is - unmeasured. Aperio sends no `TimeZoneContext`, and EWS's default context - is UTC, so `Z` may come on every series. The reader keeps only the date + A series in W. Europe, read without a `TimeZoneContext`, carries an offset + on both dates instead: `2026-11-04+01:00` (the 8a live test, 2026-10-10). + So `Z` does not come on every series. The reader keeps only the date (`range_date`), both when it parses `EndDate` and when it turns a cached range into `UNTIL`. Before READ_RULE 3 it cut only `T` and `+`, so `UNTIL=20261105ZT235959Z` came out and the expanders refused the rule: the @@ -176,12 +173,15 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in (`rule_first_day`): the device's day for an all-day series, the written zone's day on a create, the stored start zone's day on an update (234), the zone the update writes with the rule when the copy could not be read, - UTC where none is known. Where an update also changes the zone, 234 does - not hold: live round 6 turned a single created in Aperio, Monday 00:30 in - Berlin, into a weekly series and found it on Sundays at 23:30. The rule - started on the stored zone's (UTC) Sunday, and the new zone, written - after it, kept the stored wall clock (round 1, stages 9 and 12). The next - PR writes the zone before the start, the end and the rule. + UTC where none is known. Where an update also changes the zone, the item + still moves: the 8a live test turned a single created in Aperio, Monday + 00:30 in Berlin, into a weekly series and found it on Sundays at 23:30. + The update sent the rule (from the stored zone's Sunday, 234) and then the + zone, without the start or the end. Exchange applied them in that order, + as 234 assumes, and the new zone kept the stored wall clock (as the zone + after the start did in round 1, stages 9 and 12). The next PR writes the + zone, then the start and the end even when they did not change, then the + rule on the new zone's day, which replaces 234. The range's StartDate and the weekday, day of the month and month of a rule without BYDAY, BYMONTHDAY or BYMONTH come from that day. Read off the UTC date, as before, they named the day before east of UTC: a @@ -216,7 +216,8 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in change skips it). A fifth weekday reads as the month's last (238). Exchange 2019 fills these elements in `SyncFolderItems` and `GetItem`, and a custom zone's day moved on its definition's midnight lands as one day - (live round 6, PR 8a). + (the 8a live test, on an item created through EWS with a definition). An + item with a custom zone from an iPhone or an invitation is unmeasured. - **Zones Exchange cannot store** are written without a zone: CLDR has no id for them (`Antarctica/Troll`), or the id runs another clock in the five years after the pinned release (`America/Scoresbysund`, `Antarctica/Casey`, From 269ce301801456b0b7265e22b98aabadfd752607 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 17:48:49 +0200 Subject: [PATCH 16/18] Second review of the live results of #121: step 9 does not show the order Step 9 neither confirms nor refutes 234. The rule named Sunday itself, and a zone that keeps the wall clock keeps its day, so either order of the rule and the zone gives Sundays at 23:30. What it measured: the new zone, sent without the start and the end, kept the stored wall clock. 234 stays inferred from round 1 (DESIGN, TODO, ews.md and rule_first_day say so again); the next PR measures whether zone, start and end, then the rule on the new zone's day lands. Two fixture comments said no definition had been captured from a server; the live test captured Exchange echoing one the test wrote. Co-Authored-By: Claude Opus 5.5 --- DESIGN-series-time-zone.md | 14 ++++++----- TODO.md | 12 +++++---- crates/adapter-ews/src/lib.rs | 5 ++-- crates/adapter-ews/src/mapping.rs | 21 ++++++++++------ .../content/docs/developers/adapters/ews.md | 25 +++++++++++-------- 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/DESIGN-series-time-zone.md b/DESIGN-series-time-zone.md index 1a2dcf2b..53411592 100644 --- a/DESIGN-series-time-zone.md +++ b/DESIGN-series-time-zone.md @@ -932,12 +932,14 @@ Handy im selben PR. - Ein in Aperio angelegter Einzeltermin, Montag 00:30, in einem Speichern wöchentlich gemacht, steht danach sonntags um 23:30. Die Änderung schrieb die Regel (ab Sonntag, dem Tag der gespeicherten Zone UTC, 234) und danach - die Zone, aber weder Beginn noch Ende. Exchange hat die Felder in dieser - Reihenfolge angewandt, wie 234 annimmt, und die neue Zone hat die - gespeicherte Uhrzeit 23:30 behalten. Nach dem Schreibcode von main wäre - es dort genauso. Richtig wird es erst, wenn die Zone vor Beginn, Ende und - Regel geht und Beginn und Ende mitkommen; dann beginnt die Regel am Tag - der neuen Zone, und das löst 234 ab. + die Zone, aber weder Beginn noch Ende. Die neue Zone hat die gespeicherte + Uhrzeit 23:30 behalten. In welcher Reihenfolge Exchange Regel und Zone + anwendet, zeigt das nicht: Die Regel nannte den Sonntag selbst, und eine + Zone, die die Uhrzeit behält, behält auch deren Tag; jede Reihenfolge + ergäbe sonntags 23:30. 234 bleibt abgeleitet (Runde 1), weder bestätigt + noch widerlegt. Nach dem Schreibcode von main wäre es dort genauso. Der + nächste PR schreibt die Zone, dann Beginn und Ende, dann die Regel am Tag + der neuen Zone, und misst, ob das richtig landet. 5. **Exchange und Microsoft 365 lesen Serien-Daten auf der Uhr der Serie** — `fix(ews, graph): read a series' dates on its own clock when writing`. Der Exchange-Teil ist mit PR 8a gebaut, zusammen mit 47a (57a), und im Live-Test 8a gemessen. Offen ist diff --git a/TODO.md b/TODO.md index 8f7db9f4..1ce7eec1 100644 --- a/TODO.md +++ b/TODO.md @@ -3023,12 +3023,14 @@ Siehe DESIGN §4.2. Änderung ohne Beginn und Ende gemessen: Ein in Aperio angelegter Einzeltermin am Montag um 00:30, wöchentlich gemacht, steht danach sonntags um 23:30. Die Änderung schrieb nur die Regel (ab dem Tag der alten Zone UTC: - Sonntag, 234) und danach die Zone. Exchange wandte die Felder in dieser - Reihenfolge an, wie 234 annimmt. Nächster PR nach 8a: die Zone schreiben, + Sonntag, 234) und danach die Zone; die Zone hat die gespeicherte Uhrzeit + behalten. Über die Reihenfolge von Regel und Zone sagt das nichts: Jede + Reihenfolge ergibt sonntags 23:30, weil die Zone mit der Uhrzeit auch den + Tag behält. 234 bleibt abgeleitet. Nächster PR nach 8a: die Zone schreiben, dann Beginn und Ende, auch wenn sie sich nicht ändern, dann die Regel am Tag - der neuen Zone; das löst 234 ab. Dass die Zone zuerst die Zeitpunkte stehen - lässt, ist nur an einer ganztägigen Serie gemessen (Runde 2, B4); für eine - mit Uhrzeit misst es der PR. + der neuen Zone, und messen, ob das richtig landet. Dass die Zone zuerst die + Zeitpunkte stehen lässt, ist nur an einer ganztägigen Serie gemessen + (Runde 2, B4). 🚩 **Open-Source-Hinweise** in der Desktop- und der Handy-App (40a): CLDR und die eingebauten ICU4X-Bibliotheken nennen. 🚩 **Wenn der EWS-Adapter das Repository verlässt,** müssen `cldr/`, die diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 23acfa5e..21602ceb 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -3078,8 +3078,9 @@ mod server_zone_tests { ) } - /// A custom zone with W. Europe's rules (synthetic: no definition has - /// been captured from a server yet). + /// A custom zone with W. Europe's rules (synthetic; the 8a live test + /// captured Exchange 2019 echoing only a definition the test wrote, none + /// from an iPhone or an invitation). const CUSTOM_W_EUROPE: &str = r#" diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 3c94fa82..915de969 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2842,13 +2842,15 @@ pub(crate) fn device_day(when: DateTime, device: &D) -> chrono /// The day a series' rule starts on, on the clock Exchange repeats it on: an /// all-day series on the device's days (48a); a timed one on `clock`, the /// zone Exchange expands it in — the zone a create writes with it, the zone -/// the item is stored in on an update (decision 234) or, when its copy could -/// not be read, the zone the update writes with it, UTC where there is none. -/// Where the update also changes the zone, the item still moves: the 8a live -/// test made a single weekly; the update sent the rule (on the stored zone's -/// day) and then the zone, without the start or the end, and the new zone -/// kept the stored wall clock. The next PR writes the zone, then the start -/// and the end, then the rule on the new zone's day, which replaces 234. +/// the item is stored in on an update (decision 234, inferred from live round +/// 1, where Exchange applied an update's fields in order; not measured for a +/// rule and a zone) or, when its copy could not be read, the zone the update +/// writes with it, UTC where there is none. Where the update also changes the +/// zone, the item still moves: the 8a live test made a single weekly; the +/// update sent the rule (on the stored zone's day) and then the zone, without +/// the start or the end, and the new zone kept the stored wall clock. The +/// next PR writes the zone, then the start and the end, then the rule on the +/// new zone's day. pub(crate) fn rule_first_day( start: DateTime, all_day: bool, @@ -7763,7 +7765,10 @@ mod tests { /// A custom zone's definition with W. Europe's rules, under the id Toni's /// mailbox shows for a custom zone (`Customized Time Zone`, Name empty). - /// Synthetic: no definition has been captured from a server yet. + /// Synthetic rules. The 8a live test captured Exchange 2019 echoing a + /// definition the test wrote: Name empty, period ids rewritten to + /// `trule:Microsoft/Registry/Customized Time Zone/1-Standard`. No + /// definition from an iPhone or an invitation has been captured. const CUSTOM_ZONE_BODY: &str = r#" diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index bd27a9f1..af115b33 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -171,17 +171,20 @@ Time`); the rest of Aperio uses tzdata names. The translation lives in - **A series starts on its first day** (PR 8a). `rrule_to_ews_recurrence` takes the day the series starts on, on the clock Exchange repeats it on (`rule_first_day`): the device's day for an all-day series, the written - zone's day on a create, the stored start zone's day on an update (234), - the zone the update writes with the rule when the copy could not be read, - UTC where none is known. Where an update also changes the zone, the item - still moves: the 8a live test turned a single created in Aperio, Monday - 00:30 in Berlin, into a weekly series and found it on Sundays at 23:30. - The update sent the rule (from the stored zone's Sunday, 234) and then the - zone, without the start or the end. Exchange applied them in that order, - as 234 assumes, and the new zone kept the stored wall clock (as the zone - after the start did in round 1, stages 9 and 12). The next PR writes the - zone, then the start and the end even when they did not change, then the - rule on the new zone's day, which replaces 234. + zone's day on a create, the stored start zone's day on an update (234, + inferred from live round 1, where Exchange applied an update's fields in + order; not measured for a rule and a zone), the zone the update writes + with the rule when the copy could not be read, UTC where none is known. + Where an update also changes the zone, the item still moves: the 8a live + test turned a single created in Aperio, Monday 00:30 in Berlin, into a + weekly series and found it on Sundays at 23:30. The update sent the rule + (from the stored zone's Sunday, 234) and then the zone, without the start + or the end, and the new zone kept the stored wall clock (as the zone after + the start did in round 1, stages 9 and 12). The result does not show the + order of the rule and the zone: a zone that keeps the wall clock keeps its + day, so either order gives Sundays at 23:30. The next PR writes the zone, + then the start and the end even when they did not change, then the rule + on the new zone's day, and measures whether that lands. The range's StartDate and the weekday, day of the month and month of a rule without BYDAY, BYMONTHDAY or BYMONTH come from that day. Read off the UTC date, as before, they named the day before east of UTC: a From d63bbe526aa1ba43c544d49cc6f411e25d9422b0 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 18:03:34 +0200 Subject: [PATCH 17/18] Third review of the live results of #121: the EndDate comments name the measured offset Three comments in mapping.rs (the EndDate arm of to_rrule, range_date and its test) still called an offset only the schema's other form. The 8a live test measured +01:00 on both dates of a W. Europe series; a west offset stays unmeasured. Co-Authored-By: Claude Opus 5.5 --- crates/adapter-ews/src/mapping.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 915de969..aa0af263 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -3712,9 +3712,10 @@ impl EwsRecurrence { } EwsRecurrenceRange::EndDate { end } => { // EWS sends EndDate as a date, possibly with a zone - // appended (`Z` was measured on StartDate; an offset such - // as `-05:00` is the schema's other form); `range_date` - // keeps the date. The series' DTSTART + // appended (`Z` was measured on StartDate in rounds 2 and + // 3, `+01:00` on both dates of a W. Europe series in the 8a + // live test; a west offset such as `-05:00` is the schema's + // form, unmeasured); `range_date` keeps the date. The series' DTSTART // is a UTC date-time, and RFC 5545 requires UNTIL to // share that value type — i.e. a UTC date-time too. A // bare date-only UNTIL is read as floating/local, and @@ -3866,9 +3867,10 @@ pub fn parse_ews_recurrence_to_rrule(xml: &str) -> EwsResult { } /// The date of a range's `EndDate`, without a zone Exchange may append to -/// it: UTC ("2026-12-31Z", the form measured on StartDate) or an offset -/// ("2026-12-31+02:00", "2026-12-31-05:00", the schema's other forms). A -/// value that does not open with a date is kept as it came. +/// it: UTC ("2026-12-31Z", measured on StartDate in rounds 2 and 3) or an +/// offset ("2026-12-31+01:00", measured on both dates of a W. Europe series +/// in the 8a live test; "2026-12-31-05:00", the schema's west form, +/// unmeasured). A value that does not open with a date is kept as it came. fn range_date(s: &str) -> &str { match s.get(..10) { Some(day) if chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d").is_ok() => day, @@ -7377,7 +7379,8 @@ mod tests { /// Exchange may append a zone to a range's dates: `Z` was measured live /// on StartDate (rounds 2 and 3), and api.rs's master fixture uses that - /// shape; an offset is the schema's other form. Every spelling ends on + /// shape; `+01:00` on both dates of a W. Europe series (the 8a live test); + /// a west offset is the schema's form, unmeasured. Every spelling ends on /// its own date, and the rule stays one the expanders accept. #[test] fn an_end_date_reads_as_its_date_whatever_zone_exchange_appends() { From 05afb183a25060307e27a127865827a34967a361 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Sat, 10 Oct 2026 18:19:51 +0200 Subject: [PATCH 18/18] Fourth review of the live results of #121: 234 back on the unchecked list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 84012819 took 234 off DESIGN's "Ungeprüft" list on the reading that step 9 refuted it; that reading is withdrawn, and step 9 cannot show the order of a rule and a zone. It is back on the list. An older TODO line still called Exchange's EndDate inclusivity unmeasured; the live test measured it (235). Co-Authored-By: Claude Opus 5.5 --- DESIGN-series-time-zone.md | 5 ++++- TODO.md | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/DESIGN-series-time-zone.md b/DESIGN-series-time-zone.md index 53411592..2245ed51 100644 --- a/DESIGN-series-time-zone.md +++ b/DESIGN-series-time-zone.md @@ -1118,7 +1118,10 @@ Handy im selben PR. `SyncFolderItems` und `GetItem` mit (Live-Test 8a, an einem per EWS angelegten Termin); seit PR 8a liest ein ganztägiger Termin sie selbst, die Uhr einer Serie behandelt sie weiter als keine Zone. Ungeprüft aus PR 8a sind - außerdem: ein Termin mit eigener Zone vom iPhone oder aus einer Einladung und + außerdem: ob Exchange bei einem Ändern die Regel vor der Zone anwendet, die + dieselbe Änderung danach schreibt (234; Schritt 9 des Live-Tests 8a kann das + nicht zeigen, weil jede Reihenfolge sonntags 23:30 ergibt); ein Termin mit + eigener Zone vom iPhone oder aus einer Einladung und die Ablehnung nach 237 am echten Server; ob Exchange ein Ende in der Endzone rundet, wenn Beginn und Ende verschiedene Zonen tragen (233, R3-2 legt es nahe); was Exchange an einem Tag speichert, dessen Mitternacht die diff --git a/TODO.md b/TODO.md index 1ce7eec1..a68a1095 100644 --- a/TODO.md +++ b/TODO.md @@ -2673,8 +2673,9 @@ Siehe DESIGN §4.2. - iOS-Gerätekalender: jedes Schreiben und Löschen nutzt `span: .thisEvent` (`IosDeviceEventStore.swift`), trifft bei einer Serie also nur das erste Vorkommen. Ungemessen. - - Exchange/Graph: ob `EndDate` inklusive ist — dann bliebe bei jedem - Kürzen ein Vorkommen zu viel. Ungemessen. + - Exchange: `EndDate` ist inklusive (Live-Test 8a, 2026-10-10). Ein + Kürzen mit Uhrzeit lässt den Schnitttag deshalb doppelt stehen; die + Korrektur ist ein eigener PR (235). Graph: ungemessen. - Mitziehen „nur dieses Vorkommen“ an Kopien: erst der Platz in der Serie, dann die Kopie (dieselbe Reihenfolge, die #92 in den Editoren umgedreht hat).