Created
January 16, 2024 03:37
-
-
Save amasanelli/b6f28f61e8f00446bdc10ede1ca27978 to your computer and use it in GitHub Desktop.
cron expression parser and next occurrence calculator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as moment from 'moment-timezone'; | |
interface FieldBounds { | |
min: number; | |
max: number; | |
} | |
const minuteBound: FieldBounds = { | |
min: 0, | |
max: 59, | |
}; | |
const hourBound: FieldBounds = { | |
min: 0, | |
max: 23, | |
}; | |
const domBound: FieldBounds = { | |
min: 1, | |
max: 31, | |
}; | |
const monthBound: FieldBounds = { | |
min: 1, | |
max: 12, | |
}; | |
const dowBound: FieldBounds = { | |
min: 0, | |
max: 6, | |
}; | |
export class InvalidCronExpression extends Error { | |
constructor() { | |
super('invalid cron expression'); | |
} | |
} | |
export class CronParser { | |
private readonly minutes: number[]; | |
private readonly hours: number[]; | |
private readonly dom: number[]; | |
private readonly months: number[]; | |
private readonly dow: number[]; | |
private readonly timezone: string; | |
constructor(expression: string, timezone: string) { | |
const fields = expression.trim().split(' '); | |
if (fields.length !== 5) { | |
throw new InvalidCronExpression(); | |
} | |
if (moment.tz.zone(timezone) === null) { | |
throw new Error('invalid timezone'); | |
} | |
this.minutes = CronParser.parseField(fields[0], minuteBound); | |
this.hours = CronParser.parseField(fields[1], hourBound); | |
this.dom = CronParser.parseField(fields[2], domBound); | |
this.months = CronParser.parseField(fields[3], monthBound); | |
this.dow = CronParser.parseField(fields[4], dowBound); | |
this.timezone = timezone; | |
} | |
static isValid(expression: string): boolean { | |
try { | |
const fields = expression.trim().split(' '); | |
if (fields.length !== 5) { | |
throw new InvalidCronExpression(); | |
} | |
this.parseField(fields[0], minuteBound); | |
this.parseField(fields[1], hourBound); | |
this.parseField(fields[2], domBound); | |
this.parseField(fields[3], monthBound); | |
this.parseField(fields[4], dowBound); | |
return true; | |
} catch (error) { | |
return false; | |
} | |
} | |
static parseField(field: string, bounds: FieldBounds): number[] { | |
const bitset: number[] = []; | |
for (let i = 0; i <= bounds.max; i++) { | |
bitset.push(0); | |
} | |
const fieldParts = field.split(','); | |
for (const fieldPart of fieldParts) { | |
const results = this.parseFieldPart(fieldPart, bounds); | |
for (const result of results) { | |
bitset[result] = 1; | |
} | |
} | |
return bitset; | |
} | |
static parseFieldPart(fieldPart: string, bounds: FieldBounds): number[] { | |
fieldPart = fieldPart.replace('*', `${bounds.min}-${bounds.max}`); | |
const rangeAndStep = fieldPart.split('/'); | |
if (rangeAndStep.length > 2) { | |
throw new InvalidCronExpression(); | |
} | |
const hasStep = rangeAndStep.length === 2; | |
const lowAndHigh = rangeAndStep[0].split('-'); | |
if (lowAndHigh.length > 2) { | |
throw new InvalidCronExpression(); | |
} | |
const begin = parseInt(lowAndHigh[0]); | |
if (Number.isNaN(begin)) { | |
throw new InvalidCronExpression(); | |
} | |
let end: number; | |
if (lowAndHigh.length === 1 && hasStep) { | |
end = bounds.max; | |
} else if (lowAndHigh.length === 1 && !hasStep) { | |
end = begin; | |
} else { | |
end = parseInt(lowAndHigh[1]); | |
} | |
if (Number.isNaN(end)) { | |
throw new InvalidCronExpression(); | |
} | |
if (begin > bounds.max || begin < bounds.min) { | |
throw new InvalidCronExpression(); | |
} | |
if (end > bounds.max || end < bounds.min) { | |
throw new InvalidCronExpression(); | |
} | |
if (begin > end) { | |
throw new InvalidCronExpression(); | |
} | |
let step = 1; | |
if (hasStep) { | |
step = parseInt(rangeAndStep[1]); | |
} | |
if (Number.isNaN(step)) { | |
throw new InvalidCronExpression(); | |
} | |
return this.createMap(begin, end, step); | |
} | |
static createMap(begin: number, end: number, step: number): number[] { | |
const arr: number[] = []; | |
for (let i = begin; i <= end; i += step) { | |
arr.push(i); | |
} | |
return arr; | |
} | |
next(date: Date): Date | null { | |
let t = moment(date) | |
.tz(this.timezone) | |
.second(0) | |
.millisecond(0) | |
.add(1, 'minute'); | |
const maxYear = moment(t).add(5, 'years').year(); | |
while (t.year() <= maxYear) { | |
if (this.months[t.month() + 1] === 0) { | |
let i = t.month() + 1 + 1; | |
for (i; i < this.months.length; i++) { | |
if (this.months[i] === 1) { | |
break; | |
} | |
} | |
if (i >= this.months.length) { | |
t = t.month(1).date(1).hour(0).minute(0).add(1, 'year'); | |
continue; | |
} | |
t = t.date(1).hour(0).minute(0); | |
const diff = i - (t.month() + 1); | |
t.add(diff, 'months'); | |
} | |
const month = t.month() + 1; | |
if (this.dom[t.date()] === 0 || this.dow[t.weekday()] === 0) { | |
const daysInMonth = t.daysInMonth(); | |
let i = t.date() + 1; | |
for (i; i <= daysInMonth; i++) { | |
if (this.dom[i] === 1) { | |
break; | |
} | |
} | |
if (i >= daysInMonth) { | |
t = t.date(1).hour(0).minute(0).add(1, 'month'); | |
continue; | |
} | |
t = t.hour(0).minute(0); | |
const diff = i - t.date(); | |
t.add(diff, 'days'); | |
if (t.month() + 1 > month) { | |
t = t.date(1); | |
continue; | |
} | |
if (this.dow[t.weekday()] === 0) { | |
continue; | |
} | |
} | |
if (this.hours[t.hour()] === 0) { | |
let i = t.hour() + 1; | |
for (i; i < this.hours.length; i++) { | |
if (this.hours[i] === 1) { | |
break; | |
} | |
} | |
if (i >= this.hours.length) { | |
t = t.hour(0).minute(0).add(1, 'day'); | |
continue; | |
} | |
t = t.minute(0); | |
const diff = i - t.hour(); | |
t.add(diff, 'hours'); | |
} | |
if (this.minutes[t.minute()] === 0) { | |
let i = t.minute() + 1; | |
for (i; i < this.minutes.length; i++) { | |
if (this.minutes[i] === 1) { | |
break; | |
} | |
} | |
if (i >= this.minutes.length) { | |
t = t.minute(0).add(1, 'hour'); | |
continue; | |
} | |
const diff = i - t.minute(); | |
t.add(diff, 'minutes'); | |
} | |
return t.toDate(); | |
} | |
return null; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment