From 09f69c5a626426d1f346ce8de109b706817d175a Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Fri, 26 Sep 2025 16:22:03 +0200 Subject: [PATCH] implement JSCalendar (RFC 8984) --- pkg/jscalendar/jscalendar_model.go | 2188 +++++++++++++++++++++++ pkg/jscalendar/jscalendar_model_test.go | 754 ++++++++ pkg/jscontact/jscontact_model.go | 30 + pkg/jscontact/jscontact_model_test.go | 21 +- 4 files changed, 2985 insertions(+), 8 deletions(-) create mode 100644 pkg/jscalendar/jscalendar_model.go create mode 100644 pkg/jscalendar/jscalendar_model_test.go diff --git a/pkg/jscalendar/jscalendar_model.go b/pkg/jscalendar/jscalendar_model.go new file mode 100644 index 0000000000..db8fa9a5f6 --- /dev/null +++ b/pkg/jscalendar/jscalendar_model.go @@ -0,0 +1,2188 @@ +package jscalendar + +import ( + "encoding/json" + "fmt" + "time" +) + +// This is a date-time string with no time zone/offset information. +// +// It is otherwise in the same format as `UTCDateTime`, including fractional seconds. +// +// For example, `2006-01-02T15:04:05` and `2006-01-02T15:04:05.003` are both valid. +type LocalDateTime struct { + time.Time +} + +type TypeOfRelation string +type TypeOfLink string +type TypeOfEvent string +type TypeOfTask string +type TypeOfGroup string +type TypeOfLocation string +type TypeOfVirtualLocation string +type TypeOfRecurrenceRule string +type TypeOfNDay string +type TypeOfParticipant string +type TypeOfAlert string +type TypeOfOffsetTrigger string +type TypeOfAbsoluteTrigger string +type TypeOfTimeZone string +type TypeOfTimeZoneRule string + +type Duration string // TODO +type SignedDuration string // TODO + +type Relationship string +type Display string +type Rel string +type Method string +type LocationTypeOption string +type LocationRelation string +type VirtualLocationFeature string +type Frequency string +type Skip string +type DayOfWeek string +type FreeBusyStatus string +type Privacy string +type ReplyMethod string +type SendToMethod string +type ParticipantKind string +type Role string +type ParticipationStatus string +type ScheduleAgent string +type Progress string +type RelativeTo string +type AlertAction string +type Status string + +const ( + EventMediaType = "application/jscalendar+json;type=event" + TaskMediaType = "application/jscalendar+json;type=task" + GroupMediaType = "application/jscalendar+json;type=group" + + DefaultDescriptionContentType = "text/plain" + + RelationType = TypeOfRelation("Relation") + LinkType = TypeOfLink("Link") + EventType = TypeOfEvent("Event") + TaskType = TypeOfTask("Task") + GroupType = TypeOfGroup("Group") + LocationType = TypeOfLocation("Location") + VirtualLocationType = TypeOfVirtualLocation("VirtualLocation") + RecurrenceRuleType = TypeOfRecurrenceRule("RecurrenceRule") + NDayType = TypeOfNDay("NDay") + ParticipantType = TypeOfParticipant("Participant") + AlertType = TypeOfAlert("Alert") + OffsetTriggerType = TypeOfOffsetTrigger("OffsetTrigger") + AbsoluteTriggerType = TypeOfAbsoluteTrigger("AbsoluteTrigger") + TimeZoneType = TypeOfTimeZone("TimeZone") + TimeZoneRuleType = TypeOfTimeZoneRule("TimeZoneRule") + + RelationshipFirst = Relationship("first") + RelationshipNext = Relationship("next") + RelationshipChild = Relationship("child") + RelationshipParent = Relationship("parent") + + DisplayBadge = Display("badge") + DisplayGraphic = Display("graphic") + DisplayFullsize = Display("fullsize") + DisplayThumbnail = Display("thumbnail") + + // curl https://www.iana.org/assignments/link-relations/link-relations.xml | xq -x '//record/value'|sort + RelAbout = Rel("about") + RelAcl = Rel("acl") + RelAlternate = Rel("alternate") + RelAmphtml = Rel("amphtml") + RelApiCatalog = Rel("api-catalog") + RelAppendix = Rel("appendix") + RelAppleTouchIcon = Rel("apple-touch-icon") + RelAppleTouchStartupImage = Rel("apple-touch-startup-image") + RelArchives = Rel("archives") + RelAuthor = Rel("author") + RelBlockedBy = Rel("blocked-by") + RelBookmark = Rel("bookmark") + RelC2paManifest = Rel("c2pa-manifest") + RelCanonical = Rel("canonical") + RelChapter = Rel("chapter") + RelCiteAs = Rel("cite-as") + RelCollection = Rel("collection") + RelCompressionDictionary = Rel("compression-dictionary") + RelContents = Rel("contents") + RelConvertedfrom = Rel("convertedfrom") + RelCopyright = Rel("copyright") + RelCreateForm = Rel("create-form") + RelCurrent = Rel("current") + RelDeprecation = Rel("deprecation") + RelDescribedby = Rel("describedby") + RelDescribes = Rel("describes") + RelDisclosure = Rel("disclosure") + RelDnsPrefetch = Rel("dns-prefetch") + RelDuplicate = Rel("duplicate") + RelEdit = Rel("edit") + RelEditForm = Rel("edit-form") + RelEditMedia = Rel("edit-media") + RelEnclosure = Rel("enclosure") + RelExternal = Rel("external") + RelFirst = Rel("first") + RelGeofeed = Rel("geofeed") + RelGlossary = Rel("glossary") + RelHelp = Rel("help") + RelHosts = Rel("hosts") + RelHub = Rel("hub") + RelIceServer = Rel("ice-server") + RelIcon = Rel("icon") + RelIndex = Rel("index") + RelIntervalafter = Rel("intervalafter") + RelIntervalbefore = Rel("intervalbefore") + RelIntervalcontains = Rel("intervalcontains") + RelIntervaldisjoint = Rel("intervaldisjoint") + RelIntervalduring = Rel("intervalduring") + RelIntervalequals = Rel("intervalequals") + RelIntervalfinishedby = Rel("intervalfinishedby") + RelIntervalfinishes = Rel("intervalfinishes") + RelIntervalin = Rel("intervalin") + RelIntervalmeets = Rel("intervalmeets") + RelIntervalmetby = Rel("intervalmetby") + RelIntervaloverlappedby = Rel("intervaloverlappedby") + RelIntervaloverlaps = Rel("intervaloverlaps") + RelIntervalstartedby = Rel("intervalstartedby") + RelIntervalstarts = Rel("intervalstarts") + RelItem = Rel("item") + RelLast = Rel("last") + RelLatestVersion = Rel("latest-version") + RelLicense = Rel("license") + RelLinkset = Rel("linkset") + RelLrdd = Rel("lrdd") + RelManifest = Rel("manifest") + RelMaskIcon = Rel("mask-icon") + RelMe = Rel("me") + RelMediaFeed = Rel("media-feed") + RelMemento = Rel("memento") + RelMicropub = Rel("micropub") + RelModulepreload = Rel("modulepreload") + RelMonitor = Rel("monitor") + RelMonitorGroup = Rel("monitor-group") + RelNext = Rel("next") + RelNextArchive = Rel("next-archive") + RelNofollow = Rel("nofollow") + RelNoopener = Rel("noopener") + RelNoreferrer = Rel("noreferrer") + RelOpener = Rel("opener") + RelOpenid2LocalId = Rel("openid2.local_id") + RelOpenid2Provider = Rel("openid2.provider") + RelOriginal = Rel("original") + RelP3pv1 = Rel("p3pv1") + RelPayment = Rel("payment") + RelPingback = Rel("pingback") + RelPreconnect = Rel("preconnect") + RelPredecessorVersion = Rel("predecessor-version") + RelPrefetch = Rel("prefetch") + RelPreload = Rel("preload") + RelPrerender = Rel("prerender") + RelPrev = Rel("prev") + RelPrevArchive = Rel("prev-archive") + RelPreview = Rel("preview") + RelPrevious = Rel("previous") + RelPrivacyPolicy = Rel("privacy-policy") + RelProfile = Rel("profile") + RelPublication = Rel("publication") + RelRdapActive = Rel("rdap-active") + RelRdapBottom = Rel("rdap-bottom") + RelRdapDown = Rel("rdap-down") + RelRdapTop = Rel("rdap-top") + RelRdapUp = Rel("rdap-up") + RelRelated = Rel("related") + RelReplies = Rel("replies") + RelRestconf = Rel("restconf") + RelRuleinput = Rel("ruleinput") + RelSearch = Rel("search") + RelSection = Rel("section") + RelSelf = Rel("self") + RelService = Rel("service") + RelServiceDesc = Rel("service-desc") + RelServiceDoc = Rel("service-doc") + RelServiceMeta = Rel("service-meta") + RelSipTrunkingCapability = Rel("sip-trunking-capability") + RelSponsored = Rel("sponsored") + RelStart = Rel("start") + RelStatus = Rel("status") + RelStylesheet = Rel("stylesheet") + RelSubsection = Rel("subsection") + RelSuccessorVersion = Rel("successor-version") + RelSunset = Rel("sunset") + RelTag = Rel("tag") + RelTermsOfService = Rel("terms-of-service") + RelTimegate = Rel("timegate") + RelTimemap = Rel("timemap") + RelType = Rel("type") + RelUgc = Rel("ugc") + RelUp = Rel("up") + RelVersionHistory = Rel("version-history") + RelVia = Rel("via") + RelWebmention = Rel("webmention") + RelWorkingCopy = Rel("working-copy") + RelWorkingCopyOf = Rel("working-copy-of") + + MethodPublish = Method("publish") + MethodRequest = Method("request") + MethodReply = Method("reply") + MethodAdd = Method("add") + MethodCancel = Method("cancel") + MethodRefresh = Method("refresh") + MethodCounter = Method("counter") + MethodDeclineCounter = Method("declinecounter") + + // mlr --csv --headerless-csv-output cut -f Token ./location-type-registry-1.csv |sort|perl -ne 'chomp; print "LocationTypeOption".ucfirst($_)." = LocationTypeOption(\"".$_."\")\n"' + + LocationTypeOptionAircraft = LocationTypeOption("aircraft") + LocationTypeOptionAirport = LocationTypeOption("airport") + LocationTypeOptionArena = LocationTypeOption("arena") + LocationTypeOptionAutomobile = LocationTypeOption("automobile") + LocationTypeOptionBank = LocationTypeOption("bank") + LocationTypeOptionBar = LocationTypeOption("bar") + LocationTypeOptionBicycle = LocationTypeOption("bicycle") + LocationTypeOptionBus = LocationTypeOption("bus") + LocationTypeOptionBusStation = LocationTypeOption("bus-station") + LocationTypeOptionCafe = LocationTypeOption("cafe") + LocationTypeOptionCampground = LocationTypeOption("campground") + LocationTypeOptionCareFacility = LocationTypeOption("care-facility") + LocationTypeOptionClassroom = LocationTypeOption("classroom") + LocationTypeOptionClub = LocationTypeOption("club") + LocationTypeOptionConstruction = LocationTypeOption("construction") + LocationTypeOptionConventionCenter = LocationTypeOption("convention-center") + LocationTypeOptionDetachedUnit = LocationTypeOption("detached-unit") + LocationTypeOptionFireStation = LocationTypeOption("fire-station") + LocationTypeOptionGovernment = LocationTypeOption("government") + LocationTypeOptionHospital = LocationTypeOption("hospital") + LocationTypeOptionHotel = LocationTypeOption("hotel") + LocationTypeOptionIndustrial = LocationTypeOption("industrial") + LocationTypeOptionLandmarkAddress = LocationTypeOption("landmark-address") + LocationTypeOptionLibrary = LocationTypeOption("library") + LocationTypeOptionMotorcycle = LocationTypeOption("motorcycle") + LocationTypeOptionMunicipalGarage = LocationTypeOption("municipal-garage") + LocationTypeOptionMuseum = LocationTypeOption("museum") + LocationTypeOptionOffice = LocationTypeOption("office") + LocationTypeOptionOther = LocationTypeOption("other") + LocationTypeOptionOutdoors = LocationTypeOption("outdoors") + LocationTypeOptionParking = LocationTypeOption("parking") + LocationTypeOptionPhoneBox = LocationTypeOption("phone-box") + LocationTypeOptionPlaceOfWorship = LocationTypeOption("place-of-worship") + LocationTypeOptionPostOffice = LocationTypeOption("post-office") + LocationTypeOptionPrison = LocationTypeOption("prison") + LocationTypeOptionPublic = LocationTypeOption("public") + LocationTypeOptionPublicTransport = LocationTypeOption("public-transport") + LocationTypeOptionResidence = LocationTypeOption("residence") + LocationTypeOptionRestaurant = LocationTypeOption("restaurant") + LocationTypeOptionSchool = LocationTypeOption("school") + LocationTypeOptionShoppingArea = LocationTypeOption("shopping-area") + LocationTypeOptionStadium = LocationTypeOption("stadium") + LocationTypeOptionStore = LocationTypeOption("store") + LocationTypeOptionStreet = LocationTypeOption("street") + LocationTypeOptionTheater = LocationTypeOption("theater") + LocationTypeOptionTollBooth = LocationTypeOption("toll-booth") + LocationTypeOptionTownHall = LocationTypeOption("town-hall") + LocationTypeOptionTrain = LocationTypeOption("train") + LocationTypeOptionTrainStation = LocationTypeOption("train-station") + LocationTypeOptionTruck = LocationTypeOption("truck") + LocationTypeOptionUnderway = LocationTypeOption("underway") + LocationTypeOptionUnknown = LocationTypeOption("unknown") + LocationTypeOptionUtilitybox = LocationTypeOption("utilitybox") + LocationTypeOptionWarehouse = LocationTypeOption("warehouse") + LocationTypeOptionWasteTransferFacility = LocationTypeOption("waste-transfer-facility") + LocationTypeOptionWater = LocationTypeOption("water") + LocationTypeOptionWatercraft = LocationTypeOption("watercraft") + LocationTypeOptionWaterFacility = LocationTypeOption("water-facility") + LocationTypeOptionYouthCamp = LocationTypeOption("youth-camp") + + LocationRelationStart = LocationRelation("start") + LocationRelationEnd = LocationRelation("end") + + VirtualLocationFeatureAudio = VirtualLocationFeature("audio") + VirtualLocationFeatureChat = VirtualLocationFeature("chat") + VirtualLocationFeatureFeed = VirtualLocationFeature("feed") + VirtualLocationFeatureModerator = VirtualLocationFeature("moderator") + VirtualLocationFeaturePhone = VirtualLocationFeature("phone") + VirtualLocationFeatureScreen = VirtualLocationFeature("screen") + VirtualLocationFeatureVideo = VirtualLocationFeature("video") + + FrequencyYearly = Frequency("yearly") + FrequencyMonthly = Frequency("monthly") + FrequencyWeekly = Frequency("weekly") + FrequencyDaily = Frequency("daily") + FrequencyHourly = Frequency("hourly") + FrequencyMinutely = Frequency("minutely") + FrequencySecondly = Frequency("secondly") + + SkipOmit = Skip("omit") + SkipBackward = Skip("backward") + SkipForward = Skip("forward") + + DayOfWeekMonday = DayOfWeek("mo") + DayOfWeekTuesday = DayOfWeek("tu") + DayOfWeekWednesday = DayOfWeek("we") + DayOfWeekThursday = DayOfWeek("th") + DayOfWeekFriday = DayOfWeek("fr") + DayOfWeekSaturday = DayOfWeek("sa") + DayOfWeekSunday = DayOfWeek("su") + + RscaleIso8601 = "iso8601" + + FreeBusyStatusFree = FreeBusyStatus("free") + FreeBusyStatusBusy = FreeBusyStatus("busy") + + PrivacyPublic = Privacy("public") + PrivacyPrivate = Privacy("private") + PrivacySecret = Privacy("secret") + + ReplyMethodImip = ReplyMethod("imip") + ReplyMethodWeb = ReplyMethod("web") + ReplyMethodOther = ReplyMethod("other") + + SendToMethodImip = SendToMethod("imip") + SendToMethodOther = SendToMethod("other") + + ParticipantKindIndividual = ParticipantKind("individual") + ParticipantKindGroup = ParticipantKind("group") + ParticipantKindLocation = ParticipantKind("location") + ParticipantKindResource = ParticipantKind("resource") + + RoleOwner = Role("owner") + RoleAttendee = Role("attendee") + RoleOptional = Role("optional") + RoleInformational = Role("informational") + RoleChair = Role("chair") + RoleContact = Role("contact") + + ParticipationStatusNeedsAction = ParticipationStatus("needs-action") + ParticipationStatusAccepted = ParticipationStatus("accepted") + ParticipationStatusDeclined = ParticipationStatus("declined") + ParticipationStatusTentative = ParticipationStatus("tentative") + ParticipationStatusDelegated = ParticipationStatus("delegated") + + ScheduleAgentServer = ScheduleAgent("server") + ScheduleAgentClient = ScheduleAgent("client") + ScheduleAgentNone = ScheduleAgent("none") + + DefaultScheduleAgent = ScheduleAgentServer + + ProgressNeedsAction = Progress("needs-action") + ProgressInProcess = Progress("in-process") + ProgressCompleted = Progress("completed") + ProgressFailed = Progress("failed") + ProgressCancelled = Progress("cancelled") + + RelativeToStart = RelativeTo("start") + RelativeToEnd = RelativeTo("end") + + AlertActionDisplay = AlertAction("display") + AlertActionEmail = AlertAction("email") + + DefaultAlertAction = AlertActionDisplay + + StatusConfirmed = Status("confirmed") + StatusCancelled = Status("cancelled") + StatusTentative = Status("tentative") +) + +var ( + Relationships = []Relationship{ + RelationshipFirst, + RelationshipNext, + RelationshipChild, + RelationshipParent, + } + + Displays = []Display{ + DisplayBadge, + DisplayGraphic, + DisplayFullsize, + DisplayThumbnail, + } + + Rels = []Rel{ + RelAbout, + RelAcl, + RelAlternate, + RelAmphtml, + RelApiCatalog, + RelAppendix, + RelAppleTouchIcon, + RelAppleTouchStartupImage, + RelArchives, + RelAuthor, + RelBlockedBy, + RelBookmark, + RelC2paManifest, + RelCanonical, + RelChapter, + RelCiteAs, + RelCollection, + RelCompressionDictionary, + RelContents, + RelConvertedfrom, + RelCopyright, + RelCreateForm, + RelCurrent, + RelDeprecation, + RelDescribedby, + RelDescribes, + RelDisclosure, + RelDnsPrefetch, + RelDuplicate, + RelEdit, + RelEditForm, + RelEditMedia, + RelEnclosure, + RelExternal, + RelFirst, + RelGeofeed, + RelGlossary, + RelHelp, + RelHosts, + RelHub, + RelIceServer, + RelIcon, + RelIndex, + RelIntervalafter, + RelIntervalbefore, + RelIntervalcontains, + RelIntervaldisjoint, + RelIntervalduring, + RelIntervalequals, + RelIntervalfinishedby, + RelIntervalfinishes, + RelIntervalin, + RelIntervalmeets, + RelIntervalmetby, + RelIntervaloverlappedby, + RelIntervaloverlaps, + RelIntervalstartedby, + RelIntervalstarts, + RelItem, + RelLast, + RelLatestVersion, + RelLicense, + RelLinkset, + RelLrdd, + RelManifest, + RelMaskIcon, + RelMe, + RelMediaFeed, + RelMemento, + RelMicropub, + RelModulepreload, + RelMonitor, + RelMonitorGroup, + RelNext, + RelNextArchive, + RelNofollow, + RelNoopener, + RelNoreferrer, + RelOpener, + RelOpenid2LocalId, + RelOpenid2Provider, + RelOriginal, + RelP3pv1, + RelPayment, + RelPingback, + RelPreconnect, + RelPredecessorVersion, + RelPrefetch, + RelPreload, + RelPrerender, + RelPrev, + RelPrevArchive, + RelPreview, + RelPrevious, + RelPrivacyPolicy, + RelProfile, + RelPublication, + RelRdapActive, + RelRdapBottom, + RelRdapDown, + RelRdapTop, + RelRdapUp, + RelRelated, + RelReplies, + RelRestconf, + RelRuleinput, + RelSearch, + RelSection, + RelSelf, + RelService, + RelServiceDesc, + RelServiceDoc, + RelServiceMeta, + RelSipTrunkingCapability, + RelSponsored, + RelStart, + RelStatus, + RelStylesheet, + RelSubsection, + RelSuccessorVersion, + RelSunset, + RelTag, + RelTermsOfService, + RelTimegate, + RelTimemap, + RelType, + RelUgc, + RelUp, + RelVersionHistory, + RelVia, + RelWebmention, + RelWorkingCopy, + RelWorkingCopyOf, + } + + Methods = []Method{ + MethodPublish, + MethodRequest, + MethodReply, + MethodAdd, + MethodCancel, + MethodRefresh, + MethodCounter, + MethodDeclineCounter, + } + + LocationTypeOptions = []LocationTypeOption{ + LocationTypeOptionAircraft, + LocationTypeOptionAirport, + LocationTypeOptionArena, + LocationTypeOptionAutomobile, + LocationTypeOptionBank, + LocationTypeOptionBar, + LocationTypeOptionBicycle, + LocationTypeOptionBus, + LocationTypeOptionBusStation, + LocationTypeOptionCafe, + LocationTypeOptionCampground, + LocationTypeOptionCareFacility, + LocationTypeOptionClassroom, + LocationTypeOptionClub, + LocationTypeOptionConstruction, + LocationTypeOptionConventionCenter, + LocationTypeOptionDetachedUnit, + LocationTypeOptionFireStation, + LocationTypeOptionGovernment, + LocationTypeOptionHospital, + LocationTypeOptionHotel, + LocationTypeOptionIndustrial, + LocationTypeOptionLandmarkAddress, + LocationTypeOptionLibrary, + LocationTypeOptionMotorcycle, + LocationTypeOptionMunicipalGarage, + LocationTypeOptionMuseum, + LocationTypeOptionOffice, + LocationTypeOptionOther, + LocationTypeOptionOutdoors, + LocationTypeOptionParking, + LocationTypeOptionPhoneBox, + LocationTypeOptionPlaceOfWorship, + LocationTypeOptionPostOffice, + LocationTypeOptionPrison, + LocationTypeOptionPublic, + LocationTypeOptionPublicTransport, + LocationTypeOptionResidence, + LocationTypeOptionRestaurant, + LocationTypeOptionSchool, + LocationTypeOptionShoppingArea, + LocationTypeOptionStadium, + LocationTypeOptionStore, + LocationTypeOptionStreet, + LocationTypeOptionTheater, + LocationTypeOptionTollBooth, + LocationTypeOptionTownHall, + LocationTypeOptionTrain, + LocationTypeOptionTrainStation, + LocationTypeOptionTruck, + LocationTypeOptionUnderway, + LocationTypeOptionUnknown, + LocationTypeOptionUtilitybox, + LocationTypeOptionWarehouse, + LocationTypeOptionWasteTransferFacility, + LocationTypeOptionWater, + LocationTypeOptionWatercraft, + LocationTypeOptionWaterFacility, + LocationTypeOptionYouthCamp, + } + + LocationRelations = []LocationRelation{ + LocationRelationStart, + LocationRelationEnd, + } + + Frequencies = []Frequency{ + FrequencyYearly, + FrequencyMonthly, + FrequencyWeekly, + FrequencyDaily, + FrequencyHourly, + FrequencyMinutely, + FrequencySecondly, + } + + Skips = []Skip{ + SkipOmit, + SkipBackward, + SkipForward, + } + + RecurrentOverridesIgnoredPrefixes = []string{ + "@type", + "excludedRecurrenceRules", + "method", + "privacy", + "prodId", + "recurrenceId", + "recurrenceIdTimeZone", + "recurrenceOverrides", + "recurrenceRules", + "relatedTo", + "replyTo", + "sentBy", + "timeZones", + "uid", + } + + LocalizationRequiredSuffixes = []string{ + "title", + "description", + "name", + } + + FreeBusyStatuses = []FreeBusyStatus{ + FreeBusyStatusFree, + FreeBusyStatusBusy, + } + + Privacies = []Privacy{ + PrivacyPublic, + PrivacyPrivate, + PrivacySecret, + } + + ReplyMethods = []ReplyMethod{ + ReplyMethodImip, + ReplyMethodWeb, + ReplyMethodOther, + } + + SendToMethods = []SendToMethod{ + SendToMethodImip, + SendToMethodOther, + } + + ParticipantKinds = []ParticipantKind{ + ParticipantKindIndividual, + ParticipantKindGroup, + ParticipantKindLocation, + ParticipantKindResource, + } + + Roles = []Role{ + RoleOwner, + RoleAttendee, + RoleOptional, + RoleInformational, + RoleChair, + RoleContact, + } + + ParticipationStatuses = []ParticipationStatus{ + ParticipationStatusNeedsAction, + ParticipationStatusAccepted, + ParticipationStatusDeclined, + ParticipationStatusTentative, + ParticipationStatusDelegated, + } + + ScheduleAgents = []ScheduleAgent{ + ScheduleAgentServer, + ScheduleAgentClient, + ScheduleAgentNone, + } + + Progresses = []Progress{ + ProgressNeedsAction, + ProgressInProcess, + ProgressCompleted, + ProgressFailed, + ProgressCancelled, + } + + RelativeTos = []RelativeTo{ + RelativeToStart, + RelativeToEnd, + } + + AlertActions = []AlertAction{ + AlertActionDisplay, + AlertActionEmail, + } + + Statuses = []Status{ + StatusConfirmed, + StatusCancelled, + StatusTentative, + } +) + +const RFC3339Local = "2006-01-02T15:04:05" + +func (t LocalDateTime) MarshalJSON() ([]byte, error) { + return []byte("\"" + t.UTC().Format(RFC3339Local) + "\""), nil +} + +func (t *LocalDateTime) UnmarshalJSON(b []byte) error { + var tt time.Time + err := tt.UnmarshalJSON(b) + if err != nil { + return err + } + t.Time = tt.UTC() + return nil +} + +// A `PatchObject` is of type `String[*]` and represents an unordered set of patches on a JSON object. +// +// 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 of the following conditions apply: +// !1. The pointer MUST NOT reference inside an array (i.e., you MUST NOT insert/delete +// from an array; the array MUST be replaced in its entirety instead). +// !2. All parts prior to the last (i.e., the value after the final slash) MUST already +// exist on the object being patched. +// !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., `alerts/1/offset` and `alerts`. +// !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 pointer determines how to apply that patch: +// !- If null, remove the property from the patched object. If the key is not present in the parent, +// this 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. +// An `@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. +// +// The `PatchObject` format is used to significantly reduce file size and duplicated content when +// specifying variations to a common object, such as with recurring events or when translating the +// data into multiple languages. +// +// It can also better preserve semantic intent if only the properties that should differ between +// the two objects are patched. For example, if one person is not going to a particular instance +// of a regularly scheduled event, in iCalendar, you would have to duplicate the entire event in +// the override. In JSCalendar, this is a small patch to show the difference. +// +// As only this property is patched, if the location of the event is changed, the occurrence will +// automatically still inherit this. +type PatchObject map[string]any + +// A Relation object defines the relation to other objects, using a possibly empty set of relation types. +// +// The object that defines this relation is the linking object, while the other object is the linked +// object. +type Relation struct { + // This specifies the type of this object. + // + // This MUST be `Relation`. + Type TypeOfRelation `json:"@type,omitempty"` + + // This describes how the linked object is related to the linking object. + // + // The relation is defined as a set of relation types. + // + // If empty, the relationship between the two objects is unspecified. + // + // Keys in the set MUST be one of the following values, specified in the + // property definition where the `Relation` object is used: + // !- `first`: The linked object is the first in a series the linking object is part of. + // !- `next`: The linked object is next in a series the linking object is part of. + // !- `child`: The linked object is a subpart of the linking object. + // !- `parent`: The linking object is a subpart of the linked object. + // + // The value for each key in the map MUST be true. + Relation map[Relationship]bool `json:"relation,omitempty"` +} + +type Link struct { + // This specifies the type of this object. + // + // This MUST be `Link`. + Type TypeOfLink `json:"@type,omitempty"` + + // This is a URI from which the resource may be fetched. + // + // This MAY be a `data:` URL [RFC2397], but it is recommended that the file be hosted on a + // server to avoid embedding arbitrarily large data in JSCalendar object instances. + Href string `json:"href"` + + // This MUST be a valid content-id value according to the definition of Section 2 of [RFC2392]. + // + // The value MUST be unique within this `Link` object but has no meaning beyond that. + // + // It MAY be different from the link id for this `Link` object. + Cid string `json:"cid,omitempty"` + + // This is the media type [RFC6838] of the resource, if known. + ContentType string `json:"contentType,omitempty"` + + // This is the size, in octets, of the resource when fully decoded + // (i.e., the number of octets in the file the user would download), if known. + // + // Note that this is an informational estimate, and implementations must be prepared to handle + // the actual size being quite different when the resource is fetched. + Size uint `json:"size,omitzero"` + + // This identifies the relation of the linked resource to the object. + // + // If set, the value MUST be a relation type from the IANA "Link Relations" registry + // [LINKRELS], as established in [RFC8288]. + Rel Rel `json:"rel,omitempty"` + + // This describes the intended purpose of a link to an image. + // + // If set, the `rel` property MUST be set to icon. + // + // The value MUST be one of the following values: + // !- `badge`: an image meant to be displayed alongside the title of the object + // !- `graphic`: a full image replacement for the object itself + // !- `fullsize`: an image that is used to enhance the object + // !- `thumbnail`: a smaller variant of fullsize to be used when space for the image is constrained + Display Display `json:"display,omitempty"` + + // This is a human-readable, plain-text description of the resource. + Title string `json:"title,omitempty"` +} + +type Location struct { + // This specifies the type of this object. + // + // This MUST be `Location`. + Type TypeOfLocation `json:"@type,omitempty"` + + // This is the human-readable name of the location. + Name string `json:"name,omitempty"` + + // This is the human-readable, plain-text instructions for accessing this location. + // + // This may be an address, set of directions, door access code, etc. + Description string `json:"description,omitempty"` + + // This is a set of one or more location types that describe this location. + // + // All types MUST be from the "Location Types Registry" [LOCATIONTYPES], as defined in [RFC4589]. + // + // The set is represented as a map, with the keys being the location types. + // + // The value for each key in the map MUST be `true`. + LocationTypes map[LocationTypeOption]bool `json:"locationTypes,omitempty"` + + // This specifies the relation between this location and the time of the JSCalendar object. + // + // This is primarily to allow events representing travel to specify the location of departure (at the + // start of the event) and location of arrival (at the end); this is particularly important if these + // locations are in different time zones, as a client may wish to highlight this information for the user. + // + // This MUST be one of the following values; any value the client or server doesn't understand + // should be treated the same as if this property is omitted: + // !- `start`: The event/task described by this JSCalendar object occurs at this location at the time the event/task starts. + // !- `end`: The event/task described by this JSCalendar object occurs at this location at the time the event/task ends. + RelativeTo LocationRelation `json:"relativeTo,omitempty"` + + // This is a time zone for this location. + TimeZone string `json:"timeZone,omitempty"` + + // This is a geo: URI [RFC5870] for the location. + Coordinates string `json:"coordinates,omitempty"` + + // This is a map of link ids to `Link` objects, representing external resources associated with this + // location, for example, a vCard or image. + // + // If there are no links, this MUST be omitted (rather than specified as an empty set). + Links map[string]Link `json:"links,omitempty"` +} + +type VirtualLocation struct { + // This specifies the type of this object. This MUST be `VirtualLocation`. + Type TypeOfVirtualLocation `json:"@type,omitempty"` + + // This is the human-readable name of the virtual location. + Name string `json:"name,omitempty"` + + // These are human-readable plain-text instructions for accessing this virtual location. + // + // This may be a conference access code, etc. + Description string `json:"description,omitempty"` + + // Mandatory: this is a URI [RFC3986] that represents how to connect to this virtual location. + // + // This may be a telephone number (represented using the `tel:` scheme, e.g., `tel:+1-555-555-5555`) + // for a teleconference, a web address for online chat, or any custom URI. + Uri string `json:"uri"` + + // A set of features supported by this virtual location. + // + // The set is represented as a map, with the keys being the feature. + // + // The value for each key in the map MUST be true. + // + // The feature MUST be one of the following values; any value the client or server + // doesn't understand should be treated the same as if this feature is omitted: + // !- `audio`: Audio conferencing + // !- `chat`: Chat or instant messaging + // !- `feed`: Blog or atom feed + // !- `moderator`: Provides moderator-specific features + // !- `phone`: Phone conferencing + // !- `screen`: Screen sharing + // !- `video`: Video conferencing + Features map[VirtualLocationFeature]bool `json:"features,omitempty"` +} + +type NDay struct { + // This specifies the type of this object. This MUST be `NDay`. + Type TypeOfNDay `json:"@type,omitempty"` + + // This is a day of the week on which to repeat; the allowed values are the same as for the + // `firstDayOfWeek` `recurrenceRule` property. + // + // This is the day of the week of the `BYDAY` part in iCalendar, converted to lowercase. + Day DayOfWeek `json:"day"` + + // If present, rather than representing every occurrence of the weekday defined in the `day` + // property, it represents only a specific instance within the recurrence period. + // + // The value can be positive or negative but MUST NOT be zero. + // + // A negative integer means the nth-last occurrence within that period (i.e., -1 is the last + // occurrence, -2 the one before that, etc.). + // + // This is the ordinal part of the `BYDAY` value in iCalendar (e.g., `1` or `-3`). + NthOfPeriod int `json:"nthOfPeriod,omitzero"` +} + +// A RecurrenceRule object is a JSON object mapping of a `RECUR` value type in iCalendar +// [RFC5545] [RFC7529] and has the same semantics. +// +// [RFC5545]: https://www.rfc-editor.org/rfc/rfc5545.html +// [RFC7529]: https://www.rfc-editor.org/rfc/rfc7529.html +type RecurrenceRule struct { + // This specifies the type of this object. This MUST be ` RecurrenceRule`. + Type TypeOfRecurrenceRule `json:"@type,omitempty"` + + // This is the time span covered by each iteration of this recurrence rule. + // + // This MUST be one of the following values: + // !- `yearly` + // !- `monthly` + // !- `weekly` + // !- `daily` + // !- `hourly` + // !- `minutely` + // !- `secondly` + // + // This is the `FREQ` part from iCalendar, converted to lowercase. + Frequency Frequency `json:"frequency,omitempty"` + + // This is the interval of iteration periods at which the recurrence repeats. + // + // If included, it MUST be an integer >= `1`. + // + // This is the `INTERVAL` part from iCalendar. + // + // Default: `1` + Interval uint `json:"interval,omitzero"` + + // This is the calendar system in which this recurrence rule operates, in lowercase. + // + // This MUST be either a CLDR-registered calendar system name [CLDR] or a vendor-specific + // value. + // + // This is the `RSCALE` part from iCalendar RSCALE [RFC7529], converted to lowercase. + // + // Default: `gregorian` + // + // [CLDR]: https://github.com/unicode-org/cldr/blob/latest/common/bcp47/calendar.xml + // [RFC7529]: https://www.rfc-editor.org/rfc/rfc7529.html + Rscale string `json:"rscale,omitempty"` + + // This is the behavior to use when the expansion of the recurrence produces invalid dates. + // + // This property only has an effect if the frequency is `yearly` or `monthly`. + // + // It MUST be one of the following values: + // !- `omit` + // !- `backward` + // !- `forward` + // + // This is the `SKIP` part from iCalendar `RSCALE` [RFC7529], converted to lowercase. + // + // Default: `omit` + Skip Skip `json:"skip,omitempty"` + + // This is the day on which the week is considered to start, represented as a lowercase, abbreviated, + // and two-letter English day of the week. + // + // If included, it MUST be one of the following values: + // !- `mo` + // !- `tu` + // !- `we` + // !- `th` + // !- `fr` + // !- `sa` + // !- `su` + // + // This is the `WKST` part from iCalendar. + // + // Default: `mo` + FirstDayOfWeek DayOfWeek `json:"firstDayOfWeek,omitempty"` + + // These are days of the week on which to repeat. + ByDay []NDay `json:"byDay,omitempty"` + + // These are the days of the month on which to repeat. + // + // Valid values are between 1 and the maximum number of days any month may have in the calendar given by + // the `rscale` property and the negative values of these numbers. + // + // For example, in the Gregorian calendar, valid values are `1` to `31` and `-31` to `-1`. + // + // Negative values offset from the end of the month. + // + // The array MUST have at least one entry if included. + // + // This is the `BYMONTHDAY` part in iCalendar. + ByMonthDay []int `json:"byMonthDay,omitempty"` + + // These are the months in which to repeat. + // + // Each entry is a string representation of a number, starting from `"1"` for the first month in the + // calendar (e.g., `"1"` means January with the Gregorian calendar), with an optional `"L"` suffix + // (see [RFC7529]) for leap months (this MUST be uppercase, e.g., `"3L"`). + // + // The array MUST have at least one entry if included. + // + // This is the `BYMONTH` part from iCalendar. + // + // [RFC7529]: https://www.rfc-editor.org/rfc/rfc7529.html + ByMonth []string `json:"byMonth,omitempty"` + + // These are the days of the year on which to repeat. + // + // Valid values are between `1` and the maximum number of days any year may have in the calendar given + // by the `rscale` property and the negative values of these numbers. + // + // For example, in the Gregorian calendar, valid values are `1` to `366` and `-366` to `-1`. + // + // Negative values offset from the end of the year. + // + // The array MUST have at least one entry if included. + // + // This is the `BYYEARDAY` part from iCalendar. + ByYearDay []int `json:"byYearDay,omitempty"` + + // These are the weeks of the year in which to repeat. + // + // Valid values are between `1` and the maximum number of weeks any year may have in the calendar + // given by the `rscale` property and the negative values of these numbers. + // + // For example, in the Gregorian calendar, valid values are `1` to `53` and `-53` to `-1`. + // + // The array MUST have at least one entry if included. + // + // This is the `BYWEEKNO` part from iCalendar. + ByWeekNo []int `json:"byWeekNo,omitempty"` + + // These are the hours of the day in which to repeat. + // + // Valid values are `0` to `23`. + // + // The array MUST have at least one entry if included. + // + // This is the `BYHOUR` part from iCalendar. + ByHour []uint `json:"byHour,omitempty"` + + // These are the minutes of the hour in which to repeat. + // + // Valid values are `0` to `59`. + // + // The array MUST have at least one entry if included. + // + // This is the `BYMINUTE` part from iCalendar. + ByMinute []uint `json:"byMinute,omitempty"` + + // These are the seconds of the minute in which to repeat. + // + // Valid values are `0` to `60`. + // + // The array MUST have at least one entry if included. + // + // This is the `BYSECOND` part from iCalendar. + BySecond []uint `json:"bySecond,omitempty"` + + // These are the occurrences within the recurrence interval to include in the final results. + // + // Negative values offset from the end of the list of occurrences. + // + // The array MUST have at least one entry if included. + // + // This is the `BYSETPOS` part from iCalendar. + BySetPosition []int `json:"bySetPosition,omitempty"` + + // These are the number of occurrences at which to range-bound the recurrence. + // + // This MUST NOT be included if an until property is specified. + // + // This is the `COUNT` part from iCalendar. + Count uint `json:"count,omitzero"` + + // These are the date-time at which to finish recurring. + // + // The last occurrence is on or before this date-time. + // + // This MUST NOT be included if a `count` property is specified. + // + // Note that if not specified otherwise for a specific JSCalendar object, this date is to be + // interpreted in the time zone specified in the JSCalendar object's `timeZone` property. + // + // This is the UNTIL part from iCalendar. + Until *LocalDateTime `json:"until,omitempty"` +} + +type Participant struct { + // This specifies the type of this object. This MUST be ` Participant`. + Type TypeOfParticipant `json:"@type,omitempty"` + + // This is the display name of the participant (e.g., `"Joe Bloggs"``). + Name string `json:"name,omitempty"` + + // This is the email address to use to contact the participant or, for example, + // match with an address book entry. + // + // If set, the value MUST be a valid addr-spec value as defined in Section 3.4.1 of [RFC5322]. + Email string `json:"email,omitempty"` + + // This is a plain-text description of this participant. + // + // For example, this may include more information about their role in the event or how best to contact them. + Description string `json:"description,omitempty"` + + // This represents methods by which the participant may receive the invitation and updates to the calendar object. + // + // The keys in the property value are the available methods and MUST only contain ASCII alphanumeric characters (`A-Za-z0-9`). + // + // The value is a URI for the method specified in the key. Future methods may be defined in future specifications and + // registered with IANA; a calendar client MUST ignore any method it does not understand but MUST preserve the method key and URI. + // + // This property MUST be omitted if no method is defined (rather than being specified as an empty object). + // + // The following methods are defined: + // !- `imip`: The participant accepts an iMIP [RFC6047] request at this email address. The value MUST be a `mailto:` URI. + // It MAY be different from the value of the participant's email property. + // !- `other``: The participant is identified by this URI, but the method for submitting the invitation is undefined. + SendTo map[SendToMethod]string `json:"sendTo,omitempty"` + + // This is what kind of entity this participant is, if known. + // + // This MUST be one of the following values, another value registered in the IANA "JSCalendar Enum Values" registry, + // or a vendor-specific value (see Section 3.3). + // + // Any value the client or server doesn't understand should be treated the same as if this property is omitted. + // !- `individual`: a single person + // !- `group`: a collection of people invited as a whole + // !- `location`: a physical location that needs to be scheduled, e.g., a conference room + // !- `resource`: a non-human resource other than a location, such as a projector + Kind ParticipantKind `json:"kind,omitempty"` + + // This is a set of roles that this participant fulfills. + // + // At least one role MUST be specified for the participant. + // + // The keys in the set MUST be one of the following values, another value registered in the IANA "JSCalendar Enum Values" + // registry, or a vendor-specific value (see Section 3.3): + // !- `owner`: The participant is an owner of the object. This signifies they have permission to make changes to it + // that affect the other participants. Nonowner participants may only change properties that affect only themselves + // (for example, setting their own alerts or changing their RSVP status). + // !- `attendee`: The participant is expected to be present at the event. + // !- `optional`: The participant's involvement with the event is optional. This is expected to be primarily combined + // with the `"attendee"` role. + // !- `informational`: The participant is copied for informational reasons and is not expected to attend. + // !- `chair`: The participant is in charge of the event/task when it occurs. + // !- `contact`: The participant is someone that may be contacted for information about the event. + // + // The value for each key in the map MUST be true. It is expected that no more than one of the roles + // `"attendee"` and `"informational"` be present; if more than one are given, `"attendee"` takes precedence + // over `"informational"`. + // + // Roles that are unknown to the implementation MUST be preserved. + Roles map[Role]bool `json:"roles,omitempty"` + + // This is the location at which this participant is expected to be attending. + // + // If the value does not correspond to any location id in the `locations` property of the JSCalendar object, + // this MUST be treated the same as if the participant's `locationId` were omitted. + LocationId string `json:"locationId,omitempty"` + + // This is the language tag, as defined in [RFC5646], that best describes the participant's preferred language, if known. + Language string `json:"language,omitempty"` + + // This is the participation status, if any, of this participant. + // + // The value MUST be one of the following values, another value registered in the IANA "JSCalendar Enum Values" registry, + // or a vendor-specific value (see Section 3.3): + // !- `needs-action`: No status has yet been set by the participant. + // !- `accepted`: The invited participant will participate. + // !- `declined`: The invited participant will not participate. + // !- `tentative`: The invited participant may participate. + // !- `delegated`: The invited participant has delegated their attendance to another participant, as specified in the `delegatedTo` property. + ParticipationStatus ParticipationStatus `json:"participationStatus,omitempty"` + + // This is a note from the participant to explain their participation status. + ParticipationComment string `json:"participationComment,omitempty"` + + // If true, the organizer is expecting the participant to notify them of their participation status. + ExpectReply bool `json:"expectReply,omitzero"` + + // This is who is responsible for sending scheduling messages with this calendar object to the participant. + // + // The value MUST be one of the following values, another value registered in the IANA "JSCalendar Enum Values" + // registry, or a vendor-specific value (see Section 3.3): + // !- `server`: The calendar server will send the scheduling messages. + // !- `client`: The calendar client will send the scheduling messages. + // !- `none`: No scheduling messages are to be sent to this participant. + // + // Default: `server` + ScheduleAgent ScheduleAgent `json:"scheduleAgent,omitempty"` + + // A client may set the property on a participant to true to request that the server send a scheduling + // message to the participant when it would not normally do so (e.g., if no significant change is made + // the object or the scheduleAgent is set to client). + // + // The property MUST NOT be stored in the JSCalendar object on the server or appear in a scheduling message. + ScheduleForceSend bool `json:"scheduleForceSend,omitzero"` + + // This is the sequence number of the last response from the participant. + // + // If defined, this MUST be a nonnegative integer. + // + // This can be used to determine whether the participant has sent a new response following significant + // changes to the calendar object and to determine if future responses are responding to a current or older view of the data. + ScheduleSequence uint `json:"scheduleSequence,omitzero"` + + // This is a list of status codes, returned from the processing of the most recent scheduling message sent to this participant. + // + // The status codes MUST be valid statcode values as defined in the ABNF in [Section 3.8.8.3 of RFC5545]. + // + // Servers MUST only add or change this property when they send a scheduling message to the participant. + // + // Clients SHOULD NOT change or remove this property if it was provided by the server. + // + // Clients MAY add, change, or remove the property for participants where the client is handling the scheduling. + // + // This property MUST NOT be included in scheduling messages. + // + // [Section 3.8.8.3 of RFC5545]: https://www.rfc-editor.org/rfc/rfc5545#section-3.8.8.3 + ScheduleStatus []string `json:"scheduleStatus,omitempty"` + + // This is the timestamp for the most recent response from this participant. + // + // This is the updated property of the last response when using iTIP. It can be compared to the updated property in + // future responses to detect and discard older responses delivered out of order. + ScheduleUpdated time.Time `json:"scheduleUpdated,omitzero"` + + // This is the email address in the `"From"` header of the email that last updated this participant via iMIP. + // + // This SHOULD only be set if the email address is different to that in the mailto URI of this participant's `imip` + // method in the `sendTo` property (i.e., the response was received from a different address to that which the + // invitation was sent to). If set, the value MUST be a valid addr-spec value as defined in Section 3.4.1 of [RFC5322]. + SentBy string `json:"sentBy,omitempty"` + + // This is the id of the participant who added this participant to the event/task, if known. + InvitedBy string `json:"invitedBy,omitempty"` + + // This is set of participant ids that this participant has delegated their participation to. + // + // Each key in the set MUST be the id of a participant. + // + // The value for each key in the map MUST be true. + // + // If there are no delegates, this MUST be omitted (rather than specified as an empty set). + DelegatedTo map[string]bool `json:"delegatedTo,omitempty"` + + // This is a set of participant ids that this participant is acting as a delegate for. + // + // Each key in the set MUST be the id of a participant. + // + // The value for each key in the map MUST be true. + // + // If there are no delegators, this MUST be omitted (rather than specified as an empty set). + DelegatedFrom map[string]bool `json:"delegatedFrom,omitempty"` + + // This is a set of group participants that were invited to this calendar object, which caused this participant to + // be invited due to their membership in the group(s). + // + // Each key in the set MUST be the id of a participant. + // + // The value for each key in the map MUST be true. + // + // If there are no groups, this MUST be omitted (rather than specified as an empty set). + MemberOf map[string]bool `json:"memberOf,omitempty"` + + // This is a map of link ids to `Link` objects, representing external resources associated with this participant, + // for example, a vCard or image. + // + // Only allowed for participants of a Task. + // + // If there are no links, this MUST be omitted (rather than specified as an empty set). + Links map[string]Link `json:"links,omitempty"` + + // This represents the progress of the participant for this task. + // + // It MUST NOT be set if the `participationStatus` of this participant is any value other than `accepted`. + // + // Only allowed for participants of a Task. + // + // See Section 5.2.5 for allowed values and semantics. + Progress Progress `json:"progress,omitempty"` + + // This specifies the date-time the progress property was last set on this participant. + // + // Only allowed for participants of a Task. + // + // See Section 5.2.6 for allowed values and semantics. + ProgressUpdated time.Time `json:"progressUpdated,omitzero"` + + // This represents the percent completion of the participant for this task. + // + // Only allowed for participants of a Task. + // + // The property value MUST be a positive integer between 0 and 100. + PercentComplete uint `json:"percentComplete,omitzero"` +} + +type Trigger interface { + trigger() +} + +type OffsetTrigger struct { + // This specifies the type of this object. This MUST be `OffsetTrigger`. + Type TypeOfOffsetTrigger `json:"@type,omitempty"` + + // This defines the offset at which to trigger the alert relative to the time property defined in + // the `relativeTo` property of the alert. + // + // Negative durations signify alerts before the time property; + // positive durations signify alerts after the time property. + Offset SignedDuration `json:"offset"` + + // This specifies the time property that the alert offset is relative to. + // + // The value MUST be one of the following: + // !- `start`: triggers the alert relative to the start of the calendar object + // !- `end`: triggers the alert relative to the end/due time of the calendar object + RelativeTo RelativeTo `json:"relativeTo,omitempty"` +} + +var _ Trigger = OffsetTrigger{} + +func (o OffsetTrigger) trigger() {} + +type AbsoluteTrigger struct { + // This specifies the type of this object. This MUST be `AbsoluteTrigger`. + Type TypeOfAbsoluteTrigger `json:"@type,omitempty"` + + // This defines a specific UTC date-time when the alert is triggered. + When time.Time `json:"when"` +} + +var _ Trigger = AbsoluteTrigger{} + +func (o AbsoluteTrigger) trigger() {} + +// An `UnknownTrigger` object is an object that contains an `@type` property whose value is not recognized +// (i.e., not `OffsetTrigger` or `AbsoluteTrigger`) plus zero or more other properties. +// +// This is for compatibility with client extensions and future specifications. +// +// Implementations SHOULD NOT trigger for trigger types they do not understand but MUST preserve them. +type UnknownTrigger map[string]any + +var _ Trigger = UnknownTrigger{} + +func (o UnknownTrigger) trigger() {} + +type Alert struct { + // This specifies the type of this object. This MUST be `Alert`. + Type TypeOfAlert `json:"@type,omitempty"` + + // This defines when to trigger the alert. + // + // New types may be defined in future documents. + Trigger Trigger `json:"trigger"` + + // This records when an alert was last acknowledged. + // + // This is set when the user has dismissed the alert; other clients that sync this property + // SHOULD automatically dismiss or suppress duplicate alerts (alerts with the same alert id + // that triggered on or before this date-time). + // + // For a recurring calendar object, setting the acknowledged property MUST NOT add a new override + // to the recurrenceOverrides property. + // + // If the alert is not already overridden, the acknowledged property MUST be set on the alert + // in the base event/task. + // + // Certain kinds of alert action may not provide feedback as to when the user sees them, for example, + // email-based alerts. + // + // For those kinds of alerts, this property MUST be set immediately when the alert is triggered + // and the action is successfully carried out. + Acknowledged time.Time `json:"acknowledged,omitzero"` + + // This relates this alert to other alerts in the same JSCalendar object. + // + // If the user wishes to snooze an alert, the application MUST create an alert to trigger after snoozing. + // This new snooze alert MUST set a parent relation to the identifier of the original alert. + RelatedTo map[string]Relation `json:"relatedTo,omitempty"` + + // This describes how to alert the user. + // + // The value MUST be at most one of the following values, a value registered in the IANA "JSCalendar Enum Values" + // registry, or a vendor-specific value (see Section 3.3): + // !- `display`: The alert should be displayed as appropriate for the current device and user context. + // !- `email`: The alert should trigger an email sent out to the user, notifying them of the alert. This action is + // typically only appropriate for server implementations. + // + // Default: `display` + Action AlertAction `json:"action,omitempty"` +} + +func (a *Alert) UnmarshalJSON(b []byte) error { + var typ struct { + Trigger struct { + Type string `json:"@type"` + } `json:"trigger"` + } + if err := json.Unmarshal(b, &typ); err != nil { + return err + } + switch typ.Trigger.Type { + case string(OffsetTriggerType): + a.Trigger = new(OffsetTrigger) + case string(AbsoluteTriggerType): + a.Trigger = new(AbsoluteTrigger) + default: + a.Trigger = new(UnknownTrigger) + } + + type tmp Alert + return json.Unmarshal(b, (*tmp)(a)) +} + +// A `TimeZoneRule` object maps a `STANDARD` or `DAYLIGHT` sub-component from iCalendar, +// with the restriction that, at most, one recurrence rule is allowed per rule. +type TimeZoneRule struct { + // This specifies the type of this object. This MUST be `TimeZoneRule`. + Type TypeOfTimeZoneRule `json:"@type,omitempty"` + + // This is the `DTSTART` property from iCalendar. + Start LocalDateTime `json:"start"` + + // This is the `TZOFFSETFROM` property from iCalendar: specifies the offset that is in use prior to this time zone observance. + // + // This property specifies the offset that is in use prior to this time observance. + // + // It is used to calculate the absolute time at which the transition to a given observance takes place. + // + // The property value is a signed numeric indicating the number of hours and possibly minutes from UTC. + // + // Positive numbers represent time zones east of the prime meridian, or ahead of UTC. + // + // Negative numbers represent time zones west of the prime meridian, or behind UTC. + // + // Mandatory. + // + // example: -0500 + OffsetFrom string `json:"offsetFrom"` + + // This is the TZOFFSETTO property from iCalendar: specifies the offset that is in use in this time zone observance. + // + // This property specifies the offset that is in use in this time zone observance. + // + // It is used to calculate the absolute time for the new observance. + // + // The property value is a signed numeric indicating the number of hours and possibly minutes from UTC. + // + // Positive numbers represent time zones east of the prime meridian, or ahead of UTC. + // + // Negative numbers represent time zones west of the prime meridian, or behind UTC. + // + // Mandatory. + // + // example: +1245 + OffsetTo string `json:"offsetTo"` + + // This is the `RRULE` property mapped. + // + // uring recurrence rule evaluation, the `until` property value MUST be interpreted + // as a local time in the UTC time zone. + RecurrenceRules []RecurrenceRule `json:"recurrenceRules,omitempty"` + + // This maps the `RDATE` properties from iCalendar. + // + // The set is represented as an object, with the keys being the recurrence dates. + // + // The patch object MUST be the empty JSON object (`{}`). + RecurrenceOverrides map[LocalDateTime]PatchObject `json:"recurrenceOverrides,omitempty"` + + // This maps the `TZNAME` properties from iCalendar to a JSON set. + // + // The set is represented as an object, with the keys being the names, excluding any + // `tznparam` component from iCalendar. + // + // The value for each key in the map MUST be true. + Names map[string]bool `json:"names,omitempty"` + + // This maps the `COMMENT` properties from iCalendar. + // + // The order MUST be preserved during conversion. + Comments []string `json:"comments,omitempty"` +} + +type TimeZone struct { + // This specifies the type of this object. This MUST be `TimeZone`. + Type TypeOfTimeZone `json:"@type,omitempty"` + + // This is the TZID property from iCalendar. + // + // Note that this implies that the value MUST be a valid `paramtext` value as specified in Section 3.1. of [RFC5545]. + TzId string `json:"tzId"` + + // This is the `LAST-MODIFIED` property from iCalendar. + Updated time.Time `json:"updated,omitzero"` + + // This is the `TZURL` property from iCalendar. + Url string `json:"url,omitempty"` + + // This is the TZUNTIL property from iCalendar, specified in [RFC7808]. + ValidUntil time.Time `json:"validUntil,omitzero"` + + // This maps the `TZID-ALIAS-OF` properties from iCalendar, specified in [RFC7808], to a JSON set of aliases. + // + // The set is represented as an object, with the keys being the aliases. + // + // The value for each key in the map MUST be `true`. + Aliases map[string]bool `json:"aliases,omitempty"` + + // This the `STANDARD` sub-components from iCalendar. + // + // The order MUST be preserved during conversion. + Standard []TimeZoneRule `json:"standard,omitempty"` + + // This the `DAYLIGHT` sub-components from iCalendar. + // + // The order MUST be preserved during conversion. + Daylight []TimeZoneRule `json:"daylight,omitempty"` +} + +type CommonObject struct { + // This is a globally unique identifier used to associate objects representing the same event, + // task, group, or other object across different systems, calendars, and views. + // + // For recurring events and tasks, the UID is associated with the base object and therefore + // is the same for all occurrences; the combination of the UID with a recurrenceId identifies + // a particular instance. + // + // The generator of the identifier MUST guarantee that the identifier is unique. + // + // [RFC4122] describes a range of established algorithms to generate universally unique identifiers + // (UUIDs). UUID version 4, described in Section 4.4 of [RFC4122], is RECOMMENDED. + // + // For compatibility with UIDs [RFC5545], implementations MUST be able to receive and persist + // values of at least 255 octets for this property, but they MUST NOT truncate values in the + // middle of a UTF-8 multi-octet sequence. + Uid string `json:"uid"` + + // This is the identifier for the product that last updated the JSCalendar object. + // + // This should be set whenever the data in the object is modified (i.e., whenever the updated property is set) + // + // .The vendor of the implementation MUST ensure that this is a globally unique identifier, using + // ome technique such as a Formal Public Identifier (FPI) value, as defined in [ISO.9070.1991]. + // + // This property SHOULD NOT be used to alter the interpretation of a JSCalendar object beyond the semantics + // specified in this document. + // + // For example, it is not to be used to further the understanding of nonstandard properties, a practice + // that is known to cause long-term interoperability problems. + ProdId string `json:"prodId,omitempty"` + + // This is the date and time this object was initially created. + // + // TODO serialize as UTCDateTime + Created time.Time `json:"created,omitzero"` + + // This is the date and time the data in this object was last modified (or its creation date/time + // if not modified since). + // + // TODO serialize as UTCDateTime + Updated time.Time `json:"updated,omitzero"` + + // This is a short summary of the object. + Title string `json:"title,omitempty"` + + // This is a longer-form text description of the object. + // + // The content is formatted according to the `descriptionContentType` property. + Description string `json:"description,omitempty"` + + // This describes the media type [RFC6838] of the contents of the description property. + // + // Media types MUST be subtypes of type text and SHOULD be text/plain or text/html [MEDIATYPES]. + // + // They MAY include parameters, and the charset parameter value MUST be utf-8, if specified. + // + // Descriptions of type text/html MAY contain cid URLs [RFC2392] to reference links in the calendar + // object by use of the cid property of the Link object. + // + // Default: `text/plain` + DescriptionContentType string `json:"descriptionContentType,omitempty"` + + // This is a map of link ids to `Link` objects, representing external resources associated with the object. + // + // Links with a `rel` of `enclosure` MUST be considered by the client to be attachments for download. + // + // Links with a `rel` of `describedby` MUST be considered by the client to be alternative representations of the + // `description`. + // + // Links with a `rel` of `icon` MUST be considered by the client to be images that it may use when presenting + // the calendar data to a user. The `display` property may be set to indicate the purpose of this image. + Links map[string]Link `json:"links,omitempty"` + + // This is the language tag, as defined in [RFC5646], that best describes the locale used for the text in + // the calendar object, if known. + // + // [RFC5646]: https://www.rfc-editor.org/rfc/rfc5646.html + Locale string `json:"locale,omitempty"` + + // This is a set of keywords or tags that relate to the object. + // + // The set is represented as a map, with the keys being the keywords. + // + // The value for each key in the map MUST be `true`. + Keywords map[string]bool `json:"keywords,omitempty"` + + // This is a set of categories that relate to the calendar object. + // + // The set is represented as a map, with the keys being the categories specified as URIs. + // + // The value for each key in the map MUST be `true`. + // + // In contrast to keywords, categories are typically structured. + // + // For example, a vendor owning the domain `example.com` might define the categories + // `http://example.com/categories/sports/american-football` and `http://example.com/categories/music/r-b`. + Categories map[string]bool `json:"categories,omitempty"` + + // This is a color clients MAY use when displaying this calendar object. + // + // The value is a color name taken from the set of names defined in [Section 4.3 of CSS Color Module Level 3] + // or an RGB value in hexadecimal notation, as defined in [Section 4.2.1 of CSS Color Module Level 3]. + // + // [Section 4.3 of CSS Color Module Level 3]: https://www.w3.org/TR/css-color-3/#svg-color + // [Section 4.2.1 of CSS Color Module Level 3]: https://www.w3.org/TR/css-color-3/#rgb-color + Color string `json:"color,omitempty"` + + // This maps identifiers of custom time zones to their time zone definitions. + // + // The following restrictions apply for each key in the map: + // !- To avoid conflict with names in the IANA Time Zone Database [TZDB], it MUST start with the `/` character. + // !- It MUST be a valid `paramtext` value, as specified in Section 3.1 of [RFC5545]. + // !- At least one other property in the same JSCalendar object MUST reference a time zone using this identifier (i.e., + // orphaned time zones are not allowed). + // + // An identifier need only be unique to this JSCalendar object. + // + // It MAY differ from the tzId property value of the TimeZone object it maps to. + // + // A JSCalendar object may be part of a hierarchy of other JSCalendar objects (say, an `Event` is an entry in a `Group`). + // + // In this case, the set of time zones is the sum of the time zone definitions of this object and its parent objects. + // + // If multiple time zones with the same identifier exist, then the definition closest to the calendar object in relation + // to its parents MUST be used. + // + // (In context of `Event`, a time zone definition in its `timeZones` property has precedence over a definition of the + // same id in the `Group`). + // + // Time zone definitions in any children of the calendar object MUST be ignored. + // + // A `TimeZone` object maps a `VTIMEZONE` component from iCalendar, and the semantics are as defined in [RFC5545]. + // + // A valid time zone MUST define at least one transition rule in the `standard` or `daylight` property. + TimeZones map[string]TimeZone `json:"timeZones,omitempty"` +} + +// TODO +// +// ### Recurrence Properties +// +// Some events and tasks occur at regular or irregular intervals. Rather than having to copy the data for every occurrence, +// there can be a base event with rules to generate recurrences and/or overrides that add extra dates or exceptions to the rules. +// +// The recurrence set is the complete set of instances for an object. It is generated by considering the following properties in +// order, all of which are optional: +// !- The `recurrenceRules` property generates a set of extra date-times on which the object occurs. +// !- The `excludedRecurrenceRules` property generates a set of date-times that are to be removed from the previously generated +// set of date-times on which the object occurs. +// !- The `recurrenceOverrides` property defines date-times that are added or excluded to form the final set. (This property +// may also contain changes to the object to apply to particular instances.) +type Object struct { + CommonObject + + // This relates the object to other JSCalendar objects. + // + // This is represented as a map of the UIDs of the related objects to information about the relation. + // + // If an object is split to make a "this and future" change to a recurrence, the original object MUST + // be truncated to end at the previous occurrence before this split, and a new object is created to + // represent all the occurrences after the split. + // + // A next relation MUST be set on the original object's relatedTo property for the UID of the new object. + // + // A first relation for the UID of the first object in the series MUST be set on the new object. + // Clients can then follow these UIDs to get the complete set of objects if the user wishes to modify + // them all at once. + RelatedTo map[string]Relation `json:"relatedTo,omitempty"` + + // Initially zero, this MUST be incremented by one every time a change is made to the object, except + // if the change only modifies the `participants` property. + // + // This is used as part of the iCalendar Transport-independent Interoperability Protocol (iTIP) [RFC5546] + // to know which version of the object a scheduling message relates to. + Sequence uint `json:"sequence,omitzero"` + + // This is the iTIP [RFC5546] method, in lowercase. + // + // This MUST only be present if the JSCalendar object represents an iTIP scheduling message. + Method Method `json:"method,omitempty"` + + // This indicates that the time is not important to display to the user when rendering this calendar object. + // + // An example of this is an event that conceptually occurs all day or across multiple days, such as + // `"New Year's Day"` or `"Italy Vacation"`. + // + // While the time component is important for free-busy calculations and checking for scheduling clashes, + // calendars may choose to omit displaying it and/or display the object separately to other objects to + // enhance the user's view of their schedule. + // + // Such events are also commonly known as "all-day" events. + // + // Default: `false` + ShowWithoutTime bool `json:"showWithoutTime,omitzero"` + + // This is a map of location ids to `Location` objects, representing locations associated with the object. + Locations map[string]Location `json:"locations,omitempty"` + + // This is a map of virtual location ids to VirtualLocation objects, representing virtual locations, such as + // video conferences or chat rooms, associated with the object. + VirtualLocations map[string]VirtualLocation `json:"virtualLocations,omitempty"` + + // If present, this JSCalendar object represents one occurrence of a recurring JSCalendar object. + // + // If present, the `recurrenceRules` and `recurrenceOverrides` properties MUST NOT be present. + // + // The value is a date-time either produced by the `recurrenceRules` of the base event or + // added as a key to the `recurrenceOverrides` property of the base event. + RecurrenceId *LocalDateTime `json:"recurrenceId,omitempty"` + + // Identifies the time zone of the main JSCalendar object, of which this JSCalendar object is a recurrence instance. + // + // This property MUST be set if the `recurrenceId` property is set. + // + // It MUST NOT be set if the `recurrenceId` property is not set. + RecurrenceIdTimeZone string `json:"recurrenceIdTimeZone,omitempty"` + + // This defines a set of recurrence rules (repeating patterns) for recurring calendar objects. + // + // TODO select the right documentation for each copy of the Object class: + // + // An Event recurs by applying the recurrence rules to the start date-time. + // + // A Task recurs by applying the recurrence rules to the start date-time, if defined; otherwise, it recurs by + // the due date-time, if defined. If the task defines neither a start nor due date-time, it MUST NOT + // define a `recurrenceRules` property. + // + // If multiple recurrence rules are given, each rule is to be applied, and then the union of the results are used, + // ignoring any duplicates. + RecurrenceRules []RecurrenceRule `json:"recurrenceRules,omitempty"` + + // This defines a set of recurrence rules (repeating patterns) for date-times on which the object will not occur. + // + // The rules are interpreted the same as for the `recurrenceRules` property, with the exception that the initial + // date-time to which the rule is applied (the `"start"` date-time for events or the `"start"` or `"due"` + // date-time for tasks) is only considered part of the expansion if it matches the rule. + // + // The resulting set of date-times is then removed from those generated by the `recurrenceRules` property. + ExcludedRecurrenceRules []RecurrenceRule `json:"excludedRecurrenceRules,omitempty"` + + // Maps recurrence ids (the date-time produced by the recurrence rule) to the overridden properties of the + // recurrence instance. + // + // If the recurrence id does not match a date-time from the recurrence rule (or no rule is specified), it + // is to be treated as an additional occurrence (like an `RDATE` from iCalendar). + // + // The patch object may often be empty in this case. + // + // If the patch object defines the `excluded` property of an occurrence to be `true`, this occurrence is + // omitted from the final set of recurrences for the calendar object (like an `EXDATE` from iCalendar). + // + // Such a patch object MUST NOT patch any other property. + // + // By default, an occurrence inherits all properties from the main object except the start (or due) + // date-time, which is shifted to match the recurrence id `LocalDateTime`. + // + // However, individual properties of the occurrence can be modified by a patch or multiple patches. + // + // It is valid to patch the `start` property value, and this patch takes precedence over the value + // generated from the recurrence id. + // + // Both the recurrence id as well as the patched start date-time may occur before the original JSCalendar + // object's start or due date. + // + // A pointer in the `PatchObject` MUST be ignored if it starts with one of the following prefixes: + // !- `@type` + // !- `excludedRecurrenceRules` + // !- `method` + // !- `privacy` + // !- `prodId` + // !- `recurrenceId` + // !- `recurrenceIdTimeZone` + // !- `recurrenceOverrides` + // !- `recurrenceRules` + // !- `relatedTo` + // !- `replyTo` + // !- `sentBy` + // !- `timeZones` + // !- `uid` + RecurrenceOverrides map[LocalDateTime]PatchObject `json:"recurrenceOverrides,omitempty"` + + // This defines if this object is an overridden, excluded instance of a recurring JSCalendar object. + // + // If this property value is `true`, this calendar object instance MUST be removed from the occurrence expansion. + // + // The absence of this property, or the presence of its default value as `false`, indicates that this + // instance MUST be included in the occurrence expansion. + Excluded bool `json:"excluded,omitzero"` + + // This specifies a priority for the calendar object. + // + // This may be used as part of scheduling systems to help resolve conflicts for a time period. + // + // The priority is specified as an integer in the range `0` to `9`. + // + // A value of `0` specifies an undefined priority, for which the treatment will vary by situation. + // + // A value of `1` is the highest priority. + // + // A value of `2` is the second highest priority. + // + // Subsequent numbers specify a decreasing ordinal priority. + // + // A value of `9` is the lowest priority. + // + // Other integer values are reserved for future use. + Priority int `json:"priority,omitzero"` + + // This specifies how this calendar object should be treated when calculating free-busy state. + // + // This MUST be one of the following values, another value registered in the IANA + // "JSCalendar Enum Values" registry, or a vendor-specific value (see Section 3.3): + // !- `free` + // !- `busy` (default) + FreeBusyStatus FreeBusyStatus `json:"freeBusyStatus,omitempty"` + + // Privacy level. + // + // Calendar objects are normally collected together and may be shared with other users. + // The `privacy` property allows the object owner to indicate that it should not be shared or should + // only have the time information shared but the details withheld. + // + // Enforcement of the restrictions indicated by this property is up to the API via which this object is accessed. + // + // This property MUST NOT affect the information sent to scheduled participants; it is only + // interpreted by protocols that share the calendar objects belonging to one user with other users. + // + // The value MUST be one of the following values, another value registered in the IANA "JSCalendar Enum Values" + // registry, or a vendor-specific value (see Section 3.3). + // + // Any value the client or server doesn't understand should be preserved but treated as equivalent to private. + // + // !- `public`: The full details of the object are visible to those whom the object's calendar is shared with. + // !- `private`: The details of the object are hidden; only the basic time and metadata are shared. + // !- `secret`: The object is hidden completely (as though it did not exist) when the calendar this object is in is shared. + // + // When the `privacy` property is set to `private`, the following properties MAY be shared; any other + // properties MUST NOT be shared: + // !- `@type` + // !- `created` + // !- `due` + // !- `duration` + // !- `estimatedDuration` + // !- `freeBusyStatus` + // !- `privacy` + // !- `recurrenceOverrides` (Only patches that apply to another permissible property are allowed to be shared.) + // !- `sequence` + // !- `showWithoutTime` + // !- `start` + // !- `timeZone` + // !- `timeZones` + // !- `uid` + // !- `updated` + Privacy Privacy `json:"privacy,omitempty"` + + // This represents methods by which participants may submit their response to the organizer of the calendar object. + // + // The keys in the property value are the available methods and MUST only contain ASCII alphanumeric characters + // (`A-Za-z0-9`). The value is a URI for the method specified in the key. + // + // Future methods may be defined in future specifications and registered with IANA; a calendar client MUST + // ignore any method it does not understand but MUST preserve the method key and URI. + // + // This property MUST be omitted if no method is defined (rather than being specified as an empty object). + // + // The following methods are defined: + // !- `imip`: The organizer accepts an iCalendar Message-Based Interoperability Protocol (iMIP) + // [RFC6047] response at this email address. The value MUST be a `mailto:` URI. + // !- `web`: Opening this URI in a web browser will provide the user with a page where they can + // submit a reply to the organizer. The value MUST be a URL using the `https:` scheme. + // !- `other`: The organizer is identified by this URI, but the method for submitting the response + // is undefined. + ReplyTo map[ReplyMethod]string `json:"replyTo,omitempty"` + + // This is the email address in the `"From"` header of the email in which this calendar object was received. + // + // This is only relevant if the calendar object is received via iMIP or as an attachment to a message. + // + // If set, the value MUST be a valid addr-spec value as defined in Section 3.4.1 of [RFC5322]. + SentBy string `json:"sentBy,omitempty"` + + // This is a map of participant ids to participants, describing their participation in the calendar object. + // + // If this property is set and any participant has a `sendTo` property, then the `replyTo` property of this + // calendar object MUST define at least one reply method. + Participants map[string]Participant `json:"participants,omitempty"` + + // A request status as returned from processing the most recent scheduling request for this JSCalendar object. + // + // The allowed values are defined by the ABNF definitions of `statcode`, `statdesc` and `extdata` in + // Section 3.8.8.3 of [RFC5545] and the following ABNF [RFC5234]: + // + // ```text + // reqstatus = statcode ";" statdesc [";" extdata] + // ``` + // + // Servers MUST only add or change this property when they performe a scheduling action. + // + // Clients SHOULD NOT change or remove this property if it was provided by the server. + // + // Clients MAY add, change, or remove the property when the client is handling the scheduling. + // + // This property MUST only be included in scheduling messages according to the rules defined for the + // `REQUEST-STATUS` iCalendar property in [RFC5546]. + RequestStatus string `json:"requestStatus,omitempty"` + + // If `true`, use the user's default alerts and ignore the value of the alerts property. + // + // Fetching user defaults is dependent on the API from which this JSCalendar object is being fetched and + // is not defined in this specification. + // + // If an implementation cannot determine the user's default alerts, or none are set, it MUST process + // he alerts property as if `useDefaultAlerts` is set to false. + // + // Default: `false` + UseDefaultAlerts bool `json:"useDefaultAlerts,omitzero"` + + // This is a map of alert ids to Alert objects, representing alerts/reminders to display or send + // to the user for this calendar object. + Alerts map[string]Alert `json:"alerts,omitempty"` + + // A map where each key is a language tag [RFC5646], and the corresponding value is a set of patches + // to apply to the calendar object in order to localize it into that locale. + // + // See the description of PatchObject (Section 1.4.9) for the structure of the PatchObject. + // + // The patches are applied to the top-level calendar object. In addition, the locale property of the patched + // object is set to the language tag. + // + // All pointers for patches MUST end with one of the following suffixes; any patch that does not follow + // this MUST be ignored unless otherwise specified in a future RFC: + // !- `title` + // !- `description` + // !- `name` + // + // A patch MUST NOT have the prefix `recurrenceOverrides`; any localization of the override MUST be a + // patch to the `localizations` property inside the override instead. + // + // For example, a patch to `locations/abcd1234/title` is permissible, but a patch to `uid` or + // `recurrenceOverrides/2020-01-05T14:00:00/title` is not. + // + // Note that this specification does not define how to maintain validity of localized content. + // + // For example, a client application changing a JSCalendar object's `title` property might also + // need to update any localizations of this property. Client implementations SHOULD provide the means + // to manage localizations, but how to achieve this is specific to the application's workflow and requirements. + Localizations map[string]PatchObject `json:"localizations,omitempty"` + + // This identifies the time zone the object is scheduled in or is null for floating time. + // + // This is either a name from the IANA Time Zone Database [TZDB] or the `TimeZoneId` of a custom time zone + // from the `timeZones property`. + // + // If omitted, this MUST be presumed to be `null` (i.e., floating time). + TimeZone string `json:"timeZone,omitempty"` +} + +type Event struct { + Type TypeOfEvent `json:"@type,omitempty"` + + Object + + // This is the date/time the event starts in the event's time zone (as specified in the timeZone property, see Section 4.7.1). + Start LocalDateTime `json:"start"` + + // This is the zero or positive duration of the event in the event's start time zone. + // + // The end time of an event can be found by adding the duration to the event's start time. + // + // An Event MAY involve start and end locations that are in different time zones + // (e.g., a transcontinental flight). This can be expressed using the `relativeTo` and `timeZone` properties of + // the `Event`'s Location objects (see Section 4.2.5). + Duration Duration `json:"duration,omitempty"` + + // This is the scheduling status (Section 4.4) of an Event. + // + // If set, it MUST be one of the following values, another value registered in the IANA + // "JSCalendar Enum Values" registry, or a vendor-specific value (see Section 3.3): + // !- `confirmed`: indicates the event is definitely happening + // !- `cancelled`: indicates the event has been cancelled + // !- `tentative`: indicates the event may happen + Status Status `json:"status,omitempty"` +} + +type Task struct { + Type TypeOfTask `json:"@type,omitempty"` + + Object + + // This is the date/time the task is due in the task's time zone. + Due LocalDateTime `json:"due,omitzero"` + + // This the date/time the task should start in the task's time zone. + Start LocalDateTime `json:"start,omitzero"` + + // This specifies the estimated positive duration of time the task takes to complete. + EstimatedDuration Duration `json:"estimatedDuration,omitempty"` + + // This represents the percent completion of the task overall. + // + // The property value MUST be a positive integer between `0` and `100`. + PercentComplete uint `json:"percentComplete,omitzero"` + + // This defines the progress of this task. + // + // If omitted, the default progress (Section 4.4) of a Task is defined as follows (in order of evaluation): + // !- `completed`: if the progress property value of all participants is completed + // !- `failed`: if at least one progress property value of a participant is failed + // !- `in-process`: if at least one progress property value of a participant is in-process + // !- `needs-action`: if none of the other criteria match + // + // If set, it MUST be one of the following values, another value registered in the IANA "JSCalendar Enum Values" + // registry, or a vendor-specific value (see Section 3.3): + // !- `needs-action`: indicates the task needs action + // !- `in-process`: indicates the task is in process + // !- `completed`: indicates the task is completed + // !- `failed`: indicates the task failed + // !- `cancelled`: indicates the task was cancelled + Progress Progress `json:"progress,omitempty"` + + // This specifies the date/time the progress property of either the task overall (Section 5.2.5) or + // a specific participant (Section 4.4.6) was last updated. + // + // If the task is recurring and has future instances, a client may want to keep track of the last progress + // update timestamp of a specific task recurrence but leave other instances unchanged. + // + // One way to achieve this is by overriding the `progressUpdated` property in the task `recurrenceOverrides` property. + // + // However, this could produce a long list of timestamps for regularly recurring tasks. + // + // An alternative approach is to split the `Task` into a current, single instance of `Task` with this instance + // progress update time and a future recurring instance. + // + // See also Section 4.1.3 on splitting. + ProgressUpdated time.Time `json:"progressUpdated,omitzero"` +} + +type GroupEntry interface { + groupEntry() +} + +func (e Event) groupEntry() {} + +var _ GroupEntry = Event{} + +func (t Task) groupEntry() {} + +var _ GroupEntry = Task{} + +type Group struct { + Type TypeOfGroup `json:"@type,omitempty"` + + CommonObject + + // This is a collection of group members. + // + // Implementations MUST ignore entries of unknown type. + Entries []GroupEntry `json:"entries"` + + // This is the source from which updated versions of this group may be retrieved. + // + // The value MUST be a URI. + Source string `json:"source,omitempty"` +} + +func (g *Group) UnmarshalJSON(b []byte) error { + var typ struct { + Entries []struct { + Type string `json:"@type"` + } `json:"entries"` + } + if err := json.Unmarshal(b, &typ); err != nil { + return err + } + entries := make([]GroupEntry, len(typ.Entries)) + for i, entry := range typ.Entries { + switch entry.Type { + case string(EventType): + entries[i] = new(Event) + case string(TaskType): + entries[i] = new(Task) + default: + return fmt.Errorf("unsupported '%T.type' @type: \"%v\"", entry, entry.Type) + } + } + + type tmp Group + return json.Unmarshal(b, (*tmp)(g)) +} diff --git a/pkg/jscalendar/jscalendar_model_test.go b/pkg/jscalendar/jscalendar_model_test.go new file mode 100644 index 0000000000..c7b1c0fa5c --- /dev/null +++ b/pkg/jscalendar/jscalendar_model_test.go @@ -0,0 +1,754 @@ +package jscalendar + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func jsoneq[X any](t *testing.T, expected string, object X) { + data, err := json.MarshalIndent(object, "", "") + require.NoError(t, err) + require.JSONEq(t, expected, string(data)) + + var rec X + err = json.Unmarshal(data, &rec) + require.NoError(t, err) + require.Equal(t, object, rec) +} + +func TestLocalDateTime(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + + ldt := &LocalDateTime{ts} + + str, err := json.MarshalIndent(ldt, "", "") + require.NoError(t, err) + + require.Equal(t, "\"2025-09-25T16:26:14\"", string(str)) +} + +func TestLocalDateTimeUnmarshalling(t *testing.T) { + ts, err := time.Parse(RFC3339Local, "2025-09-25T18:26:14") + require.NoError(t, err) + u := ts.UTC() + + var result LocalDateTime + err = json.Unmarshal([]byte("\"2025-09-25T18:26:14Z\""), &result) + require.NoError(t, err) + + require.Equal(t, result, LocalDateTime{u}) +} + +func TestRelation(t *testing.T) { + jsoneq(t, `{ + "@type": "Relation", + "relation": { + "first": true, + "parent": true + } + }`, Relation{ + Type: RelationType, + Relation: map[Relationship]bool{ + RelationshipFirst: true, + RelationshipParent: true, + }, + }) +} + +func TestLink(t *testing.T) { + jsoneq(t, `{ + "@type": "Link", + "href": "https://opencloud.eu.example.com/f72ae875-40be-48a4-84ff-aea9aed3e085.png", + "cid": "c1", + "contentType": "image/png", + "size": 128912, + "rel": "icon", + "display": "thumbnail", + "title": "the logo" + }`, Link{ + Type: LinkType, + Href: "https://opencloud.eu.example.com/f72ae875-40be-48a4-84ff-aea9aed3e085.png", + Cid: "c1", + ContentType: "image/png", + Size: 128912, + Rel: RelIcon, + Display: DisplayThumbnail, + Title: "the logo", + }) +} + +func TestLocation(t *testing.T) { + jsoneq(t, `{ + "@type": "Location", + "name": "The Eiffel Tower", + "description": "The big iron tower in the middle of Paris, can't miss it.", + "locationTypes": { + "landmark-address": true, + "industrial": true + }, + "relativeTo": "start", + "timeZone": "Europe/Paris", + "coordinates": "geo:48.8559324,2.2932441", + "links": { + "l1": { + "@type": "Link", + "href": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Eiffel_blue.PNG", + "cid": "cl1", + "contentType": "image/png", + "size": 12345, + "rel": "icon", + "display": "A blue Eiffel tower", + "title": "Blue Eiffel Tower" + } + } + }`, Location{ + Type: LocationType, + Name: "The Eiffel Tower", + Description: "The big iron tower in the middle of Paris, can't miss it.", + LocationTypes: map[LocationTypeOption]bool{ + LocationTypeOptionLandmarkAddress: true, + LocationTypeOptionIndustrial: true, + }, + RelativeTo: LocationRelationStart, + TimeZone: "Europe/Paris", + Coordinates: "geo:48.8559324,2.2932441", + Links: map[string]Link{ + "l1": { + Type: LinkType, + Href: "https://upload.wikimedia.org/wikipedia/commons/f/fd/Eiffel_blue.PNG", + Cid: "cl1", + ContentType: "image/png", + Size: 12345, + Rel: RelIcon, + Display: "A blue Eiffel tower", + Title: "Blue Eiffel Tower", + }, + }, + }) +} + +func TestVirtualLocation(t *testing.T) { + jsoneq(t, `{ + "@type": "VirtualLocation", + "name": "OpenTalk", + "description": "The best videoconferencing.", + "uri": "https://opentalk.eu", + "features": { + "video": true, + "screen": true, + "audio": true + } + }`, VirtualLocation{ + Type: VirtualLocationType, + Name: "OpenTalk", + Description: "The best videoconferencing.", + Uri: "https://opentalk.eu", + Features: map[VirtualLocationFeature]bool{ + VirtualLocationFeatureVideo: true, + VirtualLocationFeatureScreen: true, + VirtualLocationFeatureAudio: true, + }, + }) +} + +func TestNDay(t *testing.T) { + jsoneq(t, `{ + "@type": "NDay", + "day": "fr", + "nthOfPeriod": -1 + }`, NDay{ + Type: NDayType, + Day: DayOfWeekFriday, + NthOfPeriod: -1, + }) +} + +func TestRecurrenceRule(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + ts = ts.UTC() + + jsoneq(t, `{ + "@type": "RecurrenceRule", + "frequency": "daily", + "interval": 1, + "rscale": "iso8601", + "skip": "forward", + "firstDayOfWeek": "mo", + "byDay": [ + {"@type": "NDay", "day": "mo", "nthOfPeriod": -1}, + {"@type": "NDay", "day": "tu"}, + {"day": "we"} + ], + "byMonthDay": [1, 10, 31], + "byMonth": ["1", "31L"], + "byYearDay": [-1, 366], + "byWeekNo": [-53, 53], + "byHour": [0, 23], + "byMinute": [0, 59], + "bySecond": [0, 39], + "bySetPosition": [-3, 3], + "count": 2, + "until": "2025-09-25T16:26:14Z" + }`, RecurrenceRule{ + Type: RecurrenceRuleType, + Frequency: FrequencyDaily, + Interval: 1, + Rscale: RscaleIso8601, + Skip: SkipForward, + FirstDayOfWeek: DayOfWeekMonday, + ByDay: []NDay{ + { + Type: NDayType, + Day: DayOfWeekMonday, + NthOfPeriod: -1, + }, + { + Type: NDayType, + Day: DayOfWeekTuesday, + }, + { + Day: DayOfWeekWednesday, + NthOfPeriod: 0, + }, + }, + ByMonthDay: []int{1, 10, 31}, + ByMonth: []string{"1", "31L"}, + ByYearDay: []int{-1, 366}, + ByWeekNo: []int{-53, 53}, + ByHour: []uint{0, 23}, + ByMinute: []uint{0, 59}, + BySecond: []uint{0, 39}, + BySetPosition: []int{-3, 3}, + Count: 2, + Until: &LocalDateTime{ts}, + }) +} + +func TestParticipant(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + ts = ts.UTC() + + ts2, err := time.Parse(time.RFC3339, "2025-09-29T14:32:19+02:00") + require.NoError(t, err) + ts2 = ts2.UTC() + + jsoneq(t, `{ + "@type": "Participant", + "name": "Camina Drummer", + "email": "camina@opa.org", + "description": "Camina Drummer is a Belter serving as the current President of the Transport Union.", + "sendTo": { + "imip": "mailto:cdrummer@opa.org", + "other": "https://opa.org/ping/camina" + }, + "kind": "individual", + "roles": { + "attendee": true, + "owner": true, + "chair": true + }, + "locationId": "98faaa01-b6db-4ddb-9574-e28ab83104e6", + "language": "en-JM", + "participationStatus": "accepted", + "participationComment": "always there", + "expectReply": true, + "scheduleAgent": "server", + "scheduleForceSend": true, + "scheduleSequence": 3, + "scheduleStatus": [ + "3.1", + "2.0" + ], + "scheduleUpdated": "2025-09-25T16:26:14Z", + "sentBy": "adawes@opa.org", + "invitedBy": "346be402-c340-4f3f-ac51-e4aa9955af4f", + "delegatedTo": { + "93230b90-70c6-4027-b2c1-3629877bfea5": true, + "f5fae398-cfa3-4873-bbc7-0ca9d51de5b0": true + }, + "delegatedFrom": { + "a9c1c1a1-fecf-4214-a803-1ee209e2dbec": true + }, + "memberOf": { + "0f41473b-0edd-494d-b346-8d039009a2a5": true + }, + "links":{ + "l1": { + "@type": "Link", + "href": "https://opa.org/opa.png", + "cid": "c1", + "contentType": "image/png", + "size": 182912, + "rel": "icon", + "display": "Logo", + "title": "OPA" + } + }, + "progress": "in-process", + "progressUpdated": "2025-09-29T12:32:19Z", + "percentComplete": 42 + }`, Participant{ + Type: ParticipantType, + Name: "Camina Drummer", + Email: "camina@opa.org", + Description: "Camina Drummer is a Belter serving as the current President of the Transport Union.", + SendTo: map[SendToMethod]string{ + SendToMethodImip: "mailto:cdrummer@opa.org", + SendToMethodOther: "https://opa.org/ping/camina", + }, + Kind: ParticipantKindIndividual, + Roles: map[Role]bool{ + RoleAttendee: true, + RoleOwner: true, + RoleChair: true, + }, + LocationId: "98faaa01-b6db-4ddb-9574-e28ab83104e6", + Language: "en-JM", + ParticipationStatus: ParticipationStatusAccepted, + ParticipationComment: "always there", + ExpectReply: true, + ScheduleAgent: ScheduleAgentServer, + ScheduleForceSend: true, + ScheduleSequence: 3, + ScheduleStatus: []string{ + "3.1", + "2.0", + }, + ScheduleUpdated: ts, + SentBy: "adawes@opa.org", + InvitedBy: "346be402-c340-4f3f-ac51-e4aa9955af4f", + DelegatedTo: map[string]bool{ + "93230b90-70c6-4027-b2c1-3629877bfea5": true, + "f5fae398-cfa3-4873-bbc7-0ca9d51de5b0": true, + }, + DelegatedFrom: map[string]bool{ + "a9c1c1a1-fecf-4214-a803-1ee209e2dbec": true, + }, + MemberOf: map[string]bool{ + "0f41473b-0edd-494d-b346-8d039009a2a5": true, + }, + Links: map[string]Link{ + "l1": { + Type: LinkType, + Href: "https://opa.org/opa.png", + Cid: "c1", + ContentType: "image/png", + Size: 182912, + Rel: RelIcon, + Display: "Logo", + Title: "OPA", + }, + }, + Progress: ProgressInProcess, + ProgressUpdated: ts2, + PercentComplete: 42, + }) +} + +func TestAlertWithAbsoluteTrigger(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + ts = ts.UTC() + + jsoneq(t, `{ + "@type": "Alert", + "trigger": { + "@type": "AbsoluteTrigger", + "when": "2025-09-25T16:26:14Z" + }, + "acknowledged": "2025-09-25T16:26:14Z", + "relatedTo": { + "a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": { + "@type": "Relation", + "relation": { + "first": true + } + } + }, + "action": "email" + }`, Alert{ + Type: AlertType, + Trigger: &AbsoluteTrigger{ + Type: AbsoluteTriggerType, + When: ts, + }, + Acknowledged: ts, + RelatedTo: map[string]Relation{ + "a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": { + Type: RelationType, + Relation: map[Relationship]bool{ + RelationshipFirst: true, + }, + }, + }, + Action: AlertActionEmail, + }) +} + +func TestAlertWithOffsetTrigger(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + ts = ts.UTC() + + jsoneq(t, `{ + "@type": "Alert", + "trigger": { + "@type": "OffsetTrigger", + "offset": "-PT5M", + "relativeTo": "end" + }, + "acknowledged": "2025-09-25T16:26:14Z", + "relatedTo": { + "a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": { + "@type": "Relation", + "relation": { + "first": true + } + } + }, + "action": "email" + }`, Alert{ + Type: AlertType, + Trigger: &OffsetTrigger{ + Type: OffsetTriggerType, + Offset: "-PT5M", + RelativeTo: RelativeToEnd, + }, + Acknowledged: ts, + RelatedTo: map[string]Relation{ + "a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": { + Type: RelationType, + Relation: map[Relationship]bool{ + RelationshipFirst: true, + }, + }, + }, + Action: AlertActionEmail, + }) +} + +func TestAlertWithUnknownTrigger(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + ts = ts.UTC() + + jsoneq(t, `{ + "@type": "Alert", + "trigger": { + "@type": "XYZTRIGGER", + "abc": 123, + "xyz": "zzz" + }, + "acknowledged": "2025-09-25T16:26:14Z", + "relatedTo": { + "a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": { + "@type": "Relation", + "relation": { + "first": true + } + } + }, + "action": "email" + }`, Alert{ + Type: AlertType, + Trigger: &UnknownTrigger{ + "@type": "XYZTRIGGER", + "abc": 123.0, + "xyz": "zzz", + }, + Acknowledged: ts, + RelatedTo: map[string]Relation{ + "a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": { + Type: RelationType, + Relation: map[Relationship]bool{ + RelationshipFirst: true, + }, + }, + }, + Action: AlertActionEmail, + }) +} + +func TestTimeZoneRule(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + ts = ts.UTC() + + l1 := LocalDateTime{ts} + + jsoneq(t, `{ + "@type": "TimeZoneRule", + "start": "2025-09-25T16:26:14Z", + "offsetFrom": "-0200", + "offsetTo": "+0200", + "recurrenceRules": [ + { + "@type": "RecurrenceRule", + "frequency": "weekly", + "interval": 2, + "rscale": "iso8601", + "skip": "omit", + "firstDayOfWeek": "mo", + "byDay": [ + { + "@type": "NDay", + "day": "fr" + } + ], + "byHour": [14], + "byMinute": [0], + "count": 4 + } + ], + "recurrenceOverrides": { + "2025-09-25T16:26:14Z": {} + }, + "names": { + "CEST": true + }, + "comments": ["this is a comment"] + }`, TimeZoneRule{ + Type: TimeZoneRuleType, + Start: LocalDateTime{ts}, + OffsetFrom: "-0200", + OffsetTo: "+0200", + RecurrenceRules: []RecurrenceRule{ + { + Type: RecurrenceRuleType, + Frequency: FrequencyWeekly, + Interval: 2, + Rscale: RscaleIso8601, + Skip: SkipOmit, + FirstDayOfWeek: DayOfWeekMonday, + ByDay: []NDay{ + { + Type: NDayType, + Day: DayOfWeekFriday, + }, + }, + ByHour: []uint{ + 14, + }, + ByMinute: []uint{ + 0, + }, + Count: 4, + }, + }, + RecurrenceOverrides: map[LocalDateTime]PatchObject{ + l1: {}, + }, + Names: map[string]bool{ + "CEST": true, + }, + Comments: []string{ + "this is a comment", + }, + }) +} + +func TestTimeZone(t *testing.T) { + ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + ts = ts.UTC() + + jsoneq(t, `{ + "@type": "TimeZone", + "tzId": "cest", + "updated": "2025-09-25T16:26:14Z", + "url": "https://timezones.net/cest", + "validUntil": "2025-09-25T16:26:14Z", + "aliases": { + "cet": true + }, + "standard": [{ + "@type": "TimeZoneRule", + "start": "2025-09-25T16:26:14Z", + "offsetFrom": "-0200", + "offsetTo": "+1245" + }], + "daylight": [{ + "@type": "TimeZoneRule", + "start": "2025-09-25T16:26:14Z", + "offsetFrom": "-0200", + "offsetTo": "+1245" + }] + }`, TimeZone{ + Type: TimeZoneType, + TzId: "cest", + Updated: ts, + Url: "https://timezones.net/cest", + ValidUntil: ts, + Aliases: map[string]bool{ + "cet": true, + }, + Standard: []TimeZoneRule{ + { + Type: TimeZoneRuleType, + Start: LocalDateTime{ts}, + OffsetFrom: "-0200", + OffsetTo: "+1245", + }, + }, + Daylight: []TimeZoneRule{ + { + Type: TimeZoneRuleType, + Start: LocalDateTime{ts}, + OffsetFrom: "-0200", + OffsetTo: "+1245", + }, + }, + }) +} + +func TestEvent(t *testing.T) { + ts1, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") + require.NoError(t, err) + ts1 = ts1.UTC() + + ts2, err := time.Parse(time.RFC3339, "2025-09-29T15:53:01+02:00") + require.NoError(t, err) + ts2 = ts2.UTC() + + jsoneq(t, `{ + "@type": "Event", + "start": "2025-09-25T16:26:14Z", + "duration": "PT10M", + "status": "confirmed", + "uid": "b422cfec-f7b4-4e04-8ec6-b794007f63f1", + "prodId": "OpenCloud 1.0", + "created": "2025-09-25T16:26:14Z", + "updated": "2025-09-29T13:53:01Z", + "title": "End of year party", + "description": "It's the party at the end of the year.", + "descriptionContentType": "text/plain", + "links": { + "l1": { + "@type": "Link", + "href": "https://opencloud.eu/eoy-party/2025", + "contentType": "text/html", + "rel": "about" + } + }, + "locale": "en-GB", + "keywords": { + "k1": true + }, + "categories": { + "cat": true + }, + "color": "oil", + "timeZones": { + "cest": { + "@type": "TimeZone", + "tzId": "cest" + } + }, + "relatedTo": { + "a": { + "@type": "Relation", + "relation": { + "next": true + } + } + }, + "sequence": 3, + "method": "refresh", + "showWithoutTime": true, + "locations": { + "loc1": { + "@type": "Location", + "name": "Steel Cactus Mexican Grill", + "description": "The Steel Cactus Mexican Grill used to be on the Hecate Navy Base. The place closed down and is now a take-out restaurant that sells to-go cups of Thai food", + "locationTypes": { + "bar": true + }, + "relativeTo": "start", + "timeZone": "cest", + "coordinates": "geo:16.7685657,-4.8629852", + "links": { + "l1": { + "@type": "Link", + "href": "https://mars.gov/bars/steelcactus", + "rel": "about" + } + } + } + } + }`, Event{ + Type: EventType, + Start: LocalDateTime{ts1}, + Duration: "PT10M", + Status: "confirmed", + Object: Object{ + CommonObject: CommonObject{ + Uid: "b422cfec-f7b4-4e04-8ec6-b794007f63f1", + ProdId: "OpenCloud 1.0", + Created: ts1, + Updated: ts2, + Title: "End of year party", + Description: "It's the party at the end of the year.", + DescriptionContentType: "text/plain", + Links: map[string]Link{ + "l1": { + Type: LinkType, + Href: "https://opencloud.eu/eoy-party/2025", + ContentType: "text/html", + Rel: RelAbout, + }, + }, + Locale: "en-GB", + Keywords: map[string]bool{ + "k1": true, + }, + Categories: map[string]bool{ + "cat": true, + }, + Color: "oil", + TimeZones: map[string]TimeZone{ + "cest": { + Type: TimeZoneType, + TzId: "cest", + }, + }, + }, + RelatedTo: map[string]Relation{ + "a": { + Type: RelationType, + Relation: map[Relationship]bool{ + RelationshipNext: true, + }, + }, + }, + Sequence: 3, + Method: MethodRefresh, + ShowWithoutTime: true, + Locations: map[string]Location{ + "loc1": { + Type: LocationType, + Name: "Steel Cactus Mexican Grill", + Description: "The Steel Cactus Mexican Grill used to be on the Hecate Navy Base. The place closed down and is now a take-out restaurant that sells to-go cups of Thai food", + LocationTypes: map[LocationTypeOption]bool{ + LocationTypeOptionBar: true, + }, + RelativeTo: LocationRelationStart, + TimeZone: "cest", + Coordinates: "geo:16.7685657,-4.8629852", + Links: map[string]Link{ + "l1": { + Type: LinkType, + Href: "https://mars.gov/bars/steelcactus", + Rel: RelAbout, + }, + }, + }, + }, + }, + }) +} diff --git a/pkg/jscontact/jscontact_model.go b/pkg/jscontact/jscontact_model.go index 23614e8e61..12c2b156af 100644 --- a/pkg/jscontact/jscontact_model.go +++ b/pkg/jscontact/jscontact_model.go @@ -6,6 +6,8 @@ package jscontact import ( + "encoding/json" + "fmt" "time" ) @@ -255,6 +257,8 @@ const ( PhoneFeatureTextPhone = PhoneFeature("textphone") PhoneFeatureFax = PhoneFeature("fax") PhoneFeaturePager = PhoneFeature("pager") + + RscaleIso8601 = "iso8601" ) var ( @@ -1613,6 +1617,32 @@ type AnniversaryDate interface { isAnniversaryDate() // marker } +type AnniversaryDateContainer struct { + Value AnniversaryDate +} + +func (a *Anniversary) UnmarshalJSON(b []byte) error { + var typ struct { + Date struct { + Type string `json:"@type"` + } `json:"date,omitzero"` + } + if err := json.Unmarshal(b, &typ); err != nil { + return err + } + switch typ.Date.Type { + case string(PartialDateType): + a.Date = new(PartialDate) + case string(TimestampType): + a.Date = new(Timestamp) + default: + return fmt.Errorf("unsupported '%T.date' @type: \"%v\"", a, typ.Date.Type) + } + + type tmp Anniversary + return json.Unmarshal(b, (*tmp)(a)) +} + // 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. diff --git a/pkg/jscontact/jscontact_model_test.go b/pkg/jscontact/jscontact_model_test.go index ff1b717115..41e348f70d 100644 --- a/pkg/jscontact/jscontact_model_test.go +++ b/pkg/jscontact/jscontact_model_test.go @@ -8,10 +8,15 @@ import ( "github.com/stretchr/testify/require" ) -func jsoneq(t *testing.T, expected string, object any) { - str, err := json.MarshalIndent(object, "", "") +func jsoneq[X any](t *testing.T, expected string, object X) { + data, err := json.MarshalIndent(object, "", "") require.NoError(t, err) - require.JSONEq(t, expected, string(str)) + require.JSONEq(t, expected, string(data)) + + var rec X + err = json.Unmarshal(data, &rec) + require.NoError(t, err) + require.Equal(t, object, rec) } func TestCalendar(t *testing.T) { @@ -550,7 +555,7 @@ func TestTimestamp(t *testing.T) { jsoneq(t, `{ "@type": "Timestamp", "utc": "2025-09-25T18:26:14.094725532+02:00" - }`, Timestamp{ + }`, &Timestamp{ Type: TimestampType, Utc: ts, }) @@ -569,7 +574,7 @@ func TestAnniversaryWithPartialDate(t *testing.T) { }`, Anniversary{ Type: AnniversaryType, Kind: AnniversaryKindBirth, - Date: PartialDate{ + Date: &PartialDate{ Type: PartialDateType, Year: 2025, Month: 9, @@ -592,7 +597,7 @@ func TestAnniversaryWithTimestamp(t *testing.T) { }`, Anniversary{ Type: AnniversaryType, Kind: AnniversaryKindBirth, - Date: Timestamp{ + Date: &Timestamp{ Type: TimestampType, Utc: ts, }, @@ -1201,7 +1206,7 @@ func TestContactCard(t *testing.T) { "birth": { Type: AnniversaryType, Kind: AnniversaryKindBirth, - Date: PartialDate{ + Date: &PartialDate{ Type: PartialDateType, Year: 2025, Month: 9, @@ -1238,7 +1243,7 @@ func TestContactCard(t *testing.T) { }, Localizations: map[string]PatchObject{ "fr": { - "personalInfo": PatchObject{ + "personalInfo": map[string]any{ "value": "Nuages", }, },