Jump to content

Module:LuaTemplateEvent

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

-- Module:LuaTemplateEvent
-- Renders Event templates with automatic event navigation and normalized data. Features:
-- * Auto-detects previous/next events based on series name and numbering
-- * Normalizes dates, countries, and website links
-- * Auto-derives regions from countries using CountryData
-- * Assigns categories based on country, region, and subject
-- * Integrates social media components
-- * Includes error handling and graceful failure recovery using ErrorHandling module

local p = {}

--------------------------------------------------------------------------------
-- Error Handling System
--------------------------------------------------------------------------------
-- Using the centralized ErrorHandling module for error tracking, reporting, and
-- graceful failure handling.
local ErrorHandling = require('Module:ErrorHandling')

-- Error context for this template
local ERROR_CONTEXT = nil

-- Initialize the error context at the start of rendering
local function initErrorContext()
    -- Create a new error context
    ERROR_CONTEXT = ErrorHandling.createContext("EventTemplate")
    return ERROR_CONTEXT
end

-- Add an error to the context (wrapper for ErrorHandling.addError)
-- @param source The source of the error (function name)
-- @param message The error message
-- @param details Optional additional details about the error
-- @param isCritical Whether this is a critical error (default: false)
local function addError(source, message, details, isCritical)
    return ErrorHandling.addError(ERROR_CONTEXT, source, message, details, isCritical)
end

-- Format the error context for debugging output (wrapper for ErrorHandling.formatOutput)
local function formatErrorOutput()
    return ErrorHandling.formatOutput(ERROR_CONTEXT)
end

-- Create emergency display for catastrophic failures (wrapper for ErrorHandling.createEmergencyDisplay)
-- @param args The template arguments
-- @param errorSource The source of the error
-- @param errorMessage The error message
local function createEmergencyDisplay(args, errorSource, errorMessage)
    return ErrorHandling.createEmergencyDisplay(args, errorSource, errorMessage, "Event")
end

-- Protected require function to gracefully handle missing dependencies
local function safeRequire(moduleName)
    return ErrorHandling.safeRequire(ERROR_CONTEXT, moduleName, true)
end

-- Dependencies
local CountryData           = safeRequire('Module:CountryData')
local dateNormalization     = safeRequire('Module:NormalizationDate')
local linkParser            = safeRequire('Module:LinkParser')
local socialFooter          = safeRequire('Module:SocialMedia')
local TemplateStructure     = safeRequire('Module:TemplateStructure')
local TemplateHelpers       = safeRequire('Module:TemplateHelpers')
local SemanticAnnotations   = safeRequire('Module:SemanticAnnotations')
local ConfigRepository      = safeRequire('Module:ConfigRepository')
local SemanticCategoryHelpers = safeRequire('Module:SemanticCategoryHelpers')

--------------------------------------------------------------------------------
-- Configuration and Constants
--------------------------------------------------------------------------------
-- Safely load configuration with fallback
local Config = {}
do
    -- Attempt to load standard config
    local success, config = pcall(function() 
        return ConfigRepository.getStandardConfig('Event')
    end)
    
    if success then
        Config = config
    else
        -- Record error
        addError(
            "ConfigLoading",
            "Failed to load Event configuration",
            success or "Unknown error",
            true
        )
        
        -- Set minimal fallback configuration
        Config = {
            fields = {},
            semantics = {
                properties = {},
                transforms = {},
                additionalProperties = {}
            },
            patterns = {
                seriesNumber = "([^%d]+)%s+(%d+)$",
                seriesYear = "([^%d]+)%s+(%d%d%d%d)$"
            }
        }
    end
end

--------------------------------------------------------------------------------
-- Helper Functions
--------------------------------------------------------------------------------
-- Use TemplateHelpers for common functions
local getFieldValue = TemplateHelpers.getFieldValue
local splitMultiValueString = SemanticCategoryHelpers.splitMultiValueString
local normalizeWebsites = TemplateHelpers.normalizeWebsites
local normalizeDates = TemplateHelpers.normalizeDates

