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