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:
Abin Simon 2023-06-19 17:27:32 +05:30 committed by GitHub
parent 9f7a6422a0
commit 9199db8f45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 580 additions and 51 deletions

View File

@ -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. - 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. - 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 - Improve OneDrive restore performance by paralleling item restores
- Exceptions and cancellations for recurring events are now backed up and restored
### Fixed ### Fixed
- Fix Exchange folder cache population error when parent folder isn't found. - 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 ### 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`. - 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 ## [v0.8.0] (beta) - 2023-05-15
### Added ### Added

View File

@ -114,7 +114,10 @@ func handleExchangeCalendarEventFactory(cmd *cobra.Command, args []string) error
func(id, now, subject, body string) []byte { func(id, now, subject, body string) []byte {
return exchMock.EventWith( return exchMock.EventWith(
User, subject, body, body, 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(), control.Defaults(),
errs) errs)

View File

@ -2,11 +2,16 @@ package exchange
import ( import (
"context" "context"
"fmt"
"strings"
"time"
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models" "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/ptr"
"github.com/alcionai/corso/src/internal/common/str"
"github.com/alcionai/corso/src/internal/m365/graph" "github.com/alcionai/corso/src/internal/m365/graph"
"github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
@ -82,12 +87,14 @@ func (h eventRestoreHandler) restore(
if ptr.Val(event.GetHasAttachments()) { if ptr.Val(event.GetHasAttachments()) {
attachments = event.GetAttachments() 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) item, err := h.ip.PostItem(ctx, userID, destinationID, event)
if err != nil { if err != nil {
return nil, graph.Wrap(ctx, err, "restoring mail message") return nil, graph.Wrap(ctx, err, "restoring calendar item")
} }
err = uploadAttachments( err = uploadAttachments(
@ -102,8 +109,181 @@ func (h eventRestoreHandler) restore(
return nil, clues.Stack(err) 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 := api.EventInfo(event)
info.Size = int64(len(body)) info.Size = int64(len(body))
return info, nil 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
}

View File

@ -25,11 +25,6 @@ import (
//nolint:lll //nolint:lll
const ( const (
eventTmpl = `{ 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":[], "categories":[],
"changeKey":"0hATW1CAfUS+njw3hdxSGAAAJIxNug==", "changeKey":"0hATW1CAfUS+njw3hdxSGAAAJIxNug==",
"createdDateTime":"2022-03-28T03:42:03Z", "createdDateTime":"2022-03-28T03:42:03Z",
@ -46,7 +41,6 @@ const (
"timeZone":"UTC" "timeZone":"UTC"
}, },
"hideAttendees":false, "hideAttendees":false,
"iCalUId":"040000008200E00074C5B7101A82E0080000000035723BC75542D801000000000000000010000000E1E7C8F785242E4894DA13AEFB947B85",
"importance":"normal", "importance":"normal",
"isAllDay":false, "isAllDay":false,
"isCancelled":false, "isCancelled":false,
@ -75,6 +69,7 @@ const (
"name":"Anu Pierson" "name":"Anu Pierson"
} }
}, },
%s
"originalEndTimeZone":"UTC", "originalEndTimeZone":"UTC",
"originalStartTimeZone":"UTC", "originalStartTimeZone":"UTC",
"reminderMinutesBeforeStart":15, "reminderMinutesBeforeStart":15,
@ -90,11 +85,13 @@ const (
"timeZone":"UTC" "timeZone":"UTC"
}, },
"subject":"%s", "subject":"%s",
"type":"singleInstance", "type":"%s",
"hasAttachments":%v, "hasAttachments":%v,
%s %s
"webLink":"https://outlook.office365.com/owa/?itemid=AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAAAAAG76AAA%%3D&exvsurl=1&path=/calendar/item", "webLink":"https://outlook.office365.com/owa/?itemid=AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAAAAAG76AAA%%3D&exvsurl=1&path=/calendar/item",
"recurrence":%s, "recurrence":%s,
%s
%s
"attendees":%s "attendees":%s
}` }`
@ -151,13 +148,16 @@ const (
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}]," "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}],"
originalStartDateFormat = `"originalStart": "%s",`
NoOriginalStartDate = ``
NoRecurrence = `null` NoRecurrence = `null`
recurrenceTmpl = `{ recurrenceTmpl = `{
"pattern": { "pattern": {
"type": "absoluteYearly", "type": "absoluteYearly",
"interval": 1, "interval": 1,
"month": 1, "month": %s,
"dayOfMonth": 1, "dayOfMonth": %s,
"firstDayOfWeek": "sunday", "firstDayOfWeek": "sunday",
"index": "first" "index": "first"
}, },
@ -170,6 +170,13 @@ const (
} }
}` }`
cancelledOccurrencesFormat = `"cancelledOccurrences": [%s],`
cancelledOccurrenceInstanceFormat = `"OID.AAMkAGJiZmE2NGU4LTQ4YjktNDI1Mi1iMWQzLTQ1MmMxODJkZmQyNABGAAAAAABFdiK7oifWRb4ADuqgSRcnBwBBFDg0JJk7TY1fmsJrh7tNAAAAAAENAABBFDg0JJk7TY1fmsJrh7tNAADHGTZoAAA=.%s"`
NoCancelledOccurrences = ""
exceptionOccurrencesFormat = `"exceptionOccurrences": [%s],`
NoExceptionOccurrences = ""
NoAttendees = `[]` NoAttendees = `[]`
attendeesTmpl = `[{ attendeesTmpl = `[{
"emailAddress": { "emailAddress": {
@ -227,7 +234,8 @@ func EventWithSubjectBytes(subject string) []byte {
return EventWith( return EventWith(
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, 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( return EventWith(
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, 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( recurrence := string(fmt.Sprintf(
recurrenceTmpl, recurrenceTmpl,
strconv.Itoa(int(at.Month())),
strconv.Itoa(at.Day()),
timeSlice[0], timeSlice[0],
recurrenceTimeZone, recurrenceTimeZone,
)) ))
@ -258,7 +269,70 @@ func EventWithRecurrenceBytes(subject, recurrenceTimeZone string) []byte {
return EventWith( return EventWith(
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, 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( return EventWith(
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, 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. // Body must contain a well-formatted string, consumable in a json payload. IE: no unescaped newlines.
func EventWith( func EventWith(
organizer, subject, body, bodyPreview, organizer, subject, body, bodyPreview,
startDateTime, endDateTime, recurrence, attendees string, originalStartDate, startDateTime, endDateTime, recurrence, attendees string,
hasAttachments bool, hasAttachments bool, cancelledOccurrences, exceptionOccurrences string,
) []byte { ) []byte {
var attachments string var attachments string
if hasAttachments { if hasAttachments {
@ -300,17 +375,26 @@ func EventWith(
endDateTime += ".0000000" endDateTime += ".0000000"
} }
eventType := "singleInstance"
if recurrence != "null" {
eventType = "seriesMaster"
}
return []byte(fmt.Sprintf( return []byte(fmt.Sprintf(
eventTmpl, eventTmpl,
body, body,
bodyPreview, bodyPreview,
endDateTime, endDateTime,
organizer, organizer,
originalStartDate,
startDateTime, startDateTime,
subject, subject,
eventType,
hasAttachments, hasAttachments,
attachments, attachments,
recurrence, recurrence,
cancelledOccurrences,
exceptionOccurrences,
attendees, attendees,
)) ))
} }

View File

@ -116,6 +116,14 @@ func (suite *RestoreIntgSuite) TestRestoreEvent() {
name: "Test recurrenceTimeZone: Empty", name: "Test recurrenceTimeZone: Empty",
bytes: exchMock.EventWithRecurrenceBytes(subject, `""`), bytes: exchMock.EventWithRecurrenceBytes(subject, `""`),
}, },
{
name: "Test cancelledOccurrences",
bytes: exchMock.EventWithRecurrenceAndCancellationBytes(subject),
},
{
name: "Test exceptionOccurrences",
bytes: exchMock.EventWithRecurrenceAndExceptionBytes(subject),
},
} }
for _, test := range tests { for _, test := range tests {

View File

@ -70,7 +70,6 @@ func toEventSimplified(orig models.Eventable) models.Eventable {
newContent := insertStringToBody(origBody, attendees) newContent := insertStringToBody(origBody, attendees)
newBody := models.NewItemBody() newBody := models.NewItemBody()
newBody.SetContentType(origBody.GetContentType()) newBody.SetContentType(origBody.GetContentType())
newBody.SetAdditionalData(origBody.GetAdditionalData())
newBody.SetOdataType(origBody.GetOdataType()) newBody.SetOdataType(origBody.GetOdataType())
newBody.SetContent(&newContent) newBody.SetContent(&newContent)
orig.SetBody(newBody) 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 return orig
} }

View File

@ -121,6 +121,32 @@ func (suite *TransformUnitTest) TestToEventSimplified_recurrence() {
return ptr.Val(e.GetRecurrence().GetRange().GetRecurrenceTimeZone()) == "Pacific Standard Time" 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 { for _, test := range tests {

View File

@ -813,7 +813,10 @@ func testExchangeContinuousBackups(suite *BackupOpIntegrationSuite, toggles cont
eventDBF := func(id, timeStamp, subject, body string) []byte { eventDBF := func(id, timeStamp, subject, body string) []byte {
return exchMock.EventWith( return exchMock.EventWith(
suite.user, subject, body, body, 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 // test data set

View File

@ -3,8 +3,10 @@ package api
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"strings"
"time" "time"
"github.com/alcionai/clues" "github.com/alcionai/clues"
@ -15,6 +17,7 @@ import (
"github.com/alcionai/corso/src/internal/common/dttm" "github.com/alcionai/corso/src/internal/common/dttm"
"github.com/alcionai/corso/src/internal/common/ptr" "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/internal/m365/graph"
"github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
@ -189,6 +192,14 @@ func (c Events) PatchCalendar(
return nil 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 // items
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -208,41 +219,216 @@ func (c Events) GetItem(
} }
) )
event, err = c.Stable. // 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)
}
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"},
},
Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize), preferImmutableIDs(immutableIDs)),
}
attached, err := c.LargeItem.
Client(). Client().
Users(). Users().
ByUserId(userID). ByUserId(userID).
Events(). Events().
ByEventId(itemID). ByEventId(itemID).
Attachments().
Get(ctx, config) Get(ctx, config)
if err != nil { if err != nil {
return nil, nil, graph.Stack(ctx, err) return nil, graph.Wrap(ctx, err, "event attachment download")
} }
if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) { return attached.GetValue(), nil
config := &users.ItemEventsItemAttachmentsRequestBuilderGetRequestConfiguration{ }
QueryParameters: &users.ItemEventsItemAttachmentsRequestBuilderGetQueryParameters{
Expand: []string{"microsoft.graph.itemattachment/item"},
},
Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize), preferImmutableIDs(immutableIDs)),
}
attached, err := c.LargeItem. func (c Events) GetItemInstances(
Client(). ctx context.Context,
Users(). userID, itemID string,
ByUserId(userID). startDate, endDate string,
Events(). ) ([]models.Eventable, error) {
ByEventId(itemID). config := &users.ItemEventsItemInstancesRequestBuilderGetRequestConfiguration{
Attachments(). QueryParameters: &users.ItemEventsItemInstancesRequestBuilderGetQueryParameters{
Get(ctx, config) Select: []string{"id"},
if err != nil { StartDateTime: ptr.To(startDate),
return nil, nil, graph.Wrap(ctx, err, "event attachment download") EndDateTime: ptr.To(endDate),
} },
event.SetAttachments(attached.GetValue())
} }
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( func (c Events) PostItem(
@ -250,14 +436,10 @@ func (c Events) PostItem(
userID, containerID string, userID, containerID string,
body models.Eventable, body models.Eventable,
) (models.Eventable, error) { ) (models.Eventable, error) {
itm, err := c.Stable. rawURL := fmt.Sprintf(eventPostBetaURLTemplate, userID, containerID)
Client(). builder := users.NewItemCalendarsItemEventsRequestBuilder(rawURL, c.Stable.Adapter())
Users().
ByUserId(userID). itm, err := builder.Post(ctx, body, nil)
Calendars().
ByCalendarId(containerID).
Events().
Post(ctx, body, nil)
if err != nil { if err != nil {
return nil, graph.Wrap(ctx, err, "creating calendar event") return nil, graph.Wrap(ctx, err, "creating calendar event")
} }
@ -265,6 +447,22 @@ func (c Events) PostItem(
return itm, nil 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( func (c Events) DeleteItem(
ctx context.Context, ctx context.Context,
userID, itemID string, userID, itemID string,
@ -472,3 +670,17 @@ func EventInfo(evt models.Eventable) *details.ExchangeInfo {
Modified: ptr.OrNow(evt.GetLastModifiedDateTime()), 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
}

View File

@ -26,3 +26,5 @@ included in backup and restore.
* SharePoint document library data can't be restored after the library has been deleted. * 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. * 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