UNPKG

19 kBJavaScriptView Raw
1'use strict';
2
3// Load Date class extensions
4var CronDate = require('./date');
5
6// Get Number.isNaN or the polyfill
7var safeIsNaN = require('is-nan');
8
9/**
10 * Cron iteration loop safety limit
11 */
12var LOOP_LIMIT = 10000;
13
14/**
15 * Detect if input range fully matches constraint bounds
16 * @param {Array} range Input range
17 * @param {Array} constraints Input constraints
18 * @returns {Boolean}
19 * @private
20 */
21function isWildcardRange(range, constraints) {
22 if (range instanceof Array && !range.length) {
23 return false;
24 }
25
26 if (constraints.length !== 2) {
27 return false;
28 }
29
30 return range.length === (constraints[1] - (constraints[0] < 1 ? - 1 : 0));
31}
32
33/**
34 * Construct a new expression parser
35 *
36 * Options:
37 * currentDate: iterator start date
38 * endDate: iterator end date
39 *
40 * @constructor
41 * @private
42 * @param {Object} fields Expression fields parsed values
43 * @param {Object} options Parser options
44 */
45function CronExpression (fields, options) {
46 this._options = options;
47 this._utc = options.utc || false;
48 this._tz = this._utc ? 'UTC' : options.tz;
49 this._currentDate = new CronDate(options.currentDate, this._tz);
50 this._startDate = options.startDate ? new CronDate(options.startDate, this._tz) : null;
51 this._endDate = options.endDate ? new CronDate(options.endDate, this._tz) : null;
52 this._fields = fields;
53 this._isIterator = options.iterator || false;
54 this._hasIterated = false;
55}
56
57/**
58 * Field mappings
59 * @type {Array}
60 */
61CronExpression.map = [ 'second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek' ];
62
63/**
64 * Prefined intervals
65 * @type {Object}
66 */
67CronExpression.predefined = {
68 '@yearly': '0 0 1 1 *',
69 '@monthly': '0 0 1 * *',
70 '@weekly': '0 0 * * 0',
71 '@daily': '0 0 * * *',
72 '@hourly': '0 * * * *'
73};
74
75/**
76 * Fields constraints
77 * @type {Array}
78 */
79CronExpression.constraints = [
80 [ 0, 59 ], // Second
81 [ 0, 59 ], // Minute
82 [ 0, 23 ], // Hour
83 [ 1, 31 ], // Day of month
84 [ 1, 12 ], // Month
85 [ 0, 7 ] // Day of week
86];
87
88/**
89 * Days in month
90 * @type {number[]}
91 */
92CronExpression.daysInMonth = [
93 31,
94 29,
95 31,
96 30,
97 31,
98 30,
99 31,
100 31,
101 30,
102 31,
103 30,
104 31
105];
106
107/**
108 * Field aliases
109 * @type {Object}
110 */
111CronExpression.aliases = {
112 month: {
113 jan: 1,
114 feb: 2,
115 mar: 3,
116 apr: 4,
117 may: 5,
118 jun: 6,
119 jul: 7,
120 aug: 8,
121 sep: 9,
122 oct: 10,
123 nov: 11,
124 dec: 12
125 },
126
127 dayOfWeek: {
128 sun: 0,
129 mon: 1,
130 tue: 2,
131 wed: 3,
132 thu: 4,
133 fri: 5,
134 sat: 6
135 }
136};
137
138/**
139 * Field defaults
140 * @type {Array}
141 */
142CronExpression.parseDefaults = [ '0', '*', '*', '*', '*', '*' ];
143
144CronExpression.standardValidCharacters = /^[\d|/|*|\-|,]+$/;
145CronExpression.dayValidCharacters = /^[\d|/|*|\-|,|\?]+$/;
146CronExpression.validCharacters = {
147 second: CronExpression.standardValidCharacters,
148 minute: CronExpression.standardValidCharacters,
149 hour: CronExpression.standardValidCharacters,
150 dayOfMonth: CronExpression.dayValidCharacters,
151 month: CronExpression.standardValidCharacters,
152 dayOfWeek: CronExpression.dayValidCharacters,
153}
154
155/**
156 * Parse input interval
157 *
158 * @param {String} field Field symbolic name
159 * @param {String} value Field value
160 * @param {Array} constraints Range upper and lower constraints
161 * @return {Array} Sequence of sorted values
162 * @private
163 */
164CronExpression._parseField = function _parseField (field, value, constraints) {
165 // Replace aliases
166 switch (field) {
167 case 'month':
168 case 'dayOfWeek':
169 var aliases = CronExpression.aliases[field];
170
171 value = value.replace(/[a-z]{1,3}/gi, function(match) {
172 match = match.toLowerCase();
173
174 if (typeof aliases[match] !== undefined) {
175 return aliases[match];
176 } else {
177 throw new Error('Cannot resolve alias "' + match + '"')
178 }
179 });
180 break;
181 }
182
183 // Check for valid characters.
184 if (!(CronExpression.validCharacters[field].test(value))) {
185 throw new Error('Invalid characters, got value: ' + value)
186 }
187
188 // Replace '*' and '?'
189 if (value.indexOf('*') !== -1) {
190 value = value.replace(/\*/g, constraints.join('-'));
191 } else if (value.indexOf('?') !== -1) {
192 value = value.replace(/\?/g, constraints.join('-'));
193 }
194
195 //
196 // Inline parsing functions
197 //
198 // Parser path:
199 // - parseSequence
200 // - parseRepeat
201 // - parseRange
202
203 /**
204 * Parse sequence
205 *
206 * @param {String} val
207 * @return {Array}
208 * @private
209 */
210 function parseSequence (val) {
211 var stack = [];
212
213 function handleResult (result) {
214 var max = stack.length > 0 ? Math.max.apply(Math, stack) : -1;
215
216 if (result instanceof Array) { // Make sequence linear
217 for (var i = 0, c = result.length; i < c; i++) {
218 var value = result[i];
219
220 // Check constraints
221 if (value < constraints[0] || value > constraints[1]) {
222 throw new Error(
223 'Constraint error, got value ' + value + ' expected range ' +
224 constraints[0] + '-' + constraints[1]
225 );
226 }
227
228 if (value > max) {
229 stack.push(value);
230 }
231
232 max = Math.max.apply(Math, stack);
233 }
234 } else { // Scalar value
235 result = +result;
236
237 // Check constraints
238 if (result < constraints[0] || result > constraints[1]) {
239 throw new Error(
240 'Constraint error, got value ' + result + ' expected range ' +
241 constraints[0] + '-' + constraints[1]
242 );
243 }
244
245 if (field == 'dayOfWeek') {
246 result = result % 7;
247 }
248
249 stack.push(result);
250 }
251 }
252
253 var atoms = val.split(',');
254 if (atoms.length > 1) {
255 for (var i = 0, c = atoms.length; i < c; i++) {
256 handleResult(parseRepeat(atoms[i]));
257 }
258 } else {
259 handleResult(parseRepeat(val));
260 }
261
262 stack.sort(function(a, b) {
263 return a - b;
264 });
265
266 return stack;
267 }
268
269 /**
270 * Parse repetition interval
271 *
272 * @param {String} val
273 * @return {Array}
274 */
275 function parseRepeat (val) {
276 var repeatInterval = 1;
277 var atoms = val.split('/');
278
279 if (atoms.length > 1) {
280 return parseRange(atoms[0], atoms[atoms.length - 1]);
281 }
282
283 return parseRange(val, repeatInterval);
284 }
285
286 /**
287 * Parse range
288 *
289 * @param {String} val
290 * @param {Number} repeatInterval Repetition interval
291 * @return {Array}
292 * @private
293 */
294 function parseRange (val, repeatInterval) {
295 var stack = [];
296 var atoms = val.split('-');
297
298 if (atoms.length > 1 ) {
299 // Invalid range, return value
300 if (atoms.length < 2) {
301 return +val;
302 }
303
304 if (!atoms[0].length) {
305 if (!atoms[1].length) {
306 throw new Error('Invalid range: ' + val);
307 }
308
309 return +val;
310 }
311
312 // Validate range
313 var min = +atoms[0];
314 var max = +atoms[1];
315
316 if (safeIsNaN(min) || safeIsNaN(max) ||
317 min < constraints[0] || max > constraints[1]) {
318 throw new Error(
319 'Constraint error, got range ' +
320 min + '-' + max +
321 ' expected range ' +
322 constraints[0] + '-' + constraints[1]
323 );
324 } else if (min >= max) {
325 throw new Error('Invalid range: ' + val);
326 }
327
328 // Create range
329 var repeatIndex = +repeatInterval;
330
331 if (safeIsNaN(repeatIndex) || repeatIndex <= 0) {
332 throw new Error('Constraint error, cannot repeat at every ' + repeatIndex + ' time.');
333 }
334
335 for (var index = min, count = max; index <= count; index++) {
336 if (repeatIndex > 0 && (repeatIndex % repeatInterval) === 0) {
337 repeatIndex = 1;
338 stack.push(index);
339 } else {
340 repeatIndex++;
341 }
342 }
343
344 return stack;
345 }
346
347 return +val;
348 }
349
350 return parseSequence(value);
351};
352
353CronExpression.prototype._applyTimezoneShift = function(currentDate, dateMathVerb, method) {
354 if ((method === 'Month') || (method === 'Day')) {
355 var prevTime = currentDate.getTime();
356 currentDate[dateMathVerb + method]();
357 var currTime = currentDate.getTime();
358 if (prevTime === currTime) {
359 // Jumped into a not existent date due to a DST transition
360 if ((currentDate.getMinutes() === 0) &&
361 (currentDate.getSeconds() === 0)) {
362 currentDate.addHour();
363 } else if ((currentDate.getMinutes() === 59) &&
364 (currentDate.getSeconds() === 59)) {
365 currentDate.subtractHour();
366 }
367 }
368 } else {
369 var previousHour = currentDate.getHours();
370 currentDate[dateMathVerb + method]();
371 var currentHour = currentDate.getHours();
372 var diff = currentHour - previousHour;
373 if (diff === 2) {
374 // Starting DST
375 if (this._fields.hour.length !== 24) {
376 // Hour is specified
377 this._dstStart = currentHour;
378 }
379 } else if ((diff === 0) &&
380 (currentDate.getMinutes() === 0) &&
381 (currentDate.getSeconds() === 0)) {
382 // Ending DST
383 if (this._fields.hour.length !== 24) {
384 // Hour is specified
385 this._dstEnd = currentHour;
386 }
387 }
388 }
389};
390
391
392/**
393 * Find next or previous matching schedule date
394 *
395 * @return {CronDate}
396 * @private
397 */
398CronExpression.prototype._findSchedule = function _findSchedule (reverse) {
399
400 /**
401 * Match field value
402 *
403 * @param {String} value
404 * @param {Array} sequence
405 * @return {Boolean}
406 * @private
407 */
408 function matchSchedule (value, sequence) {
409 for (var i = 0, c = sequence.length; i < c; i++) {
410 if (sequence[i] >= value) {
411 return sequence[i] === value;
412 }
413 }
414
415 return sequence[0] === value;
416 }
417
418 // Whether to use backwards directionality when searching
419 reverse = reverse || false;
420 var dateMathVerb = reverse ? 'subtract' : 'add';
421
422 var currentDate = new CronDate(this._currentDate, this._tz);
423 var startDate = this._startDate;
424 var endDate = this._endDate;
425
426 // Find matching schedule
427 var startTimestamp = currentDate.getTime();
428 var stepCount = 0;
429
430 while (stepCount < LOOP_LIMIT) {
431 stepCount++;
432
433 // Validate timespan
434 if (reverse) {
435 if (startDate && (currentDate.getTime() - startDate.getTime() < 0)) {
436 throw new Error('Out of the timespan range');
437 }
438 } else {
439 if (endDate && (endDate.getTime() - currentDate.getTime()) < 0) {
440 throw new Error('Out of the timespan range');
441 }
442 }
443
444 // Day of month and week matching:
445 //
446 // "The day of a command's execution can be specified by two fields --
447 // day of month, and day of week. If both fields are restricted (ie,
448 // aren't *), the command will be run when either field matches the cur-
449 // rent time. For example, "30 4 1,15 * 5" would cause a command to be
450 // run at 4:30 am on the 1st and 15th of each month, plus every Friday."
451 //
452 // http://unixhelp.ed.ac.uk/CGI/man-cgi?crontab+5
453 //
454
455 var dayOfMonthMatch = matchSchedule(currentDate.getDate(), this._fields.dayOfMonth);
456 var dayOfWeekMatch = matchSchedule(currentDate.getDay(), this._fields.dayOfWeek);
457
458 var isDayOfMonthWildcardMatch = isWildcardRange(this._fields.dayOfMonth, CronExpression.constraints[3]);
459 var isDayOfWeekWildcardMatch = isWildcardRange(this._fields.dayOfWeek, CronExpression.constraints[5]);
460
461 var currentHour = currentDate.getHours();
462
463 // Add or subtract day if select day not match with month (according to calendar)
464 if (!dayOfMonthMatch && !dayOfWeekMatch) {
465 this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
466 continue;
467 }
468
469 // Add or subtract day if not day of month is set (and no match) and day of week is wildcard
470 if (!isDayOfMonthWildcardMatch && isDayOfWeekWildcardMatch && !dayOfMonthMatch) {
471 this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
472 continue;
473 }
474
475 // Add or subtract day if not day of week is set (and no match) and day of month is wildcard
476 if (isDayOfMonthWildcardMatch && !isDayOfWeekWildcardMatch && !dayOfWeekMatch) {
477 this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
478 continue;
479 }
480
481 // Add or subtract day if day of month and week are non-wildcard values and both doesn't match
482 if (!(isDayOfMonthWildcardMatch && isDayOfWeekWildcardMatch) &&
483 !dayOfMonthMatch && !dayOfWeekMatch) {
484 this._applyTimezoneShift(currentDate, dateMathVerb, 'Day');
485 continue;
486 }
487
488 // Match month
489 if (!matchSchedule(currentDate.getMonth() + 1, this._fields.month)) {
490 this._applyTimezoneShift(currentDate, dateMathVerb, 'Month');
491 continue;
492 }
493
494 // Match hour
495 if (!matchSchedule(currentHour, this._fields.hour)) {
496 if (this._dstStart !== currentHour) {
497 this._dstStart = null;
498 this._applyTimezoneShift(currentDate, dateMathVerb, 'Hour');
499 continue;
500 } else if (!matchSchedule(currentHour - 1, this._fields.hour)) {
501 currentDate[dateMathVerb + 'Hour']();
502 continue;
503 }
504 } else if (this._dstEnd === currentHour) {
505 if (!reverse) {
506 this._dstEnd = null;
507 this._applyTimezoneShift(currentDate, 'add', 'Hour');
508 continue;
509 }
510 }
511
512 // Match minute
513 if (!matchSchedule(currentDate.getMinutes(), this._fields.minute)) {
514 this._applyTimezoneShift(currentDate, dateMathVerb, 'Minute');
515 continue;
516 }
517
518 // Match second
519 if (!matchSchedule(currentDate.getSeconds(), this._fields.second)) {
520 this._applyTimezoneShift(currentDate, dateMathVerb, 'Second');
521 continue;
522 }
523
524 // Increase a second in case in the first iteration the currentDate was not
525 // modified
526 if (startTimestamp === currentDate.getTime()) {
527 if ((dateMathVerb === 'add') || (currentDate.getMilliseconds() === 0)) {
528 this._applyTimezoneShift(currentDate, dateMathVerb, 'Second');
529 } else {
530 currentDate.setMilliseconds(0);
531 }
532
533 continue;
534 }
535
536 break;
537 }
538
539 if (stepCount >= LOOP_LIMIT) {
540 throw new Error('Invalid expression, loop limit exceeded');
541 }
542
543 this._currentDate = new CronDate(currentDate, this._tz);
544 this._hasIterated = true;
545
546 return currentDate;
547};
548
549/**
550 * Find next suitable date
551 *
552 * @public
553 * @return {CronDate|Object}
554 */
555CronExpression.prototype.next = function next () {
556 var schedule = this._findSchedule();
557
558 // Try to return ES6 compatible iterator
559 if (this._isIterator) {
560 return {
561 value: schedule,
562 done: !this.hasNext()
563 };
564 }
565
566 return schedule;
567};
568
569/**
570 * Find previous suitable date
571 *
572 * @public
573 * @return {CronDate|Object}
574 */
575CronExpression.prototype.prev = function prev () {
576 var schedule = this._findSchedule(true);
577
578 // Try to return ES6 compatible iterator
579 if (this._isIterator) {
580 return {
581 value: schedule,
582 done: !this.hasPrev()
583 };
584 }
585
586 return schedule;
587};
588
589/**
590 * Check if next suitable date exists
591 *
592 * @public
593 * @return {Boolean}
594 */
595CronExpression.prototype.hasNext = function() {
596 var current = this._currentDate;
597 var hasIterated = this._hasIterated;
598
599 try {
600 this._findSchedule();
601 return true;
602 } catch (err) {
603 return false;
604 } finally {
605 this._currentDate = current;
606 this._hasIterated = hasIterated;
607 }
608};
609
610/**
611 * Check if previous suitable date exists
612 *
613 * @public
614 * @return {Boolean}
615 */
616CronExpression.prototype.hasPrev = function() {
617 var current = this._currentDate;
618 var hasIterated = this._hasIterated;
619
620 try {
621 this._findSchedule(true);
622 return true;
623 } catch (err) {
624 return false;
625 } finally {
626 this._currentDate = current;
627 this._hasIterated = hasIterated;
628 }
629};
630
631/**
632 * Iterate over expression iterator
633 *
634 * @public
635 * @param {Number} steps Numbers of steps to iterate
636 * @param {Function} callback Optional callback
637 * @return {Array} Array of the iterated results
638 */
639CronExpression.prototype.iterate = function iterate (steps, callback) {
640 var dates = [];
641
642 if (steps >= 0) {
643 for (var i = 0, c = steps; i < c; i++) {
644 try {
645 var item = this.next();
646 dates.push(item);
647
648 // Fire the callback
649 if (callback) {
650 callback(item, i);
651 }
652 } catch (err) {
653 break;
654 }
655 }
656 } else {
657 for (var i = 0, c = steps; i > c; i--) {
658 try {
659 var item = this.prev();
660 dates.push(item);
661
662 // Fire the callback
663 if (callback) {
664 callback(item, i);
665 }
666 } catch (err) {
667 break;
668 }
669 }
670 }
671
672 return dates;
673};
674
675/**
676 * Reset expression iterator state
677 *
678 * @public
679 */
680CronExpression.prototype.reset = function reset () {
681 this._currentDate = new CronDate(this._options.currentDate);
682};
683
684/**
685 * Parse input expression (async)
686 *
687 * @public
688 * @param {String} expression Input expression
689 * @param {Object} [options] Parsing options
690 * @param {Function} [callback]
691 */
692CronExpression.parse = function parse(expression, options, callback) {
693 var self = this;
694 if (typeof options === 'function') {
695 callback = options;
696 options = {};
697 }
698
699 function parse (expression, options) {
700 if (!options) {
701 options = {};
702 }
703
704 if (typeof options.currentDate === 'undefined') {
705 options.currentDate = new CronDate(undefined, self._tz);
706 }
707
708 // Is input expression predefined?
709 if (CronExpression.predefined[expression]) {
710 expression = CronExpression.predefined[expression];
711 }
712
713 // Split fields
714 var fields = [];
715 var atoms = (expression + '').trim().split(/\s+/);
716
717 if (atoms.length > 6) {
718 throw new Error('Invalid cron expression');
719 }
720
721 // Resolve fields
722 var start = (CronExpression.map.length - atoms.length);
723 for (var i = 0, c = CronExpression.map.length; i < c; ++i) {
724 var field = CronExpression.map[i]; // Field name
725 var value = atoms[atoms.length > c ? i : i - start]; // Field value
726
727 if (i < start || !value) {
728 fields.push(CronExpression._parseField(
729 field,
730 CronExpression.parseDefaults[i],
731 CronExpression.constraints[i])
732 );
733 } else { // Use default value
734 fields.push(CronExpression._parseField(
735 field,
736 value,
737 CronExpression.constraints[i])
738 );
739 }
740 }
741
742 var mappedFields = {};
743 for (var i = 0, c = CronExpression.map.length; i < c; i++) {
744 var key = CronExpression.map[i];
745 mappedFields[key] = fields[i];
746 }
747
748 // Validate max daysInMonth value when explicit use
749 if (mappedFields.month.length === 1) {
750 var daysInMonth = CronExpression.daysInMonth[mappedFields.month[0] - 1];
751 var maxDayInMonthValue = Math.max.apply(null, mappedFields.dayOfMonth);
752 var isWildCardDayInMonth = isWildcardRange(mappedFields.dayOfMonth, CronExpression.constraints[3]);
753
754 if (!isWildCardDayInMonth && maxDayInMonthValue > daysInMonth) {
755 throw new Error('Invalid explicit day of month definition');
756 }
757 }
758
759 return new CronExpression(mappedFields, options);
760 }
761
762 return parse(expression, options);
763};
764
765module.exports = CronExpression;