2026-01-08 19:16:32 +00:00
|
|
|
|
//
|
|
|
|
|
|
// CalendarDataSource.swift
|
|
|
|
|
|
// iris
|
|
|
|
|
|
//
|
|
|
|
|
|
// Created by Codex.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
import EventKit
|
|
|
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
|
|
struct CalendarDataSourceConfig: Sendable {
|
|
|
|
|
|
var lookaheadSec: Int = 2 * 60 * 60
|
|
|
|
|
|
var soonWindowSec: Int = 30 * 60
|
|
|
|
|
|
var maxCandidates: Int = 3
|
|
|
|
|
|
var includeAllDay: Bool = false
|
|
|
|
|
|
var includeDeclined: Bool = false
|
|
|
|
|
|
|
|
|
|
|
|
init() {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final class CalendarDataSource {
|
2026-01-10 00:25:36 +00:00
|
|
|
|
struct Event: Sendable, Equatable {
|
|
|
|
|
|
let id: String
|
|
|
|
|
|
let title: String
|
|
|
|
|
|
let startAt: Int
|
|
|
|
|
|
let endAt: Int
|
|
|
|
|
|
let isAllDay: Bool
|
|
|
|
|
|
let location: String?
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct Data: Sendable, Equatable {
|
|
|
|
|
|
let events: [Event]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct Snapshot: Sendable, Equatable {
|
|
|
|
|
|
let data: Data
|
2026-01-08 19:16:32 +00:00
|
|
|
|
let diagnostics: [String: String]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
|
enum CalendarError: Error, LocalizedError, Sendable, Equatable {
|
|
|
|
|
|
case accessNotGranted(diagnostics: [String: String])
|
|
|
|
|
|
|
|
|
|
|
|
var errorDescription: String? {
|
|
|
|
|
|
switch self {
|
|
|
|
|
|
case .accessNotGranted:
|
|
|
|
|
|
return "Calendar access not granted."
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var diagnostics: [String: String] {
|
|
|
|
|
|
switch self {
|
|
|
|
|
|
case .accessNotGranted(let diagnostics):
|
|
|
|
|
|
return diagnostics
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 19:16:32 +00:00
|
|
|
|
private let store: EKEventStore
|
|
|
|
|
|
private let config: CalendarDataSourceConfig
|
|
|
|
|
|
|
|
|
|
|
|
init(store: EKEventStore = EKEventStore(), config: CalendarDataSourceConfig = .init()) {
|
|
|
|
|
|
self.store = store
|
|
|
|
|
|
self.config = config
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
|
func dataWithDiagnostics(now: Int) async throws -> Snapshot {
|
2026-01-08 19:16:32 +00:00
|
|
|
|
var diagnostics: [String: String] = [
|
|
|
|
|
|
"now": String(now),
|
|
|
|
|
|
"lookahead_sec": String(config.lookaheadSec),
|
|
|
|
|
|
"soon_window_sec": String(config.soonWindowSec),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
|
|
|
|
|
|
let endDate = nowDate.addingTimeInterval(TimeInterval(config.lookaheadSec))
|
|
|
|
|
|
|
|
|
|
|
|
let auth = calendarAuthorizationStatusString()
|
|
|
|
|
|
diagnostics["auth"] = auth
|
|
|
|
|
|
|
|
|
|
|
|
let accessGranted = await ensureCalendarAccess()
|
|
|
|
|
|
diagnostics["access_granted"] = accessGranted ? "true" : "false"
|
|
|
|
|
|
|
|
|
|
|
|
guard accessGranted else {
|
2026-01-10 00:25:36 +00:00
|
|
|
|
throw CalendarError.accessNotGranted(diagnostics: diagnostics)
|
2026-01-08 19:16:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil)
|
|
|
|
|
|
let events = store.events(matching: predicate)
|
|
|
|
|
|
|
|
|
|
|
|
diagnostics["events_matched"] = String(events.count)
|
|
|
|
|
|
|
|
|
|
|
|
let filtered = events
|
|
|
|
|
|
.filter { shouldInclude(event: $0) }
|
|
|
|
|
|
.sorted { $0.startDate < $1.startDate }
|
|
|
|
|
|
|
|
|
|
|
|
diagnostics["events_filtered"] = String(filtered.count)
|
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
|
let outputEvents = buildEvents(from: filtered, nowDate: nowDate)
|
|
|
|
|
|
diagnostics["events_output"] = String(outputEvents.count)
|
2026-01-08 19:16:32 +00:00
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
|
return Snapshot(data: Data(events: outputEvents), diagnostics: diagnostics)
|
2026-01-08 19:16:32 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func shouldInclude(event: EKEvent) -> Bool {
|
|
|
|
|
|
if event.isAllDay, !config.includeAllDay {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if !config.includeDeclined {
|
|
|
|
|
|
// `EKEvent.participants` is optional; safest check is `eventStatus` and organizer availability.
|
|
|
|
|
|
// Many calendars won’t provide participants; keep it simple for Phase 1.
|
|
|
|
|
|
if event.status == .canceled {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
|
private func buildEvents(from events: [EKEvent], nowDate: Date) -> [Event] {
|
|
|
|
|
|
var results: [Event] = []
|
2026-01-08 19:16:32 +00:00
|
|
|
|
results.reserveCapacity(min(config.maxCandidates, events.count))
|
|
|
|
|
|
|
|
|
|
|
|
for event in events {
|
|
|
|
|
|
if results.count >= config.maxCandidates { break }
|
|
|
|
|
|
|
|
|
|
|
|
guard let start = event.startDate, let end = event.endDate else { continue }
|
|
|
|
|
|
let isOngoing = start <= nowDate && end > nowDate
|
|
|
|
|
|
let startsInSec = Int(start.timeIntervalSince(nowDate))
|
|
|
|
|
|
|
|
|
|
|
|
if !isOngoing, startsInSec > config.soonWindowSec {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))"
|
2026-01-10 00:25:36 +00:00
|
|
|
|
let title = (event.title ?? "Event").trimmingCharacters(in: .whitespacesAndNewlines)
|
2026-01-08 19:16:32 +00:00
|
|
|
|
results.append(
|
2026-01-10 00:25:36 +00:00
|
|
|
|
Event(
|
2026-01-08 19:16:32 +00:00
|
|
|
|
id: id,
|
2026-01-10 00:25:36 +00:00
|
|
|
|
title: title.isEmpty ? "Event" : title,
|
|
|
|
|
|
startAt: Int(start.timeIntervalSince1970),
|
|
|
|
|
|
endAt: Int(end.timeIntervalSince1970),
|
|
|
|
|
|
isAllDay: event.isAllDay,
|
|
|
|
|
|
location: event.location
|
2026-01-08 19:16:32 +00:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func calendarAuthorizationStatusString() -> String {
|
|
|
|
|
|
if #available(iOS 17.0, *) {
|
|
|
|
|
|
return String(describing: EKEventStore.authorizationStatus(for: .event))
|
|
|
|
|
|
}
|
|
|
|
|
|
return String(describing: EKEventStore.authorizationStatus(for: .event))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func ensureCalendarAccess() async -> Bool {
|
|
|
|
|
|
let status = EKEventStore.authorizationStatus(for: .event)
|
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
|
if #available(iOS 17.0, *) {
|
|
|
|
|
|
if status == .fullAccess { return true }
|
|
|
|
|
|
if status == .writeOnly { return false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 19:16:32 +00:00
|
|
|
|
switch status {
|
|
|
|
|
|
case .notDetermined:
|
|
|
|
|
|
return await requestAccess()
|
2026-01-10 00:25:36 +00:00
|
|
|
|
case .denied, .restricted:
|
|
|
|
|
|
return false
|
|
|
|
|
|
case .authorized:
|
|
|
|
|
|
return true
|
2026-01-08 19:16:32 +00:00
|
|
|
|
@unknown default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func requestAccess() async -> Bool {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
// Ensure the permission prompt is allowed to present.
|
|
|
|
|
|
}
|
|
|
|
|
|
return await withCheckedContinuation { continuation in
|
|
|
|
|
|
if #available(iOS 17.0, *) {
|
|
|
|
|
|
store.requestFullAccessToEvents { granted, _ in
|
|
|
|
|
|
continuation.resume(returning: granted)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
store.requestAccess(to: .event) { granted, _ in
|
|
|
|
|
|
continuation.resume(returning: granted)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|