1mod windows_zones;
43
44use core::fmt;
45
46use alloc::{
47 borrow::{Cow, ToOwned},
48 collections::BTreeSet,
49 format,
50 string::{String, ToString},
51 vec,
52 vec::Vec,
53};
54
55use ical::{
56 param::IcalParam,
57 prop::{IcalProp, IcalPropKind, IcalPropName, action::ACTION, trigger::TRIGGER},
58 tree::{
59 codec::Codec,
60 cst::{IcalCst, IcalItem},
61 error::IcalParseError,
62 line::IcalLine,
63 param::{
64 cn::CN, cutype::CUTYPE, fmttype::FMTTYPE, partstat::PARTSTAT, role::ROLE, tzid::TZID,
65 value::VALUE,
66 },
67 },
68 tzdb,
69 value::{
70 IcalValue,
71 datetime::IcalDateTime,
72 recur::IcalRecur,
73 text::{IcalText, IcalTextList},
74 },
75};
76use jiff::{
77 Span, Timestamp,
78 civil::{Date, DateTime},
79 tz::TimeZone,
80};
81use serde_json::{from_value, to_value};
82
83use crate::v1::{
84 field::MsgraphField,
85 rest::users::{
86 contacts::MsgraphSingleValueExtendedProperty,
87 events::{
88 MsgraphAttendee, MsgraphAttendeeType, MsgraphDateTimeTimeZone, MsgraphDayOfWeek,
89 MsgraphEvent, MsgraphFreeBusyStatus, MsgraphLocation, MsgraphPatternedRecurrence,
90 MsgraphRecurrencePattern, MsgraphRecurrencePatternType, MsgraphRecurrenceRange,
91 MsgraphRecurrenceRangeType, MsgraphResponseStatus, MsgraphResponseType,
92 MsgraphSensitivity, MsgraphWeekIndex,
93 },
94 messages::{MsgraphBodyType, MsgraphEmailAddress, MsgraphImportance, MsgraphItemBody},
95 },
96};
97
98pub const MSGRAPH_EVENT_STASH_ID: &str =
101 "String {c8e5e5cf-3f6c-4f0a-9d4e-52f1e7b2a9d3} Name pimalaya-ical";
102
103pub const MSGRAPH_EVENT_STASH_EXPAND: &str = "singleValueExtendedProperties($filter=id eq \
106 'String {c8e5e5cf-3f6c-4f0a-9d4e-52f1e7b2a9d3} Name pimalaya-ical')";
107
108pub const MSGRAPH_EVENT_ICAL_SELECT: &str = "id,iCalUId,changeKey,createdDateTime,\
117 lastModifiedDateTime,type,seriesMasterId,originalStart,originalStartTimeZone,\
118 originalEndTimeZone,subject,body,start,end,isAllDay,location,recurrence,attendees,\
119 organizer,categories,showAs,sensitivity,importance,isReminderOn,\
120 reminderMinutesBeforeStart,isCancelled,webLink,onlineMeeting,cancelledOccurrences";
121
122const PRODID: &str = "-//Pimalaya//io-msgraph//EN";
124
125const MAX_STASH_LINE: usize = 8 * 1024;
128
129const MINTED_PROPS: &[&str] = &["X-MSGRAPH-WEB-LINK", "CONFERENCE", TEAMS_MEETING_URL];
131
132const TEAMS_MEETING_URL: &str = "X-MICROSOFT-SKYPETEAMSMEETINGURL";
135
136const ONLINE_MEETING: &str = "X-PIMDIR-ONLINE-MEETING";
139
140const BUSY_STATUS: &str = "X-MICROSOFT-CDO-BUSYSTATUS";
142
143const ALT_DESC: &str = "X-ALT-DESC";
145
146impl MsgraphEvent {
147 pub fn to_ical(&self) -> String {
150 self.to_ical_series(&[])
151 }
152
153 pub fn to_ical_series(&self, exceptions: &[&MsgraphEvent]) -> String {
161 let master = localized(self);
162 let master = &*master;
163 let exceptions: Vec<Cow<'_, MsgraphEvent>> = exceptions
164 .iter()
165 .map(|exception| localized(exception))
166 .collect();
167 let uid = master
168 .stashed_uid()
169 .or_else(|| master.ical_uid.as_deref().map(original_uid))
170 .unwrap_or_else(|| master.id.clone());
171
172 let mut zones = BTreeSet::new();
173 let mut events = vec![vevent(master, None, &uid, &mut zones)];
174 for exception in &exceptions {
175 events.push(vevent(exception, Some(master), &uid, &mut zones));
176 }
177
178 let mut calendar = IcalCst::v2();
179 calendar.push(IcalProp::text(IcalPropKind::ProdId, vec![], PRODID));
180
181 let anchor = anchor(master);
182 for zone in &zones {
183 if let Some(vtimezone) = tzdb::vtimezone(zone, anchor) {
184 calendar.push_component(vtimezone);
185 }
186 }
187 for event in events {
188 calendar.push_component(event);
189 }
190
191 String::from_utf8_lossy(&calendar.to_bytes()).into_owned()
192 }
193
194 pub fn from_ical(contents: &[u8]) -> Result<Self, MsgraphEventIcalError> {
201 let calendar = IcalCst::parse(contents).map_err(MsgraphEventIcalError::Parse)?;
202 project(find_vevent(&calendar, |vevent| {
203 !has_prop(vevent, "RECURRENCE-ID")
204 })?)
205 }
206
207 pub fn create_from_ical(contents: &[u8]) -> Result<Self, MsgraphEventIcalError> {
211 let mut event = Self::from_ical(contents)?;
212
213 if stash_lines(&event).is_empty() {
214 event.single_value_extended_properties = MsgraphField::Unset;
215 }
216
217 let mut body = to_value(&event).map_err(MsgraphEventIcalError::Json)?;
218 if let Some(object) = body.as_object_mut() {
219 object.retain(|_, value| {
220 !value.is_null() && value.as_array().is_none_or(|array| !array.is_empty())
221 });
222 }
223
224 from_value(body).map_err(MsgraphEventIcalError::Json)
225 }
226
227 pub fn update_from_ical(contents: &[u8], base: &[u8]) -> Result<Self, MsgraphEventIcalError> {
231 let mut event = Self::from_ical(contents)?;
232 event.unset_unchanged(&Self::from_ical(base)?);
233 Ok(event)
234 }
235
236 pub fn instance_update_from_ical(
246 contents: &[u8],
247 base: &[u8],
248 ) -> Result<Option<Self>, MsgraphEventIcalError> {
249 let occurrence = |contents| {
250 let calendar = IcalCst::parse(contents).map_err(MsgraphEventIcalError::Parse)?;
251 project(find_vevent(&calendar, |_| true)?)
252 };
253
254 let mut event = occurrence(contents)?;
255 event.unset_unchanged(&occurrence(base)?);
256 event.recurrence = MsgraphField::Unset;
257 event.single_value_extended_properties = MsgraphField::Unset;
258
259 Ok((event != Self::default()).then_some(event))
260 }
261
262 fn unset_unchanged(&mut self, base: &Self) {
264 macro_rules! unset_unchanged {
265 ($($field:ident),* $(,)?) => {$(
266 if self.$field == base.$field {
267 self.$field = MsgraphField::Unset;
268 }
269 )*};
270 }
271
272 if same_text(&body_text(&self.body), &body_text(&base.body)) {
273 self.body = MsgraphField::Unset;
274 }
275
276 unset_unchanged!(
277 subject,
278 start,
279 end,
280 is_all_day,
281 location,
282 recurrence,
283 attendees,
284 categories,
285 show_as,
286 sensitivity,
287 importance,
288 is_reminder_on,
289 reminder_minutes_before_start,
290 is_online_meeting,
291 single_value_extended_properties,
292 );
293 }
294
295 pub fn recurrence_id_of(&self, original_start: &str) -> Option<String> {
303 let master = localized(self);
304 let prop = recurrence_id(&master, original_start, &mut BTreeSet::new())?;
305 match prop.value {
306 IcalValue::Date(date) => Some(date.0.into_owned()),
307 IcalValue::DateTime(stamp) => Some(stamp.0.into_owned()),
308 _ => None,
309 }
310 }
311
312 pub fn stashed_uid(&self) -> Option<String> {
319 stash_lines(self).iter().find_map(|line| {
320 let mut bytes = line.clone();
321 bytes.push_str("\r\n");
322 let (line, _) = IcalLine::take(bytes.as_bytes()).ok()?;
323 line.name
324 .get()
325 .eq_ignore_ascii_case("UID")
326 .then(|| line.raw_value_str().trim().to_string())
327 .filter(|uid| !uid.is_empty())
328 })
329 }
330}
331
332impl MsgraphPatternedRecurrence {
333 pub fn bounds(&self) -> Option<(Date, Option<Date>)> {
342 let range = &self.range;
343 let start = range.start_date.as_deref()?.parse::<Date>().ok()?;
344
345 let end = match range.range_type? {
346 MsgraphRecurrenceRangeType::EndDate => {
347 Some(range.end_date.as_deref()?.parse::<Date>().ok()?)
348 }
349 MsgraphRecurrenceRangeType::Numbered => {
350 let periods = i64::from(range.number_of_occurrences?)
351 * i64::from(self.pattern.interval.unwrap_or(1).max(1));
352 let span = match self.pattern.pattern_type? {
353 MsgraphRecurrencePatternType::Daily => Span::new().try_days(periods),
354 MsgraphRecurrencePatternType::Weekly => Span::new().try_weeks(periods),
355 MsgraphRecurrencePatternType::AbsoluteMonthly
356 | MsgraphRecurrencePatternType::RelativeMonthly => {
357 Span::new().try_months(periods)
358 }
359 MsgraphRecurrencePatternType::AbsoluteYearly
360 | MsgraphRecurrencePatternType::RelativeYearly => {
361 Span::new().try_years(periods)
362 }
363 };
364 Some(start.checked_add(span.ok()?).ok()?)
365 }
366 MsgraphRecurrenceRangeType::NoEnd => None,
367 };
368
369 Some((start, end))
370 }
371}
372
373#[derive(Debug)]
375pub enum MsgraphEventIcalError {
376 Parse(IcalParseError),
378 NoEvent,
380 NotAnEvent(String),
382 NoZonedStart,
384 NoEnd,
386 UnsupportedRecurrence(String),
388 Json(serde_json::Error),
390}
391
392impl fmt::Display for MsgraphEventIcalError {
393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394 match self {
395 Self::Parse(err) => write!(f, "Invalid iCalendar: {err}"),
396 Self::NoEvent => write!(f, "The iCalendar contents carry no VEVENT"),
397 Self::NotAnEvent(name) => {
398 write!(
399 f,
400 "Microsoft Graph models no {name}: its calendars hold events only"
401 )
402 }
403 Self::NoZonedStart => write!(
404 f,
405 "Microsoft Graph needs a DTSTART that is a date, in UTC, or in a named zone"
406 ),
407 Self::NoEnd => write!(f, "The VEVENT has neither a DTEND nor a DURATION"),
408 Self::UnsupportedRecurrence(rule) => {
409 write!(f, "Microsoft Graph cannot hold the recurrence `{rule}`")
410 }
411 Self::Json(err) => write!(f, "Cannot shape the Graph event body: {err}"),
412 }
413 }
414}
415
416impl core::error::Error for MsgraphEventIcalError {}
417
418fn project(vevent: &IcalCst<'_>) -> Result<MsgraphEvent, MsgraphEventIcalError> {
420 let mut event = MsgraphEvent {
421 subject: MsgraphField::Null,
422 body: MsgraphField::Null,
423 location: MsgraphField::Null,
424 recurrence: MsgraphField::Null,
425 attendees: MsgraphField::Set(Vec::new()),
426 categories: MsgraphField::Set(Vec::new()),
427 show_as: MsgraphField::Null,
428 sensitivity: MsgraphField::Null,
429 importance: MsgraphField::Null,
430 is_reminder_on: MsgraphField::Set(false),
431 ..Default::default()
432 };
433
434 let mut description = None;
435 let mut html = None;
436 let mut start = None;
437 let mut end = None;
438 let mut duration = None;
439 let mut transparent = None;
440 let mut rrule = None;
441 let mut stash = Vec::new();
442
443 for item in &vevent.items {
444 match item {
445 IcalItem::Prop(line) => {
446 let name = line.name.get();
447 let consumed = if MINTED_PROPS.iter().any(|m| name.eq_ignore_ascii_case(m)) {
448 true
449 } else if name.eq_ignore_ascii_case(ONLINE_MEETING) {
450 if line.raw_value_str().trim().eq_ignore_ascii_case("TRUE") {
451 event.is_online_meeting = MsgraphField::Set(true);
452 }
453 true
454 } else if name.eq_ignore_ascii_case(BUSY_STATUS) {
455 event.show_as =
456 MsgraphField::set_or_null(busy_status_from_ical(&line.raw_value_str()));
457 true
458 } else if name.eq_ignore_ascii_case(ALT_DESC)
459 && line
460 .param::<FMTTYPE>()
461 .is_some_and(|kind| kind.eq_ignore_ascii_case("text/html"))
462 {
463 html = Some(text(line));
464 true
465 } else {
466 match name.parse::<IcalPropKind>() {
467 Ok(IcalPropKind::Summary) => {
468 event.subject = MsgraphField::Set(text(line));
469 true
470 }
471 Ok(IcalPropKind::Description) => {
472 description = Some(text(line));
473 true
474 }
475 Ok(IcalPropKind::Location) => {
476 event.location = MsgraphField::Set(MsgraphLocation {
477 display_name: Some(text(line)),
478 ..Default::default()
479 });
480 true
481 }
482 Ok(IcalPropKind::DtStart) => {
483 start = Some(boundary(line));
484 true
485 }
486 Ok(IcalPropKind::DtEnd) => {
487 end = Some(boundary(line));
488 true
489 }
490 Ok(IcalPropKind::Duration) => {
491 duration = line.raw_value_str().trim().parse::<Span>().ok();
492 duration.is_some()
493 }
494 Ok(IcalPropKind::RRule) => {
495 rrule = Some(IcalRecur::decode(&line.value).0.trim().to_owned());
496 true
497 }
498 Ok(IcalPropKind::ExDate) => true,
502 Ok(IcalPropKind::Categories) => {
503 let list = IcalTextList::decode(&line.value);
504 if let MsgraphField::Set(categories) = &mut event.categories {
505 categories.extend(list.0.into_iter().map(Cow::into_owned));
506 }
507 true
508 }
509 Ok(IcalPropKind::Class) => {
510 event.sensitivity = MsgraphField::set_or_null(sensitivity_from_ical(
511 &line.raw_value_str(),
512 ));
513 true
514 }
515 Ok(IcalPropKind::Priority) => {
516 event.importance = MsgraphField::set_or_null(importance_from_ical(
517 &line.raw_value_str(),
518 ));
519 true
520 }
521 Ok(IcalPropKind::Transp) => {
522 transparent = Some(
523 line.raw_value_str()
524 .trim()
525 .eq_ignore_ascii_case("TRANSPARENT"),
526 );
527 true
528 }
529 Ok(IcalPropKind::Attendee) => {
530 if let MsgraphField::Set(attendees) = &mut event.attendees {
531 attendees.push(attendee(line));
532 }
533 true
534 }
535 Ok(
539 IcalPropKind::Organizer
540 | IcalPropKind::Status
541 | IcalPropKind::DtStamp
542 | IcalPropKind::Created
543 | IcalPropKind::LastModified
544 | IcalPropKind::Sequence,
545 ) => true,
546 _ => false,
547 }
548 };
549
550 if !consumed {
551 let raw = raw_line(line);
552 if raw.len() <= MAX_STASH_LINE {
553 stash.push(raw);
554 }
555 }
556 }
557 IcalItem::Component(alarm) => {
558 let reminder = reminder_minutes(alarm);
559 match (&event.reminder_minutes_before_start, reminder) {
560 (MsgraphField::Unset, Some(minutes)) => {
561 event.is_reminder_on = MsgraphField::Set(true);
562 event.reminder_minutes_before_start = MsgraphField::Set(minutes);
563 }
564 _ => stash.extend(raw_component(alarm)),
565 }
566 }
567 IcalItem::Opaque(bytes) => stash.push(String::from_utf8_lossy(bytes).into_owned()),
568 }
569 }
570
571 let Some(Some(start)) = start else {
572 return Err(MsgraphEventIcalError::NoZonedStart);
573 };
574 let end = match (end, duration) {
575 (Some(Some(end)), _) => end,
576 (_, Some(duration)) => start
577 .shifted(duration)
578 .ok_or(MsgraphEventIcalError::NoEnd)?,
579 _ => return Err(MsgraphEventIcalError::NoEnd),
580 };
581
582 event.is_all_day = MsgraphField::Set(start.all_day);
583 if let Some(rule) = &rrule {
584 event.recurrence = MsgraphField::Set(recurrence(rule, &start)?);
585 }
586 event.start = MsgraphField::Set(start.into_graph());
587 event.end = MsgraphField::Set(end.into_graph());
588
589 event.body = match (html, description) {
590 (Some(html), Some(content)) if !same_text(&strip_html(&html), &content) => {
593 MsgraphField::Set(MsgraphItemBody {
594 content_type: Some(MsgraphBodyType::Text),
595 content: Some(content),
596 })
597 }
598 (Some(content), _) => MsgraphField::Set(MsgraphItemBody {
599 content_type: Some(MsgraphBodyType::Html),
600 content: Some(content),
601 }),
602 (None, Some(content)) => MsgraphField::Set(MsgraphItemBody {
603 content_type: Some(MsgraphBodyType::Text),
604 content: Some(content),
605 }),
606 (None, None) => MsgraphField::Set(MsgraphItemBody {
609 content_type: Some(MsgraphBodyType::Text),
610 content: Some(String::new()),
611 }),
612 };
613
614 if matches!(event.show_as, MsgraphField::Null) {
615 event.show_as = match transparent {
616 Some(true) => MsgraphField::Set(MsgraphFreeBusyStatus::Free),
617 Some(false) => MsgraphField::Set(MsgraphFreeBusyStatus::Busy),
618 None => MsgraphField::Null,
619 };
620 }
621
622 event.single_value_extended_properties =
625 MsgraphField::Set(vec![MsgraphSingleValueExtendedProperty {
626 id: MSGRAPH_EVENT_STASH_ID.to_string(),
627 value: stash.join("\n"),
628 }]);
629
630 Ok(event)
631}
632
633fn vevent(
635 event: &MsgraphEvent,
636 master: Option<&MsgraphEvent>,
637 uid: &str,
638 zones: &mut BTreeSet<String>,
639) -> IcalCst<'static> {
640 let mut vevent = IcalCst::empty("VEVENT");
641 vevent.push(IcalProp::text(IcalPropKind::Uid, vec![], uid.to_owned()));
642
643 let stamp = event
646 .last_modified_date_time
647 .as_deref()
648 .or(event.created_date_time.as_deref())
649 .and_then(utc_stamp);
650 if let Some(stamp) = &stamp {
651 vevent.push(stamp_prop(IcalPropKind::DtStamp, stamp.clone()));
652 }
653
654 let all_day = event.is_all_day.as_option().copied().unwrap_or(false);
655 if let Some(start) = event.start.as_option() {
656 vevent.push(boundary_prop(IcalPropKind::DtStart, start, all_day, zones));
657 }
658 if let Some(end) = event.end.as_option() {
659 vevent.push(boundary_prop(IcalPropKind::DtEnd, end, all_day, zones));
660 }
661
662 if let (Some(master), Some(original)) = (master, &event.original_start)
663 && let Some(prop) = recurrence_id(master, original, zones)
664 {
665 vevent.push(prop);
666 }
667
668 if let Some(subject) = event.subject.as_option() {
669 vevent.push(IcalProp::text(
670 IcalPropKind::Summary,
671 vec![],
672 subject.clone(),
673 ));
674 }
675
676 if let Some(body) = event.body.as_option() {
677 let content = body.content.clone().unwrap_or_default();
678 match body.content_type {
679 Some(MsgraphBodyType::Html) => {
680 vevent.push(IcalProp::text(
681 IcalPropKind::Description,
682 vec![],
683 strip_html(&content),
684 ));
685 vevent.push(IcalProp::text(
686 ALT_DESC,
687 vec![IcalParam::FmtType("text/html".into())],
688 content,
689 ));
690 }
691 _ if !content.is_empty() => {
692 vevent.push(IcalProp::text(IcalPropKind::Description, vec![], content));
693 }
694 _ => {}
695 }
696 }
697
698 if let Some(name) = event
699 .location
700 .as_option()
701 .and_then(|location| location.display_name.clone())
702 .filter(|name| !name.is_empty())
703 {
704 vevent.push(IcalProp::text(IcalPropKind::Location, vec![], name));
705 }
706
707 if event.is_cancelled == Some(true) {
708 vevent.push(IcalProp::text(IcalPropKind::Status, vec![], "CANCELLED"));
709 }
710
711 if let Some(show_as) = event.show_as.as_option() {
712 let transp = match show_as {
713 MsgraphFreeBusyStatus::Free => "TRANSPARENT",
714 _ => "OPAQUE",
715 };
716 vevent.push(IcalProp::text(IcalPropKind::Transp, vec![], transp));
717 vevent.push(IcalProp::text(
718 BUSY_STATUS,
719 vec![],
720 busy_status_to_ical(*show_as),
721 ));
722 }
723
724 if let Some(class) = event
725 .sensitivity
726 .as_option()
727 .map(|s| sensitivity_to_ical(*s))
728 {
729 vevent.push(IcalProp::text(IcalPropKind::Class, vec![], class));
730 }
731
732 if let Some(importance) = event.importance.as_option() {
733 let priority = match importance {
734 MsgraphImportance::High => "1",
735 MsgraphImportance::Normal => "5",
736 MsgraphImportance::Low => "9",
737 };
738 vevent.push(IcalProp {
739 name: IcalPropName::Kind(IcalPropKind::Priority),
740 params: Vec::new(),
741 value: IcalValue::Integer(priority.into()),
742 });
743 }
744
745 let categories: Vec<Cow<'static, str>> = event
746 .categories
747 .as_option()
748 .map(Vec::as_slice)
749 .unwrap_or(&[])
750 .iter()
751 .filter(|category| !category.is_empty())
752 .map(|category| Cow::Owned(category.clone()))
753 .collect();
754 if !categories.is_empty() {
755 vevent.push(IcalProp {
756 name: IcalPropName::Kind(IcalPropKind::Categories),
757 params: Vec::new(),
758 value: IcalValue::TextList(IcalTextList(categories)),
759 });
760 }
761
762 for (kind, stamp) in [
763 (IcalPropKind::Created, &event.created_date_time),
764 (IcalPropKind::LastModified, &event.last_modified_date_time),
765 ] {
766 if let Some(stamp) = stamp.as_deref().and_then(utc_stamp) {
767 vevent.push(stamp_prop(kind, stamp));
768 }
769 }
770
771 if let Some(organizer) = event
772 .organizer
773 .as_option()
774 .map(|organizer| &organizer.email_address)
775 {
776 vevent.push(person_prop(IcalPropKind::Organizer, organizer, Vec::new()));
777 }
778
779 for attendee in event
780 .attendees
781 .as_option()
782 .map(Vec::as_slice)
783 .unwrap_or(&[])
784 {
785 if let Some(prop) = attendee_prop(attendee) {
786 vevent.push(prop);
787 }
788 }
789
790 if master.is_none()
791 && let Some(recurrence) = event.recurrence.as_option()
792 {
793 if let Some(rule) = rrule(recurrence, event.start.as_option(), all_day) {
794 vevent.push(IcalProp {
795 name: IcalPropName::Kind(IcalPropKind::RRule),
796 params: Vec::new(),
797 value: IcalValue::Recur(rule.into()),
798 });
799 }
800 for exdate in exdates(event, all_day, zones) {
801 vevent.push(exdate);
802 }
803 }
804
805 if let Some(link) = &event.web_link {
806 vevent.push(IcalProp::text("X-MSGRAPH-WEB-LINK", vec![], link.clone()));
807 }
808
809 if let Some(url) = event
810 .online_meeting
811 .as_ref()
812 .and_then(|meeting| meeting.join_url.as_deref())
813 .filter(|url| !url.is_empty())
814 {
815 vevent.push(IcalProp {
816 name: IcalPropName::Kind(IcalPropKind::Conference),
817 params: vec![
818 IcalParam::Value("URI".into()),
819 IcalParam::Feature(vec!["AUDIO".into(), "VIDEO".into()]),
820 ],
821 value: IcalValue::Uri(url.to_owned().into()),
822 });
823 }
824
825 for line in stash_lines(event) {
826 if is_uid_line(&line) || is_minted_line(&line) {
829 continue;
830 }
831 let _ = vevent.push_raw(&line);
834 }
835
836 if event.is_reminder_on.as_option() == Some(&true)
837 && let Some(minutes) = event.reminder_minutes_before_start.as_option()
838 {
839 let mut alarm = IcalCst::empty("VALARM");
840 alarm.push(IcalProp::text(IcalPropKind::Action, vec![], "DISPLAY"));
841 alarm.push(IcalProp {
842 name: IcalPropName::Kind(IcalPropKind::Trigger),
843 params: Vec::new(),
844 value: IcalValue::Duration(format!("-PT{minutes}M").into()),
845 });
846 alarm.push(IcalProp::text(
847 IcalPropKind::Description,
848 vec![],
849 "Reminder",
850 ));
851 vevent.push_component(alarm);
852 }
853
854 vevent
855}
856
857fn localized(event: &MsgraphEvent) -> Cow<'_, MsgraphEvent> {
866 if event.is_all_day.as_option() == Some(&true) {
867 return Cow::Borrowed(event);
868 }
869
870 let start = local_boundary(
871 event.start.as_option(),
872 event.original_start_time_zone.as_deref(),
873 );
874 let end = local_boundary(
875 event.end.as_option(),
876 event.original_end_time_zone.as_deref(),
877 );
878
879 if start.is_none() && end.is_none() {
880 return Cow::Borrowed(event);
881 }
882
883 let mut event = event.clone();
884 if let Some(start) = start {
885 event.start = MsgraphField::Set(start);
886 }
887 if let Some(end) = end {
888 event.end = MsgraphField::Set(end);
889 }
890
891 Cow::Owned(event)
892}
893
894fn local_boundary(
897 boundary: Option<&MsgraphDateTimeTimeZone>,
898 original: Option<&str>,
899) -> Option<MsgraphDateTimeTimeZone> {
900 let boundary = boundary?;
901 if !matches!(zone(boundary.time_zone.as_deref()), Zone::Utc) {
902 return None;
903 }
904 let Zone::Iana(name) = zone(original) else {
905 return None;
906 };
907
908 let utc = boundary
909 .date_time
910 .split('.')
911 .next()?
912 .parse::<DateTime>()
913 .ok()?;
914 let local = utc
915 .to_zoned(TimeZone::UTC)
916 .ok()?
917 .with_time_zone(TimeZone::get(&name).ok()?)
918 .datetime();
919
920 Some(MsgraphDateTimeTimeZone {
921 date_time: local.strftime("%Y-%m-%dT%H:%M:%S").to_string(),
922 time_zone: Some(name),
923 })
924}
925
926enum Zone {
928 Utc,
930 Iana(String),
932 Unknown(String),
934}
935
936fn zone(name: Option<&str>) -> Zone {
938 let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
939 return Zone::Utc;
940 };
941
942 if name.eq_ignore_ascii_case("UTC") || name.eq_ignore_ascii_case("Etc/UTC") {
943 return Zone::Utc;
944 }
945
946 if let Some(iana) = windows_zones::iana(name) {
947 return match iana {
948 "Etc/UTC" => Zone::Utc,
949 iana => Zone::Iana(iana.to_owned()),
950 };
951 }
952
953 if tzdb::is_known(name) {
954 return Zone::Iana(name.to_owned());
955 }
956
957 Zone::Unknown(name.to_owned())
958}
959
960fn boundary_prop(
962 kind: IcalPropKind,
963 boundary: &MsgraphDateTimeTimeZone,
964 all_day: bool,
965 zones: &mut BTreeSet<String>,
966) -> IcalProp<'static> {
967 let digits = ical_digits(&boundary.date_time);
968
969 if all_day {
970 return IcalProp {
971 name: IcalPropName::Kind(kind),
972 params: vec![IcalParam::Value("DATE".into())],
973 value: IcalValue::Date(digits.get(..8).unwrap_or_default().to_owned().into()),
974 };
975 }
976
977 match zone(boundary.time_zone.as_deref()) {
978 Zone::Utc => stamp_prop(kind, format!("{digits}Z")),
979 Zone::Iana(zone) | Zone::Unknown(zone) => {
980 zones.insert(zone.clone());
981 IcalProp {
982 name: IcalPropName::Kind(kind),
983 params: vec![IcalParam::TzId(zone.into())],
984 value: IcalValue::DateTime(digits.into()),
985 }
986 }
987 }
988}
989
990fn recurrence_id(
993 master: &MsgraphEvent,
994 original: &str,
995 zones: &mut BTreeSet<String>,
996) -> Option<IcalProp<'static>> {
997 let instant = original.parse::<Timestamp>().ok()?;
998 let all_day = master.is_all_day.as_option().copied().unwrap_or(false);
999 let start_zone = master
1000 .start
1001 .as_option()
1002 .and_then(|start| start.time_zone.as_deref());
1003
1004 if all_day {
1005 let date = instant.to_zoned(TimeZone::UTC).date();
1006 return Some(IcalProp {
1007 name: IcalPropName::Kind(IcalPropKind::RecurrenceId),
1008 params: vec![IcalParam::Value("DATE".into())],
1009 value: IcalValue::Date(date.strftime("%Y%m%d").to_string().into()),
1010 });
1011 }
1012
1013 match zone(start_zone) {
1014 Zone::Iana(name) => {
1015 let local = instant.to_zoned(TimeZone::get(&name).ok()?).datetime();
1016 zones.insert(name.clone());
1017 Some(IcalProp {
1018 name: IcalPropName::Kind(IcalPropKind::RecurrenceId),
1019 params: vec![IcalParam::TzId(name.into())],
1020 value: IcalValue::DateTime(local.strftime("%Y%m%dT%H%M%S").to_string().into()),
1021 })
1022 }
1023 _ => Some(stamp_prop(
1024 IcalPropKind::RecurrenceId,
1025 instant.strftime("%Y%m%dT%H%M%SZ").to_string(),
1026 )),
1027 }
1028}
1029
1030fn rrule(
1033 recurrence: &MsgraphPatternedRecurrence,
1034 start: Option<&MsgraphDateTimeTimeZone>,
1035 all_day: bool,
1036) -> Option<String> {
1037 let pattern = &recurrence.pattern;
1038 let mut parts = Vec::new();
1039
1040 let days = || -> String {
1041 pattern
1042 .days_of_week
1043 .iter()
1044 .map(|day| weekday_to_ical(*day))
1045 .collect::<Vec<_>>()
1046 .join(",")
1047 };
1048
1049 match pattern.pattern_type? {
1050 MsgraphRecurrencePatternType::Daily => parts.push(String::from("FREQ=DAILY")),
1051 MsgraphRecurrencePatternType::Weekly => {
1052 parts.push(String::from("FREQ=WEEKLY"));
1053 if !pattern.days_of_week.is_empty() {
1054 parts.push(format!("BYDAY={}", days()));
1055 }
1056 }
1057 MsgraphRecurrencePatternType::AbsoluteMonthly => {
1058 parts.push(String::from("FREQ=MONTHLY"));
1059 parts.push(format!("BYMONTHDAY={}", pattern.day_of_month?));
1060 }
1061 MsgraphRecurrencePatternType::RelativeMonthly => {
1062 parts.push(String::from("FREQ=MONTHLY"));
1063 parts.push(format!("BYDAY={}", days()));
1064 parts.push(format!("BYSETPOS={}", setpos(pattern.index)));
1065 }
1066 MsgraphRecurrencePatternType::AbsoluteYearly => {
1067 parts.push(String::from("FREQ=YEARLY"));
1068 parts.push(format!("BYMONTH={}", pattern.month?));
1069 parts.push(format!("BYMONTHDAY={}", pattern.day_of_month?));
1070 }
1071 MsgraphRecurrencePatternType::RelativeYearly => {
1072 parts.push(String::from("FREQ=YEARLY"));
1073 parts.push(format!("BYMONTH={}", pattern.month?));
1074 parts.push(format!("BYDAY={}", days()));
1075 parts.push(format!("BYSETPOS={}", setpos(pattern.index)));
1076 }
1077 }
1078
1079 if let Some(interval) = pattern.interval.filter(|interval| *interval > 1) {
1080 parts.push(format!("INTERVAL={interval}"));
1081 }
1082
1083 if let Some(first) = pattern.first_day_of_week
1084 && pattern.pattern_type == Some(MsgraphRecurrencePatternType::Weekly)
1085 {
1086 parts.push(format!("WKST={}", weekday_to_ical(first)));
1087 }
1088
1089 let range = &recurrence.range;
1090 match range.range_type {
1091 Some(MsgraphRecurrenceRangeType::Numbered) => {
1092 parts.push(format!("COUNT={}", range.number_of_occurrences?));
1093 }
1094 Some(MsgraphRecurrenceRangeType::EndDate) => {
1095 let end = range.end_date.as_deref()?.parse::<Date>().ok()?;
1096 parts.push(format!("UNTIL={}", until(end, range, start, all_day)?));
1097 }
1098 Some(MsgraphRecurrenceRangeType::NoEnd) | None => {}
1099 }
1100
1101 Some(parts.join(";"))
1102}
1103
1104fn until(
1108 end: Date,
1109 range: &MsgraphRecurrenceRange,
1110 start: Option<&MsgraphDateTimeTimeZone>,
1111 all_day: bool,
1112) -> Option<String> {
1113 if all_day {
1114 return Some(end.strftime("%Y%m%d").to_string());
1115 }
1116
1117 let name = range
1118 .recurrence_time_zone
1119 .as_deref()
1120 .or(start.and_then(|start| start.time_zone.as_deref()));
1121 let zone = match zone(name) {
1122 Zone::Iana(name) => TimeZone::get(&name).ok()?,
1123 _ => TimeZone::UTC,
1124 };
1125
1126 let last = end.at(23, 59, 59, 0).to_zoned(zone).ok()?;
1127 Some(last.timestamp().strftime("%Y%m%dT%H%M%SZ").to_string())
1128}
1129
1130fn exdates(
1133 master: &MsgraphEvent,
1134 all_day: bool,
1135 zones: &mut BTreeSet<String>,
1136) -> Vec<IcalProp<'static>> {
1137 let Some(start) = master.start.as_option() else {
1138 return Vec::new();
1139 };
1140 let time = ical_digits(&start.date_time);
1141 let time = time.get(8..).unwrap_or("T000000").to_owned();
1142
1143 master
1144 .cancelled_occurrences
1145 .iter()
1146 .filter_map(|occurrence| occurrence.rsplit('.').next())
1147 .filter_map(|date| date.parse::<Date>().ok())
1148 .map(|date| {
1149 let day = date.strftime("%Y%m%d").to_string();
1150 if all_day {
1151 return IcalProp {
1152 name: IcalPropName::Kind(IcalPropKind::ExDate),
1153 params: vec![IcalParam::Value("DATE".into())],
1154 value: IcalValue::Date(day.into()),
1155 };
1156 }
1157 match zone(start.time_zone.as_deref()) {
1158 Zone::Utc => stamp_prop(IcalPropKind::ExDate, format!("{day}{time}Z")),
1159 Zone::Iana(name) | Zone::Unknown(name) => {
1160 zones.insert(name.clone());
1161 IcalProp {
1162 name: IcalPropName::Kind(IcalPropKind::ExDate),
1163 params: vec![IcalParam::TzId(name.into())],
1164 value: IcalValue::DateTime(format!("{day}{time}").into()),
1165 }
1166 }
1167 }
1168 })
1169 .collect()
1170}
1171
1172struct Boundary {
1174 local: DateTime,
1176 all_day: bool,
1178 zone: String,
1180}
1181
1182impl Boundary {
1183 fn shifted(&self, duration: Span) -> Option<Self> {
1185 Some(Self {
1186 local: self.local.checked_add(duration).ok()?,
1187 all_day: self.all_day,
1188 zone: self.zone.clone(),
1189 })
1190 }
1191
1192 fn into_graph(self) -> MsgraphDateTimeTimeZone {
1194 MsgraphDateTimeTimeZone {
1195 date_time: self.local.strftime("%Y-%m-%dT%H:%M:%S").to_string(),
1196 time_zone: Some(self.zone),
1197 }
1198 }
1199}
1200
1201fn boundary(line: &IcalLine<'_>) -> Option<Boundary> {
1204 let value = line.raw_value_str();
1205 let value = value.trim();
1206
1207 let is_date = line
1208 .param::<VALUE>()
1209 .is_some_and(|kind| kind.eq_ignore_ascii_case("DATE"))
1210 || (value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_digit()));
1211
1212 if is_date {
1213 let date = Date::strptime("%Y%m%d", value).ok()?;
1214 return Some(Boundary {
1215 local: date.at(0, 0, 0, 0),
1216 all_day: true,
1217 zone: String::from("UTC"),
1218 });
1219 }
1220
1221 let (local, zone) = match value.strip_suffix('Z') {
1222 Some(local) => (local, String::from("UTC")),
1223 None => (value, line.param::<TZID>()?.into_owned()),
1224 };
1225
1226 Some(Boundary {
1227 local: DateTime::strptime("%Y%m%dT%H%M%S", local).ok()?,
1228 all_day: false,
1229 zone,
1230 })
1231}
1232
1233fn recurrence(
1236 rule: &str,
1237 start: &Boundary,
1238) -> Result<MsgraphPatternedRecurrence, MsgraphEventIcalError> {
1239 let unsupported = || MsgraphEventIcalError::UnsupportedRecurrence(rule.to_owned());
1240
1241 let mut freq = None;
1242 let mut interval = None;
1243 let mut count = None;
1244 let mut until_value = None;
1245 let mut by_day = Vec::new();
1246 let mut by_month_day = None;
1247 let mut by_month = None;
1248 let mut by_setpos = None;
1249 let mut wkst = None;
1250
1251 for part in rule.split(';').filter(|part| !part.is_empty()) {
1252 let (key, value) = part.split_once('=').ok_or_else(unsupported)?;
1253 match key.to_ascii_uppercase().as_str() {
1254 "FREQ" => freq = Some(value.to_ascii_uppercase()),
1255 "INTERVAL" => interval = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1256 "COUNT" => count = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1257 "UNTIL" => until_value = Some(value.to_owned()),
1258 "BYDAY" => {
1259 for day in value.split(',') {
1260 let (ordinal, name) = day.split_at(day.len().saturating_sub(2));
1261 if !ordinal.is_empty() {
1262 by_setpos = Some(ordinal.parse::<i32>().map_err(|_| unsupported())?);
1263 }
1264 by_day.push(weekday_from_ical(name).ok_or_else(unsupported)?);
1265 }
1266 }
1267 "BYMONTHDAY" => by_month_day = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1268 "BYMONTH" => by_month = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1269 "BYSETPOS" => by_setpos = Some(value.parse::<i32>().map_err(|_| unsupported())?),
1270 "WKST" => wkst = Some(weekday_from_ical(value).ok_or_else(unsupported)?),
1271 _ => return Err(unsupported()),
1272 }
1273 }
1274
1275 let index = match by_setpos {
1276 None => None,
1277 Some(1) => Some(MsgraphWeekIndex::First),
1278 Some(2) => Some(MsgraphWeekIndex::Second),
1279 Some(3) => Some(MsgraphWeekIndex::Third),
1280 Some(4) => Some(MsgraphWeekIndex::Fourth),
1281 Some(-1) => Some(MsgraphWeekIndex::Last),
1282 Some(_) => return Err(unsupported()),
1283 };
1284
1285 let pattern_type = match (freq.as_deref(), by_day.is_empty(), index) {
1286 (Some("DAILY"), true, None) => MsgraphRecurrencePatternType::Daily,
1287 (Some("WEEKLY"), _, None) => MsgraphRecurrencePatternType::Weekly,
1288 (Some("MONTHLY"), true, None) => MsgraphRecurrencePatternType::AbsoluteMonthly,
1289 (Some("MONTHLY"), false, Some(_)) => MsgraphRecurrencePatternType::RelativeMonthly,
1290 (Some("YEARLY"), true, None) => MsgraphRecurrencePatternType::AbsoluteYearly,
1291 (Some("YEARLY"), false, Some(_)) => MsgraphRecurrencePatternType::RelativeYearly,
1292 _ => return Err(unsupported()),
1293 };
1294
1295 let date = start.local.date();
1296 let mut days_of_week = by_day;
1297 if pattern_type == MsgraphRecurrencePatternType::Weekly && days_of_week.is_empty() {
1298 days_of_week.push(weekday_of(date));
1299 }
1300
1301 let (day_of_month, month) = match pattern_type {
1302 MsgraphRecurrencePatternType::AbsoluteMonthly => {
1303 (Some(by_month_day.unwrap_or(date.day() as u32)), None)
1304 }
1305 MsgraphRecurrencePatternType::AbsoluteYearly => (
1306 Some(by_month_day.unwrap_or(date.day() as u32)),
1307 Some(by_month.unwrap_or(date.month() as u32)),
1308 ),
1309 MsgraphRecurrencePatternType::RelativeYearly => {
1310 (None, Some(by_month.unwrap_or(date.month() as u32)))
1311 }
1312 _ => (None, None),
1313 };
1314
1315 let zone_name = (!start.all_day).then(|| start.zone.clone());
1316 let range = match (count, until_value) {
1317 (Some(count), _) => MsgraphRecurrenceRange {
1318 range_type: Some(MsgraphRecurrenceRangeType::Numbered),
1319 number_of_occurrences: Some(count),
1320 ..Default::default()
1321 },
1322 (None, Some(until)) => {
1323 let day = until.get(..8).ok_or_else(unsupported)?;
1324 let end = Date::strptime("%Y%m%d", day).map_err(|_| unsupported())?;
1325 MsgraphRecurrenceRange {
1326 range_type: Some(MsgraphRecurrenceRangeType::EndDate),
1327 end_date: Some(end.strftime("%Y-%m-%d").to_string()),
1328 ..Default::default()
1329 }
1330 }
1331 (None, None) => MsgraphRecurrenceRange {
1332 range_type: Some(MsgraphRecurrenceRangeType::NoEnd),
1333 ..Default::default()
1334 },
1335 };
1336
1337 Ok(MsgraphPatternedRecurrence {
1338 pattern: MsgraphRecurrencePattern {
1339 pattern_type: Some(pattern_type),
1340 interval: Some(interval.unwrap_or(1)),
1341 month,
1342 day_of_month,
1343 days_of_week,
1344 first_day_of_week: wkst,
1345 index,
1346 },
1347 range: MsgraphRecurrenceRange {
1348 start_date: Some(date.strftime("%Y-%m-%d").to_string()),
1349 recurrence_time_zone: zone_name,
1350 ..range
1351 },
1352 })
1353}
1354
1355fn find_vevent<'a>(
1358 calendar: &'a IcalCst<'a>,
1359 pick: impl Fn(&IcalCst<'_>) -> bool,
1360) -> Result<&'a IcalCst<'a>, MsgraphEventIcalError> {
1361 let components = || {
1362 calendar.items.iter().filter_map(|item| match item {
1363 IcalItem::Component(component) => Some(component.as_ref()),
1364 _ => None,
1365 })
1366 };
1367
1368 if let Some(vevent) = components()
1369 .filter(|component| component_name(component) == "VEVENT")
1370 .find(|vevent| pick(vevent))
1371 {
1372 return Ok(vevent);
1373 }
1374
1375 match components()
1376 .map(component_name)
1377 .find(|name| name != "VTIMEZONE" && name != "VEVENT")
1378 {
1379 Some(name) => Err(MsgraphEventIcalError::NotAnEvent(name)),
1380 None => Err(MsgraphEventIcalError::NoEvent),
1381 }
1382}
1383
1384fn component_name(component: &IcalCst<'_>) -> String {
1386 component
1387 .begin
1388 .as_ref()
1389 .map(|begin| begin.raw_value_str().trim().to_uppercase())
1390 .unwrap_or_default()
1391}
1392
1393fn has_prop(component: &IcalCst<'_>, name: &str) -> bool {
1395 component.items.iter().any(|item| match item {
1396 IcalItem::Prop(line) => line.name.get().eq_ignore_ascii_case(name),
1397 _ => false,
1398 })
1399}
1400
1401fn reminder_minutes(alarm: &IcalCst<'_>) -> Option<u32> {
1404 if component_name(alarm) != "VALARM" {
1405 return None;
1406 }
1407 let action = alarm.prop::<ACTION>()?;
1408 if !matches!(action.0.trim().to_uppercase().as_str(), "DISPLAY" | "AUDIO") {
1409 return None;
1410 }
1411
1412 let trigger = alarm.prop::<TRIGGER>()?;
1413 let lead = trigger.0.trim().strip_prefix('-')?.parse::<Span>().ok()?;
1414 let minutes = lead.total(jiff::Unit::Minute).ok()?;
1415
1416 let whole = minutes as u32;
1417 (minutes >= 0.0 && f64::from(whole) == minutes).then_some(whole)
1418}
1419
1420fn person_prop(
1422 kind: IcalPropKind,
1423 address: &MsgraphEmailAddress,
1424 mut params: Vec<IcalParam<'static>>,
1425) -> IcalProp<'static> {
1426 if let Some(name) = address.name.clone().filter(|name| !name.is_empty()) {
1427 params.insert(0, IcalParam::Cn(name.into()));
1428 }
1429 let email = address.address.clone().unwrap_or_default();
1430
1431 IcalProp {
1432 name: IcalPropName::Kind(kind),
1433 params,
1434 value: IcalValue::CalAddress(format!("mailto:{email}").into()),
1435 }
1436}
1437
1438fn attendee_prop(attendee: &MsgraphAttendee) -> Option<IcalProp<'static>> {
1440 let address = attendee.email_address.as_ref()?;
1441
1442 let mut params = Vec::new();
1443 match attendee.attendee_type {
1444 Some(MsgraphAttendeeType::Optional) => {
1445 params.push(IcalParam::Role("OPT-PARTICIPANT".into()))
1446 }
1447 Some(MsgraphAttendeeType::Resource) => {
1448 params.push(IcalParam::Role("NON-PARTICIPANT".into()));
1449 params.push(IcalParam::CuType("RESOURCE".into()));
1450 }
1451 _ => params.push(IcalParam::Role("REQ-PARTICIPANT".into())),
1452 }
1453
1454 let partstat = match attendee.status.as_ref().and_then(|status| status.response) {
1455 Some(MsgraphResponseType::Accepted | MsgraphResponseType::Organizer) => "ACCEPTED",
1456 Some(MsgraphResponseType::Declined) => "DECLINED",
1457 Some(MsgraphResponseType::TentativelyAccepted) => "TENTATIVE",
1458 _ => "NEEDS-ACTION",
1459 };
1460 params.push(IcalParam::PartStat(partstat.into()));
1461
1462 Some(person_prop(IcalPropKind::Attendee, address, params))
1463}
1464
1465fn attendee(line: &IcalLine<'_>) -> MsgraphAttendee {
1467 let role = line.param::<ROLE>().unwrap_or_default();
1468 let cutype = line.param::<CUTYPE>().unwrap_or_default();
1469
1470 let attendee_type =
1471 if cutype.eq_ignore_ascii_case("RESOURCE") || cutype.eq_ignore_ascii_case("ROOM") {
1472 MsgraphAttendeeType::Resource
1473 } else if role.eq_ignore_ascii_case("OPT-PARTICIPANT") {
1474 MsgraphAttendeeType::Optional
1475 } else {
1476 MsgraphAttendeeType::Required
1477 };
1478
1479 let response =
1480 line.param::<PARTSTAT>()
1481 .and_then(|status| match status.to_ascii_uppercase().as_str() {
1482 "ACCEPTED" => Some(MsgraphResponseType::Accepted),
1483 "DECLINED" => Some(MsgraphResponseType::Declined),
1484 "TENTATIVE" => Some(MsgraphResponseType::TentativelyAccepted),
1485 "NEEDS-ACTION" => Some(MsgraphResponseType::NotResponded),
1486 _ => None,
1487 });
1488
1489 let value = line.raw_value_str();
1490 let value = value.trim();
1491 let address = value
1492 .strip_prefix("mailto:")
1493 .or_else(|| value.strip_prefix("MAILTO:"))
1494 .unwrap_or(value)
1495 .to_owned();
1496
1497 MsgraphAttendee {
1498 attendee_type: Some(attendee_type),
1499 status: response.map(|response| MsgraphResponseStatus {
1500 response: Some(response),
1501 time: None,
1502 }),
1503 email_address: Some(MsgraphEmailAddress {
1504 name: line.param::<CN>().map(Cow::into_owned),
1505 address: Some(address),
1506 }),
1507 }
1508}
1509
1510fn anchor(event: &MsgraphEvent) -> i64 {
1512 event
1513 .start
1514 .as_option()
1515 .and_then(|start| start.date_time.get(..10))
1516 .and_then(|date| date.parse::<Date>().ok())
1517 .and_then(|date| date.to_zoned(TimeZone::UTC).ok())
1518 .map_or(0, |zoned| zoned.timestamp().as_second())
1519}
1520
1521fn ical_digits(date_time: &str) -> String {
1524 let base = date_time.split('.').next().unwrap_or(date_time);
1525 base.chars()
1526 .filter(|character| *character != '-' && *character != ':')
1527 .take(15)
1528 .collect()
1529}
1530
1531fn utc_stamp(instant: &str) -> Option<String> {
1533 let instant = instant.parse::<Timestamp>().ok()?;
1534 Some(instant.strftime("%Y%m%dT%H%M%SZ").to_string())
1535}
1536
1537fn stamp_prop(kind: IcalPropKind, stamp: String) -> IcalProp<'static> {
1539 IcalProp {
1540 name: IcalPropName::Kind(kind),
1541 params: Vec::new(),
1542 value: IcalValue::DateTime(IcalDateTime(stamp.into())),
1543 }
1544}
1545
1546fn body_text(body: &MsgraphField<MsgraphItemBody>) -> String {
1549 let Some(body) = body.as_option() else {
1550 return String::new();
1551 };
1552 let content = body.content.as_deref().unwrap_or_default();
1553 match body.content_type {
1554 Some(MsgraphBodyType::Html) => strip_html(content),
1555 _ => content.to_owned(),
1556 }
1557}
1558
1559fn strip_html(html: &str) -> String {
1561 let mut text = String::with_capacity(html.len());
1562 let mut in_tag = false;
1563 for character in html.chars() {
1564 match character {
1565 '<' => in_tag = true,
1566 '>' => in_tag = false,
1567 _ if !in_tag => text.push(character),
1568 _ => {}
1569 }
1570 }
1571 text.replace(" ", " ")
1572 .replace("&", "&")
1573 .replace("<", "<")
1574 .replace(">", ">")
1575 .trim()
1576 .to_owned()
1577}
1578
1579fn same_text(left: &str, right: &str) -> bool {
1582 left.split_whitespace().eq(right.split_whitespace())
1583}
1584
1585fn stash_lines(event: &MsgraphEvent) -> Vec<String> {
1587 event
1588 .single_value_extended_properties
1589 .as_option()
1590 .map(Vec::as_slice)
1591 .unwrap_or(&[])
1592 .iter()
1593 .filter(|prop| prop.id.to_ascii_lowercase().contains("pimalaya-ical"))
1594 .flat_map(|prop| prop.value.split('\n'))
1595 .filter(|line| !line.is_empty())
1596 .map(str::to_owned)
1597 .collect()
1598}
1599
1600fn is_uid_line(line: &str) -> bool {
1602 let name = line.split([';', ':']).next().unwrap_or_default();
1603 name.eq_ignore_ascii_case("UID")
1604}
1605
1606fn is_minted_line(line: &str) -> bool {
1608 let name = line.split([';', ':']).next().unwrap_or_default();
1609 MINTED_PROPS
1610 .iter()
1611 .any(|minted| name.eq_ignore_ascii_case(minted))
1612}
1613
1614fn text(line: &IcalLine<'_>) -> String {
1616 IcalText::decode(&line.value).0.into_owned()
1617}
1618
1619fn raw_line(line: &IcalLine<'_>) -> String {
1621 line.to_string().trim_end_matches(['\r', '\n']).to_owned()
1622}
1623
1624fn raw_component(component: &IcalCst<'_>) -> Vec<String> {
1626 component
1627 .to_string()
1628 .lines()
1629 .map(|line| line.trim_end_matches('\r').to_owned())
1630 .filter(|line| !line.is_empty())
1631 .collect()
1632}
1633
1634fn setpos(index: Option<MsgraphWeekIndex>) -> i32 {
1636 match index {
1637 Some(MsgraphWeekIndex::Second) => 2,
1638 Some(MsgraphWeekIndex::Third) => 3,
1639 Some(MsgraphWeekIndex::Fourth) => 4,
1640 Some(MsgraphWeekIndex::Last) => -1,
1641 _ => 1,
1642 }
1643}
1644
1645fn weekday_to_ical(day: MsgraphDayOfWeek) -> &'static str {
1647 match day {
1648 MsgraphDayOfWeek::Sunday => "SU",
1649 MsgraphDayOfWeek::Monday => "MO",
1650 MsgraphDayOfWeek::Tuesday => "TU",
1651 MsgraphDayOfWeek::Wednesday => "WE",
1652 MsgraphDayOfWeek::Thursday => "TH",
1653 MsgraphDayOfWeek::Friday => "FR",
1654 MsgraphDayOfWeek::Saturday => "SA",
1655 }
1656}
1657
1658fn weekday_from_ical(day: &str) -> Option<MsgraphDayOfWeek> {
1660 Some(match day.to_ascii_uppercase().as_str() {
1661 "SU" => MsgraphDayOfWeek::Sunday,
1662 "MO" => MsgraphDayOfWeek::Monday,
1663 "TU" => MsgraphDayOfWeek::Tuesday,
1664 "WE" => MsgraphDayOfWeek::Wednesday,
1665 "TH" => MsgraphDayOfWeek::Thursday,
1666 "FR" => MsgraphDayOfWeek::Friday,
1667 "SA" => MsgraphDayOfWeek::Saturday,
1668 _ => return None,
1669 })
1670}
1671
1672fn weekday_of(date: Date) -> MsgraphDayOfWeek {
1674 use jiff::civil::Weekday;
1675 match date.weekday() {
1676 Weekday::Sunday => MsgraphDayOfWeek::Sunday,
1677 Weekday::Monday => MsgraphDayOfWeek::Monday,
1678 Weekday::Tuesday => MsgraphDayOfWeek::Tuesday,
1679 Weekday::Wednesday => MsgraphDayOfWeek::Wednesday,
1680 Weekday::Thursday => MsgraphDayOfWeek::Thursday,
1681 Weekday::Friday => MsgraphDayOfWeek::Friday,
1682 Weekday::Saturday => MsgraphDayOfWeek::Saturday,
1683 }
1684}
1685
1686fn busy_status_to_ical(show_as: MsgraphFreeBusyStatus) -> &'static str {
1688 match show_as {
1689 MsgraphFreeBusyStatus::Free => "FREE",
1690 MsgraphFreeBusyStatus::Tentative => "TENTATIVE",
1691 MsgraphFreeBusyStatus::Busy => "BUSY",
1692 MsgraphFreeBusyStatus::Oof => "OOF",
1693 MsgraphFreeBusyStatus::WorkingElsewhere => "WORKINGELSEWHERE",
1694 MsgraphFreeBusyStatus::Unknown => "UNKNOWN",
1695 }
1696}
1697
1698fn busy_status_from_ical(value: &str) -> Option<MsgraphFreeBusyStatus> {
1700 Some(match value.trim().to_ascii_uppercase().as_str() {
1701 "FREE" => MsgraphFreeBusyStatus::Free,
1702 "TENTATIVE" => MsgraphFreeBusyStatus::Tentative,
1703 "BUSY" => MsgraphFreeBusyStatus::Busy,
1704 "OOF" => MsgraphFreeBusyStatus::Oof,
1705 "WORKINGELSEWHERE" => MsgraphFreeBusyStatus::WorkingElsewhere,
1706 _ => return None,
1707 })
1708}
1709
1710fn sensitivity_to_ical(sensitivity: MsgraphSensitivity) -> &'static str {
1712 match sensitivity {
1713 MsgraphSensitivity::Normal => "PUBLIC",
1714 MsgraphSensitivity::Personal | MsgraphSensitivity::Private => "PRIVATE",
1715 MsgraphSensitivity::Confidential => "CONFIDENTIAL",
1716 }
1717}
1718
1719fn sensitivity_from_ical(value: &str) -> Option<MsgraphSensitivity> {
1721 Some(match value.trim().to_ascii_uppercase().as_str() {
1722 "PUBLIC" => MsgraphSensitivity::Normal,
1723 "PRIVATE" => MsgraphSensitivity::Private,
1724 "CONFIDENTIAL" => MsgraphSensitivity::Confidential,
1725 _ => return None,
1726 })
1727}
1728
1729fn importance_from_ical(value: &str) -> Option<MsgraphImportance> {
1731 match value.trim().parse::<u8>().ok()? {
1732 1..=4 => Some(MsgraphImportance::High),
1733 5 => Some(MsgraphImportance::Normal),
1734 6..=9 => Some(MsgraphImportance::Low),
1735 _ => None,
1736 }
1737}
1738
1739const GLOBAL_OBJECT_ID_CLASS: &str = "040000008200E00074C5B7101A82E008";
1742
1743const VCAL_UID_MARKER: &[u8] = b"vCal-Uid\x01\x00\x00\x00";
1746
1747pub fn original_uid(ical_uid: &str) -> String {
1756 unwrap_global_object_id(ical_uid).unwrap_or_else(|| ical_uid.to_owned())
1757}
1758
1759fn unwrap_global_object_id(ical_uid: &str) -> Option<String> {
1760 let class = ical_uid.get(..GLOBAL_OBJECT_ID_CLASS.len())?;
1761 if !class.eq_ignore_ascii_case(GLOBAL_OBJECT_ID_CLASS) {
1762 return None;
1763 }
1764 let rest = &ical_uid.as_bytes()[GLOBAL_OBJECT_ID_CLASS.len()..];
1765 if !rest.len().is_multiple_of(2) {
1766 return None;
1767 }
1768 let bytes = rest
1769 .chunks(2)
1770 .map(|pair| u8::from_str_radix(core::str::from_utf8(pair).ok()?, 16).ok())
1771 .collect::<Option<Vec<u8>>>()?;
1772 let size = u32::from_le_bytes(bytes.get(20..24)?.try_into().ok()?) as usize;
1775 let data = bytes.get(24..24usize.checked_add(size)?)?;
1776 let uid = data.strip_prefix(VCAL_UID_MARKER)?;
1777 let end = uid.iter().position(|byte| *byte == 0).unwrap_or(uid.len());
1778 String::from_utf8(uid[..end].to_vec())
1779 .ok()
1780 .filter(|uid| !uid.is_empty())
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785 use super::*;
1786 use crate::v1::rest::users::messages::MsgraphRecipient;
1787
1788 fn master() -> MsgraphEvent {
1790 MsgraphEvent {
1791 id: "SM1".into(),
1792 ical_uid: Some("040000008200E0".into()),
1793 last_modified_date_time: Some("2026-08-01T10:00:00Z".into()),
1794 subject: MsgraphField::Set("Stand-up".into()),
1795 start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1796 date_time: "2026-08-14T09:00:00.0000000".into(),
1797 time_zone: Some("Romance Standard Time".into()),
1798 }),
1799 end: MsgraphField::Set(MsgraphDateTimeTimeZone {
1800 date_time: "2026-08-14T09:15:00.0000000".into(),
1801 time_zone: Some("Romance Standard Time".into()),
1802 }),
1803 is_all_day: MsgraphField::Set(false),
1804 recurrence: MsgraphField::Set(MsgraphPatternedRecurrence {
1805 pattern: MsgraphRecurrencePattern {
1806 pattern_type: Some(MsgraphRecurrencePatternType::Weekly),
1807 interval: Some(1),
1808 days_of_week: vec![MsgraphDayOfWeek::Monday, MsgraphDayOfWeek::Friday],
1809 first_day_of_week: Some(MsgraphDayOfWeek::Monday),
1810 ..Default::default()
1811 },
1812 range: MsgraphRecurrenceRange {
1813 range_type: Some(MsgraphRecurrenceRangeType::Numbered),
1814 start_date: Some("2026-08-14".into()),
1815 number_of_occurrences: Some(10),
1816 ..Default::default()
1817 },
1818 }),
1819 organizer: MsgraphField::Set(MsgraphRecipient {
1820 email_address: MsgraphEmailAddress {
1821 name: Some("Alice".into()),
1822 address: Some("alice@x.org".into()),
1823 },
1824 }),
1825 attendees: MsgraphField::Set(vec![MsgraphAttendee {
1826 attendee_type: Some(MsgraphAttendeeType::Optional),
1827 status: Some(MsgraphResponseStatus {
1828 response: Some(MsgraphResponseType::Accepted),
1829 time: None,
1830 }),
1831 email_address: Some(MsgraphEmailAddress {
1832 name: Some("Bob".into()),
1833 address: Some("bob@x.org".into()),
1834 }),
1835 }]),
1836 show_as: MsgraphField::Set(MsgraphFreeBusyStatus::Tentative),
1837 is_reminder_on: MsgraphField::Set(true),
1838 reminder_minutes_before_start: MsgraphField::Set(15),
1839 cancelled_occurrences: vec!["OID.SM1.2026-08-21".into()],
1840 ..Default::default()
1841 }
1842 }
1843
1844 #[test]
1845 fn a_windows_zoned_series_projects_with_its_rule_and_zone() {
1846 let ical = master().to_ical();
1847
1848 assert!(ical.contains("UID:040000008200E0\r\n"), "{ical}");
1849 assert!(
1850 ical.contains("BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\n"),
1851 "{ical}"
1852 );
1853 assert!(
1854 ical.contains("DTSTART;TZID=Europe/Paris:20260814T090000\r\n"),
1855 "{ical}"
1856 );
1857 assert!(
1858 ical.contains("DTEND;TZID=Europe/Paris:20260814T091500\r\n"),
1859 "{ical}"
1860 );
1861 assert!(
1862 ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO,FR;WKST=MO;COUNT=10\r\n"),
1863 "{ical}"
1864 );
1865 assert!(
1866 ical.contains("EXDATE;TZID=Europe/Paris:20260821T090000\r\n"),
1867 "{ical}"
1868 );
1869 assert!(
1870 ical.contains("ORGANIZER;CN=Alice:mailto:alice@x.org\r\n"),
1871 "{ical}"
1872 );
1873 assert!(
1874 ical.contains(
1875 "ATTENDEE;CN=Bob;ROLE=OPT-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:bob@x.org\r\n"
1876 ),
1877 "{ical}"
1878 );
1879 assert!(
1880 ical.contains("X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE\r\n"),
1881 "{ical}"
1882 );
1883 assert!(
1884 ical.contains("BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\n"),
1885 "{ical}"
1886 );
1887 }
1888
1889 #[test]
1890 fn an_exception_carries_the_series_uid_and_its_original_start() {
1891 let exception = MsgraphEvent {
1892 id: "EX1".into(),
1893 ical_uid: Some("other".into()),
1894 event_type: Some(crate::v1::rest::users::events::MsgraphEventType::Exception),
1895 series_master_id: Some("SM1".into()),
1896 original_start: Some("2026-08-17T07:00:00Z".into()),
1898 subject: MsgraphField::Set("Stand-up, moved".into()),
1899 start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1900 date_time: "2026-08-17T10:00:00.0000000".into(),
1901 time_zone: Some("Romance Standard Time".into()),
1902 }),
1903 ..Default::default()
1904 };
1905
1906 let ical = master().to_ical_series(&[&exception]);
1907
1908 assert_eq!(ical.matches("BEGIN:VEVENT").count(), 2, "{ical}");
1909 assert_eq!(ical.matches("UID:040000008200E0\r\n").count(), 2, "{ical}");
1910 assert!(
1911 ical.contains("RECURRENCE-ID;TZID=Europe/Paris:20260817T090000\r\n"),
1912 "{ical}"
1913 );
1914 assert_eq!(ical.matches("RRULE:FREQ=WEEKLY").count(), 1, "{ical}");
1915 }
1916
1917 const WRAPPED: &str = "040000008200E00074C5B7101A82E00800000000000000000000000000000000000000002D0000007643616C2D556964010000006D6F612D393839663030616435646236393839663030616435646236406D6F6100";
1920
1921 #[test]
1922 fn a_global_object_id_gives_back_the_uid_it_wraps() {
1923 let uid = "moa-989f00ad5db6989f00ad5db6@moa";
1924 assert_eq!(original_uid(WRAPPED), uid);
1925 assert_eq!(original_uid(&WRAPPED.to_lowercase()), uid);
1926
1927 let minted = "040000008200E00074C5B7101A82E0080000000088DCD2137A53DD01000000000000000010000000F70EDDABBC4DD5439803DF5489114F0B";
1929 assert_eq!(original_uid(minted), minted);
1930 assert_eq!(original_uid("040000008200E0"), "040000008200E0");
1931 assert_eq!(original_uid("urn:uuid:4fbe8971"), "urn:uuid:4fbe8971");
1932 assert_eq!(
1934 original_uid(&WRAPPED[..WRAPPED.len() - 4]),
1935 &WRAPPED[..WRAPPED.len() - 4]
1936 );
1937 }
1938
1939 #[test]
1940 fn an_invitation_taken_in_by_mail_keeps_the_organizer_uid() {
1941 let mut event = master();
1942 event.ical_uid = Some(WRAPPED.into());
1943 let exception = MsgraphEvent {
1944 id: "EX1".into(),
1945 ical_uid: Some(WRAPPED.into()),
1946 event_type: Some(crate::v1::rest::users::events::MsgraphEventType::Exception),
1947 series_master_id: Some("SM1".into()),
1948 original_start: Some("2026-08-17T07:00:00Z".into()),
1949 ..Default::default()
1950 };
1951
1952 let ical = event.to_ical_series(&[&exception]);
1953
1954 assert_eq!(
1955 ical.matches("UID:moa-989f00ad5db6989f00ad5db6@moa\r\n")
1956 .count(),
1957 2,
1958 "{ical}"
1959 );
1960 }
1961
1962 #[test]
1963 fn an_occurrence_is_told_by_the_recurrence_id_the_projection_writes() {
1964 assert_eq!(
1966 master().recurrence_id_of("2026-08-17T07:00:00Z").as_deref(),
1967 Some("20260817T090000")
1968 );
1969 assert_eq!(
1970 master()
1971 .recurrence_id_of("2026-08-17T07:00:00.0000000Z")
1972 .as_deref(),
1973 Some("20260817T090000")
1974 );
1975
1976 let mut all_day = master();
1977 all_day.is_all_day = MsgraphField::Set(true);
1978 assert_eq!(
1979 all_day.recurrence_id_of("2026-08-17T00:00:00Z").as_deref(),
1980 Some("20260817")
1981 );
1982
1983 assert_eq!(master().recurrence_id_of("not a time"), None);
1984 }
1985
1986 #[test]
1987 fn an_all_day_event_projects_as_dates_and_back() {
1988 let event = MsgraphEvent {
1989 id: "AD1".into(),
1990 subject: MsgraphField::Set("Holiday".into()),
1991 is_all_day: MsgraphField::Set(true),
1992 start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1993 date_time: "2026-08-14T00:00:00.0000000".into(),
1994 time_zone: Some("UTC".into()),
1995 }),
1996 end: MsgraphField::Set(MsgraphDateTimeTimeZone {
1997 date_time: "2026-08-15T00:00:00.0000000".into(),
1998 time_zone: Some("UTC".into()),
1999 }),
2000 ..Default::default()
2001 };
2002
2003 let ical = event.to_ical();
2004 assert!(ical.contains("DTSTART;VALUE=DATE:20260814\r\n"), "{ical}");
2005 assert!(ical.contains("DTEND;VALUE=DATE:20260815\r\n"), "{ical}");
2006
2007 let back = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
2008 assert_eq!(back.is_all_day.as_option(), Some(&true));
2009 assert_eq!(
2010 back.start.as_option().map(|start| start.date_time.as_str()),
2011 Some("2026-08-14T00:00:00")
2012 );
2013 }
2014
2015 #[test]
2016 fn a_series_reads_back_with_its_recurrence() {
2017 let event = MsgraphEvent::from_ical(master().to_ical().as_bytes()).unwrap();
2018
2019 assert_eq!(
2020 event.subject.as_option().map(String::as_str),
2021 Some("Stand-up")
2022 );
2023 let start = event.start.as_option().unwrap();
2024 assert_eq!(start.date_time, "2026-08-14T09:00:00");
2025 assert_eq!(start.time_zone.as_deref(), Some("Europe/Paris"));
2026
2027 let recurrence = event.recurrence.as_option().unwrap();
2028 assert_eq!(
2029 recurrence.pattern.pattern_type,
2030 Some(MsgraphRecurrencePatternType::Weekly)
2031 );
2032 assert_eq!(
2033 recurrence.pattern.days_of_week,
2034 [MsgraphDayOfWeek::Monday, MsgraphDayOfWeek::Friday]
2035 );
2036 assert_eq!(recurrence.range.number_of_occurrences, Some(10));
2037 assert_eq!(recurrence.range.start_date.as_deref(), Some("2026-08-14"));
2038 assert_eq!(
2039 event.show_as.as_option(),
2040 Some(&MsgraphFreeBusyStatus::Tentative)
2041 );
2042 assert_eq!(event.reminder_minutes_before_start.as_option(), Some(&15));
2043 assert_eq!(event.attendees.as_option().map(Vec::len), Some(1));
2044 assert!(
2046 !stash_lines(&event)
2047 .iter()
2048 .any(|line| line.starts_with("EXDATE")),
2049 "{:?}",
2050 stash_lines(&event)
2051 );
2052 }
2053
2054 #[test]
2055 fn a_relative_monthly_rule_round_trips_through_its_index() {
2056 let ical = concat!(
2057 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n",
2058 "DTSTART:20260827T120000Z\r\nDTEND:20260827T130000Z\r\n",
2059 "RRULE:FREQ=MONTHLY;BYDAY=TH;BYSETPOS=-1\r\n",
2060 "END:VEVENT\r\nEND:VCALENDAR\r\n",
2061 );
2062
2063 let event = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
2064 let recurrence = event.recurrence.as_option().unwrap();
2065 assert_eq!(
2066 recurrence.pattern.pattern_type,
2067 Some(MsgraphRecurrencePatternType::RelativeMonthly)
2068 );
2069 assert_eq!(recurrence.pattern.index, Some(MsgraphWeekIndex::Last));
2070
2071 let back = event.to_ical();
2072 assert!(
2073 back.contains("RRULE:FREQ=MONTHLY;BYDAY=TH;BYSETPOS=-1\r\n"),
2074 "{back}"
2075 );
2076 }
2077
2078 #[test]
2079 fn an_end_date_ends_at_midnight_in_the_series_zone() {
2080 let mut event = master();
2081 if let MsgraphField::Set(recurrence) = &mut event.recurrence {
2082 recurrence.range = MsgraphRecurrenceRange {
2083 range_type: Some(MsgraphRecurrenceRangeType::EndDate),
2084 start_date: Some("2026-08-14".into()),
2085 end_date: Some("2026-09-30".into()),
2086 recurrence_time_zone: Some("Romance Standard Time".into()),
2087 ..Default::default()
2088 };
2089 }
2090
2091 assert!(event.to_ical().contains(";UNTIL=20260930T215959Z\r\n"));
2093 }
2094
2095 #[test]
2096 fn a_series_is_bounded_by_its_range_type_not_the_sentinel_end() {
2097 let bounds = |range_type, interval| {
2098 let recurrence = MsgraphPatternedRecurrence {
2099 pattern: MsgraphRecurrencePattern {
2100 pattern_type: Some(MsgraphRecurrencePatternType::Weekly),
2101 interval,
2102 ..Default::default()
2103 },
2104 range: MsgraphRecurrenceRange {
2105 range_type: Some(range_type),
2106 start_date: Some("2026-09-28".into()),
2107 end_date: Some("0001-01-01".into()),
2108 number_of_occurrences: Some(11),
2109 ..Default::default()
2110 },
2111 };
2112 recurrence
2113 .bounds()
2114 .map(|(start, end)| (start.to_string(), end.map(|end| end.to_string())))
2115 };
2116
2117 let start = String::from("2026-09-28");
2118 assert_eq!(
2119 bounds(MsgraphRecurrenceRangeType::Numbered, None),
2120 Some((start.clone(), Some("2026-12-14".into()))),
2121 );
2122 assert_eq!(
2123 bounds(MsgraphRecurrenceRangeType::Numbered, Some(2)),
2124 Some((start.clone(), Some("2027-03-01".into()))),
2125 );
2126 assert_eq!(
2127 bounds(MsgraphRecurrenceRangeType::NoEnd, None),
2128 Some((start.clone(), None)),
2129 );
2130 assert_eq!(
2131 bounds(MsgraphRecurrenceRangeType::EndDate, None),
2132 Some((start, Some("0001-01-01".into()))),
2133 );
2134 }
2135
2136 #[test]
2137 fn what_graph_cannot_hold_is_refused_by_name() {
2138 let with = |props: &str| {
2139 format!(
2140 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n{props}END:VEVENT\r\nEND:VCALENDAR\r\n"
2141 )
2142 };
2143
2144 let hourly =
2145 with("DTSTART:20260814T090000Z\r\nDTEND:20260814T100000Z\r\nRRULE:FREQ=HOURLY\r\n");
2146 let floating = with("DTSTART:20260814T090000\r\nDTEND:20260814T100000\r\n");
2147 let todo = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
2148
2149 assert!(matches!(
2150 MsgraphEvent::from_ical(hourly.as_bytes()),
2151 Err(MsgraphEventIcalError::UnsupportedRecurrence(_))
2152 ));
2153 assert!(matches!(
2154 MsgraphEvent::from_ical(floating.as_bytes()),
2155 Err(MsgraphEventIcalError::NoZonedStart)
2156 ));
2157 assert!(matches!(
2158 MsgraphEvent::from_ical(todo.as_bytes()),
2159 Err(MsgraphEventIcalError::NotAnEvent(name)) if name == "VTODO"
2160 ));
2161 }
2162
2163 #[test]
2164 fn a_duration_gives_the_end() {
2165 let ical = concat!(
2166 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n",
2167 "DTSTART;TZID=Europe/Paris:20260814T090000\r\nDURATION:PT1H30M\r\n",
2168 "END:VEVENT\r\nEND:VCALENDAR\r\n",
2169 );
2170
2171 let event = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
2172 let end = event.end.as_option().unwrap();
2173 assert_eq!(end.date_time, "2026-08-14T10:30:00");
2174 assert_eq!(end.time_zone.as_deref(), Some("Europe/Paris"));
2175 }
2176
2177 #[test]
2178 fn the_uid_and_the_remainder_ride_the_stash() {
2179 let ical = concat!(
2180 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:urn:uuid:4fbe8971\r\n",
2181 "DTSTART:20260814T090000Z\r\nDTEND:20260814T100000Z\r\nSUMMARY:Lunch\r\n",
2182 "X-CUSTOM;X-P=1:kept\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
2183 );
2184
2185 let mut event = MsgraphEvent::create_from_ical(ical.as_bytes()).unwrap();
2186 assert_eq!(event.stashed_uid().as_deref(), Some("urn:uuid:4fbe8971"));
2187
2188 event.id = "EV9".into();
2191 event.ical_uid = Some("040000008200E0".into());
2192 let back = event.to_ical();
2193
2194 assert!(back.contains("UID:urn:uuid:4fbe8971\r\n"), "{back}");
2195 assert!(!back.contains("UID:040000008200E0"), "{back}");
2196 assert!(back.contains("X-CUSTOM;X-P=1:kept\r\n"), "{back}");
2197 }
2198
2199 #[test]
2200 fn the_join_link_of_an_online_meeting_is_minted_read_only() {
2201 let mut event = master();
2202 event.online_meeting = Some(crate::v1::rest::users::events::MsgraphOnlineMeetingInfo {
2203 join_url: Some("https://teams.x.org/l/1".into()),
2204 });
2205 event.single_value_extended_properties =
2207 MsgraphField::Set(vec![MsgraphSingleValueExtendedProperty {
2208 id: MSGRAPH_EVENT_STASH_ID.into(),
2209 value: "X-MICROSOFT-SKYPETEAMSMEETINGURL:https://teams.x.org/l/0".into(),
2210 }]);
2211 let ical = event.to_ical();
2212
2213 assert!(
2214 ical.contains("CONFERENCE;VALUE=URI;FEATURE=AUDIO,VIDEO:https://teams.x.org/l/1\r\n"),
2215 "{ical}"
2216 );
2217 assert!(!ical.contains("SKYPETEAMS"), "{ical}");
2218 assert!(!ical.contains("teams.x.org/l/0"), "{ical}");
2219
2220 let back = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
2221 assert!(back.online_meeting.is_none());
2222 assert!(back.is_online_meeting.is_unset());
2223 assert!(
2224 !stash_lines(&back)
2225 .iter()
2226 .any(|line| line.contains("CONFERENCE") || line.contains("SKYPETEAMS")),
2227 "{back:?}"
2228 );
2229 }
2230
2231 #[test]
2232 fn an_online_meeting_asked_for_sets_is_online_meeting_once() {
2233 let base = master().to_ical();
2234 let asked = base.replace(
2235 "SUMMARY:Stand-up\r\n",
2236 "SUMMARY:Stand-up\r\nX-PIMDIR-ONLINE-MEETING:TRUE\r\n",
2237 );
2238 assert_ne!(asked, base);
2239
2240 let created = MsgraphEvent::from_ical(asked.as_bytes()).unwrap();
2241 assert_eq!(created.is_online_meeting, MsgraphField::Set(true));
2242 assert!(
2243 !stash_lines(&created)
2244 .iter()
2245 .any(|line| line.contains("ONLINE-MEETING")),
2246 "{created:?}"
2247 );
2248 let body = serde_json::to_value(&created).unwrap();
2249 assert_eq!(body["isOnlineMeeting"], true);
2250
2251 let patch = MsgraphEvent::update_from_ical(asked.as_bytes(), base.as_bytes()).unwrap();
2252 assert_eq!(patch.is_online_meeting, MsgraphField::Set(true));
2253
2254 let edited = asked.replace("SUMMARY:Stand-up", "SUMMARY:Daily");
2256 let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), asked.as_bytes()).unwrap();
2257 assert!(patch.is_online_meeting.is_unset());
2258
2259 let declined = base.replace(
2260 "SUMMARY:Stand-up\r\n",
2261 "SUMMARY:Stand-up\r\nX-PIMDIR-ONLINE-MEETING:FALSE\r\n",
2262 );
2263 let event = MsgraphEvent::from_ical(declined.as_bytes()).unwrap();
2264 assert!(event.is_online_meeting.is_unset());
2265 let body = serde_json::to_value(&event).unwrap();
2266 assert!(body.get("isOnlineMeeting").is_none());
2267 }
2268
2269 #[test]
2270 fn an_update_sends_only_what_changed() {
2271 let base = master().to_ical();
2272 let edited = base.replace("SUMMARY:Stand-up", "SUMMARY:Daily");
2273
2274 let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), base.as_bytes()).unwrap();
2275
2276 assert_eq!(patch.subject.as_option().map(String::as_str), Some("Daily"));
2277 assert!(patch.start.is_unset());
2278 assert!(patch.recurrence.is_unset());
2279 assert!(patch.attendees.is_unset());
2280 assert!(patch.single_value_extended_properties.is_unset());
2281 }
2282
2283 fn occurrence(hour: &str, summary: &str, extra: &str) -> String {
2286 format!(
2287 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\nBEGIN:VEVENT\r\n\
2288 UID:040000008200E0\r\nRECURRENCE-ID;TZID=Europe/Paris:20260817T090000\r\n\
2289 DTSTART;TZID=Europe/Paris:20260817T{hour}0000\r\n\
2290 DTEND;TZID=Europe/Paris:20260817T{hour}1500\r\n\
2291 SUMMARY:{summary}\r\n{extra}END:VEVENT\r\nEND:VCALENDAR\r\n"
2292 )
2293 }
2294
2295 #[test]
2296 fn an_occurrence_moved_alone_sends_its_new_times() {
2297 let base = occurrence("09", "Stand-up", "");
2298 let moved = occurrence("10", "Stand-up", "");
2299
2300 let patch = MsgraphEvent::instance_update_from_ical(moved.as_bytes(), base.as_bytes())
2301 .unwrap()
2302 .unwrap();
2303
2304 let start = patch.start.as_option().unwrap();
2305 assert_eq!(start.date_time, "2026-08-17T10:00:00");
2306 assert_eq!(start.time_zone.as_deref(), Some("Europe/Paris"));
2307 assert_eq!(
2308 patch.end.as_option().unwrap().date_time,
2309 "2026-08-17T10:15:00"
2310 );
2311 assert!(patch.subject.is_unset());
2312 assert!(patch.body.is_unset());
2313 assert!(patch.attendees.is_unset());
2314 assert!(patch.recurrence.is_unset());
2315 assert!(patch.single_value_extended_properties.is_unset());
2316 }
2317
2318 #[test]
2319 fn a_reverted_occurrence_takes_the_series_fields_back() {
2320 let exception = occurrence("10", "Stand-up, moved", "");
2321 let plain = occurrence("09", "Stand-up", "");
2322
2323 let patch = MsgraphEvent::instance_update_from_ical(plain.as_bytes(), exception.as_bytes())
2324 .unwrap()
2325 .unwrap();
2326
2327 assert_eq!(
2328 patch.subject.as_option().map(String::as_str),
2329 Some("Stand-up")
2330 );
2331 assert_eq!(
2332 patch.start.as_option().unwrap().date_time,
2333 "2026-08-17T09:00:00"
2334 );
2335 }
2336
2337 #[test]
2340 fn an_occurrence_as_graph_holds_it_sends_nothing() {
2341 let held = occurrence("09", "Stand-up", "");
2342 let carried = occurrence("09", "Stand-up", "RRULE:FREQ=WEEKLY\r\nX-OWN:kept\r\n");
2343
2344 assert_eq!(
2345 MsgraphEvent::instance_update_from_ical(held.as_bytes(), held.as_bytes()).unwrap(),
2346 None
2347 );
2348 assert_eq!(
2349 MsgraphEvent::instance_update_from_ical(carried.as_bytes(), held.as_bytes()).unwrap(),
2350 None
2351 );
2352 }
2353
2354 fn utc_read() -> MsgraphEvent {
2356 let utc = |date_time: &str| {
2357 MsgraphField::Set(MsgraphDateTimeTimeZone {
2358 date_time: date_time.into(),
2359 time_zone: Some("UTC".into()),
2360 })
2361 };
2362
2363 MsgraphEvent {
2364 id: "E1".into(),
2365 start: utc("2030-01-10T09:00:00.0000000"),
2366 end: utc("2030-01-10T10:00:00.0000000"),
2367 is_all_day: MsgraphField::Set(false),
2368 original_start_time_zone: Some("Romance Standard Time".into()),
2369 original_end_time_zone: Some("Romance Standard Time".into()),
2370 ..Default::default()
2371 }
2372 }
2373
2374 #[test]
2375 fn a_utc_read_is_told_in_its_original_zone() {
2376 let ical = utc_read().to_ical();
2377
2378 assert!(
2379 ical.contains("DTSTART;TZID=Europe/Paris:20300110T100000\r\n"),
2380 "{ical}"
2381 );
2382 assert!(
2383 ical.contains("DTEND;TZID=Europe/Paris:20300110T110000\r\n"),
2384 "{ical}"
2385 );
2386 assert!(
2387 ical.contains("BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\n"),
2388 "{ical}"
2389 );
2390 }
2391
2392 #[test]
2393 fn an_unknown_original_zone_stays_utc() {
2394 let mut event = utc_read();
2395 event.original_start_time_zone = Some("tzone://Microsoft/Custom".into());
2396 event.original_end_time_zone = Some("tzone://Microsoft/Custom".into());
2397
2398 let ical = event.to_ical();
2399
2400 assert!(ical.contains("DTSTART:20300110T090000Z\r\n"), "{ical}");
2401 }
2402
2403 #[test]
2404 fn an_all_day_read_keeps_its_dates() {
2405 let mut event = utc_read();
2406 event.is_all_day = MsgraphField::Set(true);
2407 event.start = MsgraphField::Set(MsgraphDateTimeTimeZone {
2408 date_time: "2030-01-10T00:00:00.0000000".into(),
2409 time_zone: Some("UTC".into()),
2410 });
2411 event.end = MsgraphField::Set(MsgraphDateTimeTimeZone {
2412 date_time: "2030-01-11T00:00:00.0000000".into(),
2413 time_zone: Some("UTC".into()),
2414 });
2415
2416 let ical = event.to_ical();
2417
2418 assert!(ical.contains("DTSTART;VALUE=DATE:20300110\r\n"), "{ical}");
2419 }
2420
2421 fn html_read() -> String {
2423 let mut event = utc_read();
2424 event.body = MsgraphField::Set(MsgraphItemBody {
2425 content_type: Some(MsgraphBodyType::Html),
2426 content: Some(
2427 "<html><body>\r\n<div class=\"PlainText\">old notes</div>\r\n</body></html>".into(),
2428 ),
2429 });
2430 event.to_ical()
2431 }
2432
2433 #[test]
2434 fn an_untouched_description_keeps_the_html() {
2435 let read = html_read();
2436
2437 let event = MsgraphEvent::from_ical(read.as_bytes()).unwrap();
2438
2439 let body = event.body.as_option().unwrap();
2440 assert_eq!(body.content_type, Some(MsgraphBodyType::Html));
2441 }
2442
2443 #[test]
2444 fn an_edited_description_beats_its_stale_html() {
2445 let read = html_read();
2446 let edited = read.replace("DESCRIPTION:old notes", "DESCRIPTION:new notes");
2447 assert_ne!(edited, read, "the document carries the stripped text");
2448
2449 let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), read.as_bytes()).unwrap();
2450
2451 let body = patch.body.as_option().expect("the edit reaches the patch");
2452 assert_eq!(body.content_type, Some(MsgraphBodyType::Text));
2453 assert_eq!(body.content.as_deref(), Some("new notes"));
2454 }
2455
2456 fn without_description(ical: &str) -> String {
2458 let mut kept = String::new();
2459 let mut dropping = false;
2460 for line in ical.split_inclusive("\r\n") {
2461 if !line.starts_with(' ') {
2462 dropping = line.starts_with("DESCRIPTION") || line.starts_with("X-ALT-DESC");
2463 }
2464 if !dropping {
2465 kept.push_str(line);
2466 }
2467 }
2468 kept
2469 }
2470
2471 #[test]
2472 fn an_empty_html_body_matches_a_document_without_description() {
2473 let mut event = utc_read();
2474 event.body = MsgraphField::Set(MsgraphItemBody {
2475 content_type: Some(MsgraphBodyType::Html),
2476 content: Some("<html><body></body></html>".into()),
2477 });
2478 let read = event.to_ical();
2479 let edited = without_description(&read);
2480
2481 let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), read.as_bytes()).unwrap();
2482
2483 assert_eq!(patch.body, MsgraphField::Unset, "no body change to send");
2484 }
2485
2486 #[test]
2487 fn a_cleared_description_sends_an_empty_text_body() {
2488 let read = html_read();
2489 let edited = without_description(&read);
2490
2491 let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), read.as_bytes()).unwrap();
2492
2493 let body = serde_json::to_value(&patch).unwrap()["body"].clone();
2494 assert_eq!(body["contentType"], "text", "Graph refuses a null body");
2495 assert_eq!(body["content"], "");
2496 }
2497
2498 #[test]
2499 fn the_expand_clause_names_the_stash() {
2500 let quoted = format!("'{MSGRAPH_EVENT_STASH_ID}'");
2501 assert!(MSGRAPH_EVENT_STASH_EXPAND.contains("ed));
2502 }
2503}