Skip to main content

io_msgraph/v1/rest/users/events/
ical.rs

1//! # iCalendar projection
2//!
3//! Projects a Microsoft Graph event onto iCalendar and back. Graph exposes
4//! no iCalendar form of an event, so a consumer needing one (a calendar
5//! client, a sync engine) synthesizes the document of record from the JSON
6//! resource ([`MsgraphEvent::to_ical`], [`MsgraphEvent::to_ical_series`])
7//! and reads it back ([`MsgraphEvent::from_ical`]).
8//!
9//! A field is managed only where it has a well-defined iCalendar slot:
10//!
11//! - the recurrence pattern maps onto an RRULE and back, a rule Graph
12//!   cannot hold being refused rather than approximated; the cancelled
13//!   occurrences of a series become EXDATEs, and an exception, an
14//!   occurrence edited on its own, its own VEVENT with a RECURRENCE-ID;
15//! - Graph names zones the Windows way when Outlook created the event, so
16//!   a Windows name is read as its CLDR IANA counterpart, every TZID gets a
17//!   VTIMEZONE from the bundled database, and a zone neither table knows
18//!   keeps its name, undefined;
19//! - `showAs` rides the `X-MICROSOFT-CDO-BUSYSTATUS` property Outlook
20//!   itself writes; the web link is minted read-only as
21//!   `X-MSGRAPH-WEB-LINK`, and the join link of an online meeting as a
22//!   standard `CONFERENCE` (RFC 7986 5.11);
23//! - `X-PIMDIR-ONLINE-MEETING:TRUE` (pimdir STORAGE Annex B.1) asks for
24//!   an online meeting: it sets `isOnlineMeeting`, Graph creating the
25//!   meeting with the calendar's default provider, and is never written
26//!   back;
27//! - an invitation that came in by mail has Exchange's global object id
28//!   for `iCalUId`; when it wraps the organizer's UID (the `vCal-Uid`
29//!   form), that UID is the one projected ([`original_uid`]);
30//! - everything else, the UID among it, Graph minting its own `iCalUId`, is
31//!   stashed verbatim in a single-value extended property
32//!   ([`MSGRAPH_EVENT_STASH_ID`]) and spliced back on read; Graph returns it
33//!   only expanded ([`MSGRAPH_EVENT_STASH_EXPAND`]).
34//!
35//! A write of a document goes to the series master alone: an exception
36//! edited locally, a cancelled occurrence and an EXDATE do not push with
37//! it, Graph taking them only through the occurrence itself, an instance
38//! of the series. [`MsgraphEvent::instance_update_from_ical`] projects one
39//! occurrence onto the update of its instance, and a consumer deletes the
40//! instance of an occurrence the document cancels.
41
42mod windows_zones;
43
44use core::fmt;
45
46use alloc::{
47    borrow::{Cow, ToOwned},
48    collections::BTreeSet,
49    format,
50    string::{String, ToString},
51    vec,
52    vec::Vec,
53};
54
55use ical::{
56    param::IcalParam,
57    prop::{IcalProp, IcalPropKind, IcalPropName, action::ACTION, trigger::TRIGGER},
58    tree::{
59        codec::Codec,
60        cst::{IcalCst, IcalItem},
61        error::IcalParseError,
62        line::IcalLine,
63        param::{
64            cn::CN, cutype::CUTYPE, fmttype::FMTTYPE, partstat::PARTSTAT, role::ROLE, tzid::TZID,
65            value::VALUE,
66        },
67    },
68    tzdb,
69    value::{
70        IcalValue,
71        datetime::IcalDateTime,
72        recur::IcalRecur,
73        text::{IcalText, IcalTextList},
74    },
75};
76use jiff::{
77    Span, Timestamp,
78    civil::{Date, DateTime},
79    tz::TimeZone,
80};
81use serde_json::{from_value, to_value};
82
83use crate::v1::{
84    field::MsgraphField,
85    rest::users::{
86        contacts::MsgraphSingleValueExtendedProperty,
87        events::{
88            MsgraphAttendee, MsgraphAttendeeType, MsgraphDateTimeTimeZone, MsgraphDayOfWeek,
89            MsgraphEvent, MsgraphFreeBusyStatus, MsgraphLocation, MsgraphPatternedRecurrence,
90            MsgraphRecurrencePattern, MsgraphRecurrencePatternType, MsgraphRecurrenceRange,
91            MsgraphRecurrenceRangeType, MsgraphResponseStatus, MsgraphResponseType,
92            MsgraphSensitivity, MsgraphWeekIndex,
93        },
94        messages::{MsgraphBodyType, MsgraphEmailAddress, MsgraphImportance, MsgraphItemBody},
95    },
96};
97
98/// Extended-property id under which the iCalendar remainder of an event is
99/// stashed, a fixed app GUID plus a name (the String MAPI form).
100pub const MSGRAPH_EVENT_STASH_ID: &str =
101    "String {c8e5e5cf-3f6c-4f0a-9d4e-52f1e7b2a9d3} Name pimalaya-ical";
102
103/// The `$expand` clause reading the stash back with an event, which Graph
104/// omits otherwise.
105pub const MSGRAPH_EVENT_STASH_EXPAND: &str = "singleValueExtendedProperties($filter=id eq \
106     'String {c8e5e5cf-3f6c-4f0a-9d4e-52f1e7b2a9d3} Name pimalaya-ical')";
107
108/// The `$select` of a read the projection is fed from: every property it
109/// reads, `cancelledOccurrences` among them, which Graph returns only
110/// selected, and the zones an event was created in, which give back the
111/// wall time Graph answers in UTC.
112///
113/// Graph returns `cancelledOccurrences` only on a read of a series master
114/// by its id, never in a listing: project a master read with
115/// `event_get`, else its cancelled occurrences get no EXDATE.
116pub const MSGRAPH_EVENT_ICAL_SELECT: &str = "id,iCalUId,changeKey,createdDateTime,\
117     lastModifiedDateTime,type,seriesMasterId,originalStart,originalStartTimeZone,\
118     originalEndTimeZone,subject,body,start,end,isAllDay,location,recurrence,attendees,\
119     organizer,categories,showAs,sensitivity,importance,isReminderOn,\
120     reminderMinutesBeforeStart,isCancelled,webLink,onlineMeeting,cancelledOccurrences";
121
122/// Product identifier the synthesized document carries.
123const PRODID: &str = "-//Pimalaya//io-msgraph//EN";
124
125/// Longest raw property line stashed server-side; a longer one, an inline
126/// attachment essentially, stays in the local document.
127const MAX_STASH_LINE: usize = 8 * 1024;
128
129/// Properties minted read-only from Graph-scoped fields, dropped on read.
130const MINTED_PROPS: &[&str] = &["X-MSGRAPH-WEB-LINK", "CONFERENCE", TEAMS_MEETING_URL];
131
132/// The Outlook property the join link of an online meeting was minted as
133/// before CONFERENCE, still dropped from the documents a store kept since.
134const TEAMS_MEETING_URL: &str = "X-MICROSOFT-SKYPETEAMSMEETINGURL";
135
136/// The property asking the source for an online meeting of its provider
137/// (pimdir STORAGE Annex B.1).
138const ONLINE_MEETING: &str = "X-PIMDIR-ONLINE-MEETING";
139
140/// The Outlook busy status extension, carrying `showAs` both ways.
141const BUSY_STATUS: &str = "X-MICROSOFT-CDO-BUSYSTATUS";
142
143/// The HTML alternative description Outlook and Thunderbird exchange.
144const ALT_DESC: &str = "X-ALT-DESC";
145
146impl MsgraphEvent {
147    /// Projects a lone event or a series master onto a fresh VCALENDAR
148    /// document.
149    pub fn to_ical(&self) -> String {
150        self.to_ical_series(&[])
151    }
152
153    /// Projects a series master and its exceptions onto one VCALENDAR
154    /// document, the exceptions as VEVENTs carrying the master's UID and a
155    /// RECURRENCE-ID.
156    ///
157    /// Read the exceptions with [`MSGRAPH_EVENT_ICAL_SELECT`] too: the
158    /// default instance listing leaves out `originalStart`, without which
159    /// an exception gets no RECURRENCE-ID and reads as a second master.
160    pub fn to_ical_series(&self, exceptions: &[&MsgraphEvent]) -> String {
161        let master = localized(self);
162        let master = &*master;
163        let exceptions: Vec<Cow<'_, MsgraphEvent>> = exceptions
164            .iter()
165            .map(|exception| localized(exception))
166            .collect();
167        let uid = master
168            .stashed_uid()
169            .or_else(|| master.ical_uid.as_deref().map(original_uid))
170            .unwrap_or_else(|| master.id.clone());
171
172        let mut zones = BTreeSet::new();
173        let mut events = vec![vevent(master, None, &uid, &mut zones)];
174        for exception in &exceptions {
175            events.push(vevent(exception, Some(master), &uid, &mut zones));
176        }
177
178        let mut calendar = IcalCst::v2();
179        calendar.push(IcalProp::text(IcalPropKind::ProdId, vec![], PRODID));
180
181        let anchor = anchor(master);
182        for zone in &zones {
183            if let Some(vtimezone) = tzdb::vtimezone(zone, anchor) {
184                calendar.push_component(vtimezone);
185            }
186        }
187        for event in events {
188            calendar.push_component(event);
189        }
190
191        String::from_utf8_lossy(&calendar.to_bytes()).into_owned()
192    }
193
194    /// Projects an iCalendar document onto an event, in full state: each
195    /// managed field is Set from the series master or Null when absent,
196    /// while Graph-only fields stay Unset.
197    ///
198    /// The exceptions of a series are not read: Graph takes each through
199    /// its instance ([`Self::instance_update_from_ical`]).
200    pub fn from_ical(contents: &[u8]) -> Result<Self, MsgraphEventIcalError> {
201        let calendar = IcalCst::parse(contents).map_err(MsgraphEventIcalError::Parse)?;
202        project(find_vevent(&calendar, |vevent| {
203            !has_prop(vevent, "RECURRENCE-ID")
204        })?)
205    }
206
207    /// Projects an iCalendar document onto a create body: the full-state
208    /// projection minus its nulls and empty collections, which a fresh event
209    /// does not need.
210    pub fn create_from_ical(contents: &[u8]) -> Result<Self, MsgraphEventIcalError> {
211        let mut event = Self::from_ical(contents)?;
212
213        if stash_lines(&event).is_empty() {
214            event.single_value_extended_properties = MsgraphField::Unset;
215        }
216
217        let mut body = to_value(&event).map_err(MsgraphEventIcalError::Json)?;
218        if let Some(object) = body.as_object_mut() {
219            object.retain(|_, value| {
220                !value.is_null() && value.as_array().is_none_or(|array| !array.is_empty())
221            });
222        }
223
224        from_value(body).map_err(MsgraphEventIcalError::Json)
225    }
226
227    /// Projects an iCalendar document onto an update body: the fields that
228    /// differ from the base document (the state last synced), the unchanged
229    /// ones left Unset and out of the PATCH.
230    pub fn update_from_ical(contents: &[u8], base: &[u8]) -> Result<Self, MsgraphEventIcalError> {
231        let mut event = Self::from_ical(contents)?;
232        event.unset_unchanged(&Self::from_ical(base)?);
233        Ok(event)
234    }
235
236    /// Projects one occurrence of a series onto the update body of its
237    /// Graph instance: the fields it sets differently from `base`, the
238    /// occurrence as Graph holds it; `None` when the two agree.
239    ///
240    /// Each document holds that occurrence alone, as its first VEVENT: an
241    /// override with its RECURRENCE-ID, or the series' own component
242    /// carried to the occurrence, which is what reverts an exception.
243    /// Neither the recurrence nor the stash is sent, Graph keeping both on
244    /// the series master.
245    pub fn instance_update_from_ical(
246        contents: &[u8],
247        base: &[u8],
248    ) -> Result<Option<Self>, MsgraphEventIcalError> {
249        let occurrence = |contents| {
250            let calendar = IcalCst::parse(contents).map_err(MsgraphEventIcalError::Parse)?;
251            project(find_vevent(&calendar, |_| true)?)
252        };
253
254        let mut event = occurrence(contents)?;
255        event.unset_unchanged(&occurrence(base)?);
256        event.recurrence = MsgraphField::Unset;
257        event.single_value_extended_properties = MsgraphField::Unset;
258
259        Ok((event != Self::default()).then_some(event))
260    }
261
262    /// Leaves Unset every managed field `base` holds the same.
263    fn unset_unchanged(&mut self, base: &Self) {
264        macro_rules! unset_unchanged {
265            ($($field:ident),* $(,)?) => {$(
266                if self.$field == base.$field {
267                    self.$field = MsgraphField::Unset;
268                }
269            )*};
270        }
271
272        if same_text(&body_text(&self.body), &body_text(&base.body)) {
273            self.body = MsgraphField::Unset;
274        }
275
276        unset_unchanged!(
277            subject,
278            start,
279            end,
280            is_all_day,
281            location,
282            recurrence,
283            attendees,
284            categories,
285            show_as,
286            sensitivity,
287            importance,
288            is_reminder_on,
289            reminder_minutes_before_start,
290            is_online_meeting,
291            single_value_extended_properties,
292        );
293    }
294
295    /// The RECURRENCE-ID value the projection gives the occurrence of this
296    /// series master that originally started at `original_start` (an
297    /// instance's `originalStart`): a date for an all-day series, the wall
298    /// time in the series' zone, or a UTC stamp when that zone is unknown.
299    ///
300    /// A consumer told an occurrence by its RECURRENCE-ID finds the Graph
301    /// instance with it, comparing values the way the projection wrote them.
302    pub fn recurrence_id_of(&self, original_start: &str) -> Option<String> {
303        let master = localized(self);
304        let prop = recurrence_id(&master, original_start, &mut BTreeSet::new())?;
305        match prop.value {
306            IcalValue::Date(date) => Some(date.0.into_owned()),
307            IcalValue::DateTime(stamp) => Some(stamp.0.into_owned()),
308            _ => None,
309        }
310    }
311
312    /// The iCalendar UID the stash carries, `None` for an event no document
313    /// was ever written to, Graph minting its own `iCalUId`.
314    ///
315    /// A sync engine checks it after a write: a server that dropped the
316    /// stash would hand the event back under Graph's UID rather than the one
317    /// it was written with.
318    pub fn stashed_uid(&self) -> Option<String> {
319        stash_lines(self).iter().find_map(|line| {
320            let mut bytes = line.clone();
321            bytes.push_str("\r\n");
322            let (line, _) = IcalLine::take(bytes.as_bytes()).ok()?;
323            line.name
324                .get()
325                .eq_ignore_ascii_case("UID")
326                .then(|| line.raw_value_str().trim().to_string())
327                .filter(|uid| !uid.is_empty())
328        })
329    }
330}
331
332impl MsgraphPatternedRecurrence {
333    /// The first and last dates a series spans, inclusive, the last
334    /// `None` for a `noEnd` range.
335    ///
336    /// The last date follows the range type: the `endDate` of an `endDate`
337    /// range, a bound past the last occurrence of a `numbered` one, sized
338    /// `numberOfOccurrences` times `interval` periods of the pattern. Graph
339    /// fills the `endDate` of the other types with `0001-01-01`, which is
340    /// no end.
341    pub fn bounds(&self) -> Option<(Date, Option<Date>)> {
342        let range = &self.range;
343        let start = range.start_date.as_deref()?.parse::<Date>().ok()?;
344
345        let end = match range.range_type? {
346            MsgraphRecurrenceRangeType::EndDate => {
347                Some(range.end_date.as_deref()?.parse::<Date>().ok()?)
348            }
349            MsgraphRecurrenceRangeType::Numbered => {
350                let periods = i64::from(range.number_of_occurrences?)
351                    * i64::from(self.pattern.interval.unwrap_or(1).max(1));
352                let span = match self.pattern.pattern_type? {
353                    MsgraphRecurrencePatternType::Daily => Span::new().try_days(periods),
354                    MsgraphRecurrencePatternType::Weekly => Span::new().try_weeks(periods),
355                    MsgraphRecurrencePatternType::AbsoluteMonthly
356                    | MsgraphRecurrencePatternType::RelativeMonthly => {
357                        Span::new().try_months(periods)
358                    }
359                    MsgraphRecurrencePatternType::AbsoluteYearly
360                    | MsgraphRecurrencePatternType::RelativeYearly => {
361                        Span::new().try_years(periods)
362                    }
363                };
364                Some(start.checked_add(span.ok()?).ok()?)
365            }
366            MsgraphRecurrenceRangeType::NoEnd => None,
367        };
368
369        Some((start, end))
370    }
371}
372
373/// An iCalendar document that cannot become a Graph event.
374#[derive(Debug)]
375pub enum MsgraphEventIcalError {
376    /// The document does not parse.
377    Parse(IcalParseError),
378    /// The document holds no VEVENT.
379    NoEvent,
380    /// The document holds a component Graph does not model.
381    NotAnEvent(String),
382    /// The VEVENT starts neither on a date, in UTC nor in a named zone.
383    NoZonedStart,
384    /// The VEVENT has neither a DTEND nor a DURATION.
385    NoEnd,
386    /// The RRULE has no Graph recurrence pattern.
387    UnsupportedRecurrence(String),
388    /// The projected event does not reshape through JSON.
389    Json(serde_json::Error),
390}
391
392impl fmt::Display for MsgraphEventIcalError {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        match self {
395            Self::Parse(err) => write!(f, "Invalid iCalendar: {err}"),
396            Self::NoEvent => write!(f, "The iCalendar contents carry no VEVENT"),
397            Self::NotAnEvent(name) => {
398                write!(
399                    f,
400                    "Microsoft Graph models no {name}: its calendars hold events only"
401                )
402            }
403            Self::NoZonedStart => write!(
404                f,
405                "Microsoft Graph needs a DTSTART that is a date, in UTC, or in a named zone"
406            ),
407            Self::NoEnd => write!(f, "The VEVENT has neither a DTEND nor a DURATION"),
408            Self::UnsupportedRecurrence(rule) => {
409                write!(f, "Microsoft Graph cannot hold the recurrence `{rule}`")
410            }
411            Self::Json(err) => write!(f, "Cannot shape the Graph event body: {err}"),
412        }
413    }
414}
415
416impl core::error::Error for MsgraphEventIcalError {}
417
418/// Projects one VEVENT onto an event, in full state.
419fn project(vevent: &IcalCst<'_>) -> Result<MsgraphEvent, MsgraphEventIcalError> {
420    let mut event = MsgraphEvent {
421        subject: MsgraphField::Null,
422        body: MsgraphField::Null,
423        location: MsgraphField::Null,
424        recurrence: MsgraphField::Null,
425        attendees: MsgraphField::Set(Vec::new()),
426        categories: MsgraphField::Set(Vec::new()),
427        show_as: MsgraphField::Null,
428        sensitivity: MsgraphField::Null,
429        importance: MsgraphField::Null,
430        is_reminder_on: MsgraphField::Set(false),
431        ..Default::default()
432    };
433
434    let mut description = None;
435    let mut html = None;
436    let mut start = None;
437    let mut end = None;
438    let mut duration = None;
439    let mut transparent = None;
440    let mut rrule = None;
441    let mut stash = Vec::new();
442
443    for item in &vevent.items {
444        match item {
445            IcalItem::Prop(line) => {
446                let name = line.name.get();
447                let consumed = if MINTED_PROPS.iter().any(|m| name.eq_ignore_ascii_case(m)) {
448                    true
449                } else if name.eq_ignore_ascii_case(ONLINE_MEETING) {
450                    if line.raw_value_str().trim().eq_ignore_ascii_case("TRUE") {
451                        event.is_online_meeting = MsgraphField::Set(true);
452                    }
453                    true
454                } else if name.eq_ignore_ascii_case(BUSY_STATUS) {
455                    event.show_as =
456                        MsgraphField::set_or_null(busy_status_from_ical(&line.raw_value_str()));
457                    true
458                } else if name.eq_ignore_ascii_case(ALT_DESC)
459                    && line
460                        .param::<FMTTYPE>()
461                        .is_some_and(|kind| kind.eq_ignore_ascii_case("text/html"))
462                {
463                    html = Some(text(line));
464                    true
465                } else {
466                    match name.parse::<IcalPropKind>() {
467                        Ok(IcalPropKind::Summary) => {
468                            event.subject = MsgraphField::Set(text(line));
469                            true
470                        }
471                        Ok(IcalPropKind::Description) => {
472                            description = Some(text(line));
473                            true
474                        }
475                        Ok(IcalPropKind::Location) => {
476                            event.location = MsgraphField::Set(MsgraphLocation {
477                                display_name: Some(text(line)),
478                                ..Default::default()
479                            });
480                            true
481                        }
482                        Ok(IcalPropKind::DtStart) => {
483                            start = Some(boundary(line));
484                            true
485                        }
486                        Ok(IcalPropKind::DtEnd) => {
487                            end = Some(boundary(line));
488                            true
489                        }
490                        Ok(IcalPropKind::Duration) => {
491                            duration = line.raw_value_str().trim().parse::<Span>().ok();
492                            duration.is_some()
493                        }
494                        Ok(IcalPropKind::RRule) => {
495                            rrule = Some(IcalRecur::decode(&line.value).0.trim().to_owned());
496                            true
497                        }
498                        // NOTE: Graph mints the EXDATEs from the cancelled
499                        // occurrences, which only a deletion of the
500                        // instance writes; a stashed copy would outlive it.
501                        Ok(IcalPropKind::ExDate) => true,
502                        Ok(IcalPropKind::Categories) => {
503                            let list = IcalTextList::decode(&line.value);
504                            if let MsgraphField::Set(categories) = &mut event.categories {
505                                categories.extend(list.0.into_iter().map(Cow::into_owned));
506                            }
507                            true
508                        }
509                        Ok(IcalPropKind::Class) => {
510                            event.sensitivity = MsgraphField::set_or_null(sensitivity_from_ical(
511                                &line.raw_value_str(),
512                            ));
513                            true
514                        }
515                        Ok(IcalPropKind::Priority) => {
516                            event.importance = MsgraphField::set_or_null(importance_from_ical(
517                                &line.raw_value_str(),
518                            ));
519                            true
520                        }
521                        Ok(IcalPropKind::Transp) => {
522                            transparent = Some(
523                                line.raw_value_str()
524                                    .trim()
525                                    .eq_ignore_ascii_case("TRANSPARENT"),
526                            );
527                            true
528                        }
529                        Ok(IcalPropKind::Attendee) => {
530                            if let MsgraphField::Set(attendees) = &mut event.attendees {
531                                attendees.push(attendee(line));
532                            }
533                            true
534                        }
535                        // NOTE: Graph sets the organizer, the status and
536                        // the stamps itself, so an incoming value is
537                        // consumed rather than stashed.
538                        Ok(
539                            IcalPropKind::Organizer
540                            | IcalPropKind::Status
541                            | IcalPropKind::DtStamp
542                            | IcalPropKind::Created
543                            | IcalPropKind::LastModified
544                            | IcalPropKind::Sequence,
545                        ) => true,
546                        _ => false,
547                    }
548                };
549
550                if !consumed {
551                    let raw = raw_line(line);
552                    if raw.len() <= MAX_STASH_LINE {
553                        stash.push(raw);
554                    }
555                }
556            }
557            IcalItem::Component(alarm) => {
558                let reminder = reminder_minutes(alarm);
559                match (&event.reminder_minutes_before_start, reminder) {
560                    (MsgraphField::Unset, Some(minutes)) => {
561                        event.is_reminder_on = MsgraphField::Set(true);
562                        event.reminder_minutes_before_start = MsgraphField::Set(minutes);
563                    }
564                    _ => stash.extend(raw_component(alarm)),
565                }
566            }
567            IcalItem::Opaque(bytes) => stash.push(String::from_utf8_lossy(bytes).into_owned()),
568        }
569    }
570
571    let Some(Some(start)) = start else {
572        return Err(MsgraphEventIcalError::NoZonedStart);
573    };
574    let end = match (end, duration) {
575        (Some(Some(end)), _) => end,
576        (_, Some(duration)) => start
577            .shifted(duration)
578            .ok_or(MsgraphEventIcalError::NoEnd)?,
579        _ => return Err(MsgraphEventIcalError::NoEnd),
580    };
581
582    event.is_all_day = MsgraphField::Set(start.all_day);
583    if let Some(rule) = &rrule {
584        event.recurrence = MsgraphField::Set(recurrence(rule, &start)?);
585    }
586    event.start = MsgraphField::Set(start.into_graph());
587    event.end = MsgraphField::Set(end.into_graph());
588
589    event.body = match (html, description) {
590        // NOTE: the text is what an editor changes, so an HTML copy
591        // it no longer matches is stale and must not win.
592        (Some(html), Some(content)) if !same_text(&strip_html(&html), &content) => {
593            MsgraphField::Set(MsgraphItemBody {
594                content_type: Some(MsgraphBodyType::Text),
595                content: Some(content),
596            })
597        }
598        (Some(content), _) => MsgraphField::Set(MsgraphItemBody {
599            content_type: Some(MsgraphBodyType::Html),
600            content: Some(content),
601        }),
602        (None, Some(content)) => MsgraphField::Set(MsgraphItemBody {
603            content_type: Some(MsgraphBodyType::Text),
604            content: Some(content),
605        }),
606        // NOTE: Graph refuses a null body, an empty text one is how
607        // it clears.
608        (None, None) => MsgraphField::Set(MsgraphItemBody {
609            content_type: Some(MsgraphBodyType::Text),
610            content: Some(String::new()),
611        }),
612    };
613
614    if matches!(event.show_as, MsgraphField::Null) {
615        event.show_as = match transparent {
616            Some(true) => MsgraphField::Set(MsgraphFreeBusyStatus::Free),
617            Some(false) => MsgraphField::Set(MsgraphFreeBusyStatus::Busy),
618            None => MsgraphField::Null,
619        };
620    }
621
622    // NOTE: the stash entry is always Set (empty when nothing is left),
623    // so an update can tell a cleared stash from an unchanged one.
624    event.single_value_extended_properties =
625        MsgraphField::Set(vec![MsgraphSingleValueExtendedProperty {
626            id: MSGRAPH_EVENT_STASH_ID.to_string(),
627            value: stash.join("\n"),
628        }]);
629
630    Ok(event)
631}
632
633/// Builds one VEVENT; `master` is the series of an exception.
634fn vevent(
635    event: &MsgraphEvent,
636    master: Option<&MsgraphEvent>,
637    uid: &str,
638    zones: &mut BTreeSet<String>,
639) -> IcalCst<'static> {
640    let mut vevent = IcalCst::empty("VEVENT");
641    vevent.push(IcalProp::text(IcalPropKind::Uid, vec![], uid.to_owned()));
642
643    // NOTE: DTSTAMP is mandatory (RFC 5545 3.6.1) and Graph keeps no field
644    // of its own for it, so the last modification stands in.
645    let stamp = event
646        .last_modified_date_time
647        .as_deref()
648        .or(event.created_date_time.as_deref())
649        .and_then(utc_stamp);
650    if let Some(stamp) = &stamp {
651        vevent.push(stamp_prop(IcalPropKind::DtStamp, stamp.clone()));
652    }
653
654    let all_day = event.is_all_day.as_option().copied().unwrap_or(false);
655    if let Some(start) = event.start.as_option() {
656        vevent.push(boundary_prop(IcalPropKind::DtStart, start, all_day, zones));
657    }
658    if let Some(end) = event.end.as_option() {
659        vevent.push(boundary_prop(IcalPropKind::DtEnd, end, all_day, zones));
660    }
661
662    if let (Some(master), Some(original)) = (master, &event.original_start)
663        && let Some(prop) = recurrence_id(master, original, zones)
664    {
665        vevent.push(prop);
666    }
667
668    if let Some(subject) = event.subject.as_option() {
669        vevent.push(IcalProp::text(
670            IcalPropKind::Summary,
671            vec![],
672            subject.clone(),
673        ));
674    }
675
676    if let Some(body) = event.body.as_option() {
677        let content = body.content.clone().unwrap_or_default();
678        match body.content_type {
679            Some(MsgraphBodyType::Html) => {
680                vevent.push(IcalProp::text(
681                    IcalPropKind::Description,
682                    vec![],
683                    strip_html(&content),
684                ));
685                vevent.push(IcalProp::text(
686                    ALT_DESC,
687                    vec![IcalParam::FmtType("text/html".into())],
688                    content,
689                ));
690            }
691            _ if !content.is_empty() => {
692                vevent.push(IcalProp::text(IcalPropKind::Description, vec![], content));
693            }
694            _ => {}
695        }
696    }
697
698    if let Some(name) = event
699        .location
700        .as_option()
701        .and_then(|location| location.display_name.clone())
702        .filter(|name| !name.is_empty())
703    {
704        vevent.push(IcalProp::text(IcalPropKind::Location, vec![], name));
705    }
706
707    if event.is_cancelled == Some(true) {
708        vevent.push(IcalProp::text(IcalPropKind::Status, vec![], "CANCELLED"));
709    }
710
711    if let Some(show_as) = event.show_as.as_option() {
712        let transp = match show_as {
713            MsgraphFreeBusyStatus::Free => "TRANSPARENT",
714            _ => "OPAQUE",
715        };
716        vevent.push(IcalProp::text(IcalPropKind::Transp, vec![], transp));
717        vevent.push(IcalProp::text(
718            BUSY_STATUS,
719            vec![],
720            busy_status_to_ical(*show_as),
721        ));
722    }
723
724    if let Some(class) = event
725        .sensitivity
726        .as_option()
727        .map(|s| sensitivity_to_ical(*s))
728    {
729        vevent.push(IcalProp::text(IcalPropKind::Class, vec![], class));
730    }
731
732    if let Some(importance) = event.importance.as_option() {
733        let priority = match importance {
734            MsgraphImportance::High => "1",
735            MsgraphImportance::Normal => "5",
736            MsgraphImportance::Low => "9",
737        };
738        vevent.push(IcalProp {
739            name: IcalPropName::Kind(IcalPropKind::Priority),
740            params: Vec::new(),
741            value: IcalValue::Integer(priority.into()),
742        });
743    }
744
745    let categories: Vec<Cow<'static, str>> = event
746        .categories
747        .as_option()
748        .map(Vec::as_slice)
749        .unwrap_or(&[])
750        .iter()
751        .filter(|category| !category.is_empty())
752        .map(|category| Cow::Owned(category.clone()))
753        .collect();
754    if !categories.is_empty() {
755        vevent.push(IcalProp {
756            name: IcalPropName::Kind(IcalPropKind::Categories),
757            params: Vec::new(),
758            value: IcalValue::TextList(IcalTextList(categories)),
759        });
760    }
761
762    for (kind, stamp) in [
763        (IcalPropKind::Created, &event.created_date_time),
764        (IcalPropKind::LastModified, &event.last_modified_date_time),
765    ] {
766        if let Some(stamp) = stamp.as_deref().and_then(utc_stamp) {
767            vevent.push(stamp_prop(kind, stamp));
768        }
769    }
770
771    if let Some(organizer) = event
772        .organizer
773        .as_option()
774        .map(|organizer| &organizer.email_address)
775    {
776        vevent.push(person_prop(IcalPropKind::Organizer, organizer, Vec::new()));
777    }
778
779    for attendee in event
780        .attendees
781        .as_option()
782        .map(Vec::as_slice)
783        .unwrap_or(&[])
784    {
785        if let Some(prop) = attendee_prop(attendee) {
786            vevent.push(prop);
787        }
788    }
789
790    if master.is_none()
791        && let Some(recurrence) = event.recurrence.as_option()
792    {
793        if let Some(rule) = rrule(recurrence, event.start.as_option(), all_day) {
794            vevent.push(IcalProp {
795                name: IcalPropName::Kind(IcalPropKind::RRule),
796                params: Vec::new(),
797                value: IcalValue::Recur(rule.into()),
798            });
799        }
800        for exdate in exdates(event, all_day, zones) {
801            vevent.push(exdate);
802        }
803    }
804
805    if let Some(link) = &event.web_link {
806        vevent.push(IcalProp::text("X-MSGRAPH-WEB-LINK", vec![], link.clone()));
807    }
808
809    if let Some(url) = event
810        .online_meeting
811        .as_ref()
812        .and_then(|meeting| meeting.join_url.as_deref())
813        .filter(|url| !url.is_empty())
814    {
815        vevent.push(IcalProp {
816            name: IcalPropName::Kind(IcalPropKind::Conference),
817            params: vec![
818                IcalParam::Value("URI".into()),
819                IcalParam::Feature(vec!["AUDIO".into(), "VIDEO".into()]),
820            ],
821            value: IcalValue::Uri(url.to_owned().into()),
822        });
823    }
824
825    for line in stash_lines(event) {
826        // NOTE: a minted property stashed by an older version would
827        // come out twice.
828        if is_uid_line(&line) || is_minted_line(&line) {
829            continue;
830        }
831        // NOTE: a line that no longer tokenises restores nothing rather
832        // than corrupting the event.
833        let _ = vevent.push_raw(&line);
834    }
835
836    if event.is_reminder_on.as_option() == Some(&true)
837        && let Some(minutes) = event.reminder_minutes_before_start.as_option()
838    {
839        let mut alarm = IcalCst::empty("VALARM");
840        alarm.push(IcalProp::text(IcalPropKind::Action, vec![], "DISPLAY"));
841        alarm.push(IcalProp {
842            name: IcalPropName::Kind(IcalPropKind::Trigger),
843            params: Vec::new(),
844            value: IcalValue::Duration(format!("-PT{minutes}M").into()),
845        });
846        alarm.push(IcalProp::text(
847            IcalPropKind::Description,
848            vec![],
849            "Reminder",
850        ));
851        vevent.push_component(alarm);
852    }
853
854    vevent
855}
856
857/// The event with a UTC start and end told back in the zones it was
858/// created in.
859///
860/// Graph answers a read in UTC unless the request names a zone, and keeps
861/// the creation zones as `originalStartTimeZone` and `originalEndTimeZone`.
862/// Left in UTC, a series anchored on a wall time drifts by an hour at each
863/// DST change once expanded. All-day boundaries are dates, and a zone the
864/// database does not know stays UTC.
865fn localized(event: &MsgraphEvent) -> Cow<'_, MsgraphEvent> {
866    if event.is_all_day.as_option() == Some(&true) {
867        return Cow::Borrowed(event);
868    }
869
870    let start = local_boundary(
871        event.start.as_option(),
872        event.original_start_time_zone.as_deref(),
873    );
874    let end = local_boundary(
875        event.end.as_option(),
876        event.original_end_time_zone.as_deref(),
877    );
878
879    if start.is_none() && end.is_none() {
880        return Cow::Borrowed(event);
881    }
882
883    let mut event = event.clone();
884    if let Some(start) = start {
885        event.start = MsgraphField::Set(start);
886    }
887    if let Some(end) = end {
888        event.end = MsgraphField::Set(end);
889    }
890
891    Cow::Owned(event)
892}
893
894/// A UTC boundary as the wall time of `original`, `None` when it is not
895/// UTC or `original` is no IANA zone.
896fn local_boundary(
897    boundary: Option<&MsgraphDateTimeTimeZone>,
898    original: Option<&str>,
899) -> Option<MsgraphDateTimeTimeZone> {
900    let boundary = boundary?;
901    if !matches!(zone(boundary.time_zone.as_deref()), Zone::Utc) {
902        return None;
903    }
904    let Zone::Iana(name) = zone(original) else {
905        return None;
906    };
907
908    let utc = boundary
909        .date_time
910        .split('.')
911        .next()?
912        .parse::<DateTime>()
913        .ok()?;
914    let local = utc
915        .to_zoned(TimeZone::UTC)
916        .ok()?
917        .with_time_zone(TimeZone::get(&name).ok()?)
918        .datetime();
919
920    Some(MsgraphDateTimeTimeZone {
921        date_time: local.strftime("%Y-%m-%dT%H:%M:%S").to_string(),
922        time_zone: Some(name),
923    })
924}
925
926/// How Graph names a zone, resolved for iCalendar.
927enum Zone {
928    /// UTC: a `Z`-suffixed time.
929    Utc,
930    /// An IANA zone, given a VTIMEZONE.
931    Iana(String),
932    /// A name neither the Windows table nor the database knows, kept.
933    Unknown(String),
934}
935
936/// Resolves a Graph zone name, Windows or IANA.
937fn zone(name: Option<&str>) -> Zone {
938    let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
939        return Zone::Utc;
940    };
941
942    if name.eq_ignore_ascii_case("UTC") || name.eq_ignore_ascii_case("Etc/UTC") {
943        return Zone::Utc;
944    }
945
946    if let Some(iana) = windows_zones::iana(name) {
947        return match iana {
948            "Etc/UTC" => Zone::Utc,
949            iana => Zone::Iana(iana.to_owned()),
950        };
951    }
952
953    if tzdb::is_known(name) {
954        return Zone::Iana(name.to_owned());
955    }
956
957    Zone::Unknown(name.to_owned())
958}
959
960/// A DTSTART or DTEND from a Graph wall time in a zone.
961fn boundary_prop(
962    kind: IcalPropKind,
963    boundary: &MsgraphDateTimeTimeZone,
964    all_day: bool,
965    zones: &mut BTreeSet<String>,
966) -> IcalProp<'static> {
967    let digits = ical_digits(&boundary.date_time);
968
969    if all_day {
970        return IcalProp {
971            name: IcalPropName::Kind(kind),
972            params: vec![IcalParam::Value("DATE".into())],
973            value: IcalValue::Date(digits.get(..8).unwrap_or_default().to_owned().into()),
974        };
975    }
976
977    match zone(boundary.time_zone.as_deref()) {
978        Zone::Utc => stamp_prop(kind, format!("{digits}Z")),
979        Zone::Iana(zone) | Zone::Unknown(zone) => {
980            zones.insert(zone.clone());
981            IcalProp {
982                name: IcalPropName::Kind(kind),
983                params: vec![IcalParam::TzId(zone.into())],
984                value: IcalValue::DateTime(digits.into()),
985            }
986        }
987    }
988}
989
990/// The RECURRENCE-ID of an exception: its original start, a UTC instant,
991/// told in the zone of its series' start, as RFC 5545 3.8.4.4 wants it.
992fn recurrence_id(
993    master: &MsgraphEvent,
994    original: &str,
995    zones: &mut BTreeSet<String>,
996) -> Option<IcalProp<'static>> {
997    let instant = original.parse::<Timestamp>().ok()?;
998    let all_day = master.is_all_day.as_option().copied().unwrap_or(false);
999    let start_zone = master
1000        .start
1001        .as_option()
1002        .and_then(|start| start.time_zone.as_deref());
1003
1004    if all_day {
1005        let date = instant.to_zoned(TimeZone::UTC).date();
1006        return Some(IcalProp {
1007            name: IcalPropName::Kind(IcalPropKind::RecurrenceId),
1008            params: vec![IcalParam::Value("DATE".into())],
1009            value: IcalValue::Date(date.strftime("%Y%m%d").to_string().into()),
1010        });
1011    }
1012
1013    match zone(start_zone) {
1014        Zone::Iana(name) => {
1015            let local = instant.to_zoned(TimeZone::get(&name).ok()?).datetime();
1016            zones.insert(name.clone());
1017            Some(IcalProp {
1018                name: IcalPropName::Kind(IcalPropKind::RecurrenceId),
1019                params: vec![IcalParam::TzId(name.into())],
1020                value: IcalValue::DateTime(local.strftime("%Y%m%dT%H%M%S").to_string().into()),
1021            })
1022        }
1023        _ => Some(stamp_prop(
1024            IcalPropKind::RecurrenceId,
1025            instant.strftime("%Y%m%dT%H%M%SZ").to_string(),
1026        )),
1027    }
1028}
1029
1030/// The RRULE of a Graph recurrence, `None` for a pattern missing what its
1031/// kind needs.
1032fn rrule(
1033    recurrence: &MsgraphPatternedRecurrence,
1034    start: Option<&MsgraphDateTimeTimeZone>,
1035    all_day: bool,
1036) -> Option<String> {
1037    let pattern = &recurrence.pattern;
1038    let mut parts = Vec::new();
1039
1040    let days = || -> String {
1041        pattern
1042            .days_of_week
1043            .iter()
1044            .map(|day| weekday_to_ical(*day))
1045            .collect::<Vec<_>>()
1046            .join(",")
1047    };
1048
1049    match pattern.pattern_type? {
1050        MsgraphRecurrencePatternType::Daily => parts.push(String::from("FREQ=DAILY")),
1051        MsgraphRecurrencePatternType::Weekly => {
1052            parts.push(String::from("FREQ=WEEKLY"));
1053            if !pattern.days_of_week.is_empty() {
1054                parts.push(format!("BYDAY={}", days()));
1055            }
1056        }
1057        MsgraphRecurrencePatternType::AbsoluteMonthly => {
1058            parts.push(String::from("FREQ=MONTHLY"));
1059            parts.push(format!("BYMONTHDAY={}", pattern.day_of_month?));
1060        }
1061        MsgraphRecurrencePatternType::RelativeMonthly => {
1062            parts.push(String::from("FREQ=MONTHLY"));
1063            parts.push(format!("BYDAY={}", days()));
1064            parts.push(format!("BYSETPOS={}", setpos(pattern.index)));
1065        }
1066        MsgraphRecurrencePatternType::AbsoluteYearly => {
1067            parts.push(String::from("FREQ=YEARLY"));
1068            parts.push(format!("BYMONTH={}", pattern.month?));
1069            parts.push(format!("BYMONTHDAY={}", pattern.day_of_month?));
1070        }
1071        MsgraphRecurrencePatternType::RelativeYearly => {
1072            parts.push(String::from("FREQ=YEARLY"));
1073            parts.push(format!("BYMONTH={}", pattern.month?));
1074            parts.push(format!("BYDAY={}", days()));
1075            parts.push(format!("BYSETPOS={}", setpos(pattern.index)));
1076        }
1077    }
1078
1079    if let Some(interval) = pattern.interval.filter(|interval| *interval > 1) {
1080        parts.push(format!("INTERVAL={interval}"));
1081    }
1082
1083    if let Some(first) = pattern.first_day_of_week
1084        && pattern.pattern_type == Some(MsgraphRecurrencePatternType::Weekly)
1085    {
1086        parts.push(format!("WKST={}", weekday_to_ical(first)));
1087    }
1088
1089    let range = &recurrence.range;
1090    match range.range_type {
1091        Some(MsgraphRecurrenceRangeType::Numbered) => {
1092            parts.push(format!("COUNT={}", range.number_of_occurrences?));
1093        }
1094        Some(MsgraphRecurrenceRangeType::EndDate) => {
1095            let end = range.end_date.as_deref()?.parse::<Date>().ok()?;
1096            parts.push(format!("UNTIL={}", until(end, range, start, all_day)?));
1097        }
1098        Some(MsgraphRecurrenceRangeType::NoEnd) | None => {}
1099    }
1100
1101    Some(parts.join(";"))
1102}
1103
1104/// The UNTIL of an `endDate` range: the date itself for an all-day series,
1105/// else the end of that day in the recurrence zone, told in UTC as RFC 5545
1106/// 3.3.10 wants it beside a zoned DTSTART.
1107fn until(
1108    end: Date,
1109    range: &MsgraphRecurrenceRange,
1110    start: Option<&MsgraphDateTimeTimeZone>,
1111    all_day: bool,
1112) -> Option<String> {
1113    if all_day {
1114        return Some(end.strftime("%Y%m%d").to_string());
1115    }
1116
1117    let name = range
1118        .recurrence_time_zone
1119        .as_deref()
1120        .or(start.and_then(|start| start.time_zone.as_deref()));
1121    let zone = match zone(name) {
1122        Zone::Iana(name) => TimeZone::get(&name).ok()?,
1123        _ => TimeZone::UTC,
1124    };
1125
1126    let last = end.at(23, 59, 59, 0).to_zoned(zone).ok()?;
1127    Some(last.timestamp().strftime("%Y%m%dT%H%M%SZ").to_string())
1128}
1129
1130/// The EXDATEs of a series' cancelled occurrences, read off their
1131/// occurrence ids and told at the series' start time.
1132fn exdates(
1133    master: &MsgraphEvent,
1134    all_day: bool,
1135    zones: &mut BTreeSet<String>,
1136) -> Vec<IcalProp<'static>> {
1137    let Some(start) = master.start.as_option() else {
1138        return Vec::new();
1139    };
1140    let time = ical_digits(&start.date_time);
1141    let time = time.get(8..).unwrap_or("T000000").to_owned();
1142
1143    master
1144        .cancelled_occurrences
1145        .iter()
1146        .filter_map(|occurrence| occurrence.rsplit('.').next())
1147        .filter_map(|date| date.parse::<Date>().ok())
1148        .map(|date| {
1149            let day = date.strftime("%Y%m%d").to_string();
1150            if all_day {
1151                return IcalProp {
1152                    name: IcalPropName::Kind(IcalPropKind::ExDate),
1153                    params: vec![IcalParam::Value("DATE".into())],
1154                    value: IcalValue::Date(day.into()),
1155                };
1156            }
1157            match zone(start.time_zone.as_deref()) {
1158                Zone::Utc => stamp_prop(IcalPropKind::ExDate, format!("{day}{time}Z")),
1159                Zone::Iana(name) | Zone::Unknown(name) => {
1160                    zones.insert(name.clone());
1161                    IcalProp {
1162                        name: IcalPropName::Kind(IcalPropKind::ExDate),
1163                        params: vec![IcalParam::TzId(name.into())],
1164                        value: IcalValue::DateTime(format!("{day}{time}").into()),
1165                    }
1166                }
1167            }
1168        })
1169        .collect()
1170}
1171
1172/// A parsed DTSTART or DTEND, ready for Graph.
1173struct Boundary {
1174    /// The wall time or the midnight of the date.
1175    local: DateTime,
1176    /// Whether the boundary is a date.
1177    all_day: bool,
1178    /// The zone, `UTC` for a `Z` time and for a date.
1179    zone: String,
1180}
1181
1182impl Boundary {
1183    /// The boundary shifted by a DURATION.
1184    fn shifted(&self, duration: Span) -> Option<Self> {
1185        Some(Self {
1186            local: self.local.checked_add(duration).ok()?,
1187            all_day: self.all_day,
1188            zone: self.zone.clone(),
1189        })
1190    }
1191
1192    /// The Graph form of the boundary.
1193    fn into_graph(self) -> MsgraphDateTimeTimeZone {
1194        MsgraphDateTimeTimeZone {
1195            date_time: self.local.strftime("%Y-%m-%dT%H:%M:%S").to_string(),
1196            time_zone: Some(self.zone),
1197        }
1198    }
1199}
1200
1201/// Reads a DTSTART or DTEND, `None` for a floating time, which has no
1202/// instant Graph could store.
1203fn boundary(line: &IcalLine<'_>) -> Option<Boundary> {
1204    let value = line.raw_value_str();
1205    let value = value.trim();
1206
1207    let is_date = line
1208        .param::<VALUE>()
1209        .is_some_and(|kind| kind.eq_ignore_ascii_case("DATE"))
1210        || (value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_digit()));
1211
1212    if is_date {
1213        let date = Date::strptime("%Y%m%d", value).ok()?;
1214        return Some(Boundary {
1215            local: date.at(0, 0, 0, 0),
1216            all_day: true,
1217            zone: String::from("UTC"),
1218        });
1219    }
1220
1221    let (local, zone) = match value.strip_suffix('Z') {
1222        Some(local) => (local, String::from("UTC")),
1223        None => (value, line.param::<TZID>()?.into_owned()),
1224    };
1225
1226    Some(Boundary {
1227        local: DateTime::strptime("%Y%m%dT%H%M%S", local).ok()?,
1228        all_day: false,
1229        zone,
1230    })
1231}
1232
1233/// The Graph recurrence of an RRULE, refused when Graph has no pattern for
1234/// it rather than approximated.
1235fn recurrence(
1236    rule: &str,
1237    start: &Boundary,
1238) -> Result<MsgraphPatternedRecurrence, MsgraphEventIcalError> {
1239    let unsupported = || MsgraphEventIcalError::UnsupportedRecurrence(rule.to_owned());
1240
1241    let mut freq = None;
1242    let mut interval = None;
1243    let mut count = None;
1244    let mut until_value = None;
1245    let mut by_day = Vec::new();
1246    let mut by_month_day = None;
1247    let mut by_month = None;
1248    let mut by_setpos = None;
1249    let mut wkst = None;
1250
1251    for part in rule.split(';').filter(|part| !part.is_empty()) {
1252        let (key, value) = part.split_once('=').ok_or_else(unsupported)?;
1253        match key.to_ascii_uppercase().as_str() {
1254            "FREQ" => freq = Some(value.to_ascii_uppercase()),
1255            "INTERVAL" => interval = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1256            "COUNT" => count = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1257            "UNTIL" => until_value = Some(value.to_owned()),
1258            "BYDAY" => {
1259                for day in value.split(',') {
1260                    let (ordinal, name) = day.split_at(day.len().saturating_sub(2));
1261                    if !ordinal.is_empty() {
1262                        by_setpos = Some(ordinal.parse::<i32>().map_err(|_| unsupported())?);
1263                    }
1264                    by_day.push(weekday_from_ical(name).ok_or_else(unsupported)?);
1265                }
1266            }
1267            "BYMONTHDAY" => by_month_day = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1268            "BYMONTH" => by_month = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1269            "BYSETPOS" => by_setpos = Some(value.parse::<i32>().map_err(|_| unsupported())?),
1270            "WKST" => wkst = Some(weekday_from_ical(value).ok_or_else(unsupported)?),
1271            _ => return Err(unsupported()),
1272        }
1273    }
1274
1275    let index = match by_setpos {
1276        None => None,
1277        Some(1) => Some(MsgraphWeekIndex::First),
1278        Some(2) => Some(MsgraphWeekIndex::Second),
1279        Some(3) => Some(MsgraphWeekIndex::Third),
1280        Some(4) => Some(MsgraphWeekIndex::Fourth),
1281        Some(-1) => Some(MsgraphWeekIndex::Last),
1282        Some(_) => return Err(unsupported()),
1283    };
1284
1285    let pattern_type = match (freq.as_deref(), by_day.is_empty(), index) {
1286        (Some("DAILY"), true, None) => MsgraphRecurrencePatternType::Daily,
1287        (Some("WEEKLY"), _, None) => MsgraphRecurrencePatternType::Weekly,
1288        (Some("MONTHLY"), true, None) => MsgraphRecurrencePatternType::AbsoluteMonthly,
1289        (Some("MONTHLY"), false, Some(_)) => MsgraphRecurrencePatternType::RelativeMonthly,
1290        (Some("YEARLY"), true, None) => MsgraphRecurrencePatternType::AbsoluteYearly,
1291        (Some("YEARLY"), false, Some(_)) => MsgraphRecurrencePatternType::RelativeYearly,
1292        _ => return Err(unsupported()),
1293    };
1294
1295    let date = start.local.date();
1296    let mut days_of_week = by_day;
1297    if pattern_type == MsgraphRecurrencePatternType::Weekly && days_of_week.is_empty() {
1298        days_of_week.push(weekday_of(date));
1299    }
1300
1301    let (day_of_month, month) = match pattern_type {
1302        MsgraphRecurrencePatternType::AbsoluteMonthly => {
1303            (Some(by_month_day.unwrap_or(date.day() as u32)), None)
1304        }
1305        MsgraphRecurrencePatternType::AbsoluteYearly => (
1306            Some(by_month_day.unwrap_or(date.day() as u32)),
1307            Some(by_month.unwrap_or(date.month() as u32)),
1308        ),
1309        MsgraphRecurrencePatternType::RelativeYearly => {
1310            (None, Some(by_month.unwrap_or(date.month() as u32)))
1311        }
1312        _ => (None, None),
1313    };
1314
1315    let zone_name = (!start.all_day).then(|| start.zone.clone());
1316    let range = match (count, until_value) {
1317        (Some(count), _) => MsgraphRecurrenceRange {
1318            range_type: Some(MsgraphRecurrenceRangeType::Numbered),
1319            number_of_occurrences: Some(count),
1320            ..Default::default()
1321        },
1322        (None, Some(until)) => {
1323            let day = until.get(..8).ok_or_else(unsupported)?;
1324            let end = Date::strptime("%Y%m%d", day).map_err(|_| unsupported())?;
1325            MsgraphRecurrenceRange {
1326                range_type: Some(MsgraphRecurrenceRangeType::EndDate),
1327                end_date: Some(end.strftime("%Y-%m-%d").to_string()),
1328                ..Default::default()
1329            }
1330        }
1331        (None, None) => MsgraphRecurrenceRange {
1332            range_type: Some(MsgraphRecurrenceRangeType::NoEnd),
1333            ..Default::default()
1334        },
1335    };
1336
1337    Ok(MsgraphPatternedRecurrence {
1338        pattern: MsgraphRecurrencePattern {
1339            pattern_type: Some(pattern_type),
1340            interval: Some(interval.unwrap_or(1)),
1341            month,
1342            day_of_month,
1343            days_of_week,
1344            first_day_of_week: wkst,
1345            index,
1346        },
1347        range: MsgraphRecurrenceRange {
1348            start_date: Some(date.strftime("%Y-%m-%d").to_string()),
1349            recurrence_time_zone: zone_name,
1350            ..range
1351        },
1352    })
1353}
1354
1355/// The first VEVENT of a document that `pick` accepts: the master is the
1356/// one without a RECURRENCE-ID.
1357fn find_vevent<'a>(
1358    calendar: &'a IcalCst<'a>,
1359    pick: impl Fn(&IcalCst<'_>) -> bool,
1360) -> Result<&'a IcalCst<'a>, MsgraphEventIcalError> {
1361    let components = || {
1362        calendar.items.iter().filter_map(|item| match item {
1363            IcalItem::Component(component) => Some(component.as_ref()),
1364            _ => None,
1365        })
1366    };
1367
1368    if let Some(vevent) = components()
1369        .filter(|component| component_name(component) == "VEVENT")
1370        .find(|vevent| pick(vevent))
1371    {
1372        return Ok(vevent);
1373    }
1374
1375    match components()
1376        .map(component_name)
1377        .find(|name| name != "VTIMEZONE" && name != "VEVENT")
1378    {
1379        Some(name) => Err(MsgraphEventIcalError::NotAnEvent(name)),
1380        None => Err(MsgraphEventIcalError::NoEvent),
1381    }
1382}
1383
1384/// The upper-cased name a component's BEGIN line gives it.
1385fn component_name(component: &IcalCst<'_>) -> String {
1386    component
1387        .begin
1388        .as_ref()
1389        .map(|begin| begin.raw_value_str().trim().to_uppercase())
1390        .unwrap_or_default()
1391}
1392
1393/// Whether a component carries a property of that name.
1394fn has_prop(component: &IcalCst<'_>, name: &str) -> bool {
1395    component.items.iter().any(|item| match item {
1396        IcalItem::Prop(line) => line.name.get().eq_ignore_ascii_case(name),
1397        _ => false,
1398    })
1399}
1400
1401/// The lead time of a DISPLAY or AUDIO alarm triggering before the start,
1402/// in minutes, `None` for any alarm Graph's single reminder cannot hold.
1403fn reminder_minutes(alarm: &IcalCst<'_>) -> Option<u32> {
1404    if component_name(alarm) != "VALARM" {
1405        return None;
1406    }
1407    let action = alarm.prop::<ACTION>()?;
1408    if !matches!(action.0.trim().to_uppercase().as_str(), "DISPLAY" | "AUDIO") {
1409        return None;
1410    }
1411
1412    let trigger = alarm.prop::<TRIGGER>()?;
1413    let lead = trigger.0.trim().strip_prefix('-')?.parse::<Span>().ok()?;
1414    let minutes = lead.total(jiff::Unit::Minute).ok()?;
1415
1416    let whole = minutes as u32;
1417    (minutes >= 0.0 && f64::from(whole) == minutes).then_some(whole)
1418}
1419
1420/// An ORGANIZER or ATTENDEE value and its CN.
1421fn person_prop(
1422    kind: IcalPropKind,
1423    address: &MsgraphEmailAddress,
1424    mut params: Vec<IcalParam<'static>>,
1425) -> IcalProp<'static> {
1426    if let Some(name) = address.name.clone().filter(|name| !name.is_empty()) {
1427        params.insert(0, IcalParam::Cn(name.into()));
1428    }
1429    let email = address.address.clone().unwrap_or_default();
1430
1431    IcalProp {
1432        name: IcalPropName::Kind(kind),
1433        params,
1434        value: IcalValue::CalAddress(format!("mailto:{email}").into()),
1435    }
1436}
1437
1438/// An ATTENDEE from a Graph attendee, `None` without an address.
1439fn attendee_prop(attendee: &MsgraphAttendee) -> Option<IcalProp<'static>> {
1440    let address = attendee.email_address.as_ref()?;
1441
1442    let mut params = Vec::new();
1443    match attendee.attendee_type {
1444        Some(MsgraphAttendeeType::Optional) => {
1445            params.push(IcalParam::Role("OPT-PARTICIPANT".into()))
1446        }
1447        Some(MsgraphAttendeeType::Resource) => {
1448            params.push(IcalParam::Role("NON-PARTICIPANT".into()));
1449            params.push(IcalParam::CuType("RESOURCE".into()));
1450        }
1451        _ => params.push(IcalParam::Role("REQ-PARTICIPANT".into())),
1452    }
1453
1454    let partstat = match attendee.status.as_ref().and_then(|status| status.response) {
1455        Some(MsgraphResponseType::Accepted | MsgraphResponseType::Organizer) => "ACCEPTED",
1456        Some(MsgraphResponseType::Declined) => "DECLINED",
1457        Some(MsgraphResponseType::TentativelyAccepted) => "TENTATIVE",
1458        _ => "NEEDS-ACTION",
1459    };
1460    params.push(IcalParam::PartStat(partstat.into()));
1461
1462    Some(person_prop(IcalPropKind::Attendee, address, params))
1463}
1464
1465/// A Graph attendee from an ATTENDEE.
1466fn attendee(line: &IcalLine<'_>) -> MsgraphAttendee {
1467    let role = line.param::<ROLE>().unwrap_or_default();
1468    let cutype = line.param::<CUTYPE>().unwrap_or_default();
1469
1470    let attendee_type =
1471        if cutype.eq_ignore_ascii_case("RESOURCE") || cutype.eq_ignore_ascii_case("ROOM") {
1472            MsgraphAttendeeType::Resource
1473        } else if role.eq_ignore_ascii_case("OPT-PARTICIPANT") {
1474            MsgraphAttendeeType::Optional
1475        } else {
1476            MsgraphAttendeeType::Required
1477        };
1478
1479    let response =
1480        line.param::<PARTSTAT>()
1481            .and_then(|status| match status.to_ascii_uppercase().as_str() {
1482                "ACCEPTED" => Some(MsgraphResponseType::Accepted),
1483                "DECLINED" => Some(MsgraphResponseType::Declined),
1484                "TENTATIVE" => Some(MsgraphResponseType::TentativelyAccepted),
1485                "NEEDS-ACTION" => Some(MsgraphResponseType::NotResponded),
1486                _ => None,
1487            });
1488
1489    let value = line.raw_value_str();
1490    let value = value.trim();
1491    let address = value
1492        .strip_prefix("mailto:")
1493        .or_else(|| value.strip_prefix("MAILTO:"))
1494        .unwrap_or(value)
1495        .to_owned();
1496
1497    MsgraphAttendee {
1498        attendee_type: Some(attendee_type),
1499        status: response.map(|response| MsgraphResponseStatus {
1500            response: Some(response),
1501            time: None,
1502        }),
1503        email_address: Some(MsgraphEmailAddress {
1504            name: line.param::<CN>().map(Cow::into_owned),
1505            address: Some(address),
1506        }),
1507    }
1508}
1509
1510/// The time-zone anchor of a series, its start date in Unix seconds.
1511fn anchor(event: &MsgraphEvent) -> i64 {
1512    event
1513        .start
1514        .as_option()
1515        .and_then(|start| start.date_time.get(..10))
1516        .and_then(|date| date.parse::<Date>().ok())
1517        .and_then(|date| date.to_zoned(TimeZone::UTC).ok())
1518        .map_or(0, |zoned| zoned.timestamp().as_second())
1519}
1520
1521/// A Graph date-time, `YYYY-MM-DDTHH:MM:SS[.fffffff]`, as iCalendar digits
1522/// `YYYYMMDDTHHMMSS`.
1523fn ical_digits(date_time: &str) -> String {
1524    let base = date_time.split('.').next().unwrap_or(date_time);
1525    base.chars()
1526        .filter(|character| *character != '-' && *character != ':')
1527        .take(15)
1528        .collect()
1529}
1530
1531/// An RFC 3339 instant as an iCalendar UTC stamp.
1532fn utc_stamp(instant: &str) -> Option<String> {
1533    let instant = instant.parse::<Timestamp>().ok()?;
1534    Some(instant.strftime("%Y%m%dT%H%M%SZ").to_string())
1535}
1536
1537/// A UTC date-time property.
1538fn stamp_prop(kind: IcalPropKind, stamp: String) -> IcalProp<'static> {
1539    IcalProp {
1540        name: IcalPropName::Kind(kind),
1541        params: Vec::new(),
1542        value: IcalValue::DateTime(IcalDateTime(stamp.into())),
1543    }
1544}
1545
1546/// The text a body says, whatever its content type: Exchange turns every
1547/// body into HTML, so the type alone never tells an edit.
1548fn body_text(body: &MsgraphField<MsgraphItemBody>) -> String {
1549    let Some(body) = body.as_option() else {
1550        return String::new();
1551    };
1552    let content = body.content.as_deref().unwrap_or_default();
1553    match body.content_type {
1554        Some(MsgraphBodyType::Html) => strip_html(content),
1555        _ => content.to_owned(),
1556    }
1557}
1558
1559/// The text of an HTML body without its markup, for DESCRIPTION.
1560fn strip_html(html: &str) -> String {
1561    let mut text = String::with_capacity(html.len());
1562    let mut in_tag = false;
1563    for character in html.chars() {
1564        match character {
1565            '<' => in_tag = true,
1566            '>' => in_tag = false,
1567            _ if !in_tag => text.push(character),
1568            _ => {}
1569        }
1570    }
1571    text.replace("&nbsp;", " ")
1572        .replace("&amp;", "&")
1573        .replace("&lt;", "<")
1574        .replace("&gt;", ">")
1575        .trim()
1576        .to_owned()
1577}
1578
1579/// Whether two texts say the same once their whitespace is collapsed, so
1580/// line endings and indentation the HTML source carried do not count.
1581fn same_text(left: &str, right: &str) -> bool {
1582    left.split_whitespace().eq(right.split_whitespace())
1583}
1584
1585/// The stashed lines behind the event's extended property, matched by name.
1586fn stash_lines(event: &MsgraphEvent) -> Vec<String> {
1587    event
1588        .single_value_extended_properties
1589        .as_option()
1590        .map(Vec::as_slice)
1591        .unwrap_or(&[])
1592        .iter()
1593        .filter(|prop| prop.id.to_ascii_lowercase().contains("pimalaya-ical"))
1594        .flat_map(|prop| prop.value.split('\n'))
1595        .filter(|line| !line.is_empty())
1596        .map(str::to_owned)
1597        .collect()
1598}
1599
1600/// Whether a stashed line is the UID, written by [`vevent`] itself.
1601fn is_uid_line(line: &str) -> bool {
1602    let name = line.split([';', ':']).next().unwrap_or_default();
1603    name.eq_ignore_ascii_case("UID")
1604}
1605
1606/// Whether a stashed line is a property minted from Graph-scoped fields.
1607fn is_minted_line(line: &str) -> bool {
1608    let name = line.split([';', ':']).next().unwrap_or_default();
1609    MINTED_PROPS
1610        .iter()
1611        .any(|minted| name.eq_ignore_ascii_case(minted))
1612}
1613
1614/// The decoded text of a property line.
1615fn text(line: &IcalLine<'_>) -> String {
1616    IcalText::decode(&line.value).0.into_owned()
1617}
1618
1619/// One logical line, its ending stripped, for the stash.
1620fn raw_line(line: &IcalLine<'_>) -> String {
1621    line.to_string().trim_end_matches(['\r', '\n']).to_owned()
1622}
1623
1624/// A whole component as its raw lines, endings stripped.
1625fn raw_component(component: &IcalCst<'_>) -> Vec<String> {
1626    component
1627        .to_string()
1628        .lines()
1629        .map(|line| line.trim_end_matches('\r').to_owned())
1630        .filter(|line| !line.is_empty())
1631        .collect()
1632}
1633
1634/// The BYSETPOS of a relative pattern's week index.
1635fn setpos(index: Option<MsgraphWeekIndex>) -> i32 {
1636    match index {
1637        Some(MsgraphWeekIndex::Second) => 2,
1638        Some(MsgraphWeekIndex::Third) => 3,
1639        Some(MsgraphWeekIndex::Fourth) => 4,
1640        Some(MsgraphWeekIndex::Last) => -1,
1641        _ => 1,
1642    }
1643}
1644
1645/// The iCalendar weekday of a Graph day.
1646fn weekday_to_ical(day: MsgraphDayOfWeek) -> &'static str {
1647    match day {
1648        MsgraphDayOfWeek::Sunday => "SU",
1649        MsgraphDayOfWeek::Monday => "MO",
1650        MsgraphDayOfWeek::Tuesday => "TU",
1651        MsgraphDayOfWeek::Wednesday => "WE",
1652        MsgraphDayOfWeek::Thursday => "TH",
1653        MsgraphDayOfWeek::Friday => "FR",
1654        MsgraphDayOfWeek::Saturday => "SA",
1655    }
1656}
1657
1658/// The Graph day of an iCalendar weekday.
1659fn weekday_from_ical(day: &str) -> Option<MsgraphDayOfWeek> {
1660    Some(match day.to_ascii_uppercase().as_str() {
1661        "SU" => MsgraphDayOfWeek::Sunday,
1662        "MO" => MsgraphDayOfWeek::Monday,
1663        "TU" => MsgraphDayOfWeek::Tuesday,
1664        "WE" => MsgraphDayOfWeek::Wednesday,
1665        "TH" => MsgraphDayOfWeek::Thursday,
1666        "FR" => MsgraphDayOfWeek::Friday,
1667        "SA" => MsgraphDayOfWeek::Saturday,
1668        _ => return None,
1669    })
1670}
1671
1672/// The Graph day a date falls on.
1673fn weekday_of(date: Date) -> MsgraphDayOfWeek {
1674    use jiff::civil::Weekday;
1675    match date.weekday() {
1676        Weekday::Sunday => MsgraphDayOfWeek::Sunday,
1677        Weekday::Monday => MsgraphDayOfWeek::Monday,
1678        Weekday::Tuesday => MsgraphDayOfWeek::Tuesday,
1679        Weekday::Wednesday => MsgraphDayOfWeek::Wednesday,
1680        Weekday::Thursday => MsgraphDayOfWeek::Thursday,
1681        Weekday::Friday => MsgraphDayOfWeek::Friday,
1682        Weekday::Saturday => MsgraphDayOfWeek::Saturday,
1683    }
1684}
1685
1686/// The `X-MICROSOFT-CDO-BUSYSTATUS` value of a `showAs`.
1687fn busy_status_to_ical(show_as: MsgraphFreeBusyStatus) -> &'static str {
1688    match show_as {
1689        MsgraphFreeBusyStatus::Free => "FREE",
1690        MsgraphFreeBusyStatus::Tentative => "TENTATIVE",
1691        MsgraphFreeBusyStatus::Busy => "BUSY",
1692        MsgraphFreeBusyStatus::Oof => "OOF",
1693        MsgraphFreeBusyStatus::WorkingElsewhere => "WORKINGELSEWHERE",
1694        MsgraphFreeBusyStatus::Unknown => "UNKNOWN",
1695    }
1696}
1697
1698/// The `showAs` of an `X-MICROSOFT-CDO-BUSYSTATUS` value.
1699fn busy_status_from_ical(value: &str) -> Option<MsgraphFreeBusyStatus> {
1700    Some(match value.trim().to_ascii_uppercase().as_str() {
1701        "FREE" => MsgraphFreeBusyStatus::Free,
1702        "TENTATIVE" => MsgraphFreeBusyStatus::Tentative,
1703        "BUSY" => MsgraphFreeBusyStatus::Busy,
1704        "OOF" => MsgraphFreeBusyStatus::Oof,
1705        "WORKINGELSEWHERE" => MsgraphFreeBusyStatus::WorkingElsewhere,
1706        _ => return None,
1707    })
1708}
1709
1710/// The CLASS of a sensitivity; `personal` has none and reads as private.
1711fn sensitivity_to_ical(sensitivity: MsgraphSensitivity) -> &'static str {
1712    match sensitivity {
1713        MsgraphSensitivity::Normal => "PUBLIC",
1714        MsgraphSensitivity::Personal | MsgraphSensitivity::Private => "PRIVATE",
1715        MsgraphSensitivity::Confidential => "CONFIDENTIAL",
1716    }
1717}
1718
1719/// The sensitivity of a CLASS.
1720fn sensitivity_from_ical(value: &str) -> Option<MsgraphSensitivity> {
1721    Some(match value.trim().to_ascii_uppercase().as_str() {
1722        "PUBLIC" => MsgraphSensitivity::Normal,
1723        "PRIVATE" => MsgraphSensitivity::Private,
1724        "CONFIDENTIAL" => MsgraphSensitivity::Confidential,
1725        _ => return None,
1726    })
1727}
1728
1729/// The importance of a PRIORITY: 1 to 4 high, 5 normal, 6 to 9 low, 0 none.
1730fn importance_from_ical(value: &str) -> Option<MsgraphImportance> {
1731    match value.trim().parse::<u8>().ok()? {
1732        1..=4 => Some(MsgraphImportance::High),
1733        5 => Some(MsgraphImportance::Normal),
1734        6..=9 => Some(MsgraphImportance::Low),
1735        _ => None,
1736    }
1737}
1738
1739/// The class id that opens every Exchange global object id
1740/// ([MS-OXOCAL] §2.2.1.27, `PidLidGlobalObjectId`), hex-encoded.
1741const GLOBAL_OBJECT_ID_CLASS: &str = "040000008200E00074C5B7101A82E008";
1742
1743/// The marker of a global object id that wraps an iCalendar UID Exchange
1744/// did not mint ([MS-OXCICAL] §2.1.3.1.1.20.26).
1745const VCAL_UID_MARKER: &[u8] = b"vCal-Uid\x01\x00\x00\x00";
1746
1747/// The iCalendar UID of a Graph `iCalUId`.
1748///
1749/// An invitation Exchange took in by mail is filed under its global object
1750/// id, which wraps the organizer's UID (the `vCal-Uid` form) rather than
1751/// keeping it: the wrapped UID is returned, so the event keeps the UID the
1752/// organizer and every other attendee know it by. A global object id
1753/// Exchange minted itself, with no UID inside, and any other value come
1754/// back as they are.
1755pub fn original_uid(ical_uid: &str) -> String {
1756    unwrap_global_object_id(ical_uid).unwrap_or_else(|| ical_uid.to_owned())
1757}
1758
1759fn unwrap_global_object_id(ical_uid: &str) -> Option<String> {
1760    let class = ical_uid.get(..GLOBAL_OBJECT_ID_CLASS.len())?;
1761    if !class.eq_ignore_ascii_case(GLOBAL_OBJECT_ID_CLASS) {
1762        return None;
1763    }
1764    let rest = &ical_uid.as_bytes()[GLOBAL_OBJECT_ID_CLASS.len()..];
1765    if !rest.len().is_multiple_of(2) {
1766        return None;
1767    }
1768    let bytes = rest
1769        .chunks(2)
1770        .map(|pair| u8::from_str_radix(core::str::from_utf8(pair).ok()?, 16).ok())
1771        .collect::<Option<Vec<u8>>>()?;
1772    // After the class id: the instance date (4 bytes), the creation time
1773    // (8), reserved (8), the size of the data (4), then the data.
1774    let size = u32::from_le_bytes(bytes.get(20..24)?.try_into().ok()?) as usize;
1775    let data = bytes.get(24..24usize.checked_add(size)?)?;
1776    let uid = data.strip_prefix(VCAL_UID_MARKER)?;
1777    let end = uid.iter().position(|byte| *byte == 0).unwrap_or(uid.len());
1778    String::from_utf8(uid[..end].to_vec())
1779        .ok()
1780        .filter(|uid| !uid.is_empty())
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785    use super::*;
1786    use crate::v1::rest::users::messages::MsgraphRecipient;
1787
1788    /// A weekly series in a Windows-named zone, as Outlook creates one.
1789    fn master() -> MsgraphEvent {
1790        MsgraphEvent {
1791            id: "SM1".into(),
1792            ical_uid: Some("040000008200E0".into()),
1793            last_modified_date_time: Some("2026-08-01T10:00:00Z".into()),
1794            subject: MsgraphField::Set("Stand-up".into()),
1795            start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1796                date_time: "2026-08-14T09:00:00.0000000".into(),
1797                time_zone: Some("Romance Standard Time".into()),
1798            }),
1799            end: MsgraphField::Set(MsgraphDateTimeTimeZone {
1800                date_time: "2026-08-14T09:15:00.0000000".into(),
1801                time_zone: Some("Romance Standard Time".into()),
1802            }),
1803            is_all_day: MsgraphField::Set(false),
1804            recurrence: MsgraphField::Set(MsgraphPatternedRecurrence {
1805                pattern: MsgraphRecurrencePattern {
1806                    pattern_type: Some(MsgraphRecurrencePatternType::Weekly),
1807                    interval: Some(1),
1808                    days_of_week: vec![MsgraphDayOfWeek::Monday, MsgraphDayOfWeek::Friday],
1809                    first_day_of_week: Some(MsgraphDayOfWeek::Monday),
1810                    ..Default::default()
1811                },
1812                range: MsgraphRecurrenceRange {
1813                    range_type: Some(MsgraphRecurrenceRangeType::Numbered),
1814                    start_date: Some("2026-08-14".into()),
1815                    number_of_occurrences: Some(10),
1816                    ..Default::default()
1817                },
1818            }),
1819            organizer: MsgraphField::Set(MsgraphRecipient {
1820                email_address: MsgraphEmailAddress {
1821                    name: Some("Alice".into()),
1822                    address: Some("alice@x.org".into()),
1823                },
1824            }),
1825            attendees: MsgraphField::Set(vec![MsgraphAttendee {
1826                attendee_type: Some(MsgraphAttendeeType::Optional),
1827                status: Some(MsgraphResponseStatus {
1828                    response: Some(MsgraphResponseType::Accepted),
1829                    time: None,
1830                }),
1831                email_address: Some(MsgraphEmailAddress {
1832                    name: Some("Bob".into()),
1833                    address: Some("bob@x.org".into()),
1834                }),
1835            }]),
1836            show_as: MsgraphField::Set(MsgraphFreeBusyStatus::Tentative),
1837            is_reminder_on: MsgraphField::Set(true),
1838            reminder_minutes_before_start: MsgraphField::Set(15),
1839            cancelled_occurrences: vec!["OID.SM1.2026-08-21".into()],
1840            ..Default::default()
1841        }
1842    }
1843
1844    #[test]
1845    fn a_windows_zoned_series_projects_with_its_rule_and_zone() {
1846        let ical = master().to_ical();
1847
1848        assert!(ical.contains("UID:040000008200E0\r\n"), "{ical}");
1849        assert!(
1850            ical.contains("BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\n"),
1851            "{ical}"
1852        );
1853        assert!(
1854            ical.contains("DTSTART;TZID=Europe/Paris:20260814T090000\r\n"),
1855            "{ical}"
1856        );
1857        assert!(
1858            ical.contains("DTEND;TZID=Europe/Paris:20260814T091500\r\n"),
1859            "{ical}"
1860        );
1861        assert!(
1862            ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO,FR;WKST=MO;COUNT=10\r\n"),
1863            "{ical}"
1864        );
1865        assert!(
1866            ical.contains("EXDATE;TZID=Europe/Paris:20260821T090000\r\n"),
1867            "{ical}"
1868        );
1869        assert!(
1870            ical.contains("ORGANIZER;CN=Alice:mailto:alice@x.org\r\n"),
1871            "{ical}"
1872        );
1873        assert!(
1874            ical.contains(
1875                "ATTENDEE;CN=Bob;ROLE=OPT-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:bob@x.org\r\n"
1876            ),
1877            "{ical}"
1878        );
1879        assert!(
1880            ical.contains("X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE\r\n"),
1881            "{ical}"
1882        );
1883        assert!(
1884            ical.contains("BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\n"),
1885            "{ical}"
1886        );
1887    }
1888
1889    #[test]
1890    fn an_exception_carries_the_series_uid_and_its_original_start() {
1891        let exception = MsgraphEvent {
1892            id: "EX1".into(),
1893            ical_uid: Some("other".into()),
1894            event_type: Some(crate::v1::rest::users::events::MsgraphEventType::Exception),
1895            series_master_id: Some("SM1".into()),
1896            // NOTE: 07:00 UTC is 09:00 in Paris in summer.
1897            original_start: Some("2026-08-17T07:00:00Z".into()),
1898            subject: MsgraphField::Set("Stand-up, moved".into()),
1899            start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1900                date_time: "2026-08-17T10:00:00.0000000".into(),
1901                time_zone: Some("Romance Standard Time".into()),
1902            }),
1903            ..Default::default()
1904        };
1905
1906        let ical = master().to_ical_series(&[&exception]);
1907
1908        assert_eq!(ical.matches("BEGIN:VEVENT").count(), 2, "{ical}");
1909        assert_eq!(ical.matches("UID:040000008200E0\r\n").count(), 2, "{ical}");
1910        assert!(
1911            ical.contains("RECURRENCE-ID;TZID=Europe/Paris:20260817T090000\r\n"),
1912            "{ical}"
1913        );
1914        assert_eq!(ical.matches("RRULE:FREQ=WEEKLY").count(), 1, "{ical}");
1915    }
1916
1917    /// A Google invitation as Microsoft 365 filed it, seen live: its global
1918    /// object id wraps the organizer's UID.
1919    const WRAPPED: &str = "040000008200E00074C5B7101A82E00800000000000000000000000000000000000000002D0000007643616C2D556964010000006D6F612D393839663030616435646236393839663030616435646236406D6F6100";
1920
1921    #[test]
1922    fn a_global_object_id_gives_back_the_uid_it_wraps() {
1923        let uid = "moa-989f00ad5db6989f00ad5db6@moa";
1924        assert_eq!(original_uid(WRAPPED), uid);
1925        assert_eq!(original_uid(&WRAPPED.to_lowercase()), uid);
1926
1927        // NOTE: Exchange's own id, nothing wrapped inside.
1928        let minted = "040000008200E00074C5B7101A82E0080000000088DCD2137A53DD01000000000000000010000000F70EDDABBC4DD5439803DF5489114F0B";
1929        assert_eq!(original_uid(minted), minted);
1930        assert_eq!(original_uid("040000008200E0"), "040000008200E0");
1931        assert_eq!(original_uid("urn:uuid:4fbe8971"), "urn:uuid:4fbe8971");
1932        // NOTE: a size running past the end is not trusted.
1933        assert_eq!(
1934            original_uid(&WRAPPED[..WRAPPED.len() - 4]),
1935            &WRAPPED[..WRAPPED.len() - 4]
1936        );
1937    }
1938
1939    #[test]
1940    fn an_invitation_taken_in_by_mail_keeps_the_organizer_uid() {
1941        let mut event = master();
1942        event.ical_uid = Some(WRAPPED.into());
1943        let exception = MsgraphEvent {
1944            id: "EX1".into(),
1945            ical_uid: Some(WRAPPED.into()),
1946            event_type: Some(crate::v1::rest::users::events::MsgraphEventType::Exception),
1947            series_master_id: Some("SM1".into()),
1948            original_start: Some("2026-08-17T07:00:00Z".into()),
1949            ..Default::default()
1950        };
1951
1952        let ical = event.to_ical_series(&[&exception]);
1953
1954        assert_eq!(
1955            ical.matches("UID:moa-989f00ad5db6989f00ad5db6@moa\r\n")
1956                .count(),
1957            2,
1958            "{ical}"
1959        );
1960    }
1961
1962    #[test]
1963    fn an_occurrence_is_told_by_the_recurrence_id_the_projection_writes() {
1964        // NOTE: 07:00 UTC is 09:00 in Paris in summer.
1965        assert_eq!(
1966            master().recurrence_id_of("2026-08-17T07:00:00Z").as_deref(),
1967            Some("20260817T090000")
1968        );
1969        assert_eq!(
1970            master()
1971                .recurrence_id_of("2026-08-17T07:00:00.0000000Z")
1972                .as_deref(),
1973            Some("20260817T090000")
1974        );
1975
1976        let mut all_day = master();
1977        all_day.is_all_day = MsgraphField::Set(true);
1978        assert_eq!(
1979            all_day.recurrence_id_of("2026-08-17T00:00:00Z").as_deref(),
1980            Some("20260817")
1981        );
1982
1983        assert_eq!(master().recurrence_id_of("not a time"), None);
1984    }
1985
1986    #[test]
1987    fn an_all_day_event_projects_as_dates_and_back() {
1988        let event = MsgraphEvent {
1989            id: "AD1".into(),
1990            subject: MsgraphField::Set("Holiday".into()),
1991            is_all_day: MsgraphField::Set(true),
1992            start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1993                date_time: "2026-08-14T00:00:00.0000000".into(),
1994                time_zone: Some("UTC".into()),
1995            }),
1996            end: MsgraphField::Set(MsgraphDateTimeTimeZone {
1997                date_time: "2026-08-15T00:00:00.0000000".into(),
1998                time_zone: Some("UTC".into()),
1999            }),
2000            ..Default::default()
2001        };
2002
2003        let ical = event.to_ical();
2004        assert!(ical.contains("DTSTART;VALUE=DATE:20260814\r\n"), "{ical}");
2005        assert!(ical.contains("DTEND;VALUE=DATE:20260815\r\n"), "{ical}");
2006
2007        let back = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
2008        assert_eq!(back.is_all_day.as_option(), Some(&true));
2009        assert_eq!(
2010            back.start.as_option().map(|start| start.date_time.as_str()),
2011            Some("2026-08-14T00:00:00")
2012        );
2013    }
2014
2015    #[test]
2016    fn a_series_reads_back_with_its_recurrence() {
2017        let event = MsgraphEvent::from_ical(master().to_ical().as_bytes()).unwrap();
2018
2019        assert_eq!(
2020            event.subject.as_option().map(String::as_str),
2021            Some("Stand-up")
2022        );
2023        let start = event.start.as_option().unwrap();
2024        assert_eq!(start.date_time, "2026-08-14T09:00:00");
2025        assert_eq!(start.time_zone.as_deref(), Some("Europe/Paris"));
2026
2027        let recurrence = event.recurrence.as_option().unwrap();
2028        assert_eq!(
2029            recurrence.pattern.pattern_type,
2030            Some(MsgraphRecurrencePatternType::Weekly)
2031        );
2032        assert_eq!(
2033            recurrence.pattern.days_of_week,
2034            [MsgraphDayOfWeek::Monday, MsgraphDayOfWeek::Friday]
2035        );
2036        assert_eq!(recurrence.range.number_of_occurrences, Some(10));
2037        assert_eq!(recurrence.range.start_date.as_deref(), Some("2026-08-14"));
2038        assert_eq!(
2039            event.show_as.as_option(),
2040            Some(&MsgraphFreeBusyStatus::Tentative)
2041        );
2042        assert_eq!(event.reminder_minutes_before_start.as_option(), Some(&15));
2043        assert_eq!(event.attendees.as_option().map(Vec::len), Some(1));
2044        // NOTE: minted from the cancelled occurrence, not stashed.
2045        assert!(
2046            !stash_lines(&event)
2047                .iter()
2048                .any(|line| line.starts_with("EXDATE")),
2049            "{:?}",
2050            stash_lines(&event)
2051        );
2052    }
2053
2054    #[test]
2055    fn a_relative_monthly_rule_round_trips_through_its_index() {
2056        let ical = concat!(
2057            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n",
2058            "DTSTART:20260827T120000Z\r\nDTEND:20260827T130000Z\r\n",
2059            "RRULE:FREQ=MONTHLY;BYDAY=TH;BYSETPOS=-1\r\n",
2060            "END:VEVENT\r\nEND:VCALENDAR\r\n",
2061        );
2062
2063        let event = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
2064        let recurrence = event.recurrence.as_option().unwrap();
2065        assert_eq!(
2066            recurrence.pattern.pattern_type,
2067            Some(MsgraphRecurrencePatternType::RelativeMonthly)
2068        );
2069        assert_eq!(recurrence.pattern.index, Some(MsgraphWeekIndex::Last));
2070
2071        let back = event.to_ical();
2072        assert!(
2073            back.contains("RRULE:FREQ=MONTHLY;BYDAY=TH;BYSETPOS=-1\r\n"),
2074            "{back}"
2075        );
2076    }
2077
2078    #[test]
2079    fn an_end_date_ends_at_midnight_in_the_series_zone() {
2080        let mut event = master();
2081        if let MsgraphField::Set(recurrence) = &mut event.recurrence {
2082            recurrence.range = MsgraphRecurrenceRange {
2083                range_type: Some(MsgraphRecurrenceRangeType::EndDate),
2084                start_date: Some("2026-08-14".into()),
2085                end_date: Some("2026-09-30".into()),
2086                recurrence_time_zone: Some("Romance Standard Time".into()),
2087                ..Default::default()
2088            };
2089        }
2090
2091        // NOTE: the end of 30 September in Paris, UTC+2 in summer.
2092        assert!(event.to_ical().contains(";UNTIL=20260930T215959Z\r\n"));
2093    }
2094
2095    #[test]
2096    fn a_series_is_bounded_by_its_range_type_not_the_sentinel_end() {
2097        let bounds = |range_type, interval| {
2098            let recurrence = MsgraphPatternedRecurrence {
2099                pattern: MsgraphRecurrencePattern {
2100                    pattern_type: Some(MsgraphRecurrencePatternType::Weekly),
2101                    interval,
2102                    ..Default::default()
2103                },
2104                range: MsgraphRecurrenceRange {
2105                    range_type: Some(range_type),
2106                    start_date: Some("2026-09-28".into()),
2107                    end_date: Some("0001-01-01".into()),
2108                    number_of_occurrences: Some(11),
2109                    ..Default::default()
2110                },
2111            };
2112            recurrence
2113                .bounds()
2114                .map(|(start, end)| (start.to_string(), end.map(|end| end.to_string())))
2115        };
2116
2117        let start = String::from("2026-09-28");
2118        assert_eq!(
2119            bounds(MsgraphRecurrenceRangeType::Numbered, None),
2120            Some((start.clone(), Some("2026-12-14".into()))),
2121        );
2122        assert_eq!(
2123            bounds(MsgraphRecurrenceRangeType::Numbered, Some(2)),
2124            Some((start.clone(), Some("2027-03-01".into()))),
2125        );
2126        assert_eq!(
2127            bounds(MsgraphRecurrenceRangeType::NoEnd, None),
2128            Some((start.clone(), None)),
2129        );
2130        assert_eq!(
2131            bounds(MsgraphRecurrenceRangeType::EndDate, None),
2132            Some((start, Some("0001-01-01".into()))),
2133        );
2134    }
2135
2136    #[test]
2137    fn what_graph_cannot_hold_is_refused_by_name() {
2138        let with = |props: &str| {
2139            format!(
2140                "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n{props}END:VEVENT\r\nEND:VCALENDAR\r\n"
2141            )
2142        };
2143
2144        let hourly =
2145            with("DTSTART:20260814T090000Z\r\nDTEND:20260814T100000Z\r\nRRULE:FREQ=HOURLY\r\n");
2146        let floating = with("DTSTART:20260814T090000\r\nDTEND:20260814T100000\r\n");
2147        let todo = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
2148
2149        assert!(matches!(
2150            MsgraphEvent::from_ical(hourly.as_bytes()),
2151            Err(MsgraphEventIcalError::UnsupportedRecurrence(_))
2152        ));
2153        assert!(matches!(
2154            MsgraphEvent::from_ical(floating.as_bytes()),
2155            Err(MsgraphEventIcalError::NoZonedStart)
2156        ));
2157        assert!(matches!(
2158            MsgraphEvent::from_ical(todo.as_bytes()),
2159            Err(MsgraphEventIcalError::NotAnEvent(name)) if name == "VTODO"
2160        ));
2161    }
2162
2163    #[test]
2164    fn a_duration_gives_the_end() {
2165        let ical = concat!(
2166            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n",
2167            "DTSTART;TZID=Europe/Paris:20260814T090000\r\nDURATION:PT1H30M\r\n",
2168            "END:VEVENT\r\nEND:VCALENDAR\r\n",
2169        );
2170
2171        let event = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
2172        let end = event.end.as_option().unwrap();
2173        assert_eq!(end.date_time, "2026-08-14T10:30:00");
2174        assert_eq!(end.time_zone.as_deref(), Some("Europe/Paris"));
2175    }
2176
2177    #[test]
2178    fn the_uid_and_the_remainder_ride_the_stash() {
2179        let ical = concat!(
2180            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:urn:uuid:4fbe8971\r\n",
2181            "DTSTART:20260814T090000Z\r\nDTEND:20260814T100000Z\r\nSUMMARY:Lunch\r\n",
2182            "X-CUSTOM;X-P=1:kept\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
2183        );
2184
2185        let mut event = MsgraphEvent::create_from_ical(ical.as_bytes()).unwrap();
2186        assert_eq!(event.stashed_uid().as_deref(), Some("urn:uuid:4fbe8971"));
2187
2188        // NOTE: what Graph hands back after the create: its own ids, and
2189        // the stash it was given.
2190        event.id = "EV9".into();
2191        event.ical_uid = Some("040000008200E0".into());
2192        let back = event.to_ical();
2193
2194        assert!(back.contains("UID:urn:uuid:4fbe8971\r\n"), "{back}");
2195        assert!(!back.contains("UID:040000008200E0"), "{back}");
2196        assert!(back.contains("X-CUSTOM;X-P=1:kept\r\n"), "{back}");
2197    }
2198
2199    #[test]
2200    fn the_join_link_of_an_online_meeting_is_minted_read_only() {
2201        let mut event = master();
2202        event.online_meeting = Some(crate::v1::rest::users::events::MsgraphOnlineMeetingInfo {
2203            join_url: Some("https://teams.x.org/l/1".into()),
2204        });
2205        // NOTE: a stash written before the property was minted.
2206        event.single_value_extended_properties =
2207            MsgraphField::Set(vec![MsgraphSingleValueExtendedProperty {
2208                id: MSGRAPH_EVENT_STASH_ID.into(),
2209                value: "X-MICROSOFT-SKYPETEAMSMEETINGURL:https://teams.x.org/l/0".into(),
2210            }]);
2211        let ical = event.to_ical();
2212
2213        assert!(
2214            ical.contains("CONFERENCE;VALUE=URI;FEATURE=AUDIO,VIDEO:https://teams.x.org/l/1\r\n"),
2215            "{ical}"
2216        );
2217        assert!(!ical.contains("SKYPETEAMS"), "{ical}");
2218        assert!(!ical.contains("teams.x.org/l/0"), "{ical}");
2219
2220        let back = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
2221        assert!(back.online_meeting.is_none());
2222        assert!(back.is_online_meeting.is_unset());
2223        assert!(
2224            !stash_lines(&back)
2225                .iter()
2226                .any(|line| line.contains("CONFERENCE") || line.contains("SKYPETEAMS")),
2227            "{back:?}"
2228        );
2229    }
2230
2231    #[test]
2232    fn an_online_meeting_asked_for_sets_is_online_meeting_once() {
2233        let base = master().to_ical();
2234        let asked = base.replace(
2235            "SUMMARY:Stand-up\r\n",
2236            "SUMMARY:Stand-up\r\nX-PIMDIR-ONLINE-MEETING:TRUE\r\n",
2237        );
2238        assert_ne!(asked, base);
2239
2240        let created = MsgraphEvent::from_ical(asked.as_bytes()).unwrap();
2241        assert_eq!(created.is_online_meeting, MsgraphField::Set(true));
2242        assert!(
2243            !stash_lines(&created)
2244                .iter()
2245                .any(|line| line.contains("ONLINE-MEETING")),
2246            "{created:?}"
2247        );
2248        let body = serde_json::to_value(&created).unwrap();
2249        assert_eq!(body["isOnlineMeeting"], true);
2250
2251        let patch = MsgraphEvent::update_from_ical(asked.as_bytes(), base.as_bytes()).unwrap();
2252        assert_eq!(patch.is_online_meeting, MsgraphField::Set(true));
2253
2254        // NOTE: a later edit of a document still asking leaves it alone.
2255        let edited = asked.replace("SUMMARY:Stand-up", "SUMMARY:Daily");
2256        let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), asked.as_bytes()).unwrap();
2257        assert!(patch.is_online_meeting.is_unset());
2258
2259        let declined = base.replace(
2260            "SUMMARY:Stand-up\r\n",
2261            "SUMMARY:Stand-up\r\nX-PIMDIR-ONLINE-MEETING:FALSE\r\n",
2262        );
2263        let event = MsgraphEvent::from_ical(declined.as_bytes()).unwrap();
2264        assert!(event.is_online_meeting.is_unset());
2265        let body = serde_json::to_value(&event).unwrap();
2266        assert!(body.get("isOnlineMeeting").is_none());
2267    }
2268
2269    #[test]
2270    fn an_update_sends_only_what_changed() {
2271        let base = master().to_ical();
2272        let edited = base.replace("SUMMARY:Stand-up", "SUMMARY:Daily");
2273
2274        let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), base.as_bytes()).unwrap();
2275
2276        assert_eq!(patch.subject.as_option().map(String::as_str), Some("Daily"));
2277        assert!(patch.start.is_unset());
2278        assert!(patch.recurrence.is_unset());
2279        assert!(patch.attendees.is_unset());
2280        assert!(patch.single_value_extended_properties.is_unset());
2281    }
2282
2283    /// One occurrence of the Monday stand-up, on its own: `hour` is when
2284    /// it starts, and `extra` what else its VEVENT carries.
2285    fn occurrence(hour: &str, summary: &str, extra: &str) -> String {
2286        format!(
2287            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\nBEGIN:VEVENT\r\n\
2288             UID:040000008200E0\r\nRECURRENCE-ID;TZID=Europe/Paris:20260817T090000\r\n\
2289             DTSTART;TZID=Europe/Paris:20260817T{hour}0000\r\n\
2290             DTEND;TZID=Europe/Paris:20260817T{hour}1500\r\n\
2291             SUMMARY:{summary}\r\n{extra}END:VEVENT\r\nEND:VCALENDAR\r\n"
2292        )
2293    }
2294
2295    #[test]
2296    fn an_occurrence_moved_alone_sends_its_new_times() {
2297        let base = occurrence("09", "Stand-up", "");
2298        let moved = occurrence("10", "Stand-up", "");
2299
2300        let patch = MsgraphEvent::instance_update_from_ical(moved.as_bytes(), base.as_bytes())
2301            .unwrap()
2302            .unwrap();
2303
2304        let start = patch.start.as_option().unwrap();
2305        assert_eq!(start.date_time, "2026-08-17T10:00:00");
2306        assert_eq!(start.time_zone.as_deref(), Some("Europe/Paris"));
2307        assert_eq!(
2308            patch.end.as_option().unwrap().date_time,
2309            "2026-08-17T10:15:00"
2310        );
2311        assert!(patch.subject.is_unset());
2312        assert!(patch.body.is_unset());
2313        assert!(patch.attendees.is_unset());
2314        assert!(patch.recurrence.is_unset());
2315        assert!(patch.single_value_extended_properties.is_unset());
2316    }
2317
2318    #[test]
2319    fn a_reverted_occurrence_takes_the_series_fields_back() {
2320        let exception = occurrence("10", "Stand-up, moved", "");
2321        let plain = occurrence("09", "Stand-up", "");
2322
2323        let patch = MsgraphEvent::instance_update_from_ical(plain.as_bytes(), exception.as_bytes())
2324            .unwrap()
2325            .unwrap();
2326
2327        assert_eq!(
2328            patch.subject.as_option().map(String::as_str),
2329            Some("Stand-up")
2330        );
2331        assert_eq!(
2332            patch.start.as_option().unwrap().date_time,
2333            "2026-08-17T09:00:00"
2334        );
2335    }
2336
2337    /// The series' own component carried to an occurrence keeps its rule
2338    /// and its remainder, which belong to the master.
2339    #[test]
2340    fn an_occurrence_as_graph_holds_it_sends_nothing() {
2341        let held = occurrence("09", "Stand-up", "");
2342        let carried = occurrence("09", "Stand-up", "RRULE:FREQ=WEEKLY\r\nX-OWN:kept\r\n");
2343
2344        assert_eq!(
2345            MsgraphEvent::instance_update_from_ical(held.as_bytes(), held.as_bytes()).unwrap(),
2346            None
2347        );
2348        assert_eq!(
2349            MsgraphEvent::instance_update_from_ical(carried.as_bytes(), held.as_bytes()).unwrap(),
2350            None
2351        );
2352    }
2353
2354    /// A read Graph answered in UTC, as it does unless asked for a zone.
2355    fn utc_read() -> MsgraphEvent {
2356        let utc = |date_time: &str| {
2357            MsgraphField::Set(MsgraphDateTimeTimeZone {
2358                date_time: date_time.into(),
2359                time_zone: Some("UTC".into()),
2360            })
2361        };
2362
2363        MsgraphEvent {
2364            id: "E1".into(),
2365            start: utc("2030-01-10T09:00:00.0000000"),
2366            end: utc("2030-01-10T10:00:00.0000000"),
2367            is_all_day: MsgraphField::Set(false),
2368            original_start_time_zone: Some("Romance Standard Time".into()),
2369            original_end_time_zone: Some("Romance Standard Time".into()),
2370            ..Default::default()
2371        }
2372    }
2373
2374    #[test]
2375    fn a_utc_read_is_told_in_its_original_zone() {
2376        let ical = utc_read().to_ical();
2377
2378        assert!(
2379            ical.contains("DTSTART;TZID=Europe/Paris:20300110T100000\r\n"),
2380            "{ical}"
2381        );
2382        assert!(
2383            ical.contains("DTEND;TZID=Europe/Paris:20300110T110000\r\n"),
2384            "{ical}"
2385        );
2386        assert!(
2387            ical.contains("BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\n"),
2388            "{ical}"
2389        );
2390    }
2391
2392    #[test]
2393    fn an_unknown_original_zone_stays_utc() {
2394        let mut event = utc_read();
2395        event.original_start_time_zone = Some("tzone://Microsoft/Custom".into());
2396        event.original_end_time_zone = Some("tzone://Microsoft/Custom".into());
2397
2398        let ical = event.to_ical();
2399
2400        assert!(ical.contains("DTSTART:20300110T090000Z\r\n"), "{ical}");
2401    }
2402
2403    #[test]
2404    fn an_all_day_read_keeps_its_dates() {
2405        let mut event = utc_read();
2406        event.is_all_day = MsgraphField::Set(true);
2407        event.start = MsgraphField::Set(MsgraphDateTimeTimeZone {
2408            date_time: "2030-01-10T00:00:00.0000000".into(),
2409            time_zone: Some("UTC".into()),
2410        });
2411        event.end = MsgraphField::Set(MsgraphDateTimeTimeZone {
2412            date_time: "2030-01-11T00:00:00.0000000".into(),
2413            time_zone: Some("UTC".into()),
2414        });
2415
2416        let ical = event.to_ical();
2417
2418        assert!(ical.contains("DTSTART;VALUE=DATE:20300110\r\n"), "{ical}");
2419    }
2420
2421    /// A read whose text body Exchange turned into HTML, as it always does.
2422    fn html_read() -> String {
2423        let mut event = utc_read();
2424        event.body = MsgraphField::Set(MsgraphItemBody {
2425            content_type: Some(MsgraphBodyType::Html),
2426            content: Some(
2427                "<html><body>\r\n<div class=\"PlainText\">old notes</div>\r\n</body></html>".into(),
2428            ),
2429        });
2430        event.to_ical()
2431    }
2432
2433    #[test]
2434    fn an_untouched_description_keeps_the_html() {
2435        let read = html_read();
2436
2437        let event = MsgraphEvent::from_ical(read.as_bytes()).unwrap();
2438
2439        let body = event.body.as_option().unwrap();
2440        assert_eq!(body.content_type, Some(MsgraphBodyType::Html));
2441    }
2442
2443    #[test]
2444    fn an_edited_description_beats_its_stale_html() {
2445        let read = html_read();
2446        let edited = read.replace("DESCRIPTION:old notes", "DESCRIPTION:new notes");
2447        assert_ne!(edited, read, "the document carries the stripped text");
2448
2449        let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), read.as_bytes()).unwrap();
2450
2451        let body = patch.body.as_option().expect("the edit reaches the patch");
2452        assert_eq!(body.content_type, Some(MsgraphBodyType::Text));
2453        assert_eq!(body.content.as_deref(), Some("new notes"));
2454    }
2455
2456    /// A document with its description lines, folded ones included, removed.
2457    fn without_description(ical: &str) -> String {
2458        let mut kept = String::new();
2459        let mut dropping = false;
2460        for line in ical.split_inclusive("\r\n") {
2461            if !line.starts_with(' ') {
2462                dropping = line.starts_with("DESCRIPTION") || line.starts_with("X-ALT-DESC");
2463            }
2464            if !dropping {
2465                kept.push_str(line);
2466            }
2467        }
2468        kept
2469    }
2470
2471    #[test]
2472    fn an_empty_html_body_matches_a_document_without_description() {
2473        let mut event = utc_read();
2474        event.body = MsgraphField::Set(MsgraphItemBody {
2475            content_type: Some(MsgraphBodyType::Html),
2476            content: Some("<html><body></body></html>".into()),
2477        });
2478        let read = event.to_ical();
2479        let edited = without_description(&read);
2480
2481        let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), read.as_bytes()).unwrap();
2482
2483        assert_eq!(patch.body, MsgraphField::Unset, "no body change to send");
2484    }
2485
2486    #[test]
2487    fn a_cleared_description_sends_an_empty_text_body() {
2488        let read = html_read();
2489        let edited = without_description(&read);
2490
2491        let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), read.as_bytes()).unwrap();
2492
2493        let body = serde_json::to_value(&patch).unwrap()["body"].clone();
2494        assert_eq!(body["contentType"], "text", "Graph refuses a null body");
2495        assert_eq!(body["content"], "");
2496    }
2497
2498    #[test]
2499    fn the_expand_clause_names_the_stash() {
2500        let quoted = format!("'{MSGRAPH_EVENT_STASH_ID}'");
2501        assert!(MSGRAPH_EVENT_STASH_EXPAND.contains(&quoted));
2502    }
2503}