From 0e1be0c5cccdff617c65e4e9c2ffbbfcba49b50d Mon Sep 17 00:00:00 2001
From: Pascal Bleser
Date: Thu, 30 Oct 2025 15:12:08 +0100
Subject: [PATCH] groupware: add real calendars and events
---
pkg/jmap/jmap_api_calendar.go | 224 ++++++-
pkg/jmap/jmap_model.go | 621 +++++++++++++++++-
pkg/jmap/jmap_test.go | 405 ++++++++++++
pkg/jmap/jmap_tools.go | 1 +
pkg/jscalendar/jscalendar_model.go | 15 +-
pkg/jscalendar/jscalendar_model_test.go | 28 +-
.../pkg/groupware/groupware_api_calendars.go | 135 +++-
.../pkg/groupware/groupware_mock_calendars.go | 20 +-
.../pkg/groupware/groupware_mock_tasks.go | 4 +-
.../pkg/groupware/groupware_route.go | 5 +
10 files changed, 1400 insertions(+), 58 deletions(-)
diff --git a/pkg/jmap/jmap_api_calendar.go b/pkg/jmap/jmap_api_calendar.go
index 6f57205077..7632c63134 100644
--- a/pkg/jmap/jmap_api_calendar.go
+++ b/pkg/jmap/jmap_api_calendar.go
@@ -2,15 +2,17 @@ package jmap
import (
"context"
+ "fmt"
"github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/pkg/structs"
)
func (j *Client) ParseICalendarBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, blobIds []string) (CalendarEventParseResponse, SessionState, State, Language, Error) {
logger = j.logger("ParseICalendarBlob", session, logger)
cmd, err := j.request(session, logger,
- invocation(CommandCalendarEventParse, CalendarEventParseCommand{AccountId: accountId, BlobIDs: blobIds}, "0"),
+ invocation(CommandCalendarEventParse, CalendarEventParseCommand{AccountId: accountId, BlobIds: blobIds}, "0"),
)
if err != nil {
return CalendarEventParseResponse{}, "", "", "", err
@@ -25,3 +27,223 @@ func (j *Client) ParseICalendarBlob(accountId string, session *Session, ctx cont
return response, "", nil
})
}
+
+type CalendarsResponse struct {
+ Calendars []Calendar `json:"calendars"`
+ NotFound []string `json:"notFound,omitempty"`
+}
+
+func (j *Client) GetCalendars(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (CalendarsResponse, SessionState, State, Language, Error) {
+ return getTemplate(j, "GetCalendars", CommandCalendarGet,
+ func(accountId string, ids []string) CalendarGetCommand {
+ return CalendarGetCommand{AccountId: accountId, Ids: ids}
+ },
+ func(resp CalendarGetResponse) CalendarsResponse {
+ return CalendarsResponse{Calendars: resp.List, NotFound: resp.NotFound}
+ },
+ func(resp CalendarGetResponse) State { return resp.State },
+ accountId, session, ctx, logger, acceptLanguage, ids,
+ )
+}
+
+func (j *Client) QueryCalendarEvents(accountIds []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string,
+ filter CalendarEventFilterElement, sortBy []CalendarEventComparator,
+ position uint, limit uint) (map[string][]CalendarEvent, SessionState, State, Language, Error) {
+ logger = j.logger("QueryCalendarEvents", session, logger)
+
+ uniqueAccountIds := structs.Uniq(accountIds)
+
+ if sortBy == nil {
+ sortBy = []CalendarEventComparator{{Property: CalendarEventPropertyUpdated, IsAscending: false}}
+ }
+
+ invocations := make([]Invocation, len(uniqueAccountIds)*2)
+ for i, accountId := range uniqueAccountIds {
+ query := CalendarEventQueryCommand{
+ AccountId: accountId,
+ Filter: filter,
+ Sort: sortBy,
+ }
+ if limit > 0 {
+ query.Limit = limit
+ }
+ if position > 0 {
+ query.Position = position
+ }
+ invocations[i*2+0] = invocation(CommandCalendarEventQuery, query, mcid(accountId, "0"))
+ invocations[i*2+1] = invocation(CommandCalendarEventGet, CalendarEventGetRefCommand{
+ AccountId: accountId,
+ IdsRef: &ResultReference{
+ Name: CommandCalendarEventQuery,
+ Path: "/ids/*",
+ ResultOf: mcid(accountId, "0"),
+ },
+ }, mcid(accountId, "1"))
+ }
+ cmd, err := j.request(session, logger, invocations...)
+ if err != nil {
+ return nil, "", "", "", err
+ }
+
+ return command(j.api, logger, ctx, session, j.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string][]CalendarEvent, State, Error) {
+ resp := map[string][]CalendarEvent{}
+ stateByAccountId := map[string]State{}
+ for _, accountId := range uniqueAccountIds {
+ var response CalendarEventGetResponse
+ err = retrieveResponseMatchParameters(logger, body, CommandCalendarEventGet, mcid(accountId, "1"), &response)
+ if err != nil {
+ return nil, "", err
+ }
+ if len(response.NotFound) > 0 {
+ // TODO what to do when there are not-found emails here? potentially nothing, they could have been deleted between query and get?
+ }
+ resp[accountId] = response.List
+ stateByAccountId[accountId] = response.State
+ }
+ return resp, squashState(stateByAccountId), nil
+ })
+}
+
+func (j *Client) CreateCalendarEvent(accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create CalendarEvent) (*CalendarEvent, SessionState, State, Language, Error) {
+ return createTemplate(j, "CreateCalendarEvent", CalendarEventType, CommandCalendarEventSet, CommandCalendarEventGet,
+ func(accountId string, create map[string]CalendarEvent) CalendarEventSetCommand {
+ return CalendarEventSetCommand{AccountId: accountId, Create: create}
+ },
+ func(accountId string, ref string) CalendarEventGetCommand {
+ return CalendarEventGetCommand{AccountId: accountId, Ids: []string{ref}}
+ },
+ func(resp CalendarEventSetResponse) map[string]*CalendarEvent {
+ return resp.Created
+ },
+ func(resp CalendarEventSetResponse) map[string]SetError {
+ return resp.NotCreated
+ },
+ func(resp CalendarEventGetResponse) []CalendarEvent {
+ return resp.List
+ },
+ func(resp CalendarEventSetResponse) State {
+ return resp.NewState
+ },
+ accountId, session, ctx, logger, acceptLanguage, create)
+}
+
+func (j *Client) DeleteCalendarEvent(accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) {
+ return deleteTemplate(j, "DeleteCalendarEvent", CommandCalendarEventSet,
+ func(accountId string, destroy []string) CalendarEventSetCommand {
+ return CalendarEventSetCommand{AccountId: accountId, Destroy: destroy}
+ },
+ func(resp CalendarEventSetResponse) map[string]SetError { return resp.NotDestroyed },
+ func(resp CalendarEventSetResponse) State { return resp.NewState },
+ accountId, destroy, session, ctx, logger, acceptLanguage)
+}
+
+func getTemplate[GETREQ any, GETRESP any, RESP any](
+ client *Client, name string, getCommand Command,
+ getCommandFactory func(string, []string) GETREQ,
+ mapper func(GETRESP) RESP,
+ stateMapper func(GETRESP) State,
+ accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, ids []string) (RESP, SessionState, State, Language, Error) {
+ logger = client.logger(name, session, logger)
+
+ var zero RESP
+
+ cmd, err := client.request(session, logger,
+ invocation(getCommand, getCommandFactory(accountId, ids), "0"),
+ )
+ if err != nil {
+ return zero, "", "", "", err
+ }
+
+ return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (RESP, State, Error) {
+ var response GETRESP
+ err = retrieveResponseMatchParameters(logger, body, getCommand, "0", &response)
+ if err != nil {
+ return zero, "", err
+ }
+
+ return mapper(response), stateMapper(response), nil
+ })
+}
+
+func createTemplate[T any, SETREQ any, GETREQ any, SETRESP any, GETRESP any](
+ client *Client, name string, t ObjectType, setCommand Command, getCommand Command,
+ setCommandFactory func(string, map[string]T) SETREQ,
+ getCommandFactory func(string, string) GETREQ,
+ createdMapper func(SETRESP) map[string]*T,
+ notCreatedMapper func(SETRESP) map[string]SetError,
+ listMapper func(GETRESP) []T,
+ stateMapper func(SETRESP) State,
+ accountId string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string, create T) (*T, SessionState, State, Language, Error) {
+ logger = client.logger(name, session, logger)
+
+ createMap := map[string]T{"c": create}
+ cmd, err := client.request(session, logger,
+ invocation(setCommand, setCommandFactory(accountId, createMap), "0"),
+ invocation(getCommand, getCommandFactory(accountId, "#c"), "1"),
+ )
+ if err != nil {
+ return nil, "", "", "", err
+ }
+
+ return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (*T, State, Error) {
+ var setResponse SETRESP
+ err = retrieveResponseMatchParameters(logger, body, setCommand, "0", &setResponse)
+ if err != nil {
+ return nil, "", err
+ }
+
+ notCreatedMap := notCreatedMapper(setResponse)
+ setErr, notok := notCreatedMap["c"]
+ if notok {
+ logger.Error().Msgf("%T.NotCreated returned an error %v", setResponse, setErr)
+ return nil, "", setErrorError(setErr, t)
+ }
+
+ createdMap := createdMapper(setResponse)
+ if created, ok := createdMap["c"]; !ok || created == nil {
+ berr := fmt.Errorf("failed to find %s in %s response", string(t), string(setCommand))
+ logger.Error().Err(berr)
+ return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ }
+
+ var getResponse GETRESP
+ err = retrieveResponseMatchParameters(logger, body, getCommand, "1", &getResponse)
+ if err != nil {
+ return nil, "", err
+ }
+
+ list := listMapper(getResponse)
+
+ if len(list) < 1 {
+ berr := fmt.Errorf("failed to find %s in %s response", string(t), string(getCommand))
+ logger.Error().Err(berr)
+ return nil, "", simpleError(berr, JmapErrorInvalidJmapResponsePayload)
+ }
+
+ return &list[0], stateMapper(setResponse), nil
+ })
+}
+
+func deleteTemplate[REQ any, RESP any](client *Client, name string, c Command,
+ commandFactory func(string, []string) REQ,
+ notDestroyedMapper func(RESP) map[string]SetError,
+ stateMapper func(RESP) State,
+ accountId string, destroy []string, session *Session, ctx context.Context, logger *log.Logger, acceptLanguage string) (map[string]SetError, SessionState, State, Language, Error) {
+ logger = client.logger(name, session, logger)
+
+ cmd, err := client.request(session, logger,
+ invocation(c, commandFactory(accountId, destroy), "0"),
+ )
+ if err != nil {
+ return nil, "", "", "", err
+ }
+
+ return command(client.api, logger, ctx, session, client.onSessionOutdated, cmd, acceptLanguage, func(body *Response) (map[string]SetError, State, Error) {
+ var setResponse RESP
+ err = retrieveResponseMatchParameters(logger, body, c, "0", &setResponse)
+ if err != nil {
+ return nil, "", err
+ }
+ return notDestroyedMapper(setResponse), stateMapper(setResponse), nil
+ })
+}
diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go
index ee34ad858e..46e24d272a 100644
--- a/pkg/jmap/jmap_model.go
+++ b/pkg/jmap/jmap_model.go
@@ -1,7 +1,10 @@
package jmap
import (
+ "encoding/json"
"io"
+ "regexp"
+ "strconv"
"time"
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
@@ -24,16 +27,34 @@ func (t UTCDate) MarshalJSON() ([]byte, error) {
// 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.RFC3339) + "\""), nil
+ 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
- err := tt.UnmarshalJSON(b)
- if err != nil {
- return err
+
+ 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()
}
- t.Time = tt.UTC()
return nil
}
@@ -594,6 +615,26 @@ type SessionContactsAccountCapabilities struct {
}
type SessionCalendarsAccountCapabilities struct {
+ // The maximum number of Calendars that can be assigned to a single CalendarEvent object.
+ //
+ // This MUST be an integer >= 1, or null for no limit (or rather, the limit is always the
+ // number of Calendars in the account).
+ MaxCalendarsPerEvent *uint `json:"maxCalendarsPerEvent,omitempty"`
+
+ // The earliest date-time value the server is willing to accept for any date stored in a CalendarEvent.
+ MinDateTime *UTCDate `json:"minDateTime,omitempty"`
+
+ // The latest date-time value the server is willing to accept for any date stored in a CalendarEvent.
+ MaxDateTime *UTCDate `json:"maxDateTime,omitempty"`
+
+ // The maximum duration the user may query over when asking the server to expand recurrences.
+ MaxExpandedQueryDuration Duration `json:"maxExpandedQueryDuration,omitzero"`
+
+ // The maximum number of participants a single event may have, or null for no limit.
+ MaxParticipantsPerEvent *uint `json:"maxParticipantsPerEvent,omitzero"`
+
+ // If true, the user may create a calendar in this account.
+ MayCreateCalendar *bool `json:"mayCreateCalendar,omitempty"`
}
type SessionCalendarsParseAccountCapabilities struct {
@@ -766,6 +807,25 @@ type SessionTasksCustomTimezonesCapabilities struct {
}
type SessionPrincipalCapabilities struct {
+ // Id of Account with the urn:ietf:params:jmap:calendars capability that contains the calendar data
+ // for this Principal, or null if either (a) there is none (e.g. the Principal is a group just used
+ // for permissions management), or (b) the user does not have access to any data in the account
+ // (with the exception of free/busy, which is governed by the "mayGetAvailability" property).
+ //
+ // The corresponding Account object can be found in the Principal's "accounts" property, as
+ // per Section 2 of [RFC9670].
+ AccountId string `json:"accountId,omitempty"`
+
+ // If true, the user may call the "Principal/getAvailability" method with this Principal.
+ MayGetAvailability *bool `json:"mayGetAvailability,omitzero"`
+
+ // If true, the user may add this Principal as a calendar share target (by adding them to the
+ // "shareWith" property of a calendar, see Section 4).
+ MayShareWith *bool `json:"mayShareWith,omitzero"`
+
+ // If this Principal may be added as a participant to an event, this is the calendarAddress to
+ // use to receive iTIP scheduling messages.
+ CalendarAddress string `json:"calendarAddress,omitempty"`
}
type SessionPrincipalAvailabilityCapabilities struct {
@@ -3894,7 +3954,10 @@ type Calendar struct {
//
// Ids MUST be unique across all default alerts in the account, including those in other
// calendars; a UUID is recommended.
-
+ //
+ // The "trigger" MUST NOT be an `AbsoluteTrigger`, as this would fire for every event at the same
+ // time and so does not make sense for a default alert.
+ //
// If omitted on creation, the default is server dependent.
//
// For example, servers may choose to always default to null, or may copy the alerts from the default calendar.
@@ -3906,6 +3969,9 @@ type Calendar struct {
// Ids MUST be unique across all default alerts in the account, including those in other
// calendars; a UUID is recommended.
//
+ // The "trigger" MUST NOT be an `AbsoluteTrigger`, as this would fire for every event at the
+ // same time and so does not make sense for a default alert.
+ //
// If omitted on creation, the default is server dependent.
//
// For example, servers may choose to always default to null, or may copy the alerts from the default calendar.
@@ -4028,6 +4094,110 @@ type CalendarEvent struct {
jscalendar.Event
}
+const (
+ CalendarEventPropertyId = "id"
+ CalendarEventPropertyBaseEventId = "baseEventId"
+ CalendarEventPropertyCalendarIds = "calendarIds"
+ CalendarEventPropertyIsDraft = "isDraft"
+ CalendarEventPropertyIsOrigin = "isOrigin"
+ CalendarEventPropertyUtcStart = "utcStart"
+ CalendarEventPropertyUtcEnd = "utcEnd"
+ CalendarEventPropertyType = "type"
+ CalendarEventPropertyStart = "start"
+ CalendarEventPropertyDuration = "duration"
+ CalendarEventPropertyStatus = "status"
+ CalendarEventPropertyRelatedTo = "relatedTo"
+ CalendarEventPropertySequence = "sequence"
+ CalendarEventPropertyShowWithoutTime = "showWithoutTime"
+ CalendarEventPropertyLocations = "locations"
+ CalendarEventPropertyVirtualLocations = "virtualLocations"
+ CalendarEventPropertyRecurrenceId = "recurrenceId"
+ CalendarEventPropertyRecurrenceIdTimeZone = "recurrenceIdTimeZone"
+ CalendarEventPropertyRecurrenceRules = "recurrenceRules"
+ CalendarEventPropertyExcludedRecurrenceRules = "excludedRecurrenceRules"
+ CalendarEventPropertyRecurrenceOverrides = "recurrenceOverrides"
+ CalendarEventPropertyExcluded = "excluded"
+ CalendarEventPropertyPriority = "priority"
+ CalendarEventPropertyFreeBusyStatus = "freeBusyStatus"
+ CalendarEventPropertyPrivacy = "privacy"
+ CalendarEventPropertyReplyTo = "replyTo"
+ CalendarEventPropertySentBy = "sentBy"
+ CalendarEventPropertyParticipants = "participants"
+ CalendarEventPropertyRequestStatus = "requestStatus"
+ CalendarEventPropertyUseDefaultAlerts = "useDefaultAlerts"
+ CalendarEventPropertyAlerts = "alerts"
+ CalendarEventPropertyLocalizations = "localizations"
+ CalendarEventPropertyTimeZone = "timeZone"
+ CalendarEventPropertyMayInviteSelf = "mayInviteSelf"
+ CalendarEventPropertyMayInviteOthers = "mayInviteOthers"
+ CalendarEventPropertyHideAttendees = "hideAttendees"
+ CalendarEventPropertyUid = "uid"
+ CalendarEventPropertyProdId = "prodId"
+ CalendarEventPropertyCreated = "created"
+ CalendarEventPropertyUpdated = "updated"
+ CalendarEventPropertyTitle = "title"
+ CalendarEventPropertyDescription = "description"
+ CalendarEventPropertyDescriptionContentType = "descriptionContentType"
+ CalendarEventPropertyLinks = "links"
+ CalendarEventPropertyLocale = "locale"
+ CalendarEventPropertyKeywords = "keywords"
+ CalendarEventPropertyCategories = "categories"
+ CalendarEventPropertyColor = "color"
+ CalendarEventPropertyTimeZones = "timeZones"
+)
+
+var CalendarEventProperties = []string{
+ CalendarEventPropertyId,
+ CalendarEventPropertyBaseEventId,
+ CalendarEventPropertyCalendarIds,
+ CalendarEventPropertyIsDraft,
+ CalendarEventPropertyIsOrigin,
+ CalendarEventPropertyUtcStart,
+ CalendarEventPropertyUtcEnd,
+ CalendarEventPropertyType,
+ CalendarEventPropertyStart,
+ CalendarEventPropertyDuration,
+ CalendarEventPropertyStatus,
+ CalendarEventPropertyRelatedTo,
+ CalendarEventPropertySequence,
+ CalendarEventPropertyShowWithoutTime,
+ CalendarEventPropertyLocations,
+ CalendarEventPropertyVirtualLocations,
+ CalendarEventPropertyRecurrenceId,
+ CalendarEventPropertyRecurrenceIdTimeZone,
+ CalendarEventPropertyRecurrenceRules,
+ CalendarEventPropertyExcludedRecurrenceRules,
+ CalendarEventPropertyRecurrenceOverrides,
+ CalendarEventPropertyExcluded,
+ CalendarEventPropertyPriority,
+ CalendarEventPropertyFreeBusyStatus,
+ CalendarEventPropertyPrivacy,
+ CalendarEventPropertyReplyTo,
+ CalendarEventPropertySentBy,
+ CalendarEventPropertyParticipants,
+ CalendarEventPropertyRequestStatus,
+ CalendarEventPropertyUseDefaultAlerts,
+ CalendarEventPropertyAlerts,
+ CalendarEventPropertyLocalizations,
+ CalendarEventPropertyTimeZone,
+ CalendarEventPropertyMayInviteSelf,
+ CalendarEventPropertyMayInviteOthers,
+ CalendarEventPropertyHideAttendees,
+ CalendarEventPropertyUid,
+ CalendarEventPropertyProdId,
+ CalendarEventPropertyCreated,
+ CalendarEventPropertyUpdated,
+ CalendarEventPropertyTitle,
+ CalendarEventPropertyDescription,
+ CalendarEventPropertyDescriptionContentType,
+ CalendarEventPropertyLinks,
+ CalendarEventPropertyLocale,
+ CalendarEventPropertyKeywords,
+ CalendarEventPropertyCategories,
+ CalendarEventPropertyColor,
+ CalendarEventPropertyTimeZones,
+}
+
// A ParticipantIdentity stores information about a URI that represents the user within that account in an event’s participants.
type ParticipantIdentity struct {
// The id of the ParticipantIdentity (immutable; server-set).
@@ -4903,13 +5073,6 @@ type AddressBookGetResponse struct {
NotFound []string `json:"notFound,omitempty"`
}
-type ContacCardGetResponse struct {
- AccountId string `json:"accountId"`
- State State `json:"state,omitempty"`
- List []AddressBook `json:"list,omitempty"`
- NotFound []string `json:"notFound,omitempty"`
-}
-
type ContactCardComparator struct {
// The name of the property on the objects to compare.
Property string `json:"property,omitempty"`
@@ -5373,7 +5536,7 @@ type CalendarEventParseCommand struct {
AccountId string `json:"accountId"`
// The ids of the blobs to parse
- BlobIDs []string `json:"blobIds,omitempty"`
+ BlobIds []string `json:"blobIds,omitempty"`
// If supplied, only the properties listed in the array are returned for each CalendarEvent object.
//
@@ -5397,6 +5560,428 @@ type CalendarEventParseResponse struct {
NotParsable []string `json:"notParsable,omitempty"`
}
+type CalendarGetCommand struct {
+ AccountId string `json:"accountId"`
+ Ids []string `json:"ids,omitempty"`
+}
+
+type CalendarGetResponse struct {
+ AccountId string `json:"accountId"`
+ State State `json:"state,omitempty"`
+ List []Calendar `json:"list,omitempty"`
+ NotFound []string `json:"notFound,omitempty"`
+}
+
+type CalendarEventComparator struct {
+ // The name of the property on the objects to compare.
+ Property string `json:"property,omitempty"`
+
+ // If true, sort in ascending order.
+ //
+ // Optional; default value: true.
+ //
+ // If false, reverse the comparator’s results to sort in descending order.
+ IsAscending bool `json:"isAscending,omitempty"`
+
+ // The identifier, as registered in the collation registry defined in [RFC4790],
+ // for the algorithm to use when comparing the order of strings.
+ //
+ // Optional; default is server dependent.
+ //
+ // The algorithms the server supports are advertised in the capabilities object returned
+ // with the Session object.
+ //
+ // [RFC4790]: https://www.rfc-editor.org/rfc/rfc4790.html
+ Collation string `json:"collation,omitempty"`
+
+ // CalendarEvent-specific: If true, the server will expand any recurring event.
+ //
+ // If true, the filter MUST be just a FilterCondition (not a FilterOperator) and MUST include both
+ // a “before” and “after” property. This ensures the server is not asked to return an infinite number of results.
+ // default: false
+ ExpandRecurrences bool `json:"expandRecurrences,omitzero"`
+
+ // CalendarEvent-specific: The time zone for before/after filter conditions.
+ // default: “Etc/UTC”
+ TimeZone string `json:"timeZone,omitempty"`
+}
+
+type CalendarEventFilterElement interface {
+ _isACalendarEventFilterElement() // marker method
+ IsNotEmpty() bool
+}
+
+type CalendarEventFilterCondition struct {
+ // A calendar id.
+ // An event must be in this calendar to match the condition.
+ InCalendar string `json:"inCalendar,omitempty"`
+
+ // The end of the event, or any recurrence of the event, in the time zone given as
+ // the timeZone argument, must be after this date to match the condition.
+ After LocalDate `json:"after,omitzero"`
+
+ // The start of the event, or any recurrence of the event, in the time zone given
+ // as the timeZone argument, must be before this date to match the condition.
+ Before LocalDate `json:"before,omitzero"`
+
+ // Looks for the text in the title, description, locations (matching name/description),
+ // participants (matching name/email) and any other textual properties of the event
+ // or any recurrence of the event.
+ Text string `json:"text,omitempty"`
+
+ // Looks for the text in the title property of the event, or the overridden title
+ // property of a recurrence.
+ Title string `json:"title,omitempty"`
+
+ // Looks for the text in the description property of the event, or the overridden
+ // description property of a recurrence.
+ Description string `json:"description,omitempty"`
+
+ // Looks for the text in the locations property of the event (matching name/description
+ // of a location), or the overridden locations property of a recurrence.
+ Location string `json:"location,omitempty"`
+
+ // Looks for the text in the name or email fields of a participant in the participants
+ // property of the event, or the overridden participants property of a recurrence,
+ // where the participant has a role of “owner”.
+ Owner string `json:"owner,omitempty"`
+
+ // Looks for the text in the name or email fields of a participant in the participants
+ // property of the event, or the overridden participants property of a recurrence,
+ // where the participant has a role of “attendee”.
+ Attendee string `json:"attendee,omitempty"`
+
+ // Must match. If owner/attendee condition, status must be of that participant. Otherwise any.
+ ParticipationStatus string `json:"participationStatus,omitempty"`
+
+ // The uid of the event is exactly the given string.
+ Uid string `json:"uid,omitempty"`
+}
+
+func (f CalendarEventFilterCondition) _isACalendarEventFilterElement() {
+}
+
+func (f CalendarEventFilterCondition) IsNotEmpty() bool {
+ if f.InCalendar != "" {
+ return true
+ }
+ if !f.After.IsZero() {
+ return true
+ }
+ if !f.Before.IsZero() {
+ return true
+ }
+ if f.Text != "" {
+ return true
+ }
+ if f.Title != "" {
+ return true
+ }
+ if f.Description != "" {
+ return true
+ }
+ if f.Location != "" {
+ return true
+ }
+ if f.Owner != "" {
+ return true
+ }
+ if f.Attendee != "" {
+ return true
+ }
+ if f.ParticipationStatus != "" {
+ return true
+ }
+ if f.Uid != "" {
+ return true
+ }
+ return false
+}
+
+var _ CalendarEventFilterElement = &CalendarEventFilterCondition{}
+
+type CalendarEventFilterOperator struct {
+ Operator FilterOperatorTerm `json:"operator"`
+ Conditions []CalendarEventFilterElement `json:"conditions,omitempty"`
+}
+
+func (o CalendarEventFilterOperator) _isACalendarEventFilterElement() {
+}
+
+func (o CalendarEventFilterOperator) IsNotEmpty() bool {
+ return len(o.Conditions) > 0
+}
+
+var _ CalendarEventFilterElement = &CalendarEventFilterOperator{}
+
+type CalendarEventQueryCommand struct {
+ AccountId string `json:"accountId"`
+
+ Filter CalendarEventFilterElement `json:"filter,omitempty"`
+
+ Sort []CalendarEventComparator `json:"sort,omitempty"`
+
+ // The zero-based index of the first id in the full list of results to return.
+ //
+ // If a negative value is given, it is an offset from the end of the list.
+ // Specifically, the negative value MUST be added to the total number of results given
+ // the filter, and if still negative, it’s clamped to 0. This is now the zero-based
+ // index of the first id to return.
+ //
+ // If the index is greater than or equal to the total number of objects in the results
+ // list, then the ids array in the response will be empty, but this is not an error.
+ Position uint `json:"position,omitempty"`
+
+ // An Email id.
+ //
+ // If supplied, the position argument is ignored.
+ // The index of this id in the results will be used in combination with the anchorOffset
+ // argument to determine the index of the first result to return.
+ Anchor string `json:"anchor,omitempty"`
+
+ // The index of the first result to return relative to the index of the anchor,
+ // if an anchor is given.
+ //
+ // Default: 0.
+ //
+ // This MAY be negative.
+ //
+ // For example, -1 means the Email immediately preceding the anchor is the first result in
+ // the list returned.
+ AnchorOffset int `json:"anchorOffset,omitzero"`
+
+ // The maximum number of results to return.
+ //
+ // If null, no limit presumed.
+ // The server MAY choose to enforce a maximum limit argument.
+ // In this case, if a greater value is given (or if it is null), the limit is clamped
+ // to the maximum; the new limit is returned with the response so the client is aware.
+ //
+ // If a negative value is given, the call MUST be rejected with an invalidArguments error.
+ Limit uint `json:"limit,omitempty"`
+
+ // Does the client wish to know the total number of results in the query?
+ //
+ // This may be slow and expensive for servers to calculate, particularly with complex filters,
+ // so clients should take care to only request the total when needed.
+ CalculateTotal bool `json:"calculateTotal,omitempty"`
+}
+
+type CalendarEventQueryResponse struct {
+ // The id of the account used for the call.
+ AccountId string `json:"accountId"`
+
+ // A string encoding the current state of the query on the server.
+ //
+ // This string MUST change if the results of the query (i.e., the matching ids and their sort order) have changed.
+ // The queryState string MAY change if something has changed on the server, which means the results may have changed
+ // but the server doesn’t know for sure.
+ //
+ // The queryState string only represents the ordered list of ids that match the particular query (including its sort/filter).
+ // There is no requirement for it to change if a property on an object matching the query changes but the query results are unaffected
+ // (indeed, it is more efficient if the queryState string does not change in this case).
+ //
+ // The queryState string only has meaning when compared to future responses to a query with the same type/sort/filter or when used with
+ // /queryChanges to fetch changes.
+ //
+ // Should a client receive back a response with a different queryState string to a previous call, it MUST either throw away the currently
+ // cached query and fetch it again (note, this does not require fetching the records again, just the list of ids) or call
+ // CalendarEvent/queryChanges to get the difference.
+ QueryState State `json:"queryState"`
+
+ // This is true if the server supports calling CalendarEvent/queryChanges with these filter/sort parameters.
+ //
+ // Note, this does not guarantee that the CalendarEvent/queryChanges call will succeed, as it may only be possible for a limited time
+ // afterwards due to server internal implementation details.
+ CanCalculateChanges bool `json:"canCalculateChanges"`
+
+ // The zero-based index of the first result in the ids array within the complete list of query results.
+ Position uint `json:"position"`
+
+ // The list of ids for each ContactCard in the query results, starting at the index given by the position argument of this
+ // response and continuing until it hits the end of the results or reaches the limit number of ids.
+ //
+ // If position is >= total, this MUST be the empty list.
+ Ids []string `json:"ids"`
+
+ // The total number of CalendarEvents in the results (given the filter).
+ //
+ // Only if requested.
+ //
+ // This argument MUST be omitted if the calculateTotal request argument is not true.
+ Total uint `json:"total,omitempty,omitzero"`
+
+ // The limit enforced by the server on the maximum number of results to return (if set by the server).
+ //
+ // This is only returned if the server set a limit or used a different limit than that given in the request.
+ Limit uint `json:"limit,omitempty,omitzero"`
+}
+
+type CalendarEventGetCommand struct {
+ // The ids of the CalendarEvent objects to return.
+ //
+ // If null, then all records of the data type are returned, if this is supported for that
+ // data type and the number of records does not exceed the maxObjectsInGet limit.
+ Ids []string `json:"ids,omitempty"`
+
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // If supplied, only the properties listed in the array are returned for each CalendarEvent object.
+ //
+ // The id property of the object is always returned, even if not explicitly requested.
+ //
+ // If an invalid property is requested, the call MUST be rejected with an invalidArguments error.
+ Properties []string `json:"properties,omitempty"`
+}
+
+type CalendarEventGetRefCommand struct {
+ // The ids of the CalendarEvent objects to return.
+ //
+ // If null, then all records of the data type are returned, if this is supported for that
+ // data type and the number of records does not exceed the maxObjectsInGet limit.
+ IdsRef *ResultReference `json:"#ids,omitempty"`
+
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // If supplied, only the properties listed in the array are returned for each CalendarEvent object.
+ //
+ // The id property of the object is always returned, even if not explicitly requested.
+ //
+ // If an invalid property is requested, the call MUST be rejected with an invalidArguments error.
+ Properties []string `json:"properties,omitempty"`
+}
+
+type CalendarEventGetResponse struct {
+ // The id of the account used for the call.
+ AccountId string `json:"accountId"`
+
+ // A (preferably short) string representing the state on the server for all the data of this type
+ // in the account (not just the objects returned in this call).
+ //
+ // If the data changes, this string MUST change.
+ // If the Email data is unchanged, servers SHOULD return the same state string on subsequent requests for this data type.
+ State State `json:"state"`
+
+ // An array of the CalendarEvent objects requested.
+ //
+ // This is the empty array if no objects were found or if the ids argument passed in was also an empty array.
+ //
+ // The results MAY be in a different order to the ids in the request arguments.
+ //
+ // If an identical id is included more than once in the request, the server MUST only include it once in either
+ // the list or the notFound argument of the response.
+ List []CalendarEvent `json:"list"`
+
+ // This array contains the ids passed to the method for records that do not exist.
+ //
+ // The array is empty if all requested ids were found or if the ids argument passed in was either null or an empty array.
+ NotFound []any `json:"notFound"`
+}
+
+type CalendarEventUpdate map[string]any
+
+type CalendarEventSetCommand struct {
+ // The id of the account to use.
+ AccountId string `json:"accountId"`
+
+ // This is a state string as returned by the `CalendarEvent/get` method.
+ //
+ // If supplied, the string must match the current state; otherwise, the method will be aborted and a
+ // `stateMismatch` error returned.
+ //
+ // If null, any changes will be applied to the current state.
+ IfInState string `json:"ifInState,omitempty"`
+
+ // A map of a creation id (a temporary id set by the client) to CalendarEvent objects,
+ // or null if no objects are to be created.
+ //
+ // The CalendarEvent object type definition may define default values for properties.
+ //
+ // Any such property may be omitted by the client.
+ //
+ // The client MUST omit any properties that may only be set by the server.
+ Create map[string]CalendarEvent `json:"create,omitempty"`
+
+ // A map of an id to a `Patch` object to apply to the current Email object with that id,
+ // or null if no objects are to be updated.
+ //
+ // A `PatchObject` is of type `String[*]` and represents an unordered set of patches.
+ //
+ // The keys are a path in JSON Pointer Format [@!RFC6901], with an implicit leading `/` (i.e., prefix each key
+ // with `/` before applying the JSON Pointer evaluation algorithm).
+ //
+ // All paths MUST also conform to the following restrictions; if there is any violation, the update
+ // MUST be rejected with an `invalidPatch` error:
+ // !- The pointer MUST NOT reference inside an array (i.e., you MUST NOT insert/delete from an array; the array MUST be replaced in its entirety instead).
+ // !- All parts prior to the last (i.e., the value after the final slash) MUST already exist on the object being patched.
+ // !- There MUST NOT be two patches in the `PatchObject` where the pointer of one is the prefix of the pointer of the other, e.g., `"alerts/1/offset"` and `"alerts"`.
+ //
+ // The value associated with each pointer determines how to apply that patch:
+ // !- If null, set to the default value if specified for this property; otherwise, remove the property from the patched object. If the key is not present in the parent, this a no-op.
+ // !- Anything else: The value to set for this property (this may be a replacement or addition to the object being patched).
+ //
+ // Any server-set properties MAY be included in the patch if their value is identical to the current server value
+ // (before applying the patches to the object). Otherwise, the update MUST be rejected with an `invalidProperties` `SetError`.
+ //
+ // This patch definition is designed such that an entire Email object is also a valid `PatchObject`.
+ //
+ // The client may choose to optimise network usage by just sending the diff or may send the whole object; the server
+ // processes it the same either way.
+ Update map[string]CalendarEventUpdate `json:"update,omitempty"`
+
+ // A list of ids for CalendarEvent objects to permanently delete, or null if no objects are to be destroyed.
+ Destroy []string `json:"destroy,omitempty"`
+}
+
+type CalendarEventSetResponse struct {
+ // The id of the account used for the call.
+ AccountId string `json:"accountId"`
+
+ // The state string that would have been returned by CalendarEvent/get before making the
+ // requested changes, or null if the server doesn’t know what the previous state
+ // string was.
+ OldState State `json:"oldState,omitempty"`
+
+ // The state string that will now be returned by Email/get.
+ NewState State `json:"newState"`
+
+ // A map of the creation id to an object containing any properties of the created Email object
+ // that were not sent by the client.
+ //
+ // This includes all server-set properties (such as the id in most object types) and any properties
+ // that were omitted by the client and thus set to a default by the server.
+ //
+ // This argument is null if no CalendarEvent objects were successfully created.
+ Created map[string]*CalendarEvent `json:"created,omitempty"`
+
+ // The keys in this map are the ids of all CalendarEvents that were successfully updated.
+ //
+ // The value for each id is an CalendarEvent object containing any property that changed in a way not
+ // explicitly requested by the PatchObject sent to the server, or null if none.
+ //
+ // This lets the client know of any changes to server-set or computed properties.
+ //
+ // This argument is null if no CalendarEvent objects were successfully updated.
+ Updated map[string]*CalendarEvent `json:"updated,omitempty"`
+
+ // A list of CalendarEvent ids for records that were successfully destroyed, or null if none.
+ Destroyed []string `json:"destroyed,omitempty"`
+
+ // A map of the creation id to a SetError object for each record that failed to be created,
+ // or null if all successful.
+ NotCreated map[string]SetError `json:"notCreated,omitempty"`
+
+ // A map of the ContactCard id to a SetError object for each record that failed to be updated,
+ // or null if all successful.
+ NotUpdated map[string]SetError `json:"notUpdated,omitempty"`
+
+ // A map of the CalendarEvent id to a SetError object for each record that failed to be destroyed,
+ // or null if all successful.
+ NotDestroyed map[string]SetError `json:"notDestroyed,omitempty"`
+}
+
type ErrorResponse struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
@@ -5429,6 +6014,10 @@ const (
CommandContactCardGet Command = "ContactCard/get"
CommandContactCardSet Command = "ContactCard/set"
CommandCalendarEventParse Command = "CalendarEvent/parse"
+ CommandCalendarGet Command = "Calendar/get"
+ CommandCalendarEventQuery Command = "CalendarEvent/query"
+ CommandCalendarEventGet Command = "CalendarEvent/get"
+ CommandCalendarEventSet Command = "CalendarEvent/set"
)
var CommandResponseTypeMap = map[Command]func() any{
@@ -5457,4 +6046,8 @@ var CommandResponseTypeMap = map[Command]func() any{
CommandContactCardGet: func() any { return ContactCardGetResponse{} },
CommandContactCardSet: func() any { return ContactCardSetResponse{} },
CommandCalendarEventParse: func() any { return CalendarEventParseResponse{} },
+ CommandCalendarGet: func() any { return CalendarGetResponse{} },
+ CommandCalendarEventQuery: func() any { return CalendarEventQueryResponse{} },
+ CommandCalendarEventGet: func() any { return CalendarEventGetResponse{} },
+ CommandCalendarEventSet: func() any { return CalendarEventSetResponse{} },
}
diff --git a/pkg/jmap/jmap_test.go b/pkg/jmap/jmap_test.go
index 47b0608c0d..195eb1f883 100644
--- a/pkg/jmap/jmap_test.go
+++ b/pkg/jmap/jmap_test.go
@@ -16,6 +16,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/stretchr/testify/require"
)
@@ -320,3 +321,407 @@ func TestEmailFilterSerialization(t *testing.T) {
json := string(b)
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 := `
+{
+ "locale" : "en-US",
+ "description" : "Internal meeting about the grand strategy for the future",
+ "locations" : {
+ "ux1uokie" : {
+ "links" : {
+ "eefe2pax" : {
+ "@type" : "Link",
+ "href" : "https://example.com/office"
+ }
+ },
+ "iCalComponent" : {
+ "name" : "vlocation"
+ },
+ "@type" : "Location",
+ "description" : "Office meeting room upstairs",
+ "name" : "Office",
+ "locationTypes" : {
+ "office" : true
+ },
+ "coordinates" : "geo:52.5334956,13.4079872",
+ "relativeTo" : "start",
+ "timeZone" : "CEST"
+ }
+ },
+ "replyTo" : {
+ "imip" : "mailto:organizer@example.com"
+ },
+ "links" : {
+ "cai0thoh" : {
+ "href" : "https://example.com/9a7ab91a-edca-4988-886f-25e00743430d",
+ "rel" : "about",
+ "contentType" : "text/html",
+ "@type" : "Link"
+ }
+ },
+ "prodId" : "Mock 0.0",
+ "@type" : "Event",
+ "keywords" : {
+ "secret" : true,
+ "meeting" : true
+ },
+ "status" : "confirmed",
+ "freeBusyStatus" : "busy",
+ "categories" : {
+ "internal" : true,
+ "secret" : true
+ },
+ "duration" : "PT30M",
+ "calendarIds" : {
+ "b" : true
+ },
+ "alerts" : {
+ "ahqu4xi0" : {
+ "@type" : "Alert"
+ }
+ },
+ "start" : "2025-09-30T12:00:00",
+ "privacy" : "public",
+ "isDraft" : false,
+ "id" : "c",
+ "isOrigin" : true,
+ "sentBy" : "organizer@example.com",
+ "descriptionContentType" : "text/plain",
+ "updated" : "2025-09-29T16:17:18Z",
+ "created" : "2025-09-29T16:17:18Z",
+ "color" : "purple",
+ "recurrenceRule" : {
+ "skip" : "omit",
+ "count" : 4,
+ "firstDayOfWeek" : "monday",
+ "rscale" : "iso8601",
+ "interval" : 1,
+ "frequency" : "weekly"
+ },
+ "timeZone" : "Etc/UTC",
+ "title" : "Meeting of the Minds",
+ "participants" : {
+ "xeikie9p" : {
+ "name" : "Klaes Ashford",
+ "locationId" : "em4eal0o",
+ "language" : "en-GB",
+ "description" : "As the first officer on the Behemoth",
+ "invitedBy" : "eegh7uph",
+ "@type" : "Participant",
+ "links" : {
+ "oifooj6g" : {
+ "@type" : "Link",
+ "contentType" : "image/png",
+ "display" : "badge",
+ "href" : "https://static.wikia.nocookie.net/expanse/images/0/02/Klaes_Ashford_-_Expanse_season_4_promotional_2.png/revision/latest?cb=20191206012007",
+ "rel" : "icon",
+ "title" : "Ashford on Medina Station"
+ }
+ },
+ "iCalComponent" : {
+ "name" : "participant"
+ },
+ "scheduleAgent" : "server",
+ "scheduleId" : "mailto:ashford@opa.org"
+ },
+ "eegh7uph" : {
+ "description" : "Called the meeting",
+ "language" : "en-GB",
+ "locationId" : "ux1uokie",
+ "name" : "Anderson Dawes",
+ "scheduleAgent" : "server",
+ "scheduleUpdated" : "2025-10-01T11:59:12Z",
+ "links" : {
+ "ieni5eiw" : {
+ "href" : "https://static.wikia.nocookie.net/expanse/images/1/1e/OPA_leader.png/revision/latest?cb=20250121103410",
+ "display" : "badge",
+ "rel" : "icon",
+ "title" : "Anderson Dawes",
+ "contentType" : "image/png",
+ "@type" : "Link"
+ }
+ },
+ "iCalComponent" : {
+ "name" : "participant"
+ },
+ "@type" : "Participant",
+ "invitedBy" : "eegh7uph",
+ "scheduleSequence" : 1,
+ "sendTo" : {
+ "imip" : "mailto:adawes@opa.org"
+ },
+ "scheduleStatus" : [
+ "1.0"
+ ],
+ "scheduleId" : "mailto:adawes@opa.org"
+ }
+ },
+ "uid" : "9a7ab91a-edca-4988-886f-25e00743430d",
+ "virtualLocations" : {
+ "em4eal0o" : {
+ "@type" : "VirtualLocation",
+ "description" : "The opentalk Conference Room",
+ "uri" : "https://meet.opentalk.eu",
+ "features" : {
+ "audio" : true,
+ "screen" : true,
+ "chat" : true,
+ "video" : true
+ },
+ "name" : "opentalk"
+ }
+ }
+}
+ `
+ require := require.New(t)
+ var result CalendarEvent
+ err := json.Unmarshal([]byte(payload), &result)
+ require.NoError(err)
+
+ require.Len(result.VirtualLocations, 1)
+ require.Len(result.Locations, 1)
+ require.Equal("9a7ab91a-edca-4988-886f-25e00743430d", result.Uid)
+ require.Equal(jscalendar.PrivacyPublic, result.Privacy)
+}
+
+func TestUnmarshallingCalendarEventGetResponse(t *testing.T) {
+ payload := `
+{
+ "sessionState" : "7d3cae5b",
+ "methodResponses" : [
+ [
+ "CalendarEvent/query",
+ {
+ "position" : 0,
+ "queryState" : "s2yba",
+ "accountId" : "b",
+ "canCalculateChanges" : true,
+ "ids" : [
+ "c"
+ ]
+ },
+ "b:0"
+ ],
+ [
+ "CalendarEvent/get",
+ {
+ "state" : "s2yba",
+ "list" : [
+ {
+ "links" : {
+ "cai0thoh" : {
+ "contentType" : "text/html",
+ "href" : "https://example.com/9a7ab91a-edca-4988-886f-25e00743430d",
+ "@type" : "Link",
+ "rel" : "about"
+ }
+ },
+ "freeBusyStatus" : "busy",
+ "color" : "purple",
+ "isDraft" : false,
+ "calendarIds" : {
+ "b" : true
+ },
+ "updated" : "2025-09-29T16:17:18Z",
+ "locations" : {
+ "ux1uokie" : {
+ "relativeTo" : "start",
+ "description" : "Office meeting room upstairs",
+ "coordinates" : "geo:52.5334956,13.4079872",
+ "name" : "Office",
+ "locationTypes" : {
+ "office" : true
+ },
+ "links" : {
+ "eefe2pax" : {
+ "href" : "https://example.com/office",
+ "@type" : "Link"
+ }
+ },
+ "iCalComponent" : {
+ "name" : "vlocation"
+ },
+ "@type" : "Location",
+ "timeZone" : "CEST"
+ }
+ },
+ "virtualLocations" : {
+ "em4eal0o" : {
+ "name" : "opentalk",
+ "@type" : "VirtualLocation",
+ "features" : {
+ "screen" : true,
+ "chat" : true,
+ "audio" : true,
+ "video" : true
+ },
+ "uri" : "https://meet.opentalk.eu",
+ "description" : "The opentalk Conference Room"
+ }
+ },
+ "uid" : "9a7ab91a-edca-4988-886f-25e00743430d",
+ "categories" : {
+ "secret" : true,
+ "internal" : true
+ },
+ "keywords" : {
+ "secret" : true,
+ "meeting" : true
+ },
+ "replyTo" : {
+ "imip" : "mailto:organizer@example.com"
+ },
+ "duration" : "PT30M",
+ "created" : "2025-09-29T16:17:18Z",
+ "start" : "2025-09-30T12:00:00",
+ "id" : "c",
+ "sentBy" : "organizer@example.com",
+ "timeZone" : "Etc/UTC",
+ "@type" : "Event",
+ "title" : "Meeting of the Minds",
+ "alerts" : {
+ "ahqu4xi0" : {
+ "@type" : "Alert"
+ }
+ },
+ "participants" : {
+ "xeikie9p" : {
+ "@type" : "Participant",
+ "scheduleId" : "mailto:ashford@opa.org",
+ "invitedBy" : "eegh7uph",
+ "language" : "en-GB",
+ "links" : {
+ "oifooj6g" : {
+ "contentType" : "image/png",
+ "display" : "badge",
+ "title" : "Ashford on Medina Station",
+ "@type" : "Link",
+ "href" : "https://static.wikia.nocookie.net/expanse/images/0/02/Klaes_Ashford_-_Expanse_season_4_promotional_2.png/revision/latest?cb=20191206012007",
+ "rel" : "icon"
+ }
+ },
+ "iCalComponent" : {
+ "name" : "participant"
+ },
+ "scheduleAgent" : "server",
+ "name" : "Klaes Ashford",
+ "locationId" : "em4eal0o",
+ "description" : "As the first officer on the Behemoth"
+ },
+ "eegh7uph" : {
+ "description" : "Called the meeting",
+ "locationId" : "ux1uokie",
+ "scheduleUpdated" : "2025-10-01T11:59:12Z",
+ "sendTo" : {
+ "imip" : "mailto:adawes@opa.org"
+ },
+ "scheduleAgent" : "server",
+ "scheduleStatus" : [
+ "1.0"
+ ],
+ "name" : "Anderson Dawes",
+ "invitedBy" : "eegh7uph",
+ "language" : "en-GB",
+ "links" : {
+ "ieni5eiw" : {
+ "rel" : "icon",
+ "display" : "badge",
+ "@type" : "Link",
+ "title" : "Anderson Dawes",
+ "href" : "https://static.wikia.nocookie.net/expanse/images/1/1e/OPA_leader.png/revision/latest?cb=20250121103410",
+ "contentType" : "image/png"
+ }
+ },
+ "iCalComponent" : {
+ "name" : "participant"
+ },
+ "scheduleSequence" : 1,
+ "scheduleId" : "mailto:adawes@opa.org",
+ "@type" : "Participant"
+ }
+ },
+ "status" : "confirmed",
+ "description" : "Internal meeting about the grand strategy for the future",
+ "locale" : "en-US",
+ "recurrenceRule" : {
+ "count" : 4,
+ "rscale" : "iso8601",
+ "frequency" : "weekly",
+ "interval" : 1,
+ "firstDayOfWeek" : "monday",
+ "skip" : "omit"
+ },
+ "descriptionContentType" : "text/plain",
+ "isOrigin" : true,
+ "prodId" : "Mock 0.0",
+ "privacy" : "public"
+ }
+ ],
+ "accountId" : "b",
+ "notFound" : []
+ },
+ "b:1"
+ ]
+ ]
+}
+ `
+
+ require := require.New(t)
+ var response Response
+ err := json.Unmarshal([]byte(payload), &response)
+ require.NoError(err)
+ r1 := response.MethodResponses[1]
+ require.Equal(CommandCalendarEventGet, r1.Command)
+ get := r1.Parameters.(CalendarEventGetResponse)
+ require.Len(get.List, 1)
+ result := get.List[0]
+ require.Len(result.VirtualLocations, 1)
+ require.Len(result.Locations, 1)
+ require.Equal("9a7ab91a-edca-4988-886f-25e00743430d", result.Uid)
+ require.Equal(jscalendar.PrivacyPublic, result.Privacy)
+}
diff --git a/pkg/jmap/jmap_tools.go b/pkg/jmap/jmap_tools.go
index a607767c49..538f8bb0b3 100644
--- a/pkg/jmap/jmap_tools.go
+++ b/pkg/jmap/jmap_tools.go
@@ -165,6 +165,7 @@ func decodeMap(input map[string]any, target any) error {
ErrorUnused: false,
ErrorUnset: false,
IgnoreUntaggedFields: false,
+ Squash: true,
})
if err != nil {
return err
diff --git a/pkg/jscalendar/jscalendar_model.go b/pkg/jscalendar/jscalendar_model.go
index 034fa7c9ac..acc6701c32 100644
--- a/pkg/jscalendar/jscalendar_model.go
+++ b/pkg/jscalendar/jscalendar_model.go
@@ -11,9 +11,12 @@ import (
// It is otherwise in the same format as `UTCDateTime`, including fractional seconds.
//
// For example, `2006-01-02T15:04:05` and `2006-01-02T15:04:05.003` are both valid.
+/*
type LocalDateTime struct {
time.Time
}
+*/
+type LocalDateTime string
type TypeOfRelation string
type TypeOfLink string
@@ -730,6 +733,7 @@ var (
}
)
+/*
const RFC3339Local = "2006-01-02T15:04:05"
func (t LocalDateTime) MarshalJSON() ([]byte, error) {
@@ -737,14 +741,19 @@ func (t LocalDateTime) MarshalJSON() ([]byte, error) {
}
func (t *LocalDateTime) UnmarshalJSON(b []byte) error {
+ str := string(b)
+ if strings.HasPrefix(str, "\"") && !strings.HasSuffix(str, "Z\"") {
+ str = str[0:len(str)-1] + "Z\""
+ }
var tt time.Time
- err := tt.UnmarshalJSON(b)
+ err := tt.UnmarshalJSON([]byte(str))
if err != nil {
return err
}
t.Time = tt.UTC()
return nil
}
+*/
// A `PatchObject` is of type `String[*]` and represents an unordered set of patches on a JSON object.
//
@@ -1834,7 +1843,9 @@ type Object struct {
//
// If multiple recurrence rules are given, each rule is to be applied, and then the union of the results are used,
// ignoring any duplicates.
- RecurrenceRules []RecurrenceRule `json:"recurrenceRules,omitempty"`
+ //
+ // TODO UPDATE RFC
+ RecurrenceRule *RecurrenceRule `json:"recurrenceRule,omitempty"`
// This defines a set of recurrence rules (repeating patterns) for date-times on which the object will not occur.
//
diff --git a/pkg/jscalendar/jscalendar_model_test.go b/pkg/jscalendar/jscalendar_model_test.go
index 5061927a5b..1da7ac275d 100644
--- a/pkg/jscalendar/jscalendar_model_test.go
+++ b/pkg/jscalendar/jscalendar_model_test.go
@@ -19,6 +19,7 @@ func jsoneq[X any](t *testing.T, expected string, object X) {
require.Equal(t, object, rec)
}
+/*
func TestLocalDateTime(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
@@ -42,6 +43,7 @@ func TestLocalDateTimeUnmarshalling(t *testing.T) {
require.Equal(t, result, LocalDateTime{u})
}
+*/
func TestRelation(t *testing.T) {
jsoneq(t, `{
@@ -171,6 +173,7 @@ func TestRecurrenceRule(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
ts = ts.UTC()
+ l := LocalDateTime("2025-09-25T16:26:14")
jsoneq(t, `{
"@type": "RecurrenceRule",
@@ -193,7 +196,7 @@ func TestRecurrenceRule(t *testing.T) {
"bySecond": [0, 39],
"bySetPosition": [-3, 3],
"count": 2,
- "until": "2025-09-25T16:26:14Z"
+ "until": "2025-09-25T16:26:14"
}`, RecurrenceRule{
Type: RecurrenceRuleType,
Frequency: FrequencyDaily,
@@ -225,7 +228,7 @@ func TestRecurrenceRule(t *testing.T) {
BySecond: []uint{0, 39},
BySetPosition: []int{-3, 3},
Count: 2,
- Until: &LocalDateTime{ts},
+ Until: &l,
})
}
@@ -476,15 +479,11 @@ func TestAlertWithUnknownTrigger(t *testing.T) {
}
func TestTimeZoneRule(t *testing.T) {
- ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
- require.NoError(t, err)
- ts = ts.UTC()
-
- l1 := LocalDateTime{ts}
+ l1 := LocalDateTime("2025-09-25T18:26:14")
jsoneq(t, `{
"@type": "TimeZoneRule",
- "start": "2025-09-25T16:26:14Z",
+ "start": "2025-09-25T16:26:14",
"offsetFrom": "-0200",
"offsetTo": "+0200",
"recurrenceRules": [
@@ -507,7 +506,7 @@ func TestTimeZoneRule(t *testing.T) {
}
],
"recurrenceOverrides": {
- "2025-09-25T16:26:14Z": {}
+ "2025-09-25T16:26:14": {}
},
"names": {
"CEST": true
@@ -515,7 +514,7 @@ func TestTimeZoneRule(t *testing.T) {
"comments": ["this is a comment"]
}`, TimeZoneRule{
Type: TimeZoneRuleType,
- Start: LocalDateTime{ts},
+ Start: l1,
OffsetFrom: "-0200",
OffsetTo: "+0200",
RecurrenceRules: []RecurrenceRule{
@@ -557,6 +556,7 @@ func TestTimeZone(t *testing.T) {
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
require.NoError(t, err)
ts = ts.UTC()
+ l := LocalDateTime("2025-09-25T18:26:14")
jsoneq(t, `{
"@type": "TimeZone",
@@ -591,7 +591,7 @@ func TestTimeZone(t *testing.T) {
Standard: []TimeZoneRule{
{
Type: TimeZoneRuleType,
- Start: LocalDateTime{ts},
+ Start: l,
OffsetFrom: "-0200",
OffsetTo: "+1245",
},
@@ -599,7 +599,7 @@ func TestTimeZone(t *testing.T) {
Daylight: []TimeZoneRule{
{
Type: TimeZoneRuleType,
- Start: LocalDateTime{ts},
+ Start: l,
OffsetFrom: "-0200",
OffsetTo: "+1245",
},
@@ -616,6 +616,8 @@ func TestEvent(t *testing.T) {
require.NoError(t, err)
ts2 = ts2.UTC()
+ l := LocalDateTime("2025-09-25T18:26:14")
+
jsoneq(t, `{
"@type": "Event",
"start": "2025-09-25T16:26:14Z",
@@ -683,7 +685,7 @@ func TestEvent(t *testing.T) {
}
}`, Event{
Type: EventType,
- Start: LocalDateTime{ts1},
+ Start: l,
Duration: "PT10M",
Status: "confirmed",
Object: Object{
diff --git a/services/groupware/pkg/groupware/groupware_api_calendars.go b/services/groupware/pkg/groupware/groupware_api_calendars.go
index a1d698d623..56b44afd9e 100644
--- a/services/groupware/pkg/groupware/groupware_api_calendars.go
+++ b/services/groupware/pkg/groupware/groupware_api_calendars.go
@@ -31,9 +31,13 @@ func (g *Groupware) GetCalendars(w http.ResponseWriter, r *http.Request) {
if !ok {
return resp
}
- var _ string = accountId
- return response(AllCalendars, req.session.State, "")
+ calendars, sessionState, state, lang, jerr := g.jmap.GetCalendars(accountId, req.session, req.ctx, req.logger, req.language(), nil)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ return etagResponse(calendars, sessionState, state, lang)
})
}
@@ -61,16 +65,23 @@ func (g *Groupware) GetCalendarById(w http.ResponseWriter, r *http.Request) {
if !ok {
return resp
}
- var _ string = accountId
+
+ l := req.logger.With()
calendarId := chi.URLParam(r, UriParamCalendarId)
- // TODO replace with proper implementation
- for _, calendar := range AllCalendars {
- if calendar.Id == calendarId {
- return response(calendar, req.session.State, "")
- }
+ l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
+
+ logger := log.From(l)
+ calendars, sessionState, state, lang, jerr := g.jmap.GetCalendars(accountId, req.session, req.ctx, logger, req.language(), []string{calendarId})
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ if len(calendars.NotFound) > 0 {
+ return notFoundResponse(sessionState)
+ } else {
+ return etagResponse(calendars.Calendars[0], sessionState, state, lang)
}
- return notFoundResponse(req.session.State)
})
}
@@ -96,15 +107,109 @@ func (g *Groupware) GetEventsInCalendar(w http.ResponseWriter, r *http.Request)
if !ok {
return resp
}
- var _ string = accountId
+
+ l := req.logger.With()
calendarId := chi.URLParam(r, UriParamCalendarId)
- // TODO replace with proper implementation
- events, ok := EventsMapByCalendarId[calendarId]
- if !ok {
- return notFoundResponse(req.session.State)
+ l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
+
+ offset, ok, err := req.parseUIntParam(QueryParamOffset, 0)
+ if err != nil {
+ return errorResponse(err)
}
- return response(events, req.session.State, "")
+ if ok {
+ l = l.Uint(QueryParamOffset, offset)
+ }
+
+ limit, ok, err := req.parseUIntParam(QueryParamLimit, g.defaultContactLimit)
+ if err != nil {
+ return errorResponse(err)
+ }
+ if ok {
+ l = l.Uint(QueryParamLimit, limit)
+ }
+
+ filter := jmap.CalendarEventFilterCondition{
+ InCalendar: calendarId,
+ }
+ sortBy := []jmap.CalendarEventComparator{{Property: jmap.CalendarEventPropertyUpdated, IsAscending: false}}
+
+ logger := log.From(l)
+ eventsByAccountId, sessionState, state, lang, jerr := g.jmap.QueryCalendarEvents([]string{accountId}, req.session, req.ctx, logger, req.language(), filter, sortBy, offset, limit)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ if events, ok := eventsByAccountId[accountId]; ok {
+ return etagResponse(events, sessionState, state, lang)
+ } else {
+ return notFoundResponse(sessionState)
+ }
+ })
+}
+
+func (g *Groupware) CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ ok, accountId, resp := req.needCalendarWithAccount()
+ if !ok {
+ return resp
+ }
+
+ l := req.logger.With()
+
+ calendarId := chi.URLParam(r, UriParamCalendarId)
+ l = l.Str(UriParamCalendarId, log.SafeString(calendarId))
+
+ var create jmap.CalendarEvent
+ err := req.body(&create)
+ if err != nil {
+ return errorResponse(err)
+ }
+
+ logger := log.From(l)
+ created, sessionState, state, lang, jerr := g.jmap.CreateCalendarEvent(accountId, req.session, req.ctx, logger, req.language(), create)
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+ return etagResponse(created, sessionState, state, lang)
+ })
+}
+
+func (g *Groupware) DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
+ g.respond(w, r, func(req Request) Response {
+ ok, accountId, resp := req.needCalendarWithAccount()
+ if !ok {
+ return resp
+ }
+ l := req.logger.With().Str(accountId, log.SafeString(accountId))
+
+ calendarId := chi.URLParam(r, UriParamCalendarId)
+ eventId := chi.URLParam(r, UriParamEventId)
+ l.Str(UriParamCalendarId, log.SafeString(calendarId)).Str(UriParamEventId, log.SafeString(eventId))
+
+ logger := log.From(l)
+
+ deleted, sessionState, state, _, jerr := g.jmap.DeleteCalendarEvent(accountId, []string{eventId}, req.session, req.ctx, logger, req.language())
+ if jerr != nil {
+ return req.errorResponseFromJmap(jerr)
+ }
+
+ for _, e := range deleted {
+ desc := e.Description
+ if desc != "" {
+ return errorResponseWithSessionState(apiError(
+ req.errorId(),
+ ErrorFailedToDeleteContact,
+ withDetail(e.Description),
+ ), sessionState)
+ } else {
+ return errorResponseWithSessionState(apiError(
+ req.errorId(),
+ ErrorFailedToDeleteContact,
+ ), sessionState)
+ }
+ }
+ return noContentResponseWithEtag(sessionState, state)
})
}
diff --git a/services/groupware/pkg/groupware/groupware_mock_calendars.go b/services/groupware/pkg/groupware/groupware_mock_calendars.go
index 64505464f7..bbe396b9e7 100644
--- a/services/groupware/pkg/groupware/groupware_mock_calendars.go
+++ b/services/groupware/pkg/groupware/groupware_mock_calendars.go
@@ -73,7 +73,7 @@ var E1 = jmap.CalendarEvent{
UtcEnd: jmap.UTCDate{Time: mustParseTime("2025-10-07T00:00:00Z")},
Event: jscalendar.Event{
Type: jscalendar.EventType,
- Start: jscalendar.LocalDateTime{Time: mustParseTime("2025-09-30T12:00:00Z")},
+ Start: jscalendar.LocalDateTime("2025-09-30T12:00:00"),
Duration: "PT30M",
Status: jscalendar.StatusConfirmed,
Object: jscalendar.Object{
@@ -146,16 +146,14 @@ var E1 = jmap.CalendarEvent{
},
},
},
- RecurrenceRules: []jscalendar.RecurrenceRule{
- {
- Type: jscalendar.RecurrenceRuleType,
- Frequency: jscalendar.FrequencyWeekly,
- Interval: 1,
- Rscale: jscalendar.RscaleIso8601,
- Skip: jscalendar.SkipOmit,
- FirstDayOfWeek: jscalendar.DayOfWeekMonday,
- Count: 4,
- },
+ RecurrenceRule: &jscalendar.RecurrenceRule{
+ Type: jscalendar.RecurrenceRuleType,
+ Frequency: jscalendar.FrequencyWeekly,
+ Interval: 1,
+ Rscale: jscalendar.RscaleIso8601,
+ Skip: jscalendar.SkipOmit,
+ FirstDayOfWeek: jscalendar.DayOfWeekMonday,
+ Count: 4,
},
FreeBusyStatus: jscalendar.FreeBusyStatusBusy,
Privacy: jscalendar.PrivacyPublic,
diff --git a/services/groupware/pkg/groupware/groupware_mock_tasks.go b/services/groupware/pkg/groupware/groupware_mock_tasks.go
index a9a8c8ddd2..37c3d18791 100644
--- a/services/groupware/pkg/groupware/groupware_mock_tasks.go
+++ b/services/groupware/pkg/groupware/groupware_mock_tasks.go
@@ -148,8 +148,8 @@ var T1 = jmap.Task{
MayInviteOthers: true,
HideAttendees: true,
},
- Due: jscalendar.LocalDateTime{Time: mustParseTime("2025-12-01T10:11:12Z")},
- Start: jscalendar.LocalDateTime{Time: mustParseTime("2025-10-01T08:00:00Z")},
+ Due: jscalendar.LocalDateTime("2025-12-01T10:11:12"),
+ Start: jscalendar.LocalDateTime("2025-10-01T08:00:00"),
EstimatedDuration: "PT8W",
PercentComplete: 5,
Progress: jscalendar.ProgressNeedsAction,
diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go
index f5df7f3b39..aedf784adc 100644
--- a/services/groupware/pkg/groupware/groupware_route.go
+++ b/services/groupware/pkg/groupware/groupware_route.go
@@ -23,6 +23,7 @@ const (
UriParamCalendarId = "calendarid"
UriParamTaskListId = "tasklistid"
UriParamContactId = "contactid"
+ UriParamEventId = "eventid"
QueryParamMailboxSearchName = "name"
QueryParamMailboxSearchRole = "role"
QueryParamMailboxSearchSubscribed = "subscribed"
@@ -154,6 +155,10 @@ func (g *Groupware) Route(r chi.Router) {
r.Get("/", g.GetCalendarById)
r.Get("/events", g.GetEventsInCalendar)
})
+ r.Route("/events", func(r chi.Router) {
+ r.Post("/", g.CreateCalendarEvent)
+ r.Delete("/{eventid}", g.DeleteCalendarEvent)
+ })
})
r.Route("/tasklists", func(r chi.Router) {
r.Get("/", g.GetTaskLists)