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