From 8891097d32f1487a7b051a89ffa6b4459d4b9cc7 Mon Sep 17 00:00:00 2001 From: Ramesh Baskaran Date: Fri, 7 Aug 2026 11:30:30 +0400 Subject: [PATCH] Add --repeat recurrence support for add/edit (closes #104) Implements EKRecurrenceRule-backed recurrence, requested in #104: - `reminders add --repeat daily|weekly|monthly|yearly` - `--repeat-interval N` to repeat every N units instead of every 1 (default 1), e.g. `--repeat-interval 2 --repeat weekly` for fortnightly. - `--repeat-until ` to set a recurrence end date; omitting it repeats forever, matching Reminders.app's own default. - `reminders edit --repeat ...` replaces an existing repeat rule; `--clear-repeat` removes it entirely. - JSON output (`--format json`) now includes `recurrence`, `recurrenceInterval`, and `recurrenceEnd` fields when a reminder has a recurrence rule, so scripts consuming `reminders show --format json` can see it. `hourly` is intentionally rejected with a clear validation error rather than silently degrading to a daily rule: EventKit's `EKRecurrenceFrequency` has no hourly case (Reminders.app's own UI doesn't expose hourly repeat either), so there's no faithful way to represent it via `EKRecurrenceRule`. Tested live against real Reminders.app data (add with daily/weekly/ monthly + custom interval + end date, edit to replace and to clear, hourly correctly rejected, --repeat-interval/--repeat-until correctly rejected without --repeat, conflicting --repeat + --clear-repeat correctly rejected). Added Tests/RemindersTests/RecurrenceTests.swift covering the frequency mapping, interval, end-date, and representability logic at the unit level. Note on test execution: this dev environment currently has only Command Line Tools installed (no full Xcode), so `swift test` can't link XCTest here (`xcrun --find xctest` fails) -- this affects the pre-existing NaturalLanguageTests.swift identically, so it's an environment gap, not something this change introduces. `swift build` succeeds cleanly and all behavior was verified against the live binary instead; the new test file is plain XCTest and should run normally under CI or on a machine with full Xcode. --- README.md | 22 ++++++ Sources/RemindersLibrary/CLI.swift | 79 ++++++++++++++++++- .../EKReminder+Encodable.swift | 19 +++++ Sources/RemindersLibrary/Reminders.swift | 66 +++++++++++++++- Tests/RemindersTests/RecurrenceTests.swift | 64 +++++++++++++++ 5 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 Tests/RemindersTests/RecurrenceTests.swift diff --git a/README.md b/README.md index 4d7b7cc..22c611d 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,28 @@ $ reminders show Soon 3: Something really important (priority: high) ``` +#### Add a repeating reminder + +``` +$ reminders add Soon Weekly review --due-date "monday 9am" --repeat weekly +$ reminders add Soon Pay rent --due-date "2026-09-01" --repeat monthly --repeat-until "2027-09-01" +$ reminders add Soon Water the plants --due-date "tomorrow" --repeat daily --repeat-interval 3 +``` + +`--repeat` accepts `daily`, `weekly`, `monthly`, or `yearly` (EventKit reminders have no hourly +recurrence frequency, so `--repeat hourly` is rejected with an explanation rather than silently +degrading to daily). `--repeat-interval` repeats every N units instead of every 1 (e.g. +`--repeat-interval 2 --repeat weekly` for every other week) and defaults to 1. `--repeat-until` +stops the recurrence after a given date; omitting it repeats forever, matching the Reminders.app +default. Both `--repeat-interval` and `--repeat-until` require `--repeat` to also be set. + +To change or remove a repeat rule on an existing reminder, use `edit`: + +``` +$ reminders edit Soon 0 --repeat monthly +$ reminders edit Soon 0 --clear-repeat +``` + #### Show reminders due on or by a date ``` diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index 8978505..4c5198e 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -153,6 +153,36 @@ private struct Add: ParsableCommand { help: "The notes to add to the reminder") var notes: String? + @Option( + name: [.customLong("repeat")], + help: "Repeat the reminder, one of: daily, weekly, monthly, yearly") + var repeat_: Recurrence? + + @Option( + name: .long, + help: "Repeat every N units of --repeat's frequency instead of every 1 (default: 1)") + var repeatInterval: Int = 1 + + @Option( + name: .long, + help: "Stop repeating after this date (default: repeats forever)") + var repeatUntil: DateComponents? + + func validate() throws { + if let repeat_ = repeat_, !repeat_.isRepresentable { + throw ValidationError( + "--repeat \(repeat_.rawValue) is not supported: EventKit reminders have no hourly " + + "recurrence frequency (Reminders.app itself doesn't expose this either). Use " + + "daily, weekly, monthly, or yearly.") + } + if repeatInterval < 1 { + throw ValidationError("--repeat-interval must be at least 1") + } + if repeat_ == nil && (repeatInterval != 1 || repeatUntil != nil) { + throw ValidationError("--repeat-interval and --repeat-until require --repeat") + } + } + func run() { reminders.addReminder( string: self.reminder.joined(separator: " "), @@ -160,6 +190,9 @@ private struct Add: ParsableCommand { toListNamed: self.listName, dueDateComponents: self.dueDate, priority: priority, + recurrence: self.repeat_, + recurrenceInterval: self.repeatInterval, + recurrenceEnd: self.repeatUntil, outputFormat: format) } } @@ -242,14 +275,50 @@ private struct Edit: ParsableCommand { help: "The notes to set on the reminder, overwriting previous notes") var notes: String? + @Option( + name: [.customLong("repeat")], + help: "Set (or replace) the reminder's repeat, one of: daily, weekly, monthly, yearly") + var repeat_: Recurrence? + + @Option( + name: .long, + help: "Repeat every N units of --repeat's frequency instead of every 1 (default: 1)") + var repeatInterval: Int = 1 + + @Option( + name: .long, + help: "Stop repeating after this date (default: repeats forever)") + var repeatUntil: DateComponents? + + @Flag( + name: .long, + help: "Remove any repeat rule from the reminder") + var clearRepeat = false + @Argument( parsing: .remaining, help: "The new reminder contents") var reminder: [String] = [] func validate() throws { - if self.reminder.isEmpty && self.notes == nil { - throw ValidationError("Must specify either new reminder content or new notes") + if self.reminder.isEmpty && self.notes == nil && self.repeat_ == nil && !self.clearRepeat { + throw ValidationError( + "Must specify new reminder content, new notes, --repeat, or --clear-repeat") + } + if self.clearRepeat && self.repeat_ != nil { + throw ValidationError("Cannot specify both --repeat and --clear-repeat") + } + if let repeat_ = repeat_, !repeat_.isRepresentable { + throw ValidationError( + "--repeat \(repeat_.rawValue) is not supported: EventKit reminders have no hourly " + + "recurrence frequency (Reminders.app itself doesn't expose this either). Use " + + "daily, weekly, monthly, or yearly.") + } + if repeatInterval < 1 { + throw ValidationError("--repeat-interval must be at least 1") + } + if repeat_ == nil && (repeatInterval != 1 || repeatUntil != nil) { + throw ValidationError("--repeat-interval and --repeat-until require --repeat") } } @@ -259,7 +328,11 @@ private struct Edit: ParsableCommand { itemAtIndex: self.index, onListNamed: self.listName, newText: newText.isEmpty ? nil : newText, - newNotes: self.notes + newNotes: self.notes, + newRecurrence: self.repeat_, + newRecurrenceInterval: self.repeatInterval, + newRecurrenceEnd: self.repeatUntil, + clearRecurrence: self.clearRepeat ) } } diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index f9f9fdb..e168914 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -16,6 +16,9 @@ extension EKReminder: @retroactive Encodable { case startDate case dueDate case list + case recurrence + case recurrenceInterval + case recurrenceEnd } public func encode(to encoder: Encoder) throws { @@ -58,6 +61,22 @@ extension EKReminder: @retroactive Encodable { if let creationDate = self.creationDate { try container.encode(format(creationDate), forKey: .creationDate) } + + if let rule = self.recurrenceRules?.first { + try container.encodeIfPresent(recurrenceName(for: rule.frequency), forKey: .recurrence) + try container.encode(rule.interval, forKey: .recurrenceInterval) + try container.encodeIfPresent(format(rule.recurrenceEnd?.endDate), forKey: .recurrenceEnd) + } + } + + private func recurrenceName(for frequency: EKRecurrenceFrequency) -> String? { + switch frequency { + case .daily: return "daily" + case .weekly: return "weekly" + case .monthly: return "monthly" + case .yearly: return "yearly" + @unknown default: return nil + } } private func format(_ date: Date?) -> String? { diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index fb04a20..e0e174c 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -35,6 +35,46 @@ public enum DisplayOptions: String, Decodable { case complete } +public enum Recurrence: String, ExpressibleByArgument { + case hourly + case daily + case weekly + case monthly + case yearly + + var frequency: EKRecurrenceFrequency { + switch self { + case .hourly: return .daily // EventKit has no hourly frequency; see interval note below. + case .daily: return .daily + case .weekly: return .weekly + case .monthly: return .monthly + case .yearly: return .yearly + } + } + + /// EventKit's `EKRecurrenceFrequency` has no hourly case, so `.hourly` is + /// modeled as a daily rule with a 1-day interval and 24 hourly recurrences + /// per day is not representable via `EKRecurrenceRule` alone. Since + /// reminders don't carry a native hourly repeat concept in EventKit (the + /// Reminders.app UI itself doesn't expose "hourly" either), `.hourly` is + /// intentionally rejected at parse time in `RecurrenceOption` rather than + /// silently degrading to daily. See CLI.swift for the actual validation; + /// this case is kept in the enum only so `--repeat hourly` produces a + /// clear, on-brand error message instead of an ArgumentParser "invalid + /// value" message with no explanation. + var isRepresentable: Bool { + self != .hourly + } + + func recurrenceRule(interval: Int, until: Date?) -> EKRecurrenceRule { + let end = until.map { EKRecurrenceEnd(end: $0) } + return EKRecurrenceRule( + recurrenceWith: self.frequency, + interval: interval, + end: end) + } +} + public enum Priority: String, ExpressibleByArgument { case none case low @@ -230,7 +270,11 @@ public final class Reminders { } } - func edit(itemAtIndex index: String, onListNamed name: String, newText: String?, newNotes: String?) { + func edit( + itemAtIndex index: String, onListNamed name: String, newText: String?, newNotes: String?, + newRecurrence: Recurrence?, newRecurrenceInterval: Int, newRecurrenceEnd: DateComponents?, + clearRecurrence: Bool) + { let calendar = self.calendar(withName: name) let semaphore = DispatchSemaphore(value: 0) @@ -243,6 +287,18 @@ public final class Reminders { do { reminder.title = newText ?? reminder.title reminder.notes = newNotes ?? reminder.notes + if clearRecurrence { + for rule in reminder.recurrenceRules ?? [] { + reminder.removeRecurrenceRule(rule) + } + } else if let newRecurrence = newRecurrence { + for rule in reminder.recurrenceRules ?? [] { + reminder.removeRecurrenceRule(rule) + } + let rule = newRecurrence.recurrenceRule( + interval: newRecurrenceInterval, until: newRecurrenceEnd?.date) + reminder.addRecurrenceRule(rule) + } try Store.save(reminder, commit: true) print("Updated reminder '\(reminder.title!)'") } catch let error { @@ -324,6 +380,9 @@ public final class Reminders { toListNamed name: String, dueDateComponents: DateComponents?, priority: Priority, + recurrence: Recurrence?, + recurrenceInterval: Int, + recurrenceEnd: DateComponents?, outputFormat: OutputFormat) { let calendar = self.calendar(withName: name) @@ -336,6 +395,11 @@ public final class Reminders { if let dueDate = dueDateComponents?.date, dueDateComponents?.hour != nil { reminder.addAlarm(EKAlarm(absoluteDate: dueDate)) } + if let recurrence = recurrence { + let rule = recurrence.recurrenceRule( + interval: recurrenceInterval, until: recurrenceEnd?.date) + reminder.addRecurrenceRule(rule) + } do { try Store.save(reminder, commit: true) diff --git a/Tests/RemindersTests/RecurrenceTests.swift b/Tests/RemindersTests/RecurrenceTests.swift new file mode 100644 index 0000000..bb60926 --- /dev/null +++ b/Tests/RemindersTests/RecurrenceTests.swift @@ -0,0 +1,64 @@ +import EventKit +@testable import RemindersLibrary +import XCTest + +final class RecurrenceTests: XCTestCase { + func testDailyFrequencyMapping() throws { + let rule = Recurrence.daily.recurrenceRule(interval: 1, until: nil) + XCTAssertEqual(rule.frequency, .daily) + XCTAssertEqual(rule.interval, 1) + XCTAssertNil(rule.recurrenceEnd) + } + + func testWeeklyFrequencyMapping() throws { + let rule = Recurrence.weekly.recurrenceRule(interval: 1, until: nil) + XCTAssertEqual(rule.frequency, .weekly) + } + + func testMonthlyFrequencyMapping() throws { + let rule = Recurrence.monthly.recurrenceRule(interval: 1, until: nil) + XCTAssertEqual(rule.frequency, .monthly) + } + + func testYearlyFrequencyMapping() throws { + let rule = Recurrence.yearly.recurrenceRule(interval: 1, until: nil) + XCTAssertEqual(rule.frequency, .yearly) + } + + func testCustomInterval() throws { + let rule = Recurrence.monthly.recurrenceRule(interval: 2, until: nil) + XCTAssertEqual(rule.interval, 2) + } + + func testRecurrenceEndDate() throws { + let end = Date() + let rule = Recurrence.weekly.recurrenceRule(interval: 1, until: end) + XCTAssertNotNil(rule.recurrenceEnd) + XCTAssertEqual( + rule.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, + end.timeIntervalSince1970, + accuracy: 1.0) + } + + func testHourlyIsNotRepresentable() throws { + // EventKit has no hourly EKRecurrenceFrequency; this is asserted at the + // model layer so CLI validation (which rejects it before ever building + // a rule) has something concrete to check against. + XCTAssertFalse(Recurrence.hourly.isRepresentable) + } + + func testRepresentableFrequenciesAreAllRepresentable() throws { + for frequency: Recurrence in [.daily, .weekly, .monthly, .yearly] { + XCTAssertTrue(frequency.isRepresentable, "\(frequency.rawValue) should be representable") + } + } + + func testRecurrenceParsesFromArgument() throws { + XCTAssertEqual(Recurrence(argument: "daily"), .daily) + XCTAssertEqual(Recurrence(argument: "weekly"), .weekly) + XCTAssertEqual(Recurrence(argument: "monthly"), .monthly) + XCTAssertEqual(Recurrence(argument: "yearly"), .yearly) + XCTAssertEqual(Recurrence(argument: "hourly"), .hourly) + XCTAssertNil(Recurrence(argument: "biweekly")) + } +}