BusinessCard/BusinessCardClip/Models/SharedCardSnapshot.swift

336 lines
12 KiB
Swift

import Foundation
import Contacts
/// Represents a shared card fetched from CloudKit for display in the App Clip.
struct SharedCardSnapshot: Sendable {
struct ContactInfoRow: Identifiable, Sendable {
enum Kind: Sendable {
case phone
case email
case website
case address
case social
case note
}
let id = UUID()
let kind: Kind
let value: String
let label: String
var systemImage: String {
switch kind {
case .phone:
return "phone.fill"
case .email:
return "envelope.fill"
case .website:
return "globe"
case .address:
return "location.fill"
case .social:
return "link"
case .note:
return "note.text"
}
}
}
let recordName: String
let vCardData: String
let displayName: String
let role: String
let company: String
let photoData: Data?
let contactInfoRows: [ContactInfoRow]
init(
recordName: String,
vCardData: String,
displayName: String? = nil,
role: String? = nil,
company: String? = nil,
photoData: Data? = nil
) {
self.recordName = recordName
self.vCardData = vCardData
// Parse display fields from vCard
let lines = vCardData.components(separatedBy: .newlines)
let parsedDisplayName = Self.parseField("FN:", from: lines) ?? String(localized: "Contact")
let parsedRole = Self.parseField("TITLE:", from: lines) ?? ""
let parsedCompany = Self.parseField("ORG:", from: lines)?
.components(separatedBy: ";").first ?? ""
let parsedPhotoData = Self.parsePhoto(from: lines)
let cleanedDisplayName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.displayName = cleanedDisplayName.isEmpty ? parsedDisplayName : cleanedDisplayName
self.role = role ?? parsedRole
self.company = company ?? parsedCompany
self.photoData = photoData ?? parsedPhotoData
self.contactInfoRows = Self.parseContactInfoRows(from: lines, vCardData: vCardData)
}
private static func parseField(_ prefix: String, from lines: [String]) -> String? {
lines.first { $0.hasPrefix(prefix) }?
.dropFirst(prefix.count)
.trimmingCharacters(in: .whitespaces)
}
private static func parsePhoto(from lines: [String]) -> Data? {
// Handles PHOTO on a single line. Folded/multiline photos are out of scope here.
guard let photoLine = lines.first(where: { $0.uppercased().hasPrefix("PHOTO") }),
let base64Start = photoLine.firstIndex(of: ":") else {
return nil
}
let valueStart = photoLine.index(after: base64Start)
let base64String = String(photoLine[valueStart...]).trimmingCharacters(in: .whitespacesAndNewlines)
return Data(base64Encoded: base64String)
}
private static func parseContactInfoRows(from lines: [String], vCardData: String) -> [ContactInfoRow] {
var rows = parseContactRowsWithContactsFramework(vCardData: vCardData)
var existingKeys = Set(rows.map { dedupeKey(for: $0) })
let useManualFallback = rows.isEmpty
for line in lines {
guard let separatorIndex = line.firstIndex(of: ":") else { continue }
let metadata = String(line[..<separatorIndex])
let metadataUpper = metadata.uppercased()
let rawValue = String(line[line.index(after: separatorIndex)...])
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !rawValue.isEmpty else { continue }
if metadataUpper.hasPrefix("X-SOCIALPROFILE") {
let row = ContactInfoRow(
kind: .social,
value: rawValue,
label: normalizeSocialLabel(extractType(from: metadata) ?? "Social")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if useManualFallback {
// Fallback when Contacts framework parsing fails.
if metadataUpper.hasPrefix("TEL") {
let row = ContactInfoRow(
kind: .phone,
value: rawValue,
label: normalizeLabel(extractType(from: metadata) ?? "Phone")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if metadataUpper.hasPrefix("EMAIL") {
let row = ContactInfoRow(
kind: .email,
value: rawValue,
label: normalizeLabel(extractType(from: metadata) ?? "Email")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if metadataUpper.hasPrefix("ADR") {
let row = ContactInfoRow(
kind: .address,
value: formatAddress(rawValue),
label: normalizeLabel(extractType(from: metadata) ?? "Address")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if metadataUpper.hasPrefix("URL") {
let row = ContactInfoRow(
kind: .website,
value: rawValue,
label: normalizeLabel(extractType(from: metadata) ?? "Website")
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
} else if metadataUpper.hasPrefix("NOTE") {
let row = ContactInfoRow(
kind: .note,
value: rawValue.replacingOccurrences(of: "\\n", with: "\n"),
label: "Note"
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
}
} else if metadataUpper.hasPrefix("NOTE") {
let row = ContactInfoRow(
kind: .note,
value: rawValue.replacingOccurrences(of: "\\n", with: "\n"),
label: "Note"
)
let key = dedupeKey(for: row)
if !existingKeys.contains(key) {
rows.append(row)
existingKeys.insert(key)
}
}
}
return rows
}
private static func parseContactRowsWithContactsFramework(vCardData: String) -> [ContactInfoRow] {
guard let data = vCardData.data(using: .utf8),
let contact = try? CNContactVCardSerialization.contacts(with: data).first else {
return []
}
var rows: [ContactInfoRow] = []
for phone in contact.phoneNumbers {
let value = phone.value.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
rows.append(
ContactInfoRow(
kind: .phone,
value: value,
label: normalizeLabel(localizedLabel(phone.label))
)
)
}
for email in contact.emailAddresses {
let value = String(email.value).trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
rows.append(
ContactInfoRow(
kind: .email,
value: value,
label: normalizeLabel(localizedLabel(email.label, fallback: "Email"))
)
)
}
for address in contact.postalAddresses {
let value = CNPostalAddressFormatter.string(from: address.value, style: .mailingAddress)
.replacingOccurrences(of: "\n", with: ", ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
rows.append(
ContactInfoRow(
kind: .address,
value: value,
label: normalizeLabel(localizedLabel(address.label, fallback: "Address"))
)
)
}
for urlAddress in contact.urlAddresses {
let value = String(urlAddress.value).trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
rows.append(
ContactInfoRow(
kind: .website,
value: value,
label: normalizeLabel(localizedLabel(urlAddress.label, fallback: "Website"))
)
)
}
let noteValue = contact.note.trimmingCharacters(in: .whitespacesAndNewlines)
if !noteValue.isEmpty {
rows.append(
ContactInfoRow(
kind: .note,
value: noteValue,
label: "Note"
)
)
}
return rows
}
private static func localizedLabel(_ label: String?, fallback: String = "Work") -> String {
guard let label else { return fallback }
return CNLabeledValue<NSString>.localizedString(forLabel: label)
}
private static func dedupeKey(for row: ContactInfoRow) -> String {
"\(row.kind)-\(row.value.lowercased())"
}
private static func extractType(from metadata: String) -> String? {
let parameters = metadata.components(separatedBy: ";")
for parameter in parameters {
let parts = parameter.components(separatedBy: "=")
guard parts.count == 2, parts[0].uppercased() == "TYPE" else { continue }
let rawType = parts[1].components(separatedBy: ",").first ?? parts[1]
return rawType.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func normalizeLabel(_ raw: String) -> String {
switch raw.uppercased() {
case "CELL":
return "Cell"
case "WORK":
return "Work"
case "HOME":
return "Home"
case "PERSONAL":
return "Personal"
default:
return raw.capitalized
}
}
private static func normalizeSocialLabel(_ raw: String) -> String {
switch raw.lowercased() {
case "linkedin":
return "LinkedIn"
case "twitter":
return "X"
case "tiktok":
return "TikTok"
case "youtube":
return "YouTube"
case "cashapp":
return "Cash App"
default:
return raw.capitalized
}
}
private static func formatAddress(_ adrValue: String) -> String {
let parts = adrValue
.split(separator: ";", omittingEmptySubsequences: false)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
guard parts.count >= 7 else { return adrValue }
let street = parts[2]
let city = parts[3]
let state = parts[4]
let postalCode = parts[5]
let country = parts[6]
let locality = [city, state, postalCode]
.filter { !$0.isEmpty }
.joined(separator: " ")
return [street, locality, country]
.filter { !$0.isEmpty }
.joined(separator: ", ")
}
}