freebusy.rs
⎇
Raw
1//! Busy time of calendar objects: for free-busy-query (RFC 4791, 7.10) and
2//! for free-busy requests to a scheduling outbox (RFC 6638, 5).
3
4use calcard::icalendar::{
5 ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarFreeBusyType,
6 ICalendarMethod, ICalendarParameterName, ICalendarParameterValue, ICalendarPeriod,
7 ICalendarProperty, ICalendarStatus, ICalendarTransparency, ICalendarValue,
8};
9use chrono::{DateTime, Utc};
10use xmltree::XMLNode;
11
12use crate::expand::{expand, stamp};
13use crate::filter::TimeRange;
14use crate::itip::Is;
15use crate::xml::{CALDAV, Name, document, el, hrefs, with_children, with_text};
16use crate::zone::{Zone, Zones, add};
17
18/// In the order a client ranks them.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
20pub enum Busy {
21 Busy,
22 Tentative,
23 Unavailable,
24}
25
26impl Busy {
27 fn fbtype(self) -> &'static str {
28 match self {
29 Busy::Busy => "BUSY",
30 Busy::Tentative => "BUSY-TENTATIVE",
31 Busy::Unavailable => "BUSY-UNAVAILABLE",
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Period {
38 pub kind: Busy,
39 pub start: DateTime<Utc>,
40 pub end: DateTime<Utc>,
41}
42
43/// The busy periods of one calendar object, clipped to `range`. Transparent
44/// and cancelled events are free; VFREEBUSY components count as stored.
45///
46/// With `me`, the calendar user's own answer counts too: an instance it
47/// declined is free, one it has not answered or accepted tentatively is
48/// tentative.
49pub fn busy(cal: &ICalendar, range: &TimeRange, floating: &Zone, me: Option<Is>) -> Vec<Period> {
50 let zones = Zones::new(cal, floating.clone());
51 let mut out = Vec::new();
52 let mut push = |kind, start: DateTime<Utc>, end: DateTime<Utc>| {
53 let (start, end) = (start.max(range.start), end.min(range.end));
54 if start < end {
55 out.push(Period { kind, start, end });
56 }
57 };
58 for x in expand(cal, range.clone(), floating.clone()).instances {
59 let c = &cal.components[x.component];
60 if c.component_type != ICalendarComponentType::VEvent
61 || c.transparency() == Some(&ICalendarTransparency::Transparent)
62 {
63 continue;
64 }
65 let kind = match c.status() {
66 Some(ICalendarStatus::Cancelled) => continue,
67 Some(ICalendarStatus::Tentative) => Busy::Tentative,
68 _ => Busy::Busy,
69 };
70 let kind = match me.and_then(|me| own_partstat(c, me)).as_deref() {
71 Some("DECLINED") => continue,
72 Some("NEEDS-ACTION" | "TENTATIVE") => Busy::Tentative,
73 _ => kind,
74 };
75 push(kind, x.start, x.end);
76 }
77 for c in cal
78 .components
79 .iter()
80 .filter(|c| c.component_type == ICalendarComponentType::VFreebusy)
81 {
82 for e in c.properties(&ICalendarProperty::Freebusy) {
83 let kind = match e.parameter(&ICalendarParameterName::Fbtype) {
84 Some(ICalendarParameterValue::Fbtype(ICalendarFreeBusyType::Free)) => continue,
85 Some(ICalendarParameterValue::Fbtype(ICalendarFreeBusyType::BusyTentative)) => {
86 Busy::Tentative
87 }
88 Some(ICalendarParameterValue::Fbtype(ICalendarFreeBusyType::BusyUnavailable)) => {
89 Busy::Unavailable
90 }
91 _ => Busy::Busy,
92 };
93 for (s, e) in periods(&zones, e) {
94 push(kind, s, e);
95 }
96 }
97 }
98 out
99}
100
101/// Sorts the periods and joins those of one kind that overlap or touch.
102pub fn merge(mut periods: Vec<Period>) -> Vec<Period> {
103 periods.sort_by_key(|p| (p.kind, p.start));
104 let mut out: Vec<Period> = Vec::with_capacity(periods.len());
105 for p in periods {
106 match out.last_mut() {
107 Some(last) if last.kind == p.kind && p.start <= last.end => {
108 last.end = last.end.max(p.end);
109 }
110 _ => out.push(p),
111 }
112 }
113 out
114}
115
116/// The VCALENDAR a free-busy-query answers with.
117pub fn vfreebusy(periods: &[Period], range: &TimeRange, now: DateTime<Utc>) -> String {
118 render(periods, range, now, "", "")
119}
120
121/// `method` goes into the VCALENDAR and `props` into the VFREEBUSY, both as
122/// ready content lines.
123fn render(
124 periods: &[Period],
125 range: &TimeRange,
126 now: DateTime<Utc>,
127 method: &str,
128 props: &str,
129) -> String {
130 let t = |d: DateTime<Utc>| d.format("%Y%m%dT%H%M%SZ").to_string();
131 let mut out = format!(
132 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//dovenest//pimdav//EN\r\n{method}\
133 BEGIN:VFREEBUSY\r\n{props}DTSTAMP:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
134 t(now),
135 t(range.start),
136 t(range.end)
137 );
138 for p in periods {
139 out.push_str(&format!(
140 "FREEBUSY;FBTYPE={}:{}/{}\r\n",
141 p.kind.fbtype(),
142 t(p.start),
143 t(p.end)
144 ));
145 }
146 out.push_str("END:VFREEBUSY\r\nEND:VCALENDAR\r\n");
147 out
148}
149
150/// The PERIOD values of a FREEBUSY property.
151pub(crate) fn periods(zones: &Zones, e: &ICalendarEntry) -> Vec<(DateTime<Utc>, DateTime<Utc>)> {
152 e.values
153 .iter()
154 .filter_map(|v| period(zones, e.tz_id(), v))
155 .collect()
156}
157
158pub(crate) fn period(
159 zones: &Zones,
160 tzid: Option<&str>,
161 v: &ICalendarValue,
162) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
163 match v {
164 ICalendarValue::Period(ICalendarPeriod::Range { start, end }) => Some((
165 stamp(zones, start, tzid)?.utc(),
166 stamp(zones, end, tzid)?.utc(),
167 )),
168 ICalendarValue::Period(ICalendarPeriod::Duration { start, duration }) => {
169 let s = stamp(zones, start, tzid)?.utc();
170 Some((s, add(s, duration.to_time_delta()?)))
171 }
172 _ => None,
173 }
174}
175
176/// The PARTSTAT of `me` in a component, if it attends.
177fn own_partstat(c: &ICalendarComponent, me: Is) -> Option<String> {
178 c.properties(&ICalendarProperty::Attendee)
179 .find(|e| {
180 e.values
181 .first()
182 .and_then(|v| v.as_text())
183 .is_some_and(|a| me(a.trim()))
184 })
185 .map(|e| {
186 e.parameter(&ICalendarParameterName::Partstat)
187 .and_then(|p| p.as_text())
188 .unwrap_or("NEEDS-ACTION")
189 .to_ascii_uppercase()
190 })
191}
192
193/// A free-busy request POSTed to a scheduling outbox (RFC 6638, 5).
194#[derive(Debug, Clone, PartialEq)]
195pub struct Request {
196 pub range: TimeRange,
197 pub organizer: String,
198 /// Without duplicates.
199 pub attendees: Vec<String>,
200 pub uid: Option<String>,
201}
202
203/// Parses an outbox POST body. `Err` names the failed precondition.
204pub fn request(body: &[u8]) -> Result<Request, Name> {
205 let invalid = || Name::new(CALDAV, "valid-scheduling-message");
206 let cal = std::str::from_utf8(body)
207 .ok()
208 .and_then(|s| ICalendar::parse(s).ok())
209 .ok_or_else(|| Name::new(CALDAV, "valid-calendar-data"))?;
210 let method = cal
211 .components
212 .first()
213 .and_then(|root| root.property(&ICalendarProperty::Method)?.values.first());
214 if !matches!(
215 method,
216 Some(ICalendarValue::Method(ICalendarMethod::Request))
217 ) {
218 return Err(invalid());
219 }
220 let mut found = cal
221 .components
222 .iter()
223 .filter(|c| c.component_type == ICalendarComponentType::VFreebusy);
224 let (Some(c), None) = (found.next(), found.next()) else {
225 return Err(invalid());
226 };
227 let zones = Zones::new(&cal, Zone::Utc);
228 let at = |p: &ICalendarProperty| {
229 let e = c.property(p)?;
230 Some(stamp(&zones, e.values.first()?.as_partial_date_time()?, e.tz_id())?.utc())
231 };
232 let text = |e: &ICalendarEntry| Some(e.values.first()?.as_text()?.trim().to_string());
233 let (Some(start), Some(end)) = (
234 at(&ICalendarProperty::Dtstart),
235 at(&ICalendarProperty::Dtend),
236 ) else {
237 return Err(invalid());
238 };
239 let Some(organizer) = c.property(&ICalendarProperty::Organizer).and_then(text) else {
240 return Err(invalid());
241 };
242 let mut attendees: Vec<String> = Vec::new();
243 for a in c.properties(&ICalendarProperty::Attendee).filter_map(text) {
244 if !attendees.iter().any(|x| x.eq_ignore_ascii_case(&a)) {
245 attendees.push(a);
246 }
247 }
248 if start >= end || attendees.is_empty() {
249 return Err(invalid());
250 }
251 Ok(Request {
252 range: start..end,
253 organizer,
254 attendees,
255 uid: c.uid().map(str::to_string),
256 })
257}
258
259/// The VFREEBUSY REPLY of `attendee` to `req`.
260pub fn reply(periods: &[Period], req: &Request, attendee: &str, now: DateTime<Utc>) -> String {
261 // The values come from the request: no line breaks may get through.
262 let clean = |s: &str| s.chars().filter(|c| !c.is_control()).collect::<String>();
263 let fold = |line: String| crate::text::fold(&line, "\r\n");
264 let mut props = fold(format!("ORGANIZER:{}", clean(&req.organizer)))
265 + &fold(format!("ATTENDEE:{}", clean(attendee)));
266 if let Some(uid) = &req.uid {
267 let uid = clean(uid)
268 .replace('\\', "\\\\")
269 .replace(';', "\\;")
270 .replace(',', "\\,");
271 props.push_str(&fold(format!("UID:{uid}")));
272 }
273 render(periods, &req.range, now, "METHOD:REPLY\r\n", &props)
274}
275
276/// The body answering an outbox POST: per recipient its address, the
277/// REQUEST-STATUS and the reply, if there is one.
278pub fn schedule_response(answers: &[(String, &str, Option<String>)]) -> String {
279 document(&with_children(
280 el(CALDAV, "schedule-response"),
281 answers.iter().map(|(to, status, data)| {
282 let mut e = with_children(
283 el(CALDAV, "response"),
284 [
285 with_children(el(CALDAV, "recipient"), hrefs([to.as_str()])),
286 with_text(el(CALDAV, "request-status"), *status),
287 ],
288 );
289 if let Some(d) = data {
290 e.children.push(XMLNode::Element(with_text(
291 el(CALDAV, "calendar-data"),
292 d.as_str(),
293 )));
294 }
295 e
296 }),
297 ))
298}
299