'use strict'; // Load Date class extensions var CronDate = require('./date'); var stringifyField = require('./field_stringify'); /** * Cron iteration loop safety limit */ var LOOP_LIMIT = 10000; /** * 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._isIterator = options.iterator || false; this._hasIterated = false; this._nthDayOfWeek = options.nthDayOfWeek || 0; this.fields = CronExpression._freezeFields(fields); } /** * 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 = [ { min: 0, max: 59, chars: [] }, // Second { min: 0, max: 59, chars: [] }, // Minute { min: 0, max: 23, chars: [] }, // Hour { min: 1, max: 31, chars: ['L'] }, // Day of month { min: 1, max: 12, chars: [] }, // Month { min: 0, max: 7, chars: ['L'] }, // 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.dayOfWeekValidCharacters = /^[?,*\dL#/-]+$/; CronExpression.dayOfMonthValidCharacters = /^[?,*\dL/-]+$/; CronExpression.validCharacters = { second: CronExpression.standardValidCharacters, minute: CronExpression.standardValidCharacters, hour: CronExpression.standardValidCharacters, dayOfMonth: CronExpression.dayOfMonthValidCharacters, month: CronExpression.standardValidCharacters, dayOfWeek: CronExpression.dayOfWeekValidCharacters, }; CronExpression._isValidConstraintChar = function _isValidConstraintChar(constraints, value) { if (typeof value !== 'string') { return false; } return constraints.chars.some(function(char) { return value.indexOf(char) > -1; }); }; /** * 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]{3}/gi, function(match) { match = match.toLowerCase(); if (typeof aliases[match] !== 'undefined') { return aliases[match]; } else { throw new Error('Validation 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.min + '-' + constraints.max); } else if (value.indexOf('?') !== -1) { value = value.replace(/\?/g, constraints.min + '-' + constraints.max); } // // Inline parsing functions // // Parser path: // - parseSequence // - parseRepeat // - parseRange /** * Parse sequence * * @param {String} val * @return {Array} * @private */ function parseSequence (val) { var stack = []; function handleResult (result) { if (result instanceof Array) { // Make sequence linear for (var i = 0, c = result.length; i < c; i++) { var value = result[i]; if (CronExpression._isValidConstraintChar(constraints, value)) { stack.push(value); continue; } // Check constraints if (typeof value !== 'number' || Number.isNaN(value) || value < constraints.min || value > constraints.max) { throw new Error( 'Constraint error, got value ' + value + ' expected range ' + constraints.min + '-' + constraints.max ); } stack.push(value); } } else { // Scalar value if (CronExpression._isValidConstraintChar(constraints, result)) { stack.push(result); return; } var numResult = +result; // Check constraints if (Number.isNaN(numResult) || numResult < constraints.min || numResult > constraints.max) { throw new Error( 'Constraint error, got value ' + result + ' expected range ' + constraints.min + '-' + constraints.max ); } if (field === 'dayOfWeek') { numResult = numResult % 7; } stack.push(numResult); } } var atoms = val.split(','); if (!atoms.every(function (atom) { return atom.length > 0; })) { throw new Error('Invalid list value format'); } if (atoms.length > 1) { for (var i = 0, c = atoms.length; i < c; i++) { handleResult(parseRepeat(atoms[i])); } } else { handleResult(parseRepeat(val)); } stack.sort(CronExpression._sortCompareFn); return stack; } /** * Parse repetition interval * * @param {String} val * @return {Array} */ function parseRepeat (val) { var repeatInterval = 1; var atoms = val.split('/'); if (atoms.length > 2) { throw new Error('Invalid repeat: ' + val); } if (atoms.length > 1) { if (atoms[0] == +atoms[0]) { atoms = [atoms[0] + '-' + constraints.max, atoms[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 (Number.isNaN(min) || Number.isNaN(max) || min < constraints.min || max > constraints.max) { throw new Error( 'Constraint error, got range ' + min + '-' + max + ' expected range ' + constraints.min + '-' + constraints.max ); } else if (min > max) { throw new Error('Invalid range: ' + val); } // Create range var repeatIndex = +repeatInterval; if (Number.isNaN(repeatIndex) || repeatIndex <= 0) { throw new Error('Constraint error, cannot repeat at every ' + repeatIndex + ' time.'); } // JS DOW is in range of 0-6 (SUN-SAT) but we also support 7 in the expression // Handle case when range contains 7 instead of 0 and translate this value to 0 if (field === 'dayOfWeek' && max % 7 === 0) { stack.push(0); } for (var index = min, count = max; index <= count; index++) { var exists = stack.indexOf(index) !== -1; if (!exists && repeatIndex > 0 && (repeatIndex % repeatInterval) === 0) { repeatIndex = 1; stack.push(index); } else { repeatIndex++; } } return stack; } return Number.isNaN(+val) ? val : +val; } return parseSequence(value); }; CronExpression._sortCompareFn = function(a, b) { var aIsNumber = typeof a === 'number'; var bIsNumber = typeof b === 'number'; if (aIsNumber && bIsNumber) { return a - b; } if (!aIsNumber && bIsNumber) { return 1; } if (aIsNumber && !bIsNumber) { return -1; } return a.localeCompare(b); }; CronExpression._handleMaxDaysInMonth = function(mappedFields) { // 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'); } return mappedFields.dayOfMonth .filter(function(dayOfMonth) { return dayOfMonth === 'L' ? true : dayOfMonth <= daysInMonth; }) .sort(CronExpression._sortCompareFn); } }; CronExpression._freezeFields = function(fields) { for (var i = 0, c = CronExpression.map.length; i < c; ++i) { var field = CronExpression.map[i]; // Field name var value = fields[field]; fields[field] = Object.freeze(value); } return Object.freeze(fields); }; 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; } /** * Helper function that checks if 'L' is in the array * * @param {Array} expressions */ function isLInExpressions(expressions) { return expressions.length > 0 && expressions.some(function(expression) { return typeof expression === 'string' && expression.indexOf('L') >= 0; }); } // 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; function isLastWeekdayOfMonthMatch(expressions) { return expressions.some(function(expression) { // There might be multiple expressions and not all of them will contain // the "L". if (!isLInExpressions([expression])) { return false; } // The first character represents the weekday var weekday = Number.parseInt(expression[0]) % 7; if (Number.isNaN(weekday)) { throw new Error('Invalid last weekday of the month expression: ' + expression); } return currentDate.getDay() === weekday && currentDate.isLastWeekdayOfMonth(); }); } 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); if (isLInExpressions(this.fields.dayOfMonth)) { dayOfMonthMatch = dayOfMonthMatch || currentDate.isLastDayOfMonth(); } var dayOfWeekMatch = matchSchedule(currentDate.getDay(), this.fields.dayOfWeek); if (isLInExpressions(this.fields.dayOfWeek)) { dayOfWeekMatch = dayOfWeekMatch || isLastWeekdayOfMonthMatch(this.fields.dayOfWeek); } var isDayOfMonthWildcardMatch = this.fields.dayOfMonth.length >= CronExpression.daysInMonth[currentDate.getMonth()]; var isDayOfWeekWildcardMatch = this.fields.dayOfWeek.length === CronExpression.constraints[5].max - CronExpression.constraints[5].min + 1; var currentHour = currentDate.getHours(); // Add or subtract day if select day not match with month (according to calendar) if (!dayOfMonthMatch && (!dayOfWeekMatch || isDayOfWeekWildcardMatch)) { 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 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); }; /** * Stringify the expression * * @public * @param {Boolean} [includeSeconds] Should stringify seconds * @return {String} */ CronExpression.prototype.stringify = function stringify(includeSeconds) { var resultArr = []; for (var i = includeSeconds ? 0 : 1, c = CronExpression.map.length; i < c; ++i) { var field = CronExpression.map[i]; var value = this.fields[field]; var constraint = CronExpression.constraints[i]; if (field === 'dayOfMonth' && this.fields.month.length === 1) { constraint = { min: 1, max: CronExpression.daysInMonth[this.fields.month[0] - 1] }; } else if (field === 'dayOfWeek') { // Prefer 0-6 range when serializing day of week field constraint = { min: 0, max: 6 }; value = value[value.length - 1] === 7 ? value.slice(0, -1) : value; } resultArr.push(stringifyField(value, constraint.min, constraint.max)); } return resultArr.join(' '); }; /** * Parse input expression (async) * * @public * @param {String} expression Input expression * @param {Object} [options] Parsing options */ CronExpression.parse = function parse(expression, options) { var self = this; if (typeof options === 'function') { 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]; } var dayOfMonth = CronExpression._handleMaxDaysInMonth(mappedFields); mappedFields.dayOfMonth = dayOfMonth || mappedFields.dayOfMonth; 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 || Number.isNaN(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); }; /** * Convert cron fields back to Cron Expression * * @public * @param {Object} fields Input fields * @param {Object} [options] Parsing options * @return {Object} */ CronExpression.fieldsToExpression = function fieldsToExpression(fields, options) { function validateConstraints (field, values, constraints) { if (!values) { throw new Error('Validation error, Field ' + field + ' is missing'); } if (values.length === 0) { throw new Error('Validation error, Field ' + field + ' contains no values'); } for (var i = 0, c = values.length; i < c; i++) { var value = values[i]; if (CronExpression._isValidConstraintChar(constraints, value)) { continue; } // Check constraints if (typeof value !== 'number' || Number.isNaN(value) || value < constraints.min || value > constraints.max) { throw new Error( 'Constraint error, got value ' + value + ' expected range ' + constraints.min + '-' + constraints.max ); } } } var mappedFields = {}; for (var i = 0, c = CronExpression.map.length; i < c; ++i) { var field = CronExpression.map[i]; // Field name var values = fields[field]; validateConstraints( field, values, CronExpression.constraints[i] ); var copy = []; var j = -1; while (++j < values.length) { copy[j] = values[j]; } values = copy.sort(CronExpression._sortCompareFn) .filter(function(item, pos, ary) { return !pos || item !== ary[pos - 1]; }); if (values.length !== copy.length) { throw new Error('Validation error, Field ' + field + ' contains duplicate values'); } mappedFields[field] = values; } var dayOfMonth = CronExpression._handleMaxDaysInMonth(mappedFields); mappedFields.dayOfMonth = dayOfMonth || mappedFields.dayOfMonth; return new CronExpression(mappedFields, options || {}); }; module.exports = CronExpression;