Show eight-week class dates on Camp Ritchie infobox row
Infobox: drop certificate/arrive/depart/years; expand other_class codes
 
(25 intermediate revisions by the same user not shown)
Line 185: Line 185:
'physically unfit', 'unfit for', 'relieved prior', 'relieved of',
'physically unfit', 'unfit for', 'relieved prior', 'relieved of',
'kicked', 'eliminated from', 'not graduated', 'did not graduate',
'kicked', 'eliminated from', 'not graduated', 'did not graduate',
'excess absence', 'illness', 'sick call', 'medical board',
'psychiatr', 'neurot', 'homosexual', 'suicid', 'alcohol', 'drunk',
'intoxicat', 'venereal', 'syphili', 'gonorrh', 'mental case',
'mental illness', 'nervous breakdown',
'non-graduate', 'non graduate', 'nongraduate', 'certificate listed as ng',
}
}


Line 451: Line 456:
EE = 'Estonia',
EE = 'Estonia',
LA = 'Latvia',
LA = 'Latvia',
LAT = 'Latvia',
LI = 'Lithuania',
LI = 'Lithuania',
LT = 'Lithuania',
LT = 'Lithuania',
LV = 'Latvia',
LV = 'Latvia',
AL = 'Albania',
AL = 'Albania',
ALB = 'Albania',
BU = 'Bulgaria',
BU = 'Bulgaria',
BUL = 'Bulgaria',
BG = 'Bulgaria',
BG = 'Bulgaria',
LUX = 'Luxembourg',
BRA = 'Brazil',
IR = 'Ireland',
IR = 'Ireland',
IE = 'Ireland',
IE = 'Ireland',
PA = 'Palestine',
PA = 'Palestine',
PAL = 'Palestine',
EG = 'Egypt',
EG = 'Egypt',
JA = 'Japan',
JA = 'Japan',
Line 466: Line 477:
BR = 'Brazil',
BR = 'Brazil',
AR = 'Argentina',
AR = 'Argentina',
ARG = 'Argentina',
PE = 'Peru',
PE = 'Peru',
CO = 'Colombia',
CO = 'Colombia',
COL = 'Colombia',
LU = 'Luxembourg',
LU = 'Luxembourg',
ME = 'Mexico',
CHN = 'China',
LITH = 'Lithuania',
PRTG = 'Portugal',
PT = 'Portugal',
DR = 'Dominican Republic',
DO = 'Dominican Republic',
OTH = 'Other',
OTH = 'Other',
OTHER = 'Other',
OTHER = 'Other',
Line 494: Line 514:
ISLAM = 'Muslim',
ISLAM = 'Muslim',
Q = 'Quaker',
Q = 'Quaker',
QU = 'Quaker',
QUA = 'Quaker',
QUAKER = 'Quaker',
FRIENDS = 'Quaker',
N = 'None',
N = 'None',
X = 'Not stated',
X = 'Not stated',
Line 663: Line 687:
key = mw.ustring.gsub(key, '%s+', ' ')
key = mw.ustring.gsub(key, '%s+', ' ')
key = trim(key)
key = trim(key)
if RELIGION_NAME[key] then
local mapped = RELIGION_NAME[key]
return RELIGION_NAME[key]
if mapped then
-- Hide non-informative roster placeholders from the infobox.
if mapped == 'Not stated' or mapped == 'None' or mapped == 'Unknown' then
return ''
end
return mapped
end
end
local lower = mw.ustring.lower(s)
local lower = mw.ustring.lower(s)
if lower == 'not stated' or lower == 'none' or lower == 'unknown'
or lower == 'n/a' or lower == 'na' then
return ''
end
if lower == 'hebrew' or lower == 'jewish' or lower == 'jew'
if lower == 'hebrew' or lower == 'jewish' or lower == 'jew'
or mw.ustring.find(lower, 'jewish', 1, true)
or mw.ustring.find(lower, 'jewish', 1, true)
or mw.ustring.find(lower, 'hebrew', 1, true) then
or mw.ustring.find(lower, 'hebrew', 1, true) then
return 'Jewish'
return 'Jewish'
end
if lower == 'quaker' or lower == 'friends' then
return 'Quaker'
end
end
return prettyProse(s)
return prettyProse(s)
end
end


local function prettyNamePart(s)
-- Roster "other_class" codes → readable course names (By The Numbers / MITC charts).
return prettyNamePhrase(s, false)
local function expandOtherClassToken(tok)
end
tok = tidyDisplay(tok)
if tok == '' then
return ''
end
local u = mw.ustring.upper(tok)
u = mw.ustring.gsub(u, '%s+', ' ')
u = trim(u)


local function isNegativeClause(s)
local n = mw.ustring.match(u, '^SC%s*B%s*(%d+)$')
local lower = mw.ustring.lower(s)
if n then
if lower == 'failed' or mw.ustring.find(lower, '^failed%s') or mw.ustring.find(lower, '%sfailed%s') or mw.ustring.find(lower, '%sfailed$') then
return 'Special Course B ' .. n
return true
end
n = mw.ustring.match(u, '^SC%s*(%d+)$')
if n then
-- SC# aligns with AGF Photo Interpretation / “Special Course” numbering
-- (e.g. SC8 = Special Course 8 in PI).
return 'Special Course ' .. n
end
n = mw.ustring.match(u, '^DC%s*(%d+)$')
if n then
return 'Division Class ' .. n
end
n = mw.ustring.match(u, '^MITU%s*(%d+)$')
if n then
return 'Mobile Intelligence Training Unit ' .. n
end
n = mw.ustring.match(u, '^RDC%s*(%d+)$')
if n then
return 'Redeployment Class ' .. n
end
n = mw.ustring.match(u, '^RCM%s*(%d+)$')
if n then
return 'Radio Countermeasures Class ' .. n
end
n = mw.ustring.match(u, '^OB%s*(%d+)$')
if n then
return 'Order of Battle Class ' .. n
end
end
for _, phrase in ipairs(NEGATIVE_PHRASES) do
if u == 'SIG' or u == 'SIG-SIG' or mw.ustring.match(u, '^SIG[%s%-]') then
if mw.ustring.find(lower, phrase, 1, true) then
return 'Special Intelligence Group–Signal'
return true
end
end
end
return false
end


local function normalizeNoteKey(s)
local unitNum, unitKind = mw.ustring.match(u, '^(%d+)(?:ST|ND|RD|TH)%s+(.+)$')
s = mw.ustring.lower(trim(tidyDisplay(s)))
if unitNum and unitKind then
s = mw.ustring.gsub(s, '^also%s+', '')
local kind = trim(unitKind)
s = mw.ustring.gsub(s, '[^%w%s%-/]', ' ')
if kind == 'INF' then
s = mw.ustring.gsub(s, '%s+', ' ')
kind = 'Infantry'
return trim(s)
elseif kind == 'ARMD' then
end
kind = 'Armored'
 
else
local function dateOnlyFragment(s)
kind = prettyProse(kind)
return mw.ustring.match(trim(s), '^(%d%d?/%d%d?/%d%d%d?%d?)%.?$')
end
end
local ord = unitNum
 
local last = mw.ustring.sub(unitNum, -1)
local function joinDateFollowons(items)
local last2 = mw.ustring.len(unitNum) >= 2 and mw.ustring.sub(unitNum, -2) or ''
local out = {}
if last2 ~= '11' and last2 ~= '12' and last2 ~= '13' then
for _, clause in ipairs(items) do
if last == '1' then
local date = dateOnlyFragment(clause)
ord = unitNum .. 'st'
if date and #out > 0 then
elseif last == '2' then
local prev = mw.ustring.gsub(out[#out], '[ ,;]+$', '')
ord = unitNum .. 'nd'
if mw.ustring.sub(prev, -mw.ustring.len(date)) ~= date then
elseif last == '3' then
out[#out] = prev .. ', ' .. date
ord = unitNum .. 'rd'
else
ord = unitNum .. 'th'
end
end
elseif clause ~= '' then
else
table.insert(out, clause)
ord = unitNum .. 'th'
end
end
return ord .. ' ' .. kind
end
end
return out
 
return prettyProse(tok)
end
end


local function sanitizeNote(s)
local function expandOtherClass(raw)
s = tidyDisplay(s)
raw = tidyDisplay(raw)
if s == '' then
if raw == '' then
return ''
return ''
end
end
local lower = mw.ustring.lower(s)
local parts = {}
if mw.ustring.find(lower, 'no notes were in the source', 1, true) then
for piece in mw.ustring.gmatch(raw, '[^;,]+') do
return ''
local expanded = expandOtherClassToken(piece)
end
if expanded ~= '' then
local kept = {}
table.insert(parts, expanded)
for clause in mw.text.gsplit(s, ';', true) do
clause = trim(clause)
if clause ~= '' then
for sent in mw.text.gsplit(clause, '. ', true) do
sent = trim(sent)
sent = mw.ustring.gsub(sent, '%.$', '')
if sent ~= '' and not isNegativeClause(sent) then
table.insert(kept, prettyProse(sent))
end
end
end
end
end
end
return table.concat(kept, '; ')
return table.concat(parts, '; ')
end
end


local function containsIgnoreCase(haystack, needle)
local function prettyNamePart(s)
return mw.ustring.find(mw.ustring.lower(haystack), mw.ustring.lower(needle), 1, true) ~= nil
return prettyNamePhrase(s, false)
end
end


local function displayName(frame)
local function isNegativeClause(s)
local first = prettyNamePart(parentArg(frame, 'first'))
local lower = mw.ustring.lower(s)
local middle = prettyNamePart(parentArg(frame, 'middle'))
if lower == 'failed' or mw.ustring.find(lower, '^failed%s') or mw.ustring.find(lower, '%sfailed%s') or mw.ustring.find(lower, '%sfailed$') then
local surname = prettyNamePart(parentArg(frame, 'surname'))
return true
local suffix = prettyNamePart(parentArg(frame, 'suffix'))
local parts = {}
if first ~= '' then
table.insert(parts, first)
end
end
if middle ~= '' then
-- Bare class-chart non-graduate marker (e.g. "NG Class 10 OB", trailing "NG")
table.insert(parts, middle)
if lower == 'ng' or mw.ustring.find(lower, '^ng%s') or mw.ustring.find(lower, '%sng%s') or mw.ustring.find(lower, '%sng$') or mw.ustring.find(lower, 'nongrad') then
return true
end
end
if surname ~= '' then
for _, phrase in ipairs(NEGATIVE_PHRASES) do
table.insert(parts, surname)
if mw.ustring.find(lower, phrase, 1, true) then
end
return true
local name = table.concat(parts, ' ')
if suffix ~= '' then
if name ~= '' then
name = name .. ', ' .. suffix
else
name = suffix
end
end
end
end
if name == '' then
return false
name = 'This soldier'
end
return name
end
end


local function classLinkList(raw, withPrefix)
local function normalizeNoteKey(s)
local ClassDates = require('Module:ClassDates')
s = mw.ustring.lower(trim(tidyDisplay(s)))
local out = {}
s = mw.ustring.gsub(s, '^also%s+', '')
for part in mw.text.gsplit(raw, ';', true) do
s = mw.ustring.gsub(s, '[^%w%s%-/]', ' ')
part = trim(part)
s = mw.ustring.gsub(s, '%s+', ' ')
if part ~= '' then
return trim(s)
local info = ClassDates.lookup(part)
local dates = info and info.dates or ''
if withPrefix then
if dates ~= '' then
table.insert(out, string.format('[[Class %s]] (%s)', part, dates))
else
table.insert(out, string.format('[[Class %s]]', part))
end
else
if dates ~= '' then
table.insert(out, string.format('[[Class %s|%s]] (%s)', part, part, dates))
else
table.insert(out, string.format('[[Class %s|%s]]', part, part))
end
end
end
end
return out
end
end


local function isJunkIpwItalianLabel(part)
local function dateOnlyFragment(s)
local lower = mw.ustring.lower(trim(part))
return mw.ustring.match(trim(s), '^(%d%d?/%d%d?/%d%d%d?%d?)%.?$')
local spaced = mw.ustring.gsub(lower, '%s+', ' ')
end
local junk = {
 
['16063'] = true, ['16095'] = true, ['16302'] = true,
local function joinDateFollowons(items)
['16320'] = true, ['16695'] = true,
local out = {}
['1st army'] = true, ['2 day'] = true, ['2/23/44'] = true,
for _, clause in ipairs(items) do
['468th cic'] = true, ['5/7/45'] = true, ['8/24/44'] = true, ['8/7/43'] = true,
local date = dateOnlyFragment(clause)
['apid'] = true, ['i-32'] = true, ['mis-eto'] = true, ['mitc'] = true,
if date and #out > 0 then
['pac'] = true, ['sc7'] = true, ['shaef'] = true, ['v-831'] = true,
local prev = mw.ustring.gsub(out[#out], '[ ,;]+$', '')
}
if mw.ustring.sub(prev, -mw.ustring.len(date)) ~= date then
return junk[spaced] == true
out[#out] = prev .. ', ' .. date
end
elseif clause ~= '' then
table.insert(out, clause)
end
end
return out
end
end


local function normalizeIpwItalianCode(part)
local function sanitizeNote(s)
part = trim(part)
s = tidyDisplay(s)
if part == '' or isJunkIpwItalianLabel(part) then
if s == '' then
return nil
return ''
end
local lower = mw.ustring.lower(s)
if mw.ustring.find(lower, 'no notes were in the source', 1, true) then
return ''
end
end
local spaced = mw.ustring.lower(mw.ustring.gsub(part, '%s+', ' '))
local kept = {}
if spaced == '5th class' or spaced == 'italian 5th class' then
for clause in mw.text.gsplit(s, ';', true) do
return '5'
clause = trim(clause)
if clause ~= '' then
for sent in mw.text.gsplit(clause, '. ', true) do
sent = trim(sent)
sent = mw.ustring.gsub(sent, '%.$', '')
if sent ~= '' and not isNegativeClause(sent) then
table.insert(kept, prettyProse(sent))
end
end
end
end
end
return part
return table.concat(kept, '; ')
end
end


local function isJunkIpwLabel(part)
local function containsIgnoreCase(haystack, needle)
local lower = mw.ustring.lower(trim(part))
return mw.ustring.find(mw.ustring.lower(haystack), mw.ustring.lower(needle), 1, true) ~= nil
lower = mw.ustring.gsub(lower, '%s+', ' ')
lower = mw.ustring.gsub(lower, '%s*%?+%s*$', '')
lower = trim(lower)
if lower == 'csu' or lower == 'fh' or lower == 'fid' or lower == 'illness'
or lower == 'mfiu-5' or lower == 'mfiu 5' or lower == 'mitu 5' or lower == 'mitu-5'
or lower == 'mrbc4' or lower == 'mrbc 4' or lower == 't force' or lower == 'to cic' then
return true
end
local compact = mw.ustring.gsub(lower, '[%s%-]+', '')
if compact == 'docteam' or compact == 'mfiu5' or compact == 'mitu5'
or compact == 'mrbc4' or compact == 'tforce' or compact == 'tocic' then
return true
end
return false
end
end


local function normalizeIpwTeamCode(part)
local function displayName(frame)
part = trim(part)
local first = prettyNamePart(parentArg(frame, 'first'))
if part == '' then
local middle = prettyNamePart(parentArg(frame, 'middle'))
return nil, nil
local surname = prettyNamePart(parentArg(frame, 'surname'))
local suffix = prettyNamePart(parentArg(frame, 'suffix'))
local parts = {}
if first ~= '' then
table.insert(parts, first)
end
end
local original = part
if middle ~= '' then
part = mw.ustring.gsub(part, '%s*%?+%s*$', '')
table.insert(parts, middle)
part = trim(part)
if part == '' then
return nil, original
end
end
local lower = mw.ustring.lower(part)
if surname ~= '' then
if mw.ustring.find(lower, 'armd', 1, true) or mw.ustring.find(lower, 'armored', 1, true) then
table.insert(parts, surname)
return nil, original, nil
end
end
local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$')
local name = table.concat(parts, ' ')
if miiNum then
if suffix ~= '' then
miiLetter = mw.ustring.upper(miiLetter)
if name ~= '' then
local mii = miiNum .. '-' .. miiLetter
name = name .. ', ' .. suffix
if miiLetter == 'R' and (miiNum == '540' or miiNum == '545' or miiNum == '546') then
else
mii = miiNum
name = suffix
end
end
return nil, mii, mii
end
end
local num = mw.ustring.match(part, '^(%d+)[Tt]$')
if name == '' then
if num then
name = 'This soldier'
return num, num, nil
end
end
if mw.ustring.match(lower, '^%d+(st|nd|rd|th)$') then
return name
return nil, original, nil
end
if isJunkIpwLabel(part) then
return nil, original, nil
end
return part, part, nil
end
end


local function teamLinkList(raw, pagePrefix, label)
local function classLinkList(raw, withPrefix)
local ClassDates = require('Module:ClassDates')
local out = {}
local out = {}
local isIpw = mw.ustring.find(pagePrefix, 'IPW', 1, true) == 1
for part in mw.text.gsplit(raw, ';', true) do
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
part = trim(part)
part = trim(part)
if part ~= '' then
if part ~= '' then
if pagePrefix == 'IPW Italian ' then
local info = ClassDates.lookup(part)
local code = normalizeIpwItalianCode(part)
local dates = info and info.dates or ''
if code then
if withPrefix then
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, code))
if dates ~= '' then
table.insert(out, string.format('[[:Category:Class %s|Class %s]] (%s)', part, part, dates))
else
table.insert(out, string.format('[[:Category:Class %s|Class %s]]', part, part))
end
else
if dates ~= '' then
table.insert(out, string.format('[[:Category:Class %s|%s]] (%s)', part, part, dates))
else
else
table.insert(out, part)
table.insert(out, string.format('[[:Category:Class %s|%s]]', part, part))
end
end
elseif isIpw then
local code, shown, mii = normalizeIpwTeamCode(part)
if mii then
table.insert(out, string.format('[[Team MII %s|MII %s]]', mii, mii))
elseif code then
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, shown))
elseif shown then
table.insert(out, shown)
end
else
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, part, label, part))
end
end
end
end
end
end
return table.concat(out, ', ')
return out
end
end


