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
Expand negative research-note filter (illness, absence, etc.)
Hide certificate=NG (non-graduate marker) in infobox
Line 189: Line 189:
'intoxicat', 'venereal', 'syphili', 'gonorrh', 'mental case',
'intoxicat', 'venereal', 'syphili', 'gonorrh', 'mental case',
'mental illness', 'nervous breakdown',
'mental illness', 'nervous breakdown',
'non-graduate', 'non graduate', 'nongraduate', 'certificate listed as ng',
}
}


Line 935: Line 936:
-- Placeholders: *, A, 5, #, and any other single character.
-- Placeholders: *, A, 5, #, and any other single character.
if mw.ustring.len(s) == 1 then
if mw.ustring.len(s) == 1 then
return ''
end
-- NG = non-graduate marker on class charts, not a certificate number.
local upper = mw.ustring.upper(s)
if upper == 'NG' or upper == 'N/G' or upper == 'N.G.' then
return ''
return ''
end
end

Revision as of 14:15, 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',
	EG = 'Egypt',
	JA = 'Japan',
	JP = 'Japan',
	PH = 'Philippines',
	BR = 'Brazil',
	AR = 'Argentina',
	PE = 'Peru',
	CO = 'Colombia',
	LU = 'Luxembourg',
	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',
	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)
	if RELIGION_NAME[key] then
		return RELIGION_NAME[key]
	end
	local lower = mw.ustring.lower(s)
	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
	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',
	['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

-- 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 = yearOnly(birth)
	death = yearOnly(death)
	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

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,
		lifespan(getArg('birth_year'), getArg('death_year')),
		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 branchRaw = parentArg(frame, 'class_branch')
	if branchRaw == '' then
		branchRaw = parentArg(frame, 'branch')
	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'))
	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