mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-02-28 13:06:56 -05:00
groupware: add real calendars and events
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{} },
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user