@fregante/mi-cron
Version:
A microscopic parser for standard cron expressions.
113 lines (112 loc) • 4.1 kB
JavaScript
/*!
* mi-cron
*
* A microscopic parser for standard cron expressions.
*
* Copyright (c) 2020-present, cheap glitch
* This software is distributed under the ISC license
*/
const shorthands = {
'@hourly': '0 * * * *',
'@daily': '0 0 * * *',
'@weekly': '0 0 * * 0',
'@monthly': '0 0 1 * *',
'@yearly': '0 0 1 1 *',
'@annually': '0 0 1 1 *',
};
export function parseCron(exp) {
const fields = exp.trim().split(/\s+/);
if (fields.length === 1) {
return (fields[0] in shorthands) ? parseCron(shorthands[fields[0]]) : undefined;
}
if (fields.length === 5) {
let schedule = undefined;
try {
schedule = {
minutes: parseField(fields[0], 0, 59),
hours: parseField(fields[1], 0, 23),
days: parseField(fields[2], 1, 31),
months: parseField(fields[3], 1, 12, ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']),
weekDays: parseField(fields[4], 0, 6, ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']),
};
}
catch {
return undefined;
}
return schedule;
}
return undefined;
}
const boundary = '(\\d{1,2}|[a-z]{3})';
const rangePattern = new RegExp(`^${boundary}(?:-${boundary})?$`, 'i');
function parseField(field, min, max, aliases = []) {
const values = [...new Set(field.split(',').flatMap(item => {
const [exp, stepStr = '1'] = item.split('/', 2);
const step = Number.parseInt(stepStr, 10);
if (Number.isNaN(step)) {
throw new TypeError();
}
if (exp === '*') {
return range(min, max, step);
}
const matches = exp.match(rangePattern);
if (!matches) {
throw new Error();
}
const [start, stop = item.includes('/') ? max : undefined] = matches.slice(1).map(match => {
if (aliases.includes(match)) {
return aliases.indexOf(match);
}
const value = Number.parseInt(match, 10);
return (!Number.isNaN(value) && min <= value && value <= max) ? value : undefined;
});
if (start === undefined || (stop !== undefined && stop < start)) {
throw new Error();
}
return stop === undefined ? [start] : range(start, stop, step);
}))];
values.sort((a, b) => a - b);
return values;
}
parseCron.nextDate = function (exp, from = new Date()) {
const schedule = typeof exp === 'string' ? parseCron(exp) : exp;
if (schedule === undefined) {
return undefined;
}
const date = {
years: from.getUTCFullYear(),
months: from.getUTCMonth() + 1,
days: from.getUTCDate(),
hours: from.getUTCHours(),
minutes: from.getUTCMinutes() + 1,
};
const dials = Object.keys(date);
for (let i = 1; i < dials.length; i++) {
const dial = dials[i];
if (!schedule[dial].includes(date[dial])) {
dials.filter((_, j) => j > i).forEach(d => date[d] = schedule[d][0]);
const nextTime = schedule[dial].find(t => t >= date[dial]);
if (nextTime !== undefined) {
date[dial] = nextTime;
}
else {
date[dial] = schedule[dial][0];
date[dials[i - 1]]++;
i = (dial !== 'months') ? (i - 2) : i;
}
}
if (dial === 'days' && !schedule.weekDays.includes(cronDateToUTC(date).getUTCDay())) {
date.days++;
date.hours = schedule.hours[0];
date.minutes = schedule.minutes[0];
i = 1;
}
}
return cronDateToUTC(date);
};
function cronDateToUTC(date) {
return new Date(Date.UTC(date.years, date.months - 1, date.days, date.hours, date.minutes));
}
function range(start, stop, step) {
return Array.from({ length: Math.floor((stop - start) / step) + 1 }, (_, i) => start + i * step);
}