Do not add unlettered MII categories when a lettered form is present
Infobox: drop certificate/arrive/depart/years; expand other_class codes
 
(52 intermediate revisions by the same user not shown)
Line 185: Line 185:
'physically unfit', 'unfit for', 'relieved prior', 'relieved of',
'physically unfit', 'unfit for', 'relieved prior', 'relieved of',
'kicked', 'eliminated from', 'not graduated', 'did not graduate',
'kicked', 'eliminated from', 'not graduated', 'did not graduate',
'excess absence', 'illness', 'sick call', 'medical board',
'psychiatr', 'neurot', 'homosexual', 'suicid', 'alcohol', 'drunk',
'intoxicat', 'venereal', 'syphili', 'gonorrh', 'mental case',
'mental illness', 'nervous breakdown',
'non-graduate', 'non graduate', 'nongraduate', 'certificate listed as ng',
}
}


Line 289: Line 294:
w = trim(w)
w = trim(w)
if w == '' then
if w == '' then
return w
end
if mw.ustring.find(w, '%d') then
return w
return w
end
end
Line 343: Line 351:
end
end
return lowBare
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
end


Line 403: Line 415:
return false
return false
end
end
-- NARA / roster nativity (birthplace) codes → display names.
local NATIVITY_NAME = {
US = 'United States',
GE = 'Germany',
AU = 'Austria',
RU = 'Russia',
SU = 'Soviet Union',
IT = 'Italy',
CA = 'Canada',
FR = 'France',
PO = 'Poland',
PL = 'Poland',
TU = 'Turkey',
PR = 'Puerto Rico',
SW = 'Sweden',
SZ = 'Switzerland',
CH = 'China',
CZ = 'Czechoslovakia',
MX = 'Mexico',
NE = 'Netherlands',
NL = 'Netherlands',
SYR = 'Syria',
SY = 'Syria',
HU = 'Hungary',
GB = 'Great Britain',
UK = 'United Kingdom',
CU = 'Cuba',
RO = 'Romania',
SP = 'Spain',
ES = 'Spain',
BE = 'Belgium',
NOR = 'Norway',
NO = 'Norway',
DK = 'Denmark',
CYP = 'Cyprus',
GR = 'Greece',
YU = 'Yugoslavia',
FI = 'Finland',
EE = 'Estonia',
LA = 'Latvia',
LAT = 'Latvia',
LI = 'Lithuania',
LT = 'Lithuania',
LV = 'Latvia',
AL = 'Albania',
ALB = 'Albania',
BU = 'Bulgaria',
BUL = 'Bulgaria',
BG = 'Bulgaria',
LUX = 'Luxembourg',
BRA = 'Brazil',
IR = 'Ireland',
IE = 'Ireland',
PA = 'Palestine',
PAL = 'Palestine',
EG = 'Egypt',
JA = 'Japan',
JP = 'Japan',
PH = 'Philippines',
BR = 'Brazil',
AR = 'Argentina',
ARG = 'Argentina',
PE = 'Peru',
CO = 'Colombia',
COL = 'Colombia',
LU = 'Luxembourg',
ME = 'Mexico',
CHN = 'China',
LITH = 'Lithuania',
PRTG = 'Portugal',
PT = 'Portugal',
DR = 'Dominican Republic',
DO = 'Dominican Republic',
OTH = 'Other',
OTHER = 'Other',
}
-- Roster religion codes → display names (Hebrew/Jewish codes unify to Jewish).
local RELIGION_NAME = {
J = 'Jewish',
['J*'] = 'Jewish',
H = 'Jewish',
HEBREW = 'Jewish',
JEWISH = 'Jewish',
JEW = 'Jewish',
['X (J)'] = 'Jewish',
['X(J)'] = 'Jewish',
P = 'Protestant',
C = 'Catholic',
RC = 'Roman Catholic',
GO = 'Greek Orthodox',
O = 'Orthodox',
CS = 'Christian Science',
LDS = 'Latter-day Saints',
MOS = 'Muslim',
M = 'Muslim',
ISLAM = 'Muslim',
Q = 'Quaker',
QU = 'Quaker',
QUA = 'Quaker',
QUAKER = 'Quaker',
FRIENDS = 'Quaker',
N = 'None',
X = 'Not stated',
['???'] = 'Unknown',
UNK = 'Unknown',
PC = 'Protestant',
}


