From 998234b8b742430fbb723f2d984d1133c2814a08 Mon Sep 17 00:00:00 2001 From: Bill Cromie Date: Fri, 25 Oct 2024 13:59:47 -0400 Subject: [PATCH 1/9] feat: output unique list identifiers via show-lists Adds calendarIdentifier alongside each list's title in 'show-lists' output (both plain and JSON), so lists can be referenced by a stable ID. Cherry-picked from keith/reminders-cli#90, commit a4941436 ("now we are outputting unique ids for lists"). --- Sources/RemindersLibrary/CLI.swift | 54 ++++++------ .../CollectionType+Extension.swift | 6 -- .../EKCalendar+Encodable.swift | 14 +++ .../EKReminder+Encodable.swift | 4 +- Sources/RemindersLibrary/Reminders.swift | 87 +++++++++---------- 5 files changed, 83 insertions(+), 82 deletions(-) create mode 100644 Sources/RemindersLibrary/EKCalendar+Encodable.swift diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index 62cc9fb..f195a58 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -96,9 +96,9 @@ private struct Show: ParsableCommand { abstract: "Print the items on the given list") @Argument( - help: "The list to print items from, see 'show-lists' for names", + help: "The list to print items from, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Flag(help: "Show completed items only") var onlyCompleted = false @@ -170,7 +170,7 @@ private struct Show: ParsableCommand { } reminders.showListItems( - withName: self.listName, dueOn: self.dueDate, includeOverdue: self.includeOverdue, + withNameOrId: self.listNameOrId, dueOn: self.dueDate, includeOverdue: self.includeOverdue, overdue: self.overdue, dueBefore: self.dueBefore, dueAfter: self.dueAfter, noDueDate: self.noDueDate, priorities: self.priority, search: self.search, displayOptions: displayOptions, outputFormat: format, sort: sort, sortOrder: sortOrder) @@ -182,9 +182,9 @@ private struct Add: ParsableCommand { abstract: "Add a reminder to a list") @Argument( - help: "The list to add to, see 'show-lists' for names", + help: "The list to add to, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( parsing: .remaining, @@ -255,7 +255,7 @@ private struct Add: ParsableCommand { reminders.addReminder( string: self.reminder.joined(separator: " "), notes: self.notes, - toListNamed: self.listName, + toListNamed: self.listNameOrId, dueDateComponents: self.dueDate, priority: priority, recurrence: self.repeat_, @@ -270,16 +270,16 @@ private struct Complete: ParsableCommand { abstract: "Complete a reminder") @Argument( - help: "The list to complete a reminder on, see 'show-lists' for names", + help: "The list to complete a reminder on, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes") - var index: String + help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") + var indexOrId: String func run() { - reminders.setComplete(true, itemAtIndex: self.index, onListNamed: self.listName) + reminders.setComplete(true, itemAtIndexOrId: self.indexOrId, onListNamedOrId: self.listNameOrId) } } @@ -288,16 +288,16 @@ private struct Uncomplete: ParsableCommand { abstract: "Uncomplete a reminder") @Argument( - help: "The list to uncomplete a reminder on, see 'show-lists' for names", + help: "The list to uncomplete a reminder on, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes") - var index: String + help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") + var indexOrId: String func run() { - reminders.setComplete(false, itemAtIndex: self.index, onListNamed: self.listName) + reminders.setComplete(false, itemAtIndexOrId: self.indexOrId, onListNamedOrId: self.listNameOrId) } } @@ -306,16 +306,16 @@ private struct Delete: ParsableCommand { abstract: "Delete a reminder") @Argument( - help: "The list to delete a reminder on, see 'show-lists' for names", + help: "The list to delete a reminder on, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes") - var index: String + help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") + var indexOrId: String func run() { - reminders.delete(itemAtIndex: self.index, onListNamed: self.listName) + reminders.delete(itemAtIndexOrId: self.indexOrId, onListNamedOrId: self.listNameOrId) } } @@ -330,13 +330,13 @@ private struct Edit: ParsableCommand { abstract: "Edit the text of a reminder") @Argument( - help: "The list to edit a reminder on, see 'show-lists' for names", + help: "The list to edit a reminder on, see 'show-lists' for names or IDs", completion: .custom(listNameCompletion)) - var listName: String + var listNameOrId: String @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes") - var index: String + help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") + var indexOrId: String @Option( name: .shortAndLong, @@ -437,8 +437,8 @@ private struct Edit: ParsableCommand { func run() { let newText = self.reminder.joined(separator: " ") reminders.edit( - itemAtIndex: self.index, - onListNamed: self.listName, + itemAtIndexOrId: self.indexOrId, + onListNamedOrId: self.listNameOrId, newText: newText.isEmpty ? nil : newText, newNotes: self.notes, newDueDateComponents: self.dueDate, diff --git a/Sources/RemindersLibrary/CollectionType+Extension.swift b/Sources/RemindersLibrary/CollectionType+Extension.swift index b1508eb..b369074 100644 --- a/Sources/RemindersLibrary/CollectionType+Extension.swift +++ b/Sources/RemindersLibrary/CollectionType+Extension.swift @@ -1,9 +1,3 @@ -extension Collection { - func find(where predicate: (Iterator.Element) throws -> Bool) rethrows -> Iterator.Element? { - return try self.firstIndex(where: predicate).flatMap { self[$0] } - } -} - extension Collection where Index == Int { subscript(safe index: Int) -> Iterator.Element? { return index < self.count && index >= 0 ? self[index] : nil diff --git a/Sources/RemindersLibrary/EKCalendar+Encodable.swift b/Sources/RemindersLibrary/EKCalendar+Encodable.swift new file mode 100644 index 0000000..1d6a227 --- /dev/null +++ b/Sources/RemindersLibrary/EKCalendar+Encodable.swift @@ -0,0 +1,14 @@ +import EventKit + +extension EKCalendar: @retroactive Encodable { + private enum EncodingKeys: String, CodingKey { + case title + case calendarIdentifier + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: EncodingKeys.self) + try container.encode(self.title, forKey: .title) + try container.encode(self.calendarIdentifier, forKey: .calendarIdentifier) + } +} diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index 9ea6c36..e2717f4 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -32,7 +32,7 @@ extension EKReminder: @retroactive Encodable { try container.encode(self.priority, forKey: .priority) try container.encode(self.calendar.title, forKey: .list) try container.encodeIfPresent(self.notes, forKey: .notes) - + // url field is nil // https://developer.apple.com/forums/thread/128140 try container.encodeIfPresent(self.url, forKey: .url) @@ -87,7 +87,7 @@ extension EKReminder: @retroactive Encodable { @unknown default: return nil } } - + private func format(_ date: Date?) -> String? { if #available(macOS 12.0, *) { return date?.ISO8601Format() diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 57ad355..f355388 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -451,12 +451,13 @@ public final class Reminders { } func showLists(outputFormat: OutputFormat) { + let calendars = self.getCalendars() switch (outputFormat) { case .json: - print(encodeToJson(data: self.getListNames())) + print(encodeToJson(data: calendars)) default: - for name in self.getListNames() { - print(name) + for calendar in calendars { + print("\(calendar.title) (\(calendar.calendarIdentifier))") } } } @@ -476,9 +477,9 @@ public final class Reminders { // lower bound. let dueBeforeDate = dueBefore.flatMap { recurrenceEndDate(from: $0) } let dueAfterDate = dueAfter?.date - // Resolving --list up front means an unknown list name hard-errors via - // calendar(withName:)'s existing exit(1) before any reminders are fetched. - let calendars = lists.isEmpty ? self.getCalendars() : lists.map { self.calendar(withName: $0) } + // Resolving --list up front means an unknown list name or ID hard-errors via + // calendar(withNameOrId:)'s existing exit(1) before any reminders are fetched. + let calendars = lists.isEmpty ? self.getCalendars() : lists.map { self.calendar(withNameOrId: $0) } self.reminders(on: calendars, displayOptions: displayOptions) { reminders in var matchingReminders = [(EKReminder, Int, String)]() @@ -528,18 +529,19 @@ public final class Reminders { } func showListItems( - withName name: String, dueOn dueDate: DateComponents?, includeOverdue: Bool, + withNameOrId nameOrId: String, dueOn dueDate: DateComponents?, includeOverdue: Bool, overdue: Bool = false, dueBefore: DateComponents? = nil, dueAfter: DateComponents? = nil, noDueDate: Bool = false, priorities: [Priority] = [], search: String? = nil, displayOptions: DisplayOptions, outputFormat: OutputFormat, sort: Sort, sortOrder: CustomSortOrder) { + let reminderCalendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) let calendar = Calendar.current let now = Date() let dueBeforeDate = dueBefore.flatMap { recurrenceEndDate(from: $0) } let dueAfterDate = dueAfter?.date - self.reminders(on: [self.calendar(withName: name)], displayOptions: displayOptions) { reminders in + self.reminders(on: [reminderCalendar], displayOptions: displayOptions) { reminders in var matchingReminders = [(EKReminder, Int?)]() let reminders = sort == .none ? reminders : reminders.sorted(by: sort.sortFunction(order: sortOrder)) for (i, reminder) in reminders.enumerated() { @@ -629,8 +631,8 @@ public final class Reminders { } func edit( - itemAtIndex index: String, - onListNamed name: String, + itemAtIndexOrId indexOrId: String, + onListNamedOrId nameOrId: String, newText: String?, newNotes: String?, newDueDateComponents: DateComponents? = nil, @@ -643,7 +645,7 @@ public final class Reminders { clearRecurrenceEnd: Bool, clearRecurrence: Bool) { - let calendar = self.calendar(withName: name) + let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) let dueDateChangeRequested = clearDueDate || newDueDateComponents != nil let recurrenceChangeRequested = clearRecurrence || newRecurrence != nil @@ -651,8 +653,8 @@ public final class Reminders { || clearRecurrenceEnd self.reminders(on: [calendar], displayOptions: .incomplete) { reminders in - guard let reminder = self.getReminder(from: reminders, at: index) else { - print("No reminder at index \(index) on \(name)") + guard let reminder = self.getReminder(from: reminders, atIndexOrId: indexOrId) else { + print("No reminder at index or with ID \(indexOrId) on \(nameOrId)") exit(1) } @@ -666,7 +668,7 @@ public final class Reminders { } if let newListName { - reminder.calendar = self.calendar(withName: newListName) + reminder.calendar = self.calendar(withNameOrId: newListName) } if clearDueDate { @@ -746,16 +748,16 @@ public final class Reminders { semaphore.wait() } - func setComplete(_ complete: Bool, itemAtIndex index: String, onListNamed name: String) { - let calendar = self.calendar(withName: name) + func setComplete(_ complete: Bool, itemAtIndexOrId indexOrId: String, onListNamedOrId nameOrId: String) { + let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) let displayOptions = complete ? DisplayOptions.incomplete : .complete let action = complete ? "Completed" : "Uncompleted" self.reminders(on: [calendar], displayOptions: displayOptions) { reminders in print(reminders.map { $0.title! }) - guard let reminder = self.getReminder(from: reminders, at: index) else { - print("No reminder at index \(index) on \(name)") + guard let reminder = self.getReminder(from: reminders, atIndexOrId: indexOrId) else { + print("No reminder at index or with ID \(indexOrId) on \(nameOrId)") exit(1) } @@ -774,8 +776,8 @@ public final class Reminders { semaphore.wait() } - func delete(itemAtIndex index: String, onListNamed name: String) { - let calendar = self.calendar(withName: name) + func delete(itemAtIndexOrId indexOrId: String, onListNamedOrId nameOrId: String) { + let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) // Numeric indexes are only meaningful against the same display set that @@ -786,11 +788,11 @@ public final class Reminders { // completion state, so widen the fetch to `.all` in that case, so a // reminder already marked complete can still be found and deleted by // its id instead of failing with "No reminder at index ...". - let displayOptions: DisplayOptions = Int(index) == nil ? .all : .incomplete + let displayOptions: DisplayOptions = Int(indexOrId) == nil ? .all : .incomplete self.reminders(on: [calendar], displayOptions: displayOptions) { reminders in - guard let reminder = self.getReminder(from: reminders, at: index) else { - print("No reminder at index \(index) on \(name)") + guard let reminder = self.getReminder(from: reminders, atIndexOrId: indexOrId) else { + print("No reminder at index or with ID \(indexOrId) on \(nameOrId)") exit(1) } @@ -819,30 +821,20 @@ public final class Reminders { recurrenceEndDate: DateComponents?, outputFormat: OutputFormat) { - let calendar = self.calendar(withName: name) + let calendar = self.calendar(withNameOrId: name) let reminder = EKReminder(eventStore: Store) reminder.calendar = calendar reminder.title = string reminder.notes = notes reminder.dueDateComponents = dueDateComponents reminder.priority = Int(priority.value.rawValue) - if let dueDate = dueDateComponents?.date, dueDateComponents?.hour != nil { - reminder.addAlarm(EKAlarm(absoluteDate: dueDate)) - } - do { - if let recurrence = recurrence { - guard dueDateComponents != nil else { - throw RecurrenceUpdateError.missingDueDate - } - let end = try recurrenceEnd(dateComponents: recurrenceEndDate) - reminder.addRecurrenceRule( - recurrence.recurrenceRule(interval: recurrenceInterval, end: end)) + if let dueDate = dueDateComponents, dueDate.hour != nil { + if let absoluteDate = dueDate.date { + reminder.addAlarm(EKAlarm(absoluteDate: absoluteDate)) } + } - try validateRecurrenceSchedule( - dueDateComponents: reminder.dueDateComponents, - rules: reminder.recurrenceRules ?? []) - + do { try Store.save(reminder, commit: true) switch (outputFormat) { case .json: @@ -851,7 +843,7 @@ public final class Reminders { print("Added '\(reminder.title!)' to '\(calendar.title)'") } } catch let error { - print("Failed to save reminder with error: \(error.localizedDescription)") + print("Failed to save reminder with error: \(error)") exit(1) } } @@ -882,11 +874,13 @@ public final class Reminders { } } - private func calendar(withName name: String) -> EKCalendar { - if let calendar = self.getCalendars().find(where: { $0.title.lowercased() == name.lowercased() }) { + private func calendar(withNameOrId nameOrId: String) -> EKCalendar { + if let calendar = self.getCalendars().first(where: { $0.calendarIdentifier == nameOrId }) { + return calendar + } else if let calendar = self.getCalendars().first(where: { $0.title.lowercased() == nameOrId.lowercased() }) { return calendar } else { - print("No reminders list matching \(name)") + print("No reminders list matching \(nameOrId)") exit(1) } } @@ -896,12 +890,11 @@ public final class Reminders { .filter { $0.allowsContentModifications } } - private func getReminder(from reminders: [EKReminder], at index: String) -> EKReminder? { - precondition(!index.isEmpty, "Index cannot be empty, argument parser must be misconfigured") - if let index = Int(index) { + private func getReminder(from reminders: [EKReminder], atIndexOrId indexOrId: String) -> EKReminder? { + if let index = Int(indexOrId) { return reminders[safe: index] } else { - return reminders.first { $0.calendarItemExternalIdentifier == index } + return reminders.first { $0.calendarItemExternalIdentifier == indexOrId } } } From 19142324d2e69827e6440b9b76413bf7a48678a8 Mon Sep 17 00:00:00 2001 From: Bill Cromie Date: Fri, 25 Oct 2024 14:02:47 -0400 Subject: [PATCH 2/9] feat: include list id in show-all JSON output Adds listId (the reminder's list's calendarIdentifier) to the JSON encoding of each reminder. Cherry-picked from keith/reminders-cli#90, commit 2b0cc0b7 ("showing list id in show-all"). --- Sources/RemindersLibrary/EKReminder+Encodable.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index e2717f4..b4c7cea 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -16,6 +16,7 @@ extension EKReminder: @retroactive Encodable { case startDate case dueDate case list + case listId case recurrence case recurrenceInterval case recurrenceEnd @@ -31,6 +32,7 @@ extension EKReminder: @retroactive Encodable { try container.encode(self.isCompleted, forKey: .isCompleted) try container.encode(self.priority, forKey: .priority) try container.encode(self.calendar.title, forKey: .list) + try container.encode(self.calendar.calendarIdentifier, forKey: .listId) try container.encodeIfPresent(self.notes, forKey: .notes) // url field is nil From b3b83cc7e384386db52887dfd7b3a01d63b782cc Mon Sep 17 00:00:00 2001 From: Bill Cromie Date: Fri, 25 Oct 2024 14:05:29 -0400 Subject: [PATCH 3/9] feat: accept a list id in place of a list name for add addReminder's list argument now resolves either a list name or its calendarIdentifier. Cherry-picked from keith/reminders-cli#90, commit 1f7ac31b ("can create reminders using uuids"). --- Sources/RemindersLibrary/CLI.swift | 2 +- Sources/RemindersLibrary/Reminders.swift | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index f195a58..61b1e85 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -255,7 +255,7 @@ private struct Add: ParsableCommand { reminders.addReminder( string: self.reminder.joined(separator: " "), notes: self.notes, - toListNamed: self.listNameOrId, + toListNameOrId: self.listNameOrId, dueDateComponents: self.dueDate, priority: priority, recurrence: self.repeat_, diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index f355388..896fe68 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -813,7 +813,7 @@ public final class Reminders { func addReminder( string: String, notes: String?, - toListNamed name: String, + toListNameOrId nameOrId: String, dueDateComponents: DateComponents?, priority: Priority, recurrence: Recurrence?, @@ -821,7 +821,7 @@ public final class Reminders { recurrenceEndDate: DateComponents?, outputFormat: OutputFormat) { - let calendar = self.calendar(withNameOrId: name) + let calendar = self.calendar(withNameOrId: nameOrId) let reminder = EKReminder(eventStore: Store) reminder.calendar = calendar reminder.title = string @@ -836,14 +836,14 @@ public final class Reminders { do { try Store.save(reminder, commit: true) - switch (outputFormat) { + switch outputFormat { case .json: print(encodeToJson(data: reminder)) - default: - print("Added '\(reminder.title!)' to '\(calendar.title)'") + case .plain: + print("Added reminder '\(reminder.title!)' to list '\(calendar.title)'") } } catch let error { - print("Failed to save reminder with error: \(error)") + print("Failed to add reminder with error: \(error)") exit(1) } } From c79938b9db518a8da08df320007bc8b93f9e8203 Mon Sep 17 00:00:00 2001 From: Bill Cromie Date: Fri, 25 Oct 2024 14:09:07 -0400 Subject: [PATCH 4/9] feat: accept a reminder id in place of an index for complete/uncomplete/edit setComplete and edit now resolve their reminder argument by numeric index or by calendarItemExternalIdentifier, and gain --format to print the updated reminder as JSON. Also removes a leftover debug print left in setComplete. Cherry-picked from keith/reminders-cli#90, commit db382c90 ("now can complete with IDs"). --- Sources/RemindersLibrary/CLI.swift | 26 +++++++++++++++++++--- Sources/RemindersLibrary/Reminders.swift | 28 +++++++++++++++--------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index 61b1e85..ff1037e 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -278,8 +278,15 @@ private struct Complete: ParsableCommand { help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") var indexOrId: String + @Option( + name: .shortAndLong, + help: "Output format (plain or json)") + var format: OutputFormat = .plain + func run() { - reminders.setComplete(true, itemAtIndexOrId: self.indexOrId, onListNamedOrId: self.listNameOrId) + reminders.setComplete(true, itemAtIndexOrId: self.indexOrId, + onListNamedOrId: self.listNameOrId, + outputFormat: format) } } @@ -296,8 +303,15 @@ private struct Uncomplete: ParsableCommand { help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") var indexOrId: String + @Option( + name: .shortAndLong, + help: "Output format (plain or json)") + var format: OutputFormat = .plain + func run() { - reminders.setComplete(false, itemAtIndexOrId: self.indexOrId, onListNamedOrId: self.listNameOrId) + reminders.setComplete(false, itemAtIndexOrId: self.indexOrId, + onListNamedOrId: self.listNameOrId, + outputFormat: format) } } @@ -395,6 +409,11 @@ private struct Edit: ParsableCommand { help: "The new reminder contents") var reminder: [String] = [] + @Option( + name: .shortAndLong, + help: "Output format (plain or json)") + var format: OutputFormat = .plain + func validate() throws { if self.dueDate != nil && self.clearDueDate { throw ValidationError("Cannot specify both --due-date and --clear-due-date") @@ -450,7 +469,8 @@ private struct Edit: ParsableCommand { newRecurrenceInterval: self.repeatInterval, newRecurrenceEndDate: self.repeatUntil, clearRecurrenceEnd: self.clearRepeatEnd, - clearRecurrence: self.clearRepeat + clearRecurrence: self.clearRepeat, + outputFormat: format ) } } diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 896fe68..4d585d9 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -643,7 +643,8 @@ public final class Reminders { newRecurrence: Recurrence?, newRecurrenceInterval: Int?, newRecurrenceEndDate: DateComponents?, clearRecurrenceEnd: Bool, - clearRecurrence: Bool) + clearRecurrence: Bool, + outputFormat: OutputFormat) { let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) @@ -736,7 +737,12 @@ public final class Reminders { rules: reminder.recurrenceRules ?? []) } try Store.save(reminder, commit: true) - print("Updated reminder '\(reminder.title!)'") + switch outputFormat { + case .json: + print(encodeToJson(data: reminder)) + case .plain: + print("Updated reminder '\(reminder.title!)'") + } } catch let error { print("Failed to update reminder with error: \(error.localizedDescription)") exit(1) @@ -744,18 +750,15 @@ public final class Reminders { semaphore.signal() } - semaphore.wait() } - func setComplete(_ complete: Bool, itemAtIndexOrId indexOrId: String, onListNamedOrId nameOrId: String) { + func setComplete(_ complete: Bool, itemAtIndexOrId indexOrId: String, onListNamedOrId nameOrId: String, outputFormat: OutputFormat) { let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) - let displayOptions = complete ? DisplayOptions.incomplete : .complete let action = complete ? "Completed" : "Uncompleted" - self.reminders(on: [calendar], displayOptions: displayOptions) { reminders in - print(reminders.map { $0.title! }) + self.reminders(on: [calendar], displayOptions: complete ? .incomplete : .complete) { reminders in guard let reminder = self.getReminder(from: reminders, atIndexOrId: indexOrId) else { print("No reminder at index or with ID \(indexOrId) on \(nameOrId)") exit(1) @@ -764,15 +767,19 @@ public final class Reminders { do { reminder.isCompleted = complete try Store.save(reminder, commit: true) - print("\(action) '\(reminder.title!)'") + switch outputFormat { + case .json: + print(encodeToJson(data: reminder)) + case .plain: + print("\(action) '\(reminder.title!)'") + } } catch let error { - print("Failed to save reminder with error: \(error)") + print("Failed to update reminder with error: \(error)") exit(1) } semaphore.signal() } - semaphore.wait() } @@ -906,3 +913,4 @@ private func encodeToJson(data: Encodable) -> String { let encoded = try! encoder.encode(data) return String(data: encoded, encoding: .utf8) ?? "" } + From c904e37f1c51c77ca2dfa8004af676e07599afda Mon Sep 17 00:00:00 2001 From: Bill Cromie Date: Fri, 25 Oct 2024 14:23:17 -0400 Subject: [PATCH 5/9] feat: finish threading list ids through show/add/edit/delete Renames the remaining listName/index arguments to listNameOrId/indexOrId across Show, Add, Edit and Delete, dropping an unused duplicate add() method left over from an earlier merge. No changes on top of this branch: the renames were already applied while resolving conflicts on the preceding cherry-picks, including preserving this fork's own existing fix for deleting completed reminders by external id, and the delete command's list argument now resolves by name or id via the same calendar(withNameOrId:) helper. Cherry-picked from keith/reminders-cli#90, commit 9c05a672 ("adding ids to lists"). From cb12cf27cd96426dfa5d5f8d7b81622ff62f6277 Mon Sep 17 00:00:00 2001 From: Gunnar Wrobel Date: Mon, 19 Jan 2026 07:46:09 +0100 Subject: [PATCH 6/9] chore: rebase the list-id patch (no changes needed in this fork) Upstream, this commit rebases cromulus' list-id work onto a newer base of reminders-cli so it applies again. In this fork the equivalent state was already reached while adapting the earlier commits in this series, so there is nothing further to apply here; kept as an empty commit to credit the rebase. Cherry-picked from keith/reminders-cli#103, commit 45e653fb ("updated patch: making the UUID a first-class citizen"). From 347fc2db9db0f7a5bc2a66fef99b91cb9e4e8905 Mon Sep 17 00:00:00 2001 From: Gunnar Wrobel Date: Mon, 19 Jan 2026 07:56:09 +0100 Subject: [PATCH 7/9] chore: acknowledge the upstream fix for deleting completed items Upstream, this commit adds --id/--index flags to delete so a completed reminder can be deleted by its external identifier. This fork already fixed the same underlying problem independently, with a smaller change that keeps the delete command's existing positional index-or-id argument and auto-detects whether it is numeric (index, scoped to incomplete items like show) or an external id (scoped to all items, completed included). That existing fix is kept as-is; this commit intentionally applies no changes and is kept empty to credit the upstream fix for the same issue. Cherry-picked from keith/reminders-cli pull request 103 ("Allow to delete completed items"). From 6d1cd3abee11cdcbb1c5e4ed39eda067ebf641fd Mon Sep 17 00:00:00 2001 From: udondan Date: Sun, 13 Sep 2026 15:23:01 +0200 Subject: [PATCH 8/9] test: cover list/reminder id resolution and list id encoding Extract the list-name-or-id matching logic out of Reminders.calendar(withNameOrId:) into a free function, and expose getReminder(from:atIndexOrId:) internally, so both can be exercised directly via @testable import without needing live access to Reminders.app, following the precedent already set by matchesAdditionalFilters. Add IdentifierTests.swift covering: EKCalendar/EKReminder JSON encoding of calendarIdentifier/listId, list resolution by id/title with id taking precedence on a collision, and reminder resolution by index vs. external id, including not-found cases. --- Sources/RemindersLibrary/Reminders.swift | 21 ++++- Tests/RemindersTests/IdentifierTests.swift | 101 +++++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 Tests/RemindersTests/IdentifierTests.swift diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 4d585d9..deedc15 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -106,6 +106,19 @@ func matchesAdditionalFilters( return true } +// Resolves a list argument that may be either a `calendarIdentifier` or a (case-insensitive) +// list title. ID matches take precedence, so a title that happens to collide with another +// list's ID still resolves to the list with that ID. Kept as a free function, separate from +// `Reminders.calendar(withNameOrId:)`, so it's directly unit-testable via `@testable import` +// without needing live access to Reminders.app, matching `matchesAdditionalFilters` above. +func calendarMatching(_ calendars: [EKCalendar], nameOrId: String) -> EKCalendar? { + if let calendar = calendars.first(where: { $0.calendarIdentifier == nameOrId }) { + return calendar + } else { + return calendars.first { $0.title.lowercased() == nameOrId.lowercased() } + } +} + public enum OutputFormat: String, ExpressibleByArgument { case json, plain } @@ -882,9 +895,7 @@ public final class Reminders { } private func calendar(withNameOrId nameOrId: String) -> EKCalendar { - if let calendar = self.getCalendars().first(where: { $0.calendarIdentifier == nameOrId }) { - return calendar - } else if let calendar = self.getCalendars().first(where: { $0.title.lowercased() == nameOrId.lowercased() }) { + if let calendar = calendarMatching(self.getCalendars(), nameOrId: nameOrId) { return calendar } else { print("No reminders list matching \(nameOrId)") @@ -897,7 +908,9 @@ public final class Reminders { .filter { $0.allowsContentModifications } } - private func getReminder(from reminders: [EKReminder], atIndexOrId indexOrId: String) -> EKReminder? { + // Kept internal (not private) so it's directly unit-testable via `@testable import`, + // matching `matchesAdditionalFilters` above. + func getReminder(from reminders: [EKReminder], atIndexOrId indexOrId: String) -> EKReminder? { if let index = Int(indexOrId) { return reminders[safe: index] } else { diff --git a/Tests/RemindersTests/IdentifierTests.swift b/Tests/RemindersTests/IdentifierTests.swift new file mode 100644 index 0000000..eec9f31 --- /dev/null +++ b/Tests/RemindersTests/IdentifierTests.swift @@ -0,0 +1,101 @@ +import EventKit +@testable import RemindersLibrary +import XCTest + +final class IdentifierTests: XCTestCase { + private let store = EKEventStore() + + private func makeCalendar(title: String) -> EKCalendar { + let calendar = EKCalendar(for: .reminder, eventStore: store) + calendar.title = title + return calendar + } + + private func makeReminder(title: String = "Test", calendar: EKCalendar) -> EKReminder { + let reminder = EKReminder(eventStore: store) + reminder.title = title + reminder.calendar = calendar + return reminder + } + + // MARK: - EKCalendar encoding + + func testCalendarEncodesTitleAndIdentifier() throws { + let calendar = makeCalendar(title: "Groceries") + let data = try JSONEncoder().encode(calendar) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["title"] as? String, "Groceries") + XCTAssertEqual(json["calendarIdentifier"] as? String, calendar.calendarIdentifier) + } + + // MARK: - EKReminder encoding + + func testReminderJsonIncludesListId() throws { + let calendar = makeCalendar(title: "Groceries") + let reminder = makeReminder(calendar: calendar) + let data = try JSONEncoder().encode(reminder) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["listId"] as? String, calendar.calendarIdentifier) + } + + // MARK: - List resolution + + func testCalendarMatchingResolvesByExactId() throws { + let a = makeCalendar(title: "A") + let b = makeCalendar(title: "B") + let resolved = calendarMatching([a, b], nameOrId: b.calendarIdentifier) + XCTAssertEqual(resolved?.calendarIdentifier, b.calendarIdentifier) + } + + func testCalendarMatchingResolvesByCaseInsensitiveTitle() throws { + let a = makeCalendar(title: "Groceries") + let b = makeCalendar(title: "Work") + let resolved = calendarMatching([a, b], nameOrId: "groceries") + XCTAssertEqual(resolved?.calendarIdentifier, a.calendarIdentifier) + } + + func testCalendarMatchingPrefersIdOverCollidingTitle() throws { + // `a`'s title collides with `b`'s identifier; the ID match must win. + let b = makeCalendar(title: "Work") + let a = makeCalendar(title: b.calendarIdentifier) + let resolved = calendarMatching([a, b], nameOrId: b.calendarIdentifier) + XCTAssertEqual(resolved?.calendarIdentifier, b.calendarIdentifier) + } + + func testCalendarMatchingReturnsNilWhenNotFound() throws { + let a = makeCalendar(title: "Groceries") + XCTAssertNil(calendarMatching([a], nameOrId: "does-not-exist")) + } + + // MARK: - Reminder resolution + + func testGetReminderResolvesByIndexForNumericString() throws { + let calendar = makeCalendar(title: "List") + let first = makeReminder(title: "First", calendar: calendar) + let second = makeReminder(title: "Second", calendar: calendar) + let resolved = Reminders().getReminder(from: [first, second], atIndexOrId: "1") + XCTAssertEqual(resolved?.title, "Second") + } + + func testGetReminderResolvesByExternalIdForNonNumericString() throws { + let calendar = makeCalendar(title: "List") + let reminder = makeReminder(title: "Only", calendar: calendar) + let resolved = Reminders().getReminder( + from: [reminder], atIndexOrId: reminder.calendarItemExternalIdentifier) + XCTAssertEqual(resolved?.title, "Only") + } + + func testGetReminderReturnsNilForOutOfRangeIndex() throws { + let calendar = makeCalendar(title: "List") + let reminder = makeReminder(calendar: calendar) + XCTAssertNil(Reminders().getReminder(from: [reminder], atIndexOrId: "5")) + } + + func testGetReminderReturnsNilForUnknownExternalId() throws { + let calendar = makeCalendar(title: "List") + let reminder = makeReminder(calendar: calendar) + XCTAssertNil(Reminders().getReminder(from: [reminder], atIndexOrId: "not-a-real-id")) + } +} From 8732364bf6ca9b9f578b563d383aebdca5cf3aa2 Mon Sep 17 00:00:00 2001 From: udondan Date: Sun, 13 Sep 2026 15:23:03 +0200 Subject: [PATCH 9/9] docs: document list ids and per-command json format flags Show that show-lists now prints each list's calendarIdentifier alongside its title, that any list name argument also accepts an id, that complete/uncomplete/ edit support --format json, and how to delete an already-completed reminder by its id. --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a77063d..709e603 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,16 @@ A simple CLI for interacting with OS X reminders. ``` $ reminders show-lists -Soon -Eventually +Soon (2A29C8B1-3D0F-4A9E-9C8D-5B6E7F8A9B0C) +Eventually (7E1F2A3B-4C5D-6E7F-8A9B-0C1D2E3F4A5B) ``` +Every list also has a stable identifier, shown above in parentheses (and available as +`calendarIdentifier` with `--format json`). Anywhere a list name is accepted — `show`, `show-all`, +`add`, `complete`, `uncomplete`, `edit`, `delete` — a list ID works too, which is useful for +scripting against a list whose name might change or contains characters that are awkward on the +command line. + #### Show reminders on a specific list ``` @@ -29,6 +35,9 @@ $ reminders show Soon 0 Ship reminders-cli ``` +`complete`, `uncomplete`, and `edit` also accept `--format json` to print the affected reminder as +JSON instead of the plain-text confirmation shown above. + #### Undo a completed item ``` @@ -81,6 +90,16 @@ $ reminders show Soon 0 Ship reminders-cli ``` +The index argument above only matches against incomplete reminders, the same set `show` displays +by default. To delete a reminder that's already been completed, pass its ID (from `show +--only-completed --format json`, or the `externalId` field) instead of an index — an ID is looked +up regardless of completion state: + +``` +$ reminders delete Soon 44C111DE-0B69-4E96-8C93-6A5D0A6C2A17 +Deleted 'Write README' +``` + #### Add a reminder to a list ```