import Foundation import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr") /// Liest den Schnappschuss, den Calendarr in den Gruppencontainer legt. /// /// Streng lesend. Onyx meldet sich nie selbst an, holt nichts aus dem Netz und /// schreibt nie in den Container zurück — der Schnappschuss gehört Calendarr, /// und zwei Schreiber auf einer Datei wären ein Fehler, den man erst bemerkt, /// wenn Termine verschwinden. public enum CalendarrSnapshotReader { /// Wie weit nach vorne der Schnappschuss reicht. /// /// Steht nicht in der Datei, sondern ist zugesagtes Verhalten des /// Schreibers. Läge der Wert zu niedrig, würde Onyx zu viel ausgrauen — /// unschön, aber ehrlich. Läge er zu hoch, würde es leere Tage als „frei" /// behaupten, für die nie jemand Daten geschrieben hat. Von beiden Fehlern /// ist nur der erste vertretbar, deshalb wird hier nicht großzügig gerundet. public static let coverageDays = 49 /// Höchste Formatfassung, deren Bedeutung Onyx kennt. public static let maxSupportedVersion = 1 /// Der Zeitraum, über den der Schnappschuss etwas aussagt. /// /// Beginnt am Tag des Schreibens, nicht früher: Calendarr schreibt nach /// vorne. Ein vergangener Tag ohne Termine kann „war nichts" heißen oder /// „wurde nie geschrieben" — das ist von außen nicht unterscheidbar, also /// wird es auch nicht behauptet. public static func coverage(writtenAt: Date, calendar: Calendar) -> DateInterval { let start = calendar.startOfDay(for: writtenAt) let end = calendar.date(byAdding: .day, value: coverageDays, to: start) ?? start return DateInterval(start: start, end: end) } /// Wertet Schnappschuss und Kalenderliste aus. Rein — ohne Dateisystem, /// damit jeder Zustand ohne installierte Calendarr-App prüfbar bleibt. public static func state(fromSnapshot snapshot: Data?, calendars: Data?, calendar: Calendar = .autoupdatingCurrent) -> CalendarSourceState { // Keine Datei heißt nicht „kaputt", sondern „Calendarr war noch nie // offen". Dafür gibt es einen eigenen, hilfreicheren Hinweis. guard let snapshot else { return .neverWritten } let raw: RawSnapshot do { raw = try JSONDecoder().decode(RawSnapshot.self, from: snapshot) } catch { log.error("Schnappschuss unlesbar: \(error.localizedDescription, privacy: .public)") return .unreadable(reason: error.localizedDescription) } let version = raw.version ?? 1 guard version <= maxSupportedVersion else { return .incompatible(found: version, maxSupported: maxSupportedVersion) } let parser = DateParser() let writtenAt = raw.writtenAtDate(parser) // Abgemeldet: die vorhandenen Termine sind alt und sehen trotzdem // aktuell aus. Genau deshalb werden sie verworfen statt angezeigt. if raw.loggedOut == true { return .loggedOut(asOf: writtenAt) } let titles = calendarTitles(from: calendars) let events = (raw.events ?? []) .compactMap { $0.asOnyxEvent(titles: titles, parser: parser) } .sorted { $0.start < $1.start } return .ready(EventWindow( events: events, coverage: writtenAt.map { coverage(writtenAt: $0, calendar: calendar) }, asOf: writtenAt)) } /// Die Kalenderliste als eigene Datei — sie trägt die Namen, die im /// Schnappschuss selbst nur als Kennung stehen. public static func calendars(from data: Data?) -> [OnyxCalendar] { guard let data, let raw = try? JSONDecoder().decode([RawCalendar].self, from: data) else { return [] } return raw.map { OnyxCalendar(id: $0.id, title: $0.name, colorHex: $0.colorHex) } } private static func calendarTitles(from data: Data?) -> [String: String] { Dictionary(calendars(from: data).map { ($0.id, $0.title) }, uniquingKeysWith: { first, _ in first }) } // MARK: - Rohformat private struct RawSnapshot: Decodable { let version: Int? let loggedOut: Bool? let writtenAt: String? let events: [RawEvent]? func writtenAtDate(_ parser: DateParser) -> Date? { writtenAt.flatMap(parser.date(from:)) } } private struct RawCalendar: Decodable { let id: String let name: String let colorHex: String? } private struct RawEvent: Decodable { let id: String let title: String let start: String let end: String let isAllDay: Bool? let location: String? let calendarKey: String? let colorHex: String? func asOnyxEvent(titles: [String: String], parser: DateParser) -> OnyxEvent? { guard let start = parser.date(from: start), let end = parser.date(from: end) else { return nil } let key = calendarKey ?? "" return OnyxEvent( id: id, title: title, start: start, end: end, isAllDay: isAllDay ?? false, colorHex: colorHex, // Ein leerer Ort ist kein Ort — sonst steht unter jedem Termin // eine leere Zeile mit Stecknadel. location: (location?.isEmpty ?? true) ? nil : location, calendarID: key, calendarTitle: titles[key] ?? key) } } /// Calendarr schreibt ISO 8601 in UTC, mal mit und mal ohne Sekundenbruch. /// /// Bewusst kein gemeinsamer statischer Formatierer: `ISO8601DateFormatter` /// ist nicht `Sendable`, und ein `nonisolated(unsafe)` wäre hier eine /// Behauptung, die niemand prüft. Einmal je Durchlauf gebaut und /// durchgereicht kostet nichts und ist nachweislich richtig. struct DateParser { private let plain: ISO8601DateFormatter private let withFraction: ISO8601DateFormatter init() { plain = ISO8601DateFormatter() plain.formatOptions = [.withInternetDateTime] withFraction = ISO8601DateFormatter() withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] } func date(from string: String) -> Date? { withFraction.date(from: string) ?? plain.date(from: string) } } } // MARK: - Quelle /// Calendarr als Terminquelle. public final class CalendarrSource: CalendarSource, @unchecked Sendable { public let id = "calendarr" /// Der Gruppencontainer, den Calendarr und Onyx teilen. /// /// Onyx läuft ohne Sandbox — der Container ist trotzdem erreichbar, weil /// die Gruppe im Entitlement steht und beide Apps im selben Team signiert /// sind. Siehe docs/spikes/D-appgroup.md. public static let appGroup = "PP34X97WS3.group.com.scarriffleservices.calendarr" /// Die Nachricht, mit der Calendarr auf einen neuen Schnappschuss hinweist. public static let changeNotification = "com.scarriffleservices.calendarr.snapshot-changed" private let containerURL: URL? private let calendar: Calendar public init(calendar: Calendar = .autoupdatingCurrent) { self.containerURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup) self.calendar = calendar if containerURL == nil { log.error("Gruppencontainer \(Self.appGroup, privacy: .public) nicht erreichbar") } } /// Calendarr braucht keinen Systemdialog — der Container ist entweder da /// oder die App ist nicht installiert. public var authorization: CalendarAuthorization { .notRequired } public var isAuthorized: Bool { true } public func requestAccess() async -> Bool { true } public func calendars() async -> [OnyxCalendar] { CalendarrSnapshotReader.calendars(from: read("widget-calendars.json")) } /// Das angefragte Intervall wird zum Filtern benutzt, nicht zum Abfragen: /// der Schnappschuss liegt fertig da, seine Reichweite bestimmt Calendarr. public func load(_ interval: DateInterval) async -> CalendarSourceState { guard containerURL != nil else { return .unreadable(reason: "Gruppencontainer nicht erreichbar") } let state = CalendarrSnapshotReader.state(fromSnapshot: read("widget-cache.json"), calendars: read("widget-calendars.json"), calendar: calendar) guard case .ready(let window) = state else { return state } // Die Abdeckung bleibt die des Schnappschusses und wird nicht auf das // angefragte Fenster eingeengt — sonst würde ein schmaler Ausschnitt // wie eine schmale Datenlage aussehen. return .ready(EventWindow( events: window.events.filter { $0.end >= interval.start && $0.start < interval.end }, coverage: window.coverage, asOf: window.asOf)) } private func read(_ name: String) -> Data? { guard let url = containerURL?.appendingPathComponent(name) else { return nil } return try? Data(contentsOf: url) } }