Story cue + same class/team rails
Infobox: drop certificate/arrive/depart/years; expand other_class codes
 
(18 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 prettyNamePart(s)
-- Roster "other_class" codes → readable course names (By The Numbers / MITC charts).
return prettyNamePhrase(s, false)
local function expandOtherClassToken(tok)
end
tok = tidyDisplay(tok)
if tok == '' then
return ''
end
local u = mw.ustring.upper(tok)
u = mw.ustring.gsub(u, '%s+', ' ')
u = trim(u)


local function isNegativeClause(s)
local n = mw.ustring.match(u, '^SC%s*B%s*(%d+)$')
local lower = mw.ustring.lower(s)
if n 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 'Special Course B ' .. n
return true
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
for _, phrase in ipairs(NEGATIVE_PHRASES) do
 
if mw.ustring.find(lower, phrase, 1, true) then
local unitNum, unitKind = mw.ustring.match(u, '^(%d+)(?:ST|ND|RD|TH)%s+(.+)$')
return true
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 false
 
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 formatAwardsRibbons(raw, rowRaw)
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 == 0 then
if #rows > 0 then
raw = trim(raw)
return rows
if raw == '' then
end
return ''
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
local files = {}
end
for part in mw.text.gsplit(raw, ';', true) do
if #files == 0 then
part = trim(part)
return {}
part = mw.ustring.gsub(part, '^[Ff]ile:', '')
end
if part ~= '' then
-- Fallback: pack ~4 across
table.insert(files, part)
local perRow = 4
end
rows = {}
end
local cur = {}
if #files == 0 then
for _, file in ipairs(files) do
return ''
table.insert(cur, file)
end
if #cur == perRow then
-- 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)
table.insert(rows, cur)
cur = {}
end
end
end
end
-- One table per row so short rows (e.g. 2 ribbons) center like full rows
if #cur > 0 then
local out = {}
table.insert(rows, cur)
for _, cells in ipairs(rows) do
table.insert(out, '{| class="soldier-ribbon-rack"')
table.insert(out, '|-')
for _, file in ipairs(cells) do
file = mw.ustring.gsub(file, '^[Ff]ile:', '')
table.insert(out, '| [[File:' .. file .. '|106px|link=]]')
end
table.insert(out, '|}')
end
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'))
local marital = prettyProse(parentArg(frame, 'marital'))
-- education / marital / occupation stay in Technical record (NARA snapshot);
local education = prettyProse(parentArg(frame, 'education'))
-- curated life facts belong in Biography / Timeline, not the infobox.
local occupation = prettyProse(parentArg(frame, 'occupation'))
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 state = prettyPlace(parentArg(frame, 'state'))


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 marital ~= '' or education ~= ''
or religion ~= '' or knownFor ~= '' or awards ~= '' then
or occupation ~= '' or knownFor ~= '' or awards ~= '' or state ~= '' 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, 'Spouse / marital', marital)
addRow(tbl, 'Education', education)
addRow(tbl, 'Occupation', occupation)
addRow(tbl, 'Known for', knownFor)
addRow(tbl, 'Known for', knownFor)
addRow(tbl, 'Awards', awards)
addRow(tbl, 'Awards', awards)
if state ~= '' then
addRow(tbl, 'State', state)
end


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
addRow(tbl, 'Years of service', prettyProse(parentArg(frame, 'years_of_service')))
-- 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)
addRow(tbl, 'Arrived', parentArg(frame, 'date_arrive'))
addRow(tbl, 'Departed', parentArg(frame, 'date_depart'))
addRow(tbl, 'Certificate', realCertificate(parentArg(frame, 'certificate')))


-- Awards ribbons render in the article body (p.awardsSection), not this
-- 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(parentArg(frame, 'awards_grid'))
-- 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: live team rosters via category members (filled by Common.js)
function p.relatedSoldiers(frame)
function p.relatedSoldiers(frame)
local asn = parentArg(frame, 'asn')
local pageTitle = mw.title.getCurrentTitle().prefixedText
local pageTitle = mw.title.getCurrentTitle().prefixedText
local classRaw = parentArg(frame, 'class_number')
local ipwGe = parentArg(frame, 'ipw_ge')
local ipwIt = parentArg(frame, 'ipw_it')


