Skip to main content

max / goingson

19.3 KB · 651 lines History Blame Raw
1 //! Native vCard 3.0/4.0 parser for contact import.
2 //!
3 //! Parses the subset of vCard properties that GO uses. The format is line-based:
4 //! each property is `NAME;PARAMS:VALUE`, with line folding (continuation lines
5 //! starting with space/tab).
6
7 use serde::Serialize;
8
9 /// A parsed email from a vCard.
10 #[derive(Debug, Clone, Serialize)]
11 #[serde(rename_all = "camelCase")]
12 pub struct ParsedEmail {
13 pub address: String,
14 pub label: String,
15 pub is_primary: bool,
16 }
17
18 /// A parsed phone number from a vCard.
19 #[derive(Debug, Clone, Serialize)]
20 #[serde(rename_all = "camelCase")]
21 pub struct ParsedPhone {
22 pub number: String,
23 pub label: String,
24 pub is_primary: bool,
25 }
26
27 /// A parsed social handle from a vCard.
28 #[derive(Debug, Clone, Serialize)]
29 #[serde(rename_all = "camelCase")]
30 pub struct ParsedSocial {
31 pub platform: String,
32 pub handle: String,
33 pub url: Option<String>,
34 }
35
36 /// A parsed custom field from a vCard.
37 #[derive(Debug, Clone, Serialize)]
38 #[serde(rename_all = "camelCase")]
39 pub struct ParsedCustomField {
40 pub label: String,
41 pub value: String,
42 pub url: Option<String>,
43 }
44
45 /// A fully parsed vCard contact.
46 #[derive(Debug, Clone, Serialize)]
47 #[serde(rename_all = "camelCase")]
48 pub struct ParsedVCard {
49 pub display_name: String,
50 pub nickname: Option<String>,
51 pub company: Option<String>,
52 pub title: Option<String>,
53 pub notes: Option<String>,
54 pub birthday: Option<String>,
55 pub timezone: Option<String>,
56 pub tags: Vec<String>,
57 pub emails: Vec<ParsedEmail>,
58 pub phones: Vec<ParsedPhone>,
59 pub social_handles: Vec<ParsedSocial>,
60 pub custom_fields: Vec<ParsedCustomField>,
61 }
62
63 /// Parse a .vcf file content into a list of contacts.
64 pub fn parse_vcf(content: &str) -> Result<Vec<ParsedVCard>, String> {
65 let unfolded = unfold_lines(content);
66 let mut contacts = Vec::new();
67 let mut in_card = false;
68 let mut lines: Vec<&str> = Vec::new();
69
70 for line in unfolded.lines() {
71 let trimmed = line.trim();
72 if trimmed.eq_ignore_ascii_case("BEGIN:VCARD") {
73 in_card = true;
74 lines.clear();
75 } else if trimmed.eq_ignore_ascii_case("END:VCARD") {
76 if in_card
77 && let Some(card) = parse_single_vcard(&lines) {
78 contacts.push(card);
79 }
80 in_card = false;
81 } else if in_card {
82 lines.push(line);
83 }
84 }
85
86 Ok(contacts)
87 }
88
89 /// Unfold continuation lines (RFC 6350 ยง3.2): lines starting with a space or tab
90 /// are continuations of the previous line.
91 fn unfold_lines(content: &str) -> String {
92 let mut result = String::with_capacity(content.len());
93 for line in content.lines() {
94 if line.starts_with(' ') || line.starts_with('\t') {
95 // Continuation: strip exactly one fold character (space or tab)
96 result.push_str(&line[1..]);
97 } else {
98 if !result.is_empty() {
99 result.push('\n');
100 }
101 result.push_str(line);
102 }
103 }
104 result
105 }
106
107 /// Parse a single vCard from its property lines.
108 fn parse_single_vcard(lines: &[&str]) -> Option<ParsedVCard> {
109 let mut display_name = String::new();
110 let mut nickname = None;
111 let mut company = None;
112 let mut title = None;
113 let mut notes = None;
114 let mut birthday = None;
115 let mut timezone = None;
116 let mut tags = Vec::new();
117 let mut emails = Vec::new();
118 let mut phones = Vec::new();
119 let mut social_handles = Vec::new();
120 let mut custom_fields = Vec::new();
121
122 // Fallback name components from N property
123 let mut family_name = String::new();
124 let mut given_name = String::new();
125
126 for line in lines {
127 let (prop_name, params, value) = parse_property_line(line);
128 let prop_upper = prop_name.to_uppercase();
129
130 match prop_upper.as_str() {
131 "FN" => {
132 display_name = decode_value(&value, &params);
133 }
134 "N" => {
135 // N:Family;Given;Middle;Prefix;Suffix
136 let parts: Vec<&str> = value.split(';').collect();
137 if let Some(f) = parts.first() {
138 family_name = decode_value(f, &params);
139 }
140 if let Some(g) = parts.get(1) {
141 given_name = decode_value(g, &params);
142 }
143 }
144 "NICKNAME" => {
145 let v = decode_value(&value, &params);
146 if !v.is_empty() {
147 nickname = Some(v);
148 }
149 }
150 "ORG" => {
151 // ORG:Company;Division
152 let v = decode_value(&value, &params);
153 let org = v.split(';').next().unwrap_or("").trim().to_string();
154 if !org.is_empty() {
155 company = Some(org);
156 }
157 }
158 "TITLE" => {
159 let v = decode_value(&value, &params);
160 if !v.is_empty() {
161 title = Some(v);
162 }
163 }
164 "NOTE" => {
165 let v = decode_value(&value, &params);
166 if !v.is_empty() {
167 notes = Some(v);
168 }
169 }
170 "BDAY" => {
171 let v = value.trim();
172 // Normalize YYYYMMDD to YYYY-MM-DD
173 let normalized = if v.len() == 8 && v.chars().all(|c| c.is_ascii_digit()) {
174 format!("{}-{}-{}", &v[0..4], &v[4..6], &v[6..8])
175 } else {
176 v.to_string()
177 };
178 // Take the YYYY-MM-DD prefix. `get` (not byte indexing) so a
179 // malformed multibyte value can't panic on a char boundary.
180 if let Some(prefix) = normalized.get(..10) {
181 birthday = Some(prefix.to_string());
182 }
183 }
184 "TZ" => {
185 let v = value.trim().to_string();
186 if !v.is_empty() {
187 timezone = Some(v);
188 }
189 }
190 "CATEGORIES" => {
191 // Split on unescaped commas only: a category containing an escaped
192 // `\,` is one tag, not two (ultra-fuzz Run #27 Data minor). Unescape
193 // each segment afterwards.
194 for cat in split_unescaped_commas(&value) {
195 let cat = decode_value(cat.trim(), &params);
196 if !cat.is_empty() {
197 tags.push(cat);
198 }
199 }
200 }
201 "EMAIL" => {
202 let address = decode_value(&value, &params);
203 if !address.is_empty() {
204 let label = extract_type_param(&params);
205 let is_primary = params_contain(&params, "PREF")
206 || params_contain_key_value(&params, "TYPE", "PREF");
207 emails.push(ParsedEmail {
208 address,
209 label,
210 is_primary,
211 });
212 }
213 }
214 "TEL" => {
215 let number = decode_value(&value, &params);
216 if !number.is_empty() {
217 let label = extract_type_param(&params);
218 let is_primary = params_contain(&params, "PREF")
219 || params_contain_key_value(&params, "TYPE", "PREF");
220 phones.push(ParsedPhone {
221 number,
222 label,
223 is_primary,
224 });
225 }
226 }
227 "URL" => {
228 let url = decode_value(&value, &params);
229 if !url.is_empty() {
230 custom_fields.push(ParsedCustomField {
231 label: "Website".to_string(),
232 value: url.clone(),
233 url: Some(url),
234 });
235 }
236 }
237 s if s.starts_with("X-SOCIALPROFILE") || s == "X-SOCIALPROFILE" => {
238 let url_val = decode_value(&value, &params);
239 let platform = extract_type_param(&params);
240 // Try to extract handle from URL
241 let handle = url_val
242 .rsplit('/')
243 .find(|s| !s.is_empty())
244 .unwrap_or(&url_val)
245 .to_string();
246 if !handle.is_empty() {
247 social_handles.push(ParsedSocial {
248 platform,
249 handle,
250 url: if url_val.starts_with("http") {
251 Some(url_val)
252 } else {
253 None
254 },
255 });
256 }
257 }
258 _ => {}
259 }
260 }
261
262 // Use FN, fall back to N components
263 if display_name.is_empty() {
264 display_name = format!("{} {}", given_name, family_name).trim().to_string();
265 }
266
267 // Skip contacts with no name at all
268 if display_name.is_empty() {
269 return None;
270 }
271
272 Some(ParsedVCard {
273 display_name,
274 nickname,
275 company,
276 title,
277 notes,
278 birthday,
279 timezone,
280 tags,
281 emails,
282 phones,
283 social_handles,
284 custom_fields,
285 })
286 }
287
288 /// Parse a property line into (name, params, value).
289 /// Format: `NAME;PARAM1=val1;PARAM2=val2:VALUE`
290 fn parse_property_line(line: &str) -> (String, Vec<String>, String) {
291 // Find the colon that separates property name+params from value.
292 // Be careful: values can contain colons (e.g., URLs).
293 // The property name cannot contain colons, but params might contain quoted colons.
294 let mut colon_idx = None;
295 let mut in_quotes = false;
296 for (i, ch) in line.char_indices() {
297 match ch {
298 '"' => in_quotes = !in_quotes,
299 ':' if !in_quotes => {
300 colon_idx = Some(i);
301 break;
302 }
303 _ => {}
304 }
305 }
306
307 let (name_params, value) = match colon_idx {
308 Some(i) => (&line[..i], &line[i + 1..]),
309 None => (line, ""),
310 };
311
312 let mut parts = name_params.split(';');
313 let name = parts.next().unwrap_or("").to_string();
314 let params: Vec<String> = parts.map(|s| s.to_string()).collect();
315
316 (name, params, value.to_string())
317 }
318
319 /// Decode a value, handling quoted-printable encoding if indicated by params.
320 /// Split a vCard list value on commas that are not backslash-escaped, preserving
321 /// the escape sequences within each segment so `decode_value` can unescape them.
322 fn split_unescaped_commas(s: &str) -> Vec<String> {
323 let mut out = Vec::new();
324 let mut cur = String::new();
325 let mut escaped = false;
326 for c in s.chars() {
327 if escaped {
328 cur.push('\\');
329 cur.push(c);
330 escaped = false;
331 } else if c == '\\' {
332 escaped = true;
333 } else if c == ',' {
334 out.push(std::mem::take(&mut cur));
335 } else {
336 cur.push(c);
337 }
338 }
339 if escaped {
340 cur.push('\\');
341 }
342 out.push(cur);
343 out
344 }
345
346 fn decode_value(value: &str, params: &[String]) -> String {
347 let is_qp = params.iter().any(|p| {
348 let upper = p.to_uppercase();
349 upper == "ENCODING=QUOTED-PRINTABLE" || upper == "QUOTED-PRINTABLE"
350 });
351
352 if is_qp {
353 decode_quoted_printable(value)
354 } else {
355 // Handle vCard escaped characters
356 value
357 .replace("\\n", "\n")
358 .replace("\\N", "\n")
359 .replace("\\,", ",")
360 .replace("\\;", ";")
361 .replace("\\\\", "\\")
362 }
363 }
364
365 /// Decode quoted-printable encoded text.
366 fn decode_quoted_printable(input: &str) -> String {
367 let mut decoded_bytes = Vec::new();
368 let bytes = input.as_bytes();
369 let mut i = 0;
370 while i < bytes.len() {
371 if bytes[i] == b'=' {
372 // Soft line break: =\r\n or =\n โ€” skip continuation
373 if i + 2 < bytes.len() && bytes[i + 1] == b'\r' && bytes[i + 2] == b'\n' {
374 i += 3;
375 continue;
376 }
377 if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
378 i += 2;
379 continue;
380 }
381 // Hex-encoded byte: =XX
382 if i + 2 < bytes.len()
383 && let (Some(h), Some(l)) = (
384 hex_val(bytes[i + 1]),
385 hex_val(bytes[i + 2]),
386 ) {
387 decoded_bytes.push(h << 4 | l);
388 i += 3;
389 continue;
390 }
391 }
392 decoded_bytes.push(bytes[i]);
393 i += 1;
394 }
395 String::from_utf8_lossy(&decoded_bytes).into_owned()
396 }
397
398 fn hex_val(b: u8) -> Option<u8> {
399 match b {
400 b'0'..=b'9' => Some(b - b'0'),
401 b'A'..=b'F' => Some(b - b'A' + 10),
402 b'a'..=b'f' => Some(b - b'a' + 10),
403 _ => None,
404 }
405 }
406
407 /// Extract a TYPE parameter value for labeling (e.g., "WORK", "HOME", "CELL").
408 fn extract_type_param(params: &[String]) -> String {
409 for p in params {
410 let upper = p.to_uppercase();
411 if upper.starts_with("TYPE=") {
412 // TYPE=WORK,VOICE โ†’ take the first meaningful one
413 let val = &p[5..];
414 return val
415 .split(',')
416 .find(|v| {
417 let u = v.to_uppercase();
418 u != "PREF" && u != "VOICE" && u != "INTERNET"
419 })
420 .unwrap_or(val.split(',').next().unwrap_or(""))
421 .to_string();
422 }
423 // Bare type params (vCard 2.1 style): e.g., just "WORK" or "CELL"
424 if matches!(
425 upper.as_str(),
426 "WORK" | "HOME" | "CELL" | "FAX" | "PAGER" | "MAIN" | "OTHER"
427 ) {
428 return p.clone();
429 }
430 }
431 String::new()
432 }
433
434 /// Check if params contain a specific bare value (case-insensitive).
435 fn params_contain(params: &[String], target: &str) -> bool {
436 params
437 .iter()
438 .any(|p| p.eq_ignore_ascii_case(target))
439 }
440
441 /// Check if params contain a KEY=VALUE where value includes target.
442 fn params_contain_key_value(params: &[String], key: &str, target: &str) -> bool {
443 let prefix = format!("{}=", key);
444 params.iter().any(|p| {
445 let upper = p.to_uppercase();
446 upper.starts_with(&prefix.to_uppercase())
447 && upper[prefix.len()..].split(',').any(|v| v == target.to_uppercase())
448 })
449 }
450
451 #[cfg(test)]
452 mod tests {
453 use super::*;
454
455 #[test]
456 fn test_parse_simple_vcard() {
457 let vcf = "\
458 BEGIN:VCARD\r\n\
459 VERSION:3.0\r\n\
460 FN:Jane Smith\r\n\
461 N:Smith;Jane;;;\r\n\
462 EMAIL;TYPE=WORK:jane@example.com\r\n\
463 TEL;TYPE=CELL:+1-555-0100\r\n\
464 ORG:Acme Corp\r\n\
465 TITLE:Engineer\r\n\
466 END:VCARD\r\n";
467
468 let cards = parse_vcf(vcf).unwrap();
469 assert_eq!(cards.len(), 1);
470
471 let c = &cards[0];
472 assert_eq!(c.display_name, "Jane Smith");
473 assert_eq!(c.company.as_deref(), Some("Acme Corp"));
474 assert_eq!(c.title.as_deref(), Some("Engineer"));
475 assert_eq!(c.emails.len(), 1);
476 assert_eq!(c.emails[0].address, "jane@example.com");
477 assert_eq!(c.emails[0].label, "WORK");
478 assert_eq!(c.phones.len(), 1);
479 assert_eq!(c.phones[0].number, "+1-555-0100");
480 assert_eq!(c.phones[0].label, "CELL");
481 }
482
483 #[test]
484 fn test_parse_multiple_vcards() {
485 let vcf = "\
486 BEGIN:VCARD\r\n\
487 VERSION:3.0\r\n\
488 FN:Alice\r\n\
489 END:VCARD\r\n\
490 BEGIN:VCARD\r\n\
491 VERSION:3.0\r\n\
492 FN:Bob\r\n\
493 END:VCARD\r\n";
494
495 let cards = parse_vcf(vcf).unwrap();
496 assert_eq!(cards.len(), 2);
497 assert_eq!(cards[0].display_name, "Alice");
498 assert_eq!(cards[1].display_name, "Bob");
499 }
500
501 #[test]
502 fn test_fallback_to_n_property() {
503 let vcf = "\
504 BEGIN:VCARD\r\n\
505 VERSION:3.0\r\n\
506 N:Doe;John;;;\r\n\
507 END:VCARD\r\n";
508
509 let cards = parse_vcf(vcf).unwrap();
510 assert_eq!(cards.len(), 1);
511 assert_eq!(cards[0].display_name, "John Doe");
512 }
513
514 #[test]
515 fn test_birthday_formats() {
516 let vcf = "\
517 BEGIN:VCARD\r\n\
518 VERSION:3.0\r\n\
519 FN:Test\r\n\
520 BDAY:19900115\r\n\
521 END:VCARD\r\n";
522
523 let cards = parse_vcf(vcf).unwrap();
524 assert_eq!(cards[0].birthday.as_deref(), Some("1990-01-15"));
525
526 let vcf2 = "\
527 BEGIN:VCARD\r\n\
528 VERSION:4.0\r\n\
529 FN:Test2\r\n\
530 BDAY:1990-01-15\r\n\
531 END:VCARD\r\n";
532
533 let cards2 = parse_vcf(vcf2).unwrap();
534 assert_eq!(cards2[0].birthday.as_deref(), Some("1990-01-15"));
535 }
536
537 #[test]
538 fn test_birthday_multibyte_does_not_panic() {
539 // A hostile/garbled BDAY with multibyte UTF-8 must not panic on a
540 // byte-slice boundary; it is simply not treated as a date.
541 let vcf = "\
542 BEGIN:VCARD\r\n\
543 VERSION:3.0\r\n\
544 FN:Test\r\n\
545 BDAY:\u{4f60}\u{597d}\u{4f60}\u{597d}\u{4f60}\u{597d}\r\n\
546 END:VCARD\r\n";
547
548 let cards = parse_vcf(vcf).unwrap();
549 // Either None or a safe ASCII prefix; the point is it does not panic.
550 assert!(cards[0].birthday.as_deref() != Some("\u{4f60}"));
551 }
552
553 #[test]
554 fn test_pref_email() {
555 let vcf = "\
556 BEGIN:VCARD\r\n\
557 VERSION:3.0\r\n\
558 FN:Test\r\n\
559 EMAIL;TYPE=WORK:work@example.com\r\n\
560 EMAIL;TYPE=HOME,PREF:home@example.com\r\n\
561 END:VCARD\r\n";
562
563 let cards = parse_vcf(vcf).unwrap();
564 assert!(!cards[0].emails[0].is_primary);
565 assert!(cards[0].emails[1].is_primary);
566 }
567
568 #[test]
569 fn test_categories() {
570 let vcf = "\
571 BEGIN:VCARD\r\n\
572 VERSION:3.0\r\n\
573 FN:Test\r\n\
574 CATEGORIES:Friend,Coworker\r\n\
575 END:VCARD\r\n";
576
577 let cards = parse_vcf(vcf).unwrap();
578 assert_eq!(cards[0].tags, vec!["Friend", "Coworker"]);
579 }
580
581 #[test]
582 fn test_categories_escaped_comma_is_one_tag() {
583 // An escaped comma inside a category must not split into two tags.
584 let vcf = "\
585 BEGIN:VCARD\r\n\
586 VERSION:3.0\r\n\
587 FN:Test\r\n\
588 CATEGORIES:Smith\\, Jones & Co,VIP\r\n\
589 END:VCARD\r\n";
590
591 let cards = parse_vcf(vcf).unwrap();
592 assert_eq!(cards[0].tags, vec!["Smith, Jones & Co", "VIP"]);
593 }
594
595 #[test]
596 fn test_line_folding() {
597 // In vCard, line folding splits content and prepends a single space/tab to continuation.
598 // The fold indicator (leading space) is stripped; the space in "continues " is content.
599 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";
600
601 let cards = parse_vcf(vcf).unwrap();
602 assert_eq!(
603 cards[0].notes.as_deref(),
604 Some("This is a long note that continues on the next line")
605 );
606 }
607
608 #[test]
609 fn test_url_as_custom_field() {
610 let vcf = "\
611 BEGIN:VCARD\r\n\
612 VERSION:3.0\r\n\
613 FN:Test\r\n\
614 URL:https://example.com\r\n\
615 END:VCARD\r\n";
616
617 let cards = parse_vcf(vcf).unwrap();
618 assert_eq!(cards[0].custom_fields.len(), 1);
619 assert_eq!(cards[0].custom_fields[0].label, "Website");
620 assert_eq!(cards[0].custom_fields[0].value, "https://example.com");
621 }
622
623 #[test]
624 fn test_skip_empty_name() {
625 let vcf = "\
626 BEGIN:VCARD\r\n\
627 VERSION:3.0\r\n\
628 EMAIL:orphan@example.com\r\n\
629 END:VCARD\r\n";
630
631 let cards = parse_vcf(vcf).unwrap();
632 assert_eq!(cards.len(), 0);
633 }
634
635 #[test]
636 fn test_escaped_characters() {
637 let vcf = "\
638 BEGIN:VCARD\r\n\
639 VERSION:3.0\r\n\
640 FN:Test\r\n\
641 NOTE:Line 1\\nLine 2\\, with comma\r\n\
642 END:VCARD\r\n";
643
644 let cards = parse_vcf(vcf).unwrap();
645 assert_eq!(
646 cards[0].notes.as_deref(),
647 Some("Line 1\nLine 2, with comma")
648 );
649 }
650 }
651