Files
iPod-export/Sources/IPodSyncKit/Models.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

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