local function joinCityState(cityRaw, stateName, srcAllCaps)
local function joinCityState(cityRaw, stateName, srcAllCaps)
Line 539: Line 660:
end
end
return s
return s
end
local function expandNativity(s)
s = tidyDisplay(s)
if s == '' then
return ''
end
if s == '???' then
return 'Unknown'
end
local key = mw.ustring.upper(s)
key = mw.ustring.gsub(key, '%.', '')
key = trim(key)
if NATIVITY_NAME[key] then
return NATIVITY_NAME[key]
end
return prettyPlace(s)
end
local function expandReligion(s)
s = tidyDisplay(s)
if s == '' then
return ''
end
local key = mw.ustring.upper(s)
key = mw.ustring.gsub(key, '%s+', ' ')
key = trim(key)
local mapped = RELIGION_NAME[key]
if mapped then
-- Hide non-informative roster placeholders from the infobox.
if mapped == 'Not stated' or mapped == 'None' or mapped == 'Unknown' then
return ''
end
return mapped
end
local lower = mw.ustring.lower(s)
if lower == 'not stated' or lower == 'none' or lower == 'unknown'
or lower == 'n/a' or lower == 'na' then
return ''
end
if lower == 'hebrew' or lower == 'jewish' or lower == 'jew'
or mw.ustring.find(lower, 'jewish', 1, true)
or mw.ustring.find(lower, 'hebrew', 1, true) then
return 'Jewish'
end
if lower == 'quaker' or lower == 'friends' then
return 'Quaker'
end
return prettyProse(s)
end
-- Roster "other_class" codes → readable course names (By The Numbers / MITC charts).
local function expandOtherClassToken(tok)
tok = tidyDisplay(tok)
if tok == '' then
return ''
end
local u = mw.ustring.upper(tok)
u = mw.ustring.gsub(u, '%s+', ' ')
u = trim(u)
local n = mw.ustring.match(u, '^SC%s*B%s*(%d+)$')
if n then
return 'Special Course B ' .. n
end
n = mw.ustring.match(u, '^SC%s*(%d+)$')
if n then
-- SC# aligns with AGF Photo Interpretation / “Special Course” numbering
-- (e.g. SC8 = Special Course 8 in PI).
return 'Special Course ' .. n
end
n = mw.ustring.match(u, '^DC%s*(%d+)$')
if n then
return 'Division Class ' .. n
end
n = mw.ustring.match(u, '^MITU%s*(%d+)$')
if n then
return 'Mobile Intelligence Training Unit ' .. n
end
n = mw.ustring.match(u, '^RDC%s*(%d+)$')
if n then
return 'Redeployment Class ' .. n
end
n = mw.ustring.match(u, '^RCM%s*(%d+)$')
if n then
return 'Radio Countermeasures Class ' .. n
end
n = mw.ustring.match(u, '^OB%s*(%d+)$')
if n then
return 'Order of Battle Class ' .. n
end
if u == 'SIG' or u == 'SIG-SIG' or mw.ustring.match(u, '^SIG[%s%-]') then
return 'Special Intelligence Group–Signal'
end
local unitNum, unitKind = mw.ustring.match(u, '^(%d+)(?:ST|ND|RD|TH)%s+(.+)$')
if unitNum and unitKind then
local kind = trim(unitKind)
if kind == 'INF' then
kind = 'Infantry'
elseif kind == 'ARMD' then
kind = 'Armored'
else
kind = prettyProse(kind)
end
local ord = unitNum
local last = mw.ustring.sub(unitNum, -1)
local last2 = mw.ustring.len(unitNum) >= 2 and mw.ustring.sub(unitNum, -2) or ''
if last2 ~= '11' and last2 ~= '12' and last2 ~= '13' then
if last == '1' then
ord = unitNum .. 'st'
elseif last == '2' then
ord = unitNum .. 'nd'
elseif last == '3' then
ord = unitNum .. 'rd'
else
ord = unitNum .. 'th'
end
else
ord = unitNum .. 'th'
end
return ord .. ' ' .. kind
end
return prettyProse(tok)
end
local function expandOtherClass(raw)
raw = tidyDisplay(raw)
if raw == '' then
return ''
end
local parts = {}
for piece in mw.ustring.gmatch(raw, '[^;,]+') do
local expanded = expandOtherClassToken(piece)
if expanded ~= '' then
table.insert(parts, expanded)
end
end
return table.concat(parts, '; ')
end
end


Line 548: Line 809:
local lower = mw.ustring.lower(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
if lower == 'failed' or mw.ustring.find(lower, '^failed%s') or mw.ustring.find(lower, '%sfailed%s') or mw.ustring.find(lower, '%sfailed$') then
return true
end
-- Bare class-chart non-graduate marker (e.g. "NG Class 10 OB", trailing "NG")
if lower == 'ng' or mw.ustring.find(lower, '^ng%s') or mw.ustring.find(lower, '%sng%s') or mw.ustring.find(lower, '%sng$') or mw.ustring.find(lower, 'nongrad') then
return true
return true
end
end
Line 556: Line 821:
end
end
return false
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
end


Line 617: Line 910:


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


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


Line 662: Line 1,053:
:addClass('infobox-header')
:addClass('infobox-header')
:wikitext(text)
: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
end


Line 805: Line 1,213:
['CAV'] = 'Cavalry',
['CAV'] = 'Cavalry',
['CAVALRY'] = 'Cavalry',
['CAVALRY'] = 'Cavalry',
['CALVALRY'] = 'Cavalry',
['CE'] = 'Corps of Engineers',
['CE'] = 'Corps of Engineers',
['ENG'] = 'Corps of Engineers',
['ENG'] = 'Corps of Engineers',
Line 871: Line 1,280:
end
end
return raw
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
end


Line 881: Line 1,497:
for part in mw.text.gsplit(raw, ';', true) do
for part in mw.text.gsplit(raw, ';', true) do
part = expandBranchToken(part)
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
if part ~= '' then
table.insert(parts, part)
table.insert(parts, part)
end
end
end
end
return table.concat(parts, '; ')
if #parts <= 1 then
return raw
end
return table.concat(parts, '<br />')
end
 
local function splitAwardRows(raw)
raw = trim(raw)
if raw == '' then
return {}
end
-- Prefer @@ row separator (safe inside {{Soldier}}); also accept legacy |||
local sep = '@@'
if not mw.ustring.find(raw, '@@', 1, true) and mw.ustring.find(raw, '|||', 1, true) then
sep = '|||'
end
local rows = {}
for row in mw.text.gsplit(raw, sep, true) do
row = trim(row)
if row ~= '' then
local cells = {}
for cell in mw.text.gsplit(row, ';;', true) do
cell = trim(cell)
-- Unescape {{!}} used if a label ever needed a pipe
cell = mw.ustring.gsub(cell, '{{!}}', '|')
if cell ~= '' then
table.insert(cells, cell)
end
end
if #cells > 0 then
table.insert(rows, cells)
end
end
end
return rows
end
 
local function awardRibbonRows(raw, rowRaw)
-- Prefer explicit Wikipedia row layout when present
local rows = splitAwardRows(rowRaw or '')
if #rows > 0 then
return rows
end
raw = trim(raw)
if raw == '' then
return {}
end
local files = {}
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '^[Ff]ile:', '')
if part ~= '' then
table.insert(files, part)
end
end
if #files == 0 then
return {}
end
-- Fallback: pack ~4 across
local perRow = 4
rows = {}
local cur = {}
for _, file in ipairs(files) do
table.insert(cur, file)
if #cur == perRow then
table.insert(rows, cur)
cur = {}
end
end
if #cur > 0 then
table.insert(rows, cur)
end
return rows
end
 
