Skip to content

Instantly share code, notes, and snippets.

@emanuelet
Last active September 29, 2023 22:22
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save emanuelet/65bd1c590265b622dcc38e9b856a410f to your computer and use it in GitHub Desktop.
Save emanuelet/65bd1c590265b622dcc38e9b856a410f to your computer and use it in GitHub Desktop.
Convert RRULE string to Cron expression (with output for Bull Repeated jobs)
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