//! What the server derives from contacts: their photos, and their birthdays //! and anniversaries as calendar events. use std::collections::HashSet; use calcard::vcard::{VCard, VCardProperty, VCardValue}; use chrono::NaiveDate; use sha2::{Digest, Sha256}; use crate::text::{ escape_text, fold, group, logical_lines, param, prop, unescape_text, unfold, value, }; /// The image of a contact's PHOTO: vCard 3 `ENCODING=b` or a vCard 4 `data:` /// URI. A photo given as a URL is `None`: fetching it would let any contact /// make the server request an address of its choice. pub fn photo(vcard: &str) -> Option> { let card = VCard::parse(vcard).ok()?; card.properties(&VCardProperty::Photo) .find_map(|e| match e.values.first()? { VCardValue::Binary(d) => Some(d.data.clone()), _ => None, }) } /// A contact's birthday and anniversary as `(uid, calendar object)` pairs: /// all-day, yearly, transparent. `key` names the contact for good, so its /// events keep their UIDs. /// /// The summary is `🎂 name`, or `💍 name` for an anniversary, with the year in /// brackets when it is known. The year stays constant, since one recurring /// event cannot carry a changing age. pub fn dates(vcard: &str, key: &str) -> Vec<(String, String)> { let lines: Vec = logical_lines(vcard).into_iter().map(unfold).collect(); let full_name = lines .iter() .find(|l| prop(l) == "FN") .map(|l| unescape_text(value(l))) .unwrap_or_default(); let [birthday, anniversary] = special_dates(&lines); [("BDAY", "🎂", birthday), ("ANNIVERSARY", "💍", anniversary)] .into_iter() .filter_map(|(what, sign, found)| { let (year, month, day) = found?; let hash = Sha256::digest(format!("{key}\0{what}")); let uid: String = hash[..16].iter().map(|b| format!("{b:02x}")).collect(); Some((uid.clone(), event(&uid, sign, &full_name, year, month, day))) }) .collect() } /// The first valid birthday and anniversary among a card's unfolded lines, /// as `(year, month, day)`. pub(crate) fn special_dates(lines: &[String]) -> [Option<(Option, u32, u32)>; 2] { // Apple writes an anniversary as X-ABDATE, labelled by a sibling line of // the same group. let labelled: HashSet = lines .iter() .filter(|o| prop(o) == "X-ABLABEL" && value(o).to_ascii_lowercase().contains("anniversary")) .filter_map(|o| group(o)) .collect(); let first = |wanted: &dyn Fn(&str) -> bool| lines.iter().filter(|l| wanted(l)).find_map(|l| date(l)); [ first(&|l| prop(l) == "BDAY"), first(&|l| { let p = prop(l); p == "ANNIVERSARY" || p == "X-ANNIVERSARY" || (p == "X-ABDATE" && group(l).is_some_and(|g| labelled.contains(&g))) }), ] } /// `(year, month, day)` of a date property: `19800315`, `1980-03-15`, /// `--0315` or `--03-15`, with or without a time. Apple's /// `X-APPLE-OMIT-YEAR` marks a placeholder year. fn date(line: &str) -> Option<(Option, u32, u32)> { if param(line, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("text")) { return None; } let v = value(line).trim(); let v = v.split(['T', 't']).next()?; // Byte offsets below: anything but ASCII digits would split a character. let digits = |s: &str, n: usize| s.len() == n && s.bytes().all(|b| b.is_ascii_digit()); let (year, md) = match v.strip_prefix("--") { Some(md) => (None, md.replace('-', "")), None => { let d = v.replace('-', ""); if !digits(&d, 8) { return None; } (Some(d[..4].parse().ok()?), d[4..].to_string()) } }; if !digits(&md, 4) { return None; } let (month, day) = (md[..2].parse().ok()?, md[2..].parse().ok()?); // 2000 is a leap year, so February 29 passes. NaiveDate::from_ymd_opt(2000, month, day)?; let year = year.filter(|_| param(line, "X-APPLE-OMIT-YEAR").is_none()); if let Some(y) = year { NaiveDate::from_ymd_opt(y, month, day)?; } Some((year, month, day)) } fn event(uid: &str, sign: &str, name: &str, year: Option, month: u32, day: u32) -> String { let leap_day = (month, day) == (2, 29); // Without a year the series starts in a year the date exists in. let start = year.unwrap_or(if leap_day { 1972 } else { 1970 }); // In other years a February 29 falls on the last day of February. let rule = if leap_day { "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1" } else { "FREQ=YEARLY" }; let summary = match year { Some(y) => format!("SUMMARY:{sign} {} ({y})", escape_text(name)), None => format!("SUMMARY:{sign} {}", escape_text(name)), }; let summary = fold(&summary, "\r\n"); // A fixed DTSTAMP keeps the ETag stable while the contact is unchanged. format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//dovenest//pimdav//EN\r\n\ BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:19700101T000000Z\r\n\ DTSTART;VALUE=DATE:{start:04}{month:02}{day:02}\r\nDURATION:P1D\r\n\ RRULE:{rule}\r\n{summary}TRANSP:TRANSPARENT\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n" ) }