feat: add simple recurrence support

Co-authored-by: Weber Wei <weberwcwei@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-05-04 05:54:01 +01:00
parent b7f1ac959f
commit 09b25089e1
No known key found for this signature in database
11 changed files with 243 additions and 7 deletions

View File

@ -1,6 +1,7 @@
# Changelog
## 0.2.0 - 2026-05-04
- Add simple recurrence support via `--repeat` and `--no-repeat`
- Add EventKit alarm support via `--alarm` and `--clear-alarm`
- Add reminder `url` to JSON output when EventKit exposes one
- Add `creationDate` to reminder JSON output

View File

@ -51,6 +51,7 @@ remindctl list Projects --create
remindctl add "Buy milk"
remindctl add --title "Call mom" --list Personal --due tomorrow
remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55"
remindctl add "Take vitamins" --due tomorrow --repeat daily
remindctl edit 1 --title "New title" --due 2026-01-04 --clear-alarm
remindctl list Work Errands # show reminders from multiple lists
remindctl complete 1 2 3
@ -63,7 +64,7 @@ remindctl authorize # request permissions
- `--json` emits JSON arrays/objects.
- `--plain` emits tab-separated lines.
- `--quiet` emits counts only.
- JSON includes EventKit metadata such as `creationDate`, `url`, and `alarmDate` when available.
- JSON includes EventKit metadata such as `creationDate`, `url`, `alarmDate`, and `recurrenceRule` when available.
File/image attachments are not exposed by EventKit.
## Date formats
@ -78,6 +79,13 @@ Date-only due inputs create all-day reminders; date-time inputs create timed rem
Timed due reminders get a notification alarm at the due time unless `--alarm` sets a different alarm time.
Use `edit <id> --clear-alarm` to remove an alarm.
## Repeat
Use `--repeat` with `add` or `edit` for simple recurrence:
- `daily`, `weekly`, `biweekly`, `monthly`, `yearly`
- `every N days/weeks/months/years`
Use `edit <id> --no-repeat` to clear recurrence.
## Permissions
Run `remindctl authorize` to trigger the system prompt. If access is denied, enable
Terminal (or remindctl) in System Settings → Privacy & Security → Reminders.

View File

