Jump to content

Module:T-Campaign: Difference between revisions

// via Wikitext Extension for VSCode
Tags: Manual revert Reverted
// via Wikitext Extension for VSCode
Tags: Manual revert Reverted
Line 1: Line 1:
--[[
-- T-Campaign.lua
* T-Campaign.lua
-- Generic campaign template that dynamically loads campaign data from JSON files
* 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}}
*
* Usage: {{#invoke:T-Campaign|render|campaign_name=ASP2025}}
]]


local p = {}
local p = {}
Line 12: Line 9:
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 = false -- REVIEW
         errorReporting = true
     }
     }
})
})
Line 24: Line 24:
-- Dynamic configuration (from JSON)
-- Dynamic configuration (from JSON)
template.config = {
template.config = {
     fields = {}, -- Dynamically generated
     fields = {},
     categories = {
     categories = { base = {} },
        base = {} -- Populated dynamically based on campaign
    },
     constants = {
     constants = {
         title = "Campaign Information", -- Default title, can be overridden by JSON
         title = "Campaign Information",
         tableClass = "" -- Empty to prevent template box styling
         tableClass = ""
     }
     }
}
}


-- 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 47: Line 43:
}
}


-- 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 92: Line 96:
     templateCall = templateCall .. "}}"
     templateCall = templateCall .. "}}"
      
      
     -- Build instruction content
     -- Use JSON config or fallback to default instruction 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:")
     local headerText = (campaignData.instructions and campaignData.instructions.header_text)
        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, "'''Available Parameters:'''")
     table.insert(output, parameterIntro)
      
      
     if campaignData.field_definitions then
     if campaignData.field_definitions then
Line 127: Line 138:
end
end


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


-- Block 1: Campaign Banner (sets noticeHandler config for top-of-page injection)
-- Helper function: Get recursion depth from frame arguments
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 or ErrorHandling.createContext('T-Campaign')
         local context = template._errorContext
          
          
         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 debug output
             return ErrorHandling.formatCombinedOutput(context)
         end
         end
          
          
         local banner = args._campaign_data.banner
         local banner = args._campaign_data.banner
         local bannerContent = banner.content or ""
         local bannerContent = banner.content or ""
         local cssClass = banner.css_class or "campaign-banner"
        -- Combine generic notice-box class with specific campaign class
         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 164: Line 183:
         end
         end
          
          
         -- Apply string normalization to ALL content first (critical for wiki link processing)
         -- DEFENSIVE: Validate campaign title before processing
         bannerContent = bannerContent:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
         local campaignTitle = nil
       
         local titleSource = "fallback"
        -- Use TemplateStarter's proven placeholder replacement strategy
         local placeholderValues = {
            CAMPAIGN_NAME = args._campaign_data.defaults.title or "Campaign"
        }
          
          
         -- Apply TemplateStarter's replacePlaceholders function logic
         if args._campaign_data and args._campaign_data.defaults then
        local function replacePlaceholders(text, values)
            local rawTitle = args._campaign_data.defaults.title
             if not text or not values then
            if rawTitle and type(rawTitle) == "string" and rawTitle ~= "" then
                 return text
                campaignTitle = rawTitle
                titleSource = "json_default"
                ErrorHandling.addStatus(context, 'campaignBanner', 'Title validation passed', 'Source: JSON defaults, Value: "' .. campaignTitle .. '"')
             else
                 ErrorHandling.addStatus(context, 'campaignBanner', 'Title validation failed', 'Raw value: "' .. tostring(rawTitle) .. '", Type: ' .. type(rawTitle))
             end
             end
           
        else
             local result = text
             ErrorHandling.addStatus(context, 'campaignBanner', 'Campaign data structure invalid', 'Missing _campaign_data or defaults')
            for key, value in pairs(values) do
                if value and value ~= "" then
                    result = result:gsub("%$" .. key .. "%$", value)
                end
            end
            return result
         end
         end
          
          
         -- Apply placeholder replacement
         -- Robust fallback with error reporting
         bannerContent = replacePlaceholders(bannerContent, placeholderValues)
         if not campaignTitle then
            campaignTitle = "Campaign"
            titleSource = "fallback"
            ErrorHandling.addStatus(context, 'campaignBanner', 'Using fallback title', 'Original title was missing or invalid')
        end
          
          
         -- Clean up any remaining unfilled placeholders (TemplateStarter's removeEmptyPlaceholders logic)
         local placeholderValues = {
        bannerContent = bannerContent:gsub("%$[A-Z_]+%$", "")
            CAMPAIGN_NAME = campaignTitle
         bannerContent = bannerContent:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
         }
          
          
         -- UNIFIED PIPELINE: Process ALL wiki links with custom patterns (no frame:preprocess)
         -- Pre-flight validation before calling WikitextProcessor
         local originalContent = bannerContent
         ErrorHandling.addStatus(context, 'campaignBanner', 'Sending to WikitextProcessor', 'CAMPAIGN_NAME="' .. campaignTitle .. '", Source=' .. titleSource)
          
          
         -- Pattern 1: [[Page Name]] -> HTML link (simple page links)
         bannerContent = WikitextProcessor.processContentForFrontend(bannerContent, placeholderValues, context)
        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 304: Line 223:
         }
         }
          
          
        -- 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 324: Line 242:
}
}


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


