forked from Simnation/Main
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
|