Skip to main content

hey_sdk/services/
calendar_events.rs

1//! Writing calendar events, and reaching one day of a repeating one. HEY's calendar writes
2//! are Rails form posts rather than JSON, so everything here is form-encoded under
3//! `calendar_event[...]`.
4//!
5//! [`CalendarEvents::create`], [`CalendarEvents::update_event`] and
6//! [`CalendarEvents::update_occurrence`] post to the `.json` path, so a current server
7//! answers the written recording while one without the JSON branch redirects and only the
8//! id comes back. Listing events is [`crate::services::calendars::Calendars`]' job.
9
10use std::fmt;
11use std::str::FromStr;
12use std::time::Duration;
13
14use crate::client::Response;
15use crate::error::Error;
16use crate::form::FormResponse;
17use crate::generated::types::Recording;
18use crate::http::Method;
19use crate::observability::OperationInfo;
20use crate::services::write_info;
21use crate::types::Date;
22
23pub use crate::generated::services::calendar_events::*;
24
25const ATTENDEES: &str = "calendar_event[attendance_email_addresses][]";
26const ALL_DAY_REMINDERS: &str = "all_day_reminder_durations[]";
27const TIMED_REMINDERS: &str = "timed_reminder_durations[]";
28
29/// An event's content, which is a replacement rather than a patch.
30///
31/// HEY reads these four out of the submitted parameters and then defaults every one of them
32/// to nothing, so a write that says nothing about a field clears it. There is no way to send
33/// a subset: the fields left empty here are the fields the event loses. An update therefore
34/// has to read the event first and pass back whatever it means to keep — including through
35/// [`CalendarEvents::update_occurrence`], which takes the same parameters.
36///
37/// The title is not in here, and that is not an oversight: HEY leaves the summary alone when
38/// it is not submitted, so it stays a partial field like the rest of a partial write.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct EventContent {
41    /// HEY's `calendar_event[description]` — Trix rich text, so HTML going in.
42    ///
43    /// It does not round-trip. HEY serves the notes back as plain text and omits the key
44    /// entirely when they are blank, so echoing a read back flattens the markup rather than
45    /// preserving it. Keeping formatted notes through an update means holding the HTML the
46    /// caller sent, not the text HEY answered.
47    pub notes: String,
48    /// A plain string. HEY truncates it at 3900 characters rather than refusing it.
49    pub location: String,
50    /// Validated as a URL and capped at 2500 characters, so a malformed one is a 422 rather
51    /// than a silent drop.
52    ///
53    /// It is not the `join_link` on a read. HEY derives that by scanning the notes, the
54    /// location and this for a known meeting service, and it is response-only — there is
55    /// nothing to submit it with.
56    pub link: Option<String>,
57    /// The email attached to the event, HEY's `calendar_event[entry_id]`. A read serves the
58    /// attachment back as `attached_entry`, so a caller keeping one passes that entry's id.
59    pub entry_id: Option<i64>,
60}
61
62/// A countdown's unit, written as the number of seconds HEY's own form submits and the only
63/// form it reads.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum CountdownUnit {
67    /// A day: 86,400 seconds.
68    #[default]
69    Days = 86_400,
70    /// A week: 604,800 seconds.
71    Weeks = 604_800,
72    /// A month as HEY averages one: 2,629,746 seconds.
73    Months = 2_629_746,
74}
75
76/// The countdown HEY runs up to an event.
77///
78/// Like [`EventContent`] it is resend-or-lose-it: HEY reads the pair on every editable write
79/// and a missing value deletes the countdown, so a zero `value` means the event has none
80/// once the write lands. A countdown is a child recording of its own rather than a field on
81/// the event, so it is not on the event's JSON and cannot be read back from one. 1 through 30
82/// is what the web app offers.
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
84pub struct Countdown {
85    /// How many units. Zero is no countdown.
86    pub value: u32,
87    /// What the value counts in.
88    pub unit: CountdownUnit,
89}
90
91/// How often an event repeats. HEY has no day-of-week parameter, so
92/// [`RepeatFrequency::EveryWeekday`] — a hardcoded Monday to Friday — is the only weekday
93/// set that can be expressed.
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum RepeatFrequency {
97    /// Every day.
98    EveryDay,
99    /// Monday to Friday.
100    EveryWeekday,
101    /// The same day every week.
102    EveryWeek,
103    /// The same day every other week.
104    EveryOtherWeek,
105    /// The same date every month.
106    EveryDayOfMonth,
107    /// The same date every year.
108    EveryYear,
109    /// Keeps whatever schedule the event already has instead of naming a new one. It is how
110    /// a write says the recurrence is none of its business.
111    #[default]
112    Custom,
113}
114
115/// When a recurrence stops.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum RepeatUntil {
118    /// The event never stops repeating.
119    Forever,
120    /// It stops after [`Repeat::until_date`].
121    Date,
122    /// It stops after [`Repeat::count`] occurrences.
123    Count,
124}
125
126/// An event's recurrence.
127///
128/// `Repeat::default()` is [`RepeatFrequency::Custom`] with no end, which says "keep the
129/// schedule the event already has". On an occurrence update that makes
130/// `Some(Repeat::default())` and `None` mean the same thing — leave the series' schedule
131/// alone — since [`CalendarEvents::update_occurrence`] sends `Custom` for a `None` one
132/// anyway. Everywhere else the two differ: a `None` writes no recurrence field at all.
133#[derive(Debug, Clone, Default, PartialEq, Eq)]
134pub struct Repeat {
135    /// How often the event repeats.
136    pub frequency: RepeatFrequency,
137    /// When it stops. `None` says nothing about an end.
138    pub until: Option<RepeatUntil>,
139    /// Read only when `until` is [`RepeatUntil::Date`].
140    pub until_date: Option<Date>,
141    /// Read only when `until` is [`RepeatUntil::Count`].
142    pub count: Option<u32>,
143}
144
145/// A new calendar event.
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
147pub struct CreateCalendarEventParams {
148    /// The calendar the event is filed on.
149    pub calendar_id: i64,
150    /// The event's title, HEY's summary.
151    pub title: String,
152    /// `YYYY-MM-DD`.
153    pub starts_at: String,
154    /// `YYYY-MM-DD`. Defaults to `starts_at`.
155    pub ends_at: String,
156    /// Whether the event takes the whole day rather than a clock time.
157    pub all_day: bool,
158    /// `HH:MM`, required unless the event is all-day.
159    pub start_time: String,
160    /// `HH:MM`, required unless the event is all-day.
161    pub end_time: String,
162    /// The IANA names of the zones the clock times are written in — `Europe/Zagreb`,
163    /// `America/New_York`. Leave them empty and the times are read in UTC, which is the zone
164    /// HEY parses an API request in. HEY keeps a zone per end, as its own form offers, so an
165    /// event can start in one and finish in another.
166    pub start_time_zone: String,
167    /// The zone the end is written in, read as `start_time_zone` is.
168    pub end_time_zone: String,
169    /// One zone for both ends.
170    ///
171    /// Deprecated: use `start_time_zone` and `end_time_zone`. It stands in for whichever of
172    /// them is empty, so a caller that only ever wanted one zone keeps working.
173    pub time_zone: String,
174    /// How long before the event each reminder goes out. HEY takes several in one write and
175    /// de-duplicates them, and accepts any duration rather than only the presets the web app
176    /// offers. Only the list matching the event's all-day flag is read, and an empty list is
177    /// an event with no reminders.
178    pub reminders: Vec<Duration>,
179    /// The notes, location, link and attached entry. Nothing exists to lose on a create, so
180    /// the default is simply an event with none of them.
181    pub content: EventContent,
182    /// The guest list. Submitting one makes the caller the organizer and sends invitations.
183    pub attendees: Option<Vec<String>>,
184    /// Circles the event. HEY reads it only when it is submitted, so `None` is "not circled"
185    /// on a create.
186    pub highlighted: Option<bool>,
187    /// Counts down to the event. The default creates none.
188    pub countdown: Countdown,
189    /// Makes the event recurring. `None` is a one-off.
190    pub repeat: Option<Repeat>,
191}
192
193/// A revision of a calendar event. The optional fields are a partial update: only the ones
194/// named are sent, and the rest are left as they are.
195///
196/// The rest are not, and the reason is on HEY's side. It reads the zones, the content
197/// fields, the reminders and the countdown out of the submitted parameters on every write
198/// and defaults each of them to nothing, so an update saying nothing about one clears it. A
199/// caller keeping any of them has to read the event and send them back.
200#[derive(Debug, Clone, Default, PartialEq, Eq)]
201pub struct UpdateCalendarEventParams {
202    /// Moves the event to another calendar, which is how the web app's calendar select
203    /// relocates one.
204    ///
205    /// It has to be a calendar the identity can file on — one it owns or shares, and not a
206    /// subscription. The personal calendar is the one that catches you out: it is in the
207    /// list the identity serves, and filing on it answers 404 all the same.
208    pub calendar_id: Option<i64>,
209    /// A new title.
210    pub title: Option<String>,
211    /// `YYYY-MM-DD`.
212    pub starts_at: Option<String>,
213    /// `YYYY-MM-DD`.
214    pub ends_at: Option<String>,
215    /// Makes the event all-day, or timed.
216    pub all_day: Option<bool>,
217    /// `HH:MM`. Clock times belong to a timed event, so an all-day revision leaves them off
218    /// however they are set here.
219    pub start_time: Option<String>,
220    /// `HH:MM`.
221    pub end_time: Option<String>,
222    /// The zones the clock times are written in, as on a create. Empty strings say the times
223    /// are UTC and clear the zones the event was saved with; `None` leaves them out of the
224    /// request, which HEY also reads as clearing them.
225    pub start_time_zone: Option<String>,
226    /// The zone the end is written in, read as `start_time_zone` is.
227    pub end_time_zone: Option<String>,
228    /// One zone for both ends.
229    ///
230    /// Deprecated: use `start_time_zone` and `end_time_zone`. It stands in for whichever of
231    /// them is `None`.
232    pub time_zone: Option<String>,
233    /// Resend-or-lose-it, like the zones: HEY reads the list on every write and unschedules
234    /// everything when it is empty, so an update that leaves it out removes the reminders the
235    /// event had.
236    pub reminders: Vec<Duration>,
237    /// The notes, location, link and attached entry, and a replacement rather than a patch —
238    /// every field left empty is cleared on the event. Read [`EventContent`] before using it.
239    pub content: EventContent,
240    /// Replaces the guest list. `None` leaves it alone; an empty list removes every guest.
241    pub attendees: Option<Vec<String>>,
242    /// Circles or uncircles the event, HEY's "Circle event". `None` leaves it as it is.
243    pub highlighted: Option<bool>,
244    /// Resend-or-lose-it too: a zero value deletes the event's countdown, because that is
245    /// what HEY does with a write that names no countdown value.
246    pub countdown: Countdown,
247    /// Changes the recurrence. `None` leaves it untouched on a whole-event update — but not
248    /// on an occurrence update; see [`UpdateOccurrenceParams`].
249    pub repeat: Option<Repeat>,
250}
251
252/// A revision of one day of a repeating event. They are a whole-event update's parameters,
253/// read the same way but for `repeat`.
254///
255/// A `None` repeat leaves a whole event's recurrence alone. Here it would end it: HEY reads
256/// an occurrence update that names no frequency as "stop repeating", drops the series'
257/// schedule and cancels every other occurrence. So [`CalendarEvents::update_occurrence`]
258/// sends [`RepeatFrequency::Custom`] for a `None` one, which keeps the schedule the series
259/// already has — the recurrence is not usually the business of an update to one day of it.
260/// Naming a repeat means changing the series' schedule on purpose.
261pub type UpdateOccurrenceParams = UpdateCalendarEventParams;
262
263/// A partial revision of an event: only the fields named are sent, and HEY leaves the
264/// rest as they are.
265#[derive(Debug, Clone, Default, PartialEq)]
266pub struct CalendarEventUpdate {
267    /// A new title.
268    pub title: Option<String>,
269    /// `YYYY-MM-DD`.
270    pub starts_at: Option<String>,
271    /// `YYYY-MM-DD`.
272    pub ends_at: Option<String>,
273    /// Makes the event all-day, or timed.
274    pub all_day: Option<bool>,
275    /// `HH:MM`. Clock times belong to a timed event, so an all-day revision leaves them
276    /// off however they are set here.
277    pub start_time: Option<String>,
278    /// `HH:MM`.
279    pub end_time: Option<String>,
280}
281
282/// One day of a repeating event.
283///
284/// A repeating event's days are served as virtual occurrences: they carry an id of 0, the
285/// series in `parent_id`, and their only handle in `occurrence_id`, which reads
286/// `<event id>_<YYYY-MM-DD>`. So an occurrence is addressed by the series it belongs to
287/// plus the day it falls on, and the writes that take an id cannot touch one.
288#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
289pub struct OccurrenceId {
290    /// The series the day belongs to.
291    pub event_id: i64,
292    /// The day it falls on.
293    pub date: Date,
294}
295
296/// How much of a repeating event a write to one of its occurrences reaches. Either way the
297/// series' earlier days are left alone.
298#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
299pub enum OccurrenceScope {
300    /// The named day alone, which HEY records as an exception in the series' schedule.
301    /// Written `this_event`.
302    #[default]
303    ThisOnly,
304    /// The named day and every one after it. On an update HEY splits the series: it records
305    /// a new repeating event starting at this day, cancels the occurrences from here on, and
306    /// either truncates the old series to the day before or destroys it if this was its
307    /// first day. The answer is still the old occurrence, not the new series, so a caller
308    /// wanting the new event has to read the period again. Written `this_and_following`.
309    ThisAndFollowing,
310}
311
312impl CalendarEvents<'_> {
313    /// Creates an event and answers it as a recording.
314    pub async fn create(&self, params: &CreateCalendarEventParams) -> Result<Recording, Error> {
315        self.write(
316            Method::POST,
317            "/calendar/events.json".to_string(),
318            write_info(
319                "CalendarEvents",
320                "CreateCalendarEvent",
321                "calendar_event",
322                None,
323            ),
324            &create_fields(params),
325        )
326        .await
327    }
328
329    /// Revises an event. HEY reads a calendar write out of the submitted form parameters,
330    /// so this is a partial update only in what it names: fields left out keep their
331    /// value, while the notes, attendees, reminders and countdown a caller says nothing
332    /// about are cleared.
333    ///
334    /// [`CalendarEvents::update_event`] takes those alongside the dates and answers the
335    /// recording; this one names the six fields a revision usually means and nothing else.
336    pub async fn update(&self, event_id: i64, update: &CalendarEventUpdate) -> Result<(), Error> {
337        let fields = update_fields(update);
338        let mut operation = self
339            .client()
340            .request(Method::PATCH, format!("/calendar/events/{event_id}"));
341        operation
342            .info(write_info(
343                "CalendarEvents",
344                "UpdateCalendarEvent",
345                "calendar_event",
346                Some(event_id),
347            ))
348            .form(&borrowed(&fields))
349            .accept("application/json");
350        self.client().send_unit(operation).await
351    }
352
353    /// Revises an event from the whole of `params` and answers it as a recording.
354    pub async fn update_event(
355        &self,
356        event_id: i64,
357        params: &UpdateCalendarEventParams,
358    ) -> Result<Recording, Error> {
359        self.write(
360            Method::PATCH,
361            format!("/calendar/events/{event_id}.json"),
362            write_info(
363                "CalendarEvents",
364                "UpdateCalendarEvent",
365                "calendar_event",
366                Some(event_id),
367            ),
368            &update_event_fields(params),
369        )
370        .await
371    }
372
373    /// Revises one day of a repeating event and answers it as a recording.
374    ///
375    /// A date that is not an occurrence of that series is a 404, as is an event the caller
376    /// cannot edit — the occurrence routes want edit rights on both the day and the series,
377    /// which is stricter than the whole-event update.
378    pub async fn update_occurrence(
379        &self,
380        occurrence: &OccurrenceId,
381        scope: OccurrenceScope,
382        params: &UpdateOccurrenceParams,
383    ) -> Result<Recording, Error> {
384        let mut fields = update_event_fields(params);
385        fields.push((
386            "apply_to_future",
387            checkbox(scope == OccurrenceScope::ThisAndFollowing),
388        ));
389        if params.repeat.is_none() {
390            fields.push(("repeat_frequency", RepeatFrequency::Custom.to_string()));
391        }
392
393        self.write(
394            Method::PATCH,
395            occurrence.path(),
396            write_info(
397                "CalendarEvents",
398                "UpdateCalendarEventOccurrence",
399                "calendar_event",
400                Some(occurrence.event_id),
401            ),
402            &fields,
403        )
404        .await
405    }
406
407    /// Removes one day of a repeating event, or that day and every one after it. The
408    /// generated [`CalendarEvents::delete_occurrence`] takes the same request in its
409    /// parts; this one takes the occurrence id HEY serves and a scope.
410    pub async fn delete_occurrence_scoped(
411        &self,
412        occurrence: &OccurrenceId,
413        scope: OccurrenceScope,
414    ) -> Result<(), Error> {
415        let params = DeleteCalendarEventOccurrenceParams {
416            apply_to_future: Some(apply_to_future(scope)),
417        };
418        self.delete_occurrence(occurrence.event_id, &occurrence.date.to_string(), &params)
419            .await
420    }
421
422    /// Posts a calendar write and reads back what it wrote. HEY parses these out of form
423    /// parameters, and answers the recording on the `.json` path.
424    async fn write(
425        &self,
426        method: Method,
427        path: String,
428        info: OperationInfo,
429        fields: &[(&'static str, String)],
430    ) -> Result<Recording, Error> {
431        let mut operation = self.client().form(method, &path)?;
432        operation.info(info);
433        operation.form(&borrowed(fields));
434        recording_from_form_response(&self.client().execute(operation).await?)
435    }
436}
437
438fn create_fields(params: &CreateCalendarEventParams) -> Vec<(&'static str, String)> {
439    let mut ends_at = params.ends_at.as_str();
440    if ends_at.is_empty() {
441        ends_at = &params.starts_at;
442    }
443
444    let mut fields = vec![
445        (
446            "calendar_event[calendar_id]",
447            params.calendar_id.to_string(),
448        ),
449        ("calendar_event[summary]", params.title.clone()),
450        ("calendar_event[starts_at]", params.starts_at.clone()),
451        ("calendar_event[ends_at]", ends_at.to_string()),
452    ];
453    push_content(&mut fields, &params.content);
454    push_attendees(&mut fields, params.attendees.as_deref());
455    push_highlighted(&mut fields, params.highlighted);
456    push_countdown(&mut fields, params.countdown);
457    push_repeat(&mut fields, params.repeat.as_ref());
458
459    if params.all_day {
460        fields.push(("calendar_event[all_day]", checkbox(true)));
461        push_reminders(&mut fields, ALL_DAY_REMINDERS, &params.reminders);
462    } else {
463        fields.push(("calendar_event[all_day]", checkbox(false)));
464        fields.push((
465            "calendar_event[starts_at_time]",
466            format!("{}:00", params.start_time),
467        ));
468        fields.push((
469            "calendar_event[ends_at_time]",
470            format!("{}:00", params.end_time),
471        ));
472        push_time_zones(
473            &mut fields,
474            time_zone_or(&params.start_time_zone, &params.time_zone),
475            time_zone_or(&params.end_time_zone, &params.time_zone),
476        );
477        push_reminders(&mut fields, TIMED_REMINDERS, &params.reminders);
478    }
479    fields
480}
481
482/// Writes the content fields. All four go out every time, because HEY clears the ones it is
483/// not sent — see [`EventContent`].
484fn push_content(fields: &mut Vec<(&'static str, String)>, content: &EventContent) {
485    fields.push(("calendar_event[description]", content.notes.clone()));
486    fields.push(("calendar_event[location]", content.location.clone()));
487    fields.push((
488        "calendar_event[url]",
489        content.link.clone().unwrap_or_default(),
490    ));
491    // A zero id names no entry, so it goes out blank rather than as "0" — which HEY would
492    // try to look up and refuse.
493    fields.push((
494        "calendar_event[entry_id]",
495        content
496            .entry_id
497            .filter(|entry_id| *entry_id != 0)
498            .map(|entry_id| entry_id.to_string())
499            .unwrap_or_default(),
500    ));
501}
502
503/// Writes the guest list, which HEY replaces wholesale rather than merging. Existing guests
504/// are matched by address and keep the status they answered with; an address HEY cannot parse
505/// is dropped without comment. Submitting the list at all also makes the caller the event's
506/// organizer and sends iCal invitations, and a read's `manage_attendance` says whether the
507/// caller may submit it in the first place.
508///
509/// `None` leaves the roster alone — HEY only touches it when the parameter is present. An
510/// empty list clears it, and needs a blank value on the wire to say so, since a form carries
511/// no empty array. That blank is what HEY's own form posts.
512fn push_attendees(fields: &mut Vec<(&'static str, String)>, attendees: Option<&[String]>) {
513    if let Some(addresses) = attendees {
514        if addresses.is_empty() {
515            fields.push((ATTENDEES, String::new()));
516        } else {
517            for address in addresses {
518                fields.push((ATTENDEES, address.clone()));
519            }
520        }
521    }
522}
523
524/// Circles or uncircles the event. HEY reads the flag only when it is submitted, so `None`
525/// leaves the circle as it was.
526///
527/// The empty `highlight_id` is what makes "off" mean off. HEY builds a new highlight when the
528/// flag is off and that key is absent — turning the circle on — and destroys the existing one
529/// when the key is there and empty. It is never served on a read, so a caller could not
530/// construct the right form from one; sending it unconditionally is how this stays a bool.
531fn push_highlighted(fields: &mut Vec<(&'static str, String)>, highlighted: Option<bool>) {
532    if let Some(highlighted) = highlighted {
533        fields.push(("calendar_event[highlighted]", checkbox(highlighted)));
534        fields.push(("calendar_event[highlight_id]", String::new()));
535    }
536}
537
538fn checkbox(value: bool) -> String {
539    if value {
540        "1".to_string()
541    } else {
542        "0".to_string()
543    }
544}
545
546/// Writes the countdown pair. A zero value sends no value at all, which is how HEY is told to
547/// delete the countdown — there is no "leave it alone".
548fn push_countdown(fields: &mut Vec<(&'static str, String)>, countdown: Countdown) {
549    if countdown.value > 0 {
550        fields.push((
551            "countdown_interval_duration_value",
552            countdown.value.to_string(),
553        ));
554        fields.push((
555            "countdown_interval_duration_unit",
556            countdown.unit.seconds().to_string(),
557        ));
558    }
559}
560
561/// Writes the recurrence. Nothing is written for a `None` one, which on a whole-event update
562/// leaves the recurrence untouched — unlike the occurrence update, where HEY reads the same
563/// silence as "stop repeating".
564fn push_repeat(fields: &mut Vec<(&'static str, String)>, repeat: Option<&Repeat>) {
565    if let Some(repeat) = repeat {
566        fields.push(("repeat_frequency", repeat.frequency.to_string()));
567        if let Some(until) = repeat.until {
568            fields.push((
569                "calendar_recurrence_schedule[recurs_until_type]",
570                until.to_string(),
571            ));
572        }
573        if repeat.until == Some(RepeatUntil::Date) {
574            fields.push((
575                "calendar_recurrence_schedule[recurs_until_date]",
576                repeat
577                    .until_date
578                    .map(|date| date.to_string())
579                    .unwrap_or_default(),
580            ));
581        }
582        if repeat.until == Some(RepeatUntil::Count) {
583            fields.push((
584                "calendar_recurrence_schedule[recurs_count]",
585                repeat.count.unwrap_or_default().to_string(),
586            ));
587        }
588    }
589}
590
591/// Writes the zones a timed event's clock times are written in, and the flag that makes HEY
592/// honour them. The flag is not decoration: without it both names are dropped and the times
593/// are read in UTC, so 08:00 sent from Zagreb is stored as 08:00Z.
594///
595/// Naming no zone is a complete answer rather than an omission — convert to UTC and say
596/// nothing. An all-day event does not come through here at all, since a date has no zone.
597fn push_time_zones(fields: &mut Vec<(&'static str, String)>, start: &str, end: &str) {
598    if start.is_empty() && end.is_empty() {
599        fields.push(("calendar_event[set_time_zone]", checkbox(false)));
600    } else {
601        let mut starts_in = start;
602        let mut ends_in = end;
603        if starts_in.is_empty() {
604            starts_in = ends_in;
605        }
606        if ends_in.is_empty() {
607            ends_in = starts_in;
608        }
609        fields.push(("calendar_event[set_time_zone]", checkbox(true)));
610        fields.push((
611            "calendar_event[starts_at_time_zone_name]",
612            starts_in.to_string(),
613        ));
614        fields.push((
615            "calendar_event[ends_at_time_zone_name]",
616            ends_in.to_string(),
617        ));
618    }
619}
620
621/// Lets the deprecated single time zone stand in for an end the caller did not name.
622fn time_zone_or<'a>(zone: &'a str, both: &'a str) -> &'a str {
623    if zone.is_empty() { both } else { zone }
624}
625
626fn push_reminders(
627    fields: &mut Vec<(&'static str, String)>,
628    key: &'static str,
629    reminders: &[Duration],
630) {
631    for reminder in reminders {
632        fields.push((key, reminder.as_secs().to_string()));
633    }
634}
635
636fn update_fields(update: &CalendarEventUpdate) -> Vec<(&'static str, String)> {
637    let mut fields = Vec::new();
638    if let Some(title) = &update.title {
639        fields.push(("calendar_event[summary]", title.clone()));
640    }
641    if let Some(starts_at) = &update.starts_at {
642        fields.push(("calendar_event[starts_at]", starts_at.clone()));
643    }
644    if let Some(ends_at) = &update.ends_at {
645        fields.push(("calendar_event[ends_at]", ends_at.clone()));
646    }
647    if let Some(all_day) = update.all_day {
648        fields.push(("calendar_event[all_day]", checkbox(all_day)));
649    }
650    if update.all_day != Some(true) {
651        if let Some(start_time) = &update.start_time {
652            fields.push(("calendar_event[starts_at_time]", format!("{start_time}:00")));
653        }
654        if let Some(end_time) = &update.end_time {
655            fields.push(("calendar_event[ends_at_time]", format!("{end_time}:00")));
656        }
657    }
658    fields
659}
660
661/// Form-encodes a whole-event update. The occurrence update takes the same fields, so it
662/// builds its body from here and adds the two parameters that only mean something to a
663/// series.
664fn update_event_fields(params: &UpdateCalendarEventParams) -> Vec<(&'static str, String)> {
665    let mut fields = Vec::new();
666    if let Some(title) = &params.title {
667        fields.push(("calendar_event[summary]", title.clone()));
668    }
669    if let Some(starts_at) = &params.starts_at {
670        fields.push(("calendar_event[starts_at]", starts_at.clone()));
671    }
672    if let Some(ends_at) = &params.ends_at {
673        fields.push(("calendar_event[ends_at]", ends_at.clone()));
674    }
675    if let Some(all_day) = params.all_day {
676        fields.push(("calendar_event[all_day]", checkbox(all_day)));
677    }
678    if params.all_day != Some(true) {
679        if let Some(start_time) = &params.start_time {
680            fields.push(("calendar_event[starts_at_time]", format!("{start_time}:00")));
681        }
682        if let Some(end_time) = &params.end_time {
683            fields.push(("calendar_event[ends_at_time]", format!("{end_time}:00")));
684        }
685    }
686    if let Some(calendar_id) = params.calendar_id {
687        fields.push(("calendar_event[calendar_id]", calendar_id.to_string()));
688    }
689    push_content(&mut fields, &params.content);
690    push_attendees(&mut fields, params.attendees.as_deref());
691    push_highlighted(&mut fields, params.highlighted);
692    push_countdown(&mut fields, params.countdown);
693    push_repeat(&mut fields, params.repeat.as_ref());
694
695    let starts_in = params
696        .start_time_zone
697        .as_ref()
698        .or(params.time_zone.as_ref());
699    let ends_in = params.end_time_zone.as_ref().or(params.time_zone.as_ref());
700    if starts_in.is_some() || ends_in.is_some() {
701        push_time_zones(
702            &mut fields,
703            starts_in.map(String::as_str).unwrap_or_default(),
704            ends_in.map(String::as_str).unwrap_or_default(),
705        );
706    }
707
708    // HEY reads the list matching the event's all-day flag as it stands after the write. An
709    // update that leaves the flag alone cannot know which that is, so it sends both lists:
710    // the one HEY does not read is ignored, and the one it does keeps the reminders scheduled.
711    let reminders_keys: &[&'static str] = match params.all_day {
712        Some(true) => &[ALL_DAY_REMINDERS],
713        Some(false) => &[TIMED_REMINDERS],
714        None => &[ALL_DAY_REMINDERS, TIMED_REMINDERS],
715    };
716    for key in reminders_keys {
717        push_reminders(&mut fields, key, &params.reminders);
718    }
719    fields
720}
721
722fn borrowed<'a>(fields: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
723    fields
724        .iter()
725        .map(|(name, value)| (*name, value.as_str()))
726        .collect()
727}
728
729/// The recording a JSON write answers with, falling back to the redirect an older server
730/// sends, whose URL still carries the recording's id.
731fn recording_from_form_response(answered: &Response) -> Result<Recording, Error> {
732    let written = FormResponse::new(answered);
733    if written.body.is_empty() {
734        Ok(Recording {
735            id: written.extract_id()?,
736            ..Recording::default()
737        })
738    } else {
739        Ok(serde_json::from_str(&written.body)?)
740    }
741}
742
743/// Whether the write reaches the following days too.
744///
745/// The scope goes out either way, as Go's own delete does. HEY reads a missing
746/// `apply_to_future` as false, so leaving it off would mean the same thing — but saying it
747/// is what makes the request read as the caller's own choice rather than a default.
748fn apply_to_future(scope: OccurrenceScope) -> bool {
749    scope == OccurrenceScope::ThisAndFollowing
750}
751
752impl CountdownUnit {
753    /// The unit as HEY's form submits it: its length in seconds.
754    pub fn seconds(self) -> u32 {
755        self as u32
756    }
757}
758
759impl RepeatFrequency {
760    /// The frequency as HEY's `repeat_frequency` parameter names it.
761    pub fn as_str(&self) -> &'static str {
762        match self {
763            RepeatFrequency::EveryDay => "every_day",
764            RepeatFrequency::EveryWeekday => "every_weekday",
765            RepeatFrequency::EveryWeek => "every_week",
766            RepeatFrequency::EveryOtherWeek => "every_other_week",
767            RepeatFrequency::EveryDayOfMonth => "every_day_of_month",
768            RepeatFrequency::EveryYear => "every_year",
769            RepeatFrequency::Custom => "custom",
770        }
771    }
772}
773
774impl fmt::Display for RepeatFrequency {
775    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
776        f.write_str(self.as_str())
777    }
778}
779
780impl RepeatUntil {
781    /// The end as HEY's `recurs_until_type` parameter names it.
782    pub fn as_str(&self) -> &'static str {
783        match self {
784            RepeatUntil::Forever => "forever",
785            RepeatUntil::Date => "date",
786            RepeatUntil::Count => "count",
787        }
788    }
789}
790
791impl fmt::Display for RepeatUntil {
792    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
793        f.write_str(self.as_str())
794    }
795}
796
797impl OccurrenceId {
798    fn path(&self) -> String {
799        format!(
800            "/calendar/events/{}/occurrences/{}.json",
801            self.event_id, self.date
802        )
803    }
804}
805
806impl FromStr for OccurrenceId {
807    type Err = Error;
808
809    fn from_str(source: &str) -> Result<OccurrenceId, Error> {
810        let (event, day) = source.split_once('_').ok_or_else(|| {
811            Error::usage(format!(
812                "occurrence id {source:?} is not <event id>_<YYYY-MM-DD>"
813            ))
814        })?;
815        let event_id = match event.parse() {
816            Ok(event_id) if event_id > 0 => event_id,
817            _ => {
818                return Err(Error::usage(format!(
819                    "occurrence id {source:?} names no event"
820                )));
821            }
822        };
823        let date = day.parse().map_err(|error| {
824            Error::usage(format!("occurrence id {source:?} names no date: {error}"))
825        })?;
826        Ok(OccurrenceId { event_id, date })
827    }
828}
829
830impl fmt::Display for OccurrenceId {
831    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
832        write!(f, "{}_{}", self.event_id, self.date)
833    }
834}
835
836impl FromStr for OccurrenceScope {
837    type Err = Error;
838
839    fn from_str(source: &str) -> Result<OccurrenceScope, Error> {
840        match source {
841            "this_event" => Ok(OccurrenceScope::ThisOnly),
842            "this_and_following" => Ok(OccurrenceScope::ThisAndFollowing),
843            _ => Err(Error::usage(format!(
844                "occurrence scope {source:?} is neither \"this_event\" nor \"this_and_following\""
845            ))),
846        }
847    }
848}