Module:T-Campaign
Appearance
Documentation for this module may be created at Module:T-Campaign/doc
-- Module:T-Campaign.lua
-- Generic campaign template that dynamically loads campaign data from JSON files
-- Usage: {{#invoke:T-Campaign|render|campaign_name=NAME}}
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 ""
local titleText = template.config.constants.title or "Campaign"
-- 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
-- Use the centralized NoticeFactory to create the notice
local noticeOptions = {
type = "campaign-js",
position = "top",
content = bannerContent,
title = titleText,
cssClass = cssClass
}
return WikitextProcessor.createNoticeForJS(noticeOptions) .. ErrorHandling.formatCombinedOutput(context)
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
-- CRITICAL: Skip 'title' as it is not a content field
if fieldDef.key ~= "title" then
table.insert(fields, {
key = fieldDef.key,
label = fieldDef.label,
type = fieldDef.type
})
end
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, defaulting to template_id
local category_value = campaignData.category or campaignData.template_id
template.config.categories.base = {category_value}
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