Jump to content

Module:TemplateHelpers

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

-- Module:TemplateHelpers
-- Common helper functions for template modules promoting code reuse and consistency.
-- Provides utilities for string processing, field handling, normalization, and block rendering.
-- 
-- This module contains the following sections:
-- * String Processing Functions - For manipulating strings and template arguments
-- * Field Processing Functions - For handling template fields and values
-- * Normalization Wrappers - For standardizing data formats
-- * Block Generation Helpers - For rendering template blocks
-- * Category and Semantic Utilities - DEPRECATED wrappers for SemanticCategoryHelpers
-- * Configuration Standardization - For creating standard config structures

local p = {}

--------------------------------------------------------------------------------
-- Caching Mechanism
--------------------------------------------------------------------------------

-- Module-level unified cache
local functionCache = {}

-- Helper for generating cache keys from multiple arguments
-- @param prefix String prefix for the cache key (usually the function name)
-- @param ... Any number of arguments to include in the cache key
-- @return A string cache key
function p.generateCacheKey(prefix, ...)
    local args = {...}
    local parts = {prefix}
    
    for i, arg in ipairs(args) do
        if type(arg) == "table" then
            -- For tables, we can't reliably generate a cache key
            -- So we just use a placeholder with the table's memory address
            parts[i+1] = "table:" .. tostring(arg)
        elseif type(arg) == "nil" then
            parts[i+1] = "nil"
        else
            parts[i+1] = tostring(arg)
        end
    end
    
    return table.concat(parts, ":")
end

-- Generic caching wrapper
-- @param cacheKey The cache key to use
-- @param operation A function that returns the value to cache
-- @return The cached result or the result of executing the operation
function p.withCache(cacheKey, operation)
    -- Check if result is already cached
    if functionCache[cacheKey] ~= nil then
        return functionCache[cacheKey]
    end
    
    -- Execute operation and cache result
    local result = operation()
    functionCache[cacheKey] = result
    return result
end

--------------------------------------------------------------------------------
-- Constants
--------------------------------------------------------------------------------

-- This constant defines how the "label" string are rendered in HTML; we further manipulate them with the "template-label-style" CSS class
-- Added class to the entire row for alternating row colors and direct cell styling for better control
local FIELD_FORMAT = "|- class=\"template-data-row\"\n| class=\"template-label-cell\" | <span class=\"template-label-style\">%s</span>\n| class=\"template-value-cell\" | %s"

-- Format with tooltip support - includes tooltip data directly on the cell
local FIELD_FORMAT_WITH_TOOLTIP = "|- class=\"template-data-row\"\n| class=\"template-label-cell%s\" %s | <span class=\"template-label-style\">%s</span>\n| class=\"template-value-cell\" | %s"

-- Expose the field formats for use by other modules
p.FIELD_FORMAT = FIELD_FORMAT
p.FIELD_FORMAT_WITH_TOOLTIP = FIELD_FORMAT_WITH_TOOLTIP

-- Dependencies
local linkParser = require('Module:LinkParser')
local CountryData = require('Module:CountryData')
local dateNormalization = require('Module:NormalizationDate')
local CanonicalForms = require('Module:CanonicalForms')
local NormalizationText = require('Module:NormalizationText')

--------------------------------------------------------------------------------
-- String Processing Functions
--------------------------------------------------------------------------------

-- Normalizes template arguments to be case-insensitive
-- Returns a new table with lowercase keys while preserving original keys as well
-- Also handles empty numeric parameters that can occur with {{Template|}} syntax
function p.normalizeArgumentCase(args)
    local normalized = {}
    
    -- Process all keys
    for key, value in pairs(args) do
        -- Skip empty numeric parameters (created by leading pipes after template name)
        if type(key) == "number" and (value == nil or value == "") then
            -- Do nothing with empty numeric parameters
        else
            -- For all other parameters, add lowercase version
            if type(key) == "string" then
                normalized[key:lower()] = value
            end
            -- Preserve original key as well
            normalized[key] = value
        end
    end
    
    return normalized
end

-- Trims leading and trailing whitespace from a string
function p.trim(s)
    return NormalizationText.trim(s)
end

