Jump to content

Module:LuaTemplatePerson

From ICANNWiki

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

-- Module:LuaTemplatePerson
-- module for rendering person profiles 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 countryNormalization  = require('Module:CountryNormalization')
local RegionalMappingICANN  = require('Module:RegionalMappingICANN')
local linkParser            = require('Module:LinkParser')
local MultiCountryDisplay   = require('Module:MultiCountryDisplay')
local socialFooter          = require('Module:SocialMedia')
local TemplateStructure     = require('Module:TemplateStructure')
local LanguageNormalization = require('Module:LanguageNormalization')

-- Debug logging
local DEBUG_MODE = false
local function debugLog(message)
    if not DEBUG_MODE then return end
    pcall(function()
        mw.logObject({
            system = "person_template",
            message = message,
            timestamp = os.date('%H:%M:%S')
        })
    end)
    mw.log("PERSON-DEBUG: " .. message)
end

-- Load the Achievement system with error handling
-- Achievement styling is defined in CSS/Templates.css, not in the 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 is unavailable
        getTitleClass = function() return '', '' end,
        renderAchievementBox = function() return '' end,
        hasAchievements = function() return false end,
        trackPage = function() return true end
    }
    debugLog('WARNING: Module:AchievementSystem not available, achievement features will be disabled')
end

--------------------------------------------------------------------------------
-- Configuration and Constants
--------------------------------------------------------------------------------

local Config = {
    communityMapping = {
        {canonical = "ICANN Community",
         synonyms = {"icann community", "icann", "community"},
         category = "[[Category:ICANN Community]]"},
        {canonical = "IG Community",
         synonyms = {"ig community", "ig", "internet governance"},
         category = "[[Category:IG Community]]"},
        {canonical = "ICANN Staff",
         synonyms = {"icann staff", "staff"},
         category = "[[Category:ICANN Staff]]"},
        {canonical = "Former ICANN Staff",
         synonyms = {"former icann staff", "former staff", "ex-staff"},
         category = "[[Category:Former ICANN Staff]]"}
    },
    fields = {
        {key="community",   label="Community"},
        {key="affiliation", label="ICANN group"},
        {key="organization",label="Organization"},
        {key="region",      label="Region"},
        {key="country",     label="Country"},
        {key="languages",   label="Languages"},
        {key="website",     label="Website"},
        {key="soi",         label="SOI"},
        {key="userbox",     label="Achievements"}
    },
    patterns = {
        itemDelimiter = ";%s*",
        websitePattern = "^https?://[^%s]+"
    }
}

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

local function splitSemicolonValues(value)
    if not value or value == "" then return {} end
    local items = {}
    for item in string.gmatch(value, "[^;]+") do
        local trimmed = item:match("^%s*(.-)%s*$")
        if trimmed and trimmed ~= "" then
            table.insert(items, trimmed)
        end
    end
    return items
end

local function getFieldValue(args, field)
    if field.keys then
        for _, key in ipairs(field.keys) do
            if args[key] and args[key] ~= "" then
                return key, args[key]
            end
        end
        return nil, nil
    end
    return field.key, (args[field.key] and args[field.key] ~= "") and args[field.key] or nil
end

local function normalizeWebsites(value)
    if not value or value == "" then return "" end
    local websites = splitSemicolonValues(value)
    if #websites > 1 then
        local listItems = {}
        for _, site in ipairs(websites) do
            local formattedLink = string.format("[%s %s]", site, linkParser.strip(site))
            table.insert(listItems, string.format("<li>%s</li>", formattedLink))
        end
        return string.format("<ul class=\"template-list template-list-website\" style=\"margin:0; padding-left:1em;\">%s</ul>", table.concat(listItems, ""))
    elseif #websites == 1 then
        return string.format("[%s %s]", websites[1], linkParser.strip(websites[1]))
    end
    return ""
end