local sections = {}
-- 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()))


local function addTeam(label, teamPage, where)
if #teamNames == 0 then
local rows = cargoRows(
return ''
'Soldiers',
'ASN,Surname,First_name,Middle_name,_pageName=pageName',
where,
8,
'Surname'
)
local links = linkList(rows, asn, pageTitle, 8)
local head = string.format('[[%s|%s]]', teamPage, label)
local cat = 'Category:' .. teamPage
if #links > 0 then
table.insert(
sections,
'<div class="soldier-related-block"><div class="soldier-related-label">Team — '
.. head
.. '</div><div class="soldier-related-links">'
.. table.concat(links, ' · ')
.. ' · [['
.. cat
.. '|full roster]]</div></div>'
)
else
table.insert(
sections,
'<div class="soldier-related-block"><div class="soldier-related-label">Team</div>'
.. '<div class="soldier-related-links">Also assigned to '
.. head
.. ' · [['
.. cat
.. '|browse teammates]]</div></div>'
)
end
end
end


if ipwGe ~= '' then
local wrap = mw.html.create('div')
local num = mw.ustring.match(ipwGe, '(%d+)') or ipwGe
:addClass('soldier-related')
addTeam('IPW ' .. num, 'Team IPW ' .. num, string.format('IPW_German="%s"', num))
: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
end
if ipwIt ~= '' then
 
local num = mw.ustring.match(ipwIt, '(%d+)') or ipwIt
wrap:tag('div')
addTeam(
:addClass('soldier-related-note')
'IPW Italian ' .. num,
:wikitext(
'Team IPW Italian ' .. num,
'These are other soldiers listed on the same team roster. '
string.format('IPW_Italian="%s"', num)
.. '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
end
for _ in ipairs(t) do
n = n + 1
end
return n
end


local classes = {}
function p.mediaCue(frame)
if classRaw ~= '' then
local pageTitle = mw.title.getCurrentTitle().prefixedText
for part in mw.text.gsplit(classRaw, ';', true) do
local ok, data = pcall(mw.loadData, 'Module:Interviews/data')
part = trim(part)
if not ok or type(data) ~= 'table' or type(data.by_title) ~= 'table' then
if part ~= '' then
return ''
table.insert(classes, part)
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
end
table.insert(
links,
string.format('[[Ritchie Boy Interviews#%s|%s]]', heading, short)
)
end
end
end
local label = nInterviews == 1 and 'Interview' or 'Interviews'
for _, classNum in ipairs(classes) do
local verb = 'Watch'
local where = classFullWhere(classNum)
if nInterviews == 1 and interviews[1].kind == 'audio' then
local rows = where
verb = 'Listen to'
and cargoRows(
elseif anyAudio and nInterviews > 1 then
'Soldiers',
verb = 'Hear'
'ASN,Surname,First_name,Middle_name,_pageName=pageName',
end
where,
local body
8,
if nInterviews == 1 then
'Surname'
body = string.format(
)
'%s %s on [[Ritchie Boy Interviews#%s|%s]].',
or nil
verb,
local links = linkList(rows, asn, pageTitle, 8)
name,
local classPage = 'Class ' .. classNum
interviews[1].heading,
local cat = 'Category:Class ' .. classNum
interviews[1].short or interviews[1].heading
if #links > 0 then
table.insert(
sections,
'<div class="soldier-related-block"><div class="soldier-related-label">Class — [['
.. classPage
.. ']]</div><div class="soldier-related-links">'
.. table.concat(links, ' · ')
.. ' · [['
.. cat
.. '|full class]]</div></div>'
)
)
else
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(
table.insert(
sections,
links,
'<div class="soldier-related-block"><div class="soldier-related-label">Class</div>'
string.format('[[Documentaries/Movies#%s|%s]]', heading, filmLabel)
.. '<div class="soldier-related-links">Also trained in [['
.. classPage
.. ']] · [['
.. cat
.. '|browse classmates]]</div></div>'
)
)
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>'
if #sections == 0 then
return ''
end
return '<div class="soldier-related">'
.. '<div class="soldier-related-heading">Same class and team</div>'
.. table.concat(sections, '\n')
.. '</div>'
end
end


return p
return p