Soft launch: Soldier biographies and research notes vary in completeness. Prefer class-roster, NARA, newspapers, and other cited sources for contested facts — treat Research notes as leads, not settled history. Anyone can correct a page; contact us with questions.

Module:Soldier: Difference between revisions

From Ritchie Boys Wiki
Jump to navigation Jump to search
Opening summary: prefer full birth/death dates (Wikipedia style)
Infobox: drop certificate/arrive/depart/years; expand other_class codes
 
(22 intermediate revisions by the same user not shown)
Line 456: 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 471: 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 499: 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 668: 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
end
 
return name
local function normalizeNoteKey(s)
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
 
local function dateOnlyFragment(s)
return mw.ustring.match(trim(s), '^(%d%d?/%d%d?/%d%d%d?%d?)%.?$')
end
end


local function classLinkList(raw, withPrefix)
local function joinDateFollowons(items)
local ClassDates = require('Module:ClassDates')
local out = {}
local out = {}
for part in mw.text.gsplit(raw, ';', true) do
for _, clause in ipairs(items) do
part = trim(part)
local date = dateOnlyFragment(clause)
if part ~= '' then
if date and #out > 0 then
local info = ClassDates.lookup(part)
local prev = mw.ustring.gsub(out[#out], '[ ,;]+$', '')
local dates = info and info.dates or ''
if mw.ustring.sub(prev, -mw.ustring.len(date)) ~= date then
if withPrefix then
out[#out] = prev .. ', ' .. date
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
elseif clause ~= '' then
table.insert(out, clause)
end
end
end
end
Line 809: Line 851:
end
end


local function isJunkIpwItalianLabel(part)
local function sanitizeNote(s)
local lower = mw.ustring.lower(trim(part))
s = tidyDisplay(s)
local spaced = mw.ustring.gsub(lower, '%s+', ' ')
if s == '' then
local junk = {
return ''
['16063'] = true, ['16095'] = true, ['16302'] = true,
end
['16320'] = true, ['16695'] = true,
local lower = mw.ustring.lower(s)
['1st army'] = true, ['2 day'] = true, ['2/23/44'] = true,
if mw.ustring.find(lower, 'no notes were in the source', 1, true) then
['468th cic'] = true, ['5/7/45'] = true, ['8/24/44'] = true, ['8/7/43'] = true,
return ''
['apid'] = true, ['i-32'] = true, ['mis-eto'] = true, ['mitc'] = true,
end
['pac'] = true, ['sc7'] = true, ['shaef'] = true, ['v-831'] = true,
local kept = {}
}
for clause in mw.text.gsplit(s, ';', true) do
return junk[spaced] == true
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
return table.concat(kept, '; ')
end
end


local function normalizeIpwItalianCode(part)
local function containsIgnoreCase(haystack, needle)
part = trim(part)
return mw.ustring.find(mw.ustring.lower(haystack), mw.ustring.lower(needle), 1, true) ~= nil
if part == '' or isJunkIpwItalianLabel(part) then
end
return nil
 
local function displayName(frame)
local first = prettyNamePart(parentArg(frame, 'first'))
local middle = prettyNamePart(parentArg(frame, 'middle'))
local surname = prettyNamePart(parentArg(frame, 'surname'))
local suffix = prettyNamePart(parentArg(frame, 'suffix'))
local parts = {}
if first ~= '' then
table.insert(parts, first)
end
if middle ~= '' then
table.insert(parts, middle)
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
part = trim(part)
if part ~= '' then
local info = ClassDates.lookup(part)
local dates = info and info.dates or ''
if withPrefix then
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
table.insert(out, string.format('[[:Category:Class %s|%s]]', part, part))
end
end
end
end
end
local original = part
return out
part = mw.ustring.gsub(part, '%s*%?+%s*$', '')
end
part = trim(part)
 
if part == '' then
local function isJunkIpwItalianLabel(part)
return nil, original
local lower = mw.ustring.lower(trim(part))
end
local spaced = mw.ustring.gsub(lower, '%s+', ' ')
local lower = mw.ustring.lower(part)
local junk = {
if mw.ustring.find(lower, 'armd', 1, true) or mw.ustring.find(lower, 'armored', 1, true) then
['16063'] = true, ['16095'] = true, ['16302'] = true,
return nil, original, nil
['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
 
local function normalizeIpwItalianCode(part)
part = trim(part)
if part == '' or isJunkIpwItalianLabel(part) then
return nil
end
end
local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$')
local spaced = mw.ustring.lower(mw.ustring.gsub(part, '%s+', ' '))
if miiNum then
if spaced == '5th class' or spaced == 'italian 5th class' then
miiLetter = mw.ustring.upper(miiLetter)
return '5'
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
end
local num = mw.ustring.match(part, '^(%d+)[Tt]$')
return part
if num then
end
return num, num, nil
 
local function isJunkIpwLabel(part)
local lower = mw.ustring.lower(trim(part))
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
if mw.ustring.match(lower, '^%d+(st|nd|rd|th)$') then
local compact = mw.ustring.gsub(lower, '[%s%-]+', '')
return nil, original, nil
if compact == 'docteam' or compact == 'mfiu5' or compact == 'mitu5'
or compact == 'mrbc4' or compact == 'tforce' or compact == 'tocic' then
return true
end
end
if isJunkIpwLabel(part) then
return false
return nil, original, nil
end
return part, part, nil
end
end


local function teamLinkList(raw, pagePrefix, label)
local function normalizeIpwTeamCode(part)
local out = {}
part = trim(part)
local isIpw = mw.ustring.find(pagePrefix, 'IPW', 1, true) == 1
if part == '' then
for part in mw.text.gsplit(raw, ';', true) do
return nil, nil
part = trim(part)
end
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
local original = part
part = trim(part)
part = mw.ustring.gsub(part, '%s*%?+%s*$', '')
if part ~= '' then
part = trim(part)
if pagePrefix == 'IPW Italian ' then
if part == '' then
local code = normalizeIpwItalianCode(part)
return nil, original
if code then
end
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, code))
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, part)
return nil, original, nil
end
end
elseif isIpw then
local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$')
local code, shown, mii = normalizeIpwTeamCode(part)
if miiNum then
if mii then
miiLetter = mw.ustring.upper(miiLetter)
table.insert(out, string.format('[[Team MII %s|MII %s]]', mii, mii))
local mii = miiNum .. '-' .. miiLetter
elseif code then
if miiLetter == 'R' and (miiNum == '540' or miiNum == '545' or miiNum == '546') then
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, shown))
mii = miiNum
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 nil, mii, mii
end
end
return table.concat(out, ', ')
local num = mw.ustring.match(part, '^(%d+)[Tt]$')
end
if num then
 
return num, num, nil
local function addHeader(tbl, text)
tbl:tag('tr'):tag('th')
:attr('colspan', '2')
:addClass('infobox-header')
:wikitext(text)
end
 
local function realCertificate(s)
s = trim(s)
if s == '' then
return ''
end
end
-- Placeholders: *, A, 5, #, and any other single character.
if mw.ustring.match(lower, '^%d+(st|nd|rd|th)$') then
if mw.ustring.len(s) == 1 then
return nil, original, nil
return ''
end
end
-- NG = non-graduate marker on class charts, not a certificate number.
if isJunkIpwLabel(part) then
local upper = mw.ustring.upper(s)
return nil, original, nil
if upper == 'NG' or upper == 'N/G' or upper == 'N.G.' then
return ''
end
end
return s
return part, part, nil
end
end


local function addRow(tbl, label, value, dataClass)
local function teamLinkList(raw, pagePrefix, label)
if trim(value) == '' then
local out = {}
return
local isIpw = mw.ustring.find(pagePrefix, 'IPW', 1, true) == 1
end
for part in mw.text.gsplit(raw, ';', true) do
local tr = tbl:tag('tr')
part = trim(part)
tr:tag('th')
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
:addClass('infobox-label')
part = trim(part)
:css('display', 'table-cell')
if part ~= '' then
:css('width', '38%')
if pagePrefix == 'IPW Italian ' then
:css('font-weight', 'bold')
local code = normalizeIpwItalianCode(part)
:css('text-align', 'left')
if code then
:css('vertical-align', 'top')
table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, code))
:wikitext(label)
else
local td = tr:tag('td')
table.insert(out, part)
:addClass('infobox-data')
end
:css('display', 'table-cell')
elseif isIpw then
:css('vertical-align', 'top')
local code, shown, mii = normalizeIpwTeamCode(part)
if dataClass and dataClass ~= '' then
if mii then
td:addClass(dataClass)
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
td:wikitext(value)
return table.concat(out, ', ')
end
end


local function yearOnly(s)
local function addHeader(tbl, text)
s = trim(s)
tbl:tag('tr'):tag('th')
if s == '' or s == '?' then
:attr('colspan', '2')
return ''
:addClass('infobox-header')
:wikitext(text)
end
 
local function realCertificate(s)
s = trim(s)
if s == '' then
return ''
end
-- Placeholders: *, A, 5, #, and any other single character.
if mw.ustring.len(s) == 1 then
return ''
end
end
local year = mw.ustring.match(s, '(%d%d%d%d)')
-- NG = non-graduate marker on class charts, not a certificate number.
if year then
local upper = mw.ustring.upper(s)
return year
if upper == 'NG' or upper == 'N/G' or upper == 'N.G.' then
return ''
end
end
return ''
return s
end
end


