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