jmap-calendars-types 0.4.2

JMAP Calendars extension data types (draft-ietf-jmap-calendars-*)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
//! CalendarEvent object and CalendarEventFilterCondition.
//!
//! Normative references:
//!   - draft-ietf-jmap-calendars-33 §5 — JMAP-specific additions
//!   - RFC 8984 §2.1, §4, §5.1 — JSCalendar Event properties

use std::collections::BTreeMap;

use jmap_jscalendar_types::LocalDateTime;
use jmap_metadata_types::MetadataTextMatch;
use jmap_types::{Id, PatchObject, UTCDate, impl_string_enum};
use serde::{Deserialize, Serialize};

/// A JMAP CalendarEvent object (draft-ietf-jmap-calendars-33 §5; RFC 8984 §5.1).
///
/// `CalendarEvent` is a JSCalendar Event with additional JMAP-specific properties.
/// Every field is `Option<T>` because RFC 8620 §5.1 allows partial responses
/// (clients may request only specific fields via the `properties` argument).
/// A field absent from the server response MUST NOT fail deserialization.
///
/// ## Complex sub-objects
///
/// JSCalendar sub-objects with their own rich structure (`locations`,
/// `participants`, `alerts`, `recurrenceRules`, etc.) are represented as
/// `Option<serde_json::Value>`.  This avoids an exhaustive typed representation
/// while preserving full round-trip fidelity.  Consumers that need to
/// manipulate these fields should parse the `Value` using the concrete types
/// re-exported at this crate's root (e.g. [`crate::Location`], [`crate::Participant`],
/// [`crate::Alert`]) — defined in the `jmap-jscalendar-types` crate.
///
/// ## PatchObject envelopes
///
/// `recurrenceOverrides` and `localizations` are typed as
/// `Option<BTreeMap<String, jmap_types::PatchObject>>`: the outer envelope
/// is JMAP-level (RFC 8620 §5.3 PatchObject) while the inner leaves stay
/// `serde_json::Value` to preserve per-leaf JSCalendar flexibility.  Wire
/// format is byte-identical to the prior opaque `Option<Value>` shape via
/// `#[serde(transparent)]` on `PatchObject`.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarEvent {
    // ── JMAP-specific properties (draft §5) ──────────────────────────────────
    /// Server-assigned immutable identifier.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<Id>,

    /// `Id` of the base recurring event when `id` is a synthetic expanded
    /// instance id.  `null` for non-synthetic events.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base_event_id: Option<Id>,

    /// Set of Calendar ids this event belongs to (values always `true`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub calendar_ids: Option<BTreeMap<Id, bool>>,

    /// If `true`, this is a draft; the server will not send scheduling messages.
    /// Always serialized; absent deserializes, and `None` serializes, as the
    /// spec default `false`.
    #[serde(default = "some_false", serialize_with = "serialize_false_if_none")]
    pub is_draft: Option<bool>,

    /// Server-set; `true` if this account is the authoritative source.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_origin: Option<bool>,

    /// Computed UTC start time (not returned by default; must be requested).
    ///
    /// Uses the [`UTCDate`] newtype to make the wire-format constraint
    /// (RFC 8620 §1.4: 20-character UTCDateTime string) explicit at the
    /// type level. JSON wire format is unchanged because `UTCDate` is a
    /// transparent newtype around `String`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub utc_start: Option<UTCDate>,

    /// Computed UTC end time (not returned by default; must be requested).
    ///
    /// See [`utc_start`](Self::utc_start) for the typing rationale.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub utc_end: Option<UTCDate>,

    /// If `true`, use per-calendar default alerts instead of `alerts` (default `false`).
    /// Always serialized; absent deserializes, and `None` serializes, as the
    /// spec default `false`.
    #[serde(default = "some_false", serialize_with = "serialize_false_if_none")]
    pub use_default_alerts: Option<bool>,

    /// If `true`, anyone may add themselves to the event as a participant
    /// (draft §5.1.1).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub may_invite_self: Option<bool>,

    /// If `true`, any current participant may add new participants
    /// (draft §5.1.2).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub may_invite_others: Option<bool>,

    /// If `true`, only users with full permission to modify the event see
    /// the complete set of participants; others see only the owners and
    /// themselves (draft §5.1.3, §5.7).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hide_attendees: Option<bool>,

    /// JMAP Blob id for the iCalendar representation of this event.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub blob_id: Option<Id>,

    /// iCalendar data that converted to this event, as an `ICalComponent`
    /// object (draft-ietf-calext-jscalendar-icalendar-28 §5.1.1).
    ///
    /// Only returned when explicitly requested via `properties:["iCalendar"]`
    /// (draft-ietf-jmap-calendars-33 §5.7: a null `properties` returns all
    /// properties except `iCalendar`). Kept as a `serde_json::Value` per the
    /// workspace Sloppy-Value policy: it carries jCal arrays (RFC 7265).
    ///
    /// Wire name is `"iCalendar"`; a manual rename keeps it explicit
    /// rather than relying on `rename_all` of a `i_calendar` field name.
    #[serde(rename = "iCalendar", skip_serializing_if = "Option::is_none")]
    pub icalendar: Option<serde_json::Value>,

    // ── JSCalendar @type ─────────────────────────────────────────────────────
    /// JSCalendar object type; always `"Event"` when present.
    /// Included for round-trip fidelity; omitted on create.
    #[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
    pub at_type: Option<String>,

    // ── JSCalendar metadata properties (RFC 8984 §4.1) ───────────────────────
    /// JSCalendar version this event conforms to, e.g. `"2.0"`
    /// (draft-ietf-calext-jscalendarbis-22 §3.1.2). The server sets it when
    /// a create omits it (draft-ietf-jmap-calendars-33 §5.9).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,

    /// Globally unique identifier for this event.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uid: Option<String>,

    /// Map of related event UIDs → `Relation` objects.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub related_to: Option<serde_json::Value>,

    /// Product identifier (who created the event data).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prod_id: Option<String>,

    /// UTC date-time when this event was created.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub created: Option<UTCDate>,

    /// UTC date-time of last modification (mandatory in full responses).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub updated: Option<UTCDate>,

    /// iCalendar sequence number (default `0`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sequence: Option<u64>,

    // ── JSCalendar what/where properties (RFC 8984 §4.2) ─────────────────────
    /// Human-readable summary/title.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,

    /// Longer-form description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Content type of `description` (default `"text/plain"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description_content_type: Option<String>,

    /// If `true`, this is an all-day event (default `false`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub show_without_time: Option<bool>,

    /// Map of location id → `Location` object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub locations: Option<serde_json::Value>,

    /// Map of virtual location id → `VirtualLocation` object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub virtual_locations: Option<serde_json::Value>,

    /// Map of link id → `Link` object (attachments, images, etc.).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub links: Option<serde_json::Value>,

    /// BCP 47 language tag for this event's locale.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub locale: Option<String>,

    /// Map of keyword strings → `true` (RFC 8984 §4.2.10).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keywords: Option<BTreeMap<String, bool>>,

    /// Map of category URI strings → `true` (RFC 8984 §4.2.11).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub categories: Option<BTreeMap<String, bool>>,

    /// CSS color name or `#rrggbb` for this event.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub color: Option<String>,

    // ── JSCalendar recurrence properties (RFC 8984 §4.3) ─────────────────────
    /// `LocalDateTime` string identifying which recurrence instance this
    /// object overrides.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recurrence_id: Option<String>,

    /// Time zone for `recurrence_id` (required when `recurrence_id` is set).
    ///
    /// This crate does not check the value against the IANA Time Zone
    /// Database; validating the zone name is the backend's job.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recurrence_id_time_zone: Option<String>,

    /// Array of `RecurrenceRule` objects.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recurrence_rules: Option<serde_json::Value>,

    /// Array of `RecurrenceRule` objects to exclude from the series.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub excluded_recurrence_rules: Option<serde_json::Value>,

    /// Map of `LocalDateTime` string → [`PatchObject`] for per-occurrence
    /// overrides (RFC 8984 §4.3.2).
    ///
    /// Outer envelope is typed at the JMAP level: keys are LocalDateTime
    /// strings and values are JMAP `PatchObject` (RFC 8620 §5.3) wire
    /// objects.  The inner `PatchObject` leaves remain `serde_json::Value`
    /// so per-occurrence overrides retain full JSCalendar shape flexibility
    /// (Sloppy-Value at the leaf, typed at the envelope).  Wire format is
    /// byte-identical via `#[serde(transparent)]`.
    ///
    /// **Maintainer note: do NOT "simplify" this shape.** Two refactors
    /// commonly surface; both are wrong:
    ///
    /// 1. **Revert to `Option<serde_json::Value>`.** Loses the type-level
    ///    signal that the outer envelope follows JMAP `PatchObject`
    ///    (RFC 8620 §5.3) semantics. JMAP and JSCalendar are
    ///    co-evolving specs with separate ownership of the envelope vs
    ///    leaf semantics, and the type-level split mirrors that.
    /// 2. **Type the inner leaves with full JSCalendar field-by-field
    ///    structs.** Would require an exhaustive JSCalendar leaf type
    ///    that must track both specs and break partial deserialize. The
    ///    workspace Sloppy-Value policy (workspace AGENTS.md) is
    ///    designed exactly to avoid this trap.
    ///
    /// Wire-format byte-identity is pinned by
    /// `calendar_event_recurrence_overrides_wire_format_is_object_map`
    /// in `tests/types_test.rs`. Design defense: bd:JMAP-1rwf.6. See
    /// also `localizations` below — same shape, same rationale.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recurrence_overrides: Option<BTreeMap<String, PatchObject>>,

    /// If `true`, this occurrence is excluded from the series.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub excluded: Option<bool>,

    // ── JSCalendar scheduling/sharing properties (RFC 8984 §4.4) ─────────────
    /// Priority of this event (0–9; 0 = undefined).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub priority: Option<u8>,

    /// Free/busy status: `"free"` or `"busy"` (default `"busy"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub free_busy_status: Option<String>,

    /// Privacy: `"public"`, `"private"`, or `"secret"` (default `"public"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub privacy: Option<String>,

    /// Map of scheduling method → reply-to URI (RFC 8984 §4.4.4).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_to: Option<BTreeMap<String, String>>,

    /// Addr-spec of the person who sent this event data.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sent_by: Option<String>,

    /// The organizer's calendarAddress (wire: `organizerCalendarAddress`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub organizer_calendar_address: Option<String>,

    /// Map of participant id → `Participant` object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub participants: Option<serde_json::Value>,

    /// iTIP request status string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub request_status: Option<String>,

    // ── JSCalendar alert properties (RFC 8984 §4.5) ───────────────────────────
    /// Map of alert id → `Alert` object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub alerts: Option<serde_json::Value>,

    // ── JSCalendar multilingual properties (RFC 8984 §4.6) ───────────────────
    /// Map of language tag → [`PatchObject`] for per-language overrides
    /// (RFC 8984 §4.6).
    ///
    /// Outer envelope is typed at the JMAP level: keys are BCP 47 language
    /// tags and values are JMAP `PatchObject` (RFC 8620 §5.3) wire objects.
    /// The inner `PatchObject` leaves remain `serde_json::Value` so per-
    /// language overrides retain full JSCalendar shape flexibility (Sloppy-
    /// Value at the leaf, typed at the envelope).  Wire format is byte-
    /// identical via `#[serde(transparent)]`.
    ///
    /// **Maintainer note: do NOT "simplify" this shape** — see the same
    /// note on [`Self::recurrence_overrides`] above for the full rationale.
    /// The two fields share the hybrid Sloppy-Value-at-the-leaf,
    /// typed-at-the-envelope pattern (JMAP §5.3 envelope ownership vs.
    /// JSCalendar §4.6 leaf ownership). Design defense: bd:JMAP-1rwf.6.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub localizations: Option<BTreeMap<String, PatchObject>>,

    // ── JSCalendar time zone properties (RFC 8984 §4.7) ───────────────────────
    /// IANA time zone id for the event's start/end times.
    ///
    /// This crate does not check the value against the IANA Time Zone
    /// Database; validating the zone name is the backend's job.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_zone: Option<String>,

    /// Custom time zone definitions; opaque passthrough (RFC 8984 §4.7.2).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_zones: Option<serde_json::Value>,

    // ── JSCalendar Event-specific properties (RFC 8984 §5.1) ─────────────────
    /// Start date-time as a `LocalDateTime` string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start: Option<String>,

    /// Duration as an ISO 8601 duration string (e.g. `"PT1H"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duration: Option<String>,

    /// Event status: `"confirmed"`, `"cancelled"`, or `"tentative"`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,

    // ── draft-ietf-jmap-metadata-04 §2 ────────────────────────────────────────
    /// Shared metadata, keyed by namespace identifier
    /// (draft-ietf-jmap-metadata-04 §2). Each value is a JSON object (§2.1).
    /// `None` when absent, which -04 §2 treats as `{}`; in an update a
    /// `null` value "is equivalent to {}" (-04 §3.2) and also decodes as
    /// `None`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,

    /// The authenticated user's own metadata, keyed by namespace
    /// identifier (draft-ietf-jmap-metadata-04 §2). Same shape and `None`
    /// handling as `metadata`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub private_metadata: Option<serde_json::Map<String, serde_json::Value>>,

    /// Catch-all for vendor / site / private extension fields not covered
    /// by the typed fields above. Preserves unknown fields across
    /// deserialize/serialize round-trip per workspace extras-preservation
    /// policy (see workspace AGENTS.md).
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// Serialize an unset `Boolean (default: false)` property as `false`.
///
/// draft-ietf-jmap-calendars-33 §5.7: the "isDraft" and "useDefaultAlerts"
/// properties MUST be included on each object returned by `CalendarEvent/get`.
fn serialize_false_if_none<S: serde::Serializer>(
    value: &Option<bool>,
    serializer: S,
) -> Result<S::Ok, S::Error> {
    serializer.serialize_bool(value.unwrap_or(false))
}

