Handle event exceptions in caledar (#3589)
This ensures that we backup and restore and exceptions in the recurring events. One thing pending here is fixing up the attachments. I hope to create that as a separate PR. This can probably go in separately as well. --- #### Does this PR need a docs update or release note? - [x] ✅ Yes, it's included - [ ] 🕐 Yes, but in a later PR - [ ] ⛔ 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/2835 #### Test Plan <!-- How will this be tested prior to merging.--> - [ ] 💪 Manual - [x] ⚡ Unit test - [x] 💚 E2E
This commit is contained in:
parent
9f7a6422a0
commit
9199db8f45
@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Added ProtectedResourceName to the backup list json output. ProtectedResourceName holds either a UPN or a WebURL, depending on the resource type.
|
||||
- Rework base selection logic for incremental backups so it's more likely to find a valid base.
|
||||
- Improve OneDrive restore performance by paralleling item restores
|
||||
- Exceptions and cancellations for recurring events are now backed up and restored
|
||||
|
||||
### Fixed
|
||||
- Fix Exchange folder cache population error when parent folder isn't found.
|
||||
@ -23,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
- Do not display all the items that we restored at the end if there are more than 15. You can override this with `--verbose`.
|
||||
|
||||
### Known Issues
|
||||
- Changes to attachments in instances of recurring events compared to the series master aren't restored
|
||||
|
||||
## [v0.8.0] (beta) - 2023-05-15
|
||||
|
||||
### Added
|
||||
|
||||
@ -114,7 +114,10 @@ func handleExchangeCalendarEventFactory(cmd *cobra.Command, args []string) error
|
||||
func(id, now, subject, body string) []byte {
|
||||
return exchMock.EventWith(
|
||||
User, subject, body, body,
|
||||
now, now, exchMock.NoRecurrence, exchMock.NoAttendees, false)
|
||||
exchMock.NoOriginalStartDate, now, now,
|
||||
exchMock.NoRecurrence, exchMock.NoAttendees,
|
||||
false, exchMock.NoCancelledOccurrences,
|
||||
exchMock.NoExceptionOccurrences)
|
||||
},
|
||||
control.Defaults(),
|
||||
errs)
|
||||
|
||||
@ -2,11 +2,16 @@ package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/dttm"
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/common/str"
|
||||
"github.com/alcionai/corso/src/internal/m365/graph"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
@ -82,12 +87,14 @@ func (h eventRestoreHandler) restore(
|
||||
|
||||
if ptr.Val(event.GetHasAttachments()) {
|
||||
attachments = event.GetAttachments()
|
||||
event.SetAttachments([]models.Attachmentable{})
|
||||
// We cannot use `[]models.Attbachmentable{}` instead of nil
|
||||
// for beta endpoint.
|
||||
event.SetAttachments(nil)
|
||||
}
|
||||
|
||||
item, err := h.ip.PostItem(ctx, userID, destinationID, event)
|
||||
if err != nil {
|
||||
return nil, graph.Wrap(ctx, err, "restoring mail message")
|
||||
return nil, graph.Wrap(ctx, err, "restoring calendar item")
|
||||
}
|
||||
|
||||
err = uploadAttachments(
|
||||
@ -102,8 +109,181 @@ func (h eventRestoreHandler) restore(
|
||||
return nil, clues.Stack(err)
|
||||
}
|
||||
|
||||
// Have to parse event again as we modified the original event and
|
||||
// removed cancelled and exceptions events form it
|
||||
event, err = api.BytesToEventable(body)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "creating event from bytes").WithClues(ctx)
|
||||
}
|
||||
|
||||
// Fix up event instances in case we have a recurring event
|
||||
err = updateRecurringEvents(ctx, h.ac, userID, destinationID, ptr.Val(item.GetId()), event)
|
||||
if err != nil {
|
||||
return nil, clues.Stack(err)
|
||||
}
|
||||
|
||||
info := api.EventInfo(event)
|
||||
info.Size = int64(len(body))
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func updateRecurringEvents(
|
||||
ctx context.Context,
|
||||
ac api.Events,
|
||||
userID, containerID, itemID string,
|
||||
event models.Eventable,
|
||||
) error {
|
||||
if event.GetRecurrence() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancellations and exceptions are currently in additional data
|
||||
// but will get their own fields once the beta API lands and
|
||||
// should be moved then
|
||||
cancelledOccurrences := event.GetAdditionalData()["cancelledOccurrences"]
|
||||
exceptionOccurrences := event.GetAdditionalData()["exceptionOccurrences"]
|
||||
|
||||
err := updateCancelledOccurrences(ctx, ac, userID, itemID, cancelledOccurrences)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "update cancelled occurrences")
|
||||
}
|
||||
|
||||
err = updateExceptionOccurrences(ctx, ac, userID, itemID, exceptionOccurrences)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "update exception occurrences")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateExceptionOccurrences take events that have exceptions, uses
|
||||
// the originalStart date to find the instance and modify it to match
|
||||
// the backup by updating the instance to match the backed up one
|
||||
func updateExceptionOccurrences(
|
||||
ctx context.Context,
|
||||
ac api.Events,
|
||||
userID string,
|
||||
itemID string,
|
||||
exceptionOccurrences any,
|
||||
) error {
|
||||
if exceptionOccurrences == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
eo, ok := exceptionOccurrences.([]any)
|
||||
if !ok {
|
||||
return clues.New("converting exceptionOccurrences to []any").
|
||||
With("type", fmt.Sprintf("%T", exceptionOccurrences))
|
||||
}
|
||||
|
||||
for _, instance := range eo {
|
||||
instance, ok := instance.(map[string]any)
|
||||
if !ok {
|
||||
return clues.New("converting instance to map[string]any").
|
||||
With("type", fmt.Sprintf("%T", instance))
|
||||
}
|
||||
|
||||
evt, err := api.EventFromMap(instance)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "parsing exception event")
|
||||
}
|
||||
|
||||
start := ptr.Val(evt.GetOriginalStart())
|
||||
startStr := dttm.FormatTo(start, dttm.DateOnly)
|
||||
endStr := dttm.FormatTo(start.Add(24*time.Hour), dttm.DateOnly)
|
||||
|
||||
// Get all instances on the day of the instance which should
|
||||
// just the one we need to modify
|
||||
evts, err := ac.GetItemInstances(ctx, userID, itemID, startStr, endStr)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "getting instances")
|
||||
}
|
||||
|
||||
// Since the min recurrence interval is 1 day and we are
|
||||
// querying for only a single day worth of instances, we
|
||||
// should not have more than one instance here.
|
||||
if len(evts) != 1 {
|
||||
return clues.New("invalid number of instances for modified").
|
||||
With("instances_count", len(evts), "search_start", startStr, "search_end", endStr)
|
||||
}
|
||||
|
||||
evt = toEventSimplified(evt)
|
||||
|
||||
// TODO(meain): Update attachments (might have to diff the
|
||||
// attachments using ids and delete or add). We will have
|
||||
// to get the id of the existing attachments, diff them
|
||||
// with what we need a then create/delete items kinda like
|
||||
// permissions
|
||||
_, err = ac.PatchItem(ctx, userID, ptr.Val(evts[0].GetId()), evt)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "updating event instance")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateCancelledOccurrences get the cancelled occurrences which is a
|
||||
// list of strings of the format "<id>.<date>", parses the date out of
|
||||
// that and uses the to get the event instance at that date to delete.
|
||||
func updateCancelledOccurrences(
|
||||
ctx context.Context,
|
||||
ac api.Events,
|
||||
userID string,
|
||||
itemID string,
|
||||
cancelledOccurrences any,
|
||||
) error {
|
||||
if cancelledOccurrences == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
co, ok := cancelledOccurrences.([]any)
|
||||
if !ok {
|
||||
return clues.New("converting cancelledOccurrences to []any").
|
||||
With("type", fmt.Sprintf("%T", cancelledOccurrences))
|
||||
}
|
||||
|
||||
// OPTIMIZATION: We can fetch a date range instead of fetching
|
||||
// instances if we have multiple cancelled events which are nearby
|
||||
// and reduce the number of API calls that we have to make
|
||||
for _, instance := range co {
|
||||
instance, err := str.AnyToString(instance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
splits := strings.Split(instance, ".")
|
||||
|
||||
startStr := splits[len(splits)-1]
|
||||
|
||||
start, err := dttm.ParseTime(startStr)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "parsing cancelled event date")
|
||||
}
|
||||
|
||||
endStr := dttm.FormatTo(start.Add(24*time.Hour), dttm.DateOnly)
|
||||
|
||||
// Get all instances on the day of the instance which should
|
||||
// just the one we need to modify
|
||||
evts, err := ac.GetItemInstances(ctx, userID, itemID, startStr, endStr)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "getting instances")
|
||||
}
|
||||
|
||||
// Since the min recurrence interval is 1 day and we are
|
||||
// querying for only a single day worth of instances, we
|
||||
// should not have more than one instance here.
|
||||
if len(evts) != 1 {
|
||||
return clues.New("invalid number of instances for cancelled").
|
||||
With("instances_count", len(evts), "search_start", startStr, "search_end", endStr)
|
||||
}
|
||||
|
||||
err = ac.DeleteItem(ctx, userID, ptr.Val(evts[0].GetId()))
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "deleting event instance")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -25,11 +25,6 @@ import (
|
||||
//nolint:lll
|
||||
const (
|
||||
eventTmpl = `{
|
||||
"id":"AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAAAAAG76AAA=",
|
||||
"calendar@odata.navigationLink":"https://graph.microsoft.com/v1.0/users('foobar@8qzvrj.onmicrosoft.com')/calendars('AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwAuAAAAAADCNgjhM9QmQYWNcI7hCpPrAQDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAAA=')",
|
||||
"calendar@odata.associationLink":"https://graph.microsoft.com/v1.0/users('foobar@8qzvrj.onmicrosoft.com')/calendars('AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwAuAAAAAADCNgjhM9QmQYWNcI7hCpPrAQDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAAA=')/$ref",
|
||||
"@odata.etag":"W/\"0hATW1CAfUS+njw3hdxSGAAAJIxNug==\"",
|
||||
"@odata.context":"https://graph.microsoft.com/v1.0/$metadata#users('foobar%%408qzvrj.onmicrosoft.com')/events/$entity",
|
||||
"categories":[],
|
||||
"changeKey":"0hATW1CAfUS+njw3hdxSGAAAJIxNug==",
|
||||
"createdDateTime":"2022-03-28T03:42:03Z",
|
||||
@ -46,7 +41,6 @@ const (
|
||||
"timeZone":"UTC"
|
||||
},
|
||||
"hideAttendees":false,
|
||||
"iCalUId":"040000008200E00074C5B7101A82E0080000000035723BC75542D801000000000000000010000000E1E7C8F785242E4894DA13AEFB947B85",
|
||||
"importance":"normal",
|
||||
"isAllDay":false,
|
||||
"isCancelled":false,
|
||||
@ -75,6 +69,7 @@ const (
|
||||
"name":"Anu Pierson"
|
||||
}
|
||||
},
|
||||
%s
|
||||
"originalEndTimeZone":"UTC",
|
||||
"originalStartTimeZone":"UTC",
|
||||
"reminderMinutesBeforeStart":15,
|
||||
@ -90,11 +85,13 @@ const (
|
||||
"timeZone":"UTC"
|
||||
},
|
||||
"subject":"%s",
|
||||
"type":"singleInstance",
|
||||
"type":"%s",
|
||||
"hasAttachments":%v,
|
||||
%s
|
||||
"webLink":"https://outlook.office365.com/owa/?itemid=AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAAAAAG76AAA%%3D&exvsurl=1&path=/calendar/item",
|
||||
"recurrence":%s,
|
||||
%s
|
||||
%s
|
||||
"attendees":%s
|
||||
}`
|
||||
|
||||
@ -151,13 +148,16 @@ const (
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}],"
|
||||
|
||||
originalStartDateFormat = `"originalStart": "%s",`
|
||||
NoOriginalStartDate = ``
|
||||
|
||||
NoRecurrence = `null`
|
||||
recurrenceTmpl = `{
|
||||
"pattern": {
|
||||
"type": "absoluteYearly",
|
||||
"interval": 1,
|
||||
"month": 1,
|
||||
"dayOfMonth": 1,
|
||||
"month": %s,
|
||||
"dayOfMonth": %s,
|
||||
"firstDayOfWeek": "sunday",
|
||||
"index": "first"
|
||||
},
|
||||
@ -170,6 +170,13 @@ const (
|
||||
}
|
||||
}`
|
||||
|
||||
cancelledOccurrencesFormat = `"cancelledOccurrences": [%s],`
|
||||
cancelledOccurrenceInstanceFormat = `"OID.AAMkAGJiZmE2NGU4LTQ4YjktNDI1Mi1iMWQzLTQ1MmMxODJkZmQyNABGAAAAAABFdiK7oifWRb4ADuqgSRcnBwBBFDg0JJk7TY1fmsJrh7tNAAAAAAENAABBFDg0JJk7TY1fmsJrh7tNAADHGTZoAAA=.%s"`
|
||||
NoCancelledOccurrences = ""
|
||||
|
||||
exceptionOccurrencesFormat = `"exceptionOccurrences": [%s],`
|
||||
NoExceptionOccurrences = ""
|
||||
|
||||
NoAttendees = `[]`
|
||||
attendeesTmpl = `[{
|
||||
"emailAddress": {
|
||||
@ -227,7 +234,8 @@ func EventWithSubjectBytes(subject string) []byte {
|
||||
return EventWith(
|
||||
defaultEventOrganizer, subject,
|
||||
defaultEventBody, defaultEventBodyPreview,
|
||||
atTime, endTime, NoRecurrence, NoAttendees, false,
|
||||
NoOriginalStartDate, atTime, endTime, NoRecurrence, NoAttendees,
|
||||
false, NoCancelledOccurrences, NoExceptionOccurrences,
|
||||
)
|
||||
}
|
||||
|
||||
@ -239,7 +247,8 @@ func EventWithAttachment(subject string) []byte {
|
||||
return EventWith(
|
||||
defaultEventOrganizer, subject,
|
||||
defaultEventBody, defaultEventBodyPreview,
|
||||
atTime, atTime, NoRecurrence, NoAttendees, true,
|
||||
NoOriginalStartDate, atTime, atTime, NoRecurrence, NoAttendees,
|
||||
true, NoCancelledOccurrences, NoExceptionOccurrences,
|
||||
)
|
||||
}
|
||||
|
||||
@ -251,6 +260,8 @@ func EventWithRecurrenceBytes(subject, recurrenceTimeZone string) []byte {
|
||||
|
||||
recurrence := string(fmt.Sprintf(
|
||||
recurrenceTmpl,
|
||||
strconv.Itoa(int(at.Month())),
|
||||
strconv.Itoa(at.Day()),
|
||||
timeSlice[0],
|
||||
recurrenceTimeZone,
|
||||
))
|
||||
@ -258,7 +269,70 @@ func EventWithRecurrenceBytes(subject, recurrenceTimeZone string) []byte {
|
||||
return EventWith(
|
||||
defaultEventOrganizer, subject,
|
||||
defaultEventBody, defaultEventBodyPreview,
|
||||
atTime, atTime, recurrence, attendeesTmpl, true,
|
||||
NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl,
|
||||
true, NoCancelledOccurrences, NoExceptionOccurrences,
|
||||
)
|
||||
}
|
||||
|
||||
func EventWithRecurrenceAndCancellationBytes(subject string) []byte {
|
||||
tomorrow := time.Now().UTC().AddDate(0, 0, 1)
|
||||
at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC)
|
||||
atTime := dttm.Format(at)
|
||||
timeSlice := strings.Split(atTime, "T")
|
||||
nextYear := tomorrow.AddDate(1, 0, 0)
|
||||
|
||||
recurrence := string(fmt.Sprintf(
|
||||
recurrenceTmpl,
|
||||
strconv.Itoa(int(at.Month())),
|
||||
strconv.Itoa(at.Day()),
|
||||
timeSlice[0],
|
||||
`"UTC"`,
|
||||
))
|
||||
|
||||
cancelledInstances := []string{fmt.Sprintf(cancelledOccurrenceInstanceFormat, dttm.FormatTo(nextYear, dttm.DateOnly))}
|
||||
cancelledOccurrences := fmt.Sprintf(cancelledOccurrencesFormat, strings.Join(cancelledInstances, ","))
|
||||
|
||||
return EventWith(
|
||||
defaultEventOrganizer, subject,
|
||||
defaultEventBody, defaultEventBodyPreview,
|
||||
NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl,
|
||||
true, cancelledOccurrences, NoExceptionOccurrences,
|
||||
)
|
||||
}
|
||||
|
||||
func EventWithRecurrenceAndExceptionBytes(subject string) []byte {
|
||||
tomorrow := time.Now().UTC().AddDate(0, 0, 1)
|
||||
at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC)
|
||||
atTime := dttm.Format(at)
|
||||
timeSlice := strings.Split(atTime, "T")
|
||||
newTime := dttm.Format(tomorrow.AddDate(0, 0, 1))
|
||||
originalStartDate := dttm.FormatTo(at, dttm.TabularOutput)
|
||||
|
||||
recurrence := string(fmt.Sprintf(
|
||||
recurrenceTmpl,
|
||||
strconv.Itoa(int(at.Month())),
|
||||
strconv.Itoa(at.Day()),
|
||||
timeSlice[0],
|
||||
`"UTC"`,
|
||||
))
|
||||
|
||||
exceptionEvent := EventWith(
|
||||
defaultEventOrganizer, subject+"(modified)",
|
||||
defaultEventBody, defaultEventBodyPreview,
|
||||
fmt.Sprintf(originalStartDateFormat, originalStartDate),
|
||||
newTime, newTime, NoRecurrence, attendeesTmpl,
|
||||
false, NoCancelledOccurrences, NoExceptionOccurrences,
|
||||
)
|
||||
exceptionOccurrences := fmt.Sprintf(
|
||||
exceptionOccurrencesFormat,
|
||||
strings.Join([]string{string(exceptionEvent)}, ","),
|
||||
)
|
||||
|
||||
return EventWith(
|
||||
defaultEventOrganizer, subject,
|
||||
defaultEventBody, defaultEventBodyPreview,
|
||||
NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl,
|
||||
true, NoCancelledOccurrences, exceptionOccurrences,
|
||||
)
|
||||
}
|
||||
|
||||
@ -270,7 +344,8 @@ func EventWithAttendeesBytes(subject string) []byte {
|
||||
return EventWith(
|
||||
defaultEventOrganizer, subject,
|
||||
defaultEventBody, defaultEventBodyPreview,
|
||||
atTime, atTime, NoRecurrence, attendeesTmpl, true,
|
||||
NoOriginalStartDate, atTime, atTime, NoRecurrence, attendeesTmpl,
|
||||
true, NoCancelledOccurrences, NoExceptionOccurrences,
|
||||
)
|
||||
}
|
||||
|
||||
@ -281,8 +356,8 @@ func EventWithAttendeesBytes(subject string) []byte {
|
||||
// Body must contain a well-formatted string, consumable in a json payload. IE: no unescaped newlines.
|
||||
func EventWith(
|
||||
organizer, subject, body, bodyPreview,
|
||||
startDateTime, endDateTime, recurrence, attendees string,
|
||||
hasAttachments bool,
|
||||
originalStartDate, startDateTime, endDateTime, recurrence, attendees string,
|
||||
hasAttachments bool, cancelledOccurrences, exceptionOccurrences string,
|
||||
) []byte {
|
||||
var attachments string
|
||||
if hasAttachments {
|
||||
@ -300,17 +375,26 @@ func EventWith(
|
||||
endDateTime += ".0000000"
|
||||
}
|
||||
|
||||
eventType := "singleInstance"
|
||||
if recurrence != "null" {
|
||||
eventType = "seriesMaster"
|
||||
}
|
||||
|
||||
return []byte(fmt.Sprintf(
|
||||
eventTmpl,
|
||||
body,
|
||||
bodyPreview,
|
||||
endDateTime,
|
||||
organizer,
|
||||
originalStartDate,
|
||||
startDateTime,
|
||||
subject,
|
||||
eventType,
|
||||
hasAttachments,
|
||||
attachments,
|
||||
recurrence,
|
||||
cancelledOccurrences,
|
||||
exceptionOccurrences,
|
||||
attendees,
|
||||
))
|
||||
}
|
||||
|
||||
@ -116,6 +116,14 @@ func (suite *RestoreIntgSuite) TestRestoreEvent() {
|
||||
name: "Test recurrenceTimeZone: Empty",
|
||||
bytes: exchMock.EventWithRecurrenceBytes(subject, `""`),
|
||||
},
|
||||
{
|
||||
name: "Test cancelledOccurrences",
|
||||
bytes: exchMock.EventWithRecurrenceAndCancellationBytes(subject),
|
||||
},
|
||||
{
|
||||
name: "Test exceptionOccurrences",
|
||||
bytes: exchMock.EventWithRecurrenceAndExceptionBytes(subject),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@ -70,7 +70,6 @@ func toEventSimplified(orig models.Eventable) models.Eventable {
|
||||
newContent := insertStringToBody(origBody, attendees)
|
||||
newBody := models.NewItemBody()
|
||||
newBody.SetContentType(origBody.GetContentType())
|
||||
newBody.SetAdditionalData(origBody.GetAdditionalData())
|
||||
newBody.SetOdataType(origBody.GetOdataType())
|
||||
newBody.SetContent(&newContent)
|
||||
orig.SetBody(newBody)
|
||||
@ -89,6 +88,14 @@ func toEventSimplified(orig models.Eventable) models.Eventable {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove exceptions for recurring events
|
||||
// These will be present in objects once we start using the API
|
||||
// that is currently in beta
|
||||
additionalData := orig.GetAdditionalData()
|
||||
delete(additionalData, "cancelledOccurrences")
|
||||
delete(additionalData, "exceptionOccurrences")
|
||||
orig.SetAdditionalData(additionalData)
|
||||
|
||||
return orig
|
||||
}
|
||||
|
||||
|
||||
@ -121,6 +121,32 @@ func (suite *TransformUnitTest) TestToEventSimplified_recurrence() {
|
||||
return ptr.Val(e.GetRecurrence().GetRange().GetRecurrenceTimeZone()) == "Pacific Standard Time"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Test cancelledOccurrences",
|
||||
event: func() models.Eventable {
|
||||
bytes := exchMock.EventWithRecurrenceAndCancellationBytes(subject)
|
||||
event, err := api.BytesToEventable(bytes)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
return event
|
||||
},
|
||||
|
||||
validateOutput: func(e models.Eventable) bool {
|
||||
return e.GetAdditionalData()["cancelledOccurrences"] == nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Test exceptionOccurrences",
|
||||
event: func() models.Eventable {
|
||||
bytes := exchMock.EventWithRecurrenceAndExceptionBytes(subject)
|
||||
event, err := api.BytesToEventable(bytes)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
return event
|
||||
},
|
||||
|
||||
validateOutput: func(e models.Eventable) bool {
|
||||
return e.GetAdditionalData()["exceptionOccurrences"] == nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@ -813,7 +813,10 @@ func testExchangeContinuousBackups(suite *BackupOpIntegrationSuite, toggles cont
|
||||
eventDBF := func(id, timeStamp, subject, body string) []byte {
|
||||
return exchMock.EventWith(
|
||||
suite.user, subject, body, body,
|
||||
now, now, exchMock.NoRecurrence, exchMock.NoAttendees, false)
|
||||
exchMock.NoOriginalStartDate, now, now,
|
||||
exchMock.NoRecurrence, exchMock.NoAttendees,
|
||||
false, exchMock.NoCancelledOccurrences,
|
||||
exchMock.NoExceptionOccurrences)
|
||||
}
|
||||
|
||||
// test data set
|
||||
|
||||
@ -3,8 +3,10 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
@ -15,6 +17,7 @@ import (
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/dttm"
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/common/str"
|
||||
"github.com/alcionai/corso/src/internal/m365/graph"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
@ -189,6 +192,14 @@ func (c Events) PatchCalendar(
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
// Beta version cannot have /calendars/%s for get and Patch
|
||||
// https://stackoverflow.com/questions/50492177/microsoft-graph-get-user-calendar-event-with-beta-version
|
||||
eventExceptionsBetaURLTemplate = "https://graph.microsoft.com/beta/users/%s/events/%s?$expand=exceptionOccurrences"
|
||||
eventPostBetaURLTemplate = "https://graph.microsoft.com/beta/users/%s/calendars/%s/events"
|
||||
eventPatchBetaURLTemplate = "https://graph.microsoft.com/beta/users/%s/events/%s"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// items
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -208,18 +219,168 @@ func (c Events) GetItem(
|
||||
}
|
||||
)
|
||||
|
||||
event, err = c.Stable.
|
||||
Client().
|
||||
Users().
|
||||
ByUserId(userID).
|
||||
Events().
|
||||
ByEventId(itemID).
|
||||
Get(ctx, config)
|
||||
// Beta endpoint helps us fetch the event exceptions, but since we
|
||||
// don't use the beta SDK, the exceptionOccurrences and
|
||||
// cancelledOccurrences end up in AdditionalData
|
||||
// https://learn.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-beta#properties
|
||||
rawURL := fmt.Sprintf(eventExceptionsBetaURLTemplate, userID, itemID)
|
||||
builder := users.NewItemEventsEventItemRequestBuilder(rawURL, c.Stable.Adapter())
|
||||
|
||||
event, err = builder.Get(ctx, config)
|
||||
if err != nil {
|
||||
return nil, nil, graph.Stack(ctx, err)
|
||||
}
|
||||
|
||||
if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) {
|
||||
err = validateCancelledOccurrences(event)
|
||||
if err != nil {
|
||||
return nil, nil, clues.Wrap(err, "verify cancelled occurrences")
|
||||
}
|
||||
|
||||
err = fixupExceptionOccurrences(ctx, c, event, immutableIDs, userID)
|
||||
if err != nil {
|
||||
return nil, nil, clues.Wrap(err, "fixup exception occurrences")
|
||||
}
|
||||
|
||||
attachments, err := c.getAttachments(ctx, event, immutableIDs, userID, itemID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
event.SetAttachments(attachments)
|
||||
|
||||
return event, EventInfo(event), nil
|
||||
}
|
||||
|
||||
// fixupExceptionOccurrences gets attachments and converts the data
|
||||
// into a format that gets serialized when storing to kopia
|
||||
func fixupExceptionOccurrences(
|
||||
ctx context.Context,
|
||||
client Events,
|
||||
event models.Eventable,
|
||||
immutableIDs bool,
|
||||
userID string,
|
||||
) error {
|
||||
// Fetch attachments for exceptions
|
||||
exceptionOccurrences := event.GetAdditionalData()["exceptionOccurrences"]
|
||||
if exceptionOccurrences == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
eo, ok := exceptionOccurrences.([]any)
|
||||
if !ok {
|
||||
return clues.New("converting exceptionOccurrences to []any").
|
||||
With("type", fmt.Sprintf("%T", exceptionOccurrences))
|
||||
}
|
||||
|
||||
for _, instance := range eo {
|
||||
instance, ok := instance.(map[string]any)
|
||||
if !ok {
|
||||
return clues.New("converting instance to map[string]any").
|
||||
With("type", fmt.Sprintf("%T", instance))
|
||||
}
|
||||
|
||||
evt, err := EventFromMap(instance)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "parsing exception event")
|
||||
}
|
||||
|
||||
// OPTIMIZATION: We don't have to store any of the
|
||||
// attachments that carry over from the original
|
||||
attachments, err := client.getAttachments(ctx, evt, immutableIDs, userID, ptr.Val(evt.GetId()))
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "getting exception attachments").
|
||||
With("exception_event_id", ptr.Val(evt.GetId()))
|
||||
}
|
||||
|
||||
// This odd roundabout way of doing this is required as
|
||||
// the json serialization at the end does not serialize if
|
||||
// you just pass in a models.Attachmentable
|
||||
convertedAttachments := []map[string]interface{}{}
|
||||
|
||||
for _, attachment := range attachments {
|
||||
am, err := parseableToMap(attachment)
|
||||
if err != nil {
|
||||
return clues.Wrap(err, "converting attachment")
|
||||
}
|
||||
|
||||
convertedAttachments = append(convertedAttachments, am)
|
||||
}
|
||||
|
||||
instance["attachments"] = convertedAttachments
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseableToMap(att serialization.Parsable) (map[string]any, error) {
|
||||
var item map[string]any
|
||||
|
||||
writer := kjson.NewJsonSerializationWriter()
|
||||
defer writer.Close()
|
||||
|
||||
if err := writer.WriteObjectValue("", att); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ats, err := writer.GetSerializedContent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(ats, &item)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "unmarshalling serialized attachment")
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (c Events) getAttachments(
|
||||
ctx context.Context,
|
||||
event models.Eventable,
|
||||
immutableIDs bool,
|
||||
userID string,
|
||||
itemID string,
|
||||
) ([]models.Attachmentable, error) {
|
||||
if !ptr.Val(event.GetHasAttachments()) && !HasAttachments(event.GetBody()) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
config := &users.ItemEventsItemAttachmentsRequestBuilderGetRequestConfiguration{
|
||||
QueryParameters: &users.ItemEventsItemAttachmentsRequestBuilderGetQueryParameters{
|
||||
Expand: []string{"microsoft.graph.itemattachment/item"},
|
||||
@ -236,13 +397,38 @@ func (c Events) GetItem(
|
||||
Attachments().
|
||||
Get(ctx, config)
|
||||
if err != nil {
|
||||
return nil, nil, graph.Wrap(ctx, err, "event attachment download")
|
||||
return nil, graph.Wrap(ctx, err, "event attachment download")
|
||||
}
|
||||
|
||||
event.SetAttachments(attached.GetValue())
|
||||
return attached.GetValue(), nil
|
||||
}
|
||||
|
||||
func (c Events) GetItemInstances(
|
||||
ctx context.Context,
|
||||
userID, itemID string,
|
||||
startDate, endDate string,
|
||||
) ([]models.Eventable, error) {
|
||||
config := &users.ItemEventsItemInstancesRequestBuilderGetRequestConfiguration{
|
||||
QueryParameters: &users.ItemEventsItemInstancesRequestBuilderGetQueryParameters{
|
||||
Select: []string{"id"},
|
||||
StartDateTime: ptr.To(startDate),
|
||||
EndDateTime: ptr.To(endDate),
|
||||
},
|
||||
}
|
||||
|
||||
return event, EventInfo(event), nil
|
||||
events, err := c.Stable.
|
||||
Client().
|
||||
Users().
|
||||
ByUserId(userID).
|
||||
Events().
|
||||
ByEventId(itemID).
|
||||
Instances().
|
||||
Get(ctx, config)
|
||||
if err != nil {
|
||||
return nil, graph.Stack(ctx, err)
|
||||
}
|
||||
|
||||
return events.GetValue(), nil
|
||||
}
|
||||
|
||||
func (c Events) PostItem(
|
||||
@ -250,14 +436,10 @@ func (c Events) PostItem(
|
||||
userID, containerID string,
|
||||
body models.Eventable,
|
||||
) (models.Eventable, error) {
|
||||
itm, err := c.Stable.
|
||||
Client().
|
||||
Users().
|
||||
ByUserId(userID).
|
||||
Calendars().
|
||||
ByCalendarId(containerID).
|
||||
Events().
|
||||
Post(ctx, body, nil)
|
||||
rawURL := fmt.Sprintf(eventPostBetaURLTemplate, userID, containerID)
|
||||
builder := users.NewItemCalendarsItemEventsRequestBuilder(rawURL, c.Stable.Adapter())
|
||||
|
||||
itm, err := builder.Post(ctx, body, nil)
|
||||
if err != nil {
|
||||
return nil, graph.Wrap(ctx, err, "creating calendar event")
|
||||
}
|
||||
@ -265,6 +447,22 @@ func (c Events) PostItem(
|
||||
return itm, nil
|
||||
}
|
||||
|
||||
func (c Events) PatchItem(
|
||||
ctx context.Context,
|
||||
userID, eventID string,
|
||||
body models.Eventable,
|
||||
) (models.Eventable, error) {
|
||||
rawURL := fmt.Sprintf(eventPatchBetaURLTemplate, userID, eventID)
|
||||
builder := users.NewItemCalendarsItemEventsEventItemRequestBuilder(rawURL, c.Stable.Adapter())
|
||||
|
||||
itm, err := builder.Patch(ctx, body, nil)
|
||||
if err != nil {
|
||||
return nil, graph.Wrap(ctx, err, "updating calendar event")
|
||||
}
|
||||
|
||||
return itm, nil
|
||||
}
|
||||
|
||||
func (c Events) DeleteItem(
|
||||
ctx context.Context,
|
||||
userID, itemID string,
|
||||
@ -472,3 +670,17 @@ func EventInfo(evt models.Eventable) *details.ExchangeInfo {
|
||||
Modified: ptr.OrNow(evt.GetLastModifiedDateTime()),
|
||||
}
|
||||
}
|
||||
|
||||
func EventFromMap(ev map[string]any) (models.Eventable, error) {
|
||||
instBytes, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "marshaling event exception instance")
|
||||
}
|
||||
|
||||
body, err := BytesToEventable(instBytes)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "converting exception event bytes to Eventable")
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@ -26,3 +26,5 @@ included in backup and restore.
|
||||
* SharePoint document library data can't be restored after the library has been deleted.
|
||||
|
||||
* Sharing information of items in OneDrive/SharePoint using sharing links aren't backed up and restored.
|
||||
|
||||
* Changes to attachments in instances of recurring events compared to the series master aren't restored
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user