Module:T-Campaign: Difference between revisions

// via Wikitext Extension for VSCode
Tag: Reverted
// via Wikitext Extension for VSCode
Tag: Reverted
Line 1: Line 1:
-- T-Campaign.lua
--[[
-- Generic campaign template that dynamically loads campaign data from JSON files
* T-Campaign.lua
-- Usage: {{#invoke:T-Campaign|render|campaign_name=ASP2025}}
* Generic campaign template that dynamically loads campaign data from JSON files. This template provides a flexible, data-driven approach to campaign templates without cluttering ConfigRepository. It dynamically generates field definitions based on the JSON structure and renders any campaign content.
*
* Usage: {{#invoke:T-Campaign|render|campaign_name=ASP2025}}
]]


local p = {}
local p = {}
Line 9: Line 12:
local ErrorHandling = require('Module:ErrorHandling')
local ErrorHandling = require('Module:ErrorHandling')
local DatasetLoader = require('Module:DatasetLoader')
local DatasetLoader = require('Module:DatasetLoader')
local WikitextProcessor = require('Module:WikitextProcessor')
local TemplateHelpers = require('Module:TemplateHelpers')
local TemplateStructure = require('Module:TemplateStructure')


-- Register the Campaign template with Blueprint
-- Register the Campaign template with Blueprint
Line 18: Line 18:
         fullPage = true, -- Full-page layout
         fullPage = true, -- Full-page layout
         categories = true,
         categories = true,
         errorReporting = true
         errorReporting = false -- REVIEW
     }
     }
})
})
Line 24: Line 24:
-- Dynamic configuration (from JSON)
-- Dynamic configuration (from JSON)
template.config = {
template.config = {
     fields = {},
     fields = {}, -- Dynamically generated
     categories = { base = {} },
     categories = {
        base = {} -- Populated dynamically based on campaign
    },
     constants = {
     constants = {
         title = "Campaign Information",
         title = "Campaign Information", -- Default title, can be overridden by JSON
         tableClass = ""
         tableClass = "" -- Empty to prevent template box styling
     }
     }
}
}


-- Blueprint default: Initialize standard configuration
Blueprint.initializeConfig(template)
Blueprint.initializeConfig(template)


-- Custom block sequence (bypass standard Blueprint blocks)
template.config.blockSequence = {
template.config.blockSequence = {
     'campaignBanner',
     'campaignBanner',
     'campaignTitle',  
     'campaignTitle',
     'campaignInstructions',
     'campaignInstructions',
     'campaignContent',
     'campaignContent',
Line 43: Line 47:
}
}


-- Initialize custom blocks
template.config.blocks = template.config.blocks or {}
template.config.blocks = template.config.blocks or {}
-- Helper function: Check for "not applicable" values
local function isNotApplicable(value)
    if not value or type(value) ~= "string" then
        return false
    end
    local lowerVal = value:lower():match("^%s*(.-)%s*$")
    return lowerVal == "n/a" or lowerVal == "na" or lowerVal == "none" or lowerVal == "no" or lowerVal == "false"
end


-- Helper function: Tokenize semicolon-separated strings for instructions
-- Helper function: Tokenize semicolon-separated strings for instructions
Line 96: Line 92:
     templateCall = templateCall .. "}}"
     templateCall = templateCall .. "}}"
      
      
     -- Use JSON config or fallback to default instruction text
     -- Build instruction content
     local headerText = (campaignData.instructions and campaignData.instructions.header_text)
     table.insert(output, "'''READ CAREFULLY'''. These are temporary instructions that will appear only until all parameters outlined here have been filled. Choose 'Edit source' at any time and edit the code below if it already exists. It can also be added manually to any page:")
        or "'''READ CAREFULLY'''. These are temporary instructions that will appear only until all parameters outlined here have been filled with data or have 'N/A' in them if the field is not applicable. Choose 'Edit source' at any time and edit the code below if it already exists. It can also be added manually to any page:"
   
    local parameterIntro = (campaignData.instructions and campaignData.instructions.parameter_intro)
        or "'''Available Parameters:'''"
   
    -- Build instruction content with configurable text
    table.insert(output, headerText)
     table.insert(output, "")
     table.insert(output, "")
     table.insert(output, "<pre>" .. templateCall .. "</pre>")
     table.insert(output, "<pre>" .. templateCall .. "</pre>")
     table.insert(output, "")
     table.insert(output, "")
     table.insert(output, parameterIntro)
     table.insert(output, "'''Available Parameters:'''")
      
      
     if campaignData.field_definitions then
     if campaignData.field_definitions then