/// Deserialize default for the same properties, so an absent value round-trips
/// to the `Some(false)` it serializes as.
fn some_false() -> Option<bool> {
    Some(false)
}

/// Filter condition for `CalendarEvent/query`
/// (draft-ietf-jmap-calendars-33 §5.11.1).
///
/// All fields are optional; a condition with no fields set matches every
/// CalendarEvent.
///
/// # Excluded from extras preservation
///
/// This type is **out of scope** for the workspace extras-preservation
/// policy: it carries no flatten-extras `extra` field. Filter clauses the
/// server does not understand are a query-correctness hazard — silently
/// preserving an unrecognised clause and round-tripping it back to the
/// client can return the wrong set of records with no error signal.
///
/// ## What to do instead
///
/// **IETF-track path.** Vendors who need both capability-level declaration
/// and filterability for custom fields should use
/// `draft-ietf-jmap-metadata` (capability URI
/// `urn:ietf:params:jmap:metadata`), which adds namespace-keyed
/// `metadata` / `privateMetadata` properties to each listed data type,
/// filterable through `metadata*` conditions on that type's own `/query`
/// (draft-ietf-jmap-metadata-04 §2, §3.6). This condition carries the six
/// -04 §3.6 conditions as typed leaf fields (bd JMAP-s4kp.3). Helpers live
/// in `jmap-metadata-types`, `jmap-metadata-server`, and
/// `jmap-metadata-client`.
///
/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
/// either escape the filter tree to `serde_json::Value` or fork the
/// `FilterCondition` type. See `crate-jmap-calendars-types/PLAN.md` for
/// the hybrid sloppy-value pattern.
///
/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CalendarEventFilterCondition {
    /// Event must be in this Calendar (Calendar id).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub in_calendar: Option<Id>,

    /// Event end (or any recurrence end) must be on or after this
    /// `LocalDateTime`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub after: Option<LocalDateTime>,

    /// Event start (or any recurrence start) must be before this
    /// `LocalDateTime`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub before: Option<LocalDateTime>,

    /// Full-text search across title, description, location names,
    /// participant names/emails and other textual properties.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,

    /// Event `title` must contain this text.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,

    /// Event `description` must contain this text.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Matches against the name of a location in `locations`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub location: Option<String>,

    /// Matches against name or email of an `"owner"` participant.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,

    /// Matches against the name or email of any participant
    /// (draft-ietf-jmap-calendars-33 §5.11.1 `participant`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub participant: Option<String>,

    /// Event `uid` must be exactly this string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uid: Option<String>,

    /// draft-ietf-jmap-metadata-04 §3.6 `metadataExists`: a value must be
    /// present at this path in the object's `metadata`. The path is a
    /// namespace identifier, optionally followed by an RFC 6901 JSON
    /// Pointer into the namespace value.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata_exists: Option<String>,

    /// -04 §3.6 `privateMetadataExists`: as `metadata_exists`, against the
    /// authenticated user's `privateMetadata`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub private_metadata_exists: Option<String>,

    /// -04 §3.6 `metadataTextContains`: substring match under the
    /// `i;unicode-casemap` collation on the `metadata` string at a path.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata_text_contains: Option<MetadataTextMatch>,

    /// -04 §3.6 `privateMetadataTextContains`: as
    /// `metadata_text_contains`, against `privateMetadata`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub private_metadata_text_contains: Option<MetadataTextMatch>,

    /// -04 §3.6 `metadataTextEquals`: exact code-point match on the
    /// `metadata` string at a path.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata_text_equals: Option<MetadataTextMatch>,

    /// -04 §3.6 `privateMetadataTextEquals`: as `metadata_text_equals`,
    /// against `privateMetadata`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub private_metadata_text_equals: Option<MetadataTextMatch>,
}

