contact.rs
| 1 | //! Photos and birthday events derived from contacts. |
| 2 | |
| 3 | use chrono::{DateTime, Utc}; |
| 4 | use pimdav::calcard::icalendar::ICalendar; |
| 5 | use pimdav::contact; |
| 6 | use pimdav::expand::expand; |
| 7 | use pimdav::zone::Zone; |
| 8 | |
| 9 | fn card(lines: &str) -> String { |
| 10 | format!( |
| 11 | "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Anna Berg\r\n{}END:VCARD\r\n", |
| 12 | lines.replace('\n', "\r\n") |
| 13 | ) |
| 14 | } |
| 15 | |
| 16 | fn utc(s: &str) -> DateTime<Utc> { |
| 17 | format!("{s}T00:00:00Z").parse().unwrap() |
| 18 | } |
| 19 | |
| 20 | /// The start dates of an event's instances in a range. |
| 21 | fn days(ics: &str, from: &str, to: &str) -> Vec<String> { |
| 22 | let cal = ICalendar::parse(ics).unwrap(); |
| 23 | expand(&cal, utc(from)..utc(to), Zone::Utc) |
| 24 | .instances |
| 25 | .iter() |
| 26 | .map(|i| i.start.format("%Y-%m-%d").to_string()) |
| 27 | .collect() |
| 28 | } |
| 29 | |
| 30 | #[test] |
| 31 | fn photo_forms() { |
| 32 | let v3 = card("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ\n"); |
| 33 | assert_eq!( |
| 34 | contact::photo(&v3), |
| 35 | Some(vec![0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]) |
| 36 | ); |
| 37 | let v4 = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:c\r\nFN:X\r\nPHOTO:data:image/png;base64,iVBORw0K\r\nEND:VCARD\r\n"; |
| 38 | assert_eq!(contact::photo(v4).unwrap()[..4], [0x89, b'P', b'N', b'G']); |
| 39 | assert_eq!( |
| 40 | contact::photo(&card("PHOTO;VALUE=uri:http://example.com/a.jpg\n")), |
| 41 | None |
| 42 | ); |
| 43 | assert_eq!(contact::photo(&card("")), None); |
| 44 | } |
| 45 | |
| 46 | #[test] |
| 47 | fn birthday_forms() { |
| 48 | let full = contact::dates(&card("BDAY:1980-03-15\n"), "k"); |
| 49 | assert_eq!(full.len(), 1); |
| 50 | let (uid, ics) = &full[0]; |
| 51 | assert!(ics.contains("SUMMARY:๐ Anna Berg (1980)\r\n")); |
| 52 | assert!(ics.contains("TRANSP:TRANSPARENT\r\n")); |
| 53 | assert_eq!( |
| 54 | days(ics, "2025-01-01", "2027-01-01"), |
| 55 | ["2025-03-15", "2026-03-15"] |
| 56 | ); |
| 57 | |
| 58 | // Stable per key and kind, different for another key. |
| 59 | assert_eq!(contact::dates(&card("BDAY:19800315\n"), "k")[0].0, *uid); |
| 60 | assert_ne!(contact::dates(&card("BDAY:19800315\n"), "other")[0].0, *uid); |
| 61 | |
| 62 | for yearless in [ |
| 63 | "BDAY:--0315\n", |
| 64 | "BDAY:--03-15\n", |
| 65 | "BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15\n", |
| 66 | "BDAY:1604-03-15\n", |
| 67 | "BDAY:0000-03-15\n", |
| 68 | ] { |
| 69 | let (_, ics) = &contact::dates(&card(yearless), "k")[0]; |
| 70 | assert!(ics.contains("SUMMARY:๐ Anna Berg\r\n"), "{yearless}"); |
| 71 | assert_eq!( |
| 72 | days(ics, "2025-01-01", "2026-01-01"), |
| 73 | ["2025-03-15"], |
| 74 | "{yearless}" |
| 75 | ); |
| 76 | } |
| 77 | assert!(contact::dates(&card("BDAY;VALUE=text:circa 1800\n"), "k").is_empty()); |
| 78 | let (_, ics) = &contact::dates(&card("BDAY:1900-02-29\n"), "k")[0]; |
| 79 | assert!(ics.contains("SUMMARY:๐ Anna Berg\r\n")); |
| 80 | assert!(ics.contains("DTSTART;VALUE=DATE:19720229")); |
| 81 | } |
| 82 | |
| 83 | #[test] |
| 84 | fn leap_day_birthday() { |
| 85 | let (_, ics) = &contact::dates(&card("BDAY:1980-02-29\n"), "k")[0]; |
| 86 | assert_eq!( |
| 87 | days(ics, "2027-01-01", "2029-01-01"), |
| 88 | ["2027-02-28", "2028-02-29"] |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | #[test] |
| 93 | fn anniversaries() { |
| 94 | let v4 = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:c\r\nFN:Anna\r\nBDAY:--0315\r\nANNIVERSARY:20010601\r\nEND:VCARD\r\n"; |
| 95 | let both = contact::dates(v4, "k"); |
| 96 | assert_eq!(both.len(), 2); |
| 97 | assert!(both[1].1.contains("SUMMARY:๐ Anna (2001)\r\n")); |
| 98 | assert_ne!(both[0].0, both[1].0); |
| 99 | |
| 100 | let evolution = contact::dates(&card("X-ANNIVERSARY:2001-06-01\n"), "k"); |
| 101 | assert!(evolution[0].1.contains("๐")); |
| 102 | |
| 103 | let apple = card( |
| 104 | "item1.X-ABDATE:2001-06-01\nitem1.X-ABLabel:_$!<Anniversary>!$_\nitem2.X-ABDATE:2010-01-01\nitem2.X-ABLabel:Other\n", |
| 105 | ); |
| 106 | let dates = contact::dates(&apple, "k"); |
| 107 | assert_eq!(dates.len(), 1); |
| 108 | assert!(dates[0].1.contains("๐ Anna Berg (2001)")); |
| 109 | } |
| 110 | |
| 111 | #[test] |
| 112 | fn non_ascii_dates_are_ignored() { |
| 113 | // Byte offsets on these would split a character. |
| 114 | for bday in ["BDAY:1980ๅนด3\n", "BDAY:--0ๅนด\n", "BDAY:รฉ1234567\n"] { |
| 115 | assert!(contact::dates(&card(bday), "k").is_empty(), "{bday}"); |
| 116 | assert_eq!(pimdav::view::card(&card(bday)).birthday, None, "{bday}"); |
| 117 | } |
| 118 | // A line without a value that ends in a multi-byte character. |
| 119 | let odd = card("X-NOTE;รฉ\nBDAY:1980-03-15\n"); |
| 120 | let v4 = pimdav::render::AddressData { |
| 121 | props: None, |
| 122 | version: Some(pimdav::calcard::vcard::VCardVersion::V4_0), |
| 123 | }; |
| 124 | assert!(pimdav::render::address_data(&odd, &v4).contains("BEGIN:VCARD")); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn many_apple_dates_stay_linear() { |
| 129 | let mut lines = String::new(); |
| 130 | for n in 0..20_000 { |
| 131 | lines.push_str(&format!("g{n}.X-ABDATE:2000-01-01\n")); |
| 132 | } |
| 133 | lines.push_str("g7.X-ABLABEL:_$!<Anniversary>!$_\n"); |
| 134 | let started = std::time::Instant::now(); |
| 135 | let dates = contact::dates(&card(&lines), "k"); |
| 136 | assert!( |
| 137 | started.elapsed().as_secs_f64() < 2.0, |
| 138 | "{:?}", |
| 139 | started.elapsed() |
| 140 | ); |
| 141 | assert_eq!(dates.len(), 1); |
| 142 | } |
| 143 | |
| 144 | #[test] |
| 145 | fn the_card_view_reads_apple_dates() { |
| 146 | let apple = card( |
| 147 | "BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15\nitem1.X-ABDATE:2001-06-01\nitem1.X-ABLabel:_$!<Anniversary>!$_\n", |
| 148 | ); |
| 149 | let view = pimdav::view::card(&apple); |
| 150 | assert_eq!(view.birthday.as_deref(), Some("--03-15")); |
| 151 | assert_eq!(view.anniversary.as_deref(), Some("2001-06-01")); |
| 152 | let plain = pimdav::view::card(&card("BDAY:19800315\n")); |
| 153 | assert_eq!(plain.birthday.as_deref(), Some("1980-03-15")); |
| 154 | let android = pimdav::view::card(&card("BDAY:0000-03-15\n")); |
| 155 | assert_eq!(android.birthday.as_deref(), Some("--03-15")); |
| 156 | } |
| 157 | |
| 158 | #[test] |
| 159 | fn many_labelled_emails_stay_linear() { |
| 160 | let mut lines = String::new(); |
| 161 | for n in 0..5000 { |
| 162 | lines.push_str(&format!("item{n}.EMAIL:u{n}@x\nitem{n}.X-ABLabel:work\n")); |
| 163 | } |
| 164 | let started = std::time::Instant::now(); |
| 165 | let view = pimdav::view::card(&card(&lines)); |
| 166 | assert!( |
| 167 | started.elapsed().as_secs_f64() < 2.0, |
| 168 | "{:?}", |
| 169 | started.elapsed() |
| 170 | ); |
| 171 | assert_eq!(view.emails.len(), 5000); |
| 172 | assert_eq!(view.emails[4999].label.as_deref(), Some("work")); |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn vcard_2_1_bare_types_label_values() { |
| 177 | let view = pimdav::view::card(&card( |
| 178 | "TEL;CELL:+49 1\nEMAIL;HOME;INTERNET:a@x\nTEL;WORK;QUOTED-PRINTABLE:+49 2\n\ |
| 179 | EMAIL;OTHER;CHARSET=UTF-8;8BIT:b@x\n", |
| 180 | )); |
| 181 | assert_eq!(view.phones[0].label.as_deref(), Some("cell")); |
| 182 | assert_eq!(view.emails[0].label.as_deref(), Some("home")); |
| 183 | assert_eq!(view.phones[1].label.as_deref(), Some("work")); |
| 184 | assert_eq!(view.emails[1].label.as_deref(), Some("other")); |
| 185 | } |
| 186 |