diff --git a/README.md b/README.md index efd46e3..19648a1 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,30 @@ $ 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, inclusive of that day (a date without a time means +the end of that day); omitting it repeats forever, matching the Reminders.app default. A repeating +reminder must have a due date, so `--repeat` requires `--due-date` on `add` (and on `edit` for a +reminder that has none). 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 ad2bbef..34c6162 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -153,6 +153,35 @@ private struct Add: ParsableCommand { help: "The notes to add to the reminder") var notes: String? + @Option( + name: [.customLong("repeat")], + help: ArgumentHelp( + "Repeat the reminder: daily, weekly, monthly or yearly. Requires --due-date.", + valueName: "frequency")) + var repeat_: Recurrence? + + @Option( + name: .long, + help: ArgumentHelp( + "With --repeat, repeat every N units instead of every 1.", + valueName: "n")) + var repeatInterval: Int = 1 + + @Option( + name: .long, + help: ArgumentHelp( + "With --repeat, stop repeating after this date (inclusive). Default: repeat forever.", + valueName: "date")) + var repeatUntil: DateComponents? + + func validate() throws { + try validateRepeatOptions( + repeat_: repeat_, interval: repeatInterval, until: repeatUntil) + if repeat_ != nil && dueDate == nil { + throw ValidationError("--repeat requires a due date (--due-date)") + } + } + func run() { reminders.addReminder( string: self.reminder.joined(separator: " "), @@ -160,6 +189,9 @@ private struct Add: ParsableCommand { toListNamed: self.listName, dueDateComponents: self.dueDate, priority: priority, + recurrence: self.repeat_, + recurrenceInterval: self.repeatInterval, + recurrenceEnd: self.repeatUntil, outputFormat: format) } } @@ -250,6 +282,32 @@ private struct Edit: ParsableCommand { @Flag(help: "Remove the due date from the reminder") var clearDueDate = false + @Option( + name: [.customLong("repeat")], + help: ArgumentHelp( + "Set or replace the repeat rule: daily, weekly, monthly or yearly. Requires a due date.", + valueName: "frequency")) + var repeat_: Recurrence? + + @Option( + name: .long, + help: ArgumentHelp( + "With --repeat, repeat every N units instead of every 1.", + valueName: "n")) + var repeatInterval: Int = 1 + + @Option( + name: .long, + help: ArgumentHelp( + "With --repeat, stop repeating after this date (inclusive). Default: repeat forever.", + valueName: "date")) + 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") @@ -259,11 +317,20 @@ private struct Edit: ParsableCommand { if self.dueDate != nil && self.clearDueDate { throw ValidationError("Cannot specify both --due-date and --clear-due-date") } - - if self.reminder.isEmpty && self.notes == nil && self.dueDate == nil && !self.clearDueDate { + if self.clearRepeat && self.repeat_ != nil { + throw ValidationError("Cannot specify both --repeat and --clear-repeat") + } + if self.repeat_ != nil && self.clearDueDate { + throw ValidationError("Cannot specify both --repeat and --clear-due-date: a repeating reminder needs a due date") + } + if self.reminder.isEmpty && self.notes == nil && self.dueDate == nil && !self.clearDueDate + && self.repeat_ == nil && !self.clearRepeat + { throw ValidationError( - "Must specify either new reminder content, new notes, or a due date change") + "Must specify new reminder content, new notes, a due date change, --repeat, or --clear-repeat") } + try validateRepeatOptions( + repeat_: repeat_, interval: repeatInterval, until: repeatUntil) } func run() { @@ -274,7 +341,11 @@ private struct Edit: ParsableCommand { newText: newText.isEmpty ? nil : newText, newNotes: self.notes, newDueDateComponents: self.dueDate, - clearDueDate: self.clearDueDate + clearDueDate: self.clearDueDate, + newRecurrence: self.repeat_, + newRecurrenceInterval: self.repeatInterval, + newRecurrenceEnd: self.repeatUntil, + clearRecurrence: self.clearRepeat ) } } diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 5c650c3..b466fa1 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -35,6 +35,64 @@ 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 // never used: .hourly is rejected by validateRepeatOptions + case .daily: return .daily + case .weekly: return .weekly + case .monthly: return .monthly + case .yearly: return .yearly + } + } + + /// EventKit has no hourly `EKRecurrenceFrequency`, so `.hourly` exists only so that + /// `--repeat hourly` fails with an explanation instead of ArgumentParser's generic + /// "invalid value" message. + var isRepresentable: Bool { + self != .hourly + } + + /// `--repeat-until 2026-10-24` means "through the end of that day": a date-only value + /// resolves to 23:59:59 so an occurrence on that day with a due time is kept. A value + /// with an explicit time is used as given. + static func endDate(from components: DateComponents) -> Date? { + guard let date = components.date else { return nil } + if components.hour != nil { return date } + let calendar = components.calendar ?? Calendar.current + return calendar.date(byAdding: DateComponents(day: 1, second: -1), to: date) + } + + func recurrenceRule(interval: Int, until: Date?) -> EKRecurrenceRule { + let end = until.map { EKRecurrenceEnd(end: $0) } + return EKRecurrenceRule( + recurrenceWith: self.frequency, + interval: interval, + end: end) + } +} + +func validateRepeatOptions(repeat_: Recurrence?, interval: Int, until: DateComponents?) 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 interval < 1 { + throw ValidationError("--repeat-interval must be at least 1") + } + if repeat_ == nil && (interval != 1 || until != nil) { + throw ValidationError("--repeat-interval and --repeat-until require --repeat") + } +} + public enum Priority: String, ExpressibleByArgument { case none case low @@ -236,7 +294,11 @@ public final class Reminders { newText: String?, newNotes: String?, newDueDateComponents: DateComponents? = nil, - clearDueDate: Bool = false) + clearDueDate: Bool = false, + newRecurrence: Recurrence? = nil, + newRecurrenceInterval: Int = 1, + newRecurrenceEnd: DateComponents? = nil, + clearRecurrence: Bool = false) { let calendar = self.calendar(withName: name) let semaphore = DispatchSemaphore(value: 0) @@ -251,6 +313,18 @@ public final class Reminders { reminder.title = newText ?? reminder.title reminder.notes = newNotes ?? reminder.notes + if clearRecurrence || newRecurrence != nil { + for rule in reminder.recurrenceRules ?? [] { + reminder.removeRecurrenceRule(rule) + } + } + if let newRecurrence = newRecurrence { + reminder.addRecurrenceRule( + newRecurrence.recurrenceRule( + interval: newRecurrenceInterval, + until: newRecurrenceEnd.flatMap(Recurrence.endDate(from:)))) + } + if clearDueDate { reminder.dueDateComponents = nil for alarm in reminder.alarms ?? [] { @@ -267,6 +341,15 @@ public final class Reminders { } } + if reminder.dueDateComponents == nil, !(reminder.recurrenceRules ?? []).isEmpty { + if newRecurrence != nil { + print("--repeat requires a due date: this reminder has none, set one with --due-date") + } else { + print("A repeating reminder needs a due date: use --clear-repeat with --clear-due-date, or keep the due date") + } + exit(1) + } + try Store.save(reminder, commit: true) print("Updated reminder '\(reminder.title!)'") } catch let error { @@ -348,6 +431,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) @@ -360,6 +446,12 @@ 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.flatMap(Recurrence.endDate(from:))) + 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..26fd241 --- /dev/null +++ b/Tests/RemindersTests/RecurrenceTests.swift @@ -0,0 +1,122 @@ +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")) + } + + func testUntilDateOnlyIsInclusiveOfThatDay() throws { + let components = try XCTUnwrap(DateComponents(argument: "2026-10-24")) + let end = try XCTUnwrap(Recurrence.endDate(from: components)) + let calendar = Calendar(identifier: .gregorian) + let nineAM = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 10, day: 24, hour: 9))) + let nextDay = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 10, day: 25, hour: 0))) + XCTAssertGreaterThan(end, nineAM) + XCTAssertLessThan(end, nextDay) + } + + func testUntilWithExplicitTimeIsUsedAsGiven() throws { + let components = try XCTUnwrap(DateComponents(argument: "2026-10-24 10:00")) + let end = try XCTUnwrap(Recurrence.endDate(from: components)) + XCTAssertEqual(end, components.date) + } + + func testValidateRejectsHourly() throws { + XCTAssertThrowsError(try validateRepeatOptions(repeat_: .hourly, interval: 1, until: nil)) + } + + func testValidateRejectsBadInterval() throws { + XCTAssertThrowsError(try validateRepeatOptions(repeat_: .daily, interval: 0, until: nil)) + } + + func testValidateRequiresRepeatForIntervalAndUntil() throws { + XCTAssertThrowsError(try validateRepeatOptions(repeat_: nil, interval: 2, until: nil)) + let until = try XCTUnwrap(DateComponents(argument: "2026-10-24")) + XCTAssertThrowsError(try validateRepeatOptions(repeat_: nil, interval: 1, until: until)) + XCTAssertNoThrow(try validateRepeatOptions(repeat_: .weekly, interval: 2, until: until)) + } + + func testAddRepeatRequiresDueDate() throws { + XCTAssertThrowsError(try CLI.parseAsRoot(["add", "L", "x", "--repeat", "daily"])) { error in + XCTAssertTrue( + CLI.message(for: error).contains("requires a due date"), "\(error)") + } + XCTAssertNoThrow( + try CLI.parseAsRoot(["add", "L", "x", "--due-date", "2026-10-24", "--repeat", "daily"])) + } + + func testAddRepeatOptionsRequireRepeat() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot(["add", "L", "x", "--due-date", "2026-10-24", "--repeat-interval", "2"])) + } + + func testEditRepeatParsing() throws { + XCTAssertNoThrow(try CLI.parseAsRoot(["edit", "L", "0", "--repeat", "monthly"])) + XCTAssertNoThrow(try CLI.parseAsRoot(["edit", "L", "0", "--clear-repeat"])) + XCTAssertThrowsError( + try CLI.parseAsRoot(["edit", "L", "0", "--repeat", "daily", "--clear-repeat"])) + XCTAssertThrowsError( + try CLI.parseAsRoot(["edit", "L", "0", "--repeat", "daily", "--clear-due-date"])) + XCTAssertThrowsError(try CLI.parseAsRoot(["edit", "L", "0", "--repeat-interval", "2"])) + XCTAssertThrowsError(try CLI.parseAsRoot(["edit", "L", "0", "--repeat", "hourly"])) + } +}