//! Whole collections as one file, and one file split into objects: public //! feeds, export and import. //! //! Works on the text, not on parsed data, so a kept line reaches the output //! unchanged. Only line endings are rewritten, to CRLF. use std::collections::{HashMap, HashSet}; use sha2::{Digest, Sha256}; use crate::text::{ escape_text, logical_lines, name, param, param_parts, unescape_text, unfold, value, value_start, }; const PRODID: &str = "PRODID:-//dovenest//pimdav//EN"; /// The properties a busy-only event keeps. const BUSY_PROPS: [&str; 13] = [ "UID", "DTSTAMP", "DTSTART", "DTEND", "DURATION", "RRULE", "RDATE", "EXDATE", "EXRULE", "RECURRENCE-ID", "SEQUENCE", "TRANSP", "STATUS", ]; /// How much of a calendar a file shows. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Detail { /// Everything, for the owner or a borrower. All, /// Everything, except that an object with a private or confidential /// component shows as busy time only, as Google and Nextcloud do. Public, /// Busy time only. Busy, } /// One VCALENDAR with the components of every object in `objects`, each /// VTIMEZONE once per TZID. Subscribers are asked to poll hourly. pub fn calendar(objects: &[&str], name: Option<&str>, detail: Detail) -> String { let mut zones = String::new(); let mut body = String::new(); let mut seen = HashSet::new(); for object in objects { for cal in top_blocks(object, "VCALENDAR") { let children = split_level(inner(&cal.lines)).1; for z in children.iter().filter(|c| c.name == "VTIMEZONE") { if z.prop("TZID").is_some_and(|id| seen.insert(id)) { push_lines(&mut zones, &z.lines); } } let busy = match detail { Detail::All => false, Detail::Busy => true, Detail::Public => children.iter().any(is_private), }; if busy { let events: Vec<&Block> = children.iter().filter(|c| c.name == "VEVENT").collect(); push_busy(&mut body, &events); } else { for c in children.iter().filter(|c| c.name != "VTIMEZONE") { push_lines(&mut body, &c.lines); } } } } // RFC 7986's REFRESH-INTERVAL, and Outlook's own name for it. let mut out = format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{PRODID}\r\n\ REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n" ); // RFC 7986's NAME, and the X-WR-CALNAME most clients read instead. if let Some(name) = name { let name = escape_text(name); out.push_str(&format!("NAME:{name}\r\nX-WR-CALNAME:{name}\r\n")); } out + &zones + &body + "END:VCALENDAR\r\n" } /// Every vCard of `objects`, one after the other. pub fn cards(objects: &[&str]) -> String { let mut out = String::new(); for object in objects { for card in top_blocks(object, "VCARD") { push_lines(&mut out, &card.lines); } } out } /// The display name and color a calendar file names for itself: RFC 7986's /// NAME and COLOR, or the X-WR-CALNAME and X-APPLE-CALENDAR-COLOR most /// exports carry instead. The color is as written; the caller checks it. pub fn calendar_meta(text: &str) -> (Option, Option) { let Some(cal) = top_blocks(text, "VCALENDAR").into_iter().next() else { return (None, None); }; let first = |props: &[&str]| { props .iter() .find_map(|p| cal.prop(p)) .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) }; ( first(&["NAME", "X-WR-CALNAME"]), first(&["COLOR", "X-APPLE-CALENDAR-COLOR"]), ) } /// Splits an iCalendar file into calendar object resources: one per UID, /// overrides with their master, each with the VTIMEZONEs it names. Keeps /// VERSION, PRODID and CALSCALE of the file and drops the other calendar /// properties, METHOD among them. A component without UID gets /// `new_uid(its text)`. pub fn split_calendar(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec { let mut header: Option> = None; let mut zones: HashMap = HashMap::new(); // (uid, components, TZIDs they name), in file order. let mut groups: Vec<(String, String, HashSet)> = Vec::new(); for cal in top_blocks(text, "VCALENDAR") { let (props, children) = split_level(inner(&cal.lines)); header.get_or_insert_with(|| { props .iter() .filter(|l| ["VERSION", "PRODID", "CALSCALE"].contains(&name(l).as_str())) .map(|l| l.to_string()) .collect() }); for c in children { if c.name == "VTIMEZONE" { if let Some(id) = c.prop("TZID") { zones.entry(id).or_insert_with(|| lines_text(&c.lines)); } continue; } let tzids: HashSet = c .lines .iter() .filter_map(|l| param(&unfold(l), "TZID")) .collect(); let (uid, text) = match c.prop("UID") { Some(uid) => (uid, lines_text(&c.lines)), None => { let uid = new_uid(&lines_text(&c.lines)); let mut text = String::new(); push_lines(&mut text, &c.lines[..1]); text.push_str(&format!("UID:{uid}\r\n")); push_lines(&mut text, &c.lines[1..]); (uid, text) } }; match groups.iter_mut().find(|g| g.0 == uid) { // A file that holds the same component twice. Some(g) if g.1.contains(&text) => {} Some(g) => { g.1.push_str(&text); g.2.extend(tzids); } None => groups.push((uid, text, tzids)), } } } let mut head = String::from("BEGIN:VCALENDAR\r\n"); let header = header.unwrap_or_default(); if !header.iter().any(|l| name(l) == "VERSION") { head.push_str("VERSION:2.0\r\n"); } if !header.iter().any(|l| name(l) == "PRODID") { head.push_str(PRODID); head.push_str("\r\n"); } for l in &header { push_lines(&mut head, &[l.as_str()]); } groups .into_iter() .map(|(_, components, tzids)| { let mut out = head.clone(); let mut tzids: Vec<_> = tzids.into_iter().collect(); tzids.sort(); for id in tzids { if let Some(z) = zones.get(&id) { out.push_str(z); } } out + &components + "END:VCALENDAR\r\n" }) .collect() } /// Splits a vCard file into one text per card. A card without UID gets /// `new_uid(its text)`, inserted before its END line: vCard 4.0 wants /// VERSION right after BEGIN. pub fn split_cards(text: &str, new_uid: &mut dyn FnMut(&str) -> String) -> Vec { top_blocks(text, "VCARD") .into_iter() .map(|card| { let text = lines_text(&card.lines); if card.prop("UID").is_some() { return text; } let (body, end) = match card.lines.split_last() { Some((last, body)) if name(last) == "END" => (body, Some(*last)), _ => (&card.lines[..], None), }; let mut out = String::new(); push_lines(&mut out, body); out.push_str(&format!("UID:{}\r\n", new_uid(&text))); if let Some(end) = end { push_lines(&mut out, &[end]); } out }) .collect() } /// A component as its logical lines, BEGIN and END included. struct Block<'a> { /// Upper case. name: String, lines: Vec<&'a str>, } impl Block<'_> { /// The value of the first own property called `prop`, not one of a /// nested component, unfolded and unescaped as TEXT. fn prop(&self, prop: &str) -> Option { split_level(inner(&self.lines)) .0 .into_iter() .find(|l| name(l) == prop) .map(|l| unescape_text(value(&unfold(l)))) } } /// `lines` without their BEGIN and END line. fn inner<'a, 'b>(lines: &'b [&'a str]) -> &'b [&'a str] { let start = usize::from(lines.first().is_some_and(|l| name(l) == "BEGIN")); let end = lines.len() - usize::from(lines.len() > start && name(lines[lines.len() - 1]) == "END"); &lines[start..end] } /// The top-level components called `outer`. fn top_blocks<'a>(text: &'a str, outer: &str) -> Vec> { split_level(&logical_lines(text)) .1 .into_iter() .filter(|b| b.name == outer) .collect() } /// The properties of this level and its components. A component cut off by /// the end of the text is kept, without END. fn split_level<'a>(lines: &[&'a str]) -> (Vec<&'a str>, Vec>) { let mut props = Vec::new(); let mut blocks: Vec> = Vec::new(); let mut depth = 0usize; for &line in lines { let n = name(line); if depth == 0 { if n == "BEGIN" { blocks.push(Block { name: value(&unfold(line)).trim().to_ascii_uppercase(), lines: vec![line], }); depth = 1; } else if !n.is_empty() { props.push(line); } continue; } blocks.last_mut().expect("depth > 0").lines.push(line); match n.as_str() { "BEGIN" => depth += 1, "END" => depth -= 1, _ => {} } } (props, blocks) } fn push_lines(out: &mut String, lines: &[&str]) { for line in lines { for physical in line.split_inclusive('\n') { out.push_str(physical.trim_end_matches(['\r', '\n'])); out.push_str("\r\n"); } } } fn lines_text(lines: &[&str]) -> String { let mut out = String::new(); push_lines(&mut out, lines); out } fn is_private(c: &Block) -> bool { c.prop("CLASS").is_some_and(|v| { v.eq_ignore_ascii_case("PRIVATE") || v.eq_ignore_ascii_case("CONFIDENTIAL") }) } /// Transparent and cancelled events block no time. fn blocks_time(e: &Block) -> bool { !(e.prop("TRANSP") .is_some_and(|v| v.eq_ignore_ascii_case("TRANSPARENT")) || e.prop("STATUS") .is_some_and(|v| v.eq_ignore_ascii_case("CANCELLED"))) } /// The events of one object as busy time. Events that block no time are /// left out; a left-out override becomes an EXDATE of its master, so its /// instance stays free. The UID becomes a hash of it, because UIDs often /// hold host names or mail addresses. fn push_busy(out: &mut String, events: &[&Block]) { let (kept, free): (Vec<&&Block>, Vec<&&Block>) = events.iter().partition(|e| blocks_time(e)); let exdates: Vec = free .iter() .filter_map(|e| { let (props, _) = split_level(inner(&e.lines)); props .into_iter() .find(|l| name(l) == "RECURRENCE-ID") .map(exdate) }) .collect(); for e in kept { out.push_str("BEGIN:VEVENT\r\nSUMMARY:Busy\r\n"); let (props, _) = split_level(inner(&e.lines)); let master = !props.iter().any(|l| name(l) == "RECURRENCE-ID"); for line in props { match name(line).as_str() { "UID" => { let uid = unescape_text(value(&unfold(line))); let hash = Sha256::digest(uid.as_bytes()); let hex: String = hash[..16].iter().map(|b| format!("{b:02x}")).collect(); out.push_str(&format!("UID:{hex}\r\n")); } n if BUSY_PROPS.contains(&n) => push_lines(out, &[line]), _ => {} } } if master { for x in &exdates { out.push_str(x); } } out.push_str("END:VEVENT\r\n"); } } /// A RECURRENCE-ID line as the EXDATE of that instance, without RANGE. fn exdate(rid: &str) -> String { let line = unfold(rid); let start = value_start(&line); let params: String = param_parts(&line) .into_iter() .filter(|p| !p.trim().to_ascii_uppercase().starts_with("RANGE=")) .map(|p| format!(";{p}")) .collect(); format!("EXDATE{params}:{}\r\n", &line[start..]) }