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