diff --git a/pkg/jmap/api_contact.go b/pkg/jmap/api_contact.go index 9d036e8da1..d684978597 100644 --- a/pkg/jmap/api_contact.go +++ b/pkg/jmap/api_contact.go @@ -76,7 +76,7 @@ func (j *Client) QueryContactCards(accountIds []string, Results: get.List, CanCalculateChanges: query.CanCalculateChanges, Position: query.Position, - Total: uintPtrIf(query.Total, calculateTotal), + Total: uintPtrIfPtr(query.Total, calculateTotal), Limit: query.Limit, } }, @@ -114,3 +114,18 @@ func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Co ctx, ) } + +func (j *Client) UpdateContactCard(accountId string, id string, changes ContactCardChange, ctx Context) (ContactCard, SessionState, State, Language, Error) { + return update(j, "UpdateContactCard", ContactCardType, + func(update map[string]PatchObject) ContactCardSetCommand { + return ContactCardSetCommand{AccountId: accountId, Update: update} + }, + func(id string) ContactCardGetCommand { + return ContactCardGetCommand{AccountId: accountId, Ids: []string{id}} + }, + func(resp ContactCardSetResponse) map[string]SetError { return resp.NotUpdated }, + func(resp ContactCardGetResponse) ContactCard { return resp.List[0] }, + id, changes, + ctx, + ) +} diff --git a/pkg/jmap/api_event.go b/pkg/jmap/api_event.go index 470e2958ee..1fabadbe14 100644 --- a/pkg/jmap/api_event.go +++ b/pkg/jmap/api_event.go @@ -29,7 +29,7 @@ func (j *Client) QueryCalendarEvents(accountIds []string, //NOSONAR Results: get.List, CanCalculateChanges: query.CanCalculateChanges, Position: query.Position, - Total: uintPtrIf(query.Total, calculateTotal), + Total: uintPtrIfPtr(query.Total, calculateTotal), Limit: query.Limit, } }, @@ -103,3 +103,18 @@ func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx ctx, ) } + +func (j *Client) UpdateCalendarEvent(accountId string, id string, changes CalendarEventChange, ctx Context) (CalendarEvent, SessionState, State, Language, Error) { + return update(j, "UpdateCalendarEvent", CalendarEventType, + func(update map[string]PatchObject) CalendarEventSetCommand { + return CalendarEventSetCommand{AccountId: accountId, Update: update} + }, + func(id string) CalendarEventGetCommand { + return CalendarEventGetCommand{AccountId: accountId, Ids: []string{id}} + }, + func(resp CalendarEventSetResponse) map[string]SetError { return resp.NotUpdated }, + func(resp CalendarEventGetResponse) CalendarEvent { return resp.List[0] }, + id, changes, + ctx, + ) +} diff --git a/pkg/jmap/error.go b/pkg/jmap/error.go index 63b7c9e0dc..6ea5f84a46 100644 --- a/pkg/jmap/error.go +++ b/pkg/jmap/error.go @@ -39,6 +39,7 @@ const ( JmapErrorSocketPushUnsupported JmapErrorMissingCreatedObject JmapInvalidObjectState + JmapPatchObjectSerialization ) var ( diff --git a/pkg/jmap/integration_calendar_test.go b/pkg/jmap/integration_calendar_test.go index cac25266fa..51b2dab0b5 100644 --- a/pkg/jmap/integration_calendar_test.go +++ b/pkg/jmap/integration_calendar_test.go @@ -2,11 +2,12 @@ package jmap import ( "encoding/base64" - "encoding/json" "fmt" golog "log" + "maps" "math" "math/rand" + "slices" "strconv" "strings" "testing" @@ -77,7 +78,7 @@ func TestEvents(t *testing.T) { session := s.Session(user.name) ctx := s.Context(session) - accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, session, user) + accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, ctx, user) require.NoError(err) require.NotEmpty(accountId) require.NotEmpty(calendarId) @@ -89,8 +90,10 @@ func TestEvents(t *testing.T) { {Property: CalendarEventPropertyStart, IsAscending: true}, } + ss := EmptySessionState + os := EmptyState { - resultsByAccount, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx) + resultsByAccount, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx) require.NoError(err) require.Len(resultsByAccount, 1) @@ -108,6 +111,9 @@ func TestEvents(t *testing.T) { require.True(ok, "failed to find created contact by its id") matchEvent(t, actual, expected) } + + ss = sessionState + os = state } { @@ -118,8 +124,7 @@ func TestEvents(t *testing.T) { for i := range slices { position := int(i * limit) page := min(remainder, limit) - m, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, limit, true, ctx) - fmt.Printf("=== i=%d | limit=%d | remainder=%d | position=%d | limit=%d | results=%d\n", i, limit, remainder, position, limit, len(m[accountId].Results)) + m, sessionState, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, limit, true, ctx) require.NoError(err) require.Len(m, 1) require.Contains(m, accountId) @@ -131,9 +136,54 @@ func TestEvents(t *testing.T) { require.NotNil(results.Total) require.Equal(count, *results.Total) remainder -= uint(len(results.Results)) + + require.Equal(ss, sessionState) } } + for _, event := range expectedEventsById { + change := CalendarEventChange{ + EventChange: jscalendar.EventChange{ + Status: ptr(jscalendar.StatusCancelled), + ObjectChange: jscalendar.ObjectChange{ + Sequence: uintPtr(99), + ShowWithoutTime: boolPtr(true), + }, + }, + } + changed, sessionState, state, _, err := s.client.UpdateCalendarEvent(accountId, event.Id, change, ctx) + require.NoError(err) + require.Equal(jscalendar.StatusCancelled, changed.Status) + require.Equal(uint(99), changed.Sequence) + require.Equal(true, changed.ShowWithoutTime) + require.Equal(ss, sessionState) + require.NotEqual(os, state) + os = state + } + + { + ids := structs.Map(slices.Collect(maps.Values(expectedEventsById)), func(e CalendarEvent) string { return e.Id }) + errMap, sessionState, state, _, err := s.client.DeleteCalendarEvent(accountId, ids, ctx) + require.NoError(err) + require.Empty(errMap) + + require.Equal(ss, sessionState) + require.NotEqual(os, state) + os = state + } + + { + shouldBeEmpty, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx) + require.NoError(err) + require.Contains(shouldBeEmpty, accountId) + resp := shouldBeEmpty[accountId] + require.Empty(resp.Results) + require.NotNil(resp.Total) + require.Equal(uint(0), *resp.Total) + require.Equal(ss, sessionState) + require.Equal(os, state) + } + exceptions := []string{} if !EnableEventMayInviteFields { exceptions = append(exceptions, "mayInvite") @@ -165,7 +215,7 @@ func (s *StalwartTest) fillCalendar( //NOSONAR boxes := CalendarBoxes{} created := []Calendar{} - ss := SessionState("") + ss := EmptySessionState as := EmptyState printer := func(s string) { golog.Println(s) } @@ -269,7 +319,7 @@ func (s *StalwartTest) fillCalendar( //NOSONAR } require.NotEmpty(sessionState) require.NotEmpty(state) - if ss != SessionState("") { + if ss != EmptySessionState { require.Equal(ss, sessionState) } if as != EmptyState { @@ -294,11 +344,11 @@ type EventsBoxes struct { func (s *StalwartTest) fillEvents( //NOSONAR t *testing.T, count uint, - session *Session, + ctx Context, user User, ) (string, string, map[string]CalendarEvent, EventsBoxes, error) { require := require.New(t) - c, err := NewTestJmapClient(session, user.name, user.password, true, true) + c, err := NewTestJmapClient(ctx.Session, user.name, user.password, true, true) require.NoError(err) defer c.Close() @@ -332,7 +382,6 @@ func (s *StalwartTest) fillEvents( //NOSONAR isDraft := false mainLocationId := "" locationIds := []string{} - locationMaps := map[string]map[string]any{} locationObjs := map[string]jscalendar.Location{} { n := 1 @@ -340,8 +389,7 @@ func (s *StalwartTest) fillEvents( //NOSONAR n++ } for range n { - locationId, locationMap, locationObj := pickLocation() - locationMaps[locationId] = locationMap + locationId, locationObj := pickLocation() locationObjs[locationId] = locationObj locationIds = append(locationIds, locationId) if n > 0 && mainLocationId == "" { @@ -349,8 +397,8 @@ func (s *StalwartTest) fillEvents( //NOSONAR } } } - virtualLocationId, virtualLocationMap, virtualLocationObj := pickVirtualLocation() - participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId}) + virtualLocationId, virtualLocationObj := pickVirtualLocation() + participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId}) duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M") tz := pickRandom(timezones...) daysDiff := rand.Intn(31) - 15 @@ -379,52 +427,10 @@ func (s *StalwartTest) fillEvents( //NOSONAR alertId := id() alertOffset := pickRandom("-PT5M", "-PT10M", "-PT15M") - event := map[string]any{ - "@type": "Event", - "calendarIds": toBoolMapS(calendarId), - "isDraft": isDraft, - "start": start, - "duration": duration, - "status": string(status), - "uid": uid, - "prodId": productName, - "title": title, - "description": description, - "descriptionContentType": descriptionFormat, - "locale": locale, - "color": color, - "sequence": sequence, - "showWithoutTime": false, - "freeBusyStatus": string(freeBusy), - "privacy": string(privacy), - "sentBy": organizerEmail, - "participants": participantMaps, - "timeZone": tz, - "hideAttendees": false, - "replyTo": map[string]string{ - "imip": "mailto:" + organizerEmail, //NOSONAR - }, - "locations": locationMaps, - "virtualLocations": map[string]any{ - virtualLocationId: virtualLocationMap, - }, - "alerts": map[string]map[string]any{ - alertId: { - "@type": "Alert", - "trigger": map[string]any{ - "@type": "OffsetTrigger", - "offset": alertOffset, - "relativeTo": "start", - }, - }, - }, - } - obj := CalendarEvent{ Id: "", CalendarIds: toBoolMapS(calendarId), IsDraft: isDraft, - IsOrigin: true, Event: jscalendar.Event{ Type: jscalendar.EventType, Start: jscalendar.LocalDateTime(start), @@ -449,7 +455,7 @@ func (s *StalwartTest) fillEvents( //NOSONAR TimeZone: tz, HideAttendees: false, ReplyTo: map[jscalendar.ReplyMethod]string{ - jscalendar.ReplyMethodImip: "mailto:" + organizerEmail, + jscalendar.ReplyMethodImip: "mailto:" + organizerEmail, //NOSONAR }, Locations: locationObjs, VirtualLocations: map[string]jscalendar.VirtualLocation{ @@ -470,31 +476,26 @@ func (s *StalwartTest) fillEvents( //NOSONAR } if EnableEventMayInviteFields { - event["mayInviteSelf"] = true - event["mayInviteOthers"] = true obj.MayInviteSelf = true obj.MayInviteOthers = true boxes.mayInvite = true } if len(keywords) > 0 { - event["keywords"] = keywords obj.Keywords = keywords boxes.keywords = true } if len(categories) > 0 { - event["categories"] = categories obj.Categories = categories boxes.categories = true } if mainLocationId != "" { - event["mainLocationId"] = mainLocationId obj.MainLocationId = mainLocationId } - err = propmap(i%2 == 0, 1, 1, event, "links", &obj.Links, func(int, string) (map[string]any, jscalendar.Link, error) { + err = propmap(i%2 == 0, 1, 1, &obj.Links, func(int, string) (jscalendar.Link, error) { mime := "" uri := "" rel := jscalendar.RelAbout @@ -508,17 +509,12 @@ func (s *StalwartTest) fillEvents( //NOSONAR mime = "image/jpeg" //NOSONAR uri = externalImageUri() } - return map[string]any{ - "@type": "Link", - "href": uri, - "contentType": mime, - "rel": string(rel), - }, jscalendar.Link{ - Type: jscalendar.LinkType, - Href: uri, - ContentType: mime, - Rel: rel, - }, nil + return jscalendar.Link{ + Type: jscalendar.LinkType, + Href: uri, + ContentType: mime, + Rel: rel, + }, nil }) if rand.Intn(10) > 7 { @@ -530,15 +526,6 @@ func (s *StalwartTest) fillEvents( //NOSONAR } else { count = 1 + rand.Intn(4) } - event["recurrenceRule"] = map[string]any{ - "@type": "RecurrenceRule", - "frequency": string(frequency), - "interval": interval, - "rscale": string(jscalendar.RscaleIso8601), - "skip": string(jscalendar.SkipOmit), - "firstDayOfWeek": string(jscalendar.DayOfWeekMonday), - "count": count, - } rr := jscalendar.RecurrenceRule{ Type: jscalendar.RecurrenceRuleType, Frequency: frequency, @@ -551,23 +538,18 @@ func (s *StalwartTest) fillEvents( //NOSONAR obj.RecurrenceRule = &rr } - id, err := s.CreateEvent(c, accountId, event) + created, _, _, _, err := s.client.CreateCalendarEvent(accountId, obj, ctx) if err != nil { return accountId, calendarId, nil, boxes, err } - obj.Id = id - filled[id] = obj + filled[created.Id] = *created printer(fmt.Sprintf("📅 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, uid)) } return accountId, calendarId, filled, boxes, nil } -func (s *StalwartTest) CreateEvent(j *TestJmapClient, accountId string, event map[string]any) (string, error) { - return j.create1(accountId, CalendarEventType, event) -} - var rooms = []jscalendar.Location{ { Type: jscalendar.LocationType, @@ -630,56 +612,35 @@ var virtualRooms = []jscalendar.VirtualLocation{ }, } -func pickLocation() (string, map[string]any, jscalendar.Location) { +func pickLocation() (string, jscalendar.Location) { locationId := id() room := rooms[rand.Intn(len(rooms))] - b, err := json.Marshal(room) - if err != nil { - panic(err) - } - var m map[string]any - err = json.Unmarshal(b, &m) - if err != nil { - panic(err) - } - return locationId, m, room + return locationId, room } -func pickVirtualLocation() (string, map[string]any, jscalendar.VirtualLocation) { +func pickVirtualLocation() (string, jscalendar.VirtualLocation) { locationId := id() vroom := virtualRooms[rand.Intn(len(virtualRooms))] - b, err := json.Marshal(vroom) - if err != nil { - panic(err) - } - var m map[string]any - err = json.Unmarshal(b, &m) - if err != nil { - panic(err) - } - return locationId, m, vroom + return locationId, vroom } var ChairRoles = toBoolMapS(jscalendar.RoleChair, jscalendar.RoleOwner) var RegularRoles = toBoolMapS(jscalendar.RoleOptional) -func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (map[string]map[string]any, map[string]jscalendar.Participant, string) { +func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (map[string]jscalendar.Participant, string) { options := structs.Concat(locationIds, virtualLocationIds) n := 1 + rand.Intn(4) - maps := map[string]map[string]any{} objs := map[string]jscalendar.Participant{} - organizerId, organizerEmail, organizerMap, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "") - maps[organizerId] = organizerMap + organizerId, organizerEmail, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "") objs[organizerId] = organizerObj for i := 1; i < n; i++ { - id, _, participantMap, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail) - maps[id] = participantMap + id, _, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail) objs[id] = participantObj } - return maps, objs, organizerEmail + return objs, organizerEmail } -func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, map[string]any, jscalendar.Participant) { +func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, jscalendar.Participant) { participantId := id() person := gofakeit.Person() roles := RegularRoles @@ -731,26 +692,6 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri } } - m := map[string]any{ - "@type": "Participant", - "name": name, - "email": email, - "calendarAddress": calendarAddress, - "kind": "individual", - "roles": structs.MapKeys(roles, func(r jscalendar.Role) string { return string(r) }), - "locationId": locationId, - "language": language, - "participationStatus": string(status), - "participationComment": statusComment, - "expectReply": true, - "scheduleAgent": "server", - "scheduleSequence": 1, - "scheduleStatus": []string{"1.0"}, - "scheduleUpdated": updated, - "sentBy": organizerEmail, - "invitedBy": organizerId, - "scheduleId": "mailto:" + email, - } o := jscalendar.Participant{ Type: jscalendar.ParticipantType, Name: name, @@ -773,36 +714,27 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri } if EnableEventParticipantDescriptionFields { - m["description"] = description - m["descriptionContentType"] = descriptionContentType o.Description = description o.DescriptionContentType = descriptionContentType } - err = propmap(i%2 == 0, 1, 2, m, "links", &o.Links, func(int, string) (map[string]any, jscalendar.Link, error) { + err = propmap(i%2 == 0, 1, 2, &o.Links, func(int, string) (jscalendar.Link, error) { href := externalImageUri() title := person.FirstName + "'s Cake Day pick" - return map[string]any{ - "@type": "Link", - "href": href, - "contentType": "image/jpeg", - "rel": "icon", - "display": "badge", - "title": title, - }, jscalendar.Link{ - Type: jscalendar.LinkType, - Href: href, - ContentType: "image/jpeg", - Rel: jscalendar.RelIcon, - Display: jscalendar.DisplayBadge, - Title: title, - }, nil + return jscalendar.Link{ + Type: jscalendar.LinkType, + Href: href, + ContentType: "image/jpeg", + Rel: jscalendar.RelIcon, + Display: jscalendar.DisplayBadge, + Title: title, + }, nil }) if err != nil { panic(err) } - return participantId, person.Contact.Email, m, o + return participantId, person.Contact.Email, o } var Keywords = []string{ @@ -825,3 +757,7 @@ var Categories = []string{ func pickCategories() map[string]bool { return toBoolMap(pickRandoms(Categories...)) } + +func ptr[T any](t T) *T { + return &t +} diff --git a/pkg/jmap/integration_contact_test.go b/pkg/jmap/integration_contact_test.go index 7f7ed5975f..6ae7236ccf 100644 --- a/pkg/jmap/integration_contact_test.go +++ b/pkg/jmap/integration_contact_test.go @@ -2,10 +2,12 @@ package jmap import ( golog "log" + "maps" "math/rand" "regexp" "slices" "testing" + "time" "github.com/stretchr/testify/require" @@ -86,7 +88,7 @@ func TestContacts(t *testing.T) { session := s.Session(user.name) ctx := s.Context(session) - accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, user) + accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, ctx, user) require.NoError(err) require.NotEmpty(accountId) require.NotEmpty(addressbookId) @@ -98,7 +100,7 @@ func TestContacts(t *testing.T) { {Property: ContactCardPropertyCreated, IsAscending: true}, } - contactsByAccount, _, _, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx) + contactsByAccount, ss, os, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx) require.NoError(err) require.Len(contactsByAccount, 1) @@ -140,6 +142,44 @@ func TestContacts(t *testing.T) { matchContact(t, fetched.List[0], actual) } + { + now := time.Now().Truncate(time.Duration(1) * time.Second).UTC() + for _, event := range expectedContactCardsById { + change := ContactCardChange{ + Language: strPtr("xyz"), + Updated: ptr(now), + } + changed, sessionState, state, _, err := s.client.UpdateContactCard(accountId, event.Id, change, ctx) + require.NoError(err) + require.Equal("xyz", changed.Language) + require.Equal(now, changed.Updated) + require.Equal(ss, sessionState) + require.NotEqual(os, state) + os = state + } + } + { + ids := structs.Map(slices.Collect(maps.Values(expectedContactCardsById)), func(e ContactCard) string { return e.Id }) + errMap, sessionState, state, _, err := s.client.DeleteContactCard(accountId, ids, ctx) + require.NoError(err) + require.Empty(errMap) + + require.Equal(ss, sessionState) + require.NotEqual(os, state) + os = state + } + { + shouldBeEmpty, sessionState, state, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx) + require.NoError(err) + require.Contains(shouldBeEmpty, accountId) + resp := shouldBeEmpty[accountId] + require.Empty(resp.Results) + require.NotNil(resp.Total) + require.Equal(uint(0), *resp.Total) + require.Equal(ss, sessionState) + require.Equal(os, state) + } + exceptions := []string{} if !EnableMediaWithBlobId { exceptions = append(exceptions, "mediaWithBlobId") @@ -181,7 +221,7 @@ func (s *StalwartTest) fillAddressBook( //NOSONAR boxes := AddressBookBoxes{} created := []AddressBook{} - ss := SessionState("") + ss := EmptySessionState as := EmptyState printer := func(s string) { golog.Println(s) } @@ -228,7 +268,7 @@ func (s *StalwartTest) fillAddressBook( //NOSONAR } require.NotEmpty(sessionState) require.NotEmpty(state) - if ss != SessionState("") { + if ss != EmptySessionState { require.Equal(ss, sessionState) } if as != EmptyState { @@ -248,6 +288,7 @@ func (s *StalwartTest) fillContacts( //NOSONAR t *testing.T, count uint, session *Session, + ctx Context, user User, ) (string, string, map[string]ContactCard, ContactsBoxes, error) { require := require.New(t) @@ -288,17 +329,9 @@ func (s *StalwartTest) fillContacts( //NOSONAR filled := map[string]ContactCard{} for i := range count { person := gofakeit.Person() - nameMap, nameObj := createName(person) + nameObj := createName(person) language := pickLanguage() - contact := map[string]any{ - "@type": "Card", - "version": "1.0", - "addressBookIds": toBoolMap([]string{addressbookId}), - "prodId": productName, - "language": language, - "kind": "individual", - "name": nameMap, - } + card := ContactCard{ Type: jscontact.ContactCardType, Version: "1.0", @@ -310,34 +343,29 @@ func (s *StalwartTest) fillContacts( //NOSONAR } if i%3 == 0 { - nicknameMap, nicknameObj := createNickName(person) + nicknameObj := createNickName(person) id := id() - contact["nicknames"] = map[string]map[string]any{id: nicknameMap} card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj} boxes.nicknames = true } { - emailMaps := map[string]map[string]any{} emailObjs := map[string]jscontact.EmailAddress{} emailId := id() - emailMap, emailObj := createEmail(person, 10) - emailMaps[emailId] = emailMap + emailObj := createEmail(person, 10) emailObjs[emailId] = emailObj for i := range rand.Intn(3) { id := id() - m, o := createSecondaryEmail(gofakeit.Email(), i*100) - emailMaps[id] = m + o := createSecondaryEmail(gofakeit.Email(), i*100) emailObjs[id] = o boxes.secondaryEmails = true } - if len(emailMaps) > 0 { - contact["emails"] = emailMaps + if len(emailObjs) > 0 { card.Emails = emailObjs } } - if err := propmap(i%2 == 0, 1, 2, contact, "phones", &card.Phones, func(i int, id string) (map[string]any, jscontact.Phone, error) { + if err := propmap(i%2 == 0, 1, 2, &card.Phones, func(i int, id string) (jscontact.Phone, error) { boxes.phones = true num := person.Contact.Phone if i > 0 { @@ -355,21 +383,16 @@ func (s *StalwartTest) fillContacts( //NOSONAR contexts[jscontact.PhoneContextPrivate] = true } tel := "tel:" + "+1" + num - return map[string]any{ - "@type": "Phone", - "number": tel, - "features": structs.MapKeys(features, func(f jscontact.PhoneFeature) string { return string(f) }), - "contexts": structs.MapKeys(contexts, func(c jscontact.PhoneContext) string { return string(c) }), - }, jscontact.Phone{ - Type: jscontact.PhoneType, - Number: tel, - Features: features, - Contexts: contexts, - }, nil + return jscontact.Phone{ + Type: jscontact.PhoneType, + Number: tel, + Features: features, + Contexts: contexts, + }, nil }); err != nil { return "", "", nil, boxes, err } - if err := propmap(i%5 < 4, 1, 2, contact, "addresses", &card.Addresses, func(i int, id string) (map[string]any, jscontact.Address, error) { + if err := propmap(i%5 < 4, 1, 2, &card.Addresses, func(i int, id string) (jscontact.Address, error) { var source *gofakeit.AddressInfo if i == 0 { source = person.Address @@ -392,108 +415,70 @@ func (s *StalwartTest) fillContacts( //NOSONAR jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip}, ) tz := pickRandom(timezones...) - return map[string]any{ - "@type": "Address", - "components": structs.Map(components, func(c jscontact.AddressComponent) map[string]string { - return map[string]string{"kind": string(c.Kind), "value": c.Value} - }), - "defaultSeparator": ", ", - "isOrdered": true, - "timeZone": tz, - }, jscontact.Address{ - Type: jscontact.AddressType, - Components: components, - DefaultSeparator: ", ", - IsOrdered: true, - TimeZone: tz, - }, nil + return jscontact.Address{ + Type: jscontact.AddressType, + Components: components, + DefaultSeparator: ", ", + IsOrdered: true, + TimeZone: tz, + }, nil }); err != nil { return "", "", nil, boxes, err } - if err := propmap(i%2 == 0, 1, 2, contact, "onlineServices", &card.OnlineServices, func(i int, id string) (map[string]any, jscontact.OnlineService, error) { + if err := propmap(i%2 == 0, 1, 2, &card.OnlineServices, func(i int, id string) (jscontact.OnlineService, error) { boxes.onlineService = true switch rand.Intn(3) { case 0: - return map[string]any{ - "@type": "OnlineService", - "service": "Mastodon", - "user": "@" + person.Contact.Email, - "uri": "https://mastodon.example.com/@" + strings.ToLower(person.FirstName), - }, jscontact.OnlineService{ - Type: jscontact.OnlineServiceType, - Service: "Mastodon", - User: "@" + person.Contact.Email, - Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName), - }, nil + return jscontact.OnlineService{ + Type: jscontact.OnlineServiceType, + Service: "Mastodon", + User: "@" + person.Contact.Email, + Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName), + }, nil case 1: - return map[string]any{ - "@type": "OnlineService", - "uri": "xmpp:" + person.Contact.Email, - }, jscontact.OnlineService{ - Type: jscontact.OnlineServiceType, - Uri: "xmpp:" + person.Contact.Email, - }, nil + return jscontact.OnlineService{ + Type: jscontact.OnlineServiceType, + Uri: "xmpp:" + person.Contact.Email, + }, nil default: - return map[string]any{ - "@type": "OnlineService", - "service": "Discord", - "user": person.Contact.Email, - "uri": "https://discord.example.com/user/" + person.Contact.Email, - }, jscontact.OnlineService{ - Type: jscontact.OnlineServiceType, - Service: "Discord", - User: person.Contact.Email, - Uri: "https://discord.example.com/user/" + person.Contact.Email, - }, nil + return jscontact.OnlineService{ + Type: jscontact.OnlineServiceType, + Service: "Discord", + User: person.Contact.Email, + Uri: "https://discord.example.com/user/" + person.Contact.Email, + }, nil } }); err != nil { return "", "", nil, boxes, err } - if err := propmap(i%3 == 0, 1, 2, contact, "preferredLanguages", &card.PreferredLanguages, func(i int, id string) (map[string]any, jscontact.LanguagePref, error) { + if err := propmap(i%3 == 0, 1, 2, &card.PreferredLanguages, func(i int, id string) (jscontact.LanguagePref, error) { boxes.preferredLanguage = true lang := pickRandom("en", "fr", "de", "es", "it") contexts := pickRandoms1("work", "private") - return map[string]any{ - "@type": "LanguagePref", - "language": lang, - "contexts": toBoolMap(contexts), - "pref": i + 1, - }, jscontact.LanguagePref{ - Type: jscontact.LanguagePrefType, - Language: lang, - Contexts: toBoolMap(structs.Map(contexts, func(s string) jscontact.LanguagePrefContext { return jscontact.LanguagePrefContext(s) })), - Pref: uint(i + 1), - }, nil + return jscontact.LanguagePref{ + Type: jscontact.LanguagePrefType, + Language: lang, + Contexts: toBoolMap(structs.Map(contexts, func(s string) jscontact.LanguagePrefContext { return jscontact.LanguagePrefContext(s) })), + Pref: uint(i + 1), + }, nil }); err != nil { return "", "", nil, boxes, err } if i%2 == 0 { - organizationMaps := map[string]map[string]any{} organizationObjs := map[string]jscontact.Organization{} - titleMaps := map[string]map[string]any{} titleObjs := map[string]jscontact.Title{} for range 1 + rand.Intn(2) { boxes.organization = true orgId := id() titleId := id() - organizationMaps[orgId] = map[string]any{ - "@type": "Organization", - "name": person.Job.Company, - "contexts": toBoolMapS("work"), - } organizationObjs[orgId] = jscontact.Organization{ Type: jscontact.OrganizationType, Name: person.Job.Company, Contexts: toBoolMapS(jscontact.OrganizationContextWork), } - titleMaps[titleId] = map[string]any{ - "@type": "Title", - "kind": "title", - "name": person.Job.Title, - "organizationId": orgId, - } + titleObjs[titleId] = jscontact.Title{ Type: jscontact.TitleType, Kind: jscontact.TitleKindTitle, @@ -501,38 +486,32 @@ func (s *StalwartTest) fillContacts( //NOSONAR OrganizationId: orgId, } } - contact["organizations"] = organizationMaps - contact["titles"] = titleMaps card.Organizations = organizationObjs card.Titles = titleObjs } - if err := propmap(i%2 == 0, 1, 1, contact, "cryptoKeys", &card.CryptoKeys, func(i int, id string) (map[string]any, jscontact.CryptoKey, error) { + if err := propmap(i%2 == 0, 1, 1, &card.CryptoKeys, func(i int, id string) (jscontact.CryptoKey, error) { boxes.cryptoKey = true entity, err := openpgp.NewEntity(person.FirstName+" "+person.LastName, "test", person.Contact.Email, nil) if err != nil { - return nil, jscontact.CryptoKey{}, err + return jscontact.CryptoKey{}, err } var b bytes.Buffer err = entity.PrimaryKey.Serialize(&b) if err != nil { - return nil, jscontact.CryptoKey{}, err + return jscontact.CryptoKey{}, err } encoded := base64.RawStdEncoding.EncodeToString(b.Bytes()) - return map[string]any{ - "@type": "CryptoKey", - "uri": "data:application/pgp-keys;base64," + encoded, - "mediaType": "application/pgp-keys", - }, jscontact.CryptoKey{ - Type: jscontact.CryptoKeyType, - Uri: "data:application/pgp-keys;base64," + encoded, - MediaType: "application/pgp-keys", - }, nil + return jscontact.CryptoKey{ + Type: jscontact.CryptoKeyType, + Uri: "data:application/pgp-keys;base64," + encoded, + MediaType: "application/pgp-keys", + }, nil }); err != nil { return "", "", nil, boxes, err } - if err := propmap(i%2 == 0, 1, 2, contact, "media", &card.Media, func(i int, id string) (map[string]any, jscontact.Media, error) { + if err := propmap(i%2 == 0, 1, 2, &card.Media, func(i int, id string) (jscontact.Media, error) { label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000)) r := 0 @@ -552,40 +531,27 @@ func (s *StalwartTest) fillContacts( //NOSONAR mime := "image/png" uri := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img) contexts := toBoolMapS(jscontact.MediaContextPrivate) - return map[string]any{ - "@type": "Media", - "kind": string(jscontact.MediaKindPhoto), - "uri": uri, - "mediaType": mime, - "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }), - "label": label, - }, jscontact.Media{ - Type: jscontact.MediaType, - Kind: jscontact.MediaKindPhoto, - Uri: uri, - MediaType: mime, - Contexts: contexts, - Label: label, - }, nil + return jscontact.Media{ + Type: jscontact.MediaType, + Kind: jscontact.MediaKindPhoto, + Uri: uri, + MediaType: mime, + Contexts: contexts, + Label: label, + }, nil case 1: boxes.mediaWithExternalUri = true // use external uri uri := externalImageUri() contexts := toBoolMapS(jscontact.MediaContextWork) - return map[string]any{ - "@type": "Media", - "kind": string(jscontact.MediaKindPhoto), - "uri": uri, - "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }), - "label": label, - }, jscontact.Media{ - Type: jscontact.MediaType, - Kind: jscontact.MediaKindPhoto, - Uri: uri, - Contexts: contexts, - Label: label, - }, nil + return jscontact.Media{ + Type: jscontact.MediaType, + Kind: jscontact.MediaKindPhoto, + Uri: uri, + Contexts: contexts, + Label: label, + }, nil default: boxes.mediaWithBlobId = true @@ -593,56 +559,41 @@ func (s *StalwartTest) fillContacts( //NOSONAR img := gofakeit.ImageJpeg(size, size) blob, err := c.uploadBlob(accountId, img, "image/jpeg") if err != nil { - return nil, jscontact.Media{}, err + return jscontact.Media{}, err } contexts := toBoolMapS(jscontact.MediaContextPrivate) - return map[string]any{ - "@type": "Media", - "kind": string(jscontact.MediaKindPhoto), - "blobId": blob.BlobId, - "contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }), - "label": label, - }, jscontact.Media{ - Type: jscontact.MediaType, - Kind: jscontact.MediaKindPhoto, - BlobId: blob.BlobId, - MediaType: blob.Type, - Contexts: contexts, - Label: label, - }, nil + return jscontact.Media{ + Type: jscontact.MediaType, + Kind: jscontact.MediaKindPhoto, + BlobId: blob.BlobId, + MediaType: blob.Type, + Contexts: contexts, + Label: label, + }, nil } }); err != nil { return "", "", nil, boxes, err } - if err := propmap(i%2 == 0, 1, 1, contact, "links", &card.Links, func(i int, id string) (map[string]any, jscontact.Link, error) { + if err := propmap(i%2 == 0, 1, 1, &card.Links, func(i int, id string) (jscontact.Link, error) { boxes.link = true - return map[string]any{ - "@type": "Link", - "kind": "contact", - "uri": "mailto:" + person.Contact.Email, - "pref": (i + 1) * 10, - }, jscontact.Link{ - Type: jscontact.LinkType, - Kind: jscontact.LinkKindContact, - Uri: "mailto:" + person.Contact.Email, - Pref: uint((i + 1) * 10), - }, nil + return jscontact.Link{ + Type: jscontact.LinkType, + Kind: jscontact.LinkKindContact, + Uri: "mailto:" + person.Contact.Email, + Pref: uint((i + 1) * 10), + }, nil }); err != nil { return "", "", nil, boxes, err } - id, err := s.CreateContact(c, accountId, contact) + created, _, _, _, err := s.client.CreateContactCard(accountId, card, ctx) if err != nil { - return "", "", nil, boxes, err + return accountId, addressbookId, filled, boxes, err } - card.Id = id - filled[id] = card - printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, id)) + require.NotNil(created) + filled[created.Id] = *created + printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, created.Id)) } return accountId, addressbookId, filled, boxes, nil } - -func (s *StalwartTest) CreateContact(j *TestJmapClient, accountId string, contact map[string]any) (string, error) { - return j.create1(accountId, ContactCardType, contact) -} diff --git a/pkg/jmap/integration_test.go b/pkg/jmap/integration_test.go index f277b44f91..2bc0b2b5aa 100644 --- a/pkg/jmap/integration_test.go +++ b/pkg/jmap/integration_test.go @@ -707,48 +707,6 @@ func (c Commander[T]) command(body map[string]any) (T, error) { return c.closure(methodResponses) } -func (j *TestJmapClient) create(id string, objectType ObjectType, body map[string]any) (string, error) { - return newCommander(j, func(methodResponses []any) (string, error) { - z := methodResponses[0].([]any) - f := z[1].(map[string]any) - if x, ok := f["created"]; ok { - created := x.(map[string]any) - if c, ok := created[id].(map[string]any); ok { - return c["id"].(string), nil - } else { - return "", fmt.Errorf("failed to create %v", objectType) - } - } else { - if ncx, ok := f["notCreated"]; ok { - nc := ncx.(map[string]any) - c := nc[id].(map[string]any) - return "", fmt.Errorf("failed to create %v: %v", objectType, c["description"]) - } else { - return "", fmt.Errorf("failed to create %v", objectType) - } - } - }).command(body) -} - -func (j *TestJmapClient) create1(accountId string, objectType ObjectType, obj map[string]any) (string, error) { - body := map[string]any{ - "using": structs.Map(objectType.Namespaces, func(n JmapNamespace) string { return string(n) }), - "methodCalls": []any{ - []any{ - objectType.Name + "/set", - map[string]any{ - "accountId": accountId, - "create": map[string]any{ - "c": obj, - }, - }, - "0", - }, - }, - } - return j.create("c", objectType, body) -} - func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType) (map[string]map[string]any, error) { m := map[string]map[string]any{} { @@ -785,91 +743,60 @@ func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType) (m return m, nil } -func createName(person *gofakeit.PersonInfo) (map[string]any, jscontact.Name) { - o := jscontact.Name{ +func createName(person *gofakeit.PersonInfo) jscontact.Name { + name := jscontact.Name{ Type: jscontact.NameType, } - m := map[string]any{ - "@type": "Name", - } - mComps := make([]map[string]string, 2) - oComps := make([]jscontact.NameComponent, 2) - mComps[0] = map[string]string{ - "kind": "given", - "value": person.FirstName, - } - oComps[0] = jscontact.NameComponent{ + comps := make([]jscontact.NameComponent, 2) + comps[0] = jscontact.NameComponent{ Type: jscontact.NameComponentType, Kind: jscontact.NameComponentKindGiven, Value: person.FirstName, } - mComps[1] = map[string]string{ - "kind": "surname", - "value": person.LastName, - } - oComps[1] = jscontact.NameComponent{ + comps[1] = jscontact.NameComponent{ Type: jscontact.NameComponentType, Kind: jscontact.NameComponentKindSurname, Value: person.LastName, } - m["components"] = mComps - o.Components = oComps - m["isOrdered"] = true - o.IsOrdered = true - m["defaultSeparator"] = " " - o.DefaultSeparator = " " + name.Components = comps + name.IsOrdered = true + name.DefaultSeparator = " " full := fmt.Sprintf("%s %s", person.FirstName, person.LastName) - m["full"] = full - o.Full = full - return m, o + name.Full = full + return name } -func createNickName(_ *gofakeit.PersonInfo) (map[string]any, jscontact.Nickname) { +func createNickName(_ *gofakeit.PersonInfo) jscontact.Nickname { name := gofakeit.PetName() contexts := pickRandoms(jscontact.NicknameContextPrivate, jscontact.NicknameContextWork) - return map[string]any{ - "@type": "Nickname", - "name": name, - "contexts": toBoolMap(structs.Map(contexts, func(s jscontact.NicknameContext) string { return string(s) })), - }, jscontact.Nickname{ - Type: jscontact.NicknameType, - Name: name, - Contexts: orNilMap(toBoolMap(contexts)), - } + return jscontact.Nickname{ + Type: jscontact.NicknameType, + Name: name, + Contexts: orNilMap(toBoolMap(contexts)), + } } -func createEmail(person *gofakeit.PersonInfo, pref int) (map[string]any, jscontact.EmailAddress) { +func createEmail(person *gofakeit.PersonInfo, pref int) jscontact.EmailAddress { email := person.Contact.Email contexts := pickRandoms1(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate) label := strings.ToLower(person.FirstName) - return map[string]any{ - "@type": "EmailAddress", - "address": email, - "contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })), - "label": label, - "pref": pref, - }, jscontact.EmailAddress{ - Type: jscontact.EmailAddressType, - Address: email, - Contexts: orNilMap(toBoolMap(contexts)), - Label: label, - Pref: uint(pref), - } + return jscontact.EmailAddress{ + Type: jscontact.EmailAddressType, + Address: email, + Contexts: orNilMap(toBoolMap(contexts)), + Label: label, + Pref: uint(pref), + } } -func createSecondaryEmail(email string, pref int) (map[string]any, jscontact.EmailAddress) { +func createSecondaryEmail(email string, pref int) jscontact.EmailAddress { contexts := pickRandoms(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate) - return map[string]any{ - "@type": "EmailAddress", - "address": email, - "contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })), - "pref": pref, - }, jscontact.EmailAddress{ - Type: jscontact.EmailAddressType, - Address: email, - Contexts: orNilMap(toBoolMap(contexts)), - Pref: uint(pref), - } + return jscontact.EmailAddress{ + Type: jscontact.EmailAddressType, + Address: email, + Contexts: orNilMap(toBoolMap(contexts)), + Pref: uint(pref), + } } var idFirstLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") @@ -1087,27 +1014,22 @@ var extendedColors = []string{ } */ -func propmap[T any](enabled bool, min int, max int, container map[string]any, name string, cardProperty *map[string]T, generator func(int, string) (map[string]any, T, error)) error { +func propmap[T any](enabled bool, min int, max int, cardProperty *map[string]T, generator func(int, string) (T, error)) error { if !enabled { return nil } n := min + rand.Intn(max-min+1) - m := make(map[string]map[string]any, n) o := make(map[string]T, n) for i := range n { id := id() - itemForMap, itemForCard, err := generator(i, id) + itemForCard, err := generator(i, id) if err != nil { return err } - if itemForMap != nil { - m[id] = itemForMap - o[id] = itemForCard - } + o[id] = itemForCard } - if len(m) > 0 { - container[name] = m + if len(o) > 0 { *cardProperty = o } return nil @@ -1255,19 +1177,18 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change]( principalIds = structs.Map(principals.List, func(p Principal) string { return p.Id }) } - ss := SessionState("") + ss := EmptySessionState as := EmptyState // we need to fetch the ID of the default object that automatically exists for each user, in order to exclude it // from the tests below - defaultContainerId := "" + preExistingIds := []string{} { resp, sessionState, state, _, err := get(s, accountId, []string{}, ctx) require.NoError(err) require.Empty(resp.GetNotFound()) objs := obj(resp) - require.Len(objs, 1) // the personal calendar that exists by default - defaultContainerId = id(objs[0]) + preExistingIds = structs.Map(objs, id) ss = sessionState as = state } @@ -1287,8 +1208,8 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change]( require.NoError(err) require.Empty(resp.GetNotFound()) objs := obj(resp) - // lets skip the default object since we did not create that one - found := structs.Filter(objs, func(a OBJ) bool { return id(a) != defaultContainerId }) + // lets skip the objects that already exist since we did not create those + found := structs.Filter(objs, func(a OBJ) bool { return !slices.Contains(preExistingIds, id(a)) }) require.Len(found, int(num)) m := structs.Index(found, id) require.Len(m, int(num)) @@ -1320,7 +1241,25 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change]( require.Equal(objs[0], a) } - // lets modify each AddressBook + // let's retrieve them all by their IDs, but this time all at once + { + ids := structs.Map(all, id) + resp, sessionState, state, _, err := get(s, accountId, ids, ctx) + require.NoError(err) + require.Empty(resp.GetNotFound()) + objs := obj(resp) + require.Len(objs, len(all)) + require.Equal(sessionState, ss) + require.Equal(state, as) + allById := structs.Index(all, id) + for _, r := range resp.GetList() { + a, ok := allById[r.GetId()] + require.True(ok, "failed to find object that was retrieved in mass ID request in the list of objects that were created") + require.Equal(a, r) + } + } + + // lets modify each object for _, a := range all { i := id(a) ch := change(a) diff --git a/pkg/jmap/model.go b/pkg/jmap/model.go index 2c70155fef..075b08ee0a 100644 --- a/pkg/jmap/model.go +++ b/pkg/jmap/model.go @@ -1,6 +1,7 @@ package jmap import ( + "encoding/json" "io" "time" @@ -930,6 +931,8 @@ type SessionPrimaryAccounts struct { type SessionState string +const EmptySessionState = SessionState("") + type State string const EmptyState = State("") @@ -1258,6 +1261,19 @@ type Idable interface { // ``` type PatchObject map[string]any +func toPatchObject[T any](value T) (PatchObject, error) { + b, err := json.Marshal(value) + if err != nil { + return PatchObject{}, err + } + var target PatchObject + err = json.Unmarshal(b, &target) + if err != nil { + return PatchObject{}, err + } + return target, nil +} + // Reference to Previous Method Results // // To allow clients to make more efficient use of the network and avoid round trips, an argument to one method @@ -1336,7 +1352,7 @@ type SetResponse[T Foo] interface { } type Change interface { - AsPatch() PatchObject + AsPatch() (PatchObject, error) } type ChangesCommand[T Foo] interface { @@ -1659,24 +1675,8 @@ type MailboxChange struct { var _ Change = MailboxChange{} -func (m MailboxChange) AsPatch() PatchObject { - p := PatchObject{} - if m.Name != "" { - p["name"] = m.Name - } - if m.ParentId != "" { - p["parentId"] = m.ParentId - } - if m.Role != "" { - p["role"] = m.Role - } - if m.SortOrder != nil { - p["sortOrder"] = m.SortOrder - } - if m.IsSubscribed != nil { - p["isSubscribed"] = m.IsSubscribed - } - return p +func (m MailboxChange) AsPatch() (PatchObject, error) { + return toPatchObject(m) } type MailboxGetCommand struct { @@ -3953,27 +3953,8 @@ type IdentityChange struct { var _ Change = IdentityChange{} -func (i IdentityChange) AsPatch() PatchObject { - p := PatchObject{} - if i.Name != "" { - p["name"] = i.Name - } - if i.Email != "" { - p["email"] = i.Email - } - if i.ReplyTo != "" { - p["replyTo"] = i.ReplyTo - } - if i.Bcc != nil { - p["bcc"] = i.Bcc - } - if i.TextSignature != nil { - p["textSignature"] = i.TextSignature - } - if i.HtmlSignature != nil { - p["htmlSignature"] = i.HtmlSignature - } - return p +func (i IdentityChange) AsPatch() (PatchObject, error) { + return toPatchObject(i) } type IdentityGetResponse struct { @@ -4794,6 +4775,222 @@ var ContactCardProperties = []string{ ContactCardPropertyPersonalInfo, } +type ContactCardChange struct { + // The set of AddressBook ids this Card belongs to. + // + // A card MUST belong to at least one AddressBook at all times (until it is destroyed). + // + // The set is represented as an object, with each key being an AddressBook id. + // + // The value for each key in the object MUST be true. + // + // This is a JMAP extension and not part of [RFC9553]. + AddressBookIds map[string]bool `json:"addressBookIds,omitempty"` + + // The JSContact type of the Card object: the value MUST be "Card". + Type jscontact.TypeOfContactCard `json:"@type,omitempty"` + + // The JSContact version of this Card. + // + // The value MUST be one of the IANA-registered JSContact Version values for the version property. + Version *jscontact.JSContactVersion `json:"version,omitzero"` + + // The kind of the entity the Card represents (default: `individual`). + // + // Values are: + // * `individual`: a single person + // * `group`: a group of people or entities + // * `org`: an organization + // * `location`: a named location + // * `device`: a device such as an appliance, a computer, or a network element + // * `application`: a software application + Kind *jscontact.ContactCardKind `json:"kind,omitempty"` + + // The language tag, as defined in [RFC5646]. + // + // The language tag that best describes the language used for text in the Card, optionally including + // additional information such as the script. + // + // Note that values MAY be localized in the `localizations` property. + Language *string `json:"language,omitempty"` + + // The set of Cards that are members of this group Card. + // + // Each key in the set is the uid property value of the member, and each boolean value MUST be `true`. + // If this property is set, then the value of the kind property MUST be `group`. + // + // The opposite is not true. A group Card will usually contain the members property to specify the members + // of the group, but it is not required to. + // + // A group Card without the members property can be considered an abstract grouping or one whose members + // are known empirically (e.g., `IETF Participants`). + Members map[string]bool `json:"members,omitempty"` + + // The identifier for the product that created the Card. + // + // If set, the value MUST be at least one character long. + ProdId *string `json:"prodId,omitempty"` + + // The set of Card objects that relate to the Card. + // + // The value is a map, where each key is the uid property value of the related Card, and the value + // defines the relation + // + // ```json + // { + // "relatedTo": { + // "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": { + // "relation": {"friend": true} + // }, + // "8cacdfb7d1ffdb59@example.com": { + // "relation": {} + // } + // } + // } + // ``` + RelatedTo map[string]jscontact.Relation `json:"relatedTo,omitempty"` + + // The date and time when the data in the Card was last modified (UTCDateTime). + Updated *time.Time `json:"updated,omitzero"` + + // The name of the entity represented by the Card. + // + // This can be any type of name, e.g., it can, but need not, be the legal name of a person. + Name *jscontact.Name `json:"name,omitempty"` + + // The nicknames of the entity represented by the Card. + Nicknames map[string]jscontact.Nickname `json:"nicknames,omitempty"` + + // The company or organization names and units associated with the Card. + Organizations map[string]jscontact.Organization `json:"organizations,omitempty"` + + // The information that directs how to address, speak to, or refer to the entity that is represented by the Card. + SpeakToAs *jscontact.SpeakToAs `json:"speakToAs,omitempty"` + + // The job titles or functional positions of the entity represented by the Card. + Titles map[string]jscontact.Title `json:"titles,omitempty"` + + // The email addresses in which to contact the entity represented by the Card. + Emails map[string]jscontact.EmailAddress `json:"emails,omitempty"` + + // The online services that are associated with the entity represented by the Card. + // + // This can be messaging services, social media profiles, and other. + OnlineServices map[string]jscontact.OnlineService `json:"onlineServices,omitempty"` + + // The phone numbers by which to contact the entity represented by the Card. + Phones map[string]jscontact.Phone `json:"phones,omitempty"` + + // The preferred languages for contacting the entity associated with the Card. + PreferredLanguages map[string]jscontact.LanguagePref `json:"preferredLanguages,omitempty"` + + // The calendaring resources of the entity represented by the Card, such as to look up free-busy information. + // + // A Calendar object has all properties of the Resource data type, with the following additional definitions: + // * The `@type` property value MUST be `Calendar`, if set + // * The `kind` property is mandatory. Its enumerated values are: + // * `calendar`: The resource is a calendar that contains entries such as calendar events or tasks + // * `freeBusy`: The resource allows for free-busy lookups, for example, to schedule group events + Calendars map[string]jscontact.Calendar `json:"calendars,omitempty"` + + // The scheduling addresses by which the entity may receive calendar scheduling invitations. + SchedulingAddresses map[string]jscontact.SchedulingAddress `json:"schedulingAddresses,omitempty"` + + // The addresses of the entity represented by the Card, such as postal addresses or geographic locations. + Addresses map[string]jscontact.Address `json:"addresses,omitempty"` + + // The cryptographic resources such as public keys and certificates associated with the entity represented by the Card. + // + // A CryptoKey object has all properties of the `Resource` data type, with the following additional definition: + // the `@type` property value MUST be `CryptoKey`, if set. + // + // The following example shows how to refer to an external cryptographic resource: + // ```json + // "cryptoKeys": { + // "mykey1": { + // "uri": "https://www.example.com/keys/jdoe.cer" + // } + // } + // ``` + CryptoKeys map[string]jscontact.CryptoKey `json:"cryptoKeys,omitempty"` + + // The directories containing information about the entity represented by the Card. + // + // A Directory object has all properties of the `Resource` data type, with the following additional definitions: + // * The `@type` property value MUST be `Directory`, if set + // * The `kind` property is mandatory; tts enumerated values are: + // * `directory`: the resource is a directory service that the entity represented by the Card is a part of; this + // typically is an organizational directory that also contains associated entities, e.g., co-workers and management + // in a company directory + // * `entry`: the resource is a directory entry of the entity represented by the Card; in contrast to the `directory` + // type, this is the specific URI for the entity within a directory + Directories map[string]jscontact.Directory `json:"directories,omitempty"` + + // The links to resources that do not fit any of the other use-case-specific resource properties. + // + // A Link object has all properties of the `Resource` data type, with the following additional definitions: + // * The `@type` property value MUST be `Link`, if set + // * The `kind` property is optional; tts enumerated values are: + // * `contact`: the resource is a URI by which the entity represented by the Card may be contacted; + // this includes web forms or other media that require user interaction + Links map[string]jscontact.Link `json:"links,omitempty"` + + // The media resources such as photographs, avatars, or sounds that are associated with the entity represented by the Card. + // + // A Media object has all properties of the Resource data type, with the following additional definitions: + // * the `@type` property value MUST be `Media`, if set + // * the `kind` property is mandatory; its enumerated values are: + // * `photo`: the resource is a photograph or avatar + // * `sound`: the resource is audio media, e.g., to specify the proper pronunciation of the name property contents + // * `logo`: the resource is a graphic image or logo associated with the entity represented by the Card + Media map[string]jscontact.Media `json:"media,omitempty"` + + // The property values localized to languages other than the main `language` of the Card. + // + // Localizations provide language-specific alternatives for existing property values and SHOULD NOT add new properties. + // + // The keys in the localizations property value are language tags [RFC5646]; the values are of type `PatchObject` and + // localize the Card in that language tag. + // + // The paths in the `PatchObject` are relative to the Card that includes the localizations property. + // + // A patch MUST NOT target the localizations property. + // + // Conceptually, a Card is localized as follows: + // * Determine the language tag in which the Card should be localized. + // * If the localizations property includes a key for that language, obtain the PatchObject value; + // if there is no such key, stop. + // * Create a copy of the Card, but do not copy the localizations property. + // * Apply all patches in the PatchObject to the copy of the Card. + // * Optionally, set the language property in the copy of the Card. + // * Use the patched copy of the Card as the localized variant of the original Card. + // + // A patch in the `PatchObject` may contain any value type. + // + // Its value MUST be a valid value according to the definition of the patched property. + Localizations map[string]jscontact.PatchObject `json:"localizations,omitempty"` + + // The memorable dates and events for the entity represented by the Card. + Anniversaries map[string]jscontact.Anniversary `json:"anniversaries,omitempty"` + + // The set of free-text keywords, also known as tags. + // + // Each key in the set is a keyword, and each boolean value MUST be `true`. + Keywords map[string]bool `json:"keywords,omitempty"` + + // The free-text notes that are associated with the Card. + Notes map[string]jscontact.Note `json:"notes,omitempty"` + + // The personal information of the entity represented by the Card. + PersonalInfo map[string]jscontact.PersonalInfo `json:"personalInfo,omitempty"` +} + +var _ Change = ContactCardChange{} + +func (e ContactCardChange) AsPatch() (PatchObject, error) { + return toPatchObject(e) +} + type CalendarRights struct { // The user may read the free-busy information for this calendar. MayReadFreeBusy bool `json:"mayReadFreeBusy"` @@ -5016,11 +5213,11 @@ type CalendarChange struct { // The user-visible name of the calendar. // // This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size. - Name *string `json:"name"` + Name *string `json:"name,omitzero"` // An optional longer-form description of the calendar, to provide context in shared environments // where users need more than just the name. - Description *string `json:"description,omitempty"` + Description *string `json:"description,omitzero"` // A color to be used when displaying events associated with the calendar. // @@ -5029,7 +5226,7 @@ type CalendarChange struct { // notation, as defined in Section 4.2.1 of CSS Color Module Level 3. // // The color SHOULD have sufficient contrast to be used as text on a white background. - Color *string `json:"color,omitempty"` + Color *string `json:"color,omitzero"` // Defines the sort order of calendars when presented in the client’s UI, so it is consistent // between devices. @@ -5055,7 +5252,7 @@ type CalendarChange struct { // For example, a company may have a large number of shared calendars which all employees have // permission to access, but you would only subscribe to the ones you care about and want to be able // to have normally accessible. - IsSubscribed *bool `json:"isSubscribed"` + IsSubscribed *bool `json:"isSubscribed,omitempty"` // Should the calendar’s events be displayed to the user at the moment? // @@ -5063,7 +5260,9 @@ type CalendarChange struct { // // If an event is in multiple calendars, it should be displayed if `isVisible` is `true` // for any of those calendars. - IsVisible *bool `json:"isVisible" default:"true" doc:"opt"` + // + // Currently unsupported in Stalwart when modifying existing objects. + IsVisible *bool `json:"isVisible,omitempty" default:"true" doc:"opt"` // Should the calendar’s events be used as part of availability calculation? // @@ -5138,35 +5337,8 @@ type CalendarChange struct { var _ Change = CalendarChange{} -func (a CalendarChange) AsPatch() PatchObject { - p := PatchObject{} - if a.Name != nil { - p["name"] = *a.Name - } - if a.Description != nil { - p["description"] = *a.Description - } - if a.Color != nil { - p["color"] = *a.Color - } - if a.SortOrder != nil { - p["sortOrder"] = *a.SortOrder - } - if a.IsSubscribed != nil { - p["isSubscribed"] = *a.IsSubscribed - } - if a.IsVisible != nil { - p["isVisible"] = *a.IsVisible - } - if a.IncludeInAvailability != nil { - p["includeInAvailability"] = *a.IncludeInAvailability - } - // TODO DefaultAlertsWithTime - // TODO DefaultAlertsWithoutTime - // TODO TimeZone - // TODO ShareWith - // TODO MyRights - return p +func (c CalendarChange) AsPatch() (PatchObject, error) { + return toPatchObject(c) } // A CalendarEvent object contains information about an event, or recurring series of events, @@ -5252,6 +5424,79 @@ type CalendarEvent struct { jscalendar.Event } +type CalendarEventChange struct { + // The set of Calendar ids this event belongs to. + // + // An event MUST belong to one or more Calendars at all times (until it is destroyed). + // + // The set is represented as an object, with each key being a Calendar id. + // + // The value for each key in the object MUST be `true`. + CalendarIds map[string]bool `json:"calendarIds,omitempty"` + + // If true, this event is to be considered a draft. + // + // The server will not send any scheduling messages to participants or send push notifications + // for alerts. + // + // This may only be set to `true` upon creation. + // + // Once set to `false`, the value cannot be updated to `true`. + // + // This property MUST NOT appear in `recurrenceOverrides`. + IsDraft *bool `json:"isDraft,omitzero"` + + // Is this the authoritative source for this event (i.e., does it control scheduling for + // this event; the event has not been added as a result of an invitation from another calendar system)? + // + // This is true if, and only if: + // * the event’s `replyTo` property is null; or + // * the account will receive messages sent to at least one of the methods specified in the `replyTo` property of the event. + IsOrigin *bool `json:"isOrigin,omitzero"` + + // For simple clients that do not implement time zone support. + // + // Clients should only use this if also asking the server to expand recurrences, as you cannot accurately + // expand a recurrence without the original time zone. + // + // This property is calculated at fetch time by the server. + // + // Time zones are political and they can and do change at any time. + // + // Fetching exactly the same property again may return a different results if the time zone data has been updated on the server. + // + // Time zone data changes are not considered `updates` to the event. + // + // If set, the server will convert the UTC date to the event's current time zone and store the local time. + // + // This property is not included in `CalendarEvent/get` responses by default and must be requested explicitly. + // + // Floating events (events without a time zone) will be interpreted as per the time zone given as a `CalendarEvent/get` argument. + // + // Note that it is not possible to accurately calculate the expansion of recurrence rules or recurrence overrides with the + // `utcStart` property rather than the local start time. Even simple recurrences such as "repeat weekly" may cross a + // daylight-savings boundary and end up at a different UTC time. Clients that wish to use "utcStart" are RECOMMENDED to + // request the server expand recurrences. + UtcStart UTCDate `json:"utcStart,omitzero"` + + // The server calculates the end time in UTC from the start/timeZone/duration properties of the event. + // + // This property is not included by default and must be requested explicitly. + // + // Like `utcStart`, it is calculated at fetch time if requested and may change due to time zone data changes. + // + // Floating events will be interpreted as per the time zone given as a `CalendarEvent/get` argument. + UtcEnd UTCDate `json:"utcEnd,omitzero"` + + jscalendar.EventChange +} + +var _ Change = CalendarEventChange{} + +func (e CalendarEventChange) AsPatch() (PatchObject, error) { + return toPatchObject(e) +} + var _ Idable = &CalendarEvent{} func (f CalendarEvent) GetObjectType() ObjectType { return CalendarEventType } @@ -6356,7 +6601,7 @@ type AddressBookChange struct { // The user-visible name of the AddressBook. // // This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size. - Name *string `json:"name"` + Name *string `json:"name,omitempty"` // An optional longer-form description of the AddressBook, to provide context in shared environments // where users need more than just the name. @@ -6381,7 +6626,7 @@ type AddressBookChange struct { // // If false, the AddressBook and its contents SHOULD only be displayed when the user explicitly requests it // or to offer it for the user to subscribe to. - IsSubscribed *bool `json:"isSubscribed"` + IsSubscribed *bool `json:"isSubscribed,omitzero"` // A map of Principal id to rights for principals this AddressBook is shared with. // @@ -6398,21 +6643,8 @@ type AddressBookChange struct { var _ Change = AddressBookChange{} -func (a AddressBookChange) AsPatch() PatchObject { - p := PatchObject{} - if a.Name != nil { - p["name"] = *a.Name - } - if a.Description != nil { - p["description"] = *a.Description - } - if a.IsSubscribed != nil { - p["isSubscribed"] = *a.IsSubscribed - } - if a.ShareWith != nil { - p["shareWith"] = a.ShareWith - } - return p +func (a AddressBookChange) AsPatch() (PatchObject, error) { + return toPatchObject(a) } type AddressBookSetCommand struct { @@ -6842,12 +7074,12 @@ type ContactCardQueryResponse struct { // Only if requested. // // This argument MUST be omitted if the calculateTotal request argument is not true. - Total uint `json:"total,omitempty,omitzero"` + Total *uint `json:"total,omitempty"` // The limit enforced by the server on the maximum number of results to return (if set by the server). // // This is only returned if the server set a limit or used a different limit than that given in the request. - Limit uint `json:"limit,omitempty,omitzero"` + Limit uint `json:"limit,omitzero"` } var _ QueryResponse[ContactCard] = &ContactCardQueryResponse{} @@ -6999,8 +7231,6 @@ func (r ContactCardChangesResponse) GetUpdated() []string { return r.Updated func (r ContactCardChangesResponse) GetDestroyed() []string { return r.Destroyed } func (r ContactCardChangesResponse) GetMarker() ContactCard { return ContactCard{} } -type ContactCardUpdate map[string]any - type ContactCardSetCommand struct { // The id of the account to use. AccountId string `json:"accountId"` @@ -7048,7 +7278,7 @@ type ContactCardSetCommand struct { // // The client may choose to optimise network usage by just sending the diff or may send the whole object; the server // processes it the same either way. - Update map[string]ContactCardUpdate `json:"update,omitempty"` + Update map[string]PatchObject `json:"update,omitempty"` // A list of ids for ContactCard objects to permanently delete, or null if no objects are to be destroyed. Destroy []string `json:"destroy,omitempty"` @@ -7572,12 +7802,12 @@ type CalendarEventQueryResponse struct { // Only if requested. // // This argument MUST be omitted if the calculateTotal request argument is not true. - Total uint `json:"total,omitempty,omitzero"` + Total *uint `json:"total,omitempty"` // The limit enforced by the server on the maximum number of results to return (if set by the server). // // This is only returned if the server set a limit or used a different limit than that given in the request. - Limit uint `json:"limit,omitempty,omitzero"` + Limit uint `json:"limit,omitzero"` } var _ QueryResponse[CalendarEvent] = &CalendarEventQueryResponse{} @@ -7735,8 +7965,6 @@ func (r CalendarEventChangesResponse) GetUpdated() []string { return r.Updat func (r CalendarEventChangesResponse) GetDestroyed() []string { return r.Destroyed } func (r CalendarEventChangesResponse) GetMarker() CalendarEvent { return CalendarEvent{} } -type CalendarEventUpdate map[string]any - type CalendarEventSetCommand struct { // The id of the account to use. AccountId string `json:"accountId"` @@ -7784,7 +8012,7 @@ type CalendarEventSetCommand struct { // // The client may choose to optimise network usage by just sending the diff or may send the whole object; the server // processes it the same either way. - Update map[string]CalendarEventUpdate `json:"update,omitempty"` + Update map[string]PatchObject `json:"update,omitempty"` // A list of ids for CalendarEvent objects to permanently delete, or null if no objects are to be destroyed. Destroy []string `json:"destroy,omitempty"` diff --git a/pkg/jmap/templates.go b/pkg/jmap/templates.go index 421d249a1e..5d97a23b31 100644 --- a/pkg/jmap/templates.go +++ b/pkg/jmap/templates.go @@ -398,10 +398,18 @@ func update[T Foo, CHANGES Change, SET SetCommand[T], GET GetCommand[T], RESP an logger := client.logger(name, ctx) ctx = ctx.WithLogger(logger) - update := setCommandFactory(map[string]PatchObject{id: changes.AsPatch()}) + var zero RESP + + var update SET + { + patch, err := changes.AsPatch() + if err != nil { + return zero, "", "", "", jmapError(err, JmapPatchObjectSerialization) + } + update = setCommandFactory(map[string]PatchObject{id: patch}) + } get := getCommandFactory(id) cmd, err := client.request(ctx, objType.Namespaces, invocation(update, "0"), invocation(get, "1")) - var zero RESP if err != nil { return zero, "", "", "", err } diff --git a/pkg/jmap/tools.go b/pkg/jmap/tools.go index bf17907daa..06ac08a701 100644 --- a/pkg/jmap/tools.go +++ b/pkg/jmap/tools.go @@ -423,6 +423,14 @@ func uintPtrIf(i uint, condition bool) *uint { } } +func uintPtrIfPtr(i *uint, condition bool) *uint { + if condition { + return i + } else { + return nil + } +} + func ns(namespaces ...JmapNamespace) []JmapNamespace { result := make([]JmapNamespace, len(namespaces)+1) result[0] = JmapCore diff --git a/pkg/jscalendar/model.go b/pkg/jscalendar/model.go index 3cb12bde3a..e27d703062 100644 --- a/pkg/jscalendar/model.go +++ b/pkg/jscalendar/model.go @@ -790,6 +790,19 @@ func (t *LocalDateTime) UnmarshalJSON(b []byte) error { // automatically still inherit this. type PatchObject map[string]any +func toPatchObject[T any](value T) (PatchObject, error) { + b, err := json.Marshal(value) + if err != nil { + return PatchObject{}, err + } + var target PatchObject + err = json.Unmarshal(b, &target) + if err != nil { + return PatchObject{}, err + } + return target, nil +} + // A Relation object defines the relation to other objects, using a possibly empty set of relation types. // // The object that defines this relation is the linking object, while the other object is the linked @@ -1740,6 +1753,25 @@ type CommonObject struct { Color string `json:"color,omitempty"` } +type CommonObjectChange struct { + Uid *string `json:"uid,omitempty"` + ProdId *string `json:"prodId,omitempty"` + Created UTCDateTime `json:"created,omitzero"` + Updated UTCDateTime `json:"updated,omitzero"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + DescriptionContentType *string `json:"descriptionContentType,omitempty" doc:"opt" default:"text/plain"` + Links map[string]Link `json:"links,omitempty"` + Locale *string `json:"locale,omitempty"` + Keywords map[string]bool `json:"keywords,omitempty"` + Categories map[string]bool `json:"categories,omitempty"` + Color *string `json:"color,omitempty"` +} + +func (m CommonObjectChange) AsPatch() (PatchObject, error) { + return toPatchObject(m) +} + // ### Recurrence Properties // // Some events and tasks occur at regular or irregular intervals. Rather than having to copy the data for every occurrence, @@ -2100,6 +2132,40 @@ type Object struct { HideAttendees bool `json:"hideAttendees,omitzero" doc:"opt" default:"false"` } +type ObjectChange struct { + CommonObjectChange + RelatedTo map[string]Relation `json:"relatedTo,omitempty"` + Sequence *uint `json:"sequence,omitzero"` + ShowWithoutTime *bool `json:"showWithoutTime,omitzero" doc:"opt" default:"false"` + Locations map[string]Location `json:"locations,omitempty"` + MainLocationId *string `json:"mainLocationId,omitempty"` + VirtualLocations map[string]VirtualLocation `json:"virtualLocations,omitempty"` + RecurrenceId *LocalDateTime `json:"recurrenceId,omitempty"` + RecurrenceIdTimeZone string `json:"recurrenceIdTimeZone,omitempty"` + RecurrenceRule *RecurrenceRule `json:"recurrenceRule,omitempty"` + ExcludedRecurrenceRules []RecurrenceRule `json:"excludedRecurrenceRules,omitempty"` + RecurrenceOverrides map[LocalDateTime]PatchObject `json:"recurrenceOverrides,omitempty"` + Excluded *bool `json:"excluded,omitzero"` + Priority *int `json:"priority,omitzero"` + FreeBusyStatus *FreeBusyStatus `json:"freeBusyStatus,omitempty" doc:"opt" default:"busy"` + Privacy *Privacy `json:"privacy,omitempty"` + ReplyTo map[ReplyMethod]string `json:"replyTo,omitempty"` + SentBy string `json:"sentBy,omitempty"` + Participants map[string]Participant `json:"participants,omitempty"` + RequestStatus string `json:"requestStatus,omitempty"` + UseDefaultAlerts *bool `json:"useDefaultAlerts,omitzero" doc:"opt" default:"false"` + Alerts map[string]Alert `json:"alerts,omitempty"` + Localizations map[string]PatchObject `json:"localizations,omitempty"` + TimeZone *string `json:"timeZone,omitempty"` + MayInviteSelf *bool `json:"mayInviteSelf,omitzero" doc:"opt" default:"false"` + MayInviteOthers *bool `json:"mayInviteOthers,omitzero" doc:"opt" default:"false"` + HideAttendees *bool `json:"hideAttendees,omitzero" doc:"opt" default:"false"` +} + +func (m ObjectChange) AsPatch() (PatchObject, error) { + return toPatchObject(m) +} + type Event struct { Type TypeOfEvent `json:"@type,omitempty"` @@ -2135,6 +2201,45 @@ type Event struct { Status Status `json:"status,omitempty"` } +type EventChange struct { + Type TypeOfEvent `json:"@type,omitempty"` + + ObjectChange + + // This is the date/time the event starts in the event's time zone (as specified in the timeZone property, see Section 4.7.1). + Start LocalDateTime `json:"start,omitempty"` + + // This is the zero or positive duration of the event in the event's start time zone. + // + // The end time of an event can be found by adding the duration to the event's start time. + // + // An Event MAY involve start and end locations that are in different time zones + // (e.g., a transcontinental flight). This can be expressed using the `relativeTo` and `timeZone` properties of + // the `Event`'s Location objects (see Section 4.2.5). + Duration *Duration `json:"duration,omitempty"` + + // This identifies the time zone in which this event ends, for cases where the start and time zones of the event differ + // (e.g., a transcontinental flight). + // + // If this property is not set, then the event starts and ends in the same time zone. + // + // This property MUST NOT be set if the timeZone property value is null or not set. + EndTimeZone string `json:"endTimeZone,omitempty"` + + // This is the scheduling status (Section 4.4) of an Event. + // + // If set, it MUST be one of the following values, another value registered in the IANA + // "JSCalendar Enum Values" registry, or a vendor-specific value (see Section 3.3): + // * `confirmed`: indicates the event is definitely happening + // * `cancelled`: indicates the event has been cancelled + // * `tentative`: indicates the event may happen + Status *Status `json:"status,omitempty"` +} + +func (e EventChange) AsPatch() (PatchObject, error) { + return toPatchObject(e) +} + type Task struct { Type TypeOfTask `json:"@type,omitempty"` diff --git a/pkg/jscalendar/model_test.go b/pkg/jscalendar/model_test.go index d21403dfe9..c9ad8151e8 100644 --- a/pkg/jscalendar/model_test.go +++ b/pkg/jscalendar/model_test.go @@ -714,3 +714,33 @@ func TestEvent(t *testing.T) { }, }) } + +func TestPatch(t *testing.T) { + require := require.New(t) + for _, tt := range []struct { + change ObjectChange + expected PatchObject + }{ + {ObjectChange{}, PatchObject{}}, + {ObjectChange{ + CommonObjectChange: CommonObjectChange{ + Uid: strPtr("e9787e0b-e824-4284-964e-6b5d77af4bc9"), + }, + }, PatchObject{ + "uid": "e9787e0b-e824-4284-964e-6b5d77af4bc9", + }}, + } { + b, err := json.Marshal(tt.expected) + require.NoError(err) + title := string(b) + t.Run(title, func(t *testing.T) { + patch, err := tt.change.AsPatch() + require.NoError(err) + require.Equal(tt.expected, patch) + }) + } +} + +func strPtr(s string) *string { + return &s +}