local function addHeader(tbl, text)
local function isJunkIpwItalianLabel(part)
tbl:tag('tr'):tag('th')
local lower = mw.ustring.lower(trim(part))
:attr('colspan', '2')
local spaced = mw.ustring.gsub(lower, '%s+', ' ')
:addClass('infobox-header')
local junk = {
:wikitext(text)
['16063'] = true, ['16095'] = true, ['16302'] = true,
['16320'] = true, ['16695'] = true,
['1st army'] = true, ['2 day'] = true, ['2/23/44'] = true,
['468th cic'] = true, ['5/7/45'] = true, ['8/24/44'] = true, ['8/7/43'] = true,
['apid'] = true, ['i-32'] = true, ['mis-eto'] = true, ['mitc'] = true,
['pac'] = true, ['sc7'] = true, ['shaef'] = true, ['v-831'] = true,
}
return junk[spaced] == true
end
end


local function realCertificate(s)
local function normalizeIpwItalianCode(part)
s = trim(s)
part = trim(part)
if s == '' then
if part == '' or isJunkIpwItalianLabel(part) then
return ''
return nil
end
end
-- Placeholders: *, A, 5, #, and any other single character.
local spaced = mw.ustring.lower(mw.ustring.gsub(part, '%s+', ' '))
if mw.ustring.len(s) == 1 then
if spaced == '5th class' or spaced == 'italian 5th class' then
return ''
return '5'
end
end
return s
return part
end
end


local function addRow(tbl, label, value, dataClass)
local function isJunkIpwLabel(part)
if trim(value) == '' then
local lower = mw.ustring.lower(trim(part))
return
lower = mw.ustring.gsub(lower, '%s+', ' ')
lower = mw.ustring.gsub(lower, '%s*%?+%s*$', '')
lower = trim(lower)
if lower == 'csu' or lower == 'fh' or lower == 'fid' or lower == 'illness'
or lower == 'mfiu-5' or lower == 'mfiu 5' or lower == 'mitu 5' or lower == 'mitu-5'
or lower == 'mrbc4' or lower == 'mrbc 4' or lower == 't force' or lower == 'to cic' then
return true
end
end
local tr = tbl:tag('tr')
local compact = mw.ustring.gsub(lower, '[%s%-]+', '')
tr:tag('th')
if compact == 'docteam' or compact == 'mfiu5' or compact == 'mitu5'
:addClass('infobox-label')
or compact == 'mrbc4' or compact == 'tforce' or compact == 'tocic' then
:css('display', 'table-cell')
return true
:css('width', '38%')
:css('font-weight', 'bold')
:css('text-align', 'left')
:css('vertical-align', 'top')
:wikitext(label)
local td = tr:tag('td')
:addClass('infobox-data')
:css('display', 'table-cell')
:css('vertical-align', 'top')
if dataClass and dataClass ~= '' then
td:addClass(dataClass)
end
end
td:wikitext(value)
return false
end
end


local function yearOnly(s)
local function normalizeIpwTeamCode(part)
s = trim(s)
part = trim(part)
if s == '' or s == '?' then
if part == '' then
return ''
return nil, nil
end
end
local year = mw.ustring.match(s, '(%d%d%d%d)')
local original = part
if year then
part = mw.ustring.gsub(part, '%s*%?+%s*$', '')
return year
part = trim(part)
if part == '' then
return nil, original
end
local lower = mw.ustring.lower(part)
if mw.ustring.find(lower, 'armd', 1, true) or mw.ustring.find(lower, 'armored', 1, true) then
return nil, original, nil
end
local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$')
if miiNum then
miiLetter = mw.ustring.upper(miiLetter)
local mii = miiNum .. '-' .. miiLetter
if miiLetter == 'R' and (miiNum == '540' or miiNum == '545' or miiNum == '546') then
mii = miiNum
end
return nil, mii, mii
end
local num = mw.ustring.match(part, '^(%d+)[Tt]$')
if num then
return num, num, nil
end
if mw.ustring.match(lower, '^%d+(st|nd|rd|th)$') then
return nil, original, nil
end
if isJunkIpwLabel(part) then
return nil, original, nil
end
end
return ''
return part, part, nil
end
end


local RANK_MAP = {
local function teamLinkList(raw, pagePrefix, label)
['PVT'] = 'Private',
local out = {}
['PVTE'] = 'Private',
local isIpw = mw.ustring.find(pagePrefix, 'IPW', 1, true) == 1
['PRIVATE'] = 'Private',
for part in mw.text.gsplit(raw, ';', true) do
['PFC'] = 'Private First Class',
part = trim(part)
['P F C'] = 'Private First Class',
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
['PRIVATE FIRST CLASS'] = 'Private First Class',
part = trim(part)
['CPL'] = 'Corporal',
if part ~= '' then
['CORPORAL'] = 'Corporal',
if pagePrefix == 'IPW Italian ' then
['T/5'] = 'Technician Fifth Grade',
local code = normalizeIpwItalianCode(part)
['TEC5'] = 'Technician Fifth Grade',
if code then
['TEC 5'] = 'Technician Fifth Grade',
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, code))
['T5'] = 'Technician Fifth Grade',
else
['TECH 5'] = 'Technician Fifth Grade',
table.insert(out, part)
['SGT'] = 'Sergeant',
end
['SERGEANT'] = 'Sergeant',
elseif isIpw then
['T/4'] = 'Technician Fourth Grade',
local code, shown, mii = normalizeIpwTeamCode(part)
['TEC4'] = 'Technician Fourth Grade',
if mii then
['TEC 4'] = 'Technician Fourth Grade',
table.insert(out, string.format('[[Team MII %s|MII %s]]', mii, mii))
['T4'] = 'Technician Fourth Grade',
elseif code then
['TECH 4'] = 'Technician Fourth Grade',
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, shown))
['S/SGT'] = 'Staff Sergeant',
elseif shown then
['SSGT'] = 'Staff Sergeant',
table.insert(out, shown)
['S SGT'] = 'Staff Sergeant',
end
['STAFF SERGEANT'] = 'Staff Sergeant',
else
['T/3'] = 'Technician Third Grade',
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, part, label, part))
['TEC3'] = 'Technician Third Grade',
end
['TEC 3'] = 'Technician Third Grade',
end
['T3'] = 'Technician Third Grade',
end
['TECH 3'] = 'Technician Third Grade',
return table.concat(out, ', ')
['T/SGT'] = 'Technical Sergeant',
end
['TSGT'] = 'Technical Sergeant',
 
['T SGT'] = 'Technical Sergeant',
local function addHeader(tbl, text)
['TECHNICAL SERGEANT'] = 'Technical Sergeant',
tbl:tag('tr'):tag('th')
['1ST SGT'] = 'First Sergeant',
:attr('colspan', '2')
['1SGT'] = 'First Sergeant',
:addClass('infobox-header')
['FIRST SERGEANT'] = 'First Sergeant',
:wikitext(text)
['M/SGT'] = 'Master Sergeant',
end
['MSGT'] = 'Master Sergeant',
 
