mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-01-24 13:39:46 -06:00
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:
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
592
pkg/jmap/jmap_integration_event_test.go
Normal file
592
pkg/jmap/jmap_integration_event_test.go
Normal 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...))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 := `
|
||||
{
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user