Jump to content

Module:LuaTemplatePerson

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

-- Module:LuaTemplatePerson
-- Renders profiles of people with a carousel for multiple images, supporting various normalizations (countries, links, etc.) and integrating social media. The module dynamically assigns categories based on community and country.

local p = {}

-- Dependencies
local CanonicalForms        = require('Module:CanonicalForms')
local CountryData           = require('Module:CountryData')
local linkParser            = require('Module:LinkParser')
local socialFooter          = require('Module:SocialMedia')
local TemplateStructure     = require('Module:TemplateStructure')
local TemplateHelpers       = require('Module:TemplateHelpers')
local LanguageNormalization = require('Module:NormalizationLanguage')
local SemanticAnnotations   = require('Module:SemanticAnnotations')
local ConfigRepository      = require('Module:ConfigRepository')
local SemanticCategoryHelpers = require('Module:SemanticCategoryHelpers')

-- Load the Achievement system with error handling
-- Achievement styling is defined in CSS/Templates.css, not in JSON data
local Achievements
local achievementsLoaded = pcall(function()
    Achievements = require('Module:AchievementSystem')
end)

if not achievementsLoaded or not Achievements then
    Achievements = {
        -- Minimal fallback to avoid errors if achievements are unavailable
        getTitleClass = function() return '', '' end,
        getTitleAchievement = function() return '', '', '' end,
        renderAchievementBox = function() return '' end,
        hasAchievements = function() return false end,
        trackPage = function() return true end
    }
end

--------------------------------------------------------------------------------
-- Configuration and Constants
--------------------------------------------------------------------------------
local Config = ConfigRepository.getStandardConfig('Person')

--------------------------------------------------------------------------------
-- Helper Functions
--------------------------------------------------------------------------------

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

--------------------------------------------------------------------------------
-- Block Rendering
--------------------------------------------------------------------------------

-- Title block function returns only the Person row; Achievement header is handled by a separate function for proper row separation
local function renderTitleBlock(args, frame)
    local currentTitle = mw.title.getCurrentTitle()
    local pageId = currentTitle.id
    
    -- Attempt to get the user's top-tier achievement class + name
    local className, achievementName = Achievements.getTitleClass(pageId, frame)
    
    -- Regular title element
    local baseClass = "template-title template-title-person person-template"
    
    -- Extract achievement ID if present
    local achievementId = ""
    if className and className ~= "" then
        achievementId = className:gsub("^achievement%-", "") -- e.g. "dev-role" instead of "achievement-dev-role"
    end
    
    -- Use the enhanced function with achievement support
    return TemplateHelpers.renderTitleBlock(args, baseClass, "Person", {
        achievementSupport = true,
        achievementClass = className or "",
        achievementId = achievementId,
        achievementName = achievementName or ""
    })
end

-- Separate function to render the achievement header as its own row, only if a title achievement exists, otherwise returns an empty string
local function renderAchievementHeaderBlock(args, frame)
    local currentTitle = mw.title.getCurrentTitle()
    local pageId = currentTitle.id
    
    -- Use getTitleAchievement function that specifically checks for type="title"
    local cssClass, displayName, achievementId = Achievements.getTitleAchievement(pageId, frame)
    
    -- If title achievement found, create a populated row
    if cssClass ~= "" and displayName ~= "" and achievementId ~= "" then
        -- Create a dedicated achievement row with the title achievement name
        return string.format(
            '|-\n! colspan="2" class="achievement-header %s" data-achievement-id="%s" data-achievement-name="%s" | %s',
            achievementId, achievementId, displayName, displayName
        )
    else
        -- Return empty string to prevent the empty row from taking up space
        return ''
    end
end

-- Render the carousel
-- Renders portrait images as either a single image or interactive carousel
local function renderPortraitCarousel(args)
    -- Return empty string if no portrait specified
    if not args.portrait or args.portrait == "" then
        return ""
    end
    -- Split semicolon-separated list of image filenames
    local imageFiles = splitMultiValueString(args.portrait, SemanticCategoryHelpers.SEMICOLON_PATTERN)
    if #imageFiles == 0 then
        return ""
    end

    -- For single image, render simple centered portrait
    if #imageFiles == 1 then
        return string.format("|-\n| colspan=\"2\" class=\"person-portrait\" | [[Image:%s|220px|center]]", imageFiles[1])
    end

    -- Start building carousel container for multiple images
    local output = "|-\n| colspan=\"2\" class=\"person-portrait-carousel\" |"
    output = output .. '<div class="carousel-container">'
    -- Add left navigation arrow for multiple images
    if #imageFiles > 1 then
        output = output .. '<div class="carousel-nav carousel-prev">&#9664;</div>'
    end

    -- Container for all carousel images
    output = output .. '<div class="carousel-images">'
    for i, imageFile in ipairs(imageFiles) do
        -- First image visible, others hidden initially
        local visibility = (i == 1) and "carousel-visible" or "carousel-hidden"
        local position = ""
        -- Special positioning for 2-image carousel (orbital layout)
        if #imageFiles == 2 then
            position = (i == 1) and "carousel-orbital-1" or "carousel-orbital-2"
        else
            -- For 3+ images: position second image right, last image left
            if i == 2 then position = "carousel-right" end
            if i == #imageFiles and #imageFiles > 2 then position = "carousel-left" end
        end
        -- Create image div with appropriate classes and data attributes
        output = output .. string.format(
            '<div class="carousel-item %s %s" data-index="%d">[[Image:%s|220px|center]]</div>',
            visibility, position, i, imageFile
        )
    end
    output = output .. '</div>'
    -- Add right navigation arrow for multiple images
    if #imageFiles > 1 then
        output = output .. '<div class="carousel-nav carousel-next">&#9654;</div>'
    end
    output = output .. '</div>'
    return output
