Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views. - Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts. - Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface. - Added loading and error states in ContactsListView for better user feedback during data fetching. - Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list. - Updated package.json to include @formkit/auto-animate for improved UI animations.
139 lines
4.4 KiB
TypeScript
139 lines
4.4 KiB
TypeScript
import type { FullContact } from "./types"
|
|
import { fullContactDisplayName } from "./types"
|
|
|
|
function contactMergeScore(c: FullContact): number {
|
|
let score = 0
|
|
if (fullContactDisplayName(c)) score += 3
|
|
if (c.emails.some((e) => e.value.trim())) score += 4
|
|
if (c.phones.some((p) => p.value.trim())) score += 2
|
|
if (c.company?.trim()) score += 1
|
|
if (c.path?.trim()) score += 1
|
|
return score
|
|
}
|
|
|
|
export function pickContactMergePrimary(contacts: FullContact[]): FullContact | undefined {
|
|
if (contacts.length === 0) return undefined
|
|
return [...contacts].sort((a, b) => contactMergeScore(b) - contactMergeScore(a))[0]
|
|
}
|
|
|
|
export function mergeManyContacts(
|
|
contacts: FullContact[],
|
|
primaryId?: string,
|
|
): { primary: FullContact; secondaries: FullContact[]; merged: FullContact } | null {
|
|
if (contacts.length < 2) return null
|
|
|
|
const primary =
|
|
(primaryId ? contacts.find((c) => c.id === primaryId) : undefined) ??
|
|
pickContactMergePrimary(contacts)
|
|
if (!primary) return null
|
|
|
|
const secondaries = contacts.filter((c) => c.id !== primary.id)
|
|
let merged = primary
|
|
for (const secondary of secondaries) {
|
|
merged = mergeFullContactFields(merged, secondary)
|
|
}
|
|
|
|
return {
|
|
primary,
|
|
secondaries,
|
|
merged: {
|
|
...merged,
|
|
id: primary.id,
|
|
path: primary.path,
|
|
etag: primary.etag,
|
|
createdAt: primary.createdAt,
|
|
},
|
|
}
|
|
}
|
|
|
|
function dedupeLabeledValues<T extends { value: string; label: string }>(items: T[]): T[] {
|
|
const seen = new Set<string>()
|
|
const out: T[] = []
|
|
for (const item of items) {
|
|
const key = item.value.trim().toLowerCase()
|
|
if (!key || seen.has(key)) continue
|
|
seen.add(key)
|
|
out.push(item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function dedupeAddresses(items: NonNullable<FullContact["addresses"]>): NonNullable<FullContact["addresses"]> {
|
|
const seen = new Set<string>()
|
|
const out: NonNullable<FullContact["addresses"]> = []
|
|
for (const item of items) {
|
|
const key = [
|
|
item.street,
|
|
item.city,
|
|
item.region,
|
|
item.postalCode,
|
|
item.country,
|
|
item.label,
|
|
]
|
|
.map((part) => part?.trim().toLowerCase() ?? "")
|
|
.join("|")
|
|
if (!key.replace(/\|/g, "") || seen.has(key)) continue
|
|
seen.add(key)
|
|
out.push(item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function dedupeStrings(items: string[]): string[] {
|
|
const seen = new Set<string>()
|
|
const out: string[] = []
|
|
for (const item of items) {
|
|
const key = item.trim().toLowerCase()
|
|
if (!key || seen.has(key)) continue
|
|
seen.add(key)
|
|
out.push(item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function mergeNotes(a?: string, b?: string): string | undefined {
|
|
const parts = [a, b]
|
|
.map((n) => n?.trim())
|
|
.filter(Boolean) as string[]
|
|
if (parts.length === 0) return undefined
|
|
return dedupeStrings(parts).join("\n\n")
|
|
}
|
|
|
|
export function mergeFullContactFields(primary: FullContact, secondary: FullContact): FullContact {
|
|
return {
|
|
...primary,
|
|
namePrefix: primary.namePrefix || secondary.namePrefix,
|
|
firstName: primary.firstName || secondary.firstName,
|
|
middleName: primary.middleName || secondary.middleName,
|
|
lastName: primary.lastName || secondary.lastName,
|
|
nameSuffix: primary.nameSuffix || secondary.nameSuffix,
|
|
phoneticFirstName: primary.phoneticFirstName || secondary.phoneticFirstName,
|
|
phoneticLastName: primary.phoneticLastName || secondary.phoneticLastName,
|
|
company: primary.company || secondary.company,
|
|
department: primary.department || secondary.department,
|
|
jobTitle: primary.jobTitle || secondary.jobTitle,
|
|
website: primary.website || secondary.website,
|
|
notes: mergeNotes(primary.notes, secondary.notes),
|
|
emails: dedupeLabeledValues([...primary.emails, ...secondary.emails]),
|
|
phones: dedupeLabeledValues([...primary.phones, ...secondary.phones]),
|
|
addresses: dedupeAddresses([
|
|
...(primary.addresses ?? []),
|
|
...(secondary.addresses ?? []),
|
|
]),
|
|
nicknames: dedupeStrings([...(primary.nicknames ?? []), ...(secondary.nicknames ?? [])]),
|
|
labels: dedupeStrings([...(primary.labels ?? []), ...(secondary.labels ?? [])]),
|
|
avatarUrl: primary.avatarUrl || secondary.avatarUrl,
|
|
birthday: primary.birthday ?? secondary.birthday,
|
|
updatedAt: Date.now(),
|
|
}
|
|
}
|
|
|
|
export function mergeTwoContacts(
|
|
a: FullContact,
|
|
b: FullContact,
|
|
): { primary: FullContact; secondary: FullContact; merged: FullContact } {
|
|
const [primary, secondary] =
|
|
contactMergeScore(a) >= contactMergeScore(b) ? [a, b] : [b, a]
|
|
return { primary, secondary, merged: mergeFullContactFields(primary, secondary) }
|
|
}
|