-- Joins a table of values with the specified delimiter
function p.joinValues(values, delimiter)
    return NormalizationText.joinValues(values, delimiter)
end

-- Get current page ID with caching
-- @return number|nil The current page ID or nil if not available
function p.getCurrentPageId()
    -- Use mw.title API to get the current page title object
    local title = mw.title.getCurrentTitle()
    -- Return the ID property or nil if not available
    return title and title.id or nil
end

-- Module-level cache for wiki link processing
local wikiLinkCache = {}

-- @deprecated See LinkParser.processWikiLink
function p.processWikiLink(value, mode)
    return require('Module:LinkParser').processWikiLink(value, mode)
end


-- Module-level pattern categories for sanitizing user input
-- These are exposed for potential extension by other modules
p.SANITIZE_PATTERNS = {
    WIKI_LINKS = {
        { 
            pattern = "%[%[([^|%]]+)%]%]", 
            replacement = function(match) 
                return linkParser.processWikiLink("[[" .. match .. "]]", "strip") 
            end 
        },
        { 
            pattern = "%[%[([^|%]]+)|([^%]]+)%]%]", 
            replacement = function(match1, match2) 
                return linkParser.processWikiLink("[[" .. match1 .. "|" .. match2 .. "]]", "strip") 
            end 
        }
    },
    SINGLE_BRACES = {
        { pattern = "{([^{}]+)}", replacement = "%1" }  -- {text} -> text
    },
    HTML_BASIC = {
        { pattern = "</?[bi]>", replacement = "" },     -- Remove <b>, </b>, <i>, </i>
        { pattern = "</?span[^>]*>", replacement = "" } -- Remove <span...>, </span>
    },
    LOGO = {
        { pattern = "^[Ff][Ii][Ll][Ee]%s*:", replacement = "" } -- Remove "File:" prefix
    },
    IMAGE_FILES = {
        { pattern = "%[%[([^|%]]+)%]%]", replacement = "%1" },     -- [[Image.jpg]] -> Image.jpg
        { pattern = "%[%[([^|%]]+)|.+%]%]", replacement = "%1" },  -- [[Image.jpg|...]] -> Image.jpg
        { pattern = "^[Ff][Ii][Ll][Ee]%s*:", replacement = "" },   -- Remove "File:" prefix
        { pattern = "^[Ii][Mm][Aa][Gg][Ee]%s*:", replacement = "" } -- Remove "Image:" prefix too
    }
}

-- Sanitizes user input by removing or transforming unwanted patterns
function p.sanitizeUserInput(value, patternCategories, customPatterns, options)
    return NormalizationText.sanitizeUserInput(value, patternCategories, customPatterns, options)
end

--------------------------------------------------------------------------------
-- Field Processing Functions
--------------------------------------------------------------------------------

function p.getFieldValue(args, field)
    -- Cache lookup for performance
    local cacheKey = p.generateCacheKey("getFieldValue", field.key or table.concat(field.keys or {}, ","), args)
    local cached = p.withCache(cacheKey, function()
        -- Case-insensitive lookup logic
        if field.keys then
            for _, key in ipairs(field.keys) do
                if args[key] and args[key] ~= "" then
                    return { key = key, value = args[key] }
                end
                local lowerKey = key:lower()
                if args[lowerKey] and args[lowerKey] ~= "" and lowerKey ~= key then
                    return { key = lowerKey, value = args[lowerKey] }
                end
            end
            return { key = nil, value = nil }
        end

        if field.key then
            if args[field.key] and args[field.key] ~= "" then
                return { key = field.key, value = args[field.key] }
            end
            local lowerKey = field.key:lower()
            if args[lowerKey] and args[lowerKey] ~= "" and lowerKey ~= field.key then
                return { key = lowerKey, value = args[lowerKey] }
            end
            return { key = field.key, value = nil }
        end

        return { key = nil, value = nil }
    end)
    return cached.key, cached.value
end

-- Processes multiple values with a given processor function
-- Uses splitMultiValueString for more flexible delimiter handling
-- Pre-allocates result table for better performance
function p.processMultipleValues(values, processor)
    if not values or values == "" then return {} end
    local items = p.splitMultiValueString(values)
    
    -- Pre-allocate results table based on input size
    local results = {}
    local resultIndex = 1
    
    for _, item in ipairs(items) do
        local processed = processor(item)
        if processed and processed ~= "" then
            results[resultIndex] = processed
            resultIndex = resultIndex + 1
        end
    end
    return results