['MASTER SERGEANT'] = 'Master Sergeant',
local function realCertificate(s)
['WO'] = 'Warrant Officer',
s = trim(s)
['WOJG'] = 'Warrant Officer',
if s == '' then
['WARRANT OFFICER'] = 'Warrant Officer',
return ''
['CWO'] = 'Chief Warrant Officer',
end
['2ND LT'] = 'Second Lieutenant',
-- Placeholders: *, A, 5, #, and any other single character.
['2D LT'] = 'Second Lieutenant',
if mw.ustring.len(s) == 1 then
['2LT'] = 'Second Lieutenant',
return ''
['SECOND LIEUTENANT'] = 'Second Lieutenant',
end
['1ST LT'] = 'First Lieutenant',
-- NG = non-graduate marker on class charts, not a certificate number.
['1LT'] = 'First Lieutenant',
local upper = mw.ustring.upper(s)
['FIRST LIEUTENANT'] = 'First Lieutenant',
if upper == 'NG' or upper == 'N/G' or upper == 'N.G.' then
['LT'] = 'Lieutenant',
return ''
['LIEUTENANT'] = 'Lieutenant',
end
['CAPT'] = 'Captain',
return s
['CPT'] = 'Captain',
end
['CAPTAIN'] = 'Captain',
 
['MAJ'] = 'Major',
local function addRow(tbl, label, value, dataClass)
['MAJOR'] = 'Major',
if trim(value) == '' then
['LT COL'] = 'Lieutenant Colonel',
return
['LTCOL'] = 'Lieutenant Colonel',
end
['LTC'] = 'Lieutenant Colonel',
local tr = tbl:tag('tr')
['LIEUTENANT COLONEL'] = 'Lieutenant Colonel',
tr:tag('th')
['COL'] = 'Colonel',
:addClass('infobox-label')
['COLONEL'] = 'Colonel',
:css('display', 'table-cell')
['BRIG GEN'] = 'Brigadier General',
:css('width', '38%')
['BG'] = 'Brigadier General',
:css('font-weight', 'bold')
['BRIGADIER GENERAL'] = 'Brigadier General',
:css('text-align', 'left')
['MAJ GEN'] = 'Major General',
:css('vertical-align', 'top')
['MG'] = 'Major General',
:wikitext(label)
['MAJOR GENERAL'] = 'Major General',
local td = tr:tag('td')
['LT GEN'] = 'Lieutenant General',
:addClass('infobox-data')
['LIEUTENANT GENERAL'] = 'Lieutenant General',
:css('display', 'table-cell')
['GEN'] = 'General',
:css('vertical-align', 'top')
['GENERAL'] = 'General',
if dataClass and dataClass ~= '' then
['TECH'] = 'Technician',
td:addClass(dataClass)
['TEC'] = 'Technician',
end
}
td:wikitext(value)
end


local function expandRank(raw)
local function yearOnly(s)
raw = trim(raw)
s = trim(s)
if raw == '' then
if s == '' or s == '?' then
return ''
return ''
end
end
local key = mw.ustring.upper(raw)
local year = mw.ustring.match(s, '(%d%d%d%d)')
key = mw.ustring.gsub(key, '%.', '')
if year then
key = mw.ustring.gsub(key, '%s+', ' ')
return year
key = trim(key)
end
return RANK_MAP[key] or raw
return ''
end
end


local BRANCH_MAP = {
local RANK_MAP = {
['INF'] = 'Infantry',
['PVT'] = 'Private',
['INFANTRY'] = 'Infantry',
['PVTE'] = 'Private',
['FA'] = 'Field Artillery',
['PRIVATE'] = 'Private',
['FIELD ARTILLERY'] = 'Field Artillery',
['PFC'] = 'Private First Class',
['CAC'] = 'Coast Artillery Corps',
['P F C'] = 'Private First Class',
['CA'] = 'Coast Artillery',
['PRIVATE FIRST CLASS'] = 'Private First Class',
['COAST ARTILLERY'] = 'Coast Artillery',
['CPL'] = 'Corporal',
['AC'] = 'Air Corps',
['CORPORAL'] = 'Corporal',
['AAF'] = 'Army Air Forces',
['T/5'] = 'Technician Fifth Grade',
['AIR CORPS'] = 'Air Corps',
['TEC5'] = 'Technician Fifth Grade',
['QMC'] = 'Quartermaster Corps',
['TEC 5'] = 'Technician Fifth Grade',
['QC'] = 'Quartermaster Corps',
['T5'] = 'Technician Fifth Grade',
['QUARTERMASTER'] = 'Quartermaster Corps',
['TECH 5'] = 'Technician Fifth Grade',
['CAV'] = 'Cavalry',
['SGT'] = 'Sergeant',
['CAVALRY'] = 'Cavalry',
['SERGEANT'] = 'Sergeant',
['CE'] = 'Corps of Engineers',
['T/4'] = 'Technician Fourth Grade',
['ENG'] = 'Corps of Engineers',
['TEC4'] = 'Technician Fourth Grade',
['ENGINEERS'] = 'Corps of Engineers',
['TEC 4'] = 'Technician Fourth Grade',
['CMP'] = 'Corps of Military Police',
['T4'] = 'Technician Fourth Grade',
['MP'] = 'Military Police',
['TECH 4'] = 'Technician Fourth Grade',
['CWS'] = 'Chemical Warfare Service',
['S/SGT'] = 'Staff Sergeant',
['AUS'] = 'Army of the United States',
['SSGT'] = 'Staff Sergeant',
['SC'] = 'Signal Corps',
['S SGT'] = 'Staff Sergeant',
['SIG'] = 'Signal Corps',
['STAFF SERGEANT'] = 'Staff Sergeant',
['SIG C'] = 'Signal Corps',
['T/3'] = 'Technician Third Grade',
['SIGC'] = 'Signal Corps',
['TEC3'] = 'Technician Third Grade',
['SIGNAL CORPS'] = 'Signal Corps',
['TEC 3'] = 'Technician Third Grade',
['ORD'] = 'Ordnance Department',
['T3'] = 'Technician Third Grade',
['ORDNANCE'] = 'Ordnance Department',
['TECH 3'] = 'Technician Third Grade',
['MAC'] = 'Medical Administrative Corps',
['T/SGT'] = 'Technical Sergeant',
['MC'] = 'Medical Corps',
['TSGT'] = 'Technical Sergeant',
['MD'] = 'Medical Department',
['T SGT'] = 'Technical Sergeant',
['TC'] = 'Transportation Corps',
['TECHNICAL SERGEANT'] = 'Technical Sergeant',
['MI'] = 'Military Intelligence',
['1ST SGT'] = 'First Sergeant',
['MIS'] = 'Military Intelligence Service',
['1SGT'] = 'First Sergeant',
['INT'] = 'Intelligence',
['FIRST SERGEANT'] = 'First Sergeant',
['CIC'] = 'Counter Intelligence Corps',
['M/SGT'] = 'Master Sergeant',
['AGD'] = "Adjutant General's Department",
['MSGT'] = 'Master Sergeant',
['USMC'] = 'United States Marine Corps',
['MASTER SERGEANT'] = 'Master Sergeant',
['USMCR'] = 'United States Marine Corps Reserve',
['WO'] = 'Warrant Officer',
['AA'] = 'Antiaircraft',
['WOJG'] = 'Warrant Officer',
['AAA'] = 'Antiaircraft Artillery',
['WARRANT OFFICER'] = 'Warrant Officer',
['TD'] = 'Tank Destroyer',
['CWO'] = 'Chief Warrant Officer',
['FD'] = 'Finance Department',
['2ND LT'] = 'Second Lieutenant',
['GSC'] = 'General Staff Corps',
['2D LT'] = 'Second Lieutenant',
['ARMD F'] = 'Armored Force',
['2LT'] = 'Second Lieutenant',
['ARMD'] = 'Armored',
['SECOND LIEUTENANT'] = 'Second Lieutenant',
['ARMOR'] = 'Armor',
['1ST LT'] = 'First Lieutenant',
['SSF'] = 'Special Service Force',
['1LT'] = 'First Lieutenant',
['PA'] = 'Philippine Army',
['FIRST LIEUTENANT'] = 'First Lieutenant',
['RCE'] = 'Royal Canadian Engineers',
['LT'] = 'Lieutenant',
['RCA'] = 'Royal Canadian Artillery',
['LIEUTENANT'] = 'Lieutenant',
['CHC'] = 'Chaplain Corps',
['CAPT'] = 'Captain',
['CHP'] = 'Chaplain Corps',
['CPT'] = 'Captain',
['CAO'] = 'Civil Affairs',
['CAPTAIN'] = 'Captain',
['CAU'] = 'Civil Affairs',
['MAJ'] = 'Major',
}
['MAJOR'] = 'Major',
 
['LT COL'] = 'Lieutenant Colonel',
local function expandBranchToken(raw)
['LTCOL'] = 'Lieutenant Colonel',
['LTC'] = 'Lieutenant Colonel',
['LIEUTENANT COLONEL'] = 'Lieutenant Colonel',
['COL'] = 'Colonel',
['COLONEL'] = 'Colonel',
['BRIG GEN'] = 'Brigadier General',
['BG'] = 'Brigadier General',
['BRIGADIER GENERAL'] = 'Brigadier General',
['MAJ GEN'] = 'Major General',
['MG'] = 'Major General',
['MAJOR GENERAL'] = 'Major General',
['LT GEN'] = 'Lieutenant General',
['LIEUTENANT GENERAL'] = 'Lieutenant General',
['GEN'] = 'General',
['GENERAL'] = 'General',
['TECH'] = 'Technician',
['TEC'] = 'Technician',
}
 
local function expandRank(raw)
raw = trim(raw)
raw = trim(raw)
if raw == '' then
if raw == '' then
Line 1,129: Line 1,194:
key = mw.ustring.gsub(key, '%s+', ' ')
key = mw.ustring.gsub(key, '%s+', ' ')
key = trim(key)
key = trim(key)
if BRANCH_MAP[key] then
return RANK_MAP[key] or raw
return BRANCH_MAP[key]
end
end
 
local left, right = mw.ustring.match(key, '^(.+) %((.+)%)$')
local BRANCH_MAP = {
if left and right then
['INF'] = 'Infantry',
left = trim(left)
['INFANTRY'] = 'Infantry',
right = trim(right)
['FA'] = 'Field Artillery',
local leftExp = BRANCH_MAP[left] or left
['FIELD ARTILLERY'] = 'Field Artillery',
local rightExp = BRANCH_MAP[right] or right
['CAC'] = 'Coast Artillery Corps',
if BRANCH_MAP[left] or BRANCH_MAP[right] then
['CA'] = 'Coast Artillery',
return leftExp .. ' (' .. rightExp .. ')'
['COAST ARTILLERY'] = 'Coast Artillery',
end
['AC'] = 'Air Corps',
end
['AAF'] = 'Army Air Forces',
return raw
['AIR CORPS'] = 'Air Corps',
end
['QMC'] = 'Quartermaster Corps',
 
['QC'] = 'Quartermaster Corps',
-- Small public-domain / Commons emblems uploaded as File:Branch emblem *.png
['QUARTERMASTER'] = 'Quartermaster Corps',
local BRANCH_EMBLEM = {
['CAV'] = 'Cavalry',
['United States Army'] = 'Branch emblem US Army.png',
['CAVALRY'] = 'Cavalry',
['United States Marine Corps'] = 'Branch emblem US Marine Corps.png',
['CALVALRY'] = 'Cavalry',
['United States Navy'] = 'Branch emblem US Navy.png',
['CE'] = 'Corps of Engineers',
['United States Army Air Forces'] = 'Branch emblem US Army Air Forces.png',
['ENG'] = 'Corps of Engineers',
['United States Air Force'] = 'Branch emblem US Air Force.png',
['ENGINEERS'] = 'Corps of Engineers',
['United States Coast Guard'] = 'Branch emblem US Coast Guard.png',
['CMP'] = 'Corps of Military Police',
}
['MP'] = 'Military Police',
 
['CWS'] = 'Chemical Warfare Service',
local function branchWithEmblem(name)
['AUS'] = 'Army of the United States',
name = trim(name)
['SC'] = 'Signal Corps',
if name == '' then
['SIG'] = 'Signal Corps',
return ''
['SIG C'] = 'Signal Corps',
end
['SIGC'] = 'Signal Corps',
local file = BRANCH_EMBLEM[name]
['SIGNAL CORPS'] = 'Signal Corps',
if file then
['ORD'] = 'Ordnance Department',
return '[[File:' .. file .. '|18px|link=|alt=]] ' .. name
['ORDNANCE'] = 'Ordnance Department',
end
['MAC'] = 'Medical Administrative Corps',
-- Fuzzy contains
['MC'] = 'Medical Corps',
local low = mw.ustring.lower(name)
['MD'] = 'Medical Department',
if mw.ustring.find(low, 'marine', 1, true) then
['TC'] = 'Transportation Corps',
return '[[File:Branch emblem US Marine Corps.png|18px|link=|alt=]] ' .. name
['MI'] = 'Military Intelligence',
end
['MIS'] = 'Military Intelligence Service',
if mw.ustring.find(low, 'navy', 1, true) then
['INT'] = 'Intelligence',
return '[[File:Branch emblem US Navy.png|18px|link=|alt=]] ' .. name
['CIC'] = 'Counter Intelligence Corps',
end
['AGD'] = "Adjutant General's Department",
if mw.ustring.find(low, 'air force', 1, true) or mw.ustring.find(low, 'air forces', 1, true) then
['USMC'] = 'United States Marine Corps',
return '[[File:Branch emblem US Army Air Forces.png|18px|link=|alt=]] ' .. name
['USMCR'] = 'United States Marine Corps Reserve',
end
['AA'] = 'Antiaircraft',
if mw.ustring.find(low, 'coast guard', 1, true) then
['AAA'] = 'Antiaircraft Artillery',
return '[[File:Branch emblem US Coast Guard.png|18px|link=|alt=]] ' .. name
['TD'] = 'Tank Destroyer',
end
['FD'] = 'Finance Department',
if mw.ustring.find(low, 'army', 1, true) then
['GSC'] = 'General Staff Corps',
return '[[File:Branch emblem US Army.png|18px|link=|alt=]] ' .. name
['ARMD F'] = 'Armored Force',
end
['ARMD'] = 'Armored',
return name
['ARMOR'] = 'Armor',
end
['SSF'] = 'Special Service Force',
['PA'] = 'Philippine Army',
['RCE'] = 'Royal Canadian Engineers',
['RCA'] = 'Royal Canadian Artillery',
['CHC'] = 'Chaplain Corps',
['CHP'] = 'Chaplain Corps',
['CAO'] = 'Civil Affairs',
['CAU'] = 'Civil Affairs',
}


