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