macsync/@packages/ical/Sources/ICalSync/Sender.swift

254 lines
8.4 KiB
Swift

import EventKit
import Foundation
import LilithLogging
import MacSyncShared
private let log = AppLogger.logger(for: "ICal.Sender")
// MARK: - Wire types
public struct CalendarSendPayload: Sendable, Decodable, Equatable {
public let eventIdentifier: String?
public let calendarIdentifier: String?
public let title: String?
public let notes: String?
public let location: String?
public let startDate: String?
public let endDate: String?
public let isAllDay: Bool?
public let url: String?
public init(
eventIdentifier: String? = nil,
calendarIdentifier: String? = nil,
title: String? = nil,
notes: String? = nil,
location: String? = nil,
startDate: String? = nil,
endDate: String? = nil,
isAllDay: Bool? = nil,
url: String? = nil
) {
self.eventIdentifier = eventIdentifier
self.calendarIdentifier = calendarIdentifier
self.title = title
self.notes = notes
self.location = location
self.startDate = startDate
self.endDate = endDate
self.isAllDay = isAllDay
self.url = url
}
}
public struct PendingCalendarSend: Sendable, Decodable, Equatable {
public let id: String
public let action: String
public let payload: CalendarSendPayload
public let createdAt: String
public init(id: String, action: String, payload: CalendarSendPayload, createdAt: String) {
self.id = id
self.action = action
self.payload = payload
self.createdAt = createdAt
}
}
// MARK: - Applier protocol (allows hermetic tests without touching EKEventStore)
/// Narrow protocol the `CalendarSender` calls into so tests can substitute a
/// fake that does not require EventKit authorization.
@MainActor
public protocol CalendarEventApplying {
func calendar(withIdentifier id: String) -> EKCalendar?
func event(withIdentifier id: String) -> EKEvent?
func makeEvent() -> EKEvent
func save(_ event: EKEvent, span: EKSpan) throws
func remove(_ event: EKEvent, span: EKSpan) throws
}
/// Default `CalendarEventApplying` backed by a real `EKEventStore`.
@MainActor
public final class EKEventStoreApplier: CalendarEventApplying {
private let store: EKEventStore
public init(eventStore: EKEventStore) { self.store = eventStore }
public func calendar(withIdentifier id: String) -> EKCalendar? {
store.calendar(withIdentifier: id)
}
public func event(withIdentifier id: String) -> EKEvent? {
store.event(withIdentifier: id)
}
public func makeEvent() -> EKEvent {
EKEvent(eventStore: store)
}
public func save(_ event: EKEvent, span: EKSpan) throws {
try store.save(event, span: span, commit: true)
}
public func remove(_ event: EKEvent, span: EKSpan) throws {
try store.remove(event, span: span, commit: true)
}
}
// MARK: - Sender
/// Applies pending calendar send-queue items against the local EventKit store.
@MainActor
public final class CalendarSender {
private let applier: any CalendarEventApplying
private let iso: ISO8601DateFormatter
public init(applier: any CalendarEventApplying) {
self.applier = applier
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.iso = f
}
public convenience init(eventStore: EKEventStore) {
self.init(applier: EKEventStoreApplier(eventStore: eventStore))
}
public func apply(_ item: PendingCalendarSend) async -> SendQueueApplyResult {
log.info("apply id=\(item.id) action=\(item.action)")
switch item.action {
case "create_event": return applyCreate(item.payload)
case "update_event": return applyUpdate(item.payload)
case "delete_event": return applyDelete(item.payload)
default:
return .failed(reason: "unknown action: \(item.action)")
}
}
// MARK: - create
private func applyCreate(_ p: CalendarSendPayload) -> SendQueueApplyResult {
guard let calId = p.calendarIdentifier, !calId.isEmpty else {
return .failed(reason: "missing calendarIdentifier")
}
guard let title = p.title, !title.isEmpty else {
return .failed(reason: "missing title")
}
guard let startStr = p.startDate, !startStr.isEmpty else {
return .failed(reason: "missing startDate")
}
guard let endStr = p.endDate, !endStr.isEmpty else {
return .failed(reason: "missing endDate")
}
guard let start = parseDate(startStr) else {
return .failed(reason: "invalid startDate")
}
guard let end = parseDate(endStr) else {
return .failed(reason: "invalid endDate")
}
guard let calendar = applier.calendar(withIdentifier: calId) else {
return .failed(reason: "calendar not found: \(calId)")
}
let event = applier.makeEvent()
event.calendar = calendar
event.title = title
event.startDate = start
event.endDate = end
if let notes = p.notes { event.notes = notes }
if let location = p.location { event.location = location }
if let isAllDay = p.isAllDay { event.isAllDay = isAllDay }
if let urlStr = p.url, let url = URL(string: urlStr) { event.url = url }
do {
try applier.save(event, span: .thisEvent)
return .sent
} catch {
return .failed(reason: "save failed: \(error.localizedDescription)")
}
}
// MARK: - update
private func applyUpdate(_ p: CalendarSendPayload) -> SendQueueApplyResult {
guard let eventId = p.eventIdentifier, !eventId.isEmpty else {
return .failed(reason: "missing eventIdentifier")
}
guard let event = applier.event(withIdentifier: eventId) else {
return .failed(reason: "event not found: \(eventId)")
}
if let title = p.title { event.title = title }
if let notes = p.notes { event.notes = notes }
if let location = p.location { event.location = location }
if let isAllDay = p.isAllDay { event.isAllDay = isAllDay }
if let urlStr = p.url, let url = URL(string: urlStr) { event.url = url }
if let startStr = p.startDate {
guard let start = parseDate(startStr) else {
return .failed(reason: "invalid startDate")
}
event.startDate = start
}
if let endStr = p.endDate {
guard let end = parseDate(endStr) else {
return .failed(reason: "invalid endDate")
}
event.endDate = end
}
do {
try applier.save(event, span: .thisEvent)
return .sent
} catch {
return .failed(reason: "save failed: \(error.localizedDescription)")
}
}
// MARK: - delete
private func applyDelete(_ p: CalendarSendPayload) -> SendQueueApplyResult {
guard let eventId = p.eventIdentifier, !eventId.isEmpty else {
return .failed(reason: "missing eventIdentifier")
}
guard let event = applier.event(withIdentifier: eventId) else {
return .failed(reason: "event not found: \(eventId)")
}
do {
try applier.remove(event, span: .thisEvent)
return .sent
} catch {
return .failed(reason: "remove failed: \(error.localizedDescription)")
}
}
// MARK: - date parsing
/// Parse ISO8601 with or without fractional seconds.
private func parseDate(_ s: String) -> Date? {
if let d = iso.date(from: s) { return d }
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f.date(from: s)
}
}
// MARK: - SendQueueTransport adapter
public struct CalendarSendTransport: SendQueueTransport {
public typealias PendingItem = PendingCalendarSend
private let apiClient: any ICalAPIClientProtocol
public init(apiClient: any ICalAPIClientProtocol) {
self.apiClient = apiClient
}
public func id(of item: PendingCalendarSend) -> String { item.id }
public func fetchPending() async throws -> [PendingCalendarSend] {
try await apiClient.getPendingSends()
}
public func reportResult(id: String, status: String, error: String?) async throws {
try await apiClient.reportSendResult(id: id, status: status, error: error)
}
}