local function expandBranch(raw)
local function expandBranchToken(raw)
raw = trim(raw)
raw = trim(raw)
if raw == '' then
if raw == '' then
return ''
return ''
end
end
local parts = {}
local key = mw.ustring.upper(raw)
for part in mw.text.gsplit(raw, ';', true) do
key = mw.ustring.gsub(key, '%.', '')
part = expandBranchToken(part)
key = mw.ustring.gsub(key, '%s+', ' ')
if part ~= '' then
key = trim(key)
table.insert(parts, branchWithEmblem(part))
if BRANCH_MAP[key] then
return BRANCH_MAP[key]
end
local left, right = mw.ustring.match(key, '^(.+) %((.+)%)$')
if left and right then
left = trim(left)
right = trim(right)
local leftExp = BRANCH_MAP[left] or left
local rightExp = BRANCH_MAP[right] or right
if BRANCH_MAP[left] or BRANCH_MAP[right] then
return leftExp .. ' (' .. rightExp .. ')'
end
end
end
end
return table.concat(parts, '; ')
return raw
end
 
local function keyEqualsAAF(part)
local key = mw.ustring.upper(trim(part))
key = mw.ustring.gsub(key, '%.', '')
key = mw.ustring.gsub(key, '%s+', ' ')
return key == 'AAF' or key == 'ARMY AIR FORCES'
end
end


local function formatBattles(raw)
-- True when a BRANCH_MAP / class_branch value is an Army arm/service, not Army/Marines/Navy.
raw = trim(raw)
local SERVICE_BRANCH_KEY = {
if raw == '' then
['AUS'] = true,
return ''
['USA'] = true,
['ARMY'] = true,
['UNITED STATES ARMY'] = true,
['US ARMY'] = true,
['USMC'] = true,
['USMCR'] = true,
['MARINES'] = true,
['MARINE CORPS'] = true,
['UNITED STATES MARINE CORPS'] = true,
['USN'] = true,
['NAVY'] = true,
['UNITED STATES NAVY'] = true,
['USCG'] = true,
['COAST GUARD'] = true,
['UNITED STATES COAST GUARD'] = true,
['AAF'] = true,
['ARMY AIR FORCES'] = true,
['UNITED STATES ARMY AIR FORCES'] = true,
['PA'] = true,
['PHILIPPINE ARMY'] = true,
['RCE'] = true,
['RCA'] = true,
}
 
local function isServiceBranchName(name)
name = trim(name)
if name == '' then
return false
end
end
-- Allow HTML <br /> or semicolon / newline separated campaigns
local key = mw.ustring.upper(name)
raw = mw.ustring.gsub(raw, '\r', '')
key = mw.ustring.gsub(key, '%.', '')
if mw.ustring.find(raw, '<br', 1, true) or mw.ustring.find(raw, '\n', 1, true) then
key = mw.ustring.gsub(key, '%s+', ' ')
return raw
key = trim(key)
if SERVICE_BRANCH_KEY[key] then
return true
end
local low = mw.ustring.lower(name)
if mw.ustring.find(low, 'marine', 1, true)
or mw.ustring.find(low, 'navy', 1, true)
or mw.ustring.find(low, 'coast guard', 1, true) then
return true
end
end
local parts = {}
if mw.ustring.find(low, 'air force', 1, true)
for part in mw.text.gsplit(raw, ';', true) do
or mw.ustring.find(low, 'air forces', 1, true) then
part = trim(part)
return true
if part ~= '' then
table.insert(parts, part)
end
end
end
if #parts <= 1 then
if mw.ustring.find(low, 'united states army', 1, true)
return raw
or low == 'army'
or low == 'u.s. army'
or low == 'us army' then
return true
end
end
return table.concat(parts, '<br />')
return false
end
end


local function splitAwardRows(raw)
local function isArmOfServiceToken(raw)
raw = trim(raw)
raw = trim(raw)
if raw == '' then
if raw == '' then
return {}
return false
end
end
-- Prefer @@ row separator (safe inside {{Soldier}}); also accept legacy |||
local key = mw.ustring.upper(raw)
local sep = '@@'
key = mw.ustring.gsub(key, '%.', '')
if not mw.ustring.find(raw, '@@', 1, true) and mw.ustring.find(raw, '|||', 1, true) then
key = mw.ustring.gsub(key, '%s+', ' ')
sep = '|||'
key = trim(key)
if SERVICE_BRANCH_KEY[key] then
return false
end
if BRANCH_MAP[key] then
return true
end
local low = mw.ustring.lower(raw)
if mw.ustring.find(low, 'calvalry', 1, true) then
return true
end
end
local rows = {}
for _, needle in ipairs({
for row in mw.text.gsplit(raw, sep, true) do
'artillery', 'signal', 'cavalry', 'infantry', 'engineer',
row = trim(row)
'ordnance', 'quartermaster', 'medical', 'police', 'intelligence',
if row ~= '' then
'armor', 'tank destroyer', 'chemical', 'chaplain', 'transportation',
local cells = {}
'adjutant',
for cell in mw.text.gsplit(row, ';;', true) do
}) do
cell = trim(cell)
if mw.ustring.find(low, needle, 1, true) then
-- Unescape {{!}} used if a label ever needed a pipe
return true
cell = mw.ustring.gsub(cell, '{{!}}', '|')
if cell ~= '' then
table.insert(cells, cell)
end
end
if #cells > 0 then
table.insert(rows, cells)
end
end
end
end
end
return rows
return false
end
end


local function formatAwardsRibbons(raw, rowRaw)
local function splitBranchAndArm(branchRaw, classBranchRaw, armRaw)
-- Prefer explicit Wikipedia row layout when present
branchRaw = trim(branchRaw)
local rows = splitAwardRows(rowRaw or '')
classBranchRaw = trim(classBranchRaw)
if #rows == 0 then
armRaw = trim(armRaw)
 
local service = ''
local arms = {}
 
local function consider(raw, preferArm)
raw = trim(raw)
raw = trim(raw)
if raw == '' then
if raw == '' then
return ''
return
end
end
local files = {}
for part in mw.text.gsplit(raw, ';', true) do
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
part = trim(part)
part = mw.ustring.gsub(part, '^[Ff]ile:', '')
if part ~= '' then
if part ~= '' then
table.insert(files, part)
local expanded = expandBranchToken(part)
if isServiceBranchName(expanded) or isServiceBranchName(part) then
if service == '' then
service = expandBranchToken(part)
if not isServiceBranchName(service) then
-- expandBranchToken may not rewrite "Army"
local low = mw.ustring.lower(part)
if mw.ustring.find(low, 'marine', 1, true) then
service = 'United States Marine Corps'
elseif mw.ustring.find(low, 'navy', 1, true) then
service = 'United States Navy'
elseif mw.ustring.find(low, 'air force', 1, true)
or mw.ustring.find(low, 'air forces', 1, true)
or keyEqualsAAF(part) then
service = 'United States Army Air Forces'
else
service = 'United States Army'
end
end
end
elseif preferArm or isArmOfServiceToken(part) or isArmOfServiceToken(expanded) then
local armName = expanded
if mw.ustring.find(mw.ustring.lower(part), 'calvalry', 1, true) then
armName = 'Cavalry'
end
local dup = false
for _, existing in ipairs(arms) do
if existing == armName then
dup = true
break
end
end
if not dup then
table.insert(arms, armName)
end
elseif preferArm then
table.insert(arms, expanded)
elseif service == '' and not preferArm then
service = expanded
end
end
end
end
if #files == 0 then
return ''
end
-- Fallback: pack ~4 across
local perRow = 4
rows = {}
local cur = {}
for _, file in ipairs(files) do
table.insert(cur, file)
if #cur == perRow then
table.insert(rows, cur)
cur = {}
end
end
if #cur > 0 then
table.insert(rows, cur)
end
end
end
end
-- One table per row so short rows (e.g. 2 ribbons) center like full rows
 
local out = {}
-- Explicit arm_or_service first
for _, cells in ipairs(rows) do
consider(armRaw, true)
table.insert(out, '{| class="soldier-ribbon-rack"')
-- Roster class_branch is almost always arm/service within the Army
table.insert(out, '|-')
consider(classBranchRaw, true)
for _, file in ipairs(cells) do
-- |branch= may be either
file = mw.ustring.gsub(file, '^[Ff]ile:', '')
consider(branchRaw, false)
table.insert(out, '| [[File:' .. file .. '|106px|link=]]')
 
end
local armJoined = table.concat(arms, '; ')
table.insert(out, '|}')
return service, armJoined
end
return table.concat(out, '\n')
end
end


local function awardGridColspanUnits(rows)
-- Small public-domain / Commons emblems uploaded as File:Branch emblem *.png
-- LCM of row lengths so 2-cell and 3-cell rows share one table width
local BRANCH_EMBLEM = {
local function gcd(a, b)
['United States Army'] = 'Branch emblem US Army.png',
while b ~= 0 do
['United States Marine Corps'] = 'Branch emblem US Marine Corps.png',
a, b = b, a % b
['United States Navy'] = 'Branch emblem US Navy.png',
end
['United States Army Air Forces'] = 'Branch emblem US Army Air Forces.png',
return a
['United States Air Force'] = 'Branch emblem US Air Force.png',
['United States Coast Guard'] = 'Branch emblem US Coast Guard.png',
}
 
local function branchWithEmblem(name)
name = trim(name)
if name == '' then
return ''
end
end
local function lcm(a, b)
local file = BRANCH_EMBLEM[name]
if a == 0 or b == 0 then
if file then
return a + b
return '[[File:' .. file .. '|18px|link=|alt=]] ' .. name
end
return (a / gcd(a, b)) * b
end
end
local units = 0
-- Fuzzy contains
for _, cells in ipairs(rows) do
local low = mw.ustring.lower(name)
local n = #cells
if mw.ustring.find(low, 'marine', 1, true) then
if n > 0 then
return '[[File:Branch emblem US Marine Corps.png|18px|link=|alt=]] ' .. name
units = lcm(units, n)
end
end
if mw.ustring.find(low, 'navy', 1, true) then
return '[[File:Branch emblem US Navy.png|18px|link=|alt=]] ' .. name
end
if mw.ustring.find(low, 'air force', 1, true) or mw.ustring.find(low, 'air forces', 1, true) then
return '[[File:Branch emblem US Army Air Forces.png|18px|link=|alt=]] ' .. name
end
if mw.ustring.find(low, 'coast guard', 1, true) then
return '[[File:Branch emblem US Coast Guard.png|18px|link=|alt=]] ' .. name
end
if mw.ustring.find(low, 'army', 1, true) then
return '[[File:Branch emblem US Army.png|18px|link=|alt=]] ' .. name
end
end
return units > 0 and units or 1
return name
end
end