local function awardRowsMatch(ribbonRows, labelRows)
if #ribbonRows == 0 or #ribbonRows ~= #labelRows then
return false
end
for i, cells in ipairs(ribbonRows) do
if #cells ~= #(labelRows[i] or {}) then
return false
end
end
return true
end
 
local function formatAwardsRibbons(raw, rowRaw, labelRaw)
local rows = awardRibbonRows(raw, rowRaw)
if #rows == 0 then
return '', false
end
local labelRows = splitAwardRows(labelRaw or '')
local labeled = awardRowsMatch(rows, labelRows)
-- One table per row so short rows (e.g. 2 ribbons) center like full rows.
-- When labels match ribbon cells, put captions in the same table so each
-- name sits centered under its ribbon (equal column widths).
local out = {}
for i, cells in ipairs(rows) do
if labeled then
table.insert(out, '{| class="soldier-ribbon-rack soldier-awards-labeled"')
else
table.insert(out, '{| class="soldier-ribbon-rack"')
end
table.insert(out, '|-')
for _, file in ipairs(cells) do
file = mw.ustring.gsub(file, '^[Ff]ile:', '')
table.insert(out, '| [[File:' .. file .. '|106px|link=]]')
end
if labeled then
table.insert(out, '|-')
for _, label in ipairs(labelRows[i]) do
table.insert(out, '| class="soldier-award-caption" | ' .. label)
end
end
table.insert(out, '|}')
end
return table.concat(out, '\n'), labeled
end
 
local function awardGridColspanUnits(rows)
-- LCM of row lengths so 2-cell and 3-cell rows share one table width
local function gcd(a, b)
while b ~= 0 do
a, b = b, a % b
end
return a
end
local function lcm(a, b)
if a == 0 or b == 0 then
return a + b
end
return (a / gcd(a, b)) * b
end
local units = 0
for _, cells in ipairs(rows) do
local n = #cells
if n > 0 then
units = lcm(units, n)
end
end
return units > 0 and units or 1
end
 
local function formatAwardsGrid(raw)
local rows = splitAwardRows(raw)
if #rows == 0 then
return ''
end
local units = awardGridColspanUnits(rows)
local out = { '{| class="wikitable soldier-awards-grid"' }
for _, cells in ipairs(rows) do
local n = #cells
if n == 0 then
-- skip
else
local span = units / n
table.insert(out, '|-')
for _, cell in ipairs(cells) do
-- Allow <br /> from Wikipedia-style multi-line labels
table.insert(out, '| colspan="' .. tostring(span) .. '" | ' .. cell)
end
end
end
table.insert(out, '|}')
return table.concat(out, '\n')
end
 
local function formatAwardsLegend(raw)
-- Do not run prettyProse/prettyPlace — commas in prose (e.g. statue note)
-- get misread as place names and Title-Cased / rewrapped.
raw = trim(raw)
if raw == '' then
return ''
end
local parts = {}
raw = mw.ustring.gsub(raw, '<br%s*/?>', ';')
for part in mw.text.gsplit(raw, ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '^%*%s*', '')
if part ~= '' then
if isAllCapsPhrase(part) then
part = prettyTitle(part, true)
end
table.insert(parts, '* ' .. part)
end
end
if #parts == 0 then
return ''
end
return table.concat(parts, '\n')
end
end


local function lifespan(birth, death)
local function lifespan(birth, death)
birth = yearOnly(birth)
birth = trim(birth or '')
death = yearOnly(death)
death = trim(death or '')
if birth ~= '' and death ~= '' then
if birth ~= '' and death ~= '' then
return ' (' .. birth .. '&ndash;' .. death .. ')'
return ' (' .. birth .. ' &ndash; ' .. death .. ')'
end
end
if birth ~= '' then
if birth ~= '' then
Line 901: Line 1,727:
end
end
return ''
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
end


