Sources/OrgApp/CalendarEvents.swift
86 lines · 4157 bytes
8 symbols in this file
1import CoreGraphics
2import EventKit
3import Foundation
4import OrgCore
5import OrgPresentation
6
7/// What the agenda reads from a calendar event: `EKEvent`, or a value made in tests.
8public protocol CalendarEventRecord {
9 var recordID: String { get }
10 var recordTitle: String { get }
11 var recordStart: Date { get }
12 var recordEnd: Date { get }
13 var recordIsAllDay: Bool { get }
14 var recordLocation: String? { get }
15 var recordCalendarID: String { get }
16 var recordCalendarTitle: String { get }
17 var recordCalendarColor: CGColor? { get }
18}
19
20extension EKEvent: CalendarEventRecord {
21 /// A repeating event's occurrences share an identifier; the start tells them apart.
22 public var recordID: String { (eventIdentifier ?? calendarItemIdentifier) + "@" + String(Int(startDate.timeIntervalSince1970)) }
23 public var recordTitle: String { title ?? "" }
24 public var recordStart: Date { startDate }
25 public var recordEnd: Date { endDate }
26 public var recordIsAllDay: Bool { isAllDay }
27 public var recordLocation: String? { location.flatMap { $0.isEmpty ? nil : $0 } }
28 public var recordCalendarID: String { calendar?.calendarIdentifier ?? "" }
29 public var recordCalendarTitle: String { calendar?.title ?? "" }
30 public var recordCalendarColor: CGColor? { calendar?.cgColor }
31}
32
33/// Read-only events from the system Calendar for the agenda (`calendar-events` in
34/// config.toml), from the calendars `calendar-event-calendars` names.
35public final class CalendarEvents: @unchecked Sendable {
36 private let store = EKEventStore()
37
38 public init() {}
39
40 /// Reading events needs full access; EventKit has no read-only level.
41 public static var isAuthorized: Bool { EKEventStore.authorizationStatus(for: .event) == .fullAccess }
42
43 /// Asks for access once; later calls answer with what the person chose.
44 public func requestAccess() async -> Bool {
45 if Self.isAuthorized { return true }
46 return (try? await store.requestFullAccessToEvents()) ?? false
47 }
48
49 /// The event calendars, for choosing among them.
50 public func calendars() -> [(id: String, title: String, color: RGBA?)] {
51 guard Self.isAuthorized else { return [] }
52 return store.calendars(for: .event).map { ($0.calendarIdentifier, $0.title, $0.cgColor.flatMap(Self.rgba)) }
53 }
54
55 /// Events overlapping `start..<end` in the chosen calendars (all when `chosen` is empty),
56 /// by start. None without access.
57 public func events(from start: Date, to end: Date, chosen: [String]) -> [AgendaEvent] {
58 guard Self.isAuthorized else { return [] }
59 let calendars = store.calendars(for: .event).filter { Self.chooses(id: $0.calendarIdentifier, title: $0.title, chosen: chosen) }
60 guard !calendars.isEmpty else { return [] }
61 let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
62 return store.events(matching: predicate).map(Self.event).sorted { $0.start < $1.start }
63 }
64
65 /// Whether a calendar is among those chosen by identifier or title; every calendar is when
66 /// none are.
67 public static func chooses(id: String, title: String, chosen: [String]) -> Bool {
68 chosen.isEmpty || chosen.contains(id) || chosen.contains(title)
69 }
70
71 public static func event(_ record: some CalendarEventRecord) -> AgendaEvent {
72 AgendaEvent(
73 id: record.recordID, title: record.recordTitle, calendarID: record.recordCalendarID, calendarTitle: record.recordCalendarTitle,
74 color: record.recordCalendarColor.flatMap(rgba), start: record.recordStart, end: record.recordEnd,
75 isAllDay: record.recordIsAllDay, location: record.recordLocation
76 )
77 }
78
79 /// A color in sRGB.
80 static func rgba(_ color: CGColor) -> RGBA? {
81 guard let space = CGColorSpace(name: CGColorSpace.sRGB),
82 let converted = color.converted(to: space, intent: .defaultIntent, options: nil),
83 let c = converted.components, c.count >= 3 else { return nil }
84 return RGBA(red: Double(c[0]), green: Double(c[1]), blue: Double(c[2]), alpha: Double(converted.alpha))
85 }
86}