diff --git a/README.md b/README.md index 4d7b7cc..de1de15 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,18 @@ $ reminders show Soon 3: Something really important (priority: high) ``` +#### Add a recurring reminder + +`--repeat` requires `--due-date` to also be set, and accepts `daily`, `weekly`, +`monthly`, `yearly`, or an interval like `2-weeks` / `3-months`. + +``` +$ reminders add Soon Water the plants --due-date "tomorrow 9am" --repeat weekly +$ reminders edit Soon 0 --repeat 2-weeks +$ reminders show Soon +0: Water the plants (in 10 hours) (repeats: weekly) +``` + #### Show reminders due on or by a date ``` diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index 8978505..f515a82 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -153,6 +153,17 @@ private struct Add: ParsableCommand { help: "The notes to add to the reminder") var notes: String? + @Option( + name: .shortAndLong, + help: "\(Recurrence.helpText). Requires --due-date to be set") + var `repeat`: Recurrence? + + func validate() throws { + if self.repeat != nil && self.dueDate == nil { + throw ValidationError("--repeat requires --due-date to also be set") + } + } + func run() { reminders.addReminder( string: self.reminder.joined(separator: " "), @@ -160,6 +171,7 @@ private struct Add: ParsableCommand { toListNamed: self.listName, dueDateComponents: self.dueDate, priority: priority, + recurrence: self.repeat, outputFormat: format) } } @@ -242,14 +254,24 @@ private struct Edit: ParsableCommand { help: "The notes to set on the reminder, overwriting previous notes") var notes: String? + @Option( + name: .shortAndLong, + help: "The due date to set on the reminder, overwriting the previous due date") + var dueDate: DateComponents? + + @Option( + name: .shortAndLong, + help: "\(Recurrence.helpText). Requires the reminder to have a due date, either previously set or via --due-date") + var `repeat`: Recurrence? + @Argument( parsing: .remaining, help: "The new reminder contents") var reminder: [String] = [] func validate() throws { - if self.reminder.isEmpty && self.notes == nil { - throw ValidationError("Must specify either new reminder content or new notes") + if self.reminder.isEmpty && self.notes == nil && self.dueDate == nil && self.repeat == nil { + throw ValidationError("Must specify new reminder content, notes, due date, or repeat") } } @@ -259,7 +281,9 @@ private struct Edit: ParsableCommand { itemAtIndex: self.index, onListNamed: self.listName, newText: newText.isEmpty ? nil : newText, - newNotes: self.notes + newNotes: self.notes, + newDueDateComponents: self.dueDate, + newRecurrence: self.repeat ) } } diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index f9f9fdb..8df9a30 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -15,6 +15,7 @@ extension EKReminder: @retroactive Encodable { case priority case startDate case dueDate + case recurrence case list } @@ -50,6 +51,10 @@ extension EKReminder: @retroactive Encodable { if let dueDateComponents = self.dueDateComponents { try container.encodeIfPresent(format(dueDateComponents.date), forKey: .dueDate) } + + if let recurrenceRule = self.recurrenceRules?.first { + try container.encode(recurrenceRule.shortDescription, forKey: .recurrence) + } if let lastModifiedDate = self.lastModifiedDate { try container.encode(format(lastModifiedDate), forKey: .lastModified) diff --git a/Sources/RemindersLibrary/Recurrence.swift b/Sources/RemindersLibrary/Recurrence.swift new file mode 100644 index 0000000..e19d076 --- /dev/null +++ b/Sources/RemindersLibrary/Recurrence.swift @@ -0,0 +1,91 @@ +import ArgumentParser +import EventKit +import Foundation + +/// A simple recurrence specification parsed from a `--repeat` CLI option, e.g. +/// `daily`, `weekly`, `monthly`, `yearly`, or with an interval like `2-weeks`. +/// +/// This intentionally covers the common cases (frequency + interval) rather +/// than the full expressiveness of `EKRecurrenceRule` (day-of-week sets, +/// end conditions, etc). Those can be layered on later if needed. +public struct Recurrence: ExpressibleByArgument { + public let frequency: EKRecurrenceFrequency + public let interval: Int + + public init?(argument: String) { + let lowered = argument.lowercased() + // Reject a leading '-' up front: `split(separator:)` omits empty + // subsequences by default, so "-1-weeks" would otherwise silently + // lose its sign and parse as the (wrong) positive interval 1. + guard !lowered.hasPrefix("-") else { return nil } + + let parts = lowered.split(separator: "-", maxSplits: 1) + + let intervalPart: Int + let frequencyPart: Substring + + if parts.count == 2, let parsedInterval = Int(parts[0]) { + intervalPart = parsedInterval + frequencyPart = parts[1] + } else { + intervalPart = 1 + frequencyPart = lowered[...] + } + + guard intervalPart > 0 else { return nil } + + switch frequencyPart { + case "day", "days", "daily": + self.frequency = .daily + case "week", "weeks", "weekly": + self.frequency = .weekly + case "month", "months", "monthly": + self.frequency = .monthly + case "year", "years", "yearly", "annually": + self.frequency = .yearly + default: + return nil + } + + self.interval = intervalPart + } + + var recurrenceRule: EKRecurrenceRule { + EKRecurrenceRule( + recurrenceWith: self.frequency, + interval: self.interval, + end: nil) + } + + static var helpText: String { + "Make the reminder recurring, one of: daily, weekly, monthly, yearly, " + + "or with an interval like '2-weeks' or '3-months'" + } +} + +extension EKRecurrenceRule { + /// A short, stable string representation used for JSON/plain output, + /// e.g. "weekly", "2-weeks". + var shortDescription: String { + let unit: String + switch self.frequency { + case .daily: unit = "days" + case .weekly: unit = "weeks" + case .monthly: unit = "months" + case .yearly: unit = "years" + @unknown default: unit = "occurrences" + } + + if self.interval == 1 { + switch self.frequency { + case .daily: return "daily" + case .weekly: return "weekly" + case .monthly: return "monthly" + case .yearly: return "yearly" + @unknown default: return "1-\(unit)" + } + } + + return "\(self.interval)-\(unit)" + } +} diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index fb04a20..e559d5f 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -19,10 +19,11 @@ private extension EKReminder { private func format(_ reminder: EKReminder, at index: Int?, listName: String? = nil) -> String { let dateString = formattedDueDate(from: reminder).map { " (\($0))" } ?? "" let priorityString = Priority(reminder.mappedPriority).map { " (priority: \($0))" } ?? "" + let recurrenceString = reminder.recurrenceRules?.first.map { " (repeats: \($0.shortDescription))" } ?? "" let listString = listName.map { "\($0): " } ?? "" let notesString = reminder.notes.map { " (\($0))" } ?? "" let indexString = index.map { "\($0): " } ?? "" - return "\(listString)\(indexString)\(reminder.title ?? "")\(notesString)\(dateString)\(priorityString)" + return "\(listString)\(indexString)\(reminder.title ?? "")\(notesString)\(dateString)\(recurrenceString)\(priorityString)" } public enum OutputFormat: String, ExpressibleByArgument { @@ -230,7 +231,9 @@ public final class Reminders { } } - func edit(itemAtIndex index: String, onListNamed name: String, newText: String?, newNotes: String?) { + func edit(itemAtIndex index: String, onListNamed name: String, newText: String?, newNotes: String?, + newDueDateComponents: DateComponents? = nil, newRecurrence: Recurrence? = nil) + { let calendar = self.calendar(withName: name) let semaphore = DispatchSemaphore(value: 0) @@ -243,6 +246,20 @@ public final class Reminders { do { reminder.title = newText ?? reminder.title reminder.notes = newNotes ?? reminder.notes + if let newDueDateComponents = newDueDateComponents { + reminder.dueDateComponents = newDueDateComponents + } + if let recurrence = newRecurrence { + guard reminder.dueDateComponents != nil else { + print("Cannot set --repeat on a reminder with no due date, pass --due-date too") + exit(1) + } + // Recurrence rules must be attached before saving, replace any existing ones. + for rule in reminder.recurrenceRules ?? [] { + reminder.removeRecurrenceRule(rule) + } + reminder.addRecurrenceRule(recurrence.recurrenceRule) + } try Store.save(reminder, commit: true) print("Updated reminder '\(reminder.title!)'") } catch let error { @@ -324,6 +341,7 @@ public final class Reminders { toListNamed name: String, dueDateComponents: DateComponents?, priority: Priority, + recurrence: Recurrence? = nil, outputFormat: OutputFormat) { let calendar = self.calendar(withName: name) @@ -336,6 +354,11 @@ public final class Reminders { if let dueDate = dueDateComponents?.date, dueDateComponents?.hour != nil { reminder.addAlarm(EKAlarm(absoluteDate: dueDate)) } + // Recurrence rules must be added before the initial save, adding them + // afterwards silently fails to persist. + if let recurrence = recurrence { + reminder.addRecurrenceRule(recurrence.recurrenceRule) + } do { try Store.save(reminder, commit: true) diff --git a/Tests/RemindersTests/RecurrenceTests.swift b/Tests/RemindersTests/RecurrenceTests.swift new file mode 100644 index 0000000..813fc39 --- /dev/null +++ b/Tests/RemindersTests/RecurrenceTests.swift @@ -0,0 +1,74 @@ +import EventKit +@testable import RemindersLibrary +import XCTest + +final class RecurrenceTests: XCTestCase { + func testDaily() throws { + let recurrence = try XCTUnwrap(Recurrence(argument: "daily")) + XCTAssertEqual(recurrence.frequency, .daily) + XCTAssertEqual(recurrence.interval, 1) + } + + func testWeekly() throws { + let recurrence = try XCTUnwrap(Recurrence(argument: "weekly")) + XCTAssertEqual(recurrence.frequency, .weekly) + XCTAssertEqual(recurrence.interval, 1) + } + + func testMonthly() throws { + let recurrence = try XCTUnwrap(Recurrence(argument: "monthly")) + XCTAssertEqual(recurrence.frequency, .monthly) + XCTAssertEqual(recurrence.interval, 1) + } + + func testYearly() throws { + let recurrence = try XCTUnwrap(Recurrence(argument: "yearly")) + XCTAssertEqual(recurrence.frequency, .yearly) + XCTAssertEqual(recurrence.interval, 1) + } + + func testAliases() throws { + XCTAssertEqual(try XCTUnwrap(Recurrence(argument: "day")).frequency, .daily) + XCTAssertEqual(try XCTUnwrap(Recurrence(argument: "days")).frequency, .daily) + XCTAssertEqual(try XCTUnwrap(Recurrence(argument: "week")).frequency, .weekly) + XCTAssertEqual(try XCTUnwrap(Recurrence(argument: "annually")).frequency, .yearly) + } + + func testWithInterval() throws { + let recurrence = try XCTUnwrap(Recurrence(argument: "2-weeks")) + XCTAssertEqual(recurrence.frequency, .weekly) + XCTAssertEqual(recurrence.interval, 2) + + let recurrence2 = try XCTUnwrap(Recurrence(argument: "3-months")) + XCTAssertEqual(recurrence2.frequency, .monthly) + XCTAssertEqual(recurrence2.interval, 3) + } + + func testCaseInsensitive() throws { + let recurrence = try XCTUnwrap(Recurrence(argument: "WEEKLY")) + XCTAssertEqual(recurrence.frequency, .weekly) + } + + func testInvalidFrequency() { + XCTAssertNil(Recurrence(argument: "fortnightly")) + XCTAssertNil(Recurrence(argument: "blah")) + } + + func testInvalidInterval() { + XCTAssertNil(Recurrence(argument: "0-weeks")) + XCTAssertNil(Recurrence(argument: "-1-weeks")) + } + + func testRecurrenceRule() throws { + let recurrence = try XCTUnwrap(Recurrence(argument: "2-weeks")) + let rule = recurrence.recurrenceRule + XCTAssertEqual(rule.frequency, .weekly) + XCTAssertEqual(rule.interval, 2) + } + + func testShortDescription() throws { + XCTAssertEqual(try XCTUnwrap(Recurrence(argument: "daily")).recurrenceRule.shortDescription, "daily") + XCTAssertEqual(try XCTUnwrap(Recurrence(argument: "weekly")).recurrenceRule.shortDescription, "weekly") + XCTAssertEqual(try XCTUnwrap(Recurrence(argument: "3-months")).recurrenceRule.shortDescription, "3-months") + } +}