macsync/@packages/ical/Sources/ICalSync/Reader.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"
}
}
}