Skip to content
Open
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
79 changes: 76 additions & 3 deletions Sources/RemindersLibrary/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,46 @@ 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: " "),
notes: self.notes,
toListNamed: self.listName,
dueDateComponents: self.dueDate,
priority: priority,
recurrence: self.repeat_,
recurrenceInterval: self.repeatInterval,
recurrenceEnd: self.repeatUntil,
outputFormat: format)
}
}
Expand Down Expand Up @@ -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")
}
}

Expand All @@ -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
)
}
}
Expand Down
19 changes: 19 additions & 0 deletions Sources/RemindersLibrary/EKReminder+Encodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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? {
Expand Down
66 changes: 65 additions & 1 deletion Sources/RemindersLibrary/Reminders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions Tests/RemindersTests/RecurrenceTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading