groupware: add real calendars and events

This commit is contained in:
Pascal Bleser
2025-10-30 15:12:08 +01:00
parent 1d66edf0d0
commit 0e1be0c5cc
10 changed files with 1400 additions and 58 deletions

View File

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

View File

@@ -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 events 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 comparators 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, its 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 doesnt 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 doesnt 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{} },
}

View File

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

View File

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

View File

@@ -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.
//

View File

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