Module:Soldier: Difference between revisions
Bump related rail (teams only) to invalidate stale parser cache |
Infobox: drop certificate/arrive/depart/years; expand other_class codes |
||
| (13 intermediate revisions by the same user not shown) | |||
| Line 456: | 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', | ||
| Line 706: | Line 711: | ||
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 | |||
if u == 'SIG' or u == 'SIG-SIG' or mw.ustring.match(u, '^SIG[%s%-]') then | |||
return 'Special Intelligence Group–Signal' | |||
end | end | ||
if mw.ustring. | 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 | end | ||
return ord .. ' ' .. kind | |||
end | end | ||
return | |||
return prettyProse(tok) | |||
end | end | ||
local function normalizeNoteKey(s) | 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 | |||
local function prettyNamePart(s) | |||
return prettyNamePhrase(s, false) | |||
end | |||
local function isNegativeClause(s) | |||
local lower = mw.ustring.lower(s) | |||
if lower == 'failed' or mw.ustring.find(lower, '^failed%s') or mw.ustring.find(lower, '%sfailed%s') or mw.ustring.find(lower, '%sfailed$') then | |||
return true | |||
end | |||
-- 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 | |||
for _, phrase in ipairs(NEGATIVE_PHRASES) do | |||
if mw.ustring.find(lower, phrase, 1, true) then | |||
return true | |||
end | |||
end | |||
return false | |||
end | |||
local function normalizeNoteKey(s) | |||
s = mw.ustring.lower(trim(tidyDisplay(s))) | s = mw.ustring.lower(trim(tidyDisplay(s))) | ||
s = mw.ustring.gsub(s, '^also%s+', '') | s = mw.ustring.gsub(s, '^also%s+', '') | ||
| Line 819: | Line 919: | ||
if withPrefix then | if withPrefix then | ||
if dates ~= '' then | if dates ~= '' then | ||
table.insert(out, string.format('[[Class %s]] (%s)', part, dates)) | table.insert(out, string.format('[[:Category:Class %s|Class %s]] (%s)', part, part, dates)) | ||
else | else | ||
table.insert(out, string.format('[[Class %s]]', part)) | table.insert(out, string.format('[[:Category:Class %s|Class %s]]', part, part)) | ||
end | end | ||
else | else | ||
if dates ~= '' then | if dates ~= '' then | ||
table.insert(out, string.format('[[Class %s|%s]] (%s)', part, part, dates)) | table.insert(out, string.format('[[:Category:Class %s|%s]] (%s)', part, part, dates)) | ||
else | else | ||
table.insert(out, string.format('[[Class %s|%s]]', part, part)) | table.insert(out, string.format('[[:Category:Class %s|%s]]', part, part)) | ||
end | end | ||
end | end | ||
| Line 1,458: | Line 1,558: | ||
end | end | ||
local function | local function awardRibbonRows(raw, rowRaw) | ||
-- Prefer explicit Wikipedia row layout when present | -- Prefer explicit Wikipedia row layout when present | ||
local rows = splitAwardRows(rowRaw or '') | local rows = splitAwardRows(rowRaw or '') | ||
if #rows | if #rows > 0 then | ||
raw = trim(raw) | 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 | ||
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) | table.insert(rows, cur) | ||
cur = {} | |||
end | end | ||
end | end | ||
if #cur > 0 then | |||
table.insert(rows, cur) | |||
table.insert( | |||
end | end | ||
return table.concat(out, '\n') | 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 | end | ||
| Line 1,817: | Line 1,952: | ||
local nativity = expandNativity(parentArg(frame, 'nativity')) | local nativity = expandNativity(parentArg(frame, 'nativity')) | ||
local religion = expandReligion(parentArg(frame, 'religion')) | 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 knownFor = prettyProse(parentArg(frame, 'known_for')) | ||
local awards = prettyProse(parentArg(frame, 'awards')) | local awards = prettyProse(parentArg(frame, 'awards')) | ||
| Line 1,828: | Line 1,962: | ||
awards = trim(first or awards) | awards = trim(first or awards) | ||
end | end | ||
local function datePlaceCell(date, place, year) | local function datePlaceCell(date, place, year) | ||
| Line 1,847: | Line 1,980: | ||
if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= '' | if alias ~= '' or birth ~= '' or death ~= '' or nativity ~= '' | ||
or religion | or religion ~= '' or knownFor ~= '' or awards ~= '' then | ||
addHeader(tbl, 'Personal details') | addHeader(tbl, 'Personal details') | ||
end | end | ||
| Line 1,857: | Line 1,989: | ||
addRow(tbl, 'Nativity', nativity) | addRow(tbl, 'Nativity', nativity) | ||
addRow(tbl, 'Religion', religion) | addRow(tbl, 'Religion', religion) | ||
addRow(tbl, 'Known for', knownFor) | addRow(tbl, 'Known for', knownFor) | ||
addRow(tbl, 'Awards', awards) | addRow(tbl, 'Awards', awards) | ||
addHeader(tbl, 'Military service') | addHeader(tbl, 'Military service') | ||
| Line 1,881: | Line 2,007: | ||
addRow(tbl, 'Arm / service', prettyProse(armName)) | 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,902: | 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,913: | Line 2,040: | ||
addRow(tbl, 'Battles / wars', formatBattles(parentArg(frame, 'battles'))) | addRow(tbl, 'Battles / wars', formatBattles(parentArg(frame, 'battles'))) | ||
addTeamsInfoboxRow(tbl, frame) | addTeamsInfoboxRow(tbl, frame) | ||
-- Awards ribbons render in the article body (p.awardsSection), not this | -- Awards ribbons render in the article body (p.awardsSection), not this | ||
| Line 1,924: | Line 2,048: | ||
function p.awardsSection(frame) | function p.awardsSection(frame) | ||
local ribbons = formatAwardsRibbons( | local gridRaw = parentArg(frame, 'awards_grid') | ||
local ribbons, labelsInRack = formatAwardsRibbons( | |||
parentArg(frame, 'awards_ribbons'), | parentArg(frame, 'awards_ribbons'), | ||
parentArg(frame, 'awards_ribbon_rows') | parentArg(frame, 'awards_ribbon_rows'), | ||
gridRaw | |||
) | ) | ||
local grid = formatAwardsGrid( | -- 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')) | local extras = formatAwardsLegend(parentArg(frame, 'awards_extra')) | ||
-- Fallback: flat awards list when no grid was extracted | -- Fallback: flat awards list when no grid was extracted | ||
if grid == '' and extras == '' then | if grid == '' and extras == '' and not labelsInRack then | ||
extras = formatAwardsLegend(parentArg(frame, 'awards')) | extras = formatAwardsLegend(parentArg(frame, 'awards')) | ||
end | end | ||
| Line 2,395: | Line 2,525: | ||
end | end | ||
-- related-rail: | -- related-rail: live team rosters via category members (filled by Common.js) | ||
function p.relatedSoldiers(frame) | function p.relatedSoldiers(frame) | ||
local pageTitle = mw.title.getCurrentTitle().prefixedText | local pageTitle = mw.title.getCurrentTitle().prefixedText | ||
-- Same sources as the infobox Teams row: roster IPW, |teams=, research notes. | |||
local teamNames = {} | |||
-- Same sources as the infobox Teams row: roster IPW, |teams=, research notes. | |||
local teamNames = {} | |||
local seenTeams = {} | local seenTeams = {} | ||
local function addTeams(list) | local function addTeams(list) | ||
| Line 2,464: | Line 2,541: | ||
addTeams(namesFromTeamsParam(parentArg(frame, 'teams'))) | addTeams(namesFromTeamsParam(parentArg(frame, 'teams'))) | ||
addTeams(noteTeamNames(pageResearchNotesText())) | 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 | 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 | end | ||
return n | |||
end | |||
if | 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 '' | return '' | ||
end | end | ||
local name = displayName(frame) | |||
local blocks = {} | |||
.. table.concat( | if nInterviews > 0 then | ||
. | local links = {} | ||
local anyAudio = false | |||
.. '</div>' | 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 | ||