123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840 |
- 'use strict';
- // Load Date class extensions
- var CronDate = require('./date');
- // Get Number.isNaN or the polyfill
- var safeIsNaN = require('is-nan');
- /**
- * Cron iteration loop safety limit
- */
- var LOOP_LIMIT = 10000;
- /**
- * Detect if input range fully matches constraint bounds
- * @param {Array} range Input range
- * @param {Array} constraints Input constraints
- * @returns {Boolean}
- * @private
- */
- function isWildcardRange(range, constraints) {
- if (range instanceof Array && !range.length) {
- return false;
- }
- if (constraints.length !== 2) {
- return false;
- }
- return range.length === (constraints[1] - (constraints[0] < 1 ? - 1 : 0));
- }
- /**
- * Construct a new expression parser
- *
- * Options:
- * currentDate: iterator start date
- * endDate: iterator end date
- *
- * @constructor
- * @private
- * @param {Object} fields Expression fields parsed values
- * @param {Object} options Parser options
- */
- function CronExpression (fields, options) {
- this._options = options;
- this._utc = options.utc || false;
- this._tz = this._utc ? 'UTC' : options.tz;
- this._currentDate = new CronDate(options.currentDate, this._tz);
- this._startDate = options.startDate ? new CronDate(options.startDate, this._tz) : null;
- this._endDate = options.endDate ? new CronDate(options.endDate, this._tz) : null;
- this._fields = fields;
- this._isIterator = options.iterator || false;
- this._hasIterated = false;
- this._nthDayOfWeek = options.nthDayOfWeek || 0;
- }
- /**
- * Field mappings
- * @type {Array}
- */
- CronExpression.map = [ 'second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek' ];
- /**
- * Prefined intervals
- * @type {Object}
- */
- CronExpression.predefined = {
- '@yearly': '0 0 1 1 *',
- '@monthly': '0 0 1 * *',
- '@weekly': '0 0 * * 0',
- '@daily': '0 0 * * *',
- '@hourly': '0 * * * *'
- };
- /**
- * Fields constraints
- * @type {Array}
- */
- CronExpression.constraints = [
- [ 0, 59 ], // Second
- [ 0, 59 ], // Minute
- [ 0, 23 ], // Hour
- [ 1, 31 ], // Day of month
- [ 1, 12 ], // Month
- [ 0, 7 ] // Day of week
- ];
- /**
- * Days in month
- * @type {number[]}
- */
- CronExpression.daysInMonth = [
- 31,
- 29,
- 31,
- 30,
- 31,
- 30,
- 31,
- 31,
- 30,
- 31,
- 30,
- 31
- ];
- /**
- * Field aliases
- * @type {Object}
- */
- CronExpression.aliases = {
- month: {
- jan: 1,
- feb: 2,
- mar: 3,
- apr: 4,
- may: 5,
- jun: 6,
- jul: 7,
- aug: 8,
- sep: 9,
- oct: 10,
- nov: 11,
- dec: 12
- },
- dayOfWeek: {
- sun: 0,
- mon: 1,
- tue: 2,
- wed: 3,
- thu: 4,
- fri: 5,
- sat: 6
- }
- };
- /**
- * Field defaults
- * @type {Array}
- */
- CronExpression.parseDefaults = [ '0', '*', '*', '*', '*', '*' ];
- CronExpression.standardValidCharacters = /^[\d|/|*|\-|,]+$/;
- CronExpression.dayValidCharacters = /^[\d|/|*|\-|,|\?]+$/;
- CronExpression.validCharacters = {
- second: CronExpression.standardValidCharacters,
- minute: CronExpression.standardValidCharacters,
- hour: CronExpression.standardValidCharacters,
- dayOfMonth: CronExpression.dayValidCharacters,
- month: CronExpression.standardValidCharacters,
- dayOfWeek: CronExpression.dayValidCharacters,
- }
- /**
- * Parse input interval
- *
- * @param {String} field Field symbolic name
- * @param {String} value Field value
- * @param {Array} constraints Range upper and lower constraints
- * @return {Array} Sequence of sorted values
- * @private
- */
- CronExpression._parseField = function _parseField (field, value, constraints) {
- // Replace aliases
- switch (field) {
- case 'month':
- case 'dayOfWeek':
- var aliases = CronExpression.aliases[field];
- value = value.replace(/[a-z]{1,3}/gi, function(match) {
- match = match.toLowerCase();
- if (typeof aliases[match] !== undefined) {
- return aliases[match];
- } else {
- throw new Error('Cannot resolve alias "' + match + '"')
- }
- });
- break;
- }
- // Check for valid characters.
- if (!(CronExpression.validCharacters[field].test(value))) {
- throw new Error('Invalid characters, got value: ' + value)
- }
- // Replace '*' and '?'
- if (value.indexOf('*') !== -1) {
- value = value.replace(/\*/g, constraints.join('-'));
- } else if (value.indexOf('?') !== -1) {
- value = value.replace(/\?/g, constraints.join('-'));
- }
- //
- // Inline parsing functions
- //
- // Parser path:
- // - parseSequence
- // - parseRepeat
- // - parseRange
- /**
- * Parse sequence
- *
- * @param {String} val
- * @return {Array}
- * @private
- */
- function parseSequence (val) {
- var stack = [];
- function handleResult (result) {
- var max = stack.length > 0 ? Math.max.apply(Math, stack) : -1;
- if (result instanceof Array) { // Make sequence linear
- for (var i = 0, c = result.length; i < c; i++) {
- var value = result[i];
- // Check constraints
- if (value < constraints[0] || value > constraints[1]) {
- throw new Error(
- 'Constraint error, got value ' + value + ' expected range ' +
- constraints[0] + '-' + constraints[1]
- );
- }
- if (value > max) {
- stack.push(value);
- }
- max = Math.max.apply(Math, stack);
- }
- } else { // Scalar value
- result = +result;
- // Check constraints
- if (result < constraints[0] || result > constraints[1]) {
- throw new Error(
- 'Constraint error, got value ' + result + ' expected range ' +
- constraints[0] + '-' + constraints[1]
- );
- }
- if (field == 'dayOfWeek') {
- result = result % 7;
- }
- stack.push(result);
- }
- }
- var atoms = val.split(',');
- if (atoms.length > 1) {
- for (var i = 0, c = atoms.length; i < c; i++) {
- handleResult(parseRepeat(atoms[i]));
- }
- } else {
- handleResult(parseRepeat(val));
- }
- stack.sort(function(a, b) {
- return a - b;
- });
- return stack;
- }
- /**
- * Parse repetition interval
- *
- * @param {String} val
- * @return {Array}
- */
- function parseRepeat (val) {
- var repeatInterval = 1;
- var atoms = val.split('/');
- if (atoms.length > 1) {
- return parseRange(atoms[0], atoms[atoms.length - 1]);
- }
- return parseRange(val, repeatInterval);
- }
- /**
- * Parse range
- *
- * @param {String} val
- * @param {Number} repeatInterval Repetition interval
- * @return {Array}
- * @private
- */
- function parseRange (val, repeatInterval) {
- var stack = [];
- var atoms = val.split('-');
- if (atoms.length > 1 ) {
- // Invalid range, return value
- if (atoms.length < 2) {
- return +val;
- }
- if (!atoms[0].length) {
- if (!atoms[1].length) {
- throw new Error('Invalid range: ' + val);
- }
- return +val;
- }
- // Validate range
- var min = +atoms[0];
- var max = +atoms[1];
- if (safeIsNaN(min) || safeIsNaN(max) ||
- min < constraints[0] || max > constraints[1]) {
- throw new Error(
- 'Constraint error, got range ' +
- min + '-' + max +
- ' expected range ' +
- constraints[0] + '-' + constraints[1]
- );
- } else if (min >= max) {
- throw new Error('Invalid range: ' + val);
- }
- // Create range
- var repeatIndex = +repeatInterval;
- if (safeIsNaN(repeatIndex) || repeatIndex <= 0) {
- throw new Error('Constraint error, cannot repeat at every ' + repeatIndex + ' time.');
- }
- for (var index = min, count = max; index <= count; index++) {
- if (repeatIndex > 0 && (repeatIndex % repeatInterval) === 0) {
- repeatIndex = 1;
- stack.push(index);
- } else {
- repeatIndex++;
- }
- }
- return stack;
- }
- return +val;
- }
- return parseSequence(value);
- };
- CronExpression.prototype._applyTimezoneShift = function(currentDate, dateMathVerb, method) {
- if ((method === 'Month') || (method === 'Day')) {
- var prevTime = currentDate.getTime();
- currentDate[dateMathVerb + method]();
- var currTime = currentDate.getTime();
- if (prevTime === currTime) {
- // Jumped into a not existent date due to a DST transition
- if ((currentDate.getMinutes() === 0) &&
- (currentDate.getSeconds() === 0)) {
- currentDate.addHour();
- } else if ((currentDate.getMinutes() === 59) &&
- (currentDate.getSeconds() === 59)) {
- currentDate.subtractHour();
- }
- }
- } else {
- var previousHour = currentDate.getHours();
- currentDate[dateMathVerb + method]();
- var currentHour = currentDate.getHours();
- var diff = currentHour - previousHour;
- if (diff === 2) {
- // Starting DST
- if (this._fields.hour.length !== 24) {
- // Hour is specified
- this._dstStart = currentHour;
- }
- } else if ((diff === 0) &&
- (currentDate.getMinutes() === 0) &&
- (currentDate.getSeconds() === 0)) {
- // Ending DST
- if (this._fields.hour.length !== 24) {
- // Hour is specified
- this._dstEnd = currentHour;
- }
- }
- }
- };
- /**
- * Find next or previous matching schedule date
- *
- * @return {CronDate}
- * @private
- */
- CronExpression.prototype._findSchedule = function _findSchedule (reverse) {
- /**
- * Match field value
- *
- * @param {String} value
- * @param {Array} sequence
- * @return {Boolean}
- * @private
- */
- function matchSchedule (value, sequence) {
- for (var i = 0, c = sequence.length; i < c; i++) {
- if (sequence[i] >= value) {
- return sequence[i] === value;
- }
- }
- return sequence[0] === value;
- }
- /**
- * Helps determine if the provided date is the correct nth occurence of the
- * desired day of week.
- *
- * @param {CronDate} date
- * @param {Number} nthDayOfWeek
- * @return {Boolean}
- * @private
- */
- function isNthDayMatch(date, nthDayOfWeek) {
- if (nthDayOfWeek < 6) {
- if (
- date.getDate() < 8 &&
- nthDayOfWeek === 1 // First occurence has to happen in first 7 days of the month
- ) {
- return true;
- }
- var offset = date.getDate() % 7 ? 1 : 0; // Math is off by 1 when dayOfWeek isn't divisible by 7
- var adjustedDate = date.getDate() - (date.getDate() % 7); // find the first occurance
- var occurrence = Math.floor(adjustedDate / 7) + offset;
- return occurrence === nthDayOfWeek;
- }
- return false;
- }
- // Whether to use backwards directionality when searching
- reverse = reverse || false;
- var dateMathVerb = reverse ? 'subtract' : 'add';
- var currentDate = new CronDate(this._currentDate, this._tz);
- var startDate = this._startDate;
- var endDate = this._endDate;
- // Find matching schedule
- var startTimestamp = currentDate.getTime();
- var stepCount = 0;
- while (stepCount < LOOP_LIMIT) {
- stepCount++;
- // Validate timespan
- if (reverse) {
- if (startDate && (currentDate.getTime() - startDate.getTime() < 0)) {
- throw new Error('Out of the timespan range');
- }
- } else {
- if (endDate && (endDate.getTime() - currentDate.getTime()) < 0) {
- throw new Error('Out of the timespan range');
- }
- }
- // Day of month and week matching:
- //
- // "The day of a command's execution can be specified by two fields --
- // day of month, and day of week. If both fields are restricted (ie,
- // aren't *), the command will be run when either field matches the cur-
- // rent time. For example, "30 4 1,15 * 5" would cause a command to be
- // run at 4:30 am on the 1st and 15th of each month, plus every Friday."
- //
- // http://unixhelp.ed.ac.uk/CGI/man-cgi?crontab+5
- //
- var dayOfMonthMatch = matchSchedule(currentDate.getDate(), this._fields.dayOfMonth);
- var dayOfWeekMatch = matchSchedule(currentDate.getDay(), this._fields.dayOfWeek);
- var isDayOfMonthWildcardMatch = isWildcardRange(this._fields.dayOfMonth, CronExpression.constraints[3]);
- var isDayOfWeekWildcardMatch = isWildcardRange(this._fields.dayOfWeek, CronExpression.constraints[5]);
- var currentHour = currentDate.getHours();
- // Add or subtract day if select day not match with month (according to calendar)
- if (!dayOfMonthMatch && !dayOfWeekMatch) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
- continue;
- }
- // Add or subtract day if not day of month is set (and no match) and day of week is wildcard
- if (!isDayOfMonthWildcardMatch && isDayOfWeekWildcardMatch && !dayOfMonthMatch) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
- continue;
- }
- // Add or subtract day if not day of week is set (and no match) and day of month is wildcard
- if (isDayOfMonthWildcardMatch && !isDayOfWeekWildcardMatch && !dayOfWeekMatch) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
- continue;
- }
- // Add or subtract day if day of month and week are non-wildcard values and both doesn't match
- if (!(isDayOfMonthWildcardMatch && isDayOfWeekWildcardMatch) &&
- !dayOfMonthMatch && !dayOfWeekMatch) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
- continue;
- }
- // Add or subtract day if day of week & nthDayOfWeek are set (and no match)
- if (
- this._nthDayOfWeek > 0 &&
- !isNthDayMatch(currentDate, this._nthDayOfWeek)
- ) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
- continue;
- }
- // Match month
- if (!matchSchedule(currentDate.getMonth() + 1, this._fields.month)) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Month');
- continue;
- }
- // Match hour
- if (!matchSchedule(currentHour, this._fields.hour)) {
- if (this._dstStart !== currentHour) {
- this._dstStart = null;
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Hour');
- continue;
- } else if (!matchSchedule(currentHour - 1, this._fields.hour)) {
- currentDate[dateMathVerb + 'Hour']();
- continue;
- }
- } else if (this._dstEnd === currentHour) {
- if (!reverse) {
- this._dstEnd = null;
- this._applyTimezoneShift(currentDate, 'add', 'Hour');
- continue;
- }
- }
- // Match minute
- if (!matchSchedule(currentDate.getMinutes(), this._fields.minute)) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Minute');
- continue;
- }
- // Match second
- if (!matchSchedule(currentDate.getSeconds(), this._fields.second)) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Second');
- continue;
- }
- // Increase a second in case in the first iteration the currentDate was not
- // modified
- if (startTimestamp === currentDate.getTime()) {
- if ((dateMathVerb === 'add') || (currentDate.getMilliseconds() === 0)) {
- this._applyTimezoneShift(currentDate, dateMathVerb, 'Second');
- } else {
- currentDate.setMilliseconds(0);
- }
- continue;
- }
- break;
- }
- if (stepCount >= LOOP_LIMIT) {
- throw new Error('Invalid expression, loop limit exceeded');
- }
- this._currentDate = new CronDate(currentDate, this._tz);
- this._hasIterated = true;
- return currentDate;
- };
- /**
- * Find next suitable date
- *
- * @public
- * @return {CronDate|Object}
- */
- CronExpression.prototype.next = function next () {
- var schedule = this._findSchedule();
- // Try to return ES6 compatible iterator
- if (this._isIterator) {
- return {
- value: schedule,
- done: !this.hasNext()
- };
- }
- return schedule;
- };
- /**
- * Find previous suitable date
- *
- * @public
- * @return {CronDate|Object}
- */
- CronExpression.prototype.prev = function prev () {
- var schedule = this._findSchedule(true);
- // Try to return ES6 compatible iterator
- if (this._isIterator) {
- return {
- value: schedule,
- done: !this.hasPrev()
- };
- }
- return schedule;
- };
- /**
- * Check if next suitable date exists
- *
- * @public
- * @return {Boolean}
- */
- CronExpression.prototype.hasNext = function() {
- var current = this._currentDate;
- var hasIterated = this._hasIterated;
- try {
- this._findSchedule();
- return true;
- } catch (err) {
- return false;
- } finally {
- this._currentDate = current;
- this._hasIterated = hasIterated;
- }
- };
- /**
- * Check if previous suitable date exists
- *
- * @public
- * @return {Boolean}
- */
- CronExpression.prototype.hasPrev = function() {
- var current = this._currentDate;
- var hasIterated = this._hasIterated;
- try {
- this._findSchedule(true);
- return true;
- } catch (err) {
- return false;
- } finally {
- this._currentDate = current;
- this._hasIterated = hasIterated;
- }
- };
- /**
- * Iterate over expression iterator
- *
- * @public
- * @param {Number} steps Numbers of steps to iterate
- * @param {Function} callback Optional callback
- * @return {Array} Array of the iterated results
- */
- CronExpression.prototype.iterate = function iterate (steps, callback) {
- var dates = [];
- if (steps >= 0) {
- for (var i = 0, c = steps; i < c; i++) {
- try {
- var item = this.next();
- dates.push(item);
- // Fire the callback
- if (callback) {
- callback(item, i);
- }
- } catch (err) {
- break;
- }
- }
- } else {
- for (var i = 0, c = steps; i > c; i--) {
- try {
- var item = this.prev();
- dates.push(item);
- // Fire the callback
- if (callback) {
- callback(item, i);
- }
- } catch (err) {
- break;
- }
- }
- }
- return dates;
- };
- /**
- * Reset expression iterator state
- *
- * @public
- */
- CronExpression.prototype.reset = function reset (newDate) {
- this._currentDate = new CronDate(newDate || this._options.currentDate);
- };
- /**
- * Parse input expression (async)
- *
- * @public
- * @param {String} expression Input expression
- * @param {Object} [options] Parsing options
- * @param {Function} [callback]
- */
- CronExpression.parse = function parse(expression, options, callback) {
- var self = this;
- if (typeof options === 'function') {
- callback = options;
- options = {};
- }
- function parse (expression, options) {
- if (!options) {
- options = {};
- }
- if (typeof options.currentDate === 'undefined') {
- options.currentDate = new CronDate(undefined, self._tz);
- }
- // Is input expression predefined?
- if (CronExpression.predefined[expression]) {
- expression = CronExpression.predefined[expression];
- }
- // Split fields
- var fields = [];
- var atoms = (expression + '').trim().split(/\s+/);
- if (atoms.length > 6) {
- throw new Error('Invalid cron expression');
- }
- // Resolve fields
- var start = (CronExpression.map.length - atoms.length);
- for (var i = 0, c = CronExpression.map.length; i < c; ++i) {
- var field = CronExpression.map[i]; // Field name
- var value = atoms[atoms.length > c ? i : i - start]; // Field value
- if (i < start || !value) { // Use default value
- fields.push(CronExpression._parseField(
- field,
- CronExpression.parseDefaults[i],
- CronExpression.constraints[i])
- );
- } else {
- var val = field === 'dayOfWeek' ? parseNthDay(value) : value;
- fields.push(CronExpression._parseField(
- field,
- val,
- CronExpression.constraints[i])
- );
- }
- }
- var mappedFields = {};
- for (var i = 0, c = CronExpression.map.length; i < c; i++) {
- var key = CronExpression.map[i];
- mappedFields[key] = fields[i];
- }
- // Filter out any day of month value that is larger than given month expects
- if (mappedFields.month.length === 1) {
- var daysInMonth = CronExpression.daysInMonth[mappedFields.month[0] - 1];
- if (mappedFields.dayOfMonth[0] > daysInMonth) {
- throw new Error('Invalid explicit day of month definition');
- }
- mappedFields.dayOfMonth = mappedFields.dayOfMonth.filter(function(dayOfMonth) {
- return dayOfMonth <= daysInMonth;
- });
- }
- return new CronExpression(mappedFields, options);
- /**
- * Parses out the # special character for the dayOfWeek field & adds it to options.
- *
- * @param {String} val
- * @return {String}
- * @private
- */
- function parseNthDay(val) {
- var atoms = val.split('#');
- if (atoms.length > 1) {
- var nthValue = +atoms[atoms.length - 1];
- if(/,/.test(val)) {
- throw new Error('Constraint error, invalid dayOfWeek `#` and `,` '
- + 'special characters are incompatible');
- }
- if(/\//.test(val)) {
- throw new Error('Constraint error, invalid dayOfWeek `#` and `/` '
- + 'special characters are incompatible');
- }
- if(/-/.test(val)) {
- throw new Error('Constraint error, invalid dayOfWeek `#` and `-` '
- + 'special characters are incompatible');
- }
- if (atoms.length > 2 || safeIsNaN(nthValue) || (nthValue < 1 || nthValue > 5)) {
- throw new Error('Constraint error, invalid dayOfWeek occurrence number (#)');
- }
- options.nthDayOfWeek = nthValue;
- return atoms[0];
- }
- return val;
- }
- }
- return parse(expression, options);
- };
- module.exports = CronExpression;
|