Update attachments for events which drift from series master (#3644)

This updates any changes to attachments for individual event instances
for the ones that differ from the series master.<!-- PR description-->

---

#### 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. -->
* fixes https://github.com/alcionai/corso/issues/2835

#### Test Plan

<!-- How will this be tested prior to merging.-->
- [ ] 💪 Manual
- [ ]  Unit test
- [x] 💚 E2E
This commit is contained in:
Abin Simon 2023-06-22 19:07:05 +05:30 committed by GitHub
parent c8ae50cb2e
commit b0305a5319
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 338 additions and 77 deletions

View File

@ -25,9 +25,6 @@ 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

@ -116,7 +116,7 @@ func handleExchangeCalendarEventFactory(cmd *cobra.Command, args []string) error
User, subject, body, body, User, subject, body, body,
exchMock.NoOriginalStartDate, now, now, exchMock.NoOriginalStartDate, now, now,
exchMock.NoRecurrence, exchMock.NoAttendees, exchMock.NoRecurrence, exchMock.NoAttendees,
false, exchMock.NoCancelledOccurrences, exchMock.NoAttachments, exchMock.NoCancelledOccurrences,
exchMock.NoExceptionOccurrences) exchMock.NoExceptionOccurrences)
}, },
control.Defaults(), control.Defaults(),

View File