Line 138: Line 127:
end
end


-- Helper function: Get campaign name with simplified fallback logic
-- Helper function: Get campaign name with fallback logic (consolidates repeated logic)
local function getCampaignName(args, campaignData)
local function getCampaignName(args, campaignData)
     -- Primary: standard parameter name
    local campaignName = args.campaign_name or args.campaignname or args.Campaign_name or args.CampaignName
     local campaignName = args.campaign_name
   
     -- If still nil, try to get it from the campaign data
     if not campaignName and campaignData and campaignData.template_id then
        campaignName = campaignData.template_id
    end
      
      
     -- Fallback: campaign data template_id or default
     -- Final fallback
     if not campaignName then
     if not campaignName then
         campaignName = (campaignData and campaignData.template_id) or "CAMPAIGN_NAME"
         campaignName = "CAMPAIGN_NAME"
     end
     end
      
      
Line 151: Line 144:
end
end


-- Helper function: Get recursion depth from frame arguments
-- Block 1: Campaign Banner (sets noticeHandler config for top-of-page injection)
local function getRecursionDepth(frame)
    local frameArgs = frame.args or {}
    local parentArgs = (frame:getParent() and frame:getParent().args) or {}
   
    return tonumber(frameArgs._recursion_depth or parentArgs._recursion_depth) or 0
end
 
