Cancelled and modified events in ics export (#4996)

<!-- 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 16:11:10 +05:30 committed by GitHub
parent 7f0b5ebf34
commit 303b8c31ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 406 additions and 115 deletions

View File

@ -3,6 +3,7 @@ package ics
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
@ -33,6 +34,11 @@ import (
// originalStartTimeZone, reminderMinutesBeforeStart, responseRequested,
// responseStatus, sensitivity
const (
iCalDateTimeFormat = "20060102T150405Z"
iCalDateFormat = "20060102"
)
func keyValues(key, value string) *ics.KeyValues {
return &ics.KeyValues{
Key: key,
@ -100,6 +106,7 @@ func getUTCTime(ts, tz string) (time.Time, error) {
// 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
// FIXME: When we have daily repeating task the last one is not getting added (due to timezone differences)
func getRecurrencePattern(
ctx context.Context,
recurrence models.PatternedRecurrenceable,
@ -173,10 +180,10 @@ func getRecurrencePattern(
// a date anymore.
endTime, err := getUTCTime(end.String(), ptr.Val(rrange.GetRecurrenceTimeZone()))
if err != nil {
return "", clues.Wrap(err, "parsing end time")
return "", clues.WrapWC(ctx, err, "parsing end time")
}
recurComponents = append(recurComponents, "UNTIL="+endTime.Format("20060102T150405Z"))
recurComponents = append(recurComponents, "UNTIL="+endTime.Format(iCalDateTimeFormat))
}
case models.NOEND_RECURRENCERANGETYPE:
// Nothing to do
@ -192,108 +199,150 @@ func getRecurrencePattern(
}
func FromJSON(ctx context.Context, body []byte) (string, error) {
data, err := api.BytesToEventable(body)
event, err := api.BytesToEventable(body)
if err != nil {
return "", clues.Wrap(err, "converting to eventable")
return "", clues.WrapWC(ctx, 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))
id := ptr.Val(event.GetId())
iCalEvent := cal.AddEvent(id)
created := data.GetCreatedDateTime()
err = updateEventProperties(ctx, event, iCalEvent)
if err != nil {
return "", clues.Wrap(err, "updating event properties")
}
exceptionOcurrances := event.GetAdditionalData()["exceptionOccurrences"]
if exceptionOcurrances == nil {
return cal.Serialize(), nil
}
for _, occ := range exceptionOcurrances.([]any) {
instance, ok := occ.(map[string]any)
if !ok {
return "", clues.NewWC(ctx, "converting exception instance to map[string]any").
With("interface_type", fmt.Sprintf("%T", instance))
}
exBody, err := json.Marshal(instance)
if err != nil {
return "", clues.WrapWC(ctx, err, "marshalling exception instance")
}
exception, err := api.BytesToEventable(exBody)
if err != nil {
return "", clues.WrapWC(ctx, err, "converting to eventable")
}
exICalEvent := cal.AddEvent(id)
start := exception.GetOriginalStart() // will always be in UTC
exICalEvent.AddProperty(ics.ComponentProperty(ics.PropertyRecurrenceId), start.Format(iCalDateTimeFormat))
err = updateEventProperties(ctx, exception, exICalEvent)
if err != nil {
return "", clues.Wrap(err, "updating exception event properties")
}
}
return cal.Serialize(), nil
}
func updateEventProperties(ctx context.Context, event models.Eventable, iCalEvent *ics.VEvent) error {
created := event.GetCreatedDateTime()
if created != nil {
event.SetCreatedTime(ptr.Val(created))
iCalEvent.SetCreatedTime(ptr.Val(created))
}
modified := data.GetLastModifiedDateTime()
modified := event.GetLastModifiedDateTime()
if modified != nil {
event.SetModifiedAt(ptr.Val(modified))
iCalEvent.SetModifiedAt(ptr.Val(modified))
}
allDay := ptr.Val(data.GetIsAllDay())
allDay := ptr.Val(event.GetIsAllDay())
startString := data.GetStart().GetDateTime()
timeZone := data.GetStart().GetTimeZone()
startString := event.GetStart().GetDateTime()
startTimezone := event.GetStart().GetTimeZone()
if startString != nil {
start, err := getUTCTime(ptr.Val(startString), ptr.Val(timeZone))
start, err := getUTCTime(ptr.Val(startString), ptr.Val(startTimezone))
if err != nil {
return "", clues.Wrap(err, "parsing start time")
return clues.WrapWC(ctx, err, "parsing start time")
}
if allDay {
event.SetStartAt(start, ics.WithValue(string(ics.ValueDataTypeDate)))
iCalEvent.SetStartAt(start, ics.WithValue(string(ics.ValueDataTypeDate)))
} else {
event.SetStartAt(start)
iCalEvent.SetStartAt(start)
}
}
endString := data.GetEnd().GetDateTime()
timeZone = data.GetEnd().GetTimeZone()
endString := event.GetEnd().GetDateTime()
endTimezone := event.GetEnd().GetTimeZone()
if endString != nil {
end, err := getUTCTime(ptr.Val(endString), ptr.Val(timeZone))
end, err := getUTCTime(ptr.Val(endString), ptr.Val(endTimezone))
if err != nil {
return "", clues.Wrap(err, "parsing end time")
return clues.WrapWC(ctx, err, "parsing end time")
}
if allDay {
event.SetEndAt(end, ics.WithValue(string(ics.ValueDataTypeDate)))
iCalEvent.SetEndAt(end, ics.WithValue(string(ics.ValueDataTypeDate)))
} else {
event.SetEndAt(end)
iCalEvent.SetEndAt(end)
}
}
recurrence := data.GetRecurrence()
recurrence := event.GetRecurrence()
if recurrence != nil {
pattern, err := getRecurrencePattern(ctx, recurrence)
if err != nil {
return "", clues.WrapWC(ctx, err, "generating RRULE")
return clues.Wrap(err, "generating RRULE")
}
event.AddRrule(pattern)
iCalEvent.AddRrule(pattern)
}
cancelled := data.GetIsCancelled()
cancelled := event.GetIsCancelled()
if cancelled != nil {
event.SetStatus(ics.ObjectStatusCancelled)
iCalEvent.SetStatus(ics.ObjectStatusCancelled)
}
draft := data.GetIsDraft()
draft := event.GetIsDraft()
if draft != nil {
event.SetStatus(ics.ObjectStatusDraft)
iCalEvent.SetStatus(ics.ObjectStatusDraft)
}
summary := data.GetSubject()
summary := event.GetSubject()
if summary != nil {
event.SetSummary(ptr.Val(summary))
iCalEvent.SetSummary(ptr.Val(summary))
}
// TODO: Emojies currently don't seem to be read properly by Outlook
bodyPreview := ptr.Val(data.GetBodyPreview())
bodyPreview := ptr.Val(event.GetBodyPreview())
if data.GetBody() != nil {
description := ptr.Val(data.GetBody().GetContent())
contentType := data.GetBody().GetContentType().String()
if event.GetBody() != nil {
description := ptr.Val(event.GetBody().GetContent())
contentType := event.GetBody().GetContentType().String()
if len(description) > 0 && contentType == "text" {
event.SetDescription(description)
} else {
iCalEvent.SetDescription(description)
} else if len(description) > 0 {
// https://stackoverflow.com/a/859475
event.SetDescription(bodyPreview)
iCalEvent.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"))
iCalEvent.AddProperty("X-ALT-DESC", desc, ics.WithFmtType("text/html"))
}
}
}
showAs := ptr.Val(data.GetShowAs()).String()
showAs := ptr.Val(event.GetShowAs()).String()
if len(showAs) > 0 && showAs != "unknown" {
var status ics.FreeBusyTimeType
@ -308,37 +357,37 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
status = ics.FreeBusyTimeTypeBusyUnavailable
}
event.AddProperty(ics.ComponentPropertyFreebusy, string(status))
iCalEvent.AddProperty(ics.ComponentPropertyFreebusy, string(status))
}
categories := data.GetCategories()
categories := event.GetCategories()
for _, category := range categories {
event.AddProperty(ics.ComponentPropertyCategories, category)
iCalEvent.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())
url := ptr.Val(event.GetWebLink())
if len(url) > 0 {
event.SetURL(url)
iCalEvent.SetURL(url)
}
organizer := data.GetOrganizer()
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?
if len(name) > 0 && len(addr) > 0 {
event.SetOrganizer(addr, ics.WithCN(name))
iCalEvent.SetOrganizer(addr, ics.WithCN(name))
} else if len(addr) > 0 {
event.SetOrganizer(addr)
iCalEvent.SetOrganizer(addr)
}
}
attendees := data.GetAttendees()
attendees := event.GetAttendees()
for _, attendee := range attendees {
props := []ics.PropertyParameter{}
@ -385,16 +434,16 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
}
addr := ptr.Val(attendee.GetEmailAddress().GetAddress())
event.AddAttendee(addr, props...)
iCalEvent.AddAttendee(addr, props...)
}
location := getLocationString(data.GetLocation())
location := getLocationString(event.GetLocation())
if len(location) > 0 {
event.SetLocation(location)
iCalEvent.SetLocation(location)
}
// TODO Handle different attachment type (file, item and reference)
attachments := data.GetAttachments()
attachments := event.GetAttachments()
for _, attachment := range attachments {
props := []ics.PropertyParameter{}
contentType := ptr.Val(attachment.GetContentType())
@ -411,12 +460,12 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
cb, err := attachment.GetBackingStore().Get("contentBytes")
if err != nil {
return "", clues.Wrap(err, "getting attachment content")
return clues.WrapWC(ctx, err, "getting attachment content")
}
content, ok := cb.([]uint8)
if !ok {
return "", clues.NewWC(ctx, "getting attachment content string")
return clues.NewWC(ctx, "getting attachment content string")
}
props = append(props, ics.WithEncoding("base64"), ics.WithValue("BINARY"))
@ -429,19 +478,55 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
if inline {
cidv, err := attachment.GetBackingStore().Get("contentId")
if err != nil {
return "", clues.Wrap(err, "getting attachment content id")
return clues.WrapWC(ctx, err, "getting attachment content id")
}
cid, err := str.AnyToString(cidv)
if err != nil {
return "", clues.Wrap(err, "getting attachment content id string")
return clues.WrapWC(ctx, err, "getting attachment content id string")
}
props = append(props, keyValues("CID", cid))
}
event.AddAttachment(base64.StdEncoding.EncodeToString(content), props...)
iCalEvent.AddAttachment(base64.StdEncoding.EncodeToString(content), props...)
}
return cal.Serialize(), nil
cancelledDates, err := getCancelledDates(ctx, event)
if err != nil {
return clues.Wrap(err, "getting cancelled dates")
}
dateStrings := []string{}
for _, date := range cancelledDates {
dateStrings = append(dateStrings, date.Format(iCalDateFormat))
}
if len(dateStrings) > 0 {
iCalEvent.AddProperty(ics.ComponentPropertyExdate, strings.Join(dateStrings, ","))
}
return nil
}
func getCancelledDates(ctx context.Context, event models.Eventable) ([]time.Time, error) {
dateStrings, err := api.GetCancelledEventDateStrings(event)
if err != nil {
return nil, clues.WrapWC(ctx, err, "getting cancelled event date strings")
}
dates := []time.Time{}
tz := ptr.Val(event.GetStart().GetTimeZone())
for _, ds := range dateStrings {
// the data just contains date and no time which seems to work
start, err := getUTCTime(ds, tz)
if err != nil {
return nil, clues.WrapWC(ctx, err, "parsing cancelled event date")
}
dates = append(dates, start)
}
return dates, nil
}

View File

@ -734,14 +734,7 @@ func (suite *ICSUnitSuite) TestEventConversion() {
ctx, flush := tester.NewContext(t)
defer flush()
// convert event to bytes
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
err := writer.WriteObjectValue("", tt.event())
require.NoError(t, err, "serializing contact")
bts, err := writer.GetSerializedContent()
bts, err := eventToJSON(tt.event())
require.NoError(t, err, "getting serialized content")
e, err := FromJSON(ctx, bts)
@ -849,10 +842,6 @@ func (suite *ICSUnitSuite) TestAttendees() {
ctx, flush := tester.NewContext(t)
defer flush()
// convert event to bytes
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
e := baseEvent()
atts := make([]models.Attendeeable, len(tt.att))
@ -889,10 +878,7 @@ func (suite *ICSUnitSuite) TestAttendees() {
e.SetAttendees(atts)
err := writer.WriteObjectValue("", e)
require.NoError(t, err, "serializing contact")
bts, err := writer.GetSerializedContent()
bts, err := eventToJSON(e)
require.NoError(t, err, "getting serialized content")
out, err := FromJSON(ctx, bts)
@ -1035,16 +1021,9 @@ func (suite *ICSUnitSuite) TestAttachments() {
ctx, flush := tester.NewContext(t)
defer flush()
// convert event to bytes
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
e := baseEvent()
err := writer.WriteObjectValue("", e)
require.NoError(t, err, "serializing contact")
bts, err := writer.GetSerializedContent()
bts, err := eventToJSON(e)
require.NoError(t, err, "getting serialized content")
parsed := map[string]any{}
@ -1079,3 +1058,225 @@ func (suite *ICSUnitSuite) TestAttachments() {
})
}
}
func (suite *ICSUnitSuite) TestCancellations() {
table := []struct {
name string
cancelledIds []string
expected string
}{
{
name: "single",
cancelledIds: []string{
"OID.DEADBEEF=.2024-01-25",
},
expected: "EXDATE:20240125",
},
{
name: "multiple",
cancelledIds: []string{
"OID.DEADBEEF=.2024-01-25",
"OID.LIVEBEEF=.2024-02-26",
},
expected: "EXDATE:20240125,20240226",
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
e := baseEvent()
e.SetIsCancelled(ptr.To(true))
e.SetAdditionalData(map[string]any{
"cancelledOccurrences": tt.cancelledIds,
})
bts, err := eventToJSON(e)
require.NoError(t, err, "getting serialized content")
out, err := FromJSON(ctx, bts)
require.NoError(t, err, "converting to ics")
assert.Contains(t, out, tt.expected, "cancellation exrule")
})
}
}
func getDateTimeZone(t time.Time, tz string) *models.DateTimeTimeZone {
dt := models.NewDateTimeTimeZone()
dt.SetDateTime(ptr.To(t.Format(time.RFC3339)))
dt.SetTimeZone(ptr.To(tz))
return dt
}
func eventToMap(e *models.Event) (map[string]any, error) {
bts, err := eventToJSON(e)
if err != nil {
return nil, err
}
parsed := map[string]any{}
err = json.Unmarshal(bts, &parsed)
if err != nil {
return nil, err
}
return parsed, nil
}
func eventToJSON(e *models.Event) ([]byte, error) {
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
err := writer.WriteObjectValue("", e)
if err != nil {
return nil, err
}
bts, err := writer.GetSerializedContent()
if err != nil {
return nil, err
}
return bts, err
}
func (suite *ICSUnitSuite) TestEventExceptions() {
table := []struct {
name string
event func() *models.Event
check func(string)
}{
{
name: "single exception",
event: func() *models.Event {
e := baseEvent()
exception := baseEvent()
exception.SetSubject(ptr.To("Exception"))
exception.SetOriginalStart(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC)))
newStart := getDateTimeZone(time.Date(2021, 1, 1, 13, 0, 0, 0, time.UTC), "UTC")
newEnd := getDateTimeZone(time.Date(2021, 1, 1, 14, 0, 0, 0, time.UTC), "UTC")
exception.SetStart(newStart)
exception.SetEnd(newEnd)
parsed, err := eventToMap(exception)
require.NoError(suite.T(), err, "parsing exception")
// add exception event to additional data
e.SetAdditionalData(map[string]any{
"exceptionOccurrences": []map[string]any{parsed},
})
return e
},
check: func(out string) {
lines := strings.Split(out, "\r\n")
events := 0
for _, l := range lines {
if strings.HasPrefix(l, "BEGIN:VEVENT") {
events++
}
}
assert.Equal(suite.T(), 2, events, "number of events")
assert.Contains(suite.T(), out, "RECURRENCE-ID:20210101T120000Z", "recurrence id")
assert.Contains(suite.T(), out, "SUMMARY:Subject", "original event")
assert.Contains(suite.T(), out, "SUMMARY:Exception", "exception event")
assert.Contains(suite.T(), out, "DTSTART:20210101T130000Z", "new start time")
assert.Contains(suite.T(), out, "DTEND:20210101T140000Z", "new end time")
},
},
{
name: "multiple exceptions",
event: func() *models.Event {
e := baseEvent()
exception1 := baseEvent()
exception1.SetSubject(ptr.To("Exception 1"))
exception1.SetOriginalStart(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC)))
newStart := getDateTimeZone(time.Date(2021, 1, 1, 13, 0, 0, 0, time.UTC), "UTC")
newEnd := getDateTimeZone(time.Date(2021, 1, 1, 14, 0, 0, 0, time.UTC), "UTC")
exception1.SetStart(newStart)
exception1.SetEnd(newEnd)
exception2 := baseEvent()
exception2.SetSubject(ptr.To("Exception 2"))
exception2.SetOriginalStart(ptr.To(time.Date(2021, 1, 2, 12, 0, 0, 0, time.UTC)))
newStart = getDateTimeZone(time.Date(2021, 1, 2, 13, 0, 0, 0, time.UTC), "UTC")
newEnd = getDateTimeZone(time.Date(2021, 1, 2, 14, 0, 0, 0, time.UTC), "UTC")
exception2.SetStart(newStart)
exception2.SetEnd(newEnd)
parsed1, err := eventToMap(exception1)
require.NoError(suite.T(), err, "parsing exception 1")
parsed2, err := eventToMap(exception2)
require.NoError(suite.T(), err, "parsing exception 2")
// add exception event to additional data
e.SetAdditionalData(map[string]any{
"exceptionOccurrences": []map[string]any{parsed1, parsed2},
})
return e
},
check: func(out string) {
lines := strings.Split(out, "\r\n")
events := 0
for _, l := range lines {
if strings.HasPrefix(l, "BEGIN:VEVENT") {
events++
}
}
assert.Equal(suite.T(), 3, events, "number of events")
assert.Contains(suite.T(), out, "RECURRENCE-ID:20210101T120000Z", "recurrence id 1")
assert.Contains(suite.T(), out, "RECURRENCE-ID:20210102T120000Z", "recurrence id 2")
assert.Contains(suite.T(), out, "SUMMARY:Subject", "original event")
assert.Contains(suite.T(), out, "SUMMARY:Exception 1", "exception event 1")
assert.Contains(suite.T(), out, "SUMMARY:Exception 2", "exception event 2")
assert.Contains(suite.T(), out, "DTSTART:20210101T130000Z", "new start time 1")
assert.Contains(suite.T(), out, "DTEND:20210101T140000Z", "new end time 1")
assert.Contains(suite.T(), out, "DTSTART:20210102T130000Z", "new start time 2")
assert.Contains(suite.T(), out, "DTEND:20210102T140000Z", "new end time 2")
},
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
ctx, flush := tester.NewContext(suite.T())
defer flush()
bts, err := eventToJSON(tt.event())
require.NoError(suite.T(), err, "getting serialized content")
out, err := FromJSON(ctx, bts)
require.NoError(suite.T(), err, "converting to ics")
tt.check(out)
})
}
}

