Module:Sandbox/Sam01101/LuaTest
跳到导航
跳到搜索
---@diagnostic disable: redefined-local --region Logging ---@class Log ---@field level nil | number ---@field debug fun(self: Log, ...: any): nil ---@field info fun(self: Log, ...: any): nil ---@field warn fun(self: Log, ...: any): nil ---@field error fun(self: Log, ...: any): nil local log = { -- Default level is info level = 2 } ---@return Log function log:new() self = setmetatable({}, { __index = self }) local level_color_map = { info = "black", warn = "orange", error = "red" } for i, level in ipairs { "debug", "info", "warn", "error" } do ---@param _ Log ---@param ... any ---@return nil ---@diagnostic disable-next-line: assign-type-mismatch self[level] = function(_, ...) if not self.level or self.level > i then return end local args = { ... } for i, v in ipairs(args) do if type(v) == "table" then args[i] = mw.dumpObject(v) end end table.insert(args, 1, ("[%s]"):format(level:upper())) local msg = mw.allToString(unpack(args)) mw.log(msg) if level_color_map[level] then mw.addWarning(("<p style=\"display: contents\">{{color|%s|%s}}<br></p>"):format(level_color_map[level], msg)) end end end return self end function log:set_level(lvl) if not lvl or lvl == "" then self.level = nil return self else lvl = tostring(lvl):lower() end for i, pattern in ipairs { "^d", -- debug "^i", -- info "^w", -- warning "^e" -- error } do if lvl:match(pattern) then mw.log(("Set log level: %d"):format(i)) self.level = i break end end end --endregion --region Utils local utils utils = { ---@param val any ---@return boolean tobool = function(val) if not val then return false elseif type(val) == "boolean" then return val end return ( (type(val) == "number" and val ~= 0) or (type(val) == "string" and (val ~= "" or val:find("^t"))) or (type(val) == "table" and next(val) ~= nil) ) end, patterns = { bt = "^bt(%d+)$", tab = "^tab(%d+)$", bticon = "^bticon(%d+)$" }, shallow_copy = function(orig) local orig_type = type(orig) local copy if orig_type == "table" then copy = {} for orig_key, orig_value in pairs(orig) do copy[orig_key] = orig_value end else -- number, string, boolean, etc copy = orig end return copy end, ---@param data table ---@param indent number | nil ---@param level number | nil json_encode = function(data, indent, level) -- Don't use .. to concat, use format local result = {} local indent, level = indent or 2, level or 1 local indent_str = string.rep(" ", indent * level) if type(data) == "table" then -- Array: #data > 0 | Object if #data > 0 then table.insert(result, "[") for i, v in ipairs(data) do table.insert(result, utils.json_encode(v, indent, level + 1) .. (i ~= #data and "," or "")) end table.insert(result, "]") else table.insert(result, "{") local i = 0 for k, v in pairs(data) do i = i + 1 table.insert(result, string.format("%q: %s", k, utils.json_encode(v, indent, level + 1)) .. (i ~= #data and "," or "") ) end table.insert(result, "}") end elseif type(data) == "string" then table.insert(result, string.format("%q", data)) else table.insert(result, tostring(data)) end return table.concat(result, (indent > 0 and "\n" or "") .. indent_str) end } ---@param orig table ---@return table function table.clone(orig) return { unpack(orig) } end --endregion --region Faker (Simple data generating, not really faker module) ---@class Faker local Faker = {} ---Generate name ---@param length number | nil ---@return string function Faker:gen_name(length) length = length or math.random(5, 10) -- First letter must be uppercase local name = string.char(math.random(65, 90)) for _ = 1, length - 1 do name = name .. string.char(math.random(97, 122)) end return name end ---Generate name, e.g. "John Smith" ---@return string function Faker:full_name() return mw.ustring.format("%s %s", self:gen_name(), self:gen_name()) end ---Generate Lorem ipsum ---@param length number | nil Count as words ---@param fixed_head boolean | nil Start with "Lorem ipsum dolor sit amet" ---@return string function Faker:lorem(length, fixed_head) length = length or math.random(10, 20) local lorem = { "Lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit", "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore", "magna", "aliqua", "Ut", "enim", "ad", "minim", "veniam", "quis", "nostrud", "exercitation", "ullamco", "laboris", "nisi", "ut", "aliquip", "ex", "ea", "commodo", "consequat", "Duis", "aute", "irure", "dolor", "in", "reprehenderit", "in", "voluptate", "velit", "esse", "cillum", "dolore", "eu", "fugiat", "nulla", "pariatur", "Excepteur", "sint", "occaecat", "cupidatat", "non", "proident", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollit", "anim", "id", "est", "laborum" } local text, uppercase = {}, true local comma, footstop = ",", "." if fixed_head then table.insert(text, "Lorem ipsum dolor sit amet") uppercase = false end for i = 1, length do local word = lorem[math.random(1, #lorem)] word = uppercase and word:gsub("^%l", string.upper) or word:lower() if uppercase then uppercase = false end if i % math.random(5, 10) == 0 then word = word .. comma elseif i % math.random(5, 10) == 0 then word = word .. footstop uppercase = true end table.insert(text, word) end if text[#text]:sub(-1) == comma then text[#text] = text[#text]:sub(1, -2) .. footstop elseif text[#text]:sub(-1) ~= footstop then text[#text] = text[#text] .. footstop end return table.concat(text, " ") end --endregion --region JSON Schema ---@diagnostic disable: unbalanced-assignments --[[ JSON Schema Validation Note: This might slightly different from the official JSON Schema # General Options - `type`: The type field, see below. - `_`: Notes for this key, used in generating docs. - `required`: Required fields. - `default`: Default value. - `faker`: Param to control how the data is generated. - When type is set to `false` (Boolean), this field will not be generated. - When type is a number for `array` and `string`, this field will be the length of the type. Note: `required` and `default` cannot be used together or it will cause conflict. # Type The `type` field ## String Options - `enum`: List of enumerate values. Note: When `default` is set, it must match one of the enum value. ## Number Options - `min`: Minimum value that's equal or greater than. Note: When `default` is set, it must be in range of `min`. ## Boolean _No options_ ## Array Options - `items`: Type of items. ## Object Options - `keys`: Type of keys. Can be a callback function(key: string) with [true, nil] or [false, msg] result. --]] ---@class JsonSchema ---@field schema table<string, table> local JsonSchema = {} ---@param schema table<string, table> ---@return JsonSchema | nil function JsonSchema:new(schema) if type(schema) ~= "table" then return log:error(("Schema 必须为 `table`,而不是 `%s`。"):format(type(schema))) end local self = setmetatable({ schema = schema }, { __index = self }) local err = self:verify_schema() if err then return log:error("验证 JSON 架构失败:", err) end return self end ---Verify the schema ---@return nil | string function JsonSchema:verify_schema() local checks checks = { ---@param key string ---@param opt table ---@reutrn nil | string _ = function(key, opt) -- Entry local key_types = { _ = "string", required = "boolean", -- String enum = "table", -- Number min = "number", -- Array items = "table", -- Object -- It can't be checked since `keys` have different types. } if not type(opt.type) then return ("[%s] `type` 未填入"):format(key) elseif not checks[opt.type] then return ("[%s] 未知类型 `%s`"):format(key, tostring(opt.type)) end for key, type_name in pairs(key_types) do if opt[key] ~= nil and type(opt[key]) ~= type_name then return ("[%s] 类型应为 `%s` 但实际为 `%s`"):format(key, type_name, type(opt[key])) end end if type(opt.faker) ~= "nil" and type(opt.faker) ~= "number" and type(opt.faker) ~= "boolean" then return ("[%s] 类型应为 `number` 或 `boolean` 但实际为 `%s`"):format(key, type(opt.faker)) elseif type(opt.faker) == "boolean" and opt.faker then return ("[%s] `faker` 不能为 `true`"):format(key) end if opt.default and type(opt.default) ~= opt.type and not ( -- Known issue: Values inside `array` and `object` were not checked. type(opt.default) == "table" and (opt.type == "array" or opt.type == "object") ) then return ("[%s] 默认值类型应为 `%s` 但实际为 `%s`"):format(key, opt.type, type(opt.default)) end if opt.required and opt.default then return ("[%s] `required` 和 `default` 不能同时使用"):format(key) end return checks[opt.type](key, opt) end, ---@param key string ---@param opt table ---@reutrn nil | string ["string"] = function(key, opt) if opt.enum and opt.default then local enum_matched, default = false, opt.default for _, value in ipairs(opt.enum) do if value == default then enum_matched = true break end end if not enum_matched then return ("[%s] `default` 值 \"%s\" 必须在 `enum` 范围内: %s"):format(key, default, table.concat(opt.enum, ", ")) end end end, ---@param key string ---@param opt table ---@reutrn nil | string ["number"] = function(key, opt) if opt.min and opt.default and opt.default < opt.min then return ("[%s] `default` 的值 %d 必须大于等于 `min` 值 %s"):format(key, opt.default, opt.min) end end, ---@param key string ---@param opt table ["boolean"] = function(key, opt) -- Nothing to check end, ---@param key string ---@param opt table ---@reutrn nil | string ["array"] = function(key, opt) if opt.items then return checks._(("%s[]"):format(key), opt.items) end end, ---@param key string ---@param opt table ---@reutrn nil | string ["object"] = function(key, opt) if opt.keys and type(opt.keys) == "table" then for k, v in pairs(opt.keys) do local err = checks._(("%s.%s"):format(key, k), v) if err then return err end end end end } for key, option in pairs(self.schema) do -- Check for invalid keys if type(key) ~= "string" then return ("Key 的名称应为 `string` 类型,但解析成了 `%s`"):format(type(key)) elseif key == "" then return "Key 不能为空值" end -- Check for type if type(option.type) ~= "string" then return ("`type` %s 的类型应为 `string` 类型,但解析成了 `%s`"):format(key, type(option.type)) end local has_key = false for typ_key in pairs(checks) do if option.type == typ_key then has_key = true break end end if not has_key then return ("[%s] 未知类型 %s"):format(key, type(option.type)) end -- Verify options return checks._(key, option) end end ---Verify data with the schema ---@param data table ---@return table | string function JsonSchema:verify_data(data) local checks checks = { ---@param schema_data table ---@param key string ---@param value any ---@return any, nil | string _ = function(schema_data, key, value) -- Check for default value if value == nil and schema_data.default ~= nil then value = schema_data.default end -- Update value (Only array and object type) if schema_data.type == "array" or schema_data.type == "object" then if schema_data.default ~= nil then for k, v in pairs(schema_data.default) do if value[k] == nil then value[k] = v end end elseif value == nil then value = {} end end -- Check for value type if type(value) ~= schema_data.type and not ( (schema_data.type == "array" or schema_data.type == "object") and type(value) == "table" ) then return nil, ("[%s] 类型不匹配: 预期为 `%s`, 实际为 `%s`"):format(key, schema_data.type, type(value)) end -- Check for required if schema_data.required and value == nil then -- There's requirement check in array and object. return nil, ("[%s] 数据不能为空"):format(key) end -- Check for data base on type return checks[schema_data.type](schema_data, key, value) end, ["string"] = function(schema_data, key, value) if schema_data.enum then local enum_matched = false for _, enum in ipairs(schema_data.enum) do if enum == value then enum_matched = true break end end if not enum_matched then return nil, ("[%s] 值 `%s` 必须在 `enum` 范围内: %s"):format(key, value, table.concat(schema_data.enum, ", ")) end end return value end, ["number"] = function(schema_data, key, value) if schema_data.min and value < schema_data.min then return nil, ("[%s] 值 %s 必须大于等于 `min` 值 %s"):format(key, value, schema_data.min) end return value end, ["boolean"] = function(schema_data, key, value) return value end, ["array"] = function(schema_data, key, value) if schema_data.items then if #value == 0 and schema_data.items.required then return nil, ("[%s] 列表不能为空"):format(key) end for i, v in ipairs(value) do local res, err = checks._(schema_data.items, ("%s[%i]"):format(key, i), v) if err then return nil, error end value[i] = res end end return value end, ["object"] = function(schema_data, key, value) if type(schema_data.keys) == "table" then local object_empty = true for k, val_schema in pairs(schema_data.keys) do if value[k] == nil and val_schema.required then return nil, ("[%s.%s] 数据不能为空"):format(key, k) elseif value[k] ~= nil then local res, err = checks._(val_schema, ("%s.%s"):format(key, k), value[k]) if err then return nil, err end value[k] = res object_empty = false end end if object_empty and schema_data.keys.required then return nil, ("[%s] 数据不能为空"):format(key) end elseif type(schema_data.keys) == "function" then local verify_func = schema_data.keys for k in pairs(value) do local res, err = verify_func(k) if not res then return nil, ("[%s.%s] 函数检查失败: %s"):format(key, k, err) end end end return value end } for key, schema_data in pairs(self.schema) do local res, err = checks._(schema_data, key, data[key]) if err then return err end data[key] = res end return data end ---Generate docs for schema ---@return string function JsonSchema:gen_docs() local type_name_map = { string = "字符串 (String)", boolean = "布尔值 (Boolean)", number = "数字 (Number)", array = "数组 (Array)", object = "对象 (Object)", } ---@param schema_data table<string, table> local function get_notes(schema_data) local notes = {} if schema_data._ then table.insert(notes, schema_data._) end if schema_data.type == "string" and schema_data.enum then local enums = {} for _, enum in ipairs(schema_data.enum) do table.insert(enums, ("<kbd>%s</kbd>"):format(enum)) end if schema_data._ then table.insert(notes, "") end table.insert(notes, ("可选值: %s"):format(table.concat(enums, ", "))) elseif schema_data.type == "number" and schema_data.min then if schema_data._ then table.insert(notes, "") end table.insert(notes, ("最小值: <code>%s</code>"):format(schema_data.min)) end if #notes == 0 then return "" end return table.concat(notes, "<br>") end ---@param key string ---@param type string ---@return string local function get_key_entry(key, type) if type == "array" then return ("%s[ ]"):format(key) elseif type == "object" then return ("%s{ }"):format(key) end return key end ---@param key string ---@param schema_data table<string, any> ---@return string local function gen_docs(key, schema_data) -- MeidaWiki table format local default = schema_data.type == "boolean" and utils.tobool(schema_data.default) or (schema_data.default or nil) local docs = { ("| %s || %s || %s || %s || %s"):format( ("<code>%s</code>"):format(get_key_entry(key, schema_data.type)), type_name_map[schema_data.type] or ("? (%s)"):format(tostring(schema_data.type)), schema_data.required and "✅" or "", default and ("<code>%s</code>"):format(default) or "", get_notes(schema_data) ) } if schema_data.type == "array" and schema_data.items then table.insert(docs, gen_docs(("%s[i]"):format((" "):rep(#key:gsub(" ", " "))), schema_data.items)) elseif schema_data.type == "object" and schema_data.keys then if type(schema_data.keys) == "table" then for k, v in pairs(schema_data.keys) do table.insert(docs, gen_docs(("%s{ }.%s"):format((" "):rep(#key:gsub(" ", " ")), k), v)) end elseif type(schema_data.keys) == "function" then table.insert(docs, ("| %s || %s || %s || %s || %s"):format( ("<code>%s.key</code>"):format(key), "<nowiki>*函数 (Function)</nowiki>", "", "", "自定义验证函数" )) end end return table.concat(docs, "\n|-\n") end ---Generate Sample JSON from schema using Faker ---@return string local function gen_sample_json() local faker = Faker ---@param schema_data table<string, any> ---@return any local function gen_sample_json_data(schema_data) if schema_data.default then return schema_data.default elseif schema_data.faker == false then return end if schema_data.type == "string" then if schema_data.enum then return schema_data.enum[math.random(1, #schema_data.enum)] elseif type(schema_data.faker) == "number" then return faker:lorem(schema_data.faker) end return faker:full_name() elseif schema_data.type == "boolean" then return utils.tobool(math.random(0, 1)) elseif schema_data.type == "number" then return math.random(schema_data.min or 0, 100) elseif schema_data.type == "array" then local items = {} local repeat_num = type(schema_data.faker) == "number" and schema_data.faker > 0 and schema_data.faker or math.random(1, 10) for i = 1, repeat_num do table.insert(items, gen_sample_json_data(schema_data.items)) end return items elseif schema_data.type == "object" then local obj = {} if type(schema_data.keys) == "table" then for k, v in pairs(schema_data.keys) do obj[k] = gen_sample_json_data(v) end -- elseif type(schema_data.keys) == "function" then -- for i = 1, math.random(1, 5) do -- obj[faker:gen_name(1)] = gen_sample_json_data(schema_data) -- end end return obj end end local json = {} for key, schema_data in pairs(self.schema) do json[key] = gen_sample_json_data(schema_data) end return utils.json_encode(json) end local docs = { "{{mbox|text=此架构文档为自动生成,请勿手动修改。}}", "", "{| class=\"wikitable mw-collapsible mw-collapsed\" style=\"margin: auto\"", "! colspan=\"5\" | JSON 架构", "|-", "! 键 !! 类型 !! 必填 !! 默认值 !! 说明", "|-", } for key, schema_data in pairs(self.schema) do table.insert(docs, gen_docs(key, schema_data)) table.insert(docs, "|-") end docs[#docs] = "|}" table.insert(docs, ("{{Hide|JSON 示范|width = auto; margin: auto|\n%s\n}}"):format( tostring(mw.html.create("pre"):css("margin", "auto"):attr("lang", "json"):wikitext(gen_sample_json())) )) return table.concat(docs, "\n") end ---Verify data using schema ---@param raw_data string Raw JSON string ---@return boolean, nil | table | string function JsonSchema:parse_data(raw_data) if not raw_data or raw_data == "" then log:warn("传入的 JSON 数据为空。") return true elseif type(raw_data) ~= "string" then return false, ("类型错误: `raw_data` 必须为字符串,但是传入了 `%s` 类型。"):format(type(raw_data)) end local ok, json = pcall(mw.text.jsonDecode, raw_data, mw.text.JSON_TRY_FIXING) if not ok then return false, ("JSON 解析失败: %s"):format(tostring(json)) end if type(json) ~= "table" then return false, ("JSON 类型错误: 传入的 JSON 顶层数据必须为一张表,但是解析成了 `%s` 类型。" ):format(type(json)) elseif not next(json) then -- empty table log:warn("传入的 JSON 数据为空。") return true end local res = self:verify_data(json) if type(res) == "string" then return false, ("数据检查失败: %s"):format(res) end return true, res end --endregion --region Routes ---@class RouteOptions local RouteOptions = { action_log = {} } ---@param routes table ---@return RouteOptions function RouteOptions:new(routes) return setmetatable(routes, { __index = self }) end ---@param id string ---@param get_index boolean | nil ---@return table | number | nil function RouteOptions:get(id, get_index) for i, route_opts in ipairs(self) do if route_opts.id == id then return get_index and i or route_opts end end end ---Get ID List ---@param mark_dupe boolean Check for duped id and mark as (Duped) function RouteOptions:get_ids(mark_dupe) local ids = {} for _, route_opts in ipairs(self) do local route_id = route_opts.id if mark_dupe then for _, id in ipairs(ids) do if id == route_opts.id then route_id = ("{{color|gray|%s (重复)}}"):format(route_opts.id) break end end end table.insert(ids, route_id) end return ids end ---@param id string ---@return boolean function RouteOptions:remove(id) local idx = self:get(id, true) if type(idx) ~= "number" then self:log_route_not_found("delete", id) return false end table.remove(self, idx) self:log("delete", id) return true end ---@param id string ---@param data table ---@return boolean function RouteOptions:insert(id, data) local idx = self:get(id, true) if type(idx) ~= "number" then self:log_route_not_found("insert", id) return false end if not data.id then data.id = id end table.insert(self, idx, data) self:log("insert", id, { data = data, id_changed = data.id ~= id }) return true end ---@param id string ---@param data table ---@return boolean function RouteOptions:replace(id, data) local idx = self:get(id, true) if type(idx) ~= "number" then self:log_route_not_found("replace", id) return false end if not data.id then data.id = id end self:log("replace", id, { data = data, id_changed = data.id ~= id }) self[idx] = data return true end ---@param id string ---@param data table ---@return boolean function RouteOptions:append(id, data) if id == "" then -- Append to last table.insert(self, data) self:log("append", id, { last = true, data = data, id_changed = true }) return true end local idx = self:get(id, true) if type(idx) ~= "number" then self:log_route_not_found("append", id) return false end if not data.id then data.id = id end self:log("append", id, { data = data, id_changed = data.id ~= id }) table.insert(self, idx + 1, data) return true end function RouteOptions:log_route_not_found(action, id) self:log(action, id, { error = true, code = "not_found" }) end ---@param action string ---@param id string ---@param data table | nil function RouteOptions:log(action, id, data) if action ~= "delete" and data then data.id_list = self:get_ids(true) end table.insert(self.action_log, { action = action, id = id, data = data }) end ---Add chapter diff in last log ---@param old_chapter string ---@param new_chapter string function RouteOptions:log_chapter(old_chapter, new_chapter) if old_chapter == new_chapter then return end self.action_log[#self.action_log].diff = { old = old_chapter, new = new_chapter } end ---Format log to human readable ---@param character_name string ---@return string function RouteOptions:format_log(character_name) local action_map = { delete = "移除", insert = "插入", replace = "替换", append = "添加" } local err_code = { not_found = "未找到" } local msgs = setmetatable({}, { __call = function(self, msg) self[#self + 1] = msg end }) for _, log_data in ipairs(self.action_log) do local details = log_data.data local color_code = (details and details.error) and "red" or "gray" local append_new_mode = (log_data.action == "append" and details and details.last) if append_new_mode then msgs(("➡ [追加选项]"):format(color_code)) else msgs(("➡ [%s] {{color|%s|选项 ID \"%s\"}}"):format(action_map[log_data.action], color_code, log_data.id)) end -- ⮑ if details then if log_data.diff then local diff = log_data.diff if diff.old == "" then msgs(("⮑ 新增章节: <code style=\"color: green\">%s</code>"):format(diff.new)) else msgs(("⮑ 章节名称: <s style=\"color: gray\">%s</s> → <code style=\"color: green\">%s</code>" ): format(diff.old, diff.new)) end end if details.data and details.data.options then msgs(("⮑ 选项数据: <code>%s</code>"):format(mw.text.jsonEncode(details.data.options))) end if details.id_changed then if append_new_mode then msgs(("⮑ 新选项 ID: <code>%s</code>"):format(details.data.id)) else msgs(("⮑ 选项 ID: <s style=\"color: gray\">%s</s> → <code>%s</code>"):format(log_data.id, details.data.id)) end end if details.error then msgs(("⮑ 错误原因: %s <code>%s</code>"):format(err_code[details.code], details.code)) msgs(("⮑ 选项 ID 列表: <code>%s</code>"):format(table.concat(self:get_ids(true), ", "))) msgs(("⮑ 完整线路数据: <code>%s</code>"):format(mw.text.jsonEncode(self:to_table()))) elseif details.id_list and #details.id_list > 0 then msgs(("⮑ 选项 ID 列表: <code>%s</code>"):format(table.concat(details.id_list, ", "))) end end end return ("<div><h4>`%s` 的路线操作记录</h4>%s</div>"):format(character_name, table.concat(msgs, "<br>")) end ---@return table function RouteOptions:to_table() local res = {} for _, route_opts in ipairs(self) do table.insert(res, route_opts) end return res end ---@param chapters table ---@param diff table ---@param special_case boolean ---@return boolean, table | string function RouteOptions:edit(chapters, diff, special_case) for diff_idx, diff_info in ipairs(diff) do local action = { delete = diff_info.action:find("^d"), replace = diff_info.action:find("^r"), insert = diff_info.action:find("^i"), append = diff_info.action:find("^a") } --region Field requirement checks if (-- Field `options` is required for the following: -- Case `insert`, `append` in `actions` (action.insert or action.append) -- Special case `route_opts` (Except `delete`) or (special_case and not action.delete) ) and not diff_info.options then --Err: "Field `options` is required for the following: insert, append, route_opts" return false, ("单个路线选项 `options` 在 %s 为必填"):format( special_case and "自定义路线 `route_opts`" or "插入/追加操作 `actions`" ) elseif (-- Field `chapter` is required for the following: -- Case `append` to the end in `actions` (action.append and diff_info.id == "") -- Special case `route_opts` (Except `delete`) or (special_case and not action.delete) ) and not diff_info.chapter then --Err: "Field `chapter` is required for the following: append, route_opts" return false, ("章节名称 `chapter` 在 %s 为必填"):format( special_case and "自定义路线 `route_opts`" or "追加操作 `actions`") elseif (-- Field `id` is required for the following: -- Case that is other than `append` in `actions` not action.append and diff_info.id == "" ) then --Err: "Field `id` is required for the following: delete, replace, insert, route_opts" return false, ("路线 ID `id` 在 %s 不能为空字符。"):format( special_case and "自定义路线 `route_opts`" or "删除/替换/插入操作 `actions`" ) elseif (-- One of field `chapter` or `options` must be set: -- Case that is other than `delete` in `actions` not action.delete ) and not (-- Field `chapter` and `options` isn't set diff_info.chapter or diff_info.options) then --Err: "One of field `chapter` or `options` must be set" return false, "路线选项 `chapter` 和 `options` 在 追加/替换/插入操作 `actions` 不能同时为空。" end --endregion -- Edit route option local route_id = diff_info.id if action.delete and not self:remove(route_id) then --Err: "Failed to delete route option" return false, ("删除路线选项 `%s` 失败"):format(route_id) end -- Pre-check for chapter local idx if (action.replace or action.insert or (action.append and route_id ~= "")) and diff_info.chapter then idx = self:get(route_id, true) if type(idx) ~= "number" then --Err: "Failed to replace route option due to route ID not exist" for action_name, cond in pairs(action) do if cond then self:log_route_not_found(action_name, route_id) end end return false, ("修改章节名称 `%s` 失败,路线 ID 不存在"):format(route_id) end end if action.replace then if diff_info.options and not self:replace(route_id, diff_info.options) then --Err: "Failed to replace route option" return false, ("替换路线选项 `%s` 失败"):format(route_id) elseif diff_info.chapter then chapters[idx] = diff_info.chapter end elseif action.insert then if diff_info.options and not self:insert(route_id, diff_info.options) then --Err: "Failed to insert route option" return false, ("插入路线选项 `%s` 失败"):format(route_id) elseif diff_info.chapter then table.insert(chapters, idx, diff_info.chapter) end elseif action.append then if diff_info.options and not self:append(route_id, diff_info.options) then --Err: "Failed to append route option" return false, ("追加路线选项 `%s` 失败"):format(route_id) elseif diff_info.chapter then if route_id == "" then idx = #chapters end table.insert(chapters, idx + 1, diff_info.chapter) end end self:log_chapter(route_id, diff_info.chapter) end return true, self:to_table() end --endregion local function get_default_schema() ---@type table<string, table> local json_schemas = { route_opts = { _ = "路线列表", type = "array", required = true, items = { _ = "路线选项", type = "object", required = true, keys = { id = { _ = "路线选项 ID,用于标注选项并在 `route_edit` 中使用。", type = "string" }, options = { _ = "选项列表", type = "array", required = true, faker = 2, items = { type = "string", required = true } } } } }, } ---@type table<string, table> local json_schema_data = { log = { _ = "日志等级", type = "string", enum = { "none", "debug", "info", "warn", "error", "d", "i", "w", "e" }, default = "none" }, options = { _ = "一般选项", type = "object", keys = { tab_mode = { _ = "Tab 模式 [[Template:Tabs]]", type = "string", enum = { "color", "core" }, default = "color" }, tab_opts = { _ = "Tabs 参数,注意不能使用任何 `bn(x)`, `bticon(x)`, `tab(x)`", type = "object", keys = (function(key) if key:find("^bn%d+$") or key:find("^bticon%d+$") or key:find("^tab%d+$") then return false, "`tab_opts` 不能使用任何 `bn(x)`, `bticon(x)`, `tab(x)` 的选项" end return true end) }, line_break = { _ = "是否为每一个路线选项开新行", type = "boolean", default = false }, display_numbers = { _ = "是否为在路线选项前添加数字", type = "boolean", default = false } } }, chapters = { _ = "章节名称", type = "array", required = true, faker = 5, items = { type = "string", required = true } }, route_opts = json_schemas.route_opts, routes = { _ = "角色路线", type = "array", required = true, faker = 3, items = { type = "object", keys = { name = { _ = "角色名称", type = "string", required = true }, icon = { _ = "Tab 的图标, core 模式不适用并自动忽略", type = "string", faker = false }, route_opts = utils.shallow_copy(json_schemas.route_opts), route_edit = { _ = "为该角色进行路线微调,按列表顺序执行操作", type = "array", items = { --[[ The following options are required for the following: - Case `insert`, `append` in `actions` - Special case `route_opts` --]] _ = table.concat({ "注: 在下列情况,以下选项为必填项:", "* `insert`, `append` 操作时的 `actions`", "* 特殊情况 - 自定义路线" }, "<br>"), type = "object", keys = { action = { --[[ 目前一共有三个操作类型: - `delete`: 移除 - `insert`: 插入,在指定路线前插入选项 - `append`: 追加,在指定路线后添加选项 - `replace`: 替换特定选项/章节名称 --]] _ = table.concat({ "操作类型,目前一共有三个操作类型:", "* `delete`: 移除", "* `insert`: 插入,在指定路线前插入选项", "* `append`: 追加,在指定路线后添加选项", "* `replace`: 替换特定选项/章节名称" }, "<br>"), type = "string", required = true, enum = { "d", "r", "i", "a", "delete", "replace", "insert", "append" } }, id = { _ = "操作的路线选项 ID,如果操作类型是 `append` 则可以留空表示追加到最后。", type = "string", required = true }, chapter = { _ = "章节名称", type = "string" }, options = utils.shallow_copy(json_schemas.route_opts.items) } }, faker = false }, selects = { _ = "该角色的路线选项,超出路线的选项将会被忽略。", type = "array", required = true, items = { type = "number", default = 0 } }, note = { -- TODO: Render as wikitext? _ = "角色路线注意事项。", type = "string", faker = 10 } } } } } -- Some dymanic edit --TODO --路线列表。此为特殊用途,将覆盖原有的 `route_opts` 选项。 --注:如您想为该角色重新自定义路线则可使用。否则建议使用 `route_edit` 替代。 json_schema_data.routes.items.keys.route_opts._ = table.concat({ "路线列表。此为特殊用途,将覆盖原有的 `route_opts` 选项。", "注: 除非您想为该角色重新自定义路线,否则建议使用 `route_edit` 替代。" }, "<br>") json_schema_data.routes.items.keys.route_opts.required = nil json_schema_data.routes.items.keys.route_opts.faker = false json_schema_data.routes.items.keys.route_edit.items.keys.options.required = nil json_schema_data.routes.items.keys.route_edit.items.keys.options.faker = false return json_schema_data end local create_elem = mw.html.create log = log:new() ---This message is shown to end user, and tells the editor to check for error messages. local function gen_error_msg_user(frame) return frame:expandTemplate { title = "color", args = { "red", "<i>[表格生成失败,请打开编辑模式查看错误信息。]</i>" } } end return { main = function() local msgs = { "<h4 style=\"padding-bottom: 0; margin-bottom: 0\"> [[Module:GameRoutes|Game Routes 模块帮助]] </h4>", "此函数 `main` 用于展示模块介绍,以下函数可用:", "<li> `gen`: 生成表格。</li>", "<li> `docs`: 生成本模块的 JSON Schema 架构文档。</li>", "<li> `test`: (模块开发者用) 模块单元测试</li>", "详细信息及使用方法参见 [[Module:GameRoutes]]", "<br>", "如有任何问题请在讨论页留言" } mw.addWarning(("<div><span style=\"color: black\">%s</span><hr></div>"):format(table.concat(msgs, ""))) end, gen = function(frame) local json_schema = JsonSchema:new(get_default_schema()) if not json_schema then return gen_error_msg_user(frame) end local res, json = json_schema:parse_data(frame.args[1]) if not res then log:error(json) return gen_error_msg_user(frame) elseif not json then return end -- Setup logging level log:set_level(json.log) local options = json.options or {} -- Setup Tabs local tab_key_name, tab_core_mode = "tabs", options.tab_mode == "core" local tab_label_key, tab_content_key = "bt", "tab" if tab_core_mode then tab_label_key, tab_content_key = "label", "text" tab_key_name = tab_key_name .. "/core" end -- Other options local line_break, display_numbers = utils.tobool(options.line_break), utils.tobool(options.display_numbers) --region Tab generating local tabs, tab_num = {}, 1 -- Main tab tabs[("%s%d"):format(tab_label_key, tab_num)] = "序言" tabs[("%s%d"):format(tab_content_key, tab_num)] = ("%s%s"):format("{{剧透提醒|align=left|width=}}", type(frame.args[2]) == "string" and frame.args[2] or "") tab_num = tab_num + 1 -- Route tabs for _, route in ipairs(json.routes) do -- In character ---@diagnostic disable-next-line: redefined-local local root_div = create_elem("div") local custom_route = utils.tobool(route.route_opts) local route_opts = table.clone(route.route_opts or json.route_opts) local chapters = custom_route and {} or table.clone(json.chapters) -- Edit route by actions if route.route_edit and #route.route_edit > 0 then log:debug("Number of diffs:", #route.route_edit) local route_opt = RouteOptions:new(route_opts) local res, new_route_opts = route_opt:edit(chapters, route.route_edit, custom_route) if not res then log:error(new_route_opts) if #route_opt.action_log > 0 and log.level then mw.addWarning(("<span style=\"color: black\">%s</span>"):format(route_opt:format_log(route.name))) end return gen_error_msg_user(frame) end assert(type(new_route_opts) == "table", ("New route options should be a table, but got %s"):format(type(route_opts))) route_opts = new_route_opts log:debug("Updated:") log:debug("Route options", route_opts) end -- Type check assert(type(route_opts) == "table", ("Route options should be a table, but got %s"):format(type(route_opts))) -- Generate tabs content for chapter_idx in ipairs(route_opts) do -- In chapter if chapter_idx > 1 then root_div:node("<br>") end local div_elem = create_elem("div") if not chapters[chapter_idx] then log:warn(("缺失章节 `%d` 的名称"):format(chapter_idx)) end local chapter_span = create_elem("span"):wikitext(("○ %s"):format(chapters[chapter_idx] or "?")) div_elem:node(chapter_span):node("<br>") local chapter_options = route_opts[chapter_idx].options for opt_idx, option_name in ipairs(chapter_options) do -- In options local option_span = create_elem("span") local text = option_name if display_numbers then text = ("%d %s"):format(opt_idx, text) end option_span:wikitext(text) if route.selects[chapter_idx] == opt_idx then option_span:css("color", "red") else option_span:css("color", "gray") end div_elem:node(option_span) if opt_idx ~= #chapter_options then div_elem:node(line_break and "<br>" or " ") elseif opt_idx == #chapter_options then root_div:node(div_elem) end end end -- Add notes if route.note and route.note ~= "" then root_div:node("<br><hr>") :node(create_elem("p"):wikitext(route.note)) end -- Add tab to tab_opts log:debug(("Adding tab `%s` as tab%d"):format(route.name, tab_num)) tabs[("%s%d"):format(tab_label_key, tab_num)] = ("%s%s"):format( not tab_core_mode and route.icon and " " or "", route.name ) if not tab_core_mode and route.icon then tabs[("bticon%d"):format(tab_num)] = route.icon end tabs[("%s%d"):format(tab_content_key, tab_num)] = tostring(root_div) tab_num = tab_num + 1 end --endregion -- Known issue: frame:expandTemplate can't be use becuase table will fuck up the order (Lua LOL) -- Replaced with frame:preprocess (Slow) local template, tab_keys = tab_key_name, {} for k in pairs(tabs) do tab_keys[#tab_keys + 1] = k end table.sort(tab_keys, function(a, b) for _, pattern in ipairs(utils.patterns) do local a1, b1 = a:match(pattern), b:match(pattern) if a1 and b1 then return tonumber(a1) < tonumber(b1) end end return a < b end) for k, v in pairs(options.tab_opts) do template = ("%s|%s = %s"):format(template, k, v) end for _, k in ipairs(tab_keys) do template = ("%s|%s = %s"):format(template, k, tabs[k]) end return frame:preprocess(("{{%s}}"):format(template)) end, docs = function(frame) local json_schema = JsonSchema:new(get_default_schema()) if not json_schema then return gen_error_msg_user(frame) end return frame:preprocess(json_schema:gen_docs()) end, ---@diagnostic disable: param-type-mismatch test = function(frame) log:set_level("d") local schema = JsonSchema:new(get_default_schema()) if not schema then return log:error("JSON Schema verification failed, abort unit test.") end ---@param test_case string ---@param func function ---@return boolean local describe = function(test_case, func) log:debug(("❓ Test case: %s"):format(test_case)) ---@param what string ---@param callback function return func(function(what, callback) local res, err = pcall(callback) if not res then log:error(("❌ Test case failed, expected it %s.\nStack trace:\n%s"):format(what, err)) else log:debug(("✅ Test case passed, it %s as expected."):format(what)) end return res end) end log:info("⚙ Schema loaded, start unit test") if not describe("Data Parsing", function(it) if not it("should failed the parsing", function() assert(not schema:parse_data("{")) assert(not schema:parse_data(true)) assert(not schema:parse_data("null")) end) then return end if not it("should pass the parsing becuase of empty data", function() assert(schema:parse_data(nil)) assert(schema:parse_data("{}")) assert(schema:parse_data("[]")) assert(schema:parse_data("")) end) then return end return true end) then return end if not describe("Data Verification", function(it) if not it("should failed the schema verify", function() assert(type(schema:verify_data({})) == "string") end) then return end if not it("should pass the verify", function() local res = schema:verify_data({ chapters = { Faker:full_name() }, route_opts = { { options = { Faker:full_name(), Faker:full_name() } } }, routes = { { name = Faker:gen_name(1), selects = { 1 } } } }) assert(type(res) == "table", res) end) then return end return true end) then return end -- TODO: Add generate test log:info("🚀 Unit test passed") end }