模块:Progression2

维基百科,自由的百科全书
跳转到导航 跳转到搜索
--- 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