From 45e653fbe98e342fd47ef8b8375608031b6fff58 Mon Sep 17 00:00:00 2001 From: Gunnar Wrobel Date: Mon, 19 Jan 2026 07:46:09 +0100 Subject: [PATCH 1/2] updated patch: making the UUID a first-class citizen --- Sources/RemindersLibrary/CLI.swift | 82 ++++++----- .../EKCalendar+Encodable.swift | 14 ++ .../EKReminder+Encodable.swift | 4 +- Sources/RemindersLibrary/Reminders.swift | 136 ++++++++++++------ 4 files changed, 158 insertions(+), 78 deletions(-) create mode 100644 Sources/RemindersLibrary/EKCalendar+Encodable.swift diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index 8978505..0fe9326 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -65,9 +65,8 @@ 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", - completion: .custom(listNameCompletion)) - var listName: String + help: "The list to print items from, see 'show-lists' for names or IDs") + var listNameOrId: String @Flag(help: "Show completed items only") var onlyCompleted = false @@ -114,7 +113,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, displayOptions: displayOptions, outputFormat: format, sort: sort, sortOrder: sortOrder) } } @@ -124,9 +123,8 @@ private struct Add: ParsableCommand { abstract: "Add a reminder to a list") @Argument( - help: "The list to add to, see 'show-lists' for names", - completion: .custom(listNameCompletion)) - var listName: String + help: "The list to add to, see 'show-lists' for names or IDs") + var listNameOrId: String @Argument( parsing: .remaining, @@ -157,7 +155,7 @@ private struct Add: ParsableCommand { reminders.addReminder( string: self.reminder.joined(separator: " "), notes: self.notes, - toListNamed: self.listName, + toListNameOrId: self.listNameOrId, dueDateComponents: self.dueDate, priority: priority, outputFormat: format) @@ -169,16 +167,22 @@ private struct Complete: ParsableCommand { abstract: "Complete a reminder") @Argument( - help: "The list to complete a reminder on, see 'show-lists' for names", - completion: .custom(listNameCompletion)) - var listName: String + help: "The list to complete a reminder on, see 'show-lists' for names or IDs") + 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, + help: "Output format (plain or json)") + var format: OutputFormat = .plain func run() { - reminders.setComplete(true, itemAtIndex: self.index, onListNamed: self.listName) + reminders.setComplete(true, itemAtIndexOrId: self.indexOrId, + onListNamedOrId: self.listNameOrId, + outputFormat: format) } } @@ -187,16 +191,22 @@ private struct Uncomplete: ParsableCommand { abstract: "Uncomplete a reminder") @Argument( - help: "The list to uncomplete a reminder on, see 'show-lists' for names", - completion: .custom(listNameCompletion)) - var listName: String + help: "The list to uncomplete a reminder on, see 'show-lists' for names or IDs") + 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, + help: "Output format (plain or json)") + var format: OutputFormat = .plain func run() { - reminders.setComplete(false, itemAtIndex: self.index, onListNamed: self.listName) + reminders.setComplete(false, itemAtIndexOrId: self.indexOrId, + onListNamedOrId: self.listNameOrId, + outputFormat: format) } } @@ -205,16 +215,15 @@ private struct Delete: ParsableCommand { abstract: "Delete a reminder") @Argument( - help: "The list to delete a reminder on, see 'show-lists' for names", - completion: .custom(listNameCompletion)) - var listName: String + help: "The list to delete a reminder on, see 'show-lists' for names or IDs") + 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) } } @@ -229,13 +238,12 @@ 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", - completion: .custom(listNameCompletion)) - var listName: String + help: "The list to edit a reminder on, see 'show-lists' for names or IDs") + 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, @@ -247,6 +255,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.reminder.isEmpty && self.notes == nil { throw ValidationError("Must specify either new reminder content or new notes") @@ -256,10 +269,11 @@ 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 + newNotes: self.notes, + outputFormat: format ) } } 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 f9f9fdb..0919a72 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 } public func encode(to encoder: Encoder) throws { @@ -25,8 +26,9 @@ 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 // https://developer.apple.com/forums/thread/128140 try container.encodeIfPresent(self.url, forKey: .url) diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 2640811..38d273b 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -90,12 +90,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))") } } } @@ -120,9 +121,9 @@ public final class Reminders { } let sameDay = calendar.compare( - reminderDueDate, to: dueDate, toGranularity: .day) == .orderedSame + reminderDueDate, to: dueDate, toGranularity: Calendar.Component.day) == .orderedSame let earlierDay = calendar.compare( - reminderDueDate, to: dueDate, toGranularity: .day) == .orderedAscending + reminderDueDate, to: dueDate, toGranularity: Calendar.Component.day) == .orderedAscending if sameDay || (includeOverdue && earlierDay) { matchingReminders.append((reminder, i, listName)) @@ -144,13 +145,14 @@ public final class Reminders { semaphore.wait() } - func showListItems(withName name: String, dueOn dueDate: DateComponents?, includeOverdue: Bool, + func showListItems(withNameOrId nameOrId: String, dueOn dueDate: DateComponents?, includeOverdue: Bool, displayOptions: DisplayOptions, outputFormat: OutputFormat, sort: Sort, sortOrder: CustomSortOrder) { + let reminderCalendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) - let calendar = Calendar.current + let currentCalendar = Calendar.current - 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() { @@ -164,10 +166,10 @@ public final class Reminders { continue } - let sameDay = calendar.compare( - reminderDueDate, to: dueDate, toGranularity: .day) == .orderedSame - let earlierDay = calendar.compare( - reminderDueDate, to: dueDate, toGranularity: .day) == .orderedAscending + let sameDay = currentCalendar.compare( + reminderDueDate, to: dueDate, toGranularity: Calendar.Component.day) == .orderedSame + let earlierDay = currentCalendar.compare( + reminderDueDate, to: dueDate, toGranularity: Calendar.Component.day) == .orderedAscending if sameDay || (includeOverdue && earlierDay) { matchingReminders.append((reminder, index)) @@ -230,13 +232,19 @@ public final class Reminders { } } - func edit(itemAtIndex index: String, onListNamed name: String, newText: String?, newNotes: String?) { - let calendar = self.calendar(withName: name) + func edit( + itemAtIndexOrId indexOrId: String, + onListNamedOrId nameOrId: String, + newText: String?, + newNotes: String?, + outputFormat: OutputFormat) + { + let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) 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) } @@ -244,7 +252,12 @@ public final class Reminders { reminder.title = newText ?? reminder.title reminder.notes = newNotes ?? reminder.notes 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)") exit(1) @@ -252,45 +265,46 @@ public final class Reminders { semaphore.signal() } - 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, 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! }) - guard let reminder = self.getReminder(from: reminders, at: index) else { - print("No reminder at index \(index) on \(name)") + 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) } 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() } - 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) 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) } @@ -311,20 +325,55 @@ public final class Reminders { func addReminder( string: String, notes: String?, - toListNamed name: String, + toListNameOrId nameOrId: String, dueDateComponents: DateComponents?, priority: Priority, outputFormat: OutputFormat) { - let calendar = self.calendar(withName: name) + let calendar = self.calendar(withNameOrId: nameOrId) 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)) + if let dueDate = dueDateComponents, dueDate.hour != nil { + if let absoluteDate = dueDate.date { + reminder.addAlarm(EKAlarm(absoluteDate: absoluteDate)) + } + } + + do { + try Store.save(reminder, commit: true) + switch outputFormat { + case .json: + print(encodeToJson(data: reminder)) + case .plain: + print("Added reminder '\(reminder.title!)' to list '\(calendar.title)'") + } + } catch let error { + print("Failed to add reminder with error: \(error)") + exit(1) + } + } + + func add( + _ text: String, + to name: String, + notes: String?, + dueDate: DateComponents?, + outputFormat: OutputFormat) + { + let calendar = self.calendar(withNameOrId: name) + let reminder = EKReminder(eventStore: Store) + reminder.calendar = calendar + reminder.title = text + reminder.notes = notes + reminder.dueDateComponents = dueDate + if let dueDate = dueDate, dueDate.hour != nil { + if let absoluteDate = dueDate.date { + reminder.addAlarm(EKAlarm(absoluteDate: absoluteDate)) + } } do { @@ -367,11 +416,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) } } @@ -381,12 +432,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 cce8f74f4826863675f6389cbe9f2614b14ac6f9 Mon Sep 17 00:00:00 2001 From: Gunnar Wrobel Date: Mon, 19 Jan 2026 07:56:09 +0100 Subject: [PATCH 2/2] Allow to delete completed items --- Sources/RemindersLibrary/CLI.swift | 27 +++++++++++++++--- Sources/RemindersLibrary/Reminders.swift | 35 +++++++++++++++++++++--- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index 0fe9326..3eaa304 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -218,12 +218,31 @@ private struct Delete: ParsableCommand { help: "The list to delete a reminder on, see 'show-lists' for names or IDs") var listNameOrId: String - @Argument( - help: "The index or id of the reminder to delete, see 'show' for indexes and IDs") - var indexOrId: String + @Option( + name: .long, + help: "Delete by reminder ID (external identifier)") + var id: String? + + @Option( + name: .long, + help: "Delete by index from 'show' output") + var index: Int? + + func validate() throws { + if id == nil && index == nil { + throw ValidationError("Specify one of --id or --index") + } + if id != nil && index != nil { + throw ValidationError("Specify only one of --id or --index") + } + } func run() { - reminders.delete(itemAtIndexOrId: self.indexOrId, onListNamedOrId: self.listNameOrId) + if let id = id { + reminders.delete(itemId: id, onListNamedOrId: self.listNameOrId) + } else if let index = index { + reminders.delete(itemAtIndex: index, onListNamedOrId: self.listNameOrId) + } } } diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 38d273b..38e7fac 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -298,13 +298,40 @@ public final class Reminders { semaphore.wait() } - func delete(itemAtIndexOrId indexOrId: String, onListNamedOrId nameOrId: String) { + func delete(itemId id: String, onListNamedOrId nameOrId: String) { + delete( + itemFromListNamedOrId: nameOrId, + displayOptions: .all, + errorSuffix: "ID \(id)", + select: { reminders in + reminders.first { $0.calendarItemExternalIdentifier == id } + } + ) + } + + func delete(itemAtIndex index: Int, onListNamedOrId nameOrId: String) { + delete( + itemFromListNamedOrId: nameOrId, + displayOptions: .incomplete, + errorSuffix: "index \(index)", + select: { reminders in + reminders[safe: index] + } + ) + } + + private func delete( + itemFromListNamedOrId nameOrId: String, + displayOptions: DisplayOptions, + errorSuffix: String, + select: @escaping (_ reminders: [EKReminder]) -> EKReminder?) + { let calendar = self.calendar(withNameOrId: nameOrId) let semaphore = DispatchSemaphore(value: 0) - self.reminders(on: [calendar], displayOptions: .incomplete) { reminders in - guard let reminder = self.getReminder(from: reminders, atIndexOrId: indexOrId) else { - print("No reminder at index or with ID \(indexOrId) on \(nameOrId)") + self.reminders(on: [calendar], displayOptions: displayOptions) { reminders in + guard let reminder = select(reminders) else { + print("No reminder at \(errorSuffix) on \(nameOrId)") exit(1) }