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..4dfb029 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -33,6 +33,71 @@ 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 { + 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) +} + +public struct ReminderRecurrence: Codable, Sendable, Equatable { + public let frequency: ReminderRecurrenceFrequency + public let interval: Int + public let daysOfWeek: [ReminderWeekday]? + public let daysOfMonth: [Int]? + public let setPositions: [Int]? + public let end: ReminderRecurrenceEnd? + + public init( + frequency: ReminderRecurrenceFrequency, + 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 + } +} + public struct ReminderList: Identifiable, Codable, Sendable, Equatable { public let id: String public let title: String @@ -51,6 +116,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 +128,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { completionDate: Date?, priority: ReminderPriority, dueDate: Date?, + recurrence: ReminderRecurrence? = nil, listID: String, listName: String ) { @@ -72,6 +139,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 +150,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 +172,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 +181,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 +189,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..07afdb6 --- /dev/null +++ b/Sources/RemindCore/RecurrenceAdapter.swift @@ -0,0 +1,138 @@ +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:)) + let daysOfWeek = recurrence.daysOfWeek? + .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, + daysOfTheWeek: daysOfWeek, + daysOfTheMonth: daysOfMonth, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: setPositions, + 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:)) + let daysOfWeek = rule.daysOfTheWeek? + .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 + ) + } + + private static func eventKitFrequency(from frequency: ReminderRecurrenceFrequency) -> EKRecurrenceFrequency { + switch frequency { + case .daily: + return .daily + case .weekly: + return .weekly + case .monthly: + return .monthly + } + } + + private static func reminderFrequency(from frequency: EKRecurrenceFrequency) -> ReminderRecurrenceFrequency? { + switch frequency { + case .daily: + return .daily + case .weekly: + return .weekly + case .monthly: + return .monthly + default: + return nil + } + } + + 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): + 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..ca49c09 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -18,6 +18,13 @@ 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: "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( label: "priority", names: [.short("p"), .long("priority")], @@ -55,9 +62,41 @@ 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 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 dueDate = try dueValue.map(CommandHelpers.parseDueDate) + 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, --setpos, --count, or --until" + ) + } + + var dueDate = try dueValue.map(CommandHelpers.parseDueDate) + let recurrence = try repeatValue.map { + try RepeatParsing.parseRecurrence( + .init( + frequency: $0, + interval: intervalValue, + count: countValue, + until: untilValue, + on: onValue, + monthDay: monthDayValue, + setpos: setposValue + ) + ) + } + + if recurrence != nil && dueDate == nil { + dueDate = Date() + } let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none let store = RemindersStore() @@ -73,7 +112,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..a57bfd0 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -18,6 +18,13 @@ 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: "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( label: "priority", names: [.short("p"), .long("priority")], @@ -54,6 +61,13 @@ 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 onValue = values.option("on") + let monthDayValue = values.option("monthDay") + let setposValue = values.option("setpos") + let countValue = values.option("count") + let untilValue = values.option("until") var dueUpdate: Date?? if let dueValue = values.option("due") { @@ -71,6 +85,28 @@ enum EditCommand { priority = try CommandHelpers.parsePriority(priorityValue) } + 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, --setpos, --count, or --until" + ) + } + + let recurrenceUpdate: ReminderRecurrence?? = try repeatValue.map { + try RepeatParsing.parseRecurrence( + .init( + frequency: $0, + interval: intervalValue, + count: countValue, + until: untilValue, + on: onValue, + monthDay: monthDayValue, + setpos: setposValue + ) + ) + } + let completeFlag = values.flag("complete") let incompleteFlag = values.flag("incomplete") if completeFlag && incompleteFlag { @@ -78,7 +114,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 +136,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..b98f9c0 --- /dev/null +++ b/Sources/remindctl/RecurrenceFormatting.swift @@ -0,0 +1,45 @@ +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 daysOfWeek = recurrence.daysOfWeek, !daysOfWeek.isEmpty { + let days = daysOfWeek.map(\.rawValue).joined(separator: ",") + 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 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): + 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..e79b37b --- /dev/null +++ b/Sources/remindctl/RepeatParsing.swift @@ -0,0 +1,177 @@ +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? + let setpos: 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|monthly)") + } + } + + 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(_ 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(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) } + 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 { + end = .count(try parseCount(count)) + } else if let until = input.until { + guard let parsedUntil = DateParsing.parseUserDate(until) else { + throw RemindCoreError.invalidDate(until) + } + end = .until(parsedUntil) + } else { + end = nil + } + + return ReminderRecurrence( + frequency: parsedFrequency, + interval: parsedInterval, + daysOfWeek: daysOfWeek, + daysOfMonth: daysOfMonth, + setPositions: setPositions, + 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 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": + 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 + } + } + + 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 new file mode 100644 index 0000000..16affa9 --- /dev/null +++ b/Tests/RemindCoreTests/RecurrenceAdapterTests.swift @@ -0,0 +1,85 @@ +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) + } + + @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) + } + + @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) + } + + @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 new file mode 100644 index 0000000..2eb59a6 --- /dev/null +++ b/Tests/remindctlTests/RecurrenceFormattingTests.swift @@ -0,0 +1,65 @@ +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 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 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 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) + 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..639facc --- /dev/null +++ b/Tests/remindctlTests/RepeatParsingTests.swift @@ -0,0 +1,228 @@ +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( + .init( + frequency: "daily", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: nil, + setpos: 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( + .init( + frequency: "weekly", + interval: "2", + count: "5", + until: nil, + on: nil, + monthDay: nil, + setpos: 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( + .init( + frequency: "daily", + interval: nil, + count: nil, + until: "2026-01-03T12:34:56Z", + on: nil, + monthDay: nil, + setpos: nil + ) + ) + guard case .until = recurrence.end else { + #expect(Bool(false)) + return + } + } + + @Test("Rejects invalid frequency") + func invalidFrequency() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "yearly", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: nil, + setpos: nil + ) + ) + } + } + + @Test("Rejects count with until") + func countAndUntil() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "daily", + interval: nil, + count: "2", + until: "tomorrow", + on: nil, + monthDay: nil, + setpos: nil + ) + ) + } + } + + @Test("Parses weekly days") + func weeklyDays() throws { + let recurrence = try RepeatParsing.parseRecurrence( + .init( + frequency: "weekly", + interval: nil, + count: nil, + until: nil, + on: "mon,wed,fri", + monthDay: nil, + setpos: nil + ) + ) + #expect(recurrence.daysOfWeek == [.monday, .wednesday, .friday]) + } + + @Test("Rejects --on for non-weekly") + func onNonWeekly() { + #expect(throws: RemindCoreError.self) { + _ = try RepeatParsing.parseRecurrence( + .init( + frequency: "daily", + interval: nil, + count: nil, + until: nil, + on: "mon", + 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 + ) + ) + } + } + + @Test("Parses monthly month days") + func monthlyDays() throws { + let recurrence = try RepeatParsing.parseRecurrence( + .init( + frequency: "monthly", + interval: nil, + count: nil, + until: nil, + on: nil, + monthDay: "1,15,31", + setpos: nil + ) + ) + #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", + 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" + ) + ) + } + } +}