-- Wrapper for country normalization
local normalizeCountries = TemplateHelpers.normalizeCountries

-- Cache for event navigation detection results
local navigationCache = {}

-- Event navigation detection function with caching
local function detectEventNavigation(pageName)
    -- Check cache first
    if navigationCache[pageName] ~= nil then
        return navigationCache[pageName]
    end
    
    local result = nil
    
    -- Try Series + Number pattern: "ICANN 76"
    local series, number = pageName:match(Config.patterns.seriesNumber)
    if series and number then
        number = tonumber(number)
        local prev = (number > 1) and string.format("%s %d", series, number - 1) or nil
        local next = string.format("%s %d", series, number + 1)
        
        -- Create page objects to check if they exist
        local prevPage = prev and mw.title.new(prev) or nil
        local nextPage = mw.title.new(next)
        
        result = {
            prev = prevPage and prevPage.exists and prev or nil,
            next = nextPage and nextPage.exists and next or nil
        }
    end
    
    -- If no result yet, try Series + Year pattern: "IGF 2023"
    if not result then
        local series, year = pageName:match(Config.patterns.seriesYear)
        if series and year then
            year = tonumber(year)
            local prev = string.format("%s %d", series, year - 1)
            local next = string.format("%s %d", series, year + 1)
            
            -- Create page objects to check if they exist
            local prevPage = mw.title.new(prev)
            local nextPage = mw.title.new(next)
            
            result = {
                prev = prevPage and prevPage.exists and prev or nil,
                next = nextPage and nextPage.exists and next or nil
            }
        end
    end
    
    -- Store in cache (including nil results)
    navigationCache[pageName] = result
    return result
end

--------------------------------------------------------------------------------
-- Block Rendering Functions
--------------------------------------------------------------------------------
local function renderTitleBlock(args)
    return TemplateHelpers.renderTitleBlock(args, "template-title template-title-event", "Event", {
        achievementSupport = false
    })
end

local function renderLogoBlock(args)
    return TemplateHelpers.renderLogoBlock(args, {
        cssClass = "event-logo",
        imageParams = "220px|center",
        errorContext = ERROR_CONTEXT
    })
end

local function renderFieldsBlock(args)
    -- Define field processors
    local processors = {
        logo = function() return false end,  -- Skip logo field as it's handled by renderLogoBlock
        
        website = normalizeWebsites,
        url = normalizeWebsites,  -- Add URL field processor to use the LinkParser
        
        -- Date fields
        start = function(value)
            -- Sanitize the date input
            value = TemplateHelpers.sanitizeUserInput(value)
            
            -- Get the end date if available
            local endValue = args["end"]
            if endValue and endValue ~= "" then
                endValue = TemplateHelpers.sanitizeUserInput(endValue)
            end
            
            -- Use the new formatDateRange function
            return TemplateHelpers.formatDateRange(value, endValue, {
                outputMode = "complete"
            })
        end,
        
        ["end"] = function()
            -- Always skip the end date field since it's incorporated into the start date field
            return false
        end,
        
        -- Location fields
        country = function(value)
            -- Sanitize country input
            value = TemplateHelpers.sanitizeUserInput(value)
            return normalizeCountries(value)
        end,
        
        territory = function(value)
            -- Sanitize territory input
            value = TemplateHelpers.sanitizeUserInput(value)
            return normalizeCountries(value)
        end,
        
        city = function(value)
            -- Sanitize city input
            return TemplateHelpers.sanitizeUserInput(value)
        end,
        
        venue = function(value)
            -- Sanitize venue input
            return TemplateHelpers.sanitizeUserInput(value)
        end,
        
        -- Organization fields
        organizer = function(value)
            -- Sanitize organizer input
            return TemplateHelpers.sanitizeUserInput(value)
        end,
        
        -- Category/subject fields
        subject = function(value)
            -- Sanitize subject input
            return TemplateHelpers.sanitizeUserInput(value)
        end,
        
        category = function(value)
            -- Sanitize category input
            return TemplateHelpers.sanitizeUserInput(value)
        end,
        
        process = function(value)
            -- Sanitize process input
            return TemplateHelpers.sanitizeUserInput(value)
        end
    }
    
    return TemplateHelpers.renderFieldsBlock(args, Config.fields, processors)