end

--------------------------------------------------------------------------------
-- Normalization Wrappers
--------------------------------------------------------------------------------

-- Formats website URLs as an HTML unordered list of links, ensuring consistent emoji display
-- Uses splitMultiValueString for more flexible delimiter handling
function p.normalizeWebsites(value)
    if not value or value == "" then return "" end
    
    -- Get websites as a table, handling both single and multiple cases
    local websites
    
    -- Quick check for single website (no delimiters)
    if not value:match(";") and not value:match("%s+and%s+") then
        -- Single website case - create a single-item table
        websites = {value}
    else
        -- Multiple websites case
        websites = p.splitMultiValueString(value)
    end
    
    -- Handle all websites consistently using the list format
    if #websites > 0 then
        -- Pre-allocate listItems table based on number of websites
        local listItems = {}
        local index = 1
        
        for _, site in ipairs(websites) do
            -- Ensure the site has a protocol prefix for proper linking
            local linkUrl = site
            if not linkUrl:match("^%a+://") then
                linkUrl = "https://" .. linkUrl
            end
            
            local formattedLink = string.format("[%s %s]", linkUrl, linkParser.strip(site))
            listItems[index] = string.format("<li>%s</li>", formattedLink)
            index = index + 1
        end
        return string.format("<ul class=\"template-list template-list-website\">%s</ul>", table.concat(listItems, ""))
    end
    
    return ""
end

-- Wrapper around CountryData for consistent country formatting
function p.normalizeCountries(value)
    if not value or value == "" then return "" end
    
    -- Create a cache key
    local cacheKey = p.generateCacheKey("normalizeCountries", value)
    
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        return CountryData.formatCountries(value)
    end)
end

-- Wrapper around DateNormalization for consistent date formatting
function p.normalizeDates(value)
    if not value or value == "" then return "" end
    
    -- Create a cache key
    local cacheKey = p.generateCacheKey("normalizeDates", value)
    
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        return tostring(dateNormalization.formatDate(value))
    end)
end

-- Formats a date range with configurable options
-- @param startDate The start date string
-- @param endDate The end date string (optional)
-- @param options Table of options for customizing the output:
--   - dateLabel: Label to use for the date field (default: ConfigRepository.fieldLabels.date)
--   - rangeDelimiter: String to use between dates (default: " – " [en dash])
--   - outputMode: Output format - "complete" (default), "text", or "html"
--   - showSingleDate: Whether to show the start date when end date is missing (default: true)
--   - consolidateIdenticalDates: Whether to show only one date when start=end (default: true)
-- @return Based on outputMode:
--   - "text": Returns the formatted date text as a string
--   - "html": Returns the complete HTML for the date field as a string
--   - "complete": Returns a table with text, html, and isCompleteHtml properties
function p.formatDateRange(startDate, endDate, options)
    -- Default options
    options = options or {}
    local dateLabel = options.dateLabel or (require('Module:ConfigRepository').fieldLabels.date)
    local rangeDelimiter = options.rangeDelimiter or " – " -- en dash
    local outputMode = options.outputMode or "complete" -- "complete", "text", or "html"
    local showSingleDate = options.showSingleDate ~= false -- true by default
    local consolidateIdenticalDates = options.consolidateIdenticalDates ~= false -- true by default

    -- Global fallback: if only end date is present, treat end as start
    if (not startDate or startDate == "") and endDate and endDate ~= "" then
        startDate, endDate = endDate, nil
    end
    
    -- Handle empty input
    if not startDate or startDate == "" then
        if outputMode == "text" then return "" end
        if outputMode == "html" then return "" end
        return { text = "", html = "", isCompleteHtml = true }
    end
    
    -- Create a cache key
    -- For options, we only include the values that affect the output
    local optionsKey = string.format(
        "%s:%s:%s:%s",
        dateLabel,
        rangeDelimiter,
        outputMode,
        consolidateIdenticalDates and "consolidate" or "noconsolidate"
    )
    
    local cacheKey = p.generateCacheKey("formatDateRange", startDate, endDate or "nil", optionsKey)
    
    -- Use the caching wrapper
    return p.withCache(cacheKey, function()
        -- Normalize dates
        local startFormatted = p.normalizeDates(startDate)
        local endFormatted = endDate and endDate ~= "" and p.normalizeDates(endDate) or nil
        
        -- Format date text based on options
        local dateText
        if endFormatted and endFormatted ~= startFormatted then
            -- Different start and end dates
            dateText = startFormatted .. rangeDelimiter .. endFormatted
        elseif endFormatted and endFormatted == startFormatted and not consolidateIdenticalDates then
            -- Same start and end dates, but option to show both
            dateText = startFormatted .. rangeDelimiter .. endFormatted
        else
            -- Single date or consolidated identical dates
            dateText = startFormatted
        end
        
        -- Format HTML using the field format and label
        local dateHtml = string.format(FIELD_FORMAT, dateLabel, dateText)
        
        -- Return based on requested output mode
        if outputMode == "text" then return dateText end
        if outputMode == "html" then return dateHtml end
        
        -- Default: return both formats
        return {
            text = dateText,
            html = dateHtml,
            isCompleteHtml = true -- For compatibility with existing code
        }
    end)
