2023年政策修订增补工作正在进行中,欢迎参与!
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
}