contact.rs
| 1 | //! What the server derives from contacts: their photos, and their birthdays |
| 2 | //! and anniversaries as calendar events. |
| 3 | |
| 4 | use std::collections::HashSet; |
| 5 | |
| 6 | use calcard::vcard::{VCard, VCardProperty, VCardValue}; |
| 7 | use chrono::NaiveDate; |
| 8 | use sha2::{Digest, Sha256}; |
| 9 | |
| 10 | use crate::text::{escape_text, fold, logical_lines, name, param, unescape_text, unfold, value}; |
| 11 | |
| 12 | /// The image of a contact's PHOTO: vCard 3 `ENCODING=b` or a vCard 4 `data:` |
| 13 | /// URI. A photo given as a URL is `None`: fetching it would let any contact |
| 14 | /// make the server request an address of its choice. |
| 15 | pub fn photo(vcard: &str) -> Option<Vec<u8>> { |
| 16 | let card = VCard::parse(vcard).ok()?; |
| 17 | card.properties(&VCardProperty::Photo) |
| 18 | .find_map(|e| match e.values.first()? { |
| 19 | VCardValue::Binary(d) => Some(d.data.clone()), |
| 20 | _ => None, |
| 21 | }) |
| 22 | } |
| 23 | |
| 24 | /// A contact's birthday and anniversary as `(uid, calendar object)` pairs: |
| 25 | /// all-day, yearly, transparent. `key` names the contact for good, so its |
| 26 | /// events keep their UIDs. |
| 27 | /// |
| 28 | /// The summary is `🎂 name`, or `💍 name` for an anniversary, with the year in |
| 29 | /// brackets when it is known. The year stays constant, since one recurring |
| 30 | /// event cannot carry a changing age. |
| 31 | pub fn dates(vcard: &str, key: &str) -> Vec<(String, String)> { |
| 32 | let lines: Vec<String> = logical_lines(vcard).into_iter().map(unfold).collect(); |
| 33 | let prop = |l: &str| { |
| 34 | let n = name(l); |
| 35 | n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string()) |
| 36 | }; |
| 37 | let group = |l: &str| name(l).rsplit_once('.').map(|(g, _)| g.to_string()); |
| 38 | let full_name = lines |
| 39 | .iter() |
| 40 | .find(|l| prop(l) == "FN") |
| 41 | .map(|l| unescape_text(value(l))) |
| 42 | .unwrap_or_default(); |
| 43 | // Apple writes an anniversary as X-ABDATE, labelled by a sibling line of |
| 44 | // the same group. |
| 45 | let labelled: HashSet<String> = lines |
| 46 | .iter() |
| 47 | .filter(|o| prop(o) == "X-ABLABEL" && value(o).to_ascii_lowercase().contains("anniversary")) |
| 48 | .filter_map(|o| group(o)) |
| 49 | .collect(); |
| 50 | let apple_anniversary = |l: &str| group(l).is_some_and(|g| labelled.contains(&g)); |
| 51 | let first = |wanted: &dyn Fn(&str) -> bool| { |
| 52 | lines |
| 53 | .iter() |
| 54 | .filter(|l| wanted(l)) |
| 55 | .find_map(|l| date(l).map(|d| (d, l.as_str()))) |
| 56 | }; |
| 57 | let birthday = first(&|l| prop(l) == "BDAY"); |
| 58 | let anniversary = first(&|l| { |
| 59 | let p = prop(l); |
| 60 | p == "ANNIVERSARY" || p == "X-ANNIVERSARY" || (p == "X-ABDATE" && apple_anniversary(l)) |
| 61 | }); |
| 62 | [("BDAY", "🎂", birthday), ("ANNIVERSARY", "💍", anniversary)] |
| 63 | .into_iter() |
| 64 | .filter_map(|(what, sign, found)| { |
| 65 | let ((year, month, day), _) = found?; |
| 66 | let hash = Sha256::digest(format!("{key}\0{what}")); |
| 67 | let uid: String = hash[..16].iter().map(|b| format!("{b:02x}")).collect(); |
| 68 | Some((uid.clone(), event(&uid, sign, &full_name, year, month, day))) |
| 69 | }) |
| 70 | .collect() |
| 71 | } |
| 72 | |
| 73 | /// `(year, month, day)` of a date property: `19800315`, `1980-03-15`, |
| 74 | /// `--0315` or `--03-15`, with or without a time. Apple's |
| 75 | /// `X-APPLE-OMIT-YEAR` marks a placeholder year. |
| 76 | fn date(line: &str) -> Option<(Option<i32>, u32, u32)> { |
| 77 | if param(line, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("text")) { |
| 78 | return None; |
| 79 | } |
| 80 | let v = value(line).trim(); |
| 81 | let v = v.split(['T', 't']).next()?; |
| 82 | // Byte offsets below: anything but ASCII digits would split a character. |
| 83 | let digits = |s: &str, n: usize| s.len() == n && s.bytes().all(|b| b.is_ascii_digit()); |
| 84 | let (year, md) = match v.strip_prefix("--") { |
| 85 | Some(md) => (None, md.replace('-', "")), |
| 86 | None => { |
| 87 | let d = v.replace('-', ""); |
| 88 | if !digits(&d, 8) { |
| 89 | return None; |
| 90 | } |
| 91 | (Some(d[..4].parse().ok()?), d[4..].to_string()) |
| 92 | } |
| 93 | }; |
| 94 | if !digits(&md, 4) { |
| 95 | return None; |
| 96 | } |
| 97 | let (month, day) = (md[..2].parse().ok()?, md[2..].parse().ok()?); |
| 98 | // 2000 is a leap year, so February 29 passes. |
| 99 | NaiveDate::from_ymd_opt(2000, month, day)?; |
| 100 | let year = year.filter(|_| param(line, "X-APPLE-OMIT-YEAR").is_none()); |
| 101 | if let Some(y) = year { |
| 102 | NaiveDate::from_ymd_opt(y, month, day)?; |
| 103 | } |
| 104 | Some((year, month, day)) |
| 105 | } |
| 106 | |
| 107 | fn event(uid: &str, sign: &str, name: &str, year: Option<i32>, month: u32, day: u32) -> String { |
| 108 | let leap_day = (month, day) == (2, 29); |
| 109 | // Without a year the series starts in a year the date exists in. |
| 110 | let start = year.unwrap_or(if leap_day { 1972 } else { 1970 }); |
| 111 | // In other years a February 29 falls on the last day of February. |
| 112 | let rule = if leap_day { |
| 113 | "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1" |
| 114 | } else { |
| 115 | "FREQ=YEARLY" |
| 116 | }; |
| 117 | let summary = match year { |
| 118 | Some(y) => format!("SUMMARY:{sign} {} ({y})", escape_text(name)), |
| 119 | None => format!("SUMMARY:{sign} {}", escape_text(name)), |
| 120 | }; |
| 121 | let summary = fold(&summary, "\r\n"); |
| 122 | // A fixed DTSTAMP keeps the ETag stable while the contact is unchanged. |
| 123 | format!( |
| 124 | "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//dovenest//pimdav//EN\r\n\ |
| 125 | BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:19700101T000000Z\r\n\ |
| 126 | DTSTART;VALUE=DATE:{start:04}{month:02}{day:02}\r\nDURATION:P1D\r\n\ |
| 127 | RRULE:{rule}\r\n{summary}TRANSP:TRANSPARENT\r\n\ |
| 128 | END:VEVENT\r\nEND:VCALENDAR\r\n" |
| 129 | ) |
| 130 | } |
| 131 |