Die Unterscheidung zwischen "keine Termine" und "keine Information" steckt von Anfang an in den Grundlagen, nicht als Nachtrag für Calendarr. Ein Kalender ohne Termine und ein Kalender, über den nichts bekannt ist, sehen in einer Liste identisch aus — nämlich leer. Ein leerer März ist überzeugend und schlicht falsch, wenn die Quelle nur 49 Tage abdeckt. EventWindow trägt deshalb eine coverage mit. Bei EventKit ist sie der angefragte Zeitraum, weil direkt abgefragt wird; bei Calendarr wird sie später aus dem Snapshot kommen. Tage außerhalb werden im Mini-Monat ausgegraut und tragen einen erklärenden Hinweis, statt als frei durchzugehen. Die Abdeckung gewinnt auch gegen widersprüchliche Eingaben: liefert eine Quelle einen Termin für einen Tag, den sie nach eigener Angabe nicht abdeckt, bleibt der Tag unbekannt. Sonst behauptet die Anzeige mehr zu wissen, als belegt ist. Als Test festgehalten. CalendarSourceState statt eines Optionals: denied, neverWritten, loggedOut, incompatible und unreadable bekommen jeweils eine eigene Antwort in der Oberfläche. "Nichts anzuzeigen" ist keine. Serientermine bekommen eine aus Kennung und Beginn zusammengesetzte ID — EKEvent.eventIdentifier ist für alle Vorkommen gleich, eine Terminliste mit einer täglichen Serie fiele sonst auf einen Eintrag zusammen. Monatsraster testgetrieben, inklusive Schaltjahr, Wochenstart Montag wie Sonntag und der Regel, dass das Ende der Abdeckung nicht mehr dazugehört. 93 Tests grün.
96 lines
3.9 KiB
Swift
96 lines
3.9 KiB
Swift
import Foundation
|
|
import EventKit
|
|
import AppKit
|
|
|
|
/// Termine aus dem Apple-Kalender. Ausschließlich lesend.
|
|
public final class EventKitSource: CalendarSource, @unchecked Sendable {
|
|
|
|
public let id = "eventkit"
|
|
private let store = EKEventStore()
|
|
|
|
/// Kalender, die der Nutzer ausgeblendet hat. Leer heißt: alle anzeigen.
|
|
public var excludedCalendarIDs: Set<String> = []
|
|
|
|
public init() {}
|
|
|
|
public func requestAccess() async -> Bool {
|
|
switch EKEventStore.authorizationStatus(for: .event) {
|
|
case .fullAccess:
|
|
return true
|
|
case .denied, .restricted:
|
|
return false
|
|
case .notDetermined, .writeOnly:
|
|
// `writeOnly` ist für uns wertlos — Onyx zeigt nur an. Der Nutzer
|
|
// muss den Vollzugriff geben, sonst bleibt das Widget leer.
|
|
return (try? await store.requestFullAccessToEvents()) ?? false
|
|
@unknown default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public func calendars() async -> [OnyxCalendar] {
|
|
guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { return [] }
|
|
return store.calendars(for: .event).map {
|
|
OnyxCalendar(id: $0.calendarIdentifier,
|
|
title: $0.title,
|
|
colorHex: $0.cgColor.map(Self.hex(from:)))
|
|
}
|
|
}
|
|
|
|
public func load(_ interval: DateInterval) async -> CalendarSourceState {
|
|
guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else {
|
|
return .denied
|
|
}
|
|
|
|
let calendars = store.calendars(for: .event)
|
|
.filter { !excludedCalendarIDs.contains($0.calendarIdentifier) }
|
|
|
|
// Ohne Kalender liefert EventKit alle statt keiner — der Unterschied
|
|
// zwischen „nichts ausgewählt" und „nicht gefiltert" geht sonst verloren.
|
|
guard !calendars.isEmpty else {
|
|
return .ready(EventWindow(events: [], coverage: interval))
|
|
}
|
|
|
|
let predicate = store.predicateForEvents(withStart: interval.start,
|
|
end: interval.end,
|
|
calendars: calendars)
|
|
let events = store.events(matching: predicate).map(Self.convert)
|
|
|
|
// EventKit wird direkt abgefragt: was hier zurückkommt, ist vollständig.
|
|
// Die Abdeckung ist deshalb genau der angefragte Zeitraum — anders als
|
|
// bei Calendarr, das aus einem begrenzten Zwischenspeicher liest.
|
|
return .ready(EventWindow(events: events.sorted { $0.start < $1.start },
|
|
coverage: interval,
|
|
asOf: Date()))
|
|
}
|
|
|
|
// MARK: - Umwandlung
|
|
|
|
private static func convert(_ event: EKEvent) -> OnyxEvent {
|
|
OnyxEvent(
|
|
// `eventIdentifier` ist bei Serienterminen für alle Vorkommen
|
|
// gleich. Zusammen mit dem Beginn wird daraus eine eindeutige
|
|
// Kennung — sonst kollabiert eine Terminliste mit einer täglichen
|
|
// Serie auf einen einzigen Eintrag.
|
|
id: "\(event.eventIdentifier ?? UUID().uuidString)@\(event.startDate.timeIntervalSince1970)",
|
|
title: event.title ?? "",
|
|
start: event.startDate,
|
|
end: event.endDate,
|
|
isAllDay: event.isAllDay,
|
|
colorHex: event.calendar.cgColor.map(hex(from:)),
|
|
location: event.location?.isEmpty == false ? event.location : nil,
|
|
calendarID: event.calendar.calendarIdentifier,
|
|
calendarTitle: event.calendar.title)
|
|
}
|
|
|
|
private static func hex(from color: CGColor) -> String {
|
|
guard let converted = NSColor(cgColor: color)?.usingColorSpace(.sRGB) else {
|
|
return "#8E8E93"
|
|
}
|
|
return String(format: "#%02X%02X%02X",
|
|
Int(round(converted.redComponent * 255)),
|
|
Int(round(converted.greenComponent * 255)),
|
|
Int(round(converted.blueComponent * 255)))
|
|
}
|
|
}
|