Line 913: Line 1,752:
function p.displayName(frame)
function p.displayName(frame)
return 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
end


Line 918: Line 1,837:
return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ')
end
end
local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' }
local function photoFileExists(filename)
filename = trim(filename)
if filename == '' then
return false
end
local title = mw.title.new('File:' .. filename)
return title ~= nil and title.exists
end
local function pushPhotoCandidate(candidates, seen, value)
value = trim(value)
if value == '' or seen[value] then
return
end
seen[value] = true
table.insert(candidates, value)
end
local function resolvePhoto(frame)
local explicit = trim(parentArg(frame, 'photo'))
if explicit ~= '' then
return explicit
end
local first = trim(parentArg(frame, 'first'))
local surname = trim(parentArg(frame, 'surname'))
local asn = trim(parentArg(frame, 'asn'))
if first == '' or surname == '' then
return ''
end
local candidates = {}
local seen = {}
local lowerFirst = mw.ustring.lower(first)
local lowerSurname = mw.ustring.lower(surname)
local bases = {
first .. ' ' .. surname,
first .. ' ' .. lowerSurname,
lowerFirst .. ' ' .. lowerSurname,
first .. '_' .. surname,
first .. '_' .. lowerSurname,
lowerFirst .. '_' .. lowerSurname,
surname .. ', ' .. first,
surname .. '_' .. first,
}
if asn ~= '' then
table.insert(bases, surname .. ', ' .. first .. ' (' .. asn .. ')')
end
for _, base in ipairs(bases) do
pushPhotoCandidate(candidates, seen, base)
end
for _, base in ipairs(candidates) do
for _, ext in ipairs(PHOTO_EXTENSIONS) do
local filename = base .. '.' .. ext
if photoFileExists(filename) then
return filename
end
local upperExt = mw.ustring.upper(ext)
if upperExt ~= ext then
filename = base .. '.' .. upperExt
if photoFileExists(filename) then
return filename
end
end
end
end
return ''
end
-- Filled after noteTeamNames (local forward declare; used by p.infobox).
local addTeamsInfoboxRow


function p.infobox(frame)
function p.infobox(frame)
Line 929: Line 1,923:
:wikitext(name)
:wikitext(name)


local photo = parentArg(frame, 'photo')
local photo = resolvePhoto(frame)
if photo ~= '' then
if photo ~= '' then
local size = parentArg(frame, 'image_size')
local size = parentArg(frame, 'image_size')
Line 950: Line 1,944:


local alias = prettyNamePart(parentArg(frame, 'alias'))
local alias = prettyNamePart(parentArg(frame, 'alias'))
local birth = yearOnly(parentArg(frame, 'birth_year'))
local birthYear = yearOnly(parentArg(frame, 'birth_year'))
local death = yearOnly(parentArg(frame, 'death_year'))
local deathYear = yearOnly(parentArg(frame, 'death_year'))
local nativity = prettyPlace(parentArg(frame, 'nativity'))
local birthDate = prettyProse(parentArg(frame, 'birth_date'))
local religion = prettyProse(parentArg(frame, 'religion'))
local deathDate = prettyProse(parentArg(frame, 'death_date'))
local marital = prettyProse(parentArg(frame, 'marital'))
local birthPlace = prettyProse(parentArg(frame, 'birth_place'))
local education = prettyProse(parentArg(frame, 'education'))
local deathPlace = prettyProse(parentArg(frame, 'death_place'))
local occupation = prettyProse(parentArg(frame, 'occupation'))
local nativity = expandNativity(parentArg(frame, 'nativity'))
local state = prettyPlace(parentArg(frame, 'state'))
local religion = expandReligion(parentArg(frame, 'religion'))
-- education / marital / occupation stay in Technical record (NARA snapshot);
-- curated life facts belong in Biography / Timeline, not the infobox.
local knownFor = prettyProse(parentArg(frame, 'known_for'))
local awards = prettyProse(parentArg(frame, 'awards'))
local ribbonsRaw = parentArg(frame, 'awards_ribbons')
-- Full medal list belongs under the body ribbon rack; keep infobox short
if ribbonsRaw ~= '' and mw.ustring.len(awards) > 90 then
local first = mw.ustring.match(awards, '^([^;]+)')
awards = trim(first or awards)
end
 
local function datePlaceCell(date, place, year)
local bits = {}
if date ~= '' then
table.insert(bits, date)
elseif year ~= '' then
table.insert(bits, year)
end
if place ~= '' then
table.insert(bits, place)
end
return table.concat(bits, '<br />')
end
 
local birth = datePlaceCell(birthDate, birthPlace, birthYear)
local death = datePlaceCell(deathDate, deathPlace, deathYear)


if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= ''
if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= ''
or religion ~= '' or marital ~= '' or education ~= ''
or religion ~= '' or knownFor ~= '' or awards ~= '' then
or occupation ~= '' or state ~= '' then
addHeader(tbl, 'Personal details')
addHeader(tbl, 'Personal details')
end
end


