From a5b092a26fa9d6587f9c285794978ef24f5dc761 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 16 Apr 2024 19:11:15 +0200 Subject: [PATCH 01/10] Add basic parsing code --- deltachat-contact-tools/src/lib.rs | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 385a18a779..b954ea67b2 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -33,6 +33,99 @@ use anyhow::Result; use once_cell::sync::Lazy; use regex::Regex; +// TODOs to clean up: +// - Check if sanitizing is done correctly everywhere +// - Apply lints everywhere (https://doc.rust-lang.org/cargo/reference/workspaces.html#the-lints-table) + +#[derive(Debug, Default)] +pub struct VcardContact { + pub addr: String, + pub display_name: String, + pub key: Option, + pub profile_photo: Option, + pub timestamp: u64, +} + +trait StringExt { + fn strip_prefix_ignore_ascii_case(&self, prefix: &str) -> Option<&str>; + fn vcard_property(&self, property: &str) -> Option<&str>; +} +impl StringExt for str { + fn strip_prefix_ignore_ascii_case(&self, prefix: &str) -> Option<&str> { + let start_of_self = self.get(..prefix.len())?; + + if start_of_self.eq_ignore_ascii_case(prefix) { + self.get(prefix.len()..) + } else { + None + } + } + + fn vcard_property(&self, property: &str) -> Option<&str> { + // TODO this doesn't handle the case where there are quotes around a colon + let remainder = self.strip_prefix_ignore_ascii_case(property)?; + let (_params, value) = remainder.split_once(':')?; + Some(value) + } +} + +pub fn parse_vcard(vcard: String) -> Result> { + let mut lines = vcard.lines().peekable(); + let mut contacts = Vec::new(); + + while lines.peek().is_some() { + // Skip to the start of the vcard: + for line in lines.by_ref() { + if line.eq_ignore_ascii_case("BEGIN:VCARD") { + break; + } + } + + let mut new_contact = VcardContact::default(); + + let mut display_name = ""; + let mut addr = ""; + let mut key = None; + let mut photo = None; + let mut timestamp = None; + + for line in lines.by_ref() { + if let Some(email) = line.vcard_property("email") { + addr = email; + } else if let Some(name) = line.vcard_property("fn") { + display_name = name; + } else if let Some(k) = line + .strip_prefix_ignore_ascii_case("KEY;PGP;ENCODING=BASE64:") + .or(line.strip_prefix_ignore_ascii_case("KEY;TYPE=PGP;ENCODING=b:")) + .or(line.strip_prefix_ignore_ascii_case("KEY:data:application/pgp-keys;base64,")) + { + key = Some(key.unwrap_or(k)); + } else if let Some(p) = line + .strip_prefix_ignore_ascii_case("PHOTO;JPEG;ENCODING=BASE64:") + .or(line.strip_prefix_ignore_ascii_case("PHOTO;TYPE=JPEG;ENCODING=b:")) + .or(line.strip_prefix_ignore_ascii_case("PHOTO;ENCODING=BASE64;TYPE=JPEG:")) + { + photo = Some(photo.unwrap_or(p)); + } else if let Some(rev) = line.vcard_property("rev") { + timestamp = Some(timestamp.unwrap_or(rev)); + } else if line.eq_ignore_ascii_case("END:VCARD") { + break; + } + } + + let (display_name, addr) = sanitize_name_and_addr(display_name, addr); + new_contact.display_name = display_name; + new_contact.addr = addr; + + new_contact.key = key.map(|s| s.to_string()); + new_contact.profile_photo = photo.map(|s| s.to_string()); + + contacts.push(new_contact); + } + + Ok(contacts) +} + /// Valid contact address. #[derive(Debug, Clone)] pub struct ContactAddress(String); From c1bd66682f763ae16e8233a98617d2fba9cff150 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Fri, 19 Apr 2024 23:07:12 +0200 Subject: [PATCH 02/10] Make parsing of name, addr, and timestamp work --- Cargo.lock | 3 +- Cargo.toml | 5 +- deltachat-contact-tools/Cargo.toml | 5 +- deltachat-contact-tools/src/lib.rs | 171 +++++++++++++++++++++-------- 4 files changed, 136 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6235204475..f2d2be291c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1239,9 +1239,10 @@ dependencies = [ [[package]] name = "deltachat-contact-tools" -version = "0.1.0" +version = "0.0.0" dependencies = [ "anyhow", + "chrono", "once_cell", "regex", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 3f7ce01e51..16badaa40c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ async_zip = { version = "0.0.12", default-features = false, features = ["deflate backtrace = "0.3" base64 = "0.22" brotli = { version = "5", default-features=false, features = ["std"] } -chrono = { version = "0.4.37", default-features=false, features = ["clock", "std"] } +chrono = { workspace = true } email = { git = "https://github.com/deltachat/rust-email", branch = "master" } encoded-words = { git = "https://github.com/async-email/encoded-words", branch = "master" } escaper = "0.1" @@ -168,7 +168,8 @@ harness = false anyhow = "1" once_cell = "1.18.0" regex = "1.10" -rusqlite = { version = "0.31" } +rusqlite = "0.31" +chrono = { version = "0.4.37", default-features=false, features = ["clock", "std"] } [features] default = ["vendored"] diff --git a/deltachat-contact-tools/Cargo.toml b/deltachat-contact-tools/Cargo.toml index 98ea3e724a..9179ddb8dc 100644 --- a/deltachat-contact-tools/Cargo.toml +++ b/deltachat-contact-tools/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "deltachat-contact-tools" -version = "0.1.0" +version = "0.0.0" # No semver-stable versioning edition = "2021" -description = "Contact-related tools, like parsing vcards and sanitizing name and address" +description = "Contact-related tools, like parsing vcards and sanitizing name and address. Meant for internal use in the deltachat crate." license = "MPL-2.0" # TODO maybe it should be called "deltachat-text-utils" or similar? @@ -13,6 +13,7 @@ anyhow = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } rusqlite = { workspace = true } # Needed in order to `impl rusqlite::types::ToSql for EmailAddress`. Could easily be put behind a feature. +chrono = { workspace = true } [dev-dependencies] anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests. diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index b954ea67b2..7029362ef1 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -29,7 +29,10 @@ use std::fmt; use std::ops::Deref; use anyhow::bail; +use anyhow::format_err; +use anyhow::Context as _; use anyhow::Result; +use chrono::DateTime; use once_cell::sync::Lazy; use regex::Regex; @@ -37,39 +40,52 @@ use regex::Regex; // - Check if sanitizing is done correctly everywhere // - Apply lints everywhere (https://doc.rust-lang.org/cargo/reference/workspaces.html#the-lints-table) -#[derive(Debug, Default)] +#[derive(Debug)] +/// A Contact, as represented in a VCard. pub struct VcardContact { + /// The email address, vcard property `email` pub addr: String, + /// The contact's display name, vcard property `fn` pub display_name: String, + /// The contact's public PGP key, vcard property `key` pub key: Option, + /// The contact's profile photo (=avatar), vcard property `photo` pub profile_photo: Option, - pub timestamp: u64, + /// The timestamp when the vcard was created / last updated, vcard property `rev` + pub timestamp: Result, } -trait StringExt { - fn strip_prefix_ignore_ascii_case(&self, prefix: &str) -> Option<&str>; - fn vcard_property(&self, property: &str) -> Option<&str>; -} -impl StringExt for str { - fn strip_prefix_ignore_ascii_case(&self, prefix: &str) -> Option<&str> { - let start_of_self = self.get(..prefix.len())?; +pub fn parse_vcard(vcard: String) -> Result> { + fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let start_of_s = s.get(..prefix.len())?; - if start_of_self.eq_ignore_ascii_case(prefix) { - self.get(prefix.len()..) + if start_of_s.eq_ignore_ascii_case(prefix) { + s.get(prefix.len()..) } else { None } } + fn vcard_property<'a>(s: &'a str, property: &str) -> Option<&'a str> { + let remainder = remove_prefix(s, property)?; - fn vcard_property(&self, property: &str) -> Option<&str> { // TODO this doesn't handle the case where there are quotes around a colon - let remainder = self.strip_prefix_ignore_ascii_case(property)?; let (_params, value) = remainder.split_once(':')?; Some(value) } -} + fn parse_datetime(datetime: Option<&str>) -> Result { + let datetime = datetime.context("No timestamp in vcard")?; + + // According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp + // is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses + // ISO.8601, but fails to parse any of the examples given. + // So, instead just parse using a format string. + let datetime = + DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") // Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500 + .or_else(|_| DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S"))?; // Parses 19961022T140000 + let timestamp = datetime.timestamp().try_into()?; + Ok(timestamp) + } -pub fn parse_vcard(vcard: String) -> Result> { let mut lines = vcard.lines().peekable(); let mut contacts = Vec::new(); @@ -81,46 +97,43 @@ pub fn parse_vcard(vcard: String) -> Result> { } } - let mut new_contact = VcardContact::default(); - let mut display_name = ""; let mut addr = ""; let mut key = None; let mut photo = None; - let mut timestamp = None; + let mut datetime = None; for line in lines.by_ref() { - if let Some(email) = line.vcard_property("email") { + if let Some(email) = vcard_property(line, "email") { addr = email; - } else if let Some(name) = line.vcard_property("fn") { + } else if let Some(name) = vcard_property(line, "fn") { display_name = name; - } else if let Some(k) = line - .strip_prefix_ignore_ascii_case("KEY;PGP;ENCODING=BASE64:") - .or(line.strip_prefix_ignore_ascii_case("KEY;TYPE=PGP;ENCODING=b:")) - .or(line.strip_prefix_ignore_ascii_case("KEY:data:application/pgp-keys;base64,")) + } else if let Some(k) = remove_prefix(line, "KEY;PGP;ENCODING=BASE64:") + .or_else(|| remove_prefix(line, "KEY;TYPE=PGP;ENCODING=b:")) + .or_else(|| remove_prefix(line, "KEY:data:application/pgp-keys;base64,")) { key = Some(key.unwrap_or(k)); - } else if let Some(p) = line - .strip_prefix_ignore_ascii_case("PHOTO;JPEG;ENCODING=BASE64:") - .or(line.strip_prefix_ignore_ascii_case("PHOTO;TYPE=JPEG;ENCODING=b:")) - .or(line.strip_prefix_ignore_ascii_case("PHOTO;ENCODING=BASE64;TYPE=JPEG:")) + } else if let Some(p) = remove_prefix(line, "PHOTO;JPEG;ENCODING=BASE64:") + .or_else(|| remove_prefix(line, "PHOTO;TYPE=JPEG;ENCODING=b:")) + .or_else(|| remove_prefix(line, "PHOTO;ENCODING=BASE64;TYPE=JPEG:")) { photo = Some(photo.unwrap_or(p)); - } else if let Some(rev) = line.vcard_property("rev") { - timestamp = Some(timestamp.unwrap_or(rev)); + } else if let Some(rev) = vcard_property(line, "rev") { + datetime = Some(datetime.unwrap_or(rev)); } else if line.eq_ignore_ascii_case("END:VCARD") { break; } } let (display_name, addr) = sanitize_name_and_addr(display_name, addr); - new_contact.display_name = display_name; - new_contact.addr = addr; - new_contact.key = key.map(|s| s.to_string()); - new_contact.profile_photo = photo.map(|s| s.to_string()); - - contacts.push(new_contact); + contacts.push(VcardContact { + display_name, + addr, + key: key.map(|s| s.to_string()), + profile_photo: photo.map(|s| s.to_string()), + timestamp: parse_datetime(datetime), + }); } Ok(contacts) @@ -174,14 +187,10 @@ impl rusqlite::types::ToSql for ContactAddress { /// Make the name and address pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) { static ADDR_WITH_NAME_REGEX: Lazy = Lazy::new(|| Regex::new("(.*)<(.*)>").unwrap()); - if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) { + let (name, addr) = if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) { ( if name.is_empty() { - strip_rtlo_characters( - &captures - .get(1) - .map_or("".to_string(), |m| normalize_name(m.as_str())), - ) + strip_rtlo_characters(captures.get(1).map_or("", |m| m.as_str())) } else { strip_rtlo_characters(name) }, @@ -190,8 +199,21 @@ pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) { .map_or("".to_string(), |m| m.as_str().to_string()), ) } else { - (strip_rtlo_characters(name), addr.to_string()) + ( + strip_rtlo_characters(&normalize_name(name)), + addr.to_string(), + ) + }; + let mut name = normalize_name(&name); + + // If the 'display name' is just the address, remove it: + // Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)" (see `get_name_n_addr()`). + // If the display name is empty, DC will just show the address when it needs a display name. + if name == addr { + name = "".to_string(); } + + (name, addr) } /// Normalize a name. @@ -323,8 +345,71 @@ impl rusqlite::types::ToSql for EmailAddress { #[cfg(test)] mod tests { + use chrono::NaiveDateTime; + use super::*; + #[test] + fn test_thunderbird() { + let contacts = parse_vcard( + "BEGIN:VCARD +VERSION:4.0 +FN:'Alice Mueller' +EMAIL;PREF=1:alice.mueller@posteo.de +UID:a8083264-ca47-4be7-98a8-8ec3db1447ca +END:VCARD +BEGIN:VCARD +VERSION:4.0 +FN:'bobzzz@freenet.de' +EMAIL;PREF=1:bobzzz@freenet.de +UID:cac4fef4-6351-4854-bbe4-9b6df857eaed +END:VCARD +" + .to_string(), + ) + .unwrap(); + + assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string()); + assert_eq!(contacts[0].display_name, "Alice Mueller".to_string()); + assert_eq!(contacts[0].key, None); + assert_eq!(contacts[0].profile_photo, None); + assert!(contacts[0].timestamp.is_err()); + + assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string()); + assert_eq!(contacts[1].display_name, "".to_string()); + assert_eq!(contacts[1].key, None); + assert_eq!(contacts[1].profile_photo, None); + assert!(contacts[1].timestamp.is_err()); + + assert_eq!(contacts.len(), 2); + } + + #[test] + fn test_simple_example() { + let contacts = parse_vcard( + "BEGIN:VCARD +VERSION:4.0 +FN:Alice Wonderland +N:Wonderland;Alice;;;Ms. +GENDER:W +EMAIL;TYPE=work:alice@example.com +KEY;TYPE=PGP;ENCODING=b:[base64-data] +REV:20240418T184242Z + +END:VCARD" + .to_string(), + ) + .unwrap(); + + assert_eq!(contacts[0].addr, "alice@example.com".to_string()); + assert_eq!(contacts[0].display_name, "Alice Wonderland".to_string()); + assert_eq!(contacts[0].key, Some("[base64-data]".to_string())); + assert_eq!(contacts[0].profile_photo, None); + assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762); // I did not check whether this timestamp is correct + + assert_eq!(contacts.len(), 1); + } + #[test] fn test_contact_address() -> Result<()> { let alice_addr = "alice@example.org"; From 503aabe454e1241d83cf7d8e2d1ca2659d8af593 Mon Sep 17 00:00:00 2001 From: iequidoo Date: Tue, 30 Apr 2024 02:09:36 -0300 Subject: [PATCH 03/10] review suggestions --- deltachat-contact-tools/Cargo.toml | 1 - deltachat-contact-tools/src/lib.rs | 43 +++++++++++++++++------------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/deltachat-contact-tools/Cargo.toml b/deltachat-contact-tools/Cargo.toml index 9179ddb8dc..50ef5f7ba9 100644 --- a/deltachat-contact-tools/Cargo.toml +++ b/deltachat-contact-tools/Cargo.toml @@ -4,7 +4,6 @@ version = "0.0.0" # No semver-stable versioning edition = "2021" description = "Contact-related tools, like parsing vcards and sanitizing name and address. Meant for internal use in the deltachat crate." license = "MPL-2.0" -# TODO maybe it should be called "deltachat-text-utils" or similar? # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 7029362ef1..228c91588e 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -29,7 +29,6 @@ use std::fmt; use std::ops::Deref; use anyhow::bail; -use anyhow::format_err; use anyhow::Context as _; use anyhow::Result; use chrono::DateTime; @@ -47,14 +46,15 @@ pub struct VcardContact { pub addr: String, /// The contact's display name, vcard property `fn` pub display_name: String, - /// The contact's public PGP key, vcard property `key` + /// The contact's public PGP key in Base64, vcard property `key` pub key: Option, - /// The contact's profile photo (=avatar), vcard property `photo` + /// The contact's profile photo (=avatar) in Base64, vcard property `photo` pub profile_photo: Option, /// The timestamp when the vcard was created / last updated, vcard property `rev` pub timestamp: Result, } +/// Parses `VcardContact`s from a given `String`. pub fn parse_vcard(vcard: String) -> Result> { fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { let start_of_s = s.get(..prefix.len())?; @@ -69,12 +69,18 @@ pub fn parse_vcard(vcard: String) -> Result> { let remainder = remove_prefix(s, property)?; // TODO this doesn't handle the case where there are quotes around a colon - let (_params, value) = remainder.split_once(':')?; + let (params, value) = remainder.split_once(':')?; + if params + .chars() + .next() + .filter(|c| !c.is_ascii_punctuation() || *c == '_') + .is_some() + { + return None; + } Some(value) } - fn parse_datetime(datetime: Option<&str>) -> Result { - let datetime = datetime.context("No timestamp in vcard")?; - + fn parse_datetime(datetime: &str) -> Result { // According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp // is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses // ISO.8601, but fails to parse any of the examples given. @@ -97,42 +103,45 @@ pub fn parse_vcard(vcard: String) -> Result> { } } - let mut display_name = ""; - let mut addr = ""; + let mut display_name = None; + let mut addr = None; let mut key = None; let mut photo = None; let mut datetime = None; for line in lines.by_ref() { if let Some(email) = vcard_property(line, "email") { - addr = email; + addr.get_or_insert(email); } else if let Some(name) = vcard_property(line, "fn") { - display_name = name; + display_name.get_or_insert(name); } else if let Some(k) = remove_prefix(line, "KEY;PGP;ENCODING=BASE64:") .or_else(|| remove_prefix(line, "KEY;TYPE=PGP;ENCODING=b:")) .or_else(|| remove_prefix(line, "KEY:data:application/pgp-keys;base64,")) { - key = Some(key.unwrap_or(k)); + key.get_or_insert(k); } else if let Some(p) = remove_prefix(line, "PHOTO;JPEG;ENCODING=BASE64:") .or_else(|| remove_prefix(line, "PHOTO;TYPE=JPEG;ENCODING=b:")) .or_else(|| remove_prefix(line, "PHOTO;ENCODING=BASE64;TYPE=JPEG:")) { - photo = Some(photo.unwrap_or(p)); + photo.get_or_insert(p); } else if let Some(rev) = vcard_property(line, "rev") { - datetime = Some(datetime.unwrap_or(rev)); + datetime.get_or_insert(rev); } else if line.eq_ignore_ascii_case("END:VCARD") { break; } } - let (display_name, addr) = sanitize_name_and_addr(display_name, addr); + let (display_name, addr) = + sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or("")); contacts.push(VcardContact { display_name, addr, key: key.map(|s| s.to_string()), profile_photo: photo.map(|s| s.to_string()), - timestamp: parse_datetime(datetime), + timestamp: datetime + .context("No timestamp in vcard") + .and_then(parse_datetime), }); } @@ -345,8 +354,6 @@ impl rusqlite::types::ToSql for EmailAddress { #[cfg(test)] mod tests { - use chrono::NaiveDateTime; - use super::*; #[test] From b7daced24071b54e93dab825c5cafd97f6f11f91 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 30 Apr 2024 16:24:08 +0200 Subject: [PATCH 04/10] Add another test --- deltachat-contact-tools/src/lib.rs | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 228c91588e..1b0aca8a14 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -462,4 +462,38 @@ END:VCARD" assert!(EmailAddress::new("u@tt").is_ok()); assert_eq!(EmailAddress::new("@d.tt").is_ok(), false); } + + #[test] + fn test_android_contact_export() { + let contacts = parse_vcard( + "BEGIN:VCARD +VERSION:2.1 +N:;Bob;;; +FN:Bob +TEL;CELL:+1-234-567-890 +EMAIL;HOME:bob@example.org +END:VCARD +BEGIN:VCARD +VERSION:2.1 +N:;Alice;;; +FN:Alice +EMAIL;HOME:alice@example.org +END:VCARD +" + .to_string(), + ) + .unwrap(); + + assert_eq!(contacts[0].addr, "bob@example.org".to_string()); + assert_eq!(contacts[0].display_name, "Bob".to_string()); + assert_eq!(contacts[0].key, None); + assert_eq!(contacts[0].profile_photo, None); + + assert_eq!(contacts[1].addr, "alice@example.org".to_string()); + assert_eq!(contacts[1].display_name, "Alice".to_string()); + assert_eq!(contacts[1].key, None); + assert_eq!(contacts[1].profile_photo, None); + + assert_eq!(contacts.len(), 2); + } } From 53e0a2c87cf38497a823cde320de83c30fe73193 Mon Sep 17 00:00:00 2001 From: Hocuri Date: Tue, 30 Apr 2024 16:36:16 +0200 Subject: [PATCH 05/10] Add some comments --- deltachat-contact-tools/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 1b0aca8a14..e8e41cbcd9 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -67,15 +67,22 @@ pub fn parse_vcard(vcard: String) -> Result> { } fn vcard_property<'a>(s: &'a str, property: &str) -> Option<&'a str> { let remainder = remove_prefix(s, property)?; + // If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`, + // then `remainder` is now `;TYPE=work:alice@example.com` // TODO this doesn't handle the case where there are quotes around a colon let (params, value) = remainder.split_once(':')?; + // In the example from above, `params` is now `;TYPE=work` + // and `value` is now `alice@example.com` + if params .chars() .next() .filter(|c| !c.is_ascii_punctuation() || *c == '_') .is_some() { + // `s` started with `property`, but the next character after it was not punctuation, + // so this line's property is actually something else return None; } Some(value) From dcc9802527b762800c8b868f63d8362afd210d2e Mon Sep 17 00:00:00 2001 From: iequidoo Date: Tue, 30 Apr 2024 21:38:05 -0300 Subject: [PATCH 06/10] parse_vcard: change param type to &str --- deltachat-contact-tools/src/lib.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index e8e41cbcd9..ef469bf233 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -54,8 +54,8 @@ pub struct VcardContact { pub timestamp: Result, } -/// Parses `VcardContact`s from a given `String`. -pub fn parse_vcard(vcard: String) -> Result> { +/// Parses `VcardContact`s from a given `&str`. +pub fn parse_vcard(vcard: &str) -> Result> { fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { let start_of_s = s.get(..prefix.len())?; @@ -378,8 +378,7 @@ FN:'bobzzz@freenet.de' EMAIL;PREF=1:bobzzz@freenet.de UID:cac4fef4-6351-4854-bbe4-9b6df857eaed END:VCARD -" - .to_string(), +", ) .unwrap(); @@ -410,8 +409,7 @@ EMAIL;TYPE=work:alice@example.com KEY;TYPE=PGP;ENCODING=b:[base64-data] REV:20240418T184242Z -END:VCARD" - .to_string(), +END:VCARD", ) .unwrap(); @@ -486,8 +484,7 @@ N:;Alice;;; FN:Alice EMAIL;HOME:alice@example.org END:VCARD -" - .to_string(), +", ) .unwrap(); From a8bb28415ae4fdf72bfcd07e02ac664c20f3de76 Mon Sep 17 00:00:00 2001 From: iequidoo Date: Wed, 1 May 2024 02:32:02 -0300 Subject: [PATCH 07/10] Rename profile_photo to profile_image Other fields of `VcardContact` are named like in the DC code, let's do the same for `profile_image`. See `Contact::get_profile_image()` for the reference. --- deltachat-contact-tools/src/lib.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index ef469bf233..48b74bf327 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -48,8 +48,8 @@ pub struct VcardContact { pub display_name: String, /// The contact's public PGP key in Base64, vcard property `key` pub key: Option, - /// The contact's profile photo (=avatar) in Base64, vcard property `photo` - pub profile_photo: Option, + /// The contact's profile image (=avatar) in Base64, vcard property `photo` + pub profile_image: Option, /// The timestamp when the vcard was created / last updated, vcard property `rev` pub timestamp: Result, } @@ -145,7 +145,7 @@ pub fn parse_vcard(vcard: &str) -> Result> { display_name, addr, key: key.map(|s| s.to_string()), - profile_photo: photo.map(|s| s.to_string()), + profile_image: photo.map(|s| s.to_string()), timestamp: datetime .context("No timestamp in vcard") .and_then(parse_datetime), @@ -385,13 +385,13 @@ END:VCARD assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string()); assert_eq!(contacts[0].display_name, "Alice Mueller".to_string()); assert_eq!(contacts[0].key, None); - assert_eq!(contacts[0].profile_photo, None); + assert_eq!(contacts[0].profile_image, None); assert!(contacts[0].timestamp.is_err()); assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string()); assert_eq!(contacts[1].display_name, "".to_string()); assert_eq!(contacts[1].key, None); - assert_eq!(contacts[1].profile_photo, None); + assert_eq!(contacts[1].profile_image, None); assert!(contacts[1].timestamp.is_err()); assert_eq!(contacts.len(), 2); @@ -416,7 +416,7 @@ END:VCARD", assert_eq!(contacts[0].addr, "alice@example.com".to_string()); assert_eq!(contacts[0].display_name, "Alice Wonderland".to_string()); assert_eq!(contacts[0].key, Some("[base64-data]".to_string())); - assert_eq!(contacts[0].profile_photo, None); + assert_eq!(contacts[0].profile_image, None); assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762); // I did not check whether this timestamp is correct assert_eq!(contacts.len(), 1); @@ -491,12 +491,12 @@ END:VCARD assert_eq!(contacts[0].addr, "bob@example.org".to_string()); assert_eq!(contacts[0].display_name, "Bob".to_string()); assert_eq!(contacts[0].key, None); - assert_eq!(contacts[0].profile_photo, None); + assert_eq!(contacts[0].profile_image, None); assert_eq!(contacts[1].addr, "alice@example.org".to_string()); assert_eq!(contacts[1].display_name, "Alice".to_string()); assert_eq!(contacts[1].key, None); - assert_eq!(contacts[1].profile_photo, None); + assert_eq!(contacts[1].profile_image, None); assert_eq!(contacts.len(), 2); } From 5a50d596d27218e1dbc0f6cec2f2e529e3d29210 Mon Sep 17 00:00:00 2001 From: iequidoo Date: Wed, 1 May 2024 04:22:17 -0300 Subject: [PATCH 08/10] remove the comment, the timestamp is correct --- deltachat-contact-tools/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 48b74bf327..b2e2c443e1 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -417,7 +417,7 @@ END:VCARD", assert_eq!(contacts[0].display_name, "Alice Wonderland".to_string()); assert_eq!(contacts[0].key, Some("[base64-data]".to_string())); assert_eq!(contacts[0].profile_image, None); - assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762); // I did not check whether this timestamp is correct + assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762); assert_eq!(contacts.len(), 1); } From 4da9d329c1c0959f849305e9000402d0e9cbdfc3 Mon Sep 17 00:00:00 2001 From: iequidoo Date: Wed, 1 May 2024 04:30:26 -0300 Subject: [PATCH 09/10] Prefix test names with `vcard` Also rename `test_android_contact_export` to `test_vcard_android` for uniformity with `test_thunderbird`. --- deltachat-contact-tools/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index b2e2c443e1..4cf24c4852 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -364,7 +364,7 @@ mod tests { use super::*; #[test] - fn test_thunderbird() { + fn test_vcard_thunderbird() { let contacts = parse_vcard( "BEGIN:VCARD VERSION:4.0 @@ -398,7 +398,7 @@ END:VCARD } #[test] - fn test_simple_example() { + fn test_vcard_simple_example() { let contacts = parse_vcard( "BEGIN:VCARD VERSION:4.0 @@ -469,7 +469,7 @@ END:VCARD", } #[test] - fn test_android_contact_export() { + fn test_vcard_android() { let contacts = parse_vcard( "BEGIN:VCARD VERSION:2.1 From f4af0467415d11cbda6b3dd16eeda2cdfd1c62a5 Mon Sep 17 00:00:00 2001 From: iequidoo Date: Wed, 1 May 2024 22:58:09 -0300 Subject: [PATCH 10/10] fix parsing datetime w/o a timezone --- deltachat-contact-tools/src/lib.rs | 49 ++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index 4cf24c4852..3930268eb7 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -31,7 +31,7 @@ use std::ops::Deref; use anyhow::bail; use anyhow::Context as _; use anyhow::Result; -use chrono::DateTime; +use chrono::{DateTime, NaiveDateTime}; use once_cell::sync::Lazy; use regex::Regex; @@ -92,11 +92,21 @@ pub fn parse_vcard(vcard: &str) -> Result> { // is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses // ISO.8601, but fails to parse any of the examples given. // So, instead just parse using a format string. - let datetime = - DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") // Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500 - .or_else(|_| DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S"))?; // Parses 19961022T140000 - let timestamp = datetime.timestamp().try_into()?; - Ok(timestamp) + + // Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500. + let timestamp = match DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") { + Ok(datetime) => datetime.timestamp(), + // Parses 19961022T140000. + Err(e) => match NaiveDateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S") { + Ok(datetime) => datetime + .and_local_timezone(chrono::offset::Local) + .single() + .context("Could not apply local timezone to parsed date and time")? + .timestamp(), + Err(_) => return Err(e.into()), + }, + }; + Ok(timestamp.try_into()?) } let mut lines = vcard.lines().peekable(); @@ -361,6 +371,8 @@ impl rusqlite::types::ToSql for EmailAddress { #[cfg(test)] mod tests { + use chrono::TimeZone; + use super::*; #[test] @@ -500,4 +512,29 @@ END:VCARD assert_eq!(contacts.len(), 2); } + + #[test] + fn test_vcard_local_datetime() { + let contacts = parse_vcard( + "BEGIN:VCARD\n\ + VERSION:4.0\n\ + FN:Alice Wonderland\n\ + EMAIL;TYPE=work:alice@example.org\n\ + REV:20240418T184242\n\ + END:VCARD", + ) + .unwrap(); + assert_eq!(contacts.len(), 1); + assert_eq!(contacts[0].addr, "alice@example.org".to_string()); + assert_eq!(contacts[0].display_name, "Alice Wonderland".to_string()); + assert_eq!( + *contacts[0].timestamp.as_ref().unwrap(), + chrono::offset::Local + .with_ymd_and_hms(2024, 4, 18, 18, 42, 42) + .unwrap() + .timestamp() + .try_into() + .unwrap() + ); + } }