Files
aris-old/IrisCompanion/iris/DataSources/CalendarDataSource.swift
2026-01-08 19:16:32 +00:00

201 lines
6.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 wont 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)
}
}
}
}
}