Module:Soldier: Difference between revisions
Wikipedia-style awards grid under ribbon rows |
Infobox: drop certificate/arrive/depart/years; expand other_class codes |
||
| (28 intermediate revisions by the same user not shown) | |||
| Line 185: | Line 185: | ||
'physically unfit', 'unfit for', 'relieved prior', 'relieved of', | 'physically unfit', 'unfit for', 'relieved prior', 'relieved of', | ||
'kicked', 'eliminated from', 'not graduated', 'did not graduate', | 'kicked', 'eliminated from', 'not graduated', 'did not graduate', | ||
'excess absence', 'illness', 'sick call', 'medical board', | |||
'psychiatr', 'neurot', 'homosexual', 'suicid', 'alcohol', 'drunk', | |||
'intoxicat', 'venereal', 'syphili', 'gonorrh', 'mental case', | |||
'mental illness', 'nervous breakdown', | |||
'non-graduate', 'non graduate', 'nongraduate', 'certificate listed as ng', | |||
} | } | ||
| Line 451: | Line 456: | ||
EE = 'Estonia', | EE = 'Estonia', | ||
LA = 'Latvia', | LA = 'Latvia', | ||
LAT = 'Latvia', | |||
LI = 'Lithuania', | LI = 'Lithuania', | ||
LT = 'Lithuania', | LT = 'Lithuania', | ||
LV = 'Latvia', | LV = 'Latvia', | ||
AL = 'Albania', | AL = 'Albania', | ||
ALB = 'Albania', | |||
BU = 'Bulgaria', | BU = 'Bulgaria', | ||
BUL = 'Bulgaria', | |||
BG = 'Bulgaria', | BG = 'Bulgaria', | ||
LUX = 'Luxembourg', | |||
BRA = 'Brazil', | |||
IR = 'Ireland', | IR = 'Ireland', | ||
IE = 'Ireland', | IE = 'Ireland', | ||
PA = 'Palestine', | PA = 'Palestine', | ||
PAL = 'Palestine', | |||
EG = 'Egypt', | EG = 'Egypt', | ||
JA = 'Japan', | JA = 'Japan', | ||
| Line 466: | Line 477: | ||
BR = 'Brazil', | BR = 'Brazil', | ||
AR = 'Argentina', | AR = 'Argentina', | ||
ARG = 'Argentina', | |||
PE = 'Peru', | PE = 'Peru', | ||
CO = 'Colombia', | CO = 'Colombia', | ||
COL = 'Colombia', | |||
LU = 'Luxembourg', | LU = 'Luxembourg', | ||
ME = 'Mexico', | |||
CHN = 'China', | |||
LITH = 'Lithuania', | |||
PRTG = 'Portugal', | |||
PT = 'Portugal', | |||
DR = 'Dominican Republic', | |||
DO = 'Dominican Republic', | |||
OTH = 'Other', | OTH = 'Other', | ||
OTHER = 'Other', | OTHER = 'Other', | ||
| Line 494: | Line 514: | ||
ISLAM = 'Muslim', | ISLAM = 'Muslim', | ||
Q = 'Quaker', | Q = 'Quaker', | ||
QU = 'Quaker', | |||
QUA = 'Quaker', | |||
QUAKER = 'Quaker', | |||
FRIENDS = 'Quaker', | |||
N = 'None', | N = 'None', | ||
X = 'Not stated', | X = 'Not stated', | ||
| Line 663: | Line 687: | ||
key = mw.ustring.gsub(key, '%s+', ' ') | key = mw.ustring.gsub(key, '%s+', ' ') | ||
key = trim(key) | key = trim(key) | ||
local mapped = RELIGION_NAME[key] | |||
return | if mapped then | ||
-- Hide non-informative roster placeholders from the infobox. | |||
if mapped == 'Not stated' or mapped == 'None' or mapped == 'Unknown' then | |||
return '' | |||
end | |||
return mapped | |||
end | end | ||
local lower = mw.ustring.lower(s) | local lower = mw.ustring.lower(s) | ||
if lower == 'not stated' or lower == 'none' or lower == 'unknown' | |||
or lower == 'n/a' or lower == 'na' then | |||
return '' | |||
end | |||
if lower == 'hebrew' or lower == 'jewish' or lower == 'jew' | if lower == 'hebrew' or lower == 'jewish' or lower == 'jew' | ||
or mw.ustring.find(lower, 'jewish', 1, true) | or mw.ustring.find(lower, 'jewish', 1, true) | ||
or mw.ustring.find(lower, 'hebrew', 1, true) then | or mw.ustring.find(lower, 'hebrew', 1, true) then | ||
return 'Jewish' | return 'Jewish' | ||
end | |||
if lower == 'quaker' or lower == 'friends' then | |||
return 'Quaker' | |||
end | end | ||
return prettyProse(s) | return prettyProse(s) | ||
end | end | ||
local function | -- Roster "other_class" codes → readable course names (By The Numbers / MITC charts). | ||
return | 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 | local n = mw.ustring.match(u, '^SC%s*B%s*(%d+)$') | ||
if n then | |||
if | return 'Special Course B ' .. n | ||
return | end | ||
n = mw.ustring.match(u, '^SC%s*(%d+)$') | |||
if n then | |||
-- SC# aligns with AGF Photo Interpretation / “Special Course” numbering | |||
-- (e.g. SC8 = Special Course 8 in PI). | |||
return 'Special Course ' .. n | |||
end | |||
n = mw.ustring.match(u, '^DC%s*(%d+)$') | |||
if n then | |||
return 'Division Class ' .. n | |||
end | |||
n = mw.ustring.match(u, '^MITU%s*(%d+)$') | |||
if n then | |||
return 'Mobile Intelligence Training Unit ' .. n | |||
end | |||
n = mw.ustring.match(u, '^RDC%s*(%d+)$') | |||
if n then | |||
return 'Redeployment Class ' .. n | |||
end | |||
n = mw.ustring.match(u, '^RCM%s*(%d+)$') | |||
if n then | |||
return 'Radio Countermeasures Class ' .. n | |||
end | |||
n = mw.ustring.match(u, '^OB%s*(%d+)$') | |||
if n then | |||
return 'Order of Battle Class ' .. n | |||
end | end | ||
if u == 'SIG' or u == 'SIG-SIG' or mw.ustring.match(u, '^SIG[%s%-]') then | |||
return 'Special Intelligence Group–Signal' | |||
end | end | ||
local | 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 | |||
end | kind = 'Armored' | ||
else | |||
local | 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' | |||
if | elseif last == '2' then | ||
ord = unitNum .. 'nd' | |||
elseif last == '3' then | |||
ord = unitNum .. 'rd' | |||
else | |||
ord = unitNum .. 'th' | |||
end | end | ||
else | |||
ord = unitNum .. 'th' | |||
end | end | ||
return ord .. ' ' .. kind | |||
end | end | ||
return | |||
return prettyProse(tok) | |||
end | end | ||
local function | local function expandOtherClass(raw) | ||
raw = tidyDisplay(raw) | |||
if | if raw == '' then | ||
return '' | return '' | ||
end | end | ||
local | local parts = {} | ||
for piece in mw.ustring.gmatch(raw, '[^;,]+') do | |||
local expanded = expandOtherClassToken(piece) | |||
if expanded ~= '' then | |||
table.insert(parts, expanded) | |||
for | |||
if | |||
end | end | ||
end | end | ||
return table.concat( | return table.concat(parts, '; ') | ||
end | end | ||
local function | local function prettyNamePart(s) | ||
return | return prettyNamePhrase(s, false) | ||
end | end | ||
local function | local function isNegativeClause(s) | ||
local | 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 | end | ||
if | -- Bare class-chart non-graduate marker (e.g. "NG Class 10 OB", trailing "NG") | ||
if lower == 'ng' or mw.ustring.find(lower, '^ng%s') or mw.ustring.find(lower, '%sng%s') or mw.ustring.find(lower, '%sng$') or mw.ustring.find(lower, 'nongrad') then | |||
return true | |||
end | end | ||
for _, phrase in ipairs(NEGATIVE_PHRASES) do | |||
if mw.ustring.find(lower, phrase, 1, true) then | |||
return true | |||
if | |||
end | end | ||
end | end | ||
return false | |||
end | |||
return | 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 | end | ||
local function | local function dateOnlyFragment(s) | ||
return mw.ustring.match(trim(s), '^(%d%d?/%d%d?/%d%d%d?%d?)%.?$') | |||
end | end | ||
local function | local function joinDateFollowons(items) | ||
local | 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) | |||
return | end | ||
end | |||
return out | |||
end | end | ||
local function | local function sanitizeNote(s) | ||
s = tidyDisplay(s) | |||
if | if s == '' then | ||
return | return '' | ||
end | |||
local lower = mw.ustring.lower(s) | |||
if mw.ustring.find(lower, 'no notes were in the source', 1, true) then | |||
return '' | |||
end | end | ||
local | 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 | end | ||
return | return table.concat(kept, '; ') | ||
end | end | ||
local function | 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 | end | ||
if middle ~= '' then | |||
if | table.insert(parts, middle) | ||
end | end | ||
if surname ~= '' then | |||
table.insert(parts, surname) | |||
if | |||
end | end | ||
local | local name = table.concat(parts, ' ') | ||
if suffix ~= '' then | |||
if name ~= '' then | |||
name = name .. ', ' .. suffix | |||
else | |||
name = suffix | |||
end | |||
end | end | ||
if name == '' then | |||
name = 'This soldier' | |||
end | end | ||
return name | |||
end | |||
local function classLinkList(raw, withPrefix) | |||
local ClassDates = require('Module:ClassDates') | |||
local | |||
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 | |||
table.insert(out, string.format('[[ | if dates ~= '' then | ||
table.insert(out, string.format('[[:Category:Class %s|Class %s]] (%s)', part, part, dates)) | |||
else | else | ||
table.insert(out, part) | table.insert(out, string.format('[[:Category:Class %s|Class %s]]', part, part)) | ||
end | end | ||
else | |||
if dates ~= '' then | |||
table.insert(out, string.format('[[:Category:Class %s|%s]] (%s)', part, part, dates)) | |||
table.insert(out, string.format('[[ | else | ||
table.insert(out, string.format('[[:Category:Class %s|%s]]', part, part)) | |||
table.insert(out, string.format('[[ | |||
end | end | ||
end | end | ||
end | end | ||
end | end | ||
return | return out | ||
end | end | ||
local function | 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, | |||
end | ['16320'] = true, ['16695'] = true, | ||
['1st army'] = true, ['2 day'] = true, ['2/23/44'] = true, | |||
local function | ['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, | |||
if | ['pac'] = true, ['sc7'] = true, ['shaef'] = true, ['v-831'] = true, | ||
return | } | ||
return junk[spaced] == true | |||
end | |||
local function normalizeIpwItalianCode(part) | |||
part = trim(part) | |||
if part == '' or isJunkIpwItalianLabel(part) then | |||
return nil | |||
end | end | ||
local spaced = mw.ustring.lower(mw.ustring.gsub(part, '%s+', ' ')) | |||
if spaced == '5th class' or spaced == 'italian 5th class' then | |||
return '' | return '5' | ||
end | end | ||
return | return part | ||
end | end | ||
local function | local function isJunkIpwLabel(part) | ||
local lower = mw.ustring.lower(trim(part)) | |||
return | lower = mw.ustring.gsub(lower, '%s+', ' ') | ||
lower = mw.ustring.gsub(lower, '%s*%?+%s*$', '') | |||
lower = trim(lower) | |||
if lower == 'csu' or lower == 'fh' or lower == 'fid' or lower == 'illness' | |||
or lower == 'mfiu-5' or lower == 'mfiu 5' or lower == 'mitu 5' or lower == 'mitu-5' | |||
or lower == 'mrbc4' or lower == 'mrbc 4' or lower == 't force' or lower == 'to cic' then | |||
return true | |||
end | end | ||
local | 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 | end | ||
return false | |||
end | end | ||
local function | local function normalizeIpwTeamCode(part) | ||
part = trim(part) | |||
if | if part == '' then | ||
return | return nil, nil | ||
end | |||
local original = part | |||
part = mw.ustring.gsub(part, '%s*%?+%s*$', '') | |||
part = trim(part) | |||
if part == '' then | |||
return nil, original | |||
end | end | ||
local | local lower = mw.ustring.lower(part) | ||
if mw.ustring.find(lower, 'armd', 1, true) or mw.ustring.find(lower, 'armored', 1, true) then | |||
return | return nil, original, nil | ||
end | end | ||
local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$') | |||
if miiNum then | |||
miiLetter = mw.ustring.upper(miiLetter) | |||
local | 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 | local function realCertificate(s) | ||
s = trim(s) | |||
if | if s == '' then | ||
return '' | return '' | ||
end | end | ||
-- Placeholders: *, A, 5, #, and any other single character. | |||
if mw.ustring.len(s) == 1 then | |||
return '' | |||
end | |||
return | -- 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 | ||
local | 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 | local function expandRank(raw) | ||
raw = trim(raw) | |||
if | if raw == '' then | ||
return '' | return '' | ||
end | end | ||
local | 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 | end | ||
local | local BRANCH_MAP = { | ||
['INF'] = 'Infantry', | |||
['INFANTRY'] = 'Infantry', | |||
['FA'] = 'Field Artillery', | |||
['FIELD ARTILLERY'] = 'Field Artillery', | |||
['CAC'] = 'Coast Artillery Corps', | |||
['CA'] = 'Coast Artillery', | |||
['COAST ARTILLERY'] = 'Coast Artillery', | |||
['AC'] = 'Air Corps', | |||
['AAF'] = 'Army Air Forces', | |||
['AIR CORPS'] = 'Air Corps', | |||
['QMC'] = 'Quartermaster Corps', | |||
['QC'] = 'Quartermaster Corps', | |||
['QUARTERMASTER'] = 'Quartermaster Corps', | |||
['CAV'] = 'Cavalry', | |||
['CAVALRY'] = 'Cavalry', | |||
['CALVALRY'] = 'Cavalry', | |||
['CE'] = 'Corps of Engineers', | |||
['ENG'] = 'Corps of Engineers', | |||
['ENGINEERS'] = 'Corps of Engineers', | |||
['CMP'] = 'Corps of Military Police', | |||
['MP'] = 'Military Police', | |||
['CWS'] = 'Chemical Warfare Service', | |||
['AUS'] = 'Army of the United States', | |||
['SC'] = 'Signal Corps', | |||
['SIG'] = 'Signal Corps', | |||
['SIG C'] = 'Signal Corps', | |||
['SIGC'] = 'Signal Corps', | |||
['SIGNAL CORPS'] = 'Signal Corps', | |||
['ORD'] = 'Ordnance Department', | |||
['ORDNANCE'] = 'Ordnance Department', | |||
['MAC'] = 'Medical Administrative Corps', | |||
['MC'] = 'Medical Corps', | |||
['MD'] = 'Medical Department', | |||
['TC'] = 'Transportation Corps', | |||
['MI'] = 'Military Intelligence', | |||
['MIS'] = 'Military Intelligence Service', | |||
['INT'] = 'Intelligence', | |||
local function | ['CIC'] = 'Counter Intelligence Corps', | ||
raw = trim(raw) | ['AGD'] = "Adjutant General's Department", | ||
if raw == '' then | ['USMC'] = 'United States Marine Corps', | ||
return | ['USMCR'] = 'United States Marine Corps Reserve', | ||
end | ['AA'] = 'Antiaircraft', | ||
local | ['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', | |||
end | ['CAU'] = 'Civil Affairs', | ||
end | } | ||
return | |||
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 | end | ||
local function | local function keyEqualsAAF(part) | ||
local key = mw.ustring.upper(trim(part)) | |||
key = mw.ustring.gsub(key, '%.', '') | |||
key = mw.ustring.gsub(key, '%s+', ' ') | |||
return key == 'AAF' or key == 'ARMY AIR FORCES' | |||
end | end | ||
local function | -- True when a BRANCH_MAP / class_branch value is an Army arm/service, not Army/Marines/Navy. | ||
local | local SERVICE_BRANCH_KEY = { | ||
if | ['AUS'] = true, | ||
return '' | ['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 | end | ||
if mw.ustring.find(low, 'united states army', 1, true) | |||
or low == 'army' | |||
or low == 'u.s. army' | |||
or low == 'us army' then | |||
return true | |||
end | end | ||
return false | |||
return | |||
end | end | ||
local function | local function isArmOfServiceToken(raw) | ||
raw = | raw = trim(raw) | ||
if raw == '' then | if raw == '' then | ||
return '' | 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 | 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 | ||
end | end | ||
return false | |||
end | end | ||
local function | local function splitBranchAndArm(branchRaw, classBranchRaw, armRaw) | ||
branchRaw = trim(branchRaw) | |||
classBranchRaw = trim(classBranchRaw) | |||
if | armRaw = trim(armRaw) | ||
local service = '' | |||
local arms = {} | |||
local function consider(raw, preferArm) | |||
raw = trim(raw) | |||
if raw == '' then | |||
return | |||
end | |||
for part in mw.text.gsplit(raw, ';', true) do | |||
part = trim(part) | |||
if part ~= '' then | |||
local expanded = expandBranchToken(part) | |||
if isServiceBranchName(expanded) or isServiceBranchName(part) then | |||
if service == '' then | |||
service = expandBranchToken(part) | |||
if not isServiceBranchName(service) then | |||
-- expandBranchToken may not rewrite "Army" | |||
local low = mw.ustring.lower(part) | |||
if mw.ustring.find(low, 'marine', 1, true) then | |||
service = 'United States Marine Corps' | |||
elseif mw.ustring.find(low, 'navy', 1, true) then | |||
service = 'United States Navy' | |||
elseif mw.ustring.find(low, 'air force', 1, true) | |||
or mw.ustring.find(low, 'air forces', 1, true) | |||
or keyEqualsAAF(part) then | |||
service = 'United States Army Air Forces' | |||
else | |||
service = 'United States Army' | |||
end | |||
end | |||
end | |||
elseif preferArm or isArmOfServiceToken(part) or isArmOfServiceToken(expanded) then | |||
local armName = expanded | |||
if mw.ustring.find(mw.ustring.lower(part), 'calvalry', 1, true) then | |||
armName = 'Cavalry' | |||
end | |||
local dup = false | |||
for _, existing in ipairs(arms) do | |||
if existing == armName then | |||
dup = true | |||
break | |||
end | |||
end | |||
if not dup then | |||
table.insert(arms, armName) | |||
end | |||
elseif preferArm then | |||
table.insert(arms, expanded) | |||
elseif service == '' and not preferArm then | |||
service = expanded | |||
end | |||
end | |||
end | |||
end | end | ||
-- 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 | |||
return | consider(branchRaw, false) | ||
local armJoined = table.concat(arms, '; ') | |||
return service, armJoined | |||
end | end | ||
local | -- 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 '' | |||
local function | |||
end | end | ||
if | local file = BRANCH_EMBLEM[name] | ||
if file then | |||
return '[[File:' .. file .. '|18px|link=|alt=]] ' .. name | |||
end | end | ||
if | -- 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 | end | ||
if mw.ustring.find(low, 'navy', 1, true) then | |||
return '[[File:Branch emblem US Navy.png|18px|link=|alt=]] ' .. name | |||
end | end | ||
if | 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 | end | ||
return name | if mw.ustring.find(low, 'coast guard', 1, true) then | ||
end | return '[[File:Branch emblem US Coast Guard.png|18px|link=|alt=]] ' .. name | ||
end | |||
if mw.ustring.find(low, 'army', 1, true) then | |||
return '[[File:Branch emblem US Army.png|18px|link=|alt=]] ' .. name | |||
end | end | ||
return | return name | ||
end | end | ||
local function | local function expandBranch(raw) | ||
raw = trim(raw) | |||
if raw == '' then | |||
return '' | |||
end | |||
local parts = {} | |||
end | for part in mw.text.gsplit(raw, ';', true) do | ||
part = expandBranchToken(part) | |||
local | if part ~= '' then | ||
table.insert(parts, branchWithEmblem(part)) | |||
end | |||
end | end | ||
return table.concat(parts, '; ') | |||
end | |||
local function formatBattles(raw) | |||
raw = trim(raw) | |||
if raw == '' then | |||
return '' | |||
if | |||
end | end | ||
if | -- 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 | end | ||
if #parts <= 1 then | |||
if | return raw | ||
end | end | ||
return | return table.concat(parts, '<br />') | ||
end | end | ||
function | local function splitAwardRows(raw) | ||
raw = trim(raw) | |||
end | if raw == '' then | ||
return {} | |||
local | 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 | end | ||
return rows | |||
return | |||
end | end | ||
local function | local function awardRibbonRows(raw, rowRaw) | ||
-- Prefer explicit Wikipedia row layout when present | |||
if | local rows = splitAwardRows(rowRaw or '') | ||
return | 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 | end | ||
return rows | |||
end | end | ||
local function | local function awardRowsMatch(ribbonRows, labelRows) | ||
if #ribbonRows == 0 or #ribbonRows ~= #labelRows then | |||
if | return false | ||
return | |||
end | end | ||
for i, cells in ipairs(ribbonRows) do | |||
if #cells ~= #(labelRows[i] or {}) then | |||
return false | |||
end | |||
end | end | ||
return true | |||
end | |||
local function formatAwardsRibbons(raw, rowRaw, labelRaw) | |||
local rows = awardRibbonRows(raw, rowRaw) | |||
if #rows == 0 then | |||
local | return '', false | ||
end | end | ||
for _, | 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 | 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 | ||
end | end | ||
return | return units > 0 and units or 1 | ||
end | end | ||
function | local function formatAwardsGrid(raw) | ||
local | local rows = splitAwardRows(raw) | ||
local | 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 '' | |||
if | 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) | |||
if | |||
end | end | ||
end | end | ||
if #parts == 0 then | |||
return '' | |||
end | |||
return table.concat(parts, '\n') | |||
end | |||
local function lifespan(birth, death) | |||
birth = trim(birth or '') | |||
death = trim(death or '') | |||
if birth ~= '' and death ~= '' then | |||
return ' (' .. birth .. ' – ' .. death .. ')' | |||
end | |||
if birth ~= '' then | |||
return ' (born ' .. birth .. ')' | |||
end | |||
if death ~= '' then | |||
return ' (died ' .. death .. ')' | |||
if | |||
end | end | ||
return '' | |||
end | |||
--- Prefer full birth/death dates (Wikipedia style); fall back to years. | |||
local function lifespanFromArgs(getArg) | |||
local birth = prettyProse(getArg('birth_date')) | |||
local death = prettyProse(getArg('death_date')) | |||
if birth == '' then | |||
birth = yearOnly(getArg('birth_year')) | |||
end | |||
if death == '' then | |||
death = yearOnly(getArg('death_year')) | |||
end | end | ||
return lifespan(birth, death) | |||
end | |||
local function preferredRank(frame) | |||
local | local rank = expandRank(parentArg(frame, 'rank_final')) | ||
if rank == '' then | |||
if | rank = expandRank(parentArg(frame, 'class_rank')) | ||
end | 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 | if first ~= '' then | ||
table.insert(parts, first) | |||
end | end | ||
if middle ~= '' then | |||
table.insert(parts, middle) | |||
if | |||
end | end | ||
if surname ~= '' then | |||
if | table.insert(parts, surname) | ||
end | end | ||
local name = table.concat(parts, ' ') | |||
if suffix ~= '' then | |||
if name ~= '' then | |||
name = name .. ', ' .. suffix | |||
local | else | ||
name = suffix | |||
end | end | ||
end | end | ||
if name == '' then | |||
name = 'This soldier' | |||
end | |||
return name | |||
end | |||
local function preferredRankFrom(getArg) | |||
local | local rank = expandRank(getArg('rank_final')) | ||
if | if rank == '' then | ||
rank = expandRank(getArg('class_rank')) | |||
end | end | ||
return rank | |||
end | |||
local | local function buildLead(getArg) | ||
if | local name = displayNameFrom(getArg) | ||
local rank = preferredRankFrom(getArg) | |||
local role = 'United States Army soldier' | |||
if rank ~= '' then | |||
role = 'United States Army ' .. rank | |||
end | 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 | local who = prettyNamePart(getArg('first')) | ||
if who == '' then | |||
who = prettyNamePart(getArg('surname')) | |||
if | |||
end | end | ||
if | if who == '' then | ||
who = 'He' | |||
end | end | ||
local enlist = prettyPlace(getArg('enlistment_place')) | |||
if enlist ~= '' then | |||
local | lead = lead .. ' ' .. who .. ' enlisted at ' .. enlist .. '.' | ||
if | |||
end | end | ||
return lead | |||
return | |||
end | end | ||
function p. | function p.classLinks(frame) | ||
return | return table.concat(classLinkList(parentArg(frame, 'class_number'), false), ', ') | ||
end | end | ||
local PHOTO_EXTENSIONS = { 'jpg', 'jpeg', 'png', 'webp', 'gif' } | |||
local function | local function photoFileExists(filename) | ||
if | filename = trim(filename) | ||
return | if filename == '' then | ||
return false | |||
end | end | ||
local | local title = mw.title.new('File:' .. filename) | ||
return title ~= nil and title.exists | |||
end | end | ||
local function | local function pushPhotoCandidate(candidates, seen, value) | ||
value = trim(value) | |||
if | if value == '' or seen[value] then | ||
return | return | ||
end | end | ||
seen[value] = true | |||
table.insert(candidates, value) | |||
end | end | ||
local function | local function resolvePhoto(frame) | ||
local explicit = trim(parentArg(frame, 'photo')) | |||
if explicit ~= '' then | |||
return explicit | |||
end | end | ||
local | |||
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 | 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 | end | ||
for | for _, base in ipairs(bases) do | ||
pushPhotoCandidate(candidates, seen, base) | |||
local | end | ||
if | |||
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 | end | ||
end | end | ||
return '' | |||
if | end | ||
-- Filled after noteTeamNames (local forward declare; used by p.infobox). | |||
local addTeamsInfoboxRow | |||
function p.infobox(frame) | |||
local name = displayName(frame) | |||
local tbl = mw.html.create('table') | |||
tbl:addClass('infobox'):addClass('vcard'):addClass('soldier-infobox') | |||
tbl:tag('tr'):tag('th') | |||
:attr('colspan', '2') | |||
:addClass('infobox-above fn') | |||
:wikitext(name) | |||
local photo = resolvePhoto(frame) | |||
if photo ~= '' then | |||
local size = parentArg(frame, 'image_size') | |||
if size == '' then | |||
size = '220px' | |||
end | |||
local alt = parentArg(frame, 'alt') | |||
if alt == '' then | |||
alt = name | |||
end | |||
local td = tbl:tag('tr'):tag('td') | |||
:attr('colspan', '2') | |||
:addClass('infobox-image') | |||
td:wikitext(string.format('[[File:%s|%s|alt=%s]]', photo, size, alt)) | |||
local caption = parentArg(frame, 'caption') | |||
if caption ~= '' then | |||
td:tag('div'):addClass('infobox-caption'):wikitext(caption) | |||
end | end | ||
end | end | ||
local alias = prettyNamePart(parentArg(frame, 'alias')) | |||
local birthYear = yearOnly(parentArg(frame, 'birth_year')) | |||
local deathYear = yearOnly(parentArg(frame, 'death_year')) | |||
local birthDate = prettyProse(parentArg(frame, 'birth_date')) | |||
local deathDate = prettyProse(parentArg(frame, 'death_date')) | |||
local birthPlace = prettyProse(parentArg(frame, 'birth_place')) | |||
local deathPlace = prettyProse(parentArg(frame, 'death_place')) | |||
local nativity = expandNativity(parentArg(frame, 'nativity')) | |||
local religion = expandReligion(parentArg(frame, 'religion')) | |||
-- education / marital / occupation stay in Technical record (NARA snapshot); | |||
-- curated life facts belong in Biography / Timeline, not the infobox. | |||
local knownFor = prettyProse(parentArg(frame, 'known_for')) | |||
local awards = prettyProse(parentArg(frame, 'awards')) | |||
local ribbonsRaw = parentArg(frame, 'awards_ribbons') | |||
-- Full medal list belongs under the body ribbon rack; keep infobox short | |||
if ribbonsRaw ~= '' and mw.ustring.len(awards) > 90 then | |||
local first = mw.ustring.match(awards, '^([^;]+)') | |||
awards = trim(first or awards) | |||
end | end | ||
function | local function datePlaceCell(date, place, year) | ||
local bits = {} | |||
if date ~= '' then | |||
return '' | table.insert(bits, date) | ||
elseif year ~= '' then | |||
table.insert(bits, year) | |||
end | |||
if place ~= '' then | |||
table.insert(bits, place) | |||
end | |||
return table.concat(bits, '<br />') | |||
end | end | ||
local | |||
local birth = datePlaceCell(birthDate, birthPlace, birthYear) | |||
local death = datePlaceCell(deathDate, deathPlace, deathYear) | |||
if | |||
if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= '' | |||
or religion ~= '' or knownFor ~= '' or awards ~= '' then | |||
addHeader(tbl, 'Personal details') | |||
end | end | ||
addRow(tbl, 'Birth name', alias, 'nickname') | |||
addRow(tbl, 'Born', birth) | |||
addRow(tbl, 'Died', death) | |||
addRow(tbl, 'Nativity', nativity) | |||
addRow(tbl, 'Religion', religion) | |||
local | addRow(tbl, 'Known for', knownFor) | ||
addRow(tbl, 'Awards', awards) | |||
if | addHeader(tbl, 'Military service') | ||
addRow(tbl, 'Allegiance', '[[File:Flag of the United States.png|20px|link=|alt=]] United States') | |||
local serviceName, armName = splitBranchAndArm( | |||
parentArg(frame, 'branch'), | |||
parentArg(frame, 'class_branch'), | |||
parentArg(frame, 'arm_or_service') | |||
) | |||
if serviceName == '' then | |||
serviceName = 'United States Army' | |||
end | end | ||
addRow(tbl, 'Branch', branchWithEmblem(serviceName)) | |||
if | if armName ~= '' then | ||
-- Roster "branch" codes (FA, Signal Corps, Cavalry, …) are arm/service, not Army/Marines. | |||
addRow(tbl, 'Arm / service', prettyProse(armName)) | |||
end | end | ||
local items = {} | -- years_of_service / date_arrive / date_depart / certificate stay in Technical | ||
for line in mw.text.gsplit(section, '\n', true) do | -- record only (rarely curated; certificate is a cryptic chart number). | ||
line = trim(line) | addRow(tbl, 'ASN', parentArg(frame, 'asn'), 'serial') | ||
if mw.ustring.sub(line, 1, 2) == '* ' then | |||
local rankFinal = expandRank(parentArg(frame, 'rank_final')) | |||
local classRank = expandRank(parentArg(frame, 'class_rank')) | |||
if rankFinal ~= '' and classRank ~= '' and classRank ~= rankFinal then | |||
addRow(tbl, 'Rank', rankFinal) | |||
addRow(tbl, 'Rank at Ritchie', classRank) | |||
else | |||
local rank = rankFinal | |||
if rank == '' then | |||
rank = classRank | |||
end | |||
addRow(tbl, 'Rank', rank) | |||
end | |||
local classRaw = parentArg(frame, 'class_number') | |||
local classBits = classLinkList(classRaw, false) | |||
if #classBits > 0 then | |||
addRow(tbl, 'Camp Ritchie', 'Class ' .. table.concat(classBits, ', ')) | |||
end | |||
addRow(tbl, 'Section', mw.ustring.upper(parentArg(frame, 'class_section'))) | |||
addRow(tbl, 'Other class', expandOtherClass(parentArg(frame, 'other_class'))) | |||
local enlist = prettyPlace(parentArg(frame, 'enlistment_place')) | |||
if enlist ~= '' then | |||
addRow(tbl, 'Enlisted', enlist) | |||
end | |||
addRow(tbl, 'Specialty', prettyProse(parentArg(frame, 'specialty'))) | |||
addRow(tbl, 'Unit / division', prettyProse(parentArg(frame, 'division'))) | |||
addRow(tbl, 'Battles / wars', formatBattles(parentArg(frame, 'battles'))) | |||
addTeamsInfoboxRow(tbl, frame) | |||
-- Awards ribbons render in the article body (p.awardsSection), not this | |||
-- narrow infobox — matching Wikipedia's main-column ribbon racks. | |||
return tostring(tbl) | |||
end | |||
function p.awardsSection(frame) | |||
local gridRaw = parentArg(frame, 'awards_grid') | |||
local ribbons, labelsInRack = formatAwardsRibbons( | |||
parentArg(frame, 'awards_ribbons'), | |||
parentArg(frame, 'awards_ribbon_rows'), | |||
gridRaw | |||
) | |||
-- Skip the separate label table when captions already sit under ribbons | |||
local grid = '' | |||
if not labelsInRack then | |||
grid = formatAwardsGrid(gridRaw) | |||
end | |||
local extras = formatAwardsLegend(parentArg(frame, 'awards_extra')) | |||
-- Fallback: flat awards list when no grid was extracted | |||
if grid == '' and extras == '' and not labelsInRack then | |||
extras = formatAwardsLegend(parentArg(frame, 'awards')) | |||
end | |||
if ribbons == '' and grid == '' and extras == '' then | |||
return '' | |||
end | |||
-- Use a styled div heading (not == wiki heading) so MediaWiki does not place | |||
-- the Contents box inside this block — JS moves the block after Biography. | |||
local parts = { | |||
'<div class="soldier-awards-section">', | |||
'<div class="soldier-awards-heading">Awards and honors</div>', | |||
} | |||
if ribbons ~= '' then | |||
table.insert(parts, ribbons) | |||
end | |||
if grid ~= '' then | |||
table.insert(parts, grid) | |||
end | |||
if extras ~= '' then | |||
table.insert(parts, '<div class="soldier-awards-list">') | |||
table.insert(parts, extras) | |||
table.insert(parts, '</div>') | |||
end | |||
table.insert(parts, '</div>') | |||
return table.concat(parts, '\n') | |||
end | |||
function p.lead(frame) | |||
return buildLead(function(key) | |||
return parentArg(frame, key) | |||
end) | |||
end | |||
function p.leadFrom(frame) | |||
return buildLead(function(key) | |||
return trim(frame.args[key] or '') | |||
end) | |||
end | |||
local function researchNotesSection(content) | |||
if not content or content == '' then | |||
return '' | |||
end | |||
local startPos = mw.ustring.find(content, '== Research notes ==', 1, true) | |||
if not startPos then | |||
return '' | |||
end | |||
local after = mw.ustring.sub(content, startPos + 20) | |||
local nextHead = mw.ustring.find(after, '\n== ', 1, true) | |||
if nextHead then | |||
return mw.ustring.sub(after, 1, nextHead - 1) | |||
end | |||
return after | |||
end | |||
local function miiPreferredLetter(num) | |||
local ok, canon = pcall(require, 'Module:TeamCanon') | |||
if ok and type(canon) == 'table' and type(canon.letter) == 'table' then | |||
return canon.letter[tostring(num)] | |||
end | |||
return nil | |||
end | |||
local function noteTeamNames(text) | |||
text = mw.ustring.upper(tostring(text or '')) | |||
text = mw.ustring.gsub(text, mw.ustring.char(0x2013), '-') | |||
text = mw.ustring.gsub(text, mw.ustring.char(0x2014), '-') | |||
text = mw.ustring.gsub(text, mw.ustring.char(0x2212), '-') | |||
local seen = {} | |||
local names = {} | |||
local letteredNums = {} | |||
local function add(name) | |||
if name ~= '' and not seen[name] then | |||
seen[name] = true | |||
table.insert(names, name) | |||
end | |||
end | |||
local work = text | |||
for num, letter in string.gmatch(work, 'MII[%s%-]+(%d+)%s*%-%s*([A-Z])') do | |||
if letter == 'R' and (num == '540' or num == '545' or num == '546') then | |||
add('MII ' .. num) | |||
else | |||
add('MII ' .. num .. '-' .. letter) | |||
letteredNums[num] = true | |||
end | |||
end | |||
work = string.gsub(work, 'MII[%s%-]+%d+%s*%-%s*[A-Z]', ' ') | |||
for num, letter in string.gmatch(' ' .. text .. ' ', '[^%d]([45]%d%d)%s*%-%s*([A-Z])[^A-Z]') do | |||
if letter == 'R' and (num == '540' or num == '545' or num == '546') then | |||
add('MII ' .. num) | |||
else | |||
add('MII ' .. num .. '-' .. letter) | |||
letteredNums[num] = true | |||
end | |||
end | |||
for num in string.gmatch(work, 'MII[%s%-]+(%d+)') do | |||
if not letteredNums[num] then | |||
local preferred = miiPreferredLetter(num) | |||
if preferred then | |||
add('MII ' .. num .. '-' .. preferred) | |||
else | |||
add('MII ' .. num) | |||
end | |||
end | |||
end | |||
for num, letter in string.gmatch(text, 'IPW%s+(%d+)%s*%-%s*([A-Z])') do | |||
if letter == 'R' and (num == '540' or num == '545' or num == '546') then | |||
add('MII ' .. num) | |||
else | |||
add('MII ' .. num .. '-' .. letter) | |||
end | |||
end | |||
for num in string.gmatch(text, 'IPW%s+(%d+)[Tt]?%s*%??%f[^%a%d%-]') do | |||
add('IPW ' .. num) | |||
end | |||
return names | |||
end | |||
local function appendUniqueTeamName(names, seen, name) | |||
name = trim(tostring(name or '')) | |||
if name == '' or seen[name] then | |||
return | |||
end | |||
seen[name] = true | |||
table.insert(names, name) | |||
end | |||
local function namesFromIpwGerman(raw) | |||
local names = {} | |||
local seen = {} | |||
for part in mw.text.gsplit(raw or '', ';', true) do | |||
part = trim(part) | |||
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '') | |||
part = trim(part) | |||
if part ~= '' then | |||
local code, shown, mii = normalizeIpwTeamCode(part) | |||
if mii then | |||
appendUniqueTeamName(names, seen, 'MII ' .. mii) | |||
elseif code then | |||
appendUniqueTeamName(names, seen, 'IPW ' .. shown) | |||
end | |||
end | |||
end | |||
return names | |||
end | |||
local function namesFromIpwItalian(raw) | |||
local names = {} | |||
local seen = {} | |||
for part in mw.text.gsplit(raw or '', ';', true) do | |||
part = trim(part) | |||
part = mw.ustring.gsub(part, '[#<>%[%]|{}]', '') | |||
part = trim(part) | |||
if part ~= '' then | |||
local code = normalizeIpwItalianCode(part) | |||
if code then | |||
appendUniqueTeamName(names, seen, 'IPW Italian ' .. code) | |||
end | |||
end | |||
end | |||
return names | |||
end | |||
--- Explicit |teams= values: "MII 439-G; IPW 12" or shorthand "439-G". | |||
local function namesFromTeamsParam(raw) | |||
local names = {} | |||
local seen = {} | |||
for part in mw.text.gsplit(raw or '', ';', true) do | |||
part = trim(part) | |||
if part ~= '' then | |||
local fromScan = noteTeamNames(part) | |||
if #fromScan > 0 then | |||
for _, name in ipairs(fromScan) do | |||
appendUniqueTeamName(names, seen, name) | |||
end | |||
else | |||
local upper = mw.ustring.upper(part) | |||
local itRest = mw.ustring.match(upper, '^IPW%s+ITALIAN%s+(.+)$') | |||
if itRest then | |||
local code = normalizeIpwItalianCode(itRest) | |||
if code then | |||
appendUniqueTeamName(names, seen, 'IPW Italian ' .. code) | |||
end | |||
else | |||
local ipwRest = mw.ustring.match(upper, '^IPW%s+(.+)$') | |||
if ipwRest then | |||
local code, shown, mii = normalizeIpwTeamCode(ipwRest) | |||
if mii then | |||
appendUniqueTeamName(names, seen, 'MII ' .. mii) | |||
elseif code then | |||
appendUniqueTeamName(names, seen, 'IPW ' .. shown) | |||
end | |||
else | |||
local code, shown, mii = normalizeIpwTeamCode(part) | |||
if mii then | |||
appendUniqueTeamName(names, seen, 'MII ' .. mii) | |||
elseif code and mw.ustring.match(tostring(shown or code), '^%d') then | |||
appendUniqueTeamName(names, seen, 'IPW ' .. shown) | |||
end | |||
end | |||
end | |||
end | |||
end | |||
end | |||
return names | |||
end | |||
local function teamNameLink(name) | |||
return string.format('[[Team %s|%s]]', name, name) | |||
end | |||
local function pageResearchNotesText() | |||
local title = mw.title.getCurrentTitle() | |||
if not title or not title.getContent then | |||
return '' | |||
end | |||
local ok, content = pcall(function() | |||
return title:getContent() | |||
end) | |||
if not ok or not content then | |||
return '' | |||
end | |||
return researchNotesSection(content) | |||
end | |||
--- Roster IPW fields + optional |teams= + research-note team codes, one linked row. | |||
addTeamsInfoboxRow = function(tbl, frame) | |||
local names = {} | |||
local seen = {} | |||
local function addAll(list) | |||
for _, name in ipairs(list) do | |||
appendUniqueTeamName(names, seen, name) | |||
end | |||
end | |||
addAll(namesFromIpwGerman(parentArg(frame, 'ipw_ge'))) | |||
addAll(namesFromIpwItalian(parentArg(frame, 'ipw_it'))) | |||
addAll(namesFromTeamsParam(parentArg(frame, 'teams'))) | |||
addAll(noteTeamNames(pageResearchNotesText())) | |||
if #names == 0 then | |||
return | |||
end | |||
local links = {} | |||
for _, name in ipairs(names) do | |||
table.insert(links, teamNameLink(name)) | |||
end | |||
local label = 'Team' | |||
if #names > 1 then | |||
label = 'Teams' | |||
end | |||
addRow(tbl, label, table.concat(links, ', ')) | |||
end | |||
function p.noteTeamCategories(frame) | |||
local title = mw.title.getCurrentTitle() | |||
if not title or not title.getContent then | |||
return '' | |||
end | |||
local ok, content = pcall(function() | |||
return title:getContent() | |||
end) | |||
if not ok or not content then | |||
return '' | |||
end | |||
local seen = {} | |||
local bits = {} | |||
local function addCat(name) | |||
name = trim(tostring(name or '')) | |||
if name == '' or seen[name] then | |||
return | |||
end | |||
seen[name] = true | |||
table.insert(bits, '[[Category:Team ' .. name .. ']]') | |||
end | |||
for _, name in ipairs(noteTeamNames(researchNotesSection(content))) do | |||
addCat(name) | |||
end | |||
for _, name in ipairs(namesFromTeamsParam(parentArg(frame, 'teams'))) do | |||
addCat(name) | |||
end | |||
return table.concat(bits) | |||
end | |||
function p.researchNotes(frame) | |||
local title = mw.title.getCurrentTitle() | |||
if not title or not title.getContent then | |||
return '' | |||
end | |||
local ok, content = pcall(function() | |||
return title:getContent() | |||
end) | |||
if not ok or not content or content == '' then | |||
return '' | |||
end | |||
local section = researchNotesSection(content) | |||
if section == '' then | |||
return '' | |||
end | |||
local items = {} | |||
for line in mw.text.gsplit(section, '\n', true) do | |||
line = trim(line) | |||
if mw.ustring.sub(line, 1, 2) == '* ' then | |||
local note = sanitizeNote(mw.ustring.sub(line, 3)) | local note = sanitizeNote(mw.ustring.sub(line, 3)) | ||
if note ~= '' then | if note ~= '' then | ||
for clause in mw.text.gsplit(note, ';', true) do | for clause in mw.text.gsplit(note, ';', true) do | ||
clause = trim(clause) | clause = trim(clause) | ||
if clause ~= '' and not isNegativeClause(clause) then | if clause ~= '' and not isNegativeClause(clause) then | ||
table.insert(items, prettyProse(clause)) | table.insert(items, prettyProse(clause)) | ||
end | end | ||
end | end | ||
end | end | ||
end | |||
end | |||
items = joinDateFollowons(items) | |||
local unique = {} | |||
local seen = {} | |||
for _, clause in ipairs(items) do | |||
local key = normalizeNoteKey(clause) | |||
if key ~= '' and not seen[key] then | |||
seen[key] = true | |||
table.insert(unique, clause) | |||
end | |||
end | |||
items = unique | |||
if #items == 0 then | |||
return '' | |||
end | |||
local wrap = mw.html.create('div') | |||
wrap:addClass('soldier-research-notes') | |||
wrap:tag('div'):addClass('soldier-research-notes-heading'):wikitext('Research notes') | |||
local ul = wrap:tag('ul') | |||
for _, note in ipairs(items) do | |||
ul:tag('li'):wikitext(note) | |||
end | |||
return tostring(wrap) | |||
end | |||
local function cargoRows(tables, fields, where, limit, orderBy) | |||
if not mw.ext or not mw.ext.cargo or not mw.ext.cargo.query then | |||
return nil | |||
end | |||
local opts = { | |||
where = where, | |||
limit = limit or 8, | |||
} | |||
if orderBy and orderBy ~= '' then | |||
opts.orderBy = orderBy | |||
end | |||
local ok, result = pcall(mw.ext.cargo.query, tables, fields, opts) | |||
if not ok or type(result) ~= 'table' then | |||
return nil | |||
end | |||
return result | |||
end | |||
local function rowField(row, ...) | |||
for i = 1, select('#', ...) do | |||
local key = select(i, ...) | |||
local v = row[key] | |||
if v ~= nil and trim(tostring(v)) ~= '' then | |||
return trim(tostring(v)) | |||
end | |||
end | |||
return '' | |||
end | |||
local function titleToCaption(title) | |||
title = trim(title) | |||
local surname, given = mw.ustring.match(title, '^([^,]+),%s*(.-)%s*%(%d') | |||
if surname and given then | |||
return trim(given .. ' ' .. surname) | |||
end | |||
return title | |||
end | |||
local function displayFromCargoRow(row) | |||
local page = rowField(row, 'pageName', '_pageName') | |||
local surname = rowField(row, 'Surname') | |||
local first = rowField(row, 'First_name', 'First name') | |||
local middle = rowField(row, 'Middle_name', 'Middle name') | |||
local given = trim(first .. (middle ~= '' and (' ' .. middle) or '')) | |||
local caption | |||
if given ~= '' and surname ~= '' then | |||
caption = given .. ' ' .. surname | |||
elseif page ~= '' then | |||
caption = titleToCaption(page) | |||
else | |||
caption = surname | |||
end | |||
if page == '' and surname ~= '' then | |||
local asn = rowField(row, 'ASN') | |||
if asn ~= '' then | |||
page = surname .. ', ' .. (given ~= '' and given or first) .. ' (' .. asn .. ')' | |||
end | |||
end | |||
return page, caption | |||
end | |||
local function linkList(rows, skipAsn, skipPage, maxLinks) | |||
local links = {} | |||
local seen = {} | |||
for _, row in ipairs(rows or {}) do | |||
local asn = rowField(row, 'ASN') | |||
local page, caption = displayFromCargoRow(row) | |||
if caption == '' and page ~= '' then | |||
caption = titleToCaption(page) | |||
end | |||
if page ~= '' and page ~= skipPage and asn ~= skipAsn and not seen[page] then | |||
seen[page] = true | |||
table.insert(links, string.format('[[%s|%s]]', page, caption)) | |||
if #links >= maxLinks then | |||
break | |||
end | |||
end | |||
end | |||
return links | |||
end | |||
local function classFullWhere(classNum) | |||
-- Prefer Class_number__full: the Class_number HOLDS index is corrupted on some hosts. | |||
local n = tostring(classNum or '') | |||
n = mw.ustring.gsub(n, '[^0-9A-Za-z%-]', '') | |||
if n == '' then | |||
return nil | |||
end | |||
return string.format( | |||
'Class_number__full="%s" OR Class_number__full LIKE "%s;%%" OR Class_number__full LIKE "%%;%s" OR Class_number__full LIKE "%%;%s;%%"', | |||
n, | |||
n, | |||
n, | |||
n | |||
) | |||
end | |||
function p.storyCue(frame) | |||
local story = parentArg(frame, 'story') | |||
if story == '' then | |||
return '' | |||
end | |||
story = mw.ustring.gsub(story, '^%[%[', '') | |||
story = mw.ustring.gsub(story, '%]%]$', '') | |||
local storyTitle, storyLabel = mw.ustring.match(story, '^([^|]+)|(.+)$') | |||
if not storyTitle then | |||
storyTitle = story | |||
storyLabel = nil | |||
end | |||
storyTitle = trim(storyTitle) | |||
if storyLabel then | |||
storyLabel = trim(storyLabel) | |||
end | |||
if not storyLabel or storyLabel == '' then | |||
storyLabel = mw.ustring.match(storyTitle, '([^/]+)$') or storyTitle | |||
end | |||
local name = displayName(frame) | |||
return string.format( | |||
'<div class="soldier-story-cue">' | |||
.. '<span class="soldier-story-cue-label">Full story</span> ' | |||
.. 'Read more about %s in [[%s|%s]] on \'\'[[The Stories]]\'\'.' | |||
.. '</div>', | |||
name, | |||
storyTitle, | |||
storyLabel | |||
) | |||
end | |||
-- related-rail: live team rosters via category members (filled by Common.js) | |||
function p.relatedSoldiers(frame) | |||
local pageTitle = mw.title.getCurrentTitle().prefixedText | |||
-- Same sources as the infobox Teams row: roster IPW, |teams=, research notes. | |||
local teamNames = {} | |||
local seenTeams = {} | |||
local function addTeams(list) | |||
for _, name in ipairs(list) do | |||
appendUniqueTeamName(teamNames, seenTeams, name) | |||
end | |||
end | |||
addTeams(namesFromIpwGerman(parentArg(frame, 'ipw_ge'))) | |||
addTeams(namesFromIpwItalian(parentArg(frame, 'ipw_it'))) | |||
addTeams(namesFromTeamsParam(parentArg(frame, 'teams'))) | |||
addTeams(noteTeamNames(pageResearchNotesText())) | |||
if #teamNames == 0 then | |||
return '' | |||
end | |||
local wrap = mw.html.create('div') | |||
:addClass('soldier-related') | |||
:attr('data-current-title', pageTitle) | |||
wrap:tag('div'):addClass('soldier-related-heading'):wikitext('Teammates') | |||
for _, teamName in ipairs(teamNames) do | |||
local cat = 'Category:Team ' .. teamName | |||
local block = wrap:tag('div') | |||
:addClass('soldier-related-block') | |||
:attr('data-team-cat', cat) | |||
block:tag('div') | |||
:addClass('soldier-related-label') | |||
:wikitext('Team — ' .. teamNameLink(teamName)) | |||
-- Noscript / pre-JS fallback: category link. Common.js replaces with live names. | |||
block:tag('div') | |||
:addClass('soldier-related-links') | |||
:addClass('soldier-related-links-live') | |||
:wikitext('[[' .. cat .. '|Browse team roster]]') | |||
end | |||
wrap:tag('div') | |||
:addClass('soldier-related-note') | |||
:wikitext( | |||
'These are other soldiers listed on the same team roster. ' | |||
.. 'People moved between teams; overlapping service dates are not always documented.' | |||
) | |||
return tostring(wrap) | |||
end | |||
local function seqCount(t) | |||
-- mw.loadData tables do not always report # correctly. | |||
local n = 0 | |||
if type(t) ~= 'table' then | |||
return 0 | |||
end | |||
for _ in ipairs(t) do | |||
n = n + 1 | |||
end | |||
return n | |||
end | |||
function p.mediaCue(frame) | |||
local pageTitle = mw.title.getCurrentTitle().prefixedText | |||
local ok, data = pcall(mw.loadData, 'Module:Interviews/data') | |||
if not ok or type(data) ~= 'table' or type(data.by_title) ~= 'table' then | |||
return '' | |||
end | |||
local rec = data.by_title[pageTitle] | |||
if type(rec) ~= 'table' then | |||
return '' | |||
end | |||
-- Prefer interviews; accept legacy `items` key from hand-edited cues. | |||
local interviews = rec.interviews | |||
if type(interviews) ~= 'table' then | |||
interviews = rec.items | |||
end | |||
local films = rec.films | |||
local nInterviews = seqCount(interviews) | |||
local nFilms = seqCount(films) | |||
if nInterviews == 0 and nFilms == 0 then | |||
return '' | |||
end | |||
local name = displayName(frame) | |||
local blocks = {} | |||
if nInterviews > 0 then | |||
local links = {} | |||
local anyAudio = false | |||
for _, item in ipairs(interviews) do | |||
local heading = item.heading or 'Interview' | |||
local short = item.short or heading | |||
if item.kind == 'audio' then | |||
anyAudio = true | |||
end | |||
table.insert( | |||
links, | |||
string.format('[[Ritchie Boy Interviews#%s|%s]]', heading, short) | |||
) | |||
end | |||
local label = nInterviews == 1 and 'Interview' or 'Interviews' | |||
local verb = 'Watch' | |||
if nInterviews == 1 and interviews[1].kind == 'audio' then | |||
verb = 'Listen to' | |||
elseif anyAudio and nInterviews > 1 then | |||
verb = 'Hear' | |||
end | |||
local body | |||
if nInterviews == 1 then | |||
body = string.format( | |||
'%s %s on [[Ritchie Boy Interviews#%s|%s]].', | |||
verb, | |||
name, | |||
interviews[1].heading, | |||
interviews[1].short or interviews[1].heading | |||
) | |||
else | |||
body = string.format( | |||
'%s %s in %d recordings on [[Ritchie Boy Interviews]]: %s.', | |||
verb, | |||
name, | |||
nInterviews, | |||
table.concat(links, ' · ') | |||
) | |||
end | end | ||
table.insert( | |||
blocks, | |||
'<div class="soldier-interview-cue">' | |||
.. '<span class="soldier-interview-cue-label">' | |||
.. label | |||
.. '</span> ' | |||
.. body | |||
.. '</div>' | |||
) | |||
end | end | ||
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, | |||
table.insert( | string.format('[[Documentaries/Movies#%s|%s]]', heading, filmLabel) | ||
) | |||
end | end | ||
local body = string.format( | |||
'%s appears in %s.', | |||
name, | |||
table.concat(links, '; ') | |||
) | |||
table.insert( | |||
blocks, | |||
'<div class="soldier-film-cue">' | |||
.. '<span class="soldier-film-cue-label">On film</span> ' | |||
.. body | |||
.. '</div>' | |||
) | |||
end | end | ||
return '<div class="soldier-media-cue">' .. table.concat(blocks, '') .. '</div>' | |||
end | end | ||
return p | return p | ||