Replaces the bash/AppleScript/Python app bundle with a SwiftUI app backed by a testable library target. The app now reads Contacts and EventKit directly rather than sanitizing files someone exported by hand, so the properties that break click-wheel firmware are never written instead of being stripped afterwards: no PHOTO, no X-, no VALARM, no VTIMEZONE, no TZID. Recurring events arrive from EventKit already expanded into occurrences, so no RRULE is emitted either - the open question from the previous version no longer applies. Sanitizing existing .vcf/.ics files is kept as a second path, and gains handling the Python lacked: vCard 2.1 quoted-printable soft line breaks, Apple's item1. property groups, nested STANDARD/DAYLIGHT components inside VTIMEZONE, and UTC times converted to local floating times. Output is laid out as Contacts/ and Calendars/ subfolders mirroring the device, one file per contact and one per calendar. Built via SwiftPM plus Scripts/bundle.sh, which assembles and ad-hoc signs the bundle. Ad-hoc signing is required rather than cosmetic: Contacts and Calendars key permissions off the code signature. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01C5X1tYo9oxAfvoiFMh1QQr
134 lines
4.7 KiB
Swift
134 lines
4.7 KiB
Swift
import Contacts
|
|
import Foundation
|
|
|
|
/// Reads the system address book into `IPodContact` values.
|
|
///
|
|
/// An actor rather than a plain type so the non-`Sendable` `CNContactStore`
|
|
/// stays isolated, and so enumerating a large address book happens off the main
|
|
/// thread.
|
|
public actor ContactsReader {
|
|
private let store = CNContactStore()
|
|
|
|
public init() {}
|
|
|
|
public nonisolated static var authorizationStatus: CNAuthorizationStatus {
|
|
CNContactStore.authorizationStatus(for: .contacts)
|
|
}
|
|
|
|
/// Prompts for Contacts access, returning whether it was granted.
|
|
public func requestAccess() async -> Bool {
|
|
(try? await store.requestAccess(for: .contacts)) ?? false
|
|
}
|
|
|
|
/// The keys fetched for every contact.
|
|
///
|
|
/// `CNContactNoteKey` is absent on purpose: since macOS 11 it requires the
|
|
/// restricted `com.apple.developer.contacts.notes` entitlement, and asking
|
|
/// for it without one makes the whole fetch throw.
|
|
///
|
|
/// Computed rather than stored because `CNKeyDescriptor` is not `Sendable`,
|
|
/// so a static constant would count as shared mutable state.
|
|
private static var keysToFetch: [CNKeyDescriptor] {
|
|
[
|
|
CNContactIdentifierKey,
|
|
CNContactNamePrefixKey,
|
|
CNContactGivenNameKey,
|
|
CNContactMiddleNameKey,
|
|
CNContactFamilyNameKey,
|
|
CNContactNameSuffixKey,
|
|
CNContactOrganizationNameKey,
|
|
CNContactJobTitleKey,
|
|
CNContactPhoneNumbersKey,
|
|
CNContactEmailAddressesKey,
|
|
CNContactPostalAddressesKey,
|
|
CNContactBirthdayKey,
|
|
] as [CNKeyDescriptor]
|
|
}
|
|
|
|
/// Every contact in every account, in the user's preferred sort order.
|
|
public func fetchContacts() throws -> [IPodContact] {
|
|
let request = CNContactFetchRequest(keysToFetch: Self.keysToFetch)
|
|
request.sortOrder = .userDefault
|
|
request.unifyResults = true
|
|
|
|
var contacts: [IPodContact] = []
|
|
try store.enumerateContacts(with: request) { contact, _ in
|
|
contacts.append(Self.convert(contact))
|
|
}
|
|
return contacts
|
|
}
|
|
|
|
static func convert(_ contact: CNContact) -> IPodContact {
|
|
IPodContact(
|
|
id: contact.identifier,
|
|
namePrefix: contact.namePrefix,
|
|
givenName: contact.givenName,
|
|
middleName: contact.middleName,
|
|
familyName: contact.familyName,
|
|
nameSuffix: contact.nameSuffix,
|
|
organization: contact.organizationName,
|
|
jobTitle: contact.jobTitle,
|
|
phoneNumbers: contact.phoneNumbers.map {
|
|
LabeledValue(
|
|
label: phoneType(for: $0.label),
|
|
value: $0.value.stringValue
|
|
)
|
|
},
|
|
emailAddresses: contact.emailAddresses.map {
|
|
LabeledValue(
|
|
label: emailType(for: $0.label),
|
|
value: $0.value as String
|
|
)
|
|
},
|
|
postalAddresses: contact.postalAddresses.map {
|
|
PostalAddress(
|
|
label: postalType(for: $0.label),
|
|
street: $0.value.street,
|
|
city: $0.value.city,
|
|
state: $0.value.state,
|
|
postalCode: $0.value.postalCode,
|
|
country: $0.value.country
|
|
)
|
|
},
|
|
birthday: contact.birthday
|
|
)
|
|
}
|
|
|
|
/// Maps a Contacts label to vCard 3.0 `TYPE` tokens.
|
|
///
|
|
/// Only tokens vCard 3.0 actually defines are emitted — Contacts labels
|
|
/// such as "Other" or a user's custom label have no equivalent, and an
|
|
/// invented `TYPE` is another thing for the device's parser to trip on, so
|
|
/// those fall back to a bare `VOICE`.
|
|
static func phoneType(for label: String?) -> String {
|
|
switch label {
|
|
case CNLabelPhoneNumberMobile, CNLabelPhoneNumberiPhone: "CELL"
|
|
case CNLabelHome: "HOME,VOICE"
|
|
case CNLabelWork: "WORK,VOICE"
|
|
case CNLabelPhoneNumberMain: "PREF,VOICE"
|
|
case CNLabelPhoneNumberHomeFax: "HOME,FAX"
|
|
case CNLabelPhoneNumberWorkFax, CNLabelPhoneNumberOtherFax: "WORK,FAX"
|
|
case CNLabelPhoneNumberPager: "PAGER"
|
|
default: "VOICE"
|
|
}
|
|
}
|
|
|
|
/// Returns an empty label when there is no standard equivalent; the writer
|
|
/// then emits `TYPE=INTERNET` alone.
|
|
static func emailType(for label: String?) -> String {
|
|
switch label {
|
|
case CNLabelHome: "HOME"
|
|
case CNLabelWork: "WORK"
|
|
default: ""
|
|
}
|
|
}
|
|
|
|
static func postalType(for label: String?) -> String {
|
|
switch label {
|
|
case CNLabelHome: "HOME"
|
|
case CNLabelWork: "WORK"
|
|
default: ""
|
|
}
|
|
}
|
|
}
|