Expand nativity/religion codes (Ge→Germany, J/H→Jewish) in infobox
Infobox: drop certificate/arrive/depart/years; expand other_class codes
 
(34 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
kind = 'Armored'
else
kind = prettyProse(kind)
end
local ord = unitNum
local last = mw.ustring.sub(unitNum, -1)
local last2 = mw.ustring.len(unitNum) >= 2 and mw.ustring.sub(unitNum, -2) or ''
if last2 ~= '11' and last2 ~= '12' and last2 ~= '13' then
if last == '1' then
ord = unitNum .. 'st'
elseif last == '2' then
ord = unitNum .. 'nd'
elseif last == '3' then
ord = unitNum .. 'rd'
else
ord = unitNum .. 'th'
end
else
ord = unitNum .. 'th'
end
return ord .. ' ' .. kind
end
 
return prettyProse(tok)
end
end


local function dateOnlyFragment(s)
local function expandOtherClass(raw)
return mw.ustring.match(trim(s), '^(%d%d?/%d%d?/%d%d%d?%d?)%.?$')
raw = tidyDisplay(raw)
if raw == '' then
return ''
end
local parts = {}
for piece in mw.ustring.gmatch(raw, '[^;,]+') do
local expanded = expandOtherClassToken(piece)
if expanded ~= '' then
table.insert(parts, expanded)
end
end
return table.concat(parts, '; ')
end
end


local function joinDateFollowons(items)
local function prettyNamePart(s)
local out = {}
return prettyNamePhrase(s, false)
for _, clause in ipairs(items) do
local date = dateOnlyFragment(clause)
if date and #out > 0 then
local prev = mw.ustring.gsub(out[#out], '[ ,;]+$', '')
if mw.ustring.sub(prev, -mw.ustring.len(date)) ~= date then
out[#out] = prev .. ', ' .. date
end
elseif clause ~= '' then
table.insert(out, clause)
end
end
return out
end
end


local function sanitizeNote(s)
local function isNegativeClause(s)
s = tidyDisplay(s)
local lower = mw.ustring.lower(s)
if s == '' 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 ''
return true
end
end
local lower = mw.ustring.lower(s)
-- Bare class-chart non-graduate marker (e.g. "NG Class 10 OB", trailing "NG")
if mw.ustring.find(lower, 'no notes were in the source', 1, true) then
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 ''
return true
end
end
local kept = {}
for _, phrase in ipairs(NEGATIVE_PHRASES) do
for clause in mw.text.gsplit(s, ';', true) do
if mw.ustring.find(lower, phrase, 1, true) then
clause = trim(clause)
return true
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 false
end
end


local function containsIgnoreCase(haystack, needle)
local function normalizeNoteKey(s)
return mw.ustring.find(mw.ustring.lower(haystack), mw.ustring.lower(needle), 1, true) ~= nil
s = mw.ustring.lower(trim(tidyDisplay(s)))
s = mw.ustring.gsub(s, '^also%s+', '')
s = mw.ustring.gsub(s, '[^%w%s%-/]', ' ')
s = mw.ustring.gsub(s, '%s+', ' ')
return trim(s)
end
end


local function displayName(frame)
local function dateOnlyFragment(s)
local first = prettyNamePart(parentArg(frame, 'first'))
return mw.ustring.match(trim(s), '^(%d%d?/%d%d?/%d%d%d?%d?)%.?$')
local middle = prettyNamePart(parentArg(frame, 'middle'))
end
local surname = prettyNamePart(parentArg(frame, 'surname'))
 
local suffix = prettyNamePart(parentArg(frame, 'suffix'))
local function joinDateFollowons(items)
local parts = {}
local out = {}
if first ~= '' then
for _, clause in ipairs(items) do
table.insert(parts, first)
local date = dateOnlyFragment(clause)
if date and #out > 0 then
local prev = mw.ustring.gsub(out[#out], '[ ,;]+$', '')
if mw.ustring.sub(prev, -mw.ustring.len(date)) ~= date then
out[#out] = prev .. ', ' .. date
end
elseif clause ~= '' then
table.insert(out, clause)
end
end
end
if middle ~= '' then
return out
table.insert(parts, middle)
end
 
local function sanitizeNote(s)
s = tidyDisplay(s)
if s == '' then
return ''
end
end
if surname ~= '' then
local lower = mw.ustring.lower(s)
table.insert(parts, surname)
if mw.ustring.find(lower, 'no notes were in the source', 1, true) then
return ''
end
end
local name = table.concat(parts, ' ')
local kept = {}
if suffix ~= '' then
for clause in mw.text.gsplit(s, ';', true) do
if name ~= '' then
clause = trim(clause)
name = name .. ', ' .. suffix
if clause ~= '' then
else
for sent in mw.text.gsplit(clause, '. ', true) do
name = suffix
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
if name == '' then
return table.concat(kept, '; ')
name = 'This soldier'
end
return name
end
end


local function classLinkList(raw, withPrefix)
local function containsIgnoreCase(haystack, needle)
local out = {}
return mw.ustring.find(mw.ustring.lower(haystack), mw.ustring.lower(needle), 1, true) ~= nil
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
if part ~= '' then
if withPrefix then
table.insert(out, string.format('[[Class %s]]', part))
else
table.insert(out, string.format('[[Class %s|%s]]', part, part))
end
end
end
return out
end
end


local function isJunkIpwItalianLabel(part)
local function displayName(frame)
local lower = mw.ustring.lower(trim(part))
local first = prettyNamePart(parentArg(frame, 'first'))
local spaced = mw.ustring.gsub(lower, '%s+', ' ')
local middle = prettyNamePart(parentArg(frame, 'middle'))
local junk = {
local surname = prettyNamePart(parentArg(frame, 'surname'))
['16063'] = true, ['16095'] = true, ['16302'] = true,
local suffix = prettyNamePart(parentArg(frame, 'suffix'))
['16320'] = true, ['16695'] = true,
local parts = {}
['1st army'] = true, ['2 day'] = true, ['2/23/44'] = true,
if first ~= '' then
['468th cic'] = true, ['5/7/45'] = true, ['8/24/44'] = true, ['8/7/43'] = true,
table.insert(parts, first)
['apid'] = true, ['i-32'] = true, ['mis-eto'] = true, ['mitc'] = true,
end
['pac'] = true, ['sc7'] = true, ['shaef'] = true, ['v-831'] = true,
if middle ~= '' then
}
table.insert(parts, middle)
return junk[spaced] == true
end
 
local function normalizeIpwItalianCode(part)
part = trim(part)
if part == '' or isJunkIpwItalianLabel(part) then
return nil
end
end
local spaced = mw.ustring.lower(mw.ustring.gsub(part, '%s+', ' '))
if surname ~= '' then
if spaced == '5th class' or spaced == 'italian 5th class' then
table.insert(parts, surname)
return '5'
end
end
return part
local name = table.concat(parts, ' ')
end
if suffix ~= '' then
 
if name ~= '' then
local function isJunkIpwLabel(part)
name = name .. ', ' .. suffix
local lower = mw.ustring.lower(trim(part))
else
lower = mw.ustring.gsub(lower, '%s+', ' ')
name = suffix
lower = mw.ustring.gsub(lower, '%s*%?+%s*$', '')
end
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 compact = mw.ustring.gsub(lower, '[%s%-]+', '')
if name == '' then
if compact == 'docteam' or compact == 'mfiu5' or compact == 'mitu5'
name = 'This soldier'
or compact == 'mrbc4' or compact == 'tforce' or compact == 'tocic' then
return true
end
end
return false
return name
end
end


local function normalizeIpwTeamCode(part)
local function classLinkList(raw, withPrefix)
part = trim(part)
local ClassDates = require('Module:ClassDates')
if part == '' then
local out = {}
return nil, nil
for part in mw.text.gsplit(raw, ';', true) do
end
part = trim(part)
local original = part
if part ~= '' then
part = mw.ustring.gsub(part, '%s*%?+%s*$', '')
local info = ClassDates.lookup(part)
part = trim(part)
local dates = info and info.dates or ''
if part == '' then
if withPrefix then
return nil, original
if dates ~= '' then
end
table.insert(out, string.format('[[:Category:Class %s|Class %s]] (%s)', part, part, dates))
local lower = mw.ustring.lower(part)
else
if mw.ustring.find(lower, 'armd', 1, true) or mw.ustring.find(lower, 'armored', 1, true) then
table.insert(out, string.format('[[:Category:Class %s|Class %s]]', part, part))
return nil, original, nil
end
end
else
local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$')
if dates ~= '' then
if miiNum then
table.insert(out, string.format('[[:Category:Class %s|%s]] (%s)', part, part, dates))
miiLetter = mw.ustring.upper(miiLetter)
else
local mii = miiNum .. '-' .. miiLetter
table.insert(out, string.format('[[:Category:Class %s|%s]]', part, part))
if miiLetter == 'R' and (miiNum == '540' or miiNum == '545' or miiNum == '546') then
end
mii = miiNum
end
end
end
return nil, mii, mii
end
end
local num = mw.ustring.match(part, '^(%d+)[Tt]$')
return out
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
return part, part, nil
end
end


local function teamLinkList(raw, pagePrefix, label)
local function isJunkIpwItalianLabel(part)
local out = {}
local lower = mw.ustring.lower(trim(part))
local isIpw = mw.ustring.find(pagePrefix, 'IPW', 1, true) == 1
local spaced = mw.ustring.gsub(lower, '%s+', ' ')
for part in mw.text.gsplit(raw, ';', true) do
local junk = {
part = trim(part)
['16063'] = true, ['16095'] = true, ['16302'] = true,
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
['16320'] = true, ['16695'] = true,
part = trim(part)
['1st army'] = true, ['2 day'] = true, ['2/23/44'] = true,
if part ~= '' then
['468th cic'] = true, ['5/7/45'] = true, ['8/24/44'] = true, ['8/7/43'] = true,
if pagePrefix == 'IPW Italian ' then
['apid'] = true, ['i-32'] = true, ['mis-eto'] = true, ['mitc'] = true,
local code = normalizeIpwItalianCode(part)
['pac'] = true, ['sc7'] = true, ['shaef'] = true, ['v-831'] = true,
if code then
}
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, code))
return junk[spaced] == true
else
table.insert(out, part)
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
return table.concat(out, ', ')
end
end


local function addHeader(tbl, text)
local function normalizeIpwItalianCode(part)
tbl:tag('tr'):tag('th')
part = trim(part)
:attr('colspan', '2')
if part == '' or isJunkIpwItalianLabel(part) then
:addClass('infobox-header')
return nil
:wikitext(text)
end
 
local function realCertificate(s)
s = trim(s)
if s == '' then
return ''
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
local original = part
part = mw.ustring.gsub(part, '%s*%?+%s*$', '')
part = trim(part)
if part == '' then
return nil, original
end
end
local year = mw.ustring.match(s, '(%d%d%d%d)')
local lower = mw.ustring.lower(part)
if year then
if mw.ustring.find(lower, 'armd', 1, true) or mw.ustring.find(lower, 'armored', 1, true) then
return year
return nil, original, nil
end
end
return ''
local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$')
end
if miiNum then
 
miiLetter = mw.ustring.upper(miiLetter)
local RANK_MAP = {
local mii = miiNum .. '-' .. miiLetter
['PVT'] = 'Private',
if miiLetter == 'R' and (miiNum == '540' or miiNum == '545' or miiNum == '546') then
['PVTE'] = 'Private',
mii = miiNum
['PRIVATE'] = 'Private',
end
['PFC'] = 'Private First Class',
return nil, mii, mii
['P F C'] = 'Private First Class',
end
['PRIVATE FIRST CLASS'] = 'Private First Class',
local num = mw.ustring.match(part, '^(%d+)[Tt]$')
['CPL'] = 'Corporal',
if num then
['CORPORAL'] = 'Corporal',
return num, num, nil
['T/5'] = 'Technician Fifth Grade',
end
['TEC5'] = 'Technician Fifth Grade',
if mw.ustring.match(lower, '^%d+(st|nd|rd|th)$') then
['TEC 5'] = 'Technician Fifth Grade',
return nil, original, nil
['T5'] = 'Technician Fifth Grade',
end
['TECH 5'] = 'Technician Fifth Grade',
if isJunkIpwLabel(part) then
['SGT'] = 'Sergeant',
return nil, original, nil
['SERGEANT'] = 'Sergeant',
end
['T/4'] = 'Technician Fourth Grade',
return part, part, nil
['TEC4'] = 'Technician Fourth Grade',
end
['TEC 4'] = 'Technician Fourth Grade',
 
['T4'] = 'Technician Fourth Grade',
local function teamLinkList(raw, pagePrefix, label)
['TECH 4'] = 'Technician Fourth Grade',
local out = {}
['S/SGT'] = 'Staff Sergeant',
local isIpw = mw.ustring.find(pagePrefix, 'IPW', 1, true) == 1
['SSGT'] = 'Staff Sergeant',
for part in mw.text.gsplit(raw, ';', true) do
['S SGT'] = 'Staff Sergeant',
part = trim(part)
['STAFF SERGEANT'] = 'Staff Sergeant',
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
['T/3'] = 'Technician Third Grade',
part = trim(part)
['TEC3'] = 'Technician Third Grade',
if part ~= '' then
['TEC 3'] = 'Technician Third Grade',
if pagePrefix == 'IPW Italian ' then
['T3'] = 'Technician Third Grade',
local code = normalizeIpwItalianCode(part)
['TECH 3'] = 'Technician Third Grade',
if code then
['T/SGT'] = 'Technical Sergeant',
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, code))
['TSGT'] = 'Technical Sergeant',
else
['T SGT'] = 'Technical Sergeant',
table.insert(out, part)
['TECHNICAL SERGEANT'] = 'Technical Sergeant',
end
['1ST SGT'] = 'First Sergeant',
elseif isIpw then
['1SGT'] = 'First Sergeant',
local code, shown, mii = normalizeIpwTeamCode(part)
['FIRST SERGEANT'] = 'First Sergeant',
if mii then
['M/SGT'] = 'Master Sergeant',
table.insert(out, string.format('[[Team MII %s|MII %s]]', mii, mii))
['MSGT'] = 'Master Sergeant',
elseif code then
['MASTER SERGEANT'] = 'Master Sergeant',
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, shown))
['WO'] = 'Warrant Officer',
elseif shown then
['WOJG'] = 'Warrant Officer',
table.insert(out, shown)
['WARRANT OFFICER'] = 'Warrant Officer',
end
['CWO'] = 'Chief Warrant Officer',
else
['2ND LT'] = 'Second Lieutenant',
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, part, label, part))
['2D LT'] = 'Second Lieutenant',
end
['2LT'] = 'Second Lieutenant',
end
['SECOND LIEUTENANT'] = 'Second Lieutenant',
end
['1ST LT'] = 'First Lieutenant',
return table.concat(out, ', ')
['1LT'] = 'First Lieutenant',
end
['FIRST LIEUTENANT'] = 'First Lieutenant',
 
