Module:T-CountryHub

Revision as of 15:07, 6 June 2025 by MarkWD (talk | contribs) (// via Wikitext Extension for VSCode)

Documentation for this module may be created at Module:T-CountryHub/doc

-- Modules/T-CountryHub.lua
-- Blueprint-based full-page template for country hubs with a Lua-driven masonry layout engine.

-- ================================================================================
-- #region REQUIRES AND HELPERS
-- ================================================================================

-- Safe require helpers
local function safeRequire(name)
    local ok, mod = pcall(require, name)
    if ok and mod then return mod end
    return setmetatable({}, { __index = function() return function() return '' end end })
end

-- Requires
local p = {}
local Blueprint     = safeRequire('Module:LuaTemplateBlueprint')
local ErrorHandling = safeRequire('Module:ErrorHandling')
local CountryData   = safeRequire('Module:CountryData')
local TemplateHelpers = safeRequire('Module:TemplateHelpers')
local NormalizationText = safeRequire('Module:NormalizationText')
local NormalizationDiacritic = safeRequire('Module:NormalizationDiacritic')
local mw            = mw
local html          = mw.html
local smw           = (mw.smw and mw.smw.ask) and mw.smw or nil

-- askCached: Performs a Semantic MediaWiki #ask query with caching.
local _askCache = {}
local function askCached(key, params)
    if not smw then return {} end
    local cacheKey = TemplateHelpers.generateCacheKey('CountryHub:ask', key)
    return TemplateHelpers.withCache(cacheKey, function()
        return smw.ask(params) or {}
    end)
end

-- safeField: Safely retrieves a field from a data row.
local function safeField(row, key)
    if not row then return '' end
    if row[key] ~= nil and row[key] ~= '' then return row[key] end
    if row[1]   ~= nil and row[1]   ~= '' then return row[1] end
    return ''
end

-- #endregion

-- ================================================================================
-- #region CORE TEMPLATE AND BLUEPRINT CONFIGURATION
-- ================================================================================

local errorContext = ErrorHandling.createContext("T-CountryHub")

local template = Blueprint.registerTemplate('CountryHub', {
    features = {
        fullPage = true,
        countryWrapper = true,
        infoBox = true,
        semanticProperties = true,
        categories = true,
        errorReporting = true
    },
    constants = {
        tableClass = ""
    }
})

Blueprint.initializeConfig(template)

-- The sequence now only controls the non-masonry parts of the page.
-- The masonry content is handled by the new layout engine.
template.config.blockSequence = {
    'wrapperOpen',
    'infoBox',
    'layoutController', -- This new block will render the entire masonry layout
    'wrapperClose',
    'categories',
    'errors'
}

template.config.blocks = template.config.blocks or {}

-- #endregion

-- ================================================================================
-- #region STATIC BLOCKS (WRAPPERS AND INFOBOX)
-- ================================================================================

template.config.blocks.wrapperOpen = {
    feature = 'countryWrapper',
    render  = function() return '<div class="country-hub-wrapper">' end
}

template.config.blocks.wrapperClose = {
    feature = 'countryWrapper',
    render  = function() return '</div>' end
}

template.config.blocks.infoBox = {
    feature = 'infoBox',
    render  = function(template, args)
        local displayCountry = (args.country or mw.title.getCurrentTitle().text or ""):gsub('_',' ')
        
        -- Derive ccTLD from ISO code via CountryData module
        local isoCode = CountryData.getCountryCodeByName(args.has_country)
        local ccTLDText
        if isoCode and #isoCode == 2 then
            local cctldValue = "." .. string.lower(isoCode)
            ccTLDText = "[[" .. cctldValue .. "]]" -- Make it a wiki link
        else
            ccTLDText = string.format('ccTLD data unavailable for %s.', displayCountry)
        end
        
        -- Fetch ICANN region for the country
        local regionParams = { string.format('[[Has country::%s]]', args.has_country), '?Has ICANN region', format = 'plain', limit = 1 }
        local regionData = askCached('infoBox:region:' .. args.has_country, regionParams)
        local regionText = regionData[1] and regionData[1]['Has ICANN region'] or ''
        regionText = NormalizationText.processWikiLink(regionText, 'strip')
        
        -- Check for an ISOC chapter in the country using fuzzy matching
        local ISOCParams = { '[[Category:Internet Society Chapter]]', limit = 200 }
        local ISOCData = askCached('infoBox:ISOC:all_chapters', ISOCParams)
        local ISOCText
        
        local function normalizeForMatching(str)
            if not str then return '' end
            local pageName = string.match(str, '^%[%[([^%]]+)%]%]$') or str
            pageName = NormalizationDiacritic.removeDiacritics(pageName)
            pageName = string.lower(pageName)
            pageName = string.gsub(pageName, 'internet society', '')
            pageName = string.gsub(pageName, 'chapter', '')
            return NormalizationText.trim(pageName)
        end

        local countryData = CountryData.getCountryByName(args.has_country)
        local countryNamesToMatch = {}
        if countryData then
            table.insert(countryNamesToMatch, normalizeForMatching(countryData.name))
            if countryData.variations then
                for _, variation in pairs(countryData.variations) do
                    table.insert(countryNamesToMatch, normalizeForMatching(variation))
                end
            end
        else
            table.insert(countryNamesToMatch, normalizeForMatching(args.has_country))
        end

        local foundMatch = false
        if ISOCData and #ISOCData > 0 then
            for _, chapter in ipairs(ISOCData) do
                local chapterName = chapter.result
                if chapterName and chapterName ~= '' then
                    local normalizedChapterName = normalizeForMatching(chapterName)
                    for _, countryNameToMatch in ipairs(countryNamesToMatch) do
                        if string.find(normalizedChapterName, countryNameToMatch, 1, true) then
                            ISOCText = chapterName
                            foundMatch = true
                            break
                        end
                    end
                end
                if foundMatch then break end
            end
        end

        if not foundMatch then
            ISOCText = string.format('[[Internet Society %s Chapter]]', args.has_country)
        end
        
        -- Check for a Youth IGF initiative in the country
        local youthParams = { string.format('[[Category:Youth IGF %s]]', args.has_country), limit = 1 }
        local youthData = askCached('infoBox:youth:' .. args.has_country, youthParams)
        local youthText = (youthData[1] and youthData[1]['result'] and youthData[1]['result'] ~= '') and youthData[1]['result'] or string.format('[[Youth IGF %s]]', args.has_country)
        
        local flagImageWikitext = ""
        if args.has_country and args.has_country ~= "" then
            local isoCode = CountryData.getCountryCodeByName(args.has_country)
            if isoCode and #isoCode == 2 then
                local flagFile = "Flag-" .. string.lower(isoCode) .. ".svg"
                flagImageWikitext = string.format("[[File:%s|link=|class=country-infobox-bg-image]]", flagFile)
            end
        end

        -- Assemble the HTML for the infobox table
        local infoBoxWrapper = html.create('div'):addClass('country-hub-infobox-wrapper')
        local infoBox = infoBoxWrapper:tag('table'):addClass('country-hub-infobox icannwiki-automatic-text')
        infoBox:tag('tr'):tag('th'):attr('colspan', '2'):addClass('country-hub-infobox-header icannwiki-automatic-text'):wikitext(string.format('%s', displayCountry)):done():done()
        infoBox:tag('tr'):tag('th'):wikitext('ccTLD'):done():tag('td'):wikitext(ccTLDText):done():done()
        infoBox:tag('tr'):tag('th'):wikitext('ICANN region'):done():tag('td'):wikitext(regionText):done():done()
        infoBox:tag('tr'):tag('th'):wikitext('ISOC chapter'):done():tag('td'):wikitext(ISOCText):done():done()
        infoBox:tag('tr'):tag('th'):wikitext('Youth IGF'):done():tag('td'):wikitext(youthText):done():done()
        infoBoxWrapper:wikitext(flagImageWikitext)
        
        return tostring(infoBoxWrapper)
    end
}

-- #endregion

-- ================================================================================
-- #region LAYOUT ENGINE AND CONTENT BLOCK DEFINITIONS
-- ================================================================================

-- This table defines all the content blocks that will be part of the masonry layout.
-- Each function fetches data and returns a block object for the layout engine.
local contentBlocks = {
    intro = function(template, args)
        local rawIntroText = '<p>Welcome to ICANNWiki\'s hub for <b>{{PAGENAME}}</b>. With the use of semantics, this page aggregates all Internet Governance content for this territory that is currently indexed in our database.</p>'
        local processedText = template.current_frame:preprocess(rawIntroText)
        return {
            id = 'intro',
            height = 2, -- Assign a fixed height proxy for the intro text
            renderFunc = function() return processedText end
        }
    end,
    organizations = function(template, args)
        local params = { string.format('[[Has country::%s]] [[Has entity type::Organization]]', args.has_country), limit = 50 }
        local data = askCached('organizations:' .. args.has_country, params)
        if #data == 0 then return nil end
        return {
            id = 'organizations',
            content = data,
            height = #data + 1, -- +1 for the header
            renderFunc = function(d)
                local t = html.create('table'):addClass('wikitable')
                t:tag('tr'):tag('th'):wikitext('Organizations'):done():done()
                for _, row in ipairs(d) do
                    t:tag('tr'):tag('td'):wikitext(safeField(row, 'result')):done():done()
                end
                return tostring(t)
            end
        }
    end,
    people = function(template, args)
        local params = { string.format('[[Has country::%s]] [[Has entity type::Person]]', args.has_country), limit = 50 }
        local data = askCached('people:' .. args.has_country, params)
        if #data == 0 then return nil end
        return {
            id = 'people',
            content = data,
            height = #data + 1,
            renderFunc = function(d)
                local t = html.create('table'):addClass('wikitable')
                t:tag('tr'):tag('th'):wikitext('People'):done():done()
                for _, row in ipairs(d) do
                    t:tag('tr'):tag('td'):wikitext(safeField(row, 'result')):done():done()
                end
                return tostring(t)
            end
        }
    end,
    geoTlds = function(template, args)
        local params = { string.format('[[Has country::%s]] [[Has entity type::TLD]] [[Has TLD subtype::geoTLD]]', args.has_country), limit = 50 }
        local data = askCached('geoTlds:' .. args.has_country, params)
        if #data == 0 then return nil end
        return {
            id = 'geoTlds',
            content = data,
            height = #data + 1,
            renderFunc = function(d)
                local t = html.create('table'):addClass('wikitable')
                t:tag('tr'):tag('th'):wikitext('GeoTLDs'):done():done()
                for _, row in ipairs(d) do
                    t:tag('tr'):tag('td'):wikitext(safeField(row, 'result')):done():done()
                end
                return tostring(t)
            end
        }
    end,
    meetings = function(template, args)
        local params = { string.format('[[Has country::%s]] [[Has entity type::Event]]', args.has_country), limit = 50 }
        local data = askCached('events:' .. args.has_country, params)
        if #data == 0 then return nil end
        return {
            id = 'meetings',
            content = data,
            height = #data + 1,
            renderFunc = function(d)
                local t = html.create('table'):addClass('wikitable')
                t:tag('tr'):tag('th'):wikitext('Internet Governance Events'):done():done()
                for _, row in ipairs(d) do
                    t:tag('tr'):tag('td'):wikitext(safeField(row, 'result')):done():done()
                end
                return tostring(t)
            end
        }
    end,
    nra = function(template, args)
        local params = { string.format('[[Has country::%s]] [[Has entity type::Organization]] [[Has organization type::Government agency]]', args.has_country), limit = 10 }
        local data = askCached('nra:' .. args.has_country, params)
        if #data == 0 then return nil end
        return {
            id = 'nra',
            content = data,
            height = #data + 1,
            renderFunc = function(d)
                local t = html.create('table'):addClass('wikitable')
                t:tag('tr'):tag('th'):wikitext('National Authorities'):done():done()
                for _, row in ipairs(d) do
                    t:tag('tr'):tag('td'):wikitext(safeField(row, 'result')):done():done()
                end
                return tostring(t)
            end
        }
    end
}

-- The main controller block that drives the masonry layout.
template.config.blocks.layoutController = {
    feature = 'fullPage',
    render = function(template, args)
        local numColumns = 3 -- Define the number of columns
        
        -- 1. Collect all available content blocks
        local availableBlocks = {}
        local blockSequenceForLayout = {'intro', 'organizations', 'people', 'geoTlds', 'meetings', 'nra'}
        for _, blockId in ipairs(blockSequenceForLayout) do
            local blockFunc = contentBlocks[blockId]
            if blockFunc then
                local blockData = blockFunc(template, args)
                if blockData then
                    table.insert(availableBlocks, blockData)
                end
            end
        end

        if #availableBlocks == 0 then return '' end

        -- 2. Sort blocks by height, descending. This is key for the greedy algorithm.
        table.sort(availableBlocks, function(a, b)
            return a.height > b.height
        end)

        -- 3. Distribute blocks into columns using a greedy algorithm
        local columns = {}
        for i = 1, numColumns do
            columns[i] = { height = 0, blocks = {} }
        end

        for _, block in ipairs(availableBlocks) do
            -- Find the shortest column
            local shortestColumnIndex = 1
            for i = 2, numColumns do
                if columns[i].height < columns[shortestColumnIndex].height then
                    shortestColumnIndex = i
                end
            end
            -- Add the block to the shortest column
            table.insert(columns[shortestColumnIndex].blocks, block)
            columns[shortestColumnIndex].height = columns[shortestColumnIndex].height + block.height
        end

        -- 4. Render the final HTML
        local root = html.create('div'):addClass('country-hub-data-container')
        for _, col in ipairs(columns) do
            if #col.blocks > 0 then
                local columnDiv = root:tag('div'):addClass('country-hub-column')
                for _, block in ipairs(col.blocks) do
                    columnDiv:wikitext(block.renderFunc(block.content))
                end
            end
        end
        
        return tostring(root)
    end
}

-- #endregion

-- ================================================================================
-- #region PREPROCESSORS AND PROVIDERS
-- ================================================================================

Blueprint.addPreprocessor(template, function(_, args)
    args.country     = args.country or mw.title.getCurrentTitle().text or ""
    args.has_country = CountryData.normalizeCountryName(args.country)
    args.region      = CountryData.getRegionByCountry(args.country)
    return args
end)

Blueprint.registerPropertyProvider(template, function(_, args)
    local props = {}
    if args.has_country and args.has_country ~= "(Unrecognized)" then
        props["Has country"]      = args.has_country
        props["Has ICANN region"] = args.region
    end
    return props
end)

Blueprint.registerCategoryProvider(template, function(_, args)
    local cats = {"Country Hub"}
    if args.has_country and args.has_country ~= "(Unrecognized)" then
        table.insert(cats, args.has_country)
        table.insert(cats, args.region)
    end
    return cats
end)

-- #endregion

-- ================================================================================
-- #region RENDER ENTRY POINT
-- ================================================================================

function p.render(frame)
    return ErrorHandling.protect(
        errorContext, "render",
        function() return template.render(frame) end,
        ErrorHandling.getMessage("TEMPLATE_RENDER_ERROR"),
        frame
    )
end

return p
-- #endregion