336 lines
12 KiB
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: ", ")
|
|
}
|
|
}
|