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, ICalendarComponentType, ICalendarEntry, ICalendarFreeBusyType, ICalendarMethod,
6 ICalendarParameterName, ICalendarParameterValue, ICalendarPeriod, ICalendarProperty,
7 ICalendarStatus, ICalendarTransparency, ICalendarValue,
8};
9use chrono::{DateTime, Utc};
10use xmltree::XMLNode;
11
12use crate::expand::{expand, prop_stamp, stamp};
13use crate::filter::TimeRange;
14use crate::itip::{Is, own_partstat};
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/// A free-busy request POSTed to a scheduling outbox (RFC 6638, 5).
177#[derive(Debug, Clone, PartialEq)]
178pub struct Request {
179 pub range: TimeRange,
180 pub organizer: String,
181 /// Without duplicates.
182 pub attendees: Vec<String>,
183 pub uid: Option<String>,
184}
185
186/// Parses an outbox POST body. `Err` names the failed precondition.
187pub fn request(body: &[u8]) -> Result<Request, Name> {
188 let invalid = || Name::new(CALDAV, "valid-scheduling-message");
189 let cal = std::str::from_utf8(body)
190 .ok()
191 .and_then(|s| ICalendar::parse(s).ok())
192 .ok_or_else(|| Name::new(CALDAV, "valid-calendar-data"))?;
193 let method = cal
194 .components
195 .first()
196 .and_then(|root| root.property(&ICalendarProperty::Method)?.values.first());
197 if !matches!(
198 method,
199 Some(ICalendarValue::Method(ICalendarMethod::Request))
200 ) {
201 return Err(invalid());
202 }
203 let mut found = cal
204 .components
205 .iter()
206 .filter(|c| c.component_type == ICalendarComponentType::VFreebusy);
207 let (Some(c), None) = (found.next(), found.next()) else {
208 return Err(invalid());
209 };
210 let zones = Zones::new(&cal, Zone::Utc);
211 let at = |p: &ICalendarProperty| Some(prop_stamp(&zones, c, p)?.utc());
212 let text = |e: &ICalendarEntry| Some(e.values.first()?.as_text()?.trim().to_string());
213 let (Some(start), Some(end)) = (
214 at(&ICalendarProperty::Dtstart),
215 at(&ICalendarProperty::Dtend),
216 ) else {
217 return Err(invalid());
218 };
219 let Some(organizer) = c.property(&ICalendarProperty::Organizer).and_then(text) else {
220 return Err(invalid());
221 };
222 let mut attendees: Vec<String> = Vec::new();
223 for a in c.properties(&ICalendarProperty::Attendee).filter_map(text) {
224 if !attendees.iter().any(|x| x.eq_ignore_ascii_case(&a)) {
225 attendees.push(a);
226 }
227 }
228 if start >= end || attendees.is_empty() {
229 return Err(invalid());
230 }
231 Ok(Request {
232 range: start..end,
233 organizer,
234 attendees,
235 uid: c.uid().map(str::to_string),
236 })
237}
238
239/// The VFREEBUSY REPLY of `attendee` to `req`.
240pub fn reply(periods: &[Period], req: &Request, attendee: &str, now: DateTime<Utc>) -> String {
241 // The values come from the request: no line breaks may get through.
242 let clean = |s: &str| s.chars().filter(|c| !c.is_control()).collect::<String>();
243 let fold = |line: String| crate::text::fold(&line, "\r\n");
244 let mut props = fold(format!("ORGANIZER:{}", clean(&req.organizer)))
245 + &fold(format!("ATTENDEE:{}", clean(attendee)));
246 if let Some(uid) = &req.uid {
247 let uid = crate::text::escape_text(&clean(uid));
248 props.push_str(&fold(format!("UID:{uid}")));
249 }
250 render(periods, &req.range, now, "METHOD:REPLY\r\n", &props)
251}
252
253/// The body answering an outbox POST: per recipient its address, the
254/// REQUEST-STATUS and the reply, if there is one.
255pub fn schedule_response(answers: &[(String, &str, Option<String>)]) -> String {
256 document(&with_children(
257 el(CALDAV, "schedule-response"),
258 answers.iter().map(|(to, status, data)| {
259 let mut e = with_children(
260 el(CALDAV, "response"),
261 [
262 with_children(el(CALDAV, "recipient"), hrefs([to.as_str()])),
263 with_text(el(CALDAV, "request-status"), *status),
264 ],
265 );
266 if let Some(d) = data {
267 e.children.push(XMLNode::Element(with_text(
268 el(CALDAV, "calendar-data"),
269 d.as_str(),
270 )));
271 }
272 e
273 }),
274 ))
275}
276