addRow(tbl, 'Nickname', alias, 'nickname')
addRow(tbl, 'Birth name', alias, 'nickname')
addRow(tbl, 'Born', birth)
addRow(tbl, 'Born', birth)
addRow(tbl, 'Died', death)
addRow(tbl, 'Nativity', nativity)
addRow(tbl, 'Nativity', nativity)
addRow(tbl, 'Died', death)
addRow(tbl, 'Religion', religion)
addRow(tbl, 'Religion', religion)
addRow(tbl, 'Spouse / marital', marital)
addRow(tbl, 'Known for', knownFor)
addRow(tbl, 'Education', education)
addRow(tbl, 'Awards', awards)
addRow(tbl, 'Occupation', occupation)
if state ~= '' then
addRow(tbl, 'State', state)
end


addHeader(tbl, 'Military service')
addHeader(tbl, 'Military service')
addRow(tbl, 'Allegiance', 'United States')
addRow(tbl, 'Allegiance', '[[File:Flag of the United States.png|20px|link=|alt=]] United States')
local branch = expandBranch(parentArg(frame, 'class_branch'))
local serviceName, armName = splitBranchAndArm(
if branch == '' then
parentArg(frame, 'branch'),
branch = 'United States Army'
parentArg(frame, 'class_branch'),
parentArg(frame, 'arm_or_service')
)
if serviceName == '' then
serviceName = 'United States Army'
end
end
addRow(tbl, 'Branch', branch)
addRow(tbl, 'Branch', branchWithEmblem(serviceName))
if armName ~= '' then
-- Roster "branch" codes (FA, Signal Corps, Cavalry, …) are arm/service, not Army/Marines.
addRow(tbl, 'Arm / service', prettyProse(armName))
end
-- years_of_service / date_arrive / date_depart / certificate stay in Technical
-- record only (rarely curated; certificate is a cryptic chart number).
addRow(tbl, 'ASN', parentArg(frame, 'asn'), 'serial')
addRow(tbl, 'ASN', parentArg(frame, 'asn'), 'serial')


Line 1,004: Line 2,029:
addRow(tbl, 'Camp Ritchie', 'Class ' .. table.concat(classBits, ', '))
addRow(tbl, 'Camp Ritchie', 'Class ' .. table.concat(classBits, ', '))
end
end
addRow(tbl, 'Section', parentArg(frame, 'class_section'))
addRow(tbl, 'Section', mw.ustring.upper(parentArg(frame, 'class_section')))
addRow(tbl, 'Other class', parentArg(frame, 'other_class'))
addRow(tbl, 'Other class', expandOtherClass(parentArg(frame, 'other_class')))


local enlist = prettyPlace(parentArg(frame, 'enlistment_place'))
local enlist = prettyPlace(parentArg(frame, 'enlistment_place'))
Line 1,011: Line 2,036:
addRow(tbl, 'Enlisted', enlist)
addRow(tbl, 'Enlisted', enlist)
end
end
addRow(tbl, 'Assignment', assignmentLink(parentArg(frame, 'assignment')))
addRow(tbl, 'Specialty', prettyProse(parentArg(frame, 'specialty')))
addRow(tbl, 'Unit / division', prettyProse(parentArg(frame, 'division')))
addRow(tbl, 'Unit / division', prettyProse(parentArg(frame, 'division')))
addRow(tbl, 'IPW (German)', teamLinkList(parentArg(frame, 'ipw_ge'), 'IPW ', 'IPW'))
addRow(tbl, 'Battles / wars', formatBattles(parentArg(frame, 'battles')))
addRow(tbl, 'IPW (Italian)', teamLinkList(parentArg(frame, 'ipw_it'), 'IPW Italian ', 'IPW Italian'))
addTeamsInfoboxRow(tbl, frame)
addRow(tbl, 'LOC', parentArg(frame, 'loc'))
 
addRow(tbl, 'Arrived', parentArg(frame, 'date_arrive'))
-- Awards ribbons render in the article body (p.awardsSection), not this
addRow(tbl, 'Departed', parentArg(frame, 'date_depart'))
-- narrow infobox — matching Wikipedia's main-column ribbon racks.
addRow(tbl, 'Certificate', parentArg(frame, 'certificate'))


return tostring(tbl)
return tostring(tbl)
end
end


function p.lead(frame)
function p.awardsSection(frame)
local name = displayName(frame)
local gridRaw = parentArg(frame, 'awards_grid')
local rank = preferredRank(frame)
local ribbons, labelsInRack = formatAwardsRibbons(
local role = 'United States Army soldier'
parentArg(frame, 'awards_ribbons'),
if rank ~= '' then
parentArg(frame, 'awards_ribbon_rows'),
role = 'United States Army ' .. rank
gridRaw
)
-- Skip the separate label table when captions already sit under ribbons
local grid = ''
if not labelsInRack then
grid = formatAwardsGrid(gridRaw)
end
local extras = formatAwardsLegend(parentArg(frame, 'awards_extra'))
-- Fallback: flat awards list when no grid was extracted
if grid == '' and extras == '' and not labelsInRack then
extras = formatAwardsLegend(parentArg(frame, 'awards'))
end
end
 
if ribbons == '' and grid == '' and extras == '' then
local classLinks = classLinkList(parentArg(frame, 'class_number'), true)
return ''
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
end
 
