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
Jump to navigation
Jump to search
Split arm/service from Branch; expand Qu religion; hide Not stated |
Expand more nativity code aliases (Pal, Col, Arg, …) |
||
| Line 465: | Line 465: | ||
IE = 'Ireland', | IE = 'Ireland', | ||
PA = 'Palestine', | PA = 'Palestine', | ||
PAL = 'Palestine', | |||
EG = 'Egypt', | EG = 'Egypt', | ||
JA = 'Japan', | JA = 'Japan', | ||
| Line 471: | Line 472: | ||
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', | ||
Revision as of 18:54, 26 August 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',
LI = 'Lithuania',
LT = 'Lithuania',
LV = 'Latvia',
AL = 'Albania',
BU = 'Bulgaria',
BG = 'Bulgaria',
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
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
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('[[Class %s]] (%s)', part, dates))
else
table.insert(out, string.format('[[Class %s]]', part))
end
else
if dates ~= '' then
table.insert(out, string.format('[[Class %s|%s]] (%s)', part, part, dates))
else
table.insert(out, string.format('[[Class %s|%s]]', part, part))
end
end
end
end
return out
end
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 formatAwardsRibbons(raw, rowRaw)
-- Prefer explicit Wikipedia row layout when present
local rows = splitAwardRows(rowRaw or '')
if #rows == 0 then
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
end
-- One table per row so short rows (e.g. 2 ribbons) center like full rows
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
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 .. ' – ' .. 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
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'))
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
local state = prettyPlace(parentArg(frame, 'state'))
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 marital ~= '' or education ~= ''
or occupation ~= '' or knownFor ~= '' or awards ~= '' or state ~= '' 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, 'Spouse / marital', marital)
addRow(tbl, 'Education', education)
addRow(tbl, 'Occupation', occupation)
addRow(tbl, 'Known for', knownFor)
addRow(tbl, 'Awards', awards)
if state ~= '' then
addRow(tbl, 'State', state)
end
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
addRow(tbl, 'Years of service', prettyProse(parentArg(frame, 'years_of_service')))
addRow(tbl, 'ASN', parentArg(frame, 'asn'), 'serial')
local rankFinal = expandRank(parentArg(frame, 'rank_final'))
local 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', parentArg(frame, 'class_section'))
addRow(tbl, 'Other class', 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')))
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
-- narrow infobox — matching Wikipedia's main-column ribbon racks.
return tostring(tbl)
end
function p.awardsSection(frame)
local ribbons = formatAwardsRibbons(
parentArg(frame, 'awards_ribbons'),
parentArg(frame, 'awards_ribbon_rows')
)
local grid = formatAwardsGrid(parentArg(frame, 'awards_grid'))
local extras = formatAwardsLegend(parentArg(frame, 'awards_extra'))
-- Fallback: flat awards list when no grid was extracted
if grid == '' and extras == '' 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
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 bits = {}
for _, name in ipairs(noteTeamNames(researchNotesSection(content))) do
table.insert(bits, '[[Category:Team ' .. 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
return p