krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgApp/CalendarEvents.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgApp/CalendarEvents.swift history · blame · raw

86 lines · 4157 bytes

 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}