-- Block 2: Campaign Instructions (only in documentation/partial modes)
-- Campaign Instructions
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 "" -- Only render when instructions should be shown
             return ""
         end
         end
          
          
Line 348: Line 266:
}
}


-- Block 3: Campaign Content (all campaign fields)
-- Campaign Content
template.config.blocks.campaignContent = {
template.config.blocks.campaignContent = {
     feature = 'fullPage',
     feature = 'fullPage',
Line 355: Line 273:
          
          
         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 361: Line 278:
                  
                  
                 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 384: Line 299:
-- 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 407: Line 326:
         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 ~= "" then
             if trimmed and trimmed ~= "" and not isNotApplicable(trimmed) then
                 table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>')
                 table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>')
             end
             end
Line 414: Line 333:
             return table.concat(items, ' ')
             return table.concat(items, ' ')
         else
         else
             return tostring(value)
             return nil -- Return nil if all items were "not applicable"
         end
         end
     else
     else
Line 423: Line 342:
-- 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)
     -- Get campaign_name from args (which should already be merged by Blueprint)
     -- CRITICAL: Get campaign_name from args (merged by Blueprint) or current frame for direct module calls
    -- But also check the current frame for direct module invocations
     local campaignName = args.campaign_name
     local campaignName = args.campaign_name
      
      
     -- If not found in args, try to get it from the current frame
     -- Fallback for direct module invocations where args may not be merged yet
     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 445: Line 363:
     -- Simple console message
     -- Simple console message
     ErrorHandling.addStatus(
     ErrorHandling.addStatus(
         template._errorContext or ErrorHandling.createContext('T-Campaign'),
         template._errorContext,
         'campaignLoader',
         'campaignLoader',
         'Campaign loaded successfully for ' .. campaignName,
         'Campaign loaded successfully for ' .. campaignName,
Line 451: Line 369:
     )
     )
      
      
     -- Detect what custom parameters are provided BEFORE merging defaults
     -- CRITICAL: Detect custom parameters BEFORE merging defaults to determine template mode
     local customParameters = {}
     local customParameters = {}
     local hasCustomParameters = false
     local hasCustomParameters = false
Line 473: Line 391:
     end
     end
      
      
     -- Determine mode:
     -- Determine template mode based on parameter completeness
     -- Pure Documentation: only campaign_name provided
     local templateMode
     -- Partial Mode: some but not all fields provided 
     if not hasCustomParameters then
     -- Complete Mode: all fields provided
        templateMode = "documentation"
    local isPureDocumentation = not hasCustomParameters
     elseif hasAllParameters then
     local isPartialMode = hasCustomParameters and not hasAllParameters
        templateMode = "complete"
    local isCompleteMode = hasCustomParameters and hasAllParameters
     else
     local showInstructions = isPureDocumentation or isPartialMode
        templateMode = "partial"
     end
      
      
     -- Store mode flags for use in rendering
     -- Store mode state for rendering
     args._documentation_mode = isPureDocumentation
     args._template_mode = templateMode
     args._partial_mode = isPartialMode
     args._show_instructions = (templateMode ~= "complete")
    args._complete_mode = isCompleteMode
     args._campaign_data = campaignData
    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 504: Line 421:
      
      
     -- Add usage instructions in documentation and partial modes
     -- Add usage instructions in documentation and partial modes
     if isPureDocumentation or isPartialMode then
     if templateMode ~= "complete" then
         table.insert(fields, {
         table.insert(fields, {
             key = "usageInstructions",
             key = "usageInstructions",
Line 510: Line 427:
             type = "text"
             type = "text"
         })
         })
        -- Set a dummy value so the field gets processed
         args.usageInstructions = "documentation"
         args.usageInstructions = "documentation"
     end
     end
Line 539: Line 455:
     else
     else
         -- Show placeholder for empty fields in documentation and partial modes
         -- Show placeholder for empty fields in documentation and partial modes
         if args._documentation_mode or args._partial_mode then
         if args._template_mode ~= "complete" then
             return "''Please see usage instructions above to customize this field.''"
             return "''Please see usage instructions above to customize this field.''"
         end
         end
Line 546: Line 462:
end
end


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


-- Export the render function
function p.render(frame)
function p.render(frame)
     -- Create a custom render function that bypasses Blueprint's argument extraction
     template.current_frame = frame
    -- and handles arguments properly for direct module invocation
      
      
    template.current_frame = frame -- Store frame on template instance
     local depth = getRecursionDepth(frame)
 
    -- 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 600: Line 506:
     end
     end
      
      
     -- Handle arguments from both direct module invocation and template calls
     -- Merge arguments: frame args override parent args
     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
     local frameArgs = frame.args or {}
        args[k] = v
    end
      
      
     -- Get arguments from current frame (module invocation parameters) - these take precedence
     for k, v in pairs(parentArgs) do args[k] = v end
    local frameArgs = frame.args or {}
     for k, v in pairs(frameArgs) do args[k] = v end
     for k, v in pairs(frameArgs) do
        args[k] = v
    end
      
      
    -- Normalize argument case like Blueprint does
    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 = tableClass,
         tableClass = (template.config.constants and template.config.constants.tableClass) or "template-table",
         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 646: Line 532:
     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 653: Line 538:
     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 -- Clear frame from template instance
     template.current_frame = nil
      
      
     return result
     return result

Revision as of 02:13, 2 August 2025

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

-- T-Campaign.lua
-- Generic campaign template that dynamically loads campaign data from JSON files
-- Usage: {{#invoke:T-Campaign|render|campaign_name=ASP2025}}

local p = {}

-- Required modules
local Blueprint = require('Module:LuaTemplateBlueprint')
local ErrorHandling = require('Module:ErrorHandling')
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
local template = Blueprint.registerTemplate('Campaign', {
    features = { 
        fullPage = true, -- Full-page layout
        categories = true,
        errorReporting = true
    }
})

-- Dynamic configuration (from JSON)
template.config = {
    fields = {},
    categories = { base = {} },
    constants = {
        title = "Campaign Information",
        tableClass = ""
    }
}

Blueprint.initializeConfig(template)

template.config.blockSequence = {
    'campaignBanner',
    'campaignTitle', 
    'campaignInstructions',
    'campaignContent',
    'categories',
    'errors'
}

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
local function tokenizeForInstructions(value)
    if not value or value == "" then
        return value
    end
    
    local items = {}
    for item in value:gmatch("[^;]+") do
        local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace
        if trimmed and trimmed ~= "" then
            table.insert(items, '<span class="campaign-instruction-token">' .. trimmed .. '</span>')
        end
    end
    
    if #items > 0 then
        return table.concat(items, ' ')
    else
        return value
    end
end

-- Helper function: Generate usage instructions
local function generateUsageInstructions(campaignName, campaignData)
    local output = {}
    
    -- Generate template syntax with all available parameters in user-friendly format
    local templateCall = "{{#invoke:T-Campaign|render|\ncampaign_name=" .. tostring(campaignName) .. "|\n"
    
    -- Add each field as a parameter option with example values (except campaign_intro which is fixed)
    if campaignData.field_definitions then
        for _, fieldDef in ipairs(campaignData.field_definitions) do
            if fieldDef.key ~= "campaign_intro" then
                local exampleValue = "text"
                if fieldDef.type == "list" then
                    exampleValue = "text 1; text 2; etc. (semicolon-separated)"
                end
                templateCall = templateCall .. tostring(fieldDef.key) .. " = " .. exampleValue .. "|\n"
            end
        end
    end
    templateCall = templateCall .. "}}"
    
    -- Use JSON config or fallback to default instruction text
    local headerText = (campaignData.instructions and campaignData.instructions.header_text) 
        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, "<pre>" .. templateCall .. "</pre>")
    table.insert(output, "")
    table.insert(output, parameterIntro)
    
    if campaignData.field_definitions then
        for _, fieldDef in ipairs(campaignData.field_definitions) do
            -- Skip campaign_intro since it's a fixed field from JSON defaults
            if fieldDef.key ~= "campaign_intro" then
                local paramDesc = "* '''<span style=\"color: var(--colored-text);\">" .. tostring(fieldDef.key) .. "</span>''' (" .. tostring(fieldDef.label) .. ", " .. tostring(fieldDef.type) .. "): "
                
                -- Add default value as example if available, with tokenization for lists
                local defaultValue = campaignData.defaults[fieldDef.key]
                if defaultValue and defaultValue ~= "" then
                    if fieldDef.type == "list" then
                        paramDesc = paramDesc .. tokenizeForInstructions(tostring(defaultValue))
                    else
                        paramDesc = paramDesc .. tostring(defaultValue)
                    end
                end
                
                -- Add helpful note for list fields
                if fieldDef.type == "list" then
                    paramDesc = paramDesc .. " (separate multiple values with semicolons)"
                end
                table.insert(output, paramDesc)
            end
        end
    end

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

-- Helper function: Get campaign name with simplified fallback logic
local function getCampaignName(args, campaignData)
    -- Primary: standard parameter name
    local campaignName = args.campaign_name
    
    -- Fallback: campaign data template_id or default
    if not campaignName then
        campaignName = (campaignData and campaignData.template_id) or "CAMPAIGN_NAME"
    end
    
    return campaignName
end

-- Helper function: Get recursion depth from frame arguments
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 = {
    feature = 'fullPage',
    render = function(template, args)
        local context = template._errorContext
        
        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')
            return ErrorHandling.formatCombinedOutput(context)
        end
        
        local banner = args._campaign_data.banner
        local bannerContent = banner.content or ""
        -- Combine generic notice-box class with specific campaign class
        local cssClass = "notice-box"
        if banner.css_class and banner.css_class ~= "" then
            cssClass = cssClass .. " " .. banner.css_class
        end
        
        if bannerContent == "" then
            ErrorHandling.addStatus(context, 'campaignBanner', 'Empty banner content', 'No content to display')
            return ErrorHandling.formatCombinedOutput(context)
        end
        
        -- DEFENSIVE: Validate campaign title before processing
        local campaignTitle = nil
        local titleSource = "fallback"
        
        if args._campaign_data and args._campaign_data.defaults then
            local rawTitle = args._campaign_data.defaults.title
            if rawTitle and type(rawTitle) == "string" and rawTitle ~= "" then
                campaignTitle = rawTitle
                titleSource = "json_default"
                ErrorHandling.addStatus(context, 'campaignBanner', 'Title validation passed', 'Source: JSON defaults, Value: "' .. campaignTitle .. '"')
            else
                ErrorHandling.addStatus(context, 'campaignBanner', 'Title validation failed', 'Raw value: "' .. tostring(rawTitle) .. '", Type: ' .. type(rawTitle))
            end
        else
            ErrorHandling.addStatus(context, 'campaignBanner', 'Campaign data structure invalid', 'Missing _campaign_data or defaults')
        end
        
        -- Robust fallback with error reporting
        if not campaignTitle then
            campaignTitle = "Campaign"
            titleSource = "fallback"
            ErrorHandling.addStatus(context, 'campaignBanner', 'Using fallback title', 'Original title was missing or invalid')
        end
        
        local placeholderValues = {
            CAMPAIGN_NAME = campaignTitle
        }
        
        -- Pre-flight validation before calling WikitextProcessor
        ErrorHandling.addStatus(context, 'campaignBanner', 'Sending to WikitextProcessor', 'CAMPAIGN_NAME="' .. campaignTitle .. '", Source=' .. titleSource)
        
        bannerContent = WikitextProcessor.processContentForFrontend(bannerContent, placeholderValues, context)
        
        local noticeData = {
            type = "campaign",
            position = "top",
            content = bannerContent,
            cssClass = cssClass
        }
        
        local success, result = pcall(function()
            return string.format(
                '<div style="display:none" class="notice-data" data-notice-type="%s" data-notice-position="%s" data-notice-content="%s" data-notice-css="%s"></div>',
                mw.text.encode(noticeData.type),
                mw.text.encode(noticeData.position),
                mw.text.encode(noticeData.content),
                mw.text.encode(noticeData.cssClass)
            )
        end)
        
        if success then
            return result .. ErrorHandling.formatCombinedOutput(context)
        else
            ErrorHandling.addError(context, 'campaignBanner', 'Data attribute creation failed', tostring(result), false)
            return ErrorHandling.formatCombinedOutput(context)
        end
    end
}

-- Campaign Title
template.config.blocks.campaignTitle = {
    feature = 'fullPage',
    render = function(template, args)
        local titleText = template.config.constants.title or "Campaign Information"
        return "== " .. titleText .. " =="
    end
}

-- Campaign Instructions
template.config.blocks.campaignInstructions = {
    feature = 'fullPage',
    render = function(template, args)
        if not args._show_instructions or not args._campaign_data then
            return ""
        end
        
        local campaignName = getCampaignName(args, args._campaign_data)
        local instructions = generateUsageInstructions(campaignName, args._campaign_data)
        
        return '<div class="campaign-instructions">\n== ⚠️ Editing Instructions ==\n\n' .. instructions .. '\n</div>'
    end
}

-- Campaign Content
template.config.blocks.campaignContent = {
    feature = 'fullPage',
    render = function(template, args)
        local output = {}
        
        for _, field in ipairs(template.config.fields or {}) do
            if field.key ~= "usageInstructions" then
                local rawValue = args[field.key]
                local processor = template._processors[field.key] or template._processors.default
                
                if processor then
                    local value = processor(rawValue, args, template, field.type)
                    if value and value ~= "" then
                        if field.key == "campaign_intro" then
                            table.insert(output, value)
                            table.insert(output, "")
                        else
                            table.insert(output, "=== " .. field.label .. " ===")
                            table.insert(output, value)
                            table.insert(output, "")
                        end
                    end
                end
            end
        end
        
        return table.concat(output, "\n")
    end
}

-- Generic field processor that handles different data types
local function processFieldValue(value, fieldType)
    if isNotApplicable(value) then
        return nil
    end

    if type(value) == "table" then
        if #value > 0 then
            -- Array of values - render as bullet list
            return "* " .. table.concat(value, "\n* ")
        else
            -- Object with key-value pairs - render as sections
            local output = {}
            for category, content in pairs(value) do
                local categoryTitle = "'''" .. category:gsub("^%l", string.upper):gsub("_", " ") .. "'''"
                table.insert(output, categoryTitle)
                if type(content) == "table" and #content > 0 then
                    table.insert(output, "* " .. table.concat(content, "\n* "))
                else
                    table.insert(output, tostring(content))
                end
            end
            return table.concat(output, "\n")
        end
    elseif fieldType == "list" and type(value) == "string" then
        -- Handle semicolon-separated lists with token styling
        local items = {}
        for item in value:gmatch("[^;]+") do
            local trimmed = item:match("^%s*(.-)%s*$") -- trim whitespace
            if trimmed and trimmed ~= "" and not isNotApplicable(trimmed) then
                table.insert(items, '<span class="campaign-token">' .. trimmed .. '</span>')
            end
        end
        if #items > 0 then
            return table.concat(items, ' ')
        else
            return nil -- Return nil if all items were "not applicable"
        end
    else
        return tostring(value)
    end
end

-- Custom preprocessor to load campaign data and generate fields dynamically
Blueprint.addPreprocessor(template, function(template, args)
    -- CRITICAL: Get campaign_name from args (merged by Blueprint) or current frame for direct module calls
    local campaignName = args.campaign_name
    
    -- Fallback for direct module invocations where args may not be merged yet
    if (not campaignName or campaignName == "") and template.current_frame then
        local frameArgs = template.current_frame.args or {}
        campaignName = frameArgs.campaign_name
    end
    
    if not campaignName or campaignName == "" then
        return args
    end
    
    -- Load campaign data from JSON
    local campaignData = DatasetLoader.get('Campaigns/' .. campaignName)
    if not campaignData or not campaignData.defaults or not campaignData.field_definitions then
        return args
    end
    
    -- Simple console message
    ErrorHandling.addStatus(
        template._errorContext,
        'campaignLoader',
        'Campaign loaded successfully for ' .. campaignName,
        nil
    )
    
    -- CRITICAL: Detect custom parameters BEFORE merging defaults to determine template mode
    local customParameters = {}
    local hasCustomParameters = false
    for k, v in pairs(args) do
        -- Skip campaign_name, internal parameters, and empty values
        if k ~= "campaign_name" and 
           not k:match("^_") and  -- Skip internal parameters like _recursion_depth
           v and v ~= "" then
            customParameters[k] = true
            hasCustomParameters = true
        end
    end
    
    -- Check if ALL fields are provided as custom parameters (excluding campaign_intro which is fixed)
    local hasAllParameters = true
    for _, fieldDef in ipairs(campaignData.field_definitions) do
        if fieldDef.key ~= "campaign_intro" and not customParameters[fieldDef.key] then
            hasAllParameters = false
            break
        end
    end
    
    -- Determine template mode based on parameter completeness
    local templateMode
    if not hasCustomParameters then
        templateMode = "documentation"
    elseif hasAllParameters then
        templateMode = "complete"
    else
        templateMode = "partial"
    end
    
    -- Store mode state for rendering
    args._template_mode = templateMode
    args._show_instructions = (templateMode ~= "complete")
    args._campaign_data = campaignData
    
    -- Defaults are only used for parameter documentation, not content rendering
    
    -- Generate field definitions based on mode
    local fields = {}
    
    -- Always show campaign content fields (they'll show placeholder text when empty)
    for _, fieldDef in ipairs(campaignData.field_definitions) do
        table.insert(fields, {
            key = fieldDef.key,
            label = fieldDef.label,
            type = fieldDef.type
        })
    end
    
    -- Add usage instructions in documentation and partial modes
    if templateMode ~= "complete" then
        table.insert(fields, {
            key = "usageInstructions",
            label = "Usage Instructions",
            type = "text"
        })
        args.usageInstructions = "documentation"
    end
    
    template.config.fields = fields
    
    -- Override title if provided in JSON defaults
    if campaignData.defaults.title then
        template.config.constants.title = campaignData.defaults.title
    end
    
    -- Add campaign-specific category
    template.config.categories.base = {campaignName}
    
    return args
end)

-- Initialize field processors for the template
-- Set up a universal processor that can handle any field type
if not template._processors then
    template._processors = {}
end

-- Set up a universal field processor that handles all field types
template._processors.default = function(value, args, template, fieldType)
    if value and value ~= "" then
        return processFieldValue(value, fieldType or "text")
    else
        -- Show placeholder for empty fields in documentation and partial modes
        if args._template_mode ~= "complete" then
            return "''Please see usage instructions above to customize this field.''"
        end
        return nil -- Don't display empty fields in complete mode
    end
end

-- SPECIAL: campaign_intro processor - always uses JSON default, never user input
template._processors.campaign_intro = function(value, args, template, fieldType)
    -- CRITICAL: Always use campaign data default, ignore user input to maintain consistency
    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
        return "''" .. tostring(defaultIntro) .. "''"
    else
        -- Fallback if no campaign data available
        if args._template_mode ~= "complete" then
            return "''Campaign introduction will appear here from JSON defaults.''"
        end
        return nil
    end
end

-- Special processor for usage instructions
template._processors.usageInstructions = function(value, args, template)
    if not args._show_instructions or not args._campaign_data then
        return nil -- Only render when instructions should be shown
    end
    
    local campaignName = getCampaignName(args, args._campaign_data)
    local instructions = generateUsageInstructions(campaignName, args._campaign_data)
    
    return "\n----\n'''Usage Instructions'''\n\n" .. instructions
end

function p.render(frame)
    template.current_frame = frame
    
    local depth = getRecursionDepth(frame)
    
    if depth > 3 then
        return '<span class="error">Template recursion depth exceeded (limit: 3)</span>'
    end
    
    if not template._errorContext then
        template._errorContext = require('Module:ErrorHandling').createContext(template.type)
    end
    
    if not template.config.meta then
        require('Module:LuaTemplateBlueprint').initializeConfig(template)
    end
    
    -- Merge arguments: frame args override parent args
    local args = {}
    local parentArgs = frame:getParent().args or {}
    local frameArgs = frame.args or {}
    
    for k, v in pairs(parentArgs) do args[k] = v end
    for k, v in pairs(frameArgs) do args[k] = v end
    
    args = TemplateHelpers.normalizeArgumentCase(args)
    
    args._recursion_depth = tostring(depth + 1)
    
    args = Blueprint.runPreprocessors(template, args)
    
    local structureConfig = {
        tableClass = (template.config.constants and template.config.constants.tableClass) or "template-table",
        blocks = {},
        containerTag = template.features.fullPage and "div" or "table"
    }
    
    local renderingSequence = Blueprint.buildRenderingSequence(template)
    
    if renderingSequence._length == 0 then
        return ""
    end
    
    for i = 1, renderingSequence._length do
        table.insert(structureConfig.blocks, function(a)
            return renderingSequence[i](a)
        end)
    end
    
    local result = TemplateStructure.render(args, structureConfig, template._errorContext)
    
    template.current_frame = nil
    
    return result
end

return p