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