Module:FileLink

From Star of Providence Wiki
Jump to navigation Jump to search

Details

The FileLink module offers a Lua class that makes it easier to work with links to files, as well as some functions to create file-linking templates. It uses Module:Size, Module:StringSet, and Module:Time internally to do this.

Unlike ordinary file links, this module will ignore key arguments that aren't used for files, making it useful in templates which create links to files but may be configured with other arguments. Although it will fail if there are more than two positional arguments (a file and a caption), the methods specifically for use in templates have some ways of getting around this.

This page will only document the functionality specific to templates, since those looking to understand how to use it in modules can just read the code directly.

Usage

fromFrame

The fromFrame function simply creates a file link directly from the arguments passed to it via an {{#invoke:...}} call. So, for example, {{#invoke:FileLink|fromFrame|MyFile|...}} will be roughly equivalent to [[File:MyFile|...]], although unrecognized key arguments will be ignored as described earlier.

This function mostly exists to test out how the FileLink module works, and probably isn't very useful in practice. You can experiment with it for yourself on a user page.

fromParent

This is the intended way to use FileLink in templates, and it's designed to help create templates which have custom options added to file links by default. It has access to both the arguments passed directly to it via {{#invoke:...}} and those passed to the parent template as well, making it quite powerful. Essentially, the invocation arguments are used to set default options for the file link while the parent's arguments are then used to construct the actual link. You can think of this as a way of converting a template into a special fromFrame call with custom defaults, where those defaults are included in the template itself.

The original code for this module (before it was rewritten), for example, was to create a pixelated image template which always applied a pixelated class to images that got passed to it. This template can be represented with the following code:

{{#invoke:FileLink|fromParent|class=pixelated}}

However, because you can use wikitext to control the arguments provided here, you can actually do more than just set options. For example, the old Pixelated module allowed an unpixelated=1 argument that would remove the pixelated class, so that you could opt out of pixelation instead of opting in by providing the class. You can accomplish this via:

{{#invoke:FileLink|fromParent|class={{#if:{{{unpixelated|}}}||pixelated}}}}

--- Module:FileLink
--- by User:LtDk, licensed CC-BY-SA 4.0
--- Class designed to make it easier to link files in custom templates.

local Size = require('Module:Size')
local StringSet = require('Module:StringSet')
local Time = require('Module:Time')

--- Format argument for file links. (frameless, framed, thumbnail)
--- @enum Format
local Format = {
    frameless = 'frameless',
    frame = 'frame',
    framed = 'frame',
    thumb = 'thumb',
    thumbnail = 'thumb',
}

local Upright = {}

--- "Upright" sizing argument to file links.
--- @class Upright
--- @field [1] number?
local UprightProto = {}
local UprightMeta = { __index = UprightProto }

--- Checks if a value is an "upright" sizing.
--- @param value any
--- @return boolean
function Upright.isUpright(value)
    return type(value) == 'table' and rawequal(getmetatable(value), UprightMeta)
end

function UprightMeta.__eq(lhs, rhs)
    if not Upright.isUpright(rhs) then
        return false
    end
    return lhs[1] == rhs[1]
end

function UprightMeta:__newindex(k, v)
    if k ~= 1 then
        error(string.format('invalid key %s', k))
    end
    if v ~= nil and (type(v) ~= 'number' or v <= 0) then
        error(string.format('upright requires a positive number, got %q', tostring(v)))
    end
    rawset(self, k, v)
end

function UprightMeta:__tostring()
    if self[1] ~= nil then
        return string.format('upright %s', tostring(self[1]))
    else
        return 'upright'
    end
end

local uplen = string.len('upright')

--- Parses an "upright" sizing from a string.
--- @param val string
--- @return Upright
function Upright.parse(val)
    val = tostring(val)
    local upright = Upright.tryParse(val)
    if upright == nil then
        error(string.format('not a valid upright sizing: %q', val))
    end
    return upright
end

--- Tries to parses an "upright" sizing from a string, returning nil instead of errors.
--- @param val string
--- @return Upright?
function Upright.tryParse(val)
    val = mw.text.trim(tostring(val))

    if string.lower(string.sub(val, 1, uplen)) == 'upright' then
        val = mw.text.trim(string.sub(val, uplen + 1))
    end

    if string.sub(val, 1, 1) == '=' then
        val = mw.text.trim(string.sub(val, 2))
    end

    if val == '' then
        return Upright.new(nil)
    end

    local num = tonumber(val)
    if num == nil or num <= 0 then
        return nil
    end
    return Upright.new(num)
end

--- Creates a new "upright" sizing.
--- @param val number?
--- @return Upright
function Upright.new(val)
    local up = { val }
    if val ~= nil and (type(val) ~= 'number' or val <= 0) then
        error(string.format('upright requires a positive number, got %q', tostring(val)))
    end
    setmetatable(up, UprightMeta)
    return up
end

--- Horizontal alignment argument for file links.
--- @enum HAlign
local HAlign = {
    left = 'left',
    right = 'right',
    center = 'center',
    none = 'none',
}

--- Vertical alignment argument for file links.
--- @enum VAlign
local VAlign = {
    baseline = 'baseline',
    sub = 'sub',
    super = 'super',
    top = 'top',
    ['text-top'] = 'text-top',
    middle = 'middle',
    bottom = 'bottom',
    ['text-bottom'] = 'text-bottom',
}

local FileLinkOptions = { Upright = Upright }

--- Options passed to a file link. Includes everything but the file being linked.
--- @class FileLinkOptions
--- @field border boolean?
--- @field format Format?
--- @field sizing Size|Upright?
--- @field halign HAlign?
--- @field valign VAlign?
--- @field link string?
--- @field alt string?
--- @field page integer?
--- @field thumbtime Time?
--- @field start Time?
--- @field muted boolean?
--- @field loop boolean?
--- @field class StringSet?
--- @field caption string?
local FileLinkOptionsProto = {}
local FileLinkOptionsMeta = { __index = FileLinkOptionsProto }

local function booleanOnly(k, v)
    if v ~= nil and type(v) ~= 'boolean' then
        error(k .. ' must be boolean or nil')
    end
    return v
end
local function stringOnly(k, v)
    if v ~= nil and type(v) ~= 'string' then
        error(k .. ' must be string or nil')
    end
    return v
end
local function positiveIntegerOnly(k, v)
    if v ~= nil and (type(v) ~= 'number' or math.floor(v) ~= v or v < 0) then
        error(k .. ' must be positive integer or nil')
    end
    return v
end
local function enumOnly(k, v)
    local enum = {
        format = Format,
        halign = HAlign,
        valign = VAlign,
    }
    if v ~= nil then
        local val = enum[k][v]
        if val == nil then
            error(k .. ' cannot have value ' .. tostring(v))
        end
        return val
    end
end
local function titleOnly(k, v)
    if v == '' and k ~= 'file' then
        if k == 'link' then
            return ''
        else
            return nil
        end
    end

    local defaultNs = nil
    if k == 'file' then
        defaultNs = 'File'
        if v == nil then
            error('file cannot be nil')
        end
    end
    if type(v) == 'table' and v.fullText then
        return v.fullText
    elseif type(v) == 'string' then
        local title = mw.title.new(v, defaultNs)
        if not title then
            error(k .. ' cannot be ' .. tostring(v))
        end
        return title.fullText
    elseif v ~= nil then
        error(k .. ' cannot be ' .. tostring(v))
    end
end
local function timeOnly(k, v)
    if v ~= nil and not Time.isTime(v) then
        error(k .. ' must be Time or nil')
    end
    return v
end
local function stringSetOnly(k, v)
    if v ~= nil and not StringSet.isStringSet(v) then
        error(k .. ' must be StringSet or nil')
    end
    return v
end
local function optionsOnly(k, v)
    if v ~= nil and not FileLinkOptions.isFileLinkOptions(v) then
        error(k .. ' must be FileLinkOptions or nil')
    end
    return v
end
local function sizingOnly(_, v)
    if v ~= nil and not Size.isSize(v) and not Upright.isUpright(v) then
        error('sizing must be Size, Upright, or nil')
    end
    return v
end
local validateOptions = {
    border = booleanOnly,
    format = enumOnly,
    sizing = sizingOnly,
    halign = enumOnly,
    valign = enumOnly,
    link = titleOnly,
    alt = stringOnly,
    page = positiveIntegerOnly,
    thumbtime = timeOnly,
    start = timeOnly,
    muted = booleanOnly,
    loop = booleanOnly,
    class = stringSetOnly,
    caption = stringOnly,
}

function FileLinkOptionsMeta:__newindex(k, v)
    local f = validateOptions[k]
    if f == nil then
        error('invalid key ' .. k)
    else
        v = f(k, v)
        rawset(self, k, v)
    end
end

--- Options are always formatted with a leading pipe if they exist, to allow easily creating links.
function FileLinkOptionsMeta:__tostring()
    local ret = ''
    if self.border then
        ret = ret .. '|border'
    end
    if self.format ~= nil then
        ret = ret .. '|' .. self.format
    end
    if self.sizing ~= nil then
        ret = ret .. '|' .. tostring(self.sizing)
    end
    if self.halign ~= nil then
        ret = ret .. '|' .. self.halign
    end
    if self.valign ~= nil then
        ret = ret .. '|' .. self.valign
    end
    if self.link ~= nil then
        ret = ret .. '|link=' .. self.link
    end
    if self.alt ~= nil then
        ret = ret .. '|alt=' .. self.alt
    end
    if self.page ~= nil then
        ret = ret .. '|page=' .. tostring(self.page)
    end
    if self.thumbtime ~= nil then
        ret = ret .. '|thumbtime=' .. tostring(self.thumbtime)
    end
    if self.start ~= nil then
        ret = ret .. '|start=' .. tostring(self.start)
    end
    if self.muted then
        ret = ret .. '|muted'
    end
    if self.loop then
        ret = ret .. '|loop'
    end
    if self.class ~= nil then
        ret = ret .. '|class='
        local first = true
        for _, key in ipairs(self.class:sortedKeys()) do
            if first then
                first = false
            else
                ret = ret .. ' '
            end
            ret = ret .. key
        end
    end
    if self.caption ~= nil then
        ret = ret .. '|' .. mw.text.nowiki('') .. self.caption
    end
    return ret
end

--- Checks if a value is FileLinkOptions.
--- @param value any
--- @return boolean
function FileLinkOptions.isFileLinkOptions(value)
    return type(value) == 'table' and rawequal(getmetatable(value), FileLinkOptionsMeta)
end

--- Creates a new FileLinkOptions with its arguments defaulted to those in defaults.
--- @param self     FileLinkOptions
--- @param defaults FileLinkOptions
--- @return FileLinkOptions
function FileLinkOptionsProto:withDefaults(defaults)
    if not FileLinkOptions.isFileLinkOptions(defaults) then
        error('cannot merge FileLinkOptions with non-FileLinkOptions')
    end

    -- copy over this link
    local opts = {
        border = self.border,
        format = self.format,
        sizing = self.sizing,
        link = self.link,
        alt = self.alt,
        page = self.page,
        thumbtime = self.thumbtime,
        start = self.start,
        muted = self.muted,
        loop = self.loop,
        class = self.class,
        caption = self.caption,
    }

    -- boolean flags: override if nil
    if self.border == nil then
        opts.border = defaults.border
    end
    if self.muted == nil then
        opts.muted = defaults.muted
    end
    if self.loop == nil then
        opts.loop = defaults.loop
    end

    -- merge classes
    if self.class == nil then
        opts.class = defaults.class
    else
        self.class:addAll(defaults.class)
    end

    -- replace caption
    if self.caption == nil then
        opts.caption = defaults.caption
    end

    setmetatable(opts, FileLinkOptionsMeta)
    return opts
end

--- Creates new file link options.
--- @return FileLinkOptions
function FileLinkOptions.new()
    local opts = {}
    setmetatable(opts, FileLinkOptionsMeta)
    return opts
end

local valMatch = {}
for key, val in pairs(Format) do
    valMatch[key] = function (opts)
        opts.format = val
    end
end
for key, val in pairs(HAlign) do
    valMatch[key] = function (opts)
        opts.halign = val
    end
end
for key, val in pairs(VAlign) do
    valMatch[key] = function (opts)
        opts.valign = val
    end
end
function valMatch.border(opts)
    opts.border = true
end

function valMatch.muted(opts)
    opts.muted = true
end

function valMatch.loop(opts)
    opts.loop = true
end

local function stringOk(opts, key, val)
    opts[key] = validateOptions[key](key, val)
end
local function numberFirst(opts, key, val)
    local num = tonumber(val)
    if num == nil then
        error(key .. ' must be a number')
    end
    opts[key] = validateOptions[key](key, num)
end
local function timeFirst(opts, key, val)
    local time = Time.parse(val)
    opts[key] = time
end

local keyMatch = {}
function keyMatch.upright(opts, key, val)
    opts.sizing = Upright.parse(val)
end

keyMatch.link = stringOk
keyMatch.alt = stringOk
keyMatch.page = numberFirst
keyMatch.thumbtime = timeFirst
keyMatch.start = timeFirst
function keyMatch.class(opts, key, val)
    opts.class = StringSet.new(mw.text.split(val, '%s'))
end

--- Creates new file link options from a list of arguments.
--- @param args { [number|string]: string }  arguments to file link; mirrors template arguments
--- @param skip number?                      number of positional arguments to skip
--- @return FileLinkOptions
function FileLinkOptions.fromArgs(args, skip)
    local opts = FileLinkOptions.new()

    if skip == nil then
        skip = 0
    end
    for idx, arg in ipairs(args) do
        if idx > skip then
            arg = mw.text.trim(arg)
            local match = valMatch[arg]
            if match ~= nil then
                match(opts)
            else
                local gotupright = false
                if string.lower(string.sub(arg, 1, uplen)) == 'upright' then
                    local upright = Upright.tryParse(arg)
                    if upright ~= nil then
                        opts.sizing = upright
                        gotupright = true
                    end
                end

                if not gotupright then
                    local size = Size.tryParse(arg)
                    if size ~= nil then
                        opts.sizing = size
                    elseif opts.caption == nil then
                        opts.caption = arg
                    else
                        error(string.format('multiple captions in options: %q and %q', opts.caption,
                                            arg))
                    end
                end
            end
        end
    end

    for key, val in pairs(args) do
        if tonumber(key) == nil then
            local match = keyMatch[key]
            if match ~= nil then
                match(opts, key, val)
            end
        end
    end

    return opts
end

local FileLink = { Options = FileLinkOptions }

--- Link to a file.
--- @class FileLink
--- @field file    string
--- @field options FileLinkOptions?
local FileLinkProto = {}
local FileLinkMeta = { __index = FileLinkProto }

local validateLink = {
    file = titleOnly,
    options = optionsOnly,
}

function FileLinkMeta:__newindex(k, v)
    local f = validateLink[k]
    if f == nil then
        error('invalid key ' .. k)
    else
        v = f(k, v)
        rawset(self, k, v)
    end
end

--- File links are formatted as, real shocker, the wikitext for file links.
function FileLinkMeta:__tostring()
    local opts = ''
    if self.options ~= nil then
        opts = tostring(self.options)
    end
    return string.format('[[%s%s]]', self.file, opts)
end

--- Creates new a file link with no options.
--- @param file string
--- @return FileLink
function FileLink.new(file)
    local fl = { file = titleOnly('file', file) }
    setmetatable(fl, FileLinkMeta)
    return fl
end

--- Creates new file link from a list of arguments.
--- @param args { [number|string]: string }  arguments to file link; mirrors template arguments
--- @param skip number?                      number of arguments to skip
--- @return FileLink
function FileLink.fromArgs(args, skip)
    if skip == nil then
        skip = 0
    end
    local file = titleOnly('file', args[skip + 1])
    local opts = FileLinkOptions.fromArgs(args, skip + 1)
    local fl = { file = file, options = opts }
    setmetatable(fl, FileLinkMeta)
    return fl
end

--- Creates a new file link from frame's arguments, stringifying the result.
--- @param frame mw.frame
--- @return string
function FileLink.fromFrame(frame)
    local link = FileLink.fromArgs(frame.args)
    return tostring(link)
end

--- Creates a new file link from the parent frame's arguments, using the defaults for this frame.
--- If the first argument to this frame is a number, uses that as the number of positional arguments
--- to skip on the parent before parsing file options.
--- @param frame mw.frame
--- @return string
function FileLink.fromParent(frame)
    local skip = tonumber(frame.args[1])
    local skipOpts = 0
    if skip ~= nil then
        skipOpts = 1
    end
    local defaults = FileLinkOptions.fromArgs(frame.args, skipOpts)

    frame = frame:getParent()

    local link = FileLink.fromArgs(frame.args, skip)
    link.options = link.options:withDefaults(defaults)
    return tostring(link)
end

return FileLink