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,146 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user