diff --git a/README.md b/README.md index 154eb4d..8d8c044 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,8 @@ $ reminders show-all --search groceries $ reminders show-all --list Soon --list Work 0: Ship reminders-cli (2 days ago) 1: Contribute to open source (in 3 hours) +$ reminders show-all --include-completed --completed-since monday +5: Write README ``` `--priority` and `--list` may be repeated to match any of several values. `--list` restricts which @@ -203,7 +205,10 @@ date without a time includes the whole local day for `--due-before` (matching th behavior above), while `--due-after` uses the parsed date's own start as an inclusive lower bound. `--overdue` is independent of, and combinable with, `--due-date`/`--include-overdue`. `--no-due-date` cannot be combined with `--due-date`, `--due-before`, `--due-after`, `--overdue`, or -`--include-overdue`, since those all require a due date to compare against. +`--include-overdue`, since those all require a due date to compare against. `--completed-since` +accepts the same natural-language dates as `--due-date`/`--due-after`, uses an inclusive lower +bound against each reminder's completion time, and requires `--only-completed` or +`--include-completed`, since there's nothing to filter otherwise. #### Sort reminders diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index bdcd769..1dd4dbe 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -65,6 +65,9 @@ private struct ShowAll: ParsableCommand { @Option(help: "Only show reminders from this list; repeat to specify multiple") var list: [String] = [] + @Option(help: "Only show completed reminders completed on or after this date") + var completedSince: DateComponents? + @Option( name: .shortAndLong, help: "format, either of 'plain' or 'json'") @@ -82,6 +85,10 @@ private struct ShowAll: ParsableCommand { throw ValidationError( "Cannot combine --no-due-date with --due-date, --due-before, --due-after, --overdue, or --include-overdue") } + if self.completedSince != nil && !self.onlyCompleted && !self.includeCompleted { + throw ValidationError( + "--completed-since requires --only-completed or --include-completed") + } } func run() { @@ -96,7 +103,7 @@ private struct ShowAll: ParsableCommand { dueOn: self.dueDate, includeOverdue: self.includeOverdue, overdue: self.overdue, dueBefore: self.dueBefore, dueAfter: self.dueAfter, noDueDate: self.noDueDate, priorities: self.priority, search: self.search, - lists: self.list, + lists: self.list, completedSince: self.completedSince, displayOptions: displayOptions, outputFormat: format, sort: sort, sortOrder: sortOrder) } } @@ -152,6 +159,9 @@ private struct Show: ParsableCommand { @Option(help: "Only show reminders whose title or notes contain this text (case-insensitive)") var search: String? + @Option(help: "Only show completed reminders completed on or after this date") + var completedSince: DateComponents? + @Option( name: .shortAndLong, help: "format, either of 'plain' or 'json'") @@ -169,6 +179,10 @@ private struct Show: ParsableCommand { throw ValidationError( "Cannot combine --no-due-date with --due-date, --due-before, --due-after, --overdue, or --include-overdue") } + if self.completedSince != nil && !self.onlyCompleted && !self.includeCompleted { + throw ValidationError( + "--completed-since requires --only-completed or --include-completed") + } } func run() { @@ -183,6 +197,7 @@ private struct Show: ParsableCommand { 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, + completedSince: self.completedSince, displayOptions: displayOptions, outputFormat: format, sort: sort, sortOrder: sortOrder) } } diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index b4c7cea..0aa5ea5 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -38,7 +38,7 @@ extension EKReminder: @retroactive Encodable { // url field is nil // https://developer.apple.com/forums/thread/128140 try container.encodeIfPresent(self.url, forKey: .url) - try container.encodeIfPresent(format(self.completionDate), forKey: .completionDate) + try container.encode(format(self.completionDate), forKey: .completionDate) for alarm in self.alarms ?? [] { if let location = alarm.structuredLocation { diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index d6d9127..4a330ee 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -63,9 +63,10 @@ private func format(_ reminder: EKReminder, at index: Int?, listName: String? = // Additional, independently-composable filters for `show`/`show-all`, ANDed together and ANDed // with the pre-existing day-granularity `--due-date`/`--include-overdue` filter. `now`, -// `dueBefore`, and `dueAfter` are resolved to concrete `Date`s once per command invocation by -// the caller, not per reminder. Kept internal (not private) so it's directly unit-testable via -// `@testable import`, matching `recurrenceEndDate`/`nextOccurrence` elsewhere in this file. +// `dueBefore`, `dueAfter`, and `completedSince` are resolved to concrete `Date`s once per command +// invocation by the caller, not per reminder. Kept internal (not private) so it's directly +// unit-testable via `@testable import`, matching `recurrenceEndDate`/`nextOccurrence` elsewhere in +// this file. func matchesAdditionalFilters( _ reminder: EKReminder, now: Date, @@ -74,7 +75,8 @@ func matchesAdditionalFilters( dueAfter: Date?, noDueDate: Bool, priorities: [Priority], - search: String? + search: String?, + completedSince: Date? = nil ) -> Bool { let reminderDueDate = reminder.dueDateComponents?.date @@ -90,6 +92,9 @@ func matchesAdditionalFilters( if let dueAfter, !(reminderDueDate.map { $0 >= dueAfter } ?? false) { return false } + if let completedSince, !(reminder.completionDate.map { $0 >= completedSince } ?? false) { + return false + } if !priorities.isEmpty { let reminderPriority = Priority(reminder.mappedPriority) ?? .none if !priorities.contains(reminderPriority) { @@ -479,7 +484,7 @@ public final class Reminders { dueOn dueDate: DateComponents?, includeOverdue: Bool, overdue: Bool = false, dueBefore: DateComponents? = nil, dueAfter: DateComponents? = nil, noDueDate: Bool = false, priorities: [Priority] = [], search: String? = nil, - lists: [String] = [], + lists: [String] = [], completedSince: DateComponents? = nil, displayOptions: DisplayOptions, outputFormat: OutputFormat, sort: Sort, sortOrder: CustomSortOrder ) { let semaphore = DispatchSemaphore(value: 0) @@ -490,6 +495,7 @@ public final class Reminders { // lower bound. let dueBeforeDate = dueBefore.flatMap { recurrenceEndDate(from: $0) } let dueAfterDate = dueAfter?.date + let completedSinceDate = completedSince?.date // 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) } @@ -520,7 +526,8 @@ public final class Reminders { guard matchesAdditionalFilters( reminder, now: now, overdue: overdue, dueBefore: dueBeforeDate, - dueAfter: dueAfterDate, noDueDate: noDueDate, priorities: priorities, search: search + dueAfter: dueAfterDate, noDueDate: noDueDate, priorities: priorities, search: search, + completedSince: completedSinceDate ) else { continue } @@ -547,6 +554,7 @@ public final class Reminders { 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, + completedSince: DateComponents? = nil, displayOptions: DisplayOptions, outputFormat: OutputFormat, sort: Sort, sortOrder: CustomSortOrder) { let reminderCalendar = self.calendar(withNameOrId: nameOrId) @@ -555,6 +563,7 @@ public final class Reminders { let now = Date() let dueBeforeDate = dueBefore.flatMap { recurrenceEndDate(from: $0) } let dueAfterDate = dueAfter?.date + let completedSinceDate = completedSince?.date self.reminders(on: [reminderCalendar], displayOptions: displayOptions) { reminders in var matchingReminders = [(EKReminder, Int?)]() @@ -581,7 +590,8 @@ public final class Reminders { guard matchesAdditionalFilters( reminder, now: now, overdue: overdue, dueBefore: dueBeforeDate, - dueAfter: dueAfterDate, noDueDate: noDueDate, priorities: priorities, search: search + dueAfter: dueAfterDate, noDueDate: noDueDate, priorities: priorities, search: search, + completedSince: completedSinceDate ) else { continue } diff --git a/Tests/RemindersTests/FilterTests.swift b/Tests/RemindersTests/FilterTests.swift index 7167fd8..fcb8a21 100644 --- a/Tests/RemindersTests/FilterTests.swift +++ b/Tests/RemindersTests/FilterTests.swift @@ -12,7 +12,8 @@ final class FilterTests: XCTestCase { } private func makeReminder( - title: String = "Test", notes: String? = nil, due: Date? = nil, priority: Priority = .none + title: String = "Test", notes: String? = nil, due: Date? = nil, priority: Priority = .none, + completed: Date? = nil ) -> EKReminder { let reminder = EKReminder(eventStore: store) let calendar = EKCalendar(for: .reminder, eventStore: store) @@ -25,6 +26,10 @@ final class FilterTests: XCTestCase { reminder.dueDateComponents = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute, .second], from: due) } + if let completed { + reminder.isCompleted = true + reminder.completionDate = completed + } return reminder } @@ -36,11 +41,12 @@ final class FilterTests: XCTestCase { dueAfter: Date? = nil, noDueDate: Bool = false, priorities: [Priority] = [], - search: String? = nil + search: String? = nil, + completedSince: Date? = nil ) -> Bool { matchesAdditionalFilters( reminder, now: now, overdue: overdue, dueBefore: dueBefore, dueAfter: dueAfter, - noDueDate: noDueDate, priorities: priorities, search: search) + noDueDate: noDueDate, priorities: priorities, search: search, completedSince: completedSince) } func testNoFiltersMatchesEverything() throws { @@ -95,6 +101,23 @@ final class FilterTests: XCTestCase { XCTAssertFalse(matches(makeReminder(), dueAfter: cutoff)) } + func testCompletedSinceIncludesExactBoundary() throws { + let cutoff = wholeSecond() + let reminder = makeReminder(completed: cutoff) + XCTAssertTrue(matches(reminder, completedSince: cutoff)) + } + + func testCompletedSinceRejectsBeforeBoundary() throws { + let cutoff = wholeSecond() + let reminder = makeReminder(completed: cutoff.addingTimeInterval(-1)) + XCTAssertFalse(matches(reminder, completedSince: cutoff)) + } + + func testCompletedSinceRejectsIncompleteReminder() throws { + let cutoff = wholeSecond() + XCTAssertFalse(matches(makeReminder(), completedSince: cutoff)) + } + func testNoDueDateMatchesReminderWithoutDueDate() throws { XCTAssertTrue(matches(makeReminder(), noDueDate: true)) }