diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e7e5a..d6067c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 143ce52..c5e59df 100644 --- a/README.md +++ b/README.md @@ -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 --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 --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. diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index 2f0d739..752a364 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -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 + } } diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5c9522f..de38516 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -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 diff --git a/Sources/remindctl/CommandHelpers.swift b/Sources/remindctl/CommandHelpers.swift index c4b3adf..c4ae394 100644 --- a/Sources/remindctl/CommandHelpers.swift +++ b/Sources/remindctl/CommandHelpers.swift @@ -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") + """ + ) + } } diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index b335526..7c50c73 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -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) diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index 78be0e1..a7a7992 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -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 diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index 63e901b..6dac77d 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -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)" } ?? "" + } } diff --git a/Tests/RemindCoreTests/ReminderItemCodingTests.swift b/Tests/RemindCoreTests/ReminderItemCodingTests.swift index 6680ad8..13dad96 100644 --- a/Tests/RemindCoreTests/ReminderItemCodingTests.swift +++ b/Tests/RemindCoreTests/ReminderItemCodingTests.swift @@ -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""#)) } } diff --git a/Tests/remindctlTests/HelpPrinterTests.swift b/Tests/remindctlTests/HelpPrinterTests.swift index 07b2a31..efafaf3 100644 --- a/Tests/remindctlTests/HelpPrinterTests.swift +++ b/Tests/remindctlTests/HelpPrinterTests.swift @@ -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")) } } diff --git a/Tests/remindctlTests/RecurrenceParsingTests.swift b/Tests/remindctlTests/RecurrenceParsingTests.swift new file mode 100644 index 0000000..6f4490b --- /dev/null +++ b/Tests/remindctlTests/RecurrenceParsingTests.swift @@ -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") + } + } +}