Module:Common

From Dune: Awakening Community Wiki
Jump to navigation Jump to search

Module:Common

Shared stateless helper functions, which can be loaded in other Lua modules via:

local common = require("Module:Common")

Usage

This module also exposes a few permanent utility entry points.

Replace tildes with another character

Tildes are good delimiters in the wiki context, as the alternatives are commas, which are common in prose, and pipes, which are special wikitext characters.

By default, tildes ~ will be replaced by pipes |:

{{#invoke:Common|replaceTilde|Item 1~Item 2~Item 3}}

Optionally, a character can be specified to be used as the replacement. For example, comma:

{{#invoke:Common|replaceTilde|Item 1~Item 2~Item 3|,}}

Render a tilde-delimited string as a bullet list

Converts a tilde-separated string into a bullet list. The list items are left-aligned (by default), while the list as a whole remains centered.

{{#invoke:Common|bulletList|Item 1~Item 2~Item 3}}

Optionally, set link=true to wrap each item as a wikilink:

{{#invoke:Common|bulletList|Item 1~Item 2~Item 3|link=true}}

Optionally, specify prefix to prepend a certain string to every item's link target:

{{#invoke:Common|bulletList|Item 1~Item 2~Item 3|link=true|prefix=ParentPage/}}

Optionally, set leftAlign=no to omit the left-alignment, allowing invoking elements to control text alignment instead:

{{#invoke:Common|bulletList|Item 1~Item 2~Item 3|leftAlign=no}}

Get URL-safe version of a given string

MessageBundle keys are built from a slugified (URL-safe) version of a string. Use this to generate the exact slug from a given input.

If no value is specified, it defaults to the current page's title:

{{#invoke:Common|toSlug}}

A specific string can also be provided instead:

{{#invoke:Common|toSlug|Terrarium of Muad'Dib}}

Get an auto-localised string from a MessageBundle

Retrieves a string for a given key from a specified MessageBundle. It auto-switches to the language that the viewer's page is currently on, and falls back to the English source if no translation exists for the current language.

{{#invoke:Common|localise|Vehicles|sandbike-name}}

Optionally, set link=true to wrap the result as a link to the page matching the key's English value (translated subpage if one exists for the viewer's language), e.g. [[Wildlife of Arrakis|Fauna von Arrakis]]:

{{#invoke:Common|localise|DLC|wildlife-of-arrakis-name|link=true}}

If the link target differs from the localised string itself (e.g. a label whose page title doesn't match the label text), specify the target page title directly instead of true. This also resolves to a translated subpage where available:

{{#invoke:Common|localise|Labels|vehicle_backup|link=Vehicle Backup Tool}}

Parameters

replaceTilde

1
string
The string containing tildes ~ to replace.
2 (optional)
string
The character to replace the tildes with.
Defaults to pipe |.

bulletList

1
string
The tilde-separated string of items to convert into a bullet list.
link (optional)
boolean
If specified with a truthy value, each item will be wrapped in double square brackets, rendering it as a wikilink.
Valid values:
true
yes
1
prefix (optional)
string
Only functions when link is truthy.
If specified, prepends the string to every item's link target.
leftAlign (optional)
boolean
If set to a falsy value, the inline text-align:left style is omitted from the list wrapper, so the caller's own CSS can control alignment instead.
Defaults to true.

toSlug

1 (optional)
string
The string to generate a slug for.
If omitted, defaults to the current page's title.

localise

1
string
The name of the MessageBundle to look up (without the "MessageBundle:" prefix).
2
string
The key to retrieve from the MessageBundle.
link (optional)
boolean or string
If true, wraps the result as a link to the page matching the key's English value, e.g. [[English|Translation]].
If set to a page title instead, links to that page rather than the key's English value. Use this when the link target differs from the localised string itself.
In both cases, the link resolves to a translated subpage of the target (e.g. Vehicle Backup Tool/de) matching the language of the current page, falling back to English if no translated subpage exists.
Defaults to false.

local p = {}

local tmb = require("mw.ext.translate.messageBundle")

-- ---------- Processing ----------

-- trims any whitespace on the edges
function p.trim(s)
    return mw.text.trim(s or "")
end

-- converts string to lowercase and trims any whitespace on the edges
function p.lowerTrim(s)
    return mw.ustring.lower(p.trim(s))
end

-- checks whether a string represents a truthy value; case-insensitive
function p.isTruthy(s)
    s = p.lowerTrim(s)
    return s == "true" or s == "yes" or s == "1"
end

-- checks whether a string represents a falsy value; case-insensitive
function p.isFalsy(s)
    s = p.lowerTrim(s)
    return s == "false" or s == "no" or s == "0"
end

-- checks whether boolean input was specified and returns corresponding value, else nil
function p.resolveBooleanInput(s)
    if p.isTruthy(s) then return true
    elseif p.isFalsy(s) then return false
    else return nil end
end

-- replaces all occurrences of one character within a string with another character
function p.replaceChar(s, a, b)
    s = s:gsub(a, b)
    return s
end

-- splits a tilde-delimited string into trimmed, non-empty items
function p.splitTilde(s)
    local items = {}
    for item in mw.text.gsplit(s or "", "~") do
        item = p.trim(item)
        if item ~= "" then items[#items + 1] = item end
    end
    return items
end

-- checks whether a list of strings contains a specified token; case-insensitive
function p.listContains(list, token)
    token = p.lowerTrim(token)

    for _, item in ipairs(list) do
        if p.lowerTrim(item) == token then return true end
    end
    return false
end

-- returns a URL-friendly version of a specified string
function p.slugify(name)
    local s = mw.uri.decode(name, "PATH")  -- decode percent-encoding
    s = mw.text.decode(s)  -- decode html entities
    s = mw.ustring.lower(s)  -- lowercase all letters
    s = mw.ustring.gsub(s, "[^%w%s_]", "")  -- remove all characters except alphanumerics, underscores and whitespace
    s = mw.ustring.gsub(s, "%s+", "_")  -- convert each group of whitespace into a single underscore
    s = mw.ustring.gsub(s, "_+", "_")  -- combine consecutive underscores into one
    s = mw.ustring.gsub(s, "^_+", ""):gsub("_+$", "")  -- trim any underscores at the edges
    return s
end

-- splits a "Bundle:slug" source token into its parts
function p.parseSourceToken(token)
    token = p.trim(token)
    if token == "" then return nil end

    return token:match("^([^:]+):(.+)$")
end

-- checks whether a source field (either a single token or a table of tokens) contains a given slug, ignoring the bundle prefix
function p.sourceMatches(source, filterSlug)
    if type(source) ~= "table" then
        local _, slug = p.parseSourceToken(source)
        return slug == filterSlug
    end

    -- recurse if table
    for _, token in ipairs(source) do
        if p.sourceMatches(token, filterSlug) then return true end
    end
    return false
end

-- splits any trailing number `_<number>` off a slug/ID
-- returns base_slug, number if matched, else nil
function p.splitTrailingNumber(str)
    local base, num = str:match("^(.-)_(%d+)$")
    if not base then
        return nil
    end
    return base, tonumber(num)
end

-- returns a display-ready error message, standardised for input validation checks across all modules
-- any number of extra args can be specified to be encoded and substituted into msg
function p.inputError(msg, ...)
    if select("#", ...) > 0 then
        local args = {...}
        for i = 1, #args do args[i] = mw.text.encode(tostring(args[i])) end
        msg = string.format(msg, unpack(args))
    end

    return '<strong class="error">⚠ ' .. msg .. '</strong>'
end

-- ---------- Localisation ----------

local pageLang
local bundleCache = {}
local subpageCache = {}

local function getBundle(bundlePage, lang)
    local cacheKey = bundlePage .. "|" .. tostring(lang)

    if bundleCache[cacheKey] == nil then
        -- if `lang` is nil, tmb.new() automatically attempts to grab a string of the invoking page's language
        -- if a translation does not exist, it falls back to the English source
        local ok, mb = pcall(tmb.new, bundlePage, lang)
        bundleCache[cacheKey] = ok and mb or false
    end

    return bundleCache[cacheKey]
end

-- returns current page's language
function p.getPageLanguage()
    if not pageLang then
        local ok, code = pcall(mw.getCurrentFrame().preprocess, mw.getCurrentFrame(), "{{PAGELANGUAGE}}")
        pageLang = mw.language.new(ok and code or mw.language.getContentLanguage():getCode())
    end
    return pageLang
end

-- formats a number according to the current page's language
function p.formatNum(number)
    return p.getPageLanguage():formatNum(number)
end

-- checks if a translation exists for a target page
-- returns the language subpage path (<target>/<lang>) if it exists, else just <target>
function p.resolveLangSubpage(target, lang)
    lang = lang or p.getPageLanguage():getCode()
    if lang == "en" then return target end

    local subpagePath = target .. "/" .. lang

    local cached = subpageCache[subpagePath]
    if cached ~= nil then return cached end

    local subpage = mw.title.new(subpagePath)
    local resolved = (subpage and subpage.exists) and subpagePath or target

    subpageCache[subpagePath] = resolved
    return resolved
end

-- retrieves a string value associated with a specified key from a specified MessageBundle
-- optionally returns result as a wikilink if `link` is true
function p.translate(bundleName, key, lang, link, silent)
    local bundlePage = "MessageBundle:" .. bundleName
    
    local mb = getBundle(bundlePage, lang)
    if not mb then
        if silent then return nil end
        return p.inputError("Unknown MessageBundle:%s", bundleName)
    end

    local msg = mb:t(key)

    if not msg then
        if silent then return nil end
        error(string.format("Missing key '%s' in MessageBundle:%s", key, bundleName))
    end

    local translated = msg:plain()
    if not link then return translated end

    -- link is now either literally true, or a string
    local target

    -- page title string
    if link ~= true then
        target = p.resolveLangSubpage(link, lang)

    -- true
    else
        local mbEn = getBundle(bundlePage, "en")
        local msgEn = mbEn and mbEn:t(key)

        if not msgEn then
            if silent then return nil end
            error(string.format("Missing English value for key '%s' in MessageBundle:%s", key, bundleName))
        end

        target = p.resolveLangSubpage(msgEn:plain(), lang)
    end

    return string.format("[[%s|%s]]", target, translated)
end

-- ---------- Rendering ----------

-- keys are MessageBundles where the strings should be anchors instead of titles
-- values are the pages the anchors will be found on
local BUNDLE_REDIRECT_PAGE = {
    Rewards = "Rewards",
}

-- creates a collapsible section: a heading followed by an inner content div
-- an item count is shown above the collapse toggle, and is always visible regardless of collapse state
-- mw-collapsed sections can be auto-expanded with wikilinks that target the heading ID
-- subtitle, if specified, renders above the item count
function p.newCollapsibleSection(title, count, headingLevel, expandedRaw, subtitle, anchorId)
    local expanded = p.resolveBooleanInput(expandedRaw)

    local outer = mw.html.create("div")
        :addClass("mw-collapsible")

    -- collapse the section if it contains more than 6 items (2 rows of 3 infoboxes)
    -- or if expanded is specified as false
    if (count > 6 and expanded == nil) or expanded == false then
        outer:addClass("mw-collapsed")
    end

    local level = tonumber(headingLevel)
    if not level or level < 0 or level > 6 then level = 3 end

    -- omit heading if headingLevel was specified as 0
    if level ~= 0 then
        local heading = outer:tag("h" .. level):wikitext(title)
        if anchorId then heading:attr("id", anchorId) end
    end

    if subtitle and subtitle ~= "" then
        outer:tag("div")
            :addClass("collapsible-subtitle")
            :addClass("scalable-text")
            :wikitext(subtitle)
    end

    outer:tag("div")
        :addClass("collapsible-count")
        :addClass("scalable-text")
        :wikitext(string.format("Total: '''%d'''", count))

    local content = outer:tag("div")
        :addClass("mw-collapsible-content")

    return outer, content
end

-- creates a standard sortable wikitable, ready for header/rows to be added
function p.newSortableTable()
    local table_ = mw.html.create("table")
        :addClass("wikitable sortable")
        :css("text-align", "center")

    return table_
end

-- creates a left-aligned bullet list, one <li> per item
-- if omitIfSingle is true and there is only 1 item in the list, then a plain span will be returned
-- if leftAlign is false, text-align:left style is omitted
function p.renderBulletList(items, omitIfSingle, leftAlign)
    if omitIfSingle and #items == 1 then
        return mw.html.create("span"):wikitext(items[1])
    end

    local wrapper = mw.html.create("div")
        :css("display", "inline-block")

    if leftAlign ~= false then
        wrapper:css("text-align", "left")
    end

    local ul = wrapper:tag("ul")
    for _, item in ipairs(items) do
        ul:tag("li"):wikitext(item)
    end

    return wrapper
end

-- resolves one or more source tokens into a display string
-- token may be a single "Bundle:slug" string, or a table of such strings (for items with multiple sources)
-- single source renders as a plain line; multiple sources render as a bullet list
function p.renderSource(token)

    local function keyFor(part)
        return part .. "-name"
    end

    if type(token) == "table" then
        if #token == 0 then return p.inputError("No source specified") end
        if #token == 1 then return p.renderSource(token[1]) end

        local rendered = {}
        for i, t in ipairs(token) do
            rendered[i] = p.renderSource(t)
        end

        return tostring(p.renderBulletList(rendered))
    end

    token = p.trim(token)
    if token == "" then return p.inputError("No source specified") end

    local bundleName, slug = p.parseSourceToken(token)
    if not bundleName then return p.inputError("Malformed source token: %s", token) end

    if not slug:find("/", 1, true) then
        -- prevent Sources:Default label from becoming a wikilink
        if bundleName == "Sources" and slug == "default" then
            return p.translate(bundleName, keyFor(slug), nil, false)
        end

        -- process strings from certain MessageBundles as page anchors instead of page titles
        local redirectPage = BUNDLE_REDIRECT_PAGE[bundleName]
        if redirectPage then
            local translated = p.translate(bundleName, keyFor(slug), nil, false)
            local anchorText = p.translate(bundleName, keyFor(slug), "en", false, true)
            return string.format("[[%s#%s|%s]]", redirectPage, anchorText, translated)
        end

        return p.translate(bundleName, keyFor(slug), nil, true)
    end

    -- handle contract pages (i.e. [[ContractChain/Contract|Contract]])
    local parts = mw.text.split(slug, "/", true)
    local targetParts = {}
    for i, part in ipairs(parts) do
        local resolved = p.translate(bundleName, keyFor(part), "en", false, true)
        if not resolved then
            return p.inputError("Missing key '%s' in MessageBundle:%s", keyFor(part), bundleName)
        end

        targetParts[i] = resolved
    end

    local displayText = p.translate(bundleName, keyFor(parts[#parts]))
    return string.format("[[%s|%s]]", table.concat(targetParts, "/"), displayText)
end

-- renders a version string as a wikilink to its corresponding Version page
function p.renderVersion(version)
    version = p.trim(version)
    if version == "" then return p.inputError("No version specified") end

    return "[[" .. version .. "]]"
end

-- ---------- Interface ----------

-- wrapper for p.replaceChar(), exposed as an entry point to replace all tildes in a string with another character (defaults to pipe)
function p.replaceTilde(frame)
    local args = frame.args

    local input = p.trim(args[1])
    if input == "" then return "" end

    local replacement = p.trim(args[2])
    if replacement == "" then replacement = "|" end

    return p.replaceChar(input, "~", replacement)
end

-- wrapper for p.renderBulletList(), exposed as an entry point to render a tilde-delimited string as a bullet list
function p.bulletList(frame)
    local args = frame.args

    local content = args[1]
    if not content or content == "" then return "" end

    local items = p.splitTilde(content)

    if p.isTruthy(args.link) then
        local prefix = args.prefix or ""
        for i, item in ipairs(items) do
            if prefix ~= "" then
                items[i] = "[[" .. prefix .. item .. "|" .. item .. "]]"
            else
                items[i] = "[[" .. item .. "]]"
            end
        end
    end

    local leftAlign = not p.isFalsy(args.leftAlign)

    return tostring(p.renderBulletList(items, nil, leftAlign))
end

-- wrapper for p.slugify(), exposed as an entry point to check URL-friendly versions of specified strings
-- this is specifically intended to help in cases where it may not be clear what IDs to use in MessageBundles
function p.toSlug(frame)
    local input = frame.args[1]
    if not input or input == "" then input = mw.title.getCurrentTitle().rootText end
    return p.slugify(input)
end

-- wrapper for p.translate(), exposed as an entry point to grab strings from MessageBundles directly
function p.localise(frame)
    local args = frame.args

    -- process link input; can either be boolean or a string
    local linkArg = p.trim(args.link or "")
    local link = false

    if p.isTruthy(linkArg) then
        link = true
    elseif linkArg ~= "" then
        link = linkArg
    end

    return p.translate(p.trim(args[1]), p.trim(args[2]), nil, link)
end

return p