view.rs
| 1 | //! Plain summaries of calendar objects and contacts, for a user interface. |
| 2 | |
| 3 | use calcard::icalendar::{ |
| 4 | ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName, |
| 5 | ICalendarProperty, ICalendarValue, |
| 6 | }; |
| 7 | use chrono::{DateTime, Utc}; |
| 8 | |
| 9 | use crate::itip::Is; |
| 10 | use crate::text::{logical_lines, name, param, param_parts, unescape_text, unfold, value}; |
| 11 | use crate::zone::{Zone, Zones}; |
| 12 | |
| 13 | #[derive(Debug, Clone, PartialEq)] |
| 14 | pub struct Person { |
| 15 | pub name: Option<String>, |
| 16 | pub address: String, |
| 17 | } |
| 18 | |
| 19 | #[derive(Debug, Clone, PartialEq)] |
| 20 | pub struct Attendee { |
| 21 | pub person: Person, |
| 22 | pub partstat: Option<String>, |
| 23 | pub role: Option<String>, |
| 24 | pub is_owner: bool, |
| 25 | } |
| 26 | |
| 27 | #[derive(Debug, Clone, PartialEq, Default)] |
| 28 | pub struct EventInfo { |
| 29 | pub component: String, |
| 30 | pub summary: Option<String>, |
| 31 | pub description: Option<String>, |
| 32 | pub location: Option<String>, |
| 33 | pub url: Option<String>, |
| 34 | pub status: Option<String>, |
| 35 | pub transparent: bool, |
| 36 | pub all_day: bool, |
| 37 | pub categories: Vec<String>, |
| 38 | /// The RRULE of the series the component belongs to. |
| 39 | pub rrule: Option<String>, |
| 40 | pub organizer: Option<Person>, |
| 41 | pub attendees: Vec<Attendee>, |
| 42 | } |
| 43 | |
| 44 | impl EventInfo { |
| 45 | /// The owner's PARTSTAT, when they are an attendee. |
| 46 | pub fn partstat(&self) -> Option<&str> { |
| 47 | let a = self.attendees.iter().find(|a| a.is_owner)?; |
| 48 | Some(a.partstat.as_deref().unwrap_or("NEEDS-ACTION")) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | pub fn is_item(c: &ICalendarComponent) -> bool { |
| 53 | matches!( |
| 54 | c.component_type, |
| 55 | ICalendarComponentType::VEvent |
| 56 | | ICalendarComponentType::VTodo |
| 57 | | ICalendarComponentType::VJournal |
| 58 | ) |
| 59 | } |
| 60 | |
| 61 | fn text(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<String> { |
| 62 | let v = c.property(prop)?.values.first()?.as_text()?.trim(); |
| 63 | (!v.is_empty()).then(|| v.to_string()) |
| 64 | } |
| 65 | |
| 66 | fn person(e: &ICalendarEntry) -> Option<Person> { |
| 67 | let address = e.values.first()?.as_text()?.trim().to_string(); |
| 68 | let name = e |
| 69 | .parameter(&ICalendarParameterName::Cn) |
| 70 | .and_then(|v| v.as_text()) |
| 71 | .map(|v| v.trim().to_string()) |
| 72 | .filter(|v| !v.is_empty()); |
| 73 | Some(Person { name, address }) |
| 74 | } |
| 75 | |
| 76 | /// What component `index` of `cal` says. `owner` recognises the calendar |
| 77 | /// owner's addresses. |
| 78 | pub fn event_info(cal: &ICalendar, index: usize, owner: Is) -> EventInfo { |
| 79 | let Some(c) = cal.components.get(index) else { |
| 80 | return EventInfo::default(); |
| 81 | }; |
| 82 | let master = cal |
| 83 | .components |
| 84 | .iter() |
| 85 | .find(|m| { |
| 86 | m.component_type == c.component_type |
| 87 | && is_item(m) |
| 88 | && !m.has_property(&ICalendarProperty::RecurrenceId) |
| 89 | }) |
| 90 | .unwrap_or(c); |
| 91 | let param = |e: &ICalendarEntry, n: &ICalendarParameterName| { |
| 92 | e.parameter(n) |
| 93 | .and_then(|v| v.as_text()) |
| 94 | .map(str::to_ascii_uppercase) |
| 95 | }; |
| 96 | EventInfo { |
| 97 | component: c.component_type.as_str().to_string(), |
| 98 | summary: text(c, &ICalendarProperty::Summary), |
| 99 | description: text(c, &ICalendarProperty::Description), |
| 100 | location: text(c, &ICalendarProperty::Location), |
| 101 | url: text(c, &ICalendarProperty::Url), |
| 102 | status: text(c, &ICalendarProperty::Status).map(|s| s.to_ascii_uppercase()), |
| 103 | transparent: text(c, &ICalendarProperty::Transp) |
| 104 | .is_some_and(|t| t.eq_ignore_ascii_case("TRANSPARENT")), |
| 105 | all_day: c |
| 106 | .property(&ICalendarProperty::Dtstart) |
| 107 | .and_then(|e| e.values.first()?.as_partial_date_time()) |
| 108 | .is_some_and(|d| d.hour.is_none()), |
| 109 | categories: c |
| 110 | .properties(&ICalendarProperty::Categories) |
| 111 | .flat_map(|e| e.values.iter().filter_map(|v| v.as_text())) |
| 112 | .map(|v| v.trim().to_string()) |
| 113 | .filter(|v| !v.is_empty()) |
| 114 | .collect(), |
| 115 | rrule: master |
| 116 | .property(&ICalendarProperty::Rrule) |
| 117 | .and_then(|e| match e.values.first()? { |
| 118 | ICalendarValue::RecurrenceRule(r) => Some(r.to_string()), |
| 119 | _ => None, |
| 120 | }), |
| 121 | organizer: c.property(&ICalendarProperty::Organizer).and_then(person), |
| 122 | attendees: c |
| 123 | .properties(&ICalendarProperty::Attendee) |
| 124 | .filter_map(|e| { |
| 125 | let p = person(e)?; |
| 126 | Some(Attendee { |
| 127 | is_owner: owner(&p.address), |
| 128 | partstat: param(e, &ICalendarParameterName::Partstat), |
| 129 | role: param(e, &ICalendarParameterName::Role), |
| 130 | person: p, |
| 131 | }) |
| 132 | }) |
| 133 | .collect(), |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | /// The component of `cal` that describes the instance at `recurrence_id`: |
| 138 | /// its override, else the master. Without an instance, the master. |
| 139 | pub fn component_for( |
| 140 | cal: &ICalendar, |
| 141 | recurrence_id: Option<DateTime<Utc>>, |
| 142 | floating: &Zone, |
| 143 | ) -> Option<usize> { |
| 144 | let zones = Zones::new(cal, floating.clone()); |
| 145 | let items = || { |
| 146 | cal.components |
| 147 | .iter() |
| 148 | .enumerate() |
| 149 | .filter(|(_, c)| is_item(c)) |
| 150 | }; |
| 151 | if let Some(rid) = recurrence_id { |
| 152 | let found = items().find(|(_, c)| { |
| 153 | c.property(&ICalendarProperty::RecurrenceId) |
| 154 | .and_then(|e| instant(&zones, e)) |
| 155 | .is_some_and(|t| t == rid) |
| 156 | }); |
| 157 | if let Some((i, _)) = found { |
| 158 | return Some(i); |
| 159 | } |
| 160 | } |
| 161 | items() |
| 162 | .find(|(_, c)| !c.has_property(&ICalendarProperty::RecurrenceId)) |
| 163 | .or_else(|| items().next()) |
| 164 | .map(|(i, _)| i) |
| 165 | } |
| 166 | |
| 167 | /// A date or date-time property as a UTC instant. Floating times and dates |
| 168 | /// are read in the floating zone of `zones`. |
| 169 | pub fn instant(zones: &Zones, e: &ICalendarEntry) -> Option<DateTime<Utc>> { |
| 170 | let v = e.values.first()?.as_partial_date_time()?; |
| 171 | let dt = v.to_date_time()?; |
| 172 | Some(match dt.offset { |
| 173 | Some(o) if v.hour.is_some() => { |
| 174 | (dt.date_time - chrono::TimeDelta::seconds(o.local_minus_utc().into())).and_utc() |
| 175 | } |
| 176 | _ if v.hour.is_none() => zones.floating().to_utc(dt.date_time), |
| 177 | _ => zones.get(e.tz_id()).to_utc(dt.date_time), |
| 178 | }) |
| 179 | } |
| 180 | |
| 181 | /// The first calendar item of an object in brief. |
| 182 | #[derive(Debug, Clone, PartialEq)] |
| 183 | pub struct Brief { |
| 184 | pub summary: Option<String>, |
| 185 | pub start: Option<DateTime<Utc>>, |
| 186 | pub all_day: bool, |
| 187 | pub recurring: bool, |
| 188 | } |
| 189 | |
| 190 | pub fn brief(cal: &ICalendar, floating: &Zone) -> Option<Brief> { |
| 191 | let i = component_for(cal, None, floating)?; |
| 192 | let c = &cal.components[i]; |
| 193 | let zones = Zones::new(cal, floating.clone()); |
| 194 | let start = c.property(&ICalendarProperty::Dtstart); |
| 195 | Some(Brief { |
| 196 | summary: text(c, &ICalendarProperty::Summary), |
| 197 | start: start.and_then(|e| instant(&zones, e)), |
| 198 | all_day: start |
| 199 | .and_then(|e| e.values.first()?.as_partial_date_time()) |
| 200 | .is_some_and(|d| d.hour.is_none()), |
| 201 | recurring: c.has_property(&ICalendarProperty::Rrule) |
| 202 | || c.has_property(&ICalendarProperty::Rdate), |
| 203 | }) |
| 204 | } |
| 205 | |
| 206 | #[derive(Debug, Clone, PartialEq)] |
| 207 | pub struct Labeled { |
| 208 | pub label: Option<String>, |
| 209 | pub value: String, |
| 210 | } |
| 211 | |
| 212 | #[derive(Debug, Clone, PartialEq, Default)] |
| 213 | pub struct Card { |
| 214 | pub uid: Option<String>, |
| 215 | /// FN, else the name, the organization or the first email. |
| 216 | pub full_name: String, |
| 217 | pub org: Option<String>, |
| 218 | pub title: Option<String>, |
| 219 | pub emails: Vec<Labeled>, |
| 220 | pub phones: Vec<Labeled>, |
| 221 | pub addresses: Vec<Labeled>, |
| 222 | pub urls: Vec<Labeled>, |
| 223 | pub birthday: Option<String>, |
| 224 | pub anniversary: Option<String>, |
| 225 | pub note: Option<String>, |
| 226 | pub is_group: bool, |
| 227 | /// Member UIDs, without `urn:uuid:`. |
| 228 | pub members: Vec<String>, |
| 229 | /// An inline PHOTO. A URL photo does not count: the server never |
| 230 | /// fetches it. |
| 231 | pub has_photo: bool, |
| 232 | } |
| 233 | |
| 234 | /// What a vCard 3 or 4 says, Apple's group and label forms included. |
| 235 | pub fn card(vcard: &str) -> Card { |
| 236 | let lines: Vec<String> = logical_lines(vcard).into_iter().map(unfold).collect(); |
| 237 | let prop = |l: &str| { |
| 238 | let n = name(l); |
| 239 | n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string()) |
| 240 | }; |
| 241 | let group = |l: &str| name(l).rsplit_once('.').map(|(g, _)| g.to_string()); |
| 242 | // Apple names a value by a sibling X-ABLABEL line, e.g. |
| 243 | // `_$!<Mobile>!$_` or a free text. |
| 244 | let label = |l: &str| { |
| 245 | let apple = group(l).and_then(|g| { |
| 246 | lines |
| 247 | .iter() |
| 248 | .find(|o| group(o).as_deref() == Some(g.as_str()) && prop(o) == "X-ABLABEL") |
| 249 | .map(|o| { |
| 250 | let v = unescape_text(value(o)); |
| 251 | let v = v.trim_start_matches("_$!<").trim_end_matches(">!$_"); |
| 252 | v.to_ascii_lowercase() |
| 253 | }) |
| 254 | }); |
| 255 | apple.or_else(|| { |
| 256 | let types: Vec<String> = param_parts(l) |
| 257 | .into_iter() |
| 258 | .filter_map(|p| { |
| 259 | let (k, v) = p.split_once('=')?; |
| 260 | k.trim().eq_ignore_ascii_case("TYPE").then_some(v) |
| 261 | }) |
| 262 | .flat_map(|v| v.trim_matches('"').split(',')) |
| 263 | .map(|t| t.trim().to_ascii_lowercase()) |
| 264 | .filter(|t| !["internet", "pref", "voice", "x400"].contains(&t.as_str())) |
| 265 | .filter(|t| !t.is_empty()) |
| 266 | .collect(); |
| 267 | (!types.is_empty()).then(|| types.join(", ")) |
| 268 | }) |
| 269 | }; |
| 270 | let first = |p: &str| { |
| 271 | lines |
| 272 | .iter() |
| 273 | .find(|l| prop(l) == p) |
| 274 | .map(|l| unescape_text(value(l)).trim().to_string()) |
| 275 | .filter(|v| !v.is_empty()) |
| 276 | }; |
| 277 | let all = |p: &str, render: &dyn Fn(&str) -> String| { |
| 278 | lines |
| 279 | .iter() |
| 280 | .filter(|l| prop(l) == p) |
| 281 | .filter_map(|l| { |
| 282 | let v = render(value(l)); |
| 283 | (!v.trim().is_empty()).then(|| Labeled { |
| 284 | label: label(l), |
| 285 | value: v.trim().to_string(), |
| 286 | }) |
| 287 | }) |
| 288 | .collect::<Vec<_>>() |
| 289 | }; |
| 290 | let plain = |v: &str| unescape_text(v); |
| 291 | let mailto = |v: &str| { |
| 292 | let v = unescape_text(v); |
| 293 | v.strip_prefix("mailto:").map(str::to_string).unwrap_or(v) |
| 294 | }; |
| 295 | let tel = |v: &str| { |
| 296 | let v = unescape_text(v); |
| 297 | v.strip_prefix("tel:").map(str::to_string).unwrap_or(v) |
| 298 | }; |
| 299 | let components = |v: &str| split_structured(v); |
| 300 | let adr = |v: &str| { |
| 301 | // PO box, extended, street, locality, region, code, country. |
| 302 | let p = components(v); |
| 303 | let at = |i: usize| { |
| 304 | p.get(i) |
| 305 | .map(String::as_str) |
| 306 | .unwrap_or("") |
| 307 | .trim() |
| 308 | .to_string() |
| 309 | }; |
| 310 | let city = [at(5), at(3)] |
| 311 | .into_iter() |
| 312 | .filter(|s| !s.is_empty()) |
| 313 | .collect::<Vec<_>>() |
| 314 | .join(" "); |
| 315 | [at(0), at(1), at(2), city, at(4), at(6)] |
| 316 | .into_iter() |
| 317 | .filter(|s| !s.is_empty()) |
| 318 | .collect::<Vec<_>>() |
| 319 | .join("\n") |
| 320 | }; |
| 321 | let kind = first("KIND").or_else(|| first("X-ADDRESSBOOKSERVER-KIND")); |
| 322 | let is_group = kind.is_some_and(|k| k.eq_ignore_ascii_case("group")); |
| 323 | let members = lines |
| 324 | .iter() |
| 325 | .filter(|l| { |
| 326 | let p = prop(l); |
| 327 | p == "MEMBER" || p == "X-ADDRESSBOOKSERVER-MEMBER" |
| 328 | }) |
| 329 | .map(|l| { |
| 330 | let v = value(l).trim(); |
| 331 | v.strip_prefix("urn:uuid:").unwrap_or(v).to_string() |
| 332 | }) |
| 333 | .collect(); |
| 334 | let has_photo = lines.iter().any(|l| { |
| 335 | prop(l) == "PHOTO" |
| 336 | && (value(l).trim_start().starts_with("data:") |
| 337 | || param(l, "ENCODING").is_some_and(|e| { |
| 338 | e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("base64") |
| 339 | })) |
| 340 | }); |
| 341 | let n = first("N").map(|_| { |
| 342 | let raw = lines.iter().find(|l| prop(l) == "N").expect("found above"); |
| 343 | let p = components(value(raw)); |
| 344 | let at = |i: usize| { |
| 345 | p.get(i) |
| 346 | .map(String::as_str) |
| 347 | .unwrap_or("") |
| 348 | .trim() |
| 349 | .to_string() |
| 350 | }; |
| 351 | [at(3), at(1), at(2), at(0), at(4)] |
| 352 | .into_iter() |
| 353 | .filter(|s| !s.is_empty()) |
| 354 | .collect::<Vec<_>>() |
| 355 | .join(" ") |
| 356 | }); |
| 357 | let org = first("ORG").map(|_| { |
| 358 | let raw = lines |
| 359 | .iter() |
| 360 | .find(|l| prop(l) == "ORG") |
| 361 | .expect("found above"); |
| 362 | components(value(raw)) |
| 363 | .into_iter() |
| 364 | .map(|s| s.trim().to_string()) |
| 365 | .filter(|s| !s.is_empty()) |
| 366 | .collect::<Vec<_>>() |
| 367 | .join(", ") |
| 368 | }); |
| 369 | let emails = all("EMAIL", &mailto); |
| 370 | let full_name = first("FN") |
| 371 | .or_else(|| n.clone().filter(|n| !n.is_empty())) |
| 372 | .or_else(|| org.clone().filter(|o| !o.is_empty())) |
| 373 | .or_else(|| emails.first().map(|e| e.value.clone())) |
| 374 | .unwrap_or_default(); |
| 375 | Card { |
| 376 | uid: first("UID").map(|u| u.strip_prefix("urn:uuid:").map(str::to_string).unwrap_or(u)), |
| 377 | full_name, |
| 378 | org: org.filter(|o| !o.is_empty()), |
| 379 | title: first("TITLE"), |
| 380 | emails, |
| 381 | phones: all("TEL", &tel), |
| 382 | addresses: all("ADR", &adr), |
| 383 | urls: all("URL", &plain), |
| 384 | birthday: lines |
| 385 | .iter() |
| 386 | .find(|l| prop(l) == "BDAY") |
| 387 | .and_then(|l| date(value(l))), |
| 388 | anniversary: lines |
| 389 | .iter() |
| 390 | .find(|l| prop(l) == "ANNIVERSARY" || prop(l) == "X-ANNIVERSARY") |
| 391 | .and_then(|l| date(value(l))), |
| 392 | note: first("NOTE"), |
| 393 | is_group, |
| 394 | members, |
| 395 | has_photo, |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | /// `1980-03-15`, or `--03-15` without a year, from the date forms vCard 3 |
| 400 | /// and 4 use. |
| 401 | fn date(v: &str) -> Option<String> { |
| 402 | let v = v.trim().split(['T', 't']).next()?; |
| 403 | match v.strip_prefix("--") { |
| 404 | Some(md) => { |
| 405 | let md = md.replace('-', ""); |
| 406 | (md.len() == 4).then(|| format!("--{}-{}", &md[..2], &md[2..])) |
| 407 | } |
| 408 | None => { |
| 409 | let d = v.replace('-', ""); |
| 410 | (d.len() == 8 && d.bytes().all(|b| b.is_ascii_digit())) |
| 411 | .then(|| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..])) |
| 412 | } |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | /// The parts of a structured value (N, ADR, ORG): split at `;` that are not |
| 417 | /// escaped, then unescaped. |
| 418 | fn split_structured(v: &str) -> Vec<String> { |
| 419 | let mut parts = Vec::new(); |
| 420 | let mut cur = String::new(); |
| 421 | let mut chars = v.chars(); |
| 422 | while let Some(c) = chars.next() { |
| 423 | match c { |
| 424 | '\\' => { |
| 425 | cur.push(c); |
| 426 | if let Some(n) = chars.next() { |
| 427 | cur.push(n); |
| 428 | } |
| 429 | } |
| 430 | ';' => parts.push(unescape_text(&std::mem::take(&mut cur))), |
| 431 | _ => cur.push(c), |
| 432 | } |
| 433 | } |
| 434 | parts.push(unescape_text(&cur)); |
| 435 | parts |
| 436 | } |
| 437 |