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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Resolve repeated reminder indexes, IDs, and prefixes once for delete and complete, preserving input order and accurate preview/confirmation counts. Thanks @SebTardif.

## 0.3.8 - 2026-09-24

**Highlights:** Surplus arguments and unsafe date inputs are rejected before they can silently change the intended command or reminder date.
Expand Down
10 changes: 8 additions & 2 deletions Sources/RemindCore/IDResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ public enum IDResolver {
let sorted = ReminderFiltering.sort(reminders)
let numericSorted = ReminderFiltering.sort(numericReminders ?? reminders)
var resolved: [ReminderItem] = []
var seen: Set<String> = []
func appendUnique(_ item: ReminderItem) {
if seen.insert(item.id).inserted {
resolved.append(item)
}
}
for input in inputs {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
if let index = Int(trimmed) {
guard index > 0 && index <= numericSorted.count else {
throw RemindCoreError.invalidIdentifier(trimmed)
}
resolved.append(numericSorted[index - 1])
appendUnique(numericSorted[index - 1])
continue
}

Expand All @@ -33,7 +39,7 @@ public enum IDResolver {
throw RemindCoreError.ambiguousIdentifier(trimmed, matches: matches.map { $0.id })
}
if let match = matches.first {
resolved.append(match)
appendUnique(match)
}
}
return resolved
Expand Down
33 changes: 33 additions & 0 deletions Tests/RemindCoreTests/IDResolverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,39 @@ struct IDResolverTests {
#expect(resolved.first?.title == "First")
}

@Test(
"Repeated identifiers resolve each reminder once",
arguments: [
["1", "1"],
["abcd1234", "abcd1234"],
["1", "abcd", " ABCD1234 "],
["abcd", "1"],
])
func duplicateIDsResolveOnce(_ inputs: [String]) throws {
let resolved = try IDResolver.resolve(inputs, from: sampleReminders())
#expect(resolved.map(\.id) == ["abcd1234"])
}

@Test("Deduplication preserves first occurrence order")
func duplicateIDsPreserveInputOrder() throws {
let resolved = try IDResolver.resolve(["2", "1", "abce", "abcd"], from: sampleReminders())
#expect(resolved.map(\.id) == ["abce5678", "abcd1234"])
}

@Test("Deduplication uses reminder identity across filtered numeric indexes")
func duplicateIDsFromFilteredShowOutput() throws {
let all = sampleReminders()
let resolved = try IDResolver.resolve(["1", "abce5678", "abcd", "1"], from: all, numericFrom: [all[1]])
#expect(resolved.map(\.id) == ["abce5678", "abcd1234"])
}

@Test("Duplicate targets do not hide invalid subsequent identifiers")
func duplicateIDsStillValidateEveryInput() {
#expect(throws: RemindCoreError.reminderNotFound("missing")) {
_ = try IDResolver.resolve(["1", "1", "missing"], from: sampleReminders())
}
}

@Test("Resolve numeric indexes from filtered show output")
func resolveIndexFromFilteredShowOutput() throws {
let all = sampleReminders()
Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ remindctl edit 4A83 --no-repeat

`edit`, `complete`, and `delete` accept indexes from the current default listing or ID prefixes.
Numeric indexes must be positive and within the current view; out-of-range values return an error.
`complete` and `delete` process each reminder once, even when repeated indexes, IDs, or prefixes identify the same reminder. Previews and delete confirmation counts use these unique targets in first-input order; every input must still be valid and unambiguous.
Reminders sort by due date, then title, then stable ID so equal dates and titles do not make numeric targets depend on EventKit's fetch order. Use IDs when reminders may be added, removed, or edited between commands.

If `add`, `edit`, or `complete` reports `Reminder is missing a calendar`, EventKit saved the reminder but returned it without its list. Check Reminders.app before retrying; this error does not roll back the saved change.
Expand Down
Loading