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