impl CalendarEventFilterCondition {
    /// `true` if any of the six draft-ietf-jmap-metadata-04 §3.6
    /// `metadata*` / `privateMetadata*` fields is set.
    pub fn has_metadata_condition(&self) -> bool {
        self.metadata_exists.is_some()
            || self.private_metadata_exists.is_some()
            || self.metadata_text_contains.is_some()
            || self.private_metadata_text_contains.is_some()
            || self.metadata_text_equals.is_some()
            || self.private_metadata_text_equals.is_some()
    }
}

/// Concrete filter type for CalendarEvent/query
/// (draft-ietf-jmap-calendars-33 §5.11).
///
/// Alias for `jmap_types::Filter<CalendarEventFilterCondition>`
/// provided so callers do not have to reach into `jmap-types` directly.
/// Mirrors the canonical [`jmap_mail_types::EmailFilter`] shape from the
/// workspace canonical extension-types template.
///
/// [`jmap_mail_types::EmailFilter`]: https://docs.rs/jmap-mail-types/latest/jmap_mail_types/query/type.EmailFilter.html
pub type CalendarEventFilter = jmap_types::Filter<CalendarEventFilterCondition>;

/// The property to sort by in a [`CalendarEventComparator`]
/// (draft-ietf-jmap-calendars-33 §5.11.2: "The following properties MUST
/// be supported for sorting: id, uid, start" and "SHOULD be supported for
/// sorting: created, updated").
///
/// Unknown property names are preserved in
/// [`Other`](CalendarEventComparatorProperty::Other) so they round-trip;
/// a server cannot sort by one it does not know (RFC 8620 §5.5
/// `unsupportedSort`). Cross-reference: bd JMAP-wf92.11.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CalendarEventComparatorProperty {
    /// Sort by `id`.
    Id,
    /// Sort by `uid`.
    Uid,
    /// Sort by `start`, as an absolute point in time rather than the
    /// lexical `start` value; a floating event's start is read in the
    /// query's `timeZone` (draft-ietf-jmap-calendars-33 §5.11.2).
    Start,
    /// Sort by `created`.
    Created,
    /// Sort by `updated`.
    Updated,
    /// A property not listed above, kept so it round-trips; a server
    /// answers it with `unsupportedSort` (RFC 8620 §5.5).
    Other(String),
}

