diff --git a/Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift b/Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift index dd8740236..95cf30182 100644 --- a/Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift +++ b/Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift @@ -71,13 +71,51 @@ extension Calendar { let start: Date /// The recurrenece rule let recurrence: RecurrenceRule - /// Range in which the search should occur. If `nil`, return all results - let range: Range? + /// The lower end of the search range. If `nil`, the search is unbounded + /// in the past. + let lowerBound: Date? + /// The upper end of the search range. If `nil`, the search is unbounded + /// in the future. If `inclusive` is true, `bound` is a valid result + let upperBound: (bound: Date, inclusive: Bool)? init(start: Date, recurrence: RecurrenceRule, range: Range?) { self.start = start self.recurrence = recurrence - self.range = range + if let range { + self.lowerBound = range.lowerBound + self.upperBound = (range.upperBound, false) + } else { + self.lowerBound = nil + self.upperBound = nil + } + } + + init(start: Date, recurrence: RecurrenceRule, range: ClosedRange) { + self.start = start + self.recurrence = recurrence + self.lowerBound = range.lowerBound + self.upperBound = (range.upperBound, true) + } + + init(start: Date, recurrence: RecurrenceRule, range: PartialRangeFrom) { + self.start = start + self.recurrence = recurrence + self.lowerBound = range.lowerBound + self.upperBound = nil + } + + init(start: Date, recurrence: RecurrenceRule, range: PartialRangeThrough) { + self.start = start + self.recurrence = recurrence + self.lowerBound = nil + self.upperBound = (range.upperBound, true) + } + + init(start: Date, recurrence: RecurrenceRule, range: PartialRangeUpTo) { + self.start = start + self.recurrence = recurrence + self.lowerBound = nil + self.upperBound = (range.upperBound, false) } struct Iterator: Sendable, IteratorProtocol { @@ -85,13 +123,11 @@ extension Calendar { let start: Date /// The recurrence rule that should be used for enumeration let recurrence: RecurrenceRule - /// The range in which the sequence should produce results - let range: Range? - - /// The lower bound of `range`, adjusted so that date expansions may - /// still fit in range even if this value is outside the range. This - /// value is used as a lower bound for ``nextBaseRecurrenceDate()``. - let rangeLowerBound: Date? + + /// The lower bound for iteration results, inclusive + let lowerBound: Date? + /// The upper bound for iteration results and whether it's inclusive + let upperBound: (bound: Date, inclusive: Bool)? /// The start date's nanoseconds component let startDateNanoseconds: TimeInterval @@ -105,6 +141,10 @@ extension Calendar { /// date, by the interval specified by the recurrence rule frequency /// This does not include the start date itself. var baseRecurrence: Calendar.DatesByMatching.Iterator + /// The lower bound for `baseRecurrence`. Note that this date can be + /// lower than `lowerBound` + let baseRecurrenceLowerBound: Date? + /// How many elements we have consumed from `baseRecurrence` var iterations: Int = 0 @@ -123,7 +163,8 @@ extension Calendar { internal init(start: Date, matching recurrence: RecurrenceRule, - range: Range?) { + lowerBound: Date?, + upperBound: (bound: Date, inclusive: Bool)?) { // Copy the calendar if it's autoupdating var recurrence = recurrence if recurrence.calendar == .autoupdatingCurrent { @@ -132,7 +173,6 @@ extension Calendar { self.recurrence = recurrence self.start = start - self.range = range let frequency = recurrence.frequency @@ -215,10 +255,12 @@ extension Calendar { secondAction = .expand } - if let range { - rangeLowerBound = recurrence.calendar.dateInterval(of: frequency.component, for: range.lowerBound)?.start + self.lowerBound = lowerBound + self.upperBound = upperBound + if let lowerBound { + baseRecurrenceLowerBound = recurrence.calendar.dateInterval(of: frequency.component, for: lowerBound)?.start } else { - rangeLowerBound = nil + baseRecurrenceLowerBound = nil } // Create date components that enumerate recurrences without any @@ -330,7 +372,7 @@ extension Calendar { } // If a range has been specified, we should skip a few extra // occurrences until we reach the start date - if let rangeLowerBound, nextDate < rangeLowerBound { + if let baseRecurrenceLowerBound, nextDate < baseRecurrenceLowerBound { continue } anchor = nextDate @@ -476,11 +518,18 @@ extension Calendar { finished = true return nil } - if let range = self.range { - if date >= range.upperBound { + if let upperBound = self.upperBound { + let outOfRange = switch upperBound.inclusive { + case true: date > upperBound.bound + case false: date >= upperBound.bound + } + if outOfRange { finished = true return nil - } else if date < range.lowerBound { + } + } + if let lowerBound = self.lowerBound { + if date < lowerBound { continue } } @@ -503,7 +552,7 @@ extension Calendar { } public func makeIterator() -> Iterator { - return Iterator(start: start, matching: recurrence, range: range) + return Iterator(start: start, matching: recurrence, lowerBound: lowerBound, upperBound: upperBound) } } } diff --git a/Sources/FoundationEssentials/Calendar/RecurrenceRule.swift b/Sources/FoundationEssentials/Calendar/RecurrenceRule.swift index d6050d155..5f0e2c886 100644 --- a/Sources/FoundationEssentials/Calendar/RecurrenceRule.swift +++ b/Sources/FoundationEssentials/Calendar/RecurrenceRule.swift @@ -270,12 +270,79 @@ extension Calendar { /// - Returns: a sequence of dates conforming to the recurrence rule, in /// the given `range`. An empty sequence if the rule doesn't match any /// dates. - /// A recurrence that repeats every `interval` minutes public func recurrences(of start: Date, in range: Range? = nil ) -> some (Sequence & Sendable) { DatesByRecurring(start: start, recurrence: self, range: range) } + + /// Find recurrences of the given date + /// + /// The calculations are implemented according to RFC-5545 and RFC-7529. + /// + /// - Parameter start: the date which defines the starting point for the + /// recurrence rule. + /// - Parameter range: a range of dates which to search for recurrences. + /// - Returns: a sequence of dates conforming to the recurrence rule, in + /// the given `range`. An empty sequence if the rule doesn't match any + /// dates. + @available(FoundationPreview 6.3, *) + public func recurrences(of start: Date, + in range: PartialRangeThrough + ) -> some (Sequence & Sendable) { + DatesByRecurring(start: start, recurrence: self, range: range) + } + + /// Find recurrences of the given date + /// + /// The calculations are implemented according to RFC-5545 and RFC-7529. + /// + /// - Parameter start: the date which defines the starting point for the + /// recurrence rule. + /// - Parameter range: a range of dates which to search for recurrences. + /// - Returns: a sequence of dates conforming to the recurrence rule, in + /// the given `range`. An empty sequence if the rule doesn't match any + /// dates. + @available(FoundationPreview 6.3, *) + public func recurrences(of start: Date, + in range: PartialRangeUpTo + ) -> some (Sequence & Sendable) { + DatesByRecurring(start: start, recurrence: self, range: range) + } + + /// Find recurrences of the given date + /// + /// The calculations are implemented according to RFC-5545 and RFC-7529. + /// + /// - Parameter start: the date which defines the starting point for the + /// recurrence rule. + /// - Parameter range: a range of dates which to search for recurrences. + /// - Returns: a sequence of dates conforming to the recurrence rule, in + /// the given `range`. An empty sequence if the rule doesn't match any + /// dates. + @available(FoundationPreview 6.3, *) + public func recurrences(of start: Date, + in range: PartialRangeFrom + ) -> some (Sequence & Sendable) { + DatesByRecurring(start: start, recurrence: self, range: range) + } + + /// Find recurrences of the given date + /// + /// The calculations are implemented according to RFC-5545 and RFC-7529. + /// + /// - Parameter start: the date which defines the starting point for the + /// recurrence rule. + /// - Parameter range: a range of dates which to search for recurrences. + /// - Returns: a sequence of dates conforming to the recurrence rule, in + /// the given `range`. An empty sequence if the rule doesn't match any + /// dates. + @available(FoundationPreview 6.3, *) + public func recurrences(of start: Date, + in range: ClosedRange + ) -> some (Sequence & Sendable) { + DatesByRecurring(start: start, recurrence: self, range: range) + } /// A recurrence that repeats every `interval` minutes public static func minutely(calendar: Calendar, interval: Int = 1, end: End = .never, matchingPolicy: Calendar.MatchingPolicy = .nextTimePreservingSmallerComponents, repeatedTimePolicy: Calendar.RepeatedTimePolicy = .first, months: [Month] = [], daysOfTheYear: [Int] = [], daysOfTheMonth: [Int] = [], weekdays: [Weekday] = [], hours: [Int] = [], minutes: [Int] = [], seconds: [Int] = [], setPositions: [Int] = []) -> Self { diff --git a/Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift b/Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift index cd38e1d37..786c0c8ff 100644 --- a/Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift +++ b/Tests/FoundationEssentialsTests/GregorianCalendarRecurrenceRuleTests.swift @@ -828,4 +828,106 @@ private struct GregorianCalendarRecurrenceRuleTests { #expect(results == expectedResults, "Failed for nanoseconds \(nsec)") } } + + @available(FoundationPreview 6.3, *) + @Test func closedRange() { + let rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .daily, end: .never) + + let eventStart = Date(timeIntervalSince1970: 1285077600.0) // 2010-09-21T14:00:00-0000 + let sept28 = Date(timeIntervalSince1970: 1285682400.0) // 2010-09-28T14:00:00-0000 + let oct3 = Date(timeIntervalSince1970: 1286114400.0) // 2010-10-03T14:00:00-0000 + + let results = Array(rule.recurrences(of: eventStart, in: sept28...oct3)) + + let expectedResults = [ + Date(timeIntervalSince1970: 1285682400.0), // 2010-09-28T14:00:00-0000 + Date(timeIntervalSince1970: 1285768800.0), // 2010-09-29T14:00:00-0000 + Date(timeIntervalSince1970: 1285855200.0), // 2010-09-30T14:00:00-0000 + Date(timeIntervalSince1970: 1285941600.0), // 2010-10-01T14:00:00-0000 + Date(timeIntervalSince1970: 1286028000.0), // 2010-10-02T14:00:00-0000 + Date(timeIntervalSince1970: 1286114400.0), // 2010-10-03T14:00:00-0000 + ] + + #expect(results == expectedResults) + } + + @available(FoundationPreview 6.3, *) + @Test func partialRangeUpTo() { + let rule = Calendar.RecurrenceRule(calendar: gregorian, frequency: .daily, end: .never) + + let eventStart = Date(timeIntervalSince1970: 1285077600.0) // 2010-09-21T14:00:00-0000 + let oct3 = Date(timeIntervalSince1970: 1286114400.0) // 2010-10-03T14:00:00-0000 + + let results = Array(rule.recurrences(of: eventStart, in: ..