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

228 lines
8.3 KiB
Swift

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)
}
}