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