@softvisio/core
Version:
Softisio core
470 lines (381 loc) • 12.7 kB
JavaScript
import "#lib/temporal";
// NOTE: https://www.man7.org/linux/man-pages/man5/crontab.5.html
const PRESETS = {
"@yearly": "0 0 1 1 *",
"@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0",
"@daily": "0 0 * * *",
"@hourly": "0 * * * *",
"@minutely": "* * * * *",
"@secondly": "* * * * * *",
"@weekdays": "0 0 * * 1-5",
"@weekends": "0 0 * * 0,6",
};
const FIELDS = [
{
"name": "seconds",
"min": 0,
"max": 59,
},
{
"name": "minutes",
"min": 0,
"max": 59,
},
{
"name": "hours",
"min": 0,
"max": 23,
},
{
"name": "days",
"min": 1,
"max": 31,
},
{
"name": "months",
"min": 1,
"max": 12,
"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,
},
},
{
"name": "daysOfWeek",
"min": 0,
"max": 6,
"names": {
"sun": 0,
"mon": 1,
"tue": 2,
"wed": 3,
"thu": 4,
"fri": 5,
"sat": 6,
},
},
];
const FIELDS_INDEX = Object.fromEntries( FIELDS.map( field => {
return [ field.name, field ];
} ) );
export default class CronExpression {
#expression;
#timezone;
#ignoreSeconds;
#hasSeconds;
#fields = {};
constructor ( expression, { timezone, ignoreSeconds } = {} ) {
this.#expression = expression;
this.#ignoreSeconds = !!ignoreSeconds;
if ( timezone ) {
this.#timezone = Temporal.Now.zonedDateTimeISO( timezone ).timeZoneId;
}
else {
this.#timezone = Temporal.Now.timeZoneId();
}
// resolve presets
if ( PRESETS[ expression ] ) expression = PRESETS[ expression ];
const fields = expression.split( " " );
if ( fields.length < 5 || fields.length > 6 ) {
throw new Error( `Cron expression is not valid: ${ expression }` );
}
if ( fields.length === 6 ) {
if ( this.#ignoreSeconds ) {
fields.shift();
}
}
if ( fields.length === 5 ) {
this.#hasSeconds = false;
}
else {
this.#hasSeconds = true;
}
for ( let n = 0; n < fields.length; n++ ) {
this.#parseField( fields[ n ], FIELDS[ n + ( this.#hasSeconds
? 0
: 1 ) ] );
}
}
// static
static isValid ( value, options ) {
try {
new this( value, options );
return true;
}
catch {
return false;
}
}
// properties
get timezone () {
return this.#timezone;
}
get ignoreSeconds () {
return this.#ignoreSeconds;
}
get hasSeconds () {
return this.#hasSeconds;
}
// public
toString () {
return this.#expression;
}
toJSON () {
return this.toString();
}
getSchedule ( { fromDate, maxItems = 1 } = {} ) {
if ( fromDate ) {
fromDate = fromDate.toTemporalInstant().toZonedDateTimeISO( this.#timezone );
}
else {
fromDate = Temporal.Now.instant().toZonedDateTimeISO( this.#timezone );
}
if ( this.#hasSeconds ) {
// round to the seconds
fromDate = fromDate.round( {
"smallestUnit": "seconds",
"roundingMode": "ceil",
} );
}
else {
// round to the minutes
fromDate = fromDate.round( {
"smallestUnit": "minutes",
"roundingMode": "ceil",
} );
}
const dates = [];
for ( let n = 0; n < maxItems; n++ ) {
fromDate = this.#getNextDate( fromDate );
dates.push( new Date( fromDate.epochMilliseconds ) );
if ( this.#hasSeconds ) {
fromDate = fromDate.add( { "seconds": 1 } );
}
else {
fromDate = fromDate.add( { "minutes": 1 } );
}
}
return dates;
}
// private
#parseField ( fieldValue, field ) {
var restricted = false;
const values = new Array( field.max + 1 );
const ranges = fieldValue.split( "," );
for ( const range of ranges ) {
var [ body, step ] = range.split( "/" );
if ( step ) {
step = +step;
if ( !Number.isInteger( step ) ) this.#throwError( fieldValue, field );
}
else {
step = 0;
}
let start, end, random;
// random value
if ( body.includes( "~" ) ) {
random = true;
restricted = true;
[ start, end ] = body.split( "~" );
if ( !start ) {
start = field.min;
}
else if ( start === "*" ) {
this.#throwError( fieldValue, field );
}
if ( !end ) {
end = field.max;
}
else if ( end === "*" ) {
this.#throwError( fieldValue, field );
}
}
// range
else {
[ start, end ] = body.split( "-" );
if ( !start ) {
this.#throwError( fieldValue, field );
}
else if ( start === "*" ) {
start = field.min;
end = field.max;
}
// field is restricted
else {
restricted = true;
}
if ( !end ) end = null;
}
if ( field.names?.[ start ] != null ) start = field.names[ start ];
start = +start;
if ( !Number.isInteger( start ) ) this.#throwError( fieldValue, field );
// special case fordaysOfWeek, map 7 to 0
if ( start === 7 && field.name === "daysOfWeek" ) {
start = 0;
}
if ( start < field.min ) this.#throwError( fieldValue, field );
if ( end != null ) {
if ( field.names?.[ end ] != null ) end = field.names[ end ];
end = +end;
if ( !Number.isInteger( end ) ) this.#throwError( fieldValue, field );
// special case fordaysOfWeek, map 7 to 0
if ( end === 7 && field.name === "daysOfWeek" ) {
end = start;
start = 0;
}
if ( end > field.max ) this.#throwError( fieldValue, field );
if ( end < start ) this.#throwError( fieldValue, field );
if ( step > end - start ) this.#throwError( fieldValue, field );
if ( random ) {
start = this.#getRandomValue( start, end );
end = null;
}
}
// single value
if ( end == null ) {
if ( step ) this.#throwError( fieldValue, field );
values[ start ] = true;
}
// range
else {
for ( let n = 0; n <= end - start; n++ ) {
if ( n % step ) continue;
values[ start + n ] = true;
}
}
}
this.#fields[ field.name ] = {
restricted,
values,
"add": new Array( values.length ),
};
}
#getRandomValue ( min, max ) {
if ( min === max ) return min;
return Math.floor( Math.random() * ( max - min + 1 ) ) + min;
}
#throwError ( fieldValue, field ) {
throw new Error( `Cron expression field ${ field.name } is not valid: ${ fieldValue }` );
}
#getNextDate ( date ) {
while ( true ) {
// current month is not allowed
if ( !this.#fields.months.values[ date.month ] ) {
date = date
.add( {
"months": this.#getAddValue( date.month, FIELDS_INDEX.months ),
} )
.with( {
"day": 1,
} )
.round( {
"smallestUnit": "days",
"roundingMode": "floor",
} );
}
let addDays = 0,
addDaysOfWeek = 0;
// current day of month is not allowed
if ( !this.#fields.days.values[ date.day ] ) {
addDays = this.#getAddValue( date.day, FIELDS_INDEX.days );
}
// current day of week is not allowed
if ( !this.#fields.daysOfWeek.values[ date.dayOfWeek === 7
? 0
: date.dayOfWeek ] ) {
addDaysOfWeek = this.#getAddValue( date.dayOfWeek === 7
? 0
: date.dayOfWeek, FIELDS_INDEX.daysOfWeek );
}
// current day or day of week are not allowed
ADD_DAYS: if ( addDays || addDaysOfWeek ) {
// if days are restrictes
// or days of week are restricted
// match days OR day of week
if ( this.#fields.days.restricted && this.#fields.daysOfWeek.restricted ) {
// current day or day of week are allowed
if ( !addDays || !addDaysOfWeek ) {
break ADD_DAYS;
}
}
date = date
.add( {
"days": addDays
? ( addDaysOfWeek
? ( addDays < addDaysOfWeek
? addDays
: addDaysOfWeek )
: addDays )
: addDaysOfWeek,
} )
.round( {
"smallestUnit": "days",
"roundingMode": "floor",
} );
continue;
}
// current hour is not allowed
if ( !this.#fields.hours.values[ date.hour ] ) {
date = date
.add( {
"hours": this.#getAddValue( date.hour, FIELDS_INDEX.hours ),
} )
.round( {
"smallestUnit": "hours",
"roundingMode": "floor",
} );
continue;
}
// current minute is not allowed
if ( !this.#fields.minutes.values[ date.minute ] ) {
date = date
.add( {
"minutes": this.#getAddValue( date.minute, FIELDS_INDEX.minutes ),
} )
.round( {
"smallestUnit": "minutes",
"roundingMode": "floor",
} );
continue;
}
// current second is not allowed
if ( this.#fields.seconds && !this.#fields.seconds.values[ date.second ] ) {
date = date.add( {
"seconds": this.#getAddValue( date.second, FIELDS_INDEX.seconds ),
} );
continue;
}
// found date, which is match all conditions
break;
}
return date;
}
#getAddValue ( currentValue, field ) {
var add = 0,
values = this.#fields[ field.name ].values;
// return cached
if ( this.#fields[ field.name ].add[ currentValue ] ) {
return this.#fields[ field.name ].add[ currentValue ];
}
for ( let n = currentValue + 1; n < values.length; n++ ) {
add++;
if ( values[ n ] ) return ( this.#fields[ field.name ].add[ currentValue ] = add );
}
for ( let n = field.min; n < currentValue; n++ ) {
add++;
if ( values[ n ] ) return ( this.#fields[ field.name ].add[ currentValue ] = add );
}
throw new Error( "Cron unable to find next value" );
}
}