Jump to content

Module:GearSet: Difference between revisions

From EverQuest Legends Wiki
No edit summary
No edit summary
Line 2: Line 2:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- Column configuration
-- Table columns
------------------------------------------------------------------------
------------------------------------------------------------------------


local NUMERIC_COLUMNS = {
local NUMERIC_COLUMNS = {
    -- Combat vitality
     {
     {
         key = 'ac',
         key = 'ac',
Line 32: Line 31:
     },
     },


    -- Base attributes
     {
     {
         key = 'str',
         key = 'str',
Line 69: Line 67:
     },
     },


    -- Resistances
     {
     {
         key = 'mr',
         key = 'mr',
Line 123: Line 120:
}
}


-- Item, Slot, numeric columns, Weight, Effect, Dropped By.
local TOTAL_COLUMN_COUNT =
local TOTAL_COLUMN_COUNT =
     2 +                 -- Item and Slot
     2 +
     #NUMERIC_COLUMNS +
     #NUMERIC_COLUMNS +
     4                    -- Haste, Weight, Effect, Dropped By
     3


------------------------------------------------------------------------
------------------------------------------------------------------------
Line 160: Line 158:


     return formatted
     return formatted
end
local function formatHaste(value)
    if value == nil then
        return ''
    end
    local formatted = formatPlainNumber(value)
    if value > 0 then
        return '+' .. formatted .. '%'
    end
    return formatted .. '%'
end
end


Line 186: Line 170:
     formatted = formatted:gsub('%.$', '')
     formatted = formatted:gsub('%.$', '')


     -- Preserve the familiar EverQuest weight style:
     -- Preserve at least one decimal place for whole-number weights.
     -- 2 becomes 2.0, while 0.1 remains 0.1.
     -- The plain-string search must look for "." rather than "%.".
     if not formatted:find('%.', 1, true) then
     if not formatted:find('.', 1, true) then
         formatted = formatted .. '.0'
         formatted = formatted .. '.0'
     end
     end
Line 199: Line 183:
------------------------------------------------------------------------
------------------------------------------------------------------------


-- Reads the named arguments inside the first {{Itempage ...}} invocation.
-- Reads the parameters inside the first {{Itempage ...}} invocation.
--
-- Nested templates, links, and wiki tables are accounted for so their
-- This parser keeps track of nested templates, wiki links, and wiki tables,
-- internal pipes are not mistaken for Itempage parameter separators.
-- preventing internal pipes from being mistaken for Itempage parameters.
local function parseItempageParameters(content)
local function parseItempageParameters(content)
     local startPosition =
     local startPosition =
Line 306: Line 289:
     local parameters = {}
     local parameters = {}


     -- Segment 1 contains the template name.
     -- The first segment is the template name.
     for index = 2, #segments do
     for index = 2, #segments do
         local parameterName, parameterValue =
         local parameterName, parameterValue =
Line 372: Line 355:
     stats = stats:gsub('<!%-%-.-%-%->', '')
     stats = stats:gsub('<!%-%-.-%-%->', '')


     -- Convert every form of <br> into a real line ending.
     -- Convert every common form of <br> into a newline.
     stats = stats:gsub(
     stats = stats:gsub(
         '<[bB][rR]%s*/?%s*>',
         '<[bB][rR]%s*/?%s*>',
Line 419: Line 402:
end
end


-- Returns every line value matching one of the supplied labels.
-- Returns all complete line values belonging to the supplied labels.
--
--
-- Example:
-- For:
-- Effect: [[Swift Spirit]] (Worn)
-- Effect: [[Swift Spirit]] (Worn)
--
--
-- returns:
-- this returns:
-- [[Swift Spirit]] (Worn)
-- [[Swift Spirit]] (Worn)
local function extractLabeledLines(
local function extractLabeledLines(
Line 484: Line 467:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- Effects
-- Item-effect extraction
------------------------------------------------------------------------
------------------------------------------------------------------------


Line 509: Line 492:


     --------------------------------------------------------------------
     --------------------------------------------------------------------
     -- Explicit statsblock effects
     -- Explicit effect lines
     --------------------------------------------------------------------
     --------------------------------------------------------------------


Line 529: Line 512:


     --------------------------------------------------------------------
     --------------------------------------------------------------------
     -- Focus effect parameter
     -- Focus effect stored as a separate Itempage parameter
     --------------------------------------------------------------------
     --------------------------------------------------------------------


Line 550: Line 533:


     --------------------------------------------------------------------
     --------------------------------------------------------------------
     -- Passive regeneration effects
     -- Passive effects represented as statsblock lines
     --------------------------------------------------------------------
     --------------------------------------------------------------------


     local passiveEffectLabels = {
     local passiveEffectLabels = {
        'Haste',
         'HP Regen',
         'HP Regen',
         'Mana Regen',
         'Mana Regen',
Line 559: Line 543:
     }
     }


     for _, label in ipairs(
     for _, label in ipairs(passiveEffectLabels) do
        passiveEffectLabels
    ) do
         local values =
         local values =
             extractLabeledLines(
             extractLabeledLines(
Line 593: Line 575:
     local seen = {}
     local seen = {}


    -- Only bullet-list entries are treated as mobs.
    -- Zone headings remain excluded.
     for line in drops:gmatch('[^\n]+') do
     for line in drops:gmatch('[^\n]+') do
         local mob =
         local mob =
Line 613: Line 597:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- Item rendering and loading
-- Item loading and hover-link rendering
------------------------------------------------------------------------
------------------------------------------------------------------------


Line 686: Line 670:
                 statsblock,
                 statsblock,
                 { 'Slot' }
                 { 'Slot' }
            ),
        haste =
            extractNumericValue(
                statsblock,
                { 'Haste' },
                true
             ),
             ),


Line 717: Line 694:
     }
     }


     for _, column in ipairs(
     for _, column in ipairs(NUMERIC_COLUMNS) do
        NUMERIC_COLUMNS
    ) do
         item[column.key] =
         item[column.key] =
             extractNumericValue(
             extractNumericValue(
Line 732: Line 707:


------------------------------------------------------------------------
------------------------------------------------------------------------
-- HTML table helpers
-- HTML helpers
------------------------------------------------------------------------
------------------------------------------------------------------------


Line 784: Line 759:
end
end


local function addHeader(
local function addHeader(row, text)
    row,
     return row
    text,
        :tag('th')
    colspan
        :attr('scope', 'col')
)
         :wikitext(text)
     local header = row:tag('th')
 
    header:attr(
        'scope',
        colspan and 'colgroup' or 'col'
    )
 
    if colspan then
        header:attr(
            'colspan',
            colspan
         )
    end
 
    header:wikitext(text)
 
    return header
end
end


Line 844: Line 802:
         :tag('caption')
         :tag('caption')
         :wikitext(caption)
         :wikitext(caption)
    --------------------------------------------------------------------
    -- Group headings
    --------------------------------------------------------------------
    local groupRow = output:tag('tr')
    groupRow
        :addClass('sortbottom')
        :css('font-weight', 'bold')
    addHeader(
        groupRow,
        'Item Info',
        2
    )
    addHeader(
        groupRow,
        'Combat Vitality',
        4
    )
    addHeader(
        groupRow,
        'Base Attributes',
        7
    )
    addHeader(
        groupRow,
        'Resistances',
        6
    )
    addHeader(
        groupRow,
        'Miscellaneous',
        4
    )


     --------------------------------------------------------------------
     --------------------------------------------------------------------
Line 894: Line 812:
     addHeader(headerRow, 'Slot')
     addHeader(headerRow, 'Slot')


     for _, column in ipairs(
     for _, column in ipairs(NUMERIC_COLUMNS) do
        NUMERIC_COLUMNS
    ) do
         addHeader(
         addHeader(
             headerRow,
             headerRow,
Line 903: Line 819:
     end
     end


    addHeader(headerRow, 'Haste')
     addHeader(headerRow, 'Weight')
     addHeader(headerRow, 'Weight')
     addHeader(headerRow, 'Effect')
     addHeader(headerRow, 'Effect')
Line 914: Line 829:
     local totals = {}
     local totals = {}


     for _, column in ipairs(
     for _, column in ipairs(NUMERIC_COLUMNS) do
        NUMERIC_COLUMNS
    ) do
         totals[column.key] = 0
         totals[column.key] = 0
     end
     end


     local totalWeight = 0
     local totalWeight = 0
    local bestHaste = nil


     --------------------------------------------------------------------
     --------------------------------------------------------------------
Line 970: Line 882:
             )
             )


             for _, column in ipairs(
             for _, column in ipairs(NUMERIC_COLUMNS) do
                NUMERIC_COLUMNS
            ) do
                 local value =
                 local value =
                     item[column.key]
                     item[column.key]
Line 986: Line 896:
                         value
                         value
                 end
                 end
            end
            addNumericCell(
                row,
                item.haste,
                formatHaste(item.haste)
            )
            if item.haste ~= nil
                and (
                    bestHaste == nil
                    or item.haste > bestHaste
                )
            then
                bestHaste = item.haste
             end
             end


Line 1,045: Line 940:
         :wikitext('Totals')
         :wikitext('Totals')


     for _, column in ipairs(
     for _, column in ipairs(NUMERIC_COLUMNS) do
        NUMERIC_COLUMNS
    ) do
         addNumericCell(
         addNumericCell(
             totalRow,
             totalRow,
Line 1,053: Line 946:
         )
         )
     end
     end
    local hasteCell =
        addNumericCell(
            totalRow,
            bestHaste,
            formatHaste(bestHaste)
        )
    hasteCell:attr(
        'title',
        'Highest haste value in the set; worn haste does not stack.'
    )


     addNumericCell(
     addNumericCell(
Line 1,228: Line 1,109:
     end
     end


     -- Allows unusual categories to be provided directly.
     -- Direct additional category names.
     for _, categoryName in ipairs(
     for _, categoryName in ipairs(
         splitMetadata(args.categories)
         splitMetadata(args.categories)
Line 1,286: Line 1,167:
     local itemNames = {}
     local itemNames = {}


     for _, item in ipairs(
     for _, item in ipairs(indexedItems) do
        indexedItems
    ) do
         table.insert(
         table.insert(
             itemNames,
             itemNames,

Revision as of 01:08, 14 July 2026

Documentation for this module may be created at Module:GearSet/doc

local p = {}

------------------------------------------------------------------------
-- Table columns
------------------------------------------------------------------------

local NUMERIC_COLUMNS = {
    {
        key = 'ac',
        header = 'AC',
        labels = { 'AC' },
    },
    {
        key = 'hp',
        header = 'HP',
        labels = { 'HP' },
    },
    {
        key = 'mana',
        header = 'MANA',
        labels = { 'MANA' },
    },
    {
        key = 'endurance',
        header = 'END',
        labels = {
            'ENDURANCE',
            'ENDUR',
            'END',
        },
    },

    {
        key = 'str',
        header = 'STR',
        labels = { 'STR' },
    },
    {
        key = 'sta',
        header = 'STA',
        labels = { 'STA' },
    },
    {
        key = 'int',
        header = 'INT',
        labels = { 'INT' },
    },
    {
        key = 'wis',
        header = 'WIS',
        labels = { 'WIS' },
    },
    {
        key = 'agi',
        header = 'AGI',
        labels = { 'AGI' },
    },
    {
        key = 'dex',
        header = 'DEX',
        labels = { 'DEX' },
    },
    {
        key = 'cha',
        header = 'CHA',
        labels = { 'CHA' },
    },

    {
        key = 'mr',
        header = 'MR',
        labels = {
            'SV MAGIC',
            'SV MAG',
            'MR',
        },
    },
    {
        key = 'fr',
        header = 'FR',
        labels = {
            'SV FIRE',
            'FR',
        },
    },
    {
        key = 'cr',
        header = 'CR',
        labels = {
            'SV COLD',
            'CR',
        },
    },
    {
        key = 'dr',
        header = 'DR',
        labels = {
            'SV DISEASE',
            'SV DIS',
            'DR',
        },
    },
    {
        key = 'pr',
        header = 'PR',
        labels = {
            'SV POISON',
            'SV POI',
            'PR',
        },
    },
    {
        key = 'vr',
        header = 'VR',
        labels = {
            'SV VR',
            'VR',
        },
    },
}

-- Item, Slot, numeric columns, Weight, Effect, Dropped By.
local TOTAL_COLUMN_COUNT =
    2 +
    #NUMERIC_COLUMNS +
    3

------------------------------------------------------------------------
-- General helpers
------------------------------------------------------------------------

local function trim(value)
    return mw.text.trim(tostring(value or ''))
end

local function escapePattern(value)
    return (
        tostring(value or ''):gsub(
            '([%^%$%(%)%%%.%[%]%*%+%-%?])',
            '%%%1'
        )
    )
end

local function formatPlainNumber(value)
    if value == nil then
        return ''
    end

    if math.floor(value) == value then
        return tostring(math.floor(value))
    end

    local formatted = string.format('%.2f', value)

    formatted = formatted:gsub('0+$', '')
    formatted = formatted:gsub('%.$', '')

    return formatted
end

local function formatWeight(value)
    if value == nil then
        return ''
    end

    local formatted = string.format('%.2f', value)

    formatted = formatted:gsub('0+$', '')
    formatted = formatted:gsub('%.$', '')

    -- Preserve at least one decimal place for whole-number weights.
    -- The plain-string search must look for "." rather than "%.".
    if not formatted:find('.', 1, true) then
        formatted = formatted .. '.0'
    end

    return formatted
end

------------------------------------------------------------------------
-- Itempage template parser
------------------------------------------------------------------------

-- Reads the parameters inside the first {{Itempage ...}} invocation.
-- Nested templates, links, and wiki tables are accounted for so their
-- internal pipes are not mistaken for Itempage parameter separators.
local function parseItempageParameters(content)
    local startPosition =
        content:find('{{%s*[Ii]tempage[%s|]')

    if not startPosition then
        return nil
    end

    local segments = {}
    local buffer = {}

    local curlyDepth = 1
    local squareDepth = 0
    local tableDepth = 0

    local position = startPosition + 2
    local contentLength = #content

    while position <= contentLength do
        local pair =
            content:sub(position, position + 1)

        local character =
            content:sub(position, position)

        if pair == '{{' then
            curlyDepth = curlyDepth + 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == '}}' then
            if curlyDepth == 1 then
                table.insert(
                    segments,
                    table.concat(buffer)
                )

                break
            end

            curlyDepth = curlyDepth - 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == '[[' then
            squareDepth = squareDepth + 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == ']]' and squareDepth > 0 then
            squareDepth = squareDepth - 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == '{|' then
            tableDepth = tableDepth + 1

            table.insert(buffer, pair)

            position = position + 2

        elseif pair == '|}' and tableDepth > 0 then
            tableDepth = tableDepth - 1

            table.insert(buffer, pair)

            position = position + 2

        elseif character == '|'
            and curlyDepth == 1
            and squareDepth == 0
            and tableDepth == 0
        then
            table.insert(
                segments,
                table.concat(buffer)
            )

            buffer = {}

            position = position + 1

        else
            table.insert(buffer, character)

            position = position + 1
        end
    end

    if #segments < 2 then
        return nil
    end

    local parameters = {}

    -- The first segment is the template name.
    for index = 2, #segments do
        local parameterName, parameterValue =
            segments[index]:match(
                '^%s*([%w_%-]+)%s*=(.*)$'
            )

        if parameterName then
            parameters[parameterName:lower()] =
                trim(parameterValue)
        end
    end

    return parameters
end

------------------------------------------------------------------------
-- Page loading
------------------------------------------------------------------------

local function getPageContent(pageName)
    local title = mw.title.new(pageName)

    -- Follow ordinary redirects.
    for _ = 1, 4 do
        if not title then
            return nil
        end

        local content = title:getContent()

        if not content then
            return nil
        end

        local redirectTarget =
            content:match(
                '^%s*#[Rr][Ee][Dd][Ii][Rr][Ee][Cc][Tt]' ..
                '%s*%[%[([^%]|#]+)'
            )

        if not redirectTarget then
            return content
        end

        title = mw.title.new(
            trim(redirectTarget)
        )
    end

    return nil
end

------------------------------------------------------------------------
-- Statsblock parsing
------------------------------------------------------------------------

local function normalizeStatsblock(statsblock)
    local stats = tostring(statsblock or '')

    stats = stats:gsub('\r\n', '\n')
    stats = stats:gsub('\r', '\n')

    -- Remove comments.
    stats = stats:gsub('<!%-%-.-%-%->', '')

    -- Convert every common form of <br> into a newline.
    stats = stats:gsub(
        '<[bB][rR]%s*/?%s*>',
        '\n'
    )

    stats = stats:gsub('&nbsp;', ' ')
    stats = stats:gsub('[ \t]+\n', '\n')

    return stats
end

local function extractNumericValue(
    statsblock,
    labels,
    allowDecimal
)
    local uppercaseStats =
        tostring(statsblock or ''):upper()

    local valuePattern

    if allowDecimal then
        valuePattern =
            '([%+%-]?%d+%.?%d*)'
    else
        valuePattern =
            '([%+%-]?%d+)'
    end

    for _, label in ipairs(labels) do
        local pattern =
            escapePattern(label:upper()) ..
            '%s*:%s*' ..
            valuePattern

        local value =
            uppercaseStats:match(pattern)

        if value then
            return tonumber(value)
        end
    end

    return nil
end

-- Returns all complete line values belonging to the supplied labels.
--
-- For:
-- Effect: [[Swift Spirit]] (Worn)
--
-- this returns:
-- [[Swift Spirit]] (Worn)
local function extractLabeledLines(
    statsblock,
    labels
)
    local results = {}
    local seen = {}

    for line in tostring(statsblock or ''):gmatch('[^\n]+') do
        local cleanLine = trim(line)
        local uppercaseLine = cleanLine:upper()

        for _, label in ipairs(labels) do
            local pattern =
                '^' ..
                escapePattern(label:upper()) ..
                '%s*:%s*'

            local _, valueStart =
                uppercaseLine:find(pattern)

            if valueStart then
                local value =
                    trim(
                        cleanLine:sub(
                            valueStart + 1
                        )
                    )

                if value ~= ''
                    and not seen[value]
                then
                    seen[value] = true

                    table.insert(
                        results,
                        value
                    )
                end

                break
            end
        end
    end

    return results
end

local function extractLine(statsblock, labels)
    local values =
        extractLabeledLines(
            statsblock,
            labels
        )

    return values[1] or ''
end

------------------------------------------------------------------------
-- Item-effect extraction
------------------------------------------------------------------------

local function buildEffect(
    parameters,
    statsblock
)
    local results = {}
    local seen = {}

    local function addEffect(value)
        value = trim(value)

        if value == ''
            or seen[value]
        then
            return
        end

        seen[value] = true

        table.insert(results, value)
    end

    --------------------------------------------------------------------
    -- Explicit effect lines
    --------------------------------------------------------------------

    local explicitEffects =
        extractLabeledLines(
            statsblock,
            {
                'Effect',
                'Click Effect',
                'Worn Effect',
                'Proc Effect',
                'Combat Effect',
            }
        )

    for _, effect in ipairs(explicitEffects) do
        addEffect(effect)
    end

    --------------------------------------------------------------------
    -- Focus effect stored as a separate Itempage parameter
    --------------------------------------------------------------------

    local focusEffect =
        trim(parameters.focus_effect)

    if focusEffect ~= '' then
        if focusEffect:find('%[%[') then
            addEffect(
                'Focus: ' .. focusEffect
            )
        else
            addEffect(
                'Focus: [[' ..
                focusEffect ..
                ']]'
            )
        end
    end

    --------------------------------------------------------------------
    -- Passive effects represented as statsblock lines
    --------------------------------------------------------------------

    local passiveEffectLabels = {
        'Haste',
        'HP Regen',
        'Mana Regen',
        'Endurance Regen',
    }

    for _, label in ipairs(passiveEffectLabels) do
        local values =
            extractLabeledLines(
                statsblock,
                { label }
            )

        for _, value in ipairs(values) do
            addEffect(
                label .. ': ' .. value
            )
        end
    end

    return table.concat(results, '<br>')
end

------------------------------------------------------------------------
-- Drop-source extraction
------------------------------------------------------------------------

local function extractDropMobs(dropsfrom)
    local drops =
        tostring(dropsfrom or '')

    drops = drops:gsub('\r\n', '\n')
    drops = drops:gsub('\r', '\n')
    drops = drops:gsub('<!%-%-.-%-%->', '')

    local results = {}
    local seen = {}

    -- Only bullet-list entries are treated as mobs.
    -- Zone headings remain excluded.
    for line in drops:gmatch('[^\n]+') do
        local mob =
            line:match(
                '^%s*%*+%s*(.-)%s*$'
            )

        if mob
            and mob ~= ''
            and not seen[mob]
        then
            seen[mob] = true

            table.insert(results, mob)
        end
    end

    return table.concat(results, '<br>')
end

------------------------------------------------------------------------
-- Item loading and hover-link rendering
------------------------------------------------------------------------

local function createHoverLink(
    frame,
    pageName
)
    local success, rendered =
        pcall(
            function()
                return frame:preprocess(
                    '{{:' .. pageName .. '}}'
                )
            end
        )

    if success
        and trim(rendered) ~= ''
    then
        return rendered
    end

    return '[[' .. pageName .. ']]'
end

local function readItem(
    frame,
    pageName
)
    local content =
        getPageContent(pageName)

    if not content then
        return nil,
            'Item page does not exist or could not be read.'
    end

    local parameters =
        parseItempageParameters(content)

    if not parameters then
        return nil,
            'No Itempage template was found on the item page.'
    end

    local statsblock =
        normalizeStatsblock(
            parameters.statsblock
        )

    if statsblock == '' then
        return nil,
            'The item has no statsblock.'
    end

    local item = {
        pageName = pageName,

        itemName =
            trim(parameters.itemname) ~= ''
            and trim(parameters.itemname)
            or pageName,

        hover =
            createHoverLink(
                frame,
                pageName
            ),

        slot =
            extractLine(
                statsblock,
                { 'Slot' }
            ),

        weight =
            extractNumericValue(
                statsblock,
                {
                    'WT',
                    'Weight',
                },
                true
            ),

        effect =
            buildEffect(
                parameters,
                statsblock
            ),

        droppedBy =
            extractDropMobs(
                parameters.dropsfrom
            ),
    }

    for _, column in ipairs(NUMERIC_COLUMNS) do
        item[column.key] =
            extractNumericValue(
                statsblock,
                column.labels,
                false
            )
    end

    return item
end

------------------------------------------------------------------------
-- HTML helpers
------------------------------------------------------------------------

local function addCell(
    row,
    value,
    alignLeft
)
    local cell = row:tag('td')

    if alignLeft then
        cell:css(
            'text-align',
            'left'
        )
    end

    if value ~= nil
        and value ~= ''
    then
        cell:wikitext(
            tostring(value)
        )
    end

    return cell
end

local function addNumericCell(
    row,
    numericValue,
    displayedValue
)
    local cell = row:tag('td')

    if numericValue ~= nil then
        cell:attr(
            'data-sort-value',
            tostring(numericValue)
        )

        cell:wikitext(
            displayedValue
                or formatPlainNumber(
                    numericValue
                )
        )
    end

    return cell
end

local function addHeader(row, text)
    return row
        :tag('th')
        :attr('scope', 'col')
        :wikitext(text)
end

------------------------------------------------------------------------
-- Table rendering
------------------------------------------------------------------------

local function renderTable(
    frame,
    args,
    itemNames
)
    local output =
        mw.html.create('table')

    output
        :addClass('wikitable')
        :addClass('sortable')
        :addClass('eql-gear-set-table')
        :css('text-align', 'center')

    --------------------------------------------------------------------
    -- Caption
    --------------------------------------------------------------------

    local caption = trim(args.caption)

    if caption == '' then
        caption = trim(args.name)
    end

    if caption == '' then
        caption =
            mw.title.getCurrentTitle().text
    end

    output
        :tag('caption')
        :wikitext(caption)

    --------------------------------------------------------------------
    -- Column headings
    --------------------------------------------------------------------

    local headerRow = output:tag('tr')

    addHeader(headerRow, 'Item')
    addHeader(headerRow, 'Slot')

    for _, column in ipairs(NUMERIC_COLUMNS) do
        addHeader(
            headerRow,
            column.header
        )
    end

    addHeader(headerRow, 'Weight')
    addHeader(headerRow, 'Effect')
    addHeader(headerRow, 'Dropped By')

    --------------------------------------------------------------------
    -- Totals
    --------------------------------------------------------------------

    local totals = {}

    for _, column in ipairs(NUMERIC_COLUMNS) do
        totals[column.key] = 0
    end

    local totalWeight = 0

    --------------------------------------------------------------------
    -- Item rows
    --------------------------------------------------------------------

    for _, pageName in ipairs(itemNames) do
        local item, errorMessage =
            readItem(
                frame,
                pageName
            )

        if not item then
            local errorRow =
                output:tag('tr')

            errorRow
                :tag('td')
                :attr(
                    'colspan',
                    TOTAL_COLUMN_COUNT
                )
                :addClass('error')
                :css(
                    'text-align',
                    'left'
                )
                :wikitext(
                    '[[' ..
                    pageName ..
                    ']]: ' ..
                    errorMessage
                )
        else
            local row = output:tag('tr')

            addCell(
                row,
                item.hover,
                true
            )

            addCell(
                row,
                item.slot,
                true
            )

            for _, column in ipairs(NUMERIC_COLUMNS) do
                local value =
                    item[column.key]

                addNumericCell(
                    row,
                    value
                )

                if value ~= nil then
                    totals[column.key] =
                        totals[column.key] +
                        value
                end
            end

            addNumericCell(
                row,
                item.weight,
                formatWeight(item.weight)
            )

            if item.weight ~= nil then
                totalWeight =
                    totalWeight +
                    item.weight
            end

            addCell(
                row,
                item.effect,
                true
            )

            addCell(
                row,
                item.droppedBy,
                true
            )
        end
    end

    --------------------------------------------------------------------
    -- Totals row
    --------------------------------------------------------------------

    local totalRow = output:tag('tr')

    totalRow
        :addClass('sortbottom')
        :css('font-weight', 'bold')

    totalRow
        :tag('td')
        :attr('colspan', 2)
        :css('text-align', 'right')
        :wikitext('Totals')

    for _, column in ipairs(NUMERIC_COLUMNS) do
        addNumericCell(
            totalRow,
            totals[column.key]
        )
    end

    addNumericCell(
        totalRow,
        totalWeight,
        formatWeight(totalWeight)
    )

    addCell(totalRow, '', false)
    addCell(totalRow, '', false)

    return tostring(output)
end

------------------------------------------------------------------------
-- Automatic category metadata
------------------------------------------------------------------------

local function splitMetadata(value)
    local values = {}

    for part in tostring(value or ''):gmatch(
        '[^,;]+'
    ) do
        part = trim(part)

        if part ~= '' then
            table.insert(values, part)
        end
    end

    return values
end

local function addCategory(
    categories,
    seen,
    categoryName
)
    categoryName = trim(categoryName)

    if categoryName == ''
        or seen[categoryName]
    then
        return
    end

    seen[categoryName] = true

    table.insert(
        categories,
        '[[Category:' ..
        categoryName ..
        ']]'
    )
end

local function addGearSetCategories(
    categories,
    seen,
    value
)
    for _, entry in ipairs(
        splitMetadata(value)
    ) do
        addCategory(
            categories,
            seen,
            entry .. ' Gear Sets'
        )
    end
end

local function buildCategories(args)
    local categories = {}
    local seen = {}

    addCategory(
        categories,
        seen,
        'Gear Sets'
    )

    local setType =
        trim(args.type):lower()

    if setType == 'weapon'
        or setType == 'weapons'
    then
        addCategory(
            categories,
            seen,
            'Weapon Sets'
        )
    else
        addCategory(
            categories,
            seen,
            'Armor Sets'
        )
    end

    addGearSetCategories(
        categories,
        seen,
        args.era
    )

    addGearSetCategories(
        categories,
        seen,
        args.source
    )

    addGearSetCategories(
        categories,
        seen,
        args.armor
    )

    addGearSetCategories(
        categories,
        seen,
        args.class
    )

    addGearSetCategories(
        categories,
        seen,
        args.region
    )

    addGearSetCategories(
        categories,
        seen,
        args.craft
    )

    addGearSetCategories(
        categories,
        seen,
        args.series
    )

    local cultures =
        splitMetadata(args.culture)

    if #cultures > 0 then
        addCategory(
            categories,
            seen,
            'Cultural Gear Sets'
        )

        for _, culture in ipairs(cultures) do
            addCategory(
                categories,
                seen,
                culture ..
                ' Cultural Gear Sets'
            )
        end
    end

    -- Direct additional category names.
    for _, categoryName in ipairs(
        splitMetadata(args.categories)
    ) do
        addCategory(
            categories,
            seen,
            categoryName
        )
    end

    return table.concat(categories)
end

------------------------------------------------------------------------
-- Module entry point
------------------------------------------------------------------------

function p.main(frame)
    local parent = frame:getParent()

    local args =
        parent and parent.args
        or frame.args

    local indexedItems = {}

    for argumentName, argumentValue
        in pairs(args)
    do
        local numericIndex =
            tonumber(argumentName)

        local itemName =
            trim(argumentValue)

        if numericIndex
            and itemName ~= ''
        then
            table.insert(
                indexedItems,
                {
                    index = numericIndex,
                    name = itemName,
                }
            )
        end
    end

    table.sort(
        indexedItems,
        function(left, right)
            return left.index < right.index
        end
    )

    local itemNames = {}

    for _, item in ipairs(indexedItems) do
        table.insert(
            itemNames,
            item.name
        )
    end

    if #itemNames == 0 then
        return
            '<strong class="error">' ..
            'Gear Set requires at least one item page.' ..
            '</strong>'
    end

    return
        renderTable(
            frame,
            args,
            itemNames
        ) ..
        buildCategories(args)
end

return p