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