Few more fixes to ics export (#5004)

<!-- PR description-->

---

#### Does this PR need a docs update or release note?

- [ ]  Yes, it's included
- [ ] 🕐 Yes, but in a later PR
- [x]  No

#### Type of change

<!--- Please check the type of change your PR introduces: --->
- [x] 🌻 Feature
- [ ] 🐛 Bugfix
- [ ] 🗺️ Documentation
- [ ] 🤖 Supportability/Tests
- [ ] 💻 CI/Deployment
- [ ] 🧹 Tech Debt/Cleanup

#### Issue(s)

<!-- Can reference multiple issues. Use one of the following "magic words" - "closes, fixes" to auto-close the Github issue. -->
* https://github.com/alcionai/corso/issues/3890

#### Test Plan

<!-- How will this be tested prior to merging.-->
- [ ] 💪 Manual
- [x]  Unit test
- [ ] 💚 E2E
This commit is contained in:
Abin Simon 2024-01-12 19:58:25 +05:30 committed by GitHub
parent da8466ae0b
commit 985220562c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 132 additions and 66 deletions

View File

@ -19,20 +19,13 @@ import (
) )
// This package is used to convert json response from graph to ics // This package is used to convert json response from graph to ics
// Ref: https://icalendar.org/ // https://icalendar.org/
// Ref: https://www.rfc-editor.org/rfc/rfc5545 // https://www.rfc-editor.org/rfc/rfc5545
// Ref: https://learn.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-1.0 // 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 // TODO locations: https://github.com/alcionai/corso/issues/5003
// 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
const ( const (
iCalDateTimeFormat = "20060102T150405Z" iCalDateTimeFormat = "20060102T150405Z"
@ -54,7 +47,6 @@ func getLocationString(location models.Locationable) string {
dn := ptr.Val(location.GetDisplayName()) dn := ptr.Val(location.GetDisplayName())
segments := []string{dn} segments := []string{dn}
// TODO: Handle different location types
addr := location.GetAddress() addr := location.GetAddress()
if addr != nil { if addr != nil {
street := ptr.Val(addr.GetStreet()) 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 { 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() created := event.GetCreatedDateTime()
if created != nil { if created != nil {
iCalEvent.SetCreatedTime(ptr.Val(created)) iCalEvent.SetCreatedTime(ptr.Val(created))
} }
// LAST-MODIFIED - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.3
modified := event.GetLastModifiedDateTime() modified := event.GetLastModifiedDateTime()
if modified != nil { if modified != nil {
iCalEvent.SetModifiedAt(ptr.Val(modified)) iCalEvent.SetModifiedAt(ptr.Val(modified))
} }
// DTSTART - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.4
allDay := ptr.Val(event.GetIsAllDay()) allDay := ptr.Val(event.GetIsAllDay())
startString := event.GetStart().GetDateTime() startString := event.GetStart().GetDateTime()
startTimezone := event.GetStart().GetTimeZone() 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() endString := event.GetEnd().GetDateTime()
endTimezone := event.GetEnd().GetTimeZone() endTimezone := event.GetEnd().GetTimeZone()
@ -314,22 +309,21 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven
iCalEvent.AddRrule(pattern) iCalEvent.AddRrule(pattern)
} }
// STATUS - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.11
cancelled := event.GetIsCancelled() cancelled := event.GetIsCancelled()
if cancelled != nil { if cancelled != nil {
iCalEvent.SetStatus(ics.ObjectStatusCancelled) iCalEvent.SetStatus(ics.ObjectStatusCancelled)
} }
draft := event.GetIsDraft() // SUMMARY - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.12
if draft != nil {
iCalEvent.SetStatus(ics.ObjectStatusDraft)
}
summary := event.GetSubject() summary := event.GetSubject()
if summary != nil { if summary != nil {
iCalEvent.SetSummary(ptr.Val(summary)) 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 // 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()) bodyPreview := ptr.Val(event.GetBodyPreview())
if event.GetBody() != nil { 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() showAs := ptr.Val(event.GetShowAs()).String()
if len(showAs) > 0 && showAs != "unknown" { if len(showAs) > 0 {
var status ics.FreeBusyTimeType var transp ics.TimeTransparency
switch showAs { switch showAs {
case "free": case "free", "unknown":
status = ics.FreeBusyTimeTypeFree transp = ics.TransparencyTransparent
case "tentative": default:
status = ics.FreeBusyTimeTypeBusyTentative transp = ics.TransparencyOpaque
case "busy":
status = ics.FreeBusyTimeTypeBusy
case "oof", "workingElsewhere": // this is just best effort conversion
status = ics.FreeBusyTimeTypeBusyUnavailable
} }
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() categories := event.GetCategories()
for _, category := range categories { for _, category := range categories {
iCalEvent.AddProperty(ics.ComponentPropertyCategories, category) 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 // According to the RFC, this property may be used in a calendar
// component to convey a location where a more dynamic rendition of // component to convey a location where a more dynamic rendition of
// the calendar information associated with the calendar component // the calendar information associated with the calendar component
@ -382,12 +375,13 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven
iCalEvent.SetURL(url) iCalEvent.SetURL(url)
} }
// ORGANIZER - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.4.3
organizer := event.GetOrganizer() organizer := event.GetOrganizer()
if organizer != nil { if organizer != nil {
name := ptr.Val(organizer.GetEmailAddress().GetName()) name := ptr.Val(organizer.GetEmailAddress().GetName())
addr := ptr.Val(organizer.GetEmailAddress().GetAddress()) 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 { if len(name) > 0 && len(addr) > 0 {
iCalEvent.SetOrganizer(addr, ics.WithCN(name)) iCalEvent.SetOrganizer(addr, ics.WithCN(name))
} else if len(addr) > 0 { } 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() attendees := event.GetAttendees()
for _, attendee := range attendees { for _, attendee := range attendees {
props := []ics.PropertyParameter{} props := []ics.PropertyParameter{}
@ -445,12 +440,50 @@ func updateEventProperties(ctx context.Context, event models.Eventable, iCalEven
iCalEvent.AddAttendee(addr, props...) iCalEvent.AddAttendee(addr, props...)
} }
// LOCATION - https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.7
location := getLocationString(event.GetLocation()) location := getLocationString(event.GetLocation())
if len(location) > 0 { if len(location) > 0 {
iCalEvent.SetLocation(location) 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() attachments := event.GetAttachments()
for _, attachment := range attachments { for _, attachment := range attachments {
props := []ics.PropertyParameter{} 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 // TODO: Inline attachments don't show up in Outlook
// Inline attachments is not something supported by the spec
inline := ptr.Val(attachment.GetIsInline()) inline := ptr.Val(attachment.GetIsInline())
if inline { if inline {
cidv, err := attachment.GetBackingStore().Get("contentId") 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...) 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) cancelledDates, err := getCancelledDates(ctx, event)
if err != nil { if err != nil {
return clues.Wrap(err, "getting cancelled dates") return clues.Wrap(err, "getting cancelled dates")

View File

@ -569,19 +569,6 @@ func (suite *ICSUnitSuite) TestEventConversion() {
assert.Contains(t, out, "STATUS:CANCELLED", "cancelled status") 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", name: "text body",
event: func() *models.Event { event: func() *models.Event {
@ -625,7 +612,7 @@ func (suite *ICSUnitSuite) TestEventConversion() {
}, },
}, },
{ {
name: "free busy free", name: "showas free",
event: func() *models.Event { event: func() *models.Event {
e := baseEvent() e := baseEvent()
@ -637,27 +624,11 @@ func (suite *ICSUnitSuite) TestEventConversion() {
return e return e
}, },
check: func(out string) { 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", name: "showas oof",
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",
event: func() *models.Event { event: func() *models.Event {
e := baseEvent() e := baseEvent()
@ -669,7 +640,7 @@ func (suite *ICSUnitSuite) TestEventConversion() {
return e return e
}, },
check: func(out string) { 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") 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 { for _, tt := range table {