| 3 |
3 |
|
//! Provides preview (dry-run) and import (create records) for .vcf and .ics files.
|
| 4 |
4 |
|
|
| 5 |
5 |
|
use chrono::NaiveDate;
|
| 6 |
|
- |
use serde::Serialize;
|
|
6 |
+ |
use serde::{Deserialize, Serialize};
|
| 7 |
7 |
|
use std::sync::Arc;
|
| 8 |
8 |
|
use tauri::State;
|
| 9 |
9 |
|
use tracing::instrument;
|
| 49 |
49 |
|
pub struct ImportResult {
|
| 50 |
50 |
|
pub imported: u64,
|
| 51 |
51 |
|
pub skipped: u64,
|
|
52 |
+ |
/// Cards folded into an existing contact (vCard import only).
|
|
53 |
+ |
pub merged: u64,
|
| 52 |
54 |
|
pub errors: Vec<String>,
|
| 53 |
55 |
|
}
|
| 54 |
56 |
|
|
| 60 |
62 |
|
pub email_count: usize,
|
| 61 |
63 |
|
pub phone_count: usize,
|
| 62 |
64 |
|
pub company: Option<String>,
|
|
65 |
+ |
/// Display name of the existing contact this card matches by email address,
|
|
66 |
+ |
/// or `None` when the card is new. Drives the duplicate choice in the
|
|
67 |
+ |
/// preview modal.
|
|
68 |
+ |
pub duplicate_of: Option<String>,
|
|
69 |
+ |
}
|
|
70 |
+ |
|
|
71 |
+ |
/// What to do with a card that matches an existing contact by email address.
|
|
72 |
+ |
/// Chosen once for the whole import in the preview modal.
|
|
73 |
+ |
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
|
|
74 |
+ |
#[serde(rename_all = "camelCase")]
|
|
75 |
+ |
pub enum DuplicateStrategy {
|
|
76 |
+ |
/// Fold the card into the existing contact: fill empty fields, union tags,
|
|
77 |
+ |
/// append sub-collection rows that are not already there. Never overwrites.
|
|
78 |
+ |
#[default]
|
|
79 |
+ |
Merge,
|
|
80 |
+ |
/// Leave the existing contact alone and count the card as skipped.
|
|
81 |
+ |
Skip,
|
|
82 |
+ |
/// Create a second contact regardless of the match.
|
|
83 |
+ |
ImportAsNew,
|
| 63 |
84 |
|
}
|
| 64 |
85 |
|
|
| 65 |
86 |
|
/// Preview of a single ICS event.
|
| 78 |
99 |
|
/// Preview a vCard import without creating records.
|
| 79 |
100 |
|
#[tauri::command]
|
| 80 |
101 |
|
#[instrument(skip_all)]
|
| 81 |
|
- |
pub async fn preview_vcf(file_path: String) -> Result<Vec<VCardPreview>, ApiError> {
|
|
102 |
+ |
pub async fn preview_vcf(
|
|
103 |
+ |
state: State<'_, Arc<AppState>>,
|
|
104 |
+ |
file_path: String,
|
|
105 |
+ |
) -> Result<Vec<VCardPreview>, ApiError> {
|
| 82 |
106 |
|
let content = read_import_file(&file_path)?;
|
| 83 |
107 |
|
|
| 84 |
108 |
|
let cards = vcard::parse_vcf(&content)
|
| 85 |
109 |
|
.map_err(|e| ApiError::internal(format!("Failed to parse vCard: {e}")))?;
|
| 86 |
110 |
|
|
| 87 |
|
- |
Ok(cards
|
| 88 |
|
- |
.into_iter()
|
| 89 |
|
- |
.map(|c| VCardPreview {
|
| 90 |
|
- |
display_name: c.display_name,
|
| 91 |
|
- |
email_count: c.emails.len(),
|
| 92 |
|
- |
phone_count: c.phones.len(),
|
| 93 |
|
- |
company: c.company,
|
| 94 |
|
- |
})
|
| 95 |
|
- |
.collect())
|
|
111 |
+ |
let mut previews = Vec::with_capacity(cards.len());
|
|
112 |
+ |
for card in cards {
|
|
113 |
+ |
previews.push(VCardPreview {
|
|
114 |
+ |
display_name: card.display_name.clone(),
|
|
115 |
+ |
email_count: card.emails.len(),
|
|
116 |
+ |
phone_count: card.phones.len(),
|
|
117 |
+ |
company: card.company.clone(),
|
|
118 |
+ |
duplicate_of: find_duplicate(&state, &card)
|
|
119 |
+ |
.await
|
|
120 |
+ |
.map(|existing| existing.display_name),
|
|
121 |
+ |
});
|
|
122 |
+ |
}
|
|
123 |
+ |
Ok(previews)
|
|
124 |
+ |
}
|
|
125 |
+ |
|
|
126 |
+ |
/// Finds an existing contact sharing any of the card's email addresses.
|
|
127 |
+ |
///
|
|
128 |
+ |
/// Email is the only key that survives a round trip through someone else's
|
|
129 |
+ |
/// address book; display names collide and vCard UIDs are regenerated by most
|
|
130 |
+ |
/// exporters. A card with no email is therefore never a duplicate.
|
|
131 |
+ |
async fn find_duplicate(
|
|
132 |
+ |
state: &Arc<AppState>,
|
|
133 |
+ |
card: &vcard::ParsedVCard,
|
|
134 |
+ |
) -> Option<goingson_core::Contact> {
|
|
135 |
+ |
for email in &card.emails {
|
|
136 |
+ |
if let Ok(Some(existing)) = state
|
|
137 |
+ |
.contacts
|
|
138 |
+ |
.find_by_email(DESKTOP_USER_ID, &email.address)
|
|
139 |
+ |
.await
|
|
140 |
+ |
{
|
|
141 |
+ |
return Some(existing);
|
|
142 |
+ |
}
|
|
143 |
+ |
}
|
|
144 |
+ |
None
|
| 96 |
145 |
|
}
|
| 97 |
146 |
|
|
| 98 |
147 |
|
/// Preview an ICS import without creating records.
|
| 129 |
178 |
|
pub async fn import_vcf(
|
| 130 |
179 |
|
state: State<'_, Arc<AppState>>,
|
| 131 |
180 |
|
file_path: String,
|
|
181 |
+ |
duplicate_strategy: Option<DuplicateStrategy>,
|
| 132 |
182 |
|
) -> Result<ImportResult, ApiError> {
|
| 133 |
183 |
|
let content = read_import_file(&file_path)?;
|
|
184 |
+ |
let strategy = duplicate_strategy.unwrap_or_default();
|
| 134 |
185 |
|
|
| 135 |
186 |
|
let cards = vcard::parse_vcf(&content)
|
| 136 |
187 |
|
.map_err(|e| ApiError::internal(format!("Failed to parse vCard: {e}")))?;
|
| 137 |
188 |
|
|
| 138 |
189 |
|
let mut imported = 0u64;
|
| 139 |
190 |
|
let mut skipped = 0u64;
|
|
191 |
+ |
let mut merged = 0u64;
|
| 140 |
192 |
|
let mut errors = Vec::new();
|
| 141 |
193 |
|
|
| 142 |
194 |
|
for card in cards {
|
| 146 |
198 |
|
.first()
|
| 147 |
199 |
|
.map_or_else(|| card.display_name.clone(), |e| e.address.clone());
|
| 148 |
200 |
|
|
| 149 |
|
- |
// Check for existing contact with same external source + id
|
| 150 |
|
- |
if let Ok(Some(_)) = state
|
|
201 |
+ |
// A card is a duplicate if it came from a previous import of this file
|
|
202 |
+ |
// (external ref) or if any of its addresses already belongs to a
|
|
203 |
+ |
// contact. Both feed the same strategy so the preview's count and the
|
|
204 |
+ |
// import's behavior agree.
|
|
205 |
+ |
let existing = match state
|
| 151 |
206 |
|
.contacts
|
| 152 |
207 |
|
.find_by_external_id("vcf", &ext_id, DESKTOP_USER_ID)
|
| 153 |
208 |
|
.await
|
| 154 |
209 |
|
{
|
| 155 |
|
- |
skipped += 1;
|
| 156 |
|
- |
continue;
|
|
210 |
+ |
Ok(Some(contact)) => Some(contact),
|
|
211 |
+ |
_ => find_duplicate(&state, &card).await,
|
|
212 |
+ |
};
|
|
213 |
+ |
|
|
214 |
+ |
if let Some(existing) = existing {
|
|
215 |
+ |
match strategy {
|
|
216 |
+ |
DuplicateStrategy::Skip => {
|
|
217 |
+ |
skipped += 1;
|
|
218 |
+ |
continue;
|
|
219 |
+ |
}
|
|
220 |
+ |
DuplicateStrategy::Merge => {
|
|
221 |
+ |
match merge_card_into(&state, &existing, &card).await {
|
|
222 |
+ |
Ok(()) => merged += 1,
|
|
223 |
+ |
Err(e) => errors.push(format!("{}: {}", card.display_name, e)),
|
|
224 |
+ |
}
|
|
225 |
+ |
continue;
|
|
226 |
+ |
}
|
|
227 |
+ |
// Fall through to the create path below.
|
|
228 |
+ |
DuplicateStrategy::ImportAsNew => {}
|
|
229 |
+ |
}
|
| 157 |
230 |
|
}
|
| 158 |
231 |
|
|
| 159 |
232 |
|
// Parse birthday
|
| 270 |
343 |
|
Ok(ImportResult {
|
| 271 |
344 |
|
imported,
|
| 272 |
345 |
|
skipped,
|
|
346 |
+ |
merged,
|
| 273 |
347 |
|
errors,
|
| 274 |
348 |
|
})
|
| 275 |
349 |
|
}
|
| 276 |
350 |
|
|
|
351 |
+ |
/// Builds the update that folds a vCard into an existing contact.
|
|
352 |
+ |
///
|
|
353 |
+ |
/// Fill-empty, never overwrite: the local record is the one the user has been
|
|
354 |
+ |
/// curating, and an address book export is not grounds for clobbering it. Tags
|
|
355 |
+ |
/// are unioned; notes are the one text field that appends, since two notes are
|
|
356 |
+ |
/// both worth keeping and neither is a correction of the other.
|
|
357 |
+ |
fn merge_contact_fields(
|
|
358 |
+ |
existing: &goingson_core::Contact,
|
|
359 |
+ |
card: &vcard::ParsedVCard,
|
|
360 |
+ |
) -> goingson_core::UpdateContact {
|
|
361 |
+ |
fn fill(current: Option<&String>, incoming: Option<&String>) -> Option<String> {
|
|
362 |
+ |
match current {
|
|
363 |
+ |
Some(v) if !v.trim().is_empty() => Some(v.clone()),
|
|
364 |
+ |
_ => incoming.filter(|v| !v.trim().is_empty()).cloned(),
|
|
365 |
+ |
}
|
|
366 |
+ |
}
|
|
367 |
+ |
|
|
368 |
+ |
let mut tags = existing.tags.clone();
|
|
369 |
+ |
for tag in &card.tags {
|
|
370 |
+ |
if !tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
|
|
371 |
+ |
tags.push(tag.clone());
|
|
372 |
+ |
}
|
|
373 |
+ |
}
|
|
374 |
+ |
|
|
375 |
+ |
let incoming_notes = card.notes.as_deref().unwrap_or("").trim();
|
|
376 |
+ |
let notes = match (existing.notes.trim(), incoming_notes) {
|
|
377 |
+ |
(existing_notes, "") => existing_notes.to_string(),
|
|
378 |
+ |
("", incoming) => incoming.to_string(),
|
|
379 |
+ |
(existing_notes, incoming) if existing_notes.contains(incoming) => {
|
|
380 |
+ |
existing_notes.to_string()
|
|
381 |
+ |
}
|
|
382 |
+ |
(existing_notes, incoming) => format!("{existing_notes}\n\n{incoming}"),
|
|
383 |
+ |
};
|
|
384 |
+ |
|
|
385 |
+ |
goingson_core::UpdateContact {
|
|
386 |
+ |
display_name: existing.display_name.clone(),
|
|
387 |
+ |
nickname: fill(existing.nickname.as_ref(), card.nickname.as_ref()),
|
|
388 |
+ |
company: fill(existing.company.as_ref(), card.company.as_ref()),
|
|
389 |
+ |
title: fill(existing.title.as_ref(), card.title.as_ref()),
|
|
390 |
+ |
notes,
|
|
391 |
+ |
tags,
|
|
392 |
+ |
birthday: existing.birthday.or_else(|| {
|
|
393 |
+ |
card.birthday
|
|
394 |
+ |
.as_deref()
|
|
395 |
+ |
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
|
|
396 |
+ |
}),
|
|
397 |
+ |
timezone: fill(existing.timezone.as_ref(), card.timezone.as_ref()),
|
|
398 |
+ |
}
|
|
399 |
+ |
}
|
|
400 |
+ |
|
|
401 |
+ |
/// Folds a card into an existing contact: scalar fill-empty, then the
|
|
402 |
+ |
/// sub-collection rows the contact does not already have.
|
|
403 |
+ |
///
|
|
404 |
+ |
/// Sub-collection failures are logged rather than propagated, matching the
|
|
405 |
+ |
/// create path: a phone number that will not insert should not cost the user
|
|
406 |
+ |
/// the rest of the merge.
|
|
407 |
+ |
async fn merge_card_into(
|
|
408 |
+ |
state: &Arc<AppState>,
|
|
409 |
+ |
existing: &goingson_core::Contact,
|
|
410 |
+ |
card: &vcard::ParsedVCard,
|
|
411 |
+ |
) -> Result<(), String> {
|
|
412 |
+ |
state
|
|
413 |
+ |
.contacts
|
|
414 |
+ |
.update(
|
|
415 |
+ |
existing.id,
|
|
416 |
+ |
DESKTOP_USER_ID,
|
|
417 |
+ |
merge_contact_fields(existing, card),
|
|
418 |
+ |
)
|
|
419 |
+ |
.await
|
|
420 |
+ |
.map_err(|e| e.to_string())?;
|
|
421 |
+ |
|
|
422 |
+ |
for email in &card.emails {
|
|
423 |
+ |
if existing
|
|
424 |
+ |
.emails
|
|
425 |
+ |
.iter()
|
|
426 |
+ |
.any(|e| e.address.eq_ignore_ascii_case(&email.address))
|
|
427 |
+ |
{
|
|
428 |
+ |
continue;
|
|
429 |
+ |
}
|
|
430 |
+ |
if let Err(e) = state
|
|
431 |
+ |
.contacts
|
|
432 |
+ |
.add_email(
|
|
433 |
+ |
existing.id,
|
|
434 |
+ |
DESKTOP_USER_ID,
|
|
435 |
+ |
NewContactEmail {
|
|
436 |
+ |
address: email.address.clone(),
|
|
437 |
+ |
label: email.label.clone(),
|
|
438 |
+ |
// The existing contact keeps whichever address it already
|
|
439 |
+ |
// considers primary.
|
|
440 |
+ |
is_primary: false,
|
|
441 |
+ |
},
|
|
442 |
+ |
)
|
|
443 |
+ |
.await
|
|
444 |
+ |
{
|
|
445 |
+ |
tracing::warn!(contact = %card.display_name, "Merge: failed to add email: {}", e);
|
|
446 |
+ |
}
|
|
447 |
+ |
}
|
|
448 |
+ |
|
|
449 |
+ |
for phone in &card.phones {
|
|
450 |
+ |
if existing
|
|
451 |
+ |
.phones
|
|
452 |
+ |
.iter()
|
|
453 |
+ |
.any(|p| digits(&p.number) == digits(&phone.number))
|
|
454 |
+ |
{
|
|
455 |
+ |
continue;
|
|
456 |
+ |
}
|
|
457 |
+ |
if let Err(e) = state
|
|
458 |
+ |
.contacts
|
|
459 |
+ |
.add_phone(
|
|
460 |
+ |
existing.id,
|
|
461 |
+ |
DESKTOP_USER_ID,
|
|
462 |
+ |
NewContactPhone {
|
|
463 |
+ |
number: phone.number.clone(),
|
|
464 |
+ |
label: phone.label.clone(),
|
|
465 |
+ |
is_primary: false,
|
|
466 |
+ |
},
|
|
467 |
+ |
)
|
|
468 |
+ |
.await
|
|
469 |
+ |
{
|
|
470 |
+ |
tracing::warn!(contact = %card.display_name, "Merge: failed to add phone: {}", e);
|
|
471 |
+ |
}
|
|
472 |
+ |
}
|
|
473 |
+ |
|
|
474 |
+ |
for social in &card.social_handles {
|
|
475 |
+ |
if existing.social_handles.iter().any(|s| {
|
|
476 |
+ |
s.platform.eq_ignore_ascii_case(&social.platform)
|
|
477 |
+ |
&& s.handle.eq_ignore_ascii_case(&social.handle)
|
|
478 |
+ |
}) {
|
|
479 |
+ |
continue;
|
|
480 |
+ |
}
|
|
481 |
+ |
if let Err(e) = state
|
|
482 |
+ |
.contacts
|
|
483 |
+ |
.add_social_handle(
|
|
484 |
+ |
existing.id,
|
|
485 |
+ |
DESKTOP_USER_ID,
|
|
486 |
+ |
NewSocialHandle {
|
|
487 |
+ |
platform: social.platform.clone(),
|
|
488 |
+ |
handle: social.handle.clone(),
|
|
489 |
+ |
url: social.url.clone(),
|
|
490 |
+ |
},
|
|
491 |
+ |
)
|
|
492 |
+ |
.await
|
|
493 |
+ |
{
|
|
494 |
+ |
tracing::warn!(contact = %card.display_name, "Merge: failed to add social handle: {}", e);
|
|
495 |
+ |
}
|
|
496 |
+ |
}
|
|
497 |
+ |
|
|
498 |
+ |
for field in &card.custom_fields {
|
|
499 |
+ |
if existing
|
|
500 |
+ |
.custom_fields
|
|
501 |
+ |
.iter()
|
|
502 |
+ |
.any(|f| f.label.eq_ignore_ascii_case(&field.label))
|
|
503 |
+ |
{
|
|
504 |
+ |
continue;
|
|
505 |
+ |
}
|
|
506 |
+ |
if let Err(e) = state
|
|
507 |
+ |
.contacts
|
|
508 |
+ |
.add_custom_field(
|
|
509 |
+ |
existing.id,
|
|
510 |
+ |
DESKTOP_USER_ID,
|
|
511 |
+ |
NewContactCustomField {
|
|
512 |
+ |
label: field.label.clone(),
|
|
513 |
+ |
value: field.value.clone(),
|
|
514 |
+ |
url: field.url.clone(),
|
|
515 |
+ |
},
|
|
516 |
+ |
)
|
|
517 |
+ |
.await
|
|
518 |
+ |
{
|
|
519 |
+ |
tracing::warn!(contact = %card.display_name, "Merge: failed to add custom field: {}", e);
|
|
520 |
+ |
}
|
|
521 |
+ |
}
|
|
522 |
+ |
|
|
523 |
+ |
Ok(())
|
|
524 |
+ |
}
|
|
525 |
+ |
|
|
526 |
+ |
/// Digits of a phone number, so `+1 (555) 010-9999` and `15550109999` are one
|
|
527 |
+ |
/// number rather than two rows on the merged contact.
|
|
528 |
+ |
fn digits(number: &str) -> String {
|
|
529 |
+ |
number.chars().filter(char::is_ascii_digit).collect()
|
|
530 |
+ |
}
|
|
531 |
+ |
|
| 277 |
532 |
|
/// Import events from an iCalendar (.ics) file.
|
| 278 |
533 |
|
#[tauri::command]
|
| 279 |
534 |
|
#[instrument(skip_all)]
|
| 361 |
616 |
|
Ok(ImportResult {
|
| 362 |
617 |
|
imported,
|
| 363 |
618 |
|
skipped,
|
|
619 |
+ |
merged: 0,
|
| 364 |
620 |
|
errors,
|
| 365 |
621 |
|
})
|
| 366 |
622 |
|
}
|
|
623 |
+ |
|
|
624 |
+ |
#[cfg(test)]
|
|
625 |
+ |
mod tests {
|
|
626 |
+ |
use super::*;
|
|
627 |
+ |
use chrono::Utc;
|
|
628 |
+ |
use goingson_core::{Contact, ContactId};
|
|
629 |
+ |
|
|
630 |
+ |
fn contact(display_name: &str) -> Contact {
|
|
631 |
+ |
Contact {
|
|
632 |
+ |
id: ContactId::new(),
|
|
633 |
+ |
display_name: display_name.to_string(),
|
|
634 |
+ |
nickname: None,
|
|
635 |
+ |
company: None,
|
|
636 |
+ |
title: None,
|
|
637 |
+ |
notes: String::new(),
|
|
638 |
+ |
tags: Vec::new(),
|
|
639 |
+ |
birthday: None,
|
|
640 |
+ |
timezone: None,
|
|
641 |
+ |
external_source: None,
|
|
642 |
+ |
external_id: None,
|
|
643 |
+ |
is_implicit: false,
|
|
644 |
+ |
emails: Vec::new(),
|
|
645 |
+ |
phones: Vec::new(),
|
|
646 |
+ |
social_handles: Vec::new(),
|
|
647 |
+ |
custom_fields: Vec::new(),
|
|
648 |
+ |
created_at: Utc::now(),
|
|
649 |
+ |
updated_at: Utc::now(),
|
|
650 |
+ |
}
|
|
651 |
+ |
}
|
|
652 |
+ |
|
|
653 |
+ |
fn card(display_name: &str) -> vcard::ParsedVCard {
|
|
654 |
+ |
vcard::ParsedVCard {
|
|
655 |
+ |
display_name: display_name.to_string(),
|
|
656 |
+ |
nickname: None,
|
|
657 |
+ |
company: None,
|
|
658 |
+ |
title: None,
|
|
659 |
+ |
notes: None,
|
|
660 |
+ |
birthday: None,
|
|
661 |
+ |
timezone: None,
|
|
662 |
+ |
tags: Vec::new(),
|
|
663 |
+ |
emails: Vec::new(),
|
|
664 |
+ |
phones: Vec::new(),
|
|
665 |
+ |
social_handles: Vec::new(),
|
|
666 |
+ |
custom_fields: Vec::new(),
|
|
667 |
+ |
}
|
|
668 |
+ |
}
|
|
669 |
+ |
|
|
670 |
+ |
#[test]
|
|
671 |
+ |
fn merge_fills_empty_fields_only() {
|
|
672 |
+ |
let mut existing = contact("Jane Smith");
|
|
673 |
+ |
existing.company = Some("Acme".to_string());
|
|
674 |
+ |
|
|
675 |
+ |
let mut incoming = card("Jane Smith");
|
|
676 |
+ |
incoming.company = Some("Globex".to_string());
|
|
677 |
+ |
incoming.title = Some("Engineer".to_string());
|
|
678 |
+ |
|
|
679 |
+ |
let update = merge_contact_fields(&existing, &incoming);
|
|
680 |
+ |
assert_eq!(update.company.as_deref(), Some("Acme"));
|
|
681 |
+ |
assert_eq!(update.title.as_deref(), Some("Engineer"));
|
|
682 |
+ |
}
|
|
683 |
+ |
|
|
684 |
+ |
#[test]
|
|
685 |
+ |
fn merge_treats_blank_existing_field_as_empty() {
|
|
686 |
+ |
let mut existing = contact("Jane Smith");
|
|
687 |
+ |
existing.nickname = Some(" ".to_string());
|
|
688 |
+ |
|
|
689 |
+ |
let mut incoming = card("Jane Smith");
|
|
690 |
+ |
incoming.nickname = Some("Janie".to_string());
|
|
691 |
+ |
|
|
692 |
+ |
assert_eq!(
|
|
693 |
+ |
merge_contact_fields(&existing, &incoming)
|
|
694 |
+ |
.nickname
|
|
695 |
+ |
.as_deref(),
|
|
696 |
+ |
Some("Janie")
|
|
697 |
+ |
);
|
|
698 |
+ |
}
|
|
699 |
+ |
|
|
700 |
+ |
#[test]
|
|
701 |
+ |
fn merge_never_replaces_display_name() {
|
|
702 |
+ |
let existing = contact("Jane Smith");
|
|
703 |
+ |
let incoming = card("J. Smith");
|
|
704 |
+ |
assert_eq!(
|
|
705 |
+ |
merge_contact_fields(&existing, &incoming).display_name,
|
|
706 |
+ |
"Jane Smith"
|
|
707 |
+ |
);
|
|
708 |
+ |
}
|
|
709 |
+ |
|
|
710 |
+ |
#[test]
|
|
711 |
+ |
fn merge_unions_tags_case_insensitively() {
|
|
712 |
+ |
let mut existing = contact("Jane Smith");
|
|
713 |
+ |
existing.tags = vec!["Friend".to_string(), "climbing".to_string()];
|
|
714 |
+ |
|
|
715 |
+ |
let mut incoming = card("Jane Smith");
|
|
716 |
+ |
incoming.tags = vec!["friend".to_string(), "coworker".to_string()];
|
|
717 |
+ |
|
|
718 |
+ |
let update = merge_contact_fields(&existing, &incoming);
|
|
719 |
+ |
assert_eq!(update.tags, vec!["Friend", "climbing", "coworker"]);
|
|
720 |
+ |
}
|
|
721 |
+ |
|
|
722 |
+ |
#[test]
|
|
723 |
+ |
fn merge_appends_notes_without_duplicating() {
|
|
724 |
+ |
let mut existing = contact("Jane Smith");
|
|
725 |
+ |
existing.notes = "Met at the conference.".to_string();
|
|
726 |
+ |
|
|
727 |
+ |
let mut incoming = card("Jane Smith");
|
|
728 |
+ |
incoming.notes = Some("Prefers email.".to_string());
|
|
729 |
+ |
|
|
730 |
+ |
let update = merge_contact_fields(&existing, &incoming);
|
|
731 |
+ |
assert_eq!(update.notes, "Met at the conference.\n\nPrefers email.");
|
|
732 |
+ |
|
|
733 |
+ |
// Re-merging the same card is a no-op rather than a second paste.
|
|
734 |
+ |
let mut already_merged = contact("Jane Smith");
|
|
735 |
+ |
already_merged.notes = update.notes.clone();
|
|
736 |
+ |
assert_eq!(
|
|
737 |
+ |
merge_contact_fields(&already_merged, &incoming).notes,
|
|
738 |
+ |
update.notes
|
|
739 |
+ |
);
|
|
740 |
+ |
}
|
|
741 |
+ |
|
|
742 |
+ |
#[test]
|
|
743 |
+ |
fn merge_parses_birthday_only_when_missing() {
|
|
744 |
+ |
let mut incoming = card("Jane Smith");
|
|
745 |
+ |
incoming.birthday = Some("1990-01-15".to_string());
|
|
746 |
+ |
|
|
747 |
+ |
let filled = merge_contact_fields(&contact("Jane Smith"), &incoming);
|
|
748 |
+ |
assert_eq!(filled.birthday, NaiveDate::from_ymd_opt(1990, 1, 15));
|
|
749 |
+ |
|
|
750 |
+ |
let mut existing = contact("Jane Smith");
|
|
751 |
+ |
existing.birthday = NaiveDate::from_ymd_opt(1988, 3, 2);
|
|
752 |
+ |
assert_eq!(
|
|
753 |
+ |
merge_contact_fields(&existing, &incoming).birthday,
|
|
754 |
+ |
NaiveDate::from_ymd_opt(1988, 3, 2)
|
|
755 |
+ |
);
|
|
756 |
+ |
}
|
|
757 |
+ |
|
|
758 |
+ |
#[test]
|
|
759 |
+ |
fn merge_ignores_unparseable_birthday() {
|
|
760 |
+ |
let mut incoming = card("Jane Smith");
|
|
761 |
+ |
incoming.birthday = Some("15 January 1990".to_string());
|
|
762 |
+ |
assert!(
|
|
763 |
+ |
merge_contact_fields(&contact("Jane Smith"), &incoming)
|
|
764 |
+ |
.birthday
|
|
765 |
+ |
.is_none()
|
|
766 |
+ |
);
|
|
767 |
+ |
}
|