--- Render a progression bar for wiki pages.
require("strict")
local getArgs = require("Module:Arguments").getArgs
local yesno = require("Module:Yesno")
--- @class Progression2Args
--- @field [integer] string?
--- @field total string?
--- @field task string?
--- @field hidecomplete string?
--- @field width string?
--- @field footer string?
--- @class Progression2Module
--- @field main fun(frame: table): string
--- @field _main fun(args: Progression2Args, frame?: table): string
--- @type Progression2Module
local p = {}
--- @type string
local TEMPLATE_STYLES = "Progression2/styles.css"
--- @type string
local PROGRESS_PERCENT_FORMAT = ".1f"
--- Return the TemplateStyles tag for this module.
---
--- @param frame table
--- @return string
local function renderStyles(frame)
return frame:extensionTag("templatestyles", "", {
src = TEMPLATE_STYLES,
})
end
--- Format the displayed progress percentage.
---
--- @param value number
--- @return string
local function formatProgressPercent(value)
return string.format("%" .. PROGRESS_PERCENT_FORMAT, value)
end
--- Trim leading and trailing whitespace.
---
--- @param value string
--- @return string
local function trim(value)
return mw.text.trim(value)
end
--- Evaluate text via `{{#expr: ... }}`.
---
--- Returns nil when no frame is available or when evaluation fails.
---
--- @param frame? table
--- @param text string?
--- @return string?
local function evaluateExpression(frame, text)
if not frame or text == nil then
return nil
end
text = trim(tostring(text))
if text == "" then
return nil
end
local ok, result = pcall(
frame.callParserFunction,
frame,
"#expr",
text
)
if not ok or result == nil then
return nil
end
result = trim(tostring(result))
if result == "" then
return nil
end
if result:match('class="error"') then
return nil
end
if result:match("^Expression error:") then
return nil
end
return result
end
--- Parse a number from plain or localized formatted text.
---
--- @param text string?
--- @return number?
local function parseNumber(text)
if text == nil then
return nil
end
local number = tonumber(text)
if number ~= nil then
return number
end
local contentLang = mw.language.getContentLanguage()
local ok, parsed = pcall(
contentLang.parseFormattedNumber,
contentLang,
tostring(text)
)
if ok then
return parsed
end
return nil
end
--- Resolve one argument to display text and numeric value.
---
--- The display text is the evaluated `#expr` result when available,
--- otherwise the original text.
---
--- @param frame? table
--- @param rawValue string?
--- @param defaultText string
--- @param defaultNumber number
--- @return string displayText
--- @return number number
local function resolveValue(frame, rawValue, defaultText, defaultNumber)
local sourceText = rawValue
if sourceText == nil or sourceText == "" then
sourceText = defaultText
end
sourceText = tostring(sourceText)
local evaluatedText = evaluateExpression(frame, sourceText)
local displayText = evaluatedText or sourceText
local number = parseNumber(displayText)
if number == nil then
number = defaultNumber
end
return displayText, number
end
--- Return resolved display and numeric values.
---
--- @param args Progression2Args
--- @param frame? table
--- @return number done
--- @return number total
--- @return string doneText
--- @return string totalText
local function getResolvedValues(args, frame)
local doneText, done = resolveValue(frame, args[1], "0", 0)
local totalText, total = resolveValue(
frame,
args[2] or args.total,
"100",
100
)
if total <= 0 then
total = 100
end
return done, total, doneText, totalText
end
--- Insert English-style thousands separators into a numeric string.
---
--- @param text string
--- @return string
local function groupThousands(text)
local sign, integerPart, fractionPart = text:match(
"^([%+%-]?)(%d+)(%.%d+)?$"
)
if not integerPart then
return text
end
integerPart = integerPart:reverse():gsub("(%d%d%d)", "%1,")
integerPart = integerPart:reverse():gsub("^,", "")
return sign .. integerPart .. (fractionPart or "")
end
--- Format a numeric value for footer placeholders.
---
--- Supported specs:
--- * `` -> raw resolved text
--- * `.2f` -> `string.format("%.2f", value)`
--- * `,` -> English-style grouping
--- * `,.2f` -> grouping after decimal formatting
---
--- @param formatSpec string?
--- @param valueText string
--- @param valueNumber number
--- @return string
local function formatNumber(formatSpec, valueText, valueNumber)
if not formatSpec or formatSpec == "" then
return valueText
end
local spec = tostring(formatSpec)
local useGrouping = false
if spec:sub(1, 1) == "," then
useGrouping = true
spec = spec:sub(2)
end
local formatted = valueText
if spec ~= "" then
local formatString = spec
if not formatString:match("^%%") then
formatString = "%" .. formatString
end
local ok, result = pcall(string.format, formatString, valueNumber)
if not ok then
return valueText
end
formatted = result
else
formatted = tostring(valueNumber)
end
if useGrouping then
formatted = groupThousands(formatted)
end
return formatted
end
--- Expand footer placeholders using resolved argument values.
---
--- @param footer string?
--- @param doneText string
--- @param totalText string
--- @param done number
--- @param total number
--- @return string?
local function expandFooter(footer, doneText, totalText, done, total)
if not footer or footer == "" then
return nil
end
local values = {
[1] = {
text = doneText,
number = done,
},
[2] = {
text = totalText,
number = total,
},
}
local text = tostring(footer)
text = text:gsub("%$([12]):([^%s$]+)", function(index, spec)
local value = values[tonumber(index)]
return formatNumber(spec, value.text, value.number)
end)
text = text:gsub("%$([12])", function(index)
return values[tonumber(index)].text
end)
return text
end
--- Render the progression table from a normalized argument table.
---
--- @param args Progression2Args
--- @param frame? table
--- @return string
function p._main(args, frame)
local done, total, doneText, totalText = getResolvedValues(args, frame)
local percent = 0
if done > 0 and total > 0 then
percent = (done / total) * 100
end
local barPercent = math.max(0, math.min(percent, 100))
local root = mw.html.create("")
local tbl = root:tag("table")
:attr("role", "presentation")
:addClass("progression")
if args.width then
tbl:css("width", args.width)
end
local headerCell = tbl:tag("tr")
:tag("td")
:addClass("progression-header")
if args.task then
headerCell:wikitext(args.task .. ":")
end
if not yesno(args.hidecomplete) then
headerCell:wikitext("完成")
end
headerCell:tag("span")
:addClass("progression-progression")
:wikitext(formatProgressPercent(percent) .. "%")
local barRow = tbl:tag("tr")
:tag("td")
:tag("table")
:attr("role", "presentation")
:addClass("progression-bar")
:addClass("skin-invert")
:tag("tr")
if done ~= 0 then
local doneWidth = barPercent
if doneWidth > 0 and doneWidth < 1 then
doneWidth = 1
end
barRow:tag("td")
:addClass("progression-done")
:css("width", tostring(doneWidth) .. "%")
end
if barPercent < 100 then
barRow:tag("td")
:addClass("progression-undone")
end
local footer = expandFooter(
args.footer,
doneText,
totalText,
done,
total
)
if footer then
tbl:tag("tr")
:tag("td")
:addClass("progression-footer")
:wikitext(footer)
end
return tostring(root)
end
--- Entry point for `#invoke`.
---
--- @param frame table
--- @return string
function p.main(frame)
local args = getArgs(frame, {
wrappers = {"Template:Progression2"},
})
return renderStyles(frame) .. p._main(args, frame)
end
return p