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:
parent
7f0b5ebf34
commit
303b8c31ce
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user