diff --git a/DESIGN-series-time-zone.md b/DESIGN-series-time-zone.md index 64ccfab6..2245ed51 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,18 @@ 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), ohne lesbaren Serverstand die Zone, die die Änderung mitschreibt, + 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. ### Nach dem Speichern @@ -879,8 +893,60 @@ 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 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 + (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 (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 Änderung schrieb + die Regel (ab Sonntag, dem Tag der gespeicherten Zone UTC, 234) und danach + 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`. - Danach messen, wie Microsoft 365 recurrenceTimeZone beim Zurücklesen behandelt. + 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 + 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 @@ -1046,9 +1112,22 @@ 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. 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: 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 + 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 8caf8bf7..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). @@ -2966,14 +2967,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 @@ -2993,9 +2991,47 @@ 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 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 + 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 + 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, 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 Änderung schrieb nur die Regel (ab dem Tag der alten Zone UTC: + 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, 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/api.rs b/crates/adapter-ews/src/api.rs index 09349246..5b163084 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, @@ -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 @@ -727,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 @@ -761,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", @@ -785,18 +789,68 @@ 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 } }; - 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). Nothing is sent, and + // the error says so: `copy-unreadable`, with the read's own failure (or an + // 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(), + &stored, + server_zones, + target.kind, + &chrono::Local, + ) { + 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); + } + return Err(EwsError::Protocol( + cal_core::WriteRefusal::CopyUnreadable.message(&why), + )); + } + 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 @@ -817,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); @@ -1100,7 +1161,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, }; @@ -1211,7 +1272,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 @@ -1242,11 +1308,16 @@ 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; an update +/// that also changes the zone still moves the item, see `rule_first_day`). 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), @@ -1261,13 +1332,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 @@ -1277,9 +1379,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`. @@ -2424,6 +2527,8 @@ mod tests { NoError + + 2026-10-25T23:00:00Z @@ -2498,8 +2603,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, @@ -2565,10 +2674,156 @@ 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")); } + /// 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 @@ -2652,9 +2907,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 end = - crate::mapping::all_day_anchor("2027-01-15T11:00:00Z".parse().unwrap(), 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(&crate::mapping::DayZone::Tz(auckland)), + ); let edit = Event { keep_attendees: false, keep_fields: Vec::new(), @@ -3771,7 +4029,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 +4134,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(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 9ec2c8f4..21602ceb 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; @@ -1439,6 +1440,22 @@ 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), 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 + | cal_core::WriteRefusal::CopyUnreadable, + _ + )) + ) => + { + CoreError::Forbidden(message) + } other => to_core_error(other), } } @@ -1468,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 { @@ -1478,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), @@ -2926,7 +2966,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 { @@ -2970,4 +3051,352 @@ 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 (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#" + + + + + + 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 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. + #[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. 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(); + 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() + .unwrap() + .iter() + .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" + ); + } + + /// 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 3a2d56fa..aa0af263 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,36 +936,35 @@ 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") { current.start_time_zone = - Some(String::from_utf8_lossy(&a.value).into_owned()); + Some(crate::zone_definition::attribute_text(&a)); } } } // The end zone's own id, where Exchange marks a series // created without a zone as `tzone://Microsoft/Utc` - // (decision 43b). Only the element's own id is read; a - // nested full definition belongs to the start zone above. + // (decision 43b), and its own definition (decision 233). b"endtimezone" if inside_item => { 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)); } } - } - b"timezonedefinition" - if inside_start_timezone && current.start_time_zone.is_none() => - { - // 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 +1000,15 @@ pub fn parse_sync_folder_items_response(xml: &str) -> EwsResult { inside_deleted_occurrences = false; @@ -992,7 +1019,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 +1072,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 +1295,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 +1307,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,31 +1440,33 @@ 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 = - Some(String::from_utf8_lossy(&a.value).into_owned()); + Some(crate::zone_definition::attribute_text(&a)); } } } - // 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") { current.end_time_zone = - Some(String::from_utf8_lossy(&a.value).into_owned()); + Some(crate::zone_definition::attribute_text(&a)); } } - } - 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 +1493,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 +1546,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; } @@ -1756,15 +1809,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) @@ -1828,7 +1881,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 } @@ -1999,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!( @@ -2023,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) @@ -2058,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'); @@ -2067,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) @@ -2185,6 +2252,33 @@ 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) 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>, + stored: &StoredZones, + server_zones: Option<&ServerTimeZones>, + target: EventIdKind, + device: &D, ) -> EwsResult<(String, String)> { let mut set = String::new(); let mut del = String::new(); @@ -2240,7 +2334,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 @@ -2255,11 +2349,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) @@ -2324,8 +2419,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 @@ -2349,16 +2447,34 @@ pub fn event_to_update_field_xml_on( 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(|| 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 .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()); - let rule_follows_slot = slot_changed && start_moves && event.recurrence.is_some(); + .map(|rec| rrule_to_ews_recurrence(&rec.rrule, first_day(event)).ok()); + // 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)); @@ -2369,7 +2485,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. @@ -2392,10 +2508,7 @@ pub fn event_to_update_field_xml_on( // 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", @@ -2419,15 +2532,39 @@ 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( + 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) + }; + 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 @@ -2463,9 +2600,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; @@ -2503,9 +2639,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; @@ -2537,7 +2672,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 } @@ -2557,53 +2692,246 @@ 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.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(), - ) { - 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.end_zone_definition.as_ref(), + ) +} + +/// 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, 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, + 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 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>, + 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 /// 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) { @@ -2669,9 +2997,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") @@ -2693,7 +3025,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. @@ -2724,7 +3056,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}", @@ -2736,7 +3068,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}")) })?; @@ -2751,7 +3083,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}", ) @@ -2764,7 +3096,7 @@ pub fn rrule_to_ews_recurrence(rrule: &str, start: DateTime) -> EwsResult() @@ -2993,9 +3325,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", @@ -3380,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 @@ -3534,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, @@ -4274,6 +4608,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(), @@ -4952,12 +5288,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. + /// + /// 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. #[test] #[ignore = "writes the live Exchange test requests of round 3; see the doc comment"] fn live_test_requests_round_3() { @@ -5107,7 +5455,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}"); @@ -5128,8 +5478,9 @@ 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 \ - series with a new title, Start and End moved to midnights of the stored zone. {REPLACE}" + "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. Its rule \ + starts on 2026-10-19 since PR 8a (round 3 sent 2026-10-18). {REPLACE}" ), update(&on_stored_midnights(&s1, set), &del), ); @@ -5138,8 +5489,9 @@ 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), but with the rule's \ + StartDate 2026-10-19 of PR 8a (round 3 sent 2026-10-18). {REPLACE}" ), update(&set, &del), ); @@ -5152,7 +5504,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), @@ -5163,7 +5515,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), ); @@ -5176,8 +5528,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), ); @@ -5191,9 +5543,10 @@ 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 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), ); @@ -5213,7 +5566,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), @@ -5278,14 +5632,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), ); } @@ -5447,7 +5805,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("")); @@ -5457,7 +5815,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")); } @@ -5465,7 +5824,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")); } @@ -5473,7 +5833,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")); @@ -5482,7 +5844,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")); } @@ -5490,8 +5852,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")); } @@ -5501,7 +5866,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")); @@ -5511,7 +5876,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")); @@ -5522,8 +5887,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")); @@ -5533,7 +5901,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")); @@ -5546,7 +5915,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"); } @@ -5554,7 +5923,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:?}"), @@ -5911,7 +6280,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, @@ -6510,8 +6888,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"); @@ -6645,6 +7024,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(), @@ -6722,7 +7103,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); @@ -6732,7 +7113,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, @@ -6751,8 +7134,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}", @@ -6776,7 +7162,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() @@ -6787,9 +7173,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, @@ -6816,7 +7204,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, @@ -6989,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() { @@ -7375,6 +7766,214 @@ 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 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#" + + + + + + + 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. 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#""#; + 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); + + // 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}"); + } + } + } + + /// 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] + 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. @@ -7769,6 +8368,186 @@ mod tests { assert_eq!(override_slot(&item, &ov), orig); } + /// 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, + 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 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")); + 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)) + ); + } + + /// 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 @@ -7810,8 +8589,11 @@ 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, @@ -7823,7 +8605,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; @@ -8609,4 +9391,533 @@ 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 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}"); + } + + /// 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] + 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}" + ); + } + } + } + + /// 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; + 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. + /// Aperio created the single in UTC; Monday 00:30 in Berlin is Sunday + /// 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; + 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/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/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 new file mode 100644 index 00000000..6f83266c --- /dev/null +++ b/crates/adapter-ews/src/zone_definition.rs @@ -0,0 +1,1199 @@ +//! 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 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. 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): +//! +//! - `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| attribute_text(&a)) +} + +/// An attribute's value as text, its entities decoded: the parsers decode a +/// `` 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 { + /// 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()))?; + // 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 { + 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 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 { + 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() || (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)] +pub(crate) 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")) + ); + } + + /// 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(); + 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}"); + } + } +} diff --git a/crates/cal-core/bindings/WriteRefusal.ts b/crates/cal-core/bindings/WriteRefusal.ts index cf85c3dd..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"; +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 4a50f1ff..c0ca364f 100644 --- a/crates/cal-core/src/write_refusal.rs +++ b/crates/cal-core/src/write_refusal.rs @@ -52,6 +52,16 @@ 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, + /// 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 { @@ -64,6 +74,8 @@ impl WriteRefusal { Self::OccurrenceNotWritable => "occurrence-not-writable", Self::UnsafeToWrite => "unsafe-to-write", Self::AccessNotGranted => "access-not-granted", + Self::DayZoneUnreadable => "day-zone-unreadable", + Self::CopyUnreadable => "copy-unreadable", } } @@ -106,6 +118,8 @@ impl WriteRefusal { Self::OccurrenceNotWritable, 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 e1c7914c..d10e7e0e 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -1695,6 +1695,8 @@ "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.", + "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": { @@ -1704,6 +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 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/locales/en/translation.json b/locales/en/translation.json index 369cf0bf..10b5da67 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -1695,6 +1695,8 @@ "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.", + "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": { @@ -1704,6 +1706,8 @@ "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", + "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 efacdf17..de492289 100644 --- a/shared/eventWriteError.ts +++ b/shared/eventWriteError.ts @@ -19,6 +19,8 @@ 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', + 'copy-unreadable': 'dialogs.event.writeError.copyUnreadable', }; const TOKENS = Object.keys(REFUSAL_KEYS) as WriteRefusal[]; @@ -32,6 +34,8 @@ 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', + '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 cf85c3dd..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"; +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 d08db17b..554b42e4 100644 --- a/src/state/eventWriteError.test.ts +++ b/src/state/eventWriteError.test.ts @@ -198,6 +198,53 @@ 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)).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/); + } + // 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); + }); +}); + +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(eventWriteFailureReason(desktop, t)).toBe( + 'Aperio konnte ihren aktuellen Stand nicht vom Server lesen', + ); + 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 3e3c49a6..33ddd0cd 100644 --- a/web/src/content/docs/de/guides/troubleshooting.md +++ b/web/src/content/docs/de/guides/troubleshooting.md @@ -175,6 +175,32 @@ 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“ 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 +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 e9ad2d50..af115b33 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 @@ -94,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 @@ -151,7 +151,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 +168,59 @@ 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, + 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 + 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 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, + 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 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 + (`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). + 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 + (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`, @@ -198,6 +253,11 @@ 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) 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). diff --git a/web/src/content/docs/guides/troubleshooting.md b/web/src/content/docs/guides/troubleshooting.md index dc49637f..48cee14f 100644 --- a/web/src/content/docs/guides/troubleshooting.md +++ b/web/src/content/docs/guides/troubleshooting.md @@ -163,6 +163,31 @@ 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" 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 +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**.