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