local RANK_MAP = {
local function addRow(tbl, label, value, dataClass)
['PVT'] = 'Private',
if trim(value) == '' then
['PVTE'] = 'Private',
return
['PRIVATE'] = 'Private',
end
['PFC'] = 'Private First Class',
local tr = tbl:tag('tr')
['P F C'] = 'Private First Class',
tr:tag('th')
['PRIVATE FIRST CLASS'] = 'Private First Class',
:addClass('infobox-label')
['CPL'] = 'Corporal',
:css('display', 'table-cell')
['CORPORAL'] = 'Corporal',
:css('width', '38%')
['T/5'] = 'Technician Fifth Grade',
:css('font-weight', 'bold')
['TEC5'] = 'Technician Fifth Grade',
:css('text-align', 'left')
['TEC 5'] = 'Technician Fifth Grade',
:css('vertical-align', 'top')
['T5'] = 'Technician Fifth Grade',
:wikitext(label)
['TECH 5'] = 'Technician Fifth Grade',
local td = tr:tag('td')
['SGT'] = 'Sergeant',
:addClass('infobox-data')
['SERGEANT'] = 'Sergeant',
:css('display', 'table-cell')
['T/4'] = 'Technician Fourth Grade',
:css('vertical-align', 'top')
['TEC4'] = 'Technician Fourth Grade',
if dataClass and dataClass ~= '' then
['TEC 4'] = 'Technician Fourth Grade',
td:addClass(dataClass)
['T4'] = 'Technician Fourth Grade',
end
['TECH 4'] = 'Technician Fourth Grade',
td:wikitext(value)
['S/SGT'] = 'Staff Sergeant',
end
['SSGT'] = 'Staff Sergeant',
 
['S SGT'] = 'Staff Sergeant',
local function yearOnly(s)
['STAFF SERGEANT'] = 'Staff Sergeant',
s = trim(s)
['T/3'] = 'Technician Third Grade',
if s == '' or s == '?' then
['TEC3'] = 'Technician Third Grade',
return ''
['TEC 3'] = 'Technician Third Grade',
end
['T3'] = 'Technician Third Grade',
local year = mw.ustring.match(s, '(%d%d%d%d)')
['TECH 3'] = 'Technician Third Grade',
if year then
['T/SGT'] = 'Technical Sergeant',
return year
['TSGT'] = 'Technical Sergeant',
end
['T SGT'] = 'Technical Sergeant',
return ''
['TECHNICAL SERGEANT'] = 'Technical Sergeant',
end
['1ST SGT'] = 'First Sergeant',
 
['1SGT'] = 'First Sergeant',
local RANK_MAP = {
['FIRST SERGEANT'] = 'First Sergeant',
['PVT'] = 'Private',
['M/SGT'] = 'Master Sergeant',
['PVTE'] = 'Private',
['MSGT'] = 'Master Sergeant',
['PRIVATE'] = 'Private',
['MASTER SERGEANT'] = 'Master Sergeant',
['PFC'] = 'Private First Class',
['WO'] = 'Warrant Officer',
['P F C'] = 'Private First Class',
['WOJG'] = 'Warrant Officer',
['PRIVATE FIRST CLASS'] = 'Private First Class',
['WARRANT OFFICER'] = 'Warrant Officer',
['CPL'] = 'Corporal',
['CWO'] = 'Chief Warrant Officer',
['CORPORAL'] = 'Corporal',
['2ND LT'] = 'Second Lieutenant',
['T/5'] = 'Technician Fifth Grade',
['2D LT'] = 'Second Lieutenant',
['TEC5'] = 'Technician Fifth Grade',
['2LT'] = 'Second Lieutenant',
['TEC 5'] = 'Technician Fifth Grade',
['SECOND LIEUTENANT'] = 'Second Lieutenant',
['T5'] = 'Technician Fifth Grade',
['1ST LT'] = 'First Lieutenant',
['TECH 5'] = 'Technician Fifth Grade',
['1LT'] = 'First Lieutenant',
['SGT'] = 'Sergeant',
['FIRST LIEUTENANT'] = 'First Lieutenant',
['SERGEANT'] = 'Sergeant',
['LT'] = 'Lieutenant',
['T/4'] = 'Technician Fourth Grade',
['LIEUTENANT'] = 'Lieutenant',
['TEC4'] = 'Technician Fourth Grade',
['CAPT'] = 'Captain',
['TEC 4'] = 'Technician Fourth Grade',
['CPT'] = 'Captain',
['T4'] = 'Technician Fourth Grade',
['CAPTAIN'] = 'Captain',
['TECH 4'] = 'Technician Fourth Grade',
['MAJ'] = 'Major',
['S/SGT'] = 'Staff Sergeant',
['MAJOR'] = 'Major',
['SSGT'] = 'Staff Sergeant',
['LT COL'] = 'Lieutenant Colonel',
['S SGT'] = 'Staff Sergeant',
['LTCOL'] = 'Lieutenant Colonel',
['STAFF SERGEANT'] = 'Staff Sergeant',
['LTC'] = 'Lieutenant Colonel',
['T/3'] = 'Technician Third Grade',
['LIEUTENANT COLONEL'] = 'Lieutenant Colonel',
['TEC3'] = 'Technician Third Grade',
['COL'] = 'Colonel',
['TEC 3'] = 'Technician Third Grade',
['COLONEL'] = 'Colonel',
['T3'] = 'Technician Third Grade',
['BRIG GEN'] = 'Brigadier General',
['TECH 3'] = 'Technician Third Grade',
['BG'] = 'Brigadier General',
['T/SGT'] = 'Technical Sergeant',
['BRIGADIER GENERAL'] = 'Brigadier General',
['TSGT'] = 'Technical Sergeant',
['MAJ GEN'] = 'Major General',
['T SGT'] = 'Technical Sergeant',
['MG'] = 'Major General',
['TECHNICAL SERGEANT'] = 'Technical Sergeant',
['MAJOR GENERAL'] = 'Major General',
['1ST SGT'] = 'First Sergeant',
['LT GEN'] = 'Lieutenant General',
['1SGT'] = 'First Sergeant',
['LIEUTENANT GENERAL'] = 'Lieutenant General',
['FIRST SERGEANT'] = 'First Sergeant',
['GEN'] = 'General',
['M/SGT'] = 'Master Sergeant',
['GENERAL'] = 'General',
['MSGT'] = 'Master Sergeant',
['TECH'] = 'Technician',
['MASTER SERGEANT'] = 'Master Sergeant',
['TEC'] = 'Technician',
['WO'] = 'Warrant Officer',
}
['WOJG'] = 'Warrant Officer',
 
['WARRANT OFFICER'] = 'Warrant Officer',
local function expandRank(raw)
['CWO'] = 'Chief Warrant Officer',
raw = trim(raw)
['2ND LT'] = 'Second Lieutenant',
if raw == '' then
['2D LT'] = 'Second Lieutenant',
return ''
['2LT'] = 'Second Lieutenant',
end
['SECOND LIEUTENANT'] = 'Second Lieutenant',
local key = mw.ustring.upper(raw)
['1ST LT'] = 'First Lieutenant',
key = mw.ustring.gsub(key, '%.', '')
['1LT'] = 'First Lieutenant',
key = mw.ustring.gsub(key, '%s+', ' ')
['FIRST LIEUTENANT'] = 'First Lieutenant',
key = trim(key)
['LT'] = 'Lieutenant',
return RANK_MAP[key] or raw
['LIEUTENANT'] = 'Lieutenant',
end
['CAPT'] = 'Captain',
 
['CPT'] = 'Captain',
local BRANCH_MAP = {
['CAPTAIN'] = 'Captain',
['INF'] = 'Infantry',
['MAJ'] = 'Major',
['INFANTRY'] = 'Infantry',
['MAJOR'] = 'Major',
['FA'] = 'Field Artillery',
['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)
raw = trim(raw)
if raw == '' then
return ''
end
local key = mw.ustring.upper(raw)
key = mw.ustring.gsub(key, '%.', '')
key = mw.ustring.gsub(key, '%s+', ' ')
key = trim(key)
return RANK_MAP[key] or raw
end
 
local BRANCH_MAP = {
['INF'] = 'Infantry',
['INFANTRY'] = 'Infantry',
['FA'] = 'Field Artillery',
['FIELD ARTILLERY'] = 'Field Artillery',
['FIELD ARTILLERY'] = 'Field Artillery',
['CAC'] = 'Coast Artillery Corps',
['CAC'] = 'Coast Artillery Corps',
Line 1,087: Line 1,213:
['CAV'] = 'Cavalry',
['CAV'] = 'Cavalry',
['CAVALRY'] = 'Cavalry',
['CAVALRY'] = 'Cavalry',
['CALVALRY'] = 'Cavalry',
['CE'] = 'Corps of Engineers',
['CE'] = 'Corps of Engineers',
['ENG'] = 'Corps of Engineers',
['ENG'] = 'Corps of Engineers',
Line 1,155: Line 1,282:
end
end


-- Small public-domain / Commons emblems uploaded as File:Branch emblem *.png
local function keyEqualsAAF(part)
local BRANCH_EMBLEM = {
local key = mw.ustring.upper(trim(part))
['United States Army'] = 'Branch emblem US Army.png',
key = mw.ustring.gsub(key, '%.', '')
['United States Marine Corps'] = 'Branch emblem US Marine Corps.png',
key = mw.ustring.gsub(key, '%s+', ' ')
['United States Navy'] = 'Branch emblem US Navy.png',
return key == 'AAF' or key == 'ARMY AIR FORCES'
['United States Army Air Forces'] = 'Branch emblem US Army Air Forces.png',
end
['United States Air Force'] = 'Branch emblem US Air Force.png',
 
['United States Coast Guard'] = 'Branch emblem US Coast Guard.png',
-- True when a BRANCH_MAP / class_branch value is an Army arm/service, not Army/Marines/Navy.
local SERVICE_BRANCH_KEY = {
['AUS'] = true,
['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 branchWithEmblem(name)
local function isServiceBranchName(name)
name = trim(name)
name = trim(name)
if name == '' then
if name == '' then
return ''
return false
end
end
local file = BRANCH_EMBLEM[name]
local key = mw.ustring.upper(name)
if file then
key = mw.ustring.gsub(key, '%.', '')
return '[[File:' .. file .. '|18px|link=|alt=]] ' .. name
key = mw.ustring.gsub(key, '%s+', ' ')
key = trim(key)
if SERVICE_BRANCH_KEY[key] then
return true
end
end
-- Fuzzy contains
local low = mw.ustring.lower(name)
local low = mw.ustring.lower(name)
if mw.ustring.find(low, 'marine', 1, true) then
if mw.ustring.find(low, 'marine', 1, true)
return '[[File:Branch emblem US Marine Corps.png|18px|link=|alt=]] ' .. name
or mw.ustring.find(low, 'navy', 1, true)
or mw.ustring.find(low, 'coast guard', 1, true) then
return true
end
end
if mw.ustring.find(low, 'navy', 1, true) then
if mw.ustring.find(low, 'air force', 1, true)
return '[[File:Branch emblem US Navy.png|18px|link=|alt=]] ' .. name
or mw.ustring.find(low, 'air forces', 1, true) then
return true
end
end
if mw.ustring.find(low, 'air force', 1, true) or mw.ustring.find(low, 'air forces', 1, true) then
if mw.ustring.find(low, 'united states army', 1, true)
return '[[File:Branch emblem US Army Air Forces.png|18px|link=|alt=]] ' .. name
or low == 'army'
or low == 'u.s. army'
or low == 'us army' then
return true
end
end
if mw.ustring.find(low, 'coast guard', 1, true) then
return false
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
return name
end
end


local function expandBranch(raw)
local function isArmOfServiceToken(raw)
raw = trim(raw)
raw = trim(raw)
if raw == '' then
if raw == '' then
return ''
return false
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 SERVICE_BRANCH_KEY[key] then
end
return false
end
end
return table.concat(parts, '; ')
if BRANCH_MAP[key] then
end
return true
 
local function formatBattles(raw)
raw = trim(raw)
if raw == '' then
return ''
end
end
-- Allow HTML <br /> or semicolon / newline separated campaigns
local low = mw.ustring.lower(raw)
raw = mw.ustring.gsub(raw, '\r', '')
if mw.ustring.find(low, 'calvalry', 1, true) then
if mw.ustring.find(raw, '<br', 1, true) or mw.ustring.find(raw, '\n', 1, true) then
return true
return raw
end
end
local parts = {}
for _, needle in ipairs({
for part in mw.text.gsplit(raw, ';', true) do
'artillery', 'signal', 'cavalry', 'infantry', 'engineer',
part = trim(part)
'ordnance', 'quartermaster', 'medical', 'police', 'intelligence',
if part ~= '' then
'armor', 'tank destroyer', 'chemical', 'chaplain', 'transportation',
table.insert(parts, part)
'adjutant',
}) do
if mw.ustring.find(low, needle, 1, true) then
return true
end
end
end
end
if #parts <= 1 then
return false
return raw
end
return table.concat(parts, '<br />')
end
end


local function splitAwardRows(raw)
local function splitBranchAndArm(branchRaw, classBranchRaw, armRaw)
raw = trim(raw)
branchRaw = trim(branchRaw)
if raw == '' then
classBranchRaw = trim(classBranchRaw)
return {}
armRaw = trim(armRaw)
 
local service = ''
local arms = {}
 
local function consider(raw, preferArm)
raw = trim(raw)
if raw == '' then
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
-- Prefer @@ row separator (safe inside {{Soldier}}); also accept legacy |||
 
local sep = '@@'
-- Explicit arm_or_service first
if not mw.ustring.find(raw, '@@', 1, true) and mw.ustring.find(raw, '|||', 1, true) then
consider(armRaw, true)
sep = '|||'
-- Roster class_branch is almost always arm/service within the Army
end
consider(classBranchRaw, true)
local rows = {}
-- |branch= may be either
for row in mw.text.gsplit(raw, sep, true) do
consider(branchRaw, false)
row = trim(row)
 
if row ~= '' then
local armJoined = table.concat(arms, '; ')
local cells = {}
return service, armJoined
for cell in mw.text.gsplit(row, ';;', true) do
end
cell = trim(cell)
 
-- Unescape {{!}} used if a label ever needed a pipe
-- Small public-domain / Commons emblems uploaded as File:Branch emblem *.png
cell = mw.ustring.gsub(cell, '{{!}}', '|')
local BRANCH_EMBLEM = {
if cell ~= '' then
['United States Army'] = 'Branch emblem US Army.png',
table.insert(cells, cell)
['United States Marine Corps'] = 'Branch emblem US Marine Corps.png',
end
['United States Navy'] = 'Branch emblem US Navy.png',
end
['United States Army Air Forces'] = 'Branch emblem US Army Air Forces.png',
if #cells > 0 then
['United States Air Force'] = 'Branch emblem US Air Force.png',
table.insert(rows, cells)
['United States Coast Guard'] = 'Branch emblem US Coast Guard.png',
end
}
end
 
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
return rows
return name
end
end


local function formatAwardsRibbons(raw, rowRaw)
local function expandBranch(raw)
-- Prefer explicit Wikipedia row layout when present
raw = trim(raw)
local rows = splitAwardRows(rowRaw or '')
if raw == '' then
if #rows == 0 then
return ''
raw = trim(raw)
end
if raw == '' then
local parts = {}
return ''
for part in mw.text.gsplit(raw, ';', true) do
end
part = expandBranchToken(part)
local files = {}
if part ~= '' then
for part in mw.text.gsplit(raw, ';', true) do
table.insert(parts, branchWithEmblem(part))
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
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
return table.concat(parts, '; ')
local out = {}
for _, cells in ipairs(rows) do
table.insert(out, '{| class="soldier-ribbon-rack"')
table.insert(out, '|-')
for _, file in ipairs(cells) do
file = mw.ustring.gsub(file, '^[Ff]ile:', '')
table.insert(out, '| [[File:' .. file .. '|106px|link=]]')
end
table.insert(out, '|}')
end
return table.concat(out, '\n')
end
end


local function awardGridColspanUnits(rows)
local function formatBattles(raw)
-- LCM of row lengths so 2-cell and 3-cell rows share one table width
raw = trim(raw)
local function gcd(a, b)
if raw == '' then
while b ~= 0 do
return ''
a, b = b, a % b
end
return a
end
end
local function lcm(a, b)
-- Allow HTML <br /> or semicolon / newline separated campaigns
if a == 0 or b == 0 then
raw = mw.ustring.gsub(raw, '\r', '')
return a + b
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
return (a / gcd(a, b)) * b
end
end
local units = 0
if #parts <= 1 then
for _, cells in ipairs(rows) do
return raw
local n = #cells
if n > 0 then
units = lcm(units, n)
end
end
end
return units > 0 and units or 1
return table.concat(parts, '<br />')
end
end


local function formatAwardsGrid(raw)
local function splitAwardRows(raw)
local rows = splitAwardRows(raw)
raw = trim(raw)
if #rows == 0 then
if raw == '' then
return ''
return {}
end
end
local units = awardGridColspanUnits(rows)
-- Prefer @@ row separator (safe inside {{Soldier}}); also accept legacy |||
local out = { '{| class="wikitable soldier-awards-grid"' }
local sep = '@@'
for _, cells in ipairs(rows) do
if not mw.ustring.find(raw, '@@', 1, true) and mw.ustring.find(raw, '|||', 1, true) then
local n = #cells
sep = '|||'
if n == 0 then
end
-- skip
local rows = {}
else
for row in mw.text.gsplit(raw, sep, true) do
local span = units / n
row = trim(row)
table.insert(out, '|-')
if row ~= '' then
for _, cell in ipairs(cells) do
local cells = {}
-- Allow <br /> from Wikipedia-style multi-line labels
for cell in mw.text.gsplit(row, ';;', true) do
table.insert(out, '| colspan="' .. tostring(span) .. '" | ' .. cell)
cell = trim(cell)
end
-- Unescape {{!}} used if a label ever needed a pipe
end
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
 
local function awardRibbonRows(raw, rowRaw)
-- Prefer explicit Wikipedia row layout when present
local rows = splitAwardRows(rowRaw or '')
if #rows > 0 then
return rows
end
end
table.insert(out, '|}')
return table.concat(out, '\n')
end
local function formatAwardsLegend(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
end
local parts = {}
local files = {}
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*', '')
part = mw.ustring.gsub(part, '^[Ff]ile:', '')
if part ~= '' then
if part ~= '' then
if isAllCapsPhrase(part) then
table.insert(files, part)
part = prettyTitle(part, true)
end
end
end
table.insert(parts, '* ' .. part)
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
if #parts == 0 then
if #cur > 0 then
return ''
table.insert(rows, cur)
end
end
return table.concat(parts, '\n')
return rows
end
end


local function lifespan(birth, death)
local function awardRowsMatch(ribbonRows, labelRows)
birth = trim(birth or '')
if #ribbonRows == 0 or #ribbonRows ~= #labelRows then
death = trim(death or '')
return false
if birth ~= '' and death ~= '' then
return ' (' .. birth .. ' &ndash; ' .. death .. ')'
end
end
if birth ~= '' then
for i, cells in ipairs(ribbonRows) do
return ' (born ' .. birth .. ')'
if #cells ~= #(labelRows[i] or {}) then
return false
end
end
end
if death ~= '' then
return true
return ' (died ' .. death .. ')'
end
return ''
end
end


--- Prefer full birth/death dates (Wikipedia style); fall back to years.
local function formatAwardsRibbons(raw, rowRaw, labelRaw)
local function lifespanFromArgs(getArg)
local rows = awardRibbonRows(raw, rowRaw)
local birth = prettyProse(getArg('birth_date'))
if #rows == 0 then
local death = prettyProse(getArg('death_date'))
return '', false
if birth == '' then
birth = yearOnly(getArg('birth_year'))
end
end
if death == '' then
local labelRows = splitAwardRows(labelRaw or '')
death = yearOnly(getArg('death_year'))
local labeled = awardRowsMatch(rows, labelRows)
-- One table per row so short rows (e.g. 2 ribbons) center like full rows.
-- When labels match ribbon cells, put captions in the same table so each
-- 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
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 lifespan(birth, death)
return table.concat(out, '\n'), labeled
end
end


local function preferredRank(frame)
local function awardGridColspanUnits(rows)
local rank = expandRank(parentArg(frame, 'rank_final'))
-- LCM of row lengths so 2-cell and 3-cell rows share one table width
if rank == '' then
local function gcd(a, b)
rank = expandRank(parentArg(frame, 'class_rank'))
while b ~= 0 do
a, b = b, a % b
end
return a
end
local function lcm(a, b)
if a == 0 or b == 0 then
return a + b
end
return (a / gcd(a, b)) * b
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 rank
return units > 0 and units or 1
end
end


function p.displayName(frame)
local function formatAwardsGrid(raw)
return displayName(frame)
local rows = splitAwardRows(raw)
end
if #rows == 0 then
 
return ''
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 units = awardGridColspanUnits(rows)
table.insert(parts, middle)
local out = { '{| class="wikitable soldier-awards-grid"' }
for _, cells in ipairs(rows) do
local n = #cells
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
if surname ~= '' then
table.insert(out, '|}')
table.insert(parts, surname)
return table.concat(out, '\n')
end
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 preferredRankFrom(getArg)
local function formatAwardsLegend(raw)
local rank = expandRank(getArg('rank_final'))
-- Do not run prettyProse/prettyPlace — commas in prose (e.g. statue note)
if rank == '' then
-- get misread as place names and Title-Cased / rewrapped.
rank = expandRank(getArg('class_rank'))
raw = trim(raw)
if raw == '' then
return ''
end
end
return rank
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
return table.concat(parts, '\n')
end
end


local function buildLead(getArg)
local function lifespan(birth, death)
local name = displayNameFrom(getArg)
birth = trim(birth or '')
local rank = preferredRankFrom(getArg)
death = trim(death or '')
local role = 'United States Army soldier'
if birth ~= '' and death ~= '' then
if rank ~= '' then
return ' (' .. birth .. ' &ndash; ' .. death .. ')'
role = 'United States Army ' .. rank
end
end
 
if birth ~= '' then
local classLinks = classLinkList(getArg('class_number'), true)
return ' (born ' .. birth .. ')'
local trained
end
if #classLinks == 1 then
if death ~= '' then
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1]
return ' (died ' .. death .. ')'
elseif #classLinks == 2 then
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1] .. ' and ' .. classLinks[2]
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
return ''
end


local lead = string.format(
--- Prefer full birth/death dates (Wikipedia style); fall back to years.
"'''%s'''%s was a %s%s.",
local function lifespanFromArgs(getArg)
name,
local birth = prettyProse(getArg('birth_date'))
lifespanFromArgs(getArg),
local death = prettyProse(getArg('death_date'))
role,
if birth == '' then
trained
birth = yearOnly(getArg('birth_year'))
)
local who = prettyNamePart(getArg('first'))
if who == '' then
who = prettyNamePart(getArg('surname'))
end
end
if who == '' then
if death == '' then
who = 'He'
death = yearOnly(getArg('death_year'))
end
end
local enlist = prettyPlace(getArg('enlistment_place'))
return lifespan(birth, death)
if enlist ~= '' then
end
lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.'
 
local function preferredRank(frame)
local rank = expandRank(parentArg(frame, 'rank_final'))
if rank == '' then
rank = expandRank(parentArg(frame, 'class_rank'))
end
end
return lead
return rank
end
end


function p.classLinks(frame)
function p.displayName(frame)
return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
return displayName(frame)
end
end


local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' }
local function displayNameFrom(getArg)
 
local first = prettyNamePart(getArg('first'))
local function photoFileExists(filename)
local middle = prettyNamePart(getArg('middle'))
filename = trim(filename)
local surname = prettyNamePart(getArg('surname'))
if filename == '' then
local suffix = prettyNamePart(getArg('suffix'))
return false
local parts = {}
if first ~= '' then
table.insert(parts, first)
end
end
local title = mw.title.new('File:' .. filename)
if middle ~= '' then
return title ~= nil and title.exists
table.insert(parts, middle)
end
 
local function pushPhotoCandidate(candidates, seen, value)
value = trim(value)
if value == '' or seen[value] then
return
end
end
seen[value] = true
if surname ~= '' then
table.insert(candidates, value)
table.insert(parts, surname)
end
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 resolvePhoto(frame)
local function preferredRankFrom(getArg)
local explicit = trim(parentArg(frame, 'photo'))
local rank = expandRank(getArg('rank_final'))
if explicit ~= '' then
if rank == '' then
return explicit
rank = expandRank(getArg('class_rank'))
end
end
return rank
end


local first = trim(parentArg(frame, 'first'))
local function buildLead(getArg)
local surname = trim(parentArg(frame, 'surname'))
local name = displayNameFrom(getArg)
local asn = trim(parentArg(frame, 'asn'))
local rank = preferredRankFrom(getArg)
if first == '' or surname == '' then
local role = 'United States Army soldier'
return ''
if rank ~= '' then
role = 'United States Army ' .. rank
end
end


local candidates = {}
local classLinks = classLinkList(getArg('class_number'), true)
local seen = {}
local trained
local lowerFirst = mw.ustring.lower(first)
if #classLinks == 1 then
local lowerSurname = mw.ustring.lower(surname)
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1]
local bases = {
elseif #classLinks == 2 then
first .. ' ' .. surname,
trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1] .. ' and ' .. classLinks[2]
first .. ' ' .. lowerSurname,
elseif #classLinks > 2 then
lowerFirst .. ' ' .. lowerSurname,
trained = ' who trained at [[Camp Ritchie]], Maryland, in '
first .. '_' .. surname,
.. table.concat(classLinks, ', ', 1, #classLinks - 1)
first .. '_' .. lowerSurname,
.. ', and ' .. classLinks[#classLinks]
lowerFirst .. '_' .. lowerSurname,
else
surname .. ', ' .. first,
trained = ' associated with Camp Ritchie, Maryland'
surname .. '_' .. first,
}
if asn ~= '' then
table.insert(bases, surname .. ', ' .. first .. ' (' .. asn .. ')')
end
for _, base in ipairs(bases) do
pushPhotoCandidate(candidates, seen, base)
end
end


for _, base in ipairs(candidates) do
local lead = string.format(
for _, ext in ipairs(PHOTO_EXTENSIONS) do
"'''%s'''%s was a %s%s.",
local filename = base .. '.' .. ext
name,
if photoFileExists(filename) then
lifespanFromArgs(getArg),
return filename
role,
end
trained
local upperExt = mw.ustring.upper(ext)
)
if upperExt ~= ext then
local who = prettyNamePart(getArg('first'))
filename = base .. '.' .. upperExt
if who == '' then
if photoFileExists(filename) then
who = prettyNamePart(getArg('surname'))
return filename
end
end
if who == '' then
end
who = 'He'
end
end
local enlist = prettyPlace(getArg('enlistment_place'))
if enlist ~= '' then
lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.'
end
end
return ''
return lead
end
end


function p.infobox(frame)
function p.classLinks(frame)
local name = displayName(frame)
return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
local tbl = mw.html.create('table')
end
tbl:addClass('infobox'):addClass('vcard'):addClass('soldier-infobox')


tbl:tag('tr'):tag('th')
local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' }
:attr('colspan', '2')
:addClass('infobox-above fn')
:wikitext(name)


local photo = resolvePhoto(frame)
local function photoFileExists(filename)
if photo ~= '' then
filename = trim(filename)
local size = parentArg(frame, 'image_size')
if filename == '' then
if size == '' then
return false
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
 
local title = mw.title.new('File:' .. filename)
local alias = prettyNamePart(parentArg(frame, 'alias'))
return title ~= nil and title.exists
local birthYear = yearOnly(parentArg(frame, 'birth_year'))
end
local deathYear = yearOnly(parentArg(frame, 'death_year'))
 
local birthDate = prettyProse(parentArg(frame, 'birth_date'))
local function pushPhotoCandidate(candidates, seen, value)
local deathDate = prettyProse(parentArg(frame, 'death_date'))
value = trim(value)
local birthPlace = prettyProse(parentArg(frame, 'birth_place'))
if value == '' or seen[value] then
local deathPlace = prettyProse(parentArg(frame, 'death_place'))
return
local nativity = expandNativity(parentArg(frame, 'nativity'))
local religion = expandReligion(parentArg(frame, 'religion'))
local marital = prettyProse(parentArg(frame, 'marital'))
local education = prettyProse(parentArg(frame, 'education'))
local occupation = prettyProse(parentArg(frame, 'occupation'))
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
local state = prettyPlace(parentArg(frame, 'state'))
seen[value] = true
table.insert(candidates, value)
end


local function datePlaceCell(date, place, year)
local function resolvePhoto(frame)
local bits = {}
local explicit = trim(parentArg(frame, 'photo'))
if date ~= '' then
if explicit ~= '' then
table.insert(bits, date)
return explicit
elseif year ~= '' then
table.insert(bits, year)
end
if place ~= '' then
table.insert(bits, place)
end
return table.concat(bits, '<br />')
end
end


local birth = datePlaceCell(birthDate, birthPlace, birthYear)
local first = trim(parentArg(frame, 'first'))
local death = datePlaceCell(deathDate, deathPlace, deathYear)
local surname = trim(parentArg(frame, 'surname'))
 
local asn = trim(parentArg(frame, 'asn'))
if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= ''
if first == '' or surname == '' then
or religion ~= '' or marital ~= '' or education ~= ''
return ''
or occupation ~= '' or knownFor ~= '' or awards ~= '' or state ~= '' then
addHeader(tbl, 'Personal details')
end
end


addRow(tbl, 'Birth name', alias, 'nickname')
local candidates = {}
addRow(tbl, 'Born', birth)
local seen = {}
addRow(tbl, 'Died', death)
local lowerFirst = mw.ustring.lower(first)
addRow(tbl, 'Nativity', nativity)
local lowerSurname = mw.ustring.lower(surname)
addRow(tbl, 'Religion', religion)
local bases = {
addRow(tbl, 'Spouse / marital', marital)
first .. ' ' .. surname,
addRow(tbl, 'Education', education)
first .. ' ' .. lowerSurname,
addRow(tbl, 'Occupation', occupation)
lowerFirst .. ' ' .. lowerSurname,
addRow(tbl, 'Known for', knownFor)
first .. '_' .. surname,
addRow(tbl, 'Awards', awards)
first .. '_' .. lowerSurname,
if state ~= '' then
lowerFirst .. '_' .. lowerSurname,
addRow(tbl, 'State', state)
surname .. ', ' .. first,
surname .. '_' .. first,
}
if asn ~= '' then
table.insert(bases, surname .. ', ' .. first .. ' (' .. asn .. ')')
end
end
 
for _, base in ipairs(bases) do
addHeader(tbl, 'Military service')
pushPhotoCandidate(candidates, seen, base)
addRow(tbl, 'Allegiance', '[[File:Flag of the United States.png|20px|link=|alt=]] United States')
local branchRaw = parentArg(frame, 'class_branch')
if branchRaw == '' then
branchRaw = parentArg(frame, 'branch')
end
end
local branch = expandBranch(branchRaw)
if branch == '' then
branch = branchWithEmblem('United States Army')
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'))
for _, base in ipairs(candidates) do
local classRank = expandRank(parentArg(frame, 'class_rank'))
for _, ext in ipairs(PHOTO_EXTENSIONS) do
if rankFinal ~= '' and classRank ~= '' and classRank ~= rankFinal then
local filename = base .. '.' .. ext
addRow(tbl, 'Rank', rankFinal)
if photoFileExists(filename) then
addRow(tbl, 'Rank at Ritchie', classRank)
return filename
else
end
local rank = rankFinal
local upperExt = mw.ustring.upper(ext)
if rank == '' then
if upperExt ~= ext then
rank = classRank
filename = base .. '.' .. upperExt
if photoFileExists(filename) then
return filename
end
end
end
end
addRow(tbl, 'Rank', rank)
end
end
return ''
end


local classRaw = parentArg(frame, 'class_number')
-- Filled after noteTeamNames (local forward declare; used by p.infobox).
local classBits = classLinkList(classRaw, false)
local addTeamsInfoboxRow
if #classBits > 0 then
 
addRow(tbl, 'Camp Ritchie', 'Class ' .. table.concat(classBits, ', '))
function p.infobox(frame)
end
local name = displayName(frame)
addRow(tbl, 'Section', parentArg(frame, 'class_section'))
local tbl = mw.html.create('table')
addRow(tbl, 'Other class', parentArg(frame, 'other_class'))
tbl:addClass('infobox'):addClass('vcard'):addClass('soldier-infobox')


local enlist = prettyPlace(parentArg(frame, 'enlistment_place'))
tbl:tag('tr'):tag('th')
if enlist ~= '' then
:attr('colspan', '2')
addRow(tbl, 'Enlisted', enlist)
:addClass('infobox-above fn')
end
:wikitext(name)
addRow(tbl, 'Specialty', prettyProse(parentArg(frame, 'specialty')))
addRow(tbl, 'Unit / division', prettyProse(parentArg(frame, 'division')))
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
local photo = resolvePhoto(frame)
-- narrow infobox — matching Wikipedia's main-column ribbon racks.
if photo ~= '' then
 
local size = parentArg(frame, 'image_size')
return tostring(tbl)
if size == '' then
end
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


function p.awardsSection(frame)
local alias = prettyNamePart(parentArg(frame, 'alias'))
local ribbons = formatAwardsRibbons(
local birthYear = yearOnly(parentArg(frame, 'birth_year'))
parentArg(frame, 'awards_ribbons'),
local deathYear = yearOnly(parentArg(frame, 'death_year'))
parentArg(frame, 'awards_ribbon_rows')
local birthDate = prettyProse(parentArg(frame, 'birth_date'))
)
local deathDate = prettyProse(parentArg(frame, 'death_date'))
local grid = formatAwardsGrid(parentArg(frame, 'awards_grid'))
local birthPlace = prettyProse(parentArg(frame, 'birth_place'))
local extras = formatAwardsLegend(parentArg(frame, 'awards_extra'))
local deathPlace = prettyProse(parentArg(frame, 'death_place'))
-- Fallback: flat awards list when no grid was extracted
local nativity = expandNativity(parentArg(frame, 'nativity'))
if grid == '' and extras == '' then
local religion = expandReligion(parentArg(frame, 'religion'))
extras = formatAwardsLegend(parentArg(frame, 'awards'))
-- 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
if ribbons == '' and grid == '' and extras == '' then
 
return ''
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
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 birth = datePlaceCell(birthDate, birthPlace, birthYear)
local parts = {
local death = datePlaceCell(deathDate, deathPlace, deathYear)
'<div class="soldier-awards-section">',
 
'<div class="soldier-awards-heading">Awards and honors</div>',
if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= ''
}
or religion ~= '' or knownFor ~= '' or awards ~= '' then
if ribbons ~= '' then
addHeader(tbl, 'Personal details')
table.insert(parts, ribbons)
end
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)
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
 
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
end
work = string.gsub(work, 'MII[%s%-]+%d+%s*%-%s*[A-Z]', ' ')
if ribbons == '' and grid == '' and extras == '' then
for num, letter in string.gmatch(' ' .. text .. ' ', '[^%d]([45]%d%d)%s*%-%s*([A-Z])[^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
end
for num in string.gmatch(work, 'MII[%s%-]+(%d+)') do
-- Use a styled div heading (not == wiki heading) so MediaWiki does not place
if not letteredNums[num] then
-- the Contents box inside this block — JS moves the block after Biography.
local preferred = miiPreferredLetter(num)
local parts = {
if preferred then
'<div class="soldier-awards-section">',
add('MII ' .. num .. '-' .. preferred)
'<div class="soldier-awards-heading">Awards and honors</div>',
else
}
add('MII ' .. num)
if ribbons ~= '' then
end
table.insert(parts, ribbons)
end
end
end
for num, letter in string.gmatch(text, 'IPW%s+(%d+)%s*%-%s*([A-Z])') do
if grid ~= '' then
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
table.insert(parts, grid)
add('MII ' .. num)
else
add('MII ' .. num .. '-' .. letter)
end
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
 
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
end


function p.noteTeamCategories(frame)
local function researchNotesSection(content)
local title = mw.title.getCurrentTitle()
if not content or content == '' then
if not title or not title.getContent 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 ''))
end
text = mw.ustring.gsub(text, mw.ustring.char(0x2013), '-')
local section = researchNotesSection(content)
text = mw.ustring.gsub(text, mw.ustring.char(0x2014), '-')
if section == '' then
text = mw.ustring.gsub(text, mw.ustring.char(0x2212), '-')
return ''
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
end
local items = {}
work = string.gsub(work, 'MII[%s%-]+%d+%s*%-%s*[A-Z]', ' ')
for line in mw.text.gsplit(section, '\n', true) do
for num, letter in string.gmatch(' ' .. text .. ' ', '[^%d]([45]%d%d)%s*%-%s*([A-Z])[^A-Z]') do
line = trim(line)
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
if mw.ustring.sub(line, 1, 2) == '* ' then
add('MII ' .. num)
local note = sanitizeNote(mw.ustring.sub(line, 3))
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

Latest revision as of 21:40, 16 September 2026

Documentation for this module may be created at Module:Soldier/doc

local p = {}

local function trim(s)
	return mw.text.trim(tostring(s or ''))
end

local function countChar(s, ch)
	local _, n = mw.ustring.gsub(s, ch, '')
	return n
end

local function tidyDisplay(s)
	s = trim(s)
	if s == '' then
		return ''
	end
	s = mw.text.decode(s, true)
	s = mw.ustring.gsub(s, mw.ustring.char(160), ' ')
	s = mw.ustring.gsub(s, mw.ustring.char(0xFFFD), '')
	s = mw.ustring.gsub(s, mw.ustring.char(0x200B), '')
	s = trim(s)
	local lower = mw.ustring.lower(s)
	if lower == '#n/a' or lower == 'n/a' or lower == 'none' or lower == 'null' or lower == '-' then
		return ''
	end
	if s == '.' or s == '?' then
		return ''
	end
	local prev
	repeat
		prev = s
		s = mw.ustring.gsub(s, '""', '"')
	until s == prev
	s = trim(s)

	local changed = true
	while changed and mw.ustring.len(s) >= 2 do
		changed = false
		local first = mw.ustring.sub(s, 1, 1)
		local last = mw.ustring.sub(s, -1)
		if first == '"' and last == '"' then
			local inner = mw.ustring.sub(s, 2, -2)
			if countChar(inner, '"') == 0 then
				s = trim(inner)
				changed = true
			elseif countChar(s, '"') % 2 == 1 then
				local second = mw.ustring.find(s, '"', 2, true)
				local slen = mw.ustring.len(s)
				if second and second + 1 < slen then
					local nxt = mw.ustring.sub(s, second + 1, second + 1)
					if mw.ustring.find(nxt, '%w') then
						s = mw.ustring.sub(s, 2)
					else
						s = mw.ustring.sub(s, 1, -2)
					end
				else
					s = mw.ustring.sub(s, 1, -2)
				end
				s = trim(s)
				changed = true
			end
		elseif first == "'" and last == "'" then
			local inner = mw.ustring.sub(s, 2, -2)
			if not mw.ustring.find(inner, "'", 1, true) then
				s = trim(inner)
				changed = true
			end
		end
	end

	local q = countChar(s, '"')
	local first = mw.ustring.sub(s, 1, 1)
	local last = mw.ustring.sub(s, -1)
	if first == '"' and q == 1 and last ~= '"' then
		s = trim(mw.ustring.sub(s, 2))
	end
	last = mw.ustring.sub(s, -1)
	first = mw.ustring.sub(s, 1, 1)
	q = countChar(s, '"')
	if last == '"' and q == 1 and first ~= '"' then
		s = trim(mw.ustring.sub(s, 1, -2))
	end
	s = mw.ustring.gsub(s, '^\\+', '')
	s = trim(s)
	if s == '"' or s == "'" or s == '.' or s == '?' or s == '\\' then
		return ''
	end
	s = mw.ustring.gsub(s, '%s+', ' ')
	return trim(s)
end

local function parentArg(frame, name)
	local parent = frame:getParent()
	local raw
	if parent and parent.args[name] then
		raw = parent.args[name]
	else
		raw = frame.args[name]
	end
	return tidyDisplay(raw)
end

local ACRONYMS = {
	US = true, USA = true, POW = true, IPW = true, MII = true, OSS = true,
	MITC = true, ASN = true, WWII = true, NARA = true, DC = true, DEML = true,
	CSU = true, ASTP = true, CAC = true, KIA = true, MIA = true, NG = true,
	MRBC = true, PACMIRS = true, GED = true, MITU = true, RDC = true,
}

local SMALL_WORDS = {
	of = true, the = true, to = true, a = true, an = true, at = true, by = true,
}
SMALL_WORDS['and'] = true
SMALL_WORDS['for'] = true
SMALL_WORDS['in'] = true
SMALL_WORDS['or'] = true

local STATE_NAME = {
	alabama = 'Alabama', alaska = 'Alaska', arizona = 'Arizona', arkansas = 'Arkansas',
	california = 'California', colorado = 'Colorado', connecticut = 'Connecticut',
	delaware = 'Delaware', florida = 'Florida', georgia = 'Georgia', hawaii = 'Hawaii',
	idaho = 'Idaho', illinois = 'Illinois', indiana = 'Indiana', iowa = 'Iowa',
	kansas = 'Kansas', kentucky = 'Kentucky', louisiana = 'Louisiana', maine = 'Maine',
	maryland = 'Maryland', massachusetts = 'Massachusetts', michigan = 'Michigan',
	minnesota = 'Minnesota', mississippi = 'Mississippi', missouri = 'Missouri',
	montana = 'Montana', nebraska = 'Nebraska', nevada = 'Nevada', ohio = 'Ohio',
	oklahoma = 'Oklahoma', oregon = 'Oregon', pennsylvania = 'Pennsylvania',
	tennessee = 'Tennessee', texas = 'Texas', utah = 'Utah', vermont = 'Vermont',
	virginia = 'Virginia', washington = 'Washington', wisconsin = 'Wisconsin',
	wyoming = 'Wyoming',
}

local STATE_ABBR = {
	al = 'Alabama', ak = 'Alaska', az = 'Arizona', ar = 'Arkansas', ca = 'California',
	co = 'Colorado', ct = 'Connecticut', de = 'Delaware', fl = 'Florida', ga = 'Georgia',
	hi = 'Hawaii', id = 'Idaho', il = 'Illinois', ia = 'Iowa',
	ks = 'Kansas', ky = 'Kentucky', la = 'Louisiana', me = 'Maine', md = 'Maryland',
	ma = 'Massachusetts', mi = 'Michigan', mn = 'Minnesota', ms = 'Mississippi',
	mo = 'Missouri', mt = 'Montana', ne = 'Nebraska', nv = 'Nevada', nh = 'New Hampshire',
	nj = 'New Jersey', nm = 'New Mexico', ny = 'New York', nc = 'North Carolina',
	nd = 'North Dakota', oh = 'Ohio', ok = 'Oklahoma', pa = 'Pennsylvania',
	ri = 'Rhode Island', sc = 'South Carolina', sd = 'South Dakota', tn = 'Tennessee',
	tx = 'Texas', ut = 'Utah', vt = 'Vermont', va = 'Virginia', wa = 'Washington',
	wv = 'West Virginia', wi = 'Wisconsin', wy = 'Wyoming', dc = 'Washington, D.C.',
}
STATE_ABBR['in'] = 'Indiana'
STATE_ABBR['or'] = 'Oregon'

local TWO_WORD_STATE = {
	['new hampshire'] = 'New Hampshire',
	['new jersey'] = 'New Jersey',
	['new mexico'] = 'New Mexico',
	['new york'] = 'New York',
	['north carolina'] = 'North Carolina',
	['north dakota'] = 'North Dakota',
	['rhode island'] = 'Rhode Island',
	['south carolina'] = 'South Carolina',
	['south dakota'] = 'South Dakota',
	['west virginia'] = 'West Virginia',
}

local PLACE_PREP = {
	at = true, from = true, near = true, of = true,
}
PLACE_PREP['in'] = true

local function stateKey(w)
	w = mw.ustring.lower(w)
	w = mw.ustring.gsub(w, '[.]', '')
	w = mw.ustring.gsub(w, '%s+', ' ')
	return trim(w)
end

local function canonicalState(w)
	local k = stateKey(w)
	return STATE_NAME[k] or STATE_ABBR[k] or TWO_WORD_STATE[k]
end

local NEGATIVE_PHRASES = {
	'unsatisfactory', 'failed class', 'failed as', 'failure', 'flunk',
	'washed out', 'wash out', 'washout', 'kicked out', 'kick out',
	'dismissed', 'expelled', 'poor performance', 'poor work', 'poor grades',
	'awol', 'court-martial', 'court martial', 'disciplinary',
	'discharged for cause', 'dropped out', 'dropped from', 'also dropped',
	'physically unfit', 'unfit for', 'relieved prior', 'relieved of',
	'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',
}

local function splitWords(s)
	local words = {}
	for w in mw.text.gsplit(s, ' ', true) do
		if w ~= '' then
			table.insert(words, w)
		end
	end
	return words
end

local function isAllCapsPhrase(s)
	local letters = mw.ustring.gsub(s, '[^%a]', '')
	if mw.ustring.len(letters) < 2 then
		return false
	end
	return letters == mw.ustring.upper(letters)
end

local function prettyTitle(s, keepSmall)
	s = trim(s)
	if s == '' then
		return s
	end
	local words = splitWords(s)
	local out = {}
	for i, w in ipairs(words) do
		local bare = mw.ustring.gsub(w, '[^%a]', '')
		local up = mw.ustring.upper(bare)
		if ACRONYMS[up] then
			local replaced = mw.ustring.gsub(w, bare, up, 1)
			table.insert(out, replaced)
		else
			local low = mw.ustring.lower(w)
			local lowBare = mw.ustring.lower(bare)
			if keepSmall and i > 1 and SMALL_WORDS[lowBare] then
				table.insert(out, mw.ustring.lower(w))
			else
				local first = mw.ustring.upper(mw.ustring.sub(low, 1, 1))
				table.insert(out, first .. mw.ustring.sub(low, 2))
			end
		end
	end
	return table.concat(out, ' ')
end

local NAME_PARTICLES = {
	von = true, van = true, de = true, der = true, den = true, da = true,
	di = true, du = true, la = true, le = true, del = true, della = true,
	ter = true, ten = true, zu = true, zum = true, bin = true,
}

local NAME_SUFFIX = {
	jr = 'Jr', ['jr.'] = 'Jr.', sr = 'Sr', ['sr.'] = 'Sr.',
	esq = 'Esq', ['esq.'] = 'Esq.',
	ii = 'II', iii = 'III', iv = 'IV',
}

local function isAllCapsLetters(s)
	local letters = mw.ustring.gsub(s, '[^%a]', '')
	if mw.ustring.len(letters) < 1 then
		return false
	end
	return letters == mw.ustring.upper(letters)
end

local function isAllLowerLetters(s)
	local letters = mw.ustring.gsub(s, '[^%a]', '')
	if mw.ustring.len(letters) < 2 then
		return false
	end
	return letters == mw.ustring.lower(letters)
end

local function titleCaseWord(w)
	local low = mw.ustring.lower(w)
	if low == '' then
		return w
	end
	return mw.ustring.upper(mw.ustring.sub(low, 1, 1)) .. mw.ustring.sub(low, 2)
end

local function prettyCapsNameWord(w)
	local trail = ''
	while mw.ustring.len(w) > 0 and mw.ustring.find(mw.ustring.sub(w, -1), '[.,]') do
		trail = mw.ustring.sub(w, -1) .. trail
		w = mw.ustring.sub(w, 1, -2)
	end
	local bare = mw.ustring.gsub(w, '[^%a]', '')
	local up = mw.ustring.upper(bare)
	local len = mw.ustring.len(up)
	if len >= 5 and mw.ustring.sub(up, 1, 2) == 'MC' then
		return 'Mc' .. titleCaseWord(mw.ustring.sub(up, 3)) .. trail
	end
	if len >= 7 and mw.ustring.sub(up, 1, 3) == 'MAC' then
		return 'Mac' .. titleCaseWord(mw.ustring.sub(up, 4)) .. trail
	end
	return titleCaseWord(up) .. trail
end

local function prettyNameToken(w, isLeading)
	w = trim(w)
	if w == '' then
		return w
	end
	if mw.ustring.find(w, '%d') then
		return w
	end
	if mw.ustring.find(w, '-', 1, true) then
		local parts = {}
		local idx = 0
		for part in mw.text.gsplit(w, '-', true) do
			idx = idx + 1
			table.insert(parts, prettyNameToken(part, isLeading and idx == 1))
		end
		return table.concat(parts, '-')
	end
	local apos = mw.ustring.find(w, "'", 1, true)
	if not apos then
		apos = mw.ustring.find(w, '’', 1, true)
	end
	if apos then
		local mark = mw.ustring.sub(w, apos, apos)
		local left = mw.ustring.sub(w, 1, apos - 1)
		local right = mw.ustring.sub(w, apos + 1)
		return prettyNameToken(left, isLeading) .. mark .. prettyNameToken(right, false)
	end
	if mw.ustring.match(w, '^(%a%.)+$') then
		if isAllCapsLetters(w) or isAllLowerLetters(w) then
			return mw.ustring.upper(w)
		end
		return w
	end

	local low = mw.ustring.lower(w)
	local mapped = NAME_SUFFIX[low]
	if mapped then
		return mapped
	end

	local bare = mw.ustring.gsub(w, '[^%a]', '')
	local lowBare = mw.ustring.lower(bare)
	if mw.ustring.len(bare) == 1 then
		local rest = mw.ustring.gsub(w, '%a', '', 1)
		return mw.ustring.upper(bare) .. rest
	end
	if NAME_SUFFIX[lowBare] and mw.ustring.match(w, '^%a+%.?$') then
		local canon = NAME_SUFFIX[lowBare]
		if mw.ustring.sub(w, -1) == '.' and mw.ustring.sub(canon, -1) ~= '.' then
			return canon .. '.'
		end
		return canon
	end

	if NAME_PARTICLES[lowBare] and mw.ustring.match(w, '^%a+$')
		and (isAllCapsLetters(w) or isAllLowerLetters(w)) then
		if isLeading then
			return titleCaseWord(lowBare)
		end
		return lowBare
	end
	if isAllCapsLetters(w) and mw.ustring.len(bare) >= 2 and mw.ustring.len(bare) <= 3
		and not mw.ustring.find(mw.ustring.upper(bare), '[AEIOUY]') then
		return w
	end

	if isAllCapsLetters(w) or isAllLowerLetters(w) then
		return prettyCapsNameWord(w)
	end
	return w
end

local function prettyNamePhrase(s, capitalizeLeadingParticle)
	s = trim(s)
	if s == '' then
		return s
	end
	s = mw.ustring.gsub(s, '%s*;%s*', ' ')
	local clauses = {}
	local clauseIdx = 0
	for clause in mw.text.gsplit(s, ',', true) do
		clause = trim(clause)
		if clause ~= '' then
			clauseIdx = clauseIdx + 1
			local words = splitWords(clause)
			local out = {}
			for i, w in ipairs(words) do
				local leading = (clauseIdx == 1 and i == 1 and capitalizeLeadingParticle) or #words == 1
				local bare = mw.ustring.lower(mw.ustring.gsub(w, '[^%a]', ''))
				if NAME_PARTICLES[bare] and (isAllCapsLetters(w) or isAllLowerLetters(w)) then
					if leading then
						table.insert(out, titleCaseWord(mw.ustring.lower(w)))
					else
						table.insert(out, mw.ustring.lower(w))
					end
				else
					table.insert(out, prettyNameToken(w, i == 1))
				end
			end
			if #out > 0 then
				table.insert(clauses, table.concat(out, ' '))
			end
		end
	end
	return table.concat(clauses, ', ')
end

local function titleCase(s)
	return prettyTitle(s, false)
end

local function looksLikeCode(s)
	s = trim(s)
	if s == '' then
		return true
	end
	if mw.ustring.find(s, '%d') and mw.ustring.len(s) <= 8 and not mw.ustring.find(s, ' ') then
		return true
	end
	if mw.ustring.len(s) <= 3 and not mw.ustring.find(s, ' ') then
		return true
	end
	return false
end

-- NARA / roster nativity (birthplace) codes → display names.
local NATIVITY_NAME = {
	US = 'United States',
	GE = 'Germany',
	AU = 'Austria',
	RU = 'Russia',
	SU = 'Soviet Union',
	IT = 'Italy',
	CA = 'Canada',
	FR = 'France',
	PO = 'Poland',
	PL = 'Poland',
	TU = 'Turkey',
	PR = 'Puerto Rico',
	SW = 'Sweden',
	SZ = 'Switzerland',
	CH = 'China',
	CZ = 'Czechoslovakia',
	MX = 'Mexico',
	NE = 'Netherlands',
	NL = 'Netherlands',
	SYR = 'Syria',
	SY = 'Syria',
	HU = 'Hungary',
	GB = 'Great Britain',
	UK = 'United Kingdom',
	CU = 'Cuba',
	RO = 'Romania',
	SP = 'Spain',
	ES = 'Spain',
	BE = 'Belgium',
	NOR = 'Norway',
	NO = 'Norway',
	DK = 'Denmark',
	CYP = 'Cyprus',
	GR = 'Greece',
	YU = 'Yugoslavia',
	FI = 'Finland',
	EE = 'Estonia',
	LA = 'Latvia',
	LAT = 'Latvia',
	LI = 'Lithuania',
	LT = 'Lithuania',
	LV = 'Latvia',
	AL = 'Albania',
	ALB = 'Albania',
	BU = 'Bulgaria',
	BUL = 'Bulgaria',
	BG = 'Bulgaria',
	LUX = 'Luxembourg',
	BRA = 'Brazil',
	IR = 'Ireland',
	IE = 'Ireland',
	PA = 'Palestine',
	PAL = 'Palestine',
	EG = 'Egypt',
	JA = 'Japan',
	JP = 'Japan',
	PH = 'Philippines',
	BR = 'Brazil',
	AR = 'Argentina',
	ARG = 'Argentina',
	PE = 'Peru',
	CO = 'Colombia',
	COL = 'Colombia',
	LU = 'Luxembourg',
	ME = 'Mexico',
	CHN = 'China',
	LITH = 'Lithuania',
	PRTG = 'Portugal',
	PT = 'Portugal',
	DR = 'Dominican Republic',
	DO = 'Dominican Republic',
	OTH = 'Other',
	OTHER = 'Other',
}

-- Roster religion codes → display names (Hebrew/Jewish codes unify to Jewish).
local RELIGION_NAME = {
	J = 'Jewish',
	['J*'] = 'Jewish',
	H = 'Jewish',
	HEBREW = 'Jewish',
	JEWISH = 'Jewish',
	JEW = 'Jewish',
	['X (J)'] = 'Jewish',
	['X(J)'] = 'Jewish',
	P = 'Protestant',
	C = 'Catholic',
	RC = 'Roman Catholic',
	GO = 'Greek Orthodox',
	O = 'Orthodox',
	CS = 'Christian Science',
	LDS = 'Latter-day Saints',
	MOS = 'Muslim',
	M = 'Muslim',
	ISLAM = 'Muslim',
	Q = 'Quaker',
	QU = 'Quaker',
	QUA = 'Quaker',
	QUAKER = 'Quaker',
	FRIENDS = 'Quaker',
	N = 'None',
	X = 'Not stated',
	['???'] = 'Unknown',
	UNK = 'Unknown',
	PC = 'Protestant',
}

local function joinCityState(cityRaw, stateName, srcAllCaps)
	cityRaw = trim(cityRaw)
	if cityRaw == '' then
		return stateName
	end
	local words = splitWords(cityRaw)
	local prepIdx = nil
	for i, w in ipairs(words) do
		local k = mw.ustring.gsub(mw.ustring.lower(w), '[^%a]', '')
		if PLACE_PREP[k] then
			prepIdx = i
		end
	end
	local prefix = ''
	local city = cityRaw
	if prepIdx and prepIdx < #words then
		local preParts = {}
		for i = 1, prepIdx do
			table.insert(preParts, words[i])
		end
		local cityParts = {}
		for i = prepIdx + 1, #words do
			table.insert(cityParts, words[i])
		end
		prefix = table.concat(preParts, ' ')
		city = table.concat(cityParts, ' ')
	end
	local cityFmt = canonicalState(city)
	if not cityFmt then
		cityFmt = prettyTitle(city, false)
	end
	local place
	if stateName == 'Washington, D.C.' and (cityFmt == '' or mw.ustring.lower(cityFmt) == 'washington') then
		place = 'Washington, D.C.'
	else
		place = cityFmt .. ', ' .. stateName
	end
	if prefix ~= '' then
		local prefixFmt = prefix
		if srcAllCaps or isAllCapsPhrase(prefix) then
			prefixFmt = prettyTitle(prefix, true)
		end
		return prefixFmt .. ' ' .. place
	end
	return place
end

local function prettyPlace(s)
	s = trim(s)
	if s == '' then
		return s
	end
	local srcAllCaps = isAllCapsPhrase(s)
	local compact = mw.ustring.lower(s)
	compact = mw.ustring.gsub(compact, '[.,]', '')
	compact = mw.ustring.gsub(compact, '%s+', ' ')
	compact = trim(compact)
	if compact == 'washington dc' or compact == 'washington d c'
		or compact == 'district of columbia' then
		return 'Washington, D.C.'
	end
	if mw.ustring.find(compact, ' washington dc$') or mw.ustring.find(compact, ' washington d c$') then
		local city = mw.ustring.gsub(s, '[Ww][Aa][Ss][Hh][Ii][Nn][Gg][Tt][Oo][Nn]%s+[Dd]%.?%s*[Cc]%.?$', '')
		return joinCityState(trim(city), 'Washington, D.C.', srcAllCaps)
	end
	if mw.ustring.find(s, ',', 1, true) then
		local bits = {}
		for part in mw.text.gsplit(s, ',', true) do
			part = trim(part)
			if part ~= '' then
				table.insert(bits, part)
			end
		end
		if #bits >= 1 then
			local lastKey = stateKey(bits[#bits])
			if lastKey == 'dc' or lastKey == 'd c' then
				local city = ''
				if #bits >= 2 then
					city = table.concat(bits, ', ', 1, #bits - 1)
				end
				return joinCityState(city, 'Washington, D.C.', srcAllCaps)
			end
		end
		local out = {}
		for _, part in ipairs(bits) do
			table.insert(out, canonicalState(part) or prettyTitle(part, false))
		end
		return table.concat(out, ', ')
	end
	local words = splitWords(s)
	if #words >= 2 then
		local last2 = stateKey(words[#words - 1] .. ' ' .. words[#words])
		if TWO_WORD_STATE[last2] then
			local city = {}
			for i = 1, #words - 2 do
				table.insert(city, words[i])
			end
			return joinCityState(table.concat(city, ' '), TWO_WORD_STATE[last2], srcAllCaps)
		end
	end
	if #words >= 1 then
		local lastKey = stateKey(words[#words])
		local state = STATE_NAME[lastKey] or STATE_ABBR[lastKey]
		if state then
			if #words >= 2 then
				local city = {}
				for i = 1, #words - 1 do
					table.insert(city, words[i])
				end
				return joinCityState(table.concat(city, ' '), state, srcAllCaps)
			end
			return state
		end
	end
	if srcAllCaps then
		return prettyTitle(s, false)
	end
	return s
end

local function prettyProse(s)
	s = trim(s)
	if s == '' or looksLikeCode(s) then
		return s
	end
	if isAllCapsPhrase(s) or prettyPlace(s) ~= s then
		local placed = prettyPlace(s)
		if placed ~= s then
			return placed
		end
	end
	if isAllCapsPhrase(s) then
		return prettyTitle(s, true)
	end
	return s
end

local function expandNativity(s)
	s = tidyDisplay(s)
	if s == '' then
		return ''
	end
	if s == '???' then
		return 'Unknown'
	end
	local key = mw.ustring.upper(s)
	key = mw.ustring.gsub(key, '%.', '')
	key = trim(key)
	if NATIVITY_NAME[key] then
		return NATIVITY_NAME[key]
	end
	return prettyPlace(s)
end

local function expandReligion(s)
	s = tidyDisplay(s)
	if s == '' then
		return ''
	end
	local key = mw.ustring.upper(s)
	key = mw.ustring.gsub(key, '%s+', ' ')
	key = trim(key)
	local mapped = 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
	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'
		or mw.ustring.find(lower, 'jewish', 1, true)
		or mw.ustring.find(lower, 'hebrew', 1, true) then
		return 'Jewish'
	end
	if lower == 'quaker' or lower == 'friends' then
		return 'Quaker'
	end
	return prettyProse(s)
end

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

	local n = mw.ustring.match(u, '^SC%s*B%s*(%d+)$')
	if n then
		return 'Special Course B ' .. n
	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
	if u == 'SIG' or u == 'SIG-SIG' or mw.ustring.match(u, '^SIG[%s%-]') then
		return 'Special Intelligence Group–Signal'
	end

	local unitNum, unitKind = mw.ustring.match(u, '^(%d+)(?:ST|ND|RD|TH)%s+(.+)$')
	if unitNum and unitKind then
		local kind = trim(unitKind)
		if kind == 'INF' then
			kind = 'Infantry'
		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

local function expandOtherClass(raw)
	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

local function prettyNamePart(s)
	return prettyNamePhrase(s, false)
end

local function isNegativeClause(s)
	local lower = mw.ustring.lower(s)
	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 true
	end
	-- Bare class-chart non-graduate marker (e.g. "NG Class 10 OB", trailing "NG")
	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
	for _, phrase in ipairs(NEGATIVE_PHRASES) do
		if mw.ustring.find(lower, phrase, 1, true) then
			return true
		end
	end
	return false
end

local function normalizeNoteKey(s)
	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

local function dateOnlyFragment(s)
	return mw.ustring.match(trim(s), '^(%d%d?/%d%d?/%d%d%d?%d?)%.?$')
end

local function joinDateFollowons(items)
	local out = {}
	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

local function sanitizeNote(s)
	s = tidyDisplay(s)
	if s == '' then
		return ''
	end
	local lower = mw.ustring.lower(s)
	if mw.ustring.find(lower, 'no notes were in the source', 1, true) then
		return ''
	end
	local kept = {}
	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
	return table.concat(kept, '; ')
end

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

local function displayName(frame)
	local first = prettyNamePart(parentArg(frame, 'first'))
	local middle = prettyNamePart(parentArg(frame, 'middle'))
	local surname = prettyNamePart(parentArg(frame, 'surname'))
	local suffix = prettyNamePart(parentArg(frame, 'suffix'))
	local parts = {}
	if first ~= '' then
		table.insert(parts, first)
	end
	if middle ~= '' then
		table.insert(parts, middle)
	end
	if surname ~= '' then
		table.insert(parts, surname)
	end
	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

local function classLinkList(raw, withPrefix)
	local ClassDates = require('Module:ClassDates')
	local out = {}
	for part in mw.text.gsplit(raw, ';', true) do
		part = trim(part)
		if part ~= '' then
			local info = ClassDates.lookup(part)
			local dates = info and info.dates or ''
			if withPrefix then
				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
					table.insert(out, string.format('[[:Category:Class %s|%s]]', part, part))
				end
			end
		end
	end
	return out
end

local function isJunkIpwItalianLabel(part)
	local lower = mw.ustring.lower(trim(part))
	local spaced = mw.ustring.gsub(lower, '%s+', ' ')
	local junk = {
		['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

local function normalizeIpwItalianCode(part)
	part = trim(part)
	if part == '' or isJunkIpwItalianLabel(part) then
		return nil
	end
	local spaced = mw.ustring.lower(mw.ustring.gsub(part, '%s+', ' '))
	if spaced == '5th class' or spaced == 'italian 5th class' then
		return '5'
	end
	return part
end

local function isJunkIpwLabel(part)
	local lower = mw.ustring.lower(trim(part))
	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

local function normalizeIpwTeamCode(part)
	part = trim(part)
	if part == '' then
		return nil, nil
	end
	local original = part
	part = mw.ustring.gsub(part, '%s*%?+%s*$', '')
	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
	return part, part, nil
end

local function teamLinkList(raw, pagePrefix, label)
	local out = {}
	local isIpw = mw.ustring.find(pagePrefix, 'IPW', 1, true) == 1
	for part in mw.text.gsplit(raw, ';', true) do
		part = trim(part)
		part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
		part = trim(part)
		if part ~= '' then
			if pagePrefix == 'IPW Italian ' then
				local code = normalizeIpwItalianCode(part)
				if code then
					table.insert(out, string.format('[[Team %s%s|%s %s]]', pagePrefix, code, label, code))
				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

local function addHeader(tbl, text)
	tbl:tag('tr'):tag('th')
		:attr('colspan', '2')
		:addClass('infobox-header')
		:wikitext(text)
end

local function realCertificate(s)
	s = trim(s)
	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 ''
	end
	return s
end

local function addRow(tbl, label, value, dataClass)
	if trim(value) == '' then
		return
	end
	local tr = tbl:tag('tr')
	tr:tag('th')
		:addClass('infobox-label')
		:css('display', 'table-cell')
		: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
	td:wikitext(value)
end

local function yearOnly(s)
	s = trim(s)
	if s == '' or s == '?' then
		return ''
	end
	local year = mw.ustring.match(s, '(%d%d%d%d)')
	if year then
		return year
	end
	return ''
end

local RANK_MAP = {
	['PVT'] = 'Private',
	['PVTE'] = 'Private',
	['PRIVATE'] = 'Private',
	['PFC'] = 'Private First Class',
	['P F C'] = 'Private First Class',
	['PRIVATE FIRST CLASS'] = 'Private First Class',
	['CPL'] = 'Corporal',
	['CORPORAL'] = 'Corporal',
	['T/5'] = 'Technician Fifth Grade',
	['TEC5'] = 'Technician Fifth Grade',
	['TEC 5'] = 'Technician Fifth Grade',
	['T5'] = 'Technician Fifth Grade',
	['TECH 5'] = 'Technician Fifth Grade',
	['SGT'] = 'Sergeant',
	['SERGEANT'] = 'Sergeant',
	['T/4'] = 'Technician Fourth Grade',
	['TEC4'] = 'Technician Fourth Grade',
	['TEC 4'] = 'Technician Fourth Grade',
	['T4'] = 'Technician Fourth Grade',
	['TECH 4'] = 'Technician Fourth Grade',
	['S/SGT'] = 'Staff Sergeant',
	['SSGT'] = 'Staff Sergeant',
	['S SGT'] = 'Staff Sergeant',
	['STAFF SERGEANT'] = 'Staff Sergeant',
	['T/3'] = 'Technician Third Grade',
	['TEC3'] = 'Technician Third Grade',
	['TEC 3'] = 'Technician Third Grade',
	['T3'] = 'Technician Third Grade',
	['TECH 3'] = 'Technician Third Grade',
	['T/SGT'] = 'Technical Sergeant',
	['TSGT'] = 'Technical Sergeant',
	['T SGT'] = 'Technical Sergeant',
	['TECHNICAL SERGEANT'] = 'Technical Sergeant',
	['1ST SGT'] = 'First Sergeant',
	['1SGT'] = 'First Sergeant',
	['FIRST SERGEANT'] = 'First Sergeant',
	['M/SGT'] = 'Master Sergeant',
	['MSGT'] = 'Master Sergeant',
	['MASTER SERGEANT'] = 'Master Sergeant',
	['WO'] = 'Warrant Officer',
	['WOJG'] = 'Warrant Officer',
	['WARRANT OFFICER'] = 'Warrant Officer',
	['CWO'] = 'Chief Warrant Officer',
	['2ND LT'] = 'Second Lieutenant',
	['2D LT'] = 'Second Lieutenant',
	['2LT'] = 'Second Lieutenant',
	['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 expandRank(raw)
	raw = trim(raw)
	if raw == '' then
		return ''
	end
	local key = mw.ustring.upper(raw)
	key = mw.ustring.gsub(key, '%.', '')
	key = mw.ustring.gsub(key, '%s+', ' ')
	key = trim(key)
	return RANK_MAP[key] or raw
end

local BRANCH_MAP = {
	['INF'] = 'Infantry',
	['INFANTRY'] = 'Infantry',
	['FA'] = 'Field Artillery',
	['FIELD ARTILLERY'] = 'Field Artillery',
	['CAC'] = 'Coast Artillery Corps',
	['CA'] = 'Coast Artillery',
	['COAST ARTILLERY'] = 'Coast Artillery',
	['AC'] = 'Air Corps',
	['AAF'] = 'Army Air Forces',
	['AIR CORPS'] = 'Air Corps',
	['QMC'] = 'Quartermaster Corps',
	['QC'] = 'Quartermaster Corps',
	['QUARTERMASTER'] = 'Quartermaster Corps',
	['CAV'] = 'Cavalry',
	['CAVALRY'] = 'Cavalry',
	['CALVALRY'] = 'Cavalry',
	['CE'] = 'Corps of Engineers',
	['ENG'] = 'Corps of Engineers',
	['ENGINEERS'] = 'Corps of Engineers',
	['CMP'] = 'Corps of Military Police',
	['MP'] = 'Military Police',
	['CWS'] = 'Chemical Warfare Service',
	['AUS'] = 'Army of the United States',
	['SC'] = 'Signal Corps',
	['SIG'] = 'Signal Corps',
	['SIG C'] = 'Signal Corps',
	['SIGC'] = 'Signal Corps',
	['SIGNAL CORPS'] = 'Signal Corps',
	['ORD'] = 'Ordnance Department',
	['ORDNANCE'] = 'Ordnance Department',
	['MAC'] = 'Medical Administrative Corps',
	['MC'] = 'Medical Corps',
	['MD'] = 'Medical Department',
	['TC'] = 'Transportation Corps',
	['MI'] = 'Military Intelligence',
	['MIS'] = 'Military Intelligence Service',
	['INT'] = 'Intelligence',
	['CIC'] = 'Counter Intelligence Corps',
	['AGD'] = "Adjutant General's Department",
	['USMC'] = 'United States Marine Corps',
	['USMCR'] = 'United States Marine Corps Reserve',
	['AA'] = 'Antiaircraft',
	['AAA'] = 'Antiaircraft Artillery',
	['TD'] = 'Tank Destroyer',
	['FD'] = 'Finance Department',
	['GSC'] = 'General Staff Corps',
	['ARMD F'] = 'Armored Force',
	['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
	local key = mw.ustring.upper(raw)
	key = mw.ustring.gsub(key, '%.', '')
	key = mw.ustring.gsub(key, '%s+', ' ')
	key = trim(key)
	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
	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

-- True when a BRANCH_MAP / class_branch value is an Army arm/service, not Army/Marines/Navy.
local SERVICE_BRANCH_KEY = {
	['AUS'] = true,
	['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
	local key = mw.ustring.upper(name)
	key = mw.ustring.gsub(key, '%.', '')
	key = mw.ustring.gsub(key, '%s+', ' ')
	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
	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
	return false
end

local function isArmOfServiceToken(raw)
	raw = trim(raw)
	if raw == '' then
		return false
	end
	local key = mw.ustring.upper(raw)
	key = mw.ustring.gsub(key, '%.', '')
	key = mw.ustring.gsub(key, '%s+', ' ')
	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
	for _, needle in ipairs({
		'artillery', 'signal', 'cavalry', 'infantry', 'engineer',
		'ordnance', 'quartermaster', 'medical', 'police', 'intelligence',
		'armor', 'tank destroyer', 'chemical', 'chaplain', 'transportation',
		'adjutant',
	}) do
		if mw.ustring.find(low, needle, 1, true) then
			return true
		end
	end
	return false
end

local function splitBranchAndArm(branchRaw, classBranchRaw, armRaw)
	branchRaw = trim(branchRaw)
	classBranchRaw = trim(classBranchRaw)
	armRaw = trim(armRaw)

	local service = ''
	local arms = {}

	local function consider(raw, preferArm)
		raw = trim(raw)
		if raw == '' then
			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

	-- 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

-- Small public-domain / Commons emblems uploaded as File:Branch emblem *.png
local BRANCH_EMBLEM = {
	['United States Army'] = 'Branch emblem US Army.png',
	['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
	return name
end

local function expandBranch(raw)
	raw = trim(raw)
	if raw == '' then
		return ''
	end
	local parts = {}
	for part in mw.text.gsplit(raw, ';', true) do
		part = expandBranchToken(part)
		if part ~= '' then
			table.insert(parts, branchWithEmblem(part))
		end
	end
	return table.concat(parts, '; ')
end

local function formatBattles(raw)
	raw = trim(raw)
	if raw == '' then
		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
	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 function splitAwardRows(raw)
	raw = trim(raw)
	if raw == '' then
		return {}
	end
	-- Prefer @@ row separator (safe inside {{Soldier}}); also accept legacy |||
	local sep = '@@'
	if not mw.ustring.find(raw, '@@', 1, true) and mw.ustring.find(raw, '|||', 1, true) then
		sep = '|||'
	end
	local rows = {}
	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
	return rows
end

local function awardRibbonRows(raw, rowRaw)
	-- Prefer explicit Wikipedia row layout when present
	local rows = splitAwardRows(rowRaw or '')
	if #rows > 0 then
		return rows
	end
	raw = trim(raw)
	if raw == '' then
		return {}
	end
	local files = {}
	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
	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
	return rows
end

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

local function formatAwardsRibbons(raw, rowRaw, labelRaw)
	local rows = awardRibbonRows(raw, rowRaw)
	if #rows == 0 then
		return '', false
	end
	local labelRows = splitAwardRows(labelRaw or '')
	local labeled = awardRowsMatch(rows, labelRows)
	-- One table per row so short rows (e.g. 2 ribbons) center like full rows.
	-- When labels match ribbon cells, put captions in the same table so each
	-- 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
		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
	return table.concat(out, '\n'), labeled
end

local function awardGridColspanUnits(rows)
	-- LCM of row lengths so 2-cell and 3-cell rows share one table width
	local function gcd(a, b)
		while b ~= 0 do
			a, b = b, a % b
		end
		return a
	end
	local function lcm(a, b)
		if a == 0 or b == 0 then
			return a + b
		end
		return (a / gcd(a, b)) * b
	end
	local units = 0
	for _, cells in ipairs(rows) do
		local n = #cells
		if n > 0 then
			units = lcm(units, n)
		end
	end
	return units > 0 and units or 1
end

local function formatAwardsGrid(raw)
	local rows = splitAwardRows(raw)
	if #rows == 0 then
		return ''
	end
	local units = awardGridColspanUnits(rows)
	local out = { '{| class="wikitable soldier-awards-grid"' }
	for _, cells in ipairs(rows) do
		local n = #cells
		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
	table.insert(out, '|}')
	return table.concat(out, '\n')
end

local function formatAwardsLegend(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)
	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
	return table.concat(parts, '\n')
end

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

--- Prefer full birth/death dates (Wikipedia style); fall back to years.
local function lifespanFromArgs(getArg)
	local birth = prettyProse(getArg('birth_date'))
	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

local function preferredRank(frame)
	local rank = expandRank(parentArg(frame, 'rank_final'))
	if rank == '' then
		rank = expandRank(parentArg(frame, 'class_rank'))
	end
	return rank
end

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
	if middle ~= '' then
		table.insert(parts, middle)
	end
	if surname ~= '' then
		table.insert(parts, surname)
	end
	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

local function preferredRankFrom(getArg)
	local rank = expandRank(getArg('rank_final'))
	if rank == '' then
		rank = expandRank(getArg('class_rank'))
	end
	return rank
end

local function buildLead(getArg)
	local name = displayNameFrom(getArg)
	local rank = preferredRankFrom(getArg)
	local role = 'United States Army soldier'
	if rank ~= '' then
		role = 'United States Army ' .. rank
	end

	local classLinks = classLinkList(getArg('class_number'), true)
	local trained
	if #classLinks == 1 then
		trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1]
	elseif #classLinks == 2 then
		trained = ' who trained at [[Camp Ritchie]], Maryland, in ' .. classLinks[1] .. ' and ' .. classLinks[2]
	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

	local lead = string.format(
		"'''%s'''%s was a %s%s.",
		name,
		lifespanFromArgs(getArg),
		role,
		trained
	)
	local who = prettyNamePart(getArg('first'))
	if who == '' then
		who = prettyNamePart(getArg('surname'))
	end
	if who == '' then
		who = 'He'
	end
	local enlist = prettyPlace(getArg('enlistment_place'))
	if enlist ~= '' then
		lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.'
	end
	return lead
end

function p.classLinks(frame)
	return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
end

local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' }

local function photoFileExists(filename)
	filename = trim(filename)
	if filename == '' then
		return false
	end
	local title = mw.title.new('File:' .. filename)
	return title ~= nil and title.exists
end

local function pushPhotoCandidate(candidates, seen, value)
	value = trim(value)
	if value == '' or seen[value] then
		return
	end
	seen[value] = true
	table.insert(candidates, value)
end

local function resolvePhoto(frame)
	local explicit = trim(parentArg(frame, 'photo'))
	if explicit ~= '' then
		return explicit
	end

	local first = trim(parentArg(frame, 'first'))
	local surname = trim(parentArg(frame, 'surname'))
	local asn = trim(parentArg(frame, 'asn'))
	if first == '' or surname == '' then
		return ''
	end

	local candidates = {}
	local seen = {}
	local lowerFirst = mw.ustring.lower(first)
	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
				for clause in mw.text.gsplit(note, ';', true) do
					clause = trim(clause)
					if clause ~= '' and not isNegativeClause(clause) then
						table.insert(items, prettyProse(clause))
					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
		table.insert(
			blocks,
			'<div class="soldier-interview-cue">'
				.. '<span class="soldier-interview-cue-label">'
				.. label
				.. '</span> '
				.. body
				.. '</div>'
		)
	end
	if nFilms > 0 then
		local links = {}
		for _, item in ipairs(films) do
			local heading = item.heading or 'Documentaries/Movies'
			local filmLabel = item.label or heading
			table.insert(
				links,
				string.format('[[Documentaries/Movies#%s|%s]]', heading, filmLabel)
			)
		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
	return '<div class="soldier-media-cue">' .. table.concat(blocks, '') .. '</div>'
end

return p