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")) + } +}