end

local function renderNavigationBlock(args, autoNavigation)
    -- Check for user-provided navigation values
    local hasPrev = args["has_previous_event"]
    local hasNext = args["has_next_event"]
    
    -- If no navigation is provided at all, return empty string
    if (not hasPrev or hasPrev == "") and (not hasNext or hasNext == "") and not autoNavigation then
        return ""
    end
    
    -- Handle specific page names vs. yes/no values
    local prevPage = nil
    local nextPage = nil
    
    -- Determine previous event page
    if hasPrev and hasPrev ~= "" then
        if hasPrev ~= "yes" and hasPrev ~= "true" then
            -- If it's a specific page name, use it directly
            prevPage = hasPrev
        elseif autoNavigation and autoNavigation.prev then
            -- If it's "yes" and auto-detection found something, use that
            prevPage = autoNavigation.prev
        end
    elseif autoNavigation and autoNavigation.prev then
        -- Fall back to auto-detection if no manual value is set
        prevPage = autoNavigation.prev
    end
    
    -- Determine next event page
    if hasNext and hasNext ~= "" then
        if hasNext ~= "yes" and hasNext ~= "true" then
            -- If it's a specific page name, use it directly
            nextPage = hasNext
        elseif autoNavigation and autoNavigation.next then
            -- If it's "yes" and auto-detection found something, use that
            nextPage = autoNavigation.next
        end
    elseif autoNavigation and autoNavigation.next then
        -- Fall back to auto-detection if no manual value is set
        nextPage = autoNavigation.next
    end
    
    -- If no actual navigation links were found, return empty string
    -- This prevents creating empty space when there are no links to display
    if not prevPage and not nextPage then
        return ""
    end
    
    -- Create a navigation row with fixed height to ensure proper vertical centering
    -- Pre-allocate output table with known initial size
    local output = {
        '|-',
        '| class="element-navigation-prev" height="40" valign="middle" |'
    }
    
    -- Add previous event link (left-aligned and centered vertically)
    if prevPage then
        table.insert(output, string.format(
            '<div class="element-navigation-prev">[[%s|← Previous]]</div>',
            prevPage
        ))
    else
        -- Empty cell if no previous event
        table.insert(output, "&nbsp;")
    end
    
    -- Add next event link cell (right-aligned and centered vertically)
    table.insert(output, '| class="element-navigation-next" height="40" valign="middle" |')
    
    if nextPage then
        table.insert(output, string.format(
            '<div class="element-navigation-next">[[%s|Next →]]</div>',
            nextPage
        ))
    else
        -- Empty cell if no next event
        table.insert(output, "&nbsp;")
    end
    
    -- Use table.concat for efficient string building
    return table.concat(output, "\n")
end

