Files
iPod-export/Sources/IPodSyncKit/VCardWriter.swift
T
thatguygriffandClaude Opus 5 7b9d16e442 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
2026-08-08 20:21:09 -03:00

86 lines
3.3 KiB
Swift

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)
}
}