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