local function formatAwardsGrid(raw)
local function expandBranch(raw)
local rows = splitAwardRows(raw)
raw = trim(raw)
if #rows == 0 then
if raw == '' then
return ''
return ''
end
end
local units = awardGridColspanUnits(rows)
local parts = {}
local out = { '{| class="wikitable soldier-awards-grid"' }
for part in mw.text.gsplit(raw, ';', true) do
for _, cells in ipairs(rows) do
part = expandBranchToken(part)
local n = #cells
if part ~= '' then
if n == 0 then
table.insert(parts, branchWithEmblem(part))
-- skip
else
local span = units / n
table.insert(out, '|-')
for _, cell in ipairs(cells) do
-- Allow <br /> from Wikipedia-style multi-line labels
table.insert(out, '| colspan="' .. tostring(span) .. '" | ' .. cell)
end
end
end
end
end
table.insert(out, '|}')
return table.concat(parts, '; ')
return table.concat(out, '\n')
end
end


local function formatAwardsLegend(raw)
local function formatBattles(raw)
-- Do not run prettyProse/prettyPlace — commas in prose (e.g. statue note)
-- get misread as place names and Title-Cased / rewrapped.
raw = trim(raw)
raw = trim(raw)
if raw == '' then
if raw == '' then
return ''
return ''
end
-- Allow HTML <br /> or semicolon / newline separated campaigns
raw = mw.ustring.gsub(raw, '\r', '')
if mw.ustring.find(raw, '<br', 1, true) or mw.ustring.find(raw, '\n', 1, true) then
return raw
end
end
local parts = {}
local parts = {}
raw = mw.ustring.gsub(raw, '<br%s*/?>', ';')
for part in mw.text.gsplit(raw, ';', true) do
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
part = trim(part)
part = mw.ustring.gsub(part, '^%*%s*', '')
if part ~= '' then
if part ~= '' then
if isAllCapsPhrase(part) then
table.insert(parts, part)
part = prettyTitle(part, true)
end
table.insert(parts, '* ' .. part)
end
end
end
end
if #parts == 0 then
if #parts <= 1 then
return ''
return raw
end
end
return table.concat(parts, '\n')
return table.concat(parts, '<br />')
end
end


local function lifespan(birth, death)
local function splitAwardRows(raw)
birth = yearOnly(birth)
raw = trim(raw)
death = yearOnly(death)
if raw == '' then
if birth ~= '' and death ~= '' then
return {}
return ' (' .. birth .. '&ndash;' .. death .. ')'
end
end
if birth ~= '' then
-- Prefer @@ row separator (safe inside {{Soldier}}); also accept legacy |||
return ' (born ' .. birth .. ')'
local sep = '@@'
if not mw.ustring.find(raw, '@@', 1, true) and mw.ustring.find(raw, '|||', 1, true) then
sep = '|||'
end
end
if death ~= '' then
local rows = {}
return ' (died ' .. death .. ')'
for row in mw.text.gsplit(raw, sep, true) do
end
row = trim(row)
return ''
if row ~= '' then
local cells = {}
for cell in mw.text.gsplit(row, ';;', true) do
cell = trim(cell)
-- Unescape {{!}} used if a label ever needed a pipe
cell = mw.ustring.gsub(cell, '{{!}}', '|')
if cell ~= '' then
table.insert(cells, cell)
end
end
if #cells > 0 then
table.insert(rows, cells)
end
end
end
return rows
end
end


local function preferredRank(frame)
local function awardRibbonRows(raw, rowRaw)
local rank = expandRank(parentArg(frame, 'rank_final'))
-- Prefer explicit Wikipedia row layout when present
if rank == '' then
local rows = splitAwardRows(rowRaw or '')
rank = expandRank(parentArg(frame, 'class_rank'))
if #rows > 0 then
return rows
end
end
return rank
raw = trim(raw)
end
if raw == '' then
 
return {}
function p.displayName(frame)
return displayName(frame)
end
 
local function displayNameFrom(getArg)
local first = prettyNamePart(getArg('first'))
local middle = prettyNamePart(getArg('middle'))
local surname = prettyNamePart(getArg('surname'))
local suffix = prettyNamePart(getArg('suffix'))
local parts = {}
if first ~= '' then
table.insert(parts, first)
end
end
if middle ~= '' then
local files = {}
table.insert(parts, middle)
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '^[Ff]ile:', '')
if part ~= '' then
table.insert(files, part)
end
end
end
if surname ~= '' then
if #files == 0 then
table.insert(parts, surname)
return {}
end
end
local name = table.concat(parts, ' ')
-- Fallback: pack ~4 across
if suffix ~= '' then
local perRow = 4
if name ~= '' then
rows = {}
name = name .. ', ' .. suffix
local cur = {}
else
for _, file in ipairs(files) do
name = suffix
table.insert(cur, file)
if #cur == perRow then
table.insert(rows, cur)
cur = {}
end
end
end
end
if name == '' then
if #cur > 0 then
name = 'This soldier'
table.insert(rows, cur)
end
end
return name
return rows
end
end


local function preferredRankFrom(getArg)
local function awardRowsMatch(ribbonRows, labelRows)
local rank = expandRank(getArg('rank_final'))
if #ribbonRows == 0 or #ribbonRows ~= #labelRows then
if rank == '' then
return false
rank = expandRank(getArg('class_rank'))
end
for i, cells in ipairs(ribbonRows) do
if #cells ~= #(labelRows[i] or {}) then
return false
end
end
end
return rank
return true
end
end


local function buildLead(getArg)
local function formatAwardsRibbons(raw, rowRaw, labelRaw)
local name = displayNameFrom(getArg)
local rows = awardRibbonRows(raw, rowRaw)
local rank = preferredRankFrom(getArg)
if #rows == 0 then
local role = 'United States Army soldier'
return '', false
if rank ~= '' then
role = 'United States Army ' .. rank
end
end
 
