Skip to content
Merged
Show file tree
Hide file tree
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
17 changes: 16 additions & 1 deletion Sources/RemindersLibrary/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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'")
Expand All @@ -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() {
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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'")
Expand All @@ -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() {
Expand All @@ -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)
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/RemindersLibrary/EKReminder+Encodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 17 additions & 7 deletions Sources/RemindersLibrary/Reminders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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) }
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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?)]()
Expand All @@ -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
}
Expand Down
29 changes: 26 additions & 3 deletions Tests/RemindersTests/FilterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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))
}
Expand Down
Loading