-- Use a styled div heading (not == wiki heading) so MediaWiki does not place
local lead = string.format(
-- the Contents box inside this block — JS moves the block after Biography.
"'''%s'''%s was a %s%s.",
local parts = {
name,
'<div class="soldier-awards-section">',
lifespan(parentArg(frame, 'birth_year'), parentArg(frame, 'death_year')),
'<div class="soldier-awards-heading">Awards and honors</div>',
role,
}
trained
if ribbons ~= '' then
)
table.insert(parts, ribbons)
local who = prettyNamePart(parentArg(frame, 'first'))
if who == '' then
who = prettyNamePart(parentArg(frame, 'surname'))
end
end
if who == '' then
if grid ~= '' then
who = 'He'
table.insert(parts, grid)
end
end
local enlist = prettyPlace(parentArg(frame, 'enlistment_place'))
if extras ~= '' then
if enlist ~= '' then
table.insert(parts, '<div class="soldier-awards-list">')
lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.'
table.insert(parts, extras)
table.insert(parts, '</div>')
end
end
return lead
table.insert(parts, '</div>')
return table.concat(parts, '\n')
end
 
function p.lead(frame)
return buildLead(function(key)
return parentArg(frame, key)
end)
end
 
function p.leadFrom(frame)
return buildLead(function(key)
return trim(frame.args[key] or '')
end)
end
end


Line 1,106: Line 2,140:
local work = text
local work = text
for num, letter in string.gmatch(work, 'MII[%s%-]+(%d+)%s*%-%s*([A-Z])') do
for num, letter in string.gmatch(work, 'MII[%s%-]+(%d+)%s*%-%s*([A-Z])') do
add('MII ' .. num .. '-' .. letter)
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
letteredNums[num] = true
add('MII ' .. num)
else
add('MII ' .. num .. '-' .. letter)
letteredNums[num] = true
end
end
end
work = string.gsub(work, 'MII[%s%-]+%d+%s*%-%s*[A-Z]', ' ')
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
for num, letter in string.gmatch(' ' .. text .. ' ', '[^%d]([45]%d%d)%s*%-%s*([A-Z])[^A-Z]') do
add('MII ' .. num .. '-' .. letter)
if letter == 'R' and (num == '540' or num == '545' or num == '546') then
letteredNums[num] = true
add('MII ' .. num)
else
add('MII ' .. num .. '-' .. letter)
letteredNums[num] = true
end
end
end
for num in string.gmatch(work, 'MII[%s%-]+(%d+)') do
for num in string.gmatch(work, 'MII[%s%-]+(%d+)') do
Line 1,124: Line 2,166:
end
end
end
end
for num in string.gmatch(text, 'IPW%s+(%d+)') do
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)
add('IPW ' .. num)
end
end
return names
return names
end
local function appendUniqueTeamName(names, seen, name)
name = trim(tostring(name or ''))
if name == '' or seen[name] then
return
end
seen[name] = true
table.insert(names, name)
end
local function namesFromIpwGerman(raw)
local names = {}
local seen = {}
for part in mw.text.gsplit(raw or '', ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
part = trim(part)
if part ~= '' then
local code, shown, mii = normalizeIpwTeamCode(part)
if mii then
appendUniqueTeamName(names, seen, 'MII ' .. mii)
elseif code then
appendUniqueTeamName(names, seen, 'IPW ' .. shown)
end
end
end
return names
end
local function namesFromIpwItalian(raw)
local names = {}
local seen = {}
for part in mw.text.gsplit(raw or '', ';', true) do
part = trim(part)
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '')
part = trim(part)
if part ~= '' then
local code = normalizeIpwItalianCode(part)
if code then
appendUniqueTeamName(names, seen, 'IPW Italian ' .. code)
end
end
end
return names
end
--- Explicit |teams= values: "MII 439-G; IPW 12" or shorthand "439-G".
local function namesFromTeamsParam(raw)
local names = {}
local seen = {}
for part in mw.text.gsplit(raw or '', ';', true) do
part = trim(part)
if part ~= '' then
local fromScan = noteTeamNames(part)
if #fromScan > 0 then
for _, name in ipairs(fromScan) do
appendUniqueTeamName(names, seen, name)
end
else
local upper = mw.ustring.upper(part)
local itRest = mw.ustring.match(upper, '^IPW%s+ITALIAN%s+(.+)$')
if itRest then
local code = normalizeIpwItalianCode(itRest)
if code then
appendUniqueTeamName(names, seen, 'IPW Italian ' .. code)
end
else
local ipwRest = mw.ustring.match(upper, '^IPW%s+(.+)$')
if ipwRest then
local code, shown, mii = normalizeIpwTeamCode(ipwRest)
if mii then
appendUniqueTeamName(names, seen, 'MII ' .. mii)
elseif code then
appendUniqueTeamName(names, seen, 'IPW ' .. shown)
end
else
local code, shown, mii = normalizeIpwTeamCode(part)
if mii then
appendUniqueTeamName(names, seen, 'MII ' .. mii)
elseif code and mw.ustring.match(tostring(shown or code), '^%d') then
appendUniqueTeamName(names, seen, 'IPW ' .. shown)
end
end
end
end
end
end
return names
end
local function teamNameLink(name)
return string.format('[[Team %s|%s]]', name, name)
end
local function pageResearchNotesText()
local title = mw.title.getCurrentTitle()
if not title or not title.getContent then
return ''
end
local ok, content = pcall(function()
return title:getContent()
end)
if not ok or not content then
return ''
end
return researchNotesSection(content)
end
--- Roster IPW fields + optional |teams= + research-note team codes, one linked row.
addTeamsInfoboxRow = function(tbl, frame)
local names = {}
local seen = {}
local function addAll(list)
for _, name in ipairs(list) do
appendUniqueTeamName(names, seen, name)
end
end
addAll(namesFromIpwGerman(parentArg(frame, 'ipw_ge')))
addAll(namesFromIpwItalian(parentArg(frame, 'ipw_it')))
addAll(namesFromTeamsParam(parentArg(frame, 'teams')))
addAll(noteTeamNames(pageResearchNotesText()))
if #names == 0 then
return
end
local links = {}
for _, name in ipairs(names) do
table.insert(links, teamNameLink(name))
end
local label = 'Team'
if #names > 1 then
label = 'Teams'
end
addRow(tbl, label, table.concat(links, ', '))
end
end


