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