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
196 lines
6.4 KiB
Swift
196 lines
6.4 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|