473 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			Lua
		
	
	
	
	
	
			
		
		
	
	
			473 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			Lua
		
	
	
	
	
	
| --[[
 | |
|     https://github.com/overextended/ox_lib
 | |
| 
 | |
|     This file is licensed under LGPL-3.0 or higher <https://www.gnu.org/licenses/lgpl-3.0.en.html>
 | |
| 
 | |
|     Copyright © 2025 Linden <https://github.com/thelindat>
 | |
| ]]
 | |
| 
 | |
| 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
 | 
