Skip to main content

max / goingson

Fold the contacts cleanup: form fold, tag autocomplete, import dedup Three folded problems from the contacts-cleanup batch. The contact form now shows Name and Notes, with Nickname, Company, Title, Tags, Birthday and Timezone under "More options". This reuses the fold openFormModal already builds for other entities rather than adding a second mechanism, which is why Notes moves up: the fold opens at the first `extended` field and closes after the last one, so extended fields have to be a contiguous trailing block. Edit mode still auto-expands when any folded field holds a value. Bulk tag gets completions. The window.prompt replacement had already landed; showPromptDialog now takes a `suggestions` array and renders a datalist, and contacts passes the tags already in use. Under an active search or tag filter the suggestions reflect the filtered list, the same limitation the tag filter dropdown has. vCard import detects duplicates by email and offers to merge. Dedup was external-ref only, so a card whose address already belonged to a contact silently created a second one; preview_vcf now reports the match and import_vcf takes a strategy chosen once for the whole import. Both the external ref and the email match feed that strategy, so the preview count and the import agree. Merge is fill-empty. The local record is the one being curated and an address book export is not grounds for clobbering it: blank-or-missing scalars fill from the card, tags union case-insensitively, notes append unless already contained, and sub-collection rows are added only when absent. Phone comparison strips formatting so one number does not land twice. Field policy is a pure function with unit tests; the sub-collection writes log and continue, matching the create path.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 18:42 UTC
Signed with PGP, not checked
Commit: 8b0bd23f7da951fa743651776c02bbba1583de42
Parent: 85b9f77
7 files changed, +520 insertions, -31 deletions
@@ -6989,6 +6989,36 @@
6989 6989 font-size: var(--font-size-sm);
6990 6990 }
6991 6991
6992 + .import-dupe-flag {
6993 + color: var(--content-muted);
6994 + font-size: var(--font-size-sm);
6995 + white-space: nowrap;
6996 + }
6997 +
6998 + .import-dupe-choice {
6999 + margin: var(--gap-section) 0 0 0;
7000 + padding: var(--gap-peer);
7001 + border: var(--border-width) solid var(--border);
7002 + border-radius: var(--radius-sm);
7003 + }
7004 +
7005 + .import-dupe-choice legend {
7006 + padding: 0 var(--gap-bound);
7007 + color: var(--content);
7008 + font-size: var(--font-size-sm);
7009 + font-weight: 600;
7010 + }
7011 +
7012 + .import-dupe-choice label {
7013 + display: flex;
7014 + align-items: baseline;
7015 + gap: var(--gap-bound);
7016 + padding: var(--gap-bound) 0;
7017 + color: var(--content-muted);
7018 + font-size: var(--font-size-sm);
7019 + cursor: pointer;
7020 + }
7021 +
6992 7022 .import-empty,
6993 7023 .import-error {
6994 7024 padding: var(--gap-pane);
@@ -377,7 +377,7 @@
377 377 execute: (filePath, options = {}, selectedIndices = []) =>
378 378 invoke('execute_import', { input: { filePath, options, selectedIndices } }), // Create entities in DB
379 379 previewVcf: (filePath) => invoke('preview_vcf', { filePath }),
380 - importVcf: (filePath) => invoke('import_vcf', { filePath }),
380 + importVcf: (filePath, duplicateStrategy) => invoke('import_vcf', { filePath, duplicateStrategy: duplicateStrategy || null }), // strategy: 'merge' | 'skip' | 'importAsNew', defaults to merge
381 381 previewIcs: (filePath) => invoke('preview_ics', { filePath }),
382 382 importIcs: (filePath) => invoke('import_ics', { filePath }),
383 383 },
@@ -377,6 +377,7 @@
377 377 * @param {string} options.confirmText - Text for confirm button (default: "OK")
378 378 * @param {string} options.cancelText - Text for cancel button (default: "Cancel")
379 379 * @param {Function} options.validate - Optional sync validator; return error string to block, null to allow
380 + * @param {string[]} options.suggestions - Optional completions offered as a datalist
380 381 * @returns {Promise<string|null>} - Resolves to the entered value (trimmed), or null if cancelled
381 382 */
382 383 function showPromptDialog(title, message, options = {}) {
@@ -387,10 +388,16 @@
387 388 confirmText = 'OK',
388 389 cancelText = 'Cancel',
389 390 validate = null,
391 + suggestions = [],
390 392 } = options;
391 393
392 394 const inputId = 'prompt-dialog-input';
393 395 const errorId = 'prompt-dialog-error';
396 + const listId = 'prompt-dialog-suggestions';
397 +
398 + const datalist = suggestions.length
399 + ? `<datalist id="${listId}">${suggestions.map(s => `<option value="${escAttr(s)}"></option>`).join('')}</datalist>`
400 + : '';
394 401
395 402 const content = `
396 403 <div class="confirm-message-wrap">
@@ -398,9 +405,11 @@
398 405 </div>
399 406 <div class="form-group">
400 407 <input type="text" class="form-input" id="${inputId}"
401 - value="${GoingsOn.utils.escapeAttrValue(defaultValue)}"
402 - placeholder="${GoingsOn.utils.escapeAttrValue(placeholder)}"
408 + value="${escAttr(defaultValue)}"
409 + placeholder="${escAttr(placeholder)}"
410 + ${suggestions.length ? `list="${listId}" autocomplete="off"` : ''}
403 411 autofocus>
412 + ${datalist}
404 413 <div id="${errorId}" class="form-error"></div>
405 414 </div>
406 415 <div class="form-actions">
@@ -72,7 +72,11 @@
72 72 const tag = await GoingsOn.ui.showPromptDialog(
73 73 `Tag ${count} contact${count !== 1 ? 's' : ''}`,
74 74 'Tag to add:',
75 - { placeholder: 'e.g. follow-up', confirmText: 'Add tag' }
75 + {
76 + placeholder: 'e.g. follow-up',
77 + confirmText: 'Add tag',
78 + suggestions: getAllTags(GoingsOn.state.contacts || []),
79 + }
76 80 );
77 81 if (!tag) return;
78 82
@@ -456,12 +460,23 @@
456 460 required: true,
457 461 value: contact?.displayName || '',
458 462 },
463 + {
464 + name: 'notes',
465 + type: 'textarea',
466 + label: 'Notes',
467 + placeholder: 'Any notes about this contact...',
468 + value: contact?.notes || '',
469 + },
470 + // Everything below folds under "More options". Must stay contiguous and
471 + // last: openFormModal opens the fold at the first `extended` field and
472 + // closes it after the final field.
459 473 {
460 474 name: 'nickname',
461 475 type: 'text',
462 476 label: 'Nickname',
463 477 placeholder: 'Optional nickname',
464 478 value: contact?.nickname || '',
479 + extended: true,
465 480 },
466 481 {
467 482 name: 'company',
@@ -469,6 +484,7 @@
469 484 label: 'Company',
470 485 placeholder: 'Acme Corp',
471 486 value: contact?.company || '',
487 + extended: true,
472 488 },
473 489 {
474 490 name: 'title',
@@ -476,6 +492,7 @@
476 492 label: 'Title',
477 493 placeholder: 'Software Engineer',
478 494 value: contact?.title || '',
495 + extended: true,
479 496 },
480 497 {
481 498 name: 'tags',
@@ -483,6 +500,7 @@
483 500 label: 'Tags (comma-separated)',
484 501 placeholder: 'friend, coworker',
485 502 value: contact?.tags?.join(', ') || '',
503 + extended: true,
486 504 },
487 505 {
488 506 name: 'birthday',
@@ -490,6 +508,7 @@
490 508 label: 'Birthday (YYYY-MM-DD)',
491 509 placeholder: '1990-01-15',
492 510 value: contact?.birthday || '',
511 + extended: true,
493 512 },
494 513 {
495 514 name: 'timezone',
@@ -497,13 +516,7 @@
497 516 label: 'Timezone',
498 517 placeholder: 'America/New_York',
499 518 value: contact?.timezone || '',
500 - },
501 - {
502 - name: 'notes',
503 - type: 'textarea',
504 - label: 'Notes',
505 - placeholder: 'Any notes about this contact...',
506 - value: contact?.notes || '',
519 + extended: true,
507 520 },
508 521 ];
509 522 }
@@ -22,6 +22,9 @@
22 22 * @property {Array<{value: string, label: string, selected?: boolean}>} [options] - Options for select fields
23 23 * @property {*} [value] - Default/current value
24 24 * @property {string} [hint] - Help text below field
25 + * @property {boolean} [extended] - Fold this field under "More options". Extended
26 + * fields must be contiguous and last: the fold opens at the first one and closes
27 + * after the final field in the list.
25 28 * @property {Function} [onInput] - Called on input event with (value, previewEl) for live preview
26 29 * @property {Object} [gridColumn] - CSS grid column (e.g., '1fr 1fr' for row)
27 30 */
@@ -59,13 +59,14 @@
59 59
60 60 const maxPreview = 25;
61 61 const display = preview.slice(0, maxPreview);
62 + const dupeCount = preview.filter(c => c.duplicateOf).length;
62 63
63 64 const html = `
64 65 <p class="import-summary"><strong>${preview.length}</strong> contact${preview.length !== 1 ? 's' : ''} found</p>
65 66 <div class="import-preview-table-wrapper">
66 67 <table class="data-table import-preview-table">
67 68 <thead>
68 - <tr><th>Name</th><th>Company</th><th>Emails</th><th>Phones</th></tr>
69 + <tr><th>Name</th><th>Company</th><th>Emails</th><th>Phones</th><th>Status</th></tr>
69 70 </thead>
70 71 <tbody>
71 72 ${display.map(c => `
@@ -74,16 +75,18 @@
74 75 <td>${esc(c.company || '')}</td>
75 76 <td>${c.emailCount}</td>
76 77 <td>${c.phoneCount}</td>
78 + <td>${c.duplicateOf ? `<span class="import-dupe-flag" title="Matches ${esc(c.duplicateOf)} by email">Already exists</span>` : ''}</td>
77 79 </tr>
78 80 `).join('')}
79 81 </tbody>
80 82 </table>
81 83 </div>
82 84 ${preview.length > maxPreview ? `<p class="import-more">...and ${preview.length - maxPreview} more</p>` : ''}
85 + ${dupeCount > 0 ? renderDuplicateChoice(dupeCount) : ''}
83 86 `;
84 87
85 88 updatePreviewContent(html, async () => {
86 - await executeContactImport(filePath, preview.length);
89 + await executeContactImport(filePath, selectedDuplicateStrategy());
87 90 });
88 91 } catch (err) {
89 92 updatePreviewContent(`<p class="import-error">Failed to parse file: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`, null);
@@ -91,9 +94,38 @@
91 94 }
92 95
93 96 /**
94 - * Executes the vCard import.
97 + * Renders the duplicate-handling choice shown when the file contains cards
98 + * that match existing contacts by email. One choice applies to the whole
99 + * import.
100 + * @param {number} dupeCount - Number of matched cards
101 + * @returns {string} HTML
95 102 */
96 - async function executeContactImport(filePath, expectedCount) {
103 + function renderDuplicateChoice(dupeCount) {
104 + return `
105 + <fieldset class="import-dupe-choice">
106 + <legend>${dupeCount} contact${dupeCount !== 1 ? 's' : ''} already exist${dupeCount === 1 ? 's' : ''} here</legend>
107 + <label><input type="radio" name="import-dupe-strategy" value="merge" checked> Merge into existing (fills blank fields, adds new emails and phones, never overwrites)</label>
108 + <label><input type="radio" name="import-dupe-strategy" value="skip"> Skip duplicates</label>
109 + <label><input type="radio" name="import-dupe-strategy" value="importAsNew"> Import as new contacts</label>
110 + </fieldset>
111 + `;
112 + }
113 +
114 + /**
115 + * Reads the duplicate strategy from the preview modal.
116 + * @returns {string} 'merge' when the choice is absent (no duplicates found)
117 + */
118 + function selectedDuplicateStrategy() {
119 + const checked = document.querySelector('input[name="import-dupe-strategy"]:checked');
120 + return checked ? checked.value : 'merge';
121 + }
122 +
123 + /**
124 + * Executes the vCard import.
125 + * @param {string} filePath - Path to the .vcf file
126 + * @param {string} duplicateStrategy - 'merge' | 'skip' | 'importAsNew'
127 + */
128 + async function executeContactImport(filePath, duplicateStrategy) {
97 129 const btn = document.getElementById('import-external-confirm');
98 130 if (btn) {
99 131 btn.disabled = true;
@@ -101,11 +133,12 @@
101 133 }
102 134
103 135 try {
104 - const result = await GoingsOn.api.import.importVcf(filePath);
136 + const result = await GoingsOn.api.import.importVcf(filePath, duplicateStrategy);
105 137 GoingsOn.ui.closeModal();
106 138
107 139 const parts = [];
108 140 if (result.imported > 0) parts.push(`${result.imported} imported`);
141 + if (result.merged > 0) parts.push(`${result.merged} merged`);
109 142 if (result.skipped > 0) parts.push(`${result.skipped} skipped (duplicates)`);
110 143 if (result.errors.length > 0) parts.push(`${result.errors.length} failed`);
111 144
@@ -3,7 +3,7 @@
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,6 +49,8 @@
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,6 +62,25 @@
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,21 +99,49 @@
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,14 +178,17 @@
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,14 +198,35 @@
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,10 +343,192 @@
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,6 +616,159 @@
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 + }
Lines truncated