end

--------------------------------------------------------------------------------
-- Block Generation Helpers
--------------------------------------------------------------------------------

-- @deprecated See TemplateStructure.renderTitleBlock and AchievementSystem.renderTitleBlockWithAchievement
function p.renderTitleBlock(args, titleClass, titleText, options)
    options = options or {}
    
    -- If achievement support is needed, use AchievementSystem
    if options.achievementSupport then
        return require('Module:AchievementSystem').renderTitleBlockWithAchievement(
            args, titleClass, titleText, 
            options.achievementClass or "", 
            options.achievementId or "", 
            options.achievementName or ""
        )
    else
        -- Otherwise use the basic title block from TemplateStructure
        return require('Module:TemplateStructure').renderTitleBlock(args, titleClass, titleText)
    end
end


-- Renders a standard fields block based on field definitions and processors
-- Enhanced to support complete HTML blocks, custom field rendering, and tooltips
-- Pre-allocates output table for better performance
function p.renderFieldsBlock(args, fields, processors, propertyMappings)
    processors = processors or {}
    propertyMappings = propertyMappings or {}

    -- filter out hidden fields
    local filteredFields = {}
    for _, f in ipairs(fields) do
        if not f.hidden then
            table.insert(filteredFields, f)
        end
    end
    
    -- Pre-allocate output table - estimate based on number of fields
    -- Not all fields may be present in args, but this gives us a reasonable upper bound
    local out = {}
    local outIndex = 1
    
    for _, field in ipairs(filteredFields) do
        local key, value = p.getFieldValue(args, field)
        if value then
            -- Create sanitization options
            local sanitizeOptions = {
                preserveWikiLinks = field.autoWikiLink or field.preserveWikiLinks
            }
            
            -- Get property name for this field if available (case-insensitive)
            local propertyName = nil
            for propName, fieldName in pairs(propertyMappings) do
                if key and (fieldName == key or tostring(fieldName):lower() == tostring(key):lower()) then
                    propertyName = propName
                    break
                end
            end
            
            -- Get tooltip text if property exists
            local tooltipText = ""
            if propertyName then
                tooltipText = require('Module:SemanticCategoryHelpers').getPropertyDescription(propertyName) or ""
            end
            
            -- Prepare tooltip attributes if tooltip text exists
            local tooltipClass = ""
            local tooltipAttr = ""
            if tooltipText and tooltipText ~= "" then
                -- Escape quotes in tooltip text to prevent HTML attribute issues
                local escapedTooltip = tooltipText:gsub('"', '&quot;')
                tooltipClass = " has-tooltip"
                tooltipAttr = string.format('data-tooltip="%s"', escapedTooltip)
            end
            
            -- Apply processor if available for this field
            if key and processors[key] and type(processors[key]) == "function" then
                local processedValue = processors[key](value, args)
                
                -- Preserve wiki links if needed
                processedValue = linkParser.preserveWikiLinks(
                    value, 
                    processedValue, 
                    sanitizeOptions.preserveWikiLinks
                )
                
                -- Handle the case where a processor returns complete HTML
                if type(processedValue) == "table" and processedValue.isCompleteHtml then
                    -- Add the complete HTML as is
                    out[outIndex] = processedValue.html
                    outIndex = outIndex + 1
                elseif processedValue ~= nil and processedValue ~= false then
                    -- Apply wiki link handling
                    processedValue = linkParser.applyWikiLinkHandling(processedValue, field)
                    
                    -- Standard field rendering with tooltip
                    out[outIndex] = string.format(FIELD_FORMAT_WITH_TOOLTIP, 
                        tooltipClass, tooltipAttr, field.label, processedValue)
                    outIndex = outIndex + 1
                end
            else
                -- Standard field rendering without processor
                -- Apply sanitization with preserveWikiLinks option if needed
                local finalValue
                if sanitizeOptions.preserveWikiLinks then
                    finalValue = value
                else
                    finalValue = p.sanitizeUserInput(value, nil, nil, sanitizeOptions)
                end
                
                -- Apply wiki link handling
                finalValue = linkParser.applyWikiLinkHandling(finalValue, field)
                
                -- Use format with tooltip
                out[outIndex] = string.format(FIELD_FORMAT_WITH_TOOLTIP, 
                    tooltipClass, tooltipAttr, field.label, finalValue)
                outIndex = outIndex + 1
            end
        end
    end
    
    return table.concat(out, "\n")
