Last active
September 18, 2024 14:44
-
-
Save emanuelet/65bd1c590265b622dcc38e9b856a410f to your computer and use it in GitHub Desktop.
Convert RRULE string to Cron expression (with output for Bull Repeated jobs)
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
const moment = require('moment-timezone') | |
const logger = require('tracer').colorConsole() | |
const { RRule, RRuleSet, rrulestr } = require('rrule') | |
function untilStringToDate(until) { | |
const re = /^(\d{4})(\d{2})(\d{2})(T(\d{2})(\d{2})(\d{2})Z?)?$/ | |
const bits = re.exec(until) | |
if (!bits) throw new Error(`Invalid UNTIL value: ${until}`) | |
return new Date( | |
Date.UTC( | |
parseInt(bits[1], 10), | |
parseInt(bits[2], 10) - 1, | |
parseInt(bits[3], 10), | |
parseInt(bits[5], 10) || 0, | |
parseInt(bits[6], 10) || 0, | |
parseInt(bits[7], 10) || 0 | |
) | |
) | |
} | |
function rtoc(r) { | |
let FREQ = '' | |
let DTSTART = '' | |
let INTERVAL = -1 | |
let BYMONTHDAY = -1 | |
let BYMONTH = -1 | |
let BYDAY = '' | |
let BYSETPOS = 0 | |
let BYHOUR = 0 | |
let BYMINUTE = 0 | |
r = r.includes('DTSTART') | |
? r.replace(/\n.*RRULE:/, ';') | |
: r.replace('RRULE:', '') | |
let tzid | |
if (r.includes('TZID')) { | |
let dtstart | |
let tzAndStamp = r.match(/TZID=(.*?);/)[1] | |
;[tzid, dtstart] = tzAndStamp.split(':') | |
r = r.replace(/TZID=(.*?);/, dtstart) | |
r = r.replace('DTSTART', 'DTSTART:') | |
} | |
const C_DAYS_OF_WEEK_RRULE = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] | |
const C_DAYS_WEEKDAYS_RRULE = ['MO', 'TU', 'WE', 'TH', 'FR'] | |
const C_DAYS_OF_WEEK_CRONE = ['2', '3', '4', '5', '6', '7', '1'] | |
const C_DAYS_OF_WEEK_CRONE_NAMED = [ | |
'MON', | |
'TUE', | |
'WED', | |
'THU', | |
'FRI', | |
'SAT', | |
'SUN', | |
] | |
const C_MONTHS = [ | |
'JAN', | |
'FEB', | |
'MAR', | |
'APR', | |
'MAY', | |
'JUN', | |
'JUL', | |
'AUG', | |
'SEP', | |
'OCT', | |
'NOV', | |
'DEC', | |
] | |
// let dayTime = '* *' | |
let dayTime = '0 0 0' | |
let dayOfMonth = '?' | |
let month = '*' | |
let dayOfWeek = '?' | |
let rarr = r.split(';') | |
for (let i = 0; i < rarr.length; i++) { | |
let param = rarr[i].includes('=') | |
? rarr[i].split('=')[0] | |
: rarr[i].split(':')[0] | |
let value = rarr[i].includes('=') | |
? rarr[i].split('=')[1] | |
: rarr[i].split(':')[1] | |
if (param === 'FREQ') FREQ = value | |
if (param === 'DTSTART') DTSTART = value | |
if (param === 'INTERVAL') INTERVAL = parseInt(value) | |
if (param === 'BYMONTHDAY') BYMONTHDAY = parseInt(value) | |
if (param === 'BYDAY') BYDAY = value | |
if (param === 'BYSETPOS') BYSETPOS = parseInt(value) | |
if (param === 'BYMONTH') BYMONTH = parseInt(value) | |
if (param === 'BYHOUR') BYHOUR = parseInt(value) | |
if (param === 'BYMINUTE') BYMINUTE = parseInt(value) | |
} | |
if (DTSTART !== '') { | |
// If a tzid is in the rrule it means that the DTSTART is | |
// a floating timezone in the tzid timezone. | |
// so we parse it as being in that timezone and get the UTC | |
// time so that the notification can be at the correct time | |
if (tzid) { | |
const dtstart = moment.tz(DTSTART, 'YYYYMMDDTHHmmss', tzid).utc() | |
DTSTART = dtstart.format('YYYYMMDDTHHmmss') | |
} | |
DTSTART = untilStringToDate(DTSTART) | |
dayTime = `0 ${DTSTART.getUTCMinutes()} ${DTSTART.getUTCHours()}` | |
} | |
if (BYHOUR !== 0 || BYMINUTE !== 0) { | |
dayTime = `0 ${BYMINUTE} ${BYHOUR}` | |
} | |
switch (FREQ) { | |
case 'MONTHLY': | |
if (INTERVAL === 1) { | |
month = '*' // every month | |
} else { | |
month = '1/' + INTERVAL // 1 - start of january, every INTERVALth month | |
} | |
if (BYMONTHDAY === -1 && DTSTART !== '') { | |
dayOfMonth = DTSTART.getUTCDate() | |
} else if (BYMONTHDAY !== -1) { | |
dayOfMonth = BYMONTHDAY.toString() | |
} else if (BYSETPOS !== 0) { | |
if (BYDAY === '') { | |
logger.error('No BYDAY specified for MONTHLY/BYSETPOS rule') | |
return INCASE_NOT_SUPPORTED | |
} | |
if (BYDAY === 'MO,TU,WE,TH,FR') { | |
if (BYSETPOS === 1) { | |
// First weekday of every month | |
// "FREQ=MONTHLY;INTERVAL=1;BYSETPOS=1;BYDAY=MO,TU,WE,TH,FR", | |
dayOfMonth = '1W' | |
} else if (BYSETPOS === -1) { | |
// Last weekday of every month | |
// "FREQ=MONTHLY;INTERVAL=1;BYSETPOS=-1;BYDAY=MO,TU,WE,TH,FR", | |
dayOfMonth = 'LW' | |
} else { | |
logger.error( | |
'Unsupported Xth weekday for MONTHLY rule (only 1st and last weekday are supported)' | |
) | |
return INCASE_NOT_SUPPORTED | |
} | |
} else if (C_DAYS_OF_WEEK_RRULE.indexOf(BYDAY) === -1) { | |
logger.error( | |
'Unsupported BYDAY rule (multiple days are not supported by crone): ' + | |
BYDAY | |
) | |
return INCASE_NOT_SUPPORTED | |
} else { | |
dayOfMonth = '?' | |
if (BYSETPOS > 0) { | |
// 3rd friday = BYSETPOS=3;BYDAY=FR in RRULE, 6#3 | |
dayOfWeek = | |
C_DAYS_OF_WEEK_CRONE[C_DAYS_OF_WEEK_RRULE.indexOf(BYDAY)] + | |
'#' + | |
BYSETPOS.toString() | |
} else { | |
// last specific day | |
dayOfWeek = | |
C_DAYS_OF_WEEK_CRONE[C_DAYS_OF_WEEK_RRULE.indexOf(BYDAY)] + 'L' | |
} | |
} | |
} else { | |
logger.error('No BYMONTHDAY or BYSETPOS in MONTHLY rrule') | |
return INCASE_NOT_SUPPORTED | |
} | |
break | |
case 'WEEKLY': | |
if (INTERVAL != 1) { | |
logger.error('every X week different from 1st is not supported') | |
return INCASE_NOT_SUPPORTED | |
} | |
if ( | |
BYDAY.split(',').sort().join(',') === | |
C_DAYS_OF_WEEK_RRULE.concat().sort().join(',') | |
) { | |
dayOfWeek = '*' // all days of week | |
} else { | |
let arrByDayRRule = BYDAY.split(',') | |
let arrByDayCron = [] | |
for (let i = 0; i < arrByDayRRule.length; i++) { | |
let indexOfDayOfWeek = C_DAYS_OF_WEEK_RRULE.indexOf(arrByDayRRule[i]) | |
arrByDayCron.push(C_DAYS_OF_WEEK_CRONE_NAMED[indexOfDayOfWeek]) | |
} | |
dayOfWeek = arrByDayCron.join(',') | |
} | |
break | |
case 'daily': | |
if (INTERVAL != 1) { | |
dayOfMonth = '1/' + INTERVAL.toString() | |
} | |
break | |
case 'YEARLY': | |
if (BYMONTH === -1) { | |
logger.error('Missing BYMONTH in YEARLY rule') | |
return INCASE_NOT_SUPPORTED | |
} | |
month = C_MONTHS[BYMONTH - 1] | |
if (BYMONTHDAY != -1) { | |
// FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=2 // 2nd day of March | |
dayOfMonth = BYMONTHDAY | |
} else { | |
if (BYSETPOS === -1) { | |
if ( | |
BYDAY.split(',').sort().join(',') === | |
C_DAYS_OF_WEEK_RRULE.concat().sort().join(',') | |
) { | |
dayOfMonth = 'L' | |
} else if ( | |
BYDAY.split(',').sort().join(',') === | |
C_DAYS_WEEKDAYS_RRULE.concat().sort().join(',') | |
) { | |
dayOfMonth = 'LW' | |
} else { | |
logger.error( | |
'Last weekends and just last specific days of Month are not supported' | |
) | |
return INCASE_NOT_SUPPORTED | |
} | |
} else { | |
if ( | |
BYDAY.split(',').sort().join(',') === | |
C_DAYS_WEEKDAYS_RRULE.concat().sort().join(',') && | |
BYSETPOS === 1 | |
) { | |
dayOfMonth = BYSETPOS.toString() + 'W' | |
} else if (BYDAY.split(',').length === 1) { | |
dayOfWeek = | |
C_DAYS_OF_WEEK_CRONE[C_DAYS_OF_WEEK_RRULE.indexOf(BYDAY)] + | |
'#' + | |
BYSETPOS.toString() | |
} else { | |
logger.error('Multiple days are not supported in YEARLY rule') | |
return INCASE_NOT_SUPPORTED | |
} | |
} | |
} | |
break | |
default: | |
return INCASE_NOT_SUPPORTED | |
} | |
return `${dayTime} ${dayOfMonth} ${month} ${dayOfWeek}` | |
} | |
function convertToCron(repeat_rule, startDate, endDate) { | |
let rrule = rrulestr(repeat_rule) | |
let res = rtoc(repeat_rule) | |
if (!endDate && rrule.options.count !== null) { | |
let occurences = rrule.all() | |
endDate = occurences[occurences.length - 1] | |
} | |
if (res === INCASE_NOT_SUPPORTED) { | |
let occurences, limit | |
if (rrule.options.until === null && rrule.options.count === null) { | |
// infinite repeat - fetch first 2 occurences | |
occurences = rrule.all((date, i) => i < 2) | |
} else { | |
occurences = rrule.all() | |
limit = occurences.length | |
} | |
if (endDate) { | |
occurences = rrule.between(new Date(), new Date(endDate), true) | |
limit = occurences.length | |
} | |
if (occurences.length === 0) { | |
return false | |
} | |
if (occurences.length === 1) { | |
// if there is only one occurence left I trigger the job as a delayed one | |
const now = moment() | |
const then = moment(occurences[0]) | |
return { delay: then.diff(now, 'milliseconds') } | |
} | |
return { | |
repeat: { | |
every: moment(occurences[1]).diff( | |
moment(occurences[0]), | |
'milliseconds' | |
), | |
...(limit && { limit }), | |
}, | |
} | |
} | |
return { repeat: { cron: rtoc(repeat_rule), startDate, endDate } } | |
} | |
module.exports = { | |
rtoc, | |
convertToCron, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment