238 lines
7.8 KiB
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)
|
|
}
|
|
}
|