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,135 @@
|
||||
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 ") + "."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user