From 303b8c31ce8b6489be477cff759e593ea7132fb7 Mon Sep 17 00:00:00 2001 From: Abin Simon Date: Fri, 12 Jan 2024 16:11:10 +0530 Subject: [PATCH] Cancelled and modified events in ics export (#4996) --- #### 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 | 205 +++++++++++++------ src/internal/converters/ics/ics_test.go | 249 +++++++++++++++++++++--- src/pkg/services/m365/api/events.go | 67 ++++--- 3 files changed, 406 insertions(+), 115 deletions(-) diff --git a/src/internal/converters/ics/ics.go b/src/internal/converters/ics/ics.go index 10f0c1d20..0682e36ef 100644 --- a/src/internal/converters/ics/ics.go +++ b/src/internal/converters/ics/ics.go @@ -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 } diff --git a/src/internal/converters/ics/ics_test.go b/src/internal/converters/ics/ics_test.go index 8dc6e249d..066e6c09d 100644 --- a/src/internal/converters/ics/ics_test.go +++ b/src/internal/converters/ics/ics_test.go @@ -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) + }) + } +} diff --git a/src/pkg/services/m365/api/events.go b/src/pkg/services/m365/api/events.go index 7feb2837b..87d798a7f 100644 --- a/src/pkg/services/m365/api/events.go +++ b/src/pkg/services/m365/api/events.go @@ -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,39 +309,44 @@ 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 { - co, ok := cancelledOccurrences.([]any) - if !ok { - return clues.New("converting cancelledOccurrences to []any"). - With("type", fmt.Sprintf("%T", cancelledOccurrences)) - } - - for _, instance := range co { - instance, err := str.AnyToString(instance) - if err != nil { - return 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"). - With("instance", instance) - } - - startStr := splits[len(splits)-1] - - _, err = dttm.ParseTime(startStr) - if err != nil { - return clues.Wrap(err, "parsing cancelled event date") - } - } + if cancelledOccurrences == nil { + return nil, nil } - return nil + co, ok := cancelledOccurrences.([]any) + if !ok { + 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 nil, err + } + + // There might be multiple `.` in the ID and hence >2 + splits := strings.Split(instance, ".") + if len(splits) < 2 { + return nil, clues.New("unexpected cancelled event format"). + With("instance", instance) + } + + startStr := splits[len(splits)-1] + + _, err = dttm.ParseTime(startStr) + if err != nil { + return nil, clues.Wrap(err, "parsing cancelled event date") + } + + dates = append(dates, startStr) + } + + return dates, nil } func parseableToMap(att serialization.Parsable) (map[string]any, error) {