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