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 | ] { |
| 67 | let (_, ics) = &contact::dates(&card(yearless), "k")[0]; |
| 68 | assert!(ics.contains("SUMMARY:๐ Anna Berg\r\n"), "{yearless}"); |
| 69 | assert_eq!( |
| 70 | days(ics, "2025-01-01", "2026-01-01"), |
| 71 | ["2025-03-15"], |
| 72 | "{yearless}" |
| 73 | ); |
| 74 | } |
| 75 | assert!(contact::dates(&card("BDAY;VALUE=text:circa 1800\n"), "k").is_empty()); |
| 76 | assert!(contact::dates(&card("BDAY:1981-02-29\n"), "k").is_empty()); |
| 77 | } |
| 78 | |
| 79 | #[test] |
| 80 | fn leap_day_birthday() { |
| 81 | let (_, ics) = &contact::dates(&card("BDAY:1980-02-29\n"), "k")[0]; |
| 82 | assert_eq!( |
| 83 | days(ics, "2027-01-01", "2029-01-01"), |
| 84 | ["2027-02-28", "2028-02-29"] |
| 85 | ); |
| 86 | } |
| 87 | |
| 88 | #[test] |
| 89 | fn anniversaries() { |
| 90 | 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"; |
| 91 | let both = contact::dates(v4, "k"); |
| 92 | assert_eq!(both.len(), 2); |
| 93 | assert!(both[1].1.contains("SUMMARY:๐ Anna (2001)\r\n")); |
| 94 | assert_ne!(both[0].0, both[1].0); |
| 95 | |
| 96 | let evolution = contact::dates(&card("X-ANNIVERSARY:2001-06-01\n"), "k"); |
| 97 | assert!(evolution[0].1.contains("๐")); |
| 98 | |
| 99 | let apple = card( |
| 100 | "item1.X-ABDATE:2001-06-01\nitem1.X-ABLabel:_$!<Anniversary>!$_\nitem2.X-ABDATE:2010-01-01\nitem2.X-ABLabel:Other\n", |
| 101 | ); |
| 102 | let dates = contact::dates(&apple, "k"); |
| 103 | assert_eq!(dates.len(), 1); |
| 104 | assert!(dates[0].1.contains("๐ Anna Berg (2001)")); |
| 105 | } |
| 106 | |
| 107 | #[test] |
| 108 | fn non_ascii_dates_are_ignored() { |
| 109 | // Byte offsets on these would split a character. |
| 110 | for bday in ["BDAY:1980ๅนด3\n", "BDAY:--0ๅนด\n", "BDAY:รฉ1234567\n"] { |
| 111 | assert!(contact::dates(&card(bday), "k").is_empty(), "{bday}"); |
| 112 | assert_eq!(pimdav::view::card(&card(bday)).birthday, None, "{bday}"); |
| 113 | } |
| 114 | // A line without a value that ends in a multi-byte character. |
| 115 | let odd = card("X-NOTE;รฉ\nBDAY:1980-03-15\n"); |
| 116 | let v4 = pimdav::render::AddressData { |
| 117 | props: None, |
| 118 | version: Some(pimdav::calcard::vcard::VCardVersion::V4_0), |
| 119 | }; |
| 120 | assert!(pimdav::render::address_data(&odd, &v4).contains("BEGIN:VCARD")); |
| 121 | } |
| 122 | |
| 123 | #[test] |
| 124 | fn many_apple_dates_stay_linear() { |
| 125 | let mut lines = String::new(); |
| 126 | for n in 0..20_000 { |
| 127 | lines.push_str(&format!("g{n}.X-ABDATE:2000-01-01\n")); |
| 128 | } |
| 129 | lines.push_str("g7.X-ABLABEL:_$!<Anniversary>!$_\n"); |
| 130 | let started = std::time::Instant::now(); |
| 131 | let dates = contact::dates(&card(&lines), "k"); |
| 132 | assert!( |
| 133 | started.elapsed().as_secs_f64() < 2.0, |
| 134 | "{:?}", |
| 135 | started.elapsed() |
| 136 | ); |
| 137 | assert_eq!(dates.len(), 1); |
| 138 | } |
| 139 |