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,85 @@
|
||||
import Foundation
|
||||
|
||||
/// Emits vCard 3.0 for a click-wheel iPod.
|
||||
///
|
||||
/// Version 3.0 rather than 4.0 deliberately: the click-wheel address book was
|
||||
/// written against 2.1/3.0 and does not recognise 4.0's `VERSION` or its
|
||||
/// property forms. Nothing here emits `PHOTO`, `LOGO`, `SOUND` or `X-`
|
||||
/// properties — the categories that made hand-exported files fail — because
|
||||
/// they are simply never written rather than stripped afterwards.
|
||||
public enum VCardWriter {
|
||||
/// Renders one contact as a complete, CRLF-terminated vCard.
|
||||
public static func makeVCard(for contact: IPodContact) -> String {
|
||||
var builder = ContentLineBuilder()
|
||||
|
||||
builder.appendVerbatim("BEGIN", "VCARD")
|
||||
builder.appendVerbatim("VERSION", "3.0")
|
||||
|
||||
// N is ordered family;given;middle;prefix;suffix and is required in 3.0.
|
||||
builder.appendStructured("N", components: [
|
||||
contact.familyName,
|
||||
contact.givenName,
|
||||
contact.middleName,
|
||||
contact.namePrefix,
|
||||
contact.nameSuffix,
|
||||
])
|
||||
builder.append("FN", contact.displayName)
|
||||
|
||||
if !contact.organization.isEmpty {
|
||||
builder.appendStructured("ORG", components: [contact.organization])
|
||||
}
|
||||
if !contact.jobTitle.isEmpty {
|
||||
builder.append("TITLE", contact.jobTitle)
|
||||
}
|
||||
|
||||
for phone in contact.phoneNumbers where !phone.value.isEmpty {
|
||||
let type = phone.label.isEmpty ? "VOICE" : phone.label
|
||||
builder.append("TEL", parameters: [("TYPE", type)], value: phone.value)
|
||||
}
|
||||
|
||||
for email in contact.emailAddresses where !email.value.isEmpty {
|
||||
let type = email.label.isEmpty ? "INTERNET" : "INTERNET,\(email.label)"
|
||||
builder.append("EMAIL", parameters: [("TYPE", type)], value: email.value)
|
||||
}
|
||||
|
||||
for address in contact.postalAddresses {
|
||||
// ADR is po-box;extended;street;locality;region;postal-code;country.
|
||||
let components = [
|
||||
"", "",
|
||||
address.street,
|
||||
address.city,
|
||||
address.state,
|
||||
address.postalCode,
|
||||
address.country,
|
||||
]
|
||||
guard components.contains(where: { !$0.isEmpty }) else { continue }
|
||||
let value = components.map(ContentLine.escapeText).joined(separator: ";")
|
||||
let name = address.label.isEmpty ? "ADR" : "ADR;TYPE=\(address.label)"
|
||||
builder.appendRawLine("\(name):\(value)")
|
||||
}
|
||||
|
||||
if let birthday = contact.birthday, let formatted = formatBirthday(birthday) {
|
||||
builder.appendVerbatim("BDAY", formatted)
|
||||
}
|
||||
|
||||
builder.appendVerbatim("END", "VCARD")
|
||||
|
||||
return builder.render()
|
||||
}
|
||||
|
||||
/// Formats a birthday as `YYYY-MM-DD`.
|
||||
///
|
||||
/// Returns nil for year-less birthdays. Contacts.app allows them, but vCard
|
||||
/// 3.0 has no representation Apple doesn't express through an `X-` parameter
|
||||
/// (`X-APPLE-OMIT-YEAR`), and an invented year would show a wrong age on the
|
||||
/// device — so those birthdays are dropped instead.
|
||||
static func formatBirthday(_ components: DateComponents) -> String? {
|
||||
guard let year = components.year,
|
||||
let month = components.month,
|
||||
let day = components.day,
|
||||
year > 1
|
||||
else { return nil }
|
||||
|
||||
return String(format: "%04d-%02d-%02d", year, month, day)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user