From e754b5a718fde58d3dba25c63647c1bfa178d8d0 Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Thu, 25 Sep 2025 18:07:50 +0200 Subject: [PATCH] Implement JSContact (RFC9553) Model * add pkg/jscontact with the implementation of the RFC9553 data model * add JMAP Calendar session capabilities support in pkg/jmap --- pkg/jmap/jmap_model.go | 96 ++ pkg/jscontact/jscontact_model.go | 1870 +++++++++++++++++++++++++ pkg/jscontact/jscontact_model_test.go | 1197 ++++++++++++++++ 3 files changed, 3163 insertions(+) create mode 100644 pkg/jscontact/jscontact_model.go create mode 100644 pkg/jscontact/jscontact_model_test.go diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 8f9de603f..6b4663eba 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -12,6 +12,7 @@ const ( JmapSubmission = "urn:ietf:params:jmap:submission" JmapVacationResponse = "urn:ietf:params:jmap:vacationresponse" JmapCalendars = "urn:ietf:params:jmap:calendars" + JmapContacts = "urn:ietf:params:jmap:contacts" JmapSieve = "urn:ietf:params:jmap:sieve" JmapBlob = "urn:ietf:params:jmap:blob" JmapQuota = "urn:ietf:params:jmap:quota" @@ -237,6 +238,17 @@ type SessionBlobAccountCapabilities struct { type SessionQuotaAccountCapabilities struct { } +type SessionContactsAccountCapabilities struct { + // The maximum number of AddressBooks that can be can assigned to a single ContactCard object. + // + // This MUST be an integer >= 1, or null for no limit (or rather, the limit is always the number of AddressBooks + // in the account). + MaxAddressBooksPerCard uint `json:"maxAddressBooksPerCard,omitzero"` + + // If true, the user may create an AddressBook in this account. + MayCreateAddressBook bool `json:"mayCreateAddressBook"` +} + type SessionAccountCapabilities struct { Mail SessionMailAccountCapabilities `json:"urn:ietf:params:jmap:mail"` Submission SessionSubmissionAccountCapabilities `json:"urn:ietf:params:jmap:submission"` @@ -244,6 +256,7 @@ type SessionAccountCapabilities struct { Sieve SessionSieveAccountCapabilities `json:"urn:ietf:params:jmap:sieve"` Blob SessionBlobAccountCapabilities `json:"urn:ietf:params:jmap:blob"` Quota SessionQuotaAccountCapabilities `json:"urn:ietf:params:jmap:quota"` + Contacts SessionContactsAccountCapabilities `json:"urn:ietf:params:jmap:contacts"` } type SessionAccount struct { @@ -310,6 +323,9 @@ type SessionWebsocketCapabilities struct { SupportsPush bool `json:"supportsPush"` } +type SessionContactsCapabilities struct { +} + type SessionCapabilities struct { Core SessionCoreCapabilities `json:"urn:ietf:params:jmap:core"` Mail SessionMailCapabilities `json:"urn:ietf:params:jmap:mail"` @@ -319,6 +335,7 @@ type SessionCapabilities struct { Blob SessionBlobCapabilities `json:"urn:ietf:params:jmap:blob"` Quota SessionQuotaCapabilities `json:"urn:ietf:params:jmap:quota"` Websocket SessionWebsocketCapabilities `json:"urn:ietf:params:jmap:websocket"` + Contacts SessionContactsCapabilities `json:"urn:ietf:params:jmap:contacts"` } type SessionPrimaryAccounts struct { @@ -2812,6 +2829,85 @@ type StateChange struct { PushState string `json:"pushState"` } +type AddressBookRights struct { + // The user may fetch the ContactCards in this AddressBook. + MayRead bool `json:"mayRead"` + + // The user may create, modify or destroy all ContactCards in this AddressBook, or move them to or from this AddressBook. + MayWrite bool `json:"mayWrite"` + + // The user may modify the “shareWith” property for this AddressBook. + MayAdmin bool `json:"mayAdmin"` + + // The user may delete the AddressBook itself. + MayDelete bool `json:"mayDelete"` +} + +// An AddressBook is a named collection of ContactCards. +// +// All ContactCards are associated with one or more AddressBook. +type AddressBook struct { + // The id of the AddressBook (immutable; server-set). + Id string `json:"id"` + + // The user-visible name of the AddressBook. + // + // This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size. + Name string `json:"name"` + + // An optional longer-form description of the AddressBook, to provide context in shared environments + // where users need more than just the name. + Description string `json:"description,omitempty"` + + // Defines the sort order of AddressBooks when presented in the client’s UI, so it is consistent between devices. + // + // The number MUST be an integer in the range 0 <= sortOrder < 2^31. + // + // An AddressBook with a lower order should be displayed before a AddressBook with a higher order in any list + // of AddressBooks in the client’s UI. + // + // AddressBooks with equal order SHOULD be sorted in alphabetical order by name. + // + // The sorting should take into account locale-specific character order convention. + // + // Default: 0 + SortOrder uint `json:"sortOrder,omitzero"` + + // This SHOULD be true for exactly one AddressBook in any account, and MUST NOT be true for more than one + // AddressBook within an account. + // + // The default AddressBook should be used by clients whenever they need to choose an AddressBook for the user + // within this account, and they do not have any other information on which to make a choice. + // + // For example, if the user creates a new contact card, the client may automatically set the card as belonging + // to the default AddressBook from the user’s primary account. + IsDefault bool `json:"isDefault,omitzero"` + + // True if the user has indicated they wish to see this AddressBook in their client. + // + // This SHOULD default to false for AddressBooks in shared accounts the user has access to and true for any + // new AddressBooks created by the user themself. + // + // If false, the AddressBook and its contents SHOULD only be displayed when the user explicitly requests it + // or to offer it for the user to subscribe to. + IsSubscribed bool `json:"isSubscribed"` + + // A map of Principal id to rights for principals this AddressBook is shared with. + // + // The principal to which this AddressBook belongs MUST NOT be in this set. + // + // This is null if the AddressBook is not shared with anyone. + // + // May be modified only if the user has the mayAdmin right. + // + // The account id for the principals may be found in the urn:ietf:params:jmap:principals:owner capability + // of the Account to which the AddressBook belongs. + ShareWith map[string]AddressBookRights `json:"shareWith,omitempty"` + + // The set of access rights the user has in relation to this AddressBook (server-set). + MyRights AddressBookRights `json:"myRights"` +} + type ErrorResponse struct { Type string `json:"type"` Description string `json:"description,omitempty"` diff --git a/pkg/jscontact/jscontact_model.go b/pkg/jscontact/jscontact_model.go new file mode 100644 index 000000000..cc292320f --- /dev/null +++ b/pkg/jscontact/jscontact_model.go @@ -0,0 +1,1870 @@ +package jscontact + +import ( + "time" +) + +const ( + JSContactVersion = "1.0" + + AddressType = "Address" + AddressComponentType = "AddressComponent" + AnniversaryType = "Anniversary" + AuthorType = "Author" + ContactCardType = "Card" + CalendarType = "Calendar" + CryptoKeyType = "CryptoKey" + DirectoryType = "Directory" + EmailAddressType = "EmailAddress" + LanguagePrefType = "LanguagePref" + LinkType = "Link" + MediaType = "Media" + NameType = "Name" + NameComponentType = "NameComponent" + NicknameType = "Nickname" + NoteType = "Note" + OnlineServiceType = "OnlineService" + OrganizationType = "Organization" + OrgUnitType = "OrgUnit" + PartialDateType = "PartialDate" + PersonalInfoType = "PersonalInfo" + PhoneType = "Phone" + PronounsType = "Pronouns" + RelationType = "Relation" + SchedulingAddressType = "SchedulingAddress" + SpeakToAsType = "SpeakToAs" + TimestampType = "Timestamp" + TitleType = "Title" + + JSContactTypeAddress = AddressType + JSContactTypeAddressComponent = AddressComponentType + JSContactTypeAnniversary = AnniversaryType + JSContactTypeAuthor = AuthorType + JSContactTypeCard = ContactCardType + JSContactTypeCalendar = CalendarType + JSContactTypeCryptoKey = CryptoKeyType + JSContactTypeDirectory = DirectoryType + JSContactTypeEmailAddress = EmailAddressType + JSContactTypeLanguagePref = LanguagePrefType + JSContactTypeLink = LinkType + JSContactTypeMedia = MediaType + JSContactTypeName = NameType + JSContactTypeNameComponent = NameComponentType + JSContactTypeNickname = NicknameType + JSContactTypeNote = NoteType + JSContactTypeOnlineService = OnlineServiceType + JSContactTypeOrganization = OrganizationType + JSContactTypeOrgUnit = OrgUnitType + JSContactTypePartialDate = PartialDateType + JSContactTypePersonalInfo = PersonalInfoType + JSContactTypePhone = PhoneType + JSContactTypePronouns = PronounsType + JSContactTypeRelation = RelationType + JSContactTypeSchedulingAddress = SchedulingAddressType + JSContactTypeSpeakToAs = SpeakToAsType + JSContactTypeTimestamp = TimestampType + JSContactTypeTitle = TitleType + + ResourceTypeCalendar = JSContactTypeCalendar + ResourceTypeCryptoKey = JSContactTypeCryptoKey + ResourceTypeDirectory = JSContactTypeDirectory + ResourceTypeLink = JSContactTypeLink + ResourceTypeMedia = JSContactTypeMedia + + CalendarResourceKindCalendar = "calendar" + CalendarResourceKindFreeBusy = "freeBusy" + + LinkResourceKindContact = "contact" + + MediaResourceKindPhoto = "photo" + MediaResourceKindSound = "sound" + MediaResourceKindLogo = "logo" + + ResourceKindCalendar = CalendarResourceKindCalendar + ResourceKindFreeBusy = CalendarResourceKindFreeBusy + ResourceKindContact = LinkResourceKindContact + ResourceKindPhoto = MediaResourceKindPhoto + ResourceKindSound = MediaResourceKindSound + ResourceKindLogo = MediaResourceKindLogo + + AddressContextBilling = "billing" + AddressContextDelivery = "delivery" + AddressContextPrivate = "private" + AddressContextWork = "work" + + CalendarContextPrivate = "private" + CalendarContextWork = "work" + + CryptoKeyContextPrivate = "private" + CryptoKeyContextWork = "work" + + DirectoryContextPrivate = "private" + DirectoryContextWork = "work" + + EmailAddressContextPrivate = "private" + EmailAddressContextWork = "work" + + LanguagePrefContextPrivate = "private" + LanguagePrefContextWork = "work" + + LinkContextPrivate = "private" + LinkContextWork = "work" + + MediaContextPrivate = "private" + MediaContextWork = "work" + + NicknameContextPrivate = "private" + NicknameContextWork = "work" + + OnlineServiceContextPrivate = "private" + OnlineServiceContextWork = "work" + + OrganizationContextPrivate = "private" + OrganizationContextWork = "work" + + PhoneContextPrivate = "private" + PhoneContextWork = "work" + + PronounsContextPrivate = "private" + PronounsContextWork = "work" + + SchedulingAddressContextPrivate = "private" + SchedulingAddressContextWork = "work" + + ResourceContextPrivate = "private" + ResourceContextWork = "work" + ResourceContextAddressBilling = "billing" + ResourceContextAddressDelivery = "delivery" + + ContactCardKindIndividual = "individual" + ContactCardKindGroup = "group" + ContactCardKindOrg = "org" + ContactCardKindLocation = "location" + ContactCardKindDevice = "device" + ContactCardKindApplication = "application" + + RelationAcquaintance = "acquaintance" + RelationAgent = "agent" + RelationChild = "child" + RelationCoResident = "co-resident" + RelationCoWorker = "co-worker" + RelationColleague = "colleague" + RelationContact = "contact" + RelationCrush = "crush" + RelationDate = "date" + RelationEmergency = "emergency" + RelationFriend = "friend" + RelationKin = "kin" + RelationMe = "me" + RelationMet = "met" + RelationMuse = "muse" + RelationNeighbor = "neighbor" + RelationParent = "parent" + RelationSibling = "sibling" + RelationSpouse = "spouse" + RelationSweetheart = "sweetheart" + + GrammaticalGenderAnimate = "animate" + GrammaticalGenderCommon = "common" + GrammaticalGenderFeminine = "feminine" + GrammaticalGenderInanimate = "inanimate" + GrammaticalGenderMasculine = "masculine" + GrammaticalGenderNeuter = "neuter" + + DirectoryResourceKindDirectory = "directory" + DirectoryResourceKindEntry = "entry" + + TitleKindTitle = "title" + TitleKindRole = "role" + + PhoneFeatureMobile = "mobile" + PhoneFeatureVoice = "voice" + PhoneFeatureText = "text" + PhoneFeatureVideo = "video" + PhoneFeatureMainNumber = "main-number" + PhoneFeatureTextPhone = "textphone" + PhoneFeatureFax = "fax" + PhoneFeaturePager = "pager" + + AddressComponentKindRoom = "room" + AddressComponentKindApartment = "apartment" + AddressComponentKindFloor = "floor" + AddressComponentKindBuilding = "building" + AddressComponentKindNumber = "number" + AddressComponentKindName = "name" + AddressComponentKindBlock = "block" + AddressComponentKindSubdistrict = "subdistrict" + AddressComponentKindDistrict = "district" + AddressComponentKindLocality = "locality" + AddressComponentKindRegion = "region" + AddressComponentKindPostcode = "postcode" + AddressComponentKindCountry = "country" + AddressComponentKindDirection = "direction" + AddressComponentKindLandmark = "landmark" + AddressComponentKindPostOfficeBox = "postOfficeBox" + AddressComponentKindSeparator = "separator" + + AnniversaryKindBirth = "birth" + AnniversaryKindDeath = "death" + AnniversaryKindWedding = "wedding" + + PersonalInfoKindExpertise = "expertise" + PersonalInfoKindHobby = "hobby" + PersonalInfoKindInterest = "interest" + PersonalInfoLevelHigh = "high" + PersonalInfoLevelMedium = "medium" + PersonalInfoLevelLow = "low" + + NameComponentKindTitle = "title" + NameComponentKindGiven = "given" + NameComponentKindGiven2 = "given2" + NameComponentKindSurname = "surname" + NameComponentKindSurname2 = "surname2" + NameComponentKindCredential = "credential" + NameComponentKindGeneration = "generation" + NameComponentKindSeparator = "separator" +) + +var ( + JSContactTypes = []string{ + JSContactTypeAddress, + JSContactTypeAddressComponent, + JSContactTypeAnniversary, + JSContactTypeAuthor, + JSContactTypeCard, + JSContactTypeCalendar, + JSContactTypeCryptoKey, + JSContactTypeDirectory, + JSContactTypeEmailAddress, + JSContactTypeLanguagePref, + JSContactTypeLink, + JSContactTypeMedia, + JSContactTypeName, + JSContactTypeNameComponent, + JSContactTypeNickname, + JSContactTypeNote, + JSContactTypeOnlineService, + JSContactTypeOrganization, + JSContactTypeOrgUnit, + JSContactTypePartialDate, + JSContactTypePersonalInfo, + JSContactTypePhone, + JSContactTypePronouns, + JSContactTypeRelation, + JSContactTypeSchedulingAddress, + JSContactTypeSpeakToAs, + JSContactTypeTimestamp, + JSContactTypeTitle, + } + AddressContexts = []string{ + AddressContextBilling, + AddressContextDelivery, + AddressContextPrivate, + AddressContextWork, + } + CalendarContexts = []string{ + CalendarContextPrivate, + CalendarContextWork, + } + + CryptoKeyContexts = []string{ + CryptoKeyContextPrivate, + CryptoKeyContextWork, + } + + DirectoryContexts = []string{ + DirectoryContextPrivate, + DirectoryContextWork, + } + + EmailAddressContexts = []string{ + EmailAddressContextPrivate, + EmailAddressContextWork, + } + + LanguagePrefContexts = []string{ + LanguagePrefContextPrivate, + LanguagePrefContextWork, + } + + LinkContexts = []string{ + LinkContextPrivate, + LinkContextWork, + } + + MediaContexts = []string{ + MediaContextPrivate, + MediaContextWork, + } + + NicknameContexts = []string{ + NicknameContextPrivate, + NicknameContextWork, + } + + OnlineServiceContexts = []string{ + OnlineServiceContextPrivate, + OnlineServiceContextWork, + } + + OrganizationContexts = []string{ + OrganizationContextPrivate, + OrganizationContextWork, + } + + PhoneContexts = []string{ + PhoneContextPrivate, + PhoneContextWork, + } + + PronounsContexts = []string{ + PronounsContextPrivate, + PronounsContextWork, + } + + SchedulingAddressContexts = []string{ + SchedulingAddressContextPrivate, + SchedulingAddressContextWork, + } + + ResourceTypes = []string{ + ResourceTypeCalendar, + ResourceTypeCryptoKey, + ResourceTypeDirectory, + ResourceTypeLink, + ResourceTypeMedia, + } + + CalendarResourceKinds = []string{ + CalendarResourceKindCalendar, + CalendarResourceKindFreeBusy, + } + + ResourceKinds = []string{ + ResourceKindCalendar, + ResourceKindFreeBusy, + ResourceKindContact, + ResourceKindPhoto, + ResourceKindSound, + ResourceKindLogo, + } + ResourceContexts = []string{ + ResourceContextPrivate, + ResourceContextWork, + ResourceContextAddressBilling, + ResourceContextAddressDelivery, + } + ContactCardKinds = []string{ + ContactCardKindIndividual, + ContactCardKindGroup, + ContactCardKindOrg, + ContactCardKindLocation, + ContactCardKindDevice, + ContactCardKindApplication, + } + Relations = []string{ + RelationAcquaintance, + RelationAgent, + RelationChild, + RelationCoResident, + RelationCoWorker, + RelationColleague, + RelationContact, + RelationCrush, + RelationDate, + RelationEmergency, + RelationFriend, + RelationKin, + RelationMe, + RelationMet, + RelationMuse, + RelationNeighbor, + RelationParent, + RelationSibling, + RelationSpouse, + RelationSweetheart, + } + GrammaticalGenders = []string{ + GrammaticalGenderAnimate, + GrammaticalGenderCommon, + GrammaticalGenderFeminine, + GrammaticalGenderInanimate, + GrammaticalGenderMasculine, + GrammaticalGenderNeuter, + } + TitleKinds = []string{ + TitleKindTitle, + TitleKindRole, + } + PhoneFeatures = []string{ + PhoneFeatureMobile, + PhoneFeatureVoice, + PhoneFeatureText, + PhoneFeatureVideo, + PhoneFeatureMainNumber, + PhoneFeatureTextPhone, + PhoneFeatureFax, + PhoneFeaturePager, + } + AddressComponentKinds = []string{ + AddressComponentKindRoom, + AddressComponentKindApartment, + AddressComponentKindFloor, + AddressComponentKindBuilding, + AddressComponentKindNumber, + AddressComponentKindName, + AddressComponentKindBlock, + AddressComponentKindSubdistrict, + AddressComponentKindDistrict, + AddressComponentKindLocality, + AddressComponentKindRegion, + AddressComponentKindPostcode, + AddressComponentKindCountry, + AddressComponentKindDirection, + AddressComponentKindLandmark, + AddressComponentKindPostOfficeBox, + AddressComponentKindSeparator, + } + AnniversaryKinds = []string{ + AnniversaryKindBirth, + AnniversaryKindDeath, + AnniversaryKindWedding, + } + PersonalInfoKinds = []string{ + PersonalInfoKindExpertise, + PersonalInfoKindHobby, + PersonalInfoKindInterest, + } + PersonalInfoLevels = []string{ + PersonalInfoLevelHigh, + PersonalInfoLevelMedium, + PersonalInfoLevelLow, + } + DirectoryResourceKinds = []string{ + DirectoryResourceKindDirectory, + DirectoryResourceKindEntry, + } + NameComponentKinds = []string{ + NameComponentKindTitle, + NameComponentKindGiven, + NameComponentKindGiven2, + NameComponentKindSurname, + NameComponentKindSurname2, + NameComponentKindCredential, + NameComponentKindGeneration, + NameComponentKindSeparator, + } + LinkResourceKinds = []string{ + LinkResourceKindContact, + } + MediaResourceKinds = []string{ + MediaResourceKindPhoto, + MediaResourceKindSound, + MediaResourceKindLogo, + } +) + +// A `PatchObject` is of type `String[*]` and represents an unordered set of patches on a JSON object. +// +// Each key is a path represented in a subset of the JSON Pointer format [RFC6901]. +// +// The paths have an implicit leading `"/"`, so each key is prefixed with `"/"` before applying the +// JSON Pointer evaluation algorithm. +// +// A patch within a `PatchObject` is only valid if all the following conditions apply: +// !1. The pointer MAY reference inside an array, but if the last reference token in the pointer is an array index, +// then the patch value MUST NOT be null. The pointer MUST NOT use `"-"` as an array index in any of its reference +// tokens (i.e., you MUST NOT insert/delete from an array, but you MAY replace the contents of its existing members. +// To add or remove members, one needs to replace the complete array value). +// !2. All reference tokens prior to the last (i.e., the value after the final slash) MUST already exist as values +// in the object being patched. If the last reference token is an array index, then a member at this index MUST +// already exist in the referenced array. +// !3. 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., `"addresses/1/city"` and `"addresses"`. +// !4. The value for the patch MUST be valid for the property being set (of the correct type and obeying any +// other applicable restrictions), or if null, the property MUST be optional. +// +// The value associated with each pointerdetermines how to apply that patch: +// !- If null, remove the property from the patched object. If the key is not present in the parent, this is a no-op. +// !- If non-null, set the value given as the value for this property (this may be a replacement or addition to the +// object being patched). +// +// A `PatchObject` does not define its own `@type` property. Instead, the `@type` property in a patch MUST be handled +// as any other patched property value. +// +// Implementations MUST reject a `PatchObject` in its entirety if any of its patches are invalid. +// +// Implementations MUST NOT apply partial patches. +// +// [RFC6901]: https://www.rfc-editor.org/rfc/rfc6901.html +type PatchObject map[string]any + +// The Resource data type defines a resource associated with the entity represented by the Card, +// identified by a URI [RFC3986]. +// +// Several property definitions refer to the `Resource` type as the basis for their property-specific +// value types. +// +// The `Resource` type defines the properties that are common to all of them. +// +// Property definitions making use of `Resource` MAY define additional properties for their value types. +type Resource struct { + // The JSContact type of the object. + // + // The value MUST NOT be "Resource"; instead, the value MUST be the name of a [concrete resource type]. + // + // [concrete resource type]: https://www.rfc-editor.org/rfc/rfc9553.html#resource-properties + Type string `json:"@type,omitempty"` + + // The kind of the resource. + // + // The allowed values are defined in the property definition that makes use of the Resource type. + // + // Some property definitions may change this property from being optional to mandatory. + // + // A contact card with a `kind` property equal to `group` represents a group of contacts. + // + // Clients often present these separately from other contact cards. + // + // The `members` property, as defined in [RFC 9553, Section 2.1.6], contains a set of UIDs for other + // contacts that are the members of this group. + // + // Clients should consider the group to contain any `ContactCard` with a matching UID, from + // any account they have access to with support for the `urn:ietf:params:jmap:contacts` capability. + // + // UIDs that cannot be found SHOULD be ignored but preserved. + // + // For example, suppose a user adds contacts from a shared address book to their private group, then + // temporarily loses access to this address book. The UIDs cannot be resolved so the contacts will + // disappear from the group. However, if they are given permission to access the data again the UIDs + // will be found and the contacts will reappear. + // + // [RFC 9553, Section 2.1.8]: https://www.rfc-editor.org/rfc/rfc9553#members + Kind string `json:"kind,omitempty"` + + // The resource value. + // + // This MUST be a URI as defined in Section 3 of [RFC3986-section3]. + // + // [RFC3986-section3]: https://www.rfc-editor.org/rfc/rfc3986.html#section-3 + Uri string `json:"uri,omitempty"` + + // The [RFC2046 media type] of the resource identified by the uri property value. + // + // [RFC2046 media type]: https://www.rfc-editor.org/rfc/rfc2046.html + MediaType string `json:"mediaType,omitempty"` + + // The contexts in which to use this resource. + // + // The contexts in which to use the contact information. + // + // For example, someone might have distinct phone numbers for work and private contexts and may set the + // desired context on the respective phone number in the phones (Section 2.3.3) property. + // + // This section defines common contexts. + // + // Additional contexts may be defined in the properties or data types that make use of this property. + // + // The enumerated common context values are: + // !- `private`: the contact information that may be used in a private context. + // !- `work`: the contact information that may be used in a professional context. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The [preference] of the resource in relation to other resources. + // + // A preference order for contact information. + // + // For example, a person may have two email addresses and prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, with 1 + // being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + // + // [preference]: https://www.rfc-editor.org/rfc/rfc9553.html#prop-pref + Pref uint `json:"pref,omitzero"` + + // A [custom label] for the value. + // + // The labels associated with the contact data. + // + // Such labels may be set for phone numbers, email addresses, and other resources. + // + // Typically, these labels are displayed along with their associated contact data in graphical user interfaces. + // + // Note that succinct labels are best for proper display on small graphical interfaces and screens. + // + // [custom label]: https://www.rfc-editor.org/rfc/rfc9553.html#prop-label + Label string `json:"label,omitempty"` +} + +type DirectoryResource struct { + // The JSContact type of the object. + // + // The value MUST NOT be "Resource"; instead, the value MUST be the name of a [concrete resource type]. + // + // [concrete resource type]: https://www.rfc-editor.org/rfc/rfc9553.html#resource-properties + Type string `json:"@type,omitempty"` + + // The kind of the resource. + // + // The allowed values are defined in the property definition that makes use of the Resource type. + // + // Some property definitions may change this property from being optional to mandatory. + // + // A contact card with a `kind` property equal to `group` represents a group of contacts. + // + // Clients often present these separately from other contact cards. + // + // The `members` property, as defined in [RFC 9553, Section 2.1.6], contains a set of UIDs for other + // contacts that are the members of this group. + // + // Clients should consider the group to contain any `ContactCard` with a matching UID, from + // any account they have access to with support for the `urn:ietf:params:jmap:contacts` capability. + // + // UIDs that cannot be found SHOULD be ignored but preserved. + // + // For example, suppose a user adds contacts from a shared address book to their private group, then + // temporarily loses access to this address book. The UIDs cannot be resolved so the contacts will + // disappear from the group. However, if they are given permission to access the data again the UIDs + // will be found and the contacts will reappear. + // + // [RFC 9553, Section 2.1.8]: https://www.rfc-editor.org/rfc/rfc9553#members + Kind string `json:"kind,omitempty"` + + // The resource value. + // + // This MUST be a URI as defined in Section 3 of [RFC3986-section3]. + // + // [RFC3986-section3]: https://www.rfc-editor.org/rfc/rfc3986.html#section-3 + Uri string `json:"uri,omitempty"` + + // The [RFC2046 media type] of the resource identified by the uri property value. + // + // [RFC2046 media type]: https://www.rfc-editor.org/rfc/rfc2046.html + MediaType string `json:"mediaType,omitempty"` + + // The contexts in which to use this resource. + // + // The contexts in which to use the contact information. + // + // For example, someone might have distinct phone numbers for work and private contexts and may set the + // desired context on the respective phone number in the phones (Section 2.3.3) property. + // + // This section defines common contexts. + // + // Additional contexts may be defined in the properties or data types that make use of this property. + // + // The enumerated common context values are: + // !- `private`: the contact information that may be used in a private context. + // !- `work`: the contact information that may be used in a professional context. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The [preference] of the resource in relation to other resources. + // + // A preference order for contact information. + // + // For example, a person may have two email addresses and prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, with 1 + // being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + // + // [preference]: https://www.rfc-editor.org/rfc/rfc9553.html#prop-pref + Pref uint `json:"pref,omitzero"` + + // A [custom label] for the value. + // + // The labels associated with the contact data. + // + // Such labels may be set for phone numbers, email addresses, and other resources. + // + // Typically, these labels are displayed along with their associated contact data in graphical user interfaces. + // + // Note that succinct labels are best for proper display on small graphical interfaces and screens. + // + // [custom label]: https://www.rfc-editor.org/rfc/rfc9553.html#prop-label + Label string `json:"label,omitempty"` + + // The position of the directory resource in the list of all `Directory` objects having the same kind property + // value in the Card. + // + // Only in `Directory` `Resource` types. + // + // If set, the `listAs` value MUST be higher than zero. + // + // Multiple directory resources MAY have the same `listAs` property value or none. + // + // Sorting such same-valued entries is implementation-specific. + ListAs uint `json:"listAs,omitzero"` +} + +type MediaResource struct { + // The JSContact type of the object. + // + // The value MUST NOT be "Resource"; instead, the value MUST be the name of a [concrete resource type]. + // + // [concrete resource type]: https://www.rfc-editor.org/rfc/rfc9553.html#resource-properties + Type string `json:"@type,omitempty"` + + // The kind of the resource. + // + // The allowed values are defined in the property definition that makes use of the Resource type. + // + // Some property definitions may change this property from being optional to mandatory. + // + // A contact card with a `kind` property equal to `group` represents a group of contacts. + // + // Clients often present these separately from other contact cards. + // + // The `members` property, as defined in [RFC 9553, Section 2.1.6], contains a set of UIDs for other + // contacts that are the members of this group. + // + // Clients should consider the group to contain any `ContactCard` with a matching UID, from + // any account they have access to with support for the `urn:ietf:params:jmap:contacts` capability. + // + // UIDs that cannot be found SHOULD be ignored but preserved. + // + // For example, suppose a user adds contacts from a shared address book to their private group, then + // temporarily loses access to this address book. The UIDs cannot be resolved so the contacts will + // disappear from the group. However, if they are given permission to access the data again the UIDs + // will be found and the contacts will reappear. + // + // [RFC 9553, Section 2.1.8]: https://www.rfc-editor.org/rfc/rfc9553#members + Kind string `json:"kind,omitempty"` + + // The resource value. + // + // This MUST be a URI as defined in Section 3 of [RFC3986-section3]. + // + // [RFC3986-section3]: https://www.rfc-editor.org/rfc/rfc3986.html#section-3 + Uri string `json:"uri,omitempty"` + + // The [RFC2046 media type] of the resource identified by the uri property value. + // + // [RFC2046 media type]: https://www.rfc-editor.org/rfc/rfc2046.html + MediaType string `json:"mediaType,omitempty"` + + // The contexts in which to use this resource. + // + // The contexts in which to use the contact information. + // + // For example, someone might have distinct phone numbers for work and private contexts and may set the + // desired context on the respective phone number in the phones (Section 2.3.3) property. + // + // This section defines common contexts. + // + // Additional contexts may be defined in the properties or data types that make use of this property. + // + // The enumerated common context values are: + // !- `private`: the contact information that may be used in a private context. + // !- `work`: the contact information that may be used in a professional context. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The [preference] of the resource in relation to other resources. + // + // A preference order for contact information. + // + // For example, a person may have two email addresses and prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, with 1 + // being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + // + // [preference]: https://www.rfc-editor.org/rfc/rfc9553.html#prop-pref + Pref uint `json:"pref,omitzero"` + + // A [custom label] for the value. + // + // The labels associated with the contact data. + // + // Such labels may be set for phone numbers, email addresses, and other resources. + // + // Typically, these labels are displayed along with their associated contact data in graphical user interfaces. + // + // Note that succinct labels are best for proper display on small graphical interfaces and screens. + // + // [custom label]: https://www.rfc-editor.org/rfc/rfc9553.html#prop-label + Label string `json:"label,omitempty"` + + // An id for the Blob representing the binary contents of the resource. + // + // This is a JMAP extension of JSContact, and only present in `Media` `Resource` types. + // + // When returning `ContactCard`s, any `Media` with a `data:` URI SHOULD return a `blobId` property + // and omit the `uri` property. + // + // The `mediaType` property MUST also be set. + // + // Similarly, when creating or updating a `ContactCard`, clients MAY send a `blobId` instead + // of the `uri` property for a `Media` object. + BlobId string `json:"blobId,omitempty"` +} + +type Relation struct { + // The JSContact type of the object: the value MUST be `Relation``, if set. + Type string `json:"@type,omitempty"` + + // The relationship of the related Card to the Card, defined as a set of relation types. + // + // The keys in the set define the relation type; the values for each key in the set MUST be "true". + // + // The relationship between the two objects is undefined if the set is empty. + // + // The initial list of enumerated relation types matches the IANA-registered TYPE `IANA-vCard`` + // parameter values of the vCard RELATED property ([Section 6.6.6 of RFC6350]): + // !- `acquaintance` + // !- `agent` + // !- `child` + // !- `co-resident` + // !- `co-worker` + // !- `colleague` + // !- `contact` + // !- `crush` + // !- `date` + // !- `emergency` + // !- `friend` + // !- `kin` + // !- `me` + // !- `met` + // !- `muse` + // !- `neighbor` + // !- `parent` + // !- `sibling` + // !- `spouse` + // !- `sweetheart` + // + // [Section 6.6.6 of RFC6350]: https://www.rfc-editor.org/rfc/rfc6350.html#section-6.6.6 + Relation map[string]bool `json:"relation,omitempty"` +} + +type NameComponent struct { + // The JSContact type of the object: the value MUST be `NameComponent`, if set. + Type string `json:"@type,omitempty"` + + // The value of the name component. + // + // This can be composed of one or multiple words such as `Poe` or `van Gogh`. + Value string `json:"value"` + + // The kind of the name component. + // + // !- `title`: an honorific title or prefix, e.g., `Mr.`, `Ms.`, or `Dr.` + // !- `given`: a given name, also known as "first name" or "personal name" + // !- `given2`: a name that appears between the given and surname such as a middle name or patronymic name + // !- `surname`: a surname, also known as "last name" or "family name" + // !- `surname2`: a secondary surname (used in some cultures), also known as "maternal surname" + // !- `credential`: a credential, also known as "accreditation qualifier" or "honorific suffix", e.g., `B.A.`, `Esq.` + // !- `generation`: a generation marker or qualifier, e.g., `Jr.` or `III` + // !- `separator`: a formatting separator between two ordered name non-separator components; the value property of the component includes the verbatim separator, for example, a hyphen character or even an empty string. This value has higher precedence than the defaultSeparator property of the Name. Implementations MUST NOT insert two consecutive separator components; instead, they SHOULD insert a single separator component with the combined value; this component kind MUST NOT be set if the `Name` `isOrdered` property value is `false` + Kind string `json:"kind"` + + // The pronunciation of the name component. + // + // If this property is set, then at least one of the `Name` object properties, `phoneticSystem` or `phoneticScript`, + // MUST be set. + Phonetic string `json:"phonetic,omitempty"` +} + +type Nickname struct { + // The JSContact type of the object: the value MUST be `Nickname`, if set. + Type string `json:"@type,omitempty"` + + // The nickname. + Name string `json:"name"` + + // The contexts in which to use the nickname. + // TODO document https://www.rfc-editor.org/rfc/rfc9553.html#prop-contexts + Contexts map[string]bool `json:"contexts,omitempty"` + + // The preference of the nickname in relation to other nicknames. + // + // A preference order for contact information. For example, a person may have two email addresses and + // prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, + // with 1 being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + Pref uint `json:"pref,omitzero"` +} + +type OrgUnit struct { + // The JSContact type of the object: the value MUST be `OrgUnit`, if set. + Type string `json:"@type,omitempty"` + + // The name of the organizational unit. + Name string `json:"name"` + + // he value to lexicographically sort the organizational unit in relation to other organizational + // units of the same level when compared by name. + // + // The level is defined by the array index of the organizational unit in the units property + // of the Organization object. + // + // The property value defines the verbatim string value to compare. + // + // In absence of this property, the name property value MAY be used for comparison. + SortAs string `json:"sortAs,omitempty"` +} + +type Organization struct { + // The JSContact type of the object: the value MUST be `Organization`, if set. + Type string `json:"@type,omitempty"` + + // The name of the organization. + Name string `json:"name,omitempty"` + + // A list of organizational units, ordered as descending by hierarchy. + // (e.g., a geographic or functional division sorts before a department within that division). + // + // If set, the list MUST contain at least one entry + Units []OrgUnit `json:"units,omitempty"` + + // The value to lexicographically sort the organization in relation to other organizations when + // compared by name. + // + // The value defines the verbatim string value to compare. + // + // In absence of this property, the name property value MAY be used for comparison. + SortAs string `json:"sortAs,omitempty"` + + // The contexts in which association with the organization applies. + // + // For example, membership in a choir may only apply in a private context. + // + // TODO document https://www.rfc-editor.org/rfc/rfc9553.html#prop-contexts + Contexts map[string]bool `json:"contexts,omitempty"` +} + +type Pronouns struct { + // The JSContact type of the object: the value MUST be `Pronouns`, if set. + Type string `json:"@type,omitempty"` + + // The pronouns. + // + // Any value or form is allowed. + // + // Examples in English include `she/her` and `they/them/theirs`. + // + // The value MAY be overridden in the `localizations` property. + Pronouns string `json:"pronouns"` + + // The contexts in which to use the pronouns. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The preference of the pronouns in relation to other pronouns in the same context. + // + // A preference order for contact information. For example, a person may have two email addresses and + // prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, + // with 1 being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + Pref uint `json:"pref,omitzero"` +} + +type Title struct { + // The JSContact type of the object: the value MUST be `Title`, if set. + Type string `json:"@type,omitempty"` + + // The title or role name of the entity represented by the Card. + Name string `json:"name"` + + // The organizational or situational kind of the title. + // + // Some organizations and individuals distinguish between titles as organizational + // positions and roles as more temporary assignments such as in project management. + // + // The enumerated values are: + // !- `title` + // !- `role` + Kind string `json:"kind,omitempty"` + + // The identifier of the organization in which this title is held. + OrganizationId string `json:"organizationId,omitempty"` +} + +type SpeakToAs struct { + // The JSContact type of the object: the value MUST be `SpeakToAs`, if set. + Type string `json:"@type,omitempty"` + + // The grammatical gender to use in salutations and other grammatical constructs. + // + // For example, the German language distinguishes by grammatical gender in salutations such as + // `Sehr geehrte` (feminine) and `Sehr geehrter` (masculine). + // + // The enumerated values are: + // !- `animate` + // !- `common` + // !- `feminine` + // !- `inanimate` + // !- `masculine` + // !- `neuter` + // + // Note that the grammatical gender does not allow inferring the gender identities or assigned + // sex of the contact. + GrammaticalGender string `json:"grammaticalGender,omitempty"` + + // The pronouns that the contact chooses to use for themselves. + Pronouns map[string]Pronouns `json:"pronouns,omitempty"` +} + +type Name struct { + // The JSContact type of the object: the value MUST be `Name`, if set. + Type string `json:"@type,omitempty"` + + // The components making up this name. + // + // The components property MUST be set if the full property is not set; otherwise, it SHOULD be set. + // + // The component list MUST have at least one entry having a different kind property value than `separator`. + // + // `Name` components SHOULD be ordered such that when their values are joined as a `string`, a valid full name + // of the entity is produced. If so, implementations MUST set the isOrdered property value to `true`. + // + // If the name `components` are ordered, then the `defaultSeparator` property and name components with the kind + // property value set to `separator` give guidance on what characters to insert between components, but + // implementations are free to choose any others. + // + // When lacking a separator, inserting a single space character in between the name component values is a good choice. + // + // If, instead, the name components follow no particular order, then the `isOrdered` property value MUST be + // `false`, the `components` property MUST NOT contain a `NameComponent` with the `kind` property value set to + // `separator`, and the `defaultSeparator` property MUST NOT be set. + Components []NameComponent `json:"components,omitempty"` + + // The indicator if the name components in the components property are ordered. + // + // Default: `false` + IsOrdered bool `json:"isOrdered,omitzero"` + + // The default separator to insert between name component values when concatenating all name component values to a single String. + // + // Also see the definition of the kind property value `separator` for the `NameComponent` object. + // + // The `defaultSeparator` property MUST NOT be set if the `Name` `isOrdered` property value is `false` or if + // the components property is not set. + // + // example: {"name": { "components": [{ "kind": "given", "value": "Diego" }, { "kind": "surname", "value": "Rivera" }, { "kind": "surname2", "value": "Barrientos" }], "isOrdered": true} + DefaultSeparator string `json:"defaultSeparator,omitempty"` + + // The full name representation of the `Name`. + // + // The `full` property MUST be set if the components property is not set. + // + // example: Mr. John Q. Public, Esq. + Full string `json:"full,omitempty"` + + // The value to lexicographically sort the name in relation to other names when compared by a name component type. + // + // The keys in the map define the name component type. The values define the verbatim string to compare when sorting + // by the name component type. + // + // Absence of a key indicates that the name component type SHOULD NOT be considered during sort. + // + // Sorting by that missing name component type, or if the sortAs property is not set, is implementation-specific. + // + // The sortAs property MUST NOT be set if the components property is not set. + // + // Each key in the map MUST be a valid name component type value as defined for the kind property of the NameComponent + // object. + // + // For each key in the map, there MUST exist at least one NameComponent object that has the type in the components + // property of the name. + SortAs map[string]string `json:"sortAs,omitempty"` + + // The script used in the value of the NameComponent phonetic property. + // TODO https://www.rfc-editor.org/rfc/rfc9553.html#prop-phonetic + PhoneticScript string `json:"phoneticScript,omitempty"` + + // The phonetic system used in the NameComponent phonetic property. + // TODO https://www.rfc-editor.org/rfc/rfc9553.html#prop-phonetic + PhoneticSystem string `json:"phoneticSystem,omitempty"` +} + +type EmailAddress struct { + // The JSContact type of the object: the value MUST be `EmailAddress`, if set. + Type string `json:"@type,omitempty"` + + // The email address. + // + // This MUST be an addr-spec value as defined in [Section 3.4.1 of RFC5322]. + // + // [Section 3.4.1 of RFC5322]: https://www.rfc-editor.org/rfc/rfc5322.html#section-3.4.1 + Address string `json:"address"` + + // The contexts in which to use this email address. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The preference of the email address in relation to other email addresses. + // + // A preference order for contact information. For example, a person may have two email addresses and + // prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, + // with 1 being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + Pref uint `json:"pref,omitzero"` + + // A custom label for the value. + // + // The labels associated with the contact data. Such labels may be set for phone numbers, email addresses, and other resources. + // + // Typically, these labels are displayed along with their associated contact data in graphical user interfaces. + // + // Note that succinct labels are best for proper display on small graphical interfaces and screens. + Label string `json:"label,omitempty"` +} + +type OnlineService struct { + // The JSContact type of the object: the value MUST be `OnlineService`, if set. + Type string `json:"@type,omitempty"` + + // The name of the online service or protocol. + // + // The name MAY be capitalized the same as on the service's website, app, or publishing material, + // but names MUST be considered equal if they match case-insensitively. + // + // Examples are `GitHub`, `kakao`, and `Mastodon`. + Service string `json:"service,omitempty"` + + // The identifier for the entity represented by the Card at the online service. + Uri string `json:"uri,omitempty"` + + // The name the entity represented by the Card at the online service. + // + // Any free-text value is allowed. + User string `json:"user,omitempty"` + + // The contexts in which to use the service. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The preference of the service in relation to other services. + // + // A preference order for contact information. For example, a person may have two email addresses and + // prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, + // with 1 being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + Pref uint `json:"pref,omitzero"` + + // A custom label for the value. + // + // The labels associated with the contact data. Such labels may be set for phone numbers, email addresses, and other resources. + // + // Typically, these labels are displayed along with their associated contact data in graphical user interfaces. + // + // Note that succinct labels are best for proper display on small graphical interfaces and screens. + Label string `json:"label,omitempty"` +} + +type Phone struct { + // The JSContact type of the object: the value MUST be `Phone`, if set. + Type string `json:"@type,omitempty"` + + // The phone number as either a URI or free text. + // + // Typical URI schemes are `tel` [RFC3966] or `sip` [RFC3261], but any URI scheme is allowed. + // + // [RFC3966]: https://www.rfc-editor.org/rfc/rfc3966.html + // [RFC3261]: https://www.rfc-editor.org/rfc/rfc3261.html + Number string `json:"number"` + + // The set of contact features that the phone number may be used for. + // + // The set is represented as an object, with each key being a method type. + // + // The boolean value MUST be `true`. + // + // The enumerated values are: + // !- `mobile`: this number is for a mobile phone + // !- `voice`: this number supports calling by voice + // !- `text`: this number supports text messages (SMS) + // !- `video`: this number supports video conferencing + // !- `main-number`: this number is a main phone number such as the number of the front desk at a company, as opposed to a direct-dial number of an individual employee + // !- `textphone`: this number is for a device for people with hearing or speech difficulties + // !- `fax`: this number supports sending faxes + // !- `pager`: this number is for a pager or beeper + Features map[string]bool `json:"features,omitempty"` + + // The contexts in which to use the number. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The preference of the number in relation to other numbers. + // + // A preference order for contact information. For example, a person may have two email addresses and + // prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, + // with 1 being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + Pref uint `json:"pref,omitzero"` + + // A custom label for the value. + // + // The labels associated with the contact data. Such labels may be set for phone numbers, email addresses, and other resources. + // + // Typically, these labels are displayed along with their associated contact data in graphical user interfaces. + // + // Note that succinct labels are best for proper display on small graphical interfaces and screens. + Label string `json:"label,omitempty"` +} + +type LanguagePref struct { + // The JSContact type of the object: the value MUST be `LanguagePref`, if set. + Type string `json:"@type,omitempty"` + + // The preferred language. + // + // This MUST be a language tag as defined in [RFC5646]. + // + // [RFC5646]: https://www.rfc-editor.org/rfc/rfc5646.html + Language string `json:"language"` + + // The contexts in which to use the language. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The preference of the language in relation to other languages of the same contexts. + // + // A preference order for contact information. For example, a person may have two email addresses and + // prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, + // with 1 being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + Pref uint `json:"pref,omitzero"` +} + +type SchedulingAddress struct { + // The JSContact type of the object: the value MUST be `SchedulingAddress`, if set. + Type string `json:"@type,omitempty"` + + // The address to use for calendar scheduling with the contact. + Uri string `json:"uri,omitempty"` + + // The contexts in which to use the scheduling address. + Contexts map[string]bool `json:"contexts,omitempty"` + + // The preference of the scheduling address in relation to other scheduling addresses. + // + // A preference order for contact information. For example, a person may have two email addresses and + // prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, + // with 1 being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + Pref uint `json:"pref,omitzero"` + + // A custom label for the scheduling address. + // + // The labels associated with the contact data. Such labels may be set for phone numbers, email addresses, and other resources. + // + // Typically, these labels are displayed along with their associated contact data in graphical user interfaces. + // + // Note that succinct labels are best for proper display on small graphical interfaces and screens. + Label string `json:"label,omitempty"` +} + +type AddressComponent struct { + // The JSContact type of the object: the value MUST be `AddressComponent`, if set. + Type string `json:"@type,omitempty"` + + // The value of the address component. + Value string `json:"value"` + + // The kind of the address component. + // + // The enumerated values are: + // !- `room`: the room, suite number, or identifier + // !- `apartment`: the extension designation such as the apartment number, unit, or box number + // !- `floor`: the floor or level the address is located on + // !- `building`: the building, tower, or condominium the address is located in + // !- `number`: the street number, e.g., `"123"`; this value is not restricted to numeric values and can include any value such + // as number ranges (`"112-10"`), grid style (`"39.2 RD"`), alphanumerics (`"N6W23001"`), or fractionals (`"123 1/2"`) + // !- `name`: the street name + // !- `block`: the block name or number + // !- `subdistrict`: the subdistrict, ward, or other subunit of a district + // !- `district`: the district name + // !- `locality`: the municipality, city, town, village, post town, or other locality + // !- `region`: the administrative area such as province, state, prefecture, county, or canton + // !- `postcode`: the postal code, post code, ZIP code, or other short code associated with the address by the relevant country's postal system + // !- `country`: the country name + // !- `direction`: the cardinal direction or quadrant, e.g., "north" + // !- `landmark`: the publicly known prominent feature that can substitute the street name and number, e.g., "White House" or "Taj Mahal" + // !- `postOfficeBox`: the post office box number or identifier + // !- `separator`: a formatting separator between two ordered address non-separator components; the value property of the component includes the + // verbatim separator, for example, a hyphen character or even an empty string; this value has higher precedence than the `defaultSeparator` property + // of the `Address`; implementations MUST NOT insert two consecutive separator components; instead, they SHOULD insert a single separator component + // with the combined value; this component kind MUST NOT be set if the `Address` `isOrdered` property value is `false`. + Kind string `json:"kind"` + + // The pronunciation of the name component. + // + // If this property is set, then at least one of the Address object `phoneticSystem` or `phoneticScript` properties MUST be set. + Phonetic string `json:"phonetic,omitempty"` +} + +// An Address object has the following properties, of which at least one of components, coordinates, countryCode, full or timeZone MUST be set. +type Address struct { + // The JSContact type of the object: the value MUST be `Address`, if set. + Type string `json:"@type,omitempty"` + + // The components that make up the address. + // + // The component list MUST have at least one entry that has a kind property value other than `separator`. + // + // Address components SHOULD be ordered such that when their values are joined as a String, a valid full address is produced. + // + // If so, implementations MUST set the isOrdered property value to `true`. + // + // If the address components are ordered, then the `defaultSeparator` property and address components with the `kind` + // property value set to `separator` give guidance on what characters to insert between components, but implementations + // are free to choose any others. + // + // When lacking a separator, inserting a single space character in between address component values is a good choice. + // + // If, instead, the address components follow no particular order, then the isOrdered property value MUST be `false`, + // the components property MUST NOT contain an `AddressComponent` with the `kind` property value set to `separator`, + // and the `defaultSeparator` property MUST NOT be set. + Components []AddressComponent `json:"components,omitempty"` + + // The indicator if the address components in the components property are ordered (default: `false`). + IsOrdered bool `json:"isOrdered,omitzero"` + + // The Alpha-2 country code as of [ISO.3166-1]. + // + // [ISO.3166-1]: https://www.iso.org/iso-3166-country-codes.html + CountryCode string `json:"countryCode,omitempty"` + + // A "geo:" URI [RFC5870] for the address. + // + // [RFC5870]: https://www.rfc-editor.org/rfc/rfc5870.html + Coordinates string `json:"coordinates,omitempty"` + + // The time zone in which the address is located. + // + // This MUST be a time zone name registered in the IANA Time Zone Database [IANA-TZ]. + // + // [IANA-TZ]: https://www.iana.org/time-zones + TimeZone string `json:"timeZone,omitempty"` + + // The contexts in which to use this address. + // + // The boolean value MUST be `true`. + // + // In addition to the common contexts, allowed key values are: + // !- `billing`: an address to be used for billing + // !- `delivery`: an address to be used for delivering physical items + Contexts map[string]bool `json:"contexts,omitempty"` + + // The full address, including street, region, or country. + // + // The purpose of this property is to define an address, even if the individual address components are not known. + Full string `json:"full,omitempty"` + + // The default separator to insert between address component values when concatenating all address component values to a single String. + // + // Also see the definition of the `kind` property value `separator` for the `AddressComponent` object. + // + // The `defaultSeparator` property MUST NOT be set if the Address `isOrdered` property value is `false` or if the `components` property is not set. + DefaultSeparator string `json:"defaultSeparator,omitempty"` + + // The preference of the address in relation to other addresses. + // + // A preference order for contact information. For example, a person may have two email addresses and + // prefer to be contacted with one of them. + // + // The value MUST be in the range of 1 to 100. Lower values correspond to a higher level of preference, + // with 1 being most preferred. + // + // If no preference is set, then the contact information MUST be interpreted as being least preferred. + // + // Note that the preference is only defined in relation to contact information of the same type. + // + // For example, the preference orders within emails and phone numbers are independent of each other. + Pref uint `json:"pref,omitzero"` + + // The script used in the value of the Address phonetic property. + // TODO https://www.rfc-editor.org/rfc/rfc9553.html#prop-phonetic + PhoneticScript string `json:"phoneticScript,omitempty"` + + // The phonetic system used in the NameComAddressponent phonetic property. + // TODO https://www.rfc-editor.org/rfc/rfc9553.html#prop-phonetic + PhoneticSystem string `json:"phoneticSystem,omitempty"` +} + +type AnniversaryDate interface { + isAnniversaryDate() // marker +} + +// A PartialDate object represents a complete or partial calendar date in the Gregorian calendar. +// +// It represents a complete date, a year, a month in a year, or a day in a month. +type PartialDate struct { + // The JSContact type of the object; the value MUST be `PartialDate`, if set. + Type string `json:"@type,omitempty"` + + // The calendar year. + Year uint `json:"year,omitzero"` + + // The calendar month, represented as the integers 1 <= month <= 12. + // + // If this property is set, then either the `year` or the `day` property MUST be set. + Month uint `json:"month,omitzero"` + + // The calendar month day, represented as the integers 1 <= day <= 31, depending on the validity + // within the month and year. + // + // If this property is set, then the `month` property MUST be set. + Day uint `json:"day,omitzero"` + + // The calendar system in which this date occurs, in lowercase. + // + // This MUST be either a calendar system name registered as a Common Locale Data Repository [CLDR] [RFC7529] + // or a vendor-specific value. + // + // The year, month, and day still MUST be represented in the Gregorian calendar. + // + // Note that the year property might be required to convert the date between the Gregorian calendar + // and the respective calendar system. + // + // [CLDR]: https://github.com/unicode-org/cldr/blob/latest/common/bcp47/calendar.xml + // [RFC7529]: https://www.rfc-editor.org/rfc/rfc7529.html + CalendarScale string `json:"calendarScale,omitempty"` +} + +func (_ PartialDate) isAnniversaryDate() { +} + +var _ AnniversaryDate = &PartialDate{} + +type Timestamp struct { + // The JSContact type of the object; the value MUST be `Timestamp`, if set. + Type string `json:"@type,omitempty"` + + // The point in time in UTC time (UTCDateTime). + Utc time.Time `json:"utc"` +} + +var _ AnniversaryDate = &Timestamp{} + +func (_ Timestamp) isAnniversaryDate() { +} + +type Anniversary struct { + // The JSContact type of the object: the value MUST be `Anniversary`, if set. + Type string `json:"@type,omitempty"` + + // The kind of anniversary. + // + // The enumerated values are: + // !- `birth`: a birthday anniversary + // !- `death`: a deathday anniversary + // !- `wedding`: a wedding day anniversary + Kind string `json:"kind"` + + // The date of the anniversary in the Gregorian calendar. + // + // This MUST be either a whole or partial calendar date or a complete UTC timestamp + // (see the definition of the `Timestamp` and `PartialDate` object types). + Date AnniversaryDate `json:"date"` +} + +type Author struct { + // The JSContact type of the object: the value MUST be `Author`, if set. + Type string `json:"@type,omitempty"` + + // The name of this author. + Name string `json:"name,omitempty"` + + // The URI value that identifies the author. + Uri string `json:"uri,omitempty"` +} + +type Note struct { + // The JSContact type of the object: the value MUST be `Note`, if set. + Type string `json:"@type,omitempty"` + + // The free-text value of this note. + Note string `json:"note"` + + // The date and time when this note was created. + Created time.Time `json:"created,omitzero"` + + // The author of this note. + Author *Author `json:"author,omitempty"` +} + +type PersonalInfo struct { + // The JSContact type of the object: the value MUST be `PersonalInfo`, if set. + Type string `json:"@type,omitempty"` + + // The kind of personal information. + // + // The enumerated values are: + // !- `expertise`: a field of expertise or a credential + // !- `hobby`: a hobby + // !- `interest`: an interest + Kind string `json:"kind"` + + // The actual information. + Value string `json:"value"` + + // The level of expertise or engagement in hobby or interest. + // + // The enumerated values are: + // !- `high` + // !- `medium` + // !- `low` + Level string `json:"level,omitempty"` + + // The position of the personal information in the list of all `PersonalInfo` objects that + // have the same kind property value in the Card. + // + // If set, the `listAs` value MUST be higher than zero. + // + // Multiple personal information entries MAY have the same `listAs` property value or none. + // + // Sorting such same-valued entries is implementation-specific. + ListAs uint `json:"listAs,omitzero"` + + // A [custom label]. + // + // The labels associated with the contact data. + // + // Such labels may be set for phone numbers, email addresses, and other resources. + // + // Typically, these labels are displayed along with their associated contact data in graphical user interfaces. + // + // Note that succinct labels are best for proper display on small graphical interfaces and screens. + // + // [custom label]: https://www.rfc-editor.org/rfc/rfc9553.html#prop-label + Label string `json:"label,omitempty"` +} + +// A ContactCard object contains information about a person, company, or other entity, or represents a group of such entities. +// +// It is a JSCard (JSContact) object, as defined in [RFC9553], with two additional properties. +// +// A contact card with a `kind` property equal to `group` represents a group of contacts. +// Clients often present these separately from other contact cards. +// +// The `members` property, as defined in RFC XXX, Section XXX, contains a set of UIDs for other contacts that are the members +// of this group. +// Clients should consider the group to contain any `ContactCard` with a matching UID, from any account they have access to with +// support for the `urn:ietf:params:jmap:contacts` capability. +// UIDs that cannot be found SHOULD be ignored but preserved. +// +// For example, suppose a user adds contacts from a shared address book to their private group, then temporarily loses access to +// this address book. +// The UIDs cannot be resolved so the contacts will disappear from the group. +// However, if they are given permission to access the data again the UIDs will be found and the contacts will reappear. +// +// [RFC9553]: https://www.rfc-editor.org/rfc/rfc9553.html +type ContactCard struct { + // The id of the Card (immutable; server-set). + // + // The id uniquely identifies a Card with a particular “uid” within a particular account. + // + // This is a JMAP extension and not part of [RFC9553]. + // + // [RFC9553]: https://www.rfc-editor.org/rfc/rfc9553.html + Id string `json:"id"` + + // The set of AddressBook ids this Card belongs to. + // + // A card MUST belong to at least one AddressBook at all times (until it is destroyed). + // + // The set is represented as an object, with each key being an AddressBook id. + // + // The value for each key in the object MUST be true. + // + // This is a JMAP extension and not part of [RFC9553]. + // + // [RFC9553]: https://www.rfc-editor.org/rfc/rfc9553.html + AddressBookIds map[string]bool `json:"addressBookIds"` + + // The JSContact type of the Card object: the value MUST be "Card". + Type string `json:"@type,omitempty"` + + // The JSContact version of this Card. + // + // The value MUST be one of the IANA-registered JSContact Version values for the version property. + // + // example: 1.0 + Version string `json:"version"` + + // The date and time when the Card was created (UTCDateTime). + // + // example: 2022-09-30T14:35:10Z + Created time.Time `json:"created,omitzero"` + + // The kind of the entity the Card represents (default: `individual``). + // + // Values are: + // !- `individual``: a single person + // !- group: a group of people or entities + // !- org: an organization + // !- location: a named location + // !- device: a device such as an appliance, a computer, or a network element + // !- application: a software application + // + // example: individual + Kind string `json:"kind,omitempty"` + + // The language tag, as defined in [RFC5646]. + // + // The language tag that best describes the language used for text in the Card, optionally including + // additional information such as the script. + // + // Note that values MAY be localized in the `localizations` property. + // + // [RFC5646]: https://www.rfc-editor.org/rfc/rfc5646.html + // + // example: de-AT + Language string `json:"language,omitempty"` + + // The set of Cards that are members of this group Card. + // + // Each key in the set is the uid property value of the member, and each boolean value MUST be `true`. + // If this property is set, then the value of the kind property MUST be `group`. + // + // The opposite is not true. A group Card will usually contain the members property to specify the members + // of the group, but it is not required to. + // + // A group Card without the members property can be considered an abstract grouping or one whose members + // are known empirically (e.g., `IETF Participants`). + // + // example: {"kind": "group", "name": {"full": "The Doe family"}, "uid": "urn:uuid:ab4310aa-fa43-11e9-8f0b-362b9e155667", "members": {"urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af": true, "urn:uuid:b8767877-b4a1-4c70-9acc-505d3819e519": true} + Members map[string]bool `json:"members,omitempty"` + + // The identifier for the product that created the Card. + // + // If set, the value MUST be at least one character long. + // + // example: ACME Contacts App version 1.23.5 + ProdId string `json:"prodId,omitempty"` + + // The set of Card objects that relate to the Card. + // + // The value is a map, where each key is the uid property value of the related Card, and the value + // defines the relation + // + // ```json + // { + // "relatedTo": { + // "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": { + // "relation": {"friend": true} + // }, + // "8cacdfb7d1ffdb59@example.com": { + // "relation": {} + // } + // } + // } + // ``` + // + // example: "relatedTo": {"urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": {"relation": {"friend": true}}, "8cacdfb7d1ffdb59@example.com": {"relation": {}}} + RelatedTo map[string]Relation `json:"relatedTo,omitempty"` + + // An identifier that associates the object as the same across different systems, address books, and views. + // + // The value SHOULD be a URN [RFC8141], but for compatibility with [RFC6350], it MAY also be a URI [RFC3986] + // or free-text value. + // + // The value of the URN SHOULD be in the "uuid" namespace [RFC9562]. + // + // [RFC9562] describes multiple versions of Universally Unique IDentifiers (UUIDs); UUID version 4 is RECOMMENDED. + // + // [RFC8141]: https://www.rfc-editor.org/rfc/rfc8141.html + // [RFC6350]: https://www.rfc-editor.org/rfc/rfc6350.html + // [RFC9562]: https://www.rfc-editor.org/rfc/rfc9562.html + // + // example: urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 + Uid string `json:"uid"` + + // The date and time when the data in the Card was last modified (UTCDateTime). + // + // example: 2021-10-31T22:27:10Z + Updated time.Time `json:"updated,omitzero"` + + // The name of the entity represented by the Card. + // + // This can be any type of name, e.g., it can, but need not, be the legal name of a person. + Name *Name `json:"name,omitempty"` + + // The nicknames of the entity represented by the Card. + Nicknames map[string]Nickname `json:"nicknames,omitempty"` + + // The company or organization names and units associated with the Card. + Organizations map[string]Organization `json:"organizations,omitempty"` + + // The information that directs how to address, speak to, or refer to the entity that is represented by the Card. + SpeakToAs *SpeakToAs `json:"speakToAs,omitempty"` + + // The job titles or functional positions of the entity represented by the Card. + Titles map[string]Title `json:"titles,omitempty"` + + // The email addresses in which to contact the entity represented by the Card. + Emails map[string]EmailAddress `json:"emails,omitempty"` + + // The online services that are associated with the entity represented by the Card. + // + // This can be messaging services, social media profiles, and other. + OnlineServices map[string]OnlineService `json:"onlineServices,omitempty"` + + // The phone numbers by which to contact the entity represented by the Card. + Phones map[string]Phone `json:"phones,omitempty"` + + // The preferred languages for contacting the entity associated with the Card. + PreferredLanguages map[string]LanguagePref `json:"preferredLanguages,omitempty"` + + // The calendaring resources of the entity represented by the Card, such as to look up free-busy information. + // + // A Calendar object has all properties of the Resource data type, with the following additional definitions: + // !- The `@type` property value MUST be `Calendar`, if set + // !- The `kind` property is mandatory. Its enumerated values are: + // !- `calendar`: The resource is a calendar that contains entries such as calendar events or tasks + // !- `freeBusy`: The resource allows for free-busy lookups, for example, to schedule group events + Calendars map[string]Resource `json:"calendars,omitempty"` + + // The scheduling addresses by which the entity may receive calendar scheduling invitations. + SchedulingAddresses map[string]SchedulingAddress `json:"schedulingAddresses,omitempty"` + + // The addresses of the entity represented by the Card, such as postal addresses or geographic locations. + Addresses map[string]Address `json:"addresses,omitempty"` + + // The cryptographic resources such as public keys and certificates associated with the entity represented by the Card. + // + // A CryptoKey object has all properties of the `Resource` data type, with the following additional definition: + // the `@type` property value MUST be `CryptoKey`, if set. + // + // The following example shows how to refer to an external cryptographic resource: + // ``` + // "cryptoKeys": { + // "mykey1": { + // "uri": "https://www.example.com/keys/jdoe.cer" + // } + // } + // ``` + CryptoKeys map[string]Resource `json:"cryptoKeys,omitempty"` + + // The directories containing information about the entity represented by the Card. + // + // A Directory object has all properties of the `Resource` data type, with the following additional definitions: + // !- The `@type` property value MUST be `Directory`, if set + // !- The `kind` property is mandatory; tts enumerated values are: + // !- `directory`: the resource is a directory service that the entity represented by the Card is a part of; this + // typically is an organizational directory that also contains associated entities, e.g., co-workers and management + // in a company directory + // !- `entry`: the resource is a directory entry of the entity represented by the Card; in contrast to the `directory` + // type, this is the specific URI for the entity within a directory + Directories map[string]DirectoryResource `json:"directories,omitempty"` + + // The links to resources that do not fit any of the other use-case-specific resource properties. + // + // A Link object has all properties of the `Resource` data type, with the following additional definitions: + // !- The `@type` property value MUST be `Link`, if set + // !- The `kind` property is optional; tts enumerated values are: + // !- `contact``: the resource is a URI by which the entity represented by the Card may be contacted; + // this includes web forms or other media that require user interaction + Links map[string]Resource `json:"links,omitempty"` + + // The media resources such as photographs, avatars, or sounds that are associated with the entity represented by the Card. + // + // A Media object has all properties of the Resource data type, with the following additional definitions: + // !- the `@type` property value MUST be `Media`, if set + // !- the `kind` property is mandatory; its enumerated values are: + // !- `photo`: the resource is a photograph or avatar + // !- `sound`: the resource is audio media, e.g., to specify the proper pronunciation of the name property contents + // !- `logo`: the resource is a graphic image or logo associated with the entity represented by the Card + Media map[string]MediaResource `json:"media,omitempty"` + + // The property values localized to languages other than the main `language` of the Card. + // + // Localizations provide language-specific alternatives for existing property values and SHOULD NOT add new properties. + // + // The keys in the localizations property value are language tags [RFC5646]; the values are of type `PatchObject` and + // localize the Card in that language tag. + // + // The paths in the `PatchObject` are relative to the Card that includes the localizations property. + // + // A patch MUST NOT target the localizations property. + // + // Conceptually, a Card is localized as follows: + // !- Determine the language tag in which the Card should be localized. + // !- If the localizations property includes a key for that language, obtain the PatchObject value; + // if there is no such key, stop. + // !- Create a copy of the Card, but do not copy the localizations property. + // !- Apply all patches in the PatchObject to the copy of the Card. + // !- Optionally, set the language property in the copy of the Card. + // !- Use the patched copy of the Card as the localized variant of the original Card. + // + // A patch in the `PatchObject` may contain any value type. + // + // Its value MUST be a valid value according to the definition of the patched property. + Localizations map[string]PatchObject `json:"localizations,omitempty"` + + // The memorable dates and events for the entity represented by the Card. + Anniversaries map[string]Anniversary `json:"anniversaries,omitempty"` + + // The set of free-text keywords, also known as tags. + // + // Each key in the set is a keyword, and each boolean value MUST be `true`. + Keywords map[string]bool `json:"keywords,omitempty"` + + // The free-text notes that are associated with the Card. + Notes map[string]Note `json:"notes,omitempty"` + + // The personal information of the entity represented by the Card. + PersonalInfo map[string]PersonalInfo `json:"personalInfo,omitempty"` +} diff --git a/pkg/jscontact/jscontact_model_test.go b/pkg/jscontact/jscontact_model_test.go new file mode 100644 index 000000000..a4aa17993 --- /dev/null +++ b/pkg/jscontact/jscontact_model_test.go @@ -0,0 +1,1197 @@ +package jscontact + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func jsoneq(t *testing.T, expected string, object any) { + str, err := json.MarshalIndent(object, "", "") + require.NoError(t, err) + require.JSONEq(t, expected, string(str)) +} + +func TestResource(t *testing.T) { + jsoneq(t, `{ + "@type": "Calendar", + "kind": "calendar", + "uri": "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025", + "mediaType": "application/jscontact+json", + "contexts": { + "work": true + }, + "label": "test" + }`, Resource{ + Type: CalendarType, + Kind: CalendarResourceKindCalendar, + Uri: "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025", + MediaType: "application/jscontact+json", + Contexts: map[string]bool{ + ResourceContextWork: true, + }, + Pref: 0, + Label: "test", + }) +} + +func TestDirectoryResource(t *testing.T) { + jsoneq(t, `{ + "@type": "Calendar", + "kind": "calendar", + "uri": "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025", + "mediaType": "application/jscontact+json", + "contexts": { + "work": true + }, + "label": "test", + "listAs": 3 + }`, DirectoryResource{ + Type: CalendarType, + Kind: CalendarResourceKindCalendar, + Uri: "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025", + MediaType: "application/jscontact+json", + Contexts: map[string]bool{ + ResourceContextWork: true, + }, + Pref: 0, + Label: "test", + ListAs: 3, + }) +} + +func TestMediaResource(t *testing.T) { + jsoneq(t, `{ + "@type": "Calendar", + "kind": "calendar", + "uri": "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025", + "mediaType": "application/jscontact+json", + "contexts": { + "work": true + }, + "label": "test", + "blobId": "1d92cf97e32b42ceb5538f0804a41891" + }`, MediaResource{ + Type: CalendarType, + Kind: CalendarResourceKindCalendar, + Uri: "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025", + MediaType: "application/jscontact+json", + Contexts: map[string]bool{ + ResourceContextWork: true, + }, + Pref: 0, + Label: "test", + BlobId: "1d92cf97e32b42ceb5538f0804a41891", + }) +} + +func TestRelation(t *testing.T) { + jsoneq(t, `{ + "@type": "Relation", + "relation": { + "co-worker": true, + "friend": true + } + }`, Relation{ + Type: RelationType, + Relation: map[string]bool{ + RelationCoWorker: true, + RelationFriend: true, + }, + }) +} + +func TestNameComponent(t *testing.T) { + jsoneq(t, `{ + "@type": "NameComponent", + "value": "Robert", + "kind": "given", + "phonetic": "Bob" + }`, NameComponent{ + Type: NameComponentType, + Value: "Robert", + Kind: NameComponentKindGiven, + Phonetic: "Bob", + }) +} + +func TestNickname(t *testing.T) { + jsoneq(t, `{ + "@type": "Nickname", + "name": "Bob", + "contexts": { + "private": true + }, + "pref": 3 + }`, Nickname{ + Type: NicknameType, + Name: "Bob", + Contexts: map[string]bool{ + NicknameContextPrivate: true, + }, + Pref: 3, + }) +} + +func TestOrgUnit(t *testing.T) { + jsoneq(t, `{ + "@type": "OrgUnit", + "name": "Skynet", + "sortAs": "SKY" + }`, OrgUnit{ + Type: OrgUnitType, + Name: "Skynet", + SortAs: "SKY", + }) +} + +func TestOrganization(t *testing.T) { + jsoneq(t, `{ + "@type": "Organization", + "name": "Cyberdyne", + "sortAs": "CYBER", + "units": [{ + "@type": "OrgUnit", + "name": "Skynet", + "sortAs": "SKY" + }, { + "@type": "OrgUnit", + "name": "Cybernics" + } + ], + "contexts": { + "work": true + } + }`, Organization{ + Type: OrganizationType, + Name: "Cyberdyne", + SortAs: "CYBER", + Units: []OrgUnit{ + { + Type: OrgUnitType, + Name: "Skynet", + SortAs: "SKY", + }, + { + Type: OrgUnitType, + Name: "Cybernics", + }, + }, + Contexts: map[string]bool{ + OrganizationContextWork: true, + }, + }) +} + +func TestPronouns(t *testing.T) { + jsoneq(t, `{ + "@type": "Pronouns", + "pronouns": "they/them", + "contexts": { + "work": true, + "private": true + }, + "pref": 1 + }`, Pronouns{ + Type: PronounsType, + Pronouns: "they/them", + Contexts: map[string]bool{ + PronounsContextWork: true, + PronounsContextPrivate: true, + }, + Pref: 1, + }) +} + +func TestTitle(t *testing.T) { + jsoneq(t, `{ + "@type": "Title", + "name": "Doctor", + "kind": "title", + "organizationId": "407e1992-9a2b-4e4f-a11b-85a509a4b5ae" + }`, Title{ + Type: TitleType, + Name: "Doctor", + Kind: TitleKindTitle, + OrganizationId: "407e1992-9a2b-4e4f-a11b-85a509a4b5ae", + }) +} + +func TestSpeakToAs(t *testing.T) { + jsoneq(t, `{ + "@type": "SpeakToAs", + "grammaticalGender": "neuter", + "pronouns": { + "a": { + "@type": "Pronouns", + "pronouns": "they/them", + "contexts": { + "private": true + }, + "pref": 1 + }, + "b": { + "@type": "Pronouns", + "pronouns": "he/him", + "contexts": { + "work": true + }, + "pref": 99 + } + } + }`, SpeakToAs{ + Type: SpeakToAsType, + GrammaticalGender: GrammaticalGenderNeuter, + Pronouns: map[string]Pronouns{ + "a": { + Type: JSContactTypePronouns, + Pronouns: "they/them", + Contexts: map[string]bool{ + PronounsContextPrivate: true, + }, + Pref: 1, + }, + "b": { + Type: JSContactTypePronouns, + Pronouns: "he/him", + Contexts: map[string]bool{ + PronounsContextWork: true, + }, + Pref: 99, + }, + }, + }) +} + +func TestName(t *testing.T) { + jsoneq(t, `{ + "@type": "Name", + "components": [ + { "@type": "NameComponent", "kind": "given", "value": "Diego", "phonetic": "/di\u02C8e\u026A\u0261əʊ/" }, + { "kind": "surname", "value": "Rivera" }, + { "kind": "surname2", "value": "Barrientos" } + ], + "isOrdered": true, + "defaultSeparator": " ", + "full": "Diego Rivera Barrientos", + "sortAs": { + "surname": "Rivera Barrientos", + "given": "Diego" + } + }`, Name{ + Type: NameType, + Components: []NameComponent{ + { + Type: NameComponentType, + Value: "Diego", + Kind: NameComponentKindGiven, + Phonetic: "/diˈeɪɡəʊ/", + }, + { + Value: "Rivera", + Kind: NameComponentKindSurname, + }, + { + Value: "Barrientos", + Kind: NameComponentKindSurname2, + }, + }, + IsOrdered: true, + DefaultSeparator: " ", + Full: "Diego Rivera Barrientos", + SortAs: map[string]string{ + NameComponentKindSurname: "Rivera Barrientos", + NameComponentKindGiven: "Diego", + }, + }) +} + +func TestEmailAddress(t *testing.T) { + jsoneq(t, `{ + "@type": "EmailAddress", + "address": "camina@opa.org", + "contexts": { + "work": true, + "private": true + }, + "pref": 1, + "label": "bosmang" + }`, EmailAddress{ + Type: EmailAddressType, + Address: "camina@opa.org", + Contexts: map[string]bool{ + EmailAddressContextWork: true, + EmailAddressContextPrivate: true, + }, + Pref: 1, + Label: "bosmang", + }) +} + +func TestOnlineService(t *testing.T) { + jsoneq(t, `{ + "@type": "OnlineService", + "service": "OPA Network", + "contexts": { + "work": true + }, + "uri": "https://opa.org/cdrummer", + "user": "cdrummer@opa.org", + "pref": 12, + "label": "opa" + }`, OnlineService{ + Type: OnlineServiceType, + Service: "OPA Network", + Contexts: map[string]bool{ + OnlineServiceContextWork: true, + }, + Uri: "https://opa.org/cdrummer", + User: "cdrummer@opa.org", + Pref: 12, + Label: "opa", + }) +} + +func TestPhone(t *testing.T) { + jsoneq(t, `{ + "@type": "Phone", + "number": "+15551234567", + "features": { + "text": true, + "main-number": true, + "mobile": true, + "video": true, + "voice": true + }, + "contexts": { + "work": true, + "private": true + }, + "pref": 42, + "label": "opa" + }`, Phone{ + Type: PhoneType, + Number: "+15551234567", + Features: map[string]bool{ + PhoneFeatureText: true, + PhoneFeatureMainNumber: true, + PhoneFeatureMobile: true, + PhoneFeatureVideo: true, + PhoneFeatureVoice: true, + }, + Contexts: map[string]bool{ + PhoneContextWork: true, + PhoneContextPrivate: true, + }, + Pref: 42, + Label: "opa", + }) +} + +func TestLanguagePref(t *testing.T) { + jsoneq(t, `{ + "@type": "LanguagePref", + "language": "fr-BE", + "contexts": { + "private": true + }, + "pref": 2 + }`, LanguagePref{ + Type: LanguagePrefType, + Language: "fr-BE", + Contexts: map[string]bool{ + LanguagePrefContextPrivate: true, + }, + Pref: 2, + }) +} + +func TestSchedulingAddress(t *testing.T) { + jsoneq(t, `{ + "@type": "SchedulingAddress", + "uri": "mailto:camina@opa.org", + "contexts": { + "work": true + }, + "pref": 3, + "label": "opa" + }`, SchedulingAddress{ + Type: SchedulingAddressType, + Uri: "mailto:camina@opa.org", + Label: "opa", + Contexts: map[string]bool{ + SchedulingAddressContextWork: true, + }, + Pref: 3, + }) +} + +func TestAddressComponent(t *testing.T) { + jsoneq(t, `{ + "@type": "AddressComponent", + "kind": "postcode", + "value": "12345", + "phonetic": "un-deux-trois-quatre-cinq" + }`, AddressComponent{ + Type: AddressComponentType, + Kind: AddressComponentKindPostcode, + Value: "12345", + Phonetic: "un-deux-trois-quatre-cinq", + }) +} + +func TestAddress(t *testing.T) { + jsoneq(t, `{ + "@type": "Address", + "contexts": { + "delivery": true, + "work": true + }, + "components": [ + {"@type": "AddressComponent", "kind": "number", "value": "54321"}, + {"kind": "separator", "value": " "}, + {"kind": "name", "value": "Oak St"}, + {"kind": "locality", "value": "Reston"}, + {"kind": "region", "value": "VA"}, + {"kind": "separator", "value": " "}, + {"kind": "postcode", "value": "20190"}, + {"kind": "country", "value": "USA"} + ], + "countryCode": "US", + "defaultSeparator": ", ", + "isOrdered": true + }`, Address{ + Type: AddressType, + Contexts: map[string]bool{ + AddressContextDelivery: true, + AddressContextWork: true, + }, + Components: []AddressComponent{ + {Type: AddressComponentType, Kind: AddressComponentKindNumber, Value: "54321"}, + {Kind: AddressComponentKindSeparator, Value: " "}, + {Kind: AddressComponentKindName, Value: "Oak St"}, + {Kind: AddressComponentKindLocality, Value: "Reston"}, + {Kind: AddressComponentKindRegion, Value: "VA"}, + {Kind: AddressComponentKindSeparator, Value: " "}, + {Kind: AddressComponentKindPostcode, Value: "20190"}, + {Kind: AddressComponentKindCountry, Value: "USA"}, + }, + CountryCode: "US", + DefaultSeparator: ", ", + IsOrdered: true, + }) +} + +func TestPartialDate(t *testing.T) { + jsoneq(t, `{ + "@type": "PartialDate", + "year": 2025, + "month": 9, + "day": 25, + "calendarScale": "iso8601" + }`, PartialDate{ + Type: PartialDateType, + Year: 2025, + Month: 9, + Day: 25, + CalendarScale: "iso8601", + }) +} + +func TestTimestamp(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14.094725532+02:00") + require.NoError(t, err) + jsoneq(t, `{ + "@type": "Timestamp", + "utc": "2025-09-25T18:26:14.094725532+02:00" + }`, Timestamp{ + Type: TimestampType, + Utc: ts, + }) +} + +func TestAnniversaryWithPartialDate(t *testing.T) { + jsoneq(t, `{ + "@type": "Anniversary", + "kind": "birth", + "date": { + "@type": "PartialDate", + "year": 2025, + "month": 9, + "day": 25 + } + }`, Anniversary{ + Type: AnniversaryType, + Kind: AnniversaryKindBirth, + Date: PartialDate{ + Type: PartialDateType, + Year: 2025, + Month: 9, + Day: 25, + }, + }) +} + +func TestAnniversaryWithTimestamp(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14.094725532+02:00") + require.NoError(t, err) + + jsoneq(t, `{ + "@type": "Anniversary", + "kind": "birth", + "date": { + "@type": "Timestamp", + "utc": "2025-09-25T18:26:14.094725532+02:00" + } + }`, Anniversary{ + Type: AnniversaryType, + Kind: AnniversaryKindBirth, + Date: Timestamp{ + Type: TimestampType, + Utc: ts, + }, + }) +} + +func TestAuthor(t *testing.T) { + jsoneq(t, `{ + "@type": "Author", + "name": "Camina Drummer", + "uri": "https://opa.org/cdrummer" + }`, Author{ + Type: AuthorType, + Name: "Camina Drummer", + Uri: "https://opa.org/cdrummer", + }) +} + +func TestNote(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14.094725532+02:00") + require.NoError(t, err) + + jsoneq(t, `{ + "@type": "Note", + "note": "this is a note", + "created": "2025-09-25T18:26:14.094725532+02:00", + "author": { + "@type": "Author", + "name": "Camina Drummer", + "uri": "https://opa.org/cdrummer" + } + }`, Note{ + Type: NoteType, + Note: "this is a note", + Created: ts, + Author: &Author{ + Type: AuthorType, + Name: "Camina Drummer", + Uri: "https://opa.org/cdrummer", + }, + }) +} + +func TestPersonalInfo(t *testing.T) { + jsoneq(t, `{ + "@type": "PersonalInfo", + "kind": "expertise", + "value": "motivation", + "level": "high", + "listAs": 1, + "label": "opa" + }`, PersonalInfo{ + Type: PersonalInfoType, + Kind: PersonalInfoKindExpertise, + Value: "motivation", + Level: PersonalInfoLevelHigh, + ListAs: 1, + Label: "opa", + }) +} + +func TestContactCard(t *testing.T) { + created, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14.094725532+02:00") + require.NoError(t, err) + + updated, err := time.Parse(time.RFC3339, "2025-09-26T09:58:01+02:00") + require.NoError(t, err) + + jsoneq(t, `{ + "@type": "Card", + "kind": "group", + "id": "20fba820-2f8e-432d-94f1-5abbb59d3ed7", + "addressBookIds": { + "79047052-ae0e-4299-8860-5bff1a139f3d": true, + "44eb6105-08c1-458b-895e-4ad1149dfabd": true + }, + "version": "1.0", + "created": "2025-09-25T18:26:14.094725532+02:00", + "language": "fr-BE", + "members": { + "314815dd-81c8-4640-aace-6dc83121616d": true, + "c528b277-d8cb-45f2-b7df-1aa3df817463": true, + "81dea240-c0a4-4929-82e7-79e713a8bbe4": true + }, + "prodId": "OpenCloud Groupware 1.0", + "relatedTo": { + "urn:uid:ca9d2a62-e068-43b6-a470-46506976d505": { + "@type": "Relation", + "relation": { + "contact": true + } + }, + "urn:uid:72183ec2-b218-4983-9c89-ff117eeb7c5e": { + "relation": { + "emergency": true, + "spouse": true + } + } + }, + "uid": "1091f2bb-6ae6-4074-bb64-df74071d7033", + "updated": "2025-09-26T09:58:01+02:00", + "name": { + "@type": "Name", + "components": [ + {"@type": "NameComponent", "value": "OpenCloud", "kind": "surname"}, + {"value": " ", "kind": "separator"}, + {"value": "Team", "kind": "surname2"} + ], + "isOrdered": true, + "defaultSeparator": ", ", + "sortAs": { + "surname": "OpenCloud Team" + }, + "full": "OpenCloud Team" + }, + "nicknames": { + "a": { + "@type": "Nickname", + "name": "The Team", + "contexts": { + "work": true + }, + "pref": 1 + } + }, + "organizations": { + "o": { + "@type": "Organization", + "name": "OpenCloud GmbH", + "units": [ + {"@type": "OrgUnit", "name": "Marketing", "sortAs": "marketing"}, + {"@type": "OrgUnit", "name": "Sales"}, + {"name": "Operations", "sortAs": "ops"} + ], + "sortAs": "opencloud", + "contexts": { + "work": true + } + } + }, + "speakToAs": { + "@type": "SpeakToAs", + "grammaticalGender": "inanimate", + "pronouns": { + "p": { + "@type": "Pronouns", + "pronouns": "it", + "contexts": { + "work": true + }, + "pref": 1 + } + } + }, + "titles": { + "t": { + "@type": "Title", + "name": "The", + "kind": "title", + "organizationId": "o" + } + }, + "emails": { + "e": { + "@type": "EmailAddress", + "address": "info@opencloud.eu.example.com", + "contexts": { + "work": true + }, + "pref": 1, + "label": "work" + } + }, + "onlineServices": { + "s": { + "@type": "OnlineService", + "service": "The Misinformation Game", + "uri": "https://misinfogame.com/91886aa0-3586-4ade-b9bb-ec031464a251", + "user": "opencloudeu", + "contexts": { + "work": true + }, + "pref": 1, + "label": "imaginary" + } + }, + "phones": { + "p": { + "@type": "Phone", + "number": "+1-804-222-1111", + "features": { + "voice": true, + "text": true + }, + "contexts": { + "work": true + }, + "pref": 1, + "label": "imaginary" + } + }, + "preferredLanguages": { + "wa": { + "@type": "LanguagePref", + "language": "wa-BE", + "contexts": { + "private": true + }, + "pref": 1 + }, + "de": { + "language": "de-DE", + "contexts": { + "work": true + }, + "pref": 2 + } + }, + "calendars": { + "c": { + "@type": "Calendar", + "kind": "calendar", + "uri": "https://opencloud.eu/calendars/521b032b-a2b3-4540-81b9-3f6bccacaab2", + "mediaType": "application/jscontact+json", + "contexts": { + "work": true + }, + "pref": 1, + "label": "work" + } + }, + "schedulingAddresses": { + "s": { + "@type": "SchedulingAddress", + "uri": "mailto:scheduling@opencloud.eu.example.com", + "contexts": { + "work": true + }, + "pref": 1, + "label": "work" + } + }, + "addresses": { + "k26": { + "@type": "Address", + "components": [ + {"@type": "AddressComponent", "kind": "block", "value": "2-7"}, + {"kind": "separator", "value": "-"}, + {"kind": "number", "value": "2"}, + {"kind": "separator", "value": " "}, + {"kind": "district", "value": "Marunouchi"}, + {"kind": "locality", "value": "Chiyoda-ku"}, + {"kind": "region", "value": "Tokyo"}, + {"kind": "separator", "value": " "}, + {"kind": "postcode", "value": "100-8994"} + ], + "isOrdered": true, + "defaultSeparator": ", ", + "full": "2-7-2 Marunouchi, Chiyoda-ku, Tokyo 100-8994", + "countryCode": "JP", + "coordinates": "geo:35.6796373,139.7616907", + "timeZone": "JST", + "contexts": { + "delivery": true, + "work": true + }, + "pref": 2 + } + }, + "cryptoKeys": { + "k1": { + "@type": "CryptoKey", + "uri": "https://opencloud.eu.example.com/keys/d550f57c-582c-43cc-8d94-822bded9ab36", + "mediaType": "application/pgp-keys", + "contexts": { + "work": true + }, + "pref": 1, + "label": "keys" + } + }, + "directories": { + "d1": { + "@type": "Directory", + "kind": "entry", + "uri": "https://opencloud.eu.example.com/addressbook/8c2f0363-af0a-4d16-a9d5-8a9cd885d722", + "listAs": 1 + } + }, + "links": { + "r1": { + "@type": "Link", + "kind": "contact", + "uri": "mailto:contact@opencloud.eu.example.com" + } + }, + "media": { + "m": { + "@type": "Media", + "kind": "logo", + "uri": "https://opencloud.eu.example.com/opencloud.svg", + "mediaType": "image/svg+xml", + "contexts": { + "work": true + }, + "pref": 123, + "label": "svg", + "blobId": "53feefbabeb146fcbe3e59e91462fa5f" + } + }, + "anniversaries": { + "birth": { + "@type": "Anniversary", + "kind": "birth", + "date": { + "@type": "PartialDate", + "year": 2025, + "month": 9, + "day": 26, + "calendarScale": "iso8601" + } + } + }, + "keywords": { + "imaginary": true, + "test": true + }, + "notes": { + "n1": { + "@type": "Note", + "note": "This is a note.", + "created": "2025-09-25T18:26:14.094725532+02:00", + "author": { + "@type": "Author", + "name": "Test Data", + "uri": "https://isbn.example.com/a461f292-6bf1-470e-b08d-f6b4b0223fe3" + } + } + }, + "personalInfo": { + "p1": { + "@type": "PersonalInfo", + "kind": "expertise", + "value": "Clouds", + "level": "high", + "listAs": 1, + "label": "experts" + } + }, + "localizations": { + "fr": { + "personalInfo": { + "value": "Nuages" + } + } + } + }`, ContactCard{ + Type: ContactCardType, + Kind: ContactCardKindGroup, + Id: "20fba820-2f8e-432d-94f1-5abbb59d3ed7", + AddressBookIds: map[string]bool{ + "79047052-ae0e-4299-8860-5bff1a139f3d": true, + "44eb6105-08c1-458b-895e-4ad1149dfabd": true, + }, + Version: JSContactVersion, + Created: created, + Language: "fr-BE", + Members: map[string]bool{ + "314815dd-81c8-4640-aace-6dc83121616d": true, + "c528b277-d8cb-45f2-b7df-1aa3df817463": true, + "81dea240-c0a4-4929-82e7-79e713a8bbe4": true, + }, + ProdId: "OpenCloud Groupware 1.0", + RelatedTo: map[string]Relation{ + "urn:uid:ca9d2a62-e068-43b6-a470-46506976d505": { + Type: RelationType, + Relation: map[string]bool{ + RelationContact: true, + }, + }, + "urn:uid:72183ec2-b218-4983-9c89-ff117eeb7c5e": { + Relation: map[string]bool{ + RelationEmergency: true, + RelationSpouse: true, + }, + }, + }, + Uid: "1091f2bb-6ae6-4074-bb64-df74071d7033", + Updated: updated, + Name: &Name{ + Type: NameType, + Components: []NameComponent{ + {Type: NameComponentType, Value: "OpenCloud", Kind: NameComponentKindSurname}, + {Value: " ", Kind: NameComponentKindSeparator}, + {Value: "Team", Kind: NameComponentKindSurname2}, + }, + IsOrdered: true, + DefaultSeparator: ", ", + SortAs: map[string]string{ + NameComponentKindSurname: "OpenCloud Team", + }, + Full: "OpenCloud Team", + }, + Nicknames: map[string]Nickname{ + "a": { + Type: NicknameType, + Name: "The Team", + Contexts: map[string]bool{ + NicknameContextWork: true, + }, + Pref: 1, + }, + }, + Organizations: map[string]Organization{ + "o": { + Type: OrganizationType, + Name: "OpenCloud GmbH", + Units: []OrgUnit{ + {Type: OrgUnitType, Name: "Marketing", SortAs: "marketing"}, + {Type: OrgUnitType, Name: "Sales"}, + {Name: "Operations", SortAs: "ops"}, + }, + SortAs: "opencloud", + Contexts: map[string]bool{ + OrganizationContextWork: true, + }, + }, + }, + SpeakToAs: &SpeakToAs{ + Type: SpeakToAsType, + GrammaticalGender: GrammaticalGenderInanimate, + Pronouns: map[string]Pronouns{ + "p": { + Type: PronounsType, + Pronouns: "it", + Contexts: map[string]bool{ + PronounsContextWork: true, + }, + Pref: 1, + }, + }, + }, + Titles: map[string]Title{ + "t": { + Type: TitleType, + Name: "The", + Kind: TitleKindTitle, + OrganizationId: "o", + }, + }, + Emails: map[string]EmailAddress{ + "e": { + Type: EmailAddressType, + Address: "info@opencloud.eu.example.com", + Contexts: map[string]bool{ + EmailAddressContextWork: true, + }, + Pref: 1, + Label: "work", + }, + }, + OnlineServices: map[string]OnlineService{ + "s": { + Type: OnlineServiceType, + Service: "The Misinformation Game", + Uri: "https://misinfogame.com/91886aa0-3586-4ade-b9bb-ec031464a251", + User: "opencloudeu", + Contexts: map[string]bool{ + OnlineServiceContextWork: true, + }, + Pref: 1, + Label: "imaginary", + }, + }, + Phones: map[string]Phone{ + "p": { + Type: PhoneType, + Number: "+1-804-222-1111", + Features: map[string]bool{ + PhoneFeatureVoice: true, + PhoneFeatureText: true, + }, + Contexts: map[string]bool{ + PhoneContextWork: true, + }, + Pref: 1, + Label: "imaginary", + }, + }, + PreferredLanguages: map[string]LanguagePref{ + "wa": { + Type: LanguagePrefType, + Language: "wa-BE", + Contexts: map[string]bool{ + LanguagePrefContextPrivate: true, + }, + Pref: 1, + }, + "de": { + Language: "de-DE", + Contexts: map[string]bool{ + LanguagePrefContextWork: true, + }, + Pref: 2, + }, + }, + Calendars: map[string]Resource{ + "c": { + Type: CalendarType, + Kind: CalendarResourceKindCalendar, + Uri: "https://opencloud.eu/calendars/521b032b-a2b3-4540-81b9-3f6bccacaab2", + MediaType: "application/jscontact+json", + Contexts: map[string]bool{ + CalendarContextWork: true, + }, + Pref: 1, + Label: "work", + }, + }, + SchedulingAddresses: map[string]SchedulingAddress{ + "s": { + Type: SchedulingAddressType, + Uri: "mailto:scheduling@opencloud.eu.example.com", + Contexts: map[string]bool{ + SchedulingAddressContextWork: true, + }, + Pref: 1, + Label: "work", + }, + }, + Addresses: map[string]Address{ + "k26": { + Type: AddressType, + Components: []AddressComponent{ + {Type: AddressComponentType, Kind: AddressComponentKindBlock, Value: "2-7"}, + {Kind: AddressComponentKindSeparator, Value: "-"}, + {Kind: AddressComponentKindNumber, Value: "2"}, + {Kind: AddressComponentKindSeparator, Value: " "}, + {Kind: AddressComponentKindDistrict, Value: "Marunouchi"}, + {Kind: AddressComponentKindLocality, Value: "Chiyoda-ku"}, + {Kind: AddressComponentKindRegion, Value: "Tokyo"}, + {Kind: AddressComponentKindSeparator, Value: " "}, + {Kind: AddressComponentKindPostcode, Value: "100-8994"}, + }, + IsOrdered: true, + DefaultSeparator: ", ", + Full: "2-7-2 Marunouchi, Chiyoda-ku, Tokyo 100-8994", + CountryCode: "JP", + Coordinates: "geo:35.6796373,139.7616907", + TimeZone: "JST", + Contexts: map[string]bool{ + AddressContextDelivery: true, + AddressContextWork: true, + }, + Pref: 2, + }, + }, + CryptoKeys: map[string]Resource{ + "k1": { + Type: CryptoKeyType, + Uri: "https://opencloud.eu.example.com/keys/d550f57c-582c-43cc-8d94-822bded9ab36", + MediaType: "application/pgp-keys", + Contexts: map[string]bool{ + CryptoKeyContextWork: true, + }, + Pref: 1, + Label: "keys", + }, + }, + Directories: map[string]DirectoryResource{ + "d1": { + Type: DirectoryType, + Kind: DirectoryResourceKindEntry, + Uri: "https://opencloud.eu.example.com/addressbook/8c2f0363-af0a-4d16-a9d5-8a9cd885d722", + ListAs: 1, + }, + }, + Links: map[string]Resource{ + "r1": { + Type: LinkType, + Kind: LinkResourceKindContact, + Uri: "mailto:contact@opencloud.eu.example.com", + }, + }, + Media: map[string]MediaResource{ + "m": { + Type: MediaType, + Kind: MediaResourceKindLogo, + Uri: "https://opencloud.eu.example.com/opencloud.svg", + MediaType: "image/svg+xml", + Contexts: map[string]bool{ + MediaContextWork: true, + }, + Pref: 123, + Label: "svg", + BlobId: "53feefbabeb146fcbe3e59e91462fa5f", + }, + }, + Anniversaries: map[string]Anniversary{ + "birth": { + Type: AnniversaryType, + Kind: AnniversaryKindBirth, + Date: PartialDate{ + Type: PartialDateType, + Year: 2025, + Month: 9, + Day: 26, + CalendarScale: "iso8601", + }, + }, + }, + Keywords: map[string]bool{ + "imaginary": true, + "test": true, + }, + Notes: map[string]Note{ + "n1": { + Type: NoteType, + Note: "This is a note.", + Created: created, + Author: &Author{ + Type: AuthorType, + Name: "Test Data", + Uri: "https://isbn.example.com/a461f292-6bf1-470e-b08d-f6b4b0223fe3", + }, + }, + }, + PersonalInfo: map[string]PersonalInfo{ + "p1": { + Type: PersonalInfoType, + Kind: PersonalInfoKindExpertise, + Value: "Clouds", + Level: PersonalInfoLevelHigh, + ListAs: 1, + Label: "experts", + }, + }, + Localizations: map[string]PatchObject{ + "fr": { + "personalInfo": PatchObject{ + "value": "Nuages", + }, + }, + }, + }) +}