201 lines
6.8 KiB
Swift
201 lines
6.8 KiB
Swift
//
|
||
// 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 {
|
||
struct CandidatesResult: Sendable {
|
||
let candidates: [Candidate]
|
||
let error: String?
|
||
let diagnostics: [String: String]
|
||
}
|
||
|
||
private let store: EKEventStore
|
||
private let config: CalendarDataSourceConfig
|
||
|
||
init(store: EKEventStore = EKEventStore(), config: CalendarDataSourceConfig = .init()) {
|
||
self.store = store
|
||
self.config = config
|
||
}
|
||
|
||
func candidatesWithDiagnostics(now: Int) async -> CandidatesResult {
|
||
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 {
|
||
return CandidatesResult(candidates: [], error: "Calendar access not granted.", diagnostics: diagnostics)
|
||
}
|
||
|
||
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)
|
||
|
||
let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate)
|
||
diagnostics["candidates"] = String(candidates.count)
|
||
|
||
return CandidatesResult(candidates: candidates, error: nil, diagnostics: diagnostics)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] {
|
||
var results: [Candidate] = []
|
||
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 title = (isOngoing ? "Now: \(event.title ?? "Event")" : event.title ?? "Upcoming")
|
||
.truncated(maxLength: TextConstraints.titleMax)
|
||
|
||
let subtitle = subtitleText(event: event, nowDate: nowDate)
|
||
.truncated(maxLength: TextConstraints.subtitleMax)
|
||
|
||
let confidence: Double = isOngoing ? 0.9 : 0.7
|
||
let ttl = ttlSec(end: end, nowDate: nowDate)
|
||
|
||
let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))"
|
||
|
||
results.append(
|
||
Candidate(
|
||
id: id,
|
||
type: .info,
|
||
title: title,
|
||
subtitle: subtitle,
|
||
confidence: confidence,
|
||
createdAt: now,
|
||
ttlSec: ttl,
|
||
metadata: [
|
||
"source": "eventkit",
|
||
"calendar": event.calendar.title,
|
||
"start": String(Int(start.timeIntervalSince1970)),
|
||
"end": String(Int(end.timeIntervalSince1970)),
|
||
"all_day": event.isAllDay ? "true" : "false",
|
||
"location": event.location ?? "",
|
||
]
|
||
)
|
||
)
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
private func ttlSec(end: Date, nowDate: Date) -> Int {
|
||
let ttl = Int(end.timeIntervalSince(nowDate))
|
||
// Keep the candidate alive until it ends, but cap at 2h and floor at 60s.
|
||
return min(max(ttl, 60), 2 * 60 * 60)
|
||
}
|
||
|
||
private func subtitleText(event: EKEvent, nowDate: Date) -> String {
|
||
guard let start = event.startDate, let end = event.endDate else { return "" }
|
||
let isOngoing = start <= nowDate && end > nowDate
|
||
|
||
if isOngoing {
|
||
let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0)))
|
||
if let loc = event.location, !loc.isEmpty {
|
||
return "\(loc) • \(remainingMin)m left"
|
||
}
|
||
return "\(remainingMin)m left"
|
||
}
|
||
|
||
let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0)))
|
||
if let loc = event.location, !loc.isEmpty {
|
||
return "In \(minutes)m • \(loc)"
|
||
}
|
||
return "In \(minutes)m"
|
||
}
|
||
|
||
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)
|
||
|
||
switch status {
|
||
case .authorized:
|
||
return true
|
||
case .denied, .restricted:
|
||
return false
|
||
case .notDetermined:
|
||
return await requestAccess()
|
||
@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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|