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