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:
2026-08-08 20:21:09 -03:00
co-authored by Claude Opus 5
commit 7b9d16e442
23 changed files with 2712 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
import Foundation
import IPodSyncKit
import Observation
@MainActor
@Observable
final class ExportModel {
enum Phase: Equatable {
case idle
case working(String)
case finished
}
// What to include
var includeContacts = true
var includeCalendars = true
var includeFiles = false
// Access
var contactsGranted = ContactsReader.authorizationStatus == .authorized
var calendarsGranted = CalendarReader.authorizationStatus == .fullAccess
// Calendars
var calendars: [CalendarSource] = []
var selectedCalendarIDs: Set<String> = []
var rangeStart: Date
var rangeEnd: Date
// Existing files
var inputFiles: [URL] = []
// Output
var destination: URL?
// Results
var phase: Phase = .idle
var summary = ""
var warnings: [String] = []
var writtenFiles: [ExportedFile] = []
var errorMessage: String?
private let contactsReader = ContactsReader()
private let calendarReader = CalendarReader()
init() {
// A window wide enough to cover what you'd actually browse to on the
// device, without dragging a decade of history across.
let calendar = Calendar.current
let now = Date()
rangeStart = calendar.date(byAdding: .month, value: -1, to: now) ?? now
rangeEnd = calendar.date(byAdding: .year, value: 1, to: now) ?? now
}
var isWorking: Bool {
if case .working = phase { return true }
return false
}
var canExport: Bool {
guard destination != nil, !isWorking else { return false }
if includeContacts && contactsGranted { return true }
if includeCalendars && calendarsGranted && !selectedCalendarIDs.isEmpty { return true }
if includeFiles && !inputFiles.isEmpty { return true }
return false
}
// MARK: - Access
func requestContactsAccess() async {
contactsGranted = await contactsReader.requestAccess()
if !contactsGranted {
errorMessage = "Contacts access was denied. Grant it in System Settings "
+ "Privacy & Security Contacts, then reopen the app."
}
}
func requestCalendarAccess() async {
calendarsGranted = await calendarReader.requestAccess()
if calendarsGranted {
await loadCalendars()
} else {
errorMessage = "Calendar access was denied. Grant it in System Settings "
+ "Privacy & Security Calendars, then reopen the app."
}
}
func loadCalendars() async {
guard calendarsGranted else { return }
calendars = await calendarReader.calendars()
// Everything is selected by default; unchecking is the rarer action.
if selectedCalendarIDs.isEmpty {
selectedCalendarIDs = Set(calendars.map(\.id))
}
}
func refreshAccessOnAppear() async {
contactsGranted = ContactsReader.authorizationStatus == .authorized
calendarsGranted = CalendarReader.authorizationStatus == .fullAccess
await loadCalendars()
}
func isSelected(_ calendar: CalendarSource) -> Bool {
selectedCalendarIDs.contains(calendar.id)
}
func setSelected(_ calendar: CalendarSource, _ selected: Bool) {
if selected {
selectedCalendarIDs.insert(calendar.id)
} else {
selectedCalendarIDs.remove(calendar.id)
}
}
func addInputFiles(_ urls: [URL]) {
let supported = urls.filter {
["vcf", "ics"].contains($0.pathExtension.lowercased())
}
for url in supported where !inputFiles.contains(url) {
inputFiles.append(url)
}
if supported.count < urls.count {
errorMessage = "Only .vcf and .ics files can be sanitized; the rest were ignored."
}
}
// MARK: - Export
func export() async {
guard let destination else { return }
phase = .working("Preparing…")
summary = ""
warnings = []
writtenFiles = []
errorMessage = nil
var session = ExportSession(destination: destination)
do {
if includeContacts && contactsGranted {
phase = .working("Reading contacts…")
let contacts = try await contactsReader.fetchContacts()
phase = .working("Writing \(contacts.count) contacts…")
try session.write(contacts: contacts)
}
if includeCalendars && calendarsGranted {
// One file per calendar, so a bad calendar can't take the
// others down with it on the device.
for calendar in calendars where selectedCalendarIDs.contains(calendar.id) {
phase = .working("Reading “\(calendar.title)”…")
let events = await calendarReader.events(
calendarIdentifiers: [calendar.id],
from: rangeStart,
to: rangeEnd
)
if events.isEmpty {
session.noteWarning(
"\(calendar.title)” had no events in the selected dates."
)
continue
}
try session.write(events: events, calendarName: calendar.title)
}
}
if includeFiles {
for url in inputFiles {
phase = .working("Sanitizing \(url.lastPathComponent)")
switch url.pathExtension.lowercased() {
case "vcf":
let result = try VCardFileSanitizer.sanitize(contentsOf: url)
try session.write(sanitized: result, as: .contacts)
case "ics":
let result = try ICalendarFileSanitizer.sanitize(contentsOf: url)
try session.write(sanitized: result, as: .calendars)
default:
continue
}
}
}
summary = session.summary
warnings = session.warnings
writtenFiles = session.writtenFiles
phase = .finished
} catch {
errorMessage = error.localizedDescription
summary = session.summary
warnings = session.warnings
writtenFiles = session.writtenFiles
phase = .finished
}
}
}