bundle.rs
⎇
Raw
1//! Whole collections as one file, and one file split into objects: public
2//! feeds, export and import.
3//!
4//! Works on the text, not on parsed data, so a kept line reaches the output
5//! unchanged. Only line endings are rewritten, to CRLF.
6
7use std::collections::{HashMap, HashSet};
8
9use sha2::{Digest, Sha256};
10
11use crate::text::{
12 escape_text, logical_lines, name, param, param_parts, unescape_text, unfold, value, value_start,
13};
14
15const PRODID: &str = "PRODID:-//dovenest//pimdav//EN";
16
17/// The properties a busy-only event keeps.
18const BUSY_PROPS: [&str; 13] = [
19 "UID",
20 "DTSTAMP",
21 "DTSTART",
22 "DTEND",
23 "DURATION",
24 "RRULE",
25 "RDATE",
26 "EXDATE",
27 "EXRULE",
28 "RECURRENCE-ID",
29 "SEQUENCE",
30 "TRANSP",
31 "STATUS",
32];
33
34/// How much of a calendar a file shows.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Detail {
37 /// Everything, for the owner or a borrower.
38 All,
39 /// Everything, except that an object with a private or confidential
40 /// component shows as busy time only, as Google and Nextcloud do.
41 Public,
42 /// Busy time only.
43 Busy,
44}
45
46/// One VCALENDAR with the components of every object in `objects`, each
47/// VTIMEZONE once per TZID. Subscribers are asked to poll hourly.
48pub fn calendar(objects: &[&str], name: Option<&str>, detail: Detail) -> String {
49 let mut zones = String::new();
50 let mut body = String::new();
51 let mut seen = HashSet::new();
52 for object in objects {
53 for cal in top_blocks(object, "VCALENDAR") {
54 let children = split_level(inner(&cal.lines)).1;
55 for z in children.iter().filter(|c| c.name == "VTIMEZONE") {
56 if z.prop("TZID").is_some_and(|id| seen.insert(id)) {
57 push_lines(&mut zones, &z.lines);
58 }
59 }
60 let busy = match detail {
61 Detail::All => false,
62 Detail::Busy => true,
63 Detail::Public => children.iter().any(is_private),
64 };
65 if busy {
66 let events: Vec<&Block> = children.iter().filter(|c| c.name == "VEVENT").collect();
67 push_busy(&mut body, &events);
68 } else {
69 for c in children.iter().filter(|c| c.name != "VTIMEZONE") {
70 push_lines(&mut body, &c.lines);
71 }
72 }
73 }
74 }
75 // RFC 7986's REFRESH-INTERVAL, and Outlook's own name for it.
76 let mut out = format!(
77 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{PRODID}\r\n\
78 REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n"
79 );
80 // RFC 7986's NAME, and the X-WR-CALNAME most clients read instead.
81 if let Some(name) = name {
82 let name = escape_text(name);
83 out.push_str(&format!("NAME:{name}\r\nX-WR-CALNAME:{name}\r\n"));
84 }
85 out + &zones + &body + "END:VCALENDAR\r\n"
86}
87
88/// Every vCard of `objects`, one after the other.
89pub fn cards(objects: &[&str]) -> String {
90 let mut out = String::new();
91 for object in objects {
92 for card in top_blocks(object, "VCARD") {
93 push_lines(&mut out, &card.lines);
94 }
95 }
96 out
97}
98
99/// The display name and color a calendar file names for itself: RFC 7986's
100/// NAME and COLOR, or the X-WR-CALNAME and X-APPLE-CALENDAR-COLOR most
101/// exports carry instead. The color is as written; the caller checks it.
102pub fn calendar_meta(text: &str) -> (Option<String>, Option<String>) {
103 let Some(cal) = top_blocks(text, "VCALENDAR").into_iter().next() else {
104 return (None, None);
105 };
106 let first = |props: &[&str]| {
107 props
108 .iter()
109 .find_map(|p| cal.prop(p))
110 .map(|v| v.trim().to_string())
111 .filter(|v| !v.is_empty())
112 };
113 (
114 first(&["NAME", "X-WR-CALNAME"]),
115 first(&["COLOR", "X-APPLE-CALENDAR-COLOR"]),
116 )
117}
118
119/// Splits an iCalendar file into calendar object resources: one per UID,
120/// overrides with their master, each with the VTIMEZONEs it names. Keeps
121/// VERSION, PRODID and CALSCALE of the file and drops the other calendar
122/// properties, METHOD among them. A component without UID gets
123/// `new_uid(its text)`.
124pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec<String> {
125 let mut header: Option<Vec<String>> = None;
126 let mut zones: HashMap<String, String> = HashMap::new();
127 // (uid, components, TZIDs they name), in file order.
128 let mut groups: Vec<(String, String, HashSet<String>)> = Vec::new();
129 let mut by_uid: HashMap<String, usize> = HashMap::new();
130 // A file that holds the same component twice keeps one copy.
131 let mut seen: HashSet<String> = HashSet::new();
132 for cal in top_blocks(text, "VCALENDAR") {
133 let (props, children) = split_level(inner(&cal.lines));
134 header.get_or_insert_with(|| {
135 props
136 .iter()
137 .filter(|l| ["VERSION", "PRODID", "CALSCALE"].contains(&name(l).as_str()))
138 .map(|l| l.to_string())
139 .collect()
140 });
141 for c in children {
142 if c.name == "VTIMEZONE" {
143 if let Some(id) = c.prop("TZID") {
144 zones.entry(id).or_insert_with(|| lines_text(&c.lines));
145 }
146 continue;
147 }
148 let tzids: HashSet<String> = c
149 .lines
150 .iter()
151 .filter_map(|l| param(&unfold(l), "TZID"))
152 .collect();
153 let (uid, text) = match c.prop("UID") {
154 Some(uid) => (uid, lines_text(&c.lines)),
155 None => {
156 let uid = new_uid(&lines_text(&c.lines));
157 let mut text = String::new();
158 push_lines(&mut text, &c.lines[..1]);
159 text.push_str(&format!("UID:{uid}\r\n"));
160 push_lines(&mut text, &c.lines[1..]);
161 (uid, text)
162 }
163 };
164 if !seen.insert(text.clone()) {
165 continue;
166 }
167 match by_uid.get(&uid) {
168 Some(&i) => {
169 groups[i].1.push_str(&text);
170 groups[i].2.extend(tzids);
171 }
172 None => {
173 by_uid.insert(uid.clone(), groups.len());
174 groups.push((uid, text, tzids));
175 }
176 }
177 }
178 }
179 let mut head = String::from("BEGIN:VCALENDAR\r\n");
180 let header = header.unwrap_or_default();
181 if !header.iter().any(|l| name(l) == "VERSION") {
182 head.push_str("VERSION:2.0\r\n");
183 }
184 if !header.iter().any(|l| name(l) == "PRODID") {
185 head.push_str(PRODID);
186 head.push_str("\r\n");
187 }
188 for l in &header {
189 push_lines(&mut head, &[l.as_str()]);
190 }
191 groups
192 .into_iter()
193 .map(|(_, components, tzids)| {
194 let mut out = head.clone();
195 let mut tzids: Vec<_> = tzids.into_iter().collect();
196 tzids.sort();
197 for id in tzids {
198 if let Some(z) = zones.get(&id) {
199 out.push_str(z);
200 }
201 }
202 out + &components + "END:VCALENDAR\r\n"
203 })
204 .collect()
205}
206
207/// Splits a vCard file into one text per card. A card without UID gets
208/// `new_uid(its text)`, inserted before its END line: vCard 4.0 wants
209/// VERSION right after BEGIN.
210pub fn split_cards(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec<String> {
211 top_blocks(text, "VCARD")
212 .into_iter()
213 .map(|card| {
214 let text = lines_text(&card.lines);
215 if card.prop("UID").is_some() {
216 return text;
217 }
218 let (body, end) = match card.lines.split_last() {
219 Some((last, body)) if name(last) == "END" => (body, Some(*last)),
220 _ => (&card.lines[..], None),
221 };
222 let mut out = String::new();
223 push_lines(&mut out, body);
224 out.push_str(&format!("UID:{}\r\n", new_uid(&text)));
225 if let Some(end) = end {
226 push_lines(&mut out, &[end]);
227 }
228 out
229 })
230 .collect()
231}
232
233/// A component as its logical lines, BEGIN and END included.
234struct Block<'a> {
235 /// Upper case.
236 name: String,
237 lines: Vec<&'a str>,
238}
239
240impl Block<'_> {
241 /// The value of the first own property called `prop`, not one of a
242 /// nested component, unfolded and unescaped as TEXT.
243 fn prop(&self, prop: &str) -> Option<String> {
244 split_level(inner(&self.lines))
245 .0
246 .into_iter()
247 .find(|l| name(l) == prop)
248 .map(|l| unescape_text(value(&unfold(l))))
249 }
250}
251
252/// `lines` without their BEGIN and END line.
253fn inner<'a, 'b>(lines: &'b [&'a str]) -> &'b [&'a str] {
254 let start = usize::from(lines.first().is_some_and(|l| name(l) == "BEGIN"));
255 let end =
256 lines.len() - usize::from(lines.len() > start && name(lines[lines.len() - 1]) == "END");
257 &lines[start..end]
258}
259
260/// The top-level components called `outer`.
261fn top_blocks<'a>(text: &'a str, outer: &str) -> Vec<Block<'a>> {
262 split_level(&logical_lines(text))
263 .1
264 .into_iter()
265 .filter(|b| b.name == outer)
266 .collect()
267}
268
269/// The properties of this level and its components. A component cut off by
270/// the end of the text is kept, without END.
271fn split_level<'a>(lines: &[&'a str]) -> (Vec<&'a str>, Vec<Block<'a>>) {
272 let mut props = Vec::new();
273 let mut blocks: Vec<Block<'a>> = Vec::new();
274 let mut depth = 0usize;
275 for &line in lines {
276 let n = name(line);
277 if depth == 0 {
278 if n == "BEGIN" {
279 blocks.push(Block {
280 name: value(&unfold(line)).trim().to_ascii_uppercase(),
281 lines: vec![line],
282 });
283 depth = 1;
284 } else if !n.is_empty() {
285 props.push(line);
286 }
287 continue;
288 }
289 blocks.last_mut().expect("depth > 0").lines.push(line);
290 match n.as_str() {
291 "BEGIN" => depth += 1,
292 "END" => depth -= 1,
293 _ => {}
294 }
295 }
296 (props, blocks)
297}
298
299fn push_lines(out: &mut String, lines: &[&str]) {
300 for line in lines {
301 for physical in line.split_inclusive('\n') {
302 out.push_str(physical.trim_end_matches(['\r', '\n']));
303 out.push_str("\r\n");
304 }
305 }
306}
307
308fn lines_text(lines: &[&str]) -> String {
309 let mut out = String::new();
310 push_lines(&mut out, lines);
311 out
312}
313
314fn is_private(c: &Block) -> bool {
315 c.prop("CLASS").is_some_and(|v| {
316 v.eq_ignore_ascii_case("PRIVATE") || v.eq_ignore_ascii_case("CONFIDENTIAL")
317 })
318}
319
320/// Transparent and cancelled events block no time.
321fn blocks_time(e: &Block) -> bool {
322 !(e.prop("TRANSP")
323 .is_some_and(|v| v.eq_ignore_ascii_case("TRANSPARENT"))
324 || e.prop("STATUS")
325 .is_some_and(|v| v.eq_ignore_ascii_case("CANCELLED")))
326}
327
328/// The events of one object as busy time. Events that block no time are
329/// left out; a left-out override becomes an EXDATE of its master, so its
330/// instance stays free. The UID becomes a hash of it, because UIDs often
331/// hold host names or mail addresses.
332fn push_busy(out: &mut String, events: &[&Block]) {
333 let (kept, free): (Vec<&&Block>, Vec<&&Block>) = events.iter().partition(|e| blocks_time(e));
334 let exdates: Vec<String> = free
335 .iter()
336 .filter_map(|e| {
337 let (props, _) = split_level(inner(&e.lines));
338 props
339 .into_iter()
340 .find(|l| name(l) == "RECURRENCE-ID")
341 .map(exdate)
342 })
343 .collect();
344 for e in kept {
345 out.push_str("BEGIN:VEVENT\r\nSUMMARY:Busy\r\n");
346 let (props, _) = split_level(inner(&e.lines));
347 let master = !props.iter().any(|l| name(l) == "RECURRENCE-ID");
348 for line in props {
349 match name(line).as_str() {
350 "UID" => {
351 let uid = unescape_text(value(&unfold(line)));
352 let hash = Sha256::digest(uid.as_bytes());
353 let hex: String = hash[..16].iter().map(|b| format!("{b:02x}")).collect();
354 out.push_str(&format!("UID:{hex}\r\n"));
355 }
356 n if BUSY_PROPS.contains(&n) => push_lines(out, &[line]),
357 _ => {}
358 }
359 }
360 if master {
361 for x in &exdates {
362 out.push_str(x);
363 }
364 }
365 out.push_str("END:VEVENT\r\n");
366 }
367}
368
369/// A RECURRENCE-ID line as the EXDATE of that instance, without RANGE.
370fn exdate(rid: &str) -> String {
371 let line = unfold(rid);
372 let start = value_start(&line);
373 let params: String = param_parts(&line)
374 .into_iter()
375 .filter(|p| !p.trim().to_ascii_uppercase().starts_with("RANGE="))
376 .map(|p| format!(";{p}"))
377 .collect();
378 format!("EXDATE{params}:{}\r\n", &line[start..])
379}
380