@ -113,6 +113,9 @@ public actor RemindersStore {
} else if let dueDate = draft.dueDate, !dueDate.isDateOnly {
reminder.addAlarm(EKAlarm(absoluteDate: dueDate.date))
}
if let recurrenceRule = draft.recurrenceRule {
replaceRecurrence(on: reminder, with: recurrenceRule)
}
try eventStore.save(reminder, commit: true)
return item(from: reminder)
}
@ -140,6 +143,9 @@ public actor RemindersStore {
if let alarmDateUpdate = update.alarmDate {
replaceAlarms(on: reminder, with: alarmDateUpdate?.date)
}
if let recurrenceUpdate = update.recurrenceRule {
replaceRecurrence(on: reminder, with: recurrenceUpdate)
}
if let priority = update.priority {
reminder.priority = priority.eventKitValue
}
@ -203,6 +209,7 @@ extension RemindersStore {
let dueDateComponents: DateComponents?
let dueDateIsAllDay: Bool
let alarmDate: Date?
let recurrenceRule: RecurrenceRule?
let listID: String
let listName: String
}
@ -224,6 +231,7 @@ extension RemindersStore {
dueDateComponents: components,
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
@ -245,6 +253,7 @@ extension RemindersStore {
dueDate: date(from: data.dueDateComponents),
dueDateIsAllDay: data.dueDateIsAllDay,
alarmDate: data.alarmDate,
recurrenceRule: data.recurrenceRule,
listID: data.listID,
listName: data.listName
)
@ -296,6 +305,7 @@ extension RemindersStore {
dueDate: date(from: components),
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
@ -315,4 +325,55 @@ extension RemindersStore {
.compactMap(\.absoluteDate)
.min()
}
private func replaceRecurrence(on reminder: EKReminder, with rule: RecurrenceRule?) {
for existing in reminder.recurrenceRules ?? [] {
reminder.removeRecurrenceRule(existing)
}
guard let rule else { return }
reminder.addRecurrenceRule(
EKRecurrenceRule(recurrenceWith: rule.eventKitFrequency, interval: rule.interval, end: nil))
}
private static func recurrenceRule(from reminder: EKReminder) -> RecurrenceRule? {
guard let rule = reminder.recurrenceRules?.first else { return nil }
guard let frequency = RecurrenceFrequency(eventKitFrequency: rule.frequency) else { return nil }
return RecurrenceRule(frequency: frequency, interval: rule.interval)
}
}
extension RecurrenceFrequency {
fileprivate init?(eventKitFrequency: EKRecurrenceFrequency) {
switch eventKitFrequency {
case .daily:
self = .daily
case .weekly:
self = .weekly
case .monthly:
self = .monthly
case .yearly:
self = .yearly
@unknown default:
return nil
}
}
fileprivate var eventKitFrequency: EKRecurrenceFrequency {
switch self {
case .daily:
return .daily
case .weekly:
return .weekly
case .monthly:
return .monthly
case .yearly:
return .yearly
}
}
}
extension RecurrenceRule {
fileprivate var eventKitFrequency: EKRecurrenceFrequency {
frequency.eventKitFrequency
}
}

View File

@ -33,6 +33,37 @@ public enum ReminderPriority: String, Codable, CaseIterable, Sendable {
}
}
public enum RecurrenceFrequency: String, Codable, CaseIterable, Sendable {
case daily
case weekly
case monthly
case yearly
}
public struct RecurrenceRule: Codable, Sendable, Equatable {
public let frequency: RecurrenceFrequency
public let interval: Int
public init(frequency: RecurrenceFrequency, interval: Int = 1) {
self.frequency = frequency
self.interval = interval
}
public var displayString: String {
if interval == 1 {
return frequency.rawValue
}
let unit =
switch frequency {
case .daily: "days"
case .weekly: "weeks"
case .monthly: "months"
case .yearly: "years"
}
return "every \(interval) \(unit)"
}
}
public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
public let id: String
public let title: String
@ -55,6 +86,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public let dueDate: Date?
public let dueDateIsAllDay: Bool
public let alarmDate: Date?
public let recurrenceRule: RecurrenceRule?
public let listID: String
public let listName: String
@ -70,6 +102,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
dueDate: Date?,
dueDateIsAllDay: Bool = false,
alarmDate: Date? = nil,
recurrenceRule: RecurrenceRule? = nil,
listID: String,
listName: String
) {
@ -84,6 +117,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
self.dueDate = dueDate
self.dueDateIsAllDay = dueDateIsAllDay
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.listID = listID
self.listName = listName
}
@ -94,6 +128,7 @@ public struct ReminderDraft: Sendable {
public let notes: String?
public let dueDate: ParsedUserDate?
public let alarmDate: ParsedUserDate?
public let recurrenceRule: RecurrenceRule?
public let priority: ReminderPriority
public init(
@ -101,12 +136,14 @@ public struct ReminderDraft: Sendable {
notes: String?,
dueDate: ParsedUserDate?,
alarmDate: ParsedUserDate? = nil,
recurrenceRule: RecurrenceRule? = nil,
priority: ReminderPriority
) {
self.title = title
self.notes = notes
self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.priority = priority
}
}
@ -116,6 +153,7 @@ public struct ReminderUpdate: Sendable {
public let notes: String?
public let dueDate: ParsedUserDate??
public let alarmDate: ParsedUserDate??
public let recurrenceRule: RecurrenceRule??
public let priority: ReminderPriority?
public let listName: String?
public let isCompleted: Bool?
@ -125,6 +163,7 @@ public struct ReminderUpdate: Sendable {
notes: String? = nil,
dueDate: ParsedUserDate?? = nil,
alarmDate: ParsedUserDate?? = nil,
recurrenceRule: RecurrenceRule?? = nil,
priority: ReminderPriority? = nil,
listName: String? = nil,
isCompleted: Bool? = nil
@ -133,6 +172,7 @@ public struct ReminderUpdate: Sendable {
self.notes = notes
self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.priority = priority
self.listName = listName
self.isCompleted = isCompleted

View File

@ -23,4 +23,53 @@ enum CommandHelpers {
}
return parsed
}
static func parseRecurrence(_ value: String) throws -> RecurrenceRule {
let normalized = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
switch normalized {
case "daily":
return RecurrenceRule(frequency: .daily)
case "weekly":
return RecurrenceRule(frequency: .weekly)
case "biweekly":
return RecurrenceRule(frequency: .weekly, interval: 2)
case "monthly":
return RecurrenceRule(frequency: .monthly)
case "yearly", "annually":
return RecurrenceRule(frequency: .yearly)
default:
return try parseCustomRecurrence(normalized, original: value)
}
}
private static func parseCustomRecurrence(_ normalized: String, original: String) throws -> RecurrenceRule {
let parts = normalized.split(separator: " ")
guard parts.count == 3, parts[0] == "every", let interval = Int(parts[1]), interval > 0 else {
throw invalidRecurrence(original)
}
let frequency: RecurrenceFrequency
switch parts[2] {
case "day", "days":
frequency = .daily
case "week", "weeks":
frequency = .weekly
case "month", "months":
frequency = .monthly
case "year", "years":
frequency = .yearly
default:
throw invalidRecurrence(original)
}
return RecurrenceRule(frequency: frequency, interval: interval)
}
private static func invalidRecurrence(_ value: String) -> RemindCoreError {
RemindCoreError.operationFailed(
"""
Invalid repeat value: "\(value)" \
(use daily|weekly|biweekly|monthly|yearly or "every N days/weeks/months/years")
"""
)
}
}

View File

@ -19,6 +19,12 @@ enum AddCommand {
.make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue),
.make(label: "alarm", names: [.short("a"), .long("alarm")], help: "Alarm date", parsing: .singleValue),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue),
.make(
label: "repeat",
names: [.short("r"), .long("repeat")],
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
parsing: .singleValue
),
.make(
label: "priority",
names: [.short("p"), .long("priority")],
@ -32,6 +38,7 @@ enum AddCommand {
"remindctl add \"Buy milk\"",
"remindctl add --title \"Call mom\" --list Personal --due tomorrow",
"remindctl add \"Call mom\" --due \"2026-01-03 09:00\" --alarm \"2026-01-03 08:55\"",
"remindctl add \"Take vitamins\" --due tomorrow --repeat daily",
"remindctl add \"Review docs\" --priority high",
]
) { values, runtime in
@ -58,10 +65,12 @@ enum AddCommand {
let notes = values.option("notes")
let dueValue = values.option("due")
let alarmValue = values.option("alarm")
let repeatValue = values.option("repeat")
let priorityValue = values.option("priority")
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate)
let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence)
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
let store = RemindersStore()
@ -82,6 +91,7 @@ enum AddCommand {
notes: notes,
dueDate: dueDate,
alarmDate: alarmDate,
recurrenceRule: recurrenceRule,
priority: priority
)
let reminder = try await store.createReminder(draft, listName: targetList)

View File

@ -19,6 +19,12 @@ enum EditCommand {
.make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue),
.make(label: "alarm", names: [.short("a"), .long("alarm")], help: "Set alarm date", parsing: .singleValue),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue),
.make(
label: "repeat",
names: [.short("r"), .long("repeat")],
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
parsing: .singleValue
),
.make(
label: "priority",
names: [.short("p"), .long("priority")],
@ -29,6 +35,7 @@ enum EditCommand {
flags: [
.make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"),
.make(label: "clearAlarm", names: [.long("clear-alarm")], help: "Clear alarm"),
.make(label: "noRepeat", names: [.long("no-repeat")], help: "Remove recurrence"),
.make(label: "complete", names: [.long("complete")], help: "Mark completed"),
.make(label: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"),
]
@ -38,8 +45,9 @@ enum EditCommand {
"remindctl edit 1 --title \"New title\"",
"remindctl edit 4A83 --due tomorrow",
"remindctl edit 4A83 --alarm \"2026-01-03 08:55\"",
"remindctl edit 4A83 --repeat weekly",
"remindctl edit 2 --priority high --notes \"Call before noon\"",
"remindctl edit 3 --clear-due --clear-alarm",
"remindctl edit 3 --clear-due --clear-alarm --no-repeat",
]
) { values, runtime in
guard let input = values.argument(0) else {
@ -58,6 +66,7 @@ enum EditCommand {
let listName = values.option("list")
let notes = values.option("notes")
let alarmValue = values.option("alarm")
let repeatValue = values.option("repeat")
var dueUpdate: ParsedUserDate??
if let dueValue = values.option("due") {
@ -81,6 +90,17 @@ enum EditCommand {
alarmUpdate = .some(nil)
}
var recurrenceUpdate: RecurrenceRule??
if let repeatValue {
recurrenceUpdate = try CommandHelpers.parseRecurrence(repeatValue)
}
if values.flag("noRepeat") {
if recurrenceUpdate != nil {
throw RemindCoreError.operationFailed("Use either --repeat or --no-repeat, not both")
}
recurrenceUpdate = .some(nil)
}
var priority: ReminderPriority?
if let priorityValue = values.option("priority") {
priority = try CommandHelpers.parsePriority(priorityValue)
@ -95,7 +115,7 @@ enum EditCommand {
let hasChanges =
title != nil || listName != nil || notes != nil || dueUpdate != nil || alarmUpdate != nil || priority != nil
|| isCompleted != nil
|| recurrenceUpdate != nil || isCompleted != nil
if !hasChanges {
throw RemindCoreError.operationFailed("No changes specified")
}
@ -105,6 +125,7 @@ enum EditCommand {
notes: notes,
dueDate: dueUpdate,
alarmDate: alarmUpdate,
recurrenceRule: recurrenceUpdate,
priority: priority,
listName: listName,
isCompleted: isCompleted

View File

@ -54,7 +54,8 @@ enum OutputRenderer {
reminder.dueDate.map {
DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay)
} ?? "no due date"
Swift.print("\(reminder.title) [\(reminder.listName)] — \(due)")
let recurrence = recurrenceSuffix(for: reminder)
Swift.print("\(reminder.title) [\(reminder.listName)] — \(due)\(recurrence)")
case .plain:
Swift.print(plainLine(for: reminder))
case .json:
@ -104,7 +105,9 @@ enum OutputRenderer {
DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay)
} ?? "no due date"
let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)"
Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)")
let recurrence = recurrenceSuffix(for: reminder)
Swift.print(
"[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)\(recurrence)")
}
}
@ -179,4 +182,8 @@ enum OutputRenderer {
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}
private static func recurrenceSuffix(for reminder: ReminderItem) -> String {
reminder.recurrenceRule.map { " repeat=\($0.displayString)" } ?? ""
}
}

View File

@ -18,6 +18,7 @@ struct ReminderItemCodingTests {
priority: .none,
dueDate: nil,
alarmDate: Date(timeIntervalSince1970: 1_700_000_300),
recurrenceRule: RecurrenceRule(frequency: .weekly, interval: 2),
listID: "list",
listName: "Inbox"
)
@ -29,5 +30,6 @@ struct ReminderItemCodingTests {
#expect(json.contains(#""creationDate""#))
#expect(json.contains(#""url":"https:\/\/example.com""#))
#expect(json.contains(#""alarmDate""#))
#expect(json.contains(#""recurrenceRule""#))
}
}

View File

@ -22,13 +22,16 @@ struct HelpPrinterTests {
#expect(joined.contains("authorize"))
}
@Test("Add and edit help include alarm options")
func alarmHelp() {
@Test("Add and edit help include alarm and repeat options")
func alarmAndRepeatHelp() {
let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n")
let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n")
#expect(addHelp.contains("--alarm"))
#expect(addHelp.contains("--repeat"))
#expect(editHelp.contains("--alarm"))
#expect(editHelp.contains("--clear-alarm"))
#expect(editHelp.contains("--repeat"))
#expect(editHelp.contains("--no-repeat"))
}
}

View File

@ -0,0 +1,34 @@
import Testing
@testable import RemindCore
@testable import remindctl
@MainActor
struct RecurrenceParsingTests {
@Test("Parse repeat presets")
func parsePresets() throws {
#expect(try CommandHelpers.parseRecurrence("daily") == RecurrenceRule(frequency: .daily))
#expect(try CommandHelpers.parseRecurrence("weekly") == RecurrenceRule(frequency: .weekly))
#expect(try CommandHelpers.parseRecurrence("biweekly") == RecurrenceRule(frequency: .weekly, interval: 2))
#expect(try CommandHelpers.parseRecurrence("monthly") == RecurrenceRule(frequency: .monthly))
#expect(try CommandHelpers.parseRecurrence("yearly") == RecurrenceRule(frequency: .yearly))
}
@Test("Parse custom repeat interval")
func parseCustomInterval() throws {
#expect(try CommandHelpers.parseRecurrence("every 3 days") == RecurrenceRule(frequency: .daily, interval: 3))
#expect(try CommandHelpers.parseRecurrence("every 4 weeks") == RecurrenceRule(frequency: .weekly, interval: 4))
#expect(try CommandHelpers.parseRecurrence("every 6 months") == RecurrenceRule(frequency: .monthly, interval: 6))
#expect(try CommandHelpers.parseRecurrence("every 2 years") == RecurrenceRule(frequency: .yearly, interval: 2))
}
@Test("Reject invalid repeat interval")
func rejectInvalidInterval() {
#expect(throws: (any Error).self) {
try CommandHelpers.parseRecurrence("every 0 weeks")
}
#expect(throws: (any Error).self) {
try CommandHelpers.parseRecurrence("weekdaily")
}
}
}