Files
iPod-export/Sources/iPodSyncApp/ExportModel.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

196 lines
6.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}