['LT'] = 'Lieutenant',
local function addHeader(tbl, text)
['LIEUTENANT'] = 'Lieutenant',
tbl:tag('tr'):tag('th')
['CAPT'] = 'Captain',
:attr('colspan', '2')
['CPT'] = 'Captain',
:addClass('infobox-header')
['CAPTAIN'] = 'Captain',
:wikitext(text)
['MAJ'] = 'Major',
end
['MAJOR'] = 'Major',
['LT COL'] = 'Lieutenant Colonel',
['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)
local function realCertificate(s)
raw = trim(raw)
s = trim(s)
if raw == '' then
if s == '' then
return ''
end
-- Placeholders: *, A, 5, #, and any other single character.
if mw.ustring.len(s) == 1 then
return ''
end
-- NG = non-graduate marker on class charts, not a certificate number.
local upper = mw.ustring.upper(s)
if upper == 'NG' or upper == 'N/G' or upper == 'N.G.' then
return ''
return ''
end
end
local key = mw.ustring.upper(raw)
return s
key = mw.ustring.gsub(key, '%.', '')
key = mw.ustring.gsub(key, '%s+', ' ')
key = trim(key)
return RANK_MAP[key] or raw
end
end


local BRANCH_MAP = {
local function addRow(tbl, label, value, dataClass)
['INF'] = 'Infantry',
if trim(value) == '' then
['INFANTRY'] = 'Infantry',
return
['FA'] = 'Field Artillery',
end
['FIELD ARTILLERY'] = 'Field Artillery',
local tr = tbl:tag('tr')
['CAC'] = 'Coast Artillery Corps',
tr:tag('th')
['CA'] = 'Coast Artillery',
:addClass('infobox-label')
['COAST ARTILLERY'] = 'Coast Artillery',
:css('display', 'table-cell')
['AC'] = 'Air Corps',
:css('width', '38%')
['AAF'] = 'Army Air Forces',
:css('font-weight', 'bold')
['AIR CORPS'] = 'Air Corps',
:css('text-align', 'left')
['QMC'] = 'Quartermaster Corps',
:css('vertical-align', 'top')
['QC'] = 'Quartermaster Corps',
:wikitext(label)
['QUARTERMASTER'] = 'Quartermaster Corps',
local td = tr:tag('td')
['CAV'] = 'Cavalry',
:addClass('infobox-data')
['CAVALRY'] = 'Cavalry',
:css('display', 'table-cell')
['CE'] = 'Corps of Engineers',
:css('vertical-align', 'top')
['ENG'] = 'Corps of Engineers',
if dataClass and dataClass ~= '' then
['ENGINEERS'] = 'Corps of Engineers',
td:addClass(dataClass)
['CMP'] = 'Corps of Military Police',
end
['MP'] = 'Military Police',
td:wikitext(value)
['CWS'] = 'Chemical Warfare Service',
end
['AUS'] = 'Army of the United States',
 
['SC'] = 'Signal Corps',
local function yearOnly(s)
['SIG'] = 'Signal Corps',
s = trim(s)
['SIG C'] = 'Signal Corps',
if s == '' or s == '?' then
['SIGC'] = 'Signal Corps',
return ''
['SIGNAL CORPS'] = 'Signal Corps',
end
['ORD'] = 'Ordnance Department',
local year = mw.ustring.match(s, '(%d%d%d%d)')
['ORDNANCE'] = 'Ordnance Department',
if year then
['MAC'] = 'Medical Administrative Corps',
return year
['MC'] = 'Medical Corps',
end
['MD'] = 'Medical Department',
return ''
['TC'] = 'Transportation Corps',
end
['MI'] = 'Military Intelligence',
 
['MIS'] = 'Military Intelligence Service',
local RANK_MAP = {
['INT'] = 'Intelligence',
['PVT'] = 'Private',
['CIC'] = 'Counter Intelligence Corps',
['PVTE'] = 'Private',
['AGD'] = "Adjutant General's Department",
['PRIVATE'] = 'Private',
['USMC'] = 'United States Marine Corps',
['PFC'] = 'Private First Class',
['USMCR'] = 'United States Marine Corps Reserve',
['P F C'] = 'Private First Class',
['AA'] = 'Antiaircraft',
['PRIVATE FIRST CLASS'] = 'Private First Class',
['AAA'] = 'Antiaircraft Artillery',
['CPL'] = 'Corporal',
['TD'] = 'Tank Destroyer',
['CORPORAL'] = 'Corporal',
['FD'] = 'Finance Department',
['T/5'] = 'Technician Fifth Grade',
['GSC'] = 'General Staff Corps',
['TEC5'] = 'Technician Fifth Grade',
['ARMD F'] = 'Armored Force',
['TEC 5'] = 'Technician Fifth Grade',
['ARMD'] = 'Armored',
['T5'] = 'Technician Fifth Grade',
['ARMOR'] = 'Armor',
['TECH 5'] = 'Technician Fifth Grade',
['SSF'] = 'Special Service Force',
['SGT'] = 'Sergeant',
['PA'] = 'Philippine Army',
['SERGEANT'] = 'Sergeant',
['RCE'] = 'Royal Canadian Engineers',
['T/4'] = 'Technician Fourth Grade',
['RCA'] = 'Royal Canadian Artillery',
['TEC4'] = 'Technician Fourth Grade',
['CHC'] = 'Chaplain Corps',
['TEC 4'] = 'Technician Fourth Grade',
['CHP'] = 'Chaplain Corps',
['T4'] = 'Technician Fourth Grade',
['CAO'] = 'Civil Affairs',
['TECH 4'] = 'Technician Fourth Grade',
['CAU'] = 'Civil Affairs',
['S/SGT'] = 'Staff Sergeant',
}
['SSGT'] = 'Staff Sergeant',
 
['S SGT'] = 'Staff Sergeant',
local function expandBranchToken(raw)
['STAFF SERGEANT'] = 'Staff Sergeant',
raw = trim(raw)
['T/3'] = 'Technician Third Grade',
if raw == '' then
['TEC3'] = 'Technician Third Grade',
return ''
['TEC 3'] = 'Technician Third Grade',
end
['T3'] = 'Technician Third Grade',
local key = mw.ustring.upper(raw)
['TECH 3'] = 'Technician Third Grade',
key = mw.ustring.gsub(key, '%.', '')
['T/SGT'] = 'Technical Sergeant',
key = mw.ustring.gsub(key, '%s+', ' ')
['TSGT'] = 'Technical Sergeant',
key = trim(key)
['T SGT'] = 'Technical Sergeant',
if BRANCH_MAP[key] then
['TECHNICAL SERGEANT'] = 'Technical Sergeant',
return BRANCH_MAP[key]
['1ST SGT'] = 'First Sergeant',
end
['1SGT'] = 'First Sergeant',
local left, right = mw.ustring.match(key, '^(.+) %((.+)%)$')
['FIRST SERGEANT'] = 'First Sergeant',
if left and right then
['M/SGT'] = 'Master Sergeant',
left = trim(left)
['MSGT'] = 'Master Sergeant',
right = trim(right)
['MASTER SERGEANT'] = 'Master Sergeant',
local leftExp = BRANCH_MAP[left] or left
['WO'] = 'Warrant Officer',
local rightExp = BRANCH_MAP[right] or right
['WOJG'] = 'Warrant Officer',
if BRANCH_MAP[left] or BRANCH_MAP[right] then
['WARRANT OFFICER'] = 'Warrant Officer',
return leftExp .. ' (' .. rightExp .. ')'
['CWO'] = 'Chief Warrant Officer',
end
['2ND LT'] = 'Second Lieutenant',
end
['2D LT'] = 'Second Lieutenant',
return raw
['2LT'] = 'Second Lieutenant',
end
['SECOND LIEUTENANT'] = 'Second Lieutenant',
['1ST LT'] = 'First Lieutenant',
['1LT'] = 'First Lieutenant',
['FIRST LIEUTENANT'] = 'First Lieutenant',
['LT'] = 'Lieutenant',
['LIEUTENANT'] = 'Lieutenant',
['CAPT'] = 'Captain',
['CPT'] = 'Captain',
['CAPTAIN'] = 'Captain',
['MAJ'] = 'Major',
['MAJOR'] = 'Major',
['LT COL'] = 'Lieutenant Colonel',
['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 expandBranch(raw)
local function expandRank(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, part)
return RANK_MAP[key] or raw
end
end
return table.concat(parts, '; ')
end
end


local function lifespan(birth, death)
local BRANCH_MAP = {
birth = yearOnly(birth)
['INF'] = 'Infantry',
death = yearOnly(death)
['INFANTRY'] = 'Infantry',
if birth ~= '' and death ~= '' then
['FA'] = 'Field Artillery',
return ' (' .. birth .. '&ndash;' .. death .. ')'
['FIELD ARTILLERY'] = 'Field Artillery',
end
['CAC'] = 'Coast Artillery Corps',
if birth ~= '' then
['CA'] = 'Coast Artillery',
return ' (born ' .. birth .. ')'
['COAST ARTILLERY'] = 'Coast Artillery',
end
['AC'] = 'Air Corps',
if death ~= '' then
['AAF'] = 'Army Air Forces',
return ' (died ' .. death .. ')'
['AIR CORPS'] = 'Air Corps',
end
['QMC'] = 'Quartermaster Corps',
return ''
['QC'] = 'Quartermaster Corps',
end
['QUARTERMASTER'] = 'Quartermaster Corps',
 
['CAV'] = 'Cavalry',
local function preferredRank(frame)
['CAVALRY'] = 'Cavalry',
local rank = expandRank(parentArg(frame, 'rank_final'))
['CALVALRY'] = 'Cavalry',
if rank == '' then
['CE'] = 'Corps of Engineers',
rank = expandRank(parentArg(frame, 'class_rank'))
['ENG'] = 'Corps of Engineers',
end
['ENGINEERS'] = 'Corps of Engineers',
return rank
['CMP'] = 'Corps of Military Police',
end
['MP'] = 'Military Police',
 
['CWS'] = 'Chemical Warfare Service',
function p.displayName(frame)
['AUS'] = 'Army of the United States',
return displayName(frame)
['SC'] = 'Signal Corps',
end
['SIG'] = 'Signal Corps',
 
['SIG C'] = 'Signal Corps',
local function displayNameFrom(getArg)
['SIGC'] = 'Signal Corps',
local first = prettyNamePart(getArg('first'))
['SIGNAL CORPS'] = 'Signal Corps',
local middle = prettyNamePart(getArg('middle'))
['ORD'] = 'Ordnance Department',
local surname = prettyNamePart(getArg('surname'))
['ORDNANCE'] = 'Ordnance Department',
local suffix = prettyNamePart(getArg('suffix'))
['MAC'] = 'Medical Administrative Corps',
local parts = {}
['MC'] = 'Medical Corps',
if first ~= '' then
['MD'] = 'Medical Department',
table.insert(parts, first)
['TC'] = 'Transportation Corps',
end
['MI'] = 'Military Intelligence',
if middle ~= '' then
['MIS'] = 'Military Intelligence Service',
table.insert(parts, middle)
['INT'] = 'Intelligence',
end
['CIC'] = 'Counter Intelligence Corps',
if surname ~= '' then
['AGD'] = "Adjutant General's Department",
table.insert(parts, surname)
['USMC'] = 'United States Marine Corps',
end
['USMCR'] = 'United States Marine Corps Reserve',
local name = table.concat(parts, ' ')
['AA'] = 'Antiaircraft',
if suffix ~= '' then
['AAA'] = 'Antiaircraft Artillery',
if name ~= '' then
['TD'] = 'Tank Destroyer',
name = name .. ', ' .. suffix
['FD'] = 'Finance Department',
else
['GSC'] = 'General Staff Corps',
name = suffix
['ARMD F'] = 'Armored Force',
end
['ARMD'] = 'Armored',
['ARMOR'] = 'Armor',
['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 expandBranchToken(raw)
raw = trim(raw)
if raw == '' then
return ''
end
end
if name == '' then
local key = mw.ustring.upper(raw)
name = 'This soldier'
key = mw.ustring.gsub(key, '%.', '')
key = mw.ustring.gsub(key, '%s+', ' ')
key = trim(key)
if BRANCH_MAP[key] then
return BRANCH_MAP[key]
end
end
return name
local left, right = mw.ustring.match(key, '^(.+) %((.+)%)$')
end
if left and right then
 
left = trim(left)
local function preferredRankFrom(getArg)
right = trim(right)
local rank = expandRank(getArg('rank_final'))
local leftExp = BRANCH_MAP[left] or left
if rank == '' then
local rightExp = BRANCH_MAP[right] or right
rank = expandRank(getArg('class_rank'))
if BRANCH_MAP[left] or BRANCH_MAP[right] then
return leftExp .. ' (' .. rightExp .. ')'
end
end
end
return rank
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 buildLead(getArg)
-- True when a BRANCH_MAP / class_branch value is an Army arm/service, not Army/Marines/Navy.
local name = displayNameFrom(getArg)
local SERVICE_BRANCH_KEY = {
local rank = preferredRankFrom(getArg)
['AUS'] = true,
local role = 'United States Army soldier'
['USA'] = true,
if rank ~= '' then
['ARMY'] = true,
role = 'United States Army ' .. rank
['UNITED STATES ARMY'] = true,
end
['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 classLinks = classLinkList(getArg('class_number'), true)
local function isServiceBranchName(name)
local trained
name = trim(name)
if #classLinks == 1 then
if name == '' then
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1]
return false
elseif #classLinks == 2 then
end
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1] .. ' and ' .. classLinks[2]
local key = mw.ustring.upper(name)
elseif #classLinks > 2 then
key = mw.ustring.gsub(key, '%.', '')
trained = ' who trained at [[Camp Ritchie]], Maryland, in '
key = mw.ustring.gsub(key, '%s+', ' ')
.. table.concat(classLinks, ', ', 1, #classLinks - 1)
key = trim(key)
.. ', and ' .. classLinks[#classLinks]
if SERVICE_BRANCH_KEY[key] then
else
return true
trained = ' associated with Camp Ritchie, Maryland'
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
if mw.ustring.find(low, 'air force', 1, true)
or mw.ustring.find(low, 'air forces', 1, true) then
return true
end
if mw.ustring.find(low, 'united states army', 1, true)
or low == 'army'
or low == 'u.s. army'
or low == 'us army' then
return true
end
end
return false
end


local lead = string.format(
local function isArmOfServiceToken(raw)
"'''%s'''%s was a %s%s.",
raw = trim(raw)
name,
if raw == '' then
lifespan(getArg('birth_year'), getArg('death_year')),
return false
role,
end
trained
local key = mw.ustring.upper(raw)
)
key = mw.ustring.gsub(key, '%.', '')
local who = prettyNamePart(getArg('first'))
key = mw.ustring.gsub(key, '%s+', ' ')
if who == '' then
key = trim(key)
who = prettyNamePart(getArg('surname'))
if SERVICE_BRANCH_KEY[key] then
return false
end
end
if who == '' then
if BRANCH_MAP[key] then
who = 'He'
return true
end
end
local enlist = prettyPlace(getArg('enlistment_place'))
local low = mw.ustring.lower(raw)
if enlist ~= '' then
if mw.ustring.find(low, 'calvalry', 1, true) then
lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.'
return true
end
end
return lead
for _, needle in ipairs({
end
'artillery', 'signal', 'cavalry', 'infantry', 'engineer',
 
'ordnance', 'quartermaster', 'medical', 'police', 'intelligence',
function p.classLinks(frame)
'armor', 'tank destroyer', 'chemical', 'chaplain', 'transportation',
return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
'adjutant',
}) do
if mw.ustring.find(low, needle, 1, true) then
return true
end
end
return false
end
end


local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' }
local function splitBranchAndArm(branchRaw, classBranchRaw, armRaw)
branchRaw = trim(branchRaw)
classBranchRaw = trim(classBranchRaw)
armRaw = trim(armRaw)
 
local service = ''
local arms = {}


local function photoFileExists(filename)
local function consider(raw, preferArm)
filename = trim(filename)
raw = trim(raw)
if filename == '' then
if raw == '' then
return false
return
end
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
if part ~= '' then
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
end
local title = mw.title.new('File:' .. filename)
 
return title ~= nil and title.exists
-- Explicit arm_or_service first
consider(armRaw, true)
-- Roster class_branch is almost always arm/service within the Army
consider(classBranchRaw, true)
-- |branch= may be either
consider(branchRaw, false)
 
local armJoined = table.concat(arms, '; ')
return service, armJoined
end
end


local function pushPhotoCandidate(candidates, seen, value)
-- Small public-domain / Commons emblems uploaded as File:Branch emblem *.png
value = trim(value)
local BRANCH_EMBLEM = {
if value == '' or seen[value] then
['United States Army'] = 'Branch emblem US Army.png',
return
['United States Marine Corps'] = 'Branch emblem US Marine Corps.png',
['United States Navy'] = 'Branch emblem US Navy.png',
['United States Army Air Forces'] = 'Branch emblem US Army Air Forces.png',
['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
local file = BRANCH_EMBLEM[name]
if file then
return '[[File:' .. file .. '|18px|link=|alt=]] ' .. name
end
-- Fuzzy contains
local low = mw.ustring.lower(name)
if mw.ustring.find(low, 'marine', 1, true) then
return '[[File:Branch emblem US Marine Corps.png|18px|link=|alt=]] ' .. name
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
seen[value] = true
return name
table.insert(candidates, value)
end
end


local function resolvePhoto(frame)
local function expandBranch(raw)
local explicit = trim(parentArg(frame, 'photo'))
raw = trim(raw)
if explicit ~= '' then
if raw == '' then
return explicit
return ''
end
end
 
local parts = {}
local first = trim(parentArg(frame, 'first'))
for part in mw.text.gsplit(raw, ';', true) do
local surname = trim(parentArg(frame, 'surname'))
part = expandBranchToken(part)
local asn = trim(parentArg(frame, 'asn'))
if part ~= '' then
if first == '' or surname == '' then
table.insert(parts, branchWithEmblem(part))
end
end
return table.concat(parts, '; ')
end
 
local function formatBattles(raw)
raw = trim(raw)
if raw == '' then
return ''
return ''
end
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
local parts = {}
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
if part ~= '' then
table.insert(parts, part)
end
end
if #parts <= 1 then
return raw
end
return table.concat(parts, '<br />')
end


local candidates = {}
local function splitAwardRows(raw)
local seen = {}
raw = trim(raw)
local lowerFirst = mw.ustring.lower(first)
if raw == '' then
local lowerSurname = mw.ustring.lower(surname)
return {}
local bases = {
end
first .. ' ' .. surname,
-- Prefer @@ row separator (safe inside {{Soldier}}); also accept legacy |||
first .. ' ' .. lowerSurname,
local sep = '@@'
lowerFirst .. ' ' .. lowerSurname,
if not mw.ustring.find(raw, '@@', 1, true) and mw.ustring.find(raw, '|||', 1, true) then
first .. '_' .. surname,
sep = '|||'
first .. '_' .. lowerSurname,
lowerFirst .. '_' .. lowerSurname,
surname .. ', ' .. first,
surname .. '_' .. first,
}
if asn ~= '' then
table.insert(bases, surname .. ', ' .. first .. ' (' .. asn .. ')')
end
end
for _, base in ipairs(bases) do
local rows = {}
pushPhotoCandidate(candidates, seen, base)
for row in mw.text.gsplit(raw, sep, true) do
row = trim(row)
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
end
return rows
end


for _, base in ipairs(candidates) do
local function awardRibbonRows(raw, rowRaw)
for _, ext in ipairs(PHOTO_EXTENSIONS) do
-- Prefer explicit Wikipedia row layout when present
local filename = base .. '.' .. ext
local rows = splitAwardRows(rowRaw or '')
if photoFileExists(filename) then
if #rows > 0 then
return filename
return rows
end
end
local upperExt = mw.ustring.upper(ext)
raw = trim(raw)
if upperExt ~= ext then
if raw == '' then
filename = base .. '.' .. upperExt
return {}
if photoFileExists(filename) then
end
return filename
local files = {}
end
for part in mw.text.gsplit(raw, ';', true) do
end
part = trim(part)
part = mw.ustring.gsub(part, '^[Ff]ile:', '')
if part ~= '' then
table.insert(files, part)
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
end
end
return ''
if #cur > 0 then
table.insert(rows, cur)
end
return rows
end
end


function p.infobox(frame)
local function awardRowsMatch(ribbonRows, labelRows)
local name = displayName(frame)
if #ribbonRows == 0 or #ribbonRows ~= #labelRows then
local tbl = mw.html.create('table')
return false
tbl:addClass('infobox'):addClass('vcard'):addClass('soldier-infobox')
end
for i, cells in ipairs(ribbonRows) do
if #cells ~= #(labelRows[i] or {}) then
return false
end
end
return true
end


tbl:tag('tr'):tag('th')
local function formatAwardsRibbons(raw, rowRaw, labelRaw)
:attr('colspan', '2')
local rows = awardRibbonRows(raw, rowRaw)
:addClass('infobox-above fn')
if #rows == 0 then
:wikitext(name)
return '', false
 
end
local photo = resolvePhoto(frame)
local labelRows = splitAwardRows(labelRaw or '')
if photo ~= '' then
local labeled = awardRowsMatch(rows, labelRows)
local size = parentArg(frame, 'image_size')
-- One table per row so short rows (e.g. 2 ribbons) center like full rows.
if size == '' then
-- When labels match ribbon cells, put captions in the same table so each
size = '220px'
-- name sits centered under its ribbon (equal column widths).
local out = {}
for i, cells in ipairs(rows) do
if labeled then
table.insert(out, '{| class="soldier-ribbon-rack soldier-awards-labeled"')
else
table.insert(out, '{| class="soldier-ribbon-rack"')
end
end
local alt = parentArg(frame, 'alt')
table.insert(out, '|-')
if alt == '' then
for _, file in ipairs(cells) do
alt = name
file = mw.ustring.gsub(file, '^[Ff]ile:', '')
table.insert(out, '| [[File:' .. file .. '|106px|link=]]')
end
end
local td = tbl:tag('tr'):tag('td')
if labeled then
:attr('colspan', '2')
table.insert(out, '|-')
:addClass('infobox-image')
for _, label in ipairs(labelRows[i]) do
td:wikitext(string.format('[[File:%s|%s|alt=%s]]', photo, size, alt))
table.insert(out, '| class="soldier-award-caption" | ' .. label)
local caption = parentArg(frame, 'caption')
end
if caption ~= '' then
td:tag('div'):addClass('infobox-caption'):wikitext(caption)
end
end
table.insert(out, '|}')
end
end
return table.concat(out, '\n'), labeled
end


local alias = prettyNamePart(parentArg(frame, 'alias'))
local function awardGridColspanUnits(rows)
local birth = yearOnly(parentArg(frame, 'birth_year'))
-- LCM of row lengths so 2-cell and 3-cell rows share one table width
local death = yearOnly(parentArg(frame, 'death_year'))
local function gcd(a, b)
local nativity = expandNativity(parentArg(frame, 'nativity'))
while b ~= 0 do
local religion = expandReligion(parentArg(frame, 'religion'))
a, b = b, a % b
local marital = prettyProse(parentArg(frame, 'marital'))
end
local education = prettyProse(parentArg(frame, 'education'))
return a
local occupation = prettyProse(parentArg(frame, 'occupation'))
end
local state = prettyPlace(parentArg(frame, 'state'))
local function lcm(a, b)
 
if a == 0 or b == 0 then
if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= ''
return a + b
or religion ~= '' or marital ~= '' or education ~= ''
end
or occupation ~= '' or state ~= '' then
return (a / gcd(a, b)) * b
addHeader(tbl, 'Personal details')
end
local units = 0
for _, cells in ipairs(rows) do
local n = #cells
if n > 0 then
units = lcm(units, n)
end
end
end
return units > 0 and units or 1
end


addRow(tbl, 'Nickname', alias, 'nickname')
local function formatAwardsGrid(raw)
addRow(tbl, 'Born', birth)
local rows = splitAwardRows(raw)
addRow(tbl, 'Died', death)
if #rows == 0 then
addRow(tbl, 'Nativity', nativity)
return ''
addRow(tbl, 'Religion', religion)
addRow(tbl, 'Spouse / marital', marital)
addRow(tbl, 'Education', education)
addRow(tbl, 'Occupation', occupation)
if state ~= '' then
addRow(tbl, 'State', state)
end
end
 
local units = awardGridColspanUnits(rows)
addHeader(tbl, 'Military service')
local out = { '{| class="wikitable soldier-awards-grid"' }
addRow(tbl, 'Allegiance', 'United States')
for _, cells in ipairs(rows) do
local branch = expandBranch(parentArg(frame, 'class_branch'))
local n = #cells
if branch == '' then
if n == 0 then
branch = 'United States Army'
-- 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
addRow(tbl, 'Branch', branch)
table.insert(out, '|}')
addRow(tbl, 'ASN', parentArg(frame, 'asn'), 'serial')
return table.concat(out, '\n')
end


local rankFinal = expandRank(parentArg(frame, 'rank_final'))
local function formatAwardsLegend(raw)
local classRank = expandRank(parentArg(frame, 'class_rank'))
-- Do not run prettyProse/prettyPlace — commas in prose (e.g. statue note)
if rankFinal ~= '' and classRank ~= '' and classRank ~= rankFinal then
-- get misread as place names and Title-Cased / rewrapped.
addRow(tbl, 'Rank', rankFinal)
raw = trim(raw)
addRow(tbl, 'Rank at Ritchie', classRank)
if raw == '' then
else
return ''
local rank = rankFinal
end
if rank == '' then
local parts = {}
rank = classRank
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
addRow(tbl, 'Rank', rank)
end
end
 
if #parts == 0 then
local classRaw = parentArg(frame, 'class_number')
return ''
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'))
return table.concat(parts, '\n')
addRow(tbl, 'Other class', parentArg(frame, 'other_class'))
end


local enlist = prettyPlace(parentArg(frame, 'enlistment_place'))
local function lifespan(birth, death)
if enlist ~= '' then
birth = trim(birth or '')
addRow(tbl, 'Enlisted', enlist)
death = trim(death or '')
if birth ~= '' and death ~= '' then
return ' (' .. birth .. ' &ndash; ' .. death .. ')'
end
end
addRow(tbl, 'Specialty', prettyProse(parentArg(frame, 'specialty')))
if birth ~= '' then
addRow(tbl, 'Unit / division', prettyProse(parentArg(frame, 'division')))
return ' (born ' .. birth .. ')'
addRow(tbl, 'IPW (German)', teamLinkList(parentArg(frame, 'ipw_ge'), 'IPW ', 'IPW'))
end
addRow(tbl, 'IPW (Italian)', teamLinkList(parentArg(frame, 'ipw_it'), 'IPW Italian ', 'IPW Italian'))
if death ~= '' then
addRow(tbl, 'LOC', parentArg(frame, 'loc'))
return ' (died ' .. death .. ')'
addRow(tbl, 'Arrived', parentArg(frame, 'date_arrive'))
end
addRow(tbl, 'Departed', parentArg(frame, 'date_depart'))
return ''
addRow(tbl, 'Certificate', realCertificate(parentArg(frame, 'certificate')))
 
return tostring(tbl)
end
end


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


function p.leadFrom(frame)
local function preferredRank(frame)
return buildLead(function(key)
local rank = expandRank(parentArg(frame, 'rank_final'))
return trim(frame.args[key] or '')
if rank == '' then
end)
rank = expandRank(parentArg(frame, 'class_rank'))
end
return rank
end
end


local function researchNotesSection(content)
function p.displayName(frame)
if not content or content == '' then
return displayName(frame)
return ''
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
local startPos = mw.ustring.find(content, '== Research notes ==', 1, true)
if middle ~= '' then
if not startPos then
table.insert(parts, middle)
return ''
end
end
local after = mw.ustring.sub(content, startPos + 20)
if surname ~= '' then
local nextHead = mw.ustring.find(after, '\n== ', 1, true)
table.insert(parts, surname)
if nextHead then
return mw.ustring.sub(after, 1, nextHead - 1)
end
end
return after
local name = table.concat(parts, ' ')
if suffix ~= '' then
if name ~= '' then
name = name .. ', ' .. suffix
else
name = suffix
end
end
if name == '' then
name = 'This soldier'
end
return name
end
end


local function miiPreferredLetter(num)
local function preferredRankFrom(getArg)
local ok, canon = pcall(require, 'Module:TeamCanon')
local rank = expandRank(getArg('rank_final'))
if ok and type(canon) == 'table' and type(canon.letter) == 'table' then
if rank == '' then
return canon.letter[tostring(num)]
rank = expandRank(getArg('class_rank'))
end
end
return nil
return rank
end
end


local function noteTeamNames(text)
local function buildLead(getArg)
text = mw.ustring.upper(tostring(text or ''))
local name = displayNameFrom(getArg)
text = mw.ustring.gsub(text, mw.ustring.char(0x2013), '-')
local rank = preferredRankFrom(getArg)
text = mw.ustring.gsub(text, mw.ustring.char(0x2014), '-')
local role = 'United States Army soldier'
text = mw.ustring.gsub(text, mw.ustring.char(0x2212), '-')
if rank ~= '' then
local seen = {}
role = 'United States Army ' .. rank
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
 
for num, letter in string.gmatch(work, 'MII[%s%-]+(%d+)%s*%-%s*([A-Z])') do
local classLinks = classLinkList(getArg('class_number'), true)
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
local trained
add('MII ' .. num)
if #classLinks == 1 then
else
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1]
add('MII ' .. num .. '-' .. letter)
elseif #classLinks == 2 then
letteredNums[num] = true
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1] .. ' and ' .. classLinks[2]
end
elseif #classLinks > 2 then
trained = ' who trained at [[Camp Ritchie]], Maryland, in '
.. table.concat(classLinks, ', ', 1, #classLinks - 1)
.. ', and ' .. classLinks[#classLinks]
else
trained = ' associated with Camp Ritchie, Maryland'
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
local lead = string.format(
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
"'''%s'''%s was a %s%s.",
add('MII ' .. num)
name,
else
lifespanFromArgs(getArg),
add('MII ' .. num .. '-' .. letter)
role,
letteredNums[num] = true
trained
end
)
local who = prettyNamePart(getArg('first'))
if who == '' then
who = prettyNamePart(getArg('surname'))
end
end
for num in string.gmatch(work, 'MII[%s%-]+(%d+)') do
if who == '' then
if not letteredNums[num] then
who = 'He'
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
local enlist = prettyPlace(getArg('enlistment_place'))
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
if enlist ~= '' then
add('MII ' .. num)
lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.'
else
add('MII ' .. num .. '-' .. letter)
end
end
end
for num in string.gmatch(text, 'IPW%s+(%d+)[Tt]?%s*%??%f[^%a%d%-]') do
return lead
add('IPW ' .. num)
end
return names
end
end


function p.noteTeamCategories(frame)
function p.classLinks(frame)
local title = mw.title.getCurrentTitle()
return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
if not title or not title.getContent then
end
return ''
 
local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' }
 
local function photoFileExists(filename)
filename = trim(filename)
if filename == '' then
return false
end
end
local ok, content = pcall(function()
local title = mw.title.new('File:' .. filename)
return title:getContent()
return title ~= nil and title.exists
end)
if not ok or not content then
return ''
end
local bits = {}
for _, name in ipairs(noteTeamNames(researchNotesSection(content))) do
table.insert(bits, '[[Category:Team ' .. name .. ']]')
end
return table.concat(bits)
end
end


function p.researchNotes(frame)
local function pushPhotoCandidate(candidates, seen, value)
local title = mw.title.getCurrentTitle()
value = trim(value)
if not title or not title.getContent then
if value == '' or seen[value] then
return ''
return
end
end
local ok, content = pcall(function()
seen[value] = true
return title:getContent()
table.insert(candidates, value)
end)
end
if not ok or not content or content == '' then
 
return ''
local function resolvePhoto(frame)
local explicit = trim(parentArg(frame, 'photo'))
if explicit ~= '' then
return explicit
end
end
local section = researchNotesSection(content)
 
if section == '' then
local first = trim(parentArg(frame, 'first'))
local surname = trim(parentArg(frame, 'surname'))
local asn = trim(parentArg(frame, 'asn'))
if first == '' or surname == '' then
return ''
return ''
end
end
local items = {}
 
for line in mw.text.gsplit(section, '\n', true) do
local candidates = {}
line = trim(line)
local seen = {}
if mw.ustring.sub(line, 1, 2) == '* ' then
local lowerFirst = mw.ustring.lower(first)
local note = sanitizeNote(mw.ustring.sub(line, 3))
local lowerSurname = mw.ustring.lower(surname)
local bases = {
first .. ' ' .. surname,
first .. ' ' .. lowerSurname,
lowerFirst .. ' ' .. lowerSurname,
first .. '_' .. surname,
first .. '_' .. lowerSurname,
lowerFirst .. '_' .. lowerSurname,
surname .. ', ' .. first,
surname .. '_' .. first,
}
if asn ~= '' then
table.insert(bases, surname .. ', ' .. first .. ' (' .. asn .. ')')
end
for _, base in ipairs(bases) do
pushPhotoCandidate(candidates, seen, base)
end
 
for _, base in ipairs(candidates) do
for _, ext in ipairs(PHOTO_EXTENSIONS) do
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
return ''
end
 
-- Filled after noteTeamNames (local forward declare; used by p.infobox).
local addTeamsInfoboxRow
 
function p.infobox(frame)
local name = displayName(frame)
local tbl = mw.html.create('table')
tbl:addClass('infobox'):addClass('vcard'):addClass('soldier-infobox')
 
tbl:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-above fn')
:wikitext(name)
 
local photo = resolvePhoto(frame)
if photo ~= '' then
local size = parentArg(frame, 'image_size')
if size == '' then
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
 
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
 
local function datePlaceCell(date, place, year)
local bits = {}
if date ~= '' then
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 death = datePlaceCell(deathDate, deathPlace, deathYear)
 
if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= ''
or religion ~= '' or knownFor ~= '' or awards ~= '' then
addHeader(tbl, 'Personal details')
end
 
addRow(tbl, 'Birth name', alias, 'nickname')
addRow(tbl, 'Born', birth)
addRow(tbl, 'Died', death)
addRow(tbl, 'Nativity', nativity)
addRow(tbl, 'Religion', religion)
addRow(tbl, 'Known for', knownFor)
addRow(tbl, 'Awards', awards)
 
addHeader(tbl, 'Military service')
addRow(tbl, 'Allegiance', '[[File:Flag of the United States.png|20px|link=|alt=]] United States')
local serviceName, armName = splitBranchAndArm(
parentArg(frame, 'branch'),
parentArg(frame, 'class_branch'),
parentArg(frame, 'arm_or_service')
)
if serviceName == '' then
serviceName = 'United States Army'
end
addRow(tbl, 'Branch', branchWithEmblem(serviceName))
if armName ~= '' then
-- Roster "branch" codes (FA, Signal Corps, Cavalry, …) are arm/service, not Army/Marines.
addRow(tbl, 'Arm / service', prettyProse(armName))
end
-- years_of_service / date_arrive / date_depart / certificate stay in Technical
-- record only (rarely curated; certificate is a cryptic chart number).
addRow(tbl, 'ASN', parentArg(frame, 'asn'), 'serial')
 
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
 
local classRaw = parentArg(frame, 'class_number')
local classBits = classLinkList(classRaw, false)
if #classBits > 0 then
addRow(tbl, 'Camp Ritchie', 'Class ' .. table.concat(classBits, ', '))
end
addRow(tbl, 'Section', mw.ustring.upper(parentArg(frame, 'class_section')))
addRow(tbl, 'Other class', expandOtherClass(parentArg(frame, 'other_class')))
 
local enlist = prettyPlace(parentArg(frame, 'enlistment_place'))
if enlist ~= '' then
addRow(tbl, 'Enlisted', enlist)
end
addRow(tbl, 'Specialty', prettyProse(parentArg(frame, 'specialty')))
addRow(tbl, 'Unit / division', prettyProse(parentArg(frame, 'division')))
addRow(tbl, 'Battles / wars', formatBattles(parentArg(frame, 'battles')))
addTeamsInfoboxRow(tbl, frame)
 
-- Awards ribbons render in the article body (p.awardsSection), not this
-- narrow infobox — matching Wikipedia's main-column ribbon racks.
 
return tostring(tbl)
end
 
function p.awardsSection(frame)
local gridRaw = parentArg(frame, 'awards_grid')
local ribbons, labelsInRack = formatAwardsRibbons(
parentArg(frame, 'awards_ribbons'),
parentArg(frame, 'awards_ribbon_rows'),
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
if ribbons == '' and grid == '' and extras == '' then
return ''
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 parts = {
'<div class="soldier-awards-section">',
'<div class="soldier-awards-heading">Awards and honors</div>',
}
if ribbons ~= '' then
table.insert(parts, ribbons)
end
if grid ~= '' then
table.insert(parts, grid)
end
if extras ~= '' then
table.insert(parts, '<div class="soldier-awards-list">')
table.insert(parts, extras)
table.insert(parts, '</div>')
end
table.insert(parts, '</div>')
return table.concat(parts, '\n')
end
 
function p.lead(frame)
return buildLead(function(key)
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 ''
end
local startPos = mw.ustring.find(content, '== Research notes ==', 1, true)
if not startPos then
return ''
end
local after = mw.ustring.sub(content, startPos + 20)
local nextHead = mw.ustring.find(after, '\n== ', 1, true)
if nextHead then
return mw.ustring.sub(after, 1, nextHead - 1)
end
return after
end
 
local function miiPreferredLetter(num)
local ok, canon = pcall(require, 'Module:TeamCanon')
if ok and type(canon) == 'table' and type(canon.letter) == 'table' then
return canon.letter[tostring(num)]
end
return nil
end
 
local function noteTeamNames(text)
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
local work = text
for num, letter in string.gmatch(work, 'MII[%s%-]+(%d+)%s*%-%s*([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
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
for num, letter in string.gmatch(text, 'IPW%s+(%d+)%s*%-%s*([A-Z])') do
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
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
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