Line 1,141: Line 2,324:
return ''
return ''
end
end
local seen = {}
local bits = {}
local bits = {}
local function addCat(name)
name = trim(tostring(name or ''))
if name == '' or seen[name] then
return
end
seen[name] = true
table.insert(bits, '[[Category:Team ' .. name .. ']]')
end
for _, name in ipairs(noteTeamNames(researchNotesSection(content))) do
for _, name in ipairs(noteTeamNames(researchNotesSection(content))) do
table.insert(bits, '[[Category:Team ' .. name .. ']]')
addCat(name)
end
for _, name in ipairs(namesFromTeamsParam(parentArg(frame, 'teams'))) do
addCat(name)
end
end
return table.concat(bits)
return table.concat(bits)
Line 1,169: Line 2,364:
local note = sanitizeNote(mw.ustring.sub(line, 3))
local note = sanitizeNote(mw.ustring.sub(line, 3))
if note ~= '' then
if note ~= '' then
table.insert(items, note)
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
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
if #items == 0 then
return ''
return ''
Line 1,184: Line 2,395:
end
end
return tostring(wrap)
return tostring(wrap)
end
local function cargoRows(tables, fields, where, limit, orderBy)
if not mw.ext or not mw.ext.cargo or not mw.ext.cargo.query then
return nil
end
local opts = {
where = where,
limit = limit or 8,
}
if orderBy and orderBy ~= '' then
opts.orderBy = orderBy
end
local ok, result = pcall(mw.ext.cargo.query, tables, fields, opts)
if not ok or type(result) ~= 'table' then
return nil
end
return result
end
local function rowField(row, ...)
for i = 1, select('#', ...) do
local key = select(i, ...)
local v = row[key]
if v ~= nil and trim(tostring(v)) ~= '' then
return trim(tostring(v))
end
end
return ''
end
local function titleToCaption(title)
title = trim(title)
local surname, given = mw.ustring.match(title, '^([^,]+),%s*(.-)%s*%(%d')
if surname and given then
return trim(given .. ' ' .. surname)
end
return title
end
local function displayFromCargoRow(row)
local page = rowField(row, 'pageName', '_pageName')
local surname = rowField(row, 'Surname')
local first = rowField(row, 'First_name', 'First name')
local middle = rowField(row, 'Middle_name', 'Middle name')
local given = trim(first .. (middle ~= '' and (' ' .. middle) or ''))
local caption
if given ~= '' and surname ~= '' then
caption = given .. ' ' .. surname
elseif page ~= '' then
caption = titleToCaption(page)
else
caption = surname
end
if page == '' and surname ~= '' then
local asn = rowField(row, 'ASN')
if asn ~= '' then
page = surname .. ', ' .. (given ~= '' and given or first) .. ' (' .. asn .. ')'
end
end
return page, caption
end
local function linkList(rows, skipAsn, skipPage, maxLinks)
local links = {}
local seen = {}
for _, row in ipairs(rows or {}) do
local asn = rowField(row, 'ASN')
local page, caption = displayFromCargoRow(row)
if caption == '' and page ~= '' then
caption = titleToCaption(page)
end
if page ~= '' and page ~= skipPage and asn ~= skipAsn and not seen[page] then
seen[page] = true
table.insert(links, string.format('[[%s|%s]]', page, caption))
if #links >= maxLinks then
break
end
end
end
return links
end
local function classFullWhere(classNum)
-- Prefer Class_number__full: the Class_number HOLDS index is corrupted on some hosts.
local n = tostring(classNum or '')
n = mw.ustring.gsub(n, '[^0-9A-Za-z%-]', '')
if n == '' then
return nil
end
return string.format(
'Class_number__full="%s" OR Class_number__full LIKE "%s;%%" OR Class_number__full LIKE "%%;%s" OR Class_number__full LIKE "%%;%s;%%"',
n,
n,
n,
n
)
end
function p.storyCue(frame)
local story = parentArg(frame, 'story')
if story == '' then
return ''
end
story = mw.ustring.gsub(story, '^%[%[', '')
story = mw.ustring.gsub(story, '%]%]$', '')
local storyTitle, storyLabel = mw.ustring.match(story, '^([^|]+)|(.+)$')
if not storyTitle then
storyTitle = story
storyLabel = nil
end
storyTitle = trim(storyTitle)
if storyLabel then
storyLabel = trim(storyLabel)
end
if not storyLabel or storyLabel == '' then
storyLabel = mw.ustring.match(storyTitle, '([^/]+)$') or storyTitle
end
local name = displayName(frame)
return string.format(
'<div class="soldier-story-cue">'
.. '<span class="soldier-story-cue-label">Full story</span> '
.. 'Read more about %s in [[%s|%s]] on \'\'[[The Stories]]\'\'.'
.. '</div>',
name,
storyTitle,
storyLabel
)
end
-- related-rail: live team rosters via category members (filled by Common.js)
function p.relatedSoldiers(frame)
local pageTitle = mw.title.getCurrentTitle().prefixedText
-- Same sources as the infobox Teams row: roster IPW, |teams=, research notes.
local teamNames = {}
local seenTeams = {}
local function addTeams(list)
for _, name in ipairs(list) do
appendUniqueTeamName(teamNames, seenTeams, name)
end
end
addTeams(namesFromIpwGerman(parentArg(frame, 'ipw_ge')))
addTeams(namesFromIpwItalian(parentArg(frame, 'ipw_it')))
addTeams(namesFromTeamsParam(parentArg(frame, 'teams')))
addTeams(noteTeamNames(pageResearchNotesText()))
if #teamNames == 0 then
return ''
end
local wrap = mw.html.create('div')
:addClass('soldier-related')
:attr('data-current-title', pageTitle)
wrap:tag('div'):addClass('soldier-related-heading'):wikitext('Teammates')
for _, teamName in ipairs(teamNames) do
local cat = 'Category:Team ' .. teamName
local block = wrap:tag('div')
:addClass('soldier-related-block')
:attr('data-team-cat', cat)
block:tag('div')
:addClass('soldier-related-label')
:wikitext('Team — ' .. teamNameLink(teamName))
-- Noscript / pre-JS fallback: category link. Common.js replaces with live names.
block:tag('div')
:addClass('soldier-related-links')
:addClass('soldier-related-links-live')
:wikitext('[[' .. cat .. '|Browse team roster]]')
end
wrap:tag('div')
:addClass('soldier-related-note')
:wikitext(
'These are other soldiers listed on the same team roster. '
.. 'People moved between teams; overlapping service dates are not always documented.'
)
return tostring(wrap)
end
local function seqCount(t)
-- mw.loadData tables do not always report # correctly.
local n = 0
if type(t) ~= 'table' then
return 0
end
for _ in ipairs(t) do
n = n + 1
end
return n
end
function p.mediaCue(frame)
local pageTitle = mw.title.getCurrentTitle().prefixedText
local ok, data = pcall(mw.loadData, 'Module:Interviews/data')
if not ok or type(data) ~= 'table' or type(data.by_title) ~= 'table' then
return ''
end
local rec = data.by_title[pageTitle]
if type(rec) ~= 'table' then
return ''
end
-- Prefer interviews; accept legacy `items` key from hand-edited cues.
local interviews = rec.interviews
if type(interviews) ~= 'table' then
interviews = rec.items
end
local films = rec.films
local nInterviews = seqCount(interviews)
local nFilms = seqCount(films)
if nInterviews == 0 and nFilms == 0 then
return ''
end
local name = displayName(frame)
local blocks = {}
if nInterviews > 0 then
local links = {}
local anyAudio = false
for _, item in ipairs(interviews) do
local heading = item.heading or 'Interview'
local short = item.short or heading
if item.kind == 'audio' then
anyAudio = true
end
table.insert(
links,
string.format('[[Ritchie Boy Interviews#%s|%s]]', heading, short)
)
end
local label = nInterviews == 1 and 'Interview' or 'Interviews'
local verb = 'Watch'
if nInterviews == 1 and interviews[1].kind == 'audio' then
verb = 'Listen to'
elseif anyAudio and nInterviews > 1 then
verb = 'Hear'
end
local body
if nInterviews == 1 then
body = string.format(
'%s %s on [[Ritchie Boy Interviews#%s|%s]].',
verb,
name,
interviews[1].heading,
interviews[1].short or interviews[1].heading
)
else
body = string.format(
'%s %s in %d recordings on [[Ritchie Boy Interviews]]: %s.',
verb,
name,
nInterviews,
table.concat(links, ' · ')
)
end
table.insert(
blocks,
'<div class="soldier-interview-cue">'
.. '<span class="soldier-interview-cue-label">'
.. label
.. '</span> '
.. body
.. '</div>'
)
end
if nFilms > 0 then
local links = {}
for _, item in ipairs(films) do
local heading = item.heading or 'Documentaries/Movies'
local filmLabel = item.label or heading
table.insert(
links,
string.format('[[Documentaries/Movies#%s|%s]]', heading, filmLabel)
)
end
local body = string.format(
'%s appears in %s.',
name,
table.concat(links, '; ')
)
table.insert(
blocks,
'<div class="soldier-film-cue">'
.. '<span class="soldier-film-cue-label">On film</span> '
.. body
.. '</div>'
)
end
return '<div class="soldier-media-cue">' .. table.concat(blocks, '') .. '</div>'
end
end


return p
return p