end

-- @deprecated See TemplateStructure.renderDividerBlock
function p.renderDividerBlock(label)
    return require('Module:TemplateStructure').renderDividerBlock(label)
end

-- Extracts semantic value from a field, handling wiki links appropriately
-- @param fieldValue The value to extract semantic data from
-- @param fieldName The name of the field (for error reporting)
-- @param errorContext Optional error context for error handling
-- @return The extracted semantic value or nil if the input is empty
function p.extractSemanticValue(fieldValue, fieldName, errorContext)
    if not fieldValue or fieldValue == "" then
        return nil
    end
    
    -- If the value already has wiki links, extract the name using LinkParser
    local LinkParser = require('Module:LinkParser')
    if LinkParser.processWikiLink(fieldValue, "check") then
        -- Use the standardized error handling helper
        return p.withErrorHandling(
            errorContext,
            "extractFromWikiLink_" .. fieldName,
            LinkParser.extractFromWikiLink,
            fieldValue,  -- fallback to original value on error
            fieldValue
        )
    else
        -- Otherwise, use the plain text value
        return fieldValue
    end
end

-- Standardized error handling helper
-- Executes a function with error protection if an error context is provided
-- @param errorContext The error context for error handling (optional)
-- @param functionName The name of the function being protected (for error reporting)
-- @param operation The function to execute
-- @param fallback The fallback value to return if an error occurs
-- @param ... Additional arguments to pass to the operation function
-- @return The result of the operation or the fallback value if an error occurs
function p.withErrorHandling(errorContext, functionName, operation, fallback, ...)
    -- Capture varargs in a local table to avoid using ... multiple times
    local args = {...}
    
    -- If no error context is provided, execute the operation directly
    if not errorContext or type(errorContext) ~= "table" then
        return operation(unpack(args))
    end
    
    -- Use ErrorHandling module for protected execution
    local ErrorHandling = require('Module:ErrorHandling')
    
    -- Create a wrapper function that passes the arguments to the operation
    local wrapper = function()
        return operation(unpack(args))
    end
    
    -- Execute with error protection
    return ErrorHandling.protect(
        errorContext,
        functionName,
        wrapper,
        fallback
    )
end