local function renderVisibleDebug(data)
    if not DEBUG_MODE then return "" end
    local jsonData = ""
    pcall(function()
        if type(data) == "table" then
            jsonData = mw.text.jsonEncode(data)
        else
            jsonData = tostring(data)
        end
    end)
    return string.format(
        '<div class="person-debug-data" style="display:none" data-debug="person_template">%s</div>',
        jsonData
    )
end

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

-- Title block that retrieves the highest-tier achievement from JSON
local function renderTitleBlock(args)
    local currentTitle = mw.title.getCurrentTitle()
    local pageId = currentTitle.id
    local pageName = currentTitle.fullText
    local namespace = currentTitle.namespace
    
    mw.log("TITLE-DEBUG: Page ID: " .. tostring(pageId) ..
           ", Page Name: " .. tostring(pageName) ..
           ", Namespace: " .. tostring(namespace))

    -- Attempt to get the user’s top-tier achievement class + name
    local className, achievementName = Achievements.getTitleClass(pageId)
    local baseClass = "template-title template-title-person person-template"
    if className and className ~= "" then
        baseClass = baseClass .. " " .. className
    end

    -- If no achievements, just return the "Person" row
    if not className or className == "" then
        return string.format('! colspan="2" class="%s" | %s', baseClass, "Person")
    end

    -- If we do have an achievement, embed it in data attributes
    local achievementId = className:gsub("^achievement%-", "") -- e.g. "padawan" instead of "achievement-padawan"
    return string.format(
        '! colspan="2" class="%s" data-achievement-id="%s" data-achievement-name="%s" | %s',
        baseClass, achievementId, achievementName, "Person"
    )
end

local function renderPortraitCarousel(args)
    if not args.portrait or args.portrait == "" then
        return ""
    end
    local imageFiles = splitSemicolonValues(args.portrait)
    if #imageFiles == 0 then
        return ""
    end

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

    local output = "|-\n| colspan=\"2\" class=\"person-portrait-carousel\" |"
    output = output .. '<div class="carousel-container">'
    if #imageFiles > 1 then
        output = output .. '<div class="carousel-nav carousel-prev">&#9664;</div>'
    end

    output = output .. '<div class="carousel-images">'
    for i, imageFile in ipairs(imageFiles) do
        local visibility = (i == 1) and "carousel-visible" or "carousel-hidden"
        local position = ""
        if #imageFiles == 2 then
            position = (i == 1) and "carousel-orbital-1" or "carousel-orbital-2"
        else
            if i == 2 then position = "carousel-right" end
            if i == #imageFiles and #imageFiles > 2 then position = "carousel-left" end
        end
        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>'
    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
-- No dev-role or test-page checks: purely from JSON.
local function renderAchievementButtons(args)
    local pageId = mw.title.getCurrentTitle().id
    if not pageId or pageId == 0 then
        return ""
    end

    if not achievementsLoaded or not Achievements then
        return ""
    end

    local data = Achievements.loadData()
    if not data or not data.user_achievements then
        return ""
    end

    local userAchievements = data.user_achievements[tostring(pageId)]
    if not userAchievements or #userAchievements == 0 then
        -- No achievements in JSON for this user => return ""
        return ""
    end

    local html = {}
    table.insert(html, '|-\n| colspan="2" |')
    table.insert(html, '<div style="display: flex; gap: 10px; margin-top: 8px; margin-bottom: 8px; flex-wrap: wrap;">')

    -- For each achievement in JSON, build a badge
    for _, achievement in ipairs(userAchievements) do
        local achType = achievement.type
        if achType and achType ~= "" then
            local achName = Achievements.getAchievementName(achType) or ""
            -- Derive a short label for the badge
            local label = "?"
            -- If we have e.g. "ach1", just use "1" as label
            local achNum = achType:match("^ach(%d+)$")
            if achNum then
                label = achNum
            elseif #achName > 0 then
                -- Maybe use first letter if you don't want the full name
                label = achName:sub(1,1)
            else
                label = achType:sub(1,1):upper()
            end

            table.insert(html, string.format(
                '<div class="achievement-button achievement-%s" title="%s">%s</div>',
                achType, achName, label
            ))
        end
    end

    table.insert(html, '</div>')
    return table.concat(html)
