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
147 lines
4.6 KiB
Swift
147 lines
4.6 KiB
Swift
import Foundation
|
|
|
|
/// A typed value with a vCard `TYPE` label, e.g. a `WORK` phone number.
|
|
public struct LabeledValue: Sendable, Equatable {
|
|
/// Already normalised to a vCard TYPE token: `HOME`, `WORK`, `CELL`, …
|
|
public var label: String
|
|
public var value: String
|
|
|
|
public init(label: String, value: String) {
|
|
self.label = label
|
|
self.value = value
|
|
}
|
|
}
|
|
|
|
/// A postal address, held as the components vCard's `ADR` expects.
|
|
public struct PostalAddress: Sendable, Equatable {
|
|
public var label: String
|
|
public var street: String
|
|
public var city: String
|
|
public var state: String
|
|
public var postalCode: String
|
|
public var country: String
|
|
|
|
public init(
|
|
label: String,
|
|
street: String = "",
|
|
city: String = "",
|
|
state: String = "",
|
|
postalCode: String = "",
|
|
country: String = ""
|
|
) {
|
|
self.label = label
|
|
self.street = street
|
|
self.city = city
|
|
self.state = state
|
|
self.postalCode = postalCode
|
|
self.country = country
|
|
}
|
|
}
|
|
|
|
/// A contact reduced to the fields a click-wheel iPod can actually display.
|
|
///
|
|
/// Deliberately has no notes field. `CNContactNoteKey` has required the
|
|
/// restricted `com.apple.developer.contacts.notes` entitlement since macOS 11,
|
|
/// and fetching with it unentitled throws — so notes are never read.
|
|
public struct IPodContact: Sendable, Equatable, Identifiable {
|
|
public var id: String
|
|
public var namePrefix: String
|
|
public var givenName: String
|
|
public var middleName: String
|
|
public var familyName: String
|
|
public var nameSuffix: String
|
|
public var organization: String
|
|
public var jobTitle: String
|
|
public var phoneNumbers: [LabeledValue]
|
|
public var emailAddresses: [LabeledValue]
|
|
public var postalAddresses: [PostalAddress]
|
|
public var birthday: DateComponents?
|
|
|
|
public init(
|
|
id: String = UUID().uuidString,
|
|
namePrefix: String = "",
|
|
givenName: String = "",
|
|
middleName: String = "",
|
|
familyName: String = "",
|
|
nameSuffix: String = "",
|
|
organization: String = "",
|
|
jobTitle: String = "",
|
|
phoneNumbers: [LabeledValue] = [],
|
|
emailAddresses: [LabeledValue] = [],
|
|
postalAddresses: [PostalAddress] = [],
|
|
birthday: DateComponents? = nil
|
|
) {
|
|
self.id = id
|
|
self.namePrefix = namePrefix
|
|
self.givenName = givenName
|
|
self.middleName = middleName
|
|
self.familyName = familyName
|
|
self.nameSuffix = nameSuffix
|
|
self.organization = organization
|
|
self.jobTitle = jobTitle
|
|
self.phoneNumbers = phoneNumbers
|
|
self.emailAddresses = emailAddresses
|
|
self.postalAddresses = postalAddresses
|
|
self.birthday = birthday
|
|
}
|
|
|
|
/// The `FN` value, and the basis for the contact's filename.
|
|
public var displayName: String {
|
|
let parts = [namePrefix, givenName, middleName, familyName, nameSuffix]
|
|
.filter { !$0.isEmpty }
|
|
if !parts.isEmpty { return parts.joined(separator: " ") }
|
|
if !organization.isEmpty { return organization }
|
|
if let email = emailAddresses.first?.value, !email.isEmpty { return email }
|
|
return "Contact"
|
|
}
|
|
|
|
/// True when the contact carries nothing worth putting on the device.
|
|
public var isEmpty: Bool {
|
|
phoneNumbers.isEmpty
|
|
&& emailAddresses.isEmpty
|
|
&& postalAddresses.isEmpty
|
|
&& organization.isEmpty
|
|
&& givenName.isEmpty
|
|
&& familyName.isEmpty
|
|
}
|
|
}
|
|
|
|
/// A single dated occurrence, already expanded out of any recurrence rule.
|
|
///
|
|
/// Times are absolute `Date`s here; they are rendered as *floating* local times
|
|
/// at write time, because click-wheel iPods have no timezone database and
|
|
/// reject or misread `TZID`-qualified values.
|
|
public struct IPodEvent: Sendable, Equatable, Identifiable {
|
|
public var id: String
|
|
public var uid: String
|
|
public var summary: String
|
|
public var location: String
|
|
public var notes: String
|
|
public var start: Date
|
|
public var end: Date
|
|
public var isAllDay: Bool
|
|
public var calendarTitle: String
|
|
|
|
public init(
|
|
id: String = UUID().uuidString,
|
|
uid: String,
|
|
summary: String,
|
|
location: String = "",
|
|
notes: String = "",
|
|
start: Date,
|
|
end: Date,
|
|
isAllDay: Bool = false,
|
|
calendarTitle: String = ""
|
|
) {
|
|
self.id = id
|
|
self.uid = uid
|
|
self.summary = summary
|
|
self.location = location
|
|
self.notes = notes
|
|
self.start = start
|
|
self.end = end
|
|
self.isAllDay = isAllDay
|
|
self.calendarTitle = calendarTitle
|
|
}
|
|
}
|