515 lines
18 KiB
JavaScript
515 lines
18 KiB
JavaScript
|
|
||
|
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Given a cronspec, return the human-readable string.
|
||
|
* @param {string} cronspec
|
||
|
* @param withSeconds
|
||
|
* @param {Object=} locale
|
||
|
*/
|
||
|
function cronToText(cronspec, withSeconds, locale) {
|
||
|
'use strict';
|
||
|
|
||
|
// Constant array to convert valid names to values
|
||
|
var NAMES = {
|
||
|
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8,
|
||
|
SEP: 9, OCT: 10, NOV: 11, DEC: 12,
|
||
|
SUN: 1, MON: 2, TUE: 3, WED: 4, THU: 5, FRI: 6, SAT: 7
|
||
|
};
|
||
|
|
||
|
// Parsable replacements for common expressions
|
||
|
var REPLACEMENTS = {
|
||
|
'* * * * * *': '0/1 * * * * *',
|
||
|
'@YEARLY': '0 0 1 1 *',
|
||
|
'@ANNUALLY': '0 0 1 1 *',
|
||
|
'@MONTHLY': '0 0 1 * *',
|
||
|
'@WEEKLY': '0 0 * * 0',
|
||
|
'@DAILY': '0 0 * * *',
|
||
|
'@HOURLY': '0 * * * *'
|
||
|
};
|
||
|
|
||
|
// Contains the index, min, and max for each of the constraints
|
||
|
var FIELDS = {
|
||
|
s: [0, 0, 59], // seconds
|
||
|
m: [1, 0, 59], // minutes
|
||
|
h: [2, 0, 23], // hours
|
||
|
D: [3, 1, 31], // day of month
|
||
|
M: [4, 1, 12], // month
|
||
|
Y: [6, 1970, 2099], // year
|
||
|
d: [5, 1, 7, 1] // day of week
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Returns the value + offset if value is a number, otherwise it
|
||
|
* attempts to look up the value in the NAMES table and returns
|
||
|
* that result instead.
|
||
|
*
|
||
|
* @param {Number,String} value: The value that should be parsed
|
||
|
* @param {Number=} offset: Any offset that must be added to the value
|
||
|
* @param {Number=} max
|
||
|
* @returns {Number|null}
|
||
|
*/
|
||
|
function getValue(value) {
|
||
|
var offset = arguments.length <= 1 || arguments[1] === undefined ? 0 : arguments[1];
|
||
|
var max = arguments.length <= 2 || arguments[2] === undefined ? 9999 : arguments[2];
|
||
|
|
||
|
return isNaN(value) ? NAMES[value] || null : Math.min(+value + offset, max);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns a deep clone of a schedule skipping any day of week
|
||
|
* constraints.
|
||
|
*
|
||
|
* @param {Object} sched: The schedule that will be cloned
|
||
|
* @returns {Object}
|
||
|
*/
|
||
|
function cloneSchedule(sched) {
|
||
|
var clone = {},
|
||
|
field;
|
||
|
|
||
|
for (field in sched) {
|
||
|
if (field !== 'dc' && field !== 'd') {
|
||
|
clone[field] = sched[field].slice(0);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return clone;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds values to the specified constraint in the current schedule.
|
||
|
*
|
||
|
* @param {Object} sched: The schedule to add the constraint to
|
||
|
* @param {String} name: Name of constraint to add
|
||
|
* @param {Number} min: Minimum value for this constraint
|
||
|
* @param {Number} max: Maximum value for this constraint
|
||
|
* @param {Number=} inc: The increment to use between min and max
|
||
|
*/
|
||
|
function add(sched, name, min, max) {
|
||
|
var inc = arguments.length <= 4 || arguments[4] === undefined ? 0 : arguments[4];
|
||
|
|
||
|
var i = min;
|
||
|
|
||
|
if (!sched[name]) {
|
||
|
sched[name] = [];
|
||
|
}
|
||
|
|
||
|
while (i <= max) {
|
||
|
if (sched[name].indexOf(i) < 0) {
|
||
|
sched[name].push(i);
|
||
|
}
|
||
|
i += inc || 1;
|
||
|
}
|
||
|
|
||
|
sched[name].sort(function (a, b) {
|
||
|
return a - b;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds a hash item (of the form x#y or xL) to the schedule.
|
||
|
*
|
||
|
* @param {Object} schedules: The current schedule array to add to
|
||
|
* @param {Object} curSched: The current schedule to add to
|
||
|
* @param {Number} value: The value to add (x of x#y or xL)
|
||
|
* @param {Number} hash: The hash value to add (y of x#y)
|
||
|
*/
|
||
|
function addHash(schedules, curSched, value, hash) {
|
||
|
// if there are any existing day of week constraints that
|
||
|
// aren't equal to the one we're adding, create a new
|
||
|
// composite schedule
|
||
|
if (curSched.d && !curSched.dc || curSched.dc && curSched.dc.indexOf(hash) < 0) {
|
||
|
schedules.push(cloneSchedule(curSched));
|
||
|
curSched = schedules[schedules.length - 1];
|
||
|
}
|
||
|
|
||
|
add(curSched, 'd', value, value);
|
||
|
add(curSched, 'dc', hash, hash);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
*
|
||
|
* @param {Object} s: The existing set of schedules
|
||
|
* @param {Object} curSched: The current schedule to add to
|
||
|
* @param {Number} value
|
||
|
*/
|
||
|
function addWeekday(s, curSched, value) {
|
||
|
var except1 = {},
|
||
|
except2 = {};
|
||
|
if (value === 1) {
|
||
|
// cron doesn't pass month boundaries, so if 1st is a
|
||
|
// weekend then we need to use 2nd or 3rd instead
|
||
|
add(curSched, 'D', 1, 3);
|
||
|
add(curSched, 'd', NAMES.MON, NAMES.FRI);
|
||
|
add(except1, 'D', 2, 2);
|
||
|
add(except1, 'd', NAMES.TUE, NAMES.FRI);
|
||
|
add(except2, 'D', 3, 3);
|
||
|
add(except2, 'd', NAMES.TUE, NAMES.FRI);
|
||
|
} else {
|
||
|
// normally you want the closest day, so if v is a
|
||
|
// Saturday, use the previous Friday. If it's a
|
||
|
// sunday, use the following Monday.
|
||
|
add(curSched, 'D', value - 1, value + 1);
|
||
|
add(curSched, 'd', NAMES.MON, NAMES.FRI);
|
||
|
add(except1, 'D', value - 1, value - 1);
|
||
|
add(except1, 'd', NAMES.MON, NAMES.THU);
|
||
|
add(except2, 'D', value + 1, value + 1);
|
||
|
add(except2, 'd', NAMES.TUE, NAMES.FRI);
|
||
|
}
|
||
|
s.exceptions.push(except1);
|
||
|
s.exceptions.push(except2);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds a range item (of the form x-y/z) to the schedule.
|
||
|
*
|
||
|
* @param {String} item: The cron expression item to add
|
||
|
* @param {Object} curSched: The current schedule to add to
|
||
|
* @param {String} name: The name to use for this constraint
|
||
|
* @param {Number} min: The min value for the constraint
|
||
|
* @param {Number} max: The max value for the constraint
|
||
|
* @param {Number} offset: The offset to apply to the cron value
|
||
|
*/
|
||
|
function addRange(item, curSched, name, min, max, offset) {
|
||
|
// parse range/x
|
||
|
var incSplit = item.split('/'),
|
||
|
inc = +incSplit[1],
|
||
|
range = incSplit[0];
|
||
|
|
||
|
// parse x-y or * or 0
|
||
|
if (range !== '*' && range !== '0') {
|
||
|
var rangeSplit = range.split('-');
|
||
|
min = getValue(rangeSplit[0], offset, max);
|
||
|
|
||
|
// fix for issue #13, range may be single digit
|
||
|
max = getValue(rangeSplit[1], offset, max) || max;
|
||
|
}
|
||
|
|
||
|
add(curSched, name, min, max, inc);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parses a particular item within a cron expression.
|
||
|
*
|
||
|
* @param {String} item: The cron expression item to parse
|
||
|
* @param {Object} s: The existing set of schedules
|
||
|
* @param {String} name: The name to use for this constraint
|
||
|
* @param {Number} min: The min value for the constraint
|
||
|
* @param {Number} max: The max value for the constraint
|
||
|
* @param {Number} offset: The offset to apply to the cron value
|
||
|
*/
|
||
|
function parse(item, s, name, min, max, offset) {
|
||
|
var value,
|
||
|
split,
|
||
|
schedules = s.schedules,
|
||
|
curSched = schedules[schedules.length - 1];
|
||
|
|
||
|
// L just means min - 1 (this also makes it work for any field)
|
||
|
if (item === 'L') {
|
||
|
item = (min - 1).toString(10);
|
||
|
}
|
||
|
|
||
|
// parse x
|
||
|
if ((value = getValue(item, offset, max)) !== null) {
|
||
|
add(curSched, name, value, value);
|
||
|
}
|
||
|
// parse xW
|
||
|
else if ((value = getValue(item.replace('W', ''), offset, max)) !== null) {
|
||
|
addWeekday(s, curSched, value);
|
||
|
}
|
||
|
// parse xL
|
||
|
else if ((value = getValue(item.replace('L', ''), offset, max)) !== null) {
|
||
|
addHash(schedules, curSched, value, min - 1);
|
||
|
}
|
||
|
// parse x#y
|
||
|
else if ((split = item.split('#')).length === 2) {
|
||
|
value = getValue(split[0], offset, max);
|
||
|
addHash(schedules, curSched, value, getValue(split[1]));
|
||
|
}
|
||
|
// parse x-y or x-y/z or */z or 0/z
|
||
|
else {
|
||
|
addRange(item, curSched, name, min, max, offset);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns true if the item is either of the form x#y or xL.
|
||
|
*
|
||
|
* @param {String} item: The expression item to check
|
||
|
*/
|
||
|
function isHash(item) {
|
||
|
return item.indexOf('#') > -1 || item.indexOf('L') > 0;
|
||
|
}
|
||
|
|
||
|
function itemSorter(a, b) {
|
||
|
return isHash(a) && !isHash(b) ? 1 : a - b;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parses each of the fields in a cron expression. The expression must
|
||
|
* include the seconds field, the year field is optional.
|
||
|
*
|
||
|
* @param {String} expr: The cron expression to parse
|
||
|
*/
|
||
|
function parseExpr(expr) {
|
||
|
var schedule = { schedules: [{}], exceptions: [] },
|
||
|
components = expr.replace(/(\s)+/g, ' ').split(' '),
|
||
|
field,
|
||
|
f,
|
||
|
component,
|
||
|
items;
|
||
|
|
||
|
for (field in FIELDS) {
|
||
|
f = FIELDS[field];
|
||
|
component = components[f[0]];
|
||
|
if (component && component !== '*' && component !== '?') {
|
||
|
// need to sort so that any #'s come last, otherwise
|
||
|
// schedule clones to handle # won't contain all of the
|
||
|
// other constraints
|
||
|
items = component.split(',').sort(itemSorter);
|
||
|
var i,
|
||
|
length = items.length;
|
||
|
for (i = 0; i < length; i++) {
|
||
|
parse(items[i], schedule, field, f[1], f[2], f[3]);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return schedule;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Make cron expression parsable.
|
||
|
*
|
||
|
* @param {String} expr: The cron expression to prepare
|
||
|
*/
|
||
|
function prepareExpr(expr) {
|
||
|
var prepared = expr.toUpperCase();
|
||
|
return REPLACEMENTS[prepared] || prepared;
|
||
|
}
|
||
|
|
||
|
function parseCron(expr, hasSeconds) {
|
||
|
var e = prepareExpr(expr);
|
||
|
return parseExpr(hasSeconds ? e : '0 ' + e);
|
||
|
}
|
||
|
|
||
|
var schedule = parseCron(cronspec, withSeconds);
|
||
|
locale = locale || LOCALE;
|
||
|
|
||
|
function absFloor(number) {
|
||
|
if (number < 0) {
|
||
|
return Math.ceil(number);
|
||
|
} else {
|
||
|
return Math.floor(number);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function toInt(argumentForCoercion) {
|
||
|
var coercedNumber = +argumentForCoercion,
|
||
|
value = 0;
|
||
|
|
||
|
if (coercedNumber !== 0 && isFinite(coercedNumber)) {
|
||
|
value = absFloor(coercedNumber);
|
||
|
}
|
||
|
|
||
|
return value;
|
||
|
}
|
||
|
|
||
|
function ordinal(number) {
|
||
|
var b = number % 10,
|
||
|
output = (toInt(number % 100 / 10) === 1) ? locale.ORDINALS.th :
|
||
|
(b === 1) ? locale.ORDINALS.st :
|
||
|
(b === 2) ? locale.ORDINALS.nd :
|
||
|
(b === 3) ? locale.ORDINALS.rd : locale.ORDINALS.th;
|
||
|
return number + output;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* For an array of numbers, e.g. a list of hours in a schedule,
|
||
|
* return a string listing out all of the values (complete with
|
||
|
* "and" plus ordinal text on the last item).
|
||
|
* @param {Number[]} numbers
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
function numberList(numbers) {
|
||
|
if (numbers.length < 2) {
|
||
|
return ordinal(numbers);
|
||
|
}
|
||
|
|
||
|
var lastVal = numbers.pop();
|
||
|
return numbers.join(', ') + ' ' + locale['and'] + ' ' + ordinal(lastVal);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parse a number into day of week, or a month name;
|
||
|
* used in dateList below.
|
||
|
* @param {Number|String} value
|
||
|
* @param {String} type
|
||
|
* @returns {String}
|
||
|
*/
|
||
|
function numberToDateName(value, type) {
|
||
|
if (type === 'dow') {
|
||
|
return locale.DOW[value - 1];
|
||
|
} else if (type === 'mon') {
|
||
|
return locale.MONTH[value - 1];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* From an array of numbers corresponding to dates (given in type: either
|
||
|
* days of the week, or months), return a string listing all the values.
|
||
|
* @param {Number[]} numbers
|
||
|
* @param {String} type
|
||
|
* @returns {String}
|
||
|
*/
|
||
|
function dateList(numbers, type) {
|
||
|
if (numbers.length < 2) {
|
||
|
return numberToDateName('' + numbers[0], type);
|
||
|
}
|
||
|
|
||
|
var lastVal = '' + numbers.pop();
|
||
|
var outputText = '';
|
||
|
|
||
|
for (var i = 0, value; value = numbers[i]; i++) {
|
||
|
if (outputText.length > 0) {
|
||
|
outputText += ', ';
|
||
|
}
|
||
|
outputText += numberToDateName(value, type);
|
||
|
}
|
||
|
return outputText + ' ' + locale['and'] + ' ' + numberToDateName(lastVal, type);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Pad to equivalent of sprintf('%02d').
|
||
|
* @param {Number} x
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
function zeroPad(x) {
|
||
|
return (x < 10) ? '0' + x : x;
|
||
|
}
|
||
|
|
||
|
//----------------
|
||
|
|
||
|
/**
|
||
|
* Given a schedule, generate a friendly sentence description.
|
||
|
* @param {Object} schedule
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
function scheduleToSentence(schedule, withSeconds) {
|
||
|
var outputText = locale.Every + ' ';
|
||
|
|
||
|
if (schedule['h'] && schedule['m'] && schedule['h'].length <= 2 && schedule['m'].length <= 2 && withSeconds && schedule['s'] && schedule['s'].length <= 2 ) {
|
||
|
// If there are only one or two specified values for
|
||
|
// hour or minute, print them in HH:MM:SS format
|
||
|
|
||
|
var hm = [];
|
||
|
for (var i = 0; i < schedule['h'].length; i++) {
|
||
|
for (var j = 0; j < schedule['m'].length; j++) {
|
||
|
for (var k = 0; k < schedule['s'].length; k++) {
|
||
|
hm.push(zeroPad(schedule['h'][i]) + ':' + zeroPad(schedule['m'][j]) + ':' + zeroPad(schedule['s'][k]));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (hm.length < 2) {
|
||
|
outputText = locale['At'] + ' ' + hm[0];
|
||
|
} else {
|
||
|
var lastVal = hm.pop();
|
||
|
outputText = locale['At'] + ' ' + hm.join(', ') + ' ' + locale.and + ' ' + lastVal;
|
||
|
}
|
||
|
if (!schedule['d'] && !schedule['D']) {
|
||
|
outputText += ' ' + locale['every day'] + ' ';
|
||
|
}
|
||
|
} else
|
||
|
if (schedule['h'] && schedule['m'] && schedule['h'].length <= 2 && schedule['m'].length <= 2) {
|
||
|
// If there are only one or two specified values for
|
||
|
// hour or minute, print them in HH:MM format
|
||
|
|
||
|
var hm = [];
|
||
|
for (var i = 0; i < schedule['h'].length; i++) {
|
||
|
for (var j = 0; j < schedule['m'].length; j++) {
|
||
|
hm.push(zeroPad(schedule['h'][i]) + ':' + zeroPad(schedule['m'][j]));
|
||
|
}
|
||
|
}
|
||
|
if (hm.length < 2) {
|
||
|
outputText = locale['At'] + ' ' + hm[0];
|
||
|
} else {
|
||
|
var lastVal = hm.pop();
|
||
|
outputText = locale['At'] + ' ' + hm.join(', ') + ' ' + locale.and + ' ' + lastVal;
|
||
|
}
|
||
|
if (!schedule['d'] && !schedule['D']) {
|
||
|
outputText += ' ' + locale['every day'] + ' ';
|
||
|
}
|
||
|
} else {
|
||
|
// Otherwise, list out every specified hour/minute value.
|
||
|
|
||
|
if (schedule['h']) { // runs only at specific hours
|
||
|
if (schedule['m']) { // and only at specific minutes
|
||
|
if (withSeconds) {
|
||
|
if (!schedule['s'] || schedule['s'].length === 60) {
|
||
|
outputText += locale['second of every'] + ' ' + numberList(schedule['m']) + ' ' + locale['minute past the'] + ' ' + numberList(schedule['h']) + ' ' + locale['hour'];
|
||
|
} else {
|
||
|
outputText += numberList(schedule['s']) + ' ' + locale['second of every'] + ' ' +numberList(schedule['m']) + ' ' + locale['minute past the'] + ' ' + numberList(schedule['h']) + ' ' + locale['hour'];
|
||
|
}
|
||
|
} else {
|
||
|
outputText += numberList(schedule['m']) + ' ' + locale['minute past the'] + ' ' + numberList(schedule['h']) + ' ' + locale['hour'];
|
||
|
}
|
||
|
} else { // specific hours, but every minute
|
||
|
if (withSeconds) {
|
||
|
if (!schedule['s'] || schedule['s'].length === 60) {
|
||
|
outputText += locale['second of every'] + ' ' + locale['minute of'] + ' ' + numberList(schedule['h']) + ' ' + locale['hour'];
|
||
|
} else {
|
||
|
outputText += numberList(schedule['s']) + ' ' + locale['second of every'] + ' ' + locale['minute of'] + ' ' + numberList(schedule['h']) + ' ' + locale['hour'];
|
||
|
}
|
||
|
} else {
|
||
|
outputText += locale['minute of'] + ' ' + numberList(schedule['h']) + ' ' + locale['hour'];
|
||
|
}
|
||
|
}
|
||
|
} else if (schedule['m']) { // every hour, but specific minutes
|
||
|
if (withSeconds) {
|
||
|
if (!schedule['s'] || schedule['s'].length === 60) {
|
||
|
outputText += locale['second of every'] + ' ' + numberList(schedule['m']) + ' ' + locale['minute every hour'];
|
||
|
} else {
|
||
|
outputText += numberList(schedule['s']) + ' ' + locale['second of every'] + ' ' + numberList(schedule['m']) + ' ' + locale['minute every hour'];
|
||
|
}
|
||
|
} else {
|
||
|
outputText += numberList(schedule['m']) + ' ' + locale['minute every hour'];
|
||
|
}
|
||
|
} else if (withSeconds) {
|
||
|
if (!schedule['s'] || schedule['s'].length === 60) {
|
||
|
outputText += locale['second'];
|
||
|
} else {
|
||
|
outputText += numberList(schedule['s']) + ' ' + locale['second'];
|
||
|
}
|
||
|
} else { // cronspec has "*" for both hour and minute
|
||
|
outputText += locale['minute'];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (schedule['D']) { // runs only on specific day(s) of month
|
||
|
outputText += (locale['on the'] ? ' ' + locale['on the'] + ' ' : ' ') + numberList(schedule['D']);
|
||
|
if (!schedule['M']) {
|
||
|
outputText += ' ' + locale['of every month'];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (schedule['d']) { // runs only on specific day(s) of week
|
||
|
if (schedule['D']) {
|
||
|
// if both day fields are specified, cron uses both; superuser.com/a/348372
|
||
|
outputText += ' ' + locale['and every'] + ' ';
|
||
|
} else {
|
||
|
outputText += ' ' + locale['on'] + ' ';
|
||
|
}
|
||
|
outputText += dateList(schedule['d'], 'dow');
|
||
|
}
|
||
|
|
||
|
if (schedule['M']) {
|
||
|
// runs only in specific months; put this output last
|
||
|
outputText += ' ' + locale['in'] + ' ' + dateList(schedule['M'], 'mon');
|
||
|
}
|
||
|
|
||
|
return outputText;
|
||
|
}
|
||
|
|
||
|
return scheduleToSentence(schedule.schedules[0], withSeconds);
|
||
|
}
|