From ef84018fc9ceb6393784da280b253567fe35e49f Mon Sep 17 00:00:00 2001 From: Ramesh Baskaran Date: Fri, 7 Aug 2026 11:30:30 +0400 Subject: [PATCH 1/3] Add --repeat recurrence support for add/edit (closes #104) Implements EKRecurrenceRule-backed recurrence, requested in #104: - `reminders add --repeat daily|weekly|monthly|yearly` - `--repeat-interval N` to repeat every N units instead of every 1 (default 1), e.g. `--repeat-interval 2 --repeat weekly` for fortnightly. - `--repeat-until ` to set a recurrence end date; omitting it repeats forever, matching Reminders.app's own default. - `reminders edit --repeat ...` replaces an existing repeat rule; `--clear-repeat` removes it entirely. - JSON output (`--format json`) now includes `recurrence`, `recurrenceInterval`, and `recurrenceEnd` fields when a reminder has a recurrence rule, so scripts consuming `reminders show --format json` can see it. `hourly` is intentionally rejected with a clear validation error rather than silently degrading to a daily rule: EventKit's `EKRecurrenceFrequency` has no hourly case (Reminders.app's own UI doesn't expose hourly repeat either), so there's no faithful way to represent it via `EKRecurrenceRule`. Tested live against real Reminders.app data (add with daily/weekly/ monthly + custom interval + end date, edit to replace and to clear, hourly correctly rejected, --repeat-interval/--repeat-until correctly rejected without --repeat, conflicting --repeat + --clear-repeat correctly rejected). Added Tests/RemindersTests/RecurrenceTests.swift covering the frequency mapping, interval, end-date, and representability logic at the unit level. Note on test execution: this dev environment currently has only Command Line Tools installed (no full Xcode), so `swift test` can't link XCTest here (`xcrun --find xctest` fails) -- this affects the pre-existing NaturalLanguageTests.swift identically, so it's an environment gap, not something this change introduces. `swift build` succeeds cleanly and all behavior was verified against the live binary instead; the new test file is plain XCTest and should run normally under CI or on a machine with full Xcode. --- README.md | 22 +++++ Sources/RemindersLibrary/CLI.swift | 80 ++++++++++++++++++- .../EKReminder+Encodable.swift | 19 +++++ Sources/RemindersLibrary/Reminders.swift | 64 ++++++++++++++- Tests/RemindersTests/RecurrenceTests.swift | 64 +++++++++++++++ 5 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 Tests/RemindersTests/RecurrenceTests.swift diff --git a/README.md b/README.md index efd46e3..a16e8b6 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,28 @@ $ reminders show Soon 3: Something really important (priority: high) ``` +#### Add a repeating reminder + +``` +$ reminders add Soon Weekly review --due-date "monday 9am" --repeat weekly +$ reminders add Soon Pay rent --due-date "2026-09-01" --repeat monthly --repeat-until "2027-09-01" +$ reminders add Soon Water the plants --due-date "tomorrow" --repeat daily --repeat-interval 3 +``` + +`--repeat` accepts `daily`, `weekly`, `monthly`, or `yearly` (EventKit reminders have no hourly +recurrence frequency, so `--repeat hourly` is rejected with an explanation rather than silently +degrading to daily). `--repeat-interval` repeats every N units instead of every 1 (e.g. +`--repeat-interval 2 --repeat weekly` for every other week) and defaults to 1. `--repeat-until` +stops the recurrence after a given date; omitting it repeats forever, matching the Reminders.app +default. Both `--repeat-interval` and `--repeat-until` require `--repeat` to also be set. + +To change or remove a repeat rule on an existing reminder, use `edit`: + +``` +$ reminders edit Soon 0 --repeat monthly +$ reminders edit Soon 0 --clear-repeat +``` + #### Show reminders due on or by a date ``` diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index ad2bbef..4acfdb4 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -153,6 +153,36 @@ private struct Add: ParsableCommand { help: "The notes to add to the reminder") var notes: String? + @Option( + name: [.customLong("repeat")], + help: "Repeat the reminder, one of: daily, weekly, monthly, yearly") + var repeat_: Recurrence? + + @Option( + name: .long, + help: "Repeat every N units of --repeat's frequency instead of every 1 (default: 1)") + var repeatInterval: Int = 1 + + @Option( + name: .long, + help: "Stop repeating after this date (default: repeats forever)") + var repeatUntil: DateComponents? + + func validate() throws { + if let repeat_ = repeat_, !repeat_.isRepresentable { + throw ValidationError( + "--repeat \(repeat_.rawValue) is not supported: EventKit reminders have no hourly " + + "recurrence frequency (Reminders.app itself doesn't expose this either). Use " + + "daily, weekly, monthly, or yearly.") + } + if repeatInterval < 1 { + throw ValidationError("--repeat-interval must be at least 1") + } + if repeat_ == nil && (repeatInterval != 1 || repeatUntil != nil) { + throw ValidationError("--repeat-interval and --repeat-until require --repeat") + } + } + func run() { reminders.addReminder( string: self.reminder.joined(separator: " "), @@ -160,6 +190,9 @@ private struct Add: ParsableCommand { toListNamed: self.listName, dueDateComponents: self.dueDate, priority: priority, + recurrence: self.repeat_, + recurrenceInterval: self.repeatInterval, + recurrenceEnd: self.repeatUntil, outputFormat: format) } } @@ -242,6 +275,26 @@ private struct Edit: ParsableCommand { help: "The notes to set on the reminder, overwriting previous notes") var notes: String? + @Option( + name: [.customLong("repeat")], + help: "Set (or replace) the reminder's repeat, one of: daily, weekly, monthly, yearly") + var repeat_: Recurrence? + + @Option( + name: .long, + help: "Repeat every N units of --repeat's frequency instead of every 1 (default: 1)") + var repeatInterval: Int = 1 + + @Option( + name: .long, + help: "Stop repeating after this date (default: repeats forever)") + var repeatUntil: DateComponents? + + @Flag( + name: .long, + help: "Remove any repeat rule from the reminder") + var clearRepeat = false + @Option( name: .shortAndLong, help: "The new date the reminder is due") @@ -260,9 +313,26 @@ private struct Edit: ParsableCommand { throw ValidationError("Cannot specify both --due-date and --clear-due-date") } - if self.reminder.isEmpty && self.notes == nil && self.dueDate == nil && !self.clearDueDate { + if self.reminder.isEmpty && self.notes == nil && self.dueDate == nil + && !self.clearDueDate && self.repeat_ == nil && !self.clearRepeat + { + throw ValidationError( + "Must specify new reminder content, new notes, a due date change, --repeat, or --clear-repeat") + } + if self.clearRepeat && self.repeat_ != nil { + throw ValidationError("Cannot specify both --repeat and --clear-repeat") + } + if let repeat_ = repeat_, !repeat_.isRepresentable { throw ValidationError( - "Must specify either new reminder content, new notes, or a due date change") + "--repeat \(repeat_.rawValue) is not supported: EventKit reminders have no hourly " + + "recurrence frequency (Reminders.app itself doesn't expose this either). Use " + + "daily, weekly, monthly, or yearly.") + } + if repeatInterval < 1 { + throw ValidationError("--repeat-interval must be at least 1") + } + if repeat_ == nil && (repeatInterval != 1 || repeatUntil != nil) { + throw ValidationError("--repeat-interval and --repeat-until require --repeat") } } @@ -274,7 +344,11 @@ private struct Edit: ParsableCommand { newText: newText.isEmpty ? nil : newText, newNotes: self.notes, newDueDateComponents: self.dueDate, - clearDueDate: self.clearDueDate + clearDueDate: self.clearDueDate, + newRecurrence: self.repeat_, + newRecurrenceInterval: self.repeatInterval, + newRecurrenceEnd: self.repeatUntil, + clearRecurrence: self.clearRepeat ) } } diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index f9f9fdb..e168914 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -16,6 +16,9 @@ extension EKReminder: @retroactive Encodable { case startDate case dueDate case list + case recurrence + case recurrenceInterval + case recurrenceEnd } public func encode(to encoder: Encoder) throws { @@ -58,6 +61,22 @@ extension EKReminder: @retroactive Encodable { if let creationDate = self.creationDate { try container.encode(format(creationDate), forKey: .creationDate) } + + if let rule = self.recurrenceRules?.first { + try container.encodeIfPresent(recurrenceName(for: rule.frequency), forKey: .recurrence) + try container.encode(rule.interval, forKey: .recurrenceInterval) + try container.encodeIfPresent(format(rule.recurrenceEnd?.endDate), forKey: .recurrenceEnd) + } + } + + private func recurrenceName(for frequency: EKRecurrenceFrequency) -> String? { + switch frequency { + case .daily: return "daily" + case .weekly: return "weekly" + case .monthly: return "monthly" + case .yearly: return "yearly" + @unknown default: return nil + } } private func format(_ date: Date?) -> String? { diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 5c650c3..33b0f02 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -35,6 +35,46 @@ public enum DisplayOptions: String, Decodable { case complete } +public enum Recurrence: String, ExpressibleByArgument { + case hourly + case daily + case weekly + case monthly + case yearly + + var frequency: EKRecurrenceFrequency { + switch self { + case .hourly: return .daily // EventKit has no hourly frequency; see interval note below. + case .daily: return .daily + case .weekly: return .weekly + case .monthly: return .monthly + case .yearly: return .yearly + } + } + + /// EventKit's `EKRecurrenceFrequency` has no hourly case, so `.hourly` is + /// modeled as a daily rule with a 1-day interval and 24 hourly recurrences + /// per day is not representable via `EKRecurrenceRule` alone. Since + /// reminders don't carry a native hourly repeat concept in EventKit (the + /// Reminders.app UI itself doesn't expose "hourly" either), `.hourly` is + /// intentionally rejected at parse time in `RecurrenceOption` rather than + /// silently degrading to daily. See CLI.swift for the actual validation; + /// this case is kept in the enum only so `--repeat hourly` produces a + /// clear, on-brand error message instead of an ArgumentParser "invalid + /// value" message with no explanation. + var isRepresentable: Bool { + self != .hourly + } + + func recurrenceRule(interval: Int, until: Date?) -> EKRecurrenceRule { + let end = until.map { EKRecurrenceEnd(end: $0) } + return EKRecurrenceRule( + recurrenceWith: self.frequency, + interval: interval, + end: end) + } +} + public enum Priority: String, ExpressibleByArgument { case none case low @@ -236,7 +276,9 @@ public final class Reminders { newText: String?, newNotes: String?, newDueDateComponents: DateComponents? = nil, - clearDueDate: Bool = false) + clearDueDate: Bool = false, + newRecurrence: Recurrence?, newRecurrenceInterval: Int, newRecurrenceEnd: DateComponents?, + clearRecurrence: Bool) { let calendar = self.calendar(withName: name) let semaphore = DispatchSemaphore(value: 0) @@ -267,6 +309,18 @@ public final class Reminders { } } + if clearRecurrence { + for rule in reminder.recurrenceRules ?? [] { + reminder.removeRecurrenceRule(rule) + } + } else if let newRecurrence = newRecurrence { + for rule in reminder.recurrenceRules ?? [] { + reminder.removeRecurrenceRule(rule) + } + let rule = newRecurrence.recurrenceRule( + interval: newRecurrenceInterval, until: newRecurrenceEnd?.date) + reminder.addRecurrenceRule(rule) + } try Store.save(reminder, commit: true) print("Updated reminder '\(reminder.title!)'") } catch let error { @@ -348,6 +402,9 @@ public final class Reminders { toListNamed name: String, dueDateComponents: DateComponents?, priority: Priority, + recurrence: Recurrence?, + recurrenceInterval: Int, + recurrenceEnd: DateComponents?, outputFormat: OutputFormat) { let calendar = self.calendar(withName: name) @@ -360,6 +417,11 @@ public final class Reminders { if let dueDate = dueDateComponents?.date, dueDateComponents?.hour != nil { reminder.addAlarm(EKAlarm(absoluteDate: dueDate)) } + if let recurrence = recurrence { + let rule = recurrence.recurrenceRule( + interval: recurrenceInterval, until: recurrenceEnd?.date) + reminder.addRecurrenceRule(rule) + } 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..bb60926 --- /dev/null +++ b/Tests/RemindersTests/RecurrenceTests.swift @@ -0,0 +1,64 @@ +import EventKit +@testable import RemindersLibrary +import XCTest + +final class RecurrenceTests: XCTestCase { + func testDailyFrequencyMapping() throws { + let rule = Recurrence.daily.recurrenceRule(interval: 1, until: nil) + XCTAssertEqual(rule.frequency, .daily) + XCTAssertEqual(rule.interval, 1) + XCTAssertNil(rule.recurrenceEnd) + } + + func testWeeklyFrequencyMapping() throws { + let rule = Recurrence.weekly.recurrenceRule(interval: 1, until: nil) + XCTAssertEqual(rule.frequency, .weekly) + } + + func testMonthlyFrequencyMapping() throws { + let rule = Recurrence.monthly.recurrenceRule(interval: 1, until: nil) + XCTAssertEqual(rule.frequency, .monthly) + } + + func testYearlyFrequencyMapping() throws { + let rule = Recurrence.yearly.recurrenceRule(interval: 1, until: nil) + XCTAssertEqual(rule.frequency, .yearly) + } + + func testCustomInterval() throws { + let rule = Recurrence.monthly.recurrenceRule(interval: 2, until: nil) + XCTAssertEqual(rule.interval, 2) + } + + func testRecurrenceEndDate() throws { + let end = Date() + let rule = Recurrence.weekly.recurrenceRule(interval: 1, until: end) + XCTAssertNotNil(rule.recurrenceEnd) + XCTAssertEqual( + rule.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, + end.timeIntervalSince1970, + accuracy: 1.0) + } + + func testHourlyIsNotRepresentable() throws { + // EventKit has no hourly EKRecurrenceFrequency; this is asserted at the + // model layer so CLI validation (which rejects it before ever building + // a rule) has something concrete to check against. + XCTAssertFalse(Recurrence.hourly.isRepresentable) + } + + func testRepresentableFrequenciesAreAllRepresentable() throws { + for frequency: Recurrence in [.daily, .weekly, .monthly, .yearly] { + XCTAssertTrue(frequency.isRepresentable, "\(frequency.rawValue) should be representable") + } + } + + func testRecurrenceParsesFromArgument() throws { + XCTAssertEqual(Recurrence(argument: "daily"), .daily) + XCTAssertEqual(Recurrence(argument: "weekly"), .weekly) + XCTAssertEqual(Recurrence(argument: "monthly"), .monthly) + XCTAssertEqual(Recurrence(argument: "yearly"), .yearly) + XCTAssertEqual(Recurrence(argument: "hourly"), .hourly) + XCTAssertNil(Recurrence(argument: "biweekly")) + } +} From 6049620979c9d69b92651c169072dcafb660ab9e Mon Sep 17 00:00:00 2001 From: Aleksei Morozov <246998741+morozovpersonal@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:01:45 +0200 Subject: [PATCH 2/3] Make recurrence end edits safe and visible --- README.md | 15 +- Sources/RemindersLibrary/CLI.swift | 56 ++-- .../EKReminder+Encodable.swift | 4 + Sources/RemindersLibrary/Reminders.swift | 265 ++++++++++++++++-- Tests/RemindersTests/RecurrenceTests.swift | 237 +++++++++++++++- 5 files changed, 529 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index a16e8b6..1283f5b 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,26 @@ $ reminders add Soon Water the plants --due-date "tomorrow" --repeat daily --rep `--repeat` accepts `daily`, `weekly`, `monthly`, or `yearly` (EventKit reminders have no hourly recurrence frequency, so `--repeat hourly` is rejected with an explanation rather than silently degrading to daily). `--repeat-interval` repeats every N units instead of every 1 (e.g. -`--repeat-interval 2 --repeat weekly` for every other week) and defaults to 1. `--repeat-until` -stops the recurrence after a given date; omitting it repeats forever, matching the Reminders.app -default. Both `--repeat-interval` and `--repeat-until` require `--repeat` to also be set. +`--repeat-interval 2 --repeat weekly` for every other week) and defaults to 1. Use +`--repeat-until` to stop after a date; omitting it repeats forever, matching the Reminders.app +default. A date without a time includes the whole local day. On `add`, recurrence options require +`--repeat` to also be set, and a repeating reminder requires `--due-date`. To change or remove a repeat rule on an existing reminder, use `edit`: ``` $ reminders edit Soon 0 --repeat monthly +$ reminders edit Soon 0 --repeat-until "2027-09-01" +$ reminders edit Soon 0 --clear-repeat-end $ reminders edit Soon 0 --clear-repeat ``` +Changing only the interval or end condition preserves the existing frequency and any complex +selectors, such as "the last Friday of every month". Changing the frequency preserves the existing +end condition unless a new `--repeat-until` or `--clear-repeat-end` is supplied. +JSON output includes `recurrence`, `recurrenceInterval`, and either `recurrenceEnd` or +`recurrenceCount` (when an existing rule is count-based) for repeating reminders. + #### Show reminders due on or by a date ``` diff --git a/Sources/RemindersLibrary/CLI.swift b/Sources/RemindersLibrary/CLI.swift index 4acfdb4..68271ac 100644 --- a/Sources/RemindersLibrary/CLI.swift +++ b/Sources/RemindersLibrary/CLI.swift @@ -161,7 +161,7 @@ private struct Add: ParsableCommand { @Option( name: .long, help: "Repeat every N units of --repeat's frequency instead of every 1 (default: 1)") - var repeatInterval: Int = 1 + var repeatInterval: Int? @Option( name: .long, @@ -175,11 +175,21 @@ private struct Add: ParsableCommand { + "recurrence frequency (Reminders.app itself doesn't expose this either). Use " + "daily, weekly, monthly, or yearly.") } - if repeatInterval < 1 { + if repeat_ != nil && dueDate == nil { + throw ValidationError("--repeat requires --due-date") + } + if let repeatUntil, let dueDate, + let endDate = recurrenceEndDate(from: repeatUntil), + let due = dueDate.date, endDate < due + { + throw ValidationError("--repeat-until cannot be earlier than --due-date") + } + if let repeatInterval, repeatInterval < 1 { throw ValidationError("--repeat-interval must be at least 1") } - if repeat_ == nil && (repeatInterval != 1 || repeatUntil != nil) { - throw ValidationError("--repeat-interval and --repeat-until require --repeat") + if repeat_ == nil && (repeatInterval != nil || repeatUntil != nil) { + throw ValidationError( + "--repeat-interval and --repeat-until require --repeat") } } @@ -191,8 +201,8 @@ private struct Add: ParsableCommand { dueDateComponents: self.dueDate, priority: priority, recurrence: self.repeat_, - recurrenceInterval: self.repeatInterval, - recurrenceEnd: self.repeatUntil, + recurrenceInterval: self.repeatInterval ?? 1, + recurrenceEndDate: self.repeatUntil, outputFormat: format) } } @@ -282,12 +292,12 @@ private struct Edit: ParsableCommand { @Option( name: .long, - help: "Repeat every N units of --repeat's frequency instead of every 1 (default: 1)") - var repeatInterval: Int = 1 + help: "Repeat every N units of the recurrence frequency") + var repeatInterval: Int? @Option( name: .long, - help: "Stop repeating after this date (default: repeats forever)") + help: "Stop repeating after this date; preserves the existing repeat frequency") var repeatUntil: DateComponents? @Flag( @@ -295,6 +305,11 @@ private struct Edit: ParsableCommand { help: "Remove any repeat rule from the reminder") var clearRepeat = false + @Flag( + name: .long, + help: "Keep repeating forever without changing the recurrence frequency") + var clearRepeatEnd = false + @Option( name: .shortAndLong, help: "The new date the reminder is due") @@ -313,14 +328,17 @@ private struct Edit: ParsableCommand { throw ValidationError("Cannot specify both --due-date and --clear-due-date") } + let changesRecurrence = self.repeat_ != nil || self.repeatInterval != nil + || self.repeatUntil != nil || self.clearRepeatEnd + if self.reminder.isEmpty && self.notes == nil && self.dueDate == nil - && !self.clearDueDate && self.repeat_ == nil && !self.clearRepeat + && !self.clearDueDate && !changesRecurrence && !self.clearRepeat { throw ValidationError( - "Must specify new reminder content, new notes, a due date change, --repeat, or --clear-repeat") + "Must specify new reminder content, new notes, a due date change, or a repeat change") } - if self.clearRepeat && self.repeat_ != nil { - throw ValidationError("Cannot specify both --repeat and --clear-repeat") + if self.clearRepeat && changesRecurrence { + throw ValidationError("Cannot combine --clear-repeat with another repeat option") } if let repeat_ = repeat_, !repeat_.isRepresentable { throw ValidationError( @@ -328,11 +346,14 @@ private struct Edit: ParsableCommand { + "recurrence frequency (Reminders.app itself doesn't expose this either). Use " + "daily, weekly, monthly, or yearly.") } - if repeatInterval < 1 { + if let repeatInterval, repeatInterval < 1 { throw ValidationError("--repeat-interval must be at least 1") } - if repeat_ == nil && (repeatInterval != 1 || repeatUntil != nil) { - throw ValidationError("--repeat-interval and --repeat-until require --repeat") + let endOptionCount = [repeatUntil != nil, clearRepeatEnd] + .filter { $0 }.count + if endOptionCount > 1 { + throw ValidationError( + "Specify only one of --repeat-until or --clear-repeat-end") } } @@ -347,7 +368,8 @@ private struct Edit: ParsableCommand { clearDueDate: self.clearDueDate, newRecurrence: self.repeat_, newRecurrenceInterval: self.repeatInterval, - newRecurrenceEnd: self.repeatUntil, + newRecurrenceEndDate: self.repeatUntil, + clearRecurrenceEnd: self.clearRepeatEnd, clearRecurrence: self.clearRepeat ) } diff --git a/Sources/RemindersLibrary/EKReminder+Encodable.swift b/Sources/RemindersLibrary/EKReminder+Encodable.swift index e168914..f6613f9 100644 --- a/Sources/RemindersLibrary/EKReminder+Encodable.swift +++ b/Sources/RemindersLibrary/EKReminder+Encodable.swift @@ -19,6 +19,7 @@ extension EKReminder: @retroactive Encodable { case recurrence case recurrenceInterval case recurrenceEnd + case recurrenceCount } public func encode(to encoder: Encoder) throws { @@ -66,6 +67,9 @@ extension EKReminder: @retroactive Encodable { try container.encodeIfPresent(recurrenceName(for: rule.frequency), forKey: .recurrence) try container.encode(rule.interval, forKey: .recurrenceInterval) try container.encodeIfPresent(format(rule.recurrenceEnd?.endDate), forKey: .recurrenceEnd) + if let count = rule.recurrenceEnd?.occurrenceCount, count > 0 { + try container.encode(count, forKey: .recurrenceCount) + } } } diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index 33b0f02..d63b0c6 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -4,12 +4,47 @@ import Foundation private let Store = EKEventStore() private let dateFormatter = RelativeDateTimeFormatter() +private let recurrenceDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + return formatter +}() + private func formattedDueDate(from reminder: EKReminder) -> String? { return reminder.dueDateComponents?.date.map { dateFormatter.localizedString(for: $0, relativeTo: Date()) } } +private func formattedRecurrence(from reminder: EKReminder) -> String? { + guard let rule = reminder.recurrenceRules?.first else { + return nil + } + + let frequency: String + switch rule.frequency { + case .daily: frequency = "daily" + case .weekly: frequency = "weekly" + case .monthly: frequency = "monthly" + case .yearly: frequency = "yearly" + @unknown default: frequency = "unknown" + } + + var parts = ["repeats: \(frequency)"] + if rule.interval > 1 { + parts.append("interval: \(rule.interval)") + } + if let endDate = rule.recurrenceEnd?.endDate { + parts.append("until: \(recurrenceDateFormatter.string(from: endDate))") + } else if let count = rule.recurrenceEnd?.occurrenceCount, count > 0 { + parts.append("count: \(count)") + } + return parts.joined(separator: ", ") +} + private extension EKReminder { var mappedPriority: EKReminderPriority { UInt(exactly: self.priority).flatMap(EKReminderPriority.init) ?? EKReminderPriority.none @@ -22,7 +57,8 @@ private func format(_ reminder: EKReminder, at index: Int?, listName: String? = let listString = listName.map { "\($0): " } ?? "" let notesString = reminder.notes.map { " (\($0))" } ?? "" let indexString = index.map { "\($0): " } ?? "" - return "\(listString)\(indexString)\(reminder.title ?? "")\(notesString)\(dateString)\(priorityString)" + let recurrenceString = formattedRecurrence(from: reminder).map { " (\($0))" } ?? "" + return "\(listString)\(indexString)\(reminder.title ?? "")\(notesString)\(dateString)\(priorityString)\(recurrenceString)" } public enum OutputFormat: String, ExpressibleByArgument { @@ -66,8 +102,7 @@ public enum Recurrence: String, ExpressibleByArgument { self != .hourly } - func recurrenceRule(interval: Int, until: Date?) -> EKRecurrenceRule { - let end = until.map { EKRecurrenceEnd(end: $0) } + func recurrenceRule(interval: Int, end: EKRecurrenceEnd?) -> EKRecurrenceRule { return EKRecurrenceRule( recurrenceWith: self.frequency, interval: interval, @@ -75,6 +110,149 @@ public enum Recurrence: String, ExpressibleByArgument { } } +enum RecurrenceEndUpdate { + case unchanged + case date(Date) + case clear + + func applying(to existingEnd: EKRecurrenceEnd?) -> EKRecurrenceEnd? { + switch self { + case .unchanged: + return existingEnd + case .date(let date): + return EKRecurrenceEnd(end: date) + case .clear: + return nil + } + } +} + +enum RecurrenceUpdateError: LocalizedError { + case invalidEndDate + case missingExistingRule + case missingDueDate + case endBeforeDueDate + + var errorDescription: String? { + switch self { + case .invalidEndDate: + return "The repeat end date could not be parsed" + case .missingExistingRule: + return "A repeat rule is required; pass --repeat or edit a repeating reminder" + case .missingDueDate: + return "A repeating reminder requires a due date" + case .endBeforeDueDate: + return "The repeat end date cannot be earlier than the reminder's due date" + } + } +} + +struct RecurrenceUpdate { + let recurrence: Recurrence? + let interval: Int? + let end: RecurrenceEndUpdate + + var isRequested: Bool { + if recurrence != nil || interval != nil { + return true + } + if case .unchanged = end { + return false + } + return true + } + + func rule(replacing existingRule: EKRecurrenceRule?) throws -> EKRecurrenceRule { + guard let frequency = recurrence?.frequency ?? existingRule?.frequency else { + throw RecurrenceUpdateError.missingExistingRule + } + + let resolvedInterval = interval ?? existingRule?.interval ?? 1 + let resolvedEnd = end.applying(to: existingRule?.recurrenceEnd) + + // An end-only or interval-only edit must preserve every selector in a + // complex rule (for example, "the last Friday of every month"). The + // same applies when the explicitly supplied frequency is unchanged. + if let existingRule, + recurrence == nil || existingRule.frequency == frequency + { + return EKRecurrenceRule( + recurrenceWith: frequency, + interval: resolvedInterval, + daysOfTheWeek: existingRule.daysOfTheWeek, + daysOfTheMonth: existingRule.daysOfTheMonth, + monthsOfTheYear: existingRule.monthsOfTheYear, + weeksOfTheYear: existingRule.weeksOfTheYear, + daysOfTheYear: existingRule.daysOfTheYear, + setPositions: existingRule.setPositions, + end: resolvedEnd) + } + + return EKRecurrenceRule( + recurrenceWith: frequency, + interval: resolvedInterval, + end: resolvedEnd) + } +} + +func recurrenceEndDate(from components: DateComponents) -> Date? { + var calendar = components.calendar ?? Calendar.current + if let timeZone = components.timeZone { + calendar.timeZone = timeZone + } + + guard let date = calendar.date(from: components) else { + return nil + } + + // A date-only value means the whole local day. Using midnight would make + // a morning or evening occurrence on the requested final day disappear. + guard components.hour == nil && components.minute == nil && components.second == nil else { + return date + } + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: date) else { + return nil + } + return nextDay.addingTimeInterval(-1) +} + +private func recurrenceEnd(dateComponents: DateComponents?) throws -> EKRecurrenceEnd? { + guard let dateComponents else { + return nil + } + guard let date = recurrenceEndDate(from: dateComponents) else { + throw RecurrenceUpdateError.invalidEndDate + } + return EKRecurrenceEnd(end: date) +} + +func validateRecurrenceEnd( + dueDateComponents: DateComponents?, + rules: [EKRecurrenceRule] +) throws { + guard let dueDate = dueDateComponents?.date else { + return + } + if rules.contains(where: { rule in + guard let endDate = rule.recurrenceEnd?.endDate else { + return false + } + return endDate < dueDate + }) { + throw RecurrenceUpdateError.endBeforeDueDate + } +} + +func validateRecurrenceSchedule( + dueDateComponents: DateComponents?, + rules: [EKRecurrenceRule] +) throws { + if !rules.isEmpty && dueDateComponents == nil { + throw RecurrenceUpdateError.missingDueDate + } + try validateRecurrenceEnd(dueDateComponents: dueDateComponents, rules: rules) +} + public enum Priority: String, ExpressibleByArgument { case none case low @@ -265,7 +443,7 @@ public final class Reminders { try store.saveCalendar(newList, commit: true) print("Created new list '\(newList.title)'!") } catch let error { - print("Failed create new list with error: \(error)") + print("Failed create new list with error: \(error.localizedDescription)") exit(1) } } @@ -277,7 +455,9 @@ public final class Reminders { newNotes: String?, newDueDateComponents: DateComponents? = nil, clearDueDate: Bool = false, - newRecurrence: Recurrence?, newRecurrenceInterval: Int, newRecurrenceEnd: DateComponents?, + newRecurrence: Recurrence?, newRecurrenceInterval: Int?, + newRecurrenceEndDate: DateComponents?, + clearRecurrenceEnd: Bool, clearRecurrence: Bool) { let calendar = self.calendar(withName: name) @@ -313,18 +493,52 @@ public final class Reminders { for rule in reminder.recurrenceRules ?? [] { reminder.removeRecurrenceRule(rule) } - } else if let newRecurrence = newRecurrence { - for rule in reminder.recurrenceRules ?? [] { - reminder.removeRecurrenceRule(rule) + } else { + let endUpdate: RecurrenceEndUpdate + if clearRecurrenceEnd { + endUpdate = .clear + } else if let newRecurrenceEndDate { + guard let date = recurrenceEndDate(from: newRecurrenceEndDate) else { + throw RecurrenceUpdateError.invalidEndDate + } + endUpdate = .date(date) + } else { + endUpdate = .unchanged + } + + let update = RecurrenceUpdate( + recurrence: newRecurrence, + interval: newRecurrenceInterval, + end: endUpdate) + if update.isRequested { + let existingRules = reminder.recurrenceRules ?? [] + let replacements: [EKRecurrenceRule] + if newRecurrence == nil { + guard !existingRules.isEmpty else { + throw RecurrenceUpdateError.missingExistingRule + } + replacements = try existingRules.map { + try update.rule(replacing: $0) + } + } else { + replacements = [try update.rule(replacing: existingRules.first)] + } + + for rule in existingRules { + reminder.removeRecurrenceRule(rule) + } + for replacement in replacements { + reminder.addRecurrenceRule(replacement) + } } - let rule = newRecurrence.recurrenceRule( - interval: newRecurrenceInterval, until: newRecurrenceEnd?.date) - reminder.addRecurrenceRule(rule) } + try validateRecurrenceSchedule( + dueDateComponents: reminder.dueDateComponents, + rules: reminder.recurrenceRules ?? []) try Store.save(reminder, commit: true) print("Updated reminder '\(reminder.title!)'") } catch let error { - print("Failed to update reminder with error: \(error)") + print("Failed to update reminder with error: \(error.localizedDescription)") exit(1) } @@ -352,7 +566,7 @@ public final class Reminders { try Store.save(reminder, commit: true) print("\(action) '\(reminder.title!)'") } catch let error { - print("Failed to save reminder with error: \(error)") + print("Failed to save reminder with error: \(error.localizedDescription)") exit(1) } @@ -386,7 +600,7 @@ public final class Reminders { try Store.remove(reminder, commit: true) print("Deleted '\(reminder.title!)'") } catch let error { - print("Failed to delete reminder with error: \(error)") + print("Failed to delete reminder with error: \(error.localizedDescription)") exit(1) } @@ -404,7 +618,7 @@ public final class Reminders { priority: Priority, recurrence: Recurrence?, recurrenceInterval: Int, - recurrenceEnd: DateComponents?, + recurrenceEndDate: DateComponents?, outputFormat: OutputFormat) { let calendar = self.calendar(withName: name) @@ -417,13 +631,20 @@ public final class Reminders { if let dueDate = dueDateComponents?.date, dueDateComponents?.hour != nil { reminder.addAlarm(EKAlarm(absoluteDate: dueDate)) } - if let recurrence = recurrence { - let rule = recurrence.recurrenceRule( - interval: recurrenceInterval, until: recurrenceEnd?.date) - reminder.addRecurrenceRule(rule) - } - do { + if let recurrence = recurrence { + guard dueDateComponents != nil else { + throw RecurrenceUpdateError.missingDueDate + } + let end = try recurrenceEnd(dateComponents: recurrenceEndDate) + reminder.addRecurrenceRule( + recurrence.recurrenceRule(interval: recurrenceInterval, end: end)) + } + + try validateRecurrenceSchedule( + dueDateComponents: reminder.dueDateComponents, + rules: reminder.recurrenceRules ?? []) + try Store.save(reminder, commit: true) switch (outputFormat) { case .json: @@ -432,7 +653,7 @@ public final class Reminders { print("Added '\(reminder.title!)' to '\(calendar.title)'") } } catch let error { - print("Failed to save reminder with error: \(error)") + print("Failed to save reminder with error: \(error.localizedDescription)") exit(1) } } diff --git a/Tests/RemindersTests/RecurrenceTests.swift b/Tests/RemindersTests/RecurrenceTests.swift index bb60926..173c28a 100644 --- a/Tests/RemindersTests/RecurrenceTests.swift +++ b/Tests/RemindersTests/RecurrenceTests.swift @@ -4,35 +4,37 @@ import XCTest final class RecurrenceTests: XCTestCase { func testDailyFrequencyMapping() throws { - let rule = Recurrence.daily.recurrenceRule(interval: 1, until: nil) + let rule = Recurrence.daily.recurrenceRule(interval: 1, end: nil) XCTAssertEqual(rule.frequency, .daily) XCTAssertEqual(rule.interval, 1) XCTAssertNil(rule.recurrenceEnd) } func testWeeklyFrequencyMapping() throws { - let rule = Recurrence.weekly.recurrenceRule(interval: 1, until: nil) + let rule = Recurrence.weekly.recurrenceRule(interval: 1, end: nil) XCTAssertEqual(rule.frequency, .weekly) } func testMonthlyFrequencyMapping() throws { - let rule = Recurrence.monthly.recurrenceRule(interval: 1, until: nil) + let rule = Recurrence.monthly.recurrenceRule(interval: 1, end: nil) XCTAssertEqual(rule.frequency, .monthly) } func testYearlyFrequencyMapping() throws { - let rule = Recurrence.yearly.recurrenceRule(interval: 1, until: nil) + let rule = Recurrence.yearly.recurrenceRule(interval: 1, end: nil) XCTAssertEqual(rule.frequency, .yearly) } func testCustomInterval() throws { - let rule = Recurrence.monthly.recurrenceRule(interval: 2, until: nil) + let rule = Recurrence.monthly.recurrenceRule(interval: 2, end: nil) XCTAssertEqual(rule.interval, 2) } func testRecurrenceEndDate() throws { let end = Date() - let rule = Recurrence.weekly.recurrenceRule(interval: 1, until: end) + let rule = Recurrence.weekly.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(end: end)) XCTAssertNotNil(rule.recurrenceEnd) XCTAssertEqual( rule.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, @@ -40,6 +42,229 @@ final class RecurrenceTests: XCTestCase { accuracy: 1.0) } + func testRecurrenceEndCount() throws { + let rule = Recurrence.daily.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(occurrenceCount: 12)) + XCTAssertEqual(rule.recurrenceEnd?.occurrenceCount, 12) + XCTAssertNil(rule.recurrenceEnd?.endDate) + } + + func testChangingFrequencyPreservesExistingEnd() throws { + let end = Date(timeIntervalSince1970: 1_800_000_000) + let existing = Recurrence.daily.recurrenceRule( + interval: 2, + end: EKRecurrenceEnd(end: end)) + let result = try RecurrenceUpdate( + recurrence: .weekly, + interval: nil, + end: .unchanged + ).rule(replacing: existing) + + XCTAssertEqual(result.frequency, .weekly) + XCTAssertEqual(result.interval, 2) + XCTAssertEqual( + result.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, + end.timeIntervalSince1970, + accuracy: 1.0) + } + + func testEndOnlyUpdatePreservesComplexSelectors() throws { + let existing = EKRecurrenceRule( + recurrenceWith: .monthly, + interval: 2, + daysOfTheWeek: [EKRecurrenceDayOfWeek(.friday, weekNumber: -1)], + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: [-1], + end: nil) + let end = Date(timeIntervalSince1970: 1_800_000_000) + let result = try RecurrenceUpdate( + recurrence: nil, + interval: nil, + end: .date(end) + ).rule(replacing: existing) + + XCTAssertEqual(result.frequency, .monthly) + XCTAssertEqual(result.interval, 2) + XCTAssertEqual(result.daysOfTheWeek?.first?.dayOfTheWeek, .friday) + XCTAssertEqual(result.daysOfTheWeek?.first?.weekNumber, -1) + XCTAssertEqual(result.setPositions?.first?.intValue, -1) + XCTAssertEqual( + result.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, + end.timeIntervalSince1970, + accuracy: 1.0) + } + + func testClearEndPreservesRecurrence() throws { + let existing = Recurrence.monthly.recurrenceRule( + interval: 3, + end: EKRecurrenceEnd(occurrenceCount: 8)) + let result = try RecurrenceUpdate( + recurrence: nil, + interval: nil, + end: .clear + ).rule(replacing: existing) + + XCTAssertEqual(result.frequency, .monthly) + XCTAssertEqual(result.interval, 3) + XCTAssertNil(result.recurrenceEnd) + } + + func testEndOnlyUpdateRequiresExistingRule() throws { + let end = Date(timeIntervalSince1970: 1_800_000_000) + XCTAssertThrowsError( + try RecurrenceUpdate( + recurrence: nil, + interval: nil, + end: .date(end) + ).rule(replacing: nil)) + } + + func testDateOnlyEndIncludesWholeLocalDay() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "Europe/Belgrade")) + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = 2026 + components.month = 9 + components.day = 10 + + let end = try XCTUnwrap(recurrenceEndDate(from: components)) + let resolved = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: end) + XCTAssertEqual(resolved.year, 2026) + XCTAssertEqual(resolved.month, 9) + XCTAssertEqual(resolved.day, 10) + XCTAssertEqual(resolved.hour, 23) + XCTAssertEqual(resolved.minute, 59) + XCTAssertEqual(resolved.second, 59) + } + + func testJSONIncludesDateEnd() throws { + let store = EKEventStore() + let reminder = EKReminder(eventStore: store) + let calendar = EKCalendar(for: .reminder, eventStore: store) + calendar.title = "Test" + reminder.calendar = calendar + reminder.title = "Medicine" + reminder.addRecurrenceRule( + Recurrence.daily.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(end: Date(timeIntervalSince1970: 1_800_000_000)))) + + let data = try JSONEncoder().encode(reminder) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(object["recurrence"] as? String, "daily") + XCTAssertEqual(object["recurrenceInterval"] as? Int, 1) + XCTAssertNotNil(object["recurrenceEnd"] as? String) + XCTAssertNil(object["recurrenceCount"]) + } + + func testJSONIncludesCountEnd() throws { + let store = EKEventStore() + let reminder = EKReminder(eventStore: store) + let calendar = EKCalendar(for: .reminder, eventStore: store) + calendar.title = "Test" + reminder.calendar = calendar + reminder.title = "Medicine" + reminder.addRecurrenceRule( + Recurrence.daily.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(occurrenceCount: 7))) + + let data = try JSONEncoder().encode(reminder) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(object["recurrenceCount"] as? Int, 7) + XCTAssertNil(object["recurrenceEnd"]) + } + + func testAddRejectsExplicitIntervalWithoutRecurrence() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot([ + "add", "Soon", "Medicine", "--due-date", "2026-09-01 09:00", + "--repeat-interval", "1", + ])) + } + + func testAddRequiresDueDateForRecurrence() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot([ + "add", "Soon", "Medicine", "--repeat", "daily", + ])) + } + + func testAddRejectsRepeatEndBeforeDueDate() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot([ + "add", "Soon", "Medicine", "--due-date", "2026-09-10 09:00", + "--repeat", "daily", "--repeat-until", "2026-09-09", + ])) + } + + func testScheduleRequiresDueDateWhenRulesRemain() throws { + let rule = Recurrence.daily.recurrenceRule(interval: 1, end: nil) + + XCTAssertThrowsError( + try validateRecurrenceSchedule(dueDateComponents: nil, rules: [rule]) + ) { error in + XCTAssertEqual( + error.localizedDescription, + RecurrenceUpdateError.missingDueDate.localizedDescription) + } + } + + func testScheduleAllowsClearingDueDateAndRepeatTogether() throws { + XCTAssertNoThrow( + try validateRecurrenceSchedule(dueDateComponents: nil, rules: [])) + } + + func testScheduleRejectsEndBeforeDueDate() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "Europe/Belgrade")) + var due = DateComponents() + due.calendar = calendar + due.timeZone = calendar.timeZone + due.year = 2026 + due.month = 9 + due.day = 10 + due.hour = 9 + let end = try XCTUnwrap(calendar.date(from: due)?.addingTimeInterval(-1)) + let rule = Recurrence.daily.recurrenceRule( + interval: 1, + end: EKRecurrenceEnd(end: end)) + + XCTAssertThrowsError( + try validateRecurrenceSchedule(dueDateComponents: due, rules: [rule]) + ) { error in + XCTAssertEqual( + error.localizedDescription, + RecurrenceUpdateError.endBeforeDueDate.localizedDescription) + } + } + + func testEditAcceptsEndOnlyUpdate() throws { + XCTAssertNoThrow( + try CLI.parseAsRoot([ + "edit", "Soon", "0", "--repeat-until", "2026-09-10", + ])) + } + + func testEditRejectsClearRepeatWithEndUpdate() throws { + XCTAssertThrowsError( + try CLI.parseAsRoot([ + "edit", "Soon", "0", "--clear-repeat", "--repeat-until", "2026-09-10", + ])) + } + + func testLocalizedRecurrenceErrorIsHumanReadable() throws { + XCTAssertEqual( + RecurrenceUpdateError.missingExistingRule.localizedDescription, + "A repeat rule is required; pass --repeat or edit a repeating reminder") + } + func testHourlyIsNotRepresentable() throws { // EventKit has no hourly EKRecurrenceFrequency; this is asserted at the // model layer so CLI validation (which rejects it before ever building From 6478b6b773c8bf326894feb264638ff9e7ef6e5b Mon Sep 17 00:00:00 2001 From: Aleksei Morozov <246998741+morozovpersonal@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:08:05 +0200 Subject: [PATCH 3/3] Preserve recurrence metadata on end-only edits --- README.md | 3 +- Sources/RemindersLibrary/Reminders.swift | 43 +++++++++++++++++----- Tests/RemindersTests/RecurrenceTests.swift | 18 ++++++++- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1283f5b..bff8a23 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ $ reminders edit Soon 0 --clear-repeat Changing only the interval or end condition preserves the existing frequency and any complex selectors, such as "the last Friday of every month". Changing the frequency preserves the existing -end condition unless a new `--repeat-until` or `--clear-repeat-end` is supplied. +end condition but resets its interval to 1 unless `--repeat-interval` is supplied. An end-only edit +copies the complete EventKit rule so provider-specific calendar metadata is preserved as well. JSON output includes `recurrence`, `recurrenceInterval`, and either `recurrenceEnd` or `recurrenceCount` (when an existing rule is count-based) for repeating reminders. diff --git a/Sources/RemindersLibrary/Reminders.swift b/Sources/RemindersLibrary/Reminders.swift index d63b0c6..600cd17 100644 --- a/Sources/RemindersLibrary/Reminders.swift +++ b/Sources/RemindersLibrary/Reminders.swift @@ -132,6 +132,7 @@ enum RecurrenceUpdateError: LocalizedError { case missingExistingRule case missingDueDate case endBeforeDueDate + case failedToCopyExistingRule var errorDescription: String? { switch self { @@ -143,6 +144,8 @@ enum RecurrenceUpdateError: LocalizedError { return "A repeating reminder requires a due date" case .endBeforeDueDate: return "The repeat end date cannot be earlier than the reminder's due date" + case .failedToCopyExistingRule: + return "The existing repeat rule could not be copied safely" } } } @@ -167,12 +170,26 @@ struct RecurrenceUpdate { throw RecurrenceUpdateError.missingExistingRule } - let resolvedInterval = interval ?? existingRule?.interval ?? 1 + let shouldPreserveInterval = recurrence == nil || existingRule?.frequency == frequency + let inheritedInterval = shouldPreserveInterval ? existingRule?.interval : nil + let resolvedInterval = interval ?? inheritedInterval ?? 1 let resolvedEnd = end.applying(to: existingRule?.recurrenceEnd) - // An end-only or interval-only edit must preserve every selector in a - // complex rule (for example, "the last Friday of every month"). The - // same applies when the explicitly supplied frequency is unchanged. + // Changing only the end condition can preserve more than the public + // initializer exposes, including provider-specific calendar metadata + // and firstDayOfTheWeek. Copy the complete EventKit rule and modify its + // sole writable recurrence property rather than reconstructing it. + if let existingRule, recurrence == nil, interval == nil { + guard let copiedRule = existingRule.copy() as? EKRecurrenceRule else { + throw RecurrenceUpdateError.failedToCopyExistingRule + } + copiedRule.recurrenceEnd = resolvedEnd + return copiedRule + } + + // An interval edit must preserve every public selector in a complex + // rule (for example, "the last Friday of every month"). The same + // applies when the explicitly supplied frequency is unchanged. if let existingRule, recurrence == nil || existingRule.frequency == frequency { @@ -443,7 +460,7 @@ public final class Reminders { try store.saveCalendar(newList, commit: true) print("Created new list '\(newList.title)'!") } catch let error { - print("Failed create new list with error: \(error.localizedDescription)") + print("Failed create new list with error: \(error)") exit(1) } } @@ -462,6 +479,10 @@ public final class Reminders { { let calendar = self.calendar(withName: name) let semaphore = DispatchSemaphore(value: 0) + let dueDateChangeRequested = clearDueDate || newDueDateComponents != nil + let recurrenceChangeRequested = clearRecurrence || newRecurrence != nil + || newRecurrenceInterval != nil || newRecurrenceEndDate != nil + || clearRecurrenceEnd self.reminders(on: [calendar], displayOptions: .incomplete) { reminders in guard let reminder = self.getReminder(from: reminders, at: index) else { @@ -532,9 +553,11 @@ public final class Reminders { } } } - try validateRecurrenceSchedule( - dueDateComponents: reminder.dueDateComponents, - rules: reminder.recurrenceRules ?? []) + if dueDateChangeRequested || recurrenceChangeRequested { + try validateRecurrenceSchedule( + dueDateComponents: reminder.dueDateComponents, + rules: reminder.recurrenceRules ?? []) + } try Store.save(reminder, commit: true) print("Updated reminder '\(reminder.title!)'") } catch let error { @@ -566,7 +589,7 @@ public final class Reminders { try Store.save(reminder, commit: true) print("\(action) '\(reminder.title!)'") } catch let error { - print("Failed to save reminder with error: \(error.localizedDescription)") + print("Failed to save reminder with error: \(error)") exit(1) } @@ -600,7 +623,7 @@ public final class Reminders { try Store.remove(reminder, commit: true) print("Deleted '\(reminder.title!)'") } catch let error { - print("Failed to delete reminder with error: \(error.localizedDescription)") + print("Failed to delete reminder with error: \(error)") exit(1) } diff --git a/Tests/RemindersTests/RecurrenceTests.swift b/Tests/RemindersTests/RecurrenceTests.swift index 173c28a..7499261 100644 --- a/Tests/RemindersTests/RecurrenceTests.swift +++ b/Tests/RemindersTests/RecurrenceTests.swift @@ -62,13 +62,25 @@ final class RecurrenceTests: XCTestCase { ).rule(replacing: existing) XCTAssertEqual(result.frequency, .weekly) - XCTAssertEqual(result.interval, 2) + XCTAssertEqual(result.interval, 1) XCTAssertEqual( result.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, end.timeIntervalSince1970, accuracy: 1.0) } + func testChangingFrequencyUsesExplicitInterval() throws { + let existing = Recurrence.daily.recurrenceRule(interval: 2, end: nil) + let result = try RecurrenceUpdate( + recurrence: .monthly, + interval: 3, + end: .unchanged + ).rule(replacing: existing) + + XCTAssertEqual(result.frequency, .monthly) + XCTAssertEqual(result.interval, 3) + } + func testEndOnlyUpdatePreservesComplexSelectors() throws { let existing = EKRecurrenceRule( recurrenceWith: .monthly, @@ -92,6 +104,10 @@ final class RecurrenceTests: XCTestCase { XCTAssertEqual(result.daysOfTheWeek?.first?.dayOfTheWeek, .friday) XCTAssertEqual(result.daysOfTheWeek?.first?.weekNumber, -1) XCTAssertEqual(result.setPositions?.first?.intValue, -1) + XCTAssertFalse(result === existing) + XCTAssertEqual(result.calendarIdentifier, existing.calendarIdentifier) + XCTAssertEqual(result.firstDayOfTheWeek, existing.firstDayOfTheWeek) + XCTAssertNil(existing.recurrenceEnd) XCTAssertEqual( result.recurrenceEnd?.endDate?.timeIntervalSince1970 ?? 0, end.timeIntervalSince1970,