macsync/@packages/ireminders/Sources/IReminderSync/Sender.swift

238 lines
7.8 KiB
Swift

import EventKit
import Foundation
import LilithLogging
import MacSyncShared
private let log = AppLogger.logger(for: "IReminder.Sender")
// MARK: - Wire types
public struct ReminderSendPayload: Sendable, Decodable, Equatable {
public let reminderIdentifier: String?
public let calendarIdentifier: String?
public let title: String?
public let notes: String?
public let dueDate: String?
public let priority: Int?
public let isCompleted: Bool?
public init(
reminderIdentifier: String? = nil,
calendarIdentifier: String? = nil,
title: String? = nil,
notes: String? = nil,
dueDate: String? = nil,
priority: Int? = nil,
isCompleted: Bool? = nil
) {
self.reminderIdentifier = reminderIdentifier
self.calendarIdentifier = calendarIdentifier
self.title = title
self.notes = notes
self.dueDate = dueDate
self.priority = priority
self.isCompleted = isCompleted
}
}
public struct PendingReminderSend: Sendable, Decodable, Equatable {
public let id: String
public let action: String
public let payload: ReminderSendPayload
public let createdAt: String
public init(id: String, action: String, payload: ReminderSendPayload, 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 `ReminderSender` calls into so tests can substitute a
/// fake that does not require EventKit authorization.
@MainActor
public protocol ReminderApplying {
func calendar(withIdentifier id: String) -> EKCalendar?
func reminder(withIdentifier id: String) -> EKReminder?
func makeReminder() -> EKReminder
func save(_ reminder: EKReminder) throws
func remove(_ reminder: EKReminder) throws
}
/// Default `ReminderApplying` backed by a real `EKEventStore`.
@MainActor
public final class EKReminderStoreApplier: ReminderApplying {
private let store: EKEventStore
public init(eventStore: EKEventStore) { self.store = eventStore }
public func calendar(withIdentifier id: String) -> EKCalendar? {
store.calendar(withIdentifier: id)
}
public func reminder(withIdentifier id: String) -> EKReminder? {
store.calendarItem(withIdentifier: id) as? EKReminder
}
public func makeReminder() -> EKReminder {
EKReminder(eventStore: store)
}
public func save(_ reminder: EKReminder) throws {
try store.save(reminder, commit: true)
}
public func remove(_ reminder: EKReminder) throws {
try store.remove(reminder, commit: true)
}
}
// MARK: - Sender
/// Applies pending reminder send-queue items against the local EventKit store.
@MainActor
public final class ReminderSender {
private let applier: any ReminderApplying
private let iso: ISO8601DateFormatter
public init(applier: any ReminderApplying) {
self.applier = applier
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.iso = f
}
public convenience init(eventStore: EKEventStore) {
self.init(applier: EKReminderStoreApplier(eventStore: eventStore))
}
public func apply(_ item: PendingReminderSend) async -> SendQueueApplyResult {
log.info("apply id=\(item.id) action=\(item.action)")
switch item.action {
case "create_reminder": return applyCreate(item.payload)
case "update_reminder": return applyUpdate(item.payload)
case "delete_reminder": return applyDelete(item.payload)
default:
return .failed(reason: "unknown action: \(item.action)")
}
}
// MARK: - create
private func applyCreate(_ p: ReminderSendPayload) -> 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 calendar = applier.calendar(withIdentifier: calId) else {
return .failed(reason: "calendar not found: \(calId)")
}
let reminder = applier.makeReminder()
reminder.calendar = calendar
reminder.title = title
if let notes = p.notes { reminder.notes = notes }
if let prio = p.priority { reminder.priority = prio }
if let comp = p.isCompleted { reminder.isCompleted = comp }
if let dueStr = p.dueDate {
guard let due = parseDate(dueStr) else {
return .failed(reason: "invalid dueDate")
}
reminder.dueDateComponents = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: due
)
}
do {
try applier.save(reminder)
return .sent
} catch {
return .failed(reason: "save failed: \(error.localizedDescription)")
}
}
// MARK: - update
private func applyUpdate(_ p: ReminderSendPayload) -> SendQueueApplyResult {
guard let reminderId = p.reminderIdentifier, !reminderId.isEmpty else {
return .failed(reason: "missing reminderIdentifier")
}
guard let reminder = applier.reminder(withIdentifier: reminderId) else {
return .failed(reason: "reminder not found: \(reminderId)")
}
if let title = p.title { reminder.title = title }
if let notes = p.notes { reminder.notes = notes }
if let prio = p.priority { reminder.priority = prio }
if let comp = p.isCompleted { reminder.isCompleted = comp }
if let dueStr = p.dueDate {
guard let due = parseDate(dueStr) else {
return .failed(reason: "invalid dueDate")
}
reminder.dueDateComponents = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: due
)
}
do {
try applier.save(reminder)
return .sent
} catch {
return .failed(reason: "save failed: \(error.localizedDescription)")
}
}
// MARK: - delete
private func applyDelete(_ p: ReminderSendPayload) -> SendQueueApplyResult {
guard let reminderId = p.reminderIdentifier, !reminderId.isEmpty else {
return .failed(reason: "missing reminderIdentifier")
}
guard let reminder = applier.reminder(withIdentifier: reminderId) else {
return .failed(reason: "reminder not found: \(reminderId)")
}
do {
try applier.remove(reminder)
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 ReminderSendTransport: SendQueueTransport {
public typealias PendingItem = PendingReminderSend
private let apiClient: any IReminderAPIClientProtocol
public init(apiClient: any IReminderAPIClientProtocol) {
self.apiClient = apiClient
}
public func id(of item: PendingReminderSend) -> String { item.id }
public func fetchPending() async throws -> [PendingReminderSend] {
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)
}
}