--------------------------------------------------------------------------------
-- Semantic Properties
--------------------------------------------------------------------------------
-- Generate semantic properties for the Event with error handling
local function generateSemanticProperties(args)
    -- Use ErrorHandling.protect instead of pcall directly
    return ErrorHandling.protect(
        ERROR_CONTEXT,
        "SemanticGeneration",
        function()
            -- Create a modified args table without subject/category
            -- This is temporary until rules for handling multiple subjects are determined
            -- Also sanitize all values to remove wiki markup and braces
            local modifiedArgs = {}
            for k, v in pairs(args) do
                -- Skip subject and category fields
                if k ~= "subject" and k ~= "category" then
                    -- Sanitize the value before adding it to modifiedArgs
                    if type(v) == "string" and v ~= "" then
                        modifiedArgs[k] = TemplateHelpers.sanitizeUserInput(v)
                    else
                        modifiedArgs[k] = v
                    end
                end
            end
            
            -- Set basic properties using the transformation functions from Config
            local semanticOutput
            local propertiesSuccess, propertiesResult = pcall(function()
                return SemanticAnnotations.setSemanticProperties(
                    modifiedArgs, 
                    Config.semantics.properties, 
                    {transform = Config.semantics.transforms}
                )
            end)
            
            if propertiesSuccess then
                semanticOutput = propertiesResult
            else
                addError(
                    "SemanticProperties",
                    "Failed to set basic semantic properties",
                    propertiesResult,
                    false -- Not critical for rendering
                )
                semanticOutput = "" -- Empty fallback
            end
            
            -- For non-SMW case, collect property HTML fragments in a table for efficient concatenation
            local propertyHtml = {}
            
            -- Handle special case for multiple countries using the helper function
            local countryValue = args["country"] or args["territory"]
            if countryValue and countryValue ~= "" then
                local countrySuccess, countryResult = pcall(function()
                    return CountryData.addCountrySemanticProperties(countryValue, semanticOutput)
                end)
                
                if countrySuccess then
                    semanticOutput = countryResult
                else
                    addError(
                        "CountryProperties",
                        "Failed to add country semantic properties",
                        countryResult,
                        false -- Not critical for rendering
                    )
                end
            end
            
            -- Handle special case for multiple regions using the unified function (if applicable)
            if args.region and args.region ~= "" then
                local regionSuccess, regionResult = pcall(function()
                    return SemanticCategoryHelpers.addSemanticProperties("region", args.region, semanticOutput)
                end)
                
                if regionSuccess then
                    semanticOutput = regionResult
                else
                    addError(
                        "RegionProperties",
                        "Failed to add region semantic properties",
                        regionResult,
                        false -- Not critical for rendering
                    )
                end
            end
            
            -- Process additional properties with multi-value support
            -- Skip properties that are handled separately above
            local additionalSuccess, additionalResult = pcall(function()
                return SemanticCategoryHelpers.processAdditionalProperties(
                    args, 
                    Config.semantics, 
                    semanticOutput, 
                    Config.semantics.skipProperties
                )
            end)
            
            if additionalSuccess then
                semanticOutput = additionalResult
            else
                addError(
                    "AdditionalProperties",
                    "Failed to process additional semantic properties",
                    additionalResult,
                    false -- Not critical for rendering
                )
            end
            
            -- For non-SMW case, concatenate all property HTML fragments at once
            if not mw.smw and #propertyHtml > 0 then
                semanticOutput = semanticOutput .. "\n" .. table.concat(propertyHtml, "\n")
            end
            
            return semanticOutput
        end,
        "" -- Return empty string as fallback
    )
end