@ -1,6 +1,7 @@
package exchange package exchange
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"strings" "strings"
@ -15,6 +16,7 @@ import (
"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"
"github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
) )
@ -117,7 +119,15 @@ func (h eventRestoreHandler) restore(
} }
// Fix up event instances in case we have a recurring event // Fix up event instances in case we have a recurring event
err = updateRecurringEvents(ctx, h.ac, userID, destinationID, ptr.Val(item.GetId()), event) err = updateRecurringEvents(
ctx,
h.ac,
userID,
destinationID,
ptr.Val(item.GetId()),
event,
errs,
)
if err != nil { if err != nil {
return nil, clues.Stack(err) return nil, clues.Stack(err)
} }
@ -133,6 +143,7 @@ func updateRecurringEvents(
ac api.Events, ac api.Events,
userID, containerID, itemID string, userID, containerID, itemID string,
event models.Eventable, event models.Eventable,
errs *fault.Bus,
) error { ) error {
if event.GetRecurrence() == nil { if event.GetRecurrence() == nil {
return nil return nil
@ -149,7 +160,7 @@ func updateRecurringEvents(
return clues.Wrap(err, "update cancelled occurrences") return clues.Wrap(err, "update cancelled occurrences")
} }
err = updateExceptionOccurrences(ctx, ac, userID, itemID, exceptionOccurrences) err = updateExceptionOccurrences(ctx, ac, userID, containerID, itemID, exceptionOccurrences, errs)
if err != nil { if err != nil {
return clues.Wrap(err, "update exception occurrences") return clues.Wrap(err, "update exception occurrences")
} }
@ -164,8 +175,10 @@ func updateExceptionOccurrences(
ctx context.Context, ctx context.Context,
ac api.Events, ac api.Events,
userID string, userID string,
containerID string,
itemID string, itemID string,
exceptionOccurrences any, exceptionOccurrences any,
errs *fault.Bus,
) error { ) error {
if exceptionOccurrences == nil { if exceptionOccurrences == nil {
return nil return nil
@ -193,9 +206,11 @@ func updateExceptionOccurrences(
startStr := dttm.FormatTo(start, dttm.DateOnly) startStr := dttm.FormatTo(start, dttm.DateOnly)
endStr := dttm.FormatTo(start.Add(24*time.Hour), dttm.DateOnly) endStr := dttm.FormatTo(start.Add(24*time.Hour), dttm.DateOnly)
ictx := clues.Add(ctx, "event_instance_id", ptr.Val(evt.GetId()), "event_instance_date", start)
// Get all instances on the day of the instance which should // Get all instances on the day of the instance which should
// just the one we need to modify // just the one we need to modify
evts, err := ac.GetItemInstances(ctx, userID, itemID, startStr, endStr) instances, err := ac.GetItemInstances(ictx, userID, itemID, startStr, endStr)
if err != nil { if err != nil {
return clues.Wrap(err, "getting instances") return clues.Wrap(err, "getting instances")
} }
@ -203,27 +218,141 @@ func updateExceptionOccurrences(
// Since the min recurrence interval is 1 day and we are // Since the min recurrence interval is 1 day and we are
// querying for only a single day worth of instances, we // querying for only a single day worth of instances, we
// should not have more than one instance here. // should not have more than one instance here.
if len(evts) != 1 { if len(instances) != 1 {
return clues.New("invalid number of instances for modified"). return clues.New("invalid number of instances for modified").
With("instances_count", len(evts), "search_start", startStr, "search_end", endStr) With("instances_count", len(instances), "search_start", startStr, "search_end", endStr)
} }
evt = toEventSimplified(evt) evt = toEventSimplified(evt)
// TODO(meain): Update attachments (might have to diff the _, err = ac.PatchItem(ictx, userID, ptr.Val(instances[0].GetId()), evt)
// 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 { if err != nil {
return clues.Wrap(err, "updating event instance") return clues.Wrap(err, "updating event instance")
} }
// We are creating event again from map as `toEventSimplified`
// removed the attachments and creating a clone from start of
// the event is non-trivial
evt, err = api.EventFromMap(instance)
if err != nil {
return clues.Wrap(err, "parsing event instance")
}
err = updateAttachments(ictx, ac, userID, containerID, ptr.Val(instances[0].GetId()), evt, errs)
if err != nil {
return clues.Wrap(err, "updating event instance attachments")
}
} }
return nil return nil
} }
// updateAttachments updates the attachments of an event to match what
// is present in the backed up event. Ideally we could make use of the
// id of the series master event's attachments to see if we had
// added/removed any attachments, but as soon an event is modified,
// the id changes which makes the ids unusable. In this function, we
// use the name and content bytes to detect the changes. This function
// can be used to update the attachments of any event irrespective of
// whether they are event instances of a series master although for
// newer event, since we probably won't already have any events it
// would be better use Post[Small|Large]Attachment.
func updateAttachments(
ctx context.Context,
client api.Events,
userID, containerID, eventID string,
event models.Eventable,
errs *fault.Bus,
) error {
el := errs.Local()
attachments, err := client.GetAttachments(ctx, false, userID, eventID)
if err != nil {
return clues.Wrap(err, "getting attachments")
}
// Delete attachments that are not present in the backup but are
// present in the event(ones that were automatically inherited
// from series master).
for _, att := range attachments {
if el.Failure() != nil {
return el.Failure()
}
name := ptr.Val(att.GetName())
id := ptr.Val(att.GetId())
content, err := api.GetAttachmentContent(att)
if err != nil {
return clues.Wrap(err, "getting attachment").With("attachment_id", id)
}
found := false
for _, nAtt := range event.GetAttachments() {
nName := ptr.Val(nAtt.GetName())
nContent, err := api.GetAttachmentContent(nAtt)
if err != nil {
return clues.Wrap(err, "getting attachment").With("attachment_id", ptr.Val(nAtt.GetId()))
}
if name == nName && bytes.Equal(content, nContent) {
found = true
break
}
}
if !found {
err = client.DeleteAttachment(ctx, userID, containerID, eventID, id)
if err != nil {
logger.CtxErr(ctx, err).With("attachment_name", name).Info("attachment delete failed")
el.AddRecoverable(ctx, clues.Wrap(err, "deleting event attachment").
WithClues(ctx).With("attachment_name", name))
}
}
}
// Upload missing(attachments that are present in the individual
// instance but not in the series master event) attachments
for _, att := range event.GetAttachments() {
name := ptr.Val(att.GetName())
id := ptr.Val(att.GetId())
content, err := api.GetAttachmentContent(att)
if err != nil {
return clues.Wrap(err, "getting attachment").With("attachment_id", id)
}
found := false
for _, nAtt := range attachments {
nName := ptr.Val(nAtt.GetName())
bContent, err := api.GetAttachmentContent(nAtt)
if err != nil {
return clues.Wrap(err, "getting attachment").With("attachment_id", ptr.Val(nAtt.GetId()))
}
// Max size allowed for an outlook attachment is 150MB
if name == nName && bytes.Equal(content, bContent) {
found = true
break
}
}
if !found {
err = uploadAttachment(ctx, client, userID, containerID, eventID, att)
if err != nil {
return clues.Wrap(err, "uploading attachment").
With("attachment_id", id)
}
}
}
return el.Failure()
}
// updateCancelledOccurrences get the cancelled occurrences which is a // updateCancelledOccurrences get the cancelled occurrences which is a
// list of strings of the format "<id>.<date>", parses the date out of // 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. // that and uses the to get the event instance at that date to delete.
@ -266,7 +395,7 @@ func updateCancelledOccurrences(
// Get all instances on the day of the instance which should // Get all instances on the day of the instance which should
// just the one we need to modify // just the one we need to modify
evts, err := ac.GetItemInstances(ctx, userID, itemID, startStr, endStr) instances, err := ac.GetItemInstances(ctx, userID, itemID, startStr, endStr)
if err != nil { if err != nil {
return clues.Wrap(err, "getting instances") return clues.Wrap(err, "getting instances")
} }
@ -274,12 +403,12 @@ func updateCancelledOccurrences(
// Since the min recurrence interval is 1 day and we are // Since the min recurrence interval is 1 day and we are
// querying for only a single day worth of instances, we // querying for only a single day worth of instances, we
// should not have more than one instance here. // should not have more than one instance here.
if len(evts) != 1 { if len(instances) != 1 {
return clues.New("invalid number of instances for cancelled"). return clues.New("invalid number of instances for cancelled").
With("instances_count", len(evts), "search_start", startStr, "search_end", endStr) With("instances_count", len(instances), "search_start", startStr, "search_end", endStr)
} }
err = ac.DeleteItem(ctx, userID, ptr.Val(evts[0].GetId())) err = ac.DeleteItem(ctx, userID, ptr.Val(instances[0].GetId()))
if err != nil { if err != nil {
return clues.Wrap(err, "deleting event instance") return clues.Wrap(err, "deleting event instance")
} }

View File

@ -23,7 +23,7 @@ import (
// 10. attendees // 10. attendees
//nolint:lll //nolint:lll
const ( var (
eventTmpl = `{ eventTmpl = `{
"categories":[], "categories":[],
"changeKey":"0hATW1CAfUS+njw3hdxSGAAAJIxNug==", "changeKey":"0hATW1CAfUS+njw3hdxSGAAAJIxNug==",
@ -98,8 +98,10 @@ const (
defaultEventBody = "This meeting is to review the latest Tailspin Toys project proposal.<br>\\r\\nBut why not eat some sushi while were at it? :)" defaultEventBody = "This meeting is to review the latest Tailspin Toys project proposal.<br>\\r\\nBut why not eat some sushi while were at it? :)"
defaultEventBodyPreview = "This meeting is to review the latest Tailspin Toys project proposal.\\r\\nBut why not eat some sushi while were at it? :)" defaultEventBodyPreview = "This meeting is to review the latest Tailspin Toys project proposal.\\r\\nBut why not eat some sushi while were at it? :)"
defaultEventOrganizer = "foobar@8qzvrj.onmicrosoft.com" defaultEventOrganizer = "foobar@8qzvrj.onmicrosoft.com"
eventAttachment = "\"attachments\":[{\"id\":\"AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAACLjfLQAAABEgAQAHoI0xBbBBVEh6bFMU78ZUo=\",\"@odata.type\":\"#microsoft.graph.fileAttachment\"," +
"\"@odata.mediaContentType\":\"application/octet-stream\",\"contentType\":\"application/octet-stream\",\"isInline\":false,\"lastModifiedDateTime\":\"2022-10-26T15:19:42Z\",\"name\":\"database.db\",\"size\":11418," + NoAttachments = ""
eventAttachmentFormat = "{\"id\":\"AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAACLjfLQAAABEgAQAHoI0xBbBBVEh6bFMU78ZUo=\",\"@odata.type\":\"#microsoft.graph.fileAttachment\"," +
"\"@odata.mediaContentType\":\"application/octet-stream\",\"contentType\":\"application/octet-stream\",\"isInline\":false,\"lastModifiedDateTime\":\"2022-10-26T15:19:42Z\",\"name\":\"%s\",\"size\":11418," +
"\"contentBytes\":\"U1FMaXRlIGZvcm1hdCAzAAQAAQEAQCAgAAAATQAAAAsAAAAEAAAACAAAAAsAAAAEAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABNAC3mBw0DZwACAg8AAxUCDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "\"contentBytes\":\"U1FMaXRlIGZvcm1hdCAzAAQAAQEAQCAgAAAATQAAAAsAAAAEAAAACAAAAAsAAAAEAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABNAC3mBw0DZwACAg8AAxUCDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCAwMHFxUVAYNpdGFibGVkYXRhZGF0YQJDUkVBVEUgVEFCTEUgZGF0YSAoCiAgICAgICAgIGlkIGludGVnZXIgcHJpbWFyeSBrZXkgYXV0b2luY3JlbWVudCwKICAgICAgICAgbWVhbiB0ZXh0IG5vdCBudWxsLAogICAgICAgICBtYXggdGV4dCBub3QgbnVsbCwKICAgICAgICAgbWluIHRleHQgbm90IG51bGwsCiAgICAgICAgIGRhdGEgdGV" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCAwMHFxUVAYNpdGFibGVkYXRhZGF0YQJDUkVBVEUgVEFCTEUgZGF0YSAoCiAgICAgICAgIGlkIGludGVnZXIgcHJpbWFyeSBrZXkgYXV0b2luY3JlbWVudCwKICAgICAgICAgbWVhbiB0ZXh0IG5vdCBudWxsLAogICAgICAgICBtYXggdGV4dCBub3QgbnVsbCwKICAgICAgICAgbWluIHRleHQgbm90IG51bGwsCiAgICAgICAgIGRhdGEgdGV" +
@ -146,7 +148,8 @@ const (
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}]," "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}"
defaultEventAttachments = "\"attachments\":[" + fmt.Sprintf(eventAttachmentFormat, "database.db") + "],"
originalStartDateFormat = `"originalStart": "%s",` originalStartDateFormat = `"originalStart": "%s",`
NoOriginalStartDate = `` NoOriginalStartDate = ``
@ -226,37 +229,43 @@ func EventBytes(subject string) []byte {
} }
func EventWithSubjectBytes(subject string) []byte { func EventWithSubjectBytes(subject string) []byte {
tomorrow := time.Now().UTC().AddDate(0, 0, 1) var (
at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) tomorrow = time.Now().UTC().AddDate(0, 0, 1)
atTime := dttm.Format(at) at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC)
endTime := dttm.Format(at.Add(30 * time.Minute)) atTime = dttm.Format(at)
endTime = dttm.Format(at.Add(30 * time.Minute))
)
return EventWith( return EventWith(
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, defaultEventBody, defaultEventBodyPreview,
NoOriginalStartDate, atTime, endTime, NoRecurrence, NoAttendees, NoOriginalStartDate, atTime, endTime, NoRecurrence, NoAttendees,
false, NoCancelledOccurrences, NoExceptionOccurrences, NoAttachments, NoCancelledOccurrences, NoExceptionOccurrences,
) )
} }
func EventWithAttachment(subject string) []byte { func EventWithAttachment(subject string) []byte {
tomorrow := time.Now().UTC().AddDate(0, 0, 1) var (
at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) tomorrow = time.Now().UTC().AddDate(0, 0, 1)
atTime := dttm.Format(at) at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC)
atTime = dttm.Format(at)
)
return EventWith( return EventWith(
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, defaultEventBody, defaultEventBodyPreview,
NoOriginalStartDate, atTime, atTime, NoRecurrence, NoAttendees, NoOriginalStartDate, atTime, atTime, NoRecurrence, NoAttendees,
true, NoCancelledOccurrences, NoExceptionOccurrences, defaultEventAttachments, NoCancelledOccurrences, NoExceptionOccurrences,
) )
} }
func EventWithRecurrenceBytes(subject, recurrenceTimeZone string) []byte { func EventWithRecurrenceBytes(subject, recurrenceTimeZone string) []byte {
tomorrow := time.Now().UTC().AddDate(0, 0, 1) var (
at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) tomorrow = time.Now().UTC().AddDate(0, 0, 1)
atTime := dttm.Format(at) at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC)
timeSlice := strings.Split(atTime, "T") atTime = dttm.Format(at)
timeSlice = strings.Split(atTime, "T")
)
recurrence := string(fmt.Sprintf( recurrence := string(fmt.Sprintf(
recurrenceTmpl, recurrenceTmpl,
@ -270,16 +279,18 @@ func EventWithRecurrenceBytes(subject, recurrenceTimeZone string) []byte {
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, defaultEventBody, defaultEventBodyPreview,
NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl, NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl,
true, NoCancelledOccurrences, NoExceptionOccurrences, NoAttachments, NoCancelledOccurrences, NoExceptionOccurrences,
) )
} }
func EventWithRecurrenceAndCancellationBytes(subject string) []byte { func EventWithRecurrenceAndCancellationBytes(subject string) []byte {
tomorrow := time.Now().UTC().AddDate(0, 0, 1) var (
at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) tomorrow = time.Now().UTC().AddDate(0, 0, 1)
atTime := dttm.Format(at) at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC)
timeSlice := strings.Split(atTime, "T") atTime = dttm.Format(at)
nextYear := tomorrow.AddDate(1, 0, 0) timeSlice = strings.Split(atTime, "T")
nextYear = tomorrow.AddDate(1, 0, 0)
)
recurrence := string(fmt.Sprintf( recurrence := string(fmt.Sprintf(
recurrenceTmpl, recurrenceTmpl,
@ -296,17 +307,19 @@ func EventWithRecurrenceAndCancellationBytes(subject string) []byte {
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, defaultEventBody, defaultEventBodyPreview,
NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl, NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl,
true, cancelledOccurrences, NoExceptionOccurrences, defaultEventAttachments, cancelledOccurrences, NoExceptionOccurrences,
) )
} }
func EventWithRecurrenceAndExceptionBytes(subject string) []byte { func EventWithRecurrenceAndExceptionBytes(subject string) []byte {
tomorrow := time.Now().UTC().AddDate(0, 0, 1) var (
at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) tomorrow = time.Now().UTC().AddDate(0, 0, 1)
atTime := dttm.Format(at) at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC)
timeSlice := strings.Split(atTime, "T") atTime = dttm.Format(at)
newTime := dttm.Format(tomorrow.AddDate(0, 0, 1)) timeSlice = strings.Split(atTime, "T")
originalStartDate := dttm.FormatTo(at, dttm.TabularOutput) newTime = dttm.Format(tomorrow.AddDate(0, 0, 1))
originalStartDate = dttm.FormatTo(at, dttm.TabularOutput)
)
recurrence := string(fmt.Sprintf( recurrence := string(fmt.Sprintf(
recurrenceTmpl, recurrenceTmpl,
@ -321,7 +334,43 @@ func EventWithRecurrenceAndExceptionBytes(subject string) []byte {
defaultEventBody, defaultEventBodyPreview, defaultEventBody, defaultEventBodyPreview,
fmt.Sprintf(originalStartDateFormat, originalStartDate), fmt.Sprintf(originalStartDateFormat, originalStartDate),
newTime, newTime, NoRecurrence, attendeesTmpl, newTime, newTime, NoRecurrence, attendeesTmpl,
false, NoCancelledOccurrences, NoExceptionOccurrences, NoAttachments, NoCancelledOccurrences, NoExceptionOccurrences,
)
exceptionOccurrences := fmt.Sprintf(exceptionOccurrencesFormat, exceptionEvent)
return EventWith(
defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview,
NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl,
defaultEventAttachments, NoCancelledOccurrences, exceptionOccurrences,
)
}
func EventWithRecurrenceAndExceptionAndAttachmentBytes(subject string) []byte {
var (
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,
"\"attachments\":["+fmt.Sprintf(eventAttachmentFormat, "exception-database.db")+"],",
NoCancelledOccurrences, NoExceptionOccurrences,
) )
exceptionOccurrences := fmt.Sprintf( exceptionOccurrences := fmt.Sprintf(
exceptionOccurrencesFormat, exceptionOccurrencesFormat,
@ -332,20 +381,22 @@ func EventWithRecurrenceAndExceptionBytes(subject string) []byte {
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, defaultEventBody, defaultEventBodyPreview,
NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl, NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl,
true, NoCancelledOccurrences, exceptionOccurrences, defaultEventAttachments, NoCancelledOccurrences, exceptionOccurrences,
) )
} }
func EventWithAttendeesBytes(subject string) []byte { func EventWithAttendeesBytes(subject string) []byte {
tomorrow := time.Now().UTC().AddDate(0, 0, 1) var (
at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) tomorrow = time.Now().UTC().AddDate(0, 0, 1)
atTime := dttm.Format(at) at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC)
atTime = dttm.Format(at)
)
return EventWith( return EventWith(
defaultEventOrganizer, subject, defaultEventOrganizer, subject,
defaultEventBody, defaultEventBodyPreview, defaultEventBody, defaultEventBodyPreview,
NoOriginalStartDate, atTime, atTime, NoRecurrence, attendeesTmpl, NoOriginalStartDate, atTime, atTime, NoRecurrence, attendeesTmpl,
true, NoCancelledOccurrences, NoExceptionOccurrences, defaultEventAttachments, NoCancelledOccurrences, NoExceptionOccurrences,
) )
} }
@ -357,13 +408,9 @@ func EventWithAttendeesBytes(subject string) []byte {
func EventWith( func EventWith(
organizer, subject, body, bodyPreview, organizer, subject, body, bodyPreview,
originalStartDate, startDateTime, endDateTime, recurrence, attendees string, originalStartDate, startDateTime, endDateTime, recurrence, attendees string,
hasAttachments bool, cancelledOccurrences, exceptionOccurrences string, attachments string, cancelledOccurrences, exceptionOccurrences string,
) []byte { ) []byte {
var attachments string hasAttachments := len(attachments) > 0
if hasAttachments {
attachments = eventAttachment
}
startDateTime = strings.TrimSuffix(startDateTime, "Z") startDateTime = strings.TrimSuffix(startDateTime, "Z")
endDateTime = strings.TrimSuffix(endDateTime, "Z") endDateTime = strings.TrimSuffix(endDateTime, "Z")

View File

@ -124,6 +124,10 @@ func (suite *RestoreIntgSuite) TestRestoreEvent() {
name: "Test exceptionOccurrences", name: "Test exceptionOccurrences",
bytes: exchMock.EventWithRecurrenceAndExceptionBytes(subject), bytes: exchMock.EventWithRecurrenceAndExceptionBytes(subject),
}, },
{
name: "Test exceptionOccurrences with different attachments",
bytes: exchMock.EventWithRecurrenceAndExceptionAndAttachmentBytes(subject),
},
} }
for _, test := range tests { for _, test := range tests {
@ -369,3 +373,70 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
}) })
} }
} }
func (suite *RestoreIntgSuite) TestRestoreAndBackupEvent_recurringInstancesWithAttachments() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
var (
userID = tester.M365UserID(t)
subject = testdata.DefaultRestoreConfig("event").Location
handler = newEventRestoreHandler(suite.ac)
)
calendar, err := handler.ac.CreateContainer(ctx, userID, subject, "")
require.NoError(t, err, clues.ToCore(err))
calendarID := ptr.Val(calendar.GetId())
bytes := exchMock.EventWithRecurrenceAndExceptionAndAttachmentBytes("Reoccurring event restore and backup test")
info, err := handler.restore(
ctx,
bytes,
userID, calendarID,
fault.New(true))
require.NoError(t, err, clues.ToCore(err))
assert.NotNil(t, info, "event item info")
ec, err := handler.ac.Stable.
Client().
Users().
ByUserId(userID).
Calendars().
ByCalendarId(calendarID).
Events().
Get(ctx, nil)
require.NoError(t, err, clues.ToCore(err))
evts := ec.GetValue()
assert.Len(t, evts, 1, "count of events")
sp, info, err := suite.ac.Events().GetItem(ctx, userID, ptr.Val(evts[0].GetId()), false, fault.New(true))
require.NoError(t, err, clues.ToCore(err))
assert.NotNil(t, info, "event item info")
body, err := suite.ac.Events().Serialize(ctx, sp, userID, ptr.Val(evts[0].GetId()))
require.NoError(t, err, clues.ToCore(err))
event, err := api.BytesToEventable(body)
require.NoError(t, err, clues.ToCore(err))
assert.NotNil(t, event.GetRecurrence(), "recurrence")
eo := event.GetAdditionalData()["exceptionOccurrences"]
assert.NotNil(t, eo, "exceptionOccurrences")
assert.NotEqual(
t,
ptr.Val(event.GetSubject()),
ptr.Val(eo.([]any)[0].(map[string]any)["subject"].(*string)),
"name equal")
atts := eo.([]any)[0].(map[string]any)["attachments"]
assert.NotEqual(
t,
ptr.Val(event.GetAttachments()[0].GetName()),
ptr.Val(atts.([]any)[0].(map[string]any)["name"].(*string)),
"attachment name equal")
}

