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}