185 lines
6.3 KiB
Swift
185 lines
6.3 KiB
Swift
import EventKit
|
|
import Foundation
|
|
|
|
// MARK: - Models
|
|
|
|
public struct CalendarEntry: Sendable {
|
|
public let calendarIdentifier: String
|
|
public let title: String
|
|
public let type: EKCalendarType
|
|
public let color: String
|
|
public let isSubscribed: Bool
|
|
public let isImmutable: Bool
|
|
public let isReadOnly: Bool
|
|
public let source: String
|
|
}
|
|
|
|
public struct EventEntry: Sendable {
|
|
public let eventIdentifier: String
|
|
public let calendarIdentifier: String
|
|
public let title: String
|
|
public let notes: String?
|
|
public let location: String?
|
|
public let url: String?
|
|
public let isAllDay: Bool
|
|
public let startDate: Date
|
|
public let endDate: Date
|
|
public let creationDate: Date?
|
|
public let lastModifiedDate: Date?
|
|
public let status: EKEventStatus
|
|
public let availability: EKEventAvailability
|
|
public let isDetached: Bool
|
|
public let hasAlarms: Bool
|
|
public let hasRecurrenceRules: Bool
|
|
public let recurrenceRule: String?
|
|
public let organizerEmail: String?
|
|
public let organizerName: String?
|
|
public let attendeeCount: Int
|
|
}
|
|
|
|
// MARK: - Reader
|
|
|
|
/// Reads calendars and events from the system EventKit store.
|
|
public final class CalendarReader: @unchecked Sendable {
|
|
public static let shared = CalendarReader()
|
|
|
|
private let store = EKEventStore()
|
|
private var authorized = false
|
|
|
|
private init() {}
|
|
|
|
/// Exposed for the Sender, which needs to call `save`/`remove` on the
|
|
/// same authorized store the Reader uses.
|
|
public var eventStore: EKEventStore { store }
|
|
|
|
// MARK: - Authorization
|
|
|
|
public func requestAuthorization() async -> Bool {
|
|
if #available(macOS 14.0, *) {
|
|
do {
|
|
let granted = try await store.requestFullAccessToEvents()
|
|
authorized = granted
|
|
} catch {
|
|
NSLog("CalendarReader: requestFullAccessToEvents failed: \(error)")
|
|
authorized = false
|
|
}
|
|
} else {
|
|
authorized = await withCheckedContinuation { continuation in
|
|
store.requestAccess(to: .event) { granted, _ in
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
}
|
|
NSLog("CalendarReader: auth granted=\(authorized)")
|
|
return authorized
|
|
}
|
|
|
|
public var isAuthorized: Bool {
|
|
if #available(macOS 14.0, *) {
|
|
EKEventStore.authorizationStatus(for: .event) == .fullAccess
|
|
} else {
|
|
EKEventStore.authorizationStatus(for: .event) == .authorized
|
|
}
|
|
}
|
|
|
|
// MARK: - Fetch
|
|
|
|
public func fetchCalendars() -> [CalendarEntry] {
|
|
store.calendars(for: .event).map { cal in
|
|
CalendarEntry(
|
|
calendarIdentifier: cal.calendarIdentifier,
|
|
title: cal.title,
|
|
type: cal.type,
|
|
color: hexColor(from: cal.cgColor),
|
|
isSubscribed: cal.isSubscribed,
|
|
isImmutable: cal.isImmutable,
|
|
isReadOnly: !cal.allowsContentModifications,
|
|
source: cal.source?.title ?? ""
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Fetch events modified on or after `since`, up to `lookaheadDays` into the future
|
|
/// and `lookbackDays` into the past (both from now, not from `since`).
|
|
public func fetchEvents(
|
|
since: Date? = nil,
|
|
lookbackDays: Int = 30,
|
|
lookaheadDays: Int = 365
|
|
) -> [EventEntry] {
|
|
let now = Date()
|
|
let start = Calendar.current.date(byAdding: .day, value: -lookbackDays, to: now)!
|
|
let end = Calendar.current.date(byAdding: .day, value: lookaheadDays, to: now)!
|
|
|
|
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
|
|
let events = store.events(matching: predicate)
|
|
NSLog("CalendarReader: fetchEvents raw=\(events.count) window=[\(lookbackDays)d ago … \(lookaheadDays)d ahead]")
|
|
|
|
let filtered: [EKEvent]
|
|
if let since {
|
|
filtered = events.filter { event in
|
|
(event.lastModifiedDate ?? event.creationDate ?? event.startDate) >= since
|
|
}
|
|
} else {
|
|
filtered = events
|
|
}
|
|
|
|
NSLog("CalendarReader: fetchEvents filtered=\(filtered.count)")
|
|
return filtered.map { mapEvent($0) }
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func mapEvent(_ event: EKEvent) -> EventEntry {
|
|
let recurrenceRule: String? = event.recurrenceRules?.first.map { rule in
|
|
// Encode as a human-readable string — server stores it as-is for display
|
|
rule.frequency.description + (rule.recurrenceEnd != nil ? " (ends)" : " (repeats)")
|
|
}
|
|
|
|
return EventEntry(
|
|
eventIdentifier: event.eventIdentifier ?? UUID().uuidString,
|
|
calendarIdentifier: event.calendar?.calendarIdentifier ?? "",
|
|
title: event.title ?? "(no title)",
|
|
notes: event.notes,
|
|
location: event.location,
|
|
url: event.url?.absoluteString,
|
|
isAllDay: event.isAllDay,
|
|
startDate: event.startDate,
|
|
endDate: event.endDate,
|
|
creationDate: event.creationDate,
|
|
lastModifiedDate: event.lastModifiedDate,
|
|
status: event.status,
|
|
availability: event.availability,
|
|
isDetached: event.isDetached,
|
|
hasAlarms: !(event.alarms?.isEmpty ?? true),
|
|
hasRecurrenceRules: !(event.recurrenceRules?.isEmpty ?? true),
|
|
recurrenceRule: recurrenceRule,
|
|
organizerEmail: event.organizer?.url.absoluteString.replacingOccurrences(of: "mailto:", with: ""),
|
|
organizerName: event.organizer?.name,
|
|
attendeeCount: event.attendees?.count ?? 0
|
|
)
|
|
}
|
|
|
|
private func hexColor(from cgColor: CGColor?) -> String {
|
|
guard let cgColor, let components = cgColor.components, components.count >= 3 else {
|
|
return "#888888"
|
|
}
|
|
let r = Int(components[0] * 255)
|
|
let g = Int(components[1] * 255)
|
|
let b = Int(components[2] * 255)
|
|
return String(format: "#%02X%02X%02X", r, g, b)
|
|
}
|
|
}
|
|
|
|
// MARK: - EKRecurrenceFrequency description
|
|
|
|
private extension EKRecurrenceFrequency {
|
|
var description: String {
|
|
switch self {
|
|
case .daily: return "daily"
|
|
case .weekly: return "weekly"
|
|
case .monthly: return "monthly"
|
|
case .yearly: return "yearly"
|
|
@unknown default: return "unknown"
|
|
}
|
|
}
|
|
}
|