View File

@ -815,7 +815,7 @@ func testExchangeContinuousBackups(suite *BackupOpIntegrationSuite, toggles cont
suite.user, subject, body, body, suite.user, subject, body, body,
exchMock.NoOriginalStartDate, now, now, exchMock.NoOriginalStartDate, now, now,
exchMock.NoRecurrence, exchMock.NoAttendees, exchMock.NoRecurrence, exchMock.NoAttendees,
false, exchMock.NoCancelledOccurrences, exchMock.NoAttachments, exchMock.NoCancelledOccurrences,
exchMock.NoExceptionOccurrences) exchMock.NoExceptionOccurrences)
} }

View File

@ -241,10 +241,13 @@ func (c Events) GetItem(
return nil, nil, clues.Wrap(err, "fixup exception occurrences") return nil, nil, clues.Wrap(err, "fixup exception occurrences")
} }
attachments, err := c.getAttachments(ctx, event, immutableIDs, userID, itemID) var attachments []models.Attachmentable
if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) {
attachments, err = c.GetAttachments(ctx, immutableIDs, userID, itemID)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
}
event.SetAttachments(attachments) event.SetAttachments(attachments)
@ -286,10 +289,14 @@ func fixupExceptionOccurrences(
// OPTIMIZATION: We don't have to store any of the // OPTIMIZATION: We don't have to store any of the
// attachments that carry over from the original // attachments that carry over from the original
attachments, err := client.getAttachments(ctx, evt, immutableIDs, userID, ptr.Val(evt.GetId()))
var attachments []models.Attachmentable
if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) {
attachments, err = client.GetAttachments(ctx, immutableIDs, userID, ptr.Val(evt.GetId()))
if err != nil { if err != nil {
return clues.Wrap(err, "getting exception attachments"). return clues.Wrap(err, "getting event instance attachments").
With("exception_event_id", ptr.Val(evt.GetId())) With("event_instance_id", ptr.Val(evt.GetId()))
}
} }
// This odd roundabout way of doing this is required as // This odd roundabout way of doing this is required as
@ -370,17 +377,12 @@ func parseableToMap(att serialization.Parsable) (map[string]any, error) {
return item, nil return item, nil
} }
func (c Events) getAttachments( func (c Events) GetAttachments(
ctx context.Context, ctx context.Context,
event models.Eventable,
immutableIDs bool, immutableIDs bool,
userID string, userID string,
itemID string, itemID string,
) ([]models.Attachmentable, error) { ) ([]models.Attachmentable, error) {
if !ptr.Val(event.GetHasAttachments()) && !HasAttachments(event.GetBody()) {
return nil, nil
}
config := &users.ItemEventsItemAttachmentsRequestBuilderGetRequestConfiguration{ config := &users.ItemEventsItemAttachmentsRequestBuilderGetRequestConfiguration{
QueryParameters: &users.ItemEventsItemAttachmentsRequestBuilderGetQueryParameters{ QueryParameters: &users.ItemEventsItemAttachmentsRequestBuilderGetQueryParameters{
Expand: []string{"microsoft.graph.itemattachment/item"}, Expand: []string{"microsoft.graph.itemattachment/item"},
@ -403,6 +405,23 @@ func (c Events) getAttachments(
return attached.GetValue(), nil return attached.GetValue(), nil
} }
func (c Events) DeleteAttachment(
ctx context.Context,
userID, calendarID, eventID, attachmentID string,
) error {
return c.Stable.
Client().
Users().
ByUserId(userID).
Calendars().
ByCalendarId(calendarID).
Events().
ByEventId(eventID).
Attachments().
ByAttachmentId(attachmentID).
Delete(ctx, nil)
}
func (c Events) GetItemInstances( func (c Events) GetItemInstances(
ctx context.Context, ctx context.Context,
userID, itemID string, userID, itemID string,

View File

@ -26,5 +26,3 @@ 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