caldav_utils/availability/
mod.rs

1use anyhow::anyhow;
2use chrono::TimeZone;
3use icalendar::Component;
4use rrule::{RRule, Tz, Unvalidated};
5use serde_with::DurationSeconds;
6use tracing::info;
7
8use crate::caldav::{calendar::Calendar, event::Event};
9use crate::error::{CaldavError, CaldavResult};
10
11#[serde_with::serde_as]
12#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
13pub struct AvailabilityRequest {
14    pub start: chrono::DateTime<chrono::Utc>,
15    pub end: chrono::DateTime<chrono::Utc>,
16}
17
18/// The open time slots for a given time range
19/// on the calendar that was requested.
20/// The slots are determined by finding the gaps between
21/// availabile blocks and booked blocks.
22#[serde_with::serde_as]
23#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
24pub struct AvailabilityResponse {
25    pub start: chrono::DateTime<chrono::Utc>,
26    pub end: chrono::DateTime<chrono::Utc>,
27    /// the amount of time to subdivide the availability into.
28    /// e.g. if this is 30 minutes, then the availability matrix will contain
29    /// a slot for every 30 minutes between start and end.
30    #[serde_as(as = "DurationSeconds<i64>")]
31    pub granularity: chrono::Duration,
32    /// the availability matrix, each value determines whether the availability is
33    /// open for the entire granularity period.
34    pub matrix: Vec<bool>,
35}
36
37/// Parse a datetime string from icalendar
38/// This may or may not have the timezone specified.
39/// TODO: support timezones other than UTC?
40fn parse_datetime_from_str(tz: &Tz, datetime_str: &str) -> CaldavResult<chrono::DateTime<Tz>> {
41    if datetime_str.ends_with('Z') {
42        let datetime = tz.datetime_from_str(datetime_str, "%Y%m%dT%H%M%SZ")?;
43        Ok(datetime)
44    } else {
45        let datetime = tz.datetime_from_str(datetime_str, "%Y%m%dT%H%M%S")?;
46        Ok(datetime)
47    }
48}
49
50pub fn get_num_slots(
51    start: chrono::DateTime<chrono::Utc>,
52    end: chrono::DateTime<chrono::Utc>,
53    granularity: chrono::Duration,
54) -> usize {
55    let duration = end - start;
56    (duration.num_minutes() / granularity.num_minutes()) as usize
57}
58
59// generates a matrix for an event with no RRULE
60pub fn generate_matrix_no_rrule(
61    // the start of the range that we're generating the matrix for
62    range_start: chrono::DateTime<chrono::Utc>,
63    // the start of the availability event - times in this range are considered available
64    event_start: chrono::DateTime<chrono::Utc>,
65    event_end: chrono::DateTime<chrono::Utc>,
66    num_slots: i64,
67    granularity: chrono::Duration,
68) -> CaldavResult<Vec<bool>> {
69    let mut matrix = vec![false; num_slots as usize];
70
71    tracing::debug!(
72        r#"generating matrix for event with no RRULE,
73            range_start: {range_start}, 
74            event_start: {event_start},
75            event_end: {event_end},
76            num_slots: {num_slots},
77            granularity: {granularity}"#,
78    );
79
80    let start_index = {
81        if event_start == range_start {
82            0
83        } else {
84            let diff = event_start - range_start;
85            let index = diff.num_minutes() / granularity.num_minutes();
86            if index < 0 {
87                0
88            } else if index >= num_slots {
89                return Ok(matrix);
90            } else {
91                index + 1
92            }
93        }
94    } as usize;
95    let end_index = {
96        if event_end == range_start {
97            0
98        } else {
99            let diff = event_end - range_start;
100            let index = diff.num_minutes() / granularity.num_minutes();
101            tracing::debug!("diff: {:#?}", diff);
102            tracing::debug!("end_index: {}", index);
103            if index + 1 > num_slots {
104                num_slots
105            } else {
106                index + 1
107            }
108        }
109    } as usize;
110    tracing::debug!("start_index: {start_index}, end_index: {end_index}",);
111
112    matrix[start_index..end_index]
113        .iter_mut()
114        .for_each(|x| *x = true);
115    tracing::debug!("matrix: {:#?}", matrix);
116    Ok(matrix)
117}
118
119fn get_rruleset(event: &icalendar::Event, tz: &Tz) -> CaldavResult<Option<rrule::RRuleSet>> {
120    let rrule_str = match event.property_value("RRULE") {
121        None => return Ok(None),
122        Some(rule) => rule,
123    };
124
125    let dtstart = {
126        let as_str = event
127            .property_value("DTSTART")
128            .ok_or_else(|| CaldavError::Anyhow(anyhow!("DTSTART not found")))?;
129        let dtstart_local = parse_datetime_from_str(tz, as_str)?;
130        Tz::UTC.from_utc_datetime(&dtstart_local.naive_utc())
131    };
132
133    let rrule: RRule<Unvalidated> = rrule_str.parse()?;
134    let rrule = rrule.build(dtstart)?;
135    Ok(Some(rrule))
136}
137
138pub fn get_event_matrix(
139    start: chrono::DateTime<chrono::Utc>,
140    end: chrono::DateTime<chrono::Utc>,
141    granularity: chrono::Duration,
142    event: &Event,
143    timezone: Option<String>,
144) -> CaldavResult<Vec<bool>> {
145    if end < start {
146        tracing::warn!("end is before start");
147        return Ok(vec![]);
148    }
149
150    let num_slots = (end - start).num_minutes() / granularity.num_minutes();
151
152    tracing::debug!("generating matrix for event: {:#?}", event);
153    // determine the time of the event compared to the requested time range.
154    // First, we need to get the properties from the inner icalendar::Event.
155    let comps = &event.ical.components;
156    // Assume there is only one component.
157    let event_comp = comps
158        .iter()
159        .find(|c| matches!(c, icalendar::CalendarComponent::Event(_)));
160    if event_comp.is_none() {
161        return Err(CaldavError::Anyhow(anyhow!(
162            "no event component found in event"
163        )));
164    }
165    let event = event_comp.unwrap().as_event().unwrap();
166
167    // Get the start and end times of the event.
168    let dtstart_str = event.property_value("DTSTART").unwrap();
169    let dtend_str = event.property_value("DTEND").unwrap();
170    tracing::debug!("dtstart_str: {:#?}", dtstart_str);
171    tracing::debug!("dtend_str: {:#?}", dtend_str);
172
173    let tz = match timezone {
174        Some(tz) => {
175            let tz_str = tz.as_str();
176            match tz_str {
177                "UTC" => Tz::UTC,
178                _ => unimplemented!(),
179            }
180        }
181        _ => Tz::UTC,
182    };
183
184    // TODO: fix formatting of the date string
185    // It may be necessary to add a trailing Z to the date string
186    let str_has_z = dtstart_str.ends_with('Z');
187    let format = if str_has_z {
188        "%Y%m%dT%H%M%SZ"
189    } else {
190        "%Y%m%dT%H%M%S"
191    };
192
193    match event.property_value("RRULE") {
194        Some(_) => generate_matrix_rrule(event, &tz, start, end, num_slots, granularity),
195        None => {
196            let dtstart_local = tz.datetime_from_str(dtstart_str, format).unwrap();
197            let dtend_local = tz.datetime_from_str(dtend_str, format).unwrap();
198            // Convert the start and end times to UTC.
199            let dtstart = chrono::Utc
200                .from_local_datetime(&dtstart_local.naive_utc())
201                .unwrap();
202            let dtend = chrono::Utc
203                .from_local_datetime(&dtend_local.naive_utc())
204                .unwrap();
205            tracing::debug!("dtstart_local: {:#?}", dtstart_local);
206            tracing::debug!("dtend_local: {:#?}", dtend_local);
207            tracing::debug!("dtstart: {:#?}", dtstart);
208            tracing::debug!("dtend: {:#?}", dtend);
209            generate_matrix_no_rrule(start, dtstart, dtend, num_slots, granularity)
210        }
211    }
212}
213
214pub async fn calendar_availability(
215    client: &reqwest::Client,
216    calendar: &Calendar,
217    start: chrono::DateTime<chrono::Utc>,
218    end: chrono::DateTime<chrono::Utc>,
219    granularity: chrono::Duration,
220) -> CaldavResult<Vec<bool>> {
221    let num_slots = get_num_slots(start, end, granularity);
222    let all_false = vec![false; num_slots];
223
224    Ok(calendar
225        .get_events(client, start, end)
226        .await?
227        .iter()
228        .map(|event| get_event_matrix(start, end, granularity, event, calendar.timezone.clone()))
229        .fold(all_false, |acc: Vec<bool>, x| {
230            let x = x.unwrap();
231            acc.iter().zip(x.iter()).map(|(a, b)| *a || *b).collect()
232        }))
233}
234
235pub async fn get_availability(
236    client: &reqwest::Client,
237    availability: &Calendar,
238    booked: &Calendar,
239    start: chrono::DateTime<chrono::Utc>,
240    end: chrono::DateTime<chrono::Utc>,
241    granularity: chrono::Duration,
242) -> CaldavResult<AvailabilityResponse> {
243    let duration = end - start;
244    let num_slots = duration.num_minutes() / granularity.num_minutes();
245
246    // lookup events in the calendar
247    let events = availability.get_events(client, start, end).await?;
248    info!("found {} events", events.len());
249    tracing::debug!("events: {:#?}", events);
250
251    let all_false = vec![false; num_slots as usize];
252    let matrix = events
253        .iter()
254        .map(|event| {
255            get_event_matrix(
256                start,
257                end,
258                granularity,
259                event,
260                availability.timezone.clone(),
261            )
262        })
263        .fold(all_false, |acc, x| {
264            let x = x.unwrap();
265            acc.iter().zip(x.iter()).map(|(a, b)| *a || *b).collect()
266        });
267
268    // Now, we need to do the same thing for the booked calendar, but we need to
269    // invert the matrix modifications so that the booked times are marked as unavailable.
270    let booked_events = booked.get_events(client, start, end).await?;
271    info!("found {} booked events", booked_events.len());
272    tracing::debug!("booked_events: {:#?}", booked_events);
273
274    let booked_matrix = booked_events
275        .iter()
276        .map(|event| get_event_matrix(start, end, granularity, event, booked.timezone.clone()))
277        .fold(matrix, |acc, x| {
278            let x = x.unwrap();
279            acc.iter().zip(x.iter()).map(|(a, b)| *a && !b).collect()
280        });
281
282    Ok(AvailabilityResponse {
283        start,
284        end,
285        granularity,
286        matrix: booked_matrix,
287    })
288}
289
290pub fn generate_matrix_rrule(
291    // event containing availability
292    event: &icalendar::Event,
293    tz: &Tz,
294    // start of the availability matrix
295    start: chrono::DateTime<chrono::Utc>,
296    // end of the availability matrix
297    end: chrono::DateTime<chrono::Utc>,
298    num_slots: i64,
299    granularity: chrono::Duration,
300) -> CaldavResult<Vec<bool>> {
301    let dtstart = {
302        let dtstart_str = event.property_value("DTSTART").unwrap();
303        tracing::debug!("dtstart: {:#?}", dtstart_str);
304        let dtstart_local = parse_datetime_from_str(tz, dtstart_str)?;
305        chrono::Utc
306            .from_local_datetime(&dtstart_local.naive_utc())
307            .unwrap()
308    };
309    let dtend = {
310        let dtend_str = event.property_value("DTEND").unwrap();
311        let dtend_local = parse_datetime_from_str(tz, dtend_str)?;
312        chrono::Utc
313            .from_local_datetime(&dtend_local.naive_utc())
314            .unwrap()
315    };
316
317    let tz_start = Tz::UTC.from_utc_datetime(&dtstart.naive_utc());
318
319    // Convert the requested time-range to rrule compatible datetimes
320    let range_tz_end = Tz::UTC.from_utc_datetime(&end.naive_utc());
321
322    let rrule = get_rruleset(event, tz)?.unwrap();
323
324    let (detected_events, _) = rrule.after(tz_start).before(range_tz_end).all(100);
325    tracing::debug!("detected_events: {:#?}", detected_events);
326
327    // for each event, determine the time range it covers.
328    let event_duration = dtend - dtstart;
329    let event_ranges = detected_events
330        .iter()
331        .map(|e| {
332            let start = chrono::Utc.from_local_datetime(&e.naive_utc()).unwrap();
333            let end = start + event_duration;
334            (start, end)
335        })
336        .collect::<Vec<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>>();
337    tracing::debug!("event_ranges: {:#?}", event_ranges);
338
339    // Now that the rrule has been resolved to multiple events, we can treat them
340    // the same as events without an rrule
341    let final_matrix = event_ranges
342        .iter()
343        .filter_map(|(begin, end)| {
344            // only include events that are within the requested time range
345            if begin < end && end > &start {
346                Some(generate_matrix_no_rrule(
347                    start,
348                    *begin,
349                    *end,
350                    num_slots,
351                    granularity,
352                ))
353            } else {
354                None
355            }
356        })
357        .fold(vec![false; num_slots as usize], |acc, x| {
358            let x = x.unwrap();
359            acc.iter().zip(x.iter()).map(|(a, b)| *a || *b).collect()
360        });
361
362    Ok(final_matrix)
363}