Module:T-Campaign

Revision as of 00:02, 2 August 2025 by MarkWD (talk | contribs) (// via Wikitext Extension for VSCode)

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. 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
        
        local placeholderValues = {
            CAMPAIGN_NAME = args._campaign_data.defaults.title or "Campaign"
        }
        
        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