-- Renders a standard logo block with sanitized image path
-- @param args The template arguments
-- @param options Table of options for customizing the output:
--   - cssClass: CSS class for the logo container (default: "template-logo")
--   - imageParams: Additional image parameters like size, alignment (default: "")
--   - errorContext: Optional error context for error handling
-- @return The rendered logo block HTML or empty string if no logo
function p.renderLogoBlock(args, options)
    -- Default options
    options = options or {}
    local cssClass = options.cssClass or "template-logo"
    local imageParams = options.imageParams or ""
    
    -- Define the logo rendering operation
    local function renderLogoOperation(args, cssClass, imageParams)
        -- Get logo parameter
        local logo = args["logo"]
        
        -- If no logo or empty, return empty string
        if not logo or logo == "" then
            return ""
        end
        
        -- Sanitize logo path - extract filename and remove prefixes
        logo = p.sanitizeUserInput(logo, "IMAGE_FILES")
        
        -- Format image parameters if provided
        local imgParams = imageParams ~= "" and "|" .. imageParams or ""
        
        -- Render the logo image
        return string.format(
            '|-\n| colspan="2" class="%s" | [[Image:%s%s]]', 
            cssClass, logo, imgParams
        )
    end
    
    -- Use the standardized error handling helper
    return p.withErrorHandling(
        options.errorContext,
        "renderLogoBlock",
        renderLogoOperation,
        "", -- Empty string fallback
        args, cssClass, imageParams
    )
end

--------------------------------------------------------------------------------
-- Multi-Value String Processing
--------------------------------------------------------------------------------

-- Default delimiters for splitMultiValueString
-- Defined once as an upvalue to avoid recreating on each function call
local defaultDelimiters = {
    {pattern = "%s+and%s+", replacement = ";"},
    {pattern = ";%s*", replacement = ";"}
}

-- Semicolon-only pattern for backward compatibility with splitSemicolonValues
-- Exposed as a module-level constant for use by other modules
p.SEMICOLON_PATTERN = {{pattern = ";%s*", replacement = ";"}}

-- Generic function to split multi-value strings with various delimiters
function p.splitMultiValueString(value, delimiters)
    return NormalizationText.splitMultiValueString(value, delimiters)
end

--------------------------------------------------------------------------------
-- Configuration Standardization
--------------------------------------------------------------------------------

--[[
    Renders a table of fields with labels and values using TemplateStructure.
    
    Parameters:
      fields       - Array of field objects, each with:
                     - label: The field label to display
                     - value: The field value to display
                     - class: Optional CSS class for the row
      options      - Optional configuration:
                     - tableClass: CSS class for the table (default: "template-field-table")
                     - tableAttrs: Additional table attributes
                     - fieldFormat: Format string for field rows (default: uses FIELD_FORMAT)
                     - errorContext: Optional error context for error handling
    
    Returns:
      Wikitext markup for the field table
]]
function p.renderFieldTable(fields, options)
    -- Early return for empty fields
    if not fields or #fields == 0 then
        return ""
    end
    
    options = options or {}
    
    -- Define the field table rendering operation
    local function renderFieldTableOperation(fields, options)
        local TemplateStructure = require('Module:TemplateStructure')
        
        -- Create a config for the render function with optimized defaults
        local config = {
            tableClass = options.tableClass or "template-field-table",
            tableAttrs = options.tableAttrs or 'cellpadding="2"',
            blocks = {}
        }
        
        -- Pre-allocate blocks array based on field count
        local blocks = {}
        
        -- Use the module's FIELD_FORMAT or a custom format if provided
        local fieldFormat = options.fieldFormat or FIELD_FORMAT
        
        -- Create a block function for each field with direct index assignment
        for i = 1, #fields do
            local field = fields[i]
            blocks[i] = function()
                -- Combine the field's class with the template-data-row class
                local fieldClass = field.class and field.class or ""
                
                -- Create the row with proper classes
                local row = '|- class="template-data-row' .. (fieldClass ~= "" and ' ' .. fieldClass or '') .. '"'
                
                -- Format the cells using the field format but replace the row start
                local cellsFormat = fieldFormat:gsub("^[^|]+", "")
                
                -- Return the complete row
                return row .. string.format(cellsFormat, field.label, field.value)
            end
        end
        
        -- Assign blocks to config
        config.blocks = blocks
        
        -- Use TemplateStructure's render function
        return TemplateStructure.render({}, config, options.errorContext)
    end
    
    -- Use the standardized error handling helper
    return p.withErrorHandling(
        options.errorContext,
        "renderFieldTable",
        renderFieldTableOperation,
        "", -- Empty string fallback
        fields, options
    )
end

return p