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