From 985220562c5b40fc3bf0e1e35775353a7ce53198 Mon Sep 17 00:00:00 2001 From: Abin Simon Date: Fri, 12 Jan 2024 19:58:25 +0530 Subject: [PATCH] Few more fixes to ics export (#5004) --- #### Does this PR need a docs update or release note? - [ ] :white_check_mark: Yes, it's included - [ ] :clock1: Yes, but in a later PR - [x] :no_entry: No #### Type of change - [x] :sunflower: Feature - [ ] :bug: Bugfix - [ ] :world_map: Documentation - [ ] :robot: Supportability/Tests - [ ] :computer: CI/Deployment - [ ] :broom: Tech Debt/Cleanup #### Issue(s) * https://github.com/alcionai/corso/issues/3890 #### Test Plan - [ ] :muscle: Manual - [x] :zap: Unit test - [ ] :green_heart: E2E --- src/internal/converters/ics/ics.go | 101 ++++++++++++++++-------- src/internal/converters/ics/ics_test.go | 97 +++++++++++++++-------- 2 files changed, 132 insertions(+), 66 deletions(-) diff --git a/src/internal/converters/ics/ics.go b/src/internal/converters/ics/ics.go index ce67b8908..23ee5d2f8 100644 --- a/src/internal/converters/ics/ics.go +++ b/src/internal/converters/ics/ics.go @@ -19,20 +19,13 @@ import ( ) // This package is used to convert json response from graph to ics -// Ref: https://icalendar.org/ -// Ref: https://www.rfc-editor.org/rfc/rfc5545 -// Ref: https://learn.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-1.0 +// https://icalendar.org/ +// https://www.rfc-editor.org/rfc/rfc5545 +// https://www.rfc-editor.org/rfc/rfc2445.txt +// https://learn.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-1.0 +// https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/a685a040-5b69-4c84-b084-795113fb4012 -// TODO: Items not handled -// locations (different from location) -// exceptions and modifications - -// Field in the backed up data that we cannot handle -// allowNewTimeProposals, hideAttendees, importance, isOnlineMeeting, -// isOrganizer, isReminderOn, onlineMeeting, onlineMeetingProvider, -// onlineMeetingUrl, originalEndTimeZone, originalStart, -// originalStartTimeZone, reminderMinutesBeforeStart, responseRequested, -// responseStatus, sensitivity +// TODO locations: https://github.com/alcionai/corso/issues/5003 const ( iCalDateTimeFormat = "20060102T150405Z" @@ -54,7 +47,6 @@ func getLocationString(location models.Locationable) string { dn := ptr.Val(location.GetDisplayName()) segments := []string{dn} - // TODO: Handle different location types addr := location.GetAddress() if addr != nil { street := ptr.Val(addr.GetStreet()) @@ -260,18 +252,20 @@ func FromJSON(ctx context.Context, body []byte) (string, error) { } func updateEventProperties(ctx context.Context, event models.Eventable, iCalEvent *ics.VEvent) error { + // CREATED - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1 created := event.GetCreatedDateTime() if created != nil { iCalEvent.SetCreatedTime(ptr.Val(created)) } + // LAST-MODIFIED - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.3 modified := event.GetLastModifiedDateTime() if modified != nil { iCalEvent.SetModifiedAt(ptr.Val(modified)) } + // DTSTART - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.4 allDay := ptr.Val(event.GetIsAllDay()) - startString := event.GetStart().GetDateTime() startTimezone := event.GetStart().GetTimeZone() @@ -288,6 +282,7 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven } } + // DTEND - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.2 endString := event.GetEnd().GetDateTime() endTimezone := event.GetEnd().GetTimeZone() @@ -314,22 +309,21 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven iCalEvent.AddRrule(pattern) } + // STATUS - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.11 cancelled := event.GetIsCancelled() if cancelled != nil { iCalEvent.SetStatus(ics.ObjectStatusCancelled) } - draft := event.GetIsDraft() - if draft != nil { - iCalEvent.SetStatus(ics.ObjectStatusDraft) - } - + // SUMMARY - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.12 summary := event.GetSubject() if summary != nil { iCalEvent.SetSummary(ptr.Val(summary)) } + // DESCRIPTION - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.5 // TODO: Emojies currently don't seem to be read properly by Outlook + // When outlook exports them(in .eml), it exports them in text as it strips down html bodyPreview := ptr.Val(event.GetBodyPreview()) if event.GetBody() != nil { @@ -350,29 +344,28 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven } } + // TRANSP - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.7 showAs := ptr.Val(event.GetShowAs()).String() - if len(showAs) > 0 && showAs != "unknown" { - var status ics.FreeBusyTimeType + if len(showAs) > 0 { + var transp ics.TimeTransparency switch showAs { - case "free": - status = ics.FreeBusyTimeTypeFree - case "tentative": - status = ics.FreeBusyTimeTypeBusyTentative - case "busy": - status = ics.FreeBusyTimeTypeBusy - case "oof", "workingElsewhere": // this is just best effort conversion - status = ics.FreeBusyTimeTypeBusyUnavailable + case "free", "unknown": + transp = ics.TransparencyTransparent + default: + transp = ics.TransparencyOpaque } - iCalEvent.AddProperty(ics.ComponentPropertyFreebusy, string(status)) + iCalEvent.AddProperty(ics.ComponentPropertyTransp, string(transp)) } + // CATEGORIES - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.2 categories := event.GetCategories() for _, category := range categories { iCalEvent.AddProperty(ics.ComponentPropertyCategories, category) } + // URL - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.6 // According to the RFC, this property may be used in a calendar // component to convey a location where a more dynamic rendition of // the calendar information associated with the calendar component @@ -382,12 +375,13 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven iCalEvent.SetURL(url) } + // ORGANIZER - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.3 organizer := event.GetOrganizer() if organizer != nil { name := ptr.Val(organizer.GetEmailAddress().GetName()) addr := ptr.Val(organizer.GetEmailAddress().GetAddress()) - // TODO: What to do if we only have a name? + // It does not look like we can get just a name without an address if len(name) > 0 && len(addr) > 0 { iCalEvent.SetOrganizer(addr, ics.WithCN(name)) } else if len(addr) > 0 { @@ -395,6 +389,7 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven } } + // ATTENDEE - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.1 attendees := event.GetAttendees() for _, attendee := range attendees { props := []ics.PropertyParameter{} @@ -445,12 +440,50 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven iCalEvent.AddAttendee(addr, props...) } + // LOCATION - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.7 location := getLocationString(event.GetLocation()) if len(location) > 0 { iCalEvent.SetLocation(location) } - // TODO Handle different attachment type (file, item and reference) + // X-MICROSOFT-LOCATIONDISPLAYNAME (Outlook seems to use this) + loc := event.GetLocation() + if loc != nil { + locationDisplayName := ptr.Val(event.GetLocation().GetDisplayName()) + if len(locationDisplayName) > 0 { + iCalEvent.AddProperty("X-MICROSOFT-LOCATIONDISPLAYNAME", locationDisplayName) + } + } + + // CLASS - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.3 + // Graph also has the value "personal" which is not supported by the spec + // Default value is "public" (works for "normal") + sensitivity := ptr.Val(event.GetSensitivity()).String() + if sensitivity == "private" { + iCalEvent.AddProperty(ics.ComponentPropertyClass, "PRIVATE") + } else if sensitivity == "confidential" { + iCalEvent.AddProperty(ics.ComponentPropertyClass, "CONFIDENTIAL") + } + + // PRIORITY - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.9 + imp := ptr.Val(event.GetImportance()).String() + switch imp { + case "high": + iCalEvent.AddProperty(ics.ComponentPropertyPriority, "1") + case "low": + iCalEvent.AddProperty(ics.ComponentPropertyPriority, "9") + } + + meeting := event.GetOnlineMeeting() + if meeting != nil { + url := ptr.Val(meeting.GetJoinUrl()) + if len(url) > 0 { + iCalEvent.AddProperty("X-MICROSOFT-SKYPETEAMSMEETINGURL", url) + } + } + + // ATTACH - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.1 + // TODO Handle different attachment types (file, item and reference) attachments := event.GetAttachments() for _, attachment := range attachments { props := []ics.PropertyParameter{} @@ -482,6 +515,7 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven } // TODO: Inline attachments don't show up in Outlook + // Inline attachments is not something supported by the spec inline := ptr.Val(attachment.GetIsInline()) if inline { cidv, err := attachment.GetBackingStore().Get("contentId") @@ -500,6 +534,7 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven iCalEvent.AddAttachment(base64.StdEncoding.EncodeToString(content), props...) } + // EXDATE - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.1 cancelledDates, err := getCancelledDates(ctx, event) if err != nil { return clues.Wrap(err, "getting cancelled dates") diff --git a/src/internal/converters/ics/ics_test.go b/src/internal/converters/ics/ics_test.go index 787132d0b..966b140a6 100644 --- a/src/internal/converters/ics/ics_test.go +++ b/src/internal/converters/ics/ics_test.go @@ -569,19 +569,6 @@ func (suite *ICSUnitSuite) TestEventConversion() { assert.Contains(t, out, "STATUS:CANCELLED", "cancelled status") }, }, - { - name: "draft event", - event: func() *models.Event { - e := baseEvent() - - e.SetIsDraft(ptr.To(true)) - - return e - }, - check: func(out string) { - assert.Contains(t, out, "STATUS:DRAFT", "draft status") - }, - }, { name: "text body", event: func() *models.Event { @@ -625,7 +612,7 @@ func (suite *ICSUnitSuite) TestEventConversion() { }, }, { - name: "free busy free", + name: "showas free", event: func() *models.Event { e := baseEvent() @@ -637,27 +624,11 @@ func (suite *ICSUnitSuite) TestEventConversion() { return e }, check: func(out string) { - assert.Contains(t, out, "FREEBUSY:FREE", "free busy status") + assert.Contains(t, out, "TRANSP:TRANSPARENT", "free busy status") }, }, { - name: "free busy unknown", - event: func() *models.Event { - e := baseEvent() - - fbs, err := models.ParseFreeBusyStatus("unknown") - require.NoError(t, err, "parse free busy status") - - e.SetShowAs(fbs.(*models.FreeBusyStatus)) - - return e - }, - check: func(out string) { - assert.NotContains(t, out, "FREEBUSY", "free busy status") - }, - }, - { - name: "free busy oof", + name: "showas oof", event: func() *models.Event { e := baseEvent() @@ -669,7 +640,7 @@ func (suite *ICSUnitSuite) TestEventConversion() { return e }, check: func(out string) { - assert.Contains(t, out, "FREEBUSY:BUSY-UNAVAILABLE", "free busy status") + assert.Contains(t, out, "TRANSP:OPAQUE", "free busy status") }, }, { @@ -756,6 +727,66 @@ func (suite *ICSUnitSuite) TestEventConversion() { assert.Contains(t, out, "LOCATION:DisplayName", "location") }, }, + { + name: "teams url", + event: func() *models.Event { + e := baseEvent() + + mi := models.NewOnlineMeetingInfo() + mi.SetJoinUrl(ptr.To("https://team.microsoft.com/meeting-url")) + + e.SetOnlineMeeting(mi) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "X-MICROSOFT-SKYPETEAMSMEETINGURL:https://team.microsoft.com/meeting-url", "teams url") + }, + }, + { + name: "X-MICROSOFT-LOCATIONDISPLAYNAME", + event: func() *models.Event { + e := baseEvent() + + loc := models.NewLocation() + loc.SetDisplayName(ptr.To("DisplayName")) + + e.SetLocation(loc) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "X-MICROSOFT-LOCATIONDISPLAYNAME:DisplayName", "location display name") + }, + }, + { + name: "class", + event: func() *models.Event { + e := baseEvent() + + sen := models.CONFIDENTIAL_SENSITIVITY + e.SetSensitivity(&sen) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "CLASS:CONFIDENTIAL", "class") + }, + }, + { + name: "priority", + event: func() *models.Event { + e := baseEvent() + + pri := models.HIGH_IMPORTANCE + e.SetImportance(&pri) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "PRIORITY:1", "priority") + }, + }, } for _, tt := range table {