yl_scheduler/internal.lua
2024-05-28 00:16:01 +02:00

709 lines
22 KiB
Lua

-- The functions and variables in this file are only for use in the mod itself.
-- Those that do real work should be local and wrapped in public functions
local debug = yl_scheduler.settings.debug or true
local function say(text)
if debug then minetest.log("action", "[MOD] yl_scheduler : " .. text) end
end
-- Helpers
local function filename2uuid(filename) return filename:sub(1, -6) end
local function is_visible(filename) return (string.sub(filename, 1, 1) ~= ".") end
local function is_json(filename) return (filename:match("%.json$")) end
local function generate_uuid()
local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
return string.gsub(template, '[xy]', function(c)
local v = (c == 'x') and math.random(0, 15) or math.random(8, 11)
return string.format('%x', v)
end)
end
local function is_uuid_duplicate(UUID)
for i, task in ipairs(yl_scheduler.tasks) do
say("is_uuid_duplicate task.id=" .. dump(task.id) .. ", UUID=" ..
dump(UUID))
if task.id == UUID then return true end
end
return false
end
local function create_uuid()
local max_attempts = 10
local UUID
repeat
UUID = generate_uuid()
max_attempts = max_attempts - 1
if max_attempts < 0 then
return false, "Cannot find non-duplicate UUID"
end
until (is_uuid_duplicate(UUID) == false)
if UUID == "" then return false, "UUID empty" end
return true, UUID
end
function yl_scheduler.create_uuid() return create_uuid() end
local function string_to_boolean(value)
if value == "true" then
return true
elseif value == "false" then
return false
else
return nil
end
end
local function string_to_table(value)
-- TODO: implement that somehow
return {}
end
-- taken from yl_cinema
-- TODO: Shoudl we API-fy this?
local function format_table(t)
-- Format of t must be {{row1,row2,row3, ...},{row1,row2,row3, ...},...}
local blanks_between_rows = 3
local max_row_length = {}
for linenumber = 1, #t do
for rownumber = 1, #t[linenumber] do
local row_length = #tostring(t[linenumber][rownumber])
if (max_row_length[rownumber] or 0) < row_length then
max_row_length[rownumber] = row_length
end
end
end
local ret = {}
for linenumber = 1, #t do
local line_s = ""
for rownumber = 1, #t[linenumber] do
local text = t[linenumber][rownumber]
local text_length = #tostring(text)
local add_blanks = max_row_length[rownumber] - text_length
local newtext = t[linenumber][rownumber]
for add = 1, (add_blanks + blanks_between_rows) do
newtext = newtext .. " "
end
line_s = line_s .. newtext
end
table.insert(ret, line_s)
end
return table.concat(ret, "\n")
end
local function split_with_escapes(str, separator, escaper)
local ret = {}
local current = ""
local esc = false
for i = 1, #str do
local char = str:sub(i, i)
if char == escaper then
if esc == true then
current = current .. char
esc = false
else
esc = true
end
elseif char == separator then
if esc == true then
current = current .. char
esc = false
else
table.insert(ret, current)
current = ""
-- esc = true
end
else
current = current .. char
end
end
table.insert(ret, current)
return ret
end
--- ###
local function sort_by_timestamp(tasks)
local function compare(task1, task2) return task1.at < task2.at end
table.sort(tasks, compare)
return tasks
end
function yl_scheduler.sort_by_timestamp(tasks) return sort_by_timestamp(tasks) end
local function split(str)
local parts = {}
for part in str:gmatch("[^,%s]+") do table.insert(parts, part) end
return parts
end
local function ends_with(str, suffix) return str:sub(-suffix:len()) == suffix end
-- Validate values
local function maximum_timeframe_to_seconds(str)
local pattern = "(%d+)%s*([mdw])"
local amount, unit = str:match(pattern)
if (unit == "m") then
return amount * 60
elseif (unit == "d") then
return amount * 60 * 60 * 24
elseif (unit == "w") then
return amount * 60 * 60 * 24 * 7
else
return 0
end
end
local function validate_at(at)
-- Let's assume the calcuation already happened and we're dealing with a
-- unix epoch timestamp in utc. We can't detect utc though.
if (at == nil) then
return false, "at: No time given"
elseif type(at) ~= "number" then
return false, "at: Wrong type"
elseif (yl_scheduler.settings.maximum_timeframe ~= nil) and
(at > os.time() +
maximum_timeframe_to_seconds(yl_scheduler.settings.maximum_timeframe)) then
return false, "at: Not within " ..
dump(yl_scheduler.settings.maximum_timeframe)
else
return true, "at: all good"
end
end
local function validate_func(func)
-- Should we check existence of the function at set time?? No.
-- Functions may be retrofitted. We need to check their existance
-- at runtime, not during storing
if (func == nil) then
return false, "func: No func given"
elseif type(func) ~= "string" then
return false, "func: Wrong type"
else
return true, "func: all good"
end
end
local function validate_params(params)
-- We don't know much about params.
-- Could be nil, could be a table
if (type(params) ~= "nil") and (type(params) ~= "table") then
return false, "params: Wrong type"
else
return true, "params: all good"
end
end
local function validate_owner(owner)
-- Owner could be an ingame player but also a mod or mechanic
if (owner == nil) then
return false, "func: No owner given"
elseif type(owner) ~= "string" then
return false, "owner: Wrong type"
else
return true, "owner: all good"
end
end
local function validate_notes(notes)
-- Notes are optional
-- Could be nil, could be a string
if (type(notes) ~= "nil") and (type(notes) ~= "string") then
return false, "notes: Wrong type"
else
return true, "notes: all good"
end
end
local function validate(at, func, params, owner, notes)
local at_succes, at_message = validate_at(at)
if (at_succes == false) then return false, at_message end
local func_succes, func_message = validate_func(func)
if (func_succes == false) then return false, func_message end
local params_succes, params_message = validate_params(params)
if (params_succes == false) then return false, params_message end
local owner_succes, owner_message = validate_owner(owner)
if (owner_succes == false) then return false, owner_message end
local notes_succes, notes_message = validate_notes(notes)
if (notes_succes == false) then return false, notes_message end
return true, "All good"
end
function yl_scheduler.validate(at, func, params, owner, notes)
return validate(at, func, params, owner, notes)
end
-- Unmask parameters
-- We need to take in the parameter table and the mask table
-- and return a parameter table casted to the types indicated
-- by the mask
local function unmask_params(string_params, masks)
-- Defense
-- What happens if there are more parameters than masks?
-- Then we only mask the first couple and assume string for the rest
-- What happens if there are more masks than parameters?
-- Then there is clearly something wrong
-- Same number? Cool.
if #string_params < #masks then return false, "Too many masks" end
-- mask may be any of "string", "number", "table", "boolean", "nil"
for _, mask in ipairs(masks) do
if ((mask ~= "string") and (mask ~= "number") and (mask ~= "table") and
(mask ~= "nil") and (mask ~= "boolean") and (mask ~= "bool")) then
return false, "Unknown type " .. dump(mask)
end
end
local ret = {}
for i, param in ipairs(string_params) do
local mask = masks[i] and string.trim(masks[i]) or "string" -- defaults to string
param = string.trim(param)
if ((mask == "") or (mask == nil) or (mask == "string")) then
local r_param = param
if type(r_param) ~= "string" then
return false, "Cannot cast " .. param .. " to string"
end
ret[i] = r_param
elseif (mask == "number") then
local r_param = tonumber(param)
if type(r_param) ~= "number" then
return false, "Cannot cast " .. param .. " to number"
end
ret[i] = r_param
elseif (mask == "table") then
local r_param = string_to_table(param) -- TODO: implement string_to_table
if type(r_param) ~= "table" then
return false, "Cannot cast " .. param .. " to table"
end
ret[i] = r_param
elseif ((mask == "bool") or (mask == "boolean")) then
local r_param = string_to_boolean(param)
if type(r_param) ~= "boolean" then
return false, "Cannot cast " .. param .. " to boolean"
end
ret[i] = r_param
else
return false, "Unknown type " .. dump(mask)
end
end
return true, ret
end
-- Loading and Saving
local function get_savepath()
-- TODO: Can we assume the path exists?
local savepath = yl_scheduler.worldpath .. yl_scheduler.settings.save_path
say("get_savepath : " .. dump(savepath))
return savepath
end
local function get_filepath(UUID)
local path_to_file = yl_scheduler.worldpath ..
yl_scheduler.settings.save_path .. DIR_DELIM ..
UUID .. ".json"
say("get_filepath : " .. dump(UUID) .. ":" .. dump(path_to_file))
return path_to_file
end
local function save_json(UUID, content)
if type(UUID) ~= "string" or type(content) ~= "table" then return false end
local savepath = get_filepath(UUID)
local savecontent = minetest.write_json(content)
return minetest.safe_file_write(savepath, savecontent)
end
local function load_json(path)
local file = io.open(path, "r")
if not file then return false, "Error opening file: " .. path end
local content = file:read("*all")
file:close()
if not content then return false, "Error reading file: " .. path end
return true, minetest.parse_json(content)
end
-- Public functions wrap the private ones, so they can be exchanged easily
function yl_scheduler.load_json(filename, ...) return load_json(filename, ...) end
function yl_scheduler.save_json(filename, content, ...)
return save_json(filename, content, ...)
end
-- ### yl_scheduler.load_all_tasks ###
local function load_all_tasks()
-- Get all json files from savepath
-- Excluding invisible
-- Excluding non-json files
local save_path = get_savepath()
local files = minetest.get_dir_list(save_path, false) or {}
local tasks = {}
local total = 0
local good = 0
local bad = 0
for key, filename in ipairs(files) do
if is_visible(filename) and is_json(filename) then
total = total + 1
local UUID = filename2uuid(filename)
local filepath = get_filepath(UUID)
local success, content = load_json(filepath)
if success and (content.id == UUID) then
good = good + 1
table.insert(tasks, content)
else
bad = bad + 1
end
end
end
-- Sort table for "at"
yl_scheduler.tasks = sort_by_timestamp(tasks)
if bad == 0 then
minetest.log("action",
"[MOD] yl_scheduler : bad = " .. tostring(bad) ..
", good = " .. tostring(good) .. ", total = " ..
tostring(total))
return true, good, bad
else
minetest.log("warning",
"[MOD] yl_scheduler : bad = " .. tostring(bad) ..
", good = " .. tostring(good) .. ", total = " ..
tostring(total))
return false, good, bad
end
end
function yl_scheduler.load_all_tasks() return load_all_tasks() end
-- ### priv exists ###
local function priv_exists(priv)
return (minetest.registered_privileges[priv] ~= nil) or false
end
function yl_scheduler.priv_exists(priv) return priv_exists(priv) end
-- ### get_privs ###
-- {[yl_scheduler.settings.admin_priv] = true}
local cmds = {}
cmds["scheduler_add"] = "taskadd_privs"
cmds["scheduler_remove"] = "taskremove_privs"
cmds["scheduler_list"] = "tasklist_privs"
cmds["scheduler_clean"] = "taskclean_privs"
local function get_privs(chatcommand_cmd) -- scheduler_add -- taskadd_privs
local privs = split(yl_scheduler.settings[cmds[chatcommand_cmd]])
local ret = {}
for _, priv in ipairs(privs) do ret[priv] = true end
return ret
end
function yl_scheduler.get_privs(chatcommand_cmd)
return get_privs(chatcommand_cmd)
end
-- ### check privs ###
local function check_privs()
for key, value in pairs(yl_scheduler.settings) do
if ends_with(key, "_privs") then
local parts = split(value)
for _, part in ipairs(parts) do
assert(priv_exists(part), "yl_scheduler : configured priv " ..
dump(part) .. " doesn not exist.")
end
end
end
say("PASS priv check")
end
function yl_scheduler.check_privs() return check_privs() end
-- Remove file
local function remove_file(UUID)
local path = get_filepath(UUID)
return os.remove(path)
end
function yl_scheduler.remove_file(UUID) return remove_file(UUID) end
-- ### Chatcommands ###
-- ### scheduler_add ###
local function cmd_scheduler_add(name, c_params)
-- This is what a chatcommand may look like:
-- /scheduler_add 384756378465$minetest.log$action, text to be logged$$some notes
-- /scheduler_add 384756378465$minetest.log$action, text to be logged
-- /scheduler_add 384756378465$switch_maze
-- /scheduler_add 384756378465$switch_maze$$$some more notes
-- cast:
-- /scheduler_add 384756378465$switch_maze$(int)456, (string)mystring$some more notes
-- mask:
-- /scheduler_add 384756378465$switch_maze$456,mystring$int, string$some more notes
-- The mask should be optional, if none is given we assume strings
if yl_scheduler.settings.taskadd_privs == "" then
return false, "Feature disabled"
end
if not c_params or c_params == "" or c_params == "help" then
return true,
"Adds a new task that executes a function with params at the given time.\n" ..
"Separate time, function, parameters, mask and notes with $\n" ..
"Example: /scheduler_add 1712679465$minetest.log$action,mytext$string,string$mynotes\n"
elseif c_params == "helpmask" then
return true,
"Provide a parameter mask to convert the parameters to number or table" ..
"Allowed values are \"string\",\"number\",\"table\"" ..
"If your use case requires other types, please create a wrapper function."
end
local t_parameters = string.split(c_params, "$", true)
local at = tonumber(t_parameters[1])
local func = t_parameters[2]
local string_params = t_parameters[3] and
split_with_escapes(t_parameters[3], ",", "\\") or
{} -- optional
local masks = t_parameters[4] and string.split(t_parameters[4], ",") or {} -- optional
local owner = name or "N/A"
local notes = t_parameters[5] or "" -- optional
local param_success, params = unmask_params(string_params, masks)
if (param_success == false) then return false, params end
local success, message = yl_scheduler.set_task(at, func, params, owner,
notes)
if success == true then
yl_scheduler.tasks = sort_by_timestamp(yl_scheduler.tasks)
end
return success, message
end
function yl_scheduler.cmd_scheduler_add(name, params)
return cmd_scheduler_add(name, params)
end
-- ### scheduler_remove ###
local function cmd_scheduler_remove(name, params)
if yl_scheduler.settings.taskremove_privs == "" then
return false, "Feature disabled"
end
if not params or params == "" or params == "help" then
return true, "Removes an existing task from the list.\n" ..
"Example: /scheduler_remove 11a47d48-4d0c-47ab-a5ef-4f56781cae03"
end
local UUID = string.trim(params)
local success, message = yl_scheduler.remove_task(UUID)
if success == true then
yl_scheduler.tasks = sort_by_timestamp(yl_scheduler.tasks)
end
return success, "Removed: " .. dump(message)
end
function yl_scheduler.cmd_scheduler_remove(name, params)
return cmd_scheduler_remove(name, params)
end
-- ### scheduler_list ###
local function cmd_scheduler_list(name, params)
if yl_scheduler.settings.tasklist_privs == "" then
return false, "Feature disabled"
end
if not params or params == "help" then
return true, "Lists all existing tasks from the list.\n" ..
"Example: /scheduler_remove 11a47d48-4d0c-47ab-a5ef-4f56781cae03"
end
local tasks = {}
local success
local args = string.split(params, "=")
if params == "" then
-- return whole list
say("cmd_scheduler_list Whole list")
success, tasks = yl_scheduler.list_all_tasks()
elseif #args == 1 then
-- return UUID search
say("cmd_scheduler_list UUID search")
local UUID = string.trim(params)
success, tasks = yl_scheduler.find_task(UUID)
elseif #args == 2 then
-- search for key
say("cmd_scheduler_list Key search")
local key = args[1]
local value = args[2]
local s_success, all_tasks = yl_scheduler.list_all_tasks()
for _, task in ipairs(all_tasks) do
-- TODO: Should we exact match or string.find?
if task[key] == value then table.insert(tasks, task) end
end
success = s_success
else
return false, "Parameter unclear, please do /scheduler_remove help"
end
if (#tasks == 0) or (success == false) then return true, "No match" end
-- Formatting output
local f_tasks = {{"ID", "at/done", "func", "#params", "owner", "notes"}}
for _, task in ipairs(tasks) do
local id = task.id or "N/A"
local atdone
if not task.done or (task.done and (task.done == -1)) then
atdone = "at " .. os.date("!%c", task.at or 0)
elseif task.done and (task.done > 0) then
atdone = "done " .. os.date("!%c", task.done or 0)
else
atdone = "N/A"
end
local func = task.func or "N/A"
local f_params = task.params and tostring(#task.params or 0) or "N/A"
if task.params and (type(task.params) ~= "table") then
f_params = "N/A"
minetest.log("warning", "[MOD] yl_scheduler : UUID " .. dump(id) ..
" has string param instead of table")
end
local owner = task.owner or "N/A"
local notes = task.notes or ""
local t = {id, atdone, func, f_params, owner, notes}
table.insert(f_tasks, t)
end
return true, format_table(f_tasks)
end
function yl_scheduler.cmd_scheduler_list(name, params)
return cmd_scheduler_list(name, params)
end
-- ### scheduler_clean ###
local function cmd_scheduler_clean(name, params)
-- Just do it and at best return how many were removed, how many remain and how many the list had before.
-- Use yl_scheduler.clean_executed_tasks() and yl_scheduler.clean_past_tasks()
if yl_scheduler.settings.taskclean_privs == "" then
return false, "Feature disabled"
end
local args = string.split(params, " ")
if ((#args > 1) or (not params) or (params == "help")) then
return true,
"Cleans executed tasks or tasks of which the scheduled execution date is in the past.\n" ..
"Omitting the parameter results in both past and done tasks cleaned.\n" ..
"Examples: /scheduler_clean or /scheduler_clean past or /scheduler_clean done"
end
local past_success
local past_amount_deleted
local past_amount_remaining
local done_success
local done_amount_deleted
local done_amount_remaining
if args[1] == "past" then
past_success, past_amount_deleted, past_amount_remaining =
yl_scheduler.clean_past_tasks()
elseif args[1] == "done" then
done_success, done_amount_deleted, done_amount_remaining =
yl_scheduler.clean_executed_tasks()
elseif args[1] == nil then
past_success, past_amount_deleted, past_amount_remaining =
yl_scheduler.clean_past_tasks()
done_success, done_amount_deleted, done_amount_remaining =
yl_scheduler.clean_executed_tasks()
else
return false, "Unknown parameter, please do /scheduler_clean help"
end
if ((past_success == true) or (done_success == true)) then
yl_scheduler.tasks = sort_by_timestamp(yl_scheduler.tasks)
end
local ret = ""
if (past_success == true) then
ret = ret .. "Cleaned up " .. tostring(past_amount_deleted) ..
" past tasks, remaining " .. tostring(past_amount_remaining) ..
". "
elseif (past_success == false) then
ret = ret .. "Cleaning of past tasks failed: " ..
dump(past_amount_deleted) .. " "
end
if (done_success == true) then
ret = ret .. "Cleaned up " .. tostring(done_amount_deleted) ..
" done tasks, remaining " .. tostring(done_amount_remaining) ..
". "
elseif (done_success == false) then
ret = ret .. "Cleaning of done tasks failed: " ..
dump(done_amount_deleted) .. " "
end
return true, ret
end
function yl_scheduler.cmd_scheduler_clean(name, params)
return cmd_scheduler_clean(name, params)
end