//! Plain summaries of calendar objects and contacts, for a user interface. use calcard::icalendar::{ ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName, ICalendarProperty, ICalendarValue, }; use chrono::{DateTime, Utc}; use crate::itip::Is; use crate::text::{logical_lines, name, param, param_parts, 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) } /// 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), }) } /// The first calendar item of an object in brief. #[derive(Debug, Clone, PartialEq)] pub struct Brief { pub summary: Option, pub start: Option>, pub all_day: bool, pub recurring: bool, } pub fn brief(cal: &ICalendar, floating: &Zone) -> Option { let i = component_for(cal, None, floating)?; let c = &cal.components[i]; let zones = Zones::new(cal, floating.clone()); let start = c.property(&ICalendarProperty::Dtstart); Some(Brief { summary: text(c, &ICalendarProperty::Summary), start: start.and_then(|e| instant(&zones, e)), all_day: start .and_then(|e| e.values.first()?.as_partial_date_time()) .is_some_and(|d| d.hour.is_none()), recurring: c.has_property(&ICalendarProperty::Rrule) || c.has_property(&ICalendarProperty::Rdate), }) } #[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(); let prop = |l: &str| { let n = name(l); n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string()) }; let group = |l: &str| name(l).rsplit_once('.').map(|(g, _)| g.to_string()); // Apple names a value by a sibling X-ABLABEL line, e.g. // `_$!!$_` or a free text. let label = |l: &str| { let apple = group(l).and_then(|g| { lines .iter() .find(|o| group(o).as_deref() == Some(g.as_str()) && prop(o) == "X-ABLABEL") .map(|o| { let v = unescape_text(value(o)); let v = v.trim_start_matches("_$!<").trim_end_matches(">!$_"); v.to_ascii_lowercase() }) }); 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(); 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: lines .iter() .find(|l| prop(l) == "BDAY") .and_then(|l| date(value(l))), anniversary: lines .iter() .find(|l| prop(l) == "ANNIVERSARY" || prop(l) == "X-ANNIVERSARY") .and_then(|l| date(value(l))), note: first("NOTE"), is_group, members, has_photo, } } /// `1980-03-15`, or `--03-15` without a year, from the date forms vCard 3 /// and 4 use. fn date(v: &str) -> Option { let v = v.trim().split(['T', 't']).next()?; match v.strip_prefix("--") { Some(md) => { let md = md.replace('-', ""); (md.len() == 4).then(|| format!("--{}-{}", &md[..2], &md[2..])) } None => { let d = v.replace('-', ""); (d.len() == 8 && d.bytes().all(|b| b.is_ascii_digit())) .then(|| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..])) } } } /// 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 }