Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
30 changes: 27 additions & 3 deletions Sources/RemindersLibrary/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,25 @@ 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: " "),
notes: self.notes,
toListNamed: self.listName,
dueDateComponents: self.dueDate,
priority: priority,
recurrence: self.repeat,
outputFormat: format)
}
}
Expand Down Expand Up @@ -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")
}
}

Expand All @@ -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
)
}
}
Expand Down
5 changes: 5 additions & 0 deletions Sources/RemindersLibrary/EKReminder+Encodable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ extension EKReminder: @retroactive Encodable {
case priority
case startDate
case dueDate
case recurrence
case list
}

Expand Down Expand Up @@ -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)
Expand Down
91 changes: 91 additions & 0 deletions Sources/RemindersLibrary/Recurrence.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
27 changes: 25 additions & 2 deletions Sources/RemindersLibrary/Reminders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "<unknown>")\(notesString)\(dateString)\(priorityString)"
return "\(listString)\(indexString)\(reminder.title ?? "<unknown>")\(notesString)\(dateString)\(recurrenceString)\(priorityString)"
}

public enum OutputFormat: String, ExpressibleByArgument {
Expand Down Expand Up @@ -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)

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions Tests/RemindersTests/RecurrenceTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading