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,\
101 lastModifiedDateTime,type,seriesMasterId,originalStart,subject,body,start,end,isAllDay,\
102 location,recurrence,attendees,organizer,categories,showAs,sensitivity,importance,\
103 isReminderOn,reminderMinutesBeforeStart,isCancelled,webLink,cancelledOccurrences";
104
105const PRODID: &str = "-//Pimalaya//io-msgraph//EN";
107
108const MAX_STASH_LINE: usize = 8 * 1024;
111
112const MINTED_PROPS: &[&str] = &["X-MSGRAPH-WEB-LINK"];
114
115const BUSY_STATUS: &str = "X-MICROSOFT-CDO-BUSYSTATUS";
117
118const ALT_DESC: &str = "X-ALT-DESC";
120
121impl MsgraphEvent {
122 pub fn to_ical(&self) -> String {
125 self.to_ical_series(&[])
126 }
127
128 pub fn to_ical_series(&self, exceptions: &[&MsgraphEvent]) -> String {
132 let master = self;
133 let uid = master
134 .stashed_uid()
135 .or_else(|| master.ical_uid.clone())
136 .unwrap_or_else(|| master.id.clone());
137
138 let mut zones = BTreeSet::new();
139 let mut events = vec![vevent(master, None, &uid, &mut zones)];
140 for exception in exceptions {
141 events.push(vevent(exception, Some(master), &uid, &mut zones));
142 }
143
144 let mut calendar = IcalCst::v2();
145 calendar.push(IcalProp::text(IcalPropKind::ProdId, vec![], PRODID));
146
147 let anchor = anchor(master);
148 for zone in &zones {
149 if let Some(vtimezone) = tzdb::vtimezone(zone, anchor) {
150 calendar.push_component(vtimezone);
151 }
152 }
153 for event in events {
154 calendar.push_component(event);
155 }
156
157 String::from_utf8_lossy(&calendar.to_bytes()).into_owned()
158 }
159
160 pub fn from_ical(contents: &[u8]) -> Result<Self, MsgraphEventIcalError> {
167 let calendar = IcalCst::parse(contents).map_err(MsgraphEventIcalError::Parse)?;
168 let vevent = master_vevent(&calendar)?;
169
170 let mut event = MsgraphEvent {
171 subject: MsgraphField::Null,
172 body: MsgraphField::Null,
173 location: MsgraphField::Null,
174 recurrence: MsgraphField::Null,
175 attendees: MsgraphField::Set(Vec::new()),
176 categories: MsgraphField::Set(Vec::new()),
177 show_as: MsgraphField::Null,
178 sensitivity: MsgraphField::Null,
179 importance: MsgraphField::Null,
180 is_reminder_on: MsgraphField::Set(false),
181 ..Default::default()
182 };
183
184 let mut description = None;
185 let mut html = None;
186 let mut start = None;
187 let mut end = None;
188 let mut duration = None;
189 let mut transparent = None;
190 let mut rrule = None;
191 let mut stash = Vec::new();
192
193 for item in &vevent.items {
194 match item {
195 IcalItem::Prop(line) => {
196 let name = line.name.get();
197 let consumed = if MINTED_PROPS.iter().any(|m| name.eq_ignore_ascii_case(m)) {
198 true
199 } else if name.eq_ignore_ascii_case(BUSY_STATUS) {
200 event.show_as =
201 MsgraphField::set_or_null(busy_status_from_ical(&line.raw_value_str()));
202 true
203 } else if name.eq_ignore_ascii_case(ALT_DESC)
204 && line
205 .param::<FMTTYPE>()
206 .is_some_and(|kind| kind.eq_ignore_ascii_case("text/html"))
207 {
208 html = Some(text(line));
209 true
210 } else {
211 match name.parse::<IcalPropKind>() {
212 Ok(IcalPropKind::Summary) => {
213 event.subject = MsgraphField::Set(text(line));
214 true
215 }
216 Ok(IcalPropKind::Description) => {
217 description = Some(text(line));
218 true
219 }
220 Ok(IcalPropKind::Location) => {
221 event.location = MsgraphField::Set(MsgraphLocation {
222 display_name: Some(text(line)),
223 ..Default::default()
224 });
225 true
226 }
227 Ok(IcalPropKind::DtStart) => {
228 start = Some(boundary(line));
229 true
230 }
231 Ok(IcalPropKind::DtEnd) => {
232 end = Some(boundary(line));
233 true
234 }
235 Ok(IcalPropKind::Duration) => {
236 duration = line.raw_value_str().trim().parse::<Span>().ok();
237 duration.is_some()
238 }
239 Ok(IcalPropKind::RRule) => {
240 rrule = Some(IcalRecur::decode(&line.value).0.trim().to_owned());
241 true
242 }
243 Ok(IcalPropKind::Categories) => {
244 let list = IcalTextList::decode(&line.value);
245 if let MsgraphField::Set(categories) = &mut event.categories {
246 categories.extend(list.0.into_iter().map(Cow::into_owned));
247 }
248 true
249 }
250 Ok(IcalPropKind::Class) => {
251 event.sensitivity = MsgraphField::set_or_null(
252 sensitivity_from_ical(&line.raw_value_str()),
253 );
254 true
255 }
256 Ok(IcalPropKind::Priority) => {
257 event.importance = MsgraphField::set_or_null(importance_from_ical(
258 &line.raw_value_str(),
259 ));
260 true
261 }
262 Ok(IcalPropKind::Transp) => {
263 transparent = Some(
264 line.raw_value_str()
265 .trim()
266 .eq_ignore_ascii_case("TRANSPARENT"),
267 );
268 true
269 }
270 Ok(IcalPropKind::Attendee) => {
271 if let MsgraphField::Set(attendees) = &mut event.attendees {
272 attendees.push(attendee(line));
273 }
274 true
275 }
276 Ok(
280 IcalPropKind::Organizer
281 | IcalPropKind::Status
282 | IcalPropKind::DtStamp
283 | IcalPropKind::Created
284 | IcalPropKind::LastModified
285 | IcalPropKind::Sequence,
286 ) => true,
287 _ => false,
288 }
289 };
290
291 if !consumed {
292 let raw = raw_line(line);
293 if raw.len() <= MAX_STASH_LINE {
294 stash.push(raw);
295 }
296 }
297 }
298 IcalItem::Component(alarm) => {
299 let reminder = reminder_minutes(alarm);
300 match (&event.reminder_minutes_before_start, reminder) {
301 (MsgraphField::Unset, Some(minutes)) => {
302 event.is_reminder_on = MsgraphField::Set(true);
303 event.reminder_minutes_before_start = MsgraphField::Set(minutes);
304 }
305 _ => stash.extend(raw_component(alarm)),
306 }
307 }
308 IcalItem::Opaque(bytes) => stash.push(String::from_utf8_lossy(bytes).into_owned()),
309 }
310 }
311
312 let Some(Some(start)) = start else {
313 return Err(MsgraphEventIcalError::NoZonedStart);
314 };
315 let end = match (end, duration) {
316 (Some(Some(end)), _) => end,
317 (_, Some(duration)) => start
318 .shifted(duration)
319 .ok_or(MsgraphEventIcalError::NoEnd)?,
320 _ => return Err(MsgraphEventIcalError::NoEnd),
321 };
322
323 event.is_all_day = MsgraphField::Set(start.all_day);
324 if let Some(rule) = &rrule {
325 event.recurrence = MsgraphField::Set(recurrence(rule, &start)?);
326 }
327 event.start = MsgraphField::Set(start.into_graph());
328 event.end = MsgraphField::Set(end.into_graph());
329
330 event.body = match (html, description) {
331 (Some(content), _) => MsgraphField::Set(MsgraphItemBody {
332 content_type: Some(MsgraphBodyType::Html),
333 content: Some(content),
334 }),
335 (None, Some(content)) => MsgraphField::Set(MsgraphItemBody {
336 content_type: Some(MsgraphBodyType::Text),
337 content: Some(content),
338 }),
339 (None, None) => MsgraphField::Null,
340 };
341
342 if matches!(event.show_as, MsgraphField::Null) {
343 event.show_as = match transparent {
344 Some(true) => MsgraphField::Set(MsgraphFreeBusyStatus::Free),
345 Some(false) => MsgraphField::Set(MsgraphFreeBusyStatus::Busy),
346 None => MsgraphField::Null,
347 };
348 }
349
350 event.single_value_extended_properties =
353 MsgraphField::Set(vec![MsgraphSingleValueExtendedProperty {
354 id: MSGRAPH_EVENT_STASH_ID.to_string(),
355 value: stash.join("\n"),
356 }]);
357
358 Ok(event)
359 }
360
361 pub fn create_from_ical(contents: &[u8]) -> Result<Self, MsgraphEventIcalError> {
365 let mut event = Self::from_ical(contents)?;
366
367 if stash_lines(&event).is_empty() {
368 event.single_value_extended_properties = MsgraphField::Unset;
369 }
370
371 let mut body = to_value(&event).map_err(MsgraphEventIcalError::Json)?;
372 if let Some(object) = body.as_object_mut() {
373 object.retain(|_, value| {
374 !value.is_null() && value.as_array().is_none_or(|array| !array.is_empty())
375 });
376 }
377
378 from_value(body).map_err(MsgraphEventIcalError::Json)
379 }
380
381 pub fn update_from_ical(contents: &[u8], base: &[u8]) -> Result<Self, MsgraphEventIcalError> {
385 let mut event = Self::from_ical(contents)?;
386 let base = Self::from_ical(base)?;
387
388 macro_rules! unset_unchanged {
389 ($($field:ident),* $(,)?) => {$(
390 if event.$field == base.$field {
391 event.$field = MsgraphField::Unset;
392 }
393 )*};
394 }
395
396 unset_unchanged!(
397 subject,
398 body,
399 start,
400 end,
401 is_all_day,
402 location,
403 recurrence,
404 attendees,
405 categories,
406 show_as,
407 sensitivity,
408 importance,
409 is_reminder_on,
410 reminder_minutes_before_start,
411 single_value_extended_properties,
412 );
413
414 Ok(event)
415 }
416
417 pub fn stashed_uid(&self) -> Option<String> {
424 stash_lines(self).iter().find_map(|line| {
425 let mut bytes = line.clone();
426 bytes.push_str("\r\n");
427 let (line, _) = IcalLine::take(bytes.as_bytes()).ok()?;
428 line.name
429 .get()
430 .eq_ignore_ascii_case("UID")
431 .then(|| line.raw_value_str().trim().to_string())
432 .filter(|uid| !uid.is_empty())
433 })
434 }
435}
436
437#[derive(Debug)]
439pub enum MsgraphEventIcalError {
440 Parse(IcalParseError),
442 NoEvent,
444 NotAnEvent(String),
446 NoZonedStart,
448 NoEnd,
450 UnsupportedRecurrence(String),
452 Json(serde_json::Error),
454}
455
456impl fmt::Display for MsgraphEventIcalError {
457 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458 match self {
459 Self::Parse(err) => write!(f, "Invalid iCalendar: {err}"),
460 Self::NoEvent => write!(f, "The iCalendar contents carry no VEVENT"),
461 Self::NotAnEvent(name) => {
462 write!(
463 f,
464 "Microsoft Graph models no {name}: its calendars hold events only"
465 )
466 }
467 Self::NoZonedStart => write!(
468 f,
469 "Microsoft Graph needs a DTSTART that is a date, in UTC, or in a named zone"
470 ),
471 Self::NoEnd => write!(f, "The VEVENT has neither a DTEND nor a DURATION"),
472 Self::UnsupportedRecurrence(rule) => {
473 write!(f, "Microsoft Graph cannot hold the recurrence `{rule}`")
474 }
475 Self::Json(err) => write!(f, "Cannot shape the Graph event body: {err}"),
476 }
477 }
478}
479
480impl core::error::Error for MsgraphEventIcalError {}
481
482fn vevent(
484 event: &MsgraphEvent,
485 master: Option<&MsgraphEvent>,
486 uid: &str,
487 zones: &mut BTreeSet<String>,
488) -> IcalCst<'static> {
489 let mut vevent = IcalCst::empty("VEVENT");
490 vevent.push(IcalProp::text(IcalPropKind::Uid, vec![], uid.to_owned()));
491
492 let stamp = event
495 .last_modified_date_time
496 .as_deref()
497 .or(event.created_date_time.as_deref())
498 .and_then(utc_stamp);
499 if let Some(stamp) = &stamp {
500 vevent.push(stamp_prop(IcalPropKind::DtStamp, stamp.clone()));
501 }
502
503 let all_day = event.is_all_day.as_option().copied().unwrap_or(false);
504 if let Some(start) = event.start.as_option() {
505 vevent.push(boundary_prop(IcalPropKind::DtStart, start, all_day, zones));
506 }
507 if let Some(end) = event.end.as_option() {
508 vevent.push(boundary_prop(IcalPropKind::DtEnd, end, all_day, zones));
509 }
510
511 if let (Some(master), Some(original)) = (master, &event.original_start)
512 && let Some(prop) = recurrence_id(master, original, zones)
513 {
514 vevent.push(prop);
515 }
516
517 if let Some(subject) = event.subject.as_option() {
518 vevent.push(IcalProp::text(
519 IcalPropKind::Summary,
520 vec![],
521 subject.clone(),
522 ));
523 }
524
525 if let Some(body) = event.body.as_option() {
526 let content = body.content.clone().unwrap_or_default();
527 match body.content_type {
528 Some(MsgraphBodyType::Html) => {
529 vevent.push(IcalProp::text(
530 IcalPropKind::Description,
531 vec![],
532 strip_html(&content),
533 ));
534 vevent.push(IcalProp::text(
535 ALT_DESC,
536 vec![IcalParam::FmtType("text/html".into())],
537 content,
538 ));
539 }
540 _ if !content.is_empty() => {
541 vevent.push(IcalProp::text(IcalPropKind::Description, vec![], content));
542 }
543 _ => {}
544 }
545 }
546
547 if let Some(name) = event
548 .location
549 .as_option()
550 .and_then(|location| location.display_name.clone())
551 .filter(|name| !name.is_empty())
552 {
553 vevent.push(IcalProp::text(IcalPropKind::Location, vec![], name));
554 }
555
556 if event.is_cancelled == Some(true) {
557 vevent.push(IcalProp::text(IcalPropKind::Status, vec![], "CANCELLED"));
558 }
559
560 if let Some(show_as) = event.show_as.as_option() {
561 let transp = match show_as {
562 MsgraphFreeBusyStatus::Free => "TRANSPARENT",
563 _ => "OPAQUE",
564 };
565 vevent.push(IcalProp::text(IcalPropKind::Transp, vec![], transp));
566 vevent.push(IcalProp::text(
567 BUSY_STATUS,
568 vec![],
569 busy_status_to_ical(*show_as),
570 ));
571 }
572
573 if let Some(class) = event
574 .sensitivity
575 .as_option()
576 .map(|s| sensitivity_to_ical(*s))
577 {
578 vevent.push(IcalProp::text(IcalPropKind::Class, vec![], class));
579 }
580
581 if let Some(importance) = event.importance.as_option() {
582 let priority = match importance {
583 MsgraphImportance::High => "1",
584 MsgraphImportance::Normal => "5",
585 MsgraphImportance::Low => "9",
586 };
587 vevent.push(IcalProp {
588 name: IcalPropName::Kind(IcalPropKind::Priority),
589 params: Vec::new(),
590 value: IcalValue::Integer(priority.into()),
591 });
592 }
593
594 let categories: Vec<Cow<'static, str>> = event
595 .categories
596 .as_option()
597 .map(Vec::as_slice)
598 .unwrap_or(&[])
599 .iter()
600 .filter(|category| !category.is_empty())
601 .map(|category| Cow::Owned(category.clone()))
602 .collect();
603 if !categories.is_empty() {
604 vevent.push(IcalProp {
605 name: IcalPropName::Kind(IcalPropKind::Categories),
606 params: Vec::new(),
607 value: IcalValue::TextList(IcalTextList(categories)),
608 });
609 }
610
611 for (kind, stamp) in [
612 (IcalPropKind::Created, &event.created_date_time),
613 (IcalPropKind::LastModified, &event.last_modified_date_time),
614 ] {
615 if let Some(stamp) = stamp.as_deref().and_then(utc_stamp) {
616 vevent.push(stamp_prop(kind, stamp));
617 }
618 }
619
620 if let Some(organizer) = event
621 .organizer
622 .as_option()
623 .map(|organizer| &organizer.email_address)
624 {
625 vevent.push(person_prop(IcalPropKind::Organizer, organizer, Vec::new()));
626 }
627
628 for attendee in event
629 .attendees
630 .as_option()
631 .map(Vec::as_slice)
632 .unwrap_or(&[])
633 {
634 if let Some(prop) = attendee_prop(attendee) {
635 vevent.push(prop);
636 }
637 }
638
639 if master.is_none()
640 && let Some(recurrence) = event.recurrence.as_option()
641 {
642 if let Some(rule) = rrule(recurrence, event.start.as_option(), all_day) {
643 vevent.push(IcalProp {
644 name: IcalPropName::Kind(IcalPropKind::RRule),
645 params: Vec::new(),
646 value: IcalValue::Recur(rule.into()),
647 });
648 }
649 for exdate in exdates(event, all_day, zones) {
650 vevent.push(exdate);
651 }
652 }
653
654 if let Some(link) = &event.web_link {
655 vevent.push(IcalProp::text("X-MSGRAPH-WEB-LINK", vec![], link.clone()));
656 }
657
658 for line in stash_lines(event) {
659 if is_uid_line(&line) {
660 continue;
661 }
662 let _ = vevent.push_raw(&line);
665 }
666
667 if event.is_reminder_on.as_option() == Some(&true)
668 && let Some(minutes) = event.reminder_minutes_before_start.as_option()
669 {
670 let mut alarm = IcalCst::empty("VALARM");
671 alarm.push(IcalProp::text(IcalPropKind::Action, vec![], "DISPLAY"));
672 alarm.push(IcalProp {
673 name: IcalPropName::Kind(IcalPropKind::Trigger),
674 params: Vec::new(),
675 value: IcalValue::Duration(format!("-PT{minutes}M").into()),
676 });
677 alarm.push(IcalProp::text(
678 IcalPropKind::Description,
679 vec![],
680 "Reminder",
681 ));
682 vevent.push_component(alarm);
683 }
684
685 vevent
686}
687
688enum Zone {
690 Utc,
692 Iana(String),
694 Unknown(String),
696}
697
698fn zone(name: Option<&str>) -> Zone {
700 let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
701 return Zone::Utc;
702 };
703
704 if name.eq_ignore_ascii_case("UTC") || name.eq_ignore_ascii_case("Etc/UTC") {
705 return Zone::Utc;
706 }
707
708 if let Some(iana) = windows_zones::iana(name) {
709 return match iana {
710 "Etc/UTC" => Zone::Utc,
711 iana => Zone::Iana(iana.to_owned()),
712 };
713 }
714
715 if tzdb::is_known(name) {
716 return Zone::Iana(name.to_owned());
717 }
718
719 Zone::Unknown(name.to_owned())
720}
721
722fn boundary_prop(
724 kind: IcalPropKind,
725 boundary: &MsgraphDateTimeTimeZone,
726 all_day: bool,
727 zones: &mut BTreeSet<String>,
728) -> IcalProp<'static> {
729 let digits = ical_digits(&boundary.date_time);
730
731 if all_day {
732 return IcalProp {
733 name: IcalPropName::Kind(kind),
734 params: vec![IcalParam::Value("DATE".into())],
735 value: IcalValue::Date(digits.get(..8).unwrap_or_default().to_owned().into()),
736 };
737 }
738
739 match zone(boundary.time_zone.as_deref()) {
740 Zone::Utc => stamp_prop(kind, format!("{digits}Z")),
741 Zone::Iana(zone) | Zone::Unknown(zone) => {
742 zones.insert(zone.clone());
743 IcalProp {
744 name: IcalPropName::Kind(kind),
745 params: vec![IcalParam::TzId(zone.into())],
746 value: IcalValue::DateTime(digits.into()),
747 }
748 }
749 }
750}
751
752fn recurrence_id(
755 master: &MsgraphEvent,
756 original: &str,
757 zones: &mut BTreeSet<String>,
758) -> Option<IcalProp<'static>> {
759 let instant = original.parse::<Timestamp>().ok()?;
760 let all_day = master.is_all_day.as_option().copied().unwrap_or(false);
761 let start_zone = master
762 .start
763 .as_option()
764 .and_then(|start| start.time_zone.as_deref());
765
766 if all_day {
767 let date = instant.to_zoned(TimeZone::UTC).date();
768 return Some(IcalProp {
769 name: IcalPropName::Kind(IcalPropKind::RecurrenceId),
770 params: vec![IcalParam::Value("DATE".into())],
771 value: IcalValue::Date(date.strftime("%Y%m%d").to_string().into()),
772 });
773 }
774
775 match zone(start_zone) {
776 Zone::Iana(name) => {
777 let local = instant.to_zoned(TimeZone::get(&name).ok()?).datetime();
778 zones.insert(name.clone());
779 Some(IcalProp {
780 name: IcalPropName::Kind(IcalPropKind::RecurrenceId),
781 params: vec![IcalParam::TzId(name.into())],
782 value: IcalValue::DateTime(local.strftime("%Y%m%dT%H%M%S").to_string().into()),
783 })
784 }
785 _ => Some(stamp_prop(
786 IcalPropKind::RecurrenceId,
787 instant.strftime("%Y%m%dT%H%M%SZ").to_string(),
788 )),
789 }
790}
791
792fn rrule(
795 recurrence: &MsgraphPatternedRecurrence,
796 start: Option<&MsgraphDateTimeTimeZone>,
797 all_day: bool,
798) -> Option<String> {
799 let pattern = &recurrence.pattern;
800 let mut parts = Vec::new();
801
802 let days = || -> String {
803 pattern
804 .days_of_week
805 .iter()
806 .map(|day| weekday_to_ical(*day))
807 .collect::<Vec<_>>()
808 .join(",")
809 };
810
811 match pattern.pattern_type? {
812 MsgraphRecurrencePatternType::Daily => parts.push(String::from("FREQ=DAILY")),
813 MsgraphRecurrencePatternType::Weekly => {
814 parts.push(String::from("FREQ=WEEKLY"));
815 if !pattern.days_of_week.is_empty() {
816 parts.push(format!("BYDAY={}", days()));
817 }
818 }
819 MsgraphRecurrencePatternType::AbsoluteMonthly => {
820 parts.push(String::from("FREQ=MONTHLY"));
821 parts.push(format!("BYMONTHDAY={}", pattern.day_of_month?));
822 }
823 MsgraphRecurrencePatternType::RelativeMonthly => {
824 parts.push(String::from("FREQ=MONTHLY"));
825 parts.push(format!("BYDAY={}", days()));
826 parts.push(format!("BYSETPOS={}", setpos(pattern.index)));
827 }
828 MsgraphRecurrencePatternType::AbsoluteYearly => {
829 parts.push(String::from("FREQ=YEARLY"));
830 parts.push(format!("BYMONTH={}", pattern.month?));
831 parts.push(format!("BYMONTHDAY={}", pattern.day_of_month?));
832 }
833 MsgraphRecurrencePatternType::RelativeYearly => {
834 parts.push(String::from("FREQ=YEARLY"));
835 parts.push(format!("BYMONTH={}", pattern.month?));
836 parts.push(format!("BYDAY={}", days()));
837 parts.push(format!("BYSETPOS={}", setpos(pattern.index)));
838 }
839 }
840
841 if let Some(interval) = pattern.interval.filter(|interval| *interval > 1) {
842 parts.push(format!("INTERVAL={interval}"));
843 }
844
845 if let Some(first) = pattern.first_day_of_week
846 && pattern.pattern_type == Some(MsgraphRecurrencePatternType::Weekly)
847 {
848 parts.push(format!("WKST={}", weekday_to_ical(first)));
849 }
850
851 let range = &recurrence.range;
852 match range.range_type {
853 Some(MsgraphRecurrenceRangeType::Numbered) => {
854 parts.push(format!("COUNT={}", range.number_of_occurrences?));
855 }
856 Some(MsgraphRecurrenceRangeType::EndDate) => {
857 let end = range.end_date.as_deref()?.parse::<Date>().ok()?;
858 parts.push(format!("UNTIL={}", until(end, range, start, all_day)?));
859 }
860 Some(MsgraphRecurrenceRangeType::NoEnd) | None => {}
861 }
862
863 Some(parts.join(";"))
864}
865
866fn until(
870 end: Date,
871 range: &MsgraphRecurrenceRange,
872 start: Option<&MsgraphDateTimeTimeZone>,
873 all_day: bool,
874) -> Option<String> {
875 if all_day {
876 return Some(end.strftime("%Y%m%d").to_string());
877 }
878
879 let name = range
880 .recurrence_time_zone
881 .as_deref()
882 .or(start.and_then(|start| start.time_zone.as_deref()));
883 let zone = match zone(name) {
884 Zone::Iana(name) => TimeZone::get(&name).ok()?,
885 _ => TimeZone::UTC,
886 };
887
888 let last = end.at(23, 59, 59, 0).to_zoned(zone).ok()?;
889 Some(last.timestamp().strftime("%Y%m%dT%H%M%SZ").to_string())
890}
891
892fn exdates(
895 master: &MsgraphEvent,
896 all_day: bool,
897 zones: &mut BTreeSet<String>,
898) -> Vec<IcalProp<'static>> {
899 let Some(start) = master.start.as_option() else {
900 return Vec::new();
901 };
902 let time = ical_digits(&start.date_time);
903 let time = time.get(8..).unwrap_or("T000000").to_owned();
904
905 master
906 .cancelled_occurrences
907 .iter()
908 .filter_map(|occurrence| occurrence.rsplit('.').next())
909 .filter_map(|date| date.parse::<Date>().ok())
910 .map(|date| {
911 let day = date.strftime("%Y%m%d").to_string();
912 if all_day {
913 return IcalProp {
914 name: IcalPropName::Kind(IcalPropKind::ExDate),
915 params: vec![IcalParam::Value("DATE".into())],
916 value: IcalValue::Date(day.into()),
917 };
918 }
919 match zone(start.time_zone.as_deref()) {
920 Zone::Utc => stamp_prop(IcalPropKind::ExDate, format!("{day}{time}Z")),
921 Zone::Iana(name) | Zone::Unknown(name) => {
922 zones.insert(name.clone());
923 IcalProp {
924 name: IcalPropName::Kind(IcalPropKind::ExDate),
925 params: vec![IcalParam::TzId(name.into())],
926 value: IcalValue::DateTime(format!("{day}{time}").into()),
927 }
928 }
929 }
930 })
931 .collect()
932}
933
934struct Boundary {
936 local: DateTime,
938 all_day: bool,
940 zone: String,
942}
943
944impl Boundary {
945 fn shifted(&self, duration: Span) -> Option<Self> {
947 Some(Self {
948 local: self.local.checked_add(duration).ok()?,
949 all_day: self.all_day,
950 zone: self.zone.clone(),
951 })
952 }
953
954 fn into_graph(self) -> MsgraphDateTimeTimeZone {
956 MsgraphDateTimeTimeZone {
957 date_time: self.local.strftime("%Y-%m-%dT%H:%M:%S").to_string(),
958 time_zone: Some(self.zone),
959 }
960 }
961}
962
963fn boundary(line: &IcalLine<'_>) -> Option<Boundary> {
966 let value = line.raw_value_str();
967 let value = value.trim();
968
969 let is_date = line
970 .param::<VALUE>()
971 .is_some_and(|kind| kind.eq_ignore_ascii_case("DATE"))
972 || (value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_digit()));
973
974 if is_date {
975 let date = Date::strptime("%Y%m%d", value).ok()?;
976 return Some(Boundary {
977 local: date.at(0, 0, 0, 0),
978 all_day: true,
979 zone: String::from("UTC"),
980 });
981 }
982
983 let (local, zone) = match value.strip_suffix('Z') {
984 Some(local) => (local, String::from("UTC")),
985 None => (value, line.param::<TZID>()?.into_owned()),
986 };
987
988 Some(Boundary {
989 local: DateTime::strptime("%Y%m%dT%H%M%S", local).ok()?,
990 all_day: false,
991 zone,
992 })
993}
994
995fn recurrence(
998 rule: &str,
999 start: &Boundary,
1000) -> Result<MsgraphPatternedRecurrence, MsgraphEventIcalError> {
1001 let unsupported = || MsgraphEventIcalError::UnsupportedRecurrence(rule.to_owned());
1002
1003 let mut freq = None;
1004 let mut interval = None;
1005 let mut count = None;
1006 let mut until_value = None;
1007 let mut by_day = Vec::new();
1008 let mut by_month_day = None;
1009 let mut by_month = None;
1010 let mut by_setpos = None;
1011 let mut wkst = None;
1012
1013 for part in rule.split(';').filter(|part| !part.is_empty()) {
1014 let (key, value) = part.split_once('=').ok_or_else(unsupported)?;
1015 match key.to_ascii_uppercase().as_str() {
1016 "FREQ" => freq = Some(value.to_ascii_uppercase()),
1017 "INTERVAL" => interval = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1018 "COUNT" => count = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1019 "UNTIL" => until_value = Some(value.to_owned()),
1020 "BYDAY" => {
1021 for day in value.split(',') {
1022 let (ordinal, name) = day.split_at(day.len().saturating_sub(2));
1023 if !ordinal.is_empty() {
1024 by_setpos = Some(ordinal.parse::<i32>().map_err(|_| unsupported())?);
1025 }
1026 by_day.push(weekday_from_ical(name).ok_or_else(unsupported)?);
1027 }
1028 }
1029 "BYMONTHDAY" => by_month_day = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1030 "BYMONTH" => by_month = Some(value.parse::<u32>().map_err(|_| unsupported())?),
1031 "BYSETPOS" => by_setpos = Some(value.parse::<i32>().map_err(|_| unsupported())?),
1032 "WKST" => wkst = Some(weekday_from_ical(value).ok_or_else(unsupported)?),
1033 _ => return Err(unsupported()),
1034 }
1035 }
1036
1037 let index = match by_setpos {
1038 None => None,
1039 Some(1) => Some(MsgraphWeekIndex::First),
1040 Some(2) => Some(MsgraphWeekIndex::Second),
1041 Some(3) => Some(MsgraphWeekIndex::Third),
1042 Some(4) => Some(MsgraphWeekIndex::Fourth),
1043 Some(-1) => Some(MsgraphWeekIndex::Last),
1044 Some(_) => return Err(unsupported()),
1045 };
1046
1047 let pattern_type = match (freq.as_deref(), by_day.is_empty(), index) {
1048 (Some("DAILY"), true, None) => MsgraphRecurrencePatternType::Daily,
1049 (Some("WEEKLY"), _, None) => MsgraphRecurrencePatternType::Weekly,
1050 (Some("MONTHLY"), true, None) => MsgraphRecurrencePatternType::AbsoluteMonthly,
1051 (Some("MONTHLY"), false, Some(_)) => MsgraphRecurrencePatternType::RelativeMonthly,
1052 (Some("YEARLY"), true, None) => MsgraphRecurrencePatternType::AbsoluteYearly,
1053 (Some("YEARLY"), false, Some(_)) => MsgraphRecurrencePatternType::RelativeYearly,
1054 _ => return Err(unsupported()),
1055 };
1056
1057 let date = start.local.date();
1058 let mut days_of_week = by_day;
1059 if pattern_type == MsgraphRecurrencePatternType::Weekly && days_of_week.is_empty() {
1060 days_of_week.push(weekday_of(date));
1061 }
1062
1063 let (day_of_month, month) = match pattern_type {
1064 MsgraphRecurrencePatternType::AbsoluteMonthly => {
1065 (Some(by_month_day.unwrap_or(date.day() as u32)), None)
1066 }
1067 MsgraphRecurrencePatternType::AbsoluteYearly => (
1068 Some(by_month_day.unwrap_or(date.day() as u32)),
1069 Some(by_month.unwrap_or(date.month() as u32)),
1070 ),
1071 MsgraphRecurrencePatternType::RelativeYearly => {
1072 (None, Some(by_month.unwrap_or(date.month() as u32)))
1073 }
1074 _ => (None, None),
1075 };
1076
1077 let zone_name = (!start.all_day).then(|| start.zone.clone());
1078 let range = match (count, until_value) {
1079 (Some(count), _) => MsgraphRecurrenceRange {
1080 range_type: Some(MsgraphRecurrenceRangeType::Numbered),
1081 number_of_occurrences: Some(count),
1082 ..Default::default()
1083 },
1084 (None, Some(until)) => {
1085 let day = until.get(..8).ok_or_else(unsupported)?;
1086 let end = Date::strptime("%Y%m%d", day).map_err(|_| unsupported())?;
1087 MsgraphRecurrenceRange {
1088 range_type: Some(MsgraphRecurrenceRangeType::EndDate),
1089 end_date: Some(end.strftime("%Y-%m-%d").to_string()),
1090 ..Default::default()
1091 }
1092 }
1093 (None, None) => MsgraphRecurrenceRange {
1094 range_type: Some(MsgraphRecurrenceRangeType::NoEnd),
1095 ..Default::default()
1096 },
1097 };
1098
1099 Ok(MsgraphPatternedRecurrence {
1100 pattern: MsgraphRecurrencePattern {
1101 pattern_type: Some(pattern_type),
1102 interval: Some(interval.unwrap_or(1)),
1103 month,
1104 day_of_month,
1105 days_of_week,
1106 first_day_of_week: wkst,
1107 index,
1108 },
1109 range: MsgraphRecurrenceRange {
1110 start_date: Some(date.strftime("%Y-%m-%d").to_string()),
1111 recurrence_time_zone: zone_name,
1112 ..range
1113 },
1114 })
1115}
1116
1117fn master_vevent<'a>(calendar: &'a IcalCst<'a>) -> Result<&'a IcalCst<'a>, MsgraphEventIcalError> {
1119 let components = || {
1120 calendar.items.iter().filter_map(|item| match item {
1121 IcalItem::Component(component) => Some(component.as_ref()),
1122 _ => None,
1123 })
1124 };
1125
1126 if let Some(master) = components()
1127 .filter(|component| component_name(component) == "VEVENT")
1128 .find(|vevent| !has_prop(vevent, "RECURRENCE-ID"))
1129 {
1130 return Ok(master);
1131 }
1132
1133 match components()
1134 .map(component_name)
1135 .find(|name| name != "VTIMEZONE" && name != "VEVENT")
1136 {
1137 Some(name) => Err(MsgraphEventIcalError::NotAnEvent(name)),
1138 None => Err(MsgraphEventIcalError::NoEvent),
1139 }
1140}
1141
1142fn component_name(component: &IcalCst<'_>) -> String {
1144 component
1145 .begin
1146 .as_ref()
1147 .map(|begin| begin.raw_value_str().trim().to_uppercase())
1148 .unwrap_or_default()
1149}
1150
1151fn has_prop(component: &IcalCst<'_>, name: &str) -> bool {
1153 component.items.iter().any(|item| match item {
1154 IcalItem::Prop(line) => line.name.get().eq_ignore_ascii_case(name),
1155 _ => false,
1156 })
1157}
1158
1159fn reminder_minutes(alarm: &IcalCst<'_>) -> Option<u32> {
1162 if component_name(alarm) != "VALARM" {
1163 return None;
1164 }
1165 let action = alarm.prop::<ACTION>()?;
1166 if !matches!(action.0.trim().to_uppercase().as_str(), "DISPLAY" | "AUDIO") {
1167 return None;
1168 }
1169
1170 let trigger = alarm.prop::<TRIGGER>()?;
1171 let lead = trigger.0.trim().strip_prefix('-')?.parse::<Span>().ok()?;
1172 let minutes = lead.total(jiff::Unit::Minute).ok()?;
1173
1174 let whole = minutes as u32;
1175 (minutes >= 0.0 && f64::from(whole) == minutes).then_some(whole)
1176}
1177
1178fn person_prop(
1180 kind: IcalPropKind,
1181 address: &MsgraphEmailAddress,
1182 mut params: Vec<IcalParam<'static>>,
1183) -> IcalProp<'static> {
1184 if let Some(name) = address.name.clone().filter(|name| !name.is_empty()) {
1185 params.insert(0, IcalParam::Cn(name.into()));
1186 }
1187 let email = address.address.clone().unwrap_or_default();
1188
1189 IcalProp {
1190 name: IcalPropName::Kind(kind),
1191 params,
1192 value: IcalValue::CalAddress(format!("mailto:{email}").into()),
1193 }
1194}
1195
1196fn attendee_prop(attendee: &MsgraphAttendee) -> Option<IcalProp<'static>> {
1198 let address = attendee.email_address.as_ref()?;
1199
1200 let mut params = Vec::new();
1201 match attendee.attendee_type {
1202 Some(MsgraphAttendeeType::Optional) => {
1203 params.push(IcalParam::Role("OPT-PARTICIPANT".into()))
1204 }
1205 Some(MsgraphAttendeeType::Resource) => {
1206 params.push(IcalParam::Role("NON-PARTICIPANT".into()));
1207 params.push(IcalParam::CuType("RESOURCE".into()));
1208 }
1209 _ => params.push(IcalParam::Role("REQ-PARTICIPANT".into())),
1210 }
1211
1212 let partstat = match attendee.status.as_ref().and_then(|status| status.response) {
1213 Some(MsgraphResponseType::Accepted | MsgraphResponseType::Organizer) => "ACCEPTED",
1214 Some(MsgraphResponseType::Declined) => "DECLINED",
1215 Some(MsgraphResponseType::TentativelyAccepted) => "TENTATIVE",
1216 _ => "NEEDS-ACTION",
1217 };
1218 params.push(IcalParam::PartStat(partstat.into()));
1219
1220 Some(person_prop(IcalPropKind::Attendee, address, params))
1221}
1222
1223fn attendee(line: &IcalLine<'_>) -> MsgraphAttendee {
1225 let role = line.param::<ROLE>().unwrap_or_default();
1226 let cutype = line.param::<CUTYPE>().unwrap_or_default();
1227
1228 let attendee_type =
1229 if cutype.eq_ignore_ascii_case("RESOURCE") || cutype.eq_ignore_ascii_case("ROOM") {
1230 MsgraphAttendeeType::Resource
1231 } else if role.eq_ignore_ascii_case("OPT-PARTICIPANT") {
1232 MsgraphAttendeeType::Optional
1233 } else {
1234 MsgraphAttendeeType::Required
1235 };
1236
1237 let response =
1238 line.param::<PARTSTAT>()
1239 .and_then(|status| match status.to_ascii_uppercase().as_str() {
1240 "ACCEPTED" => Some(MsgraphResponseType::Accepted),
1241 "DECLINED" => Some(MsgraphResponseType::Declined),
1242 "TENTATIVE" => Some(MsgraphResponseType::TentativelyAccepted),
1243 "NEEDS-ACTION" => Some(MsgraphResponseType::NotResponded),
1244 _ => None,
1245 });
1246
1247 let value = line.raw_value_str();
1248 let value = value.trim();
1249 let address = value
1250 .strip_prefix("mailto:")
1251 .or_else(|| value.strip_prefix("MAILTO:"))
1252 .unwrap_or(value)
1253 .to_owned();
1254
1255 MsgraphAttendee {
1256 attendee_type: Some(attendee_type),
1257 status: response.map(|response| MsgraphResponseStatus {
1258 response: Some(response),
1259 time: None,
1260 }),
1261 email_address: Some(MsgraphEmailAddress {
1262 name: line.param::<CN>().map(Cow::into_owned),
1263 address: Some(address),
1264 }),
1265 }
1266}
1267
1268fn anchor(event: &MsgraphEvent) -> i64 {
1270 event
1271 .start
1272 .as_option()
1273 .and_then(|start| start.date_time.get(..10))
1274 .and_then(|date| date.parse::<Date>().ok())
1275 .and_then(|date| date.to_zoned(TimeZone::UTC).ok())
1276 .map_or(0, |zoned| zoned.timestamp().as_second())
1277}
1278
1279fn ical_digits(date_time: &str) -> String {
1282 let base = date_time.split('.').next().unwrap_or(date_time);
1283 base.chars()
1284 .filter(|character| *character != '-' && *character != ':')
1285 .take(15)
1286 .collect()
1287}
1288
1289fn utc_stamp(instant: &str) -> Option<String> {
1291 let instant = instant.parse::<Timestamp>().ok()?;
1292 Some(instant.strftime("%Y%m%dT%H%M%SZ").to_string())
1293}
1294
1295fn stamp_prop(kind: IcalPropKind, stamp: String) -> IcalProp<'static> {
1297 IcalProp {
1298 name: IcalPropName::Kind(kind),
1299 params: Vec::new(),
1300 value: IcalValue::DateTime(IcalDateTime(stamp.into())),
1301 }
1302}
1303
1304fn strip_html(html: &str) -> String {
1306 let mut text = String::with_capacity(html.len());
1307 let mut in_tag = false;
1308 for character in html.chars() {
1309 match character {
1310 '<' => in_tag = true,
1311 '>' => in_tag = false,
1312 _ if !in_tag => text.push(character),
1313 _ => {}
1314 }
1315 }
1316 text.replace(" ", " ")
1317 .replace("&", "&")
1318 .replace("<", "<")
1319 .replace(">", ">")
1320 .trim()
1321 .to_owned()
1322}
1323
1324fn stash_lines(event: &MsgraphEvent) -> Vec<String> {
1326 event
1327 .single_value_extended_properties
1328 .as_option()
1329 .map(Vec::as_slice)
1330 .unwrap_or(&[])
1331 .iter()
1332 .filter(|prop| prop.id.to_ascii_lowercase().contains("pimalaya-ical"))
1333 .flat_map(|prop| prop.value.split('\n'))
1334 .filter(|line| !line.is_empty())
1335 .map(str::to_owned)
1336 .collect()
1337}
1338
1339fn is_uid_line(line: &str) -> bool {
1341 let name = line.split([';', ':']).next().unwrap_or_default();
1342 name.eq_ignore_ascii_case("UID")
1343}
1344
1345fn text(line: &IcalLine<'_>) -> String {
1347 IcalText::decode(&line.value).0.into_owned()
1348}
1349
1350fn raw_line(line: &IcalLine<'_>) -> String {
1352 line.to_string().trim_end_matches(['\r', '\n']).to_owned()
1353}
1354
1355fn raw_component(component: &IcalCst<'_>) -> Vec<String> {
1357 component
1358 .to_string()
1359 .lines()
1360 .map(|line| line.trim_end_matches('\r').to_owned())
1361 .filter(|line| !line.is_empty())
1362 .collect()
1363}
1364
1365fn setpos(index: Option<MsgraphWeekIndex>) -> i32 {
1367 match index {
1368 Some(MsgraphWeekIndex::Second) => 2,
1369 Some(MsgraphWeekIndex::Third) => 3,
1370 Some(MsgraphWeekIndex::Fourth) => 4,
1371 Some(MsgraphWeekIndex::Last) => -1,
1372 _ => 1,
1373 }
1374}
1375
1376fn weekday_to_ical(day: MsgraphDayOfWeek) -> &'static str {
1378 match day {
1379 MsgraphDayOfWeek::Sunday => "SU",
1380 MsgraphDayOfWeek::Monday => "MO",
1381 MsgraphDayOfWeek::Tuesday => "TU",
1382 MsgraphDayOfWeek::Wednesday => "WE",
1383 MsgraphDayOfWeek::Thursday => "TH",
1384 MsgraphDayOfWeek::Friday => "FR",
1385 MsgraphDayOfWeek::Saturday => "SA",
1386 }
1387}
1388
1389fn weekday_from_ical(day: &str) -> Option<MsgraphDayOfWeek> {
1391 Some(match day.to_ascii_uppercase().as_str() {
1392 "SU" => MsgraphDayOfWeek::Sunday,
1393 "MO" => MsgraphDayOfWeek::Monday,
1394 "TU" => MsgraphDayOfWeek::Tuesday,
1395 "WE" => MsgraphDayOfWeek::Wednesday,
1396 "TH" => MsgraphDayOfWeek::Thursday,
1397 "FR" => MsgraphDayOfWeek::Friday,
1398 "SA" => MsgraphDayOfWeek::Saturday,
1399 _ => return None,
1400 })
1401}
1402
1403fn weekday_of(date: Date) -> MsgraphDayOfWeek {
1405 use jiff::civil::Weekday;
1406 match date.weekday() {
1407 Weekday::Sunday => MsgraphDayOfWeek::Sunday,
1408 Weekday::Monday => MsgraphDayOfWeek::Monday,
1409 Weekday::Tuesday => MsgraphDayOfWeek::Tuesday,
1410 Weekday::Wednesday => MsgraphDayOfWeek::Wednesday,
1411 Weekday::Thursday => MsgraphDayOfWeek::Thursday,
1412 Weekday::Friday => MsgraphDayOfWeek::Friday,
1413 Weekday::Saturday => MsgraphDayOfWeek::Saturday,
1414 }
1415}
1416
1417fn busy_status_to_ical(show_as: MsgraphFreeBusyStatus) -> &'static str {
1419 match show_as {
1420 MsgraphFreeBusyStatus::Free => "FREE",
1421 MsgraphFreeBusyStatus::Tentative => "TENTATIVE",
1422 MsgraphFreeBusyStatus::Busy => "BUSY",
1423 MsgraphFreeBusyStatus::Oof => "OOF",
1424 MsgraphFreeBusyStatus::WorkingElsewhere => "WORKINGELSEWHERE",
1425 MsgraphFreeBusyStatus::Unknown => "UNKNOWN",
1426 }
1427}
1428
1429fn busy_status_from_ical(value: &str) -> Option<MsgraphFreeBusyStatus> {
1431 Some(match value.trim().to_ascii_uppercase().as_str() {
1432 "FREE" => MsgraphFreeBusyStatus::Free,
1433 "TENTATIVE" => MsgraphFreeBusyStatus::Tentative,
1434 "BUSY" => MsgraphFreeBusyStatus::Busy,
1435 "OOF" => MsgraphFreeBusyStatus::Oof,
1436 "WORKINGELSEWHERE" => MsgraphFreeBusyStatus::WorkingElsewhere,
1437 _ => return None,
1438 })
1439}
1440
1441fn sensitivity_to_ical(sensitivity: MsgraphSensitivity) -> &'static str {
1443 match sensitivity {
1444 MsgraphSensitivity::Normal => "PUBLIC",
1445 MsgraphSensitivity::Personal | MsgraphSensitivity::Private => "PRIVATE",
1446 MsgraphSensitivity::Confidential => "CONFIDENTIAL",
1447 }
1448}
1449
1450fn sensitivity_from_ical(value: &str) -> Option<MsgraphSensitivity> {
1452 Some(match value.trim().to_ascii_uppercase().as_str() {
1453 "PUBLIC" => MsgraphSensitivity::Normal,
1454 "PRIVATE" => MsgraphSensitivity::Private,
1455 "CONFIDENTIAL" => MsgraphSensitivity::Confidential,
1456 _ => return None,
1457 })
1458}
1459
1460fn importance_from_ical(value: &str) -> Option<MsgraphImportance> {
1462 match value.trim().parse::<u8>().ok()? {
1463 1..=4 => Some(MsgraphImportance::High),
1464 5 => Some(MsgraphImportance::Normal),
1465 6..=9 => Some(MsgraphImportance::Low),
1466 _ => None,
1467 }
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472 use super::*;
1473 use crate::v1::rest::users::messages::MsgraphRecipient;
1474
1475 fn master() -> MsgraphEvent {
1477 MsgraphEvent {
1478 id: "SM1".into(),
1479 ical_uid: Some("040000008200E0".into()),
1480 last_modified_date_time: Some("2026-08-01T10:00:00Z".into()),
1481 subject: MsgraphField::Set("Stand-up".into()),
1482 start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1483 date_time: "2026-08-14T09:00:00.0000000".into(),
1484 time_zone: Some("Romance Standard Time".into()),
1485 }),
1486 end: MsgraphField::Set(MsgraphDateTimeTimeZone {
1487 date_time: "2026-08-14T09:15:00.0000000".into(),
1488 time_zone: Some("Romance Standard Time".into()),
1489 }),
1490 is_all_day: MsgraphField::Set(false),
1491 recurrence: MsgraphField::Set(MsgraphPatternedRecurrence {
1492 pattern: MsgraphRecurrencePattern {
1493 pattern_type: Some(MsgraphRecurrencePatternType::Weekly),
1494 interval: Some(1),
1495 days_of_week: vec![MsgraphDayOfWeek::Monday, MsgraphDayOfWeek::Friday],
1496 first_day_of_week: Some(MsgraphDayOfWeek::Monday),
1497 ..Default::default()
1498 },
1499 range: MsgraphRecurrenceRange {
1500 range_type: Some(MsgraphRecurrenceRangeType::Numbered),
1501 start_date: Some("2026-08-14".into()),
1502 number_of_occurrences: Some(10),
1503 ..Default::default()
1504 },
1505 }),
1506 organizer: MsgraphField::Set(MsgraphRecipient {
1507 email_address: MsgraphEmailAddress {
1508 name: Some("Alice".into()),
1509 address: Some("alice@x.org".into()),
1510 },
1511 }),
1512 attendees: MsgraphField::Set(vec![MsgraphAttendee {
1513 attendee_type: Some(MsgraphAttendeeType::Optional),
1514 status: Some(MsgraphResponseStatus {
1515 response: Some(MsgraphResponseType::Accepted),
1516 time: None,
1517 }),
1518 email_address: Some(MsgraphEmailAddress {
1519 name: Some("Bob".into()),
1520 address: Some("bob@x.org".into()),
1521 }),
1522 }]),
1523 show_as: MsgraphField::Set(MsgraphFreeBusyStatus::Tentative),
1524 is_reminder_on: MsgraphField::Set(true),
1525 reminder_minutes_before_start: MsgraphField::Set(15),
1526 cancelled_occurrences: vec!["OID.SM1.2026-08-21".into()],
1527 ..Default::default()
1528 }
1529 }
1530
1531 #[test]
1532 fn a_windows_zoned_series_projects_with_its_rule_and_zone() {
1533 let ical = master().to_ical();
1534
1535 assert!(ical.contains("UID:040000008200E0\r\n"), "{ical}");
1536 assert!(
1537 ical.contains("BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\n"),
1538 "{ical}"
1539 );
1540 assert!(
1541 ical.contains("DTSTART;TZID=Europe/Paris:20260814T090000\r\n"),
1542 "{ical}"
1543 );
1544 assert!(
1545 ical.contains("DTEND;TZID=Europe/Paris:20260814T091500\r\n"),
1546 "{ical}"
1547 );
1548 assert!(
1549 ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO,FR;WKST=MO;COUNT=10\r\n"),
1550 "{ical}"
1551 );
1552 assert!(
1553 ical.contains("EXDATE;TZID=Europe/Paris:20260821T090000\r\n"),
1554 "{ical}"
1555 );
1556 assert!(
1557 ical.contains("ORGANIZER;CN=Alice:mailto:alice@x.org\r\n"),
1558 "{ical}"
1559 );
1560 assert!(
1561 ical.contains(
1562 "ATTENDEE;CN=Bob;ROLE=OPT-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:bob@x.org\r\n"
1563 ),
1564 "{ical}"
1565 );
1566 assert!(
1567 ical.contains("X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE\r\n"),
1568 "{ical}"
1569 );
1570 assert!(
1571 ical.contains("BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\n"),
1572 "{ical}"
1573 );
1574 }
1575
1576 #[test]
1577 fn an_exception_carries_the_series_uid_and_its_original_start() {
1578 let exception = MsgraphEvent {
1579 id: "EX1".into(),
1580 ical_uid: Some("other".into()),
1581 event_type: Some(crate::v1::rest::users::events::MsgraphEventType::Exception),
1582 series_master_id: Some("SM1".into()),
1583 original_start: Some("2026-08-17T07:00:00Z".into()),
1585 subject: MsgraphField::Set("Stand-up, moved".into()),
1586 start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1587 date_time: "2026-08-17T10:00:00.0000000".into(),
1588 time_zone: Some("Romance Standard Time".into()),
1589 }),
1590 ..Default::default()
1591 };
1592
1593 let ical = master().to_ical_series(&[&exception]);
1594
1595 assert_eq!(ical.matches("BEGIN:VEVENT").count(), 2, "{ical}");
1596 assert_eq!(ical.matches("UID:040000008200E0\r\n").count(), 2, "{ical}");
1597 assert!(
1598 ical.contains("RECURRENCE-ID;TZID=Europe/Paris:20260817T090000\r\n"),
1599 "{ical}"
1600 );
1601 assert_eq!(ical.matches("RRULE:FREQ=WEEKLY").count(), 1, "{ical}");
1602 }
1603
1604 #[test]
1605 fn an_all_day_event_projects_as_dates_and_back() {
1606 let event = MsgraphEvent {
1607 id: "AD1".into(),
1608 subject: MsgraphField::Set("Holiday".into()),
1609 is_all_day: MsgraphField::Set(true),
1610 start: MsgraphField::Set(MsgraphDateTimeTimeZone {
1611 date_time: "2026-08-14T00:00:00.0000000".into(),
1612 time_zone: Some("UTC".into()),
1613 }),
1614 end: MsgraphField::Set(MsgraphDateTimeTimeZone {
1615 date_time: "2026-08-15T00:00:00.0000000".into(),
1616 time_zone: Some("UTC".into()),
1617 }),
1618 ..Default::default()
1619 };
1620
1621 let ical = event.to_ical();
1622 assert!(ical.contains("DTSTART;VALUE=DATE:20260814\r\n"), "{ical}");
1623 assert!(ical.contains("DTEND;VALUE=DATE:20260815\r\n"), "{ical}");
1624
1625 let back = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
1626 assert_eq!(back.is_all_day.as_option(), Some(&true));
1627 assert_eq!(
1628 back.start.as_option().map(|start| start.date_time.as_str()),
1629 Some("2026-08-14T00:00:00")
1630 );
1631 }
1632
1633 #[test]
1634 fn a_series_reads_back_with_its_recurrence() {
1635 let event = MsgraphEvent::from_ical(master().to_ical().as_bytes()).unwrap();
1636
1637 assert_eq!(
1638 event.subject.as_option().map(String::as_str),
1639 Some("Stand-up")
1640 );
1641 let start = event.start.as_option().unwrap();
1642 assert_eq!(start.date_time, "2026-08-14T09:00:00");
1643 assert_eq!(start.time_zone.as_deref(), Some("Europe/Paris"));
1644
1645 let recurrence = event.recurrence.as_option().unwrap();
1646 assert_eq!(
1647 recurrence.pattern.pattern_type,
1648 Some(MsgraphRecurrencePatternType::Weekly)
1649 );
1650 assert_eq!(
1651 recurrence.pattern.days_of_week,
1652 [MsgraphDayOfWeek::Monday, MsgraphDayOfWeek::Friday]
1653 );
1654 assert_eq!(recurrence.range.number_of_occurrences, Some(10));
1655 assert_eq!(recurrence.range.start_date.as_deref(), Some("2026-08-14"));
1656 assert_eq!(
1657 event.show_as.as_option(),
1658 Some(&MsgraphFreeBusyStatus::Tentative)
1659 );
1660 assert_eq!(event.reminder_minutes_before_start.as_option(), Some(&15));
1661 assert_eq!(event.attendees.as_option().map(Vec::len), Some(1));
1662 }
1663
1664 #[test]
1665 fn a_relative_monthly_rule_round_trips_through_its_index() {
1666 let ical = concat!(
1667 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n",
1668 "DTSTART:20260827T120000Z\r\nDTEND:20260827T130000Z\r\n",
1669 "RRULE:FREQ=MONTHLY;BYDAY=TH;BYSETPOS=-1\r\n",
1670 "END:VEVENT\r\nEND:VCALENDAR\r\n",
1671 );
1672
1673 let event = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
1674 let recurrence = event.recurrence.as_option().unwrap();
1675 assert_eq!(
1676 recurrence.pattern.pattern_type,
1677 Some(MsgraphRecurrencePatternType::RelativeMonthly)
1678 );
1679 assert_eq!(recurrence.pattern.index, Some(MsgraphWeekIndex::Last));
1680
1681 let back = event.to_ical();
1682 assert!(
1683 back.contains("RRULE:FREQ=MONTHLY;BYDAY=TH;BYSETPOS=-1\r\n"),
1684 "{back}"
1685 );
1686 }
1687
1688 #[test]
1689 fn an_end_date_ends_at_midnight_in_the_series_zone() {
1690 let mut event = master();
1691 if let MsgraphField::Set(recurrence) = &mut event.recurrence {
1692 recurrence.range = MsgraphRecurrenceRange {
1693 range_type: Some(MsgraphRecurrenceRangeType::EndDate),
1694 start_date: Some("2026-08-14".into()),
1695 end_date: Some("2026-09-30".into()),
1696 recurrence_time_zone: Some("Romance Standard Time".into()),
1697 ..Default::default()
1698 };
1699 }
1700
1701 assert!(event.to_ical().contains(";UNTIL=20260930T215959Z\r\n"));
1703 }
1704
1705 #[test]
1706 fn what_graph_cannot_hold_is_refused_by_name() {
1707 let with = |props: &str| {
1708 format!(
1709 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n{props}END:VEVENT\r\nEND:VCALENDAR\r\n"
1710 )
1711 };
1712
1713 let hourly =
1714 with("DTSTART:20260814T090000Z\r\nDTEND:20260814T100000Z\r\nRRULE:FREQ=HOURLY\r\n");
1715 let floating = with("DTSTART:20260814T090000\r\nDTEND:20260814T100000\r\n");
1716 let todo = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
1717
1718 assert!(matches!(
1719 MsgraphEvent::from_ical(hourly.as_bytes()),
1720 Err(MsgraphEventIcalError::UnsupportedRecurrence(_))
1721 ));
1722 assert!(matches!(
1723 MsgraphEvent::from_ical(floating.as_bytes()),
1724 Err(MsgraphEventIcalError::NoZonedStart)
1725 ));
1726 assert!(matches!(
1727 MsgraphEvent::from_ical(todo.as_bytes()),
1728 Err(MsgraphEventIcalError::NotAnEvent(name)) if name == "VTODO"
1729 ));
1730 }
1731
1732 #[test]
1733 fn a_duration_gives_the_end() {
1734 let ical = concat!(
1735 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\n",
1736 "DTSTART;TZID=Europe/Paris:20260814T090000\r\nDURATION:PT1H30M\r\n",
1737 "END:VEVENT\r\nEND:VCALENDAR\r\n",
1738 );
1739
1740 let event = MsgraphEvent::from_ical(ical.as_bytes()).unwrap();
1741 let end = event.end.as_option().unwrap();
1742 assert_eq!(end.date_time, "2026-08-14T10:30:00");
1743 assert_eq!(end.time_zone.as_deref(), Some("Europe/Paris"));
1744 }
1745
1746 #[test]
1747 fn the_uid_and_the_remainder_ride_the_stash() {
1748 let ical = concat!(
1749 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:urn:uuid:4fbe8971\r\n",
1750 "DTSTART:20260814T090000Z\r\nDTEND:20260814T100000Z\r\nSUMMARY:Lunch\r\n",
1751 "X-CUSTOM;X-P=1:kept\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
1752 );
1753
1754 let mut event = MsgraphEvent::create_from_ical(ical.as_bytes()).unwrap();
1755 assert_eq!(event.stashed_uid().as_deref(), Some("urn:uuid:4fbe8971"));
1756
1757 event.id = "EV9".into();
1760 event.ical_uid = Some("040000008200E0".into());
1761 let back = event.to_ical();
1762
1763 assert!(back.contains("UID:urn:uuid:4fbe8971\r\n"), "{back}");
1764 assert!(!back.contains("UID:040000008200E0"), "{back}");
1765 assert!(back.contains("X-CUSTOM;X-P=1:kept\r\n"), "{back}");
1766 }
1767
1768 #[test]
1769 fn an_update_sends_only_what_changed() {
1770 let base = master().to_ical();
1771 let edited = base.replace("SUMMARY:Stand-up", "SUMMARY:Daily");
1772
1773 let patch = MsgraphEvent::update_from_ical(edited.as_bytes(), base.as_bytes()).unwrap();
1774
1775 assert_eq!(patch.subject.as_option().map(String::as_str), Some("Daily"));
1776 assert!(patch.start.is_unset());
1777 assert!(patch.recurrence.is_unset());
1778 assert!(patch.attendees.is_unset());
1779 assert!(patch.single_value_extended_properties.is_unset());
1780 }
1781
1782 #[test]
1783 fn the_expand_clause_names_the_stash() {
1784 let quoted = format!("'{MSGRAPH_EVENT_STASH_ID}'");
1785 assert!(MSGRAPH_EVENT_STASH_EXPAND.contains("ed));
1786 }
1787}