// EventKit has no Linux equivalent. See the note in ContactsReader.swift. #if canImport(EventKit) 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" } } #endif