-- 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 function string_to_boolean(value) if value == "true" then return true elseif value == "false" then return false else return nil end end function string_to_table(value) -- TODO: imppement 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 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 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 ### 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