Module:Football event
Documentation for this module may be created at Module:Football event/doc
-- Module:Football event
-- Implements the association-football match-event icon templates and provides the
-- goal / penalty auto-formatting for [[Module:Football box]].
--
-- Template entry points:
-- {{#invoke:Football event|goal}} {{#invoke:Football event|goldengoal}}
-- {{#invoke:Football event|yellow}} / yel {{#invoke:Football event|sentoff}}
-- {{#invoke:Football event|subon}} {{#invoke:Football event|suboff}}
-- Module entry point (called from another module):
-- require('Module:Football event').formatBox{
-- goals1 = ..., goals2 = ..., penalties1 = ..., penalties2 = ... }
local p = {}
-- --------------------------------------------------------------------------
-- shared data
-- --------------------------------------------------------------------------
local OG_LINK = '([[Own goal#Association football|o.g.]])'
local PEN_LINK = '([[Penalty kick (association football)|pen.]])'
local YEL2_CAT = '[[Category:Yellow card template with second parameter]]'
local icon = {
goal = '[[File:Soccerball shade.svg|13px|Goal|link=|alt=]]',
og = '[[File:Soccerball-red with shade.svg|13px|Own goal|link=|alt=red-colored football]]',
goldgoal = '[[File:Soccerball shade gold.svg|13px|Golden goal|link=|alt=gold-colored football]]',
goldog = '[[File:Soccerball-red with gold shade.svg|13px|Golden own goal|link=|alt=gold and red-colored football]]',
pengoal = '[[File:Soccerball shad check.svg|13px|Penalty scored|link=|alt=football with check mark]]',
penmiss = '[[File:Soccerball shade cross.svg|13px|Penalty missed|link=|alt=football with red X]]',
}
-- own-goal / penalty shorthand -> canonical modifier (lookup key is lower-cased)
local modMap = {
['og'] = 'og', ['o.g.'] = 'og', ['o.g'] = 'og',
['pen'] = 'pen', ['pen.'] = 'pen', ['p'] = 'pen', ['p.'] = 'pen',
['pk'] = 'pen', ['p.k.'] = 'pen', ['p.k'] = 'pen',
}
-- penalty shoot-out keywords -> scored / missed (lookup key is lower-cased)
local penKW = {}
for _, w in ipairs({ 'goal', 'pengoal', 'score', 'scored' }) do penKW[w] = 'goal' end
for _, w in ipairs({ 'miss', 'penmiss', 'missed', 'saved' }) do penKW[w] = 'miss' end
-- stage abbreviations for the card templates: key -> {display, title}
local STAGES = {
PRE = { 'PRE', 'Pre-match' },
HT = { 'HT', 'Half-time' },
AWET = { 'AWET', 'Awaiting extra time' },
HTET = { 'HTET', 'Half-time in extra time' },
PSO = { 'PSO', 'Penalty shoot-out' },
PK = { 'PSO', 'Penalty shoot-out' },
FT = { 'FT', 'After full-time' },
}
-- every apostrophe / prime variant
local APOS = "[`'´ʹʼˈ‘’′]"
-- --------------------------------------------------------------------------
-- small helpers
-- --------------------------------------------------------------------------
local function notBlank(v)
return v ~= nil and mw.text.trim(v) ~= ''
end
local function inMain()
return mw.title.getCurrentTitle().namespace == 0
end
local function abbr(text, title)
return '<abbr title="' .. title .. '">' .. text .. '</abbr>'
end
local function normApo(s)
return (mw.ustring.gsub(s, APOS, "'"))
end
local function delink(s)
s = mw.ustring.gsub(s, '%[%[[^%[%]|]*|([^%[%]]*)%]%]', '%1')
s = mw.ustring.gsub(s, '%[%[([^%[%]]*)%]%]', '%1')
return s
end
-- Format a minute and append the minute mark. Stoppage time: an apostrophe
-- typed before a "+" is dropped and the mark moved to the very end, with any
-- spaces around the "+" tidied away ("90' + 7" -> "90+7'").
local function fmtMin(s)
s = normApo(mw.text.trim(s))
s = mw.ustring.gsub(s, "'%s*(%+)", '%1') -- apostrophe before "+"
s = mw.ustring.gsub(s, "[%s']*$", '') -- trailing spaces / apostrophes
s = mw.ustring.gsub(s, '%s*%+%s*', '+') -- tidy spaces around "+"
return s .. "'"
end
-- True when, after removing apostrophes / spaces, the value is only digits and
-- an optional "+" (i.e. a bare minute such as "45", "90+9", "90' + 7"). Used to
-- recover minutes that mis-keyed pipes pushed into an annotation slot.
local function isMinuteToken(v)
local t = mw.ustring.gsub(normApo(v), "[%s']", '')
return mw.ustring.match(t, '^%d+%+?%d*$') ~= nil
end
-- card time / stage label.
-- mode == 'plain' -> always a minute
-- mode == 'pso' -> only PSO/PK/FT recognised (post-reset periods)
-- default -> full stage list, otherwise a minute
local function stageLabel(v, mode)
if mode == 'plain' then return fmtMin(v) end
local up = mw.ustring.upper(mw.text.trim(v))
if mode == 'pso' then
if up == 'PSO' or up == 'PK' then return abbr('PSO', 'Penalty shoot-out') end
if up == 'FT' then return abbr('FT', 'After full-time') end
return ''
end
local s = STAGES[up]
if s then return abbr(s[1], s[2]) end
return fmtMin(v)
end
-- card icons (variable tooltip)
local function img(file, px, alt, tt)
return '[[File:' .. file .. '|' .. px .. 'px|alt=' .. alt .. '|' .. tt .. '|link=]]'
end
local function yc(tt) return img('Yellow card.svg', 10, 'Yellow card', tt) end
local function rc(tt) return img('Red card.svg', 10, 'Red card', tt) end
local function yrc(tt) return img('Yellow-red card.svg', 12, 'Yellow-red card', tt) end
local function timed(ic, v, mode)
if notBlank(v) then return ic .. ' ' .. stageLabel(v, mode) end
return ic
end
-- --------------------------------------------------------------------------
-- goal rendering (shared by {{goal}}, {{golden goal}} and the box feature)
-- --------------------------------------------------------------------------
local function goalIcon(golden, og)
if golden then return og and icon.goldog or icon.goldgoal end
return og and icon.og or icon.goal
end
-- "<span><minute>[ <annotation>]</span>"
local function goalSpan(minute, ann)
local s, hasMin = '<span>', notBlank(minute)
if hasMin then s = s .. fmtMin(minute) end
if ann then
if hasMin then s = s .. ' ' end
s = s .. ann
end
return s .. '</span>'
end
-- raw annotation -> og flag, annotation text
local function goalAnnot(a)
local m = modMap[mw.ustring.lower(mw.text.trim(delink(a)))]
if m == 'og' then return true, nil end
if m == 'pen' then return false, PEN_LINK end
return false, '(' .. a .. ')'
end
-- Try to read a single positional token as a goal entry: either a bare minute
-- ("33", "90+9") or a minute followed by a RECOGNISED modifier, with optional
-- parentheses ("33 pen", "90+9 o.g.", "45 (pen.)"). Returns nil for anything
-- else (bare modifiers like "pen", or custom text), so those stay annotations
-- rather than being mistaken for minutes.
local function asMinuteEntry(v)
if isMinuteToken(v) then
return { minute = v, golden = false, og = false, ann = nil }
end
local minPart, modPart =
mw.ustring.match(normApo(mw.text.trim(v)), '^([%d%+%s]-%d)%s*%(?%s*([%a%.]+)%s*%)?$')
if minPart and isMinuteToken(minPart) then
local m = modMap[mw.ustring.lower(modPart)]
if m then
return { minute = minPart, golden = false,
og = (m == 'og'), ann = (m == 'pen') and PEN_LINK or nil }
end
end
return nil
end
-- Render a player's goal cluster from a list of entries:
-- entry = { minute = raw, golden = bool, og = bool, ann = text|nil }
local function renderGoals(entries)
local hasOG = false
for _, e in ipairs(entries) do
if e.og then hasOG = true break end
end
if hasOG then
for _, e in ipairs(entries) do
if not e.golden then e.og, e.ann = true, nil end
end
end
local parts, lastG = {}, nil
for i, e in ipairs(entries) do
local ann = e.ann
if e.og then
local last = true
for j = i + 1, #entries do
if entries[j].golden == e.golden and entries[j].og then last = false break end
end
ann = last and OG_LINK or nil
end
local span = goalSpan(e.minute, ann)
if e.golden ~= lastG then
parts[#parts + 1] = goalIcon(e.golden, e.og) .. ' ' .. span
lastG = e.golden
else
parts[#parts + 1] = span
end
end
return table.concat(parts, ', ')
end
-- --------------------------------------------------------------------------
-- free-text parsing for the box feature
-- --------------------------------------------------------------------------
local function splitLines(s)
s = mw.ustring.gsub(s, '<[Bb][Rr]%s*/?%s*>', '\n')
local out = {}
for _, l in ipairs(mw.text.split(s, '\n')) do
l = mw.text.trim((mw.ustring.gsub(l, '^%s*%*%s*', '')))
if l ~= '' then out[#out + 1] = l end
end
return out
end
local goldenPrefix = { 'golden%s+', 'gold%s+', 'g%.g%.%s*', 'gg%s*' }
-- player name (linked or unlinked) at the start of a goal line
local function extractPlayer(line)
if mw.ustring.sub(line, 1, 2) == '[[' then
local e = mw.ustring.find(line, '%]%]', 3)
if e then
return mw.ustring.sub(line, 1, e + 1), mw.text.trim(mw.ustring.sub(line, e + 2))
end
end
-- unlinked: the name ends at the first digit or standalone golden keyword
local pos = mw.ustring.find(line, '%d')
local lower = mw.ustring.lower(line)
for _, pat in ipairs({ '%s(golden)%s', '%s(gold)%s', '%s(gg)%s', '%s(gg)%d', '%s(g%.g%.)' }) do
local s = mw.ustring.find(lower, pat)
if s and (not pos or s + 1 < pos) then pos = s + 1 break end
end
if pos and pos > 1 then
local name = mw.text.trim(mw.ustring.sub(line, 1, pos - 1))
if mw.ustring.find(name, "[%-‐‑‒–—―−']$") then return line, '' end
return name, mw.text.trim(mw.ustring.sub(line, pos))
end
return line, ''
end
-- one comma segment -> goal entry
local function parseSegment(seg)
seg = mw.text.trim(seg)
local golden, lower = false, mw.ustring.lower(seg)
for _, gp in ipairs(goldenPrefix) do
local s, e = mw.ustring.find(lower, '^' .. gp)
if s then golden = true; seg = mw.text.trim(mw.ustring.sub(seg, e + 1)) break end
end
local minute, rest = mw.ustring.match(seg, "^(%d[%d%s%+']*)(.*)$")
if not minute then return nil end -- no leading minute -> not a goal
local entry = { minute = mw.text.trim(minute), golden = golden, og = false, ann = nil }
local mod = mw.ustring.match(rest, '^%s*%(%s*(.-)%s*%)%s*$') or rest -- "(o.g.)" or "o.g."
mod = mw.text.trim(delink(mod)) -- a linked annotation reduces to its text
if mod ~= '' then
local m = modMap[mw.ustring.lower(mod)]
if m == 'og' then entry.og = true
elseif m == 'pen' then entry.ann = PEN_LINK
else entry.unresolved = true end -- an annotation we do not recognise
end
return entry
end
-- a comma-separated run of segments -> list of goal entries
local function segmentsToEntries(text)
local entries = {}
for _, seg in ipairs(mw.text.split(text, ',')) do
if mw.text.trim(seg) ~= '' then
local e = parseSegment(seg)
if not e then return nil end
entries[#entries + 1] = e
end
end
return entries
end
local function joinLines(out, isList)
if isList then
for i, r in ipairs(out) do out[i] = '*' .. r end
return table.concat(out, '\n')
end
return table.concat(out, '<br>')
end
-- returns: out (table of lines), isList, autoUsed
local function formatGoals(input)
input = normApo(input)
if mw.ustring.find(input, '[xX×]%d') then return nil end
local isList = mw.ustring.match(mw.text.trim(input), '^%*') ~= nil
local out = {}
for _, line in ipairs(splitLines(input)) do
local name, rest = extractPlayer(line)
if rest == '' then return nil end
local entries = segmentsToEntries(rest)
if not entries or #entries == 0 then return nil end
for _, e in ipairs(entries) do
if e.unresolved then return nil end
end
out[#out + 1] = name .. ' <span class="fb-goal">' .. renderGoals(entries) .. '</span>'
end
if #out == 0 then return nil end
return out, isList, true
end
-- returns: out (table of lines), isList, autoUsed
local function formatPenalties(input, isTeam2)
local isList = mw.ustring.match(mw.text.trim(input), '^%*') ~= nil
local out, auto = {}, false
for _, line in ipairs(splitLines(input)) do
local name, kw
local ls, le = mw.ustring.find(line, '%[%[.-%]%]')
if ls then
name = mw.ustring.sub(line, ls, le)
local before = mw.text.trim(mw.ustring.sub(line, 1, ls - 1))
local after = mw.text.trim(mw.ustring.sub(line, le + 1))
kw = penKW[mw.ustring.lower(after)] or penKW[mw.ustring.lower(before)]
else
local lower = mw.ustring.lower(line)
for word, res in pairs(penKW) do
if mw.ustring.find(lower, '^' .. word .. '%s+') then
kw, name = res, mw.text.trim(mw.ustring.sub(line, #word + 1)) break
end
local s = mw.ustring.find(lower, '%s+' .. word .. '$')
if s then kw, name = res, mw.text.trim(mw.ustring.sub(line, 1, s - 1)) break end
end
end
if name and kw then
local ic = kw == 'goal' and icon.pengoal or icon.penmiss
out[#out + 1] = isTeam2 and (ic .. ' ' .. name) or (name .. ' ' .. ic)
auto = true
else
out[#out + 1] = line
end
end
return out, isList, auto
end
-- --------------------------------------------------------------------------
-- argument helpers for the template entry points
-- --------------------------------------------------------------------------
local function getArgs(frame)
local args = {}
local parent = frame:getParent()
if parent then for k, v in pairs(parent.args) do args[k] = v end end
for k, v in pairs(frame.args) do args[k] = v end -- direct #invoke args win
return args
end
local function maxIndex(args)
local m = 0
for k in pairs(args) do
local n = tonumber(k)
if n and n == math.floor(n) and n > m then m = n end
end
return m
end
-- --------------------------------------------------------------------------
-- template entry points
-- --------------------------------------------------------------------------
-- {{Goal}}
function p.goal(frame)
local args = getArgs(frame)
local maxn = maxIndex(args)
-- gather the non-empty positional values in order
local toks, hasComma = {}, false
for i = 1, maxn do
local v = args[i]
if notBlank(v) then
toks[#toks + 1] = v
if mw.ustring.find(v, ',') then hasComma = true end
end
end
local entries
if hasComma then
-- free-text dialect (same parser as the box feature)
entries = segmentsToEntries(normApo(table.concat(toks, ', '))) or {}
else
-- pair dialect, tolerant of misplaced pipes
entries = {}
local current
for _, v in ipairs(toks) do
local e = asMinuteEntry(v)
if e then
current = e
entries[#entries + 1] = e
elseif current and current.ann == nil and not current.og then
current.og, current.ann = goalAnnot(v) -- annotate the open minute
elseif not current then
current = { minute = '', golden = false, og = false, ann = nil }
current.og, current.ann = goalAnnot(v)
entries[#entries + 1] = current
end
-- a stray extra annotation after an already-annotated minute is dropped
end
end
local inner = #entries > 0 and renderGoals(entries) or icon.goal
return '<span class="fb-goal">' .. inner .. '</span>'
end
-- {{Golden goal}}
function p.goldengoal(frame)
local args = getArgs(frame)
local m, a = args[1], args[2]
local og, ann = false, nil
if notBlank(a) then og, ann = goalAnnot(a) end
if notBlank(m) or notBlank(a) then
return renderGoals({ { minute = m, golden = true, og = og, ann = ann } })
end
return goalIcon(true, og)
end
-- {{Yel}} -- yellow card
function p.yellow(frame)
local args = getArgs(frame)
local t1, t2 = args[1], args[2]
local out = yc('Booked')
if notBlank(t1) then
out = out .. '<span style="vertical-align: middle;"> ' .. stageLabel(t1) .. '</span>'
end
local up = notBlank(t2) and mw.ustring.upper(mw.text.trim(t2)) or ''
if up == 'PSO' or up == 'PK' or up == 'FT' then
out = out .. ' ' .. yc('Booked immediately before/during/after penalty shoot-out (cards reset)')
.. '<span style="vertical-align: middle;"> ' .. stageLabel(t2, 'pso') .. '</span>'
end
if inMain() and (notBlank(t2) or notBlank(args[3]) or notBlank(args[4])) then
out = out .. YEL2_CAT
end
return out
end
-- {{Sent off}} -- param 1 is the number of bookings before dismissal
function p.sentoff(frame)
local args = getArgs(frame)
local mode = mw.text.trim(args[1] or '')
local a2, a3, a4 = args[2], args[3], args[4]
local body
if mode == '1' then
body = timed(yc('Booked'), a2) .. ' ' .. timed(rc('Sent off (straight red)'), a3)
elseif mode == '1+1' then
body = timed(yc('Booked'), a2)
.. ' ' .. timed(yc('Booked immediately before/during/after penalty shoot-out (cards reset)'), a3, 'pso')
.. ' ' .. timed(rc('Sent off (straight red) immediately before/during/after penalty shoot-out'), a4, 'pso')
elseif mode == '2' then
body = timed(yc('Booked'), a2) .. ' ' .. timed(yrc('Sent off (second booking)'), a3)
elseif mode == '1+2' then
body = timed(yc('Booked'), a2)
.. ' ' .. timed(yc('Booked immediately before/during/after penalty shoot-out (cards reset)'), a3, 'pso')
.. ' ' .. timed(yrc('Sent off (second booking) immediately before/during/after penalty shoot-out'), a4, 'pso')
elseif mode == '3' then
body = timed(yc('Booked'), a2, 'plain')
.. ' ' .. timed(yc('Booked again'), a3, 'plain')
.. ' ' .. timed(yrc('Sent off (third booking)'), a4, 'plain')
else -- 0 / default: straight red
body = timed(rc('Sent off (straight red)'), a2)
end
return '<span style="white-space:nowrap;">' .. body .. '</span>'
end
-- {{Subon}}
function p.subon(frame)
local m = getArgs(frame)[1]
local out = '[[File:Sub on.svg|10px|Substituted on|link=|alt=upward-facing green arrow]]'
if notBlank(m) then
out = out .. ' <span style="vertical-align: middle;">' .. fmtMin(m) .. '</span>'
end
return out
end
-- {{Suboff}}
function p.suboff(frame)
local args = getArgs(frame)
local m = args[1]
local out = '[[File:Sub off.svg|10px|Substituted off|link=|alt=downward-facing red arrow]]'
if notBlank(m) then
out = out .. ' <span style="vertical-align: middle;">' .. fmtMin(m) .. '</span>'
end
if notBlank(args.con) or notBlank(args.concussion) then
out = out .. ' (' .. abbr('con.', 'Substituted off due to concussion') .. ')'
end
return out
end
p.yel = p.yellow
-- --------------------------------------------------------------------------
-- module entry point for Module:Football box
-- --------------------------------------------------------------------------
-- fields: { goals1, goals2, penalties1, penalties2 }
-- Returns a table of formatted fields plus `auto` = true when any icon was generated.
function p.formatBox(fields)
local res, auto = {}, false
local function run(key, fn, ...)
local v = fields[key]
if v ~= nil and v ~= '' then
local out, isList, a = fn(v, ...)
if out ~= nil then
res[key] = joinLines(out, isList)
auto = auto or a
end
end
end
run('goals1', formatGoals)
run('goals2', formatGoals)
run('penalties1', formatPenalties, false)
run('penalties2', formatPenalties, true)
res.auto = auto
return res
end
return p