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
+127
View File
@@ -0,0 +1,127 @@
import EventKit
import Foundation
/// A calendar the user can choose to export.
public struct CalendarSource: Sendable, Identifiable, Equatable, Hashable {
public var id: String
public var title: String
public var accountName: String
public init(id: String, title: String, accountName: String) {
self.id = id
self.title = title
self.accountName = accountName
}
}
/// Reads calendar events into `IPodEvent` values.
///
/// Events come back from EventKit already expanded a weekly standup returns
/// one event per week, not one event plus a rule which is why nothing
/// downstream ever has to emit an `RRULE` the device may not understand.
public actor CalendarReader {
private let store = EKEventStore()
public init() {}
public nonisolated static var authorizationStatus: EKAuthorizationStatus {
EKEventStore.authorizationStatus(for: .event)
}
/// Prompts for full calendar access, returning whether it was granted.
///
/// Full rather than write-only access: the app's entire job is reading
/// events out.
public func requestAccess() async -> Bool {
(try? await store.requestFullAccessToEvents()) ?? false
}
public func calendars() -> [CalendarSource] {
store.calendars(for: .event)
.map {
CalendarSource(
id: $0.calendarIdentifier,
title: $0.title,
accountName: $0.source?.title ?? ""
)
}
.sorted { ($0.accountName, $0.title) < ($1.accountName, $1.title) }
}
/// Fetches occurrences in `[start, end)` from the named calendars.
public func events(
calendarIdentifiers: [String],
from start: Date,
to end: Date
) -> [IPodEvent] {
let selected = store.calendars(for: .event)
.filter { calendarIdentifiers.contains($0.calendarIdentifier) }
guard !selected.isEmpty, start < end else { return [] }
var collected: [String: IPodEvent] = [:]
// EventKit rejects a predicate spanning more than four years, so long
// ranges are fetched in windows. Events straddling a window boundary
// come back twice, hence keying by UID.
for (windowStart, windowEnd) in Self.windows(from: start, to: end) {
let predicate = store.predicateForEvents(
withStart: windowStart,
end: windowEnd,
calendars: selected
)
for event in store.events(matching: predicate) {
let converted = Self.convert(event)
collected[converted.uid] = converted
}
}
return collected.values.sorted { $0.start < $1.start }
}
/// Splits a range into windows comfortably inside EventKit's four-year limit.
static func windows(from start: Date, to end: Date) -> [(Date, Date)] {
let calendar = Calendar(identifier: .gregorian)
var windows: [(Date, Date)] = []
var cursor = start
while cursor < end {
let next = calendar.date(byAdding: .year, value: 2, to: cursor) ?? end
windows.append((cursor, min(next, end)))
cursor = next
}
return windows
}
static func convert(_ event: EKEvent) -> IPodEvent {
IPodEvent(
id: event.eventIdentifier ?? UUID().uuidString,
uid: uid(for: event),
summary: event.title ?? "",
location: event.location ?? "",
notes: event.notes ?? "",
start: event.startDate,
end: event.endDate,
isAllDay: event.isAllDay,
calendarTitle: event.calendar?.title ?? ""
)
}
/// A UID unique per *occurrence*.
///
/// Every occurrence of a recurring event shares one external identifier, so
/// the start instant is folded in otherwise a weekly meeting would emit
/// fifty-two events all claiming the same UID, and importers are entitled to
/// treat those as one event.
static func uid(for event: EKEvent) -> String {
let base = event.calendarItemExternalIdentifier
?? event.eventIdentifier
?? UUID().uuidString
let safe = base.unicodeScalars
.map { CharacterSet.alphanumerics.contains($0) || $0 == "-" ? Character($0) : "-" }
let stamp = Int(event.startDate.timeIntervalSince1970)
return "\(String(safe).prefix(64))-\(stamp)@ipod-sync"
}
}
+133
View File
@@ -0,0 +1,133 @@
import Contacts
import Foundation
/// Reads the system address book into `IPodContact` values.
///
/// An actor rather than a plain type so the non-`Sendable` `CNContactStore`
/// stays isolated, and so enumerating a large address book happens off the main
/// thread.
public actor ContactsReader {
private let store = CNContactStore()
public init() {}
public nonisolated static var authorizationStatus: CNAuthorizationStatus {
CNContactStore.authorizationStatus(for: .contacts)
}
/// Prompts for Contacts access, returning whether it was granted.
public func requestAccess() async -> Bool {
(try? await store.requestAccess(for: .contacts)) ?? false
}
/// The keys fetched for every contact.
///
/// `CNContactNoteKey` is absent on purpose: since macOS 11 it requires the
/// restricted `com.apple.developer.contacts.notes` entitlement, and asking
/// for it without one makes the whole fetch throw.
///
/// Computed rather than stored because `CNKeyDescriptor` is not `Sendable`,
/// so a static constant would count as shared mutable state.
private static var keysToFetch: [CNKeyDescriptor] {
[
CNContactIdentifierKey,
CNContactNamePrefixKey,
CNContactGivenNameKey,
CNContactMiddleNameKey,
CNContactFamilyNameKey,
CNContactNameSuffixKey,
CNContactOrganizationNameKey,
CNContactJobTitleKey,
CNContactPhoneNumbersKey,
CNContactEmailAddressesKey,
CNContactPostalAddressesKey,
CNContactBirthdayKey,
] as [CNKeyDescriptor]
}
/// Every contact in every account, in the user's preferred sort order.
public func fetchContacts() throws -> [IPodContact] {
let request = CNContactFetchRequest(keysToFetch: Self.keysToFetch)
request.sortOrder = .userDefault
request.unifyResults = true
var contacts: [IPodContact] = []
try store.enumerateContacts(with: request) { contact, _ in
contacts.append(Self.convert(contact))
}
return contacts
}
static func convert(_ contact: CNContact) -> IPodContact {
IPodContact(
id: contact.identifier,
namePrefix: contact.namePrefix,
givenName: contact.givenName,
middleName: contact.middleName,
familyName: contact.familyName,
nameSuffix: contact.nameSuffix,
organization: contact.organizationName,
jobTitle: contact.jobTitle,
phoneNumbers: contact.phoneNumbers.map {
LabeledValue(
label: phoneType(for: $0.label),
value: $0.value.stringValue
)
},
emailAddresses: contact.emailAddresses.map {
LabeledValue(
label: emailType(for: $0.label),
value: $0.value as String
)
},
postalAddresses: contact.postalAddresses.map {
PostalAddress(
label: postalType(for: $0.label),
street: $0.value.street,
city: $0.value.city,
state: $0.value.state,
postalCode: $0.value.postalCode,
country: $0.value.country
)
},
birthday: contact.birthday
)
}
/// Maps a Contacts label to vCard 3.0 `TYPE` tokens.
///
/// Only tokens vCard 3.0 actually defines are emitted Contacts labels
/// such as "Other" or a user's custom label have no equivalent, and an
/// invented `TYPE` is another thing for the device's parser to trip on, so
/// those fall back to a bare `VOICE`.
static func phoneType(for label: String?) -> String {
switch label {
case CNLabelPhoneNumberMobile, CNLabelPhoneNumberiPhone: "CELL"
case CNLabelHome: "HOME,VOICE"
case CNLabelWork: "WORK,VOICE"
case CNLabelPhoneNumberMain: "PREF,VOICE"
case CNLabelPhoneNumberHomeFax: "HOME,FAX"
case CNLabelPhoneNumberWorkFax, CNLabelPhoneNumberOtherFax: "WORK,FAX"
case CNLabelPhoneNumberPager: "PAGER"
default: "VOICE"
}
}
/// Returns an empty label when there is no standard equivalent; the writer
/// then emits `TYPE=INTERNET` alone.
static func emailType(for label: String?) -> String {
switch label {
case CNLabelHome: "HOME"
case CNLabelWork: "WORK"
default: ""
}
}
static func postalType(for label: String?) -> String {
switch label {
case CNLabelHome: "HOME"
case CNLabelWork: "WORK"
default: ""
}
}
}
+145
View File
@@ -0,0 +1,145 @@
import Foundation
/// Emitting helpers shared by vCard (RFC 6350) and iCalendar (RFC 5545).
///
/// Both formats use the same content-line grammar: `NAME;PARAM=value:VALUE`,
/// CRLF terminators, and folding of long lines. Click-wheel iPod firmware is
/// unforgiving about all three, so everything we write goes through here.
public enum ContentLine {
/// Maximum octets in a content line, excluding the CRLF terminator.
public static let octetLimit = 75
/// Escapes a TEXT value per RFC 6350 §3.4 / RFC 5545 §3.3.11.
///
/// Also applies to individual components of structured values (`N`, `ADR`),
/// which are escaped the same way before being joined with `;`.
public static func escapeText(_ value: String) -> String {
// Swift treats CRLF as a *single* Character, so iterating without
// normalising first lets a CRLF fall through unescaped and terminate
// the content line early.
let normalized = value
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
var out = ""
out.reserveCapacity(normalized.count)
for character in normalized {
switch character {
case "\\": out += "\\\\"
case ";": out += "\\;"
case ",": out += "\\,"
case "\n": out += "\\n"
default: out.append(character)
}
}
return out
}
/// Folds a content line to `octetLimit` octets, continuing with CRLF + space.
///
/// Folding is defined in octets, not characters, so this splits on the UTF-8
/// representation backing off to a leading byte so a multi-byte character
/// is never cut in half.
public static func fold(_ line: String) -> String {
let bytes = Array(line.utf8)
guard bytes.count > octetLimit else { return line }
var pieces: [String] = []
var start = 0
// The first line spends all 75 octets on content; every continuation
// line spends one on the leading space.
var budget = octetLimit
while start < bytes.count {
var end = min(start + budget, bytes.count)
if end < bytes.count {
// 0b10xxxxxx marks a UTF-8 continuation byte; walk back to the
// start of the character it belongs to.
while end > start, bytes[end] & 0xC0 == 0x80 {
end -= 1
}
// A single character wider than the budget can't be split at
// all emit it over-length rather than looping forever.
if end == start {
end = min(start + budget, bytes.count)
while end < bytes.count, bytes[end] & 0xC0 == 0x80 {
end += 1
}
}
}
pieces.append(String(decoding: bytes[start..<end], as: UTF8.self))
start = end
budget = octetLimit - 1
}
return pieces.joined(separator: "\r\n ")
}
/// Undoes RFC folding: a line beginning with space or tab continues the previous one.
///
/// Accepts CRLF, LF or bare CR input, since files that reach us from other
/// tools are not reliably terminated.
public static func unfold(_ text: String) -> [String] {
let normalized = text
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
var lines: [String] = []
for line in normalized.split(separator: "\n", omittingEmptySubsequences: false) {
if let first = line.first, first == " " || first == "\t", !lines.isEmpty {
lines[lines.count - 1] += line.dropFirst()
} else {
lines.append(String(line))
}
}
return lines
}
}
/// Accumulates content lines and renders them as a folded, CRLF-terminated document.
public struct ContentLineBuilder {
private var lines: [String] = []
public init() {}
/// Appends `NAME:VALUE`, escaping the value as TEXT.
public mutating func append(_ name: String, _ value: String) {
lines.append("\(name):\(ContentLine.escapeText(value))")
}
/// Appends `NAME;PARAM=:VALUE`, escaping the value as TEXT.
public mutating func append(
_ name: String,
parameters: [(String, String)],
value: String
) {
let rendered = parameters.map { "\($0.0)=\($0.1)" }.joined(separator: ";")
let prefix = rendered.isEmpty ? name : "\(name);\(rendered)"
lines.append("\(prefix):\(ContentLine.escapeText(value))")
}
/// Appends a structured value whose components are escaped individually
/// and joined with `;` the shape used by `N` and `ADR`.
public mutating func appendStructured(_ name: String, components: [String]) {
let value = components.map(ContentLine.escapeText).joined(separator: ";")
lines.append("\(name):\(value)")
}
/// Appends a line whose value is already in its final form (dates, UIDs,
/// version numbers) and must not be escaped.
public mutating func appendVerbatim(_ name: String, _ value: String) {
lines.append("\(name):\(value)")
}
/// Appends an entire pre-built line, used when splicing in nested components.
public mutating func appendRawLine(_ line: String) {
lines.append(line)
}
public var isEmpty: Bool { lines.isEmpty }
/// Folds every line and joins with CRLF, including a trailing CRLF.
public func render() -> String {
lines.map(ContentLine.fold).joined(separator: "\r\n") + "\r\n"
}
}
+135
View File
@@ -0,0 +1,135 @@
import Foundation
/// One file written during an export.
public struct ExportedFile: Sendable, Equatable, Identifiable {
public var id: String { path.path }
public var path: URL
/// Which of the device's folders this belongs in.
public var destination: ExportSession.Folder
public init(path: URL, destination: ExportSession.Folder) {
self.path = path
self.destination = destination
}
}
/// Writes sanitized output into a destination folder.
///
/// Output is laid out as `Contacts/` and `Calendars/` subfolders, mirroring the
/// folders on the iPod itself, so the result can be dragged across in Finder
/// without sorting anything by hand.
public struct ExportSession {
public enum Folder: String, Sendable, CaseIterable {
case contacts = "Contacts"
case calendars = "Calendars"
}
public let destination: URL
/// One allocator per folder: names only need to be unique among their peers.
private var contactNames = FileNameAllocator()
private var calendarNames = FileNameAllocator()
public private(set) var writtenFiles: [ExportedFile] = []
public private(set) var warnings: [String] = []
public init(destination: URL) {
self.destination = destination
}
// MARK: - Writing from the system databases
/// Writes one vCard per contact.
public mutating func write(contacts: [IPodContact]) throws {
var skipped = 0
for contact in contacts {
guard !contact.isEmpty else {
skipped += 1
continue
}
let name = contactNames.allocate(
preferred: contact.displayName,
fallback: "contact",
fileExtension: "vcf"
)
try write(VCardWriter.makeVCard(for: contact), named: name, in: .contacts)
}
if skipped > 0 {
warnings.append("\(skipped) contact(s) had no name or details and were skipped.")
}
}
/// Writes one `.ics` per calendar.
public mutating func write(
events: [IPodEvent],
calendarName: String,
timeZone: TimeZone = .current
) throws {
guard !events.isEmpty else { return }
let name = calendarNames.allocate(
preferred: calendarName,
fallback: "calendar",
fileExtension: "ics"
)
let contents = ICalendarWriter.makeCalendar(events: events, timeZone: timeZone)
try write(contents, named: name, in: .calendars)
}
// MARK: - Writing from sanitized input files
public mutating func write(sanitized: FileSanitizationResult, as folder: Folder) throws {
let fileExtension = folder == .contacts ? "vcf" : "ics"
for file in sanitized.files {
let fallback = folder == .contacts ? "contact" : "calendar"
let name = folder == .contacts
? contactNames.allocate(
preferred: file.suggestedName,
fallback: fallback,
fileExtension: fileExtension
)
: calendarNames.allocate(
preferred: file.suggestedName,
fallback: fallback,
fileExtension: fileExtension
)
try write(file.contents, named: name, in: folder)
}
warnings.append(contentsOf: sanitized.warnings)
}
/// Records something the caller noticed that the user should see.
public mutating func noteWarning(_ message: String) {
warnings.append(message)
}
// MARK: -
private mutating func write(_ contents: String, named name: String, in folder: Folder) throws {
let directory = destination.appendingPathComponent(folder.rawValue, isDirectory: true)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let path = directory.appendingPathComponent(name)
try Data(contents.utf8).write(to: path, options: .atomic)
writtenFiles.append(ExportedFile(path: path, destination: folder))
}
public var summary: String {
let contacts = writtenFiles.filter { $0.destination == .contacts }.count
let calendars = writtenFiles.filter { $0.destination == .calendars }.count
var parts: [String] = []
if contacts > 0 { parts.append("\(contacts) contact file\(contacts == 1 ? "" : "s")") }
if calendars > 0 { parts.append("\(calendars) calendar file\(calendars == 1 ? "" : "s")") }
return parts.isEmpty ? "Nothing was written." : "Wrote " + parts.joined(separator: " and ") + "."
}
}
+64
View File
@@ -0,0 +1,64 @@
import Foundation
/// Produces filenames safe for an iPod's volume, and unique within one export.
///
/// Click-wheel iPods are usually FAT32 and their Contacts/Calendars browsers
/// handle non-ASCII filenames poorly, so names are folded to ASCII rather than
/// passed through. Accents fold to their base letter (`Renée` `Renee`);
/// anything still outside the safe set is dropped.
public struct FileNameAllocator {
/// Lowercased names already handed out, so collisions can be numbered.
private var used: Set<String> = []
public static let maximumLength = 60
public init() {}
/// Returns a unique filename with `fileExtension`, based on `preferred`.
public mutating func allocate(
preferred: String,
fallback: String = "item",
fileExtension: String
) -> String {
let base = Self.sanitize(preferred, fallback: fallback)
var candidate = base
var counter = 1
while used.contains(candidate.lowercased()) {
counter += 1
let suffix = "_\(counter)"
let trimmed = String(base.prefix(Self.maximumLength - suffix.count))
candidate = trimmed + suffix
}
used.insert(candidate.lowercased())
return "\(candidate).\(fileExtension)"
}
/// Folds to ASCII, keeps `[A-Za-z0-9 ._-]`, collapses runs of whitespace.
static func sanitize(_ name: String, fallback: String) -> String {
let folded = name.folding(
options: [.diacriticInsensitive, .widthInsensitive],
locale: Locale(identifier: "en_US_POSIX")
)
let allowed = folded.map { character -> Character in
character.isASCII && (character.isLetter || character.isNumber
|| character == " " || character == "." || character == "_"
|| character == "-")
? character
: " "
}
let collapsed = String(allowed)
.split(separator: " ", omittingEmptySubsequences: true)
.joined(separator: " ")
// A name of only dots would collide with "." / ".." on the device.
let trimmed = collapsed
.trimmingCharacters(in: CharacterSet(charactersIn: " ."))
.prefix(maximumLength)
return trimmed.isEmpty ? fallback : String(trimmed)
}
}
@@ -0,0 +1,140 @@
import Foundation
/// Cleans `.ics` files the user already has.
///
/// A Calendar.app export carries `VTIMEZONE` blocks, `TZID`-qualified
/// date-times and `VALARM` blocks none of which click-wheel firmware
/// implements, and any of which can cost you the entire file rather than just
/// the offending event.
public enum ICalendarFileSanitizer {
/// Components removed wholesale, including anything nested inside them.
static let strippedComponents: Set<String> = ["VALARM", "VTIMEZONE"]
/// Date-time properties whose UTC form is rewritten as floating local time.
///
/// `DTSTAMP`, `CREATED` and `LAST-MODIFIED` are deliberately absent: they
/// are bookkeeping the device never displays, and RFC 5545 requires them in
/// UTC.
static let localisedDateProperties: Set<String> = [
"DTSTART", "DTEND", "DUE", "RECURRENCE-ID",
]
public static func sanitize(
contentsOf url: URL,
timeZone: TimeZone = .current
) throws -> FileSanitizationResult {
sanitize(
text: try TextFile.read(contentsOf: url),
name: url.deletingPathExtension().lastPathComponent,
timeZone: timeZone
)
}
public static func sanitize(
text: String,
name: String,
timeZone: TimeZone = .current
) -> FileSanitizationResult {
var output: [String] = []
var warnings: [String] = []
var skipDepth = 0
var eventCount = 0
var hasRecurrence = false
for line in ContentLine.unfold(text) {
let upper = line.uppercased()
if skipDepth > 0 {
// Nested components (STANDARD/DAYLIGHT inside VTIMEZONE) have
// their own BEGIN/END pairs, so track depth rather than
// stopping at the first END.
if upper.hasPrefix("BEGIN:") {
skipDepth += 1
} else if upper.hasPrefix("END:") {
skipDepth -= 1
}
continue
}
if line.trimmingCharacters(in: .whitespaces).isEmpty { continue }
if upper.hasPrefix("BEGIN:") {
let component = String(upper.dropFirst("BEGIN:".count))
.trimmingCharacters(in: .whitespaces)
if strippedComponents.contains(component) {
skipDepth = 1
continue
}
if component == "VEVENT" { eventCount += 1 }
output.append(line)
continue
}
if upper.hasPrefix("END:") {
output.append(line)
continue
}
guard var property = PropertyLine(line) else { continue }
if property.name.hasPrefix("X-") { continue }
if property.name == "RRULE" { hasRecurrence = true }
// Dropping TZID leaves the wall-clock reading untouched, which is
// exactly the floating interpretation we want.
property.removeParameters(named: ["TZID"])
if localisedDateProperties.contains(property.name),
let floating = floatingLocalTime(from: property.value, timeZone: timeZone) {
property.value = floating
}
output.append(property.rendered)
}
if eventCount == 0 {
warnings.append("No events found — the file may not be a calendar export.")
}
if hasRecurrence {
warnings.append(
"Contains recurring events (RRULE), which were left as-is. "
+ "Click-wheel support for recurrence is unverified — check them on the device. "
+ "Exporting from Calendars instead expands recurrences into individual events."
)
}
var builder = ContentLineBuilder()
for line in output {
builder.appendRawLine(line)
}
return FileSanitizationResult(
files: [SanitizedFile(suggestedName: name, contents: builder.render())],
warnings: warnings
)
}
/// Rewrites a UTC date-time (`Z`) as the equivalent local wall-clock time.
///
/// Returns nil for values that are already floating or are dates rather
/// than date-times, leaving them untouched.
static func floatingLocalTime(from value: String, timeZone: TimeZone) -> String? {
guard value.hasSuffix("Z") else { return nil }
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.calendar = Calendar(identifier: .gregorian)
parser.timeZone = TimeZone(identifier: "UTC")
parser.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
guard let date = parser.date(from: value) else { return nil }
let renderer = DateFormatter()
renderer.locale = Locale(identifier: "en_US_POSIX")
renderer.calendar = Calendar(identifier: .gregorian)
renderer.timeZone = timeZone
renderer.dateFormat = "yyyyMMdd'T'HHmmss"
return renderer.string(from: date)
}
}
+105
View File
@@ -0,0 +1,105 @@
import Foundation
/// Emits iCalendar for a click-wheel iPod.
///
/// The three things that made Calendar.app's own exports fail on the device are
/// structurally absent here rather than filtered out afterwards:
///
/// - **No `VTIMEZONE`, no `TZID`.** Times are written as *floating* local wall
/// time. The device has no timezone database, so a `TZID` it cannot resolve
/// can cost you the whole file.
/// - **No `VALARM`.** Alarms are meaningless on a device that cannot fire them.
/// - **No `RRULE`.** Recurring events arrive here already expanded into one
/// `VEVENT` per occurrence, so recurrence support never has to be relied on.
public enum ICalendarWriter {
public static let productIdentifier = "-//unsupervised.ca//iPod Contacts and Calendar Sync//EN"
/// Renders events as a complete, CRLF-terminated `VCALENDAR`.
///
/// - Parameters:
/// - timeZone: the zone whose wall-clock reading is baked into the
/// floating times. Defaults to the Mac's current zone, which is what
/// someone syncing their own calendar expects to see on the device.
/// - now: the `DTSTAMP` instant, injectable for tests.
public static func makeCalendar(
events: [IPodEvent],
timeZone: TimeZone = .current,
now: Date = Date()
) -> String {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
let floating = formatter("yyyyMMdd'T'HHmmss", timeZone: timeZone)
let dateOnly = formatter("yyyyMMdd", timeZone: timeZone)
let utc = formatter("yyyyMMdd'T'HHmmss'Z'", timeZone: TimeZone(identifier: "UTC")!)
var builder = ContentLineBuilder()
builder.appendVerbatim("BEGIN", "VCALENDAR")
builder.appendVerbatim("VERSION", "2.0")
builder.appendVerbatim("PRODID", productIdentifier)
builder.appendVerbatim("CALSCALE", "GREGORIAN")
let stamp = utc.string(from: now)
for event in events.sorted(by: { $0.start < $1.start }) {
builder.appendVerbatim("BEGIN", "VEVENT")
builder.appendVerbatim("UID", event.uid)
builder.appendVerbatim("DTSTAMP", stamp)
if event.isAllDay {
builder.appendRawLine("DTSTART;VALUE=DATE:\(dateOnly.string(from: event.start))")
let exclusiveEnd = allDayExclusiveEnd(for: event, calendar: calendar)
builder.appendRawLine("DTEND;VALUE=DATE:\(dateOnly.string(from: exclusiveEnd))")
} else {
builder.appendVerbatim("DTSTART", floating.string(from: event.start))
// A zero- or negative-length event is legal with DTSTART alone;
// emitting DTEND <= DTSTART is not.
if event.end > event.start {
builder.appendVerbatim("DTEND", floating.string(from: event.end))
}
}
builder.append("SUMMARY", event.summary.isEmpty ? "(No title)" : event.summary)
if !event.location.isEmpty {
builder.append("LOCATION", event.location)
}
if !event.notes.isEmpty {
builder.append("DESCRIPTION", event.notes)
}
builder.appendVerbatim("END", "VEVENT")
}
builder.appendVerbatim("END", "VCALENDAR")
return builder.render()
}
/// All-day `DTEND` is exclusive the day *after* the last day covered.
///
/// EventKit is inconsistent about whether an all-day event's `endDate` is
/// the last day at 23:59:59 or the next day at midnight, so both are
/// normalised to the same exclusive boundary here.
static func allDayExclusiveEnd(for event: IPodEvent, calendar: Calendar) -> Date {
let endDay = calendar.startOfDay(for: event.end)
let exclusive = endDay == event.end
? endDay
: calendar.date(byAdding: .day, value: 1, to: endDay) ?? endDay
// Never emit a range that ends before it starts.
let startDay = calendar.startOfDay(for: event.start)
if exclusive <= startDay {
return calendar.date(byAdding: .day, value: 1, to: startDay) ?? exclusive
}
return exclusive
}
private static func formatter(_ format: String, timeZone: TimeZone) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = timeZone
formatter.dateFormat = format
return formatter
}
}
+146
View File
@@ -0,0 +1,146 @@
import Foundation
/// A typed value with a vCard `TYPE` label, e.g. a `WORK` phone number.
public struct LabeledValue: Sendable, Equatable {
/// Already normalised to a vCard TYPE token: `HOME`, `WORK`, `CELL`,
public var label: String
public var value: String
public init(label: String, value: String) {
self.label = label
self.value = value
}
}
/// A postal address, held as the components vCard's `ADR` expects.
public struct PostalAddress: Sendable, Equatable {
public var label: String
public var street: String
public var city: String
public var state: String
public var postalCode: String
public var country: String
public init(
label: String,
street: String = "",
city: String = "",
state: String = "",
postalCode: String = "",
country: String = ""
) {
self.label = label
self.street = street
self.city = city
self.state = state
self.postalCode = postalCode
self.country = country
}
}
/// A contact reduced to the fields a click-wheel iPod can actually display.
///
/// Deliberately has no notes field. `CNContactNoteKey` has required the
/// restricted `com.apple.developer.contacts.notes` entitlement since macOS 11,
/// and fetching with it unentitled throws so notes are never read.
public struct IPodContact: Sendable, Equatable, Identifiable {
public var id: String
public var namePrefix: String
public var givenName: String
public var middleName: String
public var familyName: String
public var nameSuffix: String
public var organization: String
public var jobTitle: String
public var phoneNumbers: [LabeledValue]
public var emailAddresses: [LabeledValue]
public var postalAddresses: [PostalAddress]
public var birthday: DateComponents?
public init(
id: String = UUID().uuidString,
namePrefix: String = "",
givenName: String = "",
middleName: String = "",
familyName: String = "",
nameSuffix: String = "",
organization: String = "",
jobTitle: String = "",
phoneNumbers: [LabeledValue] = [],
emailAddresses: [LabeledValue] = [],
postalAddresses: [PostalAddress] = [],
birthday: DateComponents? = nil
) {
self.id = id
self.namePrefix = namePrefix
self.givenName = givenName
self.middleName = middleName
self.familyName = familyName
self.nameSuffix = nameSuffix
self.organization = organization
self.jobTitle = jobTitle
self.phoneNumbers = phoneNumbers
self.emailAddresses = emailAddresses
self.postalAddresses = postalAddresses
self.birthday = birthday
}
/// The `FN` value, and the basis for the contact's filename.
public var displayName: String {
let parts = [namePrefix, givenName, middleName, familyName, nameSuffix]
.filter { !$0.isEmpty }
if !parts.isEmpty { return parts.joined(separator: " ") }
if !organization.isEmpty { return organization }
if let email = emailAddresses.first?.value, !email.isEmpty { return email }
return "Contact"
}
/// True when the contact carries nothing worth putting on the device.
public var isEmpty: Bool {
phoneNumbers.isEmpty
&& emailAddresses.isEmpty
&& postalAddresses.isEmpty
&& organization.isEmpty
&& givenName.isEmpty
&& familyName.isEmpty
}
}
/// A single dated occurrence, already expanded out of any recurrence rule.
///
/// Times are absolute `Date`s here; they are rendered as *floating* local times
/// at write time, because click-wheel iPods have no timezone database and
/// reject or misread `TZID`-qualified values.
public struct IPodEvent: Sendable, Equatable, Identifiable {
public var id: String
public var uid: String
public var summary: String
public var location: String
public var notes: String
public var start: Date
public var end: Date
public var isAllDay: Bool
public var calendarTitle: String
public init(
id: String = UUID().uuidString,
uid: String,
summary: String,
location: String = "",
notes: String = "",
start: Date,
end: Date,
isAllDay: Bool = false,
calendarTitle: String = ""
) {
self.id = id
self.uid = uid
self.summary = summary
self.location = location
self.notes = notes
self.start = start
self.end = end
self.isAllDay = isAllDay
self.calendarTitle = calendarTitle
}
}
+76
View File
@@ -0,0 +1,76 @@
import Foundation
/// A parsed `NAME;PARAM=value:VALUE` content line.
///
/// vCard and iCalendar share this grammar, so both sanitizers parse through
/// this type.
struct PropertyLine {
/// Apple exports group related properties as `item1.EMAIL` / `item1.X-ABLabel`.
/// The group is parsed off and never re-emitted click-wheel parsers do not
/// understand grouping, and the labels it points at are `X-` properties that
/// get dropped anyway.
var name: String
var parameters: [String]
var value: String
init?(_ line: String) {
guard let colon = line.firstIndex(of: ":") else { return nil }
let head = String(line[line.startIndex..<colon])
value = String(line[line.index(after: colon)...])
var segments = head.components(separatedBy: ";")
guard var rawName = segments.first, !rawName.isEmpty else { return nil }
segments.removeFirst()
if let dot = rawName.lastIndex(of: ".") {
rawName = String(rawName[rawName.index(after: dot)...])
}
name = rawName.uppercased()
parameters = segments
}
/// The value of `name`, for parameters written as `NAME=value`.
func parameterValue(named name: String) -> String? {
let prefix = name.uppercased() + "="
for parameter in parameters where parameter.uppercased().hasPrefix(prefix) {
return String(parameter.dropFirst(prefix.count))
}
return nil
}
mutating func removeParameters(named names: [String]) {
let prefixes = names.map { $0.uppercased() + "=" }
parameters.removeAll { parameter in
prefixes.contains { parameter.uppercased().hasPrefix($0) }
}
}
var rendered: String {
let head = ([name] + parameters).joined(separator: ";")
return "\(head):\(value)"
}
}
extension PropertyLine {
/// Unescapes a TEXT value: `\n` to a newline, `\;` `\,` `\\` to their literals.
static func unescape(_ value: String) -> String {
var output = ""
var escaped = false
for character in value {
if escaped {
switch character {
case "n", "N": output.append("\n")
default: output.append(character)
}
escaped = false
} else if character == "\\" {
escaped = true
} else {
output.append(character)
}
}
return output
}
}
+69
View File
@@ -0,0 +1,69 @@
import Foundation
/// Decodes `ENCODING=QUOTED-PRINTABLE` values, as found in vCard 2.1 exports.
///
/// Older address books encode any non-ASCII character this way, so a contact
/// named `José` arrives as `Jos=C3=A9` which a click-wheel iPod displays
/// literally rather than decoding.
public enum QuotedPrintable {
/// Decodes `=XX` escapes and returns the bytes as text.
///
/// `charset` is the value of the vCard `CHARSET` parameter when one was
/// present. It is only a hint: whatever it claims, valid UTF-8 is decoded
/// as UTF-8, since that is overwhelmingly what modern exports contain even
/// when they label themselves otherwise.
public static func decode(_ value: String, charset: String? = nil) -> String {
var bytes: [UInt8] = []
let source = Array(value.utf8)
var index = 0
while index < source.count {
if source[index] == UInt8(ascii: "="), index + 2 < source.count,
let high = hexDigit(source[index + 1]),
let low = hexDigit(source[index + 2]) {
bytes.append(high << 4 | low)
index += 3
} else if source[index] == UInt8(ascii: "="), index + 1 == source.count - 1 {
// A trailing '=' is a soft line break that was never joined.
index += 2
} else {
bytes.append(source[index])
index += 1
}
}
return decode(bytes: bytes, charset: charset)
}
static func decode(bytes: [UInt8], charset: String?) -> String {
if let text = String(bytes: bytes, encoding: .utf8) {
return text
}
if let charset, let encoding = encoding(named: charset),
let text = String(bytes: bytes, encoding: encoding) {
return text
}
if let text = String(bytes: bytes, encoding: .windowsCP1252) {
return text
}
return String(decoding: bytes, as: UTF8.self)
}
private static func encoding(named charset: String) -> String.Encoding? {
switch charset.uppercased() {
case "UTF-8", "UTF8": return .utf8
case "ISO-8859-1", "LATIN1", "ISO8859-1": return .isoLatin1
case "WINDOWS-1252", "CP1252": return .windowsCP1252
default: return nil
}
}
private static func hexDigit(_ byte: UInt8) -> UInt8? {
switch byte {
case UInt8(ascii: "0")...UInt8(ascii: "9"): return byte - UInt8(ascii: "0")
case UInt8(ascii: "A")...UInt8(ascii: "F"): return byte - UInt8(ascii: "A") + 10
case UInt8(ascii: "a")...UInt8(ascii: "f"): return byte - UInt8(ascii: "a") + 10
default: return nil
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import Foundation
/// Reads text files of unknown provenance.
///
/// `.vcf` and `.ics` files reaching the app may have come from any address book
/// or calendar going back twenty years, so the declared encoding if there is
/// one cannot be trusted. Candidates are tried in order of likelihood.
public enum TextFile {
public static func read(contentsOf url: URL) throws -> String {
let data = try Data(contentsOf: url)
return decode(data)
}
static func decode(_ data: Data) -> String {
// A UTF-8 BOM would otherwise survive into the first property name.
var bytes = data
if bytes.starts(with: [0xEF, 0xBB, 0xBF]) {
bytes = bytes.dropFirst(3)
}
for encoding in [String.Encoding.utf8, .windowsCP1252, .isoLatin1] {
if let text = String(data: bytes, encoding: encoding) {
return text
}
}
return String(decoding: bytes, as: UTF8.self)
}
}
/// One output file produced by sanitizing.
public struct SanitizedFile: Sendable, Equatable {
/// Base name without extension; the caller allocates the final filename.
public var suggestedName: String
public var contents: String
public init(suggestedName: String, contents: String) {
self.suggestedName = suggestedName
self.contents = contents
}
}
/// The outcome of sanitizing one input file.
public struct FileSanitizationResult: Sendable, Equatable {
public var files: [SanitizedFile]
public var warnings: [String]
public init(files: [SanitizedFile], warnings: [String] = []) {
self.files = files
self.warnings = warnings
}
}
@@ -0,0 +1,160 @@
import Foundation
/// Splits and cleans `.vcf` files the user already has.
///
/// This is the path for files exported from somewhere else. A single combined
/// `.vcf` holding hundreds of contacts is the failure mode that started all
/// this click-wheel firmware reads the first card and then stops, or stops at
/// the first card it cannot parse so every card becomes its own file.
public enum VCardFileSanitizer {
/// Properties carrying binary payloads the device's parser chokes on.
static let strippedProperties: Set<String> = ["PHOTO", "LOGO", "SOUND", "KEY"]
public static func sanitize(contentsOf url: URL) throws -> FileSanitizationResult {
sanitize(text: try TextFile.read(contentsOf: url))
}
public static func sanitize(text: String) -> FileSanitizationResult {
let lines = joiningSoftLineBreaks(ContentLine.unfold(text))
var cards: [[String]] = []
var current: [String] = []
var insideCard = false
var unterminated = 0
for line in lines {
let upper = line.uppercased()
if upper.hasPrefix("BEGIN:VCARD") {
if insideCard { unterminated += 1 }
insideCard = true
current = [line]
} else if upper.hasPrefix("END:VCARD") {
if insideCard {
current.append(line)
cards.append(current)
}
insideCard = false
current = []
} else if insideCard {
current.append(line)
}
}
if insideCard { unterminated += 1 }
var warnings: [String] = []
if cards.isEmpty {
warnings.append("No vCards found — the file may not be a vCard export.")
}
if unterminated > 0 {
warnings.append("\(unterminated) card(s) had no END:VCARD and were skipped.")
}
let files = cards.map { card -> SanitizedFile in
let sanitized = sanitize(cardLines: card)
var builder = ContentLineBuilder()
for line in sanitized {
builder.appendRawLine(line)
}
// The display name is read back from the *sanitized* lines, so a
// quoted-printable name yields "José" rather than "Jos=C3=A9".
return SanitizedFile(
suggestedName: displayName(in: sanitized),
contents: builder.render()
)
}
return FileSanitizationResult(files: files, warnings: warnings)
}
/// Applies the per-property filtering to one card's lines.
static func sanitize(cardLines: [String]) -> [String] {
var output: [String] = []
for line in cardLines {
let upper = line.uppercased()
if upper.hasPrefix("BEGIN:VCARD") || upper.hasPrefix("END:VCARD") {
output.append(line)
continue
}
guard var property = PropertyLine(line) else { continue }
if strippedProperties.contains(property.name) { continue }
if property.name.hasPrefix("X-") { continue }
// Any remaining base64 payload is binary the device cannot use.
if property.parameterValue(named: "ENCODING")
.map({ ["B", "BASE64"].contains($0.uppercased()) }) == true {
continue
}
if property.parameterValue(named: "ENCODING")?.uppercased() == "QUOTED-PRINTABLE" {
property.value = QuotedPrintable.decode(
property.value,
charset: property.parameterValue(named: "CHARSET")
)
property.removeParameters(named: ["ENCODING", "CHARSET"])
}
output.append(property.rendered)
}
return output
}
/// vCard 2.1 continues a quoted-printable value on the next line when the
/// current one ends in `=`. Unlike RFC folding the continuation is *not*
/// indented, so `ContentLine.unfold` leaves it split and it is rejoined here.
static func joiningSoftLineBreaks(_ lines: [String]) -> [String] {
var output: [String] = []
var awaitingContinuation = false
for line in lines {
if awaitingContinuation, !output.isEmpty {
output[output.count - 1] = String(output[output.count - 1].dropLast()) + line
} else {
output.append(line)
}
let last = output[output.count - 1]
awaitingContinuation = last.hasSuffix("=")
&& last.uppercased().contains("QUOTED-PRINTABLE")
}
return output
}
/// Best available human name for the card, used as its filename.
static func displayName(in lines: [String]) -> String {
var structuredName: String?
var organization: String?
for line in lines {
guard let property = PropertyLine(line) else { continue }
switch property.name {
case "FN" where !property.value.isEmpty:
return PropertyLine.unescape(property.value)
case "N" where structuredName == nil:
// N is family;given;middle;prefix;suffix display it given-first.
let parts = property.value
.components(separatedBy: ";")
.map { PropertyLine.unescape($0).trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
if !parts.isEmpty {
structuredName = parts.count >= 2
? "\(parts[1]) \(parts[0])"
: parts[0]
}
case "ORG" where organization == nil:
organization = PropertyLine.unescape(property.value)
.components(separatedBy: ";")
.first?
.trimmingCharacters(in: .whitespaces)
default:
break
}
}
return structuredName ?? organization ?? "contact"
}
}
+85
View File
@@ -0,0 +1,85 @@
import Foundation
/// Emits vCard 3.0 for a click-wheel iPod.
///
/// Version 3.0 rather than 4.0 deliberately: the click-wheel address book was
/// written against 2.1/3.0 and does not recognise 4.0's `VERSION` or its
/// property forms. Nothing here emits `PHOTO`, `LOGO`, `SOUND` or `X-`
/// properties the categories that made hand-exported files fail because
/// they are simply never written rather than stripped afterwards.
public enum VCardWriter {
/// Renders one contact as a complete, CRLF-terminated vCard.
public static func makeVCard(for contact: IPodContact) -> String {
var builder = ContentLineBuilder()
builder.appendVerbatim("BEGIN", "VCARD")
builder.appendVerbatim("VERSION", "3.0")
// N is ordered family;given;middle;prefix;suffix and is required in 3.0.
builder.appendStructured("N", components: [
contact.familyName,
contact.givenName,
contact.middleName,
contact.namePrefix,
contact.nameSuffix,
])
builder.append("FN", contact.displayName)
if !contact.organization.isEmpty {
builder.appendStructured("ORG", components: [contact.organization])
}
if !contact.jobTitle.isEmpty {
builder.append("TITLE", contact.jobTitle)
}
for phone in contact.phoneNumbers where !phone.value.isEmpty {
let type = phone.label.isEmpty ? "VOICE" : phone.label
builder.append("TEL", parameters: [("TYPE", type)], value: phone.value)
}
for email in contact.emailAddresses where !email.value.isEmpty {
let type = email.label.isEmpty ? "INTERNET" : "INTERNET,\(email.label)"
builder.append("EMAIL", parameters: [("TYPE", type)], value: email.value)
}
for address in contact.postalAddresses {
// ADR is po-box;extended;street;locality;region;postal-code;country.
let components = [
"", "",
address.street,
address.city,
address.state,
address.postalCode,
address.country,
]
guard components.contains(where: { !$0.isEmpty }) else { continue }
let value = components.map(ContentLine.escapeText).joined(separator: ";")
let name = address.label.isEmpty ? "ADR" : "ADR;TYPE=\(address.label)"
builder.appendRawLine("\(name):\(value)")
}
if let birthday = contact.birthday, let formatted = formatBirthday(birthday) {
builder.appendVerbatim("BDAY", formatted)
}
builder.appendVerbatim("END", "VCARD")
return builder.render()
}
/// Formats a birthday as `YYYY-MM-DD`.
///
/// Returns nil for year-less birthdays. Contacts.app allows them, but vCard
/// 3.0 has no representation Apple doesn't express through an `X-` parameter
/// (`X-APPLE-OMIT-YEAR`), and an invented year would show a wrong age on the
/// device so those birthdays are dropped instead.
static func formatBirthday(_ components: DateComponents) -> String? {
guard let year = components.year,
let month = components.month,
let day = components.day,
year > 1
else { return nil }
return String(format: "%04d-%02d-%02d", year, month, day)
}
}
+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
}
}
}
+227
View File
@@ -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)
}
}
+14
View File
@@ -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) {}
}
}
}