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
128 lines
4.4 KiB
Swift
128 lines
4.4 KiB
Swift
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"
|
|
}
|
|
}
|