import Foundation import EventKit // Production EventKit provider – read-only for list, mutating only for create. // Requirements: // - Only checks authorization status (never triggers prompt) for list/create. // - Lists calendars when status permits. // - Never calls prompting APIs except in auth provider. // - If permission absent/denied/restricted, throw permissionRequired. struct EventKitCalendarProvider: CalendarListProviding, CalendarEventsListProviding, CalendarEventCreateProviding { // MARK: - Auth check shared private func requireAuthorizedOrThrow() throws { let status = EKEventStore.authorizationStatus(for: .event) switch status { case .fullAccess, .authorized: break case .writeOnly: // writeOnly does not allow reading calendars/events; but for create we could allow? Task says already-authorized access but no permission request; map denied/not-determined to permission_required for all. // Simpler: for events list, writeOnly -> permissionRequired; for create, writeOnly should also require check but writeOnly actually allows writing. However to keep deterministic, attempt to respect writeOnly for create? // Spec: map denied/not-determined to permission_required, provider failures to calendar_unavailable. // To be safe: for list -> permissionRequired, for create we will check below differently? But shared throw would block create with writeOnly unnecessarily. // We differentiate inside methods. For this helper, allow writeOnly as authorized for mutating path? Caller should call specific check. throw CalendarProviderError.permissionRequired case .denied, .restricted, .notDetermined: throw CalendarProviderError.permissionRequired @unknown default: throw CalendarProviderError.permissionRequired } } private func requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: Bool = false) throws { let status = EKEventStore.authorizationStatus(for: .event) switch status { case .fullAccess, .authorized: return case .writeOnly: if allowsWriteOnlyRead { return } // For read paths (list calendars, list events), writeOnly does NOT permit read -> permission_required throw CalendarProviderError.permissionRequired case .denied, .restricted, .notDetermined: throw CalendarProviderError.permissionRequired @unknown default: throw CalendarProviderError.permissionRequired } } // MARK: - Calendar list (existing) func listCalendars() throws -> [CalendarListItem] { try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false) let store = EKEventStore() let ekCalendars = store.calendars(for: .event) let items: [CalendarListItem] = ekCalendars.map { cal in let sourceTitle = cal.source.title let typeString: String switch cal.type { case .birthday: typeString = "birthday" case .calDAV: typeString = "caldav" case .exchange: typeString = "exchange" case .local: typeString = "local" case .subscription: typeString = "subscription" @unknown default: typeString = "unknown" } return CalendarListItem( id: cal.calendarIdentifier, title: cal.title, source: sourceTitle, type: typeString ) } return items } // MARK: - Calendar helpers for events private func allCalendars(from store: EKEventStore) -> [EKCalendar] { store.calendars(for: .event) } private func resolveCalendarForList(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> [EKCalendar] { // Return array of matching calendars (filtered) // Rules: // - if calendarId provided (stable ID), exact match only one; if unknown -> invalidRequest // - else if calendarTitle provided, exact title match; must be unique else invalidRequest (ambiguous) or unknown => invalidRequest // - else all calendars let calendars = allCalendars(from: store) if let cid = calendarId, !cid.isEmpty { // ID wins if let found = calendars.first(where: { $0.calendarIdentifier == cid }) { return [found] } else { throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)") } } if let title = calendarTitle, !title.isEmpty { let matched = calendars.filter { $0.title == title } if matched.isEmpty { throw CalendarProviderError.invalidRequest("Calendar not found: \(title)") } if matched.count > 1 { throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars") } return matched } // no filter -> all return calendars } private func resolveCalendarForCreate(store: EKEventStore, calendarId: String?, calendarTitle: String?) throws -> EKCalendar { // Create must not default to arbitrary. // If neither id nor title -> invalidRequest let calendars = allCalendars(from: store) if let cid = calendarId, !cid.isEmpty { guard let found = calendars.first(where: { $0.calendarIdentifier == cid }) else { throw CalendarProviderError.invalidRequest("Calendar not found: \(cid)") } if !found.allowsContentModifications { throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)") } // also check writable via allowsContentModifications; isImmutable also relevant but we use allowsContentModifications return found } if let title = calendarTitle, !title.isEmpty { let matched = calendars.filter { $0.title == title } if matched.isEmpty { throw CalendarProviderError.invalidRequest("Calendar not found: \(title)") } if matched.count > 1 { throw CalendarProviderError.invalidRequest("Ambiguous calendar title: \(title) matches \(matched.count) calendars") } let found = matched[0] if !found.allowsContentModifications { throw CalendarProviderError.invalidRequest("Calendar is read-only: \(found.title)") } return found } // No calendar specified – per spec "no default first arbitrary calendar" throw CalendarProviderError.invalidRequest("Calendar must be specified by id or exact unique title") } // MARK: - Events list func listEvents(start: Date, end: Date, calendarId: String?, calendarTitle: String?, limit: Int) throws -> [CalendarEventItem] { // Permission: read requires full access try requireAuthorizedForReadOrWrite(allowsWriteOnlyRead: false) let store = EKEventStore() let targetCalendars: [EKCalendar] do { targetCalendars = try resolveCalendarForList(store: store, calendarId: calendarId, calendarTitle: calendarTitle) } catch let err as CalendarProviderError { throw err } catch { throw CalendarProviderError.unavailable("Calendar lookup failed") } if targetCalendars.isEmpty { return [] } // EK predicate let predicate = store.predicateForEvents(withStart: start, end: end, calendars: targetCalendars) let ekEvents: [EKEvent] = store.events(matching: predicate) // Map and sort deterministically, enforce overlap check (predicate already does but ensure) let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] var items: [CalendarEventItem] = [] items.reserveCapacity(min(ekEvents.count, limit)) for ev in ekEvents { guard let evStart = ev.startDate, let evEnd = ev.endDate else { continue } // Enforce overlap (EventKit predicate should already overlap but safe) if evEnd < start || evStart > end { continue } guard let cal = ev.calendar else { continue } let item = CalendarEventItem( id: ev.eventIdentifier ?? ev.calendarItemIdentifier, title: ev.title ?? "", start: iso.string(from: evStart), end: iso.string(from: evEnd), all_day: ev.isAllDay, calendar_id: cal.calendarIdentifier, calendar_title: cal.title, notes: ev.notes, location: ev.location ) items.append(item) } // Deterministic sort by start, then title, then id, then truncate to limit items.sort { if $0.start != $1.start { return $0.start < $1.start } if $0.title != $1.title { return $0.title < $1.title } return $0.id < $1.id } if items.count > limit { return Array(items.prefix(limit)) } return items } // MARK: - Event create func createEvent( title: String, start: Date, end: Date, allDay: Bool, notes: String?, location: String?, calendarId: String?, calendarTitle: String? ) throws -> CalendarEventItem { // For create, we allow fullAccess, authorized, and writeOnly (since writeOnly permits creation) let status = EKEventStore.authorizationStatus(for: .event) switch status { case .fullAccess, .authorized, .writeOnly: break case .denied, .restricted, .notDetermined: throw CalendarProviderError.permissionRequired @unknown default: throw CalendarProviderError.permissionRequired } let store = EKEventStore() let destination: EKCalendar do { destination = try resolveCalendarForCreate(store: store, calendarId: calendarId, calendarTitle: calendarTitle) } catch let err as CalendarProviderError { throw err } catch { throw CalendarProviderError.unavailable("Calendar lookup failed") } // Validate start < end already done in dispatch, but double check if start >= end { throw CalendarProviderError.invalidRequest("start must occur before end") } let ekEvent = EKEvent(eventStore: store) ekEvent.title = title ekEvent.startDate = start ekEvent.endDate = end ekEvent.isAllDay = allDay ekEvent.notes = notes ekEvent.location = location ekEvent.calendar = destination do { try store.save(ekEvent, span: .thisEvent, commit: true) } catch { throw CalendarProviderError.unavailable("Failed to save event: \(error.localizedDescription)") } let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] return CalendarEventItem( id: ekEvent.eventIdentifier ?? ekEvent.calendarItemIdentifier, title: ekEvent.title ?? title, start: iso.string(from: ekEvent.startDate ?? start), end: iso.string(from: ekEvent.endDate ?? end), all_day: ekEvent.isAllDay, calendar_id: destination.calendarIdentifier, calendar_title: destination.title, notes: ekEvent.notes, location: ekEvent.location ) } }