--------------------------------------------------------------------------------
-- Category Assignment
--------------------------------------------------------------------------------
local function computeCategories(args)
    -- Use ErrorHandling.protect instead of pcall directly
    return ErrorHandling.protect(
        ERROR_CONTEXT,
        "CategoryComputation",
        function()
            local cats = {}
            
            -- Base Event category from config
            if Config.categories and Config.categories.base then
                for _, category in ipairs(Config.categories.base) do
                    table.insert(cats, category)
                end
            end
            
            -- Process country categories from either country or territory key using CountryData
            local countryValue = args["country"] or args["territory"]
            if countryValue and countryValue ~= "" then
                local countrySuccess, countryCats = pcall(function()
                    return SemanticCategoryHelpers.addMultiValueCategories(
                        countryValue,
                        function(value)
                            -- Use the CountryData module directly
                            return CountryData.normalizeCountryName(value)
                        end,
                        cats
                    )
                end)
                
                if countrySuccess then
                    cats = countryCats
                else
                    addError(
                        "CountryCategories",
                        "Failed to add country categories",
                        countryCats,
                        false -- Not critical for rendering
                    )
                end
            end
            
            -- Process region categories using the generic function
            if args["region"] and args["region"] ~= "" then
                local regionSuccess, regionCats = pcall(function()
                    return SemanticCategoryHelpers.addMultiValueCategories(
                        args["region"],
                        function(region) return region:match("^%s*(.-)%s*$") end, -- Trim whitespace
                        cats
                    )
                end)
                
                if regionSuccess then
                    cats = regionCats
                else
                    addError(
                        "RegionCategories",
                        "Failed to add region categories",
                        regionCats,
                        false -- Not critical for rendering
                    )
                end
            end
            
            -- Add process category if provided
            if args["process"] and args["process"] ~= "" then
                -- Trim whitespace and add as a category
                local processCategory = args["process"]:match("^%s*(.-)%s*$")
                if processCategory and processCategory ~= "" then
                    table.insert(cats, processCategory)
                end
            end
            
            -- NOTE: Subject/category categories are temporarily disabled
            -- as per request due to users adding too many comma-separated values
            
            -- Build categories safely
            local buildSuccess, categories = pcall(function()
                return SemanticCategoryHelpers.buildCategories(cats)
            end)
            
            if buildSuccess then
                return categories
            else
                addError(
                    "BuildCategories",
                    "Failed to build categories",
                    categories,
                    false -- Not critical for rendering
                )
                return ""
            end
        end,
        "" -- Return empty string as fallback
    )
end

--------------------------------------------------------------------------------
-- Main Render Function
--------------------------------------------------------------------------------
-- Execute a function with protection and fallback using ErrorHandling.protect
local function safeExecute(functionName, func, ...)
    return ErrorHandling.protect(ERROR_CONTEXT, functionName, func, nil, ...)
end

