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
448 lines
12 KiB
Go
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
|
|
}
|