diff --git a/Sources/reminders-api/main.swift b/Sources/reminders-api/main.swift index 0c2e136..76871a7 100644 --- a/Sources/reminders-api/main.swift +++ b/Sources/reminders-api/main.swift @@ -158,6 +158,11 @@ func startServer( app.encoder = jsonEncoder Logger.shared.debug("JSON encoder configured") + // Middleware for JSON response decoding + let jsonDecoder = JSONDecoder() + app.decoder = jsonDecoder + Logger.shared.debug("JSON decoder configured") + // Add CORS middleware app.middleware.add( HBCORSMiddleware( @@ -287,8 +292,8 @@ func startServer( } // GET /lists/:name - Get reminders from a specific list - app.router.get("lists/:name") { request -> HBResponse in - guard let listName = request.parameters.get("name") else { + app.router.get("lists/:listName") { request -> HBResponse in + guard let listName = request.parameters.get("listName") else { Logger.shared.warn("Missing list name parameter in request") throw HBHTTPError(.badRequest, message: "Missing list name") } @@ -324,9 +329,9 @@ func startServer( } // POST /lists/:name/reminders - Add a new reminder to a list - app.router.post("lists/:name/reminders") { request -> HBResponse in + app.router.post("lists/:listName/reminders") { request -> HBResponse in - guard let listName = request.parameters.get("name") else { + guard let listName = request.parameters.get("listName") else { Logger.shared.warn("Missing list name parameter in POST request") throw HBHTTPError(.badRequest, message: "Missing list name") } @@ -436,6 +441,7 @@ func startServer( let notes: String? let dueDate: String? let priority: String? + let isCompleted: Bool? } let updateRequest = try request.decode(as: ReminderUpdateRequest.self) @@ -447,6 +453,7 @@ func startServer( notes: updateRequest.notes, dueDateString: updateRequest.dueDate, priority: updateRequest.priority, + isCompleted: updateRequest.isCompleted, remindersService: remindersService ) @@ -987,7 +994,7 @@ private func mapLogLevelToSwiftLogger(_ level: RemindersLibrary.LogLevel) -> Log func fetchReminders(from listName: String, displayOptions: DisplayOptions, remindersService: Reminders) async throws -> [EKReminder] { return try await withCheckedThrowingContinuation { continuation in do { - let calendar = try remindersService.calendar(withName: listName) + let calendar = try resolveListCalendar(identifier: listName, remindersService: remindersService) remindersService.reminders(on: [calendar], displayOptions: displayOptions) { reminders in continuation.resume(returning: reminders) @@ -1014,7 +1021,7 @@ func addReminder(title: String, notes: String?, listName: String, dueDateCompone priority: Priority, remindersService: Reminders) async throws -> EKReminder { return try await withCheckedThrowingContinuation { continuation in do { - let calendar = try remindersService.calendar(withName: listName) + let calendar = try resolveListCalendar(identifier: listName, remindersService: remindersService) let reminder = try remindersService.createReminder( title: title, notes: notes, @@ -1030,173 +1037,93 @@ func addReminder(title: String, notes: String?, listName: String, dueDateCompone } } -// Helper function to delete a reminder -func deleteReminder(id: String, listName: String, remindersService: Reminders) async throws { - return try await withCheckedThrowingContinuation { continuation in - do { - let calendar = try remindersService.calendar(withName: listName) - - // Handle both formats (with and without the protocol prefix) - let prefix = "x-apple-reminder://" - let fullId = id.hasPrefix(prefix) ? id : "\(prefix)\(id)" +// Resolve a reminder by the identifier the API exposes as `uuid` / `externalId` +// (getReminderByUUID), falling back to the legacy calendarItemExternalIdentifier +// scan within the named list. Throws a 404 if nothing matches. Shared by the +// list-scoped delete / complete / update handlers so they all accept the same +// id that GET returns -- the previous external-identifier-only match never lined +// up with the exposed uuid, which is why those endpoints 404'd. +func resolveReminder(id: String, listName: String, remindersService: Reminders) async throws -> EKReminder { + // 1. Canonical UUID lookup (matches the /reminders/:uuid routes). + if let reminder = remindersService.getReminderByUUID(id) { + return reminder + } - remindersService.reminders(on: [calendar], displayOptions: .all) { reminders in - // Try with the fully qualified ID first - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == fullId }) { - do { - try remindersService.deleteReminder(reminder) - continuation.resume() - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } + // 2. Legacy fallback: scan the named list by external identifier. + let calendar = try resolveListCalendar(identifier: listName, remindersService: remindersService) + let prefix = "x-apple-reminder://" + let fullId = id.hasPrefix(prefix) ? id : "\(prefix)\(id)" + let match = await withCheckedContinuation { (continuation: CheckedContinuation) in + remindersService.reminders(on: [calendar], displayOptions: .all) { reminders in + let found = reminders.first(where: { $0.calendarItemExternalIdentifier == fullId }) + ?? reminders.first(where: { $0.calendarItemExternalIdentifier == id }) + continuation.resume(returning: found) + } + } - // For backward compatibility, try with the original ID string - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == id }) { - do { - try remindersService.deleteReminder(reminder) - continuation.resume() - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } + guard let reminder = match else { + throw HBHTTPError(.notFound, message: "Reminder not found") + } + return reminder +} - continuation.resume(throwing: HBHTTPError(.notFound, message: "Reminder not found")) - } - } catch { - continuation.resume(throwing: error) - } +// Helper function to delete a reminder +func deleteReminder(id: String, listName: String, remindersService: Reminders) async throws { + let reminder = try await resolveReminder(id: id, listName: listName, remindersService: remindersService) + do { + try remindersService.deleteReminder(reminder) + } catch { + throw HBHTTPError(.internalServerError, message: error.localizedDescription) } } // Helper function to mark a reminder as complete or incomplete func setReminderComplete(id: String, listName: String, complete: Bool, remindersService: Reminders) async throws { - return try await withCheckedThrowingContinuation { continuation in - do { - let calendar = try remindersService.calendar(withName: listName) - - // Handle both formats (with and without the protocol prefix) - let prefix = "x-apple-reminder://" - let fullId = id.hasPrefix(prefix) ? id : "\(prefix)\(id)" - - remindersService.reminders(on: [calendar], displayOptions: .all) { reminders in - // Try with the fully qualified ID first - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == fullId }) { - do { - try remindersService.setReminderComplete(reminder, complete: complete) - continuation.resume() - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } - - // For backward compatibility, try with the original ID string - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == id }) { - do { - try remindersService.setReminderComplete(reminder, complete: complete) - continuation.resume() - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } - - continuation.resume(throwing: HBHTTPError(.notFound, message: "Reminder not found")) - } - } catch { - continuation.resume(throwing: error) - } + let reminder = try await resolveReminder(id: id, listName: listName, remindersService: remindersService) + do { + try remindersService.setReminderComplete(reminder, complete: complete) + } catch { + throw HBHTTPError(.internalServerError, message: error.localizedDescription) } } // Helper function to update a reminder func updateReminder(id: String, listName: String, title: String?, notes: String?, - dueDateString: String?, priority: String?, remindersService: Reminders) async throws -> EKReminder { - return try await withCheckedThrowingContinuation { continuation in - do { - let calendar = try remindersService.calendar(withName: listName) - - // Handle both formats (with and without the protocol prefix) - let prefix = "x-apple-reminder://" - let fullId = id.hasPrefix(prefix) ? id : "\(prefix)\(id)" + dueDateString: String?, priority: String?, isCompleted: Bool?, + remindersService: Reminders) async throws -> EKReminder { + let reminder = try await resolveReminder(id: id, listName: listName, remindersService: remindersService) - remindersService.reminders(on: [calendar], displayOptions: .all) { reminders in - // Try with the fully qualified ID first - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == fullId }) { - // Update fields if provided - if let title = title { - reminder.title = title - } - - if let notes = notes { - reminder.notes = notes - } - - if let dueDateString = dueDateString { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: dueDateString) { - reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) - } - } - - if let priorityString = priority { - if let priority = Priority(rawValue: priorityString) { - reminder.priority = Int(priority.value.rawValue) - } - } - - do { - try remindersService.updateReminder(reminder) - continuation.resume(returning: reminder) - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } - - // For backward compatibility, try with the original ID string - if let reminder = reminders.first(where: { $0.calendarItemExternalIdentifier == id }) { - // Update fields if provided - if let title = title { - reminder.title = title - } + // Apply the provided fields. + if let title = title { + reminder.title = title + } - if let notes = notes { - reminder.notes = notes - } + if let notes = notes { + reminder.notes = notes + } - if let dueDateString = dueDateString { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: dueDateString) { - reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) - } - } + if let dueDateString = dueDateString { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: dueDateString) { + reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) + } + } - if let priorityString = priority { - if let priority = Priority(rawValue: priorityString) { - reminder.priority = Int(priority.value.rawValue) - } - } + if let priorityString = priority, let priority = Priority(rawValue: priorityString) { + reminder.priority = Int(priority.value.rawValue) + } - do { - try remindersService.updateReminder(reminder) - continuation.resume(returning: reminder) - } catch { - continuation.resume(throwing: HBHTTPError(.internalServerError, message: error.localizedDescription)) - } - return - } + if let isCompleted = isCompleted { + reminder.isCompleted = isCompleted + } - continuation.resume(throwing: HBHTTPError(.notFound, message: "Reminder not found")) - } - } catch { - continuation.resume(throwing: error) - } + // Persist the change. + do { + try remindersService.updateReminder(reminder) + return reminder + } catch { + throw HBHTTPError(.internalServerError, message: error.localizedDescription) } } @@ -1309,6 +1236,31 @@ func parseSearchParameters(_ request: HBRequest) -> SearchParameters { ) } +// Resolve a reminder list (calendar) by either its UUID or its name. +// +// Order matters: we try the EventKit calendar identifier (UUID) first, then +// fall back to a case-insensitive title match. The name match is done against +// getCalendars() directly rather than via `calendar(withName:)`, because that +// shared-library call aborts the whole process (exit/precondition) when no list +// matches. Here a miss simply throws a 404, so an unknown or mistyped +// identifier can never take the server down. +func resolveListCalendar(identifier: String, remindersService: Reminders) throws -> EKCalendar { + // 1. Try as a UUID (EventKit calendarIdentifier). + if let calendar = remindersService.calendar(withUUID: identifier) { + return calendar + } + + // 2. Fall back to a case-insensitive list-name match. + if let calendar = remindersService.getCalendars().first(where: { + $0.title.caseInsensitiveCompare(identifier) == .orderedSame + }) { + return calendar + } + + // 3. Neither a known UUID nor a known name. + throw HBHTTPError(.notFound, message: "List '\(identifier)' not found") +} + // Helper function to resolve calendar by name or UUID func resolveCalendar(identifier: String, remindersService: Reminders) throws -> EKCalendar? { // First try as UUID @@ -1571,4 +1523,4 @@ func priorityFromRawValue(_ value: Int) -> Priority? { } // Run the Configuration command defined above -Configuration.main() +Configuration.main() \ No newline at end of file