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