impl_string_enum!(CalendarEventComparatorProperty, "a CalendarEvent sort property string",
    "id"      => Id,
    "uid"     => Uid,
    "start"   => Start,
    "created" => Created,
    "updated" => Updated,
);

/// Comparator for `CalendarEvent/query`
/// (draft-ietf-jmap-calendars-33 §5.11.2).
///
/// Build one with [`CalendarEventComparator::new`]; the struct is
/// `#[non_exhaustive]`.
///
/// # Excluded from extras preservation
///
/// This type is **out of scope** for the workspace extras-preservation
/// policy: it carries no flatten-extras `extra` field, and its `property`
/// field is consumed by backend dispatch to determine sort order. See
/// [`CalendarEventFilterCondition`] for the rationale and for the two
/// recommended paths (`draft-ietf-jmap-metadata`, bd JMAP-ua37; or the
/// pre-IETF sloppy-value escape).
///
/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarEventComparator {
    /// The property to sort by.
    pub property: CalendarEventComparatorProperty,

    /// If `true`, sort ascending; if `false`, sort descending (default `true`).
    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
    pub is_ascending: bool,

    /// A collation identifier (RFC 4790) to use when comparing strings.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub collation: Option<String>,
}

impl CalendarEventComparator {
    /// An ascending comparator on `property` with no collation
    /// (`isAscending` defaults to `true`, RFC 8620 §5.5).
    pub fn new(property: CalendarEventComparatorProperty) -> Self {
        Self {
            property,
            is_ascending: true,
            collation: None,
        }
    }
}

fn bool_true() -> bool {
    true
}

fn is_true(b: &bool) -> bool {
    *b
}