Files
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

136 lines
4.6 KiB
Swift

import Foundation
/// One file written during an export.
public struct ExportedFile: Sendable, Equatable, Identifiable {
public var id: String { path.path }
public var path: URL
/// Which of the device's folders this belongs in.
public var destination: ExportSession.Folder
public init(path: URL, destination: ExportSession.Folder) {
self.path = path
self.destination = destination
}
}
/// Writes sanitized output into a destination folder.
///
/// Output is laid out as `Contacts/` and `Calendars/` subfolders, mirroring the
/// folders on the iPod itself, so the result can be dragged across in Finder
/// without sorting anything by hand.
public struct ExportSession {
public enum Folder: String, Sendable, CaseIterable {
case contacts = "Contacts"
case calendars = "Calendars"
}
public let destination: URL
/// One allocator per folder: names only need to be unique among their peers.
private var contactNames = FileNameAllocator()
private var calendarNames = FileNameAllocator()
public private(set) var writtenFiles: [ExportedFile] = []
public private(set) var warnings: [String] = []
public init(destination: URL) {
self.destination = destination
}
// MARK: - Writing from the system databases
/// Writes one vCard per contact.
public mutating func write(contacts: [IPodContact]) throws {
var skipped = 0
for contact in contacts {
guard !contact.isEmpty else {
skipped += 1
continue
}
let name = contactNames.allocate(
preferred: contact.displayName,
fallback: "contact",
fileExtension: "vcf"
)
try write(VCardWriter.makeVCard(for: contact), named: name, in: .contacts)
}
if skipped > 0 {
warnings.append("\(skipped) contact(s) had no name or details and were skipped.")
}
}
/// Writes one `.ics` per calendar.
public mutating func write(
events: [IPodEvent],
calendarName: String,
timeZone: TimeZone = .current
) throws {
guard !events.isEmpty else { return }
let name = calendarNames.allocate(
preferred: calendarName,
fallback: "calendar",
fileExtension: "ics"
)
let contents = ICalendarWriter.makeCalendar(events: events, timeZone: timeZone)
try write(contents, named: name, in: .calendars)
}
// MARK: - Writing from sanitized input files
public mutating func write(sanitized: FileSanitizationResult, as folder: Folder) throws {
let fileExtension = folder == .contacts ? "vcf" : "ics"
for file in sanitized.files {
let fallback = folder == .contacts ? "contact" : "calendar"
let name = folder == .contacts
? contactNames.allocate(
preferred: file.suggestedName,
fallback: fallback,
fileExtension: fileExtension
)
: calendarNames.allocate(
preferred: file.suggestedName,
fallback: fallback,
fileExtension: fileExtension
)
try write(file.contents, named: name, in: folder)
}
warnings.append(contentsOf: sanitized.warnings)
}
/// Records something the caller noticed that the user should see.
public mutating func noteWarning(_ message: String) {
warnings.append(message)
}
// MARK: -
private mutating func write(_ contents: String, named name: String, in folder: Folder) throws {
let directory = destination.appendingPathComponent(folder.rawValue, isDirectory: true)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let path = directory.appendingPathComponent(name)
try Data(contents.utf8).write(to: path, options: .atomic)
writtenFiles.append(ExportedFile(path: path, destination: folder))
}
public var summary: String {
let contacts = writtenFiles.filter { $0.destination == .contacts }.count
let calendars = writtenFiles.filter { $0.destination == .calendars }.count
var parts: [String] = []
if contacts > 0 { parts.append("\(contacts) contact file\(contacts == 1 ? "" : "s")") }
if calendars > 0 { parts.append("\(calendars) calendar file\(calendars == 1 ? "" : "s")") }
return parts.isEmpty ? "Nothing was written." : "Wrote " + parts.joined(separator: " and ") + "."
}
}