//! Plain summaries of calendar objects and contacts, for a user interface. use std::collections::HashMap; use calcard::icalendar::{ ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName, ICalendarProperty, ICalendarValue, }; use chrono::{DateTime, TimeDelta, Utc}; use crate::expand::{Instance, expand}; use crate::itip::Is; use crate::text::{group, logical_lines, param, param_parts, prop, unescape_text, unfold, value}; use crate::zone::{Zone, Zones}; #[derive(Debug, Clone, PartialEq)] pub struct Person { pub name: Option, pub address: String, } #[derive(Debug, Clone, PartialEq)] pub struct Attendee { pub person: Person, pub partstat: Option, pub role: Option, pub is_owner: bool, } #[derive(Debug, Clone, PartialEq, Default)] pub struct EventInfo { pub component: String, pub summary: Option, pub description: Option, pub location: Option, pub url: Option, pub status: Option, pub transparent: bool, pub all_day: bool, pub categories: Vec, /// The RRULE of the series the component belongs to. pub rrule: Option, pub organizer: Option, pub attendees: Vec, } impl EventInfo { /// The owner's PARTSTAT, when they are an attendee. pub fn partstat(&self) -> Option<&str> { let a = self.attendees.iter().find(|a| a.is_owner)?; Some(a.partstat.as_deref().unwrap_or("NEEDS-ACTION")) } } pub fn is_item(c: &ICalendarComponent) -> bool { matches!( c.component_type, ICalendarComponentType::VEvent | ICalendarComponentType::VTodo | ICalendarComponentType::VJournal ) } fn text(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option { let v = c.property(prop)?.values.first()?.as_text()?.trim(); (!v.is_empty()).then(|| v.to_string()) } fn person(e: &ICalendarEntry) -> Option { let address = e.values.first()?.as_text()?.trim().to_string(); let name = e .parameter(&ICalendarParameterName::Cn) .and_then(|v| v.as_text()) .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()); Some(Person { name, address }) } /// What component `index` of `cal` says. `owner` recognises the calendar /// owner's addresses. pub fn event_info(cal: &ICalendar, index: usize, owner: Is) -> EventInfo { let Some(c) = cal.components.get(index) else { return EventInfo::default(); }; let master = cal .components .iter() .find(|m| { m.component_type == c.component_type && is_item(m) && !m.has_property(&ICalendarProperty::RecurrenceId) }) .unwrap_or(c); let param = |e: &ICalendarEntry, n: &ICalendarParameterName| { e.parameter(n) .and_then(|v| v.as_text()) .map(str::to_ascii_uppercase) }; EventInfo { component: c.component_type.as_str().to_string(), summary: text(c, &ICalendarProperty::Summary), description: text(c, &ICalendarProperty::Description), location: text(c, &ICalendarProperty::Location), url: text(c, &ICalendarProperty::Url), status: text(c, &ICalendarProperty::Status).map(|s| s.to_ascii_uppercase()), transparent: text(c, &ICalendarProperty::Transp) .is_some_and(|t| t.eq_ignore_ascii_case("TRANSPARENT")), all_day: c .property(&ICalendarProperty::Dtstart) .and_then(|e| e.values.first()?.as_partial_date_time()) .is_some_and(|d| d.hour.is_none()), categories: c .properties(&ICalendarProperty::Categories) .flat_map(|e| e.values.iter().filter_map(|v| v.as_text())) .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .collect(), rrule: master .property(&ICalendarProperty::Rrule) .and_then(|e| match e.values.first()? { ICalendarValue::RecurrenceRule(r) => Some(r.to_string()), _ => None, }), organizer: c.property(&ICalendarProperty::Organizer).and_then(person), attendees: c .properties(&ICalendarProperty::Attendee) .filter_map(|e| { let p = person(e)?; Some(Attendee { is_owner: owner(&p.address), partstat: param(e, &ICalendarParameterName::Partstat), role: param(e, &ICalendarParameterName::Role), person: p, }) }) .collect(), } } /// The component of `cal` that describes the instance at `recurrence_id`: /// its override, else the master. Without an instance, the master. pub fn component_for( cal: &ICalendar, recurrence_id: Option>, floating: &Zone, ) -> Option { let zones = Zones::new(cal, floating.clone()); let items = || { cal.components .iter() .enumerate() .filter(|(_, c)| is_item(c)) }; if let Some(rid) = recurrence_id { let found = items().find(|(_, c)| { c.property(&ICalendarProperty::RecurrenceId) .and_then(|e| instant(&zones, e)) .is_some_and(|t| t == rid) }); if let Some((i, _)) = found { return Some(i); } } items() .find(|(_, c)| !c.has_property(&ICalendarProperty::RecurrenceId)) .or_else(|| items().next()) .map(|(i, _)| i) } /// The instance at `recurrence_id`, else the first of a non-recurring /// object. `index` is the component [`component_for`] picked. pub fn instance_for( cal: &ICalendar, index: usize, recurrence_id: Option>, floating: &Zone, ) -> Option { let c = cal.components.get(index)?; let zones = Zones::new(cal, floating.clone()); let window = match recurrence_id { // A THISANDFUTURE override moves the later instances by its offset. Some(rid) if !c.has_property(&ICalendarProperty::RecurrenceId) => { let offsets: Vec = cal .components .iter() .filter_map(|o| { let r = o.property(&ICalendarProperty::RecurrenceId)?; r.parameter(&ICalendarParameterName::Range)?; let start = instant(&zones, o.property(&ICalendarProperty::Dtstart)?)?; Some(start - instant(&zones, r)?) }) .collect(); let low = offsets .iter() .copied() .min() .unwrap_or_default() .min(TimeDelta::zero()); let high = offsets .iter() .copied() .max() .unwrap_or_default() .max(TimeDelta::zero()); // A day each side: the move keeps wall-clock time, so a DST change // between the instances shifts it by up to an hour. let day = TimeDelta::days(1); rid + low - day..rid + high + day } // An override, or an object that does not recur, starts at its DTSTART. _ => { let at = instant(&zones, c.property(&ICalendarProperty::Dtstart)?)?; at..at + TimeDelta::seconds(1) } }; expand(cal, window, floating.clone()) .instances .into_iter() .find(|i| recurrence_id.is_none() || i.recurrence_id == recurrence_id) } /// A date or date-time property as a UTC instant. Floating times and dates /// are read in the floating zone of `zones`. pub fn instant(zones: &Zones, e: &ICalendarEntry) -> Option> { let v = e.values.first()?.as_partial_date_time()?; let dt = v.to_date_time()?; Some(match dt.offset { Some(o) if v.hour.is_some() => { (dt.date_time - chrono::TimeDelta::seconds(o.local_minus_utc().into())).and_utc() } _ if v.hour.is_none() => zones.floating().to_utc(dt.date_time), _ => zones.get(e.tz_id()).to_utc(dt.date_time), }) } #[derive(Debug, Clone, PartialEq)] pub struct Labeled { pub label: Option, pub value: String, } #[derive(Debug, Clone, PartialEq, Default)] pub struct Card { pub uid: Option, /// FN, else the name, the organization or the first email. pub full_name: String, pub org: Option, pub title: Option, pub emails: Vec, pub phones: Vec, pub addresses: Vec, pub urls: Vec, pub birthday: Option, pub anniversary: Option, pub note: Option, pub is_group: bool, /// Member UIDs, without `urn:uuid:`. pub members: Vec, /// An inline PHOTO. A URL photo does not count: the server never /// fetches it. pub has_photo: bool, } /// What a vCard 3 or 4 says, Apple's group and label forms included. pub fn card(vcard: &str) -> Card { let lines: Vec = logical_lines(vcard).into_iter().map(unfold).collect(); // Apple names a value by a sibling X-ABLABEL line, e.g. // `_$!!$_` or a free text. The first label of a group counts. let mut labels: HashMap = HashMap::new(); for o in lines.iter().filter(|o| prop(o) == "X-ABLABEL") { if let Some(g) = group(o) { labels.entry(g).or_insert_with(|| { let v = unescape_text(value(o)); let v = v.trim_start_matches("_$!<").trim_end_matches(">!$_"); v.to_ascii_lowercase() }); } } let label = |l: &str| { let apple = group(l).and_then(|g| labels.get(&g).cloned()); apple.or_else(|| { let types: Vec = param_parts(l) .into_iter() .filter_map(|p| { let (k, v) = p.split_once('=')?; k.trim().eq_ignore_ascii_case("TYPE").then_some(v) }) .flat_map(|v| v.trim_matches('"').split(',')) .map(|t| t.trim().to_ascii_lowercase()) .filter(|t| !["internet", "pref", "voice", "x400"].contains(&t.as_str())) .filter(|t| !t.is_empty()) .collect(); (!types.is_empty()).then(|| types.join(", ")) }) }; let first = |p: &str| { lines .iter() .find(|l| prop(l) == p) .map(|l| unescape_text(value(l)).trim().to_string()) .filter(|v| !v.is_empty()) }; let all = |p: &str, render: &dyn Fn(&str) -> String| { lines .iter() .filter(|l| prop(l) == p) .filter_map(|l| { let v = render(value(l)); (!v.trim().is_empty()).then(|| Labeled { label: label(l), value: v.trim().to_string(), }) }) .collect::>() }; let plain = |v: &str| unescape_text(v); let mailto = |v: &str| { let v = unescape_text(v); v.strip_prefix("mailto:").map(str::to_string).unwrap_or(v) }; let tel = |v: &str| { let v = unescape_text(v); v.strip_prefix("tel:").map(str::to_string).unwrap_or(v) }; let components = |v: &str| split_structured(v); let adr = |v: &str| { // PO box, extended, street, locality, region, code, country. let p = components(v); let at = |i: usize| { p.get(i) .map(String::as_str) .unwrap_or("") .trim() .to_string() }; let city = [at(5), at(3)] .into_iter() .filter(|s| !s.is_empty()) .collect::>() .join(" "); [at(0), at(1), at(2), city, at(4), at(6)] .into_iter() .filter(|s| !s.is_empty()) .collect::>() .join("\n") }; let kind = first("KIND").or_else(|| first("X-ADDRESSBOOKSERVER-KIND")); let is_group = kind.is_some_and(|k| k.eq_ignore_ascii_case("group")); let members = lines .iter() .filter(|l| { let p = prop(l); p == "MEMBER" || p == "X-ADDRESSBOOKSERVER-MEMBER" }) .map(|l| { let v = value(l).trim(); v.strip_prefix("urn:uuid:").unwrap_or(v).to_string() }) .collect(); let has_photo = lines.iter().any(|l| { prop(l) == "PHOTO" && (value(l).trim_start().starts_with("data:") || param(l, "ENCODING").is_some_and(|e| { e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("base64") })) }); let n = first("N").map(|_| { let raw = lines.iter().find(|l| prop(l) == "N").expect("found above"); let p = components(value(raw)); let at = |i: usize| { p.get(i) .map(String::as_str) .unwrap_or("") .trim() .to_string() }; [at(3), at(1), at(2), at(0), at(4)] .into_iter() .filter(|s| !s.is_empty()) .collect::>() .join(" ") }); let org = first("ORG").map(|_| { let raw = lines .iter() .find(|l| prop(l) == "ORG") .expect("found above"); components(value(raw)) .into_iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect::>() .join(", ") }); let emails = all("EMAIL", &mailto); let full_name = first("FN") .or_else(|| n.clone().filter(|n| !n.is_empty())) .or_else(|| org.clone().filter(|o| !o.is_empty())) .or_else(|| emails.first().map(|e| e.value.clone())) .unwrap_or_default(); let [birthday, anniversary] = crate::contact::special_dates(&lines).map(|d| { d.map(|(year, month, day)| match year { Some(y) => format!("{y:04}-{month:02}-{day:02}"), None => format!("--{month:02}-{day:02}"), }) }); Card { uid: first("UID").map(|u| u.strip_prefix("urn:uuid:").map(str::to_string).unwrap_or(u)), full_name, org: org.filter(|o| !o.is_empty()), title: first("TITLE"), emails, phones: all("TEL", &tel), addresses: all("ADR", &adr), urls: all("URL", &plain), birthday, anniversary, note: first("NOTE"), is_group, members, has_photo, } } /// The parts of a structured value (N, ADR, ORG): split at `;` that are not /// escaped, then unescaped. fn split_structured(v: &str) -> Vec { let mut parts = Vec::new(); let mut cur = String::new(); let mut chars = v.chars(); while let Some(c) = chars.next() { match c { '\\' => { cur.push(c); if let Some(n) = chars.next() { cur.push(n); } } ';' => parts.push(unescape_text(&std::mem::take(&mut cur))), _ => cur.push(c), } } parts.push(unescape_text(&cur)); parts }