Documentation for this module may be created at Module:Group/doc
------------------------------------------
-- Module:Group
-- Membership summaries + Membership Table
------------------------------------------
local p = {}
--------------------------------------------------
-- Shared helpers
--------------------------------------------------
-- Trim whitespace
local function trim(s)
if not s then return "" end
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
-- Merge direct #invoke args with template args
local function getArgs(frame)
local parent = frame:getParent()
local out = {}
-- 1) Args passed directly to #invoke
for k, v in pairs(frame.args or {}) do
if v ~= "" then
out[k] = v
end
end
-- 2) Fallback to parent template args
if parent and parent.args then
for k, v in pairs(parent.args) do
if out[k] == nil or out[k] == "" then
out[k] = v
end
end
end
return out
end
-- Helper: expand {{p|Name}} or {{p|Name|Label}} and return the rendered link
local function makePLink(frame, name, label)
if not name or name == "" then
return ""
end
local args = { name }
if label and label ~= "" then
args[2] = label
end
return frame:expandTemplate{
title = "p",
args = args
}
end
-- Replace empty/blank with
local function cell(value)
if not value or value == "" then
return " "
end
return value
end
-- Extract plain display text from member input (for memberN_nolink=yes)
-- Supports: plain, [[Target]], [[Target|Label]], {{p|Target}}, {{p|Target|Label}}
local function displayTextOnly(raw)
raw = trim(raw or "")
if raw == "" then return "" end
-- [[Target|Label]] or [[Target]]
local target, label = raw:match("^%[%[([^%]|]+)%|([^%]]+)%]%]$")
if target then return trim(label) end
target = raw:match("^%[%[([^%]]+)%]%]$")
if target then return trim(target) end
-- {{p|Target|Label}} or {{p|Target}}
local t1, t2 = raw:match("^{{%s*[Pp]%s*|%s*([^|}]+)%s*|%s*([^}]+)%s*}}$")
if t1 then return trim(t2) end
t1 = raw:match("^{{%s*[Pp]%s*|%s*([^}]+)%s*}}$")
if t1 then return trim(t1) end
-- plain text
return raw
end
-- Parse member input that may be:
-- * plain title: Superman (Clark Kent)
-- * wikilink: [[Superman (Clark Kent)|Supernova]]
-- * wikilink: [[Superman (Clark Kent)]]
-- * template: {{p|Superman (Clark Kent)|Supernova}}
-- * template: {{p|Superman (Clark Kent)}}
-- Returns: targetTitle (string), label (string or ""), rawKind ("plain"/"wikilink"/"p"/"plain"), rawOriginal
local function parseMemberValue(raw)
raw = trim(raw or "")
if raw == "" then
return "", "", "plain", ""
end
-- [[Target|Label]] or [[Target]]
do
local target, label = raw:match("^%[%[([^%]|]+)%|([^%]]*)%]%]$")
if target then
return trim(target), trim(label or ""), "wikilink", raw
end
target = raw:match("^%[%[([^%]]+)%]%]$")
if target then
return trim(target), "", "wikilink", raw
end
end
-- {{p|Target|Label}} or {{p|Target}}
-- (simple parse; assumes no nested pipes in the params)
do
local target, label = raw:match("^%{%{%s*[Pp]%s*%|%s*([^|}]+)%s*%|%s*([^}]+)%s*%}%}$")
if target then
return trim(target), trim(label or ""), "p", raw
end
target = raw:match("^%{%{%s*[Pp]%s*%|%s*([^}]+)%s*%}%}$")
if target then
return trim(target), "", "p", raw
end
end
-- Fallback: treat as plain text (could be unrecognized wikitext)
return raw, "", "plain", raw
end
-- True if a wiki page exists
local function pageExists(titleText)
titleText = trim(titleText or "")
if titleText == "" then return false end
local t = mw.title.new(titleText)
return (t ~= nil) and t.exists
end
-- Build output for member names:
-- - If target exists: use {{p|Target}} or {{p|Target|Label}}
-- - If target does not exist: output label (or target) as plain text (no redlink)
-- You can force redlinks by changing this behavior easily (see note below).
local function renderMember(frame, rawMemberValue, labelOverride)
local target, labelFromRaw = parseMemberValue(rawMemberValue)
target = trim(target)
if target == "" then return "" end
local label = trim(labelOverride or "")
if label == "" then
label = trim(labelFromRaw or "")
end
if pageExists(target) then
-- Use your template p for consistent styling
if label ~= "" then
return makePLink(frame, target, label)
end
return makePLink(frame, target)
end
-- No page: plain text
if label ~= "" then return label end
return target
end
-- For the gallery we need the *target title* (plain), plus the best label.
local function getMemberTargetAndLabel(rawMemberValue, labelOverride)
local target, labelFromRaw = parseMemberValue(rawMemberValue)
target = trim(target)
local label = trim(labelOverride or "")
if label == "" then label = trim(labelFromRaw or "") end
return target, label
end
-- Work out if a member is active and/or support based on status/role
local function classifyMember(args, base)
local statusRaw = args[base .. "_status"] or ""
local status = statusRaw:lower()
local role = (args[base .. "_role"] or ""):lower()
-- "Active" if status contains "active" but not "inactive", OR blank
local hasActive = status:find("active", 1, true) ~= nil
local hasInactive = status:find("inactive", 1, true) ~= nil
local isActive
if status == "" then
isActive = true -- blank = treat as not-former (current)
else
isActive = hasActive and not hasInactive
end
-- "Support" if role mentions support/staff/crew/business
local isSupport = false
if role ~= "" then
if role:find("support", 1, true)
or role:find("staff", 1, true)
or role:find("crew", 1, true)
or role:find("business", 1, true)
then
isSupport = true
end
end
return isActive, isSupport, statusRaw
end
-- Group is considered "defunct" if status says so
local function isDefunctStatus(args)
local s = (args.status or ""):lower()
if s == "" then
return false
end
if s:find("defunct", 1, true)
or s:find("disbanded", 1, true)
or s:find("dissolved", 1, true)
or s:find("inactive", 1, true)
then
return true
end
return false
end
--------------------------------------------------
-- ERA helpers
--------------------------------------------------
local function splitEraValues(s)
if not s or s == "" then return {} end
local t = {}
for part in s:gmatch("[^;]+") do
table.insert(t, trim(part):lower())
end
return t
end
-- Member matches era filter?
local function memberMatchesEra(memberEraRaw, eraFilterRaw)
-- If no filter is requested, everyone matches
local eraFilter = trim(eraFilterRaw or "")
if eraFilter == "" then
return true
end
-- If a filter IS requested, only members with an era value can match
local memberEras = splitEraValues(memberEraRaw or "")
if #memberEras == 0 then
return false
end
-- era filter itself can be a semicolon list: "original; satellite"
local wanted = splitEraValues(eraFilter)
if #wanted == 0 then
-- Edge case: malformed filter, treat as no filter
return true
end
for _, m in ipairs(memberEras) do
for _, w in ipairs(wanted) do
if m == w then
return true
end
end
end
return false
end
-- Pick the right image for a member, with optional era-specific override
local function getMemberImage(args, base, eraFilterRaw)
-- generic image
local img = trim(args[base .. "_image"] or "")
-- if an era filter is active, try era-specific image first
local eraFilter = trim(eraFilterRaw or "")
if eraFilter ~= "" then
local eras = splitEraValues(eraFilter)
if #eras > 0 then
-- we take the first era in the filter for the suffix
local suffix = "_" .. eras[1] -- eras[1] is already lowercased by splitEraValues
local key = base .. "_image" .. suffix
local eraImg = trim(args[key] or "")
if eraImg ~= "" then
img = eraImg
end
end
end
return img
end
--------------------------------------------------
-- Summary lists (Current / Former / Supporting)
--------------------------------------------------
-- Collect member names into a comma-separated list of links
-- mode = "current", "former", "support"
local function collectMembers(frame, args, max, mode, eraFilter)
max = tonumber(max)
or tonumber(args.max_members)
or tonumber(args.max)
or 50 -- default scan limit
eraFilter = eraFilter or ""
local list = {}
for i = 1, max do
local base = "member" .. i
local name = args[base]
-- Step 1: detect nolink
local nolinkRaw = trim(args[base .. "_nolink"] or ""):lower()
local nolink = (nolinkRaw == "yes" or nolinkRaw == "true" or nolinkRaw == "1")
-- Must have a name and match the era (if any)
if name and name ~= "" and memberMatchesEra(args[base .. "_era"], eraFilter) then
local isActive, isSupport = classifyMember(args, base)
local include = false
if mode == "current" then
-- Current = active, not support
include = isActive and not isSupport
elseif mode == "former" then
-- Former = not active and not support
include = (not isActive) and not isSupport
elseif mode == "support" then
-- Supporting crew only
include = isSupport
end
if include then
-- Respect optional _label override + nolink
local nameLabel = trim(args[base .. "_label"] or "")
-- Parse the raw member value (plain / [[ ]] / {{p| }})
local targetTitle, parsedLabel, rawKind, rawOriginal = parseMemberValue(name)
-- Final label precedence:
-- 1) explicit memberN_label
-- 2) label found in [[Target|Label]] or {{p|Target|Label}}
-- 3) blank (use targetTitle)
local finalLabel = nameLabel
if finalLabel == "" then
finalLabel = parsedLabel or ""
end
local outText
if nolink then
-- Plain text only (prefer explicit _label if given)
outText = (nameLabel ~= "" and nameLabel) or displayTextOnly(name)
else
-- Always link (red links OK). Normalize to {{p|Title|Label}}
if rawKind == "plain" or rawKind == "wikilink" or rawKind == "p" then
outText = makePLink(frame, targetTitle, finalLabel)
else
-- Unknown formatting: keep as-is; if an override label exists, show it as plain text
outText = (nameLabel ~= "" and nameLabel) or (rawOriginal or name)
end
end
if mode == "support" then
-- Show simplified status tag: (active) or (former)
local tag = isActive and "active" or "former"
outText = outText .. " (" .. tag .. ")"
end
table.insert(list, outText)
end
end
end
return table.concat(list, ", ")
end
-- Count how many members fall into a given mode ("current", "former", "support")
local function countMembers(args, max, mode, eraFilter)
max = tonumber(max)
or tonumber(args.max_members)
or tonumber(args.max)
or 50
eraFilter = eraFilter or ""
local count = 0
for i = 1, max do
local base = "member" .. i
local name = args[base]
-- Step 1: detect nolink
local nolinkRaw = trim(args[base .. "_nolink"] or ""):lower()
local nolink = (nolinkRaw == "yes" or nolinkRaw == "true" or nolinkRaw == "1")
-- Must have a name and match era
if name and name ~= "" and memberMatchesEra(args[base .. "_era"], eraFilter) then
local isActive, isSupport = classifyMember(args, base)
local include = false
if mode == "current" then
include = isActive and not isSupport
elseif mode == "former" then
include = (not isActive) and not isSupport
elseif mode == "support" then
include = isSupport
end
if include then
count = count + 1
end
end
end
return count
end
-- All non-support members (ignores "current"/"former" split)
local function collectMembersAll(frame, args, max)
max = tonumber(max)
or tonumber(args.max_members)
or tonumber(args.max)
or 50
local list = {}
for i = 1, max do
local base = "member" .. i
local name = args[base]
-- Step 1: detect nolink
local nolinkRaw = trim(args[base .. "_nolink"] or ""):lower()
local nolink = (nolinkRaw == "yes" or nolinkRaw == "true" or nolinkRaw == "1")
if name and name ~= "" then
local _, isSupport = classifyMember(args, base)
if not isSupport then
table.insert(list, renderMember(frame, name, args[base .. "_label"]))
end
end
end
return table.concat(list, ", ")
end
function p.membershipSummaryAll(frame)
local args = getArgs(frame)
return collectMembersAll(frame, args, args.max_members)
end
function p.membershipSummaryAllBlock(frame)
local list = p.membershipSummaryAll(frame)
if list == "" then
return ""
end
return "<br><b>Members:</b> " .. list
end
-- "Current Members:" list (names only; label is in template)
function p.membershipSummaryCurrent(frame)
local args = getArgs(frame)
-- Honour manual override
if args.current_members and args.current_members ~= "" then
return args.current_members
end
local eraFilter = args.era or ""
return collectMembers(frame, args, args.max_members, "current", eraFilter)
end
-- "Former Members:" full block (label + names), or blank if none
function p.membershipSummaryFormerBlock(frame)
local args = getArgs(frame)
local eraFilter = args.era or ""
local list = collectMembers(frame, args, args.max_members, "former", eraFilter)
if list == "" then
return ""
end
return "<br><b>Former Members:</b> " .. list
end
-- "Support Staff:" full block, or blank if none
function p.supportingSummary(frame)
local args = getArgs(frame)
local eraFilter = args.era or ""
local list = collectMembers(frame, args, args.max_members, "support", eraFilter)
if list == "" then
return ""
end
return "<br><b>Support Staff:</b> " .. list
end
--------------------------------------------------
-- Membership TABLE with era + column control
--------------------------------------------------
function p.membershipTable(frame)
local args = getArgs(frame)
local max = tonumber(args.max_members) or tonumber(args.max) or 300
local eraFilter = args.era or ""
-- include_support = "no" → hide support from table
local includeSupport = true
if args.include_support then
local v = trim(args.include_support):lower()
if v == "no" or v == "false" or v == "0" then
includeSupport = false
end
end
-- Pre-scan to discover:
-- * any members at all
-- * whether any member has joined / active_from (for smart header)
local hasAny = false
local hasActiveFromData = false
local hasJoinedData = false
for i = 1, max do
local base = "member" .. i
local name = trim(args[base] or "")
if name ~= "" then
hasAny = true
local joined = trim(args[base .. "_joined"] or "")
local activeFrom = trim(args[base .. "_active_from"] or "")
if joined ~= "" then
hasJoinedData = true
end
if activeFrom ~= "" then
hasActiveFromData = true
end
end
end
if not hasAny then
return ""
end
-- Parse membership_columns= as an ordered token list (strict, order-preserving).
-- Canonical tokens:
-- joined, first, status, note, note2, era
local function parseColumnsList(s)
s = trim((s or ""):lower())
if s == "" then return {} end
local out = {}
for token in s:gmatch("[^,%s]+") do
token = trim(token)
-- Strict canonicalization (EXACT matches only)
if token == "note2" then
token = "note2"
elseif token == "note" or token == "note1" then
token = "note"
elseif token == "joined" then
token = "joined"
elseif token == "first" then
token = "first"
elseif token == "status" then
token = "status"
elseif token == "era" then
token = "era"
else
-- ignore unknown tokens (prevents accidental mangling)
token = nil
end
if token then
out[#out+1] = token
end
end
return out
end
local function hasToken(list, t)
for _, v in ipairs(list) do
if v == t then return true end
end
return false
end
-- Auto-insert joined if missing:
-- default to 2nd column, but if "first" exists, put joined AFTER first.
local function ensureJoined(list)
if hasToken(list, "joined") then
return list
end
for i, v in ipairs(list) do
if v == "first" then
table.insert(list, i + 1, "joined")
return list
end
end
table.insert(list, 1, "joined")
return list
end
local colList = ensureJoined(parseColumnsList(args.membership_columns or ""))
-- Note column header names (only used if note/note2 are in membership_columns)
local note1Name = "Note"
if args.membership_note1 and args.membership_note1 ~= "" then
note1Name = args.membership_note1
elseif args.membership_note and args.membership_note ~= "" then
note1Name = args.membership_note
end
local note2Name = "Note 2"
if args.membership_note2 and args.membership_note2 ~= "" then
note2Name = args.membership_note2
end
-- Joined / Active from header (smart)
local joinedHeader
if hasJoinedData and not hasActiveFromData then
joinedHeader = "Joined"
elseif hasActiveFromData and not hasJoinedData then
joinedHeader = "Active From"
else
joinedHeader = "Joined / Active From"
end
-- Build table
local out = {}
out[#out+1] = '{| class="wikitable sortable membership-table" style="width:100%; border:1px; font-size:90%; border:0px;"'
-- Header row
out[#out+1] = "|-"
out[#out+1] = "! Member"
-- Optional columns in requested order (with joined inserted if missing)
for _, col in ipairs(colList) do
if col == "joined" or col == "active" or col == "activefrom" then
out[#out+1] = "!! " .. joinedHeader
elseif col == "first" then
out[#out+1] = "!! First Appearance"
elseif col == "status" then
out[#out+1] = "!! Status"
elseif col == "note" or col == "note1" then
out[#out+1] = "!! " .. note1Name
elseif col == "note2" then
out[#out+1] = "!! " .. note2Name
elseif col == "era" then
out[#out+1] = "!! Era"
end
end
-- Data rows
for i = 1, max do
local base = "member" .. i
local rawMember = trim(args[base] or "")
-- memberN_nolink = yes → show name as plain text (no links)
local nolinkRaw = trim(args[base .. "_nolink"] or ""):lower()
local nolink = (nolinkRaw == "yes" or nolinkRaw == "true" or nolinkRaw == "1")
if rawMember ~= "" then
if memberMatchesEra(args[base .. "_era"], eraFilter) then
local isActive, isSupport, statusRaw = classifyMember(args, base)
-- Skip support staff if include_support = no
if (not isSupport) or includeSupport then
out[#out+1] = "|-"
-- Member name (supports plain / [[ ]] / {{p| }} + _label + _nolink)
local labelOverride = trim(args[base .. "_label"] or "")
-- Parse raw member value
local targetTitle, parsedLabel, rawKind, rawOriginal = parseMemberValue(rawMember)
-- Label precedence: explicit _label > label found in markup > blank
local finalLabel = labelOverride
if finalLabel == "" then
finalLabel = parsedLabel or ""
end
local memberText
if nolink then
-- Plain text only (prefer explicit _label if present)
memberText = (labelOverride ~= "" and labelOverride) or displayTextOnly(rawMember)
else
-- Always link (red links OK) using {{p|Title|Label}}
if rawKind == "plain" or rawKind == "wikilink" or rawKind == "p" then
memberText = makePLink(frame, targetTitle, finalLabel)
else
-- Unknown formatting: keep as-is; if _label exists, show that instead
memberText = (labelOverride ~= "" and labelOverride) or (rawOriginal or rawMember)
end
end
-- Member cell first (always)
out[#out+1] = "| " .. cell(memberText)
-- Remaining cells in the same order as the headers
for _, col in ipairs(colList) do
if col == "joined" or col == "active" or col == "activefrom" then
local joined = trim(args[base .. "_joined"] or "")
local activeFrom = trim(args[base .. "_active_from"] or "")
local joinedDisplay = ""
if joined ~= "" then
joinedDisplay = joined
elseif activeFrom ~= "" then
joinedDisplay = activeFrom
end
out[#out+1] = "| " .. cell(joinedDisplay)
elseif col == "first" then
local first = trim(args[base .. "_first"] or "")
out[#out+1] = "| " .. cell(first)
elseif col == "status" then
local status = trim(statusRaw or "")
out[#out+1] = "| " .. cell(status)
elseif col == "note" or col == "note1" then
local n1 = trim(args[base .. "_note"] or "")
out[#out+1] = "| " .. cell(n1)
elseif col == "note2" then
local n2 = trim(args[base .. "_note2"] or "")
out[#out+1] = "| " .. cell(n2)
elseif col == "era" then
local er = trim(args[base .. "_era"] or "")
out[#out+1] = "| " .. cell(er)
end
end
end
end
end
end
out[#out+1] = "|}"
return table.concat(out, "\n")
end
--------------------------------------------------
-- Membership GALLERY (uses Template:Member)
--------------------------------------------------
function p.membershipGallery(frame)
local args = getArgs(frame)
local max = tonumber(args.max_members) or tonumber(args.max) or 50
local eraFilter = args.era or ""
-- include_support = "no" → hide support from gallery
local includeSupport = true
if args.include_support then
local v = trim(args.include_support):lower()
if v == "no" or v == "false" or v == "0" then
includeSupport = false
end
end
-- How many cards per row
local perRow = tonumber(args.gallery_per_row) or 5
local out = {}
local count = 0
local anyShown = false
for i = 1, max do
local base = "member" .. i
-- Raw member input as entered in the template.
-- IMPORTANT: keep this in its own variable and never reuse it, or parsing may silently break.
local rawMember = trim(args[base] or "")
-- memberN_nolink = yes
-- Means: show name + image, but never link to a profile page.
local nolinkRaw = trim(args[base .. "_nolink"] or ""):lower()
local nolink = (nolinkRaw == "yes" or nolinkRaw == "true" or nolinkRaw == "1")
if rawMember ~= "" then
if memberMatchesEra(args[base .. "_era"], eraFilter) then
local isActive, isSupport, statusRaw = classifyMember(args, base)
-- Skip support staff if include_support = no
if (not isSupport) or includeSupport then
if not anyShown then
-- Open container + first row (only once)
out[#out+1] = '<div style="overflow-x:auto;"><table><tr>'
anyShown = true
end
-- Membership metadata
local joined = trim(args[base .. "_joined"])
local activeFrom = trim(args[base .. "_active_from"])
local status = trim(statusRaw)
local note = trim(args[base .. "_note"])
-- Image name (explicit or era-specific)
local image = getMemberImage(args, base, eraFilter)
-- Parse member input.
-- Supports: plain title, [[Target]], [[Target|Label]], {{p|Target}}, {{p|Target|Label}}
-- Returns a clean target title suitable for: linking, default image name
local targetTitle, parsedLabel = parseMemberValue(rawMember)
-- SAFETY NET:
-- The gallery *must* always have a non-empty title in field 0, otherwise images become "File:.png".
-- If parsing fails, fall back to display-only text.
if not targetTitle or targetTitle == "" then
targetTitle = displayTextOnly(rawMember)
end
-- Label precedence:
-- 1) explicit memberN_label
-- 2) label embedded in [[Target|Label]] or {{p|Target|Label}}
-- 3) blank (Template:Member will derive a name)
local labelOverride = trim(args[base .. "_label"] or "")
local finalLabel = labelOverride
if finalLabel == "" then
finalLabel = parsedLabel or ""
end
-- Pack the member string in the exact format expected by Template:Member
local packed = table.concat({
targetTitle,
joined,
activeFrom,
status,
note,
image
}, "\\")
-- Arguments passed to Template:Member
local memberCardArgs = {
member = packed
}
-- Optional display label
if finalLabel ~= "" then
memberCardArgs.label = finalLabel
end
-- If nolink=yes:
-- - name is rendered as plain text
-- - image is shown but NOT clickable
-- - no profile page is required
if nolink then
memberCardArgs.nolink = "yes"
end
local memberCard = frame:expandTemplate{
title = "Member",
args = memberCardArgs
}
-- Add card cell
out[#out+1] = "<td>" .. memberCard .. "</td>"
count = count + 1
-- Wrap to next row after perRow cards
if (count % perRow) == 0 then
out[#out+1] = "</tr><tr>"
end
end
end
end
end
if not anyShown then
return ""
end
out[#out+1] = "</tr></table></div>"
return table.concat(out, "\n")
end
--------------------------------------------------
-- Router for membership pages
-- mode = "table", "gallery", "summaries"
--------------------------------------------------
function p.membershipRouter(frame)
local args = getArgs(frame)
local mode = (args.mode or args.membership_mode or "table"):lower()
if mode == "gallery" then
return p.membershipGallery(frame)
elseif mode == "summaries" then
-- Decide whether to list names or just link to the membership section
local summaryLimit = tonumber(args.summary_limit) or 50
local max = args.max_members or args.max
local c = countMembers(args, max, "current")
local f = countMembers(args, max, "former")
local s = countMembers(args, max, "support")
local total = c + f + s
-- If there are *lots* of members, just link to the MEMBERSHIP section or page
if total > summaryLimit then
local title = mw.title.getCurrentTitle()
local linkText = "Membership list"
local linked = trim(args.membership_linked or ""):lower()
if linked == "yes" or linked == "true" or linked == "1" then
return '<br><b>Members:</b> See [['
.. title.prefixedText
.. ' Membership|' .. linkText .. ']]'
end
return '<br><b>Members:</b> See [['
.. title.prefixedText
.. '#MEMBERSHIP|' .. linkText .. ']]'
end
-- Small enough to list inline:
-- if the team is defunct / disbanded / dissolved / inactive,
-- collapse current+former into a single "Members:" line.
if isDefunctStatus(args) then
local members = collectMembersAll(frame, args, max)
if members == "" then
return ""
end
local support = p.supportingSummary(frame)
return "<br><b>Members:</b> " .. members .. support
end
-- Normal case: Current / Former / Support Staff
local current = p.membershipSummaryCurrent(frame)
if current ~= "" then
current = "<br><b>Current Members:</b> " .. current
end
local former = p.membershipSummaryFormerBlock(frame)
local support = p.supportingSummary(frame)
return current .. former .. support
else
-- Default: table
return p.membershipTable(frame)
end
end
return p