Add Swift app for iPod contacts and calendar export
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
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user