--[[ https://github.com/overextended/ox_lib This file is licensed under LGPL-3.0 or higher Copyright © 2025 Linden ]] lib.cron = {} ---@alias Date { year: number, month: number, day: number, hour: number, min: number, sec: number, wday: number, yday: number, isdst: boolean } ---@type Date local currentDate = {} setmetatable(currentDate, { __index = function(self, index) local newDate = os.date('*t') --[[@as Date]] for k, v in pairs(newDate) do self[k] = v end SetTimeout(1000, function() table.wipe(self) end) return self[index] end }) ---@class OxTaskProperties ---@field minute? number|string|function ---@field hour? number|string|function ---@field day? number|string|function ---@field month? number|string|function ---@field year? number|string|function ---@field weekday? number|string|function ---@field job fun(task: OxTask, date: osdate) ---@field isActive boolean ---@field id number ---@field debug? boolean ---@field lastRun? number ---@field maxDelay? number Maximum allowed delay in seconds before skipping (0 to disable) ---@class OxTask : OxTaskProperties ---@field expression string ---@field private scheduleTask fun(self: OxTask): boolean? local OxTask = {} OxTask.__index = OxTask local validRanges = { min = { min = 0, max = 59 }, hour = { min = 0, max = 23 }, day = { min = 1, max = 31 }, month = { min = 1, max = 12 }, wday = { min = 0, max = 7 }, } local maxUnits = { min = 60, hour = 24, wday = 7, day = 31, month = 12, } local weekdayMap = { sun = 1, mon = 2, tue = 3, wed = 4, thu = 5, fri = 6, sat = 7, } local monthMap = { jan = 1, feb = 2, mar = 3, apr = 4, may = 5, jun = 6, jul = 7, aug = 8, sep = 9, oct = 10, nov = 11, dec = 12 } ---Returns the last day of the specified month ---@param month number ---@param year? number ---@return number local function getMaxDaysInMonth(month, year) return os.date('*t', os.time({ year = year or currentDate.year, month = month + 1, day = -1 })).day --[[@as number]] end ---@param value string|number ---@param unit string ---@return boolean local function isValueInRange(value, unit) local range = validRanges[unit] if not range then return true end return value >= range.min and value <= range.max end ---@param value string ---@param unit string ---@return number|string|function|nil local function parseCron(value, unit) if not value or value == '*' then return end if unit == 'day' and value:lower() == 'l' then return function() return getMaxDaysInMonth(currentDate.month, currentDate.year) end end local num = tonumber(value) if num then if not isValueInRange(num, unit) then error(("^1invalid cron expression. '%s' is out of range for %s^0"):format(value, unit), 3) end return num end if unit == 'wday' then local start, stop = value:match('(%a+)-(%a+)') if start and stop then start = weekdayMap[start:lower()] stop = weekdayMap[stop:lower()] if start and stop then if stop < start then stop = stop + 7 end return ('%d-%d'):format(start, stop) end end local day = weekdayMap[value:lower()] if day then return day end end if unit == 'month' then local months = {} for month in value:gmatch('[^,]+') do local monthNum = monthMap[month:lower()] if monthNum then months[#months + 1] = tostring(monthNum) end end if #months > 0 then return table.concat(months, ',') end end local stepMatch = value:match('^%*/(%d+)$') if stepMatch then local step = tonumber(stepMatch) if not step or step == 0 then error(("^1invalid cron expression. Step value cannot be %s^0"):format(step or 'nil'), 3) end return value end local start, stop = value:match('^(%d+)-(%d+)$') if start and stop then start, stop = tonumber(start), tonumber(stop) if not start or not stop or not isValueInRange(start, unit) or not isValueInRange(stop, unit) then error(("^1invalid cron expression. Range '%s' is invalid for %s^0"):format(value, unit), 3) end return value end local valid = true for item in value:gmatch('[^,]+') do local num = tonumber(item) if not num or not isValueInRange(num, unit) then valid = false break end end if valid then return value end error(("^1invalid cron expression. '%s' is not supported for %s^0"):format(value, unit), 3) end ---@param value string|number|function|nil ---@param unit string ---@return number|false|nil local function getTimeUnit(value, unit) local currentTime = currentDate[unit] if not value then return unit == 'min' and currentTime + 1 or currentTime end if type(value) == 'function' then return value() end local unitMax = maxUnits[unit] if type(value) == 'string' then local stepValue = string.match(value, '*/(%d+)') if stepValue then local step = tonumber(stepValue) for i = currentTime + 1, unitMax do if i % step == 0 then return i end end return step + unitMax end local range = string.match(value, '%d+-%d+') if range then local min, max = string.strsplit('-', range) min, max = tonumber(min, 10), tonumber(max, 10) if unit == 'min' then if currentTime >= max then return min + unitMax end elseif currentTime > max then return min + unitMax end return currentTime < min and min or currentTime end local list = string.match(value, '%d+,%d+') if list then local values = {} for listValue in string.gmatch(value, '%d+') do values[#values + 1] = tonumber(listValue) end table.sort(values) for i = 1, #values do local listValue = values[i] if unit == 'min' then if currentTime < listValue then return listValue end elseif currentTime <= listValue then return listValue end end return values[1] + unitMax end return false end if unit == 'min' then return value <= currentTime and value + unitMax or value --[[@as number]] end return value < currentTime and value + unitMax or value --[[@as number]] end ---@return number? function OxTask:getNextTime() if not self.isActive then return end local day = getTimeUnit(self.day, 'day') if day == 0 then day = getMaxDaysInMonth(currentDate.month) end if day ~= currentDate.day then return end local month = getTimeUnit(self.month, 'month') if month ~= currentDate.month then return end local weekday = getTimeUnit(self.weekday, 'wday') if weekday and weekday ~= currentDate.wday then return end local minute = getTimeUnit(self.minute, 'min') if not minute then return end local hour = getTimeUnit(self.hour, 'hour') if not hour then return end if minute >= maxUnits.min then if not self.hour then hour += math.floor(minute / maxUnits.min) end minute = minute % maxUnits.min end if hour >= maxUnits.hour and day then if not self.day then day += math.floor(hour / maxUnits.hour) end hour = hour % maxUnits.hour end local nextTime = os.time({ min = minute, hour = hour, day = day or currentDate.day, month = month or currentDate.month, year = currentDate.year, }) if self.lastRun and nextTime - self.lastRun < 60 then if self.debug then lib.print.debug(('Preventing duplicate execution of task %s - Last run: %s, Next scheduled: %s'):format( self.id, os.date('%c', self.lastRun), os.date('%c', nextTime) )) end return end return nextTime end ---@return number function OxTask:getAbsoluteNextTime() local minute = getTimeUnit(self.minute, 'min') local hour = getTimeUnit(self.hour, 'hour') local day = getTimeUnit(self.day, 'day') local month = getTimeUnit(self.month, 'month') local year = getTimeUnit(self.year, 'year') if self.day then if currentDate.hour < hour or (currentDate.hour == hour and currentDate.min < minute) then day = day - 1 if day < 1 then day = getMaxDaysInMonth(currentDate.month) end end if currentDate.hour > hour or (currentDate.hour == hour and currentDate.min >= minute) then day = day + 1 if day > getMaxDaysInMonth(currentDate.month) or day == 1 then day = 1 month = month + 1 end end end ---@diagnostic disable-next-line: assign-type-mismatch if os.time({ year = year, month = month, day = day, hour = hour, min = minute }) < os.time() then year = year and year + 1 or currentDate.year + 1 end return os.time({ min = minute < 60 and minute or 0, hour = hour < 24 and hour or 0, day = day or currentDate.day, month = month or currentDate.month, year = year or currentDate.year, }) end function OxTask:getTimeAsString(timestamp) return os.date('%A %H:%M, %d %B %Y', timestamp or self:getAbsoluteNextTime()) end ---@type OxTask[] local tasks = {} function OxTask:scheduleTask() local runAt = self:getNextTime() if not runAt then return self:stop('getNextTime returned no value') end local currentTime = os.time() local sleep = runAt - currentTime if sleep < 0 then if not self.maxDelay or -sleep > self.maxDelay then return self:stop(self.debug and ('scheduled time expired %s seconds ago'):format(-sleep)) end if self.debug then lib.print.debug(('Task %s is %s seconds overdue, executing now due to maxDelay=%s'):format( self.id, -sleep, self.maxDelay )) end sleep = 0 end local timeAsString = self:getTimeAsString(runAt) if self.debug then lib.print.debug(('(%s) task %s will run in %d seconds (%0.2f minutes / %0.2f hours)'):format(timeAsString, self.id, sleep, sleep / 60, sleep / 60 / 60)) end if sleep > 0 then Wait(sleep * 1000) else Wait(0) return true end if self.isActive then if self.debug then lib.print.debug(('(%s) running task %s'):format(timeAsString, self.id)) end Citizen.CreateThreadNow(function() self:job(currentDate) self.lastRun = os.time() end) return true end end function OxTask:run() if self.isActive then return end self.isActive = true CreateThread(function() while self:scheduleTask() do end end) end function OxTask:stop(msg) self.isActive = false if self.debug then if msg then return lib.print.debug(('stopping task %s (%s)'):format(self.id, msg)) end lib.print.debug(('stopping task %s'):format(self.id)) end end ---@param expression string A cron expression such as `* * * * *` representing minute, hour, day, month, and day of the week. ---@param job fun(task: OxTask, date: osdate) ---@param options? { debug?: boolean } ---Creates a new [cronjob](https://en.wikipedia.org/wiki/Cron), scheduling a task to run at fixed times or intervals. ---Supports numbers, any value `*`, lists `1,2,3`, ranges `1-3`, and steps `*/4`. ---Day of the week is a range of `1-7` starting from Sunday and allows short-names (i.e. sun, mon, tue). ---@note maxDelay: Maximum allowed delay in seconds before skipping (0 to disable) function lib.cron.new(expression, job, options) if not job or type(job) ~= 'function' then error(("expected job to have type 'function' (received %s)"):format(type(job))) end local minute, hour, day, month, weekday = string.strsplit(' ', string.lower(expression)) ---@type OxTask local task = setmetatable(options or {}, OxTask) task.expression = expression task.minute = parseCron(minute, 'min') task.hour = parseCron(hour, 'hour') task.day = parseCron(day, 'day') task.month = parseCron(month, 'month') task.weekday = parseCron(weekday, 'wday') task.id = #tasks + 1 task.job = job task.lastRun = nil task.maxDelay = task.maxDelay or 1 tasks[task.id] = task task:run() return task end -- reschedule any dead tasks on a new day lib.cron.new('0 0 * * *', function() for i = 1, #tasks do local task = tasks[i] if not task.isActive then task:run() end end end) return lib.cron