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,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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user