groupware: finalize JMAP events integration test, with multiple changes to the model to conform with draft-ietf-calext-jscalendarbis-10 and fields that are currently not implemented in Stalwart

This commit is contained in:
Pascal Bleser
2025-11-20 11:55:46 +01:00
parent d788cee6d3
commit deee89d683
12 changed files with 886 additions and 195 deletions

View File

@@ -78,6 +78,7 @@ func (j *Client) QueryCalendarEvents(accountIds []string, session *Session, ctx
Path: "/ids/*",
ResultOf: mcid(accountId, "0"),
},
// Properties: CalendarEventProperties, // to also retrieve UTCStart and UTCEnd
}, mcid(accountId, "1"))
}
cmd, err := j.request(session, logger, invocations...)

View File

@@ -459,26 +459,11 @@ func (s *StalwartTest) fillContacts(
}
card.Id = id
filled[id] = card
printer(fmt.Sprintf("🧑🏻 created %*s/%v uid=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, id))
printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, id))
}
return accountId, addressbookId, filled, boxes, nil
}
func (s *StalwartTest) CreateContact(j *TestJmapClient, accountId string, contact map[string]any) (string, error) {
body := map[string]any{
"using": []string{JmapCore, JmapContacts},
"methodCalls": []any{
[]any{
ContactCardType + "/set",
map[string]any{
"accountId": accountId,
"create": map[string]any{
"c": contact,
},
},
"0",
},
},
}
return j.create("c", ContactCardType, body)
return j.create1(accountId, ContactCardType, JmapContacts, contact)
}

View File

@@ -167,19 +167,10 @@ func matchEmail(t *testing.T, actual Email, expected filledMail, hasBodies bool)
}
}
func htmlJoin(parts []string) []string {
var result []string
for i := range parts {
result = append(result, fmt.Sprintf("<p>%v</p>", 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 {

View File

@@ -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...))
}

View File

@@ -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 "<!DOCTYPE html>\n<html>\n<body>\n" + strings.Join(htmlJoin(paraSplitter.Split(text, -1)), "\n") + "</body>\n</html>"
}
func htmlJoin(parts []string) []string {
var result []string
for i := range parts {
result = append(result, fmt.Sprintf("<p>%v</p>", 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()

View File

@@ -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 calendars 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 != "" {

View File

@@ -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 := `
{

View File

@@ -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"`

View File

@@ -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,
},

View File

@@ -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
}

View File

@@ -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]())
}

View File

@@ -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",