expression.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. 'use strict';
  2. // Load Date class extensions
  3. var CronDate = require('./date');
  4. /**
  5. * Construct a new expression parser
  6. *
  7. * Options:
  8. * currentDate: iterator start date
  9. * endDate: iterator end date
  10. *
  11. * @constructor
  12. * @private
  13. * @param {Object} fields Expression fields parsed values
  14. * @param {Object} options Parser options
  15. */
  16. function CronExpression (fields, options) {
  17. this._options = options;
  18. this._currentDate = new CronDate(options.currentDate);
  19. this._endDate = options.endDate ? new CronDate(options.endDate) : null;
  20. this._fields = {};
  21. this._isIterator = options.iterator || false;
  22. this._hasIterated = false;
  23. this._utc = options.utc || false;
  24. // Map fields
  25. for (var i = 0, c = CronExpression.map.length; i < c; i++) {
  26. var key = CronExpression.map[i];
  27. this._fields[key] = fields[i];
  28. }
  29. }
  30. /**
  31. * Field mappings
  32. * @type {Array}
  33. */
  34. CronExpression.map = [ 'second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek' ];
  35. /**
  36. * Prefined intervals
  37. * @type {Object}
  38. */
  39. CronExpression.predefined = {
  40. '@yearly': '0 0 1 1 *',
  41. '@monthly': '0 0 1 * *',
  42. '@weekly': '0 0 * * 0',
  43. '@daily': '0 0 * * *',
  44. '@hourly': '0 * * * *'
  45. };
  46. /**
  47. * Fields constraints
  48. * @type {Array}
  49. */
  50. CronExpression.constraints = [
  51. [ 0, 59 ], // Second
  52. [ 0, 59 ], // Minute
  53. [ 0, 23 ], // Hour
  54. [ 1, 31 ], // Day of month
  55. [ 1, 12 ], // Month
  56. [ 0, 7 ] // Day of week
  57. ];
  58. /**
  59. * Days in month
  60. * @type {number[]}
  61. */
  62. CronExpression.daysInMonth = [
  63. 31,
  64. 28,
  65. 31,
  66. 30,
  67. 31,
  68. 30,
  69. 31,
  70. 31,
  71. 30,
  72. 31,
  73. 30,
  74. 31
  75. ];
  76. /**
  77. * Field aliases
  78. * @type {Object}
  79. */
  80. CronExpression.aliases = {
  81. month: {
  82. jan: 1,
  83. feb: 2,
  84. mar: 3,
  85. apr: 4,
  86. may: 5,
  87. jun: 6,
  88. jul: 7,
  89. aug: 8,
  90. sep: 9,
  91. oct: 10,
  92. nov: 11,
  93. dec: 12
  94. },
  95. dayOfWeek: {
  96. sun: 0,
  97. mon: 1,
  98. tue: 2,
  99. wed: 3,
  100. thu: 4,
  101. fri: 5,
  102. sat: 6
  103. }
  104. };
  105. /**
  106. * Field defaults
  107. * @type {Array}
  108. */
  109. CronExpression.parseDefaults = [ '0', '*', '*', '*', '*', '*' ];
  110. /**
  111. * Parse input interval
  112. *
  113. * @param {String} field Field symbolic name
  114. * @param {String} value Field value
  115. * @param {Array} constraints Range upper and lower constraints
  116. * @return {Array} Sequence of sorted values
  117. * @private
  118. */
  119. CronExpression._parseField = function _parseField (field, value, constraints) {
  120. // Replace aliases
  121. switch (field) {
  122. case 'month':
  123. case 'dayOfWeek':
  124. var aliases = CronExpression.aliases[field];
  125. value = value.replace(/[a-z]{1,3}/gi, function(match) {
  126. match = match.toLowerCase();
  127. if (typeof aliases[match] !== undefined) {
  128. return aliases[match];
  129. } else {
  130. throw new Error('Cannot resolve alias "' + match + '"')
  131. }
  132. });
  133. break;
  134. }
  135. // Check for valid characters.
  136. if (!(/^[\d|/|*|\-|,]+$/.test(value))) {
  137. throw new Error('Invalid characters, got value: ' + value)
  138. }
  139. // Replace '*'
  140. if (value.indexOf('*') !== -1) {
  141. value = value.replace(/\*/g, constraints.join('-'));
  142. }
  143. //
  144. // Inline parsing functions
  145. //
  146. // Parser path:
  147. // - parseSequence
  148. // - parseRepeat
  149. // - parseRange
  150. /**
  151. * Parse sequence
  152. *
  153. * @param {String} val
  154. * @return {Array}
  155. * @private
  156. */
  157. function parseSequence (val) {
  158. var stack = [];
  159. function handleResult (result) {
  160. var max = stack.length > 0 ? Math.max.apply(Math, stack) : -1;
  161. if (result instanceof Array) { // Make sequence linear
  162. for (var i = 0, c = result.length; i < c; i++) {
  163. var value = result[i];
  164. // Check constraints
  165. if (value < constraints[0] || value > constraints[1]) {
  166. throw new Error(
  167. 'Constraint error, got value ' + value + ' expected range ' +
  168. constraints[0] + '-' + constraints[1]
  169. );
  170. }
  171. if (value > max) {
  172. stack.push(value);
  173. }
  174. max = Math.max.apply(Math, stack);
  175. }
  176. } else { // Scalar value
  177. result = +result;
  178. // Check constraints
  179. if (result < constraints[0] || result > constraints[1]) {
  180. throw new Error(
  181. 'Constraint error, got value ' + result + ' expected range ' +
  182. constraints[0] + '-' + constraints[1]
  183. );
  184. }
  185. if (field == 'dayOfWeek') {
  186. result = result % 7;
  187. }
  188. if (result > max) {
  189. stack.push(result);
  190. }
  191. }
  192. }
  193. var atoms = val.split(',');
  194. if (atoms.length > 1) {
  195. for (var i = 0, c = atoms.length; i < c; i++) {
  196. handleResult(parseRepeat(atoms[i]));
  197. }
  198. } else {
  199. handleResult(parseRepeat(val));
  200. }
  201. return stack;
  202. }
  203. /**
  204. * Parse repetition interval
  205. *
  206. * @param {String} val
  207. * @return {Array}
  208. */
  209. function parseRepeat (val) {
  210. var repeatInterval = 1;
  211. var atoms = val.split('/');
  212. if (atoms.length > 1) {
  213. return parseRange(atoms[0], atoms[atoms.length - 1]);
  214. }
  215. return parseRange(val, repeatInterval);
  216. }
  217. /**
  218. * Parse range
  219. *
  220. * @param {String} val
  221. * @param {Number} repeatInterval Repetition interval
  222. * @return {Array}
  223. * @private
  224. */
  225. function parseRange (val, repeatInterval) {
  226. var stack = [];
  227. var atoms = val.split('-');
  228. if (atoms.length > 1 ) {
  229. // Invalid range, return value
  230. if (atoms.length < 2 || !atoms[0].length) {
  231. return +val;
  232. }
  233. // Validate range
  234. var min = +atoms[0];
  235. var max = +atoms[1];
  236. if (Number.isNaN(min) || Number.isNaN(max) ||
  237. min < constraints[0] || max > constraints[1]) {
  238. throw new Error(
  239. 'Constraint error, got range ' +
  240. min + '-' + max +
  241. ' expected range ' +
  242. constraints[0] + '-' + constraints[1]
  243. );
  244. } else if (min >= max) {
  245. throw new Error('Invalid range: ' + val);
  246. }
  247. // Create range
  248. var repeatIndex = +repeatInterval;
  249. if (Number.isNaN(repeatIndex) || repeatIndex <= 0) {
  250. throw new Error('Constraint error, cannot repeat at every ' + repeatIndex + ' time.');
  251. }
  252. for (var index = min, count = max; index <= count; index++) {
  253. if (repeatIndex > 0 && (repeatIndex % repeatInterval) === 0) {
  254. repeatIndex = 1;
  255. stack.push(index);
  256. } else {
  257. repeatIndex++;
  258. }
  259. }
  260. return stack;
  261. }
  262. return +val;
  263. }
  264. return parseSequence(value);
  265. };
  266. /**
  267. * Find next matching schedule date
  268. *
  269. * @return {CronDate}
  270. * @private
  271. */
  272. CronExpression.prototype._findSchedule = function _findSchedule () {
  273. /**
  274. * Match field value
  275. *
  276. * @param {String} value
  277. * @param {Array} sequence
  278. * @return {Boolean}
  279. * @private
  280. */
  281. function matchSchedule (value, sequence) {
  282. for (var i = 0, c = sequence.length; i < c; i++) {
  283. if (sequence[i] >= value) {
  284. return sequence[i] === value;
  285. }
  286. }
  287. return sequence[0] === value;
  288. }
  289. /**
  290. * Detect if input range fully matches constraint bounds
  291. * @param {Array} range Input range
  292. * @param {Array} constraints Input constraints
  293. * @returns {Boolean}
  294. * @private
  295. */
  296. function isWildcardRange (range, constraints) {
  297. if (range instanceof Array && !range.length) {
  298. return false;
  299. }
  300. if (constraints.length !== 2) {
  301. return false;
  302. }
  303. return range.length === (constraints[1] - (constraints[0] < 1 ? - 1 : 0));
  304. }
  305. var method = function(name) {
  306. return !this._utc ? name : ('getUTC' + name.slice(3));
  307. }.bind(this);
  308. var currentDate = new CronDate(this._currentDate);
  309. var endDate = this._endDate;
  310. // TODO: Improve this part
  311. // Always increment second value when second part is present
  312. if (this._fields.second.length > 1 && !this._hasIterated) {
  313. currentDate.addSecond();
  314. }
  315. // Find matching schedule
  316. while (true) {
  317. // Validate timespan
  318. if (endDate && (endDate.getTime() - currentDate.getTime()) < 0) {
  319. throw new Error('Out of the timespan range');
  320. }
  321. // Day of month and week matching:
  322. //
  323. // "The day of a command's execution can be specified by two fields --
  324. // day of month, and day of week. If both fields are restricted (ie,
  325. // aren't *), the command will be run when either field matches the cur-
  326. // rent time. For example, "30 4 1,15 * 5" would cause a command to be
  327. // run at 4:30 am on the 1st and 15th of each month, plus every Friday."
  328. //
  329. // http://unixhelp.ed.ac.uk/CGI/man-cgi?crontab+5
  330. //
  331. var dayOfMonthMatch = matchSchedule(currentDate[method('getDate')](), this._fields.dayOfMonth);
  332. var dayOfWeekMatch = matchSchedule(currentDate[method('getDay')](), this._fields.dayOfWeek);
  333. var isDayOfMonthWildcardMatch = isWildcardRange(this._fields.dayOfMonth, CronExpression.constraints[3]);
  334. var isMonthWildcardMatch = isWildcardRange(this._fields.month, CronExpression.constraints[4]);
  335. var isDayOfWeekWildcardMatch = isWildcardRange(this._fields.dayOfWeek, CronExpression.constraints[5]);
  336. // Validate days in month if explicit value is given
  337. if (!isMonthWildcardMatch) {
  338. var currentYear = currentDate[method('getFullYear')]();
  339. var currentMonth = currentDate[method('getMonth')]() + 1;
  340. var previousMonth = currentMonth === 1 ? 11 : currentMonth - 1;
  341. var daysInPreviousMonth = CronExpression.daysInMonth[previousMonth - 1];
  342. var daysOfMontRangeMax = this._fields.dayOfMonth[this._fields.dayOfMonth.length - 1];
  343. var _daysInPreviousMonth = daysInPreviousMonth;
  344. var _daysOfMontRangeMax = daysOfMontRangeMax;
  345. // Handle leap year
  346. var isLeap = !((currentYear % 4) || (!(currentYear % 100) && (currentYear % 400)));
  347. if (isLeap) {
  348. _daysInPreviousMonth = 29;
  349. _daysOfMontRangeMax = 29;
  350. }
  351. if (this._fields.month[0] === previousMonth && _daysInPreviousMonth < _daysOfMontRangeMax) {
  352. throw new Error('Invalid explicit day of month definition');
  353. }
  354. }
  355. // Add day if select day not match with month (according to calendar)
  356. if (!dayOfMonthMatch || !dayOfWeekMatch) {
  357. currentDate.addDay();
  358. continue;
  359. }
  360. // Add day if not day of month is set (and no match) and day of week is wildcard
  361. if (!isDayOfMonthWildcardMatch && isDayOfWeekWildcardMatch && !dayOfMonthMatch) {
  362. currentDate.addDay();
  363. continue;
  364. }
  365. // Add day if not day of week is set (and no match) and day of month is wildcard
  366. if (isDayOfMonthWildcardMatch && !isDayOfWeekWildcardMatch && !dayOfWeekMatch) {
  367. currentDate.addDay();
  368. continue;
  369. }
  370. // Add day if day of mont and week are non-wildcard values and both doesn't match
  371. if (!(isDayOfMonthWildcardMatch && isDayOfWeekWildcardMatch) &&
  372. !dayOfMonthMatch && !dayOfWeekMatch) {
  373. currentDate.addDay();
  374. continue;
  375. }
  376. // Match month
  377. if (!matchSchedule(currentDate[method('getMonth')]() + 1, this._fields.month)) {
  378. currentDate.addMonth();
  379. continue;
  380. }
  381. // Match hour
  382. if (!matchSchedule(currentDate[method('getHours')](), this._fields.hour)) {
  383. currentDate.addHour();
  384. continue;
  385. }
  386. // Match minute
  387. if (!matchSchedule(currentDate[method('getMinutes')](), this._fields.minute)) {
  388. currentDate.addMinute();
  389. continue;
  390. }
  391. // Match second
  392. if (!matchSchedule(currentDate[method('getSeconds')](), this._fields.second)) {
  393. currentDate.addSecond();
  394. continue;
  395. }
  396. break;
  397. }
  398. // When internal date is not mutated, append one second as a padding
  399. var nextDate = new CronDate(currentDate);
  400. if (this._currentDate !== currentDate) {
  401. nextDate.addSecond();
  402. }
  403. this._currentDate = nextDate;
  404. this._hasIterated = true;
  405. return currentDate;
  406. };
  407. /**
  408. * Find next suitable date
  409. *
  410. * @public
  411. * @return {CronDate|Object}
  412. */
  413. CronExpression.prototype.next = function next () {
  414. var schedule = this._findSchedule();
  415. // Try to return ES6 compatible iterator
  416. if (this._isIterator) {
  417. return {
  418. value: schedule,
  419. done: !this.hasNext()
  420. };
  421. }
  422. return schedule;
  423. };
  424. /**
  425. * Check if next suitable date exists
  426. *
  427. * @public
  428. * @return {Boolean}
  429. */
  430. CronExpression.prototype.hasNext = function() {
  431. var current = this._currentDate;
  432. try {
  433. this.next();
  434. return true;
  435. } catch (err) {
  436. return false;
  437. } finally {
  438. this._currentDate = current;
  439. }
  440. };
  441. /**
  442. * Iterate over expression iterator
  443. *
  444. * @public
  445. * @param {Number} steps Numbers of steps to iterate
  446. * @param {Function} callback Optional callback
  447. * @return {Array} Array of the iterated results
  448. */
  449. CronExpression.prototype.iterate = function iterate (steps, callback) {
  450. var dates = [];
  451. for (var i = 0, c = steps; i < c; i++) {
  452. try {
  453. var item = this.next();
  454. dates.push(item);
  455. // Fire the callback
  456. if (callback) {
  457. callback(item, i);
  458. }
  459. } catch (err) {
  460. break;
  461. }
  462. }
  463. return dates;
  464. };
  465. /**
  466. * Reset expression iterator state
  467. *
  468. * @public
  469. */
  470. CronExpression.prototype.reset = function reset () {
  471. this._currentDate = new CronDate(this._options.currentDate);
  472. };
  473. /**
  474. * Parse input expression (async)
  475. *
  476. * @public
  477. * @param {String} expression Input expression
  478. * @param {Object} [options] Parsing options
  479. * @param {Function} [callback]
  480. */
  481. CronExpression.parse = function parse (expression, options, callback) {
  482. if (typeof options === 'function') {
  483. callback = options;
  484. options = {};
  485. }
  486. function parse (expression, options) {
  487. if (!options) {
  488. options = {};
  489. }
  490. if (!options.currentDate) {
  491. options.currentDate = new CronDate();
  492. }
  493. // Is input expression predefined?
  494. if (CronExpression.predefined[expression]) {
  495. expression = CronExpression.predefined[expression];
  496. }
  497. // Split fields
  498. var fields = [];
  499. var atoms = expression.split(' ');
  500. // Resolve fields
  501. var start = (CronExpression.map.length - atoms.length);
  502. for (var i = 0, c = CronExpression.map.length; i < c; ++i) {
  503. var field = CronExpression.map[i]; // Field name
  504. var value = atoms[atoms.length > c ? i : i - start]; // Field value
  505. if (i < start || !value) {
  506. fields.push(CronExpression._parseField(
  507. field,
  508. CronExpression.parseDefaults[i],
  509. CronExpression.constraints[i])
  510. );
  511. } else { // Use default value
  512. fields.push(CronExpression._parseField(
  513. field,
  514. value,
  515. CronExpression.constraints[i])
  516. );
  517. }
  518. }
  519. return new CronExpression(fields, options);
  520. }
  521. return parse(expression, options);
  522. };
  523. module.exports = CronExpression;