254 lines
8.4 KiB
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)
|
|
}
|
|
}
|