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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -33,6 +34,11 @@ import (
|
|||||||
// originalStartTimeZone, reminderMinutesBeforeStart, responseRequested,
|
// originalStartTimeZone, reminderMinutesBeforeStart, responseRequested,
|
||||||
// responseStatus, sensitivity
|
// responseStatus, sensitivity
|
||||||
|
|
||||||
|
const (
|
||||||
|
iCalDateTimeFormat = "20060102T150405Z"
|
||||||
|
iCalDateFormat = "20060102"
|
||||||
|
)
|
||||||
|
|
||||||
func keyValues(key, value string) *ics.KeyValues {
|
func keyValues(key, value string) *ics.KeyValues {
|
||||||
return &ics.KeyValues{
|
return &ics.KeyValues{
|
||||||
Key: key,
|
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://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
|
// 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
|
// 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(
|
func getRecurrencePattern(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
recurrence models.PatternedRecurrenceable,
|
recurrence models.PatternedRecurrenceable,
|
||||||
@ -173,10 +180,10 @@ func getRecurrencePattern(
|
|||||||
// a date anymore.
|
// a date anymore.
|
||||||
endTime, err := getUTCTime(end.String(), ptr.Val(rrange.GetRecurrenceTimeZone()))
|
endTime, err := getUTCTime(end.String(), ptr.Val(rrange.GetRecurrenceTimeZone()))
|
||||||
if err != nil {
|
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:
|
case models.NOEND_RECURRENCERANGETYPE:
|
||||||
// Nothing to do
|
// Nothing to do
|
||||||
@ -192,108 +199,150 @@ func getRecurrencePattern(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FromJSON(ctx context.Context, body []byte) (string, error) {
|
func FromJSON(ctx context.Context, body []byte) (string, error) {
|
||||||
data, err := api.BytesToEventable(body)
|
event, err := api.BytesToEventable(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", clues.Wrap(err, "converting to eventable")
|
return "", clues.WrapWC(ctx, err, "converting to eventable")
|
||||||
}
|
}
|
||||||
|
|
||||||
cal := ics.NewCalendar()
|
cal := ics.NewCalendar()
|
||||||
cal.SetProductId("-//Alcion//Corso") // Does this have to be customizable?
|
cal.SetProductId("-//Alcion//Corso") // Does this have to be customizable?
|
||||||
|
|
||||||
id := data.GetId() // XXX: iCalUId?
|
id := ptr.Val(event.GetId())
|
||||||
event := cal.AddEvent(ptr.Val(id))
|
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 {
|
if created != nil {
|
||||||
event.SetCreatedTime(ptr.Val(created))
|
iCalEvent.SetCreatedTime(ptr.Val(created))
|
||||||
}
|
}
|
||||||
|
|
||||||
modified := data.GetLastModifiedDateTime()
|
modified := event.GetLastModifiedDateTime()
|
||||||
if modified != nil {
|
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()
|
startString := event.GetStart().GetDateTime()
|
||||||
timeZone := data.GetStart().GetTimeZone()
|
startTimezone := event.GetStart().GetTimeZone()
|
||||||
|
|
||||||
if startString != nil {
|
if startString != nil {
|
||||||
start, err := getUTCTime(ptr.Val(startString), ptr.Val(timeZone))
|
start, err := getUTCTime(ptr.Val(startString), ptr.Val(startTimezone))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", clues.Wrap(err, "parsing start time")
|
return clues.WrapWC(ctx, err, "parsing start time")
|
||||||
}
|
}
|
||||||
|
|
||||||
if allDay {
|
if allDay {
|
||||||
event.SetStartAt(start, ics.WithValue(string(ics.ValueDataTypeDate)))
|
iCalEvent.SetStartAt(start, ics.WithValue(string(ics.ValueDataTypeDate)))
|
||||||
} else {
|
} else {
|
||||||
event.SetStartAt(start)
|
iCalEvent.SetStartAt(start)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
endString := data.GetEnd().GetDateTime()
|
endString := event.GetEnd().GetDateTime()
|
||||||
timeZone = data.GetEnd().GetTimeZone()
|
endTimezone := event.GetEnd().GetTimeZone()
|
||||||
|
|
||||||
if endString != nil {
|
if endString != nil {
|
||||||
end, err := getUTCTime(ptr.Val(endString), ptr.Val(timeZone))
|
end, err := getUTCTime(ptr.Val(endString), ptr.Val(endTimezone))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", clues.Wrap(err, "parsing end time")
|
return clues.WrapWC(ctx, err, "parsing end time")
|
||||||
}
|
}
|
||||||
|
|
||||||
if allDay {
|
if allDay {
|
||||||
event.SetEndAt(end, ics.WithValue(string(ics.ValueDataTypeDate)))
|
iCalEvent.SetEndAt(end, ics.WithValue(string(ics.ValueDataTypeDate)))
|
||||||
} else {
|
} else {
|
||||||
event.SetEndAt(end)
|
iCalEvent.SetEndAt(end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recurrence := data.GetRecurrence()
|
recurrence := event.GetRecurrence()
|
||||||
if recurrence != nil {
|
if recurrence != nil {
|
||||||
pattern, err := getRecurrencePattern(ctx, recurrence)
|
pattern, err := getRecurrencePattern(ctx, recurrence)
|
||||||
if err != nil {
|
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 {
|
if cancelled != nil {
|
||||||
event.SetStatus(ics.ObjectStatusCancelled)
|
iCalEvent.SetStatus(ics.ObjectStatusCancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
draft := data.GetIsDraft()
|
draft := event.GetIsDraft()
|
||||||
if draft != nil {
|
if draft != nil {
|
||||||
event.SetStatus(ics.ObjectStatusDraft)
|
iCalEvent.SetStatus(ics.ObjectStatusDraft)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := data.GetSubject()
|
summary := event.GetSubject()
|
||||||
if summary != nil {
|
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
|
// 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 {
|
if event.GetBody() != nil {
|
||||||
description := ptr.Val(data.GetBody().GetContent())
|
description := ptr.Val(event.GetBody().GetContent())
|
||||||
contentType := data.GetBody().GetContentType().String()
|
contentType := event.GetBody().GetContentType().String()
|
||||||
|
|
||||||
if len(description) > 0 && contentType == "text" {
|
if len(description) > 0 && contentType == "text" {
|
||||||
event.SetDescription(description)
|
iCalEvent.SetDescription(description)
|
||||||
} else {
|
} else if len(description) > 0 {
|
||||||
// https://stackoverflow.com/a/859475
|
// https://stackoverflow.com/a/859475
|
||||||
event.SetDescription(bodyPreview)
|
iCalEvent.SetDescription(bodyPreview)
|
||||||
|
|
||||||
if contentType == "html" {
|
if contentType == "html" {
|
||||||
desc := strings.ReplaceAll(description, "\r\n", "")
|
desc := strings.ReplaceAll(description, "\r\n", "")
|
||||||
desc = strings.ReplaceAll(desc, "\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" {
|
if len(showAs) > 0 && showAs != "unknown" {
|
||||||
var status ics.FreeBusyTimeType
|
var status ics.FreeBusyTimeType
|
||||||
|
|
||||||
@ -308,37 +357,37 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
|
|||||||
status = ics.FreeBusyTimeTypeBusyUnavailable
|
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 {
|
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
|
// According to the RFC, this property may be used in a calendar
|
||||||
// component to convey a location where a more dynamic rendition of
|
// component to convey a location where a more dynamic rendition of
|
||||||
// the calendar information associated with the calendar component
|
// the calendar information associated with the calendar component
|
||||||
// can be found.
|
// can be found.
|
||||||
url := ptr.Val(data.GetWebLink())
|
url := ptr.Val(event.GetWebLink())
|
||||||
if len(url) > 0 {
|
if len(url) > 0 {
|
||||||
event.SetURL(url)
|
iCalEvent.SetURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
organizer := data.GetOrganizer()
|
organizer := event.GetOrganizer()
|
||||||
if organizer != nil {
|
if organizer != nil {
|
||||||
name := ptr.Val(organizer.GetEmailAddress().GetName())
|
name := ptr.Val(organizer.GetEmailAddress().GetName())
|
||||||
addr := ptr.Val(organizer.GetEmailAddress().GetAddress())
|
addr := ptr.Val(organizer.GetEmailAddress().GetAddress())
|
||||||
|
|
||||||
// TODO: What to do if we only have a name?
|
// TODO: What to do if we only have a name?
|
||||||
if len(name) > 0 && len(addr) > 0 {
|
if len(name) > 0 && len(addr) > 0 {
|
||||||
event.SetOrganizer(addr, ics.WithCN(name))
|
iCalEvent.SetOrganizer(addr, ics.WithCN(name))
|
||||||
} else if len(addr) > 0 {
|
} else if len(addr) > 0 {
|
||||||
event.SetOrganizer(addr)
|
iCalEvent.SetOrganizer(addr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attendees := data.GetAttendees()
|
attendees := event.GetAttendees()
|
||||||
for _, attendee := range attendees {
|
for _, attendee := range attendees {
|
||||||
props := []ics.PropertyParameter{}
|
props := []ics.PropertyParameter{}
|
||||||
|
|
||||||
@ -385,16 +434,16 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addr := ptr.Val(attendee.GetEmailAddress().GetAddress())
|
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 {
|
if len(location) > 0 {
|
||||||
event.SetLocation(location)
|
iCalEvent.SetLocation(location)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Handle different attachment type (file, item and reference)
|
// TODO Handle different attachment type (file, item and reference)
|
||||||
attachments := data.GetAttachments()
|
attachments := event.GetAttachments()
|
||||||
for _, attachment := range attachments {
|
for _, attachment := range attachments {
|
||||||
props := []ics.PropertyParameter{}
|
props := []ics.PropertyParameter{}
|
||||||
contentType := ptr.Val(attachment.GetContentType())
|
contentType := ptr.Val(attachment.GetContentType())
|
||||||
@ -411,12 +460,12 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
|
|||||||
|
|
||||||
cb, err := attachment.GetBackingStore().Get("contentBytes")
|
cb, err := attachment.GetBackingStore().Get("contentBytes")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", clues.Wrap(err, "getting attachment content")
|
return clues.WrapWC(ctx, err, "getting attachment content")
|
||||||
}
|
}
|
||||||
|
|
||||||
content, ok := cb.([]uint8)
|
content, ok := cb.([]uint8)
|
||||||
if !ok {
|
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"))
|
props = append(props, ics.WithEncoding("base64"), ics.WithValue("BINARY"))
|
||||||
@ -429,19 +478,55 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
|
|||||||
if inline {
|
if inline {
|
||||||
cidv, err := attachment.GetBackingStore().Get("contentId")
|
cidv, err := attachment.GetBackingStore().Get("contentId")
|
||||||
if err != nil {
|
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)
|
cid, err := str.AnyToString(cidv)
|
||||||
if err != nil {
|
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))
|
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)
|
ctx, flush := tester.NewContext(t)
|
||||||
defer flush()
|
defer flush()
|
||||||
|
|
||||||
// convert event to bytes
|
bts, err := eventToJSON(tt.event())
|
||||||
writer := kjson.NewJsonSerializationWriter()
|
|
||||||
defer writer.Close()
|
|
||||||
|
|
||||||
err := writer.WriteObjectValue("", tt.event())
|
|
||||||
require.NoError(t, err, "serializing contact")
|
|
||||||
|
|
||||||
bts, err := writer.GetSerializedContent()
|
|
||||||
require.NoError(t, err, "getting serialized content")
|
require.NoError(t, err, "getting serialized content")
|
||||||
|
|
||||||
e, err := FromJSON(ctx, bts)
|
e, err := FromJSON(ctx, bts)
|
||||||
@ -849,10 +842,6 @@ func (suite *ICSUnitSuite) TestAttendees() {
|
|||||||
ctx, flush := tester.NewContext(t)
|
ctx, flush := tester.NewContext(t)
|
||||||
defer flush()
|
defer flush()
|
||||||
|
|
||||||
// convert event to bytes
|
|
||||||
writer := kjson.NewJsonSerializationWriter()
|
|
||||||
defer writer.Close()
|
|
||||||
|
|
||||||
e := baseEvent()
|
e := baseEvent()
|
||||||
|
|
||||||
atts := make([]models.Attendeeable, len(tt.att))
|
atts := make([]models.Attendeeable, len(tt.att))
|
||||||
@ -889,10 +878,7 @@ func (suite *ICSUnitSuite) TestAttendees() {
|
|||||||
|
|
||||||
e.SetAttendees(atts)
|
e.SetAttendees(atts)
|
||||||
|
|
||||||
err := writer.WriteObjectValue("", e)
|
bts, err := eventToJSON(e)
|
||||||
require.NoError(t, err, "serializing contact")
|
|
||||||
|
|
||||||
bts, err := writer.GetSerializedContent()
|
|
||||||
require.NoError(t, err, "getting serialized content")
|
require.NoError(t, err, "getting serialized content")
|
||||||
|
|
||||||
out, err := FromJSON(ctx, bts)
|
out, err := FromJSON(ctx, bts)
|
||||||
@ -1035,16 +1021,9 @@ func (suite *ICSUnitSuite) TestAttachments() {
|
|||||||
ctx, flush := tester.NewContext(t)
|
ctx, flush := tester.NewContext(t)
|
||||||
defer flush()
|
defer flush()
|
||||||
|
|
||||||
// convert event to bytes
|
|
||||||
writer := kjson.NewJsonSerializationWriter()
|
|
||||||
defer writer.Close()
|
|
||||||
|
|
||||||
e := baseEvent()
|
e := baseEvent()
|
||||||
|
|
||||||
err := writer.WriteObjectValue("", e)
|
bts, err := eventToJSON(e)
|
||||||
require.NoError(t, err, "serializing contact")
|
|
||||||
|
|
||||||
bts, err := writer.GetSerializedContent()
|
|
||||||
require.NoError(t, err, "getting serialized content")
|
require.NoError(t, err, "getting serialized content")
|
||||||
|
|
||||||
parsed := map[string]any{}
|
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)
|
return nil, nil, graph.Stack(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateCancelledOccurrences(event)
|
_, err = GetCancelledEventDateStrings(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, clues.Wrap(err, "verify cancelled occurrences")
|
return nil, nil, clues.Wrap(err, "verify cancelled occurrences")
|
||||||
}
|
}
|
||||||
@ -309,26 +309,30 @@ func fixupExceptionOccurrences(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adding checks to ensure that the data is in the format that we expect M365 to return
|
func GetCancelledEventDateStrings(event models.Eventable) ([]string, error) {
|
||||||
func validateCancelledOccurrences(event models.Eventable) error {
|
|
||||||
cancelledOccurrences := event.GetAdditionalData()["cancelledOccurrences"]
|
cancelledOccurrences := event.GetAdditionalData()["cancelledOccurrences"]
|
||||||
if cancelledOccurrences != nil {
|
if cancelledOccurrences == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
co, ok := cancelledOccurrences.([]any)
|
co, ok := cancelledOccurrences.([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return clues.New("converting cancelledOccurrences to []any").
|
return nil, clues.New("converting cancelledOccurrences to []any").
|
||||||
With("type", fmt.Sprintf("%T", cancelledOccurrences))
|
With("type", fmt.Sprintf("%T", cancelledOccurrences))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dates := []string{}
|
||||||
|
|
||||||
for _, instance := range co {
|
for _, instance := range co {
|
||||||
instance, err := str.AnyToString(instance)
|
instance, err := str.AnyToString(instance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// There might be multiple `.` in the ID and hence >2
|
// There might be multiple `.` in the ID and hence >2
|
||||||
splits := strings.Split(instance, ".")
|
splits := strings.Split(instance, ".")
|
||||||
if len(splits) < 2 {
|
if len(splits) < 2 {
|
||||||
return clues.New("unexpected cancelled event format").
|
return nil, clues.New("unexpected cancelled event format").
|
||||||
With("instance", instance)
|
With("instance", instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,12 +340,13 @@ func validateCancelledOccurrences(event models.Eventable) error {
|
|||||||
|
|
||||||
_, err = dttm.ParseTime(startStr)
|
_, err = dttm.ParseTime(startStr)
|
||||||
if err != nil {
|
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) {
|
func parseableToMap(att serialization.Parsable) (map[string]any, error) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user