@rschedule/rschedule
Version:
A typescript library for working with recurring dates and events.
1,376 lines (1,359 loc) • 173 kB
JavaScript
class IntersectionOperatorConfig {
}
class RuleConfig {
}
class MergeDurationOperatorConfig {
}
class SplitDurationOperatorConfig {
}
class RScheduleConfig {
}
RScheduleConfig.Rule = RuleConfig;
RScheduleConfig.IntersectionOperator = IntersectionOperatorConfig;
RScheduleConfig.MergeDurationOperator = MergeDurationOperatorConfig;
RScheduleConfig.SplitDurationOperator = SplitDurationOperatorConfig;
class ArgumentError extends Error {
}
class InfiniteLoopError extends Error {
}
// Taken from https://stackoverflow.com/a/53985533/5490505
// export type TupleUnshift<A, B extends readonly [...any[]]> = ((a: A, ...r: ForcedTuple<B>) => void) extends (
// ...a: infer R
// ) => any
// ? R
// : never;
// type ForcedTuple<T> = T extends [
// infer A,
// infer B,
// infer C,
// infer D,
// infer E,
// infer F,
// infer G,
// infer H,
// infer I,
// infer J,
// infer K,
// infer L,
// infer M,
// infer N,
// infer O,
// infer P,
// infer Q,
// infer R,
// infer S,
// infer T,
// infer U,
// infer V,
// infer W,
// infer X,
// infer Y,
// infer Z
// ]
// ? [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z]
// : T;
function numberSortComparer(a, b) {
if (a > b) {
return 1;
}
else if (b > a) {
return -1;
}
else {
return 0;
}
}
function freqToGranularity(freq) {
switch (freq) {
case 'YEARLY':
return 'year';
case 'MONTHLY':
return 'month';
case 'WEEKLY':
return 'week';
case 'DAILY':
return 'day';
case 'HOURLY':
return 'hour';
case 'MINUTELY':
return 'minute';
case 'SECONDLY':
return 'second';
default:
return 'millisecond';
}
}
function cloneJSON(json) {
return JSON.parse(JSON.stringify(json));
}
var _a;
const WEEKDAYS = [
'SU',
'MO',
'TU',
'WE',
'TH',
'FR',
'SA',
];
const MILLISECONDS_IN_SECOND = 1000;
const MILLISECONDS_IN_MINUTE = MILLISECONDS_IN_SECOND * 60;
const MILLISECONDS_IN_HOUR = MILLISECONDS_IN_MINUTE * 60;
const MILLISECONDS_IN_DAY = MILLISECONDS_IN_HOUR * 24;
const MILLISECONDS_IN_WEEK = MILLISECONDS_IN_DAY * 7;
class InvalidDateTimeError extends Error {
}
const DATETIME_ID = Symbol.for('b1231462-3560-4770-94f0-d16295d5965c');
class DateTime {
constructor(date, timezone, duration) {
/**
* This property contains an ordered array of the generator objects
* responsible for producing this DateAdapter.
*
* - If this DateAdapter was produced by a `Rule` object, this array
* will just contain the `Rule` object.
* - If this DateAdapter was produced by a `Schedule` object, this
* array will contain the `Schedule` object as well as the `Rule`
* or `Dates` object which generated it.
* - If this DateAdapter was produced by a `Calendar` object, this
* array will contain, at minimum, the `Calendar`, `Schedule`, and
* `Rule`/`Dates` objects which generated it.
*/
this.generators = [];
this[_a] = true;
this.date = new Date(date);
this.timezone = timezone || null;
this.duration = duration;
this.assertIsValid();
}
/**
* Similar to `Array.isArray()`, `isInstance()` provides a surefire method
* of determining if an object is a `DateTime` by checking against the
* global symbol registry.
*/
static isInstance(object) {
return !!(object && object[DATETIME_ID]);
}
static fromJSON(json) {
const date = new Date(Date.UTC(json.year, json.month - 1, json.day, json.hour, json.minute, json.second, json.millisecond));
return new DateTime(date, json.timezone, json.duration);
}
static fromDateAdapter(adapter) {
return DateTime.fromJSON(adapter.toJSON());
}
/**
* Returns `undefined` if `this.duration` is falsey. Else returns
* the `end` date.
*/
get end() {
if (!this.duration)
return;
if (this._end)
return this._end;
this._end = this.add(this.duration, 'millisecond');
return this._end;
}
// While we constrain the argument to be another DateAdapter in typescript
// we handle the case of someone passing in another type of object in javascript
isEqual(object) {
if (!object) {
return false;
}
assertSameTimeZone(this, object);
return this.valueOf() === object.valueOf();
}
isBefore(object) {
assertSameTimeZone(this, object);
return this.valueOf() < object.valueOf();
}
isBeforeOrEqual(object) {
assertSameTimeZone(this, object);
return this.valueOf() <= object.valueOf();
}
isAfter(object) {
assertSameTimeZone(this, object);
return this.valueOf() > object.valueOf();
}
isAfterOrEqual(object) {
assertSameTimeZone(this, object);
return this.valueOf() >= object.valueOf();
}
isOccurring(object) {
if (!this.duration) {
throw new Error('DateTime#isOccurring() is only applicable to DateTimes with durations');
}
assertSameTimeZone(this, object);
return (object.isAfterOrEqual(this) && object.isBeforeOrEqual(this.add(this.duration, 'millisecond')));
}
add(amount, unit) {
switch (unit) {
case 'year':
return this.forkDateTime(addUTCYears(this.date, amount));
case 'month':
return this.forkDateTime(addUTCMonths(this.date, amount));
case 'week':
return this.forkDateTime(addUTCWeeks(this.date, amount));
case 'day':
return this.forkDateTime(addUTCDays(this.date, amount));
case 'hour':
return this.forkDateTime(addUTCHours(this.date, amount));
case 'minute':
return this.forkDateTime(addUTCMinutes(this.date, amount));
case 'second':
return this.forkDateTime(addUTCSeconds(this.date, amount));
case 'millisecond':
return this.forkDateTime(addUTCMilliseconds(this.date, amount));
default:
throw new ArgumentError('Invalid unit provided to `DateTime#add`');
}
}
subtract(amount, unit) {
switch (unit) {
case 'year':
return this.forkDateTime(subUTCYears(this.date, amount));
case 'month':
return this.forkDateTime(subUTCMonths(this.date, amount));
case 'week':
return this.forkDateTime(subUTCWeeks(this.date, amount));
case 'day':
return this.forkDateTime(subUTCDays(this.date, amount));
case 'hour':
return this.forkDateTime(subUTCHours(this.date, amount));
case 'minute':
return this.forkDateTime(subUTCMinutes(this.date, amount));
case 'second':
return this.forkDateTime(subUTCSeconds(this.date, amount));
case 'millisecond':
return this.forkDateTime(subUTCMilliseconds(this.date, amount));
default:
throw new ArgumentError('Invalid unit provided to `DateTime#subtract`');
}
}
get(unit) {
switch (unit) {
case 'year':
return this.date.getUTCFullYear();
case 'month':
return (this.date.getUTCMonth() + 1);
case 'yearday':
return getUTCYearDay(this.date);
case 'weekday':
return WEEKDAYS[this.date.getUTCDay()];
case 'day':
return this.date.getUTCDate();
case 'hour':
return this.date.getUTCHours();
case 'minute':
return this.date.getUTCMinutes();
case 'second':
return this.date.getUTCSeconds();
case 'millisecond':
return this.date.getUTCMilliseconds();
default:
throw new ArgumentError('Invalid unit provided to `DateTime#set`');
}
}
set(unit, value) {
if (unit === 'duration') {
return new DateTime(this.date, this.timezone, value);
}
let date = new Date(this.date);
switch (unit) {
case 'year':
date.setUTCFullYear(value);
break;
case 'month': {
// If the current day of the month
// is greater than days in the month we are moving to, we need to also
// set the day to the end of that month.
const length = monthLength(value, date.getUTCFullYear());
const day = date.getUTCDate();
if (day > length) {
date.setUTCDate(1);
date.setUTCMonth(value);
date = subUTCDays(date, 1);
}
else {
date.setUTCMonth(value - 1);
}
break;
}
case 'day':
date.setUTCDate(value);
break;
case 'hour':
date.setUTCHours(value);
break;
case 'minute':
date.setUTCMinutes(value);
break;
case 'second':
date.setUTCSeconds(value);
break;
case 'millisecond':
date.setUTCMilliseconds(value);
break;
default:
throw new ArgumentError('Invalid unit provided to `DateTime#set`');
}
return this.forkDateTime(date);
}
granularity(granularity, opt = {}) {
let date = this.forkDateTime(this.date);
switch (granularity) {
case 'year':
date = date.set('month', 1);
case 'month':
date = date.set('day', 1);
break;
case 'week':
date = setDateToStartOfWeek(date, opt.weekStart);
}
switch (granularity) {
case 'year':
case 'month':
case 'week':
case 'day':
date = date.set('hour', 0);
case 'hour':
date = date.set('minute', 0);
case 'minute':
date = date.set('second', 0);
case 'second':
date = date.set('millisecond', 0);
case 'millisecond':
return date;
default:
throw new ArgumentError('Invalid granularity provided to `DateTime#granularity`: ' + granularity);
}
}
endGranularity(granularity, opt = {}) {
let date = this.forkDateTime(this.date);
switch (granularity) {
case 'year':
date = date.set('month', 12);
case 'month':
date = date.set('day', monthLength(date.get('month'), date.get('year')));
break;
case 'week':
date = setDateToEndOfWeek(date, opt.weekStart);
}
switch (granularity) {
case 'year':
case 'month':
case 'week':
case 'day':
date = date.set('hour', 23);
case 'hour':
date = date.set('minute', 59);
case 'minute':
date = date.set('second', 59);
case 'second':
date = date.set('millisecond', 999);
case 'millisecond':
return date;
default:
throw new ArgumentError('Invalid granularity provided to `DateTime#granularity`: ' + granularity);
}
}
toISOString() {
return this.date.toISOString();
}
toDateTime() {
return this;
}
toJSON() {
return {
timezone: this.timezone,
duration: this.duration,
year: this.get('year'),
month: this.get('month'),
day: this.get('day'),
hour: this.get('hour'),
minute: this.get('minute'),
second: this.get('second'),
millisecond: this.get('millisecond'),
};
}
valueOf() {
return this.date.valueOf();
}
assertIsValid() {
if (isNaN(this.valueOf())) {
throw new InvalidDateTimeError('DateTime has invalid date.');
}
return true;
}
forkDateTime(date) {
return new DateTime(date, this.timezone, this.duration);
}
}
_a = DATETIME_ID;
function assertSameTimeZone(x, y) {
if (x.timezone !== y.timezone) {
throw new InvalidDateTimeError('Attempted to compare a datetime to another date in a different timezone: ' +
JSON.stringify(x) +
' and ' +
JSON.stringify(y));
}
return true;
}
function setDateToStartOfWeek(date, wkst) {
const index = orderedWeekdays(wkst).indexOf(date.get('weekday'));
return date.subtract(index, 'day');
}
function setDateToEndOfWeek(date, wkst) {
const index = orderedWeekdays(wkst).indexOf(date.get('weekday'));
return date.add(6 - index, 'day');
}
function dateTimeSortComparer(a, b) {
if (a.isAfter(b))
return 1;
if (a.isBefore(b))
return -1;
if (a.duration && b.duration) {
if (a.duration > b.duration)
return 1;
if (a.duration < b.duration)
return -1;
}
return 0;
}
function uniqDateTimes(dates) {
return Array.from(new Map(dates.map(date => [date.toISOString(), date])).values());
}
function orderedWeekdays(wkst = 'SU') {
const wkdays = WEEKDAYS.slice();
let index = wkdays.indexOf(wkst);
while (index !== 0) {
shiftArray(wkdays);
index--;
}
return wkdays;
}
function shiftArray(array, from = 'first') {
if (array.length === 0) {
return array;
}
else if (from === 'first') {
array.push(array.shift());
}
else {
array.unshift(array.pop());
}
return array;
}
/**
* Returns the days in the given month.
*
* @param month base-1
* @param year
*/
function monthLength(month, year) {
const block = {
1: 31,
2: getDaysInFebruary(year),
3: 31,
4: 30,
5: 31,
6: 30,
7: 31,
8: 31,
9: 30,
10: 31,
11: 30,
12: 31,
};
return block[month];
}
function getDaysInFebruary(year) {
return isLeapYear(year) ? 29 : 28;
}
// taken from date-fn
function isLeapYear(year) {
return year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0);
}
function getDaysInYear(year) {
return isLeapYear(year) ? 366 : 365;
}
function getUTCYearDay(now) {
const start = new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
const diff = now.valueOf() - start.valueOf();
return 1 + Math.floor(diff / MILLISECONDS_IN_DAY);
}
/**
* These functions are basically lifted from `date-fns`, but changed
* to use the UTC date methods, which `date-fns` doesn't support.
*/
function toInteger(input) {
if (input === null || input === true || input === false) {
return NaN;
}
const int = Number(input);
if (isNaN(int)) {
return int;
}
return int < 0 ? Math.ceil(int) : Math.floor(int);
}
function addMilliseconds(dirtyDate, dirtyAmount) {
if (arguments.length < 2) {
throw new TypeError('2 arguments required, but only ' + arguments.length + ' present');
}
const timestamp = dirtyDate.valueOf();
const amount = toInteger(dirtyAmount);
return new Date(timestamp + amount);
}
function addUTCYears(date, input) {
const amount = toInteger(input);
return addUTCMonths(date, amount * 12);
}
function addUTCMonths(date, input) {
const amount = toInteger(input);
date = new Date(date);
const desiredMonth = date.getUTCMonth() + amount;
const dateWithDesiredMonth = new Date(0);
dateWithDesiredMonth.setUTCFullYear(date.getUTCFullYear(), desiredMonth, 1);
dateWithDesiredMonth.setUTCHours(0, 0, 0, 0);
const daysInMonth = monthLength(dateWithDesiredMonth.getUTCMonth() + 1, dateWithDesiredMonth.getUTCFullYear());
// Set the last day of the new month
// if the original date was the last day of the longer month
date.setUTCMonth(desiredMonth, Math.min(daysInMonth, date.getUTCDate()));
return date;
}
function addUTCWeeks(date, input) {
const amount = toInteger(input);
const days = amount * 7;
return addUTCDays(date, days);
}
function addUTCDays(date, input) {
// by adding milliseconds rather than days, we supress the native Date object's automatic
// daylight savings time conversions which we don't want in UTC mode
return addUTCMilliseconds(date, toInteger(input) * MILLISECONDS_IN_DAY);
}
function addUTCHours(date, input) {
const amount = toInteger(input);
return addMilliseconds(date, amount * MILLISECONDS_IN_HOUR);
}
function addUTCMinutes(date, input) {
const amount = toInteger(input);
return addMilliseconds(date, amount * MILLISECONDS_IN_MINUTE);
}
function addUTCSeconds(date, input) {
const amount = toInteger(input);
return addMilliseconds(date, amount * MILLISECONDS_IN_SECOND);
}
function addUTCMilliseconds(date, input) {
const amount = toInteger(input);
const timestamp = date.getTime();
return new Date(timestamp + amount);
}
function subUTCYears(date, amount) {
return addUTCYears(date, -amount);
}
function subUTCMonths(date, amount) {
return addUTCMonths(date, -amount);
}
function subUTCWeeks(date, amount) {
return addUTCWeeks(date, -amount);
}
function subUTCDays(date, amount) {
return addUTCDays(date, -amount);
}
function subUTCHours(date, amount) {
return addUTCHours(date, -amount);
}
function subUTCMinutes(date, amount) {
return addUTCMinutes(date, -amount);
}
function subUTCSeconds(date, amount) {
return addUTCSeconds(date, -amount);
}
function subUTCMilliseconds(date, amount) {
return addUTCMilliseconds(date, -amount);
}
var _a$1;
class InvalidDateAdapterError extends Error {
}
const DATE_ADAPTER_ID = Symbol.for('9d2c0b75-7a72-4f24-b57f-c27e131e37b2');
class DateAdapter {
constructor(_date, _options) {
/**
* An array of OccurrenceGenerator objects which produced this DateAdapter.
*
* #### Details
*
* When a Rule object creates a DateAdapter, that Rule object adds itself to
* the DateAdapter's generators property before yielding the DateAdapter. If you are using a Rule
* object directly, the process ends there and the DateAdapter is yielded to you (in this case,
* generators will have the type `[Rule]`)
*
* If you are using another object, like a Schedule however, then each DateAdapter is generated
* by either a Dates (rdates) or Rule (rrule) within the Schedule. After being originally
* generated by a Dates/Rule, the DateAdapter is then filtered by any exdate/exrules and,
* assuming it passes, then the DateAdapter "bubbles up" to the Schedule object itself. At this
* point the Schedule adds itself to the generators array of the DateAdapter and yields the date
* to you. So each DateAdapter produced by a Schedule has a generators property of type
* `[Schedule, Rule | Dates]`.
*
* The generators property pairs well with the `data` property on many OccurrenceGenerators. You
* can access the OccurrenceGenerators which produced a DateAdapter via `generators`, and then
* access any arbitrary data via the `data` property.
*
* _Note: occurrence operators are never included in the generators array._
*
*/
// using `unknown[]` instead of `never[]` to support convenient generator typing in `Calendar`.
// If `never[]` is used, then `Calendar#schedules` *must* be typed as a tuple in order to
// access any values in `generators` beyond the first (Calendar) value (the rest of the values
// get typed as `never`). This would prevent passing a variable to `Calendar#schedules`.
this.generators = [];
this[_a$1] = true;
}
/**
* Similar to `Array.isArray()`, `isInstance()` provides a surefire method
* of determining if an object is a `DateAdapter` by checking against the
* global symbol registry.
*/
static isInstance(object) {
return !!(object && typeof object === 'object' && object[DATE_ADAPTER_ID]);
}
static isDate(_object) {
throw unimplementedError('isDate()');
}
static fromJSON(_json) {
throw unimplementedError('fromJSON()');
}
static fromDateTime(_datetime) {
throw unimplementedError('fromDateTime()');
}
/**
* Returns `undefined` if `this.duration` is falsey. Else returns
* the `end` date.
*/
get end() {
throw unimplementedError('end');
}
set(_prop, _value) {
throw unimplementedError('set()');
}
valueOf() {
throw unimplementedError('valueOf()');
}
toISOString() {
throw unimplementedError('toISOString()');
}
toDateTime() {
const date = DateTime.fromJSON(this.toJSON());
date.generators.push(...this.generators);
return date;
}
toJSON() {
throw unimplementedError('toJSON()');
}
assertIsValid() {
throw unimplementedError('assertIsValid()');
}
}
_a$1 = DATE_ADAPTER_ID;
DateAdapter.hasTimezoneSupport = false;
function unimplementedError(name) {
return new Error(`You must implement the "${name}" method for this DateAdapter class`);
}
class OccurrenceIterator {
constructor(iterable, args) {
this.iterable = iterable;
this.args = args;
this[Symbol.iterator] = () => this._run();
this.iterator = iterable._run(args);
this.isInfinite = iterable.isInfinite;
}
next(args) {
return this._run(args).next();
}
toArray() {
if (this.args.end || this.args.take || !this.isInfinite) {
return Array.from(this._run());
}
throw new InfiniteLoopError('OccurrenceIterator#toArray() can only be called if the iterator ' +
'is not infinite, or you provide and `end` argument, or you provide ' +
'a `take` argument.');
}
*_run(rawArgs) {
let args = this.normalizeRunArgs(rawArgs);
let date = this.iterator.next(args).value;
while (date) {
const yieldArgs = yield this.normalizeDateOutput(date);
args = this.normalizeRunArgs(yieldArgs);
date = this.iterator.next(args).value;
}
}
normalizeRunArgs(args) {
return {
skipToDate: this.normalizeDateInput(args && args.skipToDate),
};
}
normalizeDateInput(date) {
if (!date) {
return;
}
return DateAdapter.isInstance(date)
? date.set('timezone', this.iterable.timezone).toDateTime()
: new this.iterable.dateAdapter(date).set('timezone', this.iterable.timezone).toDateTime();
}
normalizeDateOutput(date) {
if (!date) {
return;
}
return this.iterable.dateAdapter.fromDateTime(date);
}
}
class CollectionIterator {
constructor(iterable, args) {
this.iterable = iterable;
this.args = args;
this.granularity = 'INSTANTANIOUSLY';
this[Symbol.iterator] = () => this.iterator;
if (args.granularity) {
this.granularity = args.granularity;
}
if (args.weekStart) {
this.weekStart = args.weekStart;
}
if (args.reverse) {
throw new Error('`Calendar#collections()` does not support iterating in reverse. ' +
'Though `Calendar#occurrences()` does support iterating in reverse.');
}
// Set the end arg, if present, to the end of the period.
this.args = {
...args,
start: args.start || iterable._run().next().value,
end: args.end && this.getPeriod(args.end).end,
};
this.startDate =
(this.args.start && this.normalizeDateOutput(this.getPeriod(this.args.start).start)) || null;
this.iterator = this._run();
}
next() {
return this.iterator.next();
}
/**
* While `next()` and `[Symbol.iterator]` both share state,
* `toArray()` does not share state and always returns the whole
* collections array.
*/
toArray() {
if (this.args.end || this.args.take || !this.iterable.isInfinite) {
const collections = [];
for (const collection of this._run()) {
collections.push(collection);
}
return collections;
}
throw new InfiniteLoopError('CollectionIterator#toArray() can only be called if the iterator ' +
'is not infinite, or you provide and `end` argument, or you provide ' +
'a `take` argument.');
}
normalizeDateOutput(date) {
if (!date)
return;
return this.iterable.dateAdapter.fromDateTime(date);
}
*_run() {
if (!this.startDate)
return;
let iterator = this.occurrenceIterator(this.iterable, this.args);
let date = iterator.next().value;
if (!date)
return;
// `period` === `periodStart` unless the granularity
// is `MONTHLY` and a `weekStart` param was provided. In this case,
// period holds a date === the first of the current month while
// periodStart holds a date === the beginning of the first week of the month
// (which might be in the the previous month). Read the
// `Calendar#collections()` description for more info.
let period = this.getPeriod(this.args.start);
let dates = [];
let index = 0;
while (date && (this.args.take === undefined || this.args.take > index)) {
while (date && date.isBeforeOrEqual(period.end)) {
dates.push(date);
date = iterator.next().value;
}
yield new Collection(dates.map(date => this.normalizeDateOutput(date)), this.granularity, this.normalizeDateOutput(period.start), this.normalizeDateOutput(period.end));
if (!date)
return;
dates = [];
period = this.args.incrementLinearly
? this.getPeriod(this.incrementPeriod(period.period))
: this.getPeriod(date);
// With these args, periods may overlap and the same date may show up
// in two periods. Because of this, we need to reset the iterator
// (otherwise it won't spit out a date it has already spit out).
if (this.granularity === 'MONTHLY' && this.weekStart) {
iterator = this.iterable._run({
start: period.start,
end: this.args.end,
});
date = iterator.next().value;
}
index++;
}
}
getPeriod(date) {
const granularity = freqToGranularity(this.granularity);
let start;
let end;
let period;
if (this.granularity === 'MONTHLY' && this.weekStart) {
start = date.granularity('month').granularity('week', { weekStart: this.weekStart });
end = date.endGranularity('month').endGranularity('week', { weekStart: this.weekStart });
period = start;
}
else if (this.granularity === 'WEEKLY') {
if (!this.weekStart) {
throw new ArgumentError('"WEEKLY" granularity requires `weekStart` arg');
}
start = date.granularity('week', { weekStart: this.weekStart });
end = date.endGranularity('week', { weekStart: this.weekStart });
period = start;
}
else {
start = date.granularity(granularity);
end = date.endGranularity(granularity);
period = start;
}
return { start, end, period };
}
incrementPeriod(date) {
switch (this.granularity) {
case 'YEARLY':
return date.add(1, 'year');
case 'MONTHLY':
return date.add(1, 'month');
case 'WEEKLY':
return date.add(1, 'week');
case 'DAILY':
return date.add(1, 'day');
case 'HOURLY':
return date.add(1, 'hour');
case 'MINUTELY':
return date.add(1, 'minute');
case 'SECONDLY':
return date.add(1, 'second');
case 'INSTANTANIOUSLY':
default:
return date.add(1, 'millisecond');
}
}
occurrenceIterator(iterable, args) {
let start = args.start || iterable._run().next().value;
if (!start)
return iterable._run(args);
start = this.getPeriod(start).start;
return iterable._run({
start,
end: args.end,
});
}
}
class Collection {
constructor(dates = [], granularity, periodStart, periodEnd) {
this.dates = dates;
this.granularity = granularity;
this.periodStart = periodStart;
this.periodEnd = periodEnd;
}
}
class PipeError extends Error {
}
class PipeRuleBase {
constructor(args) {
this.start = args.start;
this.end = args.end;
this.options = args.options;
}
}
class PipeRule extends PipeRuleBase {
nextValidDate(args, skipToDate) {
return this.nextPipe.run({
date: args.date,
invalidDate: true,
skipToDate,
});
}
}
/**
* The `FrequencyPipe` is the first pipe in the chain of rule pipes. It is
* responsible for incrementing the date, as appropriate, while taking into
* account the `RRULE` frequency and interval.
*/
class FrequencyPipe extends PipeRule {
constructor() {
super(...arguments);
this.intervalUnit = freqToGranularity(this.options.frequency);
this.intervalStartDate = this.normalizedStartDate(this.start);
this.intervalEndDate = this.normalizedEndDate(this.intervalStartDate);
}
run(args) {
let date = args.date;
// if a date is invalid, skipToDate will always be present
// skipToDate may also be passed by a user on an otherwise valid date
if (args.skipToDate) {
date = args.skipToDate;
this.skipToIntervalOnOrAfter(date);
if (!this.dateIsWithinInterval(date)) {
// this only applies when the interval is not 1
date = this.intervalStartDate;
}
}
else if (this.dateIsWithinInterval(date) &&
this.dateIsWithinInterval(date.add(1, 'millisecond'))) {
date = date.add(1, 'millisecond');
}
else {
this.incrementInterval(this.options.interval);
date = this.intervalStartDate;
}
return this.nextPipe.run({ date });
}
normalizedStartDate(date) {
if (this.options.frequency === 'WEEKLY') {
return date.granularity('week', { weekStart: this.options.weekStart });
}
return date.granularity(this.intervalUnit);
}
normalizedEndDate(start) {
switch (this.options.frequency) {
case 'YEARLY':
return start.add(1, 'year');
case 'MONTHLY':
return start.add(1, 'month');
case 'WEEKLY':
return start.add(1, 'week');
case 'DAILY':
return start.add(1, 'day');
case 'HOURLY':
return start.add(1, 'hour');
case 'MINUTELY':
return start.add(1, 'minute');
case 'SECONDLY':
return start.add(1, 'second');
case 'MILLISECONDLY':
return start.add(1, 'millisecond');
}
}
incrementInterval(amount) {
this.intervalStartDate = this.normalizedStartDate(this.intervalStartDate.add(amount, this.intervalUnit));
this.intervalEndDate = this.normalizedEndDate(this.intervalStartDate);
}
skipToIntervalOnOrAfter(date) {
this.incrementInterval(intervalDifferenceBetweenDates({
first: this.intervalStartDate,
second: date,
unit: this.intervalUnit,
interval: this.options.interval,
weekStart: this.options.weekStart,
}));
}
dateIsWithinInterval(date) {
return this.intervalStartDate.isBeforeOrEqual(date) && this.intervalEndDate.isAfter(date);
}
}
/**
* Given the frequency (unit) and interval, this function finds
* how many jumps forward the first date needs in order to equal
* or exceed the second date.
*
* For example:
*
* 1. Unit is daily and interval is 1. The second date is 3 days
* after the first. This will return 3.
* 2. Unit is yearly and interval is 1. The second date is 3 days
* after the first. This will return 0.
* 3. Unit is yearly and interval is 3. The second date is 4 years
* after the first. This will return 6.
*/
function intervalDifferenceBetweenDates({ first, second, unit, interval, weekStart, }) {
let difference = (() => {
let intervalDuration;
switch (unit) {
case 'year':
let years = (second.get('year') - first.get('year')) * 12;
years = years + second.get('month') - first.get('month');
return Math.floor(years / 12);
case 'month':
let months = (second.get('year') - first.get('year')) * 12;
months = months + second.get('month') - first.get('month');
return months;
case 'week':
first = first.granularity('week', { weekStart });
intervalDuration = MILLISECONDS_IN_WEEK;
break;
case 'day':
intervalDuration = MILLISECONDS_IN_DAY;
break;
case 'hour':
intervalDuration = MILLISECONDS_IN_HOUR;
break;
case 'minute':
intervalDuration = MILLISECONDS_IN_MINUTE;
break;
case 'second':
intervalDuration = MILLISECONDS_IN_SECOND;
break;
case 'millisecond':
intervalDuration = 1;
break;
default:
throw new Error('Unexpected `unit` value');
}
const diff = second.valueOf() - first.valueOf();
const sign = Math.sign(diff);
return Math.floor(Math.abs(diff) / intervalDuration) * sign;
})();
if (difference > 0 && difference < interval) {
difference = interval;
}
else if (difference > interval) {
difference = Math.ceil(difference / interval) * interval;
}
return difference;
}
class ByMonthOfYearPipe extends PipeRule {
run(args) {
if (args.invalidDate) {
return this.nextPipe.run(args);
}
let { date } = args;
const currentMonth = date.get('month');
for (const month of this.options.byMonthOfYear) {
if (currentMonth > month)
continue;
if (currentMonth === month)
return this.nextPipe.run({ date });
return this.nextValidDate(args, date.granularity('year').set('month', month));
}
date = date
.granularity('year')
.add(1, 'year')
.set('month', this.options.byMonthOfYear[0]);
return this.nextValidDate(args, date);
}
}
function getDifferenceBetweenWeekdays(x, y) {
if (x === y)
return 0;
const result = WEEKDAYS.indexOf(x) - WEEKDAYS.indexOf(y);
return result > 0 ? 7 - result : Math.abs(result);
}
function getNextWeekday(date, weekday) {
return date.add(getDifferenceBetweenWeekdays(date.get('weekday'), weekday), 'day');
}
function getPreviousWeekday(date, weekday) {
const diff = getDifferenceBetweenWeekdays(date.get('weekday'), weekday);
return date.subtract(diff === 0 ? 0 : 7 - diff, 'day');
}
function getNthWeekdayOfMonth(date, weekday, nth) {
let base = date.set('day', 1);
if (nth < 0) {
base = base.add(1, 'month');
}
base = getNextWeekday(base, weekday);
// when nth is negative, adding it will act as subtraction
return nth < 0 ? base.add(nth, 'week') : base.add(nth - 1, 'week');
}
function getNthWeekdayOfYear(date, weekday, nth) {
let base = date.set('month', 1).set('day', 1);
if (nth < 0) {
base = base.add(1, 'year');
}
base = getNextWeekday(base, weekday);
// when nth is negative, adding it will act as subtraction
return nth < 0 ? base.add(nth, 'week') : base.add(nth - 1, 'week');
}
class ByDayOfMonthPipe extends PipeRule {
run(args) {
if (args.invalidDate) {
return this.nextPipe.run(args);
}
let { date } = args;
const normalizedByDayOfMonth = normalizeByDayOfMonth(date, this.options.byDayOfMonth, this.options.byDayOfWeek);
const currentDay = date.get('day');
for (const day of normalizedByDayOfMonth) {
if (currentDay > day)
continue;
if (currentDay === day)
return this.nextPipe.run({ date });
return this.nextValidDate(args, date.granularity('month').set('day', day));
}
let next;
let nextMonth = date;
let index = 0;
while (!next && index < 30) {
nextMonth = nextMonth.granularity('month').add(1, 'month');
next = normalizeByDayOfMonth(nextMonth, this.options.byDayOfMonth, this.options.byDayOfWeek)[0];
index++;
}
if (index >= 13) {
throw new PipeError('byDayOfMonth Infinite while loop');
}
date = nextMonth.set('day', next);
return this.nextValidDate(args, date);
}
}
/**
* Does a few things:
*
* 1. filters out byDayOfMonth entries which are not applicable
* to current month
* 2. negative entries to positive ones
* 3. if a byDayOfWeek option is given, removes days which are
* not on the correct day of the week
*/
function normalizeByDayOfMonth(date, byDayOfMonth, byDayOfWeek) {
const lengthOfMonth = date.endGranularity('month').get('day');
let normalizedByDayOfMonth = byDayOfMonth
.filter(day => lengthOfMonth >= Math.abs(day))
.map(day => (day > 0 ? day : lengthOfMonth + day + 1));
if (byDayOfWeek) {
const base = date.granularity('month');
const filteredByDayOfMonth = [];
byDayOfWeek.forEach(entry => {
if (typeof entry === 'string') {
filteredByDayOfMonth.push(...normalizedByDayOfMonth.filter(day => base.set('day', day).get('weekday') === entry));
return;
}
const nthWeekdayOfMonth = getNthWeekdayOfMonth(date, ...entry).get('day');
if (normalizedByDayOfMonth.includes(nthWeekdayOfMonth)) {
filteredByDayOfMonth.push(nthWeekdayOfMonth);
}
});
normalizedByDayOfMonth = Array.from(new Set(filteredByDayOfMonth));
}
return normalizedByDayOfMonth.sort((a, b) => {
if (a > b)
return 1;
if (a < b)
return -1;
else
return 0;
});
}
class ByDayOfWeekPipe extends PipeRule {
run(args) {
if (args.invalidDate) {
return this.nextPipe.run(args);
}
if (this.options.frequency === 'YEARLY') {
return this.options.byMonthOfYear === undefined
? this.expandYearly(args)
: this.expandMonthly(args);
}
else if (this.options.frequency === 'MONTHLY') {
return this.expandMonthly(args);
}
return this.expand(args);
}
expandYearly(args) {
const { date } = args;
let next = getNextWeekdaysOfYear(date, this.options.byDayOfWeek)[0];
let index = 0;
let base = date;
// If we can't find a valid date this year,
// search next year. Only search the next 28 years.
// (the calendar repeats on a 28 year cycle, according
// to the internet).
while (!next && index < 28) {
index++;
base = base.granularity('year').add(1, 'year');
next = getNextWeekdaysOfYear(base, this.options.byDayOfWeek)[0];
}
if (!next) {
throw new PipeError('The byDayOfWeek rule appears to contain an impossible combination');
}
if (next.isEqual(date)) {
return this.nextPipe.run({ date });
}
return this.nextValidDate(args, next.granularity('day'));
}
expandMonthly(args) {
const { date } = args;
let next = getNextWeekdaysOfMonth(date, this.options.byDayOfWeek)[0];
let index = 0;
let base = date;
// TODO: performance improvment
// If, in the first year, a match isn't found, we should be able to
// jumpt to the next leap year and check that. Or, if already on
// a leap year, we can just error immediately.
// If we can't find a valid date this month,
// search the next month. Only search the next 4 years
// (to account for leap year).
while (!next && index < 50) {
index++;
base = base.granularity('month').add(1, 'month');
next = getNextWeekdaysOfMonth(base, this.options.byDayOfWeek)[0];
}
if (!next) {
throw new PipeError('The byDayOfWeek rule appears to contain an impossible combination');
}
if (next.isEqual(date)) {
return this.nextPipe.run({ date });
}
return this.nextValidDate(args, next.granularity('day'));
}
expand(args) {
const { date } = args;
const next = this.options.byDayOfWeek
.map(weekday => getNextWeekday(date, weekday))
.sort(dateTimeSortComparer)[0];
if (next.isEqual(date)) {
return this.nextPipe.run({ date });
}
return this.nextValidDate(args, next.granularity('day'));
}
}
/** For each byDayOfWeek entry, find the next DateTime */
function getNextWeekdaysOfYear(date, byDayOfWeek) {
const normalizedNthWeekdaysOfYear = byDayOfWeek
.filter(entry => Array.isArray(entry))
.map(entry => getNthWeekdayOfYear(date, ...entry));
const normalizedNextWeekdays = byDayOfWeek
.filter(entry => typeof entry === 'string')
.map(weekday => getNextWeekday(date, weekday))
.filter(entry => entry.get('year') === date.get('year'));
return uniqDateTimes([...normalizedNthWeekdaysOfYear, ...normalizedNextWeekdays])
.filter(entry => entry.isAfterOrEqual(date))
.sort(dateTimeSortComparer);
}
/** For each byDayOfWeek entry, find the next DateTime */
function getNextWeekdaysOfMonth(date, byDayOfWeek) {
const normalizedNthWeekdaysOfMonth = byDayOfWeek
.filter(entry => Array.isArray(entry))
.map(entry => getNthWeekdayOfMonth(date, ...entry));
const normalizedNextWeekdays = byDayOfWeek
.filter(entry => typeof entry === 'string')
.map(weekday => getNextWeekday(date, weekday))
.filter(entry => entry.get('year') === date.get('year') && entry.get('month') === date.get('month'));
return uniqDateTimes([...normalizedNthWeekdaysOfMonth, ...normalizedNextWeekdays])
.filter(entry => entry.isAfterOrEqual(date))
.sort(dateTimeSortComparer);
}
class ByHourOfDayPipe extends PipeRule {
run(args) {
if (args.invalidDate) {
return this.nextPipe.run(args);
}
let { date } = args;
const currentHour = date.get('hour');
for (const hour of this.options.byHourOfDay) {
if (currentHour > hour)
continue;
if (currentHour === hour)
return this.nextPipe.run({ date });
return this.nextValidDate(args, date.granularity('day').set('hour', hour));
}
date = date
.granularity('day')
.add(1, 'day')
.set('hour', this.options.byHourOfDay[0]);
return this.nextValidDate(args, date);
}
}
class ByMinuteOfHourPipe extends PipeRule {
run(args) {
if (args.invalidDate) {
return this.nextPipe.run(args);
}
let date = args.date;
const currentMinute = date.get('minute');
for (const minute of this.options.byMinuteOfHour) {
if (currentMinute > minute)
continue;
if (currentMinute === minute)
return this.nextPipe.run({ date });
return this.nextValidDate(args, date.granularity('hour').set('minute', minute));
}
date = date
.granularity('hour')
.add(1, 'hour')
.set('minute', this.options.byMinuteOfHour[0]);
return this.nextValidDate(args, date);
}
}
class BySecondOfMinutePipe extends PipeRule {
run(args) {
if (args.invalidDate) {
return this.nextPipe.run(args);
}
let date = args.date;
const currentSecond = date.get('second');
for (const second of this.options.bySecondOfMinute) {
if (currentSecond > second)
continue;
if (currentSecond === second)
return this.nextPipe.run({ date });
return this.nextValidDate(args, date.granularity('minute').set('second', second));
}
date = date
.granularity('minute')
.add(1, 'minute')
.set('second', this.options.bySecondOfMinute[0]);
return this.nextValidDate(args, date);
}
}
class ResultPipe extends PipeRule {
constructor() {
super(...arguments);
this.invalidIterationCount = 0;
}
// This pipe exists to facilitate the adding of dev callbacks to an iteration
// of the pipe. It is meant to always be the last pipe in the chain.
run(args) {
if (this.end && args.date.isAfter(this.end)) {
return null;
}
if (args.invalidDate) {
// To prevent getting into an infinite loop.
// - I somewhat arbitrarily chose 50
// - I noticed that, when limited to 10 iterations, some tests failed
this.invalidIterationCount++;
if (this.invalidIterationCount > 50) {
throw new PipeError('Failed to find a single matching occurrence in 50 iterations. ' +
`Last iterated date: "${args.date.toISOString()}"`);
}
}
else {
if (this.previousIterationDate && this.previousIterationDate.isAfterOrEqual(args.date)) {
console.error(`Previous run's date is after or equal current run's date of "${args.date.toISOString()}". ` +
'This is probably caused by a bug.');
return null;
}
this.previousIterationDate = args.date;
this.invalidIterationCount = 0;
}
return args.invalidDate ? this.firstPipe.run(args) : args.date;
}
}
class ByMillisecondOfSecondPipe extends PipeRule {
run(args) {
if (args.invalidDate) {
return this.nextPipe.run(args);
}
let date = args.date;
const currentMillisecond = date.get('millisecond');
for (const millisecond of this.options.byMillisecondOfSecond) {
if (currentMillisecond > millisecond)
continue;
if (currentMillisecond === millisecond)
return this.nextPipe.run({ date });
return this.nextValidDate(args, date.granularity('second').set('millisecond', millisecond));
}
date = date
.granularity('second')
.add(1, 'second')
.set('millisecond', this.options.byMillisecondOfSecond[0]);
return this.nextValidDate(args, date);
}
}
/**
* The `RevFrequencyPipe` is the first pipe in the chain of rule pipes. It