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:
2026-08-08 20:21:09 -03:00
co-authored by Claude Opus 5
commit 7b9d16e442
23 changed files with 2712 additions and 0 deletions
+133
View File
@@ -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: ""
}
}
}