Skip to main content

jmap_calendars_types/
event.rs

1//! CalendarEvent object and CalendarEventFilterCondition.
2//!
3//! Normative references:
4//!   - draft-ietf-jmap-calendars-33 §5 — JMAP-specific additions
5//!   - RFC 8984 §2.1, §4, §5.1 — JSCalendar Event properties
6
7use std::collections::BTreeMap;
8
9use jmap_jscalendar_types::LocalDateTime;
10use jmap_metadata_types::MetadataTextMatch;
11use jmap_types::{Id, PatchObject, UTCDate, impl_string_enum};
12use serde::{Deserialize, Serialize};
13
14/// A JMAP CalendarEvent object (draft-ietf-jmap-calendars-33 §5; RFC 8984 §5.1).
15///
16/// `CalendarEvent` is a JSCalendar Event with additional JMAP-specific properties.
17/// Every field is `Option<T>` because RFC 8620 §5.1 allows partial responses
18/// (clients may request only specific fields via the `properties` argument).
19/// A field absent from the server response MUST NOT fail deserialization.
20///
21/// ## Complex sub-objects
22///
23/// JSCalendar sub-objects with their own rich structure (`locations`,
24/// `participants`, `alerts`, `recurrenceRules`, etc.) are represented as
25/// `Option<serde_json::Value>`.  This avoids an exhaustive typed representation
26/// while preserving full round-trip fidelity.  Consumers that need to
27/// manipulate these fields should parse the `Value` using the concrete types
28/// re-exported at this crate's root (e.g. [`crate::Location`], [`crate::Participant`],
29/// [`crate::Alert`]) — defined in the `jmap-jscalendar-types` crate.
30///
31/// ## PatchObject envelopes
32///
33/// `recurrenceOverrides` and `localizations` are typed as
34/// `Option<BTreeMap<String, jmap_types::PatchObject>>`: the outer envelope
35/// is JMAP-level (RFC 8620 §5.3 PatchObject) while the inner leaves stay
36/// `serde_json::Value` to preserve per-leaf JSCalendar flexibility.  Wire
37/// format is byte-identical to the prior opaque `Option<Value>` shape via
38/// `#[serde(transparent)]` on `PatchObject`.
39#[non_exhaustive]
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct CalendarEvent {
43    // ── JMAP-specific properties (draft §5) ──────────────────────────────────
44    /// Server-assigned immutable identifier.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub id: Option<Id>,
47
48    /// `Id` of the base recurring event when `id` is a synthetic expanded
49    /// instance id.  `null` for non-synthetic events.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub base_event_id: Option<Id>,
52
53    /// Set of Calendar ids this event belongs to (values always `true`).
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub calendar_ids: Option<BTreeMap<Id, bool>>,
56
57    /// If `true`, this is a draft; the server will not send scheduling messages.
58    /// Always serialized; absent deserializes, and `None` serializes, as the
59    /// spec default `false`.
60    #[serde(default = "some_false", serialize_with = "serialize_false_if_none")]
61    pub is_draft: Option<bool>,
62
63    /// Server-set; `true` if this account is the authoritative source.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub is_origin: Option<bool>,
66
67    /// Computed UTC start time (not returned by default; must be requested).
68    ///
69    /// Uses the [`UTCDate`] newtype to make the wire-format constraint
70    /// (RFC 8620 §1.4: 20-character UTCDateTime string) explicit at the
71    /// type level. JSON wire format is unchanged because `UTCDate` is a
72    /// transparent newtype around `String`.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub utc_start: Option<UTCDate>,
75
76    /// Computed UTC end time (not returned by default; must be requested).
77    ///
78    /// See [`utc_start`](Self::utc_start) for the typing rationale.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub utc_end: Option<UTCDate>,
81
82    /// If `true`, use per-calendar default alerts instead of `alerts` (default `false`).
83    /// Always serialized; absent deserializes, and `None` serializes, as the
84    /// spec default `false`.
85    #[serde(default = "some_false", serialize_with = "serialize_false_if_none")]
86    pub use_default_alerts: Option<bool>,
87
88    /// If `true`, anyone may add themselves to the event as a participant
89    /// (draft §5.1.1).
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub may_invite_self: Option<bool>,
92
93    /// If `true`, any current participant may add new participants
94    /// (draft §5.1.2).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub may_invite_others: Option<bool>,
97
98    /// If `true`, only users with full permission to modify the event see
99    /// the complete set of participants; others see only the owners and
100    /// themselves (draft §5.1.3, §5.7).
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub hide_attendees: Option<bool>,
103
104    /// JMAP Blob id for the iCalendar representation of this event.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub blob_id: Option<Id>,
107
108    /// iCalendar data that converted to this event, as an `ICalComponent`
109    /// object (draft-ietf-calext-jscalendar-icalendar-28 §5.1.1).
110    ///
111    /// Only returned when explicitly requested via `properties:["iCalendar"]`
112    /// (draft-ietf-jmap-calendars-33 §5.7: a null `properties` returns all
113    /// properties except `iCalendar`). Kept as a `serde_json::Value` per the
114    /// workspace Sloppy-Value policy: it carries jCal arrays (RFC 7265).
115    ///
116    /// Wire name is `"iCalendar"`; a manual rename keeps it explicit
117    /// rather than relying on `rename_all` of a `i_calendar` field name.
118    #[serde(rename = "iCalendar", skip_serializing_if = "Option::is_none")]
119    pub icalendar: Option<serde_json::Value>,
120
121    // ── JSCalendar @type ─────────────────────────────────────────────────────
122    /// JSCalendar object type; always `"Event"` when present.
123    /// Included for round-trip fidelity; omitted on create.
124    #[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
125    pub at_type: Option<String>,
126
127    // ── JSCalendar metadata properties (RFC 8984 §4.1) ───────────────────────
128    /// JSCalendar version this event conforms to, e.g. `"2.0"`
129    /// (draft-ietf-calext-jscalendarbis-22 §3.1.2). The server sets it when
130    /// a create omits it (draft-ietf-jmap-calendars-33 §5.9).
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub version: Option<String>,
133
134    /// Globally unique identifier for this event.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub uid: Option<String>,
137
138    /// Map of related event UIDs → `Relation` objects.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub related_to: Option<serde_json::Value>,
141
142    /// Product identifier (who created the event data).
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub prod_id: Option<String>,
145
146    /// UTC date-time when this event was created.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub created: Option<UTCDate>,
149
150    /// UTC date-time of last modification (mandatory in full responses).
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub updated: Option<UTCDate>,
153
154    /// iCalendar sequence number (default `0`).
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub sequence: Option<u64>,
157
158    // ── JSCalendar what/where properties (RFC 8984 §4.2) ─────────────────────
159    /// Human-readable summary/title.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub title: Option<String>,
162
163    /// Longer-form description.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub description: Option<String>,
166
167    /// Content type of `description` (default `"text/plain"`).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub description_content_type: Option<String>,
170
171    /// If `true`, this is an all-day event (default `false`).
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub show_without_time: Option<bool>,
174
175    /// Map of location id → `Location` object.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub locations: Option<serde_json::Value>,
178
179    /// Map of virtual location id → `VirtualLocation` object.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub virtual_locations: Option<serde_json::Value>,
182
183    /// Map of link id → `Link` object (attachments, images, etc.).
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub links: Option<serde_json::Value>,
186
187    /// BCP 47 language tag for this event's locale.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub locale: Option<String>,
190
191    /// Map of keyword strings → `true` (RFC 8984 §4.2.10).
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub keywords: Option<BTreeMap<String, bool>>,
194
195    /// Map of category URI strings → `true` (RFC 8984 §4.2.11).
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub categories: Option<BTreeMap<String, bool>>,
198
199    /// CSS color name or `#rrggbb` for this event.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub color: Option<String>,
202
203    // ── JSCalendar recurrence properties (RFC 8984 §4.3) ─────────────────────
204    /// `LocalDateTime` string identifying which recurrence instance this
205    /// object overrides.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub recurrence_id: Option<String>,
208
209    /// Time zone for `recurrence_id` (required when `recurrence_id` is set).
210    ///
211    /// This crate does not check the value against the IANA Time Zone
212    /// Database; validating the zone name is the backend's job.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub recurrence_id_time_zone: Option<String>,
215
216    /// Array of `RecurrenceRule` objects.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub recurrence_rules: Option<serde_json::Value>,
219
220    /// Array of `RecurrenceRule` objects to exclude from the series.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub excluded_recurrence_rules: Option<serde_json::Value>,
223
224    /// Map of `LocalDateTime` string → [`PatchObject`] for per-occurrence
225    /// overrides (RFC 8984 §4.3.2).
226    ///
227    /// Outer envelope is typed at the JMAP level: keys are LocalDateTime
228    /// strings and values are JMAP `PatchObject` (RFC 8620 §5.3) wire
229    /// objects.  The inner `PatchObject` leaves remain `serde_json::Value`
230    /// so per-occurrence overrides retain full JSCalendar shape flexibility
231    /// (Sloppy-Value at the leaf, typed at the envelope).  Wire format is
232    /// byte-identical via `#[serde(transparent)]`.
233    ///
234    /// **Maintainer note: do NOT "simplify" this shape.** Two refactors
235    /// commonly surface; both are wrong:
236    ///
237    /// 1. **Revert to `Option<serde_json::Value>`.** Loses the type-level
238    ///    signal that the outer envelope follows JMAP `PatchObject`
239    ///    (RFC 8620 §5.3) semantics. JMAP and JSCalendar are
240    ///    co-evolving specs with separate ownership of the envelope vs
241    ///    leaf semantics, and the type-level split mirrors that.
242    /// 2. **Type the inner leaves with full JSCalendar field-by-field
243    ///    structs.** Would require an exhaustive JSCalendar leaf type
244    ///    that must track both specs and break partial deserialize. The
245    ///    workspace Sloppy-Value policy (workspace AGENTS.md) is
246    ///    designed exactly to avoid this trap.
247    ///
248    /// Wire-format byte-identity is pinned by
249    /// `calendar_event_recurrence_overrides_wire_format_is_object_map`
250    /// in `tests/types_test.rs`. Design defense: bd:JMAP-1rwf.6. See
251    /// also `localizations` below — same shape, same rationale.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub recurrence_overrides: Option<BTreeMap<String, PatchObject>>,
254
255    /// If `true`, this occurrence is excluded from the series.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub excluded: Option<bool>,
258
259    // ── JSCalendar scheduling/sharing properties (RFC 8984 §4.4) ─────────────
260    /// Priority of this event (0–9; 0 = undefined).
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub priority: Option<u8>,
263
264    /// Free/busy status: `"free"` or `"busy"` (default `"busy"`).
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub free_busy_status: Option<String>,
267
268    /// Privacy: `"public"`, `"private"`, or `"secret"` (default `"public"`).
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub privacy: Option<String>,
271
272    /// Map of scheduling method → reply-to URI (RFC 8984 §4.4.4).
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub reply_to: Option<BTreeMap<String, String>>,
275
276    /// Addr-spec of the person who sent this event data.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub sent_by: Option<String>,
279
280    /// The organizer's calendarAddress (wire: `organizerCalendarAddress`).
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub organizer_calendar_address: Option<String>,
283
284    /// Map of participant id → `Participant` object.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub participants: Option<serde_json::Value>,
287
288    /// iTIP request status string.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub request_status: Option<String>,
291
292    // ── JSCalendar alert properties (RFC 8984 §4.5) ───────────────────────────
293    /// Map of alert id → `Alert` object.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub alerts: Option<serde_json::Value>,
296
297    // ── JSCalendar multilingual properties (RFC 8984 §4.6) ───────────────────
298    /// Map of language tag → [`PatchObject`] for per-language overrides
299    /// (RFC 8984 §4.6).
300    ///
301    /// Outer envelope is typed at the JMAP level: keys are BCP 47 language
302    /// tags and values are JMAP `PatchObject` (RFC 8620 §5.3) wire objects.
303    /// The inner `PatchObject` leaves remain `serde_json::Value` so per-
304    /// language overrides retain full JSCalendar shape flexibility (Sloppy-
305    /// Value at the leaf, typed at the envelope).  Wire format is byte-
306    /// identical via `#[serde(transparent)]`.
307    ///
308    /// **Maintainer note: do NOT "simplify" this shape** — see the same
309    /// note on [`Self::recurrence_overrides`] above for the full rationale.
310    /// The two fields share the hybrid Sloppy-Value-at-the-leaf,
311    /// typed-at-the-envelope pattern (JMAP §5.3 envelope ownership vs.
312    /// JSCalendar §4.6 leaf ownership). Design defense: bd:JMAP-1rwf.6.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub localizations: Option<BTreeMap<String, PatchObject>>,
315
316    // ── JSCalendar time zone properties (RFC 8984 §4.7) ───────────────────────
317    /// IANA time zone id for the event's start/end times.
318    ///
319    /// This crate does not check the value against the IANA Time Zone
320    /// Database; validating the zone name is the backend's job.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub time_zone: Option<String>,
323
324    /// Custom time zone definitions; opaque passthrough (RFC 8984 §4.7.2).
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub time_zones: Option<serde_json::Value>,
327
328    // ── JSCalendar Event-specific properties (RFC 8984 §5.1) ─────────────────
329    /// Start date-time as a `LocalDateTime` string.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub start: Option<String>,
332
333    /// Duration as an ISO 8601 duration string (e.g. `"PT1H"`).
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub duration: Option<String>,
336
337    /// Event status: `"confirmed"`, `"cancelled"`, or `"tentative"`.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub status: Option<String>,
340
341    // ── draft-ietf-jmap-metadata-04 §2 ────────────────────────────────────────
342    /// Shared metadata, keyed by namespace identifier
343    /// (draft-ietf-jmap-metadata-04 §2). Each value is a JSON object (§2.1).
344    /// `None` when absent, which -04 §2 treats as `{}`; in an update a
345    /// `null` value "is equivalent to {}" (-04 §3.2) and also decodes as
346    /// `None`.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
349
350    /// The authenticated user's own metadata, keyed by namespace
351    /// identifier (draft-ietf-jmap-metadata-04 §2). Same shape and `None`
352    /// handling as `metadata`.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub private_metadata: Option<serde_json::Map<String, serde_json::Value>>,
355
356    /// Catch-all for vendor / site / private extension fields not covered
357    /// by the typed fields above. Preserves unknown fields across
358    /// deserialize/serialize round-trip per workspace extras-preservation
359    /// policy (see workspace AGENTS.md).
360    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
361    pub extra: serde_json::Map<String, serde_json::Value>,
362}
363
364/// Serialize an unset `Boolean (default: false)` property as `false`.
365///
366/// draft-ietf-jmap-calendars-33 §5.7: the "isDraft" and "useDefaultAlerts"
367/// properties MUST be included on each object returned by `CalendarEvent/get`.
368fn serialize_false_if_none<S: serde::Serializer>(
369    value: &Option<bool>,
370    serializer: S,
371) -> Result<S::Ok, S::Error> {
372    serializer.serialize_bool(value.unwrap_or(false))
373}
374
375/// Deserialize default for the same properties, so an absent value round-trips
376/// to the `Some(false)` it serializes as.
377fn some_false() -> Option<bool> {
378    Some(false)
379}
380
381/// Filter condition for `CalendarEvent/query`
382/// (draft-ietf-jmap-calendars-33 §5.11.1).
383///
384/// All fields are optional; a condition with no fields set matches every
385/// CalendarEvent.
386///
387/// # Excluded from extras preservation
388///
389/// This type is **out of scope** for the workspace extras-preservation
390/// policy: it carries no flatten-extras `extra` field. Filter clauses the
391/// server does not understand are a query-correctness hazard — silently
392/// preserving an unrecognised clause and round-tripping it back to the
393/// client can return the wrong set of records with no error signal.
394///
395/// ## What to do instead
396///
397/// **IETF-track path.** Vendors who need both capability-level declaration
398/// and filterability for custom fields should use
399/// `draft-ietf-jmap-metadata` (capability URI
400/// `urn:ietf:params:jmap:metadata`), which adds namespace-keyed
401/// `metadata` / `privateMetadata` properties to each listed data type,
402/// filterable through `metadata*` conditions on that type's own `/query`
403/// (draft-ietf-jmap-metadata-04 §2, §3.6). This condition carries the six
404/// -04 §3.6 conditions as typed leaf fields (bd JMAP-s4kp.3). Helpers live
405/// in `jmap-metadata-types`, `jmap-metadata-server`, and
406/// `jmap-metadata-client`.
407///
408/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
409/// either escape the filter tree to `serde_json::Value` or fork the
410/// `FilterCondition` type. See `crate-jmap-calendars-types/PLAN.md` for
411/// the hybrid sloppy-value pattern.
412///
413/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
414#[non_exhaustive]
415#[derive(Debug, Clone, Default, Serialize, Deserialize)]
416#[serde(rename_all = "camelCase", deny_unknown_fields)]
417pub struct CalendarEventFilterCondition {
418    /// Event must be in this Calendar (Calendar id).
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub in_calendar: Option<Id>,
421
422    /// Event end (or any recurrence end) must be on or after this
423    /// `LocalDateTime`.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub after: Option<LocalDateTime>,
426
427    /// Event start (or any recurrence start) must be before this
428    /// `LocalDateTime`.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub before: Option<LocalDateTime>,
431
432    /// Full-text search across title, description, location names,
433    /// participant names/emails and other textual properties.
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub text: Option<String>,
436
437    /// Event `title` must contain this text.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub title: Option<String>,
440
441    /// Event `description` must contain this text.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub description: Option<String>,
444
445    /// Matches against the name of a location in `locations`.
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub location: Option<String>,
448
449    /// Matches against name or email of an `"owner"` participant.
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub owner: Option<String>,
452
453    /// Matches against the name or email of any participant
454    /// (draft-ietf-jmap-calendars-33 §5.11.1 `participant`).
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub participant: Option<String>,
457
458    /// Event `uid` must be exactly this string.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub uid: Option<String>,
461
462    /// draft-ietf-jmap-metadata-04 §3.6 `metadataExists`: a value must be
463    /// present at this path in the object's `metadata`. The path is a
464    /// namespace identifier, optionally followed by an RFC 6901 JSON
465    /// Pointer into the namespace value.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub metadata_exists: Option<String>,
468
469    /// -04 §3.6 `privateMetadataExists`: as `metadata_exists`, against the
470    /// authenticated user's `privateMetadata`.
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub private_metadata_exists: Option<String>,
473
474    /// -04 §3.6 `metadataTextContains`: substring match under the
475    /// `i;unicode-casemap` collation on the `metadata` string at a path.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub metadata_text_contains: Option<MetadataTextMatch>,
478
479    /// -04 §3.6 `privateMetadataTextContains`: as
480    /// `metadata_text_contains`, against `privateMetadata`.
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub private_metadata_text_contains: Option<MetadataTextMatch>,
483
484    /// -04 §3.6 `metadataTextEquals`: exact code-point match on the
485    /// `metadata` string at a path.
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub metadata_text_equals: Option<MetadataTextMatch>,
488
489    /// -04 §3.6 `privateMetadataTextEquals`: as `metadata_text_equals`,
490    /// against `privateMetadata`.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub private_metadata_text_equals: Option<MetadataTextMatch>,
493}
494
495impl CalendarEventFilterCondition {
496    /// `true` if any of the six draft-ietf-jmap-metadata-04 §3.6
497    /// `metadata*` / `privateMetadata*` fields is set.
498    pub fn has_metadata_condition(&self) -> bool {
499        self.metadata_exists.is_some()
500            || self.private_metadata_exists.is_some()
501            || self.metadata_text_contains.is_some()
502            || self.private_metadata_text_contains.is_some()
503            || self.metadata_text_equals.is_some()
504            || self.private_metadata_text_equals.is_some()
505    }
506}
507
508/// Concrete filter type for CalendarEvent/query
509/// (draft-ietf-jmap-calendars-33 §5.11).
510///
511/// Alias for `jmap_types::Filter<CalendarEventFilterCondition>`
512/// provided so callers do not have to reach into `jmap-types` directly.
513/// Mirrors the canonical [`jmap_mail_types::EmailFilter`] shape from the
514/// workspace canonical extension-types template.
515///
516/// [`jmap_mail_types::EmailFilter`]: https://docs.rs/jmap-mail-types/latest/jmap_mail_types/query/type.EmailFilter.html
517pub type CalendarEventFilter = jmap_types::Filter<CalendarEventFilterCondition>;
518
519/// The property to sort by in a [`CalendarEventComparator`]
520/// (draft-ietf-jmap-calendars-33 §5.11.2: "The following properties MUST
521/// be supported for sorting: id, uid, start" and "SHOULD be supported for
522/// sorting: created, updated").
523///
524/// Unknown property names are preserved in
525/// [`Other`](CalendarEventComparatorProperty::Other) so they round-trip;
526/// a server cannot sort by one it does not know (RFC 8620 §5.5
527/// `unsupportedSort`). Cross-reference: bd JMAP-wf92.11.
528#[non_exhaustive]
529#[derive(Debug, Clone, PartialEq, Eq, Hash)]
530pub enum CalendarEventComparatorProperty {
531    /// Sort by `id`.
532    Id,
533    /// Sort by `uid`.
534    Uid,
535    /// Sort by `start`, as an absolute point in time rather than the
536    /// lexical `start` value; a floating event's start is read in the
537    /// query's `timeZone` (draft-ietf-jmap-calendars-33 §5.11.2).
538    Start,
539    /// Sort by `created`.
540    Created,
541    /// Sort by `updated`.
542    Updated,
543    /// A property not listed above, kept so it round-trips; a server
544    /// answers it with `unsupportedSort` (RFC 8620 §5.5).
545    Other(String),
546}
547
548impl_string_enum!(CalendarEventComparatorProperty, "a CalendarEvent sort property string",
549    "id"      => Id,
550    "uid"     => Uid,
551    "start"   => Start,
552    "created" => Created,
553    "updated" => Updated,
554);
555
556/// Comparator for `CalendarEvent/query`
557/// (draft-ietf-jmap-calendars-33 §5.11.2).
558///
559/// Build one with [`CalendarEventComparator::new`]; the struct is
560/// `#[non_exhaustive]`.
561///
562/// # Excluded from extras preservation
563///
564/// This type is **out of scope** for the workspace extras-preservation
565/// policy: it carries no flatten-extras `extra` field, and its `property`
566/// field is consumed by backend dispatch to determine sort order. See
567/// [`CalendarEventFilterCondition`] for the rationale and for the two
568/// recommended paths (`draft-ietf-jmap-metadata`, bd JMAP-ua37; or the
569/// pre-IETF sloppy-value escape).
570///
571/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
572#[non_exhaustive]
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574#[serde(rename_all = "camelCase")]
575pub struct CalendarEventComparator {
576    /// The property to sort by.
577    pub property: CalendarEventComparatorProperty,
578
579    /// If `true`, sort ascending; if `false`, sort descending (default `true`).
580    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
581    pub is_ascending: bool,
582
583    /// A collation identifier (RFC 4790) to use when comparing strings.
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub collation: Option<String>,
586}
587
588impl CalendarEventComparator {
589    /// An ascending comparator on `property` with no collation
590    /// (`isAscending` defaults to `true`, RFC 8620 §5.5).
591    pub fn new(property: CalendarEventComparatorProperty) -> Self {
592        Self {
593            property,
594            is_ascending: true,
595            collation: None,
596        }
597    }
598}
599
600fn bool_true() -> bool {
601    true
602}
603
604fn is_true(b: &bool) -> bool {
605    *b
606}