View File

@ -221,7 +221,7 @@ func (c Events) GetItem(
return nil, nil, graph.Stack(ctx, err)
}
err = validateCancelledOccurrences(event)
_, err = GetCancelledEventDateStrings(event)
if err != nil {
return nil, nil, clues.Wrap(err, "verify cancelled occurrences")
}
@ -309,26 +309,30 @@ func fixupExceptionOccurrences(
return nil
}
// Adding checks to ensure that the data is in the format that we expect M365 to return
func validateCancelledOccurrences(event models.Eventable) error {
func GetCancelledEventDateStrings(event models.Eventable) ([]string, error) {
cancelledOccurrences := event.GetAdditionalData()["cancelledOccurrences"]
if cancelledOccurrences != nil {
if cancelledOccurrences == nil {
return nil, nil
}
co, ok := cancelledOccurrences.([]any)
if !ok {
return clues.New("converting cancelledOccurrences to []any").
return nil, clues.New("converting cancelledOccurrences to []any").
With("type", fmt.Sprintf("%T", cancelledOccurrences))
}
dates := []string{}
for _, instance := range co {
instance, err := str.AnyToString(instance)
if err != nil {
return err
return nil, err
}
// There might be multiple `.` in the ID and hence >2
splits := strings.Split(instance, ".")
if len(splits) < 2 {
return clues.New("unexpected cancelled event format").
return nil, clues.New("unexpected cancelled event format").
With("instance", instance)
}
@ -336,12 +340,13 @@ func validateCancelledOccurrences(event models.Eventable) error {
_, err = dttm.ParseTime(startStr)
if err != nil {
return clues.Wrap(err, "parsing cancelled event date")
}
}
return nil, clues.Wrap(err, "parsing cancelled event date")
}
return nil
dates = append(dates, startStr)
}
return dates, nil
}
func parseableToMap(att serialization.Parsable) (map[string]any, error) {