From 22fc1087503ac0bc952dc9b82c7d97a0b674005f Mon Sep 17 00:00:00 2001 From: Weber Wei Date: Mon, 16 Mar 2026 09:41:28 -0500 Subject: [PATCH] feat: add recurrence rule support (--repeat/--no-repeat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for reading and writing EKRecurrenceRule on reminders: - New --repeat flag on add/edit: daily, weekly, biweekly, monthly, yearly, or custom intervals like 'every 3 months' - New --no-repeat flag on edit to remove recurrence - Display recurrence in standard output (🔄 every 2 weeks) - Include recurrenceRule in JSON output - New RecurrenceRule model with frequency and interval - Parse EKRecurrenceRule from EventKit and map bidirectionally --- Sources/RemindCore/EventKitStore.swift | 93 ++++++++++++-------- Sources/RemindCore/Models.swift | 49 ++++++++++- Sources/remindctl/CommandHelpers.swift | 34 +++++++ Sources/remindctl/Commands/AddCommand.swift | 11 ++- Sources/remindctl/Commands/EditCommand.swift | 27 +++++- Sources/remindctl/OutputFormatting.swift | 8 +- 6 files changed, 179 insertions(+), 43 deletions(-) diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index e44dc89..e2a726c 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -103,18 +103,11 @@ public actor RemindersStore { if let dueDate = draft.dueDate { reminder.dueDateComponents = calendarComponents(from: dueDate) } + if let rule = draft.recurrenceRule { + applyRecurrence(rule, to: reminder) + } try eventStore.save(reminder, commit: true) - return ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) + return item(from: reminder) } public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem { @@ -142,20 +135,14 @@ public actor RemindersStore { if let isCompleted = update.isCompleted { reminder.isCompleted = isCompleted } + if let recurrenceUpdate = update.recurrenceRule { + // .some(nil) = clear recurrence, .some(rule) = set recurrence + applyRecurrence(recurrenceUpdate, to: reminder) + } try eventStore.save(reminder, commit: true) - return ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) + return item(from: reminder) } public func completeReminders(ids: [String]) async throws -> [ReminderItem] { @@ -164,19 +151,7 @@ public actor RemindersStore { let reminder = try reminder(withID: id) reminder.isCompleted = true try eventStore.save(reminder, commit: true) - updated.append( - ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) - ) + updated.append(item(from: reminder)) } return updated } @@ -212,6 +187,7 @@ public actor RemindersStore { let completionDate: Date? let priority: Int let dueDateComponents: DateComponents? + let recurrenceRule: RecurrenceRule? let listID: String let listName: String } @@ -220,7 +196,19 @@ public actor RemindersStore { let predicate = eventStore.predicateForReminders(in: calendars) eventStore.fetchReminders(matching: predicate) { reminders in let data = (reminders ?? []).map { reminder in - ReminderData( + let rule: RecurrenceRule? = { + guard let ekRule = reminder.recurrenceRules?.first else { return nil } + let freq: RecurrenceFrequency + switch ekRule.frequency { + case .daily: freq = .daily + case .weekly: freq = .weekly + case .monthly: freq = .monthly + case .yearly: freq = .yearly + @unknown default: return nil + } + return RecurrenceRule(frequency: freq, interval: ekRule.interval) + }() + return ReminderData( id: reminder.calendarItemIdentifier, title: reminder.title ?? "", notes: reminder.notes, @@ -228,6 +216,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: Int(reminder.priority), dueDateComponents: reminder.dueDateComponents, + recurrenceRule: rule, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -245,6 +234,7 @@ public actor RemindersStore { completionDate: data.completionDate, priority: ReminderPriority(eventKitValue: data.priority), dueDate: date(from: data.dueDateComponents), + recurrenceRule: data.recurrenceRule, listID: data.listID, listName: data.listName ) @@ -284,8 +274,39 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + recurrenceRule: recurrenceRule(from: reminder), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) } + + private func recurrenceRule(from reminder: EKReminder) -> RecurrenceRule? { + guard let ekRule = reminder.recurrenceRules?.first else { return nil } + let frequency: RecurrenceFrequency + switch ekRule.frequency { + case .daily: frequency = .daily + case .weekly: frequency = .weekly + case .monthly: frequency = .monthly + case .yearly: frequency = .yearly + @unknown default: return nil + } + return RecurrenceRule(frequency: frequency, interval: ekRule.interval) + } + + private func applyRecurrence(_ rule: RecurrenceRule?, to reminder: EKReminder) { + // Remove existing rules + if let existing = reminder.recurrenceRules { + for r in existing { reminder.removeRecurrenceRule(r) } + } + guard let rule else { return } + let ekFrequency: EKRecurrenceFrequency + switch rule.frequency { + case .daily: ekFrequency = .daily + case .weekly: ekFrequency = .weekly + case .monthly: ekFrequency = .monthly + case .yearly: ekFrequency = .yearly + } + let ekRule = EKRecurrenceRule(recurrenceWith: ekFrequency, interval: rule.interval, end: nil) + reminder.addRecurrenceRule(ekRule) + } } diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..52ca486 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, Sendable, CaseIterable { + 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) { + self.frequency = frequency + self.interval = interval + } + + public var displayString: String { + if interval == 1 { + return frequency.rawValue + } + let unit: String + switch frequency { + case .daily: unit = interval == 1 ? "day" : "days" + case .weekly: unit = interval == 1 ? "week" : "weeks" + case .monthly: unit = interval == 1 ? "month" : "months" + case .yearly: unit = interval == 1 ? "year" : "years" + } + return "every \(interval) \(unit)" + } +} + public struct ReminderList: Identifiable, Codable, Sendable, Equatable { public let id: String public let title: String @@ -51,6 +82,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { public let completionDate: Date? public let priority: ReminderPriority public let dueDate: Date? + public let recurrenceRule: RecurrenceRule? public let listID: String public let listName: String @@ -62,6 +94,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { completionDate: Date?, priority: ReminderPriority, dueDate: Date?, + recurrenceRule: RecurrenceRule? = nil, listID: String, listName: String ) { @@ -72,6 +105,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { self.completionDate = completionDate self.priority = priority self.dueDate = dueDate + self.recurrenceRule = recurrenceRule self.listID = listID self.listName = listName } @@ -82,12 +116,20 @@ public struct ReminderDraft: Sendable { public let notes: String? public let dueDate: Date? public let priority: ReminderPriority + public let recurrenceRule: RecurrenceRule? - public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) { + public init( + title: String, + notes: String?, + dueDate: Date?, + priority: ReminderPriority, + recurrenceRule: RecurrenceRule? = nil + ) { self.title = title self.notes = notes self.dueDate = dueDate self.priority = priority + self.recurrenceRule = recurrenceRule } } @@ -98,6 +140,7 @@ public struct ReminderUpdate: Sendable { public let priority: ReminderPriority? public let listName: String? public let isCompleted: Bool? + public let recurrenceRule: RecurrenceRule?? public init( title: String? = nil, @@ -105,7 +148,8 @@ public struct ReminderUpdate: Sendable { dueDate: Date?? = nil, priority: ReminderPriority? = nil, listName: String? = nil, - isCompleted: Bool? = nil + isCompleted: Bool? = nil, + recurrenceRule: RecurrenceRule?? = nil ) { self.title = title self.notes = notes @@ -113,5 +157,6 @@ public struct ReminderUpdate: Sendable { self.priority = priority self.listName = listName self.isCompleted = isCompleted + self.recurrenceRule = recurrenceRule } } diff --git a/Sources/remindctl/CommandHelpers.swift b/Sources/remindctl/CommandHelpers.swift index 6323cdd..0946667 100644 --- a/Sources/remindctl/CommandHelpers.swift +++ b/Sources/remindctl/CommandHelpers.swift @@ -23,4 +23,38 @@ enum CommandHelpers { } return date } + + static func parseRecurrence(_ value: String) throws -> RecurrenceRule { + let lower = value.lowercased().trimmingCharacters(in: .whitespaces) + switch lower { + case "daily": + return RecurrenceRule(frequency: .daily, interval: 1) + case "weekly": + return RecurrenceRule(frequency: .weekly, interval: 1) + case "biweekly": + return RecurrenceRule(frequency: .weekly, interval: 2) + case "monthly": + return RecurrenceRule(frequency: .monthly, interval: 1) + case "yearly": + return RecurrenceRule(frequency: .yearly, interval: 1) + default: + // Parse "every N days/weeks/months/years" + let pattern = #/^every\s+(\d+)\s+(days?|weeks?|months?|years?)$/# + if let match = lower.firstMatch(of: pattern) { + guard let n = Int(match.1), n > 0 else { + throw RemindCoreError.operationFailed("Invalid repeat interval: \"\(value)\"") + } + let unit = String(match.2) + let freq: RecurrenceFrequency + if unit.hasPrefix("day") { freq = .daily } + else if unit.hasPrefix("week") { freq = .weekly } + else if unit.hasPrefix("month") { freq = .monthly } + else { freq = .yearly } + return RecurrenceRule(frequency: freq, interval: n) + } + throw 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 571bc25..802e29e 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -24,6 +24,12 @@ enum AddCommand { help: "none|low|medium|high", parsing: .singleValue ), + .make( + label: "repeat", + names: [.short("r"), .long("repeat")], + help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months", + parsing: .singleValue + ), ] ) ), @@ -31,6 +37,7 @@ enum AddCommand { "remindctl add \"Buy milk\"", "remindctl add --title \"Call mom\" --list Personal --due tomorrow", "remindctl add \"Review docs\" --priority high", + "remindctl add \"Take vitamins\" --due tomorrow --repeat daily", ] ) { values, runtime in let titleOption = values.option("title") @@ -59,6 +66,8 @@ enum AddCommand { let dueDate = try dueValue.map(CommandHelpers.parseDueDate) let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none + let repeatValue = values.option("repeat") + let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence) let store = RemindersStore() try await store.requestAccess() @@ -73,7 +82,7 @@ enum AddCommand { throw RemindCoreError.operationFailed("No default list found. Specify --list.") } - let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority) + let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority, recurrenceRule: recurrenceRule) let reminder = try await store.createReminder(draft, listName: targetList) OutputRenderer.printReminder(reminder, format: runtime.outputFormat) } diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index 75a3c31..72b25e1 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -24,9 +24,16 @@ enum EditCommand { help: "none|low|medium|high", parsing: .singleValue ), + .make( + label: "repeat", + names: [.short("r"), .long("repeat")], + help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months", + parsing: .singleValue + ), ], flags: [ .make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"), + .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"), ] @@ -37,6 +44,8 @@ enum EditCommand { "remindctl edit 4A83 --due tomorrow", "remindctl edit 2 --priority high --notes \"Call before noon\"", "remindctl edit 3 --clear-due", + "remindctl edit 1 --repeat weekly", + "remindctl edit 2 --no-repeat", ] ) { values, runtime in guard let input = values.argument(0) else { @@ -71,6 +80,17 @@ enum EditCommand { priority = try CommandHelpers.parsePriority(priorityValue) } + var recurrenceUpdate: RecurrenceRule?? + if let repeatValue = values.option("repeat") { + 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) + } + let completeFlag = values.flag("complete") let incompleteFlag = values.flag("incomplete") if completeFlag && incompleteFlag { @@ -78,7 +98,9 @@ enum EditCommand { } let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil) - if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil { + if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil + && recurrenceUpdate == nil + { throw RemindCoreError.operationFailed("No changes specified") } @@ -88,7 +110,8 @@ enum EditCommand { dueDate: dueUpdate, priority: priority, listName: listName, - isCompleted: isCompleted + isCompleted: isCompleted, + recurrenceRule: recurrenceUpdate ) let updated = try await store.updateReminder(id: reminder.id, update: update) diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index ee85c61..2c6c016 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -51,7 +51,8 @@ enum OutputRenderer { switch format { case .standard: let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" - Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)") + let recur = reminder.recurrenceRule.map { " 🔄 \($0.displayString)" } ?? "" + Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)\(recur)") case .plain: Swift.print(plainLine(for: reminder)) case .json: @@ -98,7 +99,8 @@ enum OutputRenderer { let status = reminder.isCompleted ? "x" : " " let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)" - Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)") + let recur = reminder.recurrenceRule.map { " 🔄 \($0.displayString)" } ?? "" + Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)\(recur)") } } @@ -111,6 +113,7 @@ enum OutputRenderer { private static func plainLine(for reminder: ReminderItem) -> String { let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? "" + let recur = reminder.recurrenceRule.map { $0.displayString } ?? "" return [ reminder.id, reminder.listName, @@ -118,6 +121,7 @@ enum OutputRenderer { reminder.priority.rawValue, due, reminder.title, + recur, ].joined(separator: "\t") }