local labelRows = splitAwardRows(labelRaw or '')
local classLinks = classLinkList(getArg('class_number'), true)
local labeled = awardRowsMatch(rows, labelRows)
local trained
-- One table per row so short rows (e.g. 2 ribbons) center like full rows.
if #classLinks == 1 then
-- When labels match ribbon cells, put captions in the same table so each
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1]
-- name sits centered under its ribbon (equal column widths).
elseif #classLinks == 2 then
local out = {}
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1] .. ' and ' .. classLinks[2]
for i, cells in ipairs(rows) do
elseif #classLinks > 2 then
if labeled then
trained = ' who trained at [[Camp Ritchie]], Maryland, in '
table.insert(out, '{| class="soldier-ribbon-rack soldier-awards-labeled"')
.. table.concat(classLinks, ', ', 1, #classLinks - 1)
else
.. ', and ' .. classLinks[#classLinks]
table.insert(out, '{| class="soldier-ribbon-rack"')
else
end
trained = ' associated with Camp Ritchie, Maryland'
table.insert(out, '|-')
for _, file in ipairs(cells) do
file = mw.ustring.gsub(file, '^[Ff]ile:', '')
table.insert(out, '| [[File:' .. file .. '|106px|link=]]')
end
if labeled then
table.insert(out, '|-')
for _, label in ipairs(labelRows[i]) do
table.insert(out, '| class="soldier-award-caption" | ' .. label)
end
end
table.insert(out, '|}')
end
end
return table.concat(out, '\n'), labeled
end


local lead = string.format(
local function awardGridColspanUnits(rows)
"'''%s'''%s was a %s%s.",
-- LCM of row lengths so 2-cell and 3-cell rows share one table width
name,
local function gcd(a, b)
lifespan(getArg('birth_year'), getArg('death_year')),
while b ~= 0 do
role,
a, b = b, a % b
trained
end
)
return a
local who = prettyNamePart(getArg('first'))
if who == '' then
who = prettyNamePart(getArg('surname'))
end
end
if who == '' then
local function lcm(a, b)
who = 'He'
if a == 0 or b == 0 then
return a + b
end
return (a / gcd(a, b)) * b
end
end
local enlist = prettyPlace(getArg('enlistment_place'))
local units = 0
if enlist ~= '' then
for _, cells in ipairs(rows) do
lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.'
local n = #cells
if n > 0 then
units = lcm(units, n)
end
end
end
return lead
return units > 0 and units or 1
end
end


function p.classLinks(frame)
local function formatAwardsGrid(raw)
return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
local rows = splitAwardRows(raw)
end
if #rows == 0 then
 
return ''
local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' }
end
 
local units = awardGridColspanUnits(rows)
local function photoFileExists(filename)
local out = { '{| class="wikitable soldier-awards-grid"' }
filename = trim(filename)
for _, cells in ipairs(rows) do
if filename == '' then
local n = #cells
return false
if n == 0 then
-- skip
else
local span = units / n
table.insert(out, '|-')
for _, cell in ipairs(cells) do
-- Allow <br /> from Wikipedia-style multi-line labels
table.insert(out, '| colspan="' .. tostring(span) .. '" | ' .. cell)
end
end
end
end
local title = mw.title.new('File:' .. filename)
table.insert(out, '|}')
return title ~= nil and title.exists
return table.concat(out, '\n')
end
end


local function pushPhotoCandidate(candidates, seen, value)
local function formatAwardsLegend(raw)
value = trim(value)
-- Do not run prettyProse/prettyPlace — commas in prose (e.g. statue note)
if value == '' or seen[value] then
-- get misread as place names and Title-Cased / rewrapped.
return
raw = trim(raw)
if raw == '' then
return ''
end
local parts = {}
raw = mw.ustring.gsub(raw, '<br%s*/?>', ';')
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '^%*%s*', '')
if part ~= '' then
if isAllCapsPhrase(part) then
part = prettyTitle(part, true)
end
table.insert(parts, '* ' .. part)
end
end
if #parts == 0 then
return ''
end
end
seen[value] = true
return table.concat(parts, '\n')
table.insert(candidates, value)
end
end


local function resolvePhoto(frame)
local function lifespan(birth, death)
local explicit = trim(parentArg(frame, 'photo'))
birth = trim(birth or '')
if explicit ~= '' then
death = trim(death or '')
return explicit
if birth ~= '' and death ~= '' then
return ' (' .. birth .. ' &ndash; ' .. death .. ')'
end
if birth ~= '' then
return ' (born ' .. birth .. ')'
end
if death ~= '' then
return ' (died ' .. death .. ')'
end
end
return ''
end


local first = trim(parentArg(frame, 'first'))
--- Prefer full birth/death dates (Wikipedia style); fall back to years.
local surname = trim(parentArg(frame, 'surname'))
local function lifespanFromArgs(getArg)
local asn = trim(parentArg(frame, 'asn'))
local birth = prettyProse(getArg('birth_date'))
if first == '' or surname == '' then
local death = prettyProse(getArg('death_date'))
return ''
if birth == '' then
birth = yearOnly(getArg('birth_year'))
end
if death == '' then
death = yearOnly(getArg('death_year'))
end
end
return lifespan(birth, death)
end


local candidates = {}
local function preferredRank(frame)
local seen = {}
local rank = expandRank(parentArg(frame, 'rank_final'))
local lowerFirst = mw.ustring.lower(first)
if rank == '' then
local lowerSurname = mw.ustring.lower(surname)
rank = expandRank(parentArg(frame, 'class_rank'))
local bases = {
end
first .. ' ' .. surname,
return rank
first .. ' ' .. lowerSurname,
end
lowerFirst .. ' ' .. lowerSurname,
 
first .. '_' .. surname,
function p.displayName(frame)
first .. '_' .. lowerSurname,
return displayName(frame)
lowerFirst .. '_' .. lowerSurname,
end
surname .. ', ' .. first,
 
surname .. '_' .. first,
local function displayNameFrom(getArg)
}
local first = prettyNamePart(getArg('first'))
if asn ~= '' then
local middle = prettyNamePart(getArg('middle'))
table.insert(bases, surname .. ', ' .. first .. ' (' .. asn .. ')')
local surname = prettyNamePart(getArg('surname'))
local suffix = prettyNamePart(getArg('suffix'))
local parts = {}
if first ~= '' then
table.insert(parts, first)
end
end
for _, base in ipairs(bases) do
if middle ~= '' then
pushPhotoCandidate(candidates, seen, base)
table.insert(parts, middle)
end
end
 
if surname ~= '' then
for _, base in ipairs(candidates) do
table.insert(parts, surname)
for _, ext in ipairs(PHOTO_EXTENSIONS) do
end
local filename = base .. '.' .. ext
local name = table.concat(parts, ' ')
if photoFileExists(filename) then
if suffix ~= '' then
return filename
if name ~= '' then
end
name = name .. ', ' .. suffix
local upperExt = mw.ustring.upper(ext)
else
if upperExt ~= ext then
name = suffix
filename = base .. '.' .. upperExt
if photoFileExists(filename) then
return filename
end
end
end
end
end
end
return ''
if name == '' then
name = 'This soldier'
end
return name
end
end


function p.infobox(frame)
local function preferredRankFrom(getArg)
local name = displayName(frame)
local rank = expandRank(getArg('rank_final'))
local tbl = mw.html.create('table')
if rank == '' then
tbl:addClass('infobox'):addClass('vcard'):addClass('soldier-infobox')
rank = expandRank(getArg('class_rank'))
end
return rank
end


tbl:tag('tr'):tag('th')
local function buildLead(getArg)
:attr('colspan', '2')
local name = displayNameFrom(getArg)
:addClass('infobox-above fn')
local rank = preferredRankFrom(getArg)
:wikitext(name)
local role = 'United States Army soldier'
if rank ~= '' then
role = 'United States Army ' .. rank
end


local photo = resolvePhoto(frame)
local classLinks = classLinkList(getArg('class_number'), true)
if photo ~= '' then
local trained
local size = parentArg(frame, 'image_size')
if #classLinks == 1 then
if size == '' then
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1]
size = '220px'
elseif #classLinks == 2 then
end
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1] .. ' and ' .. classLinks[2]
local alt = parentArg(frame, 'alt')
elseif #classLinks > 2 then
if alt == '' then
trained = ' who trained at [[Camp Ritchie]], Maryland, in '
alt = name
.. table.concat(classLinks, ', ', 1, #classLinks - 1)
end
.. ', and ' .. classLinks[#classLinks]
local td = tbl:tag('tr'):tag('td')
else
:attr('colspan', '2')
trained = ' associated with Camp Ritchie, Maryland'
:addClass('infobox-image')
td:wikitext(string.format('[[File:%s|%s|alt=%s]]', photo, size, alt))
local caption = parentArg(frame, 'caption')
if caption ~= '' then
td:tag('div'):addClass('infobox-caption'):wikitext(caption)
end
end
end


local alias = prettyNamePart(parentArg(frame, 'alias'))
local lead = string.format(
local birthYear = yearOnly(parentArg(frame, 'birth_year'))
"'''%s'''%s was a %s%s.",
local deathYear = yearOnly(parentArg(frame, 'death_year'))
name,
local birthDate = prettyProse(parentArg(frame, 'birth_date'))
lifespanFromArgs(getArg),
local deathDate = prettyProse(parentArg(frame, 'death_date'))
role,
local birthPlace = prettyProse(parentArg(frame, 'birth_place'))
trained
local deathPlace = prettyProse(parentArg(frame, 'death_place'))
)
local nativity = expandNativity(parentArg(frame, 'nativity'))
local who = prettyNamePart(getArg('first'))
local religion = expandReligion(parentArg(frame, 'religion'))
if who == '' then
local marital = prettyProse(parentArg(frame, 'marital'))
who = prettyNamePart(getArg('surname'))
local education = prettyProse(parentArg(frame, 'education'))
end
local occupation = prettyProse(parentArg(frame, 'occupation'))
if who == '' then
local knownFor = prettyProse(parentArg(frame, 'known_for'))
who = 'He'
local awards = prettyProse(parentArg(frame, 'awards'))
end
local ribbonsRaw = parentArg(frame, 'awards_ribbons')
local enlist = prettyPlace(getArg('enlistment_place'))
-- Full medal list belongs under the body ribbon rack; keep infobox short
if enlist ~= '' then
if ribbonsRaw ~= '' and mw.ustring.len(awards) > 90 then
lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.'
local first = mw.ustring.match(awards, '^([^;]+)')
awards = trim(first or awards)
end
end
local state = prettyPlace(parentArg(frame, 'state'))
return lead
end


local function datePlaceCell(date, place, year)
function p.classLinks(frame)
local bits = {}
return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
if date ~= '' then
end
table.insert(bits, date)
elseif year ~= '' then
table.insert(bits, year)
end
if place ~= '' then
table.insert(bits, place)
end
return table.concat(bits, '<br />')
end


local birth = datePlaceCell(birthDate, birthPlace, birthYear)
local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' }
local death = datePlaceCell(deathDate, deathPlace, deathYear)


if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= ''
local function photoFileExists(filename)
or religion ~= '' or marital ~= '' or education ~= ''
filename = trim(filename)
or occupation ~= '' or knownFor ~= '' or awards ~= '' or state ~= '' then
if filename == '' then
addHeader(tbl, 'Personal details')
return false
end
end
local title = mw.title.new('File:' .. filename)
return title ~= nil and title.exists
end


addRow(tbl, 'Birth name', alias, 'nickname')
local function pushPhotoCandidate(candidates, seen, value)
addRow(tbl, 'Born', birth)
value = trim(value)
addRow(tbl, 'Died', death)
if value == '' or seen[value] then
addRow(tbl, 'Nativity', nativity)
return
addRow(tbl, 'Religion', religion)
addRow(tbl, 'Spouse / marital', marital)
addRow(tbl, 'Education', education)
addRow(tbl, 'Occupation', occupation)
addRow(tbl, 'Known for', knownFor)
addRow(tbl, 'Awards', awards)
if state ~= '' then
addRow(tbl, 'State', state)
end
end
seen[value] = true
table.insert(candidates, value)
end


addHeader(tbl, 'Military service')
local function resolvePhoto(frame)
addRow(tbl, 'Allegiance', '[[File:Flag of the United States.png|20px|link=|alt=]] United States')
local explicit = trim(parentArg(frame, 'photo'))
local branchRaw = parentArg(frame, 'class_branch')
if explicit ~= '' then
if branchRaw == '' then
return explicit
branchRaw = parentArg(frame, 'branch')
end
end
local branch = expandBranch(branchRaw)
 
if branch == '' then
local first = trim(parentArg(frame, 'first'))
branch = branchWithEmblem('United States Army')
local surname = trim(parentArg(frame, 'surname'))
local asn = trim(parentArg(frame, 'asn'))
if first == '' or surname == '' then
return ''
end
end
addRow(tbl, 'Branch', branch)
addRow(tbl, 'Years of service', prettyProse(parentArg(frame, 'years_of_service')))
addRow(tbl, 'ASN', parentArg(frame, 'asn'), 'serial')


local rankFinal = expandRank(parentArg(frame, 'rank_final'))
local candidates = {}
local classRank = expandRank(parentArg(frame, 'class_rank'))
local seen = {}
if rankFinal ~= '' and classRank ~= '' and classRank ~= rankFinal then
local lowerFirst = mw.ustring.lower(first)
addRow(tbl, 'Rank', rankFinal)
local lowerSurname = mw.ustring.lower(surname)
addRow(tbl, 'Rank at Ritchie', classRank)
local bases = {
else
first .. ' ' .. surname,
local rank = rankFinal
first .. ' ' .. lowerSurname,
if rank == '' then
lowerFirst .. ' ' .. lowerSurname,
rank = classRank
first .. '_' .. surname,
end
first .. '_' .. lowerSurname,
addRow(tbl, 'Rank', rank)
lowerFirst .. '_' .. lowerSurname,
surname .. ', ' .. first,
surname .. '_' .. first,
}
if asn ~= '' then
table.insert(bases, surname .. ', ' .. first .. ' (' .. asn .. ')')
end
end
 
for _, base in ipairs(bases) do
local classRaw = parentArg(frame, 'class_number')
pushPhotoCandidate(candidates, seen, base)
local classBits = classLinkList(classRaw, false)
if #classBits > 0 then
addRow(tbl, 'Camp Ritchie', 'Class ' .. table.concat(classBits, ', '))
end
end
addRow(tbl, 'Section', parentArg(frame, 'class_section'))
addRow(tbl, 'Other class', parentArg(frame, 'other_class'))


local enlist = prettyPlace(parentArg(frame, 'enlistment_place'))
for _, base in ipairs(candidates) do
if enlist ~= '' then
for _, ext in ipairs(PHOTO_EXTENSIONS) do
addRow(tbl, 'Enlisted', enlist)
local filename = base .. '.' .. ext
if photoFileExists(filename) then
return filename
end
local upperExt = mw.ustring.upper(ext)
if upperExt ~= ext then
filename = base .. '.' .. upperExt
if photoFileExists(filename) then
return filename
end
end
end
end
end
addRow(tbl, 'Specialty', prettyProse(parentArg(frame, 'specialty')))
return ''
addRow(tbl, 'Unit / division', prettyProse(parentArg(frame, 'division')))
end
addRow(tbl, 'Battles / wars', formatBattles(parentArg(frame, 'battles')))
addRow(tbl, 'IPW (German)', teamLinkList(parentArg(frame, 'ipw_ge'), 'IPW ', 'IPW'))
addRow(tbl, 'IPW (Italian)', teamLinkList(parentArg(frame, 'ipw_it'), 'IPW Italian ', 'IPW Italian'))
addRow(tbl, 'Arrived', parentArg(frame, 'date_arrive'))
addRow(tbl, 'Departed', parentArg(frame, 'date_depart'))
addRow(tbl, 'Certificate', realCertificate(parentArg(frame, 'certificate')))


-- Awards ribbons render in the article body (p.awardsSection), not this
-- Filled after noteTeamNames (local forward declare; used by p.infobox).
-- narrow infobox — matching Wikipedia's main-column ribbon racks.
local addTeamsInfoboxRow


return tostring(tbl)
function p.infobox(frame)
end
local name = displayName(frame)
local tbl = mw.html.create('table')
tbl:addClass('infobox'):addClass('vcard'):addClass('soldier-infobox')


function p.awardsSection(frame)
tbl:tag('tr'):tag('th')
local ribbons = formatAwardsRibbons(
:attr('colspan', '2')
parentArg(frame, 'awards_ribbons'),
:addClass('infobox-above fn')
parentArg(frame, 'awards_ribbon_rows')
:wikitext(name)
)
 
local grid = formatAwardsGrid(parentArg(frame, 'awards_grid'))
local photo = resolvePhoto(frame)
local extras = formatAwardsLegend(parentArg(frame, 'awards_extra'))
if photo ~= '' then
-- Fallback: flat awards list when no grid was extracted
local size = parentArg(frame, 'image_size')
if grid == '' and extras == '' then
if size == '' then
extras = formatAwardsLegend(parentArg(frame, 'awards'))
size = '220px'
end
local alt = parentArg(frame, 'alt')
if alt == '' then
alt = name
end
local td = tbl:tag('tr'):tag('td')
:attr('colspan', '2')
:addClass('infobox-image')
td:wikitext(string.format('[[File:%s|%s|alt=%s]]', photo, size, alt))
local caption = parentArg(frame, 'caption')
if caption ~= '' then
td:tag('div'):addClass('infobox-caption'):wikitext(caption)
end
end
end
if ribbons == '' and grid == '' and extras == '' then
 
return ''
local alias = prettyNamePart(parentArg(frame, 'alias'))
local birthYear = yearOnly(parentArg(frame, 'birth_year'))
local deathYear = yearOnly(parentArg(frame, 'death_year'))
local birthDate = prettyProse(parentArg(frame, 'birth_date'))
local deathDate = prettyProse(parentArg(frame, 'death_date'))
local birthPlace = prettyProse(parentArg(frame, 'birth_place'))
local deathPlace = prettyProse(parentArg(frame, 'death_place'))
local nativity = expandNativity(parentArg(frame, 'nativity'))
local religion = expandReligion(parentArg(frame, 'religion'))
-- education / marital / occupation stay in Technical record (NARA snapshot);
-- curated life facts belong in Biography / Timeline, not the infobox.
local knownFor = prettyProse(parentArg(frame, 'known_for'))
local awards = prettyProse(parentArg(frame, 'awards'))
local ribbonsRaw = parentArg(frame, 'awards_ribbons')
-- Full medal list belongs under the body ribbon rack; keep infobox short
if ribbonsRaw ~= '' and mw.ustring.len(awards) > 90 then
local first = mw.ustring.match(awards, '^([^;]+)')
awards = trim(first or awards)
end
end
-- Use a styled div heading (not == wiki heading) so MediaWiki does not place
 
-- the Contents box inside this block — JS moves the block after Biography.
local function datePlaceCell(date, place, year)
local parts = {
local bits = {}
'<div class="soldier-awards-section">',
if date ~= '' then
'<div class="soldier-awards-heading">Awards and honors</div>',
table.insert(bits, date)
}
elseif year ~= '' then
if ribbons ~= '' then
table.insert(bits, year)
table.insert(parts, ribbons)
end
if place ~= '' then
table.insert(bits, place)
end
return table.concat(bits, '<br />')
end
end
if grid ~= '' then
 
table.insert(parts, grid)
local birth = datePlaceCell(birthDate, birthPlace, birthYear)
end
local death = datePlaceCell(deathDate, deathPlace, deathYear)
if extras ~= '' then
 
table.insert(parts, '<div class="soldier-awards-list">')
if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= ''
table.insert(parts, extras)
or religion ~= '' or knownFor ~= '' or awards ~= '' then
table.insert(parts, '</div>')
addHeader(tbl, 'Personal details')
end
end
table.insert(parts, '</div>')
return table.concat(parts, '\n')
end


function p.lead(frame)
addRow(tbl, 'Birth name', alias, 'nickname')
return buildLead(function(key)
addRow(tbl, 'Born', birth)
return parentArg(frame, key)
addRow(tbl, 'Died', death)
end)
addRow(tbl, 'Nativity', nativity)
end
addRow(tbl, 'Religion', religion)
addRow(tbl, 'Known for', knownFor)
addRow(tbl, 'Awards', awards)


function p.leadFrom(frame)
addHeader(tbl, 'Military service')
return buildLead(function(key)
addRow(tbl, 'Allegiance', '[[File:Flag of the United States.png|20px|link=|alt=]] United States')
return trim(frame.args[key] or '')
local serviceName, armName = splitBranchAndArm(
end)
parentArg(frame, 'branch'),
end
parentArg(frame, 'class_branch'),
 
parentArg(frame, 'arm_or_service')
local function researchNotesSection(content)
)
if not content or content == '' then
if serviceName == '' then
return ''
serviceName = 'United States Army'
end
end
local startPos = mw.ustring.find(content, '== Research notes ==', 1, true)
addRow(tbl, 'Branch', branchWithEmblem(serviceName))
if not startPos then
if armName ~= '' then
return ''
-- Roster "branch" codes (FA, Signal Corps, Cavalry, …) are arm/service, not Army/Marines.
addRow(tbl, 'Arm / service', prettyProse(armName))
end
end
local after = mw.ustring.sub(content, startPos + 20)
-- years_of_service / date_arrive / date_depart / certificate stay in Technical
local nextHead = mw.ustring.find(after, '\n== ', 1, true)
-- record only (rarely curated; certificate is a cryptic chart number).
if nextHead then
addRow(tbl, 'ASN', parentArg(frame, 'asn'), 'serial')
return mw.ustring.sub(after, 1, nextHead - 1)
 
local rankFinal = expandRank(parentArg(frame, 'rank_final'))
local classRank = expandRank(parentArg(frame, 'class_rank'))
if rankFinal ~= '' and classRank ~= '' and classRank ~= rankFinal then
addRow(tbl, 'Rank', rankFinal)
addRow(tbl, 'Rank at Ritchie', classRank)
else
local rank = rankFinal
if rank == '' then
rank = classRank
end
addRow(tbl, 'Rank', rank)
end
end
return after
end


local function miiPreferredLetter(num)
local classRaw = parentArg(frame, 'class_number')
local ok, canon = pcall(require, 'Module:TeamCanon')
local classBits = classLinkList(classRaw, false)
if ok and type(canon) == 'table' and type(canon.letter) == 'table' then
if #classBits > 0 then
return canon.letter[tostring(num)]
addRow(tbl, 'Camp Ritchie', 'Class ' .. table.concat(classBits, ', '))
end
end
return nil
addRow(tbl, 'Section', mw.ustring.upper(parentArg(frame, 'class_section')))
end
addRow(tbl, 'Other class', expandOtherClass(parentArg(frame, 'other_class')))


local function noteTeamNames(text)
local enlist = prettyPlace(parentArg(frame, 'enlistment_place'))
text = mw.ustring.upper(tostring(text or ''))
if enlist ~= '' then
text = mw.ustring.gsub(text, mw.ustring.char(0x2013), '-')
addRow(tbl, 'Enlisted', enlist)
text = mw.ustring.gsub(text, mw.ustring.char(0x2014), '-')
text = mw.ustring.gsub(text, mw.ustring.char(0x2212), '-')
local seen = {}
local names = {}
local letteredNums = {}
local function add(name)
if name ~= '' and not seen[name] then
seen[name] = true
table.insert(names, name)
end
end
end
local work = text
addRow(tbl, 'Specialty', prettyProse(parentArg(frame, 'specialty')))
for num, letter in string.gmatch(work, 'MII[%s%-]+(%d+)%s*%-%s*([A-Z])') do
addRow(tbl, 'Unit / division', prettyProse(parentArg(frame, 'division')))
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
addRow(tbl, 'Battles / wars', formatBattles(parentArg(frame, 'battles')))
add('MII ' .. num)
addTeamsInfoboxRow(tbl, frame)
else
 
add('MII ' .. num .. '-' .. letter)
-- Awards ribbons render in the article body (p.awardsSection), not this
letteredNums[num] = true
-- narrow infobox — matching Wikipedia's main-column ribbon racks.
end
 
end
return tostring(tbl)
work = string.gsub(work, 'MII[%s%-]+%d+%s*%-%s*[A-Z]', ' ')
end
for num, letter in string.gmatch(' ' .. text .. ' ', '[^%d]([45]%d%d)%s*%-%s*([A-Z])[^A-Z]') do
 
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
function p.awardsSection(frame)
add('MII ' .. num)
local gridRaw = parentArg(frame, 'awards_grid')
else
local ribbons, labelsInRack = formatAwardsRibbons(
add('MII ' .. num .. '-' .. letter)
parentArg(frame, 'awards_ribbons'),
letteredNums[num] = true
parentArg(frame, 'awards_ribbon_rows'),
end
gridRaw
)
-- Skip the separate label table when captions already sit under ribbons
local grid = ''
if not labelsInRack then
grid = formatAwardsGrid(gridRaw)
end
local extras = formatAwardsLegend(parentArg(frame, 'awards_extra'))
-- Fallback: flat awards list when no grid was extracted
if grid == '' and extras == '' and not labelsInRack then
extras = formatAwardsLegend(parentArg(frame, 'awards'))
end
end
for num in string.gmatch(work, 'MII[%s%-]+(%d+)') do
if ribbons == '' and grid == '' and extras == '' then
if not letteredNums[num] then
return ''
local preferred = miiPreferredLetter(num)
if preferred then
add('MII ' .. num .. '-' .. preferred)
else
add('MII ' .. num)
end
end
end
end
for num, letter in string.gmatch(text, 'IPW%s+(%d+)%s*%-%s*([A-Z])') do
-- Use a styled div heading (not == wiki heading) so MediaWiki does not place
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
-- the Contents box inside this block — JS moves the block after Biography.
add('MII ' .. num)
local parts = {
else
'<div class="soldier-awards-section">',
add('MII ' .. num .. '-' .. letter)
'<div class="soldier-awards-heading">Awards and honors</div>',
end
}
if ribbons ~= '' then
table.insert(parts, ribbons)
end
if grid ~= '' then
table.insert(parts, grid)
end
end
for num in string.gmatch(text, 'IPW%s+(%d+)[Tt]?%s*%??%f[^%a%d%-]') do
if extras ~= '' then
add('IPW ' .. num)
table.insert(parts, '<div class="soldier-awards-list">')
table.insert(parts, extras)
table.insert(parts, '</div>')
end
end
return names
table.insert(parts, '</div>')
return table.concat(parts, '\n')
end
end


function p.noteTeamCategories(frame)
function p.lead(frame)
local title = mw.title.getCurrentTitle()
return buildLead(function(key)
if not title or not title.getContent then
return parentArg(frame, key)
end)
end
 
function p.leadFrom(frame)
return buildLead(function(key)
return trim(frame.args[key] or '')
end)
end
 
local function researchNotesSection(content)
if not content or content == '' then
return ''
return ''
end
end
local ok, content = pcall(function()
local startPos = mw.ustring.find(content, '== Research notes ==', 1, true)
return title:getContent()
if not startPos then
end)
if not ok or not content then
return ''
return ''
end
end
local bits = {}
local after = mw.ustring.sub(content, startPos + 20)
for _, name in ipairs(noteTeamNames(researchNotesSection(content))) do
local nextHead = mw.ustring.find(after, '\n== ', 1, true)
table.insert(bits, '[[Category:Team ' .. name .. ']]')
if nextHead then
return mw.ustring.sub(after, 1, nextHead - 1)
end
end
return table.concat(bits)
return after
end
end


function p.researchNotes(frame)
local function miiPreferredLetter(num)
local title = mw.title.getCurrentTitle()
local ok, canon = pcall(require, 'Module:TeamCanon')
if not title or not title.getContent then
if ok and type(canon) == 'table' and type(canon.letter) == 'table' then
return ''
return canon.letter[tostring(num)]
end
end
local ok, content = pcall(function()
return nil
return title:getContent()
end
end)
 
if not ok or not content or content == '' then
local function noteTeamNames(text)
return ''
text = mw.ustring.upper(tostring(text or ''))
text = mw.ustring.gsub(text, mw.ustring.char(0x2013), '-')
text = mw.ustring.gsub(text, mw.ustring.char(0x2014), '-')
text = mw.ustring.gsub(text, mw.ustring.char(0x2212), '-')
local seen = {}
local names = {}
local letteredNums = {}
local function add(name)
if name ~= '' and not seen[name] then
seen[name] = true
table.insert(names, name)
end
end
end
local section = researchNotesSection(content)
local work = text
if section == '' then
for num, letter in string.gmatch(work, 'MII[%s%-]+(%d+)%s*%-%s*([A-Z])') do
return ''
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
add('MII ' .. num)
else
add('MII ' .. num .. '-' .. letter)
letteredNums[num] = true
end
end
work = string.gsub(work, 'MII[%s%-]+%d+%s*%-%s*[A-Z]', ' ')
for num, letter in string.gmatch(' ' .. text .. ' ', '[^%d]([45]%d%d)%s*%-%s*([A-Z])[^A-Z]') do
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
add('MII ' .. num)
else
add('MII ' .. num .. '-' .. letter)
letteredNums[num] = true
end
end
for num in string.gmatch(work, 'MII[%s%-]+(%d+)') do
if not letteredNums[num] then
local preferred = miiPreferredLetter(num)
if preferred then
add('MII ' .. num .. '-' .. preferred)
else
add('MII ' .. num)
end
end
end
end
local items = {}
for num, letter in string.gmatch(text, 'IPW%s+(%d+)%s*%-%s*([A-Z])') do
for line in mw.text.gsplit(section, '\n', true) do
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
line = trim(line)
add('MII ' .. num)
else
add('MII ' .. num .. '-' .. letter)
end
end
for num in string.gmatch(text, 'IPW%s+(%d+)[Tt]?%s*%??%f[^%a%d%-]') do
add('IPW ' .. num)
end
return names
end
 
local function appendUniqueTeamName(names, seen, name)
name = trim(tostring(name or ''))
if name == '' or seen[name] then
return
end
seen[name] = true
table.insert(names, name)
end
 
local function namesFromIpwGerman(raw)
local names = {}
local seen = {}
for part in mw.text.gsplit(raw or '', ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
part = trim(part)
if part ~= '' then
local code, shown, mii = normalizeIpwTeamCode(part)
if mii then
appendUniqueTeamName(names, seen, 'MII ' .. mii)
elseif code then
appendUniqueTeamName(names, seen, 'IPW ' .. shown)
end
end
end
return names
end
 
local function namesFromIpwItalian(raw)
local names = {}
local seen = {}
for part in mw.text.gsplit(raw or '', ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
part = trim(part)
if part ~= '' then
local code = normalizeIpwItalianCode(part)
if code then
appendUniqueTeamName(names, seen, 'IPW Italian ' .. code)
end
end
end
return names
end
 
--- Explicit |teams= values: "MII 439-G; IPW 12" or shorthand "439-G".
local function namesFromTeamsParam(raw)
local names = {}
local seen = {}
for part in mw.text.gsplit(raw or '', ';', true) do
part = trim(part)
if part ~= '' then
local fromScan = noteTeamNames(part)
if #fromScan > 0 then
for _, name in ipairs(fromScan) do
appendUniqueTeamName(names, seen, name)
end
else
local upper = mw.ustring.upper(part)
local itRest = mw.ustring.match(upper, '^IPW%s+ITALIAN%s+(.+)$')
if itRest then
local code = normalizeIpwItalianCode(itRest)
if code then
appendUniqueTeamName(names, seen, 'IPW Italian ' .. code)
end
else
local ipwRest = mw.ustring.match(upper, '^IPW%s+(.+)$')
if ipwRest then
local code, shown, mii = normalizeIpwTeamCode(ipwRest)
if mii then
appendUniqueTeamName(names, seen, 'MII ' .. mii)
elseif code then
appendUniqueTeamName(names, seen, 'IPW ' .. shown)
end
else
local code, shown, mii = normalizeIpwTeamCode(part)
if mii then
appendUniqueTeamName(names, seen, 'MII ' .. mii)
elseif code and mw.ustring.match(tostring(shown or code), '^%d') then
appendUniqueTeamName(names, seen, 'IPW ' .. shown)
end
end
end
end
end
end
return names
end
 
local function teamNameLink(name)
return string.format('[[Team %s|%s]]', name, name)
end
 
local function pageResearchNotesText()
local title = mw.title.getCurrentTitle()
if not title or not title.getContent then
return ''
end
local ok, content = pcall(function()
return title:getContent()
end)
if not ok or not content then
return ''
end
return researchNotesSection(content)
end
 
--- Roster IPW fields + optional |teams= + research-note team codes, one linked row.
addTeamsInfoboxRow = function(tbl, frame)
local names = {}
local seen = {}
local function addAll(list)
for _, name in ipairs(list) do
appendUniqueTeamName(names, seen, name)
end
end
addAll(namesFromIpwGerman(parentArg(frame, 'ipw_ge')))
addAll(namesFromIpwItalian(parentArg(frame, 'ipw_it')))
addAll(namesFromTeamsParam(parentArg(frame, 'teams')))
addAll(noteTeamNames(pageResearchNotesText()))
if #names == 0 then
return
end
local links = {}
for _, name in ipairs(names) do
table.insert(links, teamNameLink(name))
end
local label = 'Team'
if #names > 1 then
label = 'Teams'
end
addRow(tbl, label, table.concat(links, ', '))
end
 
function p.noteTeamCategories(frame)
local title = mw.title.getCurrentTitle()
if not title or not title.getContent then
return ''
end
local ok, content = pcall(function()
return title:getContent()
end)
if not ok or not content then
return ''
end
local seen = {}
local bits = {}
local function addCat(name)
name = trim(tostring(name or ''))
if name == '' or seen[name] then
return
end
seen[name] = true
table.insert(bits, '[[Category:Team ' .. name .. ']]')
end
for _, name in ipairs(noteTeamNames(researchNotesSection(content))) do
addCat(name)
end
for _, name in ipairs(namesFromTeamsParam(parentArg(frame, 'teams'))) do
addCat(name)
end
return table.concat(bits)
end
 
function p.researchNotes(frame)
local title = mw.title.getCurrentTitle()
if not title or not title.getContent then
return ''
end
local ok, content = pcall(function()
return title:getContent()
end)
if not ok or not content or content == '' then
return ''
end
local section = researchNotesSection(content)
if section == '' then
return ''
end
local items = {}
for line in mw.text.gsplit(section, '\n', true) do
line = trim(line)
if mw.ustring.sub(line, 1, 2) == '* ' then
if mw.ustring.sub(line, 1, 2) == '* ' then
local note = sanitizeNote(mw.ustring.sub(line, 3))
local note = sanitizeNote(mw.ustring.sub(line, 3))
if note ~= '' then
if note ~= '' then
for clause in mw.text.gsplit(note, ';', true) do
for clause in mw.text.gsplit(note, ';', true) do
clause = trim(clause)
clause = trim(clause)
if clause ~= '' and not isNegativeClause(clause) then
if clause ~= '' and not isNegativeClause(clause) then
table.insert(items, prettyProse(clause))
table.insert(items, prettyProse(clause))
end
end
end
end
end
end
end
end
items = joinDateFollowons(items)
local unique = {}
local seen = {}
for _, clause in ipairs(items) do
local key = normalizeNoteKey(clause)
if key ~= '' and not seen[key] then
seen[key] = true
table.insert(unique, clause)
end
end
items = unique
if #items == 0 then
return ''
end
local wrap = mw.html.create('div')
wrap:addClass('soldier-research-notes')
wrap:tag('div'):addClass('soldier-research-notes-heading'):wikitext('Research notes')
local ul = wrap:tag('ul')
for _, note in ipairs(items) do
ul:tag('li'):wikitext(note)
end
return tostring(wrap)
end
 
local function cargoRows(tables, fields, where, limit, orderBy)
if not mw.ext or not mw.ext.cargo or not mw.ext.cargo.query then
return nil
end
local opts = {
where = where,
limit = limit or 8,
}
if orderBy and orderBy ~= '' then
opts.orderBy = orderBy
end
local ok, result = pcall(mw.ext.cargo.query, tables, fields, opts)
if not ok or type(result) ~= 'table' then
return nil
end
return result
end
 
local function rowField(row, ...)
for i = 1, select('#', ...) do
local key = select(i, ...)
local v = row[key]
if v ~= nil and trim(tostring(v)) ~= '' then
return trim(tostring(v))
end
end
return ''
end
 
local function titleToCaption(title)
title = trim(title)
local surname, given = mw.ustring.match(title, '^([^,]+),%s*(.-)%s*%(%d')
if surname and given then
return trim(given .. ' ' .. surname)
end
return title
end
 
local function displayFromCargoRow(row)
local page = rowField(row, 'pageName', '_pageName')
local surname = rowField(row, 'Surname')
local first = rowField(row, 'First_name', 'First name')
local middle = rowField(row, 'Middle_name', 'Middle name')
local given = trim(first .. (middle ~= '' and (' ' .. middle) or ''))
local caption
if given ~= '' and surname ~= '' then
caption = given .. ' ' .. surname
elseif page ~= '' then
caption = titleToCaption(page)
else
caption = surname
end
if page == '' and surname ~= '' then
local asn = rowField(row, 'ASN')
if asn ~= '' then
page = surname .. ', ' .. (given ~= '' and given or first) .. ' (' .. asn .. ')'
end
end
return page, caption
end
 
local function linkList(rows, skipAsn, skipPage, maxLinks)
local links = {}
local seen = {}
for _, row in ipairs(rows or {}) do
local asn = rowField(row, 'ASN')
local page, caption = displayFromCargoRow(row)
if caption == '' and page ~= '' then
caption = titleToCaption(page)
end
if page ~= '' and page ~= skipPage and asn ~= skipAsn and not seen[page] then
seen[page] = true
table.insert(links, string.format('[[%s|%s]]', page, caption))
if #links >= maxLinks then
break
end
end
end
return links
end
 
local function classFullWhere(classNum)
-- Prefer Class_number__full: the Class_number HOLDS index is corrupted on some hosts.
local n = tostring(classNum or '')
n = mw.ustring.gsub(n, '[^0-9A-Za-z%-]', '')
if n == '' then
return nil
end
return string.format(
'Class_number__full="%s" OR Class_number__full LIKE "%s;%%" OR Class_number__full LIKE "%%;%s" OR Class_number__full LIKE "%%;%s;%%"',
n,
n,
n,
n
)
end
 
function p.storyCue(frame)
local story = parentArg(frame, 'story')
if story == '' then
return ''
end
story = mw.ustring.gsub(story, '^%[%[', '')
story = mw.ustring.gsub(story, '%]%]$', '')
local storyTitle, storyLabel = mw.ustring.match(story, '^([^|]+)|(.+)$')
if not storyTitle then
storyTitle = story
storyLabel = nil
end
storyTitle = trim(storyTitle)
if storyLabel then
storyLabel = trim(storyLabel)
end
if not storyLabel or storyLabel == '' then
storyLabel = mw.ustring.match(storyTitle, '([^/]+)$') or storyTitle
end
local name = displayName(frame)
return string.format(
'<div class="soldier-story-cue">'
.. '<span class="soldier-story-cue-label">Full story</span> '
.. 'Read more about %s in [[%s|%s]] on \'\'[[The Stories]]\'\'.'
.. '</div>',
name,
storyTitle,
storyLabel
)
end
 
-- related-rail: live team rosters via category members (filled by Common.js)
function p.relatedSoldiers(frame)
local pageTitle = mw.title.getCurrentTitle().prefixedText
 
-- Same sources as the infobox Teams row: roster IPW, |teams=, research notes.
local teamNames = {}
local seenTeams = {}
local function addTeams(list)
for _, name in ipairs(list) do
appendUniqueTeamName(teamNames, seenTeams, name)
end
end
addTeams(namesFromIpwGerman(parentArg(frame, 'ipw_ge')))
addTeams(namesFromIpwItalian(parentArg(frame, 'ipw_it')))
addTeams(namesFromTeamsParam(parentArg(frame, 'teams')))
addTeams(noteTeamNames(pageResearchNotesText()))
 
if #teamNames == 0 then
return ''
end
 
local wrap = mw.html.create('div')
:addClass('soldier-related')
:attr('data-current-title', pageTitle)
wrap:tag('div'):addClass('soldier-related-heading'):wikitext('Teammates')
 
for _, teamName in ipairs(teamNames) do
local cat = 'Category:Team ' .. teamName
local block = wrap:tag('div')
:addClass('soldier-related-block')
:attr('data-team-cat', cat)
block:tag('div')
:addClass('soldier-related-label')
:wikitext('Team — ' .. teamNameLink(teamName))
-- Noscript / pre-JS fallback: category link. Common.js replaces with live names.
block:tag('div')
:addClass('soldier-related-links')
:addClass('soldier-related-links-live')
:wikitext('[[' .. cat .. '|Browse team roster]]')
end
 
wrap:tag('div')
:addClass('soldier-related-note')
:wikitext(
'These are other soldiers listed on the same team roster. '
.. 'People moved between teams; overlapping service dates are not always documented.'
)
return tostring(wrap)
end
 
local function seqCount(t)
-- mw.loadData tables do not always report # correctly.
local n = 0
if type(t) ~= 'table' then
return 0
end
for _ in ipairs(t) do
n = n + 1
end
return n
end
 
function p.mediaCue(frame)
local pageTitle = mw.title.getCurrentTitle().prefixedText
local ok, data = pcall(mw.loadData, 'Module:Interviews/data')
if not ok or type(data) ~= 'table' or type(data.by_title) ~= 'table' then
return ''
end
local rec = data.by_title[pageTitle]
if type(rec) ~= 'table' then
return ''
end
-- Prefer interviews; accept legacy `items` key from hand-edited cues.
local interviews = rec.interviews
if type(interviews) ~= 'table' then
interviews = rec.items
end
local films = rec.films
local nInterviews = seqCount(interviews)
local nFilms = seqCount(films)
if nInterviews == 0 and nFilms == 0 then
return ''
end
local name = displayName(frame)
local blocks = {}
if nInterviews > 0 then
local links = {}
local anyAudio = false
for _, item in ipairs(interviews) do
local heading = item.heading or 'Interview'
local short = item.short or heading
if item.kind == 'audio' then
anyAudio = true
end
table.insert(
links,
string.format('[[Ritchie Boy Interviews#%s|%s]]', heading, short)
)
end
local label = nInterviews == 1 and 'Interview' or 'Interviews'
local verb = 'Watch'
if nInterviews == 1 and interviews[1].kind == 'audio' then
verb = 'Listen to'
elseif anyAudio and nInterviews > 1 then
verb = 'Hear'
end
local body
if nInterviews == 1 then
body = string.format(
'%s %s on [[Ritchie Boy Interviews#%s|%s]].',
verb,
name,
interviews[1].heading,
interviews[1].short or interviews[1].heading
)
else
body = string.format(
'%s %s in %d recordings on [[Ritchie Boy Interviews]]: %s.',
verb,
name,
nInterviews,
table.concat(links, ' · ')
)
end
end
table.insert(
blocks,
'<div class="soldier-interview-cue">'
.. '<span class="soldier-interview-cue-label">'
.. label
.. '</span> '
.. body
.. '</div>'
)
end
end
items = joinDateFollowons(items)
if nFilms > 0 then
local unique = {}
local links = {}
local seen = {}
for _, item in ipairs(films) do
for _, clause in ipairs(items) do
local heading = item.heading or 'Documentaries/Movies'
local key = normalizeNoteKey(clause)
local filmLabel = item.label or heading
if key ~= '' and not seen[key] then
table.insert(
seen[key] = true
links,
table.insert(unique, clause)
string.format('[[Documentaries/Movies#%s|%s]]', heading, filmLabel)
)
end
end
local body = string.format(
'%s appears in %s.',
name,
table.concat(links, '; ')
)
table.insert(
blocks,
'<div class="soldier-film-cue">'
.. '<span class="soldier-film-cue-label">On film</span> '
.. body
.. '</div>'
)
end
end
items = unique
return '<div class="soldier-media-cue">' .. table.concat(blocks, '') .. '</div>'
if #items == 0 then
return ''
end
local wrap = mw.html.create('div')
wrap:addClass('soldier-research-notes')
wrap:tag('div'):addClass('soldier-research-notes-heading'):wikitext('Research notes')
local ul = wrap:tag('ul')
for _, note in ipairs(items) do
ul:tag('li'):wikitext(note)
end
return tostring(wrap)
end
end


return p
return p