-- Campaign Banner
template.config.blocks.campaignBanner = {
template.config.blocks.campaignBanner = {
     feature = 'fullPage',
     feature = 'fullPage',
     render = function(template, args)
     render = function(template, args)
         local context = template._errorContext
         local context = template._errorContext or ErrorHandling.createContext('T-Campaign')
          
          
         if not args._campaign_data or not args._campaign_data.banner then
         if not args._campaign_data or not args._campaign_data.banner then
             ErrorHandling.addStatus(context, 'campaignBanner', 'No banner data found', 'Campaign data or banner config missing')
             ErrorHandling.addStatus(context, 'campaignBanner', 'No banner data found', 'Campaign data or banner config missing')
             return ErrorHandling.formatCombinedOutput(context)
             return ErrorHandling.formatCombinedOutput(context) -- Return debug output
         end
         end
          
          
         local banner = args._campaign_data.banner
         local banner = args._campaign_data.banner
         local bannerContent = banner.content or ""
         local bannerContent = banner.content or ""
        -- Combine generic notice-box class with specific campaign class
         local cssClass = banner.css_class or "campaign-banner"
         local cssClass = "notice-box"
        if banner.css_class and banner.css_class ~= "" then
            cssClass = cssClass .. " " .. banner.css_class
        end
          
          
         if bannerContent == "" then
         if bannerContent == "" then
Line 183: Line 164:
         end
         end
          
          
        -- Apply string normalization to ALL content first (critical for wiki link processing)
        bannerContent = bannerContent:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
       
        -- Use TemplateStarter's proven placeholder replacement strategy
         local placeholderValues = {
         local placeholderValues = {
             CAMPAIGN_NAME = tostring(args._campaign_data.template_id or "Campaign")
             CAMPAIGN_NAME = args._campaign_data.defaults.title or "Campaign"
         }
         }
          
          
         bannerContent = WikitextProcessor.processContentForFrontend(bannerContent, placeholderValues, context)
         -- Apply TemplateStarter's replacePlaceholders function logic
        local function replacePlaceholders(text, values)
            if not text or not values then
                return text
            end
           
            local result = text
            for key, value in pairs(values) do
                if value and value ~= "" then
                    result = result:gsub("%$" .. key .. "%$", value)
                end
            end
            return result
        end
          
          
        -- Apply placeholder replacement
        bannerContent = replacePlaceholders(bannerContent, placeholderValues)
       
        -- Clean up any remaining unfilled placeholders (TemplateStarter's removeEmptyPlaceholders logic)
        bannerContent = bannerContent:gsub("%$[A-Z_]+%$", "")
        bannerContent = bannerContent:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
       
        -- UNIFIED PIPELINE: Process ALL wiki links with custom patterns (no frame:preprocess)
        local originalContent = bannerContent
       
        -- Pattern 1: [[Page Name]] -> HTML link (simple page links)
        bannerContent = bannerContent:gsub('%[%[([^#|%]]+)%]%]', function(pageName)
            local spacesReplaced = pageName:gsub(' ', '_')
            local success1, pageUrl = pcall(function()
                return tostring(mw.uri.fullUrl(spacesReplaced))
            end)
           
            if not success1 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'mw.uri.fullUrl failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(pageUrl))
                return '[[' .. pageName .. ']]'
            end
           
            local success2, encodedName = pcall(function()
                return mw.text.encode(pageName)
            end)
           
            if not success2 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'mw.text.encode failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(encodedName))
                return '[[' .. pageName .. ']]'
            end
           
            local success3, result = pcall(function()
                return string.format('<a href="%s">%s</a>', pageUrl, encodedName)
            end)
           
            if not success3 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'string.format failed for page link', 'Page: ' .. pageName .. ', Error: ' .. tostring(result))
                return '[[' .. pageName .. ']]'
            end
           
            return result
        end)
       
        -- Pattern 2: [[Page|text]] -> HTML link (page links with custom text)
        bannerContent = bannerContent:gsub('%[%[([^#|%]]+)|([^%]]+)%]%]', function(pageName, text)
            local success1, pageUrl = pcall(function()
                return tostring(mw.uri.fullUrl((pageName:gsub(' ', '_'))))
            end)
           
            if not success1 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 mw.uri.fullUrl failed', 'Error: ' .. tostring(pageUrl))
                return '[[' .. pageName .. '|' .. text .. ']]'
            end
           
            local success2, encodedText = pcall(function()
                local encoded = mw.text.encode(text)
                return encoded
            end)
           
            if not success2 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 mw.text.encode failed', 'Error: ' .. tostring(encodedText))
                return '[[' .. pageName .. '|' .. text .. ']]'
            end
           
            local success3, result = pcall(function()
                return string.format('<a href="%s">%s</a>', pageUrl, encodedText)
            end)
           
            if not success3 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 2 string.format failed', 'Error: ' .. tostring(result))
                return '[[' .. pageName .. '|' .. text .. ']]'
            end
           
            return result
        end)
       
        -- Pattern 3: [[#anchor|text]] -> HTML anchor link
        bannerContent = bannerContent:gsub('%[%[#([^|%]]+)|([^%]]+)%]%]', function(anchor, text)
            local success1, encodedAnchor = pcall(function()
                return mw.uri.anchorEncode(anchor)
            end)
           
            if not success1 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 mw.uri.anchorEncode failed', 'Error: ' .. tostring(encodedAnchor))
                return '[[#' .. anchor .. '|' .. text .. ']]'
            end
           
            local success2, encodedText = pcall(function()
                local encoded = mw.text.encode(text)
                return encoded
            end)
           
            if not success2 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 mw.text.encode failed', 'Error: ' .. tostring(encodedText))
                return '[[#' .. anchor .. '|' .. text .. ']]'
            end
           
            local success3, result = pcall(function()
                return string.format('<a href="#%s">%s</a>', encodedAnchor, encodedText)
            end)
           
            if not success3 then
                ErrorHandling.addStatus(context, 'campaignBanner', 'Pattern 3 string.format failed', 'Error: ' .. tostring(result))
                return '[[#' .. anchor .. '|' .. text .. ']]'
            end
           
            return result
        end)
       
        -- Set notice data for JavaScript injection
         local noticeData = {
         local noticeData = {
             type = "campaign",
             type = "campaign",
Line 196: Line 304:
         }
         }
          
          
        -- Output data attributes for NoticeHandler to read (instead of mw.config)
         local success, result = pcall(function()
         local success, result = pcall(function()
             return string.format(
             return string.format(
Line 215: Line 324:
}
}


-- Campaign Title
-- Block 2: Campaign Title
template.config.blocks.campaignTitle = {
template.config.blocks.campaignTitle = {
     feature = 'fullPage',
     feature = 'fullPage',
Line 224: Line 333:
}
}


-- Campaign Instructions
-- Block 2: Campaign Instructions (only in documentation/partial modes)
template.config.blocks.campaignInstructions = {
template.config.blocks.campaignInstructions = {
     feature = 'fullPage',
     feature = 'fullPage',
     render = function(template, args)
     render = function(template, args)
         if not args._show_instructions or not args._campaign_data then
         if not args._show_instructions or not args._campaign_data then
             return ""
             return "" -- Only render when instructions should be shown
         end
         end
          
          
Line 239: Line 348:
}
}


-- Campaign Content
-- Block 3: Campaign Content (all campaign fields)
template.config.blocks.campaignContent = {
template.config.blocks.campaignContent = {
     feature = 'fullPage',
     feature = 'fullPage',
Line 246: Line 355:
          
          
         for _, field in ipairs(template.config.fields or {}) do
         for _, field in ipairs(template.config.fields or {}) do
            -- Skip usage instructions field (handled by campaignInstructions block)
             if field.key ~= "usageInstructions" then
             if field.key ~= "usageInstructions" then
                 local rawValue = args[field.key]
                 local rawValue = args[field.key]
Line 251: Line 361:
                  
                  
                 if processor then
                 if processor then
                    -- Pass field type as additional parameter to fix broken detection
                     local value = processor(rawValue, args, template, field.type)
                     local value = processor(rawValue, args, template, field.type)
                     if value and value ~= "" then
                     if value and value ~= "" then
                        -- Special handling for campaign_intro - no section header
                         if field.key == "campaign_intro" then
                         if field.key == "campaign_intro" then
                             table.insert(output, value)
                             table.insert(output, value)
Line 272: Line 384:
-- Generic field processor that handles different data types
-- Generic field processor that handles different data types
local function processFieldValue(value, fieldType)
local function processFieldValue(value, fieldType)
    if isNotApplicable(value) then
        return nil
    end
     if type(value) == "table" then
     if type(value) == "table" then
         if #value > 0 then
         if #value > 0 then
Line 299: Line 407:
         for item in value:gmatch("[^;]+") do
         for item in value:gmatch("[^;]+") do
             local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace
             local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace
             if trimmed and trimmed ~= "" and not isNotApplicable(trimmed) then
             if trimmed and trimmed ~= "" then
                 table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>')
                 table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>')
             end
             end
Line 306: Line 414:
             return table.concat(items, ' ')
             return table.concat(items, ' ')
         else
         else
             return nil -- Return nil if all items were "not applicable"
             return tostring(value)
         end
         end
     else
     else
Line 315: Line 423:
-- Custom preprocessor to load campaign data and generate fields dynamically
-- Custom preprocessor to load campaign data and generate fields dynamically
Blueprint.addPreprocessor(template, function(template, args)
Blueprint.addPreprocessor(template, function(template, args)
     -- CRITICAL: Get campaign_name from args (merged by Blueprint) or current frame for direct module calls
     -- Get campaign_name from args (which should already be merged by Blueprint)
    -- But also check the current frame for direct module invocations
     local campaignName = args.campaign_name
     local campaignName = args.campaign_name
      
      
     -- Fallback for direct module invocations where args may not be merged yet
     -- If not found in args, try to get it from the current frame
     if (not campaignName or campaignName == "") and template.current_frame then
     if (not campaignName or campaignName == "") and template.current_frame then
         local frameArgs = template.current_frame.args or {}
         local frameArgs = template.current_frame.args or {}
Line 336: Line 445:
     -- Simple console message
     -- Simple console message
     ErrorHandling.addStatus(
     ErrorHandling.addStatus(
         template._errorContext,
         template._errorContext or ErrorHandling.createContext('T-Campaign'),
         'campaignLoader',
         'campaignLoader',
         'Campaign loaded successfully for ' .. campaignName,
         'Campaign loaded successfully for ' .. campaignName,
Line 342: Line 451:
     )
     )
      
      
     -- CRITICAL: Detect custom parameters BEFORE merging defaults to determine template mode
     -- Detect what custom parameters are provided BEFORE merging defaults
     local customParameters = {}
     local customParameters = {}
     local hasCustomParameters = false
     local hasCustomParameters = false
Line 364: Line 473:
     end
     end
      
      
     -- Determine template mode based on parameter completeness
     -- Determine mode:
     local templateMode
     -- Pure Documentation: only campaign_name provided
     if not hasCustomParameters then
     -- Partial Mode: some but not all fields provided 
        templateMode = "documentation"
    -- Complete Mode: all fields provided
     elseif hasAllParameters then
    local isPureDocumentation = not hasCustomParameters
        templateMode = "complete"
     local isPartialMode = hasCustomParameters and not hasAllParameters
     else
    local isCompleteMode = hasCustomParameters and hasAllParameters
        templateMode = "partial"
     local showInstructions = isPureDocumentation or isPartialMode
    end
      
      
     -- Store mode state for rendering
     -- Store mode flags for use in rendering
     args._template_mode = templateMode
     args._documentation_mode = isPureDocumentation
     args._show_instructions = (templateMode ~= "complete")
    args._partial_mode = isPartialMode
     args._campaign_data = campaignData
    args._complete_mode = isCompleteMode
     args._show_instructions = showInstructions
     args._campaign_data = campaignData -- Store for usage instruction generation
      
      
     -- Defaults are only used for parameter documentation, not content rendering
     -- Defaults are only used for parameter documentation, not content rendering
Line 394: Line 504:
      
      
     -- Add usage instructions in documentation and partial modes
     -- Add usage instructions in documentation and partial modes
     if templateMode ~= "complete" then
     if isPureDocumentation or isPartialMode then
         table.insert(fields, {
         table.insert(fields, {
             key = "usageInstructions",
             key = "usageInstructions",
Line 400: Line 510:
             type = "text"
             type = "text"
         })
         })
        -- Set a dummy value so the field gets processed
         args.usageInstructions = "documentation"
         args.usageInstructions = "documentation"
     end
     end
Line 428: Line 539:
     else
     else
         -- Show placeholder for empty fields in documentation and partial modes
         -- Show placeholder for empty fields in documentation and partial modes
         if args._template_mode ~= "complete" then
         if args._documentation_mode or args._partial_mode then
             return "''Please see usage instructions above to customize this field.''"
             return "''Please see usage instructions above to customize this field.''"
         end
         end
Line 435: Line 546:
end
end


-- SPECIAL: campaign_intro processor - always uses JSON default, never user input
-- Special processor for campaign introduction - always uses JSON default, not user input
template._processors.campaign_intro = function(value, args, template, fieldType)
template._processors.campaign_intro = function(value, args, template, fieldType)
     -- CRITICAL: Always use campaign data default, ignore user input to maintain consistency
     -- Always use the campaign data default, ignore user input
     if args._campaign_data and args._campaign_data.defaults and args._campaign_data.defaults.campaign_intro then
     if args._campaign_data and args._campaign_data.defaults and args._campaign_data.defaults.campaign_intro then
         local defaultIntro = args._campaign_data.defaults.campaign_intro
         local defaultIntro = args._campaign_data.defaults.campaign_intro
Line 443: Line 554:
     else
     else
         -- Fallback if no campaign data available
         -- Fallback if no campaign data available
         if args._template_mode ~= "complete" then
         if args._documentation_mode or args._partial_mode then
             return "''Campaign introduction will appear here from JSON defaults.''"
             return "''Campaign introduction will appear here from JSON defaults.''"
         end
         end
Line 462: Line 573:
end
end


-- Export the render function
function p.render(frame)
function p.render(frame)
     template.current_frame = frame
     -- Create a custom render function that bypasses Blueprint's argument extraction
    -- and handles arguments properly for direct module invocation
      
      
     local depth = getRecursionDepth(frame)
    template.current_frame = frame -- Store frame on template instance
 
    -- Check recursion depth to prevent infinite loops
     local depth = 0
    if frame.args and frame.args._recursion_depth then
        depth = tonumber(frame.args._recursion_depth) or 0
    elseif frame:getParent() and frame:getParent().args and frame:getParent().args._recursion_depth then
        depth = tonumber(frame:getParent().args._recursion_depth) or 0
    end
      
      
     if depth > 3 then
     if depth > 3 then
Line 479: Line 600:
     end
     end
      
      
     -- Merge arguments: frame args override parent args
     -- Handle arguments from both direct module invocation and template calls
     local args = {}
     local args = {}
   
    -- Get arguments from parent frame (template parameters)
     local parentArgs = frame:getParent().args or {}
     local parentArgs = frame:getParent().args or {}
    for k, v in pairs(parentArgs) do
        args[k] = v
    end
   
    -- Get arguments from current frame (module invocation parameters) - these take precedence
     local frameArgs = frame.args or {}
     local frameArgs = frame.args or {}
    for k, v in pairs(frameArgs) do
        args[k] = v
    end
      
      
     for k, v in pairs(parentArgs) do args[k] = v end
     -- Normalize argument case like Blueprint does
     for k, v in pairs(frameArgs) do args[k] = v end
     local TemplateHelpers = require('Module:TemplateHelpers')
   
     args = TemplateHelpers.normalizeArgumentCase(args)
     args = TemplateHelpers.normalizeArgumentCase(args)
      
      
    -- Increment recursion depth for any child template calls
     args._recursion_depth = tostring(depth + 1)
     args._recursion_depth = tostring(depth + 1)
      
      
    -- Run preprocessors manually (since we're bypassing Blueprint's renderTemplate)
    local Blueprint = require('Module:LuaTemplateBlueprint')
     args = Blueprint.runPreprocessors(template, args)
     args = Blueprint.runPreprocessors(template, args)
      
      
    -- Get table class configuration
    local tableClass = "template-table"
    if template.config.constants and template.config.constants.tableClass then
        tableClass = template.config.constants.tableClass
    end
   
    -- Set up structure configuration for rendering
     local structureConfig = {
     local structureConfig = {
         tableClass = (template.config.constants and template.config.constants.tableClass) or "template-table",
         tableClass = tableClass,
         blocks = {},
         blocks = {},
         containerTag = template.features.fullPage and "div" or "table"
         containerTag = template.features.fullPage and "div" or "table"
     }
     }
      
      
    -- Build rendering sequence manually
     local renderingSequence = Blueprint.buildRenderingSequence(template)
     local renderingSequence = Blueprint.buildRenderingSequence(template)
      
      
Line 505: Line 646:
     end
     end
      
      
    -- Add rendering functions to structure config
     for i = 1, renderingSequence._length do
     for i = 1, renderingSequence._length do
         table.insert(structureConfig.blocks, function(a)
         table.insert(structureConfig.blocks, function(a)
Line 511: Line 653:
     end
     end
      
      
    -- Render using TemplateStructure
    local TemplateStructure = require('Module:TemplateStructure')
     local result = TemplateStructure.render(args, structureConfig, template._errorContext)
     local result = TemplateStructure.render(args, structureConfig, template._errorContext)
      
      
     template.current_frame = nil
     template.current_frame = nil -- Clear frame from template instance
      
      
     return result
     return result