//! Native vCard 3.0/4.0 parser for contact import. //! //! Parses the subset of vCard properties that GO uses. The format is line-based: //! each property is `NAME;PARAMS:VALUE`, with line folding (continuation lines //! starting with space/tab). use serde::Serialize; /// A parsed email from a vCard. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ParsedEmail { pub address: String, pub label: String, pub is_primary: bool, } /// A parsed phone number from a vCard. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ParsedPhone { pub number: String, pub label: String, pub is_primary: bool, } /// A parsed social handle from a vCard. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ParsedSocial { pub platform: String, pub handle: String, pub url: Option, } /// A parsed custom field from a vCard. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ParsedCustomField { pub label: String, pub value: String, pub url: Option, } /// A fully parsed vCard contact. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ParsedVCard { pub display_name: String, pub nickname: Option, pub company: Option, pub title: Option, pub notes: Option, pub birthday: Option, pub timezone: Option, pub tags: Vec, pub emails: Vec, pub phones: Vec, pub social_handles: Vec, pub custom_fields: Vec, } /// Parse a .vcf file content into a list of contacts. pub fn parse_vcf(content: &str) -> Result, String> { let unfolded = unfold_lines(content); let mut contacts = Vec::new(); let mut in_card = false; let mut lines: Vec<&str> = Vec::new(); for line in unfolded.lines() { let trimmed = line.trim(); if trimmed.eq_ignore_ascii_case("BEGIN:VCARD") { in_card = true; lines.clear(); } else if trimmed.eq_ignore_ascii_case("END:VCARD") { if in_card { if let Some(card) = parse_single_vcard(&lines) { contacts.push(card); } } in_card = false; } else if in_card { lines.push(line); } } Ok(contacts) } /// Unfold continuation lines (RFC 6350 §3.2): lines starting with a space or tab /// are continuations of the previous line. fn unfold_lines(content: &str) -> String { let mut result = String::with_capacity(content.len()); for line in content.lines() { if line.starts_with(' ') || line.starts_with('\t') { // Continuation: strip exactly one fold character (space or tab) result.push_str(&line[1..]); } else { if !result.is_empty() { result.push('\n'); } result.push_str(line); } } result } /// Parse a single vCard from its property lines. fn parse_single_vcard(lines: &[&str]) -> Option { let mut display_name = String::new(); let mut nickname = None; let mut company = None; let mut title = None; let mut notes = None; let mut birthday = None; let mut timezone = None; let mut tags = Vec::new(); let mut emails = Vec::new(); let mut phones = Vec::new(); let mut social_handles = Vec::new(); let mut custom_fields = Vec::new(); // Fallback name components from N property let mut family_name = String::new(); let mut given_name = String::new(); for line in lines { let (prop_name, params, value) = parse_property_line(line); let prop_upper = prop_name.to_uppercase(); match prop_upper.as_str() { "FN" => { display_name = decode_value(&value, ¶ms); } "N" => { // N:Family;Given;Middle;Prefix;Suffix let parts: Vec<&str> = value.split(';').collect(); if let Some(f) = parts.first() { family_name = decode_value(f, ¶ms); } if let Some(g) = parts.get(1) { given_name = decode_value(g, ¶ms); } } "NICKNAME" => { let v = decode_value(&value, ¶ms); if !v.is_empty() { nickname = Some(v); } } "ORG" => { // ORG:Company;Division let v = decode_value(&value, ¶ms); let org = v.split(';').next().unwrap_or("").trim().to_string(); if !org.is_empty() { company = Some(org); } } "TITLE" => { let v = decode_value(&value, ¶ms); if !v.is_empty() { title = Some(v); } } "NOTE" => { let v = decode_value(&value, ¶ms); if !v.is_empty() { notes = Some(v); } } "BDAY" => { let v = value.trim(); // Normalize YYYYMMDD to YYYY-MM-DD let normalized = if v.len() == 8 && v.chars().all(|c| c.is_ascii_digit()) { format!("{}-{}-{}", &v[0..4], &v[4..6], &v[6..8]) } else { v.to_string() }; if normalized.len() >= 10 { birthday = Some(normalized[..10].to_string()); } } "TZ" => { let v = value.trim().to_string(); if !v.is_empty() { timezone = Some(v); } } "CATEGORIES" => { for cat in value.split(',') { let cat = decode_value(cat.trim(), ¶ms); if !cat.is_empty() { tags.push(cat); } } } "EMAIL" => { let address = decode_value(&value, ¶ms); if !address.is_empty() { let label = extract_type_param(¶ms); let is_primary = params_contain(¶ms, "PREF") || params_contain_key_value(¶ms, "TYPE", "PREF"); emails.push(ParsedEmail { address, label, is_primary, }); } } "TEL" => { let number = decode_value(&value, ¶ms); if !number.is_empty() { let label = extract_type_param(¶ms); let is_primary = params_contain(¶ms, "PREF") || params_contain_key_value(¶ms, "TYPE", "PREF"); phones.push(ParsedPhone { number, label, is_primary, }); } } "URL" => { let url = decode_value(&value, ¶ms); if !url.is_empty() { custom_fields.push(ParsedCustomField { label: "Website".to_string(), value: url.clone(), url: Some(url), }); } } s if s.starts_with("X-SOCIALPROFILE") || s == "X-SOCIALPROFILE" => { let url_val = decode_value(&value, ¶ms); let platform = extract_type_param(¶ms); // Try to extract handle from URL let handle = url_val .rsplit('/') .find(|s| !s.is_empty()) .unwrap_or(&url_val) .to_string(); if !handle.is_empty() { social_handles.push(ParsedSocial { platform, handle, url: if url_val.starts_with("http") { Some(url_val) } else { None }, }); } } _ => {} } } // Use FN, fall back to N components if display_name.is_empty() { display_name = format!("{} {}", given_name, family_name).trim().to_string(); } // Skip contacts with no name at all if display_name.is_empty() { return None; } Some(ParsedVCard { display_name, nickname, company, title, notes, birthday, timezone, tags, emails, phones, social_handles, custom_fields, }) } /// Parse a property line into (name, params, value). /// Format: `NAME;PARAM1=val1;PARAM2=val2:VALUE` fn parse_property_line(line: &str) -> (String, Vec, String) { // Find the colon that separates property name+params from value. // Be careful: values can contain colons (e.g., URLs). // The property name cannot contain colons, but params might contain quoted colons. let mut colon_idx = None; let mut in_quotes = false; for (i, ch) in line.char_indices() { match ch { '"' => in_quotes = !in_quotes, ':' if !in_quotes => { colon_idx = Some(i); break; } _ => {} } } let (name_params, value) = match colon_idx { Some(i) => (&line[..i], &line[i + 1..]), None => (line, ""), }; let mut parts = name_params.split(';'); let name = parts.next().unwrap_or("").to_string(); let params: Vec = parts.map(|s| s.to_string()).collect(); (name, params, value.to_string()) } /// Decode a value, handling quoted-printable encoding if indicated by params. fn decode_value(value: &str, params: &[String]) -> String { let is_qp = params.iter().any(|p| { let upper = p.to_uppercase(); upper == "ENCODING=QUOTED-PRINTABLE" || upper == "QUOTED-PRINTABLE" }); if is_qp { decode_quoted_printable(value) } else { // Handle vCard escaped characters value .replace("\\n", "\n") .replace("\\N", "\n") .replace("\\,", ",") .replace("\\;", ";") .replace("\\\\", "\\") } } /// Decode quoted-printable encoded text. fn decode_quoted_printable(input: &str) -> String { let mut decoded_bytes = Vec::new(); let bytes = input.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'=' { // Soft line break: =\r\n or =\n — skip continuation if i + 2 < bytes.len() && bytes[i + 1] == b'\r' && bytes[i + 2] == b'\n' { i += 3; continue; } if i + 1 < bytes.len() && bytes[i + 1] == b'\n' { i += 2; continue; } // Hex-encoded byte: =XX if i + 2 < bytes.len() { if let (Some(h), Some(l)) = ( hex_val(bytes[i + 1]), hex_val(bytes[i + 2]), ) { decoded_bytes.push(h << 4 | l); i += 3; continue; } } } decoded_bytes.push(bytes[i]); i += 1; } String::from_utf8_lossy(&decoded_bytes).into_owned() } fn hex_val(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), b'A'..=b'F' => Some(b - b'A' + 10), b'a'..=b'f' => Some(b - b'a' + 10), _ => None, } } /// Extract a TYPE parameter value for labeling (e.g., "WORK", "HOME", "CELL"). fn extract_type_param(params: &[String]) -> String { for p in params { let upper = p.to_uppercase(); if upper.starts_with("TYPE=") { // TYPE=WORK,VOICE → take the first meaningful one let val = &p[5..]; return val .split(',') .find(|v| { let u = v.to_uppercase(); u != "PREF" && u != "VOICE" && u != "INTERNET" }) .unwrap_or(val.split(',').next().unwrap_or("")) .to_string(); } // Bare type params (vCard 2.1 style): e.g., just "WORK" or "CELL" if matches!( upper.as_str(), "WORK" | "HOME" | "CELL" | "FAX" | "PAGER" | "MAIN" | "OTHER" ) { return p.clone(); } } String::new() } /// Check if params contain a specific bare value (case-insensitive). fn params_contain(params: &[String], target: &str) -> bool { params .iter() .any(|p| p.eq_ignore_ascii_case(target)) } /// Check if params contain a KEY=VALUE where value includes target. fn params_contain_key_value(params: &[String], key: &str, target: &str) -> bool { let prefix = format!("{}=", key); params.iter().any(|p| { let upper = p.to_uppercase(); upper.starts_with(&prefix.to_uppercase()) && upper[prefix.len()..].split(',').any(|v| v == target.to_uppercase()) }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple_vcard() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Jane Smith\r\n\ N:Smith;Jane;;;\r\n\ EMAIL;TYPE=WORK:jane@example.com\r\n\ TEL;TYPE=CELL:+1-555-0100\r\n\ ORG:Acme Corp\r\n\ TITLE:Engineer\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!(cards.len(), 1); let c = &cards[0]; assert_eq!(c.display_name, "Jane Smith"); assert_eq!(c.company.as_deref(), Some("Acme Corp")); assert_eq!(c.title.as_deref(), Some("Engineer")); assert_eq!(c.emails.len(), 1); assert_eq!(c.emails[0].address, "jane@example.com"); assert_eq!(c.emails[0].label, "WORK"); assert_eq!(c.phones.len(), 1); assert_eq!(c.phones[0].number, "+1-555-0100"); assert_eq!(c.phones[0].label, "CELL"); } #[test] fn test_parse_multiple_vcards() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Alice\r\n\ END:VCARD\r\n\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Bob\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!(cards.len(), 2); assert_eq!(cards[0].display_name, "Alice"); assert_eq!(cards[1].display_name, "Bob"); } #[test] fn test_fallback_to_n_property() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ N:Doe;John;;;\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!(cards.len(), 1); assert_eq!(cards[0].display_name, "John Doe"); } #[test] fn test_birthday_formats() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Test\r\n\ BDAY:19900115\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!(cards[0].birthday.as_deref(), Some("1990-01-15")); let vcf2 = "\ BEGIN:VCARD\r\n\ VERSION:4.0\r\n\ FN:Test2\r\n\ BDAY:1990-01-15\r\n\ END:VCARD\r\n"; let cards2 = parse_vcf(vcf2).unwrap(); assert_eq!(cards2[0].birthday.as_deref(), Some("1990-01-15")); } #[test] fn test_pref_email() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Test\r\n\ EMAIL;TYPE=WORK:work@example.com\r\n\ EMAIL;TYPE=HOME,PREF:home@example.com\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert!(!cards[0].emails[0].is_primary); assert!(cards[0].emails[1].is_primary); } #[test] fn test_categories() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Test\r\n\ CATEGORIES:Friend,Coworker\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!(cards[0].tags, vec!["Friend", "Coworker"]); } #[test] fn test_line_folding() { // In vCard, line folding splits content and prepends a single space/tab to continuation. // The fold indicator (leading space) is stripped; the space in "continues " is content. let vcf = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Test\r\nNOTE:This is a long note that continues \r\n on the next line\r\nEND:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!( cards[0].notes.as_deref(), Some("This is a long note that continues on the next line") ); } #[test] fn test_url_as_custom_field() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Test\r\n\ URL:https://example.com\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!(cards[0].custom_fields.len(), 1); assert_eq!(cards[0].custom_fields[0].label, "Website"); assert_eq!(cards[0].custom_fields[0].value, "https://example.com"); } #[test] fn test_skip_empty_name() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ EMAIL:orphan@example.com\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!(cards.len(), 0); } #[test] fn test_escaped_characters() { let vcf = "\ BEGIN:VCARD\r\n\ VERSION:3.0\r\n\ FN:Test\r\n\ NOTE:Line 1\\nLine 2\\, with comma\r\n\ END:VCARD\r\n"; let cards = parse_vcf(vcf).unwrap(); assert_eq!( cards[0].notes.as_deref(), Some("Line 1\nLine 2, with comma") ); } }