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