Files
ox_lib/imports/cron/server.lua
Linden fb2225e373 chore: add copyright notices to each file
Some people accidentally, mistakenly, and without any malicious intent forget to attribute code from this resource to its source. This change will hopefully assist people, so that they do not make that mistake again.
2025-03-18 18:46:09 +11:00

474 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 (c) 2025 Linden <https://github.com/thelindat/fivem>
]]
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