Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 107 additions & 155 deletions Sources/reminders-api/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Expand All @@ -447,6 +453,7 @@ func startServer(
notes: updateRequest.notes,
dueDateString: updateRequest.dueDate,
priority: updateRequest.priority,
isCompleted: updateRequest.isCompleted,
remindersService: remindersService
)

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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<EKReminder?, Never>) 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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1571,4 +1523,4 @@ func priorityFromRawValue(_ value: Int) -> Priority? {
}

// Run the Configuration command defined above
Configuration.main()
Configuration.main()