cron-schedule
Version:
A zero-dependency cron parser and scheduler for Node.js, Deno and the browser.
352 lines • 18.6 kB
JavaScript
import { extractDateElements, getDaysBetweenWeekdays, getDaysInMonth, } from './utils.js';
export class Cron {
constructor({ seconds, minutes, hours, days, months, weekdays, }) {
// Validate that there are values provided.
if (!seconds || seconds.size === 0)
throw new Error('There must be at least one allowed second.');
if (!minutes || minutes.size === 0)
throw new Error('There must be at least one allowed minute.');
if (!hours || hours.size === 0)
throw new Error('There must be at least one allowed hour.');
if (!months || months.size === 0)
throw new Error('There must be at least one allowed month.');
if ((!weekdays || weekdays.size === 0) && (!days || days.size === 0))
throw new Error('There must be at least one allowed day or weekday.');
// Convert set to array and sort in ascending order.
this.seconds = Array.from(seconds).sort((a, b) => a - b);
this.minutes = Array.from(minutes).sort((a, b) => a - b);
this.hours = Array.from(hours).sort((a, b) => a - b);
this.days = Array.from(days).sort((a, b) => a - b);
this.months = Array.from(months).sort((a, b) => a - b);
this.weekdays = Array.from(weekdays).sort((a, b) => a - b);
// Validate that all values are integers within the constraint.
const validateData = (name, data, constraint) => {
if (data.some((x) => typeof x !== 'number' ||
x % 1 !== 0 ||
x < constraint.min ||
x > constraint.max)) {
throw new Error(`${name} must only consist of integers which are within the range of ${constraint.min} and ${constraint.max}`);
}
};
validateData('seconds', this.seconds, { min: 0, max: 59 });
validateData('minutes', this.minutes, { min: 0, max: 59 });
validateData('hours', this.hours, { min: 0, max: 23 });
validateData('days', this.days, { min: 1, max: 31 });
validateData('months', this.months, { min: 0, max: 11 });
validateData('weekdays', this.weekdays, { min: 0, max: 6 });
// For each element, store a reversed copy in the reversed attribute for finding prev dates.
this.reversed = {
seconds: this.seconds.map((x) => x).reverse(),
minutes: this.minutes.map((x) => x).reverse(),
hours: this.hours.map((x) => x).reverse(),
days: this.days.map((x) => x).reverse(),
months: this.months.map((x) => x).reverse(),
weekdays: this.weekdays.map((x) => x).reverse(),
};
}
/**
* Find the next or previous hour, starting from the given start hour that matches the hour constraint.
* startHour itself might also be allowed.
*/
findAllowedHour(dir, startHour) {
return dir === 'next'
? this.hours.find((x) => x >= startHour)
: this.reversed.hours.find((x) => x <= startHour);
}
/**
* Find the next or previous minute, starting from the given start minute that matches the minute constraint.
* startMinute itself might also be allowed.
*/
findAllowedMinute(dir, startMinute) {
return dir === 'next'
? this.minutes.find((x) => x >= startMinute)
: this.reversed.minutes.find((x) => x <= startMinute);
}
/**
* Find the next or previous second, starting from the given start second that matches the second constraint.
* startSecond itself IS NOT allowed.
*/
findAllowedSecond(dir, startSecond) {
return dir === 'next'
? this.seconds.find((x) => x > startSecond)
: this.reversed.seconds.find((x) => x < startSecond);
}
/**
* Find the next or previous time, starting from the given start time that matches the hour, minute
* and second constraints. startTime itself might also be allowed.
*/
findAllowedTime(dir, startTime) {
// Try to find an allowed hour.
let hour = this.findAllowedHour(dir, startTime.hour);
if (hour !== undefined) {
if (hour === startTime.hour) {
// We found an hour that is the start hour. Try to find an allowed minute.
let minute = this.findAllowedMinute(dir, startTime.minute);
if (minute !== undefined) {
if (minute === startTime.minute) {
// We found a minute that is the start minute. Try to find an allowed second.
const second = this.findAllowedSecond(dir, startTime.second);
if (second !== undefined) {
// We found a second within the start hour and minute.
return { hour, minute, second };
}
// We did not find a valid second within the start minute. Try to find another minute.
minute = this.findAllowedMinute(dir, dir === 'next' ? startTime.minute + 1 : startTime.minute - 1);
if (minute !== undefined) {
// We found a minute which is not the start minute. Return that minute together with the hour and the first / last allowed second.
return {
hour,
minute,
second: dir === 'next' ? this.seconds[0] : this.reversed.seconds[0],
};
}
}
else {
// We found a minute which is not the start minute. Return that minute together with the hour and the first / last allowed second.
return {
hour,
minute,
second: dir === 'next' ? this.seconds[0] : this.reversed.seconds[0],
};
}
}
// We did not find an allowed minute / second combination inside the start hour. Try to find the next / previous allowed hour.
hour = this.findAllowedHour(dir, dir === 'next' ? startTime.hour + 1 : startTime.hour - 1);
if (hour !== undefined) {
// We found an allowed hour which is not the start hour. Return that hour together with the first / last allowed minutes / seconds.
return {
hour,
minute: dir === 'next' ? this.minutes[0] : this.reversed.minutes[0],
second: dir === 'next' ? this.seconds[0] : this.reversed.seconds[0],
};
}
}
else {
// We found an allowed hour which is not the start hour. Return that hour together with the first / last allowed minutes / seconds.
return {
hour,
minute: dir === 'next' ? this.minutes[0] : this.reversed.minutes[0],
second: dir === 'next' ? this.seconds[0] : this.reversed.seconds[0],
};
}
}
// No allowed time found.
return undefined;
}
/**
* Find the next or previous day in the given month, starting from the given startDay
* that matches either the day or the weekday constraint. startDay itself might also be allowed.
*/
findAllowedDayInMonth(dir, year, month, startDay) {
var _a, _b;
if (startDay < 1)
throw new Error('startDay must not be smaller than 1.');
// If only days are restricted: allow day based on day constraint only.
// If only weekdays are restricted: allow day based on weekday constraint only.
// If both are restricted: allow day based on both day and weekday constraint. pick day that is closer to startDay.
// If none are restricted: return the day closest to startDay (respecting dir) that is allowed (or startDay itself).
const daysInMonth = getDaysInMonth(year, month);
const daysRestricted = this.days.length !== 31;
const weekdaysRestricted = this.weekdays.length !== 7;
if (!daysRestricted && !weekdaysRestricted) {
if (startDay > daysInMonth) {
return dir === 'next' ? undefined : daysInMonth;
}
return startDay;
}
// Try to find a day based on the days constraint.
let allowedDayByDays;
if (daysRestricted) {
allowedDayByDays =
dir === 'next'
? this.days.find((x) => x >= startDay)
: this.reversed.days.find((x) => x <= startDay);
// Make sure the day does not exceed the amount of days in month.
if (allowedDayByDays !== undefined && allowedDayByDays > daysInMonth) {
allowedDayByDays = undefined;
}
}
// Try to find a day based on the weekday constraint.
let allowedDayByWeekdays;
if (weekdaysRestricted) {
const startWeekday = new Date(year, month, startDay).getDay();
const nearestAllowedWeekday = dir === 'next'
? ((_a = this.weekdays.find((x) => x >= startWeekday)) !== null && _a !== void 0 ? _a : this.weekdays[0])
: ((_b = this.reversed.weekdays.find((x) => x <= startWeekday)) !== null && _b !== void 0 ? _b : this.reversed.weekdays[0]);
if (nearestAllowedWeekday !== undefined) {
const daysBetweenWeekdays = dir === 'next'
? getDaysBetweenWeekdays(startWeekday, nearestAllowedWeekday)
: getDaysBetweenWeekdays(nearestAllowedWeekday, startWeekday);
allowedDayByWeekdays =
dir === 'next'
? startDay + daysBetweenWeekdays
: startDay - daysBetweenWeekdays;
// Make sure the day does not exceed the month boundaries.
if (allowedDayByWeekdays > daysInMonth || allowedDayByWeekdays < 1) {
allowedDayByWeekdays = undefined;
}
}
}
if (allowedDayByDays !== undefined && allowedDayByWeekdays !== undefined) {
// If a day is found both via the days and the weekdays constraint, pick the day
// that is closer to start date.
return dir === 'next'
? Math.min(allowedDayByDays, allowedDayByWeekdays)
: Math.max(allowedDayByDays, allowedDayByWeekdays);
}
if (allowedDayByDays !== undefined) {
return allowedDayByDays;
}
if (allowedDayByWeekdays !== undefined) {
return allowedDayByWeekdays;
}
return undefined;
}
/** Gets the next date starting from the given start date or now. */
getNextDate(startDate = new Date()) {
const startDateElements = extractDateElements(startDate);
let minYear = startDateElements.year;
let startIndexMonth = this.months.findIndex((x) => x >= startDateElements.month);
if (startIndexMonth === -1) {
startIndexMonth = 0;
minYear++;
}
// We try every month within the next 5 years to make sure that we tried to
// find a matching date insidde a whole leap year.
const maxIterations = this.months.length * 5;
for (let i = 0; i < maxIterations; i++) {
// Get the next year and month.
const year = minYear + Math.floor((startIndexMonth + i) / this.months.length);
const month = this.months[(startIndexMonth + i) % this.months.length];
const isStartMonth = year === startDateElements.year && month === startDateElements.month;
// Find the next day.
let day = this.findAllowedDayInMonth('next', year, month, isStartMonth ? startDateElements.day : 1);
let isStartDay = isStartMonth && day === startDateElements.day;
// If we found a day and it is the start day, try to find a valid time beginning from the start date time.
if (day !== undefined && isStartDay) {
const nextTime = this.findAllowedTime('next', startDateElements);
if (nextTime !== undefined) {
return new Date(year, month, day, nextTime.hour, nextTime.minute, nextTime.second);
}
// If no valid time has been found for the start date, try the next day.
day = this.findAllowedDayInMonth('next', year, month, day + 1);
isStartDay = false;
}
// If we found a next day and it is not the start day, just use the next day with the first allowed values
// for hours, minutes and seconds.
if (day !== undefined && !isStartDay) {
return new Date(year, month, day, this.hours[0], this.minutes[0], this.seconds[0]);
}
// No allowed day has been found for this month. Continue to search in next month.
}
throw new Error('No valid next date was found.');
}
/** Gets the specified amount of future dates starting from the given start date or now. */
getNextDates(amount, startDate) {
const dates = [];
let nextDate;
for (let i = 0; i < amount; i++) {
nextDate = this.getNextDate(nextDate !== null && nextDate !== void 0 ? nextDate : startDate);
dates.push(nextDate);
}
return dates;
}
/**
* Get an ES6 compatible iterator which iterates over the next dates starting from startDate or now.
* The iterator runs until the optional endDate is reached or forever.
*/
*getNextDatesIterator(startDate, endDate) {
let nextDate;
while (true) {
nextDate = this.getNextDate(nextDate !== null && nextDate !== void 0 ? nextDate : startDate);
if (endDate && endDate.getTime() < nextDate.getTime()) {
return;
}
yield nextDate;
}
}
/** Gets the previous date starting from the given start date or now. */
getPrevDate(startDate = new Date()) {
const startDateElements = extractDateElements(startDate);
let maxYear = startDateElements.year;
let startIndexMonth = this.reversed.months.findIndex((x) => x <= startDateElements.month);
if (startIndexMonth === -1) {
startIndexMonth = 0;
maxYear--;
}
// We try every month within the past 5 years to make sure that we tried to
// find a matching date inside a whole leap year.
const maxIterations = this.reversed.months.length * 5;
for (let i = 0; i < maxIterations; i++) {
// Get the next year and month.
const year = maxYear -
Math.floor((startIndexMonth + i) / this.reversed.months.length);
const month = this.reversed.months[(startIndexMonth + i) % this.reversed.months.length];
const isStartMonth = year === startDateElements.year && month === startDateElements.month;
// Find the previous day.
let day = this.findAllowedDayInMonth('prev', year, month, isStartMonth
? startDateElements.day
: // Start searching from the last day of the month.
getDaysInMonth(year, month));
let isStartDay = isStartMonth && day === startDateElements.day;
// If we found a day and it is the start day, try to find a valid time beginning from the start date time.
if (day !== undefined && isStartDay) {
const prevTime = this.findAllowedTime('prev', startDateElements);
if (prevTime !== undefined) {
return new Date(year, month, day, prevTime.hour, prevTime.minute, prevTime.second);
}
// If no valid time has been found for the start date, try the previous day.
if (day > 1) {
day = this.findAllowedDayInMonth('prev', year, month, day - 1);
isStartDay = false;
}
}
// If we found a previous day and it is not the start day, just use the previous day with the first allowed values
// for hours, minutes and seconds (which will be the latest time due to using the reversed array).
if (day !== undefined && !isStartDay) {
return new Date(year, month, day, this.reversed.hours[0], this.reversed.minutes[0], this.reversed.seconds[0]);
}
// No allowed day has been found for this month. Continue to search in previous month.
}
throw new Error('No valid previous date was found.');
}
/** Gets the specified amount of previous dates starting from the given start date or now. */
getPrevDates(amount, startDate) {
const dates = [];
let prevDate;
for (let i = 0; i < amount; i++) {
prevDate = this.getPrevDate(prevDate !== null && prevDate !== void 0 ? prevDate : startDate);
dates.push(prevDate);
}
return dates;
}
/**
* Get an ES6 compatible iterator which iterates over the previous dates starting from startDate or now.
* The iterator runs until the optional endDate is reached or forever.
*/
*getPrevDatesIterator(startDate, endDate) {
let prevDate;
while (true) {
prevDate = this.getPrevDate(prevDate !== null && prevDate !== void 0 ? prevDate : startDate);
if (endDate && endDate.getTime() > prevDate.getTime()) {
return;
}
yield prevDate;
}
}
/** Returns true when there is a cron date at the given date. */
matchDate(date) {
const { second, minute, hour, day, month, weekday } = extractDateElements(date);
if (this.seconds.indexOf(second) === -1 ||
this.minutes.indexOf(minute) === -1 ||
this.hours.indexOf(hour) === -1 ||
this.months.indexOf(month) === -1) {
return false;
}
if (this.days.length !== 31 && this.weekdays.length !== 7) {
return (this.days.indexOf(day) !== -1 || this.weekdays.indexOf(weekday) !== -1);
}
return (this.days.indexOf(day) !== -1 && this.weekdays.indexOf(weekday) !== -1);
}
}
//# sourceMappingURL=cron.js.map