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

```
Expand Down
79 changes: 75 additions & 4 deletions Sources/RemindersLibrary/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,45 @@ 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: " "),
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 @@ -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")
Expand All @@ -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() {
Expand All @@ -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
)
}
}
Expand Down
94 changes: 93 additions & 1 deletion Sources/RemindersLibrary/Reminders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 ?? [] {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading