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