function p.render(frame)
    -- Initialize error tracking
    initErrorContext()
    
    -- Outer pcall to ensure we always return something, even in catastrophic failure
    local success, result = pcall(function()
        local args = frame:getParent().args or {}
        
        -- Normalize arguments for case-insensitivity
        args = TemplateHelpers.normalizeArgumentCase(args)
        
        -- Get template name from frame if available
        local title = mw.title.getCurrentTitle()
        if title then
            -- Extract template name from page title (usually in Module:LuaTemplateX format)
            local templateName = title.text:match("LuaTemplate([^/]+)") or "Event"
            ERROR_CONTEXT.TEMPLATE_NAME = templateName .. "Template"
        end
        
        -- Safely derive region from country, overriding any user-provided value
        if args.country then
            local success, _ = pcall(function()
                -- Use splitMultiValueString for consistent multi-value handling
                local countryList = splitMultiValueString(args.country, SemanticCategoryHelpers.SEMICOLON_PATTERN)
                
                -- Get regions for each country
                local regions = {}
                local unique = {}
                
                for _, country in ipairs(countryList) do
                    local region = CountryData.getRegionByCountry(country)
                    if region and region ~= "(Unrecognized)" and not unique[region] then
                        unique[region] = true
                        table.insert(regions, region)
                    end
                end
                
                -- Use table.concat for efficient string building
                if #regions > 0 then
                    -- Override any user-provided region value
                    args.region = table.concat(regions, " and ")
                end
            end)
            
            if not success then
                addError(
                    "RegionDerivation",
                    "Failed to derive region from country",
                    args.country,
                    false -- Not critical
                )
            end
        end
            
        local titleObj = mw.title.getCurrentTitle()
        local pageName = titleObj and titleObj.text or ""
        
        -- Detect navigation links for previous/next events
        local autoNavigation = detectEventNavigation(pageName)
        
        -- Only set auto-detected values if user hasn't provided specific values
        if autoNavigation then
            -- Check for previous event - only set if not already provided by user
            if autoNavigation.prev and (not args["has_previous_event"] or args["has_previous_event"] == "") then
                args["has_previous_event"] = "yes"
            end
            
            -- Check for next event - only set if not already provided by user
            if autoNavigation.next and (not args["has_next_event"] or args["has_next_event"] == "") then
                args["has_next_event"] = "yes"
            end
        end
        
        -- Protected block rendering - this is the most critical part of the template
        local outputComponents = {}
        
        -- Safely execute each block rendering function
        local function executeBlock(name, func, ...)
            local blockContent = safeExecute(name, func, ...)
            return blockContent or "" -- Return empty string as fallback
        end
        
        -- Configure block rendering
        local config = {
            blocks = {
                function(a) return executeBlock("TitleBlock", renderTitleBlock, a) end,
                function(a) return executeBlock("LogoBlock", renderLogoBlock, a) end,
                function(a) return executeBlock("FieldsBlock", renderFieldsBlock, a) end,
                function(a) return executeBlock("NavigationBlock", renderNavigationBlock, a, autoNavigation) end,
                function(a) return executeBlock("SocialFooter", socialFooter.render, a) or "" end
            }
        }
        
        -- Generate template output with protected rendering
        -- Pass the ERROR_CONTEXT to TemplateStructure.render
        local success, output = pcall(function()
            return TemplateStructure.render(args, config, ERROR_CONTEXT)
        end)
        
        if not success then
            -- If TemplateStructure.render fails, use emergency rendering
            addError(
                "TemplateStructure",
                "Failed to render template structure",
                output,
                true -- This is a critical error
            )
            
            -- Create a minimal table structure as fallback
            output = '{| class="template-table" cellpadding="2"\n'
            
            -- Add the title at minimum
            local titleSuccess, titleOutput = pcall(renderTitleBlock, args)
            output = output .. (titleSuccess and titleOutput or "|-\n| class=\"template-title template-title-event\"><span>" .. (args.name or "Unnamed Event") .. "</span>")
            
            -- Close the table
            output = output .. "\n|}"
        end
        
        -- Start with main output
        table.insert(outputComponents, output)
        
        -- Safely add categories
        local categories = computeCategories(args)
        if categories and categories ~= "" then
            table.insert(outputComponents, categories)
        end
        
        -- Safely add semantic properties
        local semantics = generateSemanticProperties(args)
        if semantics and semantics ~= "" then
            table.insert(outputComponents, semantics)
        end
        
        -- Add error debug information if any errors occurred
        if ERROR_CONTEXT.ERROR_COUNT > 0 then
            -- Add data attribute div with error information
            table.insert(outputComponents, formatErrorOutput())
            
            -- Add minimal visual indicator for errors
            table.insert(outputComponents, string.format(
                "\n|-\n|colspan=\"2\" style=\"text-align:right; font-size:9px; color:#999;\"" ..
                "|<span title=\"%d error(s)\">⚠</span>",
                ERROR_CONTEXT.ERROR_COUNT
            ))
        end
        
        -- Use table.concat for efficient string building
        return table.concat(outputComponents, "\n")
    end)
    
    -- Final fallback for catastrophic failures
    if not success then
        -- Log the catastrophic error
        ERROR_CONTEXT.HAS_CRITICAL_ERROR = true
        addError(
            "CatastrophicFailure",
            "Template rendering failed completely",
            result,
            true
        )
        
        -- Create emergency display with minimal information
        local emergencyOutput = createEmergencyDisplay(
            {name = "Event"}, -- Minimal args
            "render", 
            tostring(result)
        )
        
        -- Add error debug information
        local errorOutput = formatErrorOutput()
        
        -- Return emergency output with debug information
        return emergencyOutput .. "\n" .. errorOutput
    end
    
    return result
end

return p