From 5bcfe27c3b6bab19970a9125773caac0dd1fcd13 Mon Sep 17 00:00:00 2001 From: John Schweikert Date: Sat, 24 Jan 2026 08:33:58 -0500 Subject: [PATCH 1/5] Add daily/weekly recurrence support --- Makefile | 2 +- Sources/RemindCore/EventKitStore.swift | 19 +++++ Sources/RemindCore/Models.swift | 42 ++++++++++- Sources/RemindCore/RecurrenceAdapter.swift | 59 ++++++++++++++++ Sources/remindctl/Commands/AddCommand.swift | 34 ++++++++- Sources/remindctl/Commands/EditCommand.swift | 37 +++++++++- Sources/remindctl/OutputFormatting.swift | 10 ++- Sources/remindctl/RecurrenceFormatting.swift | 30 ++++++++ Sources/remindctl/RepeatParsing.swift | 61 ++++++++++++++++ .../RecurrenceAdapterTests.swift | 34 +++++++++ .../RecurrenceFormattingTests.swift | 31 ++++++++ Tests/remindctlTests/RepeatParsingTests.swift | 70 +++++++++++++++++++ 12 files changed, 422 insertions(+), 7 deletions(-) create mode 100644 Sources/RemindCore/RecurrenceAdapter.swift create mode 100644 Sources/remindctl/RecurrenceFormatting.swift create mode 100644 Sources/remindctl/RepeatParsing.swift create mode 100644 Tests/RemindCoreTests/RecurrenceAdapterTests.swift create mode 100644 Tests/remindctlTests/RecurrenceFormattingTests.swift create mode 100644 Tests/remindctlTests/RepeatParsingTests.swift diff --git a/Makefile b/Makefile index 25b111e..7d8935b 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ format: lint: swift format lint --recursive Sources Tests - swiftlint + swiftlint lint --no-cache test: scripts/generate-version.sh diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index e44dc89..a984185 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -103,6 +103,9 @@ public actor RemindersStore { if let dueDate = draft.dueDate { reminder.dueDateComponents = calendarComponents(from: dueDate) } + if let recurrence = draft.recurrence { + reminder.recurrenceRules = [RecurrenceAdapter.rule(from: recurrence)] + } try eventStore.save(reminder, commit: true) return ReminderItem( id: reminder.calendarItemIdentifier, @@ -112,6 +115,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + recurrence: reminder.recurrenceRules?.compactMap(RecurrenceAdapter.recurrence(from:)).first, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -136,6 +140,13 @@ public actor RemindersStore { if let priority = update.priority { reminder.priority = priority.eventKitValue } + if let recurrenceUpdate = update.recurrence { + if let recurrence = recurrenceUpdate { + reminder.recurrenceRules = [RecurrenceAdapter.rule(from: recurrence)] + } else { + reminder.recurrenceRules = nil + } + } if let listName = update.listName { reminder.calendar = try calendar(named: listName) } @@ -153,6 +164,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + recurrence: reminder.recurrenceRules?.compactMap(RecurrenceAdapter.recurrence(from:)).first, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -173,6 +185,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + recurrence: reminder.recurrenceRules?.compactMap(RecurrenceAdapter.recurrence(from:)).first, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -190,7 +203,9 @@ public actor RemindersStore { } return deleted } +} +extension RemindersStore { private func requestFullAccess() async throws -> Bool { try await withCheckedThrowingContinuation { continuation in eventStore.requestFullAccessToReminders { granted, error in @@ -212,6 +227,7 @@ public actor RemindersStore { let completionDate: Date? let priority: Int let dueDateComponents: DateComponents? + let recurrence: ReminderRecurrence? let listID: String let listName: String } @@ -228,6 +244,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: Int(reminder.priority), dueDateComponents: reminder.dueDateComponents, + recurrence: reminder.recurrenceRules?.compactMap(RecurrenceAdapter.recurrence(from:)).first, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) @@ -245,6 +262,7 @@ public actor RemindersStore { completionDate: data.completionDate, priority: ReminderPriority(eventKitValue: data.priority), dueDate: date(from: data.dueDateComponents), + recurrence: data.recurrence, listID: data.listID, listName: data.listName ) @@ -284,6 +302,7 @@ public actor RemindersStore { completionDate: reminder.completionDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), + recurrence: reminder.recurrenceRules?.compactMap(RecurrenceAdapter.recurrence(from:)).first, listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..56d2e17 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -33,6 +33,32 @@ public enum ReminderPriority: String, Codable, CaseIterable, Sendable { } } +public enum ReminderRecurrenceFrequency: String, Codable, CaseIterable, Sendable { + case daily + case weekly +} + +public enum ReminderRecurrenceEnd: Codable, Sendable, Equatable { + case count(Int) + case until(Date) +} + +public struct ReminderRecurrence: Codable, Sendable, Equatable { + public let frequency: ReminderRecurrenceFrequency + public let interval: Int + public let end: ReminderRecurrenceEnd? + + public init( + frequency: ReminderRecurrenceFrequency, + interval: Int = 1, + end: ReminderRecurrenceEnd? = nil + ) { + self.frequency = frequency + self.interval = interval + self.end = end + } +} + public struct ReminderList: Identifiable, Codable, Sendable, Equatable { public let id: String public let title: String @@ -51,6 +77,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { public let completionDate: Date? public let priority: ReminderPriority public let dueDate: Date? + public let recurrence: ReminderRecurrence? public let listID: String public let listName: String @@ -62,6 +89,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { completionDate: Date?, priority: ReminderPriority, dueDate: Date?, + recurrence: ReminderRecurrence? = nil, listID: String, listName: String ) { @@ -72,6 +100,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { self.completionDate = completionDate self.priority = priority self.dueDate = dueDate + self.recurrence = recurrence self.listID = listID self.listName = listName } @@ -82,12 +111,20 @@ public struct ReminderDraft: Sendable { public let notes: String? public let dueDate: Date? public let priority: ReminderPriority + public let recurrence: ReminderRecurrence? - public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) { + public init( + title: String, + notes: String?, + dueDate: Date?, + priority: ReminderPriority, + recurrence: ReminderRecurrence? = nil + ) { self.title = title self.notes = notes self.dueDate = dueDate self.priority = priority + self.recurrence = recurrence } } @@ -96,6 +133,7 @@ public struct ReminderUpdate: Sendable { public let notes: String? public let dueDate: Date?? public let priority: ReminderPriority? + public let recurrence: ReminderRecurrence?? public let listName: String? public let isCompleted: Bool? @@ -104,6 +142,7 @@ public struct ReminderUpdate: Sendable { notes: String? = nil, dueDate: Date?? = nil, priority: ReminderPriority? = nil, + recurrence: ReminderRecurrence?? = nil, listName: String? = nil, isCompleted: Bool? = nil ) { @@ -111,6 +150,7 @@ public struct ReminderUpdate: Sendable { self.notes = notes self.dueDate = dueDate self.priority = priority + self.recurrence = recurrence self.listName = listName self.isCompleted = isCompleted } diff --git a/Sources/RemindCore/RecurrenceAdapter.swift b/Sources/RemindCore/RecurrenceAdapter.swift new file mode 100644 index 0000000..73e004c --- /dev/null +++ b/Sources/RemindCore/RecurrenceAdapter.swift @@ -0,0 +1,59 @@ +import EventKit +import Foundation + +enum RecurrenceAdapter { + static func rule(from recurrence: ReminderRecurrence) -> EKRecurrenceRule { + let frequency = eventKitFrequency(from: recurrence.frequency) + let interval = max(recurrence.interval, 1) + let end = recurrence.end.map(recurrenceEnd(from:)) + return EKRecurrenceRule(recurrenceWith: frequency, interval: interval, end: end) + } + + static func recurrence(from rule: EKRecurrenceRule) -> ReminderRecurrence? { + guard let frequency = reminderFrequency(from: rule.frequency) else { + return nil + } + let interval = max(rule.interval, 1) + let end = rule.recurrenceEnd.flatMap(reminderEnd(from:)) + return ReminderRecurrence(frequency: frequency, interval: interval, end: end) + } + + private static func eventKitFrequency(from frequency: ReminderRecurrenceFrequency) -> EKRecurrenceFrequency { + switch frequency { + case .daily: + return .daily + case .weekly: + return .weekly + } + } + + private static func reminderFrequency(from frequency: EKRecurrenceFrequency) -> ReminderRecurrenceFrequency? { + switch frequency { + case .daily: + return .daily + case .weekly: + return .weekly + default: + return nil + } + } + + private static func recurrenceEnd(from end: ReminderRecurrenceEnd) -> EKRecurrenceEnd { + switch end { + case .count(let count): + return EKRecurrenceEnd(occurrenceCount: max(count, 1)) + case .until(let date): + return EKRecurrenceEnd(end: date) + } + } + + private static func reminderEnd(from end: EKRecurrenceEnd) -> ReminderRecurrenceEnd? { + if end.occurrenceCount > 0 { + return .count(end.occurrenceCount) + } + if let endDate = end.endDate { + return .until(endDate) + } + return nil + } +} diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index 571bc25..02c22a5 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -18,6 +18,10 @@ enum AddCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue), + .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly", parsing: .singleValue), + .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), + .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), + .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( label: "priority", names: [.short("p"), .long("priority")], @@ -55,9 +59,29 @@ enum AddCommand { let listName = values.option("list") let notes = values.option("notes") let dueValue = values.option("due") + let repeatValue = values.option("repeat") + let intervalValue = values.option("interval") + let countValue = values.option("count") + let untilValue = values.option("until") let priorityValue = values.option("priority") - let dueDate = try dueValue.map(CommandHelpers.parseDueDate) + if repeatValue == nil && (intervalValue != nil || countValue != nil || untilValue != nil) { + throw RemindCoreError.operationFailed("Use --repeat with --interval, --count, or --until") + } + + var dueDate = try dueValue.map(CommandHelpers.parseDueDate) + let recurrence = try repeatValue.map { + try RepeatParsing.parseRecurrence( + frequency: $0, + interval: intervalValue, + count: countValue, + until: untilValue + ) + } + + if recurrence != nil && dueDate == nil { + dueDate = Date() + } let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none let store = RemindersStore() @@ -73,7 +97,13 @@ 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, + recurrence: recurrence + ) 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..e312a42 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -18,6 +18,10 @@ enum EditCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue), + .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly", parsing: .singleValue), + .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), + .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), + .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( label: "priority", names: [.short("p"), .long("priority")], @@ -54,6 +58,10 @@ enum EditCommand { let title = values.option("title") let listName = values.option("list") let notes = values.option("notes") + let repeatValue = values.option("repeat") + let intervalValue = values.option("interval") + let countValue = values.option("count") + let untilValue = values.option("until") var dueUpdate: Date?? if let dueValue = values.option("due") { @@ -71,6 +79,19 @@ enum EditCommand { priority = try CommandHelpers.parsePriority(priorityValue) } + if repeatValue == nil && (intervalValue != nil || countValue != nil || untilValue != nil) { + throw RemindCoreError.operationFailed("Use --repeat with --interval, --count, or --until") + } + + let recurrenceUpdate: ReminderRecurrence?? = try repeatValue.map { + try RepeatParsing.parseRecurrence( + frequency: $0, + interval: intervalValue, + count: countValue, + until: untilValue + ) + } + let completeFlag = values.flag("complete") let incompleteFlag = values.flag("incomplete") if completeFlag && incompleteFlag { @@ -78,7 +99,20 @@ enum EditCommand { } let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil) - if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil { + if recurrenceUpdate != nil && dueUpdate == nil && reminder.dueDate == nil { + dueUpdate = .some(Date()) + } + + let hasChanges = + title != nil + || listName != nil + || notes != nil + || dueUpdate != nil + || priority != nil + || recurrenceUpdate != nil + || isCompleted != nil + + if !hasChanges { throw RemindCoreError.operationFailed("No changes specified") } @@ -87,6 +121,7 @@ enum EditCommand { notes: notes, dueDate: dueUpdate, priority: priority, + recurrence: recurrenceUpdate, listName: listName, isCompleted: isCompleted ) diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index ee85c61..28e532c 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 recurrence = reminder.recurrence.map { " " + RecurrenceFormatting.summary(for: $0, useISO: false) } ?? "" + Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)\(recurrence)") case .plain: Swift.print(plainLine(for: reminder)) case .json: @@ -98,7 +99,10 @@ 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 recurrence = reminder.recurrence.map { " " + RecurrenceFormatting.summary(for: $0, useISO: false) } ?? "" + let base = "[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)" + let line = base + priority + recurrence + Swift.print(line) } } @@ -111,12 +115,14 @@ enum OutputRenderer { private static func plainLine(for reminder: ReminderItem) -> String { let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? "" + let recurrence = reminder.recurrence.map { RecurrenceFormatting.summary(for: $0, useISO: true) } ?? "" return [ reminder.id, reminder.listName, reminder.isCompleted ? "1" : "0", reminder.priority.rawValue, due, + recurrence, reminder.title, ].joined(separator: "\t") } diff --git a/Sources/remindctl/RecurrenceFormatting.swift b/Sources/remindctl/RecurrenceFormatting.swift new file mode 100644 index 0000000..711b21d --- /dev/null +++ b/Sources/remindctl/RecurrenceFormatting.swift @@ -0,0 +1,30 @@ +import Foundation +import RemindCore + +enum RecurrenceFormatting { + static func summary(for recurrence: ReminderRecurrence, useISO: Bool) -> String { + var parts: [String] = ["repeat=\(recurrence.frequency.rawValue)"] + + if recurrence.interval != 1 { + parts.append("interval=\(recurrence.interval)") + } + + if let end = recurrence.end { + switch end { + case .count(let count): + parts.append("count=\(count)") + case .until(let date): + let formatted = useISO ? isoFormatter().string(from: date) : DateParsing.formatDisplay(date) + parts.append("until=\(formatted)") + } + } + + return parts.joined(separator: " ") + } + + private static func isoFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } +} diff --git a/Sources/remindctl/RepeatParsing.swift b/Sources/remindctl/RepeatParsing.swift new file mode 100644 index 0000000..d2c40a9 --- /dev/null +++ b/Sources/remindctl/RepeatParsing.swift @@ -0,0 +1,61 @@ +import Foundation +import RemindCore + +enum RepeatParsing { + static func parseFrequency(_ value: String) throws -> ReminderRecurrenceFrequency { + switch value.lowercased() { + case "daily": + return .daily + case "weekly": + return .weekly + default: + throw RemindCoreError.operationFailed("Invalid repeat frequency: \"\(value)\" (use daily|weekly)") + } + } + + static func parseInterval(_ value: String) throws -> Int { + guard let interval = Int(value), interval > 0 else { + throw RemindCoreError.operationFailed("Invalid interval: \"\(value)\" (use a positive integer)") + } + return interval + } + + static func parseCount(_ value: String) throws -> Int { + guard let count = Int(value), count > 0 else { + throw RemindCoreError.operationFailed("Invalid count: \"\(value)\" (use a positive integer)") + } + return count + } + + static func parseRecurrence( + frequency: String, + interval: String?, + count: String?, + until: String? + ) throws -> ReminderRecurrence { + if count != nil && until != nil { + throw RemindCoreError.operationFailed("Use either --count or --until, not both") + } + + let parsedFrequency = try parseFrequency(frequency) + let parsedInterval = try interval.map(parseInterval) ?? 1 + + let end: ReminderRecurrenceEnd? + if let count { + end = .count(try parseCount(count)) + } else if let until { + guard let parsedUntil = DateParsing.parseUserDate(until) else { + throw RemindCoreError.invalidDate(until) + } + end = .until(parsedUntil) + } else { + end = nil + } + + return ReminderRecurrence( + frequency: parsedFrequency, + interval: parsedInterval, + end: end + ) + } +} diff --git a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift new file mode 100644 index 0000000..94109d2 --- /dev/null +++ b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift @@ -0,0 +1,34 @@ +import EventKit +import Foundation +import Testing + +@testable import RemindCore + +@MainActor +struct RecurrenceAdapterTests { + @Test("Daily recurrence maps to EventKit and back") + func dailyRoundTrip() { + let recurrence = ReminderRecurrence(frequency: .daily, interval: 2, end: .count(5)) + let rule = RecurrenceAdapter.rule(from: recurrence) + + #expect(rule.frequency == .daily) + #expect(rule.interval == 2) + #expect(rule.recurrenceEnd?.occurrenceCount == 5) + + let roundTrip = RecurrenceAdapter.recurrence(from: rule) + #expect(roundTrip == recurrence) + } + + @Test("Weekly recurrence maps end date") + func weeklyEndDate() { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let recurrence = ReminderRecurrence(frequency: .weekly, interval: 1, end: .until(date)) + let rule = RecurrenceAdapter.rule(from: recurrence) + + #expect(rule.frequency == .weekly) + #expect(rule.recurrenceEnd?.endDate == date) + + let roundTrip = RecurrenceAdapter.recurrence(from: rule) + #expect(roundTrip == recurrence) + } +} diff --git a/Tests/remindctlTests/RecurrenceFormattingTests.swift b/Tests/remindctlTests/RecurrenceFormattingTests.swift new file mode 100644 index 0000000..99669f0 --- /dev/null +++ b/Tests/remindctlTests/RecurrenceFormattingTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing + +@testable import RemindCore +@testable import remindctl + +@MainActor +struct RecurrenceFormattingTests { + @Test("Formats simple daily recurrence") + func dailySummary() { + let recurrence = ReminderRecurrence(frequency: .daily) + let summary = RecurrenceFormatting.summary(for: recurrence, useISO: false) + #expect(summary == "repeat=daily") + } + + @Test("Formats weekly recurrence with interval and count") + func weeklySummary() { + let recurrence = ReminderRecurrence(frequency: .weekly, interval: 2, end: .count(4)) + let summary = RecurrenceFormatting.summary(for: recurrence, useISO: false) + #expect(summary == "repeat=weekly interval=2 count=4") + } + + @Test("Formats ISO until date for plain output") + func untilSummaryISO() { + let date = Date(timeIntervalSince1970: 0) + let recurrence = ReminderRecurrence(frequency: .daily, end: .until(date)) + let summary = RecurrenceFormatting.summary(for: recurrence, useISO: true) + #expect(summary.contains("repeat=daily")) + #expect(summary.contains("until=1970-01-01T00:00:00.000Z")) + } +} diff --git a/Tests/remindctlTests/RepeatParsingTests.swift b/Tests/remindctlTests/RepeatParsingTests.swift new file mode 100644 index 0000000..84e836b --- /dev/null +++ b/Tests/remindctlTests/RepeatParsingTests.swift @@ -0,0 +1,70 @@ +import Testing +@testable import RemindCore +@testable import remindctl + +@MainActor +struct RepeatParsingTests { + @Test("Parses daily recurrence with defaults") + func dailyDefaults() throws { + let recurrence = try RepeatParsing.parseRecurrence( + frequency: "daily", + interval: nil, + count: nil, + until: nil + ) + #expect(recurrence.frequency == .daily) + #expect(recurrence.interval == 1) + #expect(recurrence.end == nil) + } + + @Test("Parses weekly recurrence with interval and count") + func weeklyCount() throws { + let recurrence = try RepeatParsing.parseRecurrence( + frequency: "weekly", + interval: "2", + count: "5", + until: nil + ) + #expect(recurrence.frequency == .weekly) + #expect(recurrence.interval == 2) + #expect(recurrence.end == .count(5)) + } + + @Test("Parses recurrence with until date") + func untilDate() throws { + let recurrence = try RepeatParsing.parseRecurrence( + frequency: "daily", + interval: nil, + count: nil, + until: "2026-01-03T12:34:56Z" + ) + guard case .until = recurrence.end else { + #expect(Bool(false)) + return + } + } + + @Test("Rejects invalid frequency") + func invalidFrequency() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + frequency: "monthly", + interval: nil, + count: nil, + until: nil + ) + } + } + + @Test("Rejects count with until") + func countAndUntil() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + frequency: "daily", + interval: nil, + count: "2", + until: "tomorrow" + ) + } + } +} From fa7cd642c445ca9988032e5988d4fe6ad3cd02f1 Mon Sep 17 00:00:00 2001 From: John Schweikert Date: Sat, 24 Jan 2026 08:45:20 -0500 Subject: [PATCH 2/5] Add weekly --on selectors for recurrence --- Sources/RemindCore/Models.swift | 32 ++++++++ Sources/RemindCore/RecurrenceAdapter.swift | 73 ++++++++++++++++++- Sources/remindctl/Commands/AddCommand.swift | 9 ++- Sources/remindctl/Commands/EditCommand.swift | 9 ++- Sources/remindctl/RecurrenceFormatting.swift | 5 ++ Sources/remindctl/RepeatParsing.swift | 48 +++++++++++- .../RecurrenceAdapterTests.swift | 16 ++++ .../RecurrenceFormattingTests.swift | 11 +++ Tests/remindctlTests/RepeatParsingTests.swift | 40 ++++++++-- 9 files changed, 229 insertions(+), 14 deletions(-) diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 56d2e17..472dfab 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -38,6 +38,35 @@ public enum ReminderRecurrenceFrequency: String, Codable, CaseIterable, Sendable case weekly } +public enum ReminderWeekday: String, Codable, CaseIterable, Sendable { + case monday = "mon" + case tuesday = "tue" + case wednesday = "wed" + case thursday = "thu" + case friday = "fri" + case saturday = "sat" + case sunday = "sun" + + public var displayOrder: Int { + switch self { + case .monday: + return 1 + case .tuesday: + return 2 + case .wednesday: + return 3 + case .thursday: + return 4 + case .friday: + return 5 + case .saturday: + return 6 + case .sunday: + return 7 + } + } +} + public enum ReminderRecurrenceEnd: Codable, Sendable, Equatable { case count(Int) case until(Date) @@ -46,15 +75,18 @@ public enum ReminderRecurrenceEnd: Codable, Sendable, Equatable { public struct ReminderRecurrence: Codable, Sendable, Equatable { public let frequency: ReminderRecurrenceFrequency public let interval: Int + public let daysOfWeek: [ReminderWeekday]? public let end: ReminderRecurrenceEnd? public init( frequency: ReminderRecurrenceFrequency, interval: Int = 1, + daysOfWeek: [ReminderWeekday]? = nil, end: ReminderRecurrenceEnd? = nil ) { self.frequency = frequency self.interval = interval + self.daysOfWeek = daysOfWeek self.end = end } } diff --git a/Sources/RemindCore/RecurrenceAdapter.swift b/Sources/RemindCore/RecurrenceAdapter.swift index 73e004c..5032899 100644 --- a/Sources/RemindCore/RecurrenceAdapter.swift +++ b/Sources/RemindCore/RecurrenceAdapter.swift @@ -6,7 +6,20 @@ enum RecurrenceAdapter { let frequency = eventKitFrequency(from: recurrence.frequency) let interval = max(recurrence.interval, 1) let end = recurrence.end.map(recurrenceEnd(from:)) - return EKRecurrenceRule(recurrenceWith: frequency, interval: interval, end: end) + let daysOfWeek = recurrence.daysOfWeek? + .sorted { $0.displayOrder < $1.displayOrder } + .compactMap(eventKitDayOfWeek(from:)) + return EKRecurrenceRule( + recurrenceWith: frequency, + interval: interval, + daysOfTheWeek: daysOfWeek, + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: nil, + end: end + ) } static func recurrence(from rule: EKRecurrenceRule) -> ReminderRecurrence? { @@ -15,7 +28,15 @@ enum RecurrenceAdapter { } let interval = max(rule.interval, 1) let end = rule.recurrenceEnd.flatMap(reminderEnd(from:)) - return ReminderRecurrence(frequency: frequency, interval: interval, end: end) + let daysOfWeek = rule.daysOfTheWeek? + .compactMap(reminderDayOfWeek(from:)) + .sorted { $0.displayOrder < $1.displayOrder } + return ReminderRecurrence( + frequency: frequency, + interval: interval, + daysOfWeek: daysOfWeek, + end: end + ) } private static func eventKitFrequency(from frequency: ReminderRecurrenceFrequency) -> EKRecurrenceFrequency { @@ -38,6 +59,54 @@ enum RecurrenceAdapter { } } + private static func eventKitDayOfWeek(from day: ReminderWeekday) -> EKRecurrenceDayOfWeek { + EKRecurrenceDayOfWeek(dayOfTheWeek: eventKitWeekday(from: day), weekNumber: 0) + } + + private static func reminderDayOfWeek(from day: EKRecurrenceDayOfWeek) -> ReminderWeekday? { + reminderWeekday(from: day.dayOfTheWeek) + } + + private static func eventKitWeekday(from day: ReminderWeekday) -> EKWeekday { + switch day { + case .sunday: + return .sunday + case .monday: + return .monday + case .tuesday: + return .tuesday + case .wednesday: + return .wednesday + case .thursday: + return .thursday + case .friday: + return .friday + case .saturday: + return .saturday + } + } + + private static func reminderWeekday(from day: EKWeekday) -> ReminderWeekday? { + switch day { + case .sunday: + return .sunday + case .monday: + return .monday + case .tuesday: + return .tuesday + case .wednesday: + return .wednesday + case .thursday: + return .thursday + case .friday: + return .friday + case .saturday: + return .saturday + @unknown default: + return nil + } + } + private static func recurrenceEnd(from end: ReminderRecurrenceEnd) -> EKRecurrenceEnd { switch end { case .count(let count): diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index 02c22a5..3b8b3d6 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -20,6 +20,7 @@ enum AddCommand { .make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue), .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly", parsing: .singleValue), .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), + .make(label: "on", names: [.long("on")], help: "Weekdays (mon,tue,...)", parsing: .singleValue), .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( @@ -61,12 +62,13 @@ enum AddCommand { let dueValue = values.option("due") let repeatValue = values.option("repeat") let intervalValue = values.option("interval") + let onValue = values.option("on") let countValue = values.option("count") let untilValue = values.option("until") let priorityValue = values.option("priority") - if repeatValue == nil && (intervalValue != nil || countValue != nil || untilValue != nil) { - throw RemindCoreError.operationFailed("Use --repeat with --interval, --count, or --until") + if repeatValue == nil && (intervalValue != nil || onValue != nil || countValue != nil || untilValue != nil) { + throw RemindCoreError.operationFailed("Use --repeat with --interval, --on, --count, or --until") } var dueDate = try dueValue.map(CommandHelpers.parseDueDate) @@ -75,7 +77,8 @@ enum AddCommand { frequency: $0, interval: intervalValue, count: countValue, - until: untilValue + until: untilValue, + on: onValue ) } diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index e312a42..91a9ed5 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -20,6 +20,7 @@ enum EditCommand { .make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue), .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly", parsing: .singleValue), .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), + .make(label: "on", names: [.long("on")], help: "Weekdays (mon,tue,...)", parsing: .singleValue), .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( @@ -60,6 +61,7 @@ enum EditCommand { let notes = values.option("notes") let repeatValue = values.option("repeat") let intervalValue = values.option("interval") + let onValue = values.option("on") let countValue = values.option("count") let untilValue = values.option("until") @@ -79,8 +81,8 @@ enum EditCommand { priority = try CommandHelpers.parsePriority(priorityValue) } - if repeatValue == nil && (intervalValue != nil || countValue != nil || untilValue != nil) { - throw RemindCoreError.operationFailed("Use --repeat with --interval, --count, or --until") + if repeatValue == nil && (intervalValue != nil || onValue != nil || countValue != nil || untilValue != nil) { + throw RemindCoreError.operationFailed("Use --repeat with --interval, --on, --count, or --until") } let recurrenceUpdate: ReminderRecurrence?? = try repeatValue.map { @@ -88,7 +90,8 @@ enum EditCommand { frequency: $0, interval: intervalValue, count: countValue, - until: untilValue + until: untilValue, + on: onValue ) } diff --git a/Sources/remindctl/RecurrenceFormatting.swift b/Sources/remindctl/RecurrenceFormatting.swift index 711b21d..f2948f7 100644 --- a/Sources/remindctl/RecurrenceFormatting.swift +++ b/Sources/remindctl/RecurrenceFormatting.swift @@ -9,6 +9,11 @@ enum RecurrenceFormatting { parts.append("interval=\(recurrence.interval)") } + if let daysOfWeek = recurrence.daysOfWeek, !daysOfWeek.isEmpty { + let days = daysOfWeek.map(\.rawValue).joined(separator: ",") + parts.append("on=\(days)") + } + if let end = recurrence.end { switch end { case .count(let count): diff --git a/Sources/remindctl/RepeatParsing.swift b/Sources/remindctl/RepeatParsing.swift index d2c40a9..7867c74 100644 --- a/Sources/remindctl/RepeatParsing.swift +++ b/Sources/remindctl/RepeatParsing.swift @@ -31,7 +31,8 @@ enum RepeatParsing { frequency: String, interval: String?, count: String?, - until: String? + until: String?, + on: String? ) throws -> ReminderRecurrence { if count != nil && until != nil { throw RemindCoreError.operationFailed("Use either --count or --until, not both") @@ -39,6 +40,10 @@ enum RepeatParsing { let parsedFrequency = try parseFrequency(frequency) let parsedInterval = try interval.map(parseInterval) ?? 1 + let daysOfWeek = try on.map { try parseWeekdays($0) } + if daysOfWeek != nil && parsedFrequency != .weekly { + throw RemindCoreError.operationFailed("--on is only supported with weekly repeats") + } let end: ReminderRecurrenceEnd? if let count { @@ -55,7 +60,48 @@ enum RepeatParsing { return ReminderRecurrence( frequency: parsedFrequency, interval: parsedInterval, + daysOfWeek: daysOfWeek, end: end ) } + + private static func parseWeekdays(_ value: String) throws -> [ReminderWeekday] { + let tokens = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !tokens.isEmpty else { + throw RemindCoreError.operationFailed("Invalid weekdays: \"\(value)\"") + } + + var weekdays: [ReminderWeekday] = [] + var seen = Set() + for token in tokens where !token.isEmpty { + guard let day = parseWeekday(String(token)) else { + throw RemindCoreError.operationFailed("Invalid weekday: \"\(token)\"") + } + if seen.insert(day).inserted { + weekdays.append(day) + } + } + return weekdays + } + + private static func parseWeekday(_ value: String) -> ReminderWeekday? { + switch value.lowercased() { + case "mon", "monday": + return .monday + case "tue", "tues", "tuesday": + return .tuesday + case "wed", "weds", "wednesday": + return .wednesday + case "thu", "thur", "thurs", "thursday": + return .thursday + case "fri", "friday": + return .friday + case "sat", "saturday": + return .saturday + case "sun", "sunday": + return .sunday + default: + return nil + } + } } diff --git a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift index 94109d2..9666d23 100644 --- a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift +++ b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift @@ -31,4 +31,20 @@ struct RecurrenceAdapterTests { let roundTrip = RecurrenceAdapter.recurrence(from: rule) #expect(roundTrip == recurrence) } + + @Test("Weekly recurrence maps days of week") + func weeklyDays() { + let recurrence = ReminderRecurrence( + frequency: .weekly, + interval: 1, + daysOfWeek: [.monday, .wednesday, .friday] + ) + let rule = RecurrenceAdapter.rule(from: recurrence) + let days = rule.daysOfTheWeek?.map(\.dayOfTheWeek) + + #expect(days == [.monday, .wednesday, .friday]) + + let roundTrip = RecurrenceAdapter.recurrence(from: rule) + #expect(roundTrip == recurrence) + } } diff --git a/Tests/remindctlTests/RecurrenceFormattingTests.swift b/Tests/remindctlTests/RecurrenceFormattingTests.swift index 99669f0..72ecf1c 100644 --- a/Tests/remindctlTests/RecurrenceFormattingTests.swift +++ b/Tests/remindctlTests/RecurrenceFormattingTests.swift @@ -20,6 +20,17 @@ struct RecurrenceFormattingTests { #expect(summary == "repeat=weekly interval=2 count=4") } + @Test("Formats weekly recurrence with days") + func weeklyDaysSummary() { + let recurrence = ReminderRecurrence( + frequency: .weekly, + interval: 1, + daysOfWeek: [.monday, .wednesday, .friday] + ) + let summary = RecurrenceFormatting.summary(for: recurrence, useISO: false) + #expect(summary == "repeat=weekly on=mon,wed,fri") + } + @Test("Formats ISO until date for plain output") func untilSummaryISO() { let date = Date(timeIntervalSince1970: 0) diff --git a/Tests/remindctlTests/RepeatParsingTests.swift b/Tests/remindctlTests/RepeatParsingTests.swift index 84e836b..3bd4ed6 100644 --- a/Tests/remindctlTests/RepeatParsingTests.swift +++ b/Tests/remindctlTests/RepeatParsingTests.swift @@ -10,7 +10,8 @@ struct RepeatParsingTests { frequency: "daily", interval: nil, count: nil, - until: nil + until: nil, + on: nil ) #expect(recurrence.frequency == .daily) #expect(recurrence.interval == 1) @@ -23,7 +24,8 @@ struct RepeatParsingTests { frequency: "weekly", interval: "2", count: "5", - until: nil + until: nil, + on: nil ) #expect(recurrence.frequency == .weekly) #expect(recurrence.interval == 2) @@ -36,7 +38,8 @@ struct RepeatParsingTests { frequency: "daily", interval: nil, count: nil, - until: "2026-01-03T12:34:56Z" + until: "2026-01-03T12:34:56Z", + on: nil ) guard case .until = recurrence.end else { #expect(Bool(false)) @@ -51,7 +54,8 @@ struct RepeatParsingTests { frequency: "monthly", interval: nil, count: nil, - until: nil + until: nil, + on: nil ) } } @@ -63,7 +67,33 @@ struct RepeatParsingTests { frequency: "daily", interval: nil, count: "2", - until: "tomorrow" + until: "tomorrow", + on: nil + ) + } + } + + @Test("Parses weekly days") + func weeklyDays() throws { + let recurrence = try RepeatParsing.parseRecurrence( + frequency: "weekly", + interval: nil, + count: nil, + until: nil, + on: "mon,wed,fri" + ) + #expect(recurrence.daysOfWeek == [.monday, .wednesday, .friday]) + } + + @Test("Rejects --on for non-weekly") + func onNonWeekly() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + frequency: "daily", + interval: nil, + count: nil, + until: nil, + on: "mon" ) } } From c789f424b81c89a184b27d677b865eca4a62a1bf Mon Sep 17 00:00:00 2001 From: John Schweikert Date: Sat, 24 Jan 2026 08:51:45 -0500 Subject: [PATCH 3/5] Add monthly day-of-month recurrence --- Sources/RemindCore/Models.swift | 4 + Sources/RemindCore/RecurrenceAdapter.swift | 9 +- Sources/remindctl/Commands/AddCommand.swift | 23 ++-- Sources/remindctl/Commands/EditCommand.swift | 23 ++-- Sources/remindctl/RecurrenceFormatting.swift | 5 + Sources/remindctl/RepeatParsing.swift | 57 ++++++--- .../RecurrenceAdapterTests.swift | 16 +++ .../RecurrenceFormattingTests.swift | 11 ++ Tests/remindctlTests/RepeatParsingTests.swift | 116 +++++++++++++----- 9 files changed, 201 insertions(+), 63 deletions(-) diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 472dfab..2f01976 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -36,6 +36,7 @@ public enum ReminderPriority: String, Codable, CaseIterable, Sendable { public enum ReminderRecurrenceFrequency: String, Codable, CaseIterable, Sendable { case daily case weekly + case monthly } public enum ReminderWeekday: String, Codable, CaseIterable, Sendable { @@ -76,17 +77,20 @@ public struct ReminderRecurrence: Codable, Sendable, Equatable { public let frequency: ReminderRecurrenceFrequency public let interval: Int public let daysOfWeek: [ReminderWeekday]? + public let daysOfMonth: [Int]? public let end: ReminderRecurrenceEnd? public init( frequency: ReminderRecurrenceFrequency, interval: Int = 1, daysOfWeek: [ReminderWeekday]? = nil, + daysOfMonth: [Int]? = nil, end: ReminderRecurrenceEnd? = nil ) { self.frequency = frequency self.interval = interval self.daysOfWeek = daysOfWeek + self.daysOfMonth = daysOfMonth self.end = end } } diff --git a/Sources/RemindCore/RecurrenceAdapter.swift b/Sources/RemindCore/RecurrenceAdapter.swift index 5032899..9c7a03c 100644 --- a/Sources/RemindCore/RecurrenceAdapter.swift +++ b/Sources/RemindCore/RecurrenceAdapter.swift @@ -9,11 +9,12 @@ enum RecurrenceAdapter { let daysOfWeek = recurrence.daysOfWeek? .sorted { $0.displayOrder < $1.displayOrder } .compactMap(eventKitDayOfWeek(from:)) + let daysOfMonth = recurrence.daysOfMonth?.sorted().map { NSNumber(value: $0) } return EKRecurrenceRule( recurrenceWith: frequency, interval: interval, daysOfTheWeek: daysOfWeek, - daysOfTheMonth: nil, + daysOfTheMonth: daysOfMonth, monthsOfTheYear: nil, weeksOfTheYear: nil, daysOfTheYear: nil, @@ -31,10 +32,12 @@ enum RecurrenceAdapter { let daysOfWeek = rule.daysOfTheWeek? .compactMap(reminderDayOfWeek(from:)) .sorted { $0.displayOrder < $1.displayOrder } + let daysOfMonth = rule.daysOfTheMonth?.map { $0.intValue }.sorted() return ReminderRecurrence( frequency: frequency, interval: interval, daysOfWeek: daysOfWeek, + daysOfMonth: daysOfMonth, end: end ) } @@ -45,6 +48,8 @@ enum RecurrenceAdapter { return .daily case .weekly: return .weekly + case .monthly: + return .monthly } } @@ -54,6 +59,8 @@ enum RecurrenceAdapter { return .daily case .weekly: return .weekly + case .monthly: + return .monthly default: return nil } diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index 3b8b3d6..33e239d 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -18,9 +18,10 @@ enum AddCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue), - .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly", parsing: .singleValue), + .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly|monthly", parsing: .singleValue), .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), .make(label: "on", names: [.long("on")], help: "Weekdays (mon,tue,...)", parsing: .singleValue), + .make(label: "monthDay", names: [.long("month-day")], help: "Days of month (1-31)", parsing: .singleValue), .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( @@ -63,22 +64,28 @@ enum AddCommand { let repeatValue = values.option("repeat") let intervalValue = values.option("interval") let onValue = values.option("on") + let monthDayValue = values.option("monthDay") let countValue = values.option("count") let untilValue = values.option("until") let priorityValue = values.option("priority") - if repeatValue == nil && (intervalValue != nil || onValue != nil || countValue != nil || untilValue != nil) { - throw RemindCoreError.operationFailed("Use --repeat with --interval, --on, --count, or --until") + let hasRepeatModifiers = [intervalValue, onValue, monthDayValue, countValue, untilValue] + .contains { $0 != nil } + if repeatValue == nil && hasRepeatModifiers { + throw RemindCoreError.operationFailed("Use --repeat with --interval, --on, --month-day, --count, or --until") } var dueDate = try dueValue.map(CommandHelpers.parseDueDate) let recurrence = try repeatValue.map { try RepeatParsing.parseRecurrence( - frequency: $0, - interval: intervalValue, - count: countValue, - until: untilValue, - on: onValue + .init( + frequency: $0, + interval: intervalValue, + count: countValue, + until: untilValue, + on: onValue, + monthDay: monthDayValue + ) ) } diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index 91a9ed5..a6b0497 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -18,9 +18,10 @@ enum EditCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue), - .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly", parsing: .singleValue), + .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly|monthly", parsing: .singleValue), .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), .make(label: "on", names: [.long("on")], help: "Weekdays (mon,tue,...)", parsing: .singleValue), + .make(label: "monthDay", names: [.long("month-day")], help: "Days of month (1-31)", parsing: .singleValue), .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( @@ -62,6 +63,7 @@ enum EditCommand { let repeatValue = values.option("repeat") let intervalValue = values.option("interval") let onValue = values.option("on") + let monthDayValue = values.option("monthDay") let countValue = values.option("count") let untilValue = values.option("until") @@ -81,17 +83,22 @@ enum EditCommand { priority = try CommandHelpers.parsePriority(priorityValue) } - if repeatValue == nil && (intervalValue != nil || onValue != nil || countValue != nil || untilValue != nil) { - throw RemindCoreError.operationFailed("Use --repeat with --interval, --on, --count, or --until") + let hasRepeatModifiers = [intervalValue, onValue, monthDayValue, countValue, untilValue] + .contains { $0 != nil } + if repeatValue == nil && hasRepeatModifiers { + throw RemindCoreError.operationFailed("Use --repeat with --interval, --on, --month-day, --count, or --until") } let recurrenceUpdate: ReminderRecurrence?? = try repeatValue.map { try RepeatParsing.parseRecurrence( - frequency: $0, - interval: intervalValue, - count: countValue, - until: untilValue, - on: onValue + .init( + frequency: $0, + interval: intervalValue, + count: countValue, + until: untilValue, + on: onValue, + monthDay: monthDayValue + ) ) } diff --git a/Sources/remindctl/RecurrenceFormatting.swift b/Sources/remindctl/RecurrenceFormatting.swift index f2948f7..897ac16 100644 --- a/Sources/remindctl/RecurrenceFormatting.swift +++ b/Sources/remindctl/RecurrenceFormatting.swift @@ -14,6 +14,11 @@ enum RecurrenceFormatting { parts.append("on=\(days)") } + if let daysOfMonth = recurrence.daysOfMonth, !daysOfMonth.isEmpty { + let days = daysOfMonth.map(String.init).joined(separator: ",") + parts.append("month-day=\(days)") + } + if let end = recurrence.end { switch end { case .count(let count): diff --git a/Sources/remindctl/RepeatParsing.swift b/Sources/remindctl/RepeatParsing.swift index 7867c74..9a35cf8 100644 --- a/Sources/remindctl/RepeatParsing.swift +++ b/Sources/remindctl/RepeatParsing.swift @@ -2,14 +2,25 @@ import Foundation import RemindCore enum RepeatParsing { + struct RepeatInput { + let frequency: String + let interval: String? + let count: String? + let until: String? + let on: String? + let monthDay: String? + } + static func parseFrequency(_ value: String) throws -> ReminderRecurrenceFrequency { switch value.lowercased() { case "daily": return .daily case "weekly": return .weekly + case "monthly": + return .monthly default: - throw RemindCoreError.operationFailed("Invalid repeat frequency: \"\(value)\" (use daily|weekly)") + throw RemindCoreError.operationFailed("Invalid repeat frequency: \"\(value)\" (use daily|weekly|monthly)") } } @@ -27,28 +38,26 @@ enum RepeatParsing { return count } - static func parseRecurrence( - frequency: String, - interval: String?, - count: String?, - until: String?, - on: String? - ) throws -> ReminderRecurrence { - if count != nil && until != nil { + static func parseRecurrence(_ input: RepeatInput) throws -> ReminderRecurrence { + if input.count != nil && input.until != nil { throw RemindCoreError.operationFailed("Use either --count or --until, not both") } - let parsedFrequency = try parseFrequency(frequency) - let parsedInterval = try interval.map(parseInterval) ?? 1 - let daysOfWeek = try on.map { try parseWeekdays($0) } + let parsedFrequency = try parseFrequency(input.frequency) + let parsedInterval = try input.interval.map(parseInterval) ?? 1 + let daysOfWeek = try input.on.map { try parseWeekdays($0) } + let daysOfMonth = try input.monthDay.map { try parseMonthDays($0) } if daysOfWeek != nil && parsedFrequency != .weekly { throw RemindCoreError.operationFailed("--on is only supported with weekly repeats") } + if daysOfMonth != nil && parsedFrequency != .monthly { + throw RemindCoreError.operationFailed("--month-day is only supported with monthly repeats") + } let end: ReminderRecurrenceEnd? - if let count { + if let count = input.count { end = .count(try parseCount(count)) - } else if let until { + } else if let until = input.until { guard let parsedUntil = DateParsing.parseUserDate(until) else { throw RemindCoreError.invalidDate(until) } @@ -61,6 +70,7 @@ enum RepeatParsing { frequency: parsedFrequency, interval: parsedInterval, daysOfWeek: daysOfWeek, + daysOfMonth: daysOfMonth, end: end ) } @@ -84,6 +94,25 @@ enum RepeatParsing { return weekdays } + private static func parseMonthDays(_ value: String) throws -> [Int] { + let tokens = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !tokens.isEmpty else { + throw RemindCoreError.operationFailed("Invalid month days: \"\(value)\"") + } + + var days: [Int] = [] + var seen = Set() + for token in tokens where !token.isEmpty { + guard let day = Int(token), (1...31).contains(day) else { + throw RemindCoreError.operationFailed("Invalid month day: \"\(token)\"") + } + if seen.insert(day).inserted { + days.append(day) + } + } + return days + } + private static func parseWeekday(_ value: String) -> ReminderWeekday? { switch value.lowercased() { case "mon", "monday": diff --git a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift index 9666d23..9a582ac 100644 --- a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift +++ b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift @@ -47,4 +47,20 @@ struct RecurrenceAdapterTests { let roundTrip = RecurrenceAdapter.recurrence(from: rule) #expect(roundTrip == recurrence) } + + @Test("Monthly recurrence maps days of month") + func monthlyDays() { + let recurrence = ReminderRecurrence( + frequency: .monthly, + interval: 1, + daysOfMonth: [1, 15, 31] + ) + let rule = RecurrenceAdapter.rule(from: recurrence) + let days = rule.daysOfTheMonth?.map { $0.intValue } + + #expect(days == [1, 15, 31]) + + let roundTrip = RecurrenceAdapter.recurrence(from: rule) + #expect(roundTrip == recurrence) + } } diff --git a/Tests/remindctlTests/RecurrenceFormattingTests.swift b/Tests/remindctlTests/RecurrenceFormattingTests.swift index 72ecf1c..49a57f1 100644 --- a/Tests/remindctlTests/RecurrenceFormattingTests.swift +++ b/Tests/remindctlTests/RecurrenceFormattingTests.swift @@ -31,6 +31,17 @@ struct RecurrenceFormattingTests { #expect(summary == "repeat=weekly on=mon,wed,fri") } + @Test("Formats monthly recurrence with month days") + func monthlyDaysSummary() { + let recurrence = ReminderRecurrence( + frequency: .monthly, + interval: 1, + daysOfMonth: [1, 15, 31] + ) + let summary = RecurrenceFormatting.summary(for: recurrence, useISO: false) + #expect(summary == "repeat=monthly month-day=1,15,31") + } + @Test("Formats ISO until date for plain output") func untilSummaryISO() { let date = Date(timeIntervalSince1970: 0) diff --git a/Tests/remindctlTests/RepeatParsingTests.swift b/Tests/remindctlTests/RepeatParsingTests.swift index 3bd4ed6..9b8e7a4 100644 --- a/Tests/remindctlTests/RepeatParsingTests.swift +++ b/Tests/remindctlTests/RepeatParsingTests.swift @@ -7,11 +7,14 @@ struct RepeatParsingTests { @Test("Parses daily recurrence with defaults") func dailyDefaults() throws { let recurrence = try RepeatParsing.parseRecurrence( - frequency: "daily", - interval: nil, - count: nil, - until: nil, - on: nil + .init( + frequency: "daily", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: nil + ) ) #expect(recurrence.frequency == .daily) #expect(recurrence.interval == 1) @@ -21,11 +24,14 @@ struct RepeatParsingTests { @Test("Parses weekly recurrence with interval and count") func weeklyCount() throws { let recurrence = try RepeatParsing.parseRecurrence( - frequency: "weekly", - interval: "2", - count: "5", - until: nil, - on: nil + .init( + frequency: "weekly", + interval: "2", + count: "5", + until: nil, + on: nil, + monthDay: nil + ) ) #expect(recurrence.frequency == .weekly) #expect(recurrence.interval == 2) @@ -35,11 +41,14 @@ struct RepeatParsingTests { @Test("Parses recurrence with until date") func untilDate() throws { let recurrence = try RepeatParsing.parseRecurrence( - frequency: "daily", - interval: nil, - count: nil, - until: "2026-01-03T12:34:56Z", - on: nil + .init( + frequency: "daily", + interval: nil, + count: nil, + until: "2026-01-03T12:34:56Z", + on: nil, + monthDay: nil + ) ) guard case .until = recurrence.end else { #expect(Bool(false)) @@ -51,11 +60,14 @@ struct RepeatParsingTests { func invalidFrequency() { #expect(throws: RemindCoreError.self) { _ = try RepeatParsing.parseRecurrence( - frequency: "monthly", - interval: nil, - count: nil, - until: nil, - on: nil + .init( + frequency: "yearly", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: nil + ) ) } } @@ -64,11 +76,14 @@ struct RepeatParsingTests { func countAndUntil() { #expect(throws: RemindCoreError.self) { _ = try RepeatParsing.parseRecurrence( - frequency: "daily", - interval: nil, - count: "2", - until: "tomorrow", - on: nil + .init( + frequency: "daily", + interval: nil, + count: "2", + until: "tomorrow", + on: nil, + monthDay: nil + ) ) } } @@ -76,11 +91,14 @@ struct RepeatParsingTests { @Test("Parses weekly days") func weeklyDays() throws { let recurrence = try RepeatParsing.parseRecurrence( - frequency: "weekly", - interval: nil, - count: nil, - until: nil, - on: "mon,wed,fri" + .init( + frequency: "weekly", + interval: nil, + count: nil, + until: nil, + on: "mon,wed,fri", + monthDay: nil + ) ) #expect(recurrence.daysOfWeek == [.monday, .wednesday, .friday]) } @@ -89,11 +107,45 @@ struct RepeatParsingTests { func onNonWeekly() { #expect(throws: RemindCoreError.self) { _ = try RepeatParsing.parseRecurrence( - frequency: "daily", + .init( + frequency: "daily", + interval: nil, + count: nil, + until: nil, + on: "mon", + monthDay: nil + ) + ) + } + } + + @Test("Parses monthly month days") + func monthlyDays() throws { + let recurrence = try RepeatParsing.parseRecurrence( + .init( + frequency: "monthly", interval: nil, count: nil, until: nil, - on: "mon" + on: nil, + monthDay: "1,15,31" + ) + ) + #expect(recurrence.daysOfMonth == [1, 15, 31]) + } + + @Test("Rejects --month-day for non-monthly") + func monthDayNonMonthly() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "weekly", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: "1" + ) ) } } From cdb5a4f5a594e2aeee4a1896d50edce586b63e26 Mon Sep 17 00:00:00 2001 From: John Schweikert Date: Sat, 24 Jan 2026 08:55:33 -0500 Subject: [PATCH 4/5] Add monthly weekday set position recurrence --- Sources/RemindCore/Models.swift | 3 + Sources/RemindCore/RecurrenceAdapter.swift | 5 +- Sources/remindctl/Commands/AddCommand.swift | 11 ++- Sources/remindctl/Commands/EditCommand.swift | 11 ++- Sources/remindctl/RecurrenceFormatting.swift | 5 + Sources/remindctl/RepeatParsing.swift | 45 ++++++++- .../RecurrenceAdapterTests.swift | 19 ++++ .../RecurrenceFormattingTests.swift | 12 +++ Tests/remindctlTests/RepeatParsingTests.swift | 94 +++++++++++++++++-- 9 files changed, 187 insertions(+), 18 deletions(-) diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 2f01976..4dfb029 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -78,6 +78,7 @@ public struct ReminderRecurrence: Codable, Sendable, Equatable { public let interval: Int public let daysOfWeek: [ReminderWeekday]? public let daysOfMonth: [Int]? + public let setPositions: [Int]? public let end: ReminderRecurrenceEnd? public init( @@ -85,12 +86,14 @@ public struct ReminderRecurrence: Codable, Sendable, Equatable { interval: Int = 1, daysOfWeek: [ReminderWeekday]? = nil, daysOfMonth: [Int]? = nil, + setPositions: [Int]? = nil, end: ReminderRecurrenceEnd? = nil ) { self.frequency = frequency self.interval = interval self.daysOfWeek = daysOfWeek self.daysOfMonth = daysOfMonth + self.setPositions = setPositions self.end = end } } diff --git a/Sources/RemindCore/RecurrenceAdapter.swift b/Sources/RemindCore/RecurrenceAdapter.swift index 9c7a03c..07afdb6 100644 --- a/Sources/RemindCore/RecurrenceAdapter.swift +++ b/Sources/RemindCore/RecurrenceAdapter.swift @@ -10,6 +10,7 @@ enum RecurrenceAdapter { .sorted { $0.displayOrder < $1.displayOrder } .compactMap(eventKitDayOfWeek(from:)) let daysOfMonth = recurrence.daysOfMonth?.sorted().map { NSNumber(value: $0) } + let setPositions = recurrence.setPositions?.sorted().map { NSNumber(value: $0) } return EKRecurrenceRule( recurrenceWith: frequency, interval: interval, @@ -18,7 +19,7 @@ enum RecurrenceAdapter { monthsOfTheYear: nil, weeksOfTheYear: nil, daysOfTheYear: nil, - setPositions: nil, + setPositions: setPositions, end: end ) } @@ -33,11 +34,13 @@ enum RecurrenceAdapter { .compactMap(reminderDayOfWeek(from:)) .sorted { $0.displayOrder < $1.displayOrder } let daysOfMonth = rule.daysOfTheMonth?.map { $0.intValue }.sorted() + let setPositions = rule.setPositions?.map { $0.intValue }.sorted() return ReminderRecurrence( frequency: frequency, interval: interval, daysOfWeek: daysOfWeek, daysOfMonth: daysOfMonth, + setPositions: setPositions, end: end ) } diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index 33e239d..ca49c09 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -22,6 +22,7 @@ enum AddCommand { .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), .make(label: "on", names: [.long("on")], help: "Weekdays (mon,tue,...)", parsing: .singleValue), .make(label: "monthDay", names: [.long("month-day")], help: "Days of month (1-31)", parsing: .singleValue), + .make(label: "setpos", names: [.long("setpos")], help: "Week of month (-1,1-4)", parsing: .singleValue), .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( @@ -65,14 +66,17 @@ enum AddCommand { let intervalValue = values.option("interval") let onValue = values.option("on") let monthDayValue = values.option("monthDay") + let setposValue = values.option("setpos") let countValue = values.option("count") let untilValue = values.option("until") let priorityValue = values.option("priority") - let hasRepeatModifiers = [intervalValue, onValue, monthDayValue, countValue, untilValue] + let hasRepeatModifiers = [intervalValue, onValue, monthDayValue, setposValue, countValue, untilValue] .contains { $0 != nil } if repeatValue == nil && hasRepeatModifiers { - throw RemindCoreError.operationFailed("Use --repeat with --interval, --on, --month-day, --count, or --until") + throw RemindCoreError.operationFailed( + "Use --repeat with --interval, --on, --month-day, --setpos, --count, or --until" + ) } var dueDate = try dueValue.map(CommandHelpers.parseDueDate) @@ -84,7 +88,8 @@ enum AddCommand { count: countValue, until: untilValue, on: onValue, - monthDay: monthDayValue + monthDay: monthDayValue, + setpos: setposValue ) ) } diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index a6b0497..a57bfd0 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -22,6 +22,7 @@ enum EditCommand { .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), .make(label: "on", names: [.long("on")], help: "Weekdays (mon,tue,...)", parsing: .singleValue), .make(label: "monthDay", names: [.long("month-day")], help: "Days of month (1-31)", parsing: .singleValue), + .make(label: "setpos", names: [.long("setpos")], help: "Week of month (-1,1-4)", parsing: .singleValue), .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( @@ -64,6 +65,7 @@ enum EditCommand { let intervalValue = values.option("interval") let onValue = values.option("on") let monthDayValue = values.option("monthDay") + let setposValue = values.option("setpos") let countValue = values.option("count") let untilValue = values.option("until") @@ -83,10 +85,12 @@ enum EditCommand { priority = try CommandHelpers.parsePriority(priorityValue) } - let hasRepeatModifiers = [intervalValue, onValue, monthDayValue, countValue, untilValue] + let hasRepeatModifiers = [intervalValue, onValue, monthDayValue, setposValue, countValue, untilValue] .contains { $0 != nil } if repeatValue == nil && hasRepeatModifiers { - throw RemindCoreError.operationFailed("Use --repeat with --interval, --on, --month-day, --count, or --until") + throw RemindCoreError.operationFailed( + "Use --repeat with --interval, --on, --month-day, --setpos, --count, or --until" + ) } let recurrenceUpdate: ReminderRecurrence?? = try repeatValue.map { @@ -97,7 +101,8 @@ enum EditCommand { count: countValue, until: untilValue, on: onValue, - monthDay: monthDayValue + monthDay: monthDayValue, + setpos: setposValue ) ) } diff --git a/Sources/remindctl/RecurrenceFormatting.swift b/Sources/remindctl/RecurrenceFormatting.swift index 897ac16..b98f9c0 100644 --- a/Sources/remindctl/RecurrenceFormatting.swift +++ b/Sources/remindctl/RecurrenceFormatting.swift @@ -19,6 +19,11 @@ enum RecurrenceFormatting { parts.append("month-day=\(days)") } + if let setPositions = recurrence.setPositions, !setPositions.isEmpty { + let positions = setPositions.map(String.init).joined(separator: ",") + parts.append("setpos=\(positions)") + } + if let end = recurrence.end { switch end { case .count(let count): diff --git a/Sources/remindctl/RepeatParsing.swift b/Sources/remindctl/RepeatParsing.swift index 9a35cf8..e79b37b 100644 --- a/Sources/remindctl/RepeatParsing.swift +++ b/Sources/remindctl/RepeatParsing.swift @@ -9,6 +9,7 @@ enum RepeatParsing { let until: String? let on: String? let monthDay: String? + let setpos: String? } static func parseFrequency(_ value: String) throws -> ReminderRecurrenceFrequency { @@ -47,12 +48,28 @@ enum RepeatParsing { let parsedInterval = try input.interval.map(parseInterval) ?? 1 let daysOfWeek = try input.on.map { try parseWeekdays($0) } let daysOfMonth = try input.monthDay.map { try parseMonthDays($0) } - if daysOfWeek != nil && parsedFrequency != .weekly { - throw RemindCoreError.operationFailed("--on is only supported with weekly repeats") + let setPositions = try input.setpos.map { try parseSetPositions($0) } + if daysOfWeek != nil { + switch parsedFrequency { + case .weekly: + break + case .monthly: + if setPositions == nil { + throw RemindCoreError.operationFailed("--on requires --setpos for monthly repeats") + } + default: + throw RemindCoreError.operationFailed("--on is only supported with weekly repeats") + } } if daysOfMonth != nil && parsedFrequency != .monthly { throw RemindCoreError.operationFailed("--month-day is only supported with monthly repeats") } + if setPositions != nil && parsedFrequency != .monthly { + throw RemindCoreError.operationFailed("--setpos is only supported with monthly repeats") + } + if setPositions != nil && daysOfWeek == nil { + throw RemindCoreError.operationFailed("--setpos requires --on") + } let end: ReminderRecurrenceEnd? if let count = input.count { @@ -71,6 +88,7 @@ enum RepeatParsing { interval: parsedInterval, daysOfWeek: daysOfWeek, daysOfMonth: daysOfMonth, + setPositions: setPositions, end: end ) } @@ -133,4 +151,27 @@ enum RepeatParsing { return nil } } + + private static func parseSetPositions(_ value: String) throws -> [Int] { + let tokens = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !tokens.isEmpty else { + throw RemindCoreError.operationFailed("Invalid set positions: \"\(value)\"") + } + + var positions: [Int] = [] + var seen = Set() + for token in tokens where !token.isEmpty { + guard let position = Int(token), isValidSetPosition(position) else { + throw RemindCoreError.operationFailed("Invalid set position: \"\(token)\"") + } + if seen.insert(position).inserted { + positions.append(position) + } + } + return positions + } + + private static func isValidSetPosition(_ value: Int) -> Bool { + value == -1 || (1...4).contains(value) + } } diff --git a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift index 9a582ac..16affa9 100644 --- a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift +++ b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift @@ -63,4 +63,23 @@ struct RecurrenceAdapterTests { let roundTrip = RecurrenceAdapter.recurrence(from: rule) #expect(roundTrip == recurrence) } + + @Test("Monthly recurrence maps set positions with weekdays") + func monthlySetPositions() { + let recurrence = ReminderRecurrence( + frequency: .monthly, + interval: 1, + daysOfWeek: [.monday], + setPositions: [2] + ) + let rule = RecurrenceAdapter.rule(from: recurrence) + let positions = rule.setPositions?.map { $0.intValue } + let days = rule.daysOfTheWeek?.map(\.dayOfTheWeek) + + #expect(positions == [2]) + #expect(days == [.monday]) + + let roundTrip = RecurrenceAdapter.recurrence(from: rule) + #expect(roundTrip == recurrence) + } } diff --git a/Tests/remindctlTests/RecurrenceFormattingTests.swift b/Tests/remindctlTests/RecurrenceFormattingTests.swift index 49a57f1..2eb59a6 100644 --- a/Tests/remindctlTests/RecurrenceFormattingTests.swift +++ b/Tests/remindctlTests/RecurrenceFormattingTests.swift @@ -42,6 +42,18 @@ struct RecurrenceFormattingTests { #expect(summary == "repeat=monthly month-day=1,15,31") } + @Test("Formats monthly recurrence with set positions") + func monthlySetposSummary() { + let recurrence = ReminderRecurrence( + frequency: .monthly, + interval: 1, + daysOfWeek: [.monday], + setPositions: [2] + ) + let summary = RecurrenceFormatting.summary(for: recurrence, useISO: false) + #expect(summary == "repeat=monthly on=mon setpos=2") + } + @Test("Formats ISO until date for plain output") func untilSummaryISO() { let date = Date(timeIntervalSince1970: 0) diff --git a/Tests/remindctlTests/RepeatParsingTests.swift b/Tests/remindctlTests/RepeatParsingTests.swift index 9b8e7a4..639facc 100644 --- a/Tests/remindctlTests/RepeatParsingTests.swift +++ b/Tests/remindctlTests/RepeatParsingTests.swift @@ -13,7 +13,8 @@ struct RepeatParsingTests { count: nil, until: nil, on: nil, - monthDay: nil + monthDay: nil, + setpos: nil ) ) #expect(recurrence.frequency == .daily) @@ -30,7 +31,8 @@ struct RepeatParsingTests { count: "5", until: nil, on: nil, - monthDay: nil + monthDay: nil, + setpos: nil ) ) #expect(recurrence.frequency == .weekly) @@ -47,7 +49,8 @@ struct RepeatParsingTests { count: nil, until: "2026-01-03T12:34:56Z", on: nil, - monthDay: nil + monthDay: nil, + setpos: nil ) ) guard case .until = recurrence.end else { @@ -66,7 +69,8 @@ struct RepeatParsingTests { count: nil, until: nil, on: nil, - monthDay: nil + monthDay: nil, + setpos: nil ) ) } @@ -82,7 +86,8 @@ struct RepeatParsingTests { count: "2", until: "tomorrow", on: nil, - monthDay: nil + monthDay: nil, + setpos: nil ) ) } @@ -97,7 +102,8 @@ struct RepeatParsingTests { count: nil, until: nil, on: "mon,wed,fri", - monthDay: nil + monthDay: nil, + setpos: nil ) ) #expect(recurrence.daysOfWeek == [.monday, .wednesday, .friday]) @@ -113,7 +119,25 @@ struct RepeatParsingTests { count: nil, until: nil, on: "mon", - monthDay: nil + monthDay: nil, + setpos: nil + ) + ) + } + } + + @Test("Rejects monthly --on without --setpos") + func monthlyOnWithoutSetpos() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "monthly", + interval: nil, + count: nil, + until: nil, + on: "mon", + monthDay: nil, + setpos: nil ) ) } @@ -128,7 +152,8 @@ struct RepeatParsingTests { count: nil, until: nil, on: nil, - monthDay: "1,15,31" + monthDay: "1,15,31", + setpos: nil ) ) #expect(recurrence.daysOfMonth == [1, 15, 31]) @@ -144,7 +169,58 @@ struct RepeatParsingTests { count: nil, until: nil, on: nil, - monthDay: "1" + monthDay: "1", + setpos: nil + ) + ) + } + } + + @Test("Parses set positions for monthly weekday rules") + func setPositionsMonthly() throws { + let recurrence = try RepeatParsing.parseRecurrence( + .init( + frequency: "monthly", + interval: nil, + count: nil, + until: nil, + on: "mon", + monthDay: nil, + setpos: "2" + ) + ) + #expect(recurrence.setPositions == [2]) + } + + @Test("Rejects --setpos without --on") + func setposRequiresOn() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "monthly", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: nil, + setpos: "2" + ) + ) + } + } + + @Test("Rejects --setpos for non-monthly") + func setposNonMonthly() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "weekly", + interval: nil, + count: nil, + until: nil, + on: "mon", + monthDay: nil, + setpos: "2" ) ) } From d560a42c7b8ae9bec8f6a8f9944f8b92e3e5b949 Mon Sep 17 00:00:00 2001 From: John Schweikert Date: Sat, 24 Jan 2026 09:02:27 -0500 Subject: [PATCH 5/5] Add yearly recurrence constraints --- Sources/RemindCore/Models.swift | 7 ++ Sources/RemindCore/RecurrenceAdapter.swift | 14 ++- Sources/remindctl/Commands/AddCommand.swift | 53 ++++++-- Sources/remindctl/Commands/EditCommand.swift | 53 ++++++-- Sources/remindctl/RecurrenceFormatting.swift | 10 ++ Sources/remindctl/RepeatParsing.swift | 89 +++++++++++++- .../RecurrenceAdapterTests.swift | 19 +++ .../RecurrenceFormattingTests.swift | 12 ++ Tests/remindctlTests/RepeatParsingTests.swift | 116 +++++++++++++++--- 9 files changed, 342 insertions(+), 31 deletions(-) diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 4dfb029..6d5a326 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -37,6 +37,7 @@ public enum ReminderRecurrenceFrequency: String, Codable, CaseIterable, Sendable case daily case weekly case monthly + case yearly } public enum ReminderWeekday: String, Codable, CaseIterable, Sendable { @@ -79,6 +80,8 @@ public struct ReminderRecurrence: Codable, Sendable, Equatable { public let daysOfWeek: [ReminderWeekday]? public let daysOfMonth: [Int]? public let setPositions: [Int]? + public let monthsOfYear: [Int]? + public let weeksOfYear: [Int]? public let end: ReminderRecurrenceEnd? public init( @@ -87,6 +90,8 @@ public struct ReminderRecurrence: Codable, Sendable, Equatable { daysOfWeek: [ReminderWeekday]? = nil, daysOfMonth: [Int]? = nil, setPositions: [Int]? = nil, + monthsOfYear: [Int]? = nil, + weeksOfYear: [Int]? = nil, end: ReminderRecurrenceEnd? = nil ) { self.frequency = frequency @@ -94,6 +99,8 @@ public struct ReminderRecurrence: Codable, Sendable, Equatable { self.daysOfWeek = daysOfWeek self.daysOfMonth = daysOfMonth self.setPositions = setPositions + self.monthsOfYear = monthsOfYear + self.weeksOfYear = weeksOfYear self.end = end } } diff --git a/Sources/RemindCore/RecurrenceAdapter.swift b/Sources/RemindCore/RecurrenceAdapter.swift index 07afdb6..718e161 100644 --- a/Sources/RemindCore/RecurrenceAdapter.swift +++ b/Sources/RemindCore/RecurrenceAdapter.swift @@ -11,13 +11,15 @@ enum RecurrenceAdapter { .compactMap(eventKitDayOfWeek(from:)) let daysOfMonth = recurrence.daysOfMonth?.sorted().map { NSNumber(value: $0) } let setPositions = recurrence.setPositions?.sorted().map { NSNumber(value: $0) } + let monthsOfYear = recurrence.monthsOfYear?.sorted().map { NSNumber(value: $0) } + let weeksOfYear = recurrence.weeksOfYear?.sorted().map { NSNumber(value: $0) } return EKRecurrenceRule( recurrenceWith: frequency, interval: interval, daysOfTheWeek: daysOfWeek, daysOfTheMonth: daysOfMonth, - monthsOfTheYear: nil, - weeksOfTheYear: nil, + monthsOfTheYear: monthsOfYear, + weeksOfTheYear: weeksOfYear, daysOfTheYear: nil, setPositions: setPositions, end: end @@ -35,12 +37,16 @@ enum RecurrenceAdapter { .sorted { $0.displayOrder < $1.displayOrder } let daysOfMonth = rule.daysOfTheMonth?.map { $0.intValue }.sorted() let setPositions = rule.setPositions?.map { $0.intValue }.sorted() + let monthsOfYear = rule.monthsOfTheYear?.map { $0.intValue }.sorted() + let weeksOfYear = rule.weeksOfTheYear?.map { $0.intValue }.sorted() return ReminderRecurrence( frequency: frequency, interval: interval, daysOfWeek: daysOfWeek, daysOfMonth: daysOfMonth, setPositions: setPositions, + monthsOfYear: monthsOfYear, + weeksOfYear: weeksOfYear, end: end ) } @@ -53,6 +59,8 @@ enum RecurrenceAdapter { return .weekly case .monthly: return .monthly + case .yearly: + return .yearly } } @@ -64,6 +72,8 @@ enum RecurrenceAdapter { return .weekly case .monthly: return .monthly + case .yearly: + return .yearly default: return nil } diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index ca49c09..428cdf6 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -18,11 +18,38 @@ enum AddCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue), - .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly|monthly", parsing: .singleValue), + .make( + label: "repeat", + names: [.long("repeat")], + help: "daily|weekly|monthly|yearly", + parsing: .singleValue + ), .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), .make(label: "on", names: [.long("on")], help: "Weekdays (mon,tue,...)", parsing: .singleValue), - .make(label: "monthDay", names: [.long("month-day")], help: "Days of month (1-31)", parsing: .singleValue), - .make(label: "setpos", names: [.long("setpos")], help: "Week of month (-1,1-4)", parsing: .singleValue), + .make( + label: "monthDay", + names: [.long("month-day")], + help: "Days of month (1-31)", + parsing: .singleValue + ), + .make( + label: "setpos", + names: [.long("setpos")], + help: "Week of month (-1,1-4)", + parsing: .singleValue + ), + .make( + label: "month", + names: [.long("month")], + help: "Months (1-12 or jan-dec)", + parsing: .singleValue + ), + .make( + label: "week", + names: [.long("week")], + help: "Weeks of year (1-53)", + parsing: .singleValue + ), .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( @@ -67,15 +94,25 @@ enum AddCommand { let onValue = values.option("on") let monthDayValue = values.option("monthDay") let setposValue = values.option("setpos") + let monthValue = values.option("month") + let weekValue = values.option("week") let countValue = values.option("count") let untilValue = values.option("until") let priorityValue = values.option("priority") - let hasRepeatModifiers = [intervalValue, onValue, monthDayValue, setposValue, countValue, untilValue] - .contains { $0 != nil } + let hasRepeatModifiers = [ + intervalValue, + onValue, + monthDayValue, + setposValue, + monthValue, + weekValue, + countValue, + untilValue, + ].contains { $0 != nil } if repeatValue == nil && hasRepeatModifiers { throw RemindCoreError.operationFailed( - "Use --repeat with --interval, --on, --month-day, --setpos, --count, or --until" + "Use --repeat with --interval, --on, --month-day, --setpos, --month, --week, --count, or --until" ) } @@ -89,7 +126,9 @@ enum AddCommand { until: untilValue, on: onValue, monthDay: monthDayValue, - setpos: setposValue + setpos: setposValue, + month: monthValue, + week: weekValue ) ) } diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index a57bfd0..73518f5 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -18,11 +18,38 @@ enum EditCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue), - .make(label: "repeat", names: [.long("repeat")], help: "daily|weekly|monthly", parsing: .singleValue), + .make( + label: "repeat", + names: [.long("repeat")], + help: "daily|weekly|monthly|yearly", + parsing: .singleValue + ), .make(label: "interval", names: [.long("interval")], help: "Repeat interval", parsing: .singleValue), .make(label: "on", names: [.long("on")], help: "Weekdays (mon,tue,...)", parsing: .singleValue), - .make(label: "monthDay", names: [.long("month-day")], help: "Days of month (1-31)", parsing: .singleValue), - .make(label: "setpos", names: [.long("setpos")], help: "Week of month (-1,1-4)", parsing: .singleValue), + .make( + label: "monthDay", + names: [.long("month-day")], + help: "Days of month (1-31)", + parsing: .singleValue + ), + .make( + label: "setpos", + names: [.long("setpos")], + help: "Week of month (-1,1-4)", + parsing: .singleValue + ), + .make( + label: "month", + names: [.long("month")], + help: "Months (1-12 or jan-dec)", + parsing: .singleValue + ), + .make( + label: "week", + names: [.long("week")], + help: "Weeks of year (1-53)", + parsing: .singleValue + ), .make(label: "count", names: [.long("count")], help: "Repeat occurrence count", parsing: .singleValue), .make(label: "until", names: [.long("until")], help: "Repeat end date", parsing: .singleValue), .make( @@ -66,6 +93,8 @@ enum EditCommand { let onValue = values.option("on") let monthDayValue = values.option("monthDay") let setposValue = values.option("setpos") + let monthValue = values.option("month") + let weekValue = values.option("week") let countValue = values.option("count") let untilValue = values.option("until") @@ -85,11 +114,19 @@ enum EditCommand { priority = try CommandHelpers.parsePriority(priorityValue) } - let hasRepeatModifiers = [intervalValue, onValue, monthDayValue, setposValue, countValue, untilValue] - .contains { $0 != nil } + let hasRepeatModifiers = [ + intervalValue, + onValue, + monthDayValue, + setposValue, + monthValue, + weekValue, + countValue, + untilValue, + ].contains { $0 != nil } if repeatValue == nil && hasRepeatModifiers { throw RemindCoreError.operationFailed( - "Use --repeat with --interval, --on, --month-day, --setpos, --count, or --until" + "Use --repeat with --interval, --on, --month-day, --setpos, --month, --week, --count, or --until" ) } @@ -102,7 +139,9 @@ enum EditCommand { until: untilValue, on: onValue, monthDay: monthDayValue, - setpos: setposValue + setpos: setposValue, + month: monthValue, + week: weekValue ) ) } diff --git a/Sources/remindctl/RecurrenceFormatting.swift b/Sources/remindctl/RecurrenceFormatting.swift index b98f9c0..c9cb527 100644 --- a/Sources/remindctl/RecurrenceFormatting.swift +++ b/Sources/remindctl/RecurrenceFormatting.swift @@ -24,6 +24,16 @@ enum RecurrenceFormatting { parts.append("setpos=\(positions)") } + if let monthsOfYear = recurrence.monthsOfYear, !monthsOfYear.isEmpty { + let months = monthsOfYear.map(String.init).joined(separator: ",") + parts.append("month=\(months)") + } + + if let weeksOfYear = recurrence.weeksOfYear, !weeksOfYear.isEmpty { + let weeks = weeksOfYear.map(String.init).joined(separator: ",") + parts.append("week=\(weeks)") + } + if let end = recurrence.end { switch end { case .count(let count): diff --git a/Sources/remindctl/RepeatParsing.swift b/Sources/remindctl/RepeatParsing.swift index e79b37b..e4bf5ae 100644 --- a/Sources/remindctl/RepeatParsing.swift +++ b/Sources/remindctl/RepeatParsing.swift @@ -10,6 +10,8 @@ enum RepeatParsing { let on: String? let monthDay: String? let setpos: String? + let month: String? + let week: String? } static func parseFrequency(_ value: String) throws -> ReminderRecurrenceFrequency { @@ -20,8 +22,10 @@ enum RepeatParsing { return .weekly case "monthly": return .monthly + case "yearly": + return .yearly default: - throw RemindCoreError.operationFailed("Invalid repeat frequency: \"\(value)\" (use daily|weekly|monthly)") + throw RemindCoreError.operationFailed("Invalid repeat frequency: \"\(value)\" (use daily|weekly|monthly|yearly)") } } @@ -49,6 +53,8 @@ enum RepeatParsing { let daysOfWeek = try input.on.map { try parseWeekdays($0) } let daysOfMonth = try input.monthDay.map { try parseMonthDays($0) } let setPositions = try input.setpos.map { try parseSetPositions($0) } + let monthsOfYear = try input.month.map { try parseMonths($0) } + let weeksOfYear = try input.week.map { try parseWeeks($0) } if daysOfWeek != nil { switch parsedFrequency { case .weekly: @@ -70,6 +76,12 @@ enum RepeatParsing { if setPositions != nil && daysOfWeek == nil { throw RemindCoreError.operationFailed("--setpos requires --on") } + if monthsOfYear != nil && parsedFrequency != .yearly { + throw RemindCoreError.operationFailed("--month is only supported with yearly repeats") + } + if weeksOfYear != nil && parsedFrequency != .yearly { + throw RemindCoreError.operationFailed("--week is only supported with yearly repeats") + } let end: ReminderRecurrenceEnd? if let count = input.count { @@ -89,6 +101,8 @@ enum RepeatParsing { daysOfWeek: daysOfWeek, daysOfMonth: daysOfMonth, setPositions: setPositions, + monthsOfYear: monthsOfYear, + weeksOfYear: weeksOfYear, end: end ) } @@ -174,4 +188,77 @@ enum RepeatParsing { private static func isValidSetPosition(_ value: Int) -> Bool { value == -1 || (1...4).contains(value) } + + private static func parseMonths(_ value: String) throws -> [Int] { + let tokens = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !tokens.isEmpty else { + throw RemindCoreError.operationFailed("Invalid months: \"\(value)\"") + } + + var months: [Int] = [] + var seen = Set() + for token in tokens where !token.isEmpty { + guard let month = parseMonth(String(token)) else { + throw RemindCoreError.operationFailed("Invalid month: \"\(token)\"") + } + if seen.insert(month).inserted { + months.append(month) + } + } + return months + } + + private static func parseMonth(_ value: String) -> Int? { + if let month = Int(value), (1...12).contains(month) { + return month + } + + switch value.lowercased() { + case "jan", "january": + return 1 + case "feb", "february": + return 2 + case "mar", "march": + return 3 + case "apr", "april": + return 4 + case "may": + return 5 + case "jun", "june": + return 6 + case "jul", "july": + return 7 + case "aug", "august": + return 8 + case "sep", "sept", "september": + return 9 + case "oct", "october": + return 10 + case "nov", "november": + return 11 + case "dec", "december": + return 12 + default: + return nil + } + } + + private static func parseWeeks(_ value: String) throws -> [Int] { + let tokens = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !tokens.isEmpty else { + throw RemindCoreError.operationFailed("Invalid weeks: \"\(value)\"") + } + + var weeks: [Int] = [] + var seen = Set() + for token in tokens where !token.isEmpty { + guard let week = Int(token), (1...53).contains(week) else { + throw RemindCoreError.operationFailed("Invalid week: \"\(token)\"") + } + if seen.insert(week).inserted { + weeks.append(week) + } + } + return weeks + } } diff --git a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift index 16affa9..d148293 100644 --- a/Tests/RemindCoreTests/RecurrenceAdapterTests.swift +++ b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift @@ -82,4 +82,23 @@ struct RecurrenceAdapterTests { let roundTrip = RecurrenceAdapter.recurrence(from: rule) #expect(roundTrip == recurrence) } + + @Test("Yearly recurrence maps months and weeks") + func yearlyMonthsWeeks() { + let recurrence = ReminderRecurrence( + frequency: .yearly, + interval: 1, + monthsOfYear: [1, 12], + weeksOfYear: [1, 52] + ) + let rule = RecurrenceAdapter.rule(from: recurrence) + let months = rule.monthsOfTheYear?.map { $0.intValue } + let weeks = rule.weeksOfTheYear?.map { $0.intValue } + + #expect(months == [1, 12]) + #expect(weeks == [1, 52]) + + let roundTrip = RecurrenceAdapter.recurrence(from: rule) + #expect(roundTrip == recurrence) + } } diff --git a/Tests/remindctlTests/RecurrenceFormattingTests.swift b/Tests/remindctlTests/RecurrenceFormattingTests.swift index 2eb59a6..85fb7a5 100644 --- a/Tests/remindctlTests/RecurrenceFormattingTests.swift +++ b/Tests/remindctlTests/RecurrenceFormattingTests.swift @@ -54,6 +54,18 @@ struct RecurrenceFormattingTests { #expect(summary == "repeat=monthly on=mon setpos=2") } + @Test("Formats yearly recurrence with months and weeks") + func yearlySummary() { + let recurrence = ReminderRecurrence( + frequency: .yearly, + interval: 1, + monthsOfYear: [1, 12], + weeksOfYear: [1, 52] + ) + let summary = RecurrenceFormatting.summary(for: recurrence, useISO: false) + #expect(summary == "repeat=yearly month=1,12 week=1,52") + } + @Test("Formats ISO until date for plain output") func untilSummaryISO() { let date = Date(timeIntervalSince1970: 0) diff --git a/Tests/remindctlTests/RepeatParsingTests.swift b/Tests/remindctlTests/RepeatParsingTests.swift index 639facc..9bdc9a6 100644 --- a/Tests/remindctlTests/RepeatParsingTests.swift +++ b/Tests/remindctlTests/RepeatParsingTests.swift @@ -1,4 +1,5 @@ import Testing + @testable import RemindCore @testable import remindctl @@ -14,7 +15,9 @@ struct RepeatParsingTests { until: nil, on: nil, monthDay: nil, - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) #expect(recurrence.frequency == .daily) @@ -32,7 +35,9 @@ struct RepeatParsingTests { until: nil, on: nil, monthDay: nil, - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) #expect(recurrence.frequency == .weekly) @@ -50,7 +55,9 @@ struct RepeatParsingTests { until: "2026-01-03T12:34:56Z", on: nil, monthDay: nil, - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) guard case .until = recurrence.end else { @@ -64,13 +71,15 @@ struct RepeatParsingTests { #expect(throws: RemindCoreError.self) { _ = try RepeatParsing.parseRecurrence( .init( - frequency: "yearly", + frequency: "hourly", interval: nil, count: nil, until: nil, on: nil, monthDay: nil, - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) } @@ -87,7 +96,9 @@ struct RepeatParsingTests { until: "tomorrow", on: nil, monthDay: nil, - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) } @@ -103,7 +114,9 @@ struct RepeatParsingTests { until: nil, on: "mon,wed,fri", monthDay: nil, - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) #expect(recurrence.daysOfWeek == [.monday, .wednesday, .friday]) @@ -120,7 +133,9 @@ struct RepeatParsingTests { until: nil, on: "mon", monthDay: nil, - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) } @@ -137,7 +152,9 @@ struct RepeatParsingTests { until: nil, on: "mon", monthDay: nil, - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) } @@ -153,7 +170,9 @@ struct RepeatParsingTests { until: nil, on: nil, monthDay: "1,15,31", - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) #expect(recurrence.daysOfMonth == [1, 15, 31]) @@ -170,7 +189,9 @@ struct RepeatParsingTests { until: nil, on: nil, monthDay: "1", - setpos: nil + setpos: nil, + month: nil, + week: nil ) ) } @@ -186,7 +207,9 @@ struct RepeatParsingTests { until: nil, on: "mon", monthDay: nil, - setpos: "2" + setpos: "2", + month: nil, + week: nil ) ) #expect(recurrence.setPositions == [2]) @@ -203,7 +226,9 @@ struct RepeatParsingTests { until: nil, on: nil, monthDay: nil, - setpos: "2" + setpos: "2", + month: nil, + week: nil ) ) } @@ -220,7 +245,70 @@ struct RepeatParsingTests { until: nil, on: "mon", monthDay: nil, - setpos: "2" + setpos: "2", + month: nil, + week: nil + ) + ) + } + } + +} + +@MainActor +struct RepeatParsingYearlyTests { + @Test("Parses yearly months and weeks") + func yearlyMonthsWeeks() throws { + let recurrence = try RepeatParsing.parseRecurrence( + .init( + frequency: "yearly", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: nil, + setpos: nil, + month: "jan,dec", + week: "1,52" + ) + ) + #expect(recurrence.monthsOfYear == [1, 12]) + #expect(recurrence.weeksOfYear == [1, 52]) + } + + @Test("Rejects --month for non-yearly") + func monthNonYearly() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "monthly", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: nil, + setpos: nil, + month: "1", + week: nil + ) + ) + } + } + + @Test("Rejects --week for non-yearly") + func weekNonYearly() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "weekly", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: nil, + setpos: nil, + month: nil, + week: "1" ) ) }