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
136 lines
4.6 KiB
Swift
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 ") + "."
|
|
}
|
|
}
|