end

-- Renders all achievements for the current user in badge style; always creates a minimal container
local function renderAchievementButtons(args, frame)
    local pageId = mw.title.getCurrentTitle().id
    if not pageId or pageId == 0 then
        -- Create minimal empty container
        local html = {}
        table.insert(html, '|-\n| colspan="2" |')
        table.insert(html, '<div class="achievement-badges"></div>')
        return table.concat(html)
    end

    if not achievementsLoaded or not Achievements then
        -- Create minimal empty container
        local html = {}
        table.insert(html, '|-\n| colspan="2" |')
        table.insert(html, '<div class="achievement-badges"></div>')
        return table.concat(html)
    end

    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        -- Create minimal empty container
        local html = {}
        table.insert(html, '|-\n| colspan="2" |')
        table.insert(html, '<div class="achievement-badges"></div>')
        return table.concat(html)
    end
    
    -- Get user achievements
    local userAchievements = Achievements.getUserAchievements(pageId)
    
    -- Get achievement types from the new loadTypes function
    local types = Achievements.loadTypes(frame)
    
    -- Build a table of achievement definitions for quick lookup
    local achievementDefinitions = {}
    for _, typeData in ipairs(types) do
        achievementDefinitions[typeData.id] = typeData
    end
    
    -- Find badge achievements only
    local badgeAchievements = {}
    if #userAchievements > 0 then
        for _, achievement in ipairs(userAchievements) do
            local achType = achievement.type
            if achType then
                local typeData = achievementDefinitions[achType]
                if typeData and typeData.type == "badge" then
                    table.insert(badgeAchievements, {
                        id = achType,
                        name = typeData.name or achType
                    })
                end
            end
        end
    end
    
    -- Create the container (minimal or populated)
    local html = {}
    table.insert(html, '|-\n| colspan="2" |')
    
    -- If badge achievements found, create a populated container
    if #badgeAchievements > 0 then
        table.insert(html, '<div class="achievement-badges">')
        
        -- For each badge achievement, create a badge element
        for _, badge in ipairs(badgeAchievements) do
            table.insert(html, string.format(
                '<div class="achievement-badge %s" data-achievement-id="%s" data-achievement-name="%s" title="%s"></div>',
                badge.id, badge.id, badge.name, badge.name
            ))
        end
        
        table.insert(html, '</div>') -- Close achievement-badges
    else
        -- Create minimal empty container
        table.insert(html, '<div class="achievement-badges"></div>')
    end
    
    return table.concat(html)
end

-- Dedicated block function for achievement badges to ensure complete decoupling
local function renderAchievementBadgesBlock(args, frame)
    -- Simply call the existing renderAchievementButtons function
    -- This creates a dedicated block for achievements independent of any other fields
    return renderAchievementButtons(args, frame)
end

local function renderFieldsBlock(args, frame)
    -- Define field processors with special handling for certain fields
    local processors = {
        community = function(value)
            return select(1, CanonicalForms.normalize(value, Config.mappings.community)) or value
        end,
        languages = function(value)
            return LanguageNormalization.formatLanguages(value)
        end,
        country = TemplateHelpers.normalizeCountries,
        website = normalizeWebsites,
        soi = function(value, args)
            -- Format SOI link
            local formattedValue = string.format("[%s Here]", value)
            
            -- Create a complete HTML object that will be inserted as-is
            local html = {}
            table.insert(html, string.format(TemplateHelpers.FIELD_FORMAT, "SOI", formattedValue))
            
            -- Return as a complete HTML object to prevent escaping
            return {
                isCompleteHtml = true,
                html = table.concat(html, "\n")
            }
        end,
        userbox = function(value, args, frame)
            -- Attempt to show achievements in the legacy "userbox" area
            local pageId = mw.title.getCurrentTitle().id
            if achievementsLoaded and Achievements and pageId then
                local success, achievementBox = pcall(function()
                    return Achievements.renderAchievementBox(pageId, frame)
                end)
                if success and achievementBox and achievementBox ~= "" then
                    return achievementBox
                end
            end
            return value
        end
    }
    
    -- Use TemplateHelpers.renderFieldsBlock for consistent field rendering
    return TemplateHelpers.renderFieldsBlock(args, Config.fields, processors)
end

--------------------------------------------------------------------------------
-- Categories and Semantics
--------------------------------------------------------------------------------

