Module:T-Campaign: Difference between revisions
// via Wikitext Extension for VSCode |
// via Wikitext Extension for VSCode |
||
| (201 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
-- | -- 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 = {} | 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 | ||
local template = Blueprint.registerTemplate('Campaign', { | local template = Blueprint.registerTemplate('Campaign', { | ||
features = { | features = { | ||
fullPage = true, -- Full-page layout | |||
categories = true, | categories = true, | ||
errorReporting = true | errorReporting = true | ||
| Line 23: | Line 22: | ||
}) | }) | ||
-- Dynamic configuration | -- Dynamic configuration (from JSON) | ||
template.config = { | template.config = { | ||
fields = {}, | fields = {}, | ||
categories = { | categories = { base = {} }, | ||
constants = { | constants = { | ||
title = "Campaign Information" | 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 | -- Generic field processor that handles different data types | ||
local function processFieldValue(value) | 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 53: | Line 276: | ||
end | end | ||
return table.concat(output, "\n") | 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 | end | ||
else | else | ||
| Line 61: | Line 298: | ||
-- 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 ( | -- CRITICAL: Get campaign_name from args (merged by Blueprint) or current frame for direct module calls | ||
local campaignName = args.campaign_name | 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 | 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 72: | Line 308: | ||
if not campaignName or campaignName == "" then | if not campaignName or campaignName == "" then | ||
return args | return args | ||
end | end | ||
-- Load campaign data from JSON | -- Load campaign data from JSON | ||
local campaignData = DatasetLoader. | local campaignData = DatasetLoader.get('Campaigns/' .. campaignName) | ||
if not campaignData | if not campaignData or not campaignData.defaults or not campaignData.field_definitions then | ||
return args | return args | ||
end | 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 = {} | local fields = {} | ||
for | |||
-- Skip | -- Always show campaign content fields (they'll show placeholder text when empty) | ||
if key ~= " | 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 = key, label = label}) | table.insert(fields, { | ||
key = fieldDef.key, | |||
label = fieldDef.label, | |||
type = fieldDef.type | |||
}) | |||
end | end | ||
end | end | ||
-- | -- Add usage instructions in documentation and partial modes | ||
table. | if templateMode ~= "complete" then | ||
table.insert(fields, { | |||
key = "usageInstructions", | |||
label = "Usage Instructions", | |||
type = "text" | |||
}) | |||
args.usageInstructions = "documentation" | |||
end | |||
template.config.fields = fields | template.config.fields = fields | ||
-- Override title if provided in JSON | -- Override title if provided in JSON defaults | ||
if campaignData.title then | if campaignData.defaults.title then | ||
template.config.constants.title = campaignData.title | template.config.constants.title = campaignData.defaults.title | ||
end | end | ||
-- | -- Add campaign-specific category, defaulting to template_id | ||
local category_value = campaignData.category or campaignData.template_id | |||
template.config.categories.base = {category_value} | |||
template.config.categories.base = { | |||
return args | return args | ||
| Line 133: | Line 409: | ||
end | end | ||
-- Set up a universal field processor that | -- Set up a universal field processor that handles all field types | ||
-- | template._processors.default = function(value, args, template, fieldType) | ||
template._processors.default = | 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. | 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 | |||
template | |||
if | |||
return | |||
end | end | ||
return | |||
local campaignName = getCampaignName(args, args._campaign_data) | |||
local instructions = generateUsageInstructions(campaignName, args._campaign_data) | |||
return "\n----\n'''Usage Instructions'''\n\n" .. instructions | |||
end | end | ||
function p.render(frame) | function p.render(frame) | ||
return template.render( | 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 | end | ||
return p | return p | ||