818 lines
24 KiB
JavaScript
818 lines
24 KiB
JavaScript
|
const CONSTRAINTS = [
|
||
|
[0, 59],
|
||
|
[0, 59],
|
||
|
[0, 23],
|
||
|
[1, 31],
|
||
|
[0, 11],
|
||
|
[0, 6]
|
||
|
];
|
||
|
const MONTH_CONSTRAINTS = [
|
||
|
31,
|
||
|
29, // support leap year...not perfect
|
||
|
31,
|
||
|
30,
|
||
|
31,
|
||
|
30,
|
||
|
31,
|
||
|
31,
|
||
|
30,
|
||
|
31,
|
||
|
30,
|
||
|
31
|
||
|
];
|
||
|
const PARSE_DEFAULTS = ['0', '*', '*', '*', '*', '*'];
|
||
|
const ALIASES = {
|
||
|
jan: 0,
|
||
|
feb: 1,
|
||
|
mar: 2,
|
||
|
apr: 3,
|
||
|
may: 4,
|
||
|
jun: 5,
|
||
|
jul: 6,
|
||
|
aug: 7,
|
||
|
sep: 8,
|
||
|
oct: 9,
|
||
|
nov: 10,
|
||
|
dec: 11,
|
||
|
sun: 0,
|
||
|
mon: 1,
|
||
|
tue: 2,
|
||
|
wed: 3,
|
||
|
thu: 4,
|
||
|
fri: 5,
|
||
|
sat: 6
|
||
|
};
|
||
|
const TIME_UNITS = [
|
||
|
'second',
|
||
|
'minute',
|
||
|
'hour',
|
||
|
'dayOfMonth',
|
||
|
'month',
|
||
|
'dayOfWeek'
|
||
|
];
|
||
|
const TIME_UNITS_LEN = TIME_UNITS.length;
|
||
|
const PRESETS = {
|
||
|
'@yearly': '0 0 0 1 0 *',
|
||
|
'@monthly': '0 0 0 1 * *',
|
||
|
'@weekly': '0 0 0 * * 0',
|
||
|
'@daily': '0 0 0 * * *',
|
||
|
'@hourly': '0 0 * * * *',
|
||
|
'@minutely': '0 * * * * *',
|
||
|
'@secondly': '* * * * * *',
|
||
|
'@weekdays': '0 0 0 * * 1-5',
|
||
|
'@weekends': '0 0 0 * * 0,6'
|
||
|
};
|
||
|
const RE_WILDCARDS = /\*/g;
|
||
|
const RE_RANGE = /^(\d+)(?:-(\d+))?(?:\/(\d+))?$/g;
|
||
|
|
||
|
function CronTime(luxon) {
|
||
|
function CT(source, zone, utcOffset) {
|
||
|
this.source = source;
|
||
|
|
||
|
if (zone) {
|
||
|
const dt = luxon.DateTime.fromObject({}, { zone: zone });
|
||
|
if (dt.invalid) {
|
||
|
throw new Error('Invalid timezone.');
|
||
|
}
|
||
|
|
||
|
this.zone = zone;
|
||
|
}
|
||
|
|
||
|
if (typeof utcOffset !== 'undefined') {
|
||
|
this.utcOffset = utcOffset;
|
||
|
}
|
||
|
|
||
|
var that = this;
|
||
|
TIME_UNITS.map(timeUnit => {
|
||
|
that[timeUnit] = {};
|
||
|
});
|
||
|
|
||
|
if (this.source instanceof Date || this.source instanceof luxon.DateTime) {
|
||
|
if (this.source instanceof Date) {
|
||
|
this.source = luxon.DateTime.fromJSDate(this.source);
|
||
|
}
|
||
|
this.realDate = true;
|
||
|
} else {
|
||
|
this._parse(this.source);
|
||
|
this._verifyParse();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
CT.prototype = {
|
||
|
/*
|
||
|
* Ensure that the syntax parsed correctly and correct the specified values if needed.
|
||
|
*/
|
||
|
_verifyParse: function () {
|
||
|
var months = Object.keys(this.month);
|
||
|
var dom = Object.keys(this.dayOfMonth);
|
||
|
var ok = false;
|
||
|
|
||
|
/* if a dayOfMonth is not found in all months, we only need to fix the last
|
||
|
wrong month to prevent infinite loop */
|
||
|
var lastWrongMonth = NaN;
|
||
|
for (var i = 0; i < months.length; i++) {
|
||
|
var m = months[i];
|
||
|
var con = MONTH_CONSTRAINTS[parseInt(m, 10)];
|
||
|
|
||
|
for (var j = 0; j < dom.length; j++) {
|
||
|
var day = dom[j];
|
||
|
if (day <= con) {
|
||
|
ok = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!ok) {
|
||
|
// save the month in order to be fixed if all months fails (infinite loop)
|
||
|
lastWrongMonth = m;
|
||
|
console.warn(`Month '${m}' is limited to '${con}' days.`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// infinite loop detected (dayOfMonth is not found in all months)
|
||
|
if (!ok) {
|
||
|
var notOkCon = MONTH_CONSTRAINTS[parseInt(lastWrongMonth, 10)];
|
||
|
for (var k = 0; k < dom.length; k++) {
|
||
|
var notOkDay = dom[k];
|
||
|
if (notOkDay > notOkCon) {
|
||
|
delete this.dayOfMonth[notOkDay];
|
||
|
var fixedDay = Number(notOkDay) % notOkCon;
|
||
|
this.dayOfMonth[fixedDay] = true;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Calculate the "next" scheduled time
|
||
|
*/
|
||
|
sendAt: function (i) {
|
||
|
var date = this.realDate ? this.source : luxon.DateTime.local();
|
||
|
if (this.zone) {
|
||
|
date = date.setZone(this.zone);
|
||
|
}
|
||
|
|
||
|
if (typeof this.utcOffset !== 'undefined') {
|
||
|
let offset =
|
||
|
this.utcOffset >= 60 || this.utcOffset <= -60
|
||
|
? this.utcOffset / 60
|
||
|
: this.utcOffset;
|
||
|
offset = parseInt(offset);
|
||
|
|
||
|
let utcZone = 'UTC';
|
||
|
if (offset < 0) {
|
||
|
utcZone += offset;
|
||
|
} else if (offset > 0) {
|
||
|
utcZone += `+${offset}`;
|
||
|
}
|
||
|
|
||
|
date = date.setZone(utcZone);
|
||
|
|
||
|
if (date.invalid) {
|
||
|
throw new Error('ERROR: You specified an invalid UTC offset.');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (this.realDate) {
|
||
|
if (luxon.DateTime.local() > date) {
|
||
|
throw new Error('WARNING: Date in past. Will never be fired.');
|
||
|
}
|
||
|
|
||
|
return date;
|
||
|
}
|
||
|
|
||
|
if (isNaN(i) || i < 0) {
|
||
|
// just get the next scheduled time
|
||
|
return this._getNextDateFrom(date);
|
||
|
} else {
|
||
|
// return the next schedule times
|
||
|
var dates = [];
|
||
|
for (; i > 0; i--) {
|
||
|
date = this._getNextDateFrom(date);
|
||
|
dates.push(date);
|
||
|
}
|
||
|
|
||
|
return dates;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Get the number of milliseconds in the future at which to fire our callbacks.
|
||
|
*/
|
||
|
getTimeout: function () {
|
||
|
return Math.max(-1, this.sendAt() - luxon.DateTime.local());
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* writes out a cron string
|
||
|
*/
|
||
|
toString: function () {
|
||
|
return this.toJSON().join(' ');
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Json representation of the parsed cron syntax.
|
||
|
*/
|
||
|
toJSON: function () {
|
||
|
var self = this;
|
||
|
return TIME_UNITS.map(function (timeName) {
|
||
|
return self._wcOrAll(timeName);
|
||
|
});
|
||
|
},
|
||
|
|
||
|
getNextDateFrom: function (start, zone) {
|
||
|
return this._getNextDateFrom(start, zone);
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Get next date matching the specified cron time.
|
||
|
*
|
||
|
* Algorithm:
|
||
|
* - Start with a start date and a parsed crontime.
|
||
|
* - Loop until 5 seconds have passed, or we found the next date.
|
||
|
* - Within the loop:
|
||
|
* - If it took longer than 5 seconds to select a date, throw an exception.
|
||
|
* - Find the next month to run at.
|
||
|
* - Find the next day of the month to run at.
|
||
|
* - Find the next day of the week to run at.
|
||
|
* - Find the next hour to run at.
|
||
|
* - Find the next minute to run at.
|
||
|
* - Find the next second to run at.
|
||
|
* - Check that the chosen time does not equal the current execution.
|
||
|
* - Return the selected date object.
|
||
|
*/
|
||
|
_getNextDateFrom: function (start, zone) {
|
||
|
if (start instanceof Date) {
|
||
|
start = luxon.DateTime.fromJSDate(start);
|
||
|
}
|
||
|
var date = start;
|
||
|
var firstDate = start.toMillis();
|
||
|
if (zone) {
|
||
|
date = date.setZone(zone);
|
||
|
}
|
||
|
if (!this.realDate) {
|
||
|
if (date.millisecond > 0) {
|
||
|
date = date.set({ millisecond: 0, second: date.second + 1 });
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (date.invalid) {
|
||
|
throw new Error('ERROR: You specified an invalid date.');
|
||
|
}
|
||
|
|
||
|
// it shouldn't take more than 5 seconds to find the next execution time
|
||
|
// being very generous with this. Throw error if it takes too long to find the next time to protect from
|
||
|
// infinite loop.
|
||
|
var timeout = Date.now() + 5000;
|
||
|
// determine next date
|
||
|
while (true) {
|
||
|
var diff = date - start;
|
||
|
|
||
|
// hard stop if the current date is after the expected execution
|
||
|
if (Date.now() > timeout) {
|
||
|
throw new Error(
|
||
|
`Something went wrong. It took over five seconds to find the next execution time for the cron job.
|
||
|
Please refer to the canonical issue (https://github.com/kelektiv/node-cron/issues/467) and provide the following string if you would like to help debug:
|
||
|
Time Zone: ${zone || '""'} - Cron String: ${this} - UTC offset: ${date.offset}
|
||
|
- current Date: ${luxon.DateTime.local().toString()}`
|
||
|
);
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
!(date.month - 1 in this.month) &&
|
||
|
Object.keys(this.month).length !== 12
|
||
|
) {
|
||
|
date = date.plus({ months: 1 });
|
||
|
date = date.set({ day: 1, hour: 0, minute: 0, second: 0 });
|
||
|
|
||
|
if (this._forwardDSTJump(0, 0, date)) {
|
||
|
const [done, newDate] = this._findPreviousDSTJump(date);
|
||
|
date = newDate;
|
||
|
if (done) break;
|
||
|
}
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
!(date.day in this.dayOfMonth) &&
|
||
|
Object.keys(this.dayOfMonth).length !== 31 &&
|
||
|
!(
|
||
|
date.getWeekDay() in this.dayOfWeek &&
|
||
|
Object.keys(this.dayOfWeek).length !== 7
|
||
|
)
|
||
|
) {
|
||
|
date = date.plus({ days: 1 });
|
||
|
date = date.set({ hour: 0, minute: 0, second: 0 });
|
||
|
|
||
|
if (this._forwardDSTJump(0, 0, date)) {
|
||
|
const [done, newDate] = this._findPreviousDSTJump(date);
|
||
|
date = newDate;
|
||
|
if (done) break;
|
||
|
}
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
!(date.getWeekDay() in this.dayOfWeek) &&
|
||
|
Object.keys(this.dayOfWeek).length !== 7 &&
|
||
|
!(
|
||
|
date.day in this.dayOfMonth &&
|
||
|
Object.keys(this.dayOfMonth).length !== 31
|
||
|
)
|
||
|
) {
|
||
|
date = date.plus({ days: 1 });
|
||
|
date = date.set({ hour: 0, minute: 0, second: 0 });
|
||
|
if (this._forwardDSTJump(0, 0, date)) {
|
||
|
const [done, newDate] = this._findPreviousDSTJump(date);
|
||
|
date = newDate;
|
||
|
if (done) break;
|
||
|
}
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (!(date.hour in this.hour) && Object.keys(this.hour).length !== 24) {
|
||
|
const expectedHour =
|
||
|
date.hour === 23 && diff > 86400000 ? 0 : date.hour + 1;
|
||
|
const expectedMinute = date.minute; // expect no change.
|
||
|
|
||
|
date = date.set({ hour: expectedHour });
|
||
|
date = date.set({ minute: 0, second: 0 });
|
||
|
|
||
|
// When this is the case, Asking luxon to go forward by 1 hour actually made us go forward by more hours...
|
||
|
// This indicates that somewhere between these two time points, a forward DST adjustment has happened.
|
||
|
// When this happens, the job should be scheduled to execute as though the time has come when the jump is made.
|
||
|
// Therefore, the job should be scheduled on the first tick after the forward jump.
|
||
|
if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
|
||
|
const [done, newDate] = this._findPreviousDSTJump(date);
|
||
|
date = newDate;
|
||
|
if (done) break;
|
||
|
}
|
||
|
// backwards jumps do not seem to have any problems (i.e. double activations),
|
||
|
// so they need not be handled in a similar way.
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
!(date.minute in this.minute) &&
|
||
|
Object.keys(this.minute).length !== 60
|
||
|
) {
|
||
|
const expectedMinute =
|
||
|
date.minute === 59 && diff > 3600000 ? 0 : date.minute + 1;
|
||
|
const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0);
|
||
|
|
||
|
date = date.set({ minute: expectedMinute });
|
||
|
date = date.set({ second: 0 });
|
||
|
|
||
|
// Same case as with hours: DST forward jump.
|
||
|
// This must be accounted for if a minute increment pushed us to a jumping point.
|
||
|
if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
|
||
|
const [done, newDate] = this._findPreviousDSTJump(date);
|
||
|
date = newDate;
|
||
|
if (done) break;
|
||
|
}
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (
|
||
|
!(date.second in this.second) &&
|
||
|
Object.keys(this.second).length !== 60
|
||
|
) {
|
||
|
const expectedSecond =
|
||
|
date.second === 59 && diff > 60000 ? 0 : date.second + 1;
|
||
|
const expectedMinute = date.minute + (expectedSecond === 60);
|
||
|
const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0);
|
||
|
|
||
|
date = date.set({ second: expectedSecond });
|
||
|
|
||
|
// Seconds can cause it too, imagine 21:59:59 -> 23:00:00.
|
||
|
if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
|
||
|
const [done, newDate] = this._findPreviousDSTJump(date);
|
||
|
date = newDate;
|
||
|
if (done) break;
|
||
|
}
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (date.toMillis() === firstDate) {
|
||
|
const expectedSecond = date.second + 1;
|
||
|
const expectedMinute = date.minute + (expectedSecond === 60);
|
||
|
const expectedHour = date.hour + (expectedMinute === 60 ? 1 : 0);
|
||
|
|
||
|
date = date.set({ second: expectedSecond });
|
||
|
|
||
|
// Same as always.
|
||
|
if (this._forwardDSTJump(expectedHour, expectedMinute, date)) {
|
||
|
const [done, newDate] = this._findPreviousDSTJump(date);
|
||
|
date = newDate;
|
||
|
if (done) break;
|
||
|
}
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
return date;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Search backwards in time 1 minute at a time, to detect a DST forward jump.
|
||
|
* When the jump is found, the range of the jump is investigated to check for acceptable cron times.
|
||
|
*
|
||
|
* A pair is returned, whose first is a boolean representing if an acceptable time was found inside the jump,
|
||
|
* and whose second is a DateTime representing the first millisecond after the jump.
|
||
|
*
|
||
|
* The input date is expected to be decently close to a DST jump.
|
||
|
* Up to a day in the past is checked before an error is thrown.
|
||
|
* @param date
|
||
|
* @return [boolean, DateTime]
|
||
|
*/
|
||
|
_findPreviousDSTJump: function (date) {
|
||
|
/** @type number */
|
||
|
let expectedMinute, expectedHour, actualMinute, actualHour;
|
||
|
/** @type DateTime */
|
||
|
let maybeJumpingPoint = date;
|
||
|
|
||
|
// representing one day of backwards checking. If this is hit, the input must be wrong.
|
||
|
const iterationLimit = 60 * 24;
|
||
|
let iteration = 0;
|
||
|
do {
|
||
|
if (++iteration > iterationLimit) {
|
||
|
throw new Error(
|
||
|
`ERROR: This DST checking related function assumes the input DateTime (${date.toISO()}) is within 24 hours of a DST jump.`
|
||
|
);
|
||
|
}
|
||
|
|
||
|
expectedMinute = maybeJumpingPoint.minute - 1;
|
||
|
expectedHour = maybeJumpingPoint.hour;
|
||
|
|
||
|
if (expectedMinute < 0) {
|
||
|
expectedMinute += 60;
|
||
|
expectedHour = (expectedHour + 24 - 1) % 24; // Subtract 1 hour, but we must account for the -1 case.
|
||
|
}
|
||
|
|
||
|
maybeJumpingPoint = maybeJumpingPoint.minus({ minute: 1 });
|
||
|
|
||
|
actualMinute = maybeJumpingPoint.minute;
|
||
|
actualHour = maybeJumpingPoint.hour;
|
||
|
} while (expectedMinute === actualMinute && expectedHour === actualHour);
|
||
|
|
||
|
// Setting the seconds and milliseconds to zero is necessary for two reasons:
|
||
|
// Firstly, the range checking function needs the earliest moment after the jump.
|
||
|
// Secondly, this DateTime may be used for scheduling jobs, if there existed a job in the skipped range.
|
||
|
const afterJumpingPoint = maybeJumpingPoint
|
||
|
.plus({ minute: 1 }) // back to the first minute _after_ the jump
|
||
|
.set({ seconds: 0, millisecond: 0 });
|
||
|
|
||
|
// Get the lower bound of the range to check as well. This only has to be accurate down to minutes.
|
||
|
const beforeJumpingPoint = afterJumpingPoint.minus({ second: 1 });
|
||
|
|
||
|
if (
|
||
|
date.month in this.month &&
|
||
|
date.day in this.dayOfMonth &&
|
||
|
date.getWeekDay() in this.dayOfWeek
|
||
|
) {
|
||
|
return [
|
||
|
this._checkTimeInSkippedRange(beforeJumpingPoint, afterJumpingPoint),
|
||
|
afterJumpingPoint
|
||
|
];
|
||
|
}
|
||
|
|
||
|
// no valid time in the range for sure, units that didn't change from the skip mismatch.
|
||
|
return [false, afterJumpingPoint];
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Given 2 DateTimes, which represent 1 second before and immediately after a DST forward jump,
|
||
|
* checks if a time in the skipped range would have been a valid CronJob time.
|
||
|
*
|
||
|
* Could technically work with just one of these values, extracting the other by adding or subtracting seconds.
|
||
|
* However, this couples the input DateTime to actually being tied to a DST jump,
|
||
|
* which would make the function harder to test.
|
||
|
* This way the logic just tests a range of minutes and hours, regardless if there are skipped time points underneath.
|
||
|
*
|
||
|
* Assumes the DST jump started no earlier than 0:00 and jumped forward by at least 1 minute, to at most 23:59.
|
||
|
* i.e. The day is assumed constant, but the jump is not assumed to be an hour long.
|
||
|
* Empirically, it is almost always one hour, but very, very rarely 30 minutes.
|
||
|
*
|
||
|
* Assumes dayOfWeek, dayOfMonth and month match all match, so only the hours, minutes and seconds are to be checked.
|
||
|
* @param {DateTime} beforeJumpingPoint
|
||
|
* @param {DateTime} afterJumpingPoint
|
||
|
* @returns {boolean}
|
||
|
*/
|
||
|
_checkTimeInSkippedRange: function (beforeJumpingPoint, afterJumpingPoint) {
|
||
|
// start by getting the first minute & hour inside the skipped range.
|
||
|
const startingMinute = (beforeJumpingPoint.minute + 1) % 60;
|
||
|
const startingHour =
|
||
|
(beforeJumpingPoint.hour + (startingMinute === 0)) % 24;
|
||
|
|
||
|
const hourRangeSize = afterJumpingPoint.hour - startingHour + 1;
|
||
|
const isHourJump = startingMinute === 0 && afterJumpingPoint.minute === 0;
|
||
|
|
||
|
// There exist DST jumps other than 1 hour long, and the function is built to deal with it.
|
||
|
// It may be overkill to assume some cases, but it shouldn't cost much at runtime.
|
||
|
// https://en.wikipedia.org/wiki/Daylight_saving_time_by_country
|
||
|
if (hourRangeSize === 2 && isHourJump) {
|
||
|
// Exact 1 hour jump, most common real-world case.
|
||
|
// There is no need to check minutes and seconds, as any value would suffice.
|
||
|
return startingHour in this.hour;
|
||
|
} else if (hourRangeSize === 1) {
|
||
|
// less than 1 hour jump, rare but does exist.
|
||
|
return (
|
||
|
startingHour in this.hour &&
|
||
|
this._checkTimeInSkippedRangeSingleHour(
|
||
|
startingMinute,
|
||
|
afterJumpingPoint.minute
|
||
|
)
|
||
|
);
|
||
|
} else {
|
||
|
// non-round or multi-hour jump. (does not exist in the real world at the time of writing)
|
||
|
return this._checkTimeInSkippedRangeMultiHour(
|
||
|
startingHour,
|
||
|
startingMinute,
|
||
|
afterJumpingPoint.hour,
|
||
|
afterJumpingPoint.minute
|
||
|
);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Component of checking if a CronJob time existed in a DateTime range skipped by DST.
|
||
|
* This subroutine makes a further assumption that the skipped range is fully contained in one hour,
|
||
|
* and that all other larger units are valid for the job.
|
||
|
*
|
||
|
* for example a jump from 02:00:00 to 02:30:00, but not from 02:00:00 to 03:00:00.
|
||
|
* @see _checkTimeInSkippedRange
|
||
|
*
|
||
|
* This is done by checking if any minute in startMinute - endMinute is valid, excluding endMinute.
|
||
|
* For endMinute, there is only a match if the 0th second is a valid time.
|
||
|
*/
|
||
|
_checkTimeInSkippedRangeSingleHour: function (startMinute, endMinute) {
|
||
|
for (let minute = startMinute; minute < endMinute; ++minute) {
|
||
|
if (minute in this.minute) return true;
|
||
|
}
|
||
|
|
||
|
// Unless the very last second of the jump matched, there is no match.
|
||
|
return endMinute in this.minute && 0 in this.second;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Component of checking if a CronJob time existed in a DateTime range skipped by DST.
|
||
|
* This subroutine assumes the jump touches at least 2 hours, but the jump does not necessarily fully contain these hours.
|
||
|
*
|
||
|
* @see _checkTimeInSkippedRange
|
||
|
*
|
||
|
* This is done by defining the minutes to check for the first and last hour,
|
||
|
* and checking all 60 minutes for any hours in between them.
|
||
|
*
|
||
|
* If any hour x minute combination is a valid time, true is returned.
|
||
|
* The endMinute x endHour combination is only checked with the 0th second, since the rest would be out of the range.
|
||
|
*
|
||
|
* @param startHour {number}
|
||
|
* @param startMinute {number}
|
||
|
* @param endHour {number}
|
||
|
* @param endMinute {number}
|
||
|
*/
|
||
|
_checkTimeInSkippedRangeMultiHour: function (
|
||
|
startHour,
|
||
|
startMinute,
|
||
|
endHour,
|
||
|
endMinute
|
||
|
) {
|
||
|
if (startHour >= endHour) {
|
||
|
throw new Error(
|
||
|
`ERROR: This DST checking related function assumes the forward jump starting hour (${startHour}) is less than the end hour (${endHour})`
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/** @type number[] */
|
||
|
const firstHourMinuteRange = Array.from(
|
||
|
{ length: 60 - startMinute },
|
||
|
(_, k) => startMinute + k
|
||
|
);
|
||
|
/** @type {number[]} The final minute is not contained on purpose. Every minute in this range represents one for which any second is valid. */
|
||
|
const lastHourMinuteRange = Array.from(
|
||
|
{ length: endMinute },
|
||
|
(_, k) => k
|
||
|
);
|
||
|
/** @type number[] */
|
||
|
const middleHourMinuteRange = Array.from({ length: 60 }, (_, k) => k);
|
||
|
|
||
|
/** @type (number) => number[] */
|
||
|
const selectRange = forHour => {
|
||
|
if (forHour === startHour) {
|
||
|
return firstHourMinuteRange;
|
||
|
} else if (forHour === endHour) {
|
||
|
return lastHourMinuteRange;
|
||
|
} else {
|
||
|
return middleHourMinuteRange;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Include the endHour: Selecting the right range still ensures no values outside the skip are checked.
|
||
|
for (let hour = startHour; hour <= endHour; ++hour) {
|
||
|
if (!(hour in this.hour)) continue;
|
||
|
|
||
|
// The hour matches, so if the minute is in the range, we have a match!
|
||
|
const usingRange = selectRange(hour);
|
||
|
|
||
|
for (const minute of usingRange) {
|
||
|
// All minutes in any of the selected ranges represent minutes which are fully contained in the jump,
|
||
|
// So we need not check the seconds. If the minute is in there, it is a match.
|
||
|
if (minute in this.minute) return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// The endMinute of the endHour was not checked in the loop, because only the 0th second of it is in the range.
|
||
|
// Arriving here means no match was found yet, but this final check may turn up as a match.
|
||
|
return (
|
||
|
endHour in this.hour && endMinute in this.minute && 0 in this.second
|
||
|
);
|
||
|
},
|
||
|
/**
|
||
|
* Given expected and actual hours and minutes, report if a DST forward jump occurred.
|
||
|
*
|
||
|
* This is the case when the expected is smaller than the acutal.
|
||
|
*
|
||
|
* It is not sufficient to check only hours, because some parts of the world apply DST by shifting in minutes.
|
||
|
* Better to account for it by checking minutes too, before an Australian of Lord Howe Island call us.
|
||
|
* @param expectedHour
|
||
|
* @param expectedMinute
|
||
|
* @param {DateTime} actualDate
|
||
|
*/
|
||
|
_forwardDSTJump: function (expectedHour, expectedMinute, actualDate) {
|
||
|
const actualHour = actualDate.hour;
|
||
|
const actualMinute = actualDate.minute;
|
||
|
|
||
|
const hoursJumped = expectedHour % 24 < actualHour;
|
||
|
const minutesJumped = expectedMinute % 60 < actualMinute;
|
||
|
|
||
|
return hoursJumped || minutesJumped;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* wildcard, or all params in array (for to string)
|
||
|
*/
|
||
|
_wcOrAll: function (type) {
|
||
|
if (this._hasAll(type)) {
|
||
|
return '*';
|
||
|
}
|
||
|
|
||
|
var all = [];
|
||
|
for (var time in this[type]) {
|
||
|
all.push(time);
|
||
|
}
|
||
|
|
||
|
return all.join(',');
|
||
|
},
|
||
|
|
||
|
_hasAll: function (type) {
|
||
|
var constraints = CONSTRAINTS[TIME_UNITS.indexOf(type)];
|
||
|
|
||
|
for (var i = constraints[0], n = constraints[1]; i < n; i++) {
|
||
|
if (!(i in this[type])) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* Parse the cron syntax into something useful for selecting the next execution time.
|
||
|
*
|
||
|
* Algorithm:
|
||
|
* - Replace preset
|
||
|
* - Replace aliases in the source.
|
||
|
* - Trim string and split for processing.
|
||
|
* - Loop over split options (ms -> month):
|
||
|
* - Get the value (or default) in the current position.
|
||
|
* - Parse the value.
|
||
|
*/
|
||
|
_parse: function (source) {
|
||
|
source = source.toLowerCase();
|
||
|
|
||
|
if (source in PRESETS) {
|
||
|
source = PRESETS[source];
|
||
|
}
|
||
|
|
||
|
source = source.replace(/[a-z]{1,3}/gi, alias => {
|
||
|
if (alias in ALIASES) {
|
||
|
return ALIASES[alias];
|
||
|
}
|
||
|
|
||
|
throw new Error(`Unknown alias: ${alias}`);
|
||
|
});
|
||
|
|
||
|
var units = source.trim().split(/\s+/);
|
||
|
|
||
|
// seconds are optional
|
||
|
if (units.length < TIME_UNITS_LEN - 1) {
|
||
|
throw new Error('Too few fields');
|
||
|
}
|
||
|
|
||
|
if (units.length > TIME_UNITS_LEN) {
|
||
|
throw new Error('Too many fields');
|
||
|
}
|
||
|
|
||
|
var unitsLen = units.length;
|
||
|
for (var i = 0; i < TIME_UNITS_LEN; i++) {
|
||
|
// If the split source string doesn't contain all digits,
|
||
|
// assume defaults for first n missing digits.
|
||
|
// This adds support for 5-digit standard cron syntax
|
||
|
var cur = units[i - (TIME_UNITS_LEN - unitsLen)] || PARSE_DEFAULTS[i];
|
||
|
this._parseField(cur, TIME_UNITS[i], CONSTRAINTS[i]);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/*
|
||
|
* Parse individual field from the cron syntax provided.
|
||
|
*
|
||
|
* Algorithm:
|
||
|
* - Split field by commas aand check for wildcards to ensure proper user.
|
||
|
* - Replace wildcard values with <low>-<high> boundaries.
|
||
|
* - Split field by commas and then iterate over ranges inside field.
|
||
|
* - If range matches pattern then map over matches using replace (to parse the range by the regex pattern)
|
||
|
* - Starting with the lower bounds of the range iterate by step up to the upper bounds and toggle the CronTime field value flag on.
|
||
|
*/
|
||
|
_parseField: function (value, type, constraints) {
|
||
|
var typeObj = this[type];
|
||
|
var pointer;
|
||
|
var low = constraints[0];
|
||
|
var high = constraints[1];
|
||
|
|
||
|
var fields = value.split(',');
|
||
|
fields.forEach(field => {
|
||
|
var wildcardIndex = field.indexOf('*');
|
||
|
if (wildcardIndex !== -1 && wildcardIndex !== 0) {
|
||
|
throw new Error(
|
||
|
`Field (${field}) has an invalid wildcard expression`
|
||
|
);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// * is a shortcut to [low-high] range for the field
|
||
|
value = value.replace(RE_WILDCARDS, `${low}-${high}`);
|
||
|
|
||
|
// commas separate information, so split based on those
|
||
|
var allRanges = value.split(',');
|
||
|
|
||
|
for (var i = 0; i < allRanges.length; i++) {
|
||
|
if (allRanges[i].match(RE_RANGE)) {
|
||
|
allRanges[i].replace(RE_RANGE, ($0, lower, upper, step) => {
|
||
|
lower = parseInt(lower, 10);
|
||
|
upper = parseInt(upper, 10) || undefined;
|
||
|
|
||
|
const wasStepDefined = !isNaN(parseInt(step, 10));
|
||
|
if (step === '0') {
|
||
|
throw new Error(`Field (${type}) has a step of zero`);
|
||
|
}
|
||
|
step = parseInt(step, 10) || 1;
|
||
|
|
||
|
if (upper && lower > upper) {
|
||
|
throw new Error(`Field (${type}) has an invalid range`);
|
||
|
}
|
||
|
|
||
|
const outOfRangeError =
|
||
|
lower < low ||
|
||
|
(upper && upper > high) ||
|
||
|
(!upper && lower > high);
|
||
|
|
||
|
if (outOfRangeError) {
|
||
|
throw new Error(`Field value (${value}) is out of range`);
|
||
|
}
|
||
|
|
||
|
// Positive integer higher than constraints[0]
|
||
|
lower = Math.min(Math.max(low, ~~Math.abs(lower)), high);
|
||
|
|
||
|
// Positive integer lower than constraints[1]
|
||
|
if (upper) {
|
||
|
upper = Math.min(high, ~~Math.abs(upper));
|
||
|
} else {
|
||
|
// If step is provided, the default upper range is the highest value
|
||
|
upper = wasStepDefined ? high : lower;
|
||
|
}
|
||
|
|
||
|
// Count from the lower barrier to the upper
|
||
|
pointer = lower;
|
||
|
|
||
|
do {
|
||
|
typeObj[pointer] = true; // mutates the field objects values inside CronTime
|
||
|
pointer += step;
|
||
|
} while (pointer <= upper);
|
||
|
});
|
||
|
} else {
|
||
|
throw new Error(`Field (${type}) cannot be parsed`);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
return CT;
|
||
|
}
|
||
|
|
||
|
module.exports = CronTime;
|