end

local function renderFieldsBlock(args)
    local out = {}
    for _, field in ipairs(Config.fields) do
        local key, value = getFieldValue(args, field)
        if value then
            local skipInsertion = false

            if key == "community" then
                value = select(1, CanonicalForms.normalize(value, Config.communityMapping)) or value
            elseif key == "languages" then
                value = LanguageNormalization.formatLanguages(value)
            elseif key == "country" then
                value = MultiCountryDisplay.formatCountries(value)
            elseif key == "website" then
                value = normalizeWebsites(value)
            elseif key == "soi" then
                -- Insert the row for SOI
                value = string.format("[%s Here]", value)
                table.insert(out, string.format("|-\n| '''%s''':\n| %s", field.label, value))

                -- Then immediately insert the achievement buttons row
                local buttonsHTML = renderAchievementButtons(args)
                if buttonsHTML and buttonsHTML ~= "" then
                    table.insert(out, buttonsHTML)
                end

                skipInsertion = true
            elseif key == "userbox" then
                -- Attempt to show achievements in the userbox area
                local pageId = mw.title.getCurrentTitle().id
                if achievementsLoaded and Achievements and pageId then
                    local success, achievementBox = pcall(function()
                        return Achievements.renderAchievementBox(pageId)
                    end)
                    if success and achievementBox and achievementBox ~= "" then
                        value = renderVisibleDebug({
                            section = "achievements_userbox",
                            pageId = pageId,
                            contentLength = #achievementBox,
                            timestamp = os.date()
                        }) .. achievementBox
                    end
                end
            end

            if not skipInsertion then
                table.insert(out, string.format("|-\n| '''%s''':\n| %s", field.label, value))
            end
        end
    end
    return table.concat(out, "\n")
end

--------------------------------------------------------------------------------
-- Categories
--------------------------------------------------------------------------------

local function computeCategories(args)
    local cats = {}

    if args.community and args.community ~= "" then
        local community = select(1, CanonicalForms.normalize(args.community, Config.communityMapping))
        if community then
            for _, group in ipairs(Config.communityMapping) do
                if group.canonical == community and group.category then
                    table.insert(cats, group.category)
                    break
                end
            end
        end
    end

    if args.country and args.country ~= "" then
        local normalizedCountries = MultiCountryDisplay.getCountriesForCategories(args.country)
        for _, countryName in ipairs(normalizedCountries) do
            table.insert(cats, string.format("[[Category:%s]]", countryName))
        end
    end

    if args.region and args.region ~= "" then
        local regions = splitSemicolonValues(args.region)
        for _, region in ipairs(regions) do
            local trimmedRegion = region:match("^%s*(.-)%s*$")
            if trimmedRegion and trimmedRegion ~= "" then
                table.insert(cats, string.format("[[Category:%s]]", trimmedRegion))
            end
        end
    end

    return table.concat(cats, "\n")
end

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

function p.render(frame)
    local args = frame:getParent().args or {}

    -- Derive region from country if empty
    if args.country and (not args.region or args.region == "") then
        local regions = RegionalMappingICANN.getRegionsForCountries(args.country)
        if #regions > 0 then
            args.region = table.concat(regions, " and ")
        end
    elseif args.region and args.region ~= "" then
        local regions = RegionalMappingICANN.normalizeRegions(args.region)
        if #regions > 0 then
            args.region = table.concat(regions, " and ")
        end
    end

    local config = {
        blocks = {
            function(a) return renderTitleBlock(a) end,
            function(a) return renderPortraitCarousel(a) end,
            function(a) return renderFieldsBlock(a) end,
            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
                debugLog("Tracking page ID " .. tostring(pageId) .. " for cache purging")
                Achievements.trackPage(pageId, pageName)
            end
        end)
    end

    return TemplateStructure.render(args, config) .. "\n" .. computeCategories(args)
end

return p