@cheap-glitch/mi-cron
Version:
A microscopic parser for standard cron expressions.
141 lines (140 loc) • 5.42 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 *',
};
// Return a schedule as a collection of numerical arrays, or `undefined` if the cron expression is deemed invalid
export function parseCron(exp) {
const fields = exp.toLowerCase().trim().split(/\s+/);
if (fields.length === 1) {
return fields[0] in shorthands ? parseCron(shorthands[fields[0]]) : undefined;
}
if (fields.length !== 5) {
return undefined;
}
try {
return {
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;
}
}
const boundary = '(\\d{1,2}|[a-z]{3})';
const rangePattern = new RegExp(`^${boundary}(?:-${boundary})?$`, 'i');
function parseField(field, min, max, aliases = []) {
// Parse every item of the comma-separated list, merge the values and remove duplicates
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;
});
// Invalid range
if (start === undefined || (stop !== undefined && stop < start)) {
throw new Error();
}
return stop === undefined ? [start] : range(start, stop, step);
})),
];
// Sort the array numerically
// The sort function is needed to avoid the default conversion into strings (which would give e.g. [10, 2])
values.sort((a, b) => a - b);
return values;
}
// Return the closest date and time matched by the cron schedule, or `undefined` if the schedule is deemed invalid
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, // For whatever reason, UTC months are numbered from 0 to 11...
days: from.getUTCDate(),
hours: from.getUTCHours(),
minutes: from.getUTCMinutes() + 1, // Always consider the current minute to be already passed
};
const dials = Object.keys(date);
for (let i = 1; i < dials.length; i++) {
const dial = dials[i];
if (!schedule[dial].includes(date[dial])) {
// Reset all the next dials
dials.filter((_, j) => j > i).forEach((d) => (date[d] = schedule[d][0]));
// Try to find the next incoming time
const nextTime = schedule[dial].find((t) => t >= date[dial]);
if (nextTime !== undefined) {
date[dial] = nextTime;
// If no fitting time is found...
}
else {
// ...restart from the beginning of the list...
date[dial] = schedule[dial][0];
// ...and increment the previous dial
// NB: We can just increment without worrying about boundaries
// JavaScript will just fix incorrect dates magically \o/
date[dials[i - 1]]++;
// Go back to the previous dial (unless we're at the months)
i = dial !== 'months' ? i - 2 : i;
}
}
// Make sure the selected day is one of the possible weekdays
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);
}