Module:Soldier: Difference between revisions
Link IPW T/? to numeric teams; Armd plain; 424-G as MII |
Infobox: drop certificate/arrive/depart/years; expand other_class codes |
||
| (49 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 410: | 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 546: | 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 555: | 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 563: | 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 624: | 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 | end | ||
| Line 655: | Line 996: | ||
local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$') | local miiNum, miiLetter = mw.ustring.match(part, '^(%d+)%s*[%-–—]%s*([A-Za-z])$') | ||
if miiNum then | if miiNum then | ||
local mii = miiNum .. '-' .. | 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 | return nil, mii, mii | ||
end | end | ||
| Line 663: | Line 1,008: | ||
end | end | ||
if mw.ustring.match(lower, '^%d+(st|nd|rd|th)$') then | if mw.ustring.match(lower, '^%d+(st|nd|rd|th)$') then | ||
return nil, original, nil | |||
end | |||
if isJunkIpwLabel(part) then | |||
return nil, original, nil | return nil, original, nil | ||
end | end | ||
| Line 676: | Line 1,024: | ||
part = trim(part) | part = trim(part) | ||
if part ~= '' then | if part ~= '' then | ||
if isIpw 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) | local code, shown, mii = normalizeIpwTeamCode(part) | ||
if mii then | if mii then | ||
| Line 691: | Line 1,046: | ||
end | end | ||
return table.concat(out, ', ') | return table.concat(out, ', ') | ||
end | end | ||
| Line 711: | 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 854: | 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 920: | 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 930: | 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 = | birth = trim(birth or '') | ||
death = | death = trim(death or '') | ||
if birth ~= '' and death ~= '' then | if birth ~= '' and death ~= '' then | ||
return ' (' .. birth .. '–' .. death .. ')' | return ' (' .. birth .. ' – ' .. death .. ')' | ||
end | end | ||
if birth ~= '' then | if birth ~= '' then | ||
| Line 950: | 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 962: | 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 967: | 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 978: | Line 1,923: | ||
:wikitext(name) | :wikitext(name) | ||
local photo = | local photo = resolvePhoto(frame) | ||
if photo ~= '' then | if photo ~= '' then | ||
local size = parentArg(frame, 'image_size') | local size = parentArg(frame, 'image_size') | ||
| Line 999: | Line 1,944: | ||
local alias = prettyNamePart(parentArg(frame, 'alias')) | local alias = prettyNamePart(parentArg(frame, 'alias')) | ||
local | local birthYear = yearOnly(parentArg(frame, 'birth_year')) | ||
local | local deathYear = yearOnly(parentArg(frame, 'death_year')) | ||
local | local birthDate = prettyProse(parentArg(frame, 'birth_date')) | ||
local | local deathDate = prettyProse(parentArg(frame, 'death_date')) | ||
local | local birthPlace = prettyProse(parentArg(frame, 'birth_place')) | ||
local | local deathPlace = prettyProse(parentArg(frame, 'death_place')) | ||
local occupation = prettyProse(parentArg(frame, ' | local nativity = expandNativity(parentArg(frame, 'nativity')) | ||
local | 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 | or religion ~= '' or knownFor ~= '' or awards ~= '' then | ||
addHeader(tbl, 'Personal details') | addHeader(tbl, 'Personal details') | ||
end | end | ||
addRow(tbl, ' | 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, 'Religion', religion) | addRow(tbl, 'Religion', religion) | ||
addRow(tbl, ' | addRow(tbl, 'Known for', knownFor) | ||
addRow(tbl, ' | addRow(tbl, 'Awards', awards) | ||
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 | local serviceName, armName = splitBranchAndArm( | ||
if | parentArg(frame, 'branch'), | ||
parentArg(frame, 'class_branch'), | |||
parentArg(frame, 'arm_or_service') | |||
) | |||
if serviceName == '' then | |||
serviceName = 'United States Army' | |||
end | |||
addRow(tbl, 'Branch', branchWithEmblem(serviceName)) | |||
if armName ~= '' then | |||
-- Roster "branch" codes (FA, Signal Corps, Cavalry, …) are arm/service, not Army/Marines. | |||
addRow(tbl, 'Arm / service', prettyProse(armName)) | |||
end | 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,053: | 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,060: | Line 2,036: | ||
addRow(tbl, 'Enlisted', enlist) | addRow(tbl, 'Enlisted', enlist) | ||
end | end | ||
addRow(tbl, ' | 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, ' | 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) | return tostring(tbl) | ||
end | end | ||
function p. | function p.awardsSection(frame) | ||
local | local gridRaw = parentArg(frame, 'awards_grid') | ||
local | local ribbons, labelsInRack = formatAwardsRibbons( | ||
local | parentArg(frame, 'awards_ribbons'), | ||
if | parentArg(frame, 'awards_ribbon_rows'), | ||
gridRaw | |||
) | |||
-- Skip the separate label table when captions already sit under ribbons | |||
local grid = '' | |||
if not labelsInRack then | |||
grid = formatAwardsGrid(gridRaw) | |||
end | |||
local extras = formatAwardsLegend(parentArg(frame, 'awards_extra')) | |||
-- Fallback: flat awards list when no grid was extracted | |||
if grid == '' and extras == '' and not labelsInRack then | |||
extras = formatAwardsLegend(parentArg(frame, 'awards')) | |||
end | end | ||
if ribbons == '' and grid == '' and extras == '' then | |||
return '' | |||
if | |||
end | end | ||
-- Use a styled div heading (not == wiki heading) so MediaWiki does not place | |||
local | -- 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) | |||
if | |||
end | end | ||
if | if grid ~= '' then | ||
table.insert(parts, grid) | |||
end | end | ||
if extras ~= '' then | |||
if | table.insert(parts, '<div class="soldier-awards-list">') | ||
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,155: | 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 | ||
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 | ||
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,174: | Line 2,167: | ||
end | end | ||
for num, letter in string.gmatch(text, 'IPW%s+(%d+)%s*%-%s*([A-Z])') do | for num, letter in string.gmatch(text, 'IPW%s+(%d+)%s*%-%s*([A-Z])') do | ||
add('MII ' .. num .. '-' .. letter) | if letter == 'R' and (num == '540' or num == '545' or num == '546') then | ||
add('MII ' .. num) | |||
else | |||
add('MII ' .. num .. '-' .. letter) | |||
end | |||
end | end | ||
for num in string.gmatch(text, 'IPW%s+(%d+)[Tt]?%s*%??%f[^%a%d%-]') do | for num in string.gmatch(text, 'IPW%s+(%d+)[Tt]?%s*%??%f[^%a%d%-]') do | ||
| Line 1,180: | Line 2,177: | ||
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,193: | 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 | ||
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,221: | 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, | 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,236: | 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 | ||