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(), ¶ms)
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 = ¶ms.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, ¶ms.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, ¶ms.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(¶ms.start_time_zone, ¶ms.time_zone),
475 time_zone_or(¶ms.end_time_zone, ¶ms.time_zone),
476 );
477 push_reminders(&mut fields, TIMED_REMINDERS, ¶ms.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) = ¶ms.title {
667 fields.push(("calendar_event[summary]", title.clone()));
668 }
669 if let Some(starts_at) = ¶ms.starts_at {
670 fields.push(("calendar_event[starts_at]", starts_at.clone()));
671 }
672 if let Some(ends_at) = ¶ms.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) = ¶ms.start_time {
680 fields.push(("calendar_event[starts_at_time]", format!("{start_time}:00")));
681 }
682 if let Some(end_time) = ¶ms.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, ¶ms.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, ¶ms.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}