From deee89d6837e5d496c3f4b8c6e2b0f5cced1f1e0 Mon Sep 17 00:00:00 2001 From: Pascal Bleser
%v
", parts[i])) - } - return result -} - -var paraSplitter = regexp.MustCompile("[\r\n]+") var emailSplitter = regexp.MustCompile("(.+)@(.+)$") func htmlFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder { - return msg.HTML([]byte(strings.Join(htmlJoin(paraSplitter.Split(body, -1)), "\n"))) + return msg.HTML([]byte(toHtml(body))) } func textFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder { diff --git a/pkg/jmap/jmap_integration_event_test.go b/pkg/jmap/jmap_integration_event_test.go new file mode 100644 index 0000000000..c394ea10b3 --- /dev/null +++ b/pkg/jmap/jmap_integration_event_test.go @@ -0,0 +1,592 @@ +package jmap + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "math" + "math/rand" + "strconv" + "strings" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/require" + + "github.com/opencloud-eu/opencloud/pkg/jscalendar" + "github.com/opencloud-eu/opencloud/pkg/structs" +) + +func TestEvents(t *testing.T) { + if skip(t) { + return + } + + count := uint(20 + rand.Intn(30)) + + require := require.New(t) + + s, err := newStalwartTest(t) + require.NoError(err) + defer s.Close() + + accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count) + require.NoError(err) + require.NotEmpty(accountId) + require.NotEmpty(calendarId) + + allTrue(t, boxes) + + filter := CalendarEventFilterCondition{ + InCalendar: calendarId, + } + sortBy := []CalendarEventComparator{ + {Property: CalendarEventPropertyCreated, IsAscending: true}, + } + + contactsByAccount, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, s.session, t.Context(), s.logger, "", filter, sortBy, 0, 0) + require.NoError(err) + + require.Len(contactsByAccount, 1) + require.Contains(contactsByAccount, accountId) + contacts := contactsByAccount[accountId] + require.Len(contacts, int(count)) + + for _, actual := range contacts { + expected, ok := expectedEventsById[actual.Id] + require.True(ok, "failed to find created contact by its id") + matchEvent(t, actual, expected) + } +} + +func matchEvent(t *testing.T, actual CalendarEvent, expected CalendarEvent) { + require.Equal(t, expected, actual) +} + +type EventsBoxes struct { +} + +func (s *StalwartTest) fillEvents( + t *testing.T, + count uint, +) (string, string, map[string]CalendarEvent, EventsBoxes, error) { + require := require.New(t) + c, err := NewTestJmapClient(s.session, s.username, s.password, true, true) + require.NoError(err) + defer c.Close() + + boxes := EventsBoxes{} + + printer := func(s string) { log.Println(s) } + + accountId := c.session.PrimaryAccounts.Calendars + require.NotEmpty(accountId, "no primary account for calendars in session") + + calendarId := "" + { + calendarsById, err := c.objectsById(accountId, CalendarType, JmapCalendars) + require.NoError(err) + + for id, calendar := range calendarsById { + if isDefault, ok := calendar["isDefault"]; ok { + if isDefault.(bool) { + calendarId = id + break + } + } + } + } + require.NotEmpty(calendarId) + + u := true + + filled := map[string]CalendarEvent{} + for i := range count { + uid := gofakeit.UUID() + + isDraft := false + mainLocationId := "" + locationIds := []string{} + locationMaps := map[string]map[string]any{} + locationObjs := map[string]jscalendar.Location{} + { + n := 1 + if i%4 == 0 { + n++ + } + for range n { + locationId, locationMap, locationObj := pickLocation() + locationMaps[locationId] = locationMap + locationObjs[locationId] = untype(locationObj, u) + locationIds = append(locationIds, locationId) + if n > 0 && mainLocationId == "" { + mainLocationId = locationId + } + } + } + virtualLocationId, virtualLocationMap, virtualLocationObj := pickVirtualLocation() + participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId}, u) + duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M") + tz := pickRandom(timezones...) + daysDiff := rand.Intn(31) - 15 + t := time.Now().Add(time.Duration(daysDiff) * time.Hour * 24) + h := pickRandom(9, 10, 11, 14, 15, 16, 18) + m := pickRandom(0, 30) + t = time.Date(t.Year(), t.Month(), t.Day(), h, m, 0, 0, t.Location()) + start := strings.ReplaceAll(t.Format(time.DateTime), " ", "T") + title := gofakeit.Sentence(1) + description := gofakeit.Paragraph(1+rand.Intn(3), 1+rand.Intn(4), 1+rand.Intn(32), "\n") + + descriptionFormat := pickRandom("text/plain", "text/html") + if descriptionFormat == "text/html" { + description = toHtml(description) + } + status := pickRandom(jscalendar.Statuses...) + freeBusy := pickRandom(jscalendar.FreeBusyStatuses...) + privacy := pickRandom(jscalendar.Privacies...) + color := pickRandom(basicColors...) + locale := pickLocale() + keywords := keywords() + categories := categories() + var _ = categories // currently not used because it's unsupported in Stalwart + sequence := 0 + + 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, + // "categories": categories, // currently unsupported in Stalwart + "color": color, + "sequence": sequence, + "showWithoutTime": false, + "freeBusyStatus": string(freeBusy), + "privacy": string(privacy), + "sentBy": organizerEmail, + "participants": participantMaps, + "timeZone": tz, + // "mayInviteSelf": true, // currently unsupported in Stalwart + // "mayInviteOthers": true, // currently unsupported in Stalwart + "hideAttendees": false, + "replyTo": map[string]string{ + "imip": "mailto:" + organizerEmail, + }, + "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: untype(jscalendar.Event{ + Type: jscalendar.EventType, + Start: jscalendar.LocalDateTime(start), + Duration: jscalendar.Duration(duration), + Status: status, + Object: jscalendar.Object{ + CommonObject: jscalendar.CommonObject{ + Uid: uid, + ProdId: productName, + Title: title, + Description: description, + DescriptionContentType: descriptionFormat, + Locale: locale, + // Categories: categories, // currently unsupported in Stalwart + Color: color, + }, + Sequence: uint(sequence), + ShowWithoutTime: false, + FreeBusyStatus: freeBusy, + Privacy: privacy, + SentBy: organizerEmail, + Participants: participantObjs, + TimeZone: tz, + // MayInviteSelf: true, // currently unsupported in Stalwart + // MayInviteOthers: true, // currently unsupported in Stalwart + HideAttendees: false, + ReplyTo: map[jscalendar.ReplyMethod]string{ + jscalendar.ReplyMethodImip: "mailto:" + organizerEmail, + }, + Locations: locationObjs, + VirtualLocations: map[string]jscalendar.VirtualLocation{ + virtualLocationId: untype(virtualLocationObj, u), + }, + Alerts: map[string]jscalendar.Alert{ + alertId: untype(jscalendar.Alert{ + Type: jscalendar.AlertType, + Trigger: jscalendar.OffsetTrigger{ + Type: jscalendar.OffsetTriggerType, + Offset: jscalendar.SignedDuration(alertOffset), + RelativeTo: jscalendar.RelativeToStart, + }, + }, u), + }, + }, + }, u), + } + + if len(keywords) > 0 { + event["keywords"] = keywords + obj.Keywords = keywords + } + + 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) { + mime := "" + uri := "" + rel := jscalendar.RelAbout + switch rand.Intn(2) { + case 0: + size := pickRandom(16, 24, 32, 48, 64) + img := gofakeit.ImagePng(size, size) + mime = "image/png" + uri = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img) + default: + mime = "image/jpeg" + uri = "https://picsum.photos/id/" + strconv.Itoa(1+rand.Intn(200)) + "/200/300" + } + return map[string]any{ + "@type": "Link", + "href": uri, + "contentType": mime, + "rel": string(rel), + }, untype(jscalendar.Link{ + Type: jscalendar.LinkType, + Href: uri, + ContentType: mime, + Rel: rel, + }, u), nil + }) + + if rand.Intn(10) > 7 { + frequency := pickRandom(jscalendar.FrequencyWeekly, jscalendar.FrequencyDaily) + interval := pickRandom(1, 2) + count := 1 + if frequency == jscalendar.FrequencyWeekly { + count = 1 + rand.Intn(8) + } 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 := untype(jscalendar.RecurrenceRule{ + Type: jscalendar.RecurrenceRuleType, + Frequency: frequency, + Interval: uint(interval), + Rscale: jscalendar.RscaleIso8601, + Skip: jscalendar.SkipOmit, + FirstDayOfWeek: jscalendar.DayOfWeekMonday, + Count: uint(count), + }, u) + obj.RecurrenceRule = &rr + } + + id, err := s.CreateEvent(c, accountId, event) + if err != nil { + return accountId, calendarId, nil, boxes, err + } + + obj.Id = id + filled[id] = obj + + 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, JmapCalendars, event) +} + +var rooms = []jscalendar.Location{ + { + Type: jscalendar.LocationType, + Name: "Office meeting room upstairs", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice), + Coordinates: "geo:52.5335389,13.4103296", + Links: map[string]jscalendar.Link{ + "l1": {Href: "https://www.heinlein-support.de/"}, + }, + }, + { + Type: jscalendar.LocationType, + Name: "office-nue", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice), + Coordinates: "geo:49.4723337,11.1042282", + Links: map[string]jscalendar.Link{ + "l2": {Href: "https://www.workandpepper.de/"}, + }, + }, + { + Type: jscalendar.LocationType, + Name: "Meetingraum Prenzlauer Berg", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionPublic), + Coordinates: "geo:52.554222,13.4142387", + Links: map[string]jscalendar.Link{ + "l3": {Href: "https://www.spacebase.com/en/venue/meeting-room-prenzlauer-be-11499/"}, + }, + }, + { + Type: jscalendar.LocationType, + Name: "Meetingraum LIANE 1", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionLibrary), + Coordinates: "geo:52.4854301,13.4224763", + Links: map[string]jscalendar.Link{ + "l4": {Href: "https://www.spacebase.com/en/venue/rent-a-jungle-8372/"}, + }, + }, + { + Type: jscalendar.LocationType, + Name: "Dark Horse", + LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice), + Coordinates: "geo:52.4942254,13.4346015", + Links: map[string]jscalendar.Link{ + "l5": {Href: "https://www.spacebase.com/en/event-venue/workshop-white-space-2667/"}, + }, + }, +} + +var virtualRooms = []jscalendar.VirtualLocation{ + { + Type: jscalendar.VirtualLocationType, + Name: "opentalk", + Uri: "https://meet.opentalk.eu/fake/room/06fb8f7d-42eb-4212-8112-769fac2cb111", + Features: toBoolMapS( + jscalendar.VirtualLocationFeatureAudio, + jscalendar.VirtualLocationFeatureChat, + jscalendar.VirtualLocationFeatureVideo, + jscalendar.VirtualLocationFeatureScreen, + ), + }, +} + +func pickLocation() (string, map[string]any, jscalendar.Location) { + locationId := id() + room := rooms[rand.Intn(len(rooms))] + room.Links = nil // currently unsupported in Stalwart + 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 +} + +func pickVirtualLocation() (string, map[string]any, 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 +} + +var ChairRoles = toBoolMapS(jscalendar.RoleChair, jscalendar.RoleOwner) +var RegularRoles = toBoolMapS(jscalendar.RoleOptional) + +func createParticipants(uid string, locationIds []string, virtualLocationIds []string, u bool) (map[string]map[string]any, 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...), "", "", u) + maps[organizerId] = organizerMap + objs[organizerId] = organizerObj + for i := 1; i < n; i++ { + id, _, participantMap, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail, u) + maps[id] = participantMap + objs[id] = participantObj + } + return maps, objs, organizerEmail +} + +func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string, u bool) (string, string, map[string]any, jscalendar.Participant) { + participantId := id() + person := gofakeit.Person() + roles := RegularRoles + if i == 0 { + roles = ChairRoles + } + status := jscalendar.ParticipationStatusAccepted + if i != 0 { + status = pickRandom( + jscalendar.ParticipationStatusNeedsAction, + jscalendar.ParticipationStatusAccepted, + jscalendar.ParticipationStatusDeclined, + jscalendar.ParticipationStatusTentative, + ) + //, delegated + set "delegatedTo" + } + statusComment := "" + if rand.Intn(5) >= 3 { + statusComment = gofakeit.HipsterSentence(1 + rand.Intn(5)) + } + if i == 0 { + organizerEmail = person.Contact.Email + organizerId = participantId + } + + name := person.FirstName + " " + person.LastName + email := person.Contact.Email + description := gofakeit.SentenceSimple() + descriptionContentType := pickRandom("text/html", "text/plain") + if descriptionContentType == "text/html" { + description = toHtml(description) + } + language := pickLanguage() + updated := "2025-10-01T01:59:12Z" + updatedTime, err := time.Parse(time.RFC3339, updated) + if err != nil { + panic(err) + } + + var calendarAddress string + { + pos := strings.LastIndex(email, "@") + if pos < 0 { + calendarAddress = email + } else { + local := email[0:pos] + domain := email[pos+1:] + calendarAddress = local + "+itip+" + uid + "@" + "itip." + domain + } + } + + m := map[string]any{ + "@type": "Participant", + "name": name, + "email": email, + // "description": description, // currently not supported in Stalwart + // "descriptionContentType": descriptionContentType, // currently not supported in Stalwart + "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, + Email: email, + // Description: description, // currently not supported in Stalwart + // DescriptionContentType: descriptionContentType, // currently not supported in Stalwart + Kind: jscalendar.ParticipantKindIndividual, + CalendarAddress: calendarAddress, + Roles: roles, + LocationId: locationId, + Language: language, + ParticipationStatus: status, + ParticipationComment: statusComment, + ExpectReply: true, + ScheduleAgent: jscalendar.ScheduleAgentServer, + ScheduleSequence: uint(1), + ScheduleStatus: []string{"1.0"}, + ScheduleUpdated: updatedTime, + SentBy: organizerEmail, + InvitedBy: organizerId, + ScheduleId: "mailto:" + email, + } + + err = propmap(i%2 == 0, 1, 2, m, "links", &o.Links, func(int, string) (map[string]any, jscalendar.Link, error) { + href := "https://picsum.photos/id/" + strconv.Itoa(1+rand.Intn(200)) + "/200/300" + title := person.FirstName + "'s Cake Day pick" + return map[string]any{ + "@type": "Link", + "href": href, + "contentType": "image/jpeg", + "rel": "icon", + "display": "badge", + "title": title, + }, untype(jscalendar.Link{ + Type: jscalendar.LinkType, + Href: href, + ContentType: "image/jpeg", + Rel: jscalendar.RelIcon, + Display: jscalendar.DisplayBadge, + Title: title, + }, u), nil + }) + if err != nil { + panic(err) + } + + return participantId, person.Contact.Email, m, untype(o, u) +} + +var Keywords = []string{ + "office", + "important", + "sales", + "coordination", + "decision", +} + +func keywords() map[string]bool { + return toBoolMap(pickRandoms(Keywords...)) +} + +var Categories = []string{ + "http://opencloud.eu/categories/secret", + "http://opencloud.eu/categories/internal", +} + +func categories() map[string]bool { + return toBoolMap(pickRandoms(Categories...)) +} diff --git a/pkg/jmap/jmap_integration_test.go b/pkg/jmap/jmap_integration_test.go index 40d7e2d427..a8783608c0 100644 --- a/pkg/jmap/jmap_integration_test.go +++ b/pkg/jmap/jmap_integration_test.go @@ -590,6 +590,25 @@ func (j *TestJmapClient) create(id string, objectType ObjectType, body map[strin }).command(body) } +func (j *TestJmapClient) create1(accountId string, objectType ObjectType, ns string, obj map[string]any) (string, error) { + body := map[string]any{ + "using": []string{JmapCore, ns}, + "methodCalls": []any{ + []any{ + objectType + "/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, scope string) (map[string]map[string]any, error) { m := map[string]map[string]any{} { @@ -726,6 +745,20 @@ func id() string { return string(b) } +func toHtml(text string) string { + return "\n\n\n" + strings.Join(htmlJoin(paraSplitter.Split(text, -1)), "\n") + "\n" +} + +func htmlJoin(parts []string) []string { + var result []string + for i := range parts { + result = append(result, fmt.Sprintf("%v
", parts[i])) + } + return result +} + +var paraSplitter = regexp.MustCompile("[\r\n]+") + var timezones = []string{ "America/Adak", "America/Anchorage", @@ -736,6 +769,180 @@ var timezones = []string{ "America/Kentucky/Louisville", "America/Los_Angeles", "America/New_York", + "Europe/Brussels", + "Europe/Berlin", + "Europe/Paris", +} + +// https://www.w3.org/TR/css-color-3/#html4 +var basicColors = []string{ + "black", + "silver", + "gray", + "white", + "maroon", + "red", + "purple", + "fuchsia", + "green", + "lime", + "olive", + "yellow", + "navy", + "blue", + "teal", + "aqua", +} + +// https://www.w3.org/TR/SVG11/types.html#ColorKeywords +var extendedColors = []string{ + "aliceblue", + "antiquewhite", + "aqua", + "aquamarine", + "azure", + "beige", + "bisque", + "black", + "blanchedalmond", + "blue", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "cyan", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkgrey", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkslategrey", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dimgrey", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "fuchsia", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "gray", + "grey", + "green", + "greenyellow", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightgrey", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightslategrey", + "lightsteelblue", + "lightyellow", + "lime", + "limegreen", + "linen", + "magenta", + "maroon", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "navy", + "oldlace", + "olive", + "olivedrab", + "orange", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "pink", + "plum", + "powderblue", + "purple", + "red", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "silver", + "skyblue", + "slateblue", + "slategray", + "slategrey", + "snow", + "springgreen", + "steelblue", + "tan", + "teal", + "thistle", + "tomato", + "turquoise", + "violet", + "wheat", + "white", + "whitesmoke", + "yellow", + "yellowgreen", } 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 { @@ -835,6 +1042,10 @@ func pickLanguage() string { return pickRandom("en-US", "en-GB", "en-AU") } +func pickLocale() string { + return pickRandom("en", "fr", "de") +} + func allTrue[S any](t *testing.T, s S, exceptions ...string) { v := reflect.ValueOf(s) typ := v.Type() diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 7a653c54e3..c09d59eb82 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -1,10 +1,7 @@ package jmap import ( - "encoding/json" "io" - "regexp" - "strconv" "time" "github.com/opencloud-eu/opencloud/pkg/jscalendar" @@ -18,45 +15,7 @@ type ObjectType string // component MUST be `"Z"` (i.e., it must be in UTC time). // // For example, `"2014-10-30T06:12:00Z"`. -type UTCDate struct { - time.Time -} - -func (t UTCDate) MarshalJSON() ([]byte, error) { - // TODO imperfect, we're going to need something smarter here as the timezone is not actually - // fixed to be UTC but, instead, depends on the timezone that is defined in another property - // of the object where this LocalDate shows up in; alternatively, we might have to use a string - // here and leave the conversion to a usable timestamp up to the client or caller instead - return []byte("\"" + t.UTC().Format(time.RFC3339Nano) + "\""), nil -} - -var longDateRegexp = regexp.MustCompile(`^"(\d+?)(\d\d\d\d-.*)$`) - -func (t *UTCDate) UnmarshalJSON(b []byte) error { - var tt time.Time - - str := string(b) - m := longDateRegexp.FindAllStringSubmatch(str, 2) - if m != nil { - p, err := strconv.Atoi(m[0][1]) - if err != nil { - return err - } - ndate := "\"" + m[0][2] - err = json.Unmarshal([]byte(ndate), &tt) - if err != nil { - return err - } - t.Time = tt.AddDate(p*10000, 0, 0).UTC() - } else { - err := tt.UnmarshalJSON(b) - if err != nil { - return err - } - t.Time = tt.UTC() - } - return nil -} +type UTCDate string // Where `LocalDate` is given as a type, it means a string in the same format as `Date` // (see [RFC8620, Section 1.4]), but with the time-offset omitted from the end. @@ -67,29 +26,7 @@ func (t *UTCDate) UnmarshalJSON(b []byte) error { // may not be a fixed offset (for example when daylight saving time occurs). // // [RFC8620, Section 1.4]: https://www.rfc-editor.org/rfc/rfc8620.html#section-1.4 -type LocalDate struct { - time.Time -} - -const RFC3339Local = "2006-01-02T15:04:05" - -func (t LocalDate) MarshalJSON() ([]byte, error) { - // TODO imperfect, we're going to need something smarter here as the timezone is not actually - // fixed to be UTC but, instead, depends on the timezone that is defined in another property - // of the object where this LocalDate shows up in; alternatively, we might have to use a string - // here and leave the conversion to a usable timestamp up to the client or caller instead - return []byte("\"" + t.UTC().Format(RFC3339Local) + "\""), nil -} - -func (t *LocalDate) UnmarshalJSON(b []byte) error { - var tt time.Time - err := tt.UnmarshalJSON(b) - if err != nil { - return err - } - t.Time = tt.UTC() - return nil -} +type LocalDate string // Should the calendar’s events be used as part of availability calculation? // @@ -5204,16 +5141,16 @@ func (f ContactCardFilterCondition) IsNotEmpty() bool { if f.Kind != "" { return true } - if !f.CreatedBefore.IsZero() { + if f.CreatedBefore != "" { return true } - if !f.CreatedAfter.IsZero() { + if f.CreatedAfter != "" { return true } - if !f.UpdatedBefore.IsZero() { + if f.UpdatedBefore != "" { return true } - if !f.UpdatedAfter.IsZero() { + if f.UpdatedAfter != "" { return true } if f.Text != "" { @@ -5673,10 +5610,10 @@ func (f CalendarEventFilterCondition) IsNotEmpty() bool { if f.InCalendar != "" { return true } - if !f.After.IsZero() { + if f.After != "" { return true } - if !f.Before.IsZero() { + if f.Before != "" { return true } if f.Text != "" { diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go index 413e1f0f4b..f01171f7ba 100644 --- a/pkg/jmap/jmap_test.go +++ b/pkg/jmap/jmap_test.go @@ -318,50 +318,6 @@ func TestEmailFilterSerialization(t *testing.T) { require.Equal(strings.TrimSpace(expectedFilterJson), json) } -func TestUtcDateUnmarshalling(t *testing.T) { - require := require.New(t) - r := struct { - Ts UTCDate `json:"ts"` - }{} - err := json.Unmarshal([]byte(`{"ts":"2025-10-30T14:15:16.987Z"}`), &r) - require.NoError(err) - require.Equal(2025, r.Ts.Year()) - require.Equal(time.Month(10), r.Ts.Month()) - require.Equal(30, r.Ts.Day()) - require.Equal(14, r.Ts.Hour()) - require.Equal(15, r.Ts.Minute()) - require.Equal(16, r.Ts.Second()) - require.Equal(987000000, r.Ts.Nanosecond()) -} - -func TestUtcDateMarshalling(t *testing.T) { - require := require.New(t) - r := struct { - Ts UTCDate `json:"ts"` - }{} - ts, err := time.Parse(time.RFC3339, "2025-10-30T14:15:16.987Z") - require.NoError(err) - r.Ts = UTCDate{ts} - - jsoneq(t, `{"ts":"2025-10-30T14:15:16.987Z"}`, r) -} - -func TestUtcDateUnmarshallingWithWeirdDate(t *testing.T) { - require := require.New(t) - r := struct { - Ts UTCDate `json:"ts"` - }{} - err := json.Unmarshal([]byte(`{"ts":"65534-12-31T23:59:59Z"}`), &r) - require.NoError(err) - require.Equal(65534, r.Ts.Year()) - require.Equal(time.Month(12), r.Ts.Month()) - require.Equal(31, r.Ts.Day()) - require.Equal(23, r.Ts.Hour()) - require.Equal(59, r.Ts.Minute()) - require.Equal(59, r.Ts.Second()) - require.Equal(0, r.Ts.Nanosecond()) -} - func TestUnmarshallingCalendarEvent(t *testing.T) { payload := ` { diff --git a/pkg/jscalendar/jscalendar_model.go b/pkg/jscalendar/jscalendar_model.go index 3d21f23326..53bdb640aa 100644 --- a/pkg/jscalendar/jscalendar_model.go +++ b/pkg/jscalendar/jscalendar_model.go @@ -355,11 +355,10 @@ const ( ParticipantKindResource = ParticipantKind("resource") RoleOwner = Role("owner") - RoleAttendee = Role("attendee") RoleOptional = Role("optional") RoleInformational = Role("informational") RoleChair = Role("chair") - RoleContact = Role("contact") + RoleRequired = Role("required") // JMAP Task extension: the participant is expected to work on the task. RoleAssignee = Role("assignee") @@ -681,11 +680,10 @@ var ( Roles = []Role{ RoleOwner, - RoleAttendee, RoleOptional, RoleInformational, RoleChair, - RoleContact, + RoleRequired, RoleAssignee, } @@ -871,11 +869,6 @@ type Location struct { // This is the human-readable name of the location. Name string `json:"name,omitempty"` - // This is the human-readable, plain-text instructions for accessing this location. - // - // This may be an address, set of directions, door access code, etc. - Description string `json:"description,omitempty"` - // This is a set of one or more location types that describe this location. // // All types MUST be from the "Location Types Registry" [LOCATIONTYPES], as defined in [RFC4589]. @@ -902,11 +895,6 @@ type VirtualLocation struct { // This is the human-readable name of the virtual location. Name string `json:"name,omitempty"` - // These are human-readable plain-text instructions for accessing this virtual location. - // - // This may be a conference access code, etc. - Description string `json:"description,omitempty"` - // Mandatory: this is a URI [RFC3986] that represents how to connect to this virtual location. // // This may be a telephone number (represented using the `tel:` scheme, e.g., `tel:+1-555-555-5555`) @@ -1159,23 +1147,22 @@ type Participant struct { // For example, this may include more information about their role in the event or how best to contact them. Description string `json:"description,omitempty"` - // This represents methods by which the participant may receive the invitation and updates to the calendar object. + // This describes the media type of the contents of the description property. // - // The keys in the property value are the available methods and MUST only contain ASCII alphanumeric characters (`A-Za-z0-9`). + // If this property is set, then the description property MUST be set. + DescriptionContentType string `json:"descriptionContentType,omitempty"` + + // This is a URI as defined by [RFC3986] or any other IANA-registered form for a URI. // - // The value is a URI for the method specified in the key. Future methods may be defined in future specifications and - // registered with IANA; a calendar client MUST ignore any method it does not understand but MUST preserve the method key and URI. - // - // This property MUST be omitted if no method is defined (rather than being specified as an empty object). - // - // The following methods are defined: - // !- `imip`: The participant accepts an iMIP [RFC6047] request at this email address. The value MUST be a `mailto:` URI. - // It MAY be different from the value of the participant's email property. - // !- `other``: The participant is identified by this URI, but the method for submitting the invitation is undefined. - SendTo map[SendToMethod]string `json:"sendTo,omitempty"` + // It is the same as the CAL-ADDRESS value of an iCalendar ATTENDEE property [RFC5545] (Section 3.8.4.1) + // or ORGANIZER property [RFC5545] (Section 3.8.4.3) — it globally identifies a particular participant, + // even across different calendaring objects. + CalendarAddress string `json:"calendarAddress,omitempty"` // This is what kind of entity this participant is, if known. // + // If this property is set, then the calendarAddress property MUST be set. + // // This 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). // @@ -1188,6 +1175,8 @@ type Participant struct { // This is a set of roles that this participant fulfills. // + // If this property is set, then the calendarAddress property MUST be set. + // // At least one role MUST be specified for the participant. // // The keys in the set MUST be one of the following values, another value registered in the IANA "JSCalendar Enum Values" @@ -1233,6 +1222,8 @@ type Participant struct { ParticipationComment string `json:"participationComment,omitempty"` // If true, the organizer is expecting the participant to notify them of their participation status. + // + // If this property is set, then the calendarAddress property MUST be set. ExpectReply bool `json:"expectReply,omitzero"` // This is who is responsible for sending scheduling messages with this calendar object to the participant. @@ -1820,6 +1811,17 @@ type Object struct { // This is a map of location ids to `Location` objects, representing locations associated with the object. Locations map[string]Location `json:"locations,omitempty"` + // This indicates which of the multiple entries in the locations property can be considered the main location + // for the event or task. + // + // A client implementation MAY choose to display this location more prominently. + // + // The main location is undefined if this property is not set. + // + // If this property is set, then its value MUST match a key in the locations property and the name property + // of that main Location object MUST be set. + MainLocationId string `json:"mainLocationId,omitempty"` + // This is a map of virtual location ids to VirtualLocation objects, representing virtual locations, such as // video conferences or chat rooms, associated with the object. VirtualLocations map[string]VirtualLocation `json:"virtualLocations,omitempty"` diff --git a/pkg/jscalendar/jscalendar_model_test.go b/pkg/jscalendar/jscalendar_model_test.go index be00c0d0ce..4aae73fdc3 100644 --- a/pkg/jscalendar/jscalendar_model_test.go +++ b/pkg/jscalendar/jscalendar_model_test.go @@ -107,9 +107,8 @@ func TestLocation(t *testing.T) { } } }`, Location{ - Type: LocationType, - Name: "The Eiffel Tower", - Description: "The big iron tower in the middle of Paris, can't miss it.", + Type: LocationType, + Name: "The Eiffel Tower", LocationTypes: map[LocationTypeOption]bool{ LocationTypeOptionLandmarkAddress: true, LocationTypeOptionIndustrial: true, @@ -141,10 +140,9 @@ func TestVirtualLocation(t *testing.T) { "audio": true } }`, VirtualLocation{ - Type: VirtualLocationType, - Name: "OpenTalk", - Description: "The best videoconferencing.", - Uri: "https://opentalk.eu", + Type: VirtualLocationType, + Name: "OpenTalk", + Uri: "https://opentalk.eu", Features: map[VirtualLocationFeature]bool{ VirtualLocationFeatureVideo: true, VirtualLocationFeatureScreen: true, @@ -293,19 +291,15 @@ func TestParticipant(t *testing.T) { "progressUpdated": "2025-09-29T12:32:19Z", "percentComplete": 42 }`, Participant{ - Type: ParticipantType, - Name: "Camina Drummer", - Email: "camina@opa.org", - Description: "Camina Drummer is a Belter serving as the current President of the Transport Union.", - SendTo: map[SendToMethod]string{ - SendToMethodImip: "mailto:cdrummer@opa.org", - SendToMethodOther: "https://opa.org/ping/camina", - }, - Kind: ParticipantKindIndividual, + Type: ParticipantType, + Name: "Camina Drummer", + Email: "camina@opa.org", + Description: "Camina Drummer is a Belter serving as the current President of the Transport Union.", + CalendarAddress: "cdrummer@itip.opa.org", + Kind: ParticipantKindIndividual, Roles: map[Role]bool{ - RoleAttendee: true, - RoleOwner: true, - RoleChair: true, + RoleOwner: true, + RoleChair: true, }, LocationId: "98faaa01-b6db-4ddb-9574-e28ab83104e6", Language: "en-JM", @@ -723,9 +717,8 @@ func TestEvent(t *testing.T) { ShowWithoutTime: true, Locations: map[string]Location{ "loc1": { - Type: LocationType, - Name: "Steel Cactus Mexican Grill", - Description: "The Steel Cactus Mexican Grill used to be on the Hecate Navy Base. The place closed down and is now a take-out restaurant that sells to-go cups of Thai food", + Type: LocationType, + Name: "Steel Cactus Mexican Grill", LocationTypes: map[LocationTypeOption]bool{ LocationTypeOptionBar: true, }, diff --git a/pkg/structs/structs.go b/pkg/structs/structs.go index bf605c533c..ea7126f186 100644 --- a/pkg/structs/structs.go +++ b/pkg/structs/structs.go @@ -224,3 +224,19 @@ func AnyItem[K comparable, V any](m map[K]V, predicate func(K, V) bool) bool { } return false } + +func Concat[E any](arys ...[]E) []E { + l := 0 + for _, ary := range arys { + l += len(ary) + } + r := make([]E, l) + + i := 0 + for _, ary := range arys { + if ary != nil { + i += copy(r[i:], ary) + } + } + return r +} diff --git a/pkg/structs/structs_test.go b/pkg/structs/structs_test.go index f56485c7c4..f81de10ce1 100644 --- a/pkg/structs/structs_test.go +++ b/pkg/structs/structs_test.go @@ -164,3 +164,10 @@ func TestAnyItem(t *testing.T) { assert.False(t, AnyItem(map[string]bool{"a": true, "b": false}, never)) assert.False(t, AnyItem(nil, never)) } + +func TestConcat(t *testing.T) { + assert.Equal(t, []string{"a", "b", "c", "d", "e", "f"}, Concat([]string{"a", "b"}, []string{"c"}, []string{"d", "e", "f"})) + assert.Equal(t, []string{"a"}, Concat([]string{"a"})) + assert.Equal(t, []string{"a"}, Concat([]string{}, nil, []string{"a"})) + assert.Equal(t, []string{}, Concat[string]()) +} diff --git a/services/groupware/pkg/groupware/groupware_mock_tasks.go b/services/groupware/pkg/groupware/groupware_mock_tasks.go index aac8057095..049270503b 100644 --- a/services/groupware/pkg/groupware/groupware_mock_tasks.go +++ b/services/groupware/pkg/groupware/groupware_mock_tasks.go @@ -74,8 +74,8 @@ var T1 = jmap.Task{ Id: "laoj0ahk", TaskListId: TL1.Id, IsDraft: false, - UtcStart: jmap.UTCDate{Time: mustParseTime("2025-10-02T10:00:00Z")}, - UtcDue: jmap.UTCDate{Time: mustParseTime("2025-10-12T18:00:00Z")}, + UtcStart: jmap.UTCDate("2025-10-02T10:00:00Z"), + UtcDue: jmap.UTCDate("2025-10-12T18:00:00Z"), SortOrder: 1, WorkflowStatus: "new", Task: jscalendar.Task{ @@ -171,7 +171,7 @@ var T1 = jmap.Task{ Title: "Control Medina Station", SortOrder: 1, IsComplete: true, - Updated: jmap.UTCDate{Time: mustParseTime("2025-04-01T09:32:10Z")}, + Updated: jmap.UTCDate("2025-04-01T09:32:10Z"), Assignee: &jmap.TaskPerson{ Type: jmap.TaskPersonType, Name: "Fred Johnson", @@ -182,8 +182,8 @@ var T1 = jmap.Task{ "ooze1iet": { Type: jmap.CommentType, Message: "We first need to control Medina Station before we can get through the Sol Gate", - Created: jmap.UTCDate{Time: mustParseTime("2025-04-01T12:11:10Z")}, - Updated: jmap.UTCDate{Time: mustParseTime("2025-04-01T12:29:19Z")}, + Created: jmap.UTCDate("2025-04-01T12:11:10Z"), + Updated: jmap.UTCDate("2025-04-01T12:29:19Z"), Author: &jmap.TaskPerson{ Type: jmap.TaskPersonType, Name: "Anderson Dawes",