import EventKit import Foundation // MARK: - Models public struct CalendarEntry: Sendable { public let calendarIdentifier: String public let title: String public let type: EKCalendarType public let color: String public let isSubscribed: Bool public let isImmutable: Bool public let isReadOnly: Bool public let source: String } public struct EventEntry: Sendable { public let eventIdentifier: String public let calendarIdentifier: String public let title: String public let notes: String? public let location: String? public let url: String? public let isAllDay: Bool public let startDate: Date public let endDate: Date public let creationDate: Date? public let lastModifiedDate: Date? public let status: EKEventStatus public let availability: EKEventAvailability public let isDetached: Bool public let hasAlarms: Bool public let hasRecurrenceRules: Bool public let recurrenceRule: String? public let organizerEmail: String? public let organizerName: String? public let attendeeCount: Int } // MARK: - Reader /// Reads calendars and events from the system EventKit store. public final class CalendarReader: @unchecked Sendable { public static let shared = CalendarReader() private let store = EKEventStore() private var authorized = false private init() {} /// Exposed for the Sender, which needs to call `save`/`remove` on the /// same authorized store the Reader uses. public var eventStore: EKEventStore { store } // MARK: - Authorization public func requestAuthorization() async -> Bool { if #available(macOS 14.0, *) { do { let granted = try await store.requestFullAccessToEvents() authorized = granted } catch { NSLog("CalendarReader: requestFullAccessToEvents failed: \(error)") authorized = false } } else { authorized = await withCheckedContinuation { continuation in store.requestAccess(to: .event) { granted, _ in continuation.resume(returning: granted) } } } NSLog("CalendarReader: auth granted=\(authorized)") return authorized } public var isAuthorized: Bool { if #available(macOS 14.0, *) { EKEventStore.authorizationStatus(for: .event) == .fullAccess } else { EKEventStore.authorizationStatus(for: .event) == .authorized } } // MARK: - Fetch public func fetchCalendars() -> [CalendarEntry] { store.calendars(for: .event).map { cal in CalendarEntry( calendarIdentifier: cal.calendarIdentifier, title: cal.title, type: cal.type, color: hexColor(from: cal.cgColor), isSubscribed: cal.isSubscribed, isImmutable: cal.isImmutable, isReadOnly: !cal.allowsContentModifications, source: cal.source?.title ?? "" ) } } /// Fetch events modified on or after `since`, up to `lookaheadDays` into the future /// and `lookbackDays` into the past (both from now, not from `since`). public func fetchEvents( since: Date? = nil, lookbackDays: Int = 30, lookaheadDays: Int = 365 ) -> [EventEntry] { let now = Date() let start = Calendar.current.date(byAdding: .day, value: -lookbackDays, to: now)! let end = Calendar.current.date(byAdding: .day, value: lookaheadDays, to: now)! let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil) let events = store.events(matching: predicate) NSLog("CalendarReader: fetchEvents raw=\(events.count) window=[\(lookbackDays)d ago … \(lookaheadDays)d ahead]") let filtered: [EKEvent] if let since { filtered = events.filter { event in (event.lastModifiedDate ?? event.creationDate ?? event.startDate) >= since } } else { filtered = events } NSLog("CalendarReader: fetchEvents filtered=\(filtered.count)") return filtered.map { mapEvent($0) } } // MARK: - Helpers private func mapEvent(_ event: EKEvent) -> EventEntry { let recurrenceRule: String? = event.recurrenceRules?.first.map { rule in // Encode as a human-readable string — server stores it as-is for display rule.frequency.description + (rule.recurrenceEnd != nil ? " (ends)" : " (repeats)") } return EventEntry( eventIdentifier: event.eventIdentifier ?? UUID().uuidString, calendarIdentifier: event.calendar?.calendarIdentifier ?? "", title: event.title ?? "(no title)", notes: event.notes, location: event.location, url: event.url?.absoluteString, isAllDay: event.isAllDay, startDate: event.startDate, endDate: event.endDate, creationDate: event.creationDate, lastModifiedDate: event.lastModifiedDate, status: event.status, availability: event.availability, isDetached: event.isDetached, hasAlarms: !(event.alarms?.isEmpty ?? true), hasRecurrenceRules: !(event.recurrenceRules?.isEmpty ?? true), recurrenceRule: recurrenceRule, organizerEmail: event.organizer?.url.absoluteString.replacingOccurrences(of: "mailto:", with: ""), organizerName: event.organizer?.name, attendeeCount: event.attendees?.count ?? 0 ) } private func hexColor(from cgColor: CGColor?) -> String { guard let cgColor, let components = cgColor.components, components.count >= 3 else { return "#888888" } let r = Int(components[0] * 255) let g = Int(components[1] * 255) let b = Int(components[2] * 255) return String(format: "#%02X%02X%02X", r, g, b) } } // MARK: - EKRecurrenceFrequency description private extension EKRecurrenceFrequency { var description: String { switch self { case .daily: return "daily" case .weekly: return "weekly" case .monthly: return "monthly" case .yearly: return "yearly" @unknown default: return "unknown" } } }