import Foundation import OSLog import EventKit import AppKit private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "EventKit") /// Termine aus dem Apple-Kalender. Ausschließlich lesend. public final class EventKitSource: CalendarSource, @unchecked Sendable { public let id = "eventkit" private let store = EKEventStore() /// Kalender, die der Nutzer ausgeblendet hat. Leer heißt: alle anzeigen. public var excludedCalendarIDs: Set = [] public init() {} public var authorization: CalendarAuthorization { switch EKEventStore.authorizationStatus(for: .event) { case .fullAccess: .granted case .denied, .restricted: .denied // `writeOnly` ist für uns so gut wie nichts — Onyx zeigt nur an. case .notDetermined, .writeOnly: .undetermined @unknown default: .denied } } public var isAuthorized: Bool { authorization == .granted } public func requestAccess() async -> Bool { switch EKEventStore.authorizationStatus(for: .event) { case .fullAccess: return true case .denied, .restricted: return false case .notDetermined, .writeOnly: // `writeOnly` ist für uns wertlos — Onyx zeigt nur an. Der Nutzer // muss den Vollzugriff geben, sonst bleibt das Widget leer. do { return try await store.requestFullAccessToEvents() } catch { // Nicht verschlucken: wenn EventKit hier wirft, erscheint gar // kein Dialog und der Zustand bleibt `notDetermined` — von außen // sieht das aus, als hätte die App nie gefragt. log.error("requestFullAccessToEvents warf: \(error.localizedDescription, privacy: .public) — \(String(describing: error), privacy: .public)") return false } @unknown default: return false } } public func calendars() async -> [OnyxCalendar] { guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { return [] } return store.calendars(for: .event).map { OnyxCalendar(id: $0.calendarIdentifier, title: $0.title, colorHex: $0.cgColor.map(Self.hex(from:))) } } public func load(_ interval: DateInterval) async -> CalendarSourceState { guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { return .denied } let calendars = store.calendars(for: .event) .filter { !excludedCalendarIDs.contains($0.calendarIdentifier) } // Ohne Kalender liefert EventKit alle statt keiner — der Unterschied // zwischen „nichts ausgewählt" und „nicht gefiltert" geht sonst verloren. guard !calendars.isEmpty else { return .ready(EventWindow(events: [], coverage: interval)) } let predicate = store.predicateForEvents(withStart: interval.start, end: interval.end, calendars: calendars) let events = store.events(matching: predicate).map(Self.convert) // EventKit wird direkt abgefragt: was hier zurückkommt, ist vollständig. // Die Abdeckung ist deshalb genau der angefragte Zeitraum — anders als // bei Calendarr, das aus einem begrenzten Zwischenspeicher liest. return .ready(EventWindow(events: events.sorted { $0.start < $1.start }, coverage: interval, asOf: Date())) } // MARK: - Umwandlung private static func convert(_ event: EKEvent) -> OnyxEvent { OnyxEvent( // `eventIdentifier` ist bei Serienterminen für alle Vorkommen // gleich. Zusammen mit dem Beginn wird daraus eine eindeutige // Kennung — sonst kollabiert eine Terminliste mit einer täglichen // Serie auf einen einzigen Eintrag. id: "\(event.eventIdentifier ?? UUID().uuidString)@\(event.startDate.timeIntervalSince1970)", title: event.title ?? "", start: event.startDate, end: event.endDate, isAllDay: event.isAllDay, colorHex: event.calendar.cgColor.map(hex(from:)), location: event.location?.isEmpty == false ? event.location : nil, calendarID: event.calendar.calendarIdentifier, calendarTitle: event.calendar.title) } private static func hex(from color: CGColor) -> String { guard let converted = NSColor(cgColor: color)?.usingColorSpace(.sRGB) else { return "#8E8E93" } return String(format: "#%02X%02X%02X", Int(round(converted.redComponent * 255)), Int(round(converted.greenComponent * 255)), Int(round(converted.blueComponent * 255))) } }