Skip to content

Instantly share code, notes, and snippets.

@blwinters
Last active July 13, 2021 22:14
Show Gist options
  • Save blwinters/51e3faeac754f86f859a342d0dcb7f3d to your computer and use it in GitHub Desktop.
Save blwinters/51e3faeac754f86f859a342d0dcb7f3d to your computer and use it in GitHub Desktop.
This code generates a series of dates based on complex recurrence rules as described in RFC 5445, section 3.8.5 (https://tools.ietf.org/html/rfc5545#section-3.8.5). This is used in Summit to allow users to create repeating tasks on the second Tuesday of January and July, for example.
//
// SMRecurrenceRule.swift
// Summit
//
// Created by Ben Winters on 8/27/17.
// Copyright © 2017 Goals LLC. All rights reserved.
//
import EventKit
import Foundation
import RealmSwift
//The parameters `rangePeriodComps` refer to an array of RecurrencePeriodComps, which cover all of the recurrence periods within a date range
//rangePeriodComps == [RecurrencePeriodComponents] == [[DateComponents]]
typealias RecurrencePeriodComponents = [DateComponents]
typealias DatePair = (start: Date, end: Date)
typealias Occurrence = (start: Date, end: Date?) //handles events and tasks
///Arrays used in the initialization of custom SMRecurrenceRules
struct SMRecurrenceRuleUnitArrays {
var daysOfTheWeek: [SMRecurrenceDayOfWeek]?
var daysOfTheMonth: [Int]?
var monthsOfTheYear: [Int]?
var weeksOfTheYear: [Int]?
var daysOfTheYear: [Int]?
var setPositions: [Int]?
}
/**
Mirrors EKRecurrenceRule: https://developer.apple.com/documentation/eventkit/ekrecurrencerule
SMRecurrenceRule is used for creating simulated and stored instances of SMItem for both tasks and events.
*/
class SMRecurrenceRule: Object {
@objc dynamic var id: String = UUID().uuidString
@objc dynamic var creationDate: Date = Date.now()
/**
Defaults to true. Set this to false when editing/deleting all future occurrences of an SMListItem.
It should not be set to true again after being deactivated.
*/
//TODO: set this to false when a rule's occurrence count has been met and no more instances should be created.
@objc dynamic var isActive: Bool = true
private let listItems = LinkingObjects(fromType: SMListItem.self, property: "recurrenceRule")
var listItem: SMListItem? {
return listItems.first
}
/**
Indicates when the recurrence rule ends. This can be represented by an end date or a number of occurrences.
*/
@objc dynamic var recurrenceEnd: SMRecurrenceEnd?
/**
The frequency of the recurrence rule.
*/
@objc dynamic var frequency: SMRecurrenceFrequency = .daily
/**
Specifies how often the recurrence rule repeats over the unit of time indicated by its frequency.
For example, a recurrence rule with a frequency type of EKRecurrenceFrequencyWeekly and an interval of 2 repeats every two weeks.
Default value is 1.
*/
@objc dynamic var interval: Int = 1
/**
Indicates which day of the week the recurrence rule treats as the first day of the week.
Values of 1 to 7 correspond to Sunday through Saturday. A value of 0 indicates that this
property is not set for the recurrence rule.
From the EKRecurrenceRule headers:
Recurrence patterns can specify which day of the week should be treated as the first day. Possible values for this
property are integers 0 and 1-7, which correspond to days of the week with Sunday = 1. Zero indicates that the
property is not set for this recurrence. The first day of the week only affects the way the recurrence is expanded
for weekly recurrence patterns with an interval greater than 1. For those types of recurrence patterns, the
Calendar framework will set firstDayOfTheWeek to be 2 (Monday). In all other cases, this property will be set
to zero. The iCalendar spec stipulates that the default value is Monday if this property is not set.
*/
@objc dynamic var firstDayOfTheWeek: Int = 0
/**
The days of the week associated with the recurrence rule, as a list of SMRecurrenceDayOfWeek objects.
This property value is valid only for recurrence rules that were initialized with specific days of the week
and a frequency type of .weekly, .monthly, or .yearly.
*/
let daysOfTheWeek = List<SMRecurrenceDayOfWeek>()
/**
The days of the month associated with the recurrence rule, as a string representing an array of Int.
Values can be from 1 to 31 and from -1 to -31. This property value is valid only for recurrence rules
that were initialized with specific days of the month and a frequency type of .monthly.
*/
let daysOfTheMonth = List<Int>()
/**
The days of the year associated with the recurrence rule, as a string representing an array of Int.
Values can be from 1 to 366 and from -1 to -366. This property value is valid only for recurrence rules
initialized with a frequency type of .yearly.
*/
let daysOfTheYear = List<Int>()
/**
The weeks of the year associated with the recurrence rule, as a string representing an array of Int.
Values can be from 1 to 53 and from -1 to -53. This property value is valid only for recurrence rules
initialized with specific weeks of the year and a frequency type of .yearly.
*/
let weeksOfTheYear = List<Int>()
/**
The months of the year associated with the recurrence rule, as a string representing an array of Int.
Values can be from 1 to 12. This property value is valid only for recurrence rules
initialized with specific months of the year and a frequency type of .yearly.
*/
let monthsOfTheYear = List<Int>()
/**
An array of ordinal numbers that filters which recurrences to include in the recurrence rule’s frequency.
For example, a yearly recurrence rule that has a daysOfTheWeek value that specifies Monday through Friday,
and a setPositions array containing 2 and -1, occurs only on the second weekday and last weekday of every year.
Discussion: Values can be from 1 to 366 and from -1 to -366. Negative values indicate counting backwards from the
end of the recurrence rule’s frequency (week, month, or year).
From the EKRecurrenceRule headers:
This property is valid for rules which have a valid daysOfTheWeek, daysOfTheMonth, weeksOfTheYear, or monthsOfTheYear property.
It allows you to specify a set of ordinal numbers to help choose which objects out of the set of selected events should be
included. For example, setting the daysOfTheWeek to Monday-Friday and including a value of -1 in the array would indicate
the last weekday in the recurrence range (month, year, etc). This value corresponds to the iCalendar BYSETPOS property.
*/
let setPositions = List<Int>()
override static func primaryKey() -> String? {
return "id"
}
///For debugging only, see SMPlannerItem for user-facing description
override var description: String {
var string = "SMRecurrenceRule:"
let frequencyUnit = frequency.unitDescription(forCount: interval)
string.append("\n Every \(interval) \(frequencyUnit)")
if !daysOfTheWeek.isEmpty {
let desc = SMRecurrenceDayOfWeek.descriptionOfDays(Array(daysOfTheWeek))
string.append("\n on \(desc)")
}
//TODO: add in description of more custom rules
return string
}
//Initializers
convenience init(frequency: SMRecurrenceFrequency, interval: Int, end: SMRecurrenceEnd?) {
self.init()
self.frequency = frequency
self.interval = interval
self.recurrenceEnd = end
}
convenience init(frequency: SMRecurrenceFrequency, interval: Int, end: SMRecurrenceEnd?, unitArrays: SMRecurrenceRuleUnitArrays, firstDayOfTheWeek: Int = 0) {
self.init(frequency: frequency, interval: interval, end: end)
if let days = unitArrays.daysOfTheWeek {
self.daysOfTheWeek.append(objectsIn: days)
}
if let integers = unitArrays.daysOfTheMonth {
self.setDaysOfTheMonth(integers)
}
if let integers = unitArrays.monthsOfTheYear {
self.setMonthsOfTheYear(integers)
}
if let integers = unitArrays.weeksOfTheYear {
self.setWeeksOfTheYear(integers)
}
if let integers = unitArrays.daysOfTheYear {
self.setDaysOfTheYear(integers)
}
if let integers = unitArrays.setPositions {
self.setSetPositions(integers)
}
self.firstDayOfTheWeek = firstDayOfTheWeek
}
/**
Returns instances of `SMRecurrenceRule`, `SMRecurrenceDayOfWeek?`, `SMRecurrenceEnd?` so that they can be added to the Realm.
This should be called within a realm.write block.
*/
static func create(from ekRecurrenceRule: EKRecurrenceRule, in realm: Realm) -> SMRecurrenceRule {
//Create SMRecurrenceFrequency
let smRecurrenceFrequency = SMRecurrenceFrequency(with: ekRecurrenceRule.frequency)!
//Create SMRecurrenceEnd
let smRecurrenceEnd: SMRecurrenceEnd? = SMRecurrenceEnd(with: ekRecurrenceRule.recurrenceEnd)
if let smEnd = smRecurrenceEnd {
realm.add(smEnd)
}
//Create the unit arrays for custom rules, save new instances of SMRecurrenceDayOfWeek
let unitArrays = ekRecurrenceRule.unitArrays(in: realm)
//Create SMRecurrenceRule
let smRecurrenceRule = SMRecurrenceRule(frequency: smRecurrenceFrequency,
interval: ekRecurrenceRule.interval,
end: smRecurrenceEnd,
unitArrays: unitArrays,
firstDayOfTheWeek: ekRecurrenceRule.firstDayOfTheWeek)
realm.add(smRecurrenceRule)
return smRecurrenceRule
}
func cascadeDelete(from realm: Realm) {
daysOfTheWeek.forEach({$0.cascadeDelete(from: realm)})
recurrenceEnd?.cascadeDelete(from: realm)
realm.delete(self)
}
}
extension SMRecurrenceRule: SemanticallyEquatable {
/**
This does not consider the associated listItem or whether the rule isActive. It only checks the
properties involved in generating a series of dates for semantic equality.
*/
func matches(_ object: SMRecurrenceRule) -> Bool {
//Equatable properties,
//Because List is a reference type, we need to ignore the container object and use array()
//to compare the primitives inside the List for equality
guard self.frequency == object.frequency,
self.interval == object.interval,
self.firstDayOfTheWeek == object.firstDayOfTheWeek,
self.daysOfTheMonth.array() == object.daysOfTheMonth.array(),
self.daysOfTheYear.array() == object.daysOfTheYear.array(),
self.weeksOfTheYear.array() == object.weeksOfTheYear.array(),
self.monthsOfTheYear.array() == object.monthsOfTheYear.array(),
self.setPositions.array() == object.setPositions.array()
else { return false }
//SemanticallyEquatable properties
//recurrenceEnd
if let selfEnd = self.recurrenceEnd {
guard let objectEnd = object.recurrenceEnd,
selfEnd.matches(objectEnd)
else { return false }
} else {
guard object.recurrenceEnd == nil else { return false }
}
//daysOfTheWeek
guard self.daysOfTheWeek.count == object.daysOfTheWeek.count else { return false }
outerLoop: for dayOfTheWeek in self.daysOfTheWeek {
innerLoop: for objectDay in object.daysOfTheWeek where dayOfTheWeek.matches(objectDay) {
continue outerLoop //found a match for dayOfTheWeek
}
return false //each innerLoop needs to find a match or it will hit this and return false
}
return true
}
}
extension SMRecurrenceRule {
/**
Performs a similar function to the SemanticallyEquatable protocol, but between SMRecurrenceRule and EKRecurrenceRule.
*/
func matchesEKRecurrenceRule(_ ekRule: EKRecurrenceRule) -> Bool {
guard self.frequency.rawValue == ekRule.frequency.rawValue,
self.interval == ekRule.interval,
self.firstDayOfTheWeek == ekRule.firstDayOfTheWeek,
self.daysOfTheMonth.array() == ekRule.daysOfTheMonth?.map({$0.intValue}) ?? [],
self.daysOfTheYear.array() == ekRule.daysOfTheYear?.map({$0.intValue}) ?? [],
self.weeksOfTheYear.array() == ekRule.weeksOfTheYear?.map({$0.intValue}) ?? [],
self.monthsOfTheYear.array() == ekRule.monthsOfTheYear?.map({$0.intValue}) ?? [],
self.setPositions.array() == ekRule.setPositions?.map({$0.intValue}) ?? []
else { print("Unit arrays do not match"); return false }
//recurrenceEnd
if let selfEnd = self.recurrenceEnd {
guard let ekEnd = ekRule.recurrenceEnd,
selfEnd.matchesEKRecurrenceEnd(ekEnd)
else { return false }
} else {
guard ekRule.recurrenceEnd == nil else { print("Recurrence end does not match"); return false }
}
//daysOfTheWeek
let ekDaysOfTheWeek = ekRule.daysOfTheWeek ?? []
guard self.daysOfTheWeek.count == ekDaysOfTheWeek.count else { return false }
outerLoop: for dayOfTheWeek in self.daysOfTheWeek {
innerLoop: for ekDay in ekDaysOfTheWeek where dayOfTheWeek.matchesEKRecurrenceDayOfWeek(ekDay) {
continue outerLoop // found a match for dayOfTheWeek
}
return false //each innerLoop needs to find a match or it will hit this and return false
}
return true
}
}
extension SMRecurrenceRule {
/**
Creates an unmanaged copy of the rule.
*/
func duplicate() -> SMRecurrenceRule {
let newRuleEnd = SMRecurrenceEnd(with: recurrenceEnd)
let newDaysOfWeek: [SMRecurrenceDayOfWeek] = daysOfTheWeek.map({SMRecurrenceDayOfWeek(with: $0)})
let newUnitArrays = SMRecurrenceRuleUnitArrays(daysOfTheWeek: newDaysOfWeek,
daysOfTheMonth: getDaysOfTheMonth(),
monthsOfTheYear: getMonthsOfTheYear(),
weeksOfTheYear: getWeeksOfTheYear(),
daysOfTheYear: getDaysOfTheYear(),
setPositions: getSetPositions())
return SMRecurrenceRule(frequency: frequency, interval: interval, end: newRuleEnd, unitArrays: newUnitArrays, firstDayOfTheWeek: firstDayOfTheWeek)
}
}
extension SMRecurrenceRule { //Getter and setter functions for unit arrays
var hasUnitArrays: Bool {
if !getDaysOfTheWeek().isEmpty { return true }
if getDaysOfTheMonth() != nil { return true }
if getDaysOfTheYear() != nil { return true }
if getWeeksOfTheYear() != nil { return true }
if getMonthsOfTheYear() != nil { return true }
if getSetPositions() != nil { return true }
return false
}
/**
Returns true if daysOfTheWeek is non-nil and a member has a non-zero weekNumber.
This doesn't check the validity of the weekNumber based on the frequency.
*/
var daysOfTheWeekHaveWeekNumbers: Bool {
guard !daysOfTheWeek.isEmpty else { return false }
let daysWithWeekNumbers = daysOfTheWeek.flatMap({ ($0.weekNumber != 0) ? $0 : nil })
return !daysWithWeekNumbers.isEmpty
}
func getDaysOfTheWeek() -> [SMRecurrenceDayOfWeek] {
return daysOfTheWeek.sorted(by: {$0.dayOfTheWeek.rawValue < $1.dayOfTheWeek.rawValue})
}
func getDaysOfTheMonth() -> [Int]? {
return daysOfTheMonth.isEmpty ? nil : daysOfTheMonth.array()
}
func getDaysOfTheYear() -> [Int]? {
return daysOfTheYear.isEmpty ? nil : daysOfTheYear.array()
}
func getWeeksOfTheYear() -> [Int]? {
return weeksOfTheYear.isEmpty ? nil : weeksOfTheYear.array()
}
func getMonthsOfTheYear() -> [Int]? {
return monthsOfTheYear.isEmpty ? nil : monthsOfTheYear.array()
}
func getSetPositions() -> [Int]? {
return setPositions.isEmpty ? nil : setPositions.array()
}
func setDaysOfTheWeek(_ daysOfTheWeek: [SMRecurrenceDayOfWeek]?) {
if let realm = self.realm {
for existingDayOfWeek in self.daysOfTheWeek { //delete existing days whether or not parameter is nil
realm.delete(existingDayOfWeek)
}
if let days = daysOfTheWeek {
for day in days {
realm.add(day)
self.daysOfTheWeek.append(day)
}
}
} else { //this recurrence rule is not yet managed by a realm
self.daysOfTheWeek.removeAll()
if let days = daysOfTheWeek {
self.daysOfTheWeek.append(objectsIn: days)
}
}
}
func setDaysOfTheMonth(_ days: [Int]?) {
daysOfTheMonth.removeAll()
if let dayArray = days {
daysOfTheMonth.append(objectsIn: dayArray)
}
}
func setDaysOfTheYear(_ days: [Int]?) {
daysOfTheYear.removeAll()
if let dayArray = days {
daysOfTheYear.append(objectsIn: dayArray)
}
}
func setWeeksOfTheYear(_ weeks: [Int]?) {
weeksOfTheYear.removeAll()
if let weekArray = weeks {
weeksOfTheYear.append(objectsIn: weekArray)
}
}
func setMonthsOfTheYear(_ months: [Int]?) {
monthsOfTheYear.removeAll()
if let monthArray = months {
monthsOfTheYear.append(objectsIn: monthArray)
}
}
func setSetPositions(_ positions: [Int]?) {
setPositions.removeAll()
if let positionArray = positions {
setPositions.append(objectsIn: positionArray)
}
}
}
// MARK: - Date Generation
extension SMRecurrenceRule {
/**
Important to remove `.nanosecond`, `.quarter`, `.weekdayOrdinal`, and `.weekOfMonth`, as they
can conflict with other components when generating dates. `.second` is used by all-day events.
*/
var generationComponents: Set<Calendar.Component> {
return [.second, .minute, .hour, .day, .weekday,
.month, .year,
]
}
var generationComponentsWithWeekOfYear: Set<Calendar.Component> {
return generationComponents.union([.weekOfYear, .yearForWeekOfYear])
}
var weekOfYearIsRelevant: Bool {
let daysOfTheWeekApplyToCertainWeeks = (frequency == .yearly && daysOfTheWeekHaveWeekNumbers)
return (!weeksOfTheYear.isEmpty || !daysOfTheYear.isEmpty) || frequency == .weekly || daysOfTheWeekApplyToCertainWeeks
}
var relevantComponents: Set<Calendar.Component> {
return weekOfYearIsRelevant ? generationComponentsWithWeekOfYear : generationComponents
}
/**
Returns an array of date pairs, each representing the start and end time of an occurrence of this EKRecurrenceRule.
Returns an empty array if no occurrences are scheduled within the date range.
The dates are absolute date values, the time zone should be handled when formatting the date for display later on.
We need to have the first date in the series in case EKRecurrenceEnd uses end after occurrenceCount.
This only returns occurrence dates based on the recurrence rule. It may be the case that the dates are preceded
by their initial instance which has a date that does not conform to the rule.
firstStart/firstEnd: dates representing the first occurrence associated with the rule, in case occurrenceCount is relevant.
This date does not necessarily follow the rule.
rangeStart/rangeEnd: the date range used to filter the generated results before they are returned. EKRecurrenceEnd.endDate
takes precedence over rangeEnd.
strictRange: Set it to false for single-day date ranges where you want to include occurrences that straddle multiple days
if true, rangeStart <= firstResult.start && rangeEnd > lastResult.end
if false, rangeStart < firstResult.end && rangeEnd > lastResult.start
*/
func generateOccurrenceDates(firstStart: Date, firstEnd: Date?, exceptionDates: [Date]?, from rangeStart: Date, upTo rangeEnd: Date, strictRange: Bool = true, detailedLogs: Bool = false) -> [Occurrence] {
let cal = Calendar(identifier: .gregorian)
let firstStartComps = cal.dateComponents(relevantComponents, from: firstStart)
let cleanedFirstStart = cal.date(from: firstStartComps)!
//Used to create the endDate for each generated Occurrence at the end of this process
var durationComps: DateComponents? //will be nil for tasks and other items without an endDate
if let end = firstEnd {
let firstEndComps = cal.dateComponents(relevantComponents, from: end)
durationComps = cal.dateComponents([.second], from: firstStartComps, to: firstEndComps)
}
let adjustedRange: DatePair = (cleanedFirstStart, rangeEnd)
if detailedLogs { print("Full range: \(adjustedRange)") }
//Return early if rangeEnd precedes firstStart, for efficiency
guard adjustedRange.end >= cleanedFirstStart else { return [] } //print("firstStart greater than date range");
/* From the IETF RFC-2445 standards document, p.43:
If multiple `BYxxx` rule parts are specified, then after evaluating the
specified `FREQ` and `INTERVAL` rule parts, the `BYxxx` rule parts are
applied to the current set of evaluated occurrences in the following
order: `BYMONTH`, `BYWEEKNO`, `BYYEARDAY`, `BYMONTHDAY`, `BYDAY`, `BYHOUR`,
`BYMINUTE`, `BYSECOND` and `BYSETPOS`; then `COUNT` and `UNTIL` are evaluated.
*/
let rangePeriodComps = createRangePeriodComponents(frequency: frequency, interval: interval, from: firstStartComps, within: adjustedRange, using: cal)
if detailedLogs {
print("Initial range period comps:")
printRangePeriodComps(rangePeriodComps)
}
//print("First range period comps: \(rangePeriodComps.first?.first?.debugDescription ?? "")")
//If these unit array parameters are nil or invalid, the functions return the components that were passed in from the previous step.
let compsWithMonthsOfYear = applyMonthsOfYear(getMonthsOfTheYear(), to: rangePeriodComps)
if detailedLogs {
print("Comps with months of year:")
printRangePeriodComps(compsWithMonthsOfYear)
}
let compsWithWeeksOfYear = applyWeeksOfYear(getWeeksOfTheYear(), to: compsWithMonthsOfYear, calendar: cal)
let compsWithDaysOfYear = applyDaysOfYear(getDaysOfTheYear(), to: compsWithWeeksOfYear, calendar: cal)
let compsWithDaysOfMonth = applyDaysOfMonth(getDaysOfTheMonth(), to: compsWithDaysOfYear, calendar: cal)
if detailedLogs {
print("Comps with days of month:")
printRangePeriodComps(compsWithDaysOfMonth)
}
let compsWithDaysOfWeek = applyDaysOfWeek(Array(daysOfTheWeek), to: compsWithDaysOfMonth, calendar: cal, detailedLogs: detailedLogs)
if detailedLogs {
print("Comps with days of week:")
printRangePeriodComps(compsWithDaysOfWeek)
}
//Get the start dates for each RangePeriodComponents and sort them since the preceding steps don't guarantee chronological order
let rangePeriodDates = mapRangePeriodDates(from: compsWithDaysOfWeek, calendar: cal)
if detailedLogs {
print("Range period dates:")
printRangePeriodDates(rangePeriodDates)
}
let sortedStartDates = sortRangePeriodDates(rangePeriodDates)
let trimmedDates = trimPrecedingFirstPeriodDates(of: sortedStartDates, firstStart: firstStart)
if detailedLogs {
print("Validated sorted dates:")
printRangePeriodDates(trimmedDates)
}
let datesWithSetPositions = applySetPositions(getSetPositions(), to: trimmedDates)
//Combine the dates from all periods
let joinedDates = joinRangeDates(for: datesWithSetPositions, calendar: cal)
let allStartDates = validateFirstStartDate(of: joinedDates, firstStart: firstStart)
if detailedLogs {
print("All start dates:")
printDates(allStartDates)
}
//Apply the maxCount before the date range filter so that the count is applied from the correct starting point
let maxCountDates = applyMaxOccurrenceCount(recurrenceEnd?.validOccurrenceCount, to: allStartDates, starting: cleanedFirstStart)
let maxCountOccurrences: [Occurrence] = createEventOccurrences(from: maxCountDates, durationComponents: durationComps, calendar: cal)
let occurrencesForDateRange = applyDateRange(rangeStart: rangeStart, rangeEnd: rangeEnd, recurrenceEnd: recurrenceEnd?.endDate, strictRange: strictRange, to: maxCountOccurrences)
let occurrencesExcludingExceptionDates = removeExceptionDates(exceptionDates, from: occurrencesForDateRange)
if detailedLogs {
print("Occurrences for date range:")
printOccurrences(occurrencesExcludingExceptionDates)
}
return occurrencesExcludingExceptionDates
}
/**
This returns an array of RecurrencePeriodComponents, each of which contain a single-element array of `DateComponents` matching the occurrence for that recurrence period.
The elements are created within the dateRange parameter.
*/
private func createRangePeriodComponents(frequency: SMRecurrenceFrequency, interval: Int, from baseComponents: DateComponents, within dateRange: DatePair, using calendar: Calendar) -> [RecurrencePeriodComponents] {
guard let firstDate = calendar.date(from: baseComponents) else { return [] }
//print("Base components: \(baseComponents)")
var rangePeriodComps: [RecurrencePeriodComponents] = []
var nextPeriodDate: Date = firstDate //holds all the correct components for that period
var nextPeriodStartDate: Date = firstDate //used to determine whether the range end has been exceeded, important because unit arrays like daysOfTheWeek have not yet been applied
//The components to add to get the corresponding date for the next period
let deltaComponents = frequency.deltaComponents(with: interval)
//For more info, see comments on SMRecurrenceFrequency.periodStart()
//default to Sunday, the following approach to evaluating the nextPeriodStartDate is not compatible with simple weekly repeating on Sundays when the firstDayOfWeek is Monday
//Unit test testEverySundayStartingSunday() shows this incompatibility
var firstDayOfWeek = 1
if self.firstDayOfTheWeek != 0 && self.interval > 1 && !self.daysOfTheWeek.isEmpty {
firstDayOfWeek = self.firstDayOfTheWeek
}
while nextPeriodStartDate <= dateRange.end {
//Append the nextComponents to the results if the date comparison is true
let comps = calendar.dateComponents(relevantComponents, from: nextPeriodDate)
let periodComps: RecurrencePeriodComponents = [comps]
rangePeriodComps.append(periodComps)
//print("Period comps: \(periodComps)")
//Create the [DateComponents] for the next period, which will be evaluated and appended to allPeriodComponents during the next while loop
nextPeriodDate = calendar.date(byAdding: deltaComponents, to: nextPeriodDate)!
let nextComps = calendar.dateComponents(relevantComponents, from: nextPeriodDate)
nextPeriodStartDate = frequency.periodStart(for: nextComps, calendar: calendar, firstDayOfWeek: firstDayOfWeek, useWeekOfYear: weekOfYearIsRelevant)
//print("Next period start date: \(nextPeriodStartDate)")
}
if self.shouldIgnoreDayComponent {
//print("Will remove day component")
//Strip out the day of the month component so that it doesn't conflict with dayOfTheWeek, daysOfTheYear, or weeksOfTheYear
return rangePeriodComps.map({ periodComps in
return periodComps.map({ dateComps in
var compsWithoutDay = dateComps
compsWithoutDay.setValue(nil, for: .day)
return compsWithoutDay
})
})
} else {
return rangePeriodComps
}
}
/**
The frequency is yearly when monthsOfTheYear is set without specific days of the week, e.g. the 5th of January and July. The day of the month is determined by the date of the first occurrence.
The frequency is monthly when days of the week are set with setPositions, e.g. the 1st/2nd/3rd/4th/5th/last day/weekday/weekendDay of January and July.
*/
private func applyMonthsOfYear(_ months: [Int]?, to rangePeriodComps: [RecurrencePeriodComponents]) -> [RecurrencePeriodComponents] {
guard let monthValues = months, frequency.allowsMonthsOfTheYear else { return rangePeriodComps }
//print("Will apply months: \(monthValues)")
return rangePeriodComps.flatMap({ periodComps in //returns [RecurrencePeriodComponents]
let filteredPeriodComps: RecurrencePeriodComponents = periodComps.reduce([], { results, dateComps in //returns combined [DateComponents], i.e. RecurrencePeriodComponents
//With monthly frequency, the comps.month would have been set during createRangePeriodComponents() for all months in the range
if frequency == .monthly {
if let componentMonth = dateComps.month, monthValues.contains(componentMonth) {
return results + [dateComps]
} else {
return results //creates an empty array that will be filtered out below
}
} else {
return results + monthValues.flatMap({ month in //returns [DateComponents]
//carry over the year, day, hour, minute and second from the previous component, the day may be removed later by other unit arrays
return DateComponents(year: dateComps.year, month: month, day: dateComps.day, hour: dateComps.hour, minute: dateComps.minute, second: dateComps.second)
})
}
})
//remove periods that have been emptied because their components.month didn't match monthsOfTheYear
return filteredPeriodComps.isEmpty ? nil : filteredPeriodComps
})
}
private func applyWeeksOfYear(_ weeks: [Int]?, to rangePeriodComps: [RecurrencePeriodComponents], calendar: Calendar) -> [RecurrencePeriodComponents] {
guard let weekValues = weeks, frequency.allowsWeeksOfTheYear else { return rangePeriodComps }
//print("Will apply weeks of year: \(weekValues)")
//TODO: may need to create a period/array for each week of the year (52-53 periods). These would replace the single period for each year.
return rangePeriodComps.map({ periodComps in //returns [RecurrencePeriodComponents]
return periodComps.reduce([], { results, dateComps in //returns combined [DateComponents], i.e. RecurrencePeriodComponents
//comps for the weekday in first week of the year
let periodBaseComps = DateComponents(hour: dateComps.hour, minute: dateComps.minute, second: dateComps.second, weekday: dateComps.weekday, weekOfYear: 1, yearForWeekOfYear: dateComps.year)
guard let periodBaseDate = calendar.date(from: periodBaseComps) else { return results }
return results + weekValues.flatMap({ weekOfYear in //returns [DateComponents]
//Get the corresponding weekNumber if weekOfYear is negative.
//Carry over the year, hour, and minute from the previous component.
//Remove the month and day as they are incompatible with weekOfYear.
//The weekday will likely be changed later.
if weekOfYear < 0 {
let changeComps = DateComponents(weekOfYear: weekOfYear, yearForWeekOfYear: 1) //go to first week of next year, subtract the weekOfYear (is negative here)
guard let nextPeriodDate = calendar.date(byAdding: changeComps, to: periodBaseDate) else { return nil }
let nextComps = calendar.dateComponents([.hour, .minute, .second, .weekday, .weekOfYear, .yearForWeekOfYear], from: nextPeriodDate)
return nextComps
} else {
var nextComps = periodBaseComps
nextComps.setValue(weekOfYear, for: .weekOfYear)
//Apply EKRecurrenceRule.firstDayOfTheWeek
//This check could be more robust to handle -53, but honestly not sure of how this property is used in practice
if weekOfYear == 1, let compsWeekday = periodBaseComps.weekday, compsWeekday < validatedFirstDayOfWeek {
return nil //weekday precedes start of first week of the year
}
return nextComps
}
})
})
})
}
private func applyDaysOfYear(_ days: [Int]?, to rangePeriodComps: [RecurrencePeriodComponents], calendar: Calendar) -> [RecurrencePeriodComponents] {
guard let dayValues = days, frequency.allowsDaysOfTheYear else { return rangePeriodComps }
//print("Days of the year: \(dayValues)")
return rangePeriodComps.map({ periodComps in
return periodComps.reduce([], { results, dateComps in
//comps for the weekday in first week of the year
let periodBaseComps = DateComponents(year: dateComps.year, month: 1, day: 1, hour: dateComps.hour, minute: dateComps.minute, second: dateComps.second)
guard let periodBaseDate = calendar.date(from: periodBaseComps) else { return results }
return results + dayValues.flatMap({ dayOfYear in
if dayOfYear < 0 {
let changeComps = DateComponents(year: 1, day: dayOfYear) //go to first day of next year then subtract the dayOfYear (is negative here)
guard let nextPeriodDate = calendar.date(byAdding: changeComps, to: periodBaseDate) else { return nil }
let nextComps = calendar.dateComponents([.hour, .minute, .second, .weekday, .weekOfYear, .yearForWeekOfYear], from: nextPeriodDate)
return nextComps
} else {
var nextComps = periodBaseComps
nextComps.setValue(dayOfYear, for: .day)
return nextComps
}
})
})
})
}
/**
RFC standards document says that "Valid values are 1 to 31 or -31 to -1." Due to Apple's UIs, these values are almost always positive,
but this function supports both possibilities.
*/
private func applyDaysOfMonth(_ days: [Int]?, to rangePeriodComps: [RecurrencePeriodComponents], calendar: Calendar) -> [RecurrencePeriodComponents] {
guard let dayValues = days, frequency.allowsDaysOfTheMonth else { return rangePeriodComps }
//Monthly frequency only
//print("Days of the month: \(dayValues)")
return rangePeriodComps.map({ periodComps in
return periodComps.reduce([], { results, dateComps in
//comps for the first of that month
let periodBaseComps = DateComponents(year: dateComps.year, month: dateComps.month, day: 1, hour: dateComps.hour, minute: dateComps.minute, second: dateComps.second)
guard let periodBaseDate = calendar.date(from: periodBaseComps) else { return results }
return results + dayValues.flatMap({ dayOfMonth in
if dayOfMonth < 0 {
let changeComps = DateComponents(month: 1, day: dayOfMonth) //go to first day of next year then subtract the dayOfMonth (is negative here)
guard let nextPeriodDate = calendar.date(byAdding: changeComps, to: periodBaseDate) else { return nil }
let nextComps = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: nextPeriodDate)
return nextComps
} else {
var nextComps = periodBaseComps
nextComps.setValue(dayOfMonth, for: .day)
return nextComps.isValidDate(in: calendar) ? nextComps : nil //strips out 29/30/31 according to the month
}
})
})
})
}
private func applyDaysOfWeek(_ recurrenceDays: [SMRecurrenceDayOfWeek], to rangePeriodComps: [RecurrencePeriodComponents], calendar: Calendar, detailedLogs: Bool) -> [RecurrencePeriodComponents] {
//recurrenceDays are stored as a Realm List, so the property cannot be nil. Instead we check for an empty array.
guard !recurrenceDays.isEmpty, frequency.allowsDaysOfTheWeek else { return rangePeriodComps }
//print("Recurrence days: \(recurrenceDays)")
switch frequency {
case .daily: //invalid frequency
return []
case .weekly: //Ignore weekNumbers
//TODO: It may be appropriate to handle the firstDayOfTheWeek property here, according to the interval. Not yet sure if it is needed.
return rangePeriodComps.map({ weekPeriodComps in
return weekPeriodComps.reduce([], { results, dateComps in
return results + recurrenceDays.flatMap({ smDayOfWeek in
if detailedLogs, let compsDate = calendar.date(from: dateComps) {
print("Date from period comps: \t\(compsDate)")
}
var nextComps = dateComps
nextComps.setValue(smDayOfWeek.dayOfTheWeek.rawValue, for: .weekday)
if detailedLogs, let date = calendar.date(from: nextComps) {
print("\t\t\t\t\t\t\(date), \(smDayOfWeek.dayOfTheWeek.description)")
}
return nextComps
})
})
})
case .monthly, .yearly:
if daysOfTheWeekHaveWeekNumbers {
//Treat weekNumber values as setPositions for .weekdayOrdinal
if !self.monthsOfTheYear.isEmpty || frequency == .monthly {
return rangePeriodComps.map({ monthPeriodComps in
return monthPeriodComps.reduce([], { results, dateComps in
return results + recurrenceDays.flatMap({ smDayOfWeek in
//Note that .weekdayOrdinal = -1 returns the last instance of the weekday in the given month, not the previous month
//This is different from how DateComponents handles negative values for .weekOfYear and .day
var nextComps = dateComps
nextComps.setValue(smDayOfWeek.dayOfTheWeek.rawValue, for: .weekday)
nextComps.setValue(smDayOfWeek.weekNumber, for: .weekdayOrdinal)
if smDayOfWeek.weekNumber < 0 {
return nextComps
} else {
return nextComps.isValidDate(in: calendar) ? nextComps : nil //exclude dates that don't exist in the month, relevant when weekNumber = 5
}
})
})
})
} else {
//Treat weekNumber values as setPositions for .weekOfYear (very rare)
return rangePeriodComps.map({ yearPeriodComps in
return yearPeriodComps.reduce([], { periodResults, dateComps in
let yearByWeekStartComps = DateComponents(hour: 0, minute: 0, second: 0, weekday: validatedFirstDayOfWeek, weekOfYear: 1, yearForWeekOfYear: dateComps.year)
guard let yearStartDate = calendar.date(from: yearByWeekStartComps) else { return periodResults }
//The returned results will not be chronologically sorted due to enumerating for each day of the week
//Sorting will happen in the next step before applying setPositions
return periodResults + recurrenceDays.reduce([], { weekdayResults, smDayOfWeek in
let compsToMatch = DateComponents(year: dateComps.year, month: dateComps.month, hour: dateComps.hour, minute: dateComps.minute, second: dateComps.second, weekday: smDayOfWeek.dayOfTheWeek.rawValue)
var matchingDateComps: [DateComponents] = []
calendar.enumerateDates(startingAfter: yearStartDate, matching: compsToMatch, matchingPolicy: .nextTimePreservingSmallerComponents, using: { (date, isExact, stop) in
guard let enumeratedDate = date else { return } //is nil when no more matches are found
let enumeratedComps = calendar.dateComponents(relevantComponents, from: enumeratedDate)
matchingDateComps.append(enumeratedComps)
//the enumeration should automatically stop when it can no longer match the year and month components
guard matchingDateComps.count <= 366 else {
print("Found more than 366 matching dates in the year")
stop = true
return
}
})
return weekdayResults + matchingDateComps
})
})
})
}
} else { //daysOfTheWeek do not have weekNumber values
if self.weeksOfTheYear.isEmpty == false {
//apply daysOfTheWeek where each period is a weekOfTheYear, as created in applyWeeksOfYear()
return rangePeriodComps.map({ weekPeriodComps in
return weekPeriodComps.reduce([], { periodResults, dateComps in
//dateComps already has the correct value for .weekOfYear, just need to map the recurrenceDayValues
return periodResults + recurrenceDays.map({ smDayOfWeek in
var weekdayOfWeekComps = dateComps
weekdayOfWeekComps.setValue(smDayOfWeek.dayOfTheWeek.rawValue, for: .weekday)
return weekdayOfWeekComps
})
})
})
} else if frequency == .yearly, self.setPositions.isEmpty == false, monthsOfTheYear.isEmpty {
//Set positions are applied to a series of weekdays matching daysOfTheWeek throughout the year
//e.g. Second and last weekday of the year: daysOfTheWeek: [2,3,4,5,6], setPositions: [2, -1]
//print("Will apply days of the week to all days in the year: \(rangePeriodComps)")
return rangePeriodComps.map({ yearPeriodComps in
return yearPeriodComps.reduce([], { periodResults, dateComps in
let yearStartComps = DateComponents(year: dateComps.year, month: 1, day: 1, hour: 0, minute: 0, second: 0)
guard let yearStartDate = calendar.date(from: yearStartComps) else { return periodResults }
let yearEndDate = yearStartDate.yearEnd
//The returned results will not be chronologically sorted due to enumerating for each day of the week
//Sorting will happen in the next step before applying setPositions
return periodResults + recurrenceDays.reduce([], { weekdayResults, smDayOfWeek in
let compsToMatch = DateComponents(hour: dateComps.hour, minute: dateComps.minute, second: dateComps.second, weekday: smDayOfWeek.dayOfTheWeek.rawValue)
if detailedLogs { print("Comps to match: \(compsToMatch)") }
var matchingDateComps: [DateComponents] = []
calendar.enumerateDates(startingAfter: yearStartDate, matching: compsToMatch, matchingPolicy: .nextTimePreservingSmallerComponents, using: { (date, isExact, stop) in
guard let enumeratedDate = date else { return } //is nil when no more matches are found
guard enumeratedDate <= yearEndDate else {
print("Reached end of year \(dateComps.year ?? 0)")
stop = true
return
}
let enumeratedComps = calendar.dateComponents(relevantComponents, from: enumeratedDate)
matchingDateComps.append(enumeratedComps)
})
return weekdayResults + matchingDateComps
})
})
})
} else {
//This covers both monthly and yearly frequencies, but each period covers 1 month in both cases since monthsOfTheYear has already been applied.
//Since the weekdays have no weekNumber properties, iterate through all days of the period (month or year), creating comps for each one that matches one of the weekdays
//The setPositions property will be applied to the resulting array in the next step, e.g. 3rd Thursday of every month or last weekday of the month/year
return rangePeriodComps.map({ monthPeriodComps in
return monthPeriodComps.reduce([], { periodResults, dateComps in
let monthStartComps = DateComponents(year: dateComps.year, month: dateComps.month, day: 1, hour: 0, minute: 0, second: 0)
guard let monthStartDate = calendar.date(from: monthStartComps) else { return periodResults }
let monthEndDate = monthStartDate.monthEnd
//The returned results will not be chronologically sorted due to enumerating for each day of the week
//Sorting will happen in the next step before applying setPositions
return periodResults + recurrenceDays.reduce([], { weekdayResults, smDayOfWeek in
//TODO: apply changes here to other instances of enumerateDates()
let compsToMatch = DateComponents(hour: dateComps.hour, minute: dateComps.minute, second: dateComps.second, weekday: smDayOfWeek.dayOfTheWeek.rawValue)
if detailedLogs { print("Comps to match: \(compsToMatch)") }
var matchingDateComps: [DateComponents] = []
calendar.enumerateDates(startingAfter: monthStartDate, matching: compsToMatch, matchingPolicy: .nextTimePreservingSmallerComponents, using: { (date, isExact, stop) in
guard let enumeratedDate = date else { return } //is nil when no more matches are found
guard enumeratedDate <= monthEndDate else {
stop = true
return
}
let enumeratedComps = calendar.dateComponents(relevantComponents, from: enumeratedDate)
matchingDateComps.append(enumeratedComps)
})
return weekdayResults + matchingDateComps
})
})
})
}
}
}
}
private func mapRangePeriodDates(from rangePeriodComps: [RecurrencePeriodComponents], calendar: Calendar) -> [[Date]] {
return rangePeriodComps.map({ periodComps in
return periodComps.flatMap({ dateComps in
calendar.date(from: dateComps)
})
})
}
private func sortRangePeriodDates(_ rangePeriodDates: [[Date]]) -> [[Date]] {
return rangePeriodDates.map({ periodDates in
return periodDates.sorted(by: { $0 < $1 })
})
}
/**
In some cases the first instance (firstStart) shouldn't follow the recurrence rule. This function makes sure that firstStart
is the first date and that no other dates precede it, as might be caused by applying daysOfTheWeek to the base date components.
This is skipped if setPositions is set for the rule.
*/
private func trimPrecedingFirstPeriodDates(of sortedStartDates: [[Date]], firstStart: Date) -> [[Date]] {
//The setPositions property needs to have all instances for a period in order to be correctly applied
guard self.setPositions.isEmpty,
let firstPeriod = sortedStartDates.first
else { return sortedStartDates }
var validatedPeriods = Array(sortedStartDates.dropFirst())
//Remove any dates that precede the firstStart
let filteredPeriodDates = firstPeriod.filter({$0 >= firstStart})
validatedPeriods.insert(filteredPeriodDates, at: 0)
return validatedPeriods
}
/**
Apply this after setPositions as the first instance may not follow the recurrence rule,
but should still be included in the end results.
*/
private func validateFirstStartDate(of allDates: [Date], firstStart: Date) -> [Date] {
if allDates.contains(firstStart) {
return allDates
} else {
var newDates = allDates
newDates.insert(firstStart, at: 0)
return newDates
}
}
/**
From the headers for setPositions:
This property is valid for rules which have a valid daysOfTheWeek, daysOfTheMonth, weeksOfTheYear, or monthsOfTheYear property.
It allows you to specify a set of ordinal numbers to help choose which objects out of the set of selected events should be
included. For example, setting the daysOfTheWeek to Monday-Friday and including a value of -1 in the array would indicate
the last weekday in the recurrence range (month, year, etc). This value corresponds to the iCalendar BYSETPOS property.
*/
private func applySetPositions(_ setPositions: [Int]?, to rangePeriodDates: [[Date]]) -> [[Date]] {
guard let positions = setPositions, setPositionsContextIsCompatible else { return rangePeriodDates }
//print(positions)
return rangePeriodDates.map({ periodDates in
//return a subset of the periodDates based on the indices
let dateIndices = indices(forPositions: positions, inArray: periodDates)
//print("Date indices: \(dateIndices)")
let datesForIndices: [Date] = dateIndices.map({ periodDates[$0] })
return datesForIndices
})
}
/**
Joins the Date arrays from each recurrence period to return a single array for the entire date range.
*/
private func joinRangeDates(for rangePeriodDates: [[Date]], calendar: Calendar) -> [Date] {
return rangePeriodDates.reduce([], { joinedDates, periodDates in
return joinedDates + periodDates
})
}
///setPositions represent positive and negative cardinal numbers instead of indices, 0 is not a valid setPosition
private func indices(forPositions setPositions: [Int], inArray array: [Any]) -> [Int] {
let count = array.count
var results: [Int] = []
for pos in setPositions {
if pos > 0, (pos <= count) {
results.append(pos - 1)
} else if pos < 0, (abs(pos) <= count) {
results.append(array.endIndex + pos)
} else {
print("Set position \(pos) is invalid, count: \(count)")
}
}
return results
}
private func createEventOccurrences(from startDates: [Date], durationComponents: DateComponents?, calendar: Calendar) -> [Occurrence] {
if let durationComps = durationComponents {
return startDates.flatMap({ start in
let end = calendar.date(byAdding: durationComps, to: start)!
return (start, end)
})
} else {
return startDates.flatMap({($0, nil)})
}
}
private var shouldIgnoreDayComponent: Bool {
return (!daysOfTheWeek.isEmpty || !daysOfTheYear.isEmpty || !weeksOfTheYear.isEmpty)
}
private func applyMaxOccurrenceCount(_ maxCount: Int?, to dates: [Date], starting firstStart: Date) -> [Date] {
let cleanedFirstStart = firstStart.dayStart //in case firstStart has seconds
//print("First start: \(cleanedFirstStart.debugDescription)")
let validDatesForFirstStart = dates.filter({ cleanedFirstStart <= $0 })
if let max = maxCount {
return Array(validDatesForFirstStart.prefix(max))
} else {
return validDatesForFirstStart
}
}
private func applyDateRange(rangeStart: Date, rangeEnd: Date, recurrenceEnd: Date?, strictRange: Bool, to occurrences: [Occurrence]) -> [Occurrence] {
//print("\(occurrences.count) occurrences before applying rangeStart: \(rangeStart), rangeEnd: \(rangeEnd)")
var adjustedRangeEnd = rangeEnd
if let end = recurrenceEnd {
adjustedRangeEnd = min(end, rangeEnd)
}
let occurrencesForDateRange = occurrences.filter({ (occurrence) in
//For the recurrenceEnd, only block occurrences whose start is later, the occurrenceEnd can be after if the start is before
if let end = recurrenceEnd, occurrence.start > end {
return false
}
if let occurrenceEnd = occurrence.end {
if strictRange {
return (rangeStart <= occurrence.start) && (adjustedRangeEnd >= occurrenceEnd)
} else {
return (rangeStart <= occurrenceEnd) && (adjustedRangeEnd >= occurrence.start)
}
} else { //only dealing with occurrence.start
return (rangeStart <= occurrence.start) && (adjustedRangeEnd >= occurrence.start)
}
})
//print("Will return \(occurrencesForDateRange.count) occurrences for date range")
return occurrencesForDateRange
}
private func removeExceptionDates(_ exceptionDates: [Date]?, from occurrences: [Occurrence]) -> [Occurrence] {
guard let exDates = exceptionDates, !exDates.isEmpty else { return occurrences }
let filteredOccurrences = occurrences.filter({ !exDates.contains($0.start) })
return filteredOccurrences
}
/**
From the headers for setPositions:
The setPositions property is valid for rules which have a valid daysOfTheWeek,
daysOfTheMonth, weeksOfTheYear, or monthsOfTheYear property.
*/
var setPositionsContextIsCompatible: Bool {
return (!daysOfTheWeek.isEmpty || !daysOfTheMonth.isEmpty || !weeksOfTheYear.isEmpty || !monthsOfTheYear.isEmpty)
}
var validatedFirstDayOfWeek: Int {
guard firstDayOfTheWeek > 0 && firstDayOfTheWeek <= 7 else { return 2 }
return firstDayOfTheWeek
}
func printRangePeriodComps(_ rangeComps: [RecurrencePeriodComponents], asDatesIfPossible: Bool = true) {
let cal = Calendar(identifier: .gregorian)
for periodComps in rangeComps {
for comps in periodComps {
if asDatesIfPossible, let date = cal.date(from: comps) {
print(date)
} else {
print(comps)
}
}
print("") //line break between periods
}
}
func printRangePeriodDates(_ rangeDates: [[Date]]) {
for periodDates in rangeDates {
for date in periodDates {
print(date)
}
print("") //line break between periods
}
}
func printDates(_ dates: [Date]) {
for date in dates {
print(date)
}
}
func printOccurrences(_ occurrences: [Occurrence]) {
print("Generated \(occurrences.count) occurrences:")
for occurrence in occurrences {
var description = "start: \(occurrence.start.description)"
if let end = occurrence.end {
description += ", end: \(end.description)"
}
print(description)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment