From 5c1888016409351ddf1fa233fe9a158b727e181d Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 3 Oct 2026 19:50:03 -0700 Subject: [PATCH 1/2] fix: dedupe reminder ids before delete delete 1 1 resolved the same reminder twice, deleted it, then failed the second lookup. Keep the first id so the prompt and the delete see one reminder. Signed-off-by: Sebastien Tardif --- Sources/RemindCore/IDResolver.swift | 10 ++++++++-- Tests/RemindCoreTests/IDResolverTests.swift | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Sources/RemindCore/IDResolver.swift b/Sources/RemindCore/IDResolver.swift index cf366be..f8c7fd2 100644 --- a/Sources/RemindCore/IDResolver.swift +++ b/Sources/RemindCore/IDResolver.swift @@ -11,13 +11,19 @@ public enum IDResolver { let sorted = ReminderFiltering.sort(reminders) let numericSorted = ReminderFiltering.sort(numericReminders ?? reminders) var resolved: [ReminderItem] = [] + var seen: Set = [] + 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 } @@ -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 diff --git a/Tests/RemindCoreTests/IDResolverTests.swift b/Tests/RemindCoreTests/IDResolverTests.swift index 0252121..345699e 100644 --- a/Tests/RemindCoreTests/IDResolverTests.swift +++ b/Tests/RemindCoreTests/IDResolverTests.swift @@ -38,6 +38,16 @@ struct IDResolverTests { #expect(resolved.first?.title == "First") } + @Test("Duplicate ids resolve once before a delete prompt") + func duplicateIDsResolveOnce() throws { + let repeated = try IDResolver.resolve(["1", "1"], from: sampleReminders()) + #expect(repeated.map(\.id) == ["abcd1234"]) + let mixed = try IDResolver.resolve(["1", "abcd"], from: sampleReminders()) + #expect(mixed.map(\.id) == ["abcd1234"]) + let distinct = try IDResolver.resolve(["2", "1"], from: sampleReminders()) + #expect(distinct.map(\.id) == ["abce5678", "abcd1234"]) + } + @Test("Resolve numeric indexes from filtered show output") func resolveIndexFromFilteredShowOutput() throws { let all = sampleReminders() From fa8ca21f84e0e23b63a5a320666e0bd15f7fb9cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Oct 2026 04:46:32 -0700 Subject: [PATCH 2/2] test: cover duplicate reminder target resolution Cover repeated IDs and indexes, mixed prefixes, filtered numeric views, first-input order, and validation after duplicates. Document unique delete and complete targets. Co-authored-by: Sebastien Tardif --- CHANGELOG.md | 2 ++ Tests/RemindCoreTests/IDResolverTests.swift | 39 ++++++++++++++++----- docs/commands.md | 1 + 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6cb48e..d45c957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Tests/RemindCoreTests/IDResolverTests.swift b/Tests/RemindCoreTests/IDResolverTests.swift index 345699e..bc7244e 100644 --- a/Tests/RemindCoreTests/IDResolverTests.swift +++ b/Tests/RemindCoreTests/IDResolverTests.swift @@ -38,14 +38,37 @@ struct IDResolverTests { #expect(resolved.first?.title == "First") } - @Test("Duplicate ids resolve once before a delete prompt") - func duplicateIDsResolveOnce() throws { - let repeated = try IDResolver.resolve(["1", "1"], from: sampleReminders()) - #expect(repeated.map(\.id) == ["abcd1234"]) - let mixed = try IDResolver.resolve(["1", "abcd"], from: sampleReminders()) - #expect(mixed.map(\.id) == ["abcd1234"]) - let distinct = try IDResolver.resolve(["2", "1"], from: sampleReminders()) - #expect(distinct.map(\.id) == ["abce5678", "abcd1234"]) + @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") diff --git a/docs/commands.md b/docs/commands.md index 9045254..8567547 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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.