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.

Get URL-safe version of a given string

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

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

{{#invoke:Common|checkSlug}}

A specific string can also be provided instead:

{{#invoke:Common|checkSlug|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 stable link to the English page name, e.g. [[Wildlife of Arrakis|Fauna von Arrakis]]:

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

Resolve translation of a specified page

Checks whether a translated subpage exists for a given page title, and returns the subpage path if so, e.g. Buildables/de. If no translation exists, the original title is returned unchanged.

This is intended for building wikilinks manually, so that readers on a translated page stay in their language when following the link:

[[{{#invoke:Common|localiseTitle|Buildables|de}}|Bauwerke]]

Note that link targets which are redirects are not resolved, so their translations will not be picked up.

Parameters

checkSlug

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
If true, wraps the result as [[English|Translation]] instead of just plain text.
Defaults to false.

localiseTitle

1
string
The English title of the target page.
2
string
Language code of the translated subpage to look for.

local p = {}

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

-- ---------- String ----------

-- 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

-- 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.error(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 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

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


-- formats a number according to the current page's language
function p.formatNum(number)
    return 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)
    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 wraps the result as [[<ENGLISH>|<TRANSLATION>]] if `link` is true
function p.translate(bundleName, key, lang, link, silent)
    local bundlePage = "MessageBundle:" .. bundleName
    local langCode = lang or getPageLanguage():getCode()
    
    local mb = getBundle(bundlePage, lang)
    if not mb then
        if silent then return nil end
        return p.error("MessageBundle:%s not found.", bundleName)
    end

    local msg = mb:t(key)

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

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

    local mbEn = getBundle(bundlePage, "en")
    local msgEn = mbEn and mbEn:t(key)
    if not msgEn then
        if silent then return nil end
        return p.error("Missing English value for key '%s' in MessageBundle:%s.", key, bundleName)
    end

    local target = p.resolveLangSubpage(msgEn:plain(), langCode)
    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
function p.renderBulletList(items, omitIfSingle)
    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")
        :css("text-align", "left")

    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.error("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.error("No source specified") end

    local bundleName, slug = p.parseSourceToken(token)
    if not bundleName then return p.error("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.error("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.error("No version specified") end

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

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

-- 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.checkSlug(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 for wikitext pages to grab strings from MessageBundles directly
function p.localise(frame)
    local args = frame.args
    return p.translate(args[1], args[2], nil, p.isTruthy(args.link))
end

-- wrapper for p.resolveLangSubpage(), exposed as an entry point for wikitext pages to resolve a page to its translated subpage if it exists
function p.localiseTitle(frame)
    local args = frame.args
    return p.resolveLangSubpage(args[1], args[2])
end

return p