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" + ) + } + } +}