Abin Simon b49c124512
Events export as ICS (#4958)
There are a few more pending items. I've added TODO entries for them. The PR was getting pretty big and so I thought I would address them in a followup PR.

---

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

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

#### Type of change

<!--- Please check the type of change your PR introduces: --->
- [ ] 🌻 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
- [ ]  Unit test
- [ ] 💚 E2E
2024-01-03 10:44:28 +00:00

448 lines
12 KiB
Go

package ics
import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/alcionai/clues"
ics "github.com/arran4/golang-ical"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/common/str"
"github.com/alcionai/corso/src/pkg/dttm"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
// 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
// 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
func keyValues(key, value string) *ics.KeyValues {
return &ics.KeyValues{
Key: key,
Value: []string{value},
}
}
func getLocationString(location models.Locationable) string {
if location == nil {
return ""
}
dn := ptr.Val(location.GetDisplayName())
segments := []string{dn}
// TODO: Handle different location types
addr := location.GetAddress()
if addr != nil {
street := ptr.Val(addr.GetStreet())
city := ptr.Val(addr.GetCity())
state := ptr.Val(addr.GetState())
country := ptr.Val(addr.GetCountryOrRegion())
postal := ptr.Val(addr.GetPostalCode())
segments = append(segments, street, city, state, country, postal)
}
nonEmpty := []string{}
for _, seg := range segments {
if len(seg) > 0 {
nonEmpty = append(nonEmpty, seg)
}
}
return strings.Join(nonEmpty, ", ")
}
func getUTCTime(ts, tz string) (time.Time, error) {
// Timezone is always converted to UTC. This is the easiest way to
// ensure we have the correct time as the .ics file expects the same
// timezone everywhere according to the spec.
it, err := dttm.ParseTime(ts)
if err != nil {
return time.Now(), clues.Wrap(err, "parsing time")
}
timezone, ok := GraphTimeZoneToTZ[tz]
if !ok {
return it, clues.New("unknown timezone")
}
loc, err := time.LoadLocation(timezone)
if err != nil {
return time.Now(), clues.Wrap(err, "loading timezone")
}
// embed timezone
locTime := time.Date(it.Year(), it.Month(), it.Day(), it.Hour(), it.Minute(), it.Second(), 0, loc)
return locTime.UTC(), nil
}
// https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3
// https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10
// https://learn.microsoft.com/en-us/graph/api/resources/patternedrecurrence?view=graph-rest-1.0
// Ref: https://github.com/closeio/sync-engine/pull/381/files
func getRecurrencePattern(
ctx context.Context,
recurrence models.PatternedRecurrenceable,
) (string, error) {
recurComponents := []string{}
pat := recurrence.GetPattern()
freq := pat.GetTypeEscaped()
if freq != nil {
switch *freq {
case models.DAILY_RECURRENCEPATTERNTYPE:
recurComponents = append(recurComponents, "FREQ=DAILY")
case models.WEEKLY_RECURRENCEPATTERNTYPE:
recurComponents = append(recurComponents, "FREQ=WEEKLY")
case models.ABSOLUTEMONTHLY_RECURRENCEPATTERNTYPE, models.RELATIVEMONTHLY_RECURRENCEPATTERNTYPE:
recurComponents = append(recurComponents, "FREQ=MONTHLY")
case models.ABSOLUTEYEARLY_RECURRENCEPATTERNTYPE, models.RELATIVEYEARLY_RECURRENCEPATTERNTYPE:
recurComponents = append(recurComponents, "FREQ=YEARLY")
}
}
interval := pat.GetInterval()
if interval != nil {
recurComponents = append(recurComponents, "INTERVAL="+fmt.Sprint(ptr.Val(interval)))
}
month := ptr.Val(pat.GetMonth())
if month > 0 {
recurComponents = append(recurComponents, "BYMONTH="+fmt.Sprint(month))
}
// This is required if absoluteMonthly or absoluteYearly
day := ptr.Val(pat.GetDayOfMonth())
if day > 0 {
recurComponents = append(recurComponents, "BYMONTHDAY="+fmt.Sprint(day))
}
dow := pat.GetDaysOfWeek()
if dow != nil {
dowComponents := []string{}
for _, day := range dow {
icalday, ok := GraphToICalDOW[day.String()]
if !ok {
return "", clues.NewWC(ctx, "unknown day of week").With("day", day.String())
}
dowComponents = append(dowComponents, icalday)
}
index := pat.GetIndex()
prefix := ""
if index != nil &&
(ptr.Val(freq) == models.RELATIVEMONTHLY_RECURRENCEPATTERNTYPE ||
ptr.Val(freq) == models.RELATIVEYEARLY_RECURRENCEPATTERNTYPE) {
prefix = fmt.Sprint(GraphToICalIndex[index.String()])
}
recurComponents = append(recurComponents, "BYDAY="+prefix+strings.Join(dowComponents, ","))
}
rrange := recurrence.GetRangeEscaped()
if rrange != nil {
switch ptr.Val(rrange.GetTypeEscaped()) {
case models.ENDDATE_RECURRENCERANGETYPE:
end := rrange.GetEndDate()
if end != nil {
// NOTE: We convert just a date into date+time in a
// different timezone which will cause it to not be just
// a date anymore.
endTime, err := getUTCTime(end.String(), ptr.Val(rrange.GetRecurrenceTimeZone()))
if err != nil {
return "", clues.Wrap(err, "parsing end time")
}
recurComponents = append(recurComponents, "UNTIL="+endTime.Format("20060102T150405Z"))
}
case models.NOEND_RECURRENCERANGETYPE:
// Nothing to do
case models.NUMBERED_RECURRENCERANGETYPE:
count := ptr.Val(rrange.GetNumberOfOccurrences())
if count > 0 {
recurComponents = append(recurComponents, "COUNT="+fmt.Sprint(count))
}
}
}
return strings.Join(recurComponents, ";"), nil
}
func FromJSON(ctx context.Context, body []byte) (string, error) {
data, err := api.BytesToEventable(body)
if err != nil {
return "", clues.Wrap(err, "converting to eventable")
}
cal := ics.NewCalendar()
cal.SetProductId("-//Alcion//Corso") // Does this have to be customizable?
id := data.GetId() // XXX: iCalUId?
event := cal.AddEvent(ptr.Val(id))
created := data.GetCreatedDateTime()
if created != nil {
event.SetCreatedTime(ptr.Val(created))
}
modified := data.GetLastModifiedDateTime()
if modified != nil {
event.SetModifiedAt(ptr.Val(modified))
}
allDay := ptr.Val(data.GetIsAllDay())
startString := data.GetStart().GetDateTime()
timeZone := data.GetStart().GetTimeZone()
if startString != nil {
start, err := getUTCTime(ptr.Val(startString), ptr.Val(timeZone))
if err != nil {
return "", clues.Wrap(err, "parsing start time")
}
if allDay {
event.SetStartAt(start, ics.WithValue(string(ics.ValueDataTypeDate)))
} else {
event.SetStartAt(start)
}
}
endString := data.GetEnd().GetDateTime()
timeZone = data.GetEnd().GetTimeZone()
if endString != nil {
end, err := getUTCTime(ptr.Val(endString), ptr.Val(timeZone))
if err != nil {
return "", clues.Wrap(err, "parsing end time")
}
if allDay {
event.SetEndAt(end, ics.WithValue(string(ics.ValueDataTypeDate)))
} else {
event.SetEndAt(end)
}
}
recurrence := data.GetRecurrence()
if recurrence != nil {
pattern, err := getRecurrencePattern(ctx, recurrence)
if err != nil {
return "", clues.WrapWC(ctx, err, "generating RRULE")
}
event.AddRrule(pattern)
}
cancelled := data.GetIsCancelled()
if cancelled != nil {
event.SetStatus(ics.ObjectStatusCancelled)
}
draft := data.GetIsDraft()
if draft != nil {
event.SetStatus(ics.ObjectStatusDraft)
}
summary := data.GetSubject()
if summary != nil {
event.SetSummary(ptr.Val(summary))
}
// TODO: Emojies currently don't seem to be read properly by Outlook
bodyPreview := ptr.Val(data.GetBodyPreview())
if data.GetBody() != nil {
description := ptr.Val(data.GetBody().GetContent())
contentType := data.GetBody().GetContentType().String()
if len(description) > 0 && contentType == "text" {
event.SetDescription(description)
} else {
// https://stackoverflow.com/a/859475
event.SetDescription(bodyPreview)
if contentType == "html" {
desc := strings.ReplaceAll(description, "\r\n", "")
desc = strings.ReplaceAll(desc, "\n", "")
event.AddProperty("X-ALT-DESC", desc, ics.WithFmtType("text/html"))
}
}
}
showAs := ptr.Val(data.GetShowAs()).String()
if len(showAs) > 0 && showAs != "unknown" {
var status ics.FreeBusyTimeType
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
}
event.AddProperty(ics.ComponentPropertyFreebusy, string(status))
}
categories := data.GetCategories()
for _, category := range categories {
event.AddProperty(ics.ComponentPropertyCategories, category)
}
// 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
// can be found.
url := ptr.Val(data.GetWebLink())
if len(url) > 0 {
event.SetURL(url)
}
organizer := data.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?
if len(name) > 0 && len(addr) > 0 {
event.SetOrganizer(addr, ics.WithCN(name))
} else if len(addr) > 0 {
event.SetOrganizer(addr)
}
}
attendees := data.GetAttendees()
for _, attendee := range attendees {
props := []ics.PropertyParameter{}
atype := attendee.GetTypeEscaped()
if atype != nil {
var role ics.ParticipationRole
switch atype.String() {
case "required":
role = ics.ParticipationRoleReqParticipant
case "optional":
role = ics.ParticipationRoleOptParticipant
case "resource":
role = ics.ParticipationRoleNonParticipant
}
props = append(props, keyValues(string(ics.ParameterRole), string(role)))
}
name := ptr.Val(attendee.GetEmailAddress().GetName())
if len(name) > 0 {
props = append(props, ics.WithCN(name))
}
// Time when a resp change occurred is not recorded
if attendee.GetStatus() != nil {
resp := ptr.Val(attendee.GetStatus().GetResponse()).String()
if len(resp) > 0 && resp != "none" {
var pstat ics.ParticipationStatus
switch resp {
case "accepted", "organizer":
pstat = ics.ParticipationStatusAccepted
case "declined":
pstat = ics.ParticipationStatusDeclined
case "tentativelyAccepted":
pstat = ics.ParticipationStatusTentative
case "notResponded":
pstat = ics.ParticipationStatusNeedsAction
}
props = append(props, keyValues(string(ics.ParameterParticipationStatus), string(pstat)))
}
}
addr := ptr.Val(attendee.GetEmailAddress().GetAddress())
event.AddAttendee(addr, props...)
}
location := getLocationString(data.GetLocation())
if len(location) > 0 {
event.SetLocation(location)
}
// TODO Handle different attachment type (file, item and reference)
attachments := data.GetAttachments()
for _, attachment := range attachments {
props := []ics.PropertyParameter{}
contentType := ptr.Val(attachment.GetContentType())
name := ptr.Val(attachment.GetName())
if len(name) > 0 {
// FILENAME does not seem to be parsed by Outlook
props = append(props,
&ics.KeyValues{
Key: "FILENAME",
Value: []string{name},
})
}
cb, err := attachment.GetBackingStore().Get("contentBytes")
if err != nil {
return "", clues.Wrap(err, "getting attachment content")
}
content, ok := cb.([]uint8)
if !ok {
return "", clues.NewWC(ctx, "getting attachment content string")
}
props = append(props, ics.WithEncoding("base64"), ics.WithValue("BINARY"))
if len(contentType) > 0 {
props = append(props, ics.WithFmtType(contentType))
}
// TODO: Inline attachments don't show up in Outlook
inline := ptr.Val(attachment.GetIsInline())
if inline {
cidv, err := attachment.GetBackingStore().Get("contentId")
if err != nil {
return "", clues.Wrap(err, "getting attachment content id")
}
cid, err := str.AnyToString(cidv)
if err != nil {
return "", clues.Wrap(err, "getting attachment content id string")
}
props = append(props, keyValues("CID", cid))
}
event.AddAttachment(base64.StdEncoding.EncodeToString(content), props...)
}
return cal.Serialize(), nil
}