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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import AppKit
|
||||
import IPodSyncKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ExportView: View {
|
||||
@State private var model = ExportModel()
|
||||
@State private var isChoosingFiles = false
|
||||
@State private var isChoosingDestination = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text(
|
||||
"Prepares contacts and calendars for a click-wheel iPod, which reads "
|
||||
+ "dropped .vcf and .ics files but rejects the timezone, alarm and "
|
||||
+ "embedded-image data a normal export contains."
|
||||
)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
contactsSection
|
||||
calendarsSection
|
||||
filesSection
|
||||
destinationSection
|
||||
resultsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(minWidth: 520, idealWidth: 580, minHeight: 560)
|
||||
.safeAreaInset(edge: .bottom) { footer }
|
||||
.task { await model.refreshAccessOnAppear() }
|
||||
.fileImporter(
|
||||
isPresented: $isChoosingFiles,
|
||||
allowedContentTypes: Self.importableTypes,
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
if case .success(let urls) = result { model.addInputFiles(urls) }
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $isChoosingDestination,
|
||||
allowedContentTypes: [.folder]
|
||||
) { result in
|
||||
if case .success(let url) = result { model.destination = url }
|
||||
}
|
||||
.alert(
|
||||
"Something went wrong",
|
||||
isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK") { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private static var importableTypes: [UTType] {
|
||||
[.vCard, UTType(filenameExtension: "ics") ?? .calendarEvent]
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var contactsSection: some View {
|
||||
Section("Contacts") {
|
||||
Toggle("Export contacts", isOn: $model.includeContacts)
|
||||
|
||||
if model.includeContacts {
|
||||
if model.contactsGranted {
|
||||
Label("Access granted — one .vcf per contact", systemImage: "checkmark.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.callout)
|
||||
} else {
|
||||
HStack {
|
||||
Text("Contacts access is needed to read your address book.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Grant Access…") {
|
||||
Task { await model.requestContactsAccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarsSection: some View {
|
||||
Section("Calendars") {
|
||||
Toggle("Export calendars", isOn: $model.includeCalendars)
|
||||
|
||||
if model.includeCalendars {
|
||||
if model.calendarsGranted {
|
||||
DatePicker("From", selection: $model.rangeStart, displayedComponents: .date)
|
||||
DatePicker("To", selection: $model.rangeEnd, displayedComponents: .date)
|
||||
|
||||
if model.calendars.isEmpty {
|
||||
Text("No calendars found.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(model.calendars) { calendar in
|
||||
Toggle(isOn: Binding(
|
||||
get: { model.isSelected(calendar) },
|
||||
set: { model.setSelected(calendar, $0) }
|
||||
)) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(calendar.title)
|
||||
if !calendar.accountName.isEmpty {
|
||||
Text(calendar.accountName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Repeating events are written out one occurrence at a time, so the "
|
||||
+ "iPod never has to interpret a recurrence rule.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
HStack {
|
||||
Text("Calendar access is needed to read your events.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Grant Access…") {
|
||||
Task { await model.requestCalendarAccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filesSection: some View {
|
||||
Section("Existing files") {
|
||||
Toggle("Sanitize .vcf / .ics files I already have", isOn: $model.includeFiles)
|
||||
|
||||
if model.includeFiles {
|
||||
HStack {
|
||||
Text(model.inputFiles.isEmpty
|
||||
? "No files chosen."
|
||||
: "\(model.inputFiles.count) file(s) chosen.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if !model.inputFiles.isEmpty {
|
||||
Button("Clear") { model.inputFiles.removeAll() }
|
||||
}
|
||||
Button("Choose Files…") { isChoosingFiles = true }
|
||||
}
|
||||
|
||||
ForEach(model.inputFiles, id: \.self) { url in
|
||||
Text(url.lastPathComponent)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var destinationSection: some View {
|
||||
Section("Output folder") {
|
||||
HStack {
|
||||
Text(model.destination?.path(percentEncoded: false) ?? "Not chosen.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(model.destination == nil ? .secondary : .primary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button("Choose…") { isChoosingDestination = true }
|
||||
}
|
||||
|
||||
Text("Files are written into Contacts and Calendars subfolders, matching the "
|
||||
+ "folders on the iPod, so you can drag them straight across in Finder.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var resultsSection: some View {
|
||||
if model.phase == .finished {
|
||||
Section("Result") {
|
||||
Text(model.summary)
|
||||
|
||||
ForEach(Array(model.warnings.enumerated()), id: \.offset) { _, warning in
|
||||
Label(warning, systemImage: "exclamationmark.triangle")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let first = model.writtenFiles.first {
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([first.path])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
if case .working(let message) = model.phase {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(message).font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Export") {
|
||||
Task { await model.export() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!model.canExport)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IPodSyncApp: App {
|
||||
var body: some Scene {
|
||||
Window("iPod Contacts and Calendar Sync", id: "main") {
|
||||
ExportView()
|
||||
}
|
||||
.windowResizability(.contentMinSize)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user