contact.rs
⎇
Raw
1//! What the server derives from contacts: their photos, and their birthdays
2//! and anniversaries as calendar events.
3
4use std::collections::HashSet;
5
6use calcard::vcard::{VCard, VCardProperty, VCardValue};
7use chrono::NaiveDate;
8use sha2::{Digest, Sha256};
9
10use 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.
15pub 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.
31pub 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.
76fn 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
107fn 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