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