-- Generate semantic properties for the person
local function generateSemanticProperties(args)
    -- Set basic properties using the transformation functions from Config
    local semanticOutput = SemanticAnnotations.setSemanticProperties(
        args, 
        Config.semantics.properties, 
        {transform = Config.semantics.transforms}
    )
    
    -- For non-SMW case, collect property HTML fragments in a table for efficient concatenation
    local propertyHtml = {}
    
    -- Handle special case for multiple countries using the unified function
    local countryValue = args.country
    semanticOutput = SemanticCategoryHelpers.addSemanticProperties("country", countryValue, semanticOutput)
    
    -- Handle special case for multiple regions using the unified function
    semanticOutput = SemanticCategoryHelpers.addSemanticProperties("region", args.region, semanticOutput)
    
    -- Handle special case for multiple languages using the unified function
    semanticOutput = SemanticCategoryHelpers.addSemanticProperties("language", args.languages, semanticOutput)
    
    -- Process additional properties with multi-value support
    -- Skip properties that are handled separately above
    semanticOutput = SemanticCategoryHelpers.processAdditionalProperties(
        args, 
        Config.semantics, 
        semanticOutput, 
        Config.semantics.skipProperties
    )
    
    -- 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

local function computeCategories(args)
    local cats = {}
    
    -- Base Person 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

    -- Add community categories
    if args.community and args.community ~= "" then
        local communityCategories = SemanticCategoryHelpers.addMappingCategories(args.community, Config.mappings.community)
        for _, cat in ipairs(communityCategories) do
            table.insert(cats, cat)
        end
    end

    -- Add country categories using the generic function
    if args.country and args.country ~= "" then
        cats = SemanticCategoryHelpers.addMultiValueCategories(
            args.country,
            nil, -- No processor needed as we use a custom value getter
            cats,
            {
                valueGetter = function(value)
                    return CountryData.getCountriesForCategories(value)
                end
            }
        )
    end

    -- Add region categories using the generic function
    if args.region and args.region ~= "" then
        cats = SemanticCategoryHelpers.addMultiValueCategories(
            args.region,
            function(region) return region:match("^%s*(.-)%s*$") end, -- Trim whitespace
            cats
        )
    end
    
    -- Add language speaker categories using the generic function
    if args.languages and args.languages ~= "" then
        cats = SemanticCategoryHelpers.addMultiValueCategories(
            args.languages,
            function(langCode)
                local canonicalLang = LanguageNormalization.normalize(langCode)
                if canonicalLang and canonicalLang ~= "" then
                    return canonicalLang .. " speaker"
                end
                return nil
            end,
            cats
        )
    end

    return SemanticCategoryHelpers.buildCategories(cats)
end

--------------------------------------------------------------------------------
-- Main render
--------------------------------------------------------------------------------

function p.render(frame)
    local args = frame:getParent().args or {}
    
    -- Normalize arguments for case-insensitivity
    args = TemplateHelpers.normalizeArgumentCase(args)

    -- Derive region from country if empty
    if args.country and (not args.region or args.region == "") then
        -- Parse the semicolon-separated country list
        local countries = {}
        for country in string.gmatch(args.country, "[^;]+") do
            local trimmedCountry = country:match("^%s*(.-)%s*$")
            if trimmedCountry and trimmedCountry ~= "" then
                local region = CountryData.getRegionByCountry(trimmedCountry)
                if region and region ~= "(Unrecognized)" then
                    table.insert(countries, region)
                end
            end
        end
        
        -- Remove duplicates from regions list
        local unique = {}
        local regions = {}
        for _, region in ipairs(countries) do
            if not unique[region] then
                unique[region] = true
                table.insert(regions, region)
            end
        end
        
        if #regions > 0 then
            args.region = table.concat(regions, " and ")
        end
    end

    -- Load achievement data and types with frame for proper JSON loading
    if achievementsLoaded and Achievements then
        pcall(function()
            -- Pre-load achievement data and types with frame
            Achievements.loadData(frame)
            Achievements.loadTypes(frame)
        end)
    end

    local config = {
        blocks = {
            function(a) return renderTitleBlock(a, frame) end,
            function(a) return renderAchievementHeaderBlock(a, frame) end,
            function(a) return renderPortraitCarousel(a) end,
            function(a) return renderFieldsBlock(a, frame) end,
            function(a) return renderAchievementBadgesBlock(a, frame) end, -- New dedicated block for achievements
            function(a) return socialFooter.render(a) or "" end
        }
    }

    -- Track for cache purging
    if achievementsLoaded and Achievements then
        pcall(function()
            local pageId = mw.title.getCurrentTitle().id
            local pageName = mw.title.getCurrentTitle().fullText
            if pageId then
                Achievements.trackPage(pageId, pageName)
            end
        end)
    end

    -- Generate template output with categories
    local output = TemplateStructure.render(args, config)
    
    -- Add categories
    local categories = computeCategories(args)
    if categories and categories ~= "" then
        output = output .. "\n" .. categories
    end
    
    -- Add semantic properties
    local semantics = generateSemanticProperties(args)
    if semantics and semantics ~= "" then
        output = output .. "\n" .. semantics
    end
    
    return output
end

return p