import Foundation import EventKit // MARK: - Reminders data models struct ReminderListItem: Codable, Equatable, Sendable { let id: String let title: String let source: String let type: String // local/caldav/exchange/etc } struct ReminderItem: Codable, Equatable, Sendable { let id: String let list_id: String let list_title: String let title: String let notes: String? let completed: Bool let due: String? // ISO8601 or nil let priority: Int // 0-9 (EKReminderPriority mapped) enum CodingKeys: String, CodingKey { case id case list_id case list_title case title case notes case completed case due case priority } } struct ReminderCreateResult: Codable, Equatable, Sendable { let id: String let list_id: String let list_title: String let title: String } enum RemindersProviderError: Error, Equatable, Sendable { case permissionRequired case permissionDenied case unavailable(String) case invalidRequest(String) var isPermission: Bool { if case .permissionRequired = self { return true } return false } } // MARK: - Provider protocols protocol RemindersListsProviding: Sendable { func listReminderLists() throws -> [ReminderListItem] } protocol RemindersListProviding: Sendable { func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] } protocol RemindersCreateProviding: Sendable { func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult } protocol FullRemindersProviding: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding {} // MARK: - Production EventKit provider (scoped read non-prompt; create requires explicit list) struct EventKitRemindersProvider: RemindersListsProviding, RemindersListProviding, RemindersCreateProviding { // Shared auth check – only checks status, never prompts private func requireAuthorizedForRead() throws { let status = EKEventStore.authorizationStatus(for: .reminder) switch status { case .fullAccess, .authorized: return case .writeOnly: // writeOnly does not permit listing reminders per EventKit; treat as permission_required for list operations throw RemindersProviderError.permissionRequired case .denied, .restricted, .notDetermined: throw RemindersProviderError.permissionRequired @unknown default: throw RemindersProviderError.permissionRequired } } private func requireAuthorizedForCreate() throws { let status = EKEventStore.authorizationStatus(for: .reminder) switch status { case .fullAccess, .authorized, .writeOnly: return case .denied, .restricted, .notDetermined: throw RemindersProviderError.permissionRequired @unknown default: throw RemindersProviderError.permissionRequired } } private func allReminderCalendars(store: EKEventStore) -> [EKCalendar] { store.calendars(for: .reminder) } private func resolveForList(store: EKEventStore, listId: String?, listTitle: String?) throws -> [EKCalendar] { let calendars = allReminderCalendars(store: store) if let lid = listId, !lid.isEmpty { if let found = calendars.first(where: { $0.calendarIdentifier == lid }) { return [found] } else { throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)") } } if let title = listTitle, !title.isEmpty { let matched = calendars.filter { $0.title == title } if matched.isEmpty { throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)") } if matched.count > 1 { throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists") } return matched } return calendars } private func resolveForCreate(store: EKEventStore, listId: String?, listTitle: String?) throws -> EKCalendar { let calendars = allReminderCalendars(store: store) if let lid = listId, !lid.isEmpty { guard let found = calendars.first(where: { $0.calendarIdentifier == lid }) else { throw RemindersProviderError.invalidRequest("Reminders list not found: \(lid)") } if !found.allowsContentModifications { throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)") } return found } if let title = listTitle, !title.isEmpty { let matched = calendars.filter { $0.title == title } if matched.isEmpty { throw RemindersProviderError.invalidRequest("Reminders list not found: \(title)") } if matched.count > 1 { throw RemindersProviderError.invalidRequest("Ambiguous reminders list title: \(title) matches \(matched.count) lists") } let found = matched[0] if !found.allowsContentModifications { throw RemindersProviderError.invalidRequest("Reminders list is read-only: \(found.title)") } return found } throw RemindersProviderError.invalidRequest("Reminders list must be specified by id or exact unique title") } // MARK: - lists func listReminderLists() throws -> [ReminderListItem] { try requireAuthorizedForRead() let store = EKEventStore() let calendars = allReminderCalendars(store: store) return calendars.map { cal in let sourceTitle = cal.source.title let typeString: String switch cal.type { case .local: typeString = "local" case .calDAV: typeString = "caldav" case .exchange: typeString = "exchange" case .subscription: typeString = "subscription" case .birthday: typeString = "birthday" @unknown default: typeString = "unknown" } return ReminderListItem(id: cal.calendarIdentifier, title: cal.title, source: sourceTitle, type: typeString) } } // MARK: - list reminders func listReminders(listId: String?, listTitle: String?, completed: Bool?, limit: Int) throws -> [ReminderItem] { try requireAuthorizedForRead() let store = EKEventStore() let targetCalendars: [EKCalendar] do { targetCalendars = try resolveForList(store: store, listId: listId, listTitle: listTitle) } catch let e as RemindersProviderError { throw e } catch { throw RemindersProviderError.unavailable("Reminders lookup failed") } if targetCalendars.isEmpty { return [] } let predicate = store.predicateForReminders(in: targetCalendars) var fetched: [EKReminder] = [] let sem = DispatchSemaphore(value: 0) var fetchError: Error? = nil store.fetchReminders(matching: predicate) { rems in fetched = rems ?? [] sem.signal() } // fetchReminders is async on newer APIs? In EventKit even on macOS 13 fetchReminders matching is async via completion. // Wait bounded 10s let waitRes = sem.wait(timeout: .now() + 10) if waitRes == .timedOut { throw RemindersProviderError.unavailable("Reminders fetch timed out") } if let err = fetchError { throw RemindersProviderError.unavailable(err.localizedDescription) } var items: [ReminderItem] = [] let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] for rem in fetched { let isCompleted = rem.isCompleted if let filterCompleted = completed, filterCompleted != isCompleted { continue } guard let cal = rem.calendar else { continue } let dueStr: String? if let comps = rem.dueDateComponents, let d = Calendar.current.date(from: comps) { dueStr = iso.string(from: d) } else { dueStr = nil } let item = ReminderItem( id: rem.calendarItemIdentifier, list_id: cal.calendarIdentifier, list_title: cal.title, title: rem.title ?? "", notes: rem.notes, completed: isCompleted, due: dueStr, priority: rem.priority ) items.append(item) } // Deterministic sort: due, title, id items.sort { let due0 = $0.due ?? "" let due1 = $1.due ?? "" if due0 != due1 { return due0 < due1 } 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: - create func createReminder(title: String, listId: String?, listTitle: String?, notes: String?, due: Date?, priority: Int?) throws -> ReminderCreateResult { try requireAuthorizedForCreate() guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw RemindersProviderError.invalidRequest("Missing required argument: title") } let store = EKEventStore() let destination: EKCalendar do { destination = try resolveForCreate(store: store, listId: listId, listTitle: listTitle) } catch let e as RemindersProviderError { throw e } catch { throw RemindersProviderError.unavailable("Reminders list lookup failed") } let rem = EKReminder(eventStore: store) rem.title = title rem.calendar = destination rem.notes = notes if let p = priority { rem.priority = p } if let dueDate = due { let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: dueDate) rem.dueDateComponents = comps } do { try store.save(rem, commit: true) } catch { throw RemindersProviderError.unavailable("Failed to save reminder: \(error.localizedDescription)") } return ReminderCreateResult( id: rem.calendarItemIdentifier, list_id: destination.calendarIdentifier, list_title: destination.title, title: rem.title ?? title ) } }