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