Module:Sports results/schedule
Appearance
-- Schedule-format results for non-round-robin competitions.
-- Called by Module:Sports results; each fixture appears from both teams' views.
require('strict')
local pp = {}
local yesno = require('Module:Yesno')
local MAX_ERRORS = 25
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
local function trim(s)
return mw.text.trim(tostring(s or ''))
end
local function add_error(errors, msg)
for _, e in ipairs(errors) do
if e == msg then return end
end
if #errors < MAX_ERRORS then
errors[#errors + 1] = msg
elseif #errors == MAX_ERRORS then
errors[#errors + 1] = 'further problems were found but are not listed'
end
end
local function split_list(s)
local out = {}
for _, v in ipairs(mw.text.split(trim(s), '%s*[;,]%s*')) do
if v ~= '' then out[#out + 1] = v end
end
return out
end
-- Build a match or result parameter prefix.
local function param_name(base, neutral, num)
return base .. (neutral and 'n' or '') .. ((num > 1) and tostring(num) or '') .. '_'
end
--------------------------------------------------------------------------------
-- Colours
--------------------------------------------------------------------------------
-- Existing cancelled colour; postponed avoids the neutral-venue orange.
local CANCELLED_HEX, POSTPONED_HEX = '#DDA0DD', '#B3B7EE'
local CANCELLED_NAME, POSTPONED_NAME = 'Plum', 'Periwinkle'
-- Standard display values for unplayed matches.
local STATES = {
canc = {text = '<small>Canc.</small>', hex = CANCELLED_HEX,
name = CANCELLED_NAME, meaning = 'cancelled'},
ppd = {text = '<small>Ppd.</small>', hex = POSTPONED_HEX,
name = POSTPONED_NAME, meaning = 'postponed'},
}
local function match_state(raw)
if raw == nil then return nil end
local s = trim(raw)
if s == '' then return nil end
-- Unwrap a simple small-text wrapper.
local inner = s:match('^<%s*[Ss][Mm][Aa][Ll][Ll]%s*>(.-)<%s*/%s*[Ss][Mm][Aa][Ll][Ll]%s*>$')
if not inner then
local attrs, text = s:match('^<%s*[Ss][Pp][Aa][Nn]%s([^<>]*)>(.-)<%s*/%s*[Ss][Pp][Aa][Nn]%s*>$')
if attrs and attrs:lower():find('font%-size', 1, true) then inner = text end
end
local word = trim(inner or s):lower()
word = (word:gsub('%.$', ''))
return STATES[word] and word or nil
end
--------------------------------------------------------------------------------
-- Reading |schedule_TTT=
--------------------------------------------------------------------------------
-- Parse OPP:h/a/n entries, numbered repeats, and null byes.
local function parse_entry(raw, team, errors, all_neutral)
local s = trim(raw)
if s == '' then return nil end
-- Parse byes, optional spans, and optional text.
local nullq = s:match('^[Nn][Uu][Ll][Ll]$') and '' or s:match('^[Nn][Uu][Ll][Ll]:(.*)$')
if nullq then
local span, text
local a, b = nullq:match('^(%d+):(.*)$')
if a then
span, text = tonumber(a), b
elseif nullq:match('^%d+$') then
span = tonumber(nullq)
elseif nullq ~= '' then
text = nullq
end
if span and span < 1 then
add_error(errors, '"' .. s .. '" in |schedule_' .. team
.. '= spans fewer than one column')
span = 1
end
return {null = true, span = span or 1, text = text}
end
local code, qual = s:match('^(.-):(.*)$')
local venue, num
if code and code ~= '' then
venue, num = qual:match('^([hanHAN])(%d*)$')
if not venue and all_neutral then
-- Treat OPP:2 as the second neutral meeting.
num = qual:match('^(%d*)$')
if num then venue = 'n' end
end
elseif all_neutral then
-- Bare codes are allowed for all-neutral schedules.
code, num = s:match('^(.-)(%d*)$')
if code and code ~= '' then venue = 'n' end
end
if not venue then
add_error(errors, 'could not read "' .. s .. '" in |schedule_' .. team
.. '=; entries look like OPP:h, OPP:a or OPP:n, numbered for a repeat meeting '
.. '(OPP:h2), or null for a bye'
.. (all_neutral and '. With |all_neutral= a bare OPP or OPP:2 also works' or ''))
return nil
end
return {opponent = code, venue = venue:lower(), num = tonumber(num), raw = s}
end
-- Read a team's schedule and assign missing meeting numbers.
local function read_schedule(Args, team, errors, all_neutral)
local raw = Args['schedule_' .. team]
if raw == nil then return {} end
local entries, used, seen = {}, {}, {}
local bad = false
for _, item in ipairs(split_list(raw)) do
local e = parse_entry(item, team, errors, all_neutral)
if e then
entries[#entries + 1] = e
else
bad = true
end
end
entries.incomplete = bad
-- Reserve explicit numbers first.
for _, e in ipairs(entries) do
if not e.null and e.num then
local key = e.opponent .. '|' .. e.venue .. '|' .. e.num
if used[key] then
add_error(errors, '|schedule_' .. team .. '= gives meeting ' .. e.num .. ' against '
.. e.opponent .. ' more than once')
end
used[key] = true
end
end
-- Unnumbered repeats must identify their meeting number.
for _, e in ipairs(entries) do
if not e.null and not e.num then
local base = e.opponent .. '|' .. e.venue
if seen[base] or used[base .. '|1'] then
add_error(errors, '|schedule_' .. team .. '= gives "' .. e.raw
.. '" more than once; number the repeat meeting, for example '
.. e.opponent .. ':' .. e.venue .. '2')
else
seen[base] = 1
used[base .. '|1'] = true
e.num = 1
end
end
end
-- Discard unresolved entries.
local out = {}
for _, e in ipairs(entries) do
if e.null or e.num then out[#out + 1] = e end
end
out.incomplete = entries.incomplete
return out
end
-- Return row width, including multi-column byes.
local function row_width(entries)
local w = 0
for _, e in ipairs(entries or {}) do
w = w + (e.span or 1)
end
return w
end
-- Split HOME_AWAY, preferring known codes with underscores.
local function split_pair(rest, codes)
local cands = {}
local i = rest:find('_', 1, true)
while i do
local h, a = rest:sub(1, i - 1), rest:sub(i + 1)
if h ~= '' and a ~= '' then cands[#cands + 1] = {h, a} end
i = rest:find('_', i + 1, true)
end
if #cands == 0 then return nil end
for _, c in ipairs(cands) do
if codes[c[1]] and codes[c[2]] then return c[1], c[2] end
end
for _, c in ipairs(cands) do
if codes[c[1]] then return c[1], c[2] end
end
for _, c in ipairs(cands) do
if codes[c[2]] then return c[1], c[2] end
end
return cands[#cands][1], cands[#cands][2]
end
--------------------------------------------------------------------------------
-- Reading |roundN_matches=
--------------------------------------------------------------------------------
-- Parse home-first round fixtures, optional numbers, and neutral venues.
local function parse_round_entry(raw, n, codes, errors, all_neutral)
local s = trim(raw)
if s == '' then return nil end
local pair, qual = s:match('^(.-):(.*)$')
if not pair then pair, qual = s, '' end
local neutral, num = qual:match('^([nN]?)(%d*)$')
if not neutral then
add_error(errors, 'could not read the qualifier in "' .. s .. '" in |round' .. n
.. '_matches=; it is a meeting number, n for a neutral venue, or both (n2)')
return nil
end
local h, a = split_pair(pair, codes)
if not h then
add_error(errors, 'could not read two team codes from "' .. s .. '" in |round' .. n
.. '_matches=; entries look like HOME_AWAY')
return nil
end
if h == a then
add_error(errors, '"' .. s .. '" in |round' .. n .. '_matches= is a team against itself')
return nil
end
return {home = h, away = a, neutral = all_neutral or (neutral ~= ''),
num = tonumber(num), raw = s}
end
-- Convert round lists to per-team schedules, adding byes as needed.
-- Outside opponents play fixtures but receive no row or bye.
local function read_rounds(Args, codes, named, errors, all_neutral, external, series_mode)
external = external or {}
local nums = {}
for k in pairs(Args) do
if type(k) == 'string' then
local n = k:match('^round(%d+)_matches$')
if n then nums[#nums + 1] = tonumber(n) end
end
end
if #nums == 0 then return nil end
table.sort(nums)
-- Missing rounds may be blank or mistyped.
for i = 1, nums[#nums] do
if nums[i] ~= i then
add_error(errors, 'the rounds run from 1 to ' .. nums[#nums]
.. ' but |round' .. i .. '_matches= is missing')
break
end
end
local schedules, seen, by_round = {}, {}, {}
for _, n in ipairs(nums) do
local this_round = {}
local fixtures = {}
for _, item in ipairs(split_list(Args['round' .. n .. '_matches'])) do
local e = parse_round_entry(item, n, codes, errors, all_neutral)
if e then
-- Without names, round lists define the team codes.
local ok = true
if named then
for _, c in ipairs({e.home, e.away}) do
if not codes[c] then
ok = false
add_error(errors, 'the team code "' .. c .. '" in |round' .. n
.. '_matches= is not one of the teams')
end
end
end
-- Outside opponents may appear in multiple fixtures per round.
local clash = (not external[e.home] and this_round[e.home] and e.home)
or (not external[e.away] and this_round[e.away] and e.away)
if ok and clash then
ok = false
add_error(errors, '|round' .. n .. '_matches= has '
.. clash .. ' playing more than once')
end
if ok and external[e.home] and external[e.away] and not series_mode then
-- Team layout cannot show a fixture between two outsiders.
add_error(errors, '"' .. e.raw .. '" in |round' .. n
.. '_matches= is between two teams in |external_teams=, so neither has a '
.. 'row to show it in; it appears nowhere in the schedule')
end
local id
if ok then
local x, y = e.home, e.away
if e.neutral and y < x then x, y = y, x end
id = (e.neutral and 'n' or '') .. tostring(e.num or 1) .. '|' .. x .. '|' .. y
if seen[id] then
ok = false
add_error(errors, '"' .. e.raw .. '" in |round' .. n
.. '_matches= repeats a fixture already given in |round' .. seen[id]
.. '_matches=; number the repeat meeting, for example ' .. e.home .. '_'
.. e.away .. ':' .. (e.neutral and 'n2' or '2'))
end
end
if ok then
seen[id] = n
this_round[e.home] = true
this_round[e.away] = true
fixtures[#fixtures + 1] = e
end
end
end
-- Give each listed team a fixture or bye.
for c in pairs(codes) do
if not external[c] then schedules[c] = schedules[c] or {} end
end
for _, e in ipairs(fixtures) do
if not external[e.home] then
schedules[e.home][n] = {opponent = e.away, num = e.num,
venue = e.neutral and 'n' or 'h'}
end
if not external[e.away] then
schedules[e.away][n] = {opponent = e.home, num = e.num,
venue = e.neutral and 'n' or 'a'}
end
end
for c in pairs(codes) do
if not external[c] then schedules[c][n] = schedules[c][n] or {null = true} end
end
by_round[n] = fixtures
end
-- Fill missing meeting numbers in round order.
local counts = {}
for _, n in ipairs(nums) do
for c in pairs(codes) do
local e = schedules[c] and schedules[c][n]
if e and not e.null and not e.num then
local key = c .. '|' .. e.opponent .. '|' .. e.venue
counts[key] = (counts[key] or 0) + 1
e.num = counts[key]
end
end
end
return schedules, nums[#nums], by_round
end
--------------------------------------------------------------------------------
-- Match lookup
--------------------------------------------------------------------------------
-- Look up matches; neutral fixtures accept either team order.
local function lookup(Args, base, team, opponent, venue, num, errors, all_neutral)
if venue == 'n' then
local k1 = param_name(base, true, num) .. team .. '_' .. opponent
local k2 = param_name(base, true, num) .. opponent .. '_' .. team
local alt1 = (num == 1) and (base .. 'n1_' .. team .. '_' .. opponent) or nil
local alt2 = (num == 1) and (base .. 'n1_' .. opponent .. '_' .. team) or nil
-- Plain keys also apply when every match is neutral.
local p1, p2
if all_neutral then
p1 = param_name(base, false, num) .. team .. '_' .. opponent
p2 = param_name(base, false, num) .. opponent .. '_' .. team
if num == 1 then
alt1 = alt1 or (base .. '1_' .. team .. '_' .. opponent)
alt2 = alt2 or (base .. '1_' .. opponent .. '_' .. team)
end
end
local v1 = Args[k1] or (alt1 and Args[alt1]) or (p1 and Args[p1])
local v2 = Args[k2] or (alt2 and Args[alt2]) or (p2 and Args[p2])
if all_neutral and errors then
local found = {}
for _, kk in ipairs({k1, k2, p1, p2, alt1, alt2}) do
if kk and Args[kk] ~= nil then found[#found + 1] = kk end
end
if #found > 1 then
table.sort(found)
local x, y = team, opponent
if y < x then x, y = y, x end
add_error(errors, 'the match between ' .. x .. ' and ' .. y
.. ' is given more than once: |' .. table.concat(found, '=, |')
.. '=; with every match at a neutral venue these are all the same match')
end
end
if v1 ~= nil and v2 ~= nil and errors then
-- Report each key clash once.
local a, b = k1, k2
if b < a then a, b = b, a end
add_error(errors, 'both |' .. a .. '= and |' .. b
.. '= are given; a neutral match is the same match either way round, so only one may be used')
end
if Args[k1] ~= nil then return Args[k1], false, k1 end
if alt1 and Args[alt1] ~= nil then return Args[alt1], false, alt1 end
if p1 and Args[p1] ~= nil then return Args[p1], false, p1 end
if Args[k2] ~= nil then return Args[k2], true, k2 end
if alt2 and Args[alt2] ~= nil then return Args[alt2], true, alt2 end
if p2 and Args[p2] ~= nil then return Args[p2], true, p2 end
return nil, false, k1
end
local home = (venue == 'h') and team or opponent
local away = (venue == 'h') and opponent or team
local key = param_name(base, false, num) .. home .. '_' .. away
local val = Args[key]
if num == 1 then
local alt = base .. '1_' .. home .. '_' .. away
if val ~= nil and Args[alt] ~= nil and errors then
add_error(errors, 'both |' .. key .. '= and |' .. alt
.. '= are given; for the schedule format they are the same meeting')
end
if val == nil then val = Args[alt] end
end
-- reversed means the score is opponent-first.
return val, (venue == 'a'), key
end
--------------------------------------------------------------------------------
-- Score handling
--------------------------------------------------------------------------------
-- Reverse every score pair for the opposite team, preserving markup targets,
-- URLs, and strip markers.
-- Parser markers are delimited by DEL (\127).
local MARKER = '\127'
-- Apply f only to safely rewritable text.
local function map_score_text(s, f)
local out, i, n = {}, 1, #s
while i <= n do
-- Find the next protected construct.
local nxt, kind = nil, nil
local p = s:find(MARKER, i, true)
if p then nxt, kind = p, 'marker' end
p = s:find('[[', i, true)
if p and (not nxt or p < nxt) then nxt, kind = p, 'link' end
p = s:find('%[%a[%w%+%.%-]*://', i)
if p and (not nxt or p < nxt) then nxt, kind = p, 'url' end
if not nxt then
out[#out + 1] = f(s:sub(i))
break
end
if nxt > i then out[#out + 1] = f(s:sub(i, nxt - 1)) end
if kind == 'marker' then
-- Markers begin and end with DEL.
local e = s:find(MARKER, nxt + 1, true)
if not e then
out[#out + 1] = s:sub(nxt)
break
end
out[#out + 1] = s:sub(nxt, e)
i = e + 1
elseif kind == 'link' then
-- Find the matching brackets, allowing nesting.
local depth, j, close = 0, nxt, nil
while j <= n and not close do
if s:sub(j, j + 1) == '[[' then
depth = depth + 1
j = j + 2
elseif s:sub(j, j + 1) == ']]' then
depth = depth - 1
j = j + 2
if depth == 0 then close = j - 1 end
else
j = j + 1
end
end
if not close then
out[#out + 1] = s:sub(nxt)
break
end
local body = s:sub(nxt + 2, close - 2)
local pipe = body:find('|', 1, true)
if pipe then
-- Rewrite only the displayed link text.
out[#out + 1] = '[[' .. body:sub(1, pipe)
.. map_score_text(body:sub(pipe + 1), f) .. ']]'
else
out[#out + 1] = s:sub(nxt, close)
end
i = close + 1
else
-- Preserve the URL; rewrite its label.
local close = s:find(']', nxt, true)
if not close then
out[#out + 1] = s:sub(nxt)
break
end
local body = s:sub(nxt + 1, close - 1)
local sp = body:find('%s')
if sp then
out[#out + 1] = '[' .. body:sub(1, sp)
.. map_score_text(body:sub(sp + 1), f) .. ']'
else
out[#out + 1] = s:sub(nxt, close)
end
i = close + 1
end
end
return table.concat(out)
end
-- Normalize score dashes to en dashes.
local function normalise_dashes(t)
t = mw.ustring.gsub(t, '(%d)%s*&[Nn][Dd][Aa][Ss][Hh];%s*(%d)', '%1–%2')
t = mw.ustring.gsub(t, '(%d)%s*&[Mm][Dd][Aa][Ss][Hh];%s*(%d)', '%1–%2')
t = mw.ustring.gsub(t, '(%d)%s*̵[12];%s*(%d)', '%1–%2')
t = mw.ustring.gsub(t, '(%d)%s*[%-–—−]%s*(%d)', '%1–%2')
return t
end
local function swap_dashes(t)
return (mw.ustring.gsub(t, '(%d[%d%.]*)–(%d[%d%.]*)', '%2–%1'))
end
-- Tags make a score unsafe to reorder; refs are already strip markers.
local function has_markup(s)
return s:find('<%s*/?%s*%a') ~= nil
end
-- Return normal and reversed scores, plus whether markup blocked reversal.
local function score_forms(raw, util)
local s = util.format_score(raw)
if has_markup(s) then
local flippable = mw.ustring.find(s, '%d%s*[%-–—−]%s*%d') ~= nil
return s, s, flippable
end
local plain = map_score_text(s, normalise_dashes)
local turned = map_score_text(plain, swap_dashes)
return plain, turned, false
end
-- Invert team-specific result modifiers for the opposite team.
local INVERT = {W = 'L', L = 'W', OTW = 'OTL', OTL = 'OTW', PKW = 'PKL', PKL = 'PKW'}
local function invert_result(r)
return INVERT[r] or r
end
--------------------------------------------------------------------------------
-- Round headings
--------------------------------------------------------------------------------
-- $1 inserts the round number; otherwise the value is a prefix.
local function round_text(pattern, n)
if not pattern or pattern == '' then return nil end
if pattern:find('$1', 1, true) then
return (pattern:gsub('%$1', tostring(n)))
end
return pattern .. tostring(n)
end
-- Common round-heading presets; explicit values override them.
local ROUND_TYPES = {
matchday = {'MD$1', 'Matchday $1'},
matchweek = {'MW$1', 'Matchweek $1'},
gameweek = {'GW$1', 'Game week $1'},
round = {'R$1', 'Round $1'},
week = {'W$1', 'Week $1'},
game = {'G$1', 'Game $1'},
}
-- Fallback for round labels and a bare |round_span_header=yes.
local DEFAULT_ROUND_NAME = 'Match $1'
-- Return a round pattern's singular form, e.g. 'Matchday $1' -> 'Matchday'.
local function singular_round_name(pattern)
return (pattern:gsub('%s*%$1%s*$', ''))
end
-- Whether |round_span_header= is enable vs. being unset, a negative value, or custom header text.
local function round_span_is_default(Args)
local raw = Args['round_span_header']
if raw == nil or trim(raw) == '' then return false end
return yesno(raw) == true
end
-- Build a round heading, defaulting to its number.
local function round_header(frame, Args, n)
local override = Args['round' .. n .. '_header']
if override then return override end
local preset = ROUND_TYPES[string.lower(trim(Args['round_type']))]
if preset and round_span_is_default(Args) then
return tostring(n)
end
local abbr = round_text(Args['round_abbreviation'] or (preset and preset[1]), n)
local name = round_text(Args['round_name'] or (preset and preset[2]), n)
if abbr and name then
return frame:expandTemplate{title = 'abbr', args = {abbr, name}}
end
return abbr or name or tostring(n)
end
-- Return the |round_span_header= text above the round headings.
-- A positive value uses the singular |round_type= name (or "Match"); custom
-- text is used as given, while negative or unset values omit the row.
local function round_span_header(Args)
local raw = Args['round_span_header']
if raw == nil or trim(raw) == '' then return nil end
if round_span_is_default(Args) then
local preset = ROUND_TYPES[string.lower(trim(Args['round_type']))]
return singular_round_name((preset and preset[2]) or DEFAULT_ROUND_NAME)
end
if yesno(raw) == false then return nil end
-- Not recognised as a yes/no value, so it is custom header text.
return raw
end
-- Check whether a second row of round dates is needed.
local function any_round_dates(Args)
for k in pairs(Args) do
if type(k) == 'string' and k:match('^round%d+_date$') and trim(Args[k]) ~= '' then
return true
end
end
return false
end
--------------------------------------------------------------------------------
-- Team list and blocks
--------------------------------------------------------------------------------
-- Sort team codes by display name, then code.
local function name_sorter(Args, util)
local key = {}
local function keyof(c)
if key[c] == nil then
local n = Args['name_' .. c]
key[c] = string.lower(n and util.clean_team_name(n, c) or c)
end
return key[c]
end
return function(x, y)
local kx, ky = keyof(x), keyof(y)
if kx == ky then return x < y end
return kx < ky
end
end
local function team_list(Args, util, top_pos, bottom_pos, extra, external)
local roster, all = {}, {}
external = external or {}
for k in pairs(Args) do
if type(k) == 'string' then
local c = k:match('^schedule_(.+)$') or k:match('^name_(.+)$')
-- Outside opponents have no schedule row.
if c and c ~= '' and not roster[c] and not external[c] then
roster[c] = true
all[#all + 1] = c
end
end
end
for _, c in ipairs(extra or {}) do
if not roster[c] and not external[c] then
roster[c] = true
all[#all + 1] = c
end
end
table.sort(all, name_sorter(Args, util))
local ordered, seen = {}, {}
local function push(c)
if c and roster[c] and not seen[c] then
seen[c] = true
ordered[#ordered + 1] = c
end
end
if trim(Args['default_order']) ~= '' then
for _, c in ipairs(split_list(Args['default_order'])) do push(c) end
elseif trim(Args['team_order']) ~= '' then
for _, c in ipairs(split_list(Args['team_order'])) do push(c) end
else
local i = top_pos
while Args['team' .. i] ~= nil and (bottom_pos < top_pos or (i - top_pos + 1) <= (bottom_pos - top_pos + 1)) do
push(trim(Args['team' .. i]))
i = i + 1
end
end
for _, c in ipairs(all) do push(c) end
return ordered, roster
end
-- Unassigned teams form a final untitled block.
local function using_blocks_early(Args)
for k in pairs(Args) do
if type(k) == 'string' and k:match('^block%d+$') then return true end
end
return false
end
local function build_blocks(Args, teams, errors, external)
external = external or {}
local nums = {}
for k in pairs(Args) do
if type(k) == 'string' then
local n = k:match('^block(%d+)$')
if n then nums[#nums + 1] = tonumber(n) end
end
end
if #nums == 0 then
return {{title = Args['title'], teams = teams}}, false
end
table.sort(nums)
local known = {}
for _, c in ipairs(teams) do known[c] = true end
local blocks, placed = {}, {}
for _, n in ipairs(nums) do
local members = {}
for _, c in ipairs(split_list(Args['block' .. n])) do
if external[c] then
add_error(errors, 'the team "' .. c .. '" in |block' .. n
.. '= is in |external_teams=, so it has no row to put in a block')
elseif not known[c] then
add_error(errors, 'the team "' .. c .. '" in |block' .. n .. '= has no |schedule_' .. c .. '=')
elseif placed[c] then
add_error(errors, 'the team "' .. c .. '" is listed in more than one block')
else
placed[c] = true
members[#members + 1] = c
end
end
blocks[#blocks + 1] = {title = Args['block' .. n .. '_title'], teams = members}
end
local leftover = {}
for _, c in ipairs(teams) do
if not placed[c] then leftover[#leftover + 1] = c end
end
if #leftover > 0 then
add_error(errors, 'no block was given for ' .. table.concat(leftover, ', ')
.. '; they have been put in a final untitled block')
blocks[#blocks + 1] = {title = nil, teams = leftover}
end
if Args['title'] then
add_error(errors, '|title= is ignored when blocks are used; give each block its own |blockN_title=')
end
return blocks, true
end
--------------------------------------------------------------------------------
-- Schedule consistency
--------------------------------------------------------------------------------
-- Warn about empty schedules only when another team has fixtures.
local function check_idle(teams, schedules, errors)
local any, idle = false, {}
for _, team in ipairs(teams) do
if #(schedules[team] or {}) > 0 then any = true else idle[#idle + 1] = team end
end
if any and #idle > 0 then
table.sort(idle)
add_error(errors, 'no fixtures were given for ' .. table.concat(idle, ', ')
.. '; they appear as empty rows')
end
end
local function check_pairs(schedules, roster, errors, external)
external = external or {}
local names = {}
for team in pairs(schedules) do names[#names + 1] = team end
table.sort(names)
local count = {}
for _, team in ipairs(names) do
local entries = schedules[team]
for _, e in ipairs(entries) do
if not e.null then
local key = team .. '|' .. e.opponent .. '|' .. e.venue
count[key] = (count[key] or 0) + 1
end
end
end
local reported = {}
for _, team in ipairs(names) do
local entries = schedules[team]
for _, e in ipairs(entries) do
-- Skip incomplete lists to avoid false mismatches.
if entries.incomplete or (schedules[e.opponent] or {}).incomplete then
-- Skip.
elseif external[e.opponent] then
-- Outside opponents have no list to compare.
elseif not e.null and roster[e.opponent] then
local mirror_venue = (e.venue == 'h' and 'a') or (e.venue == 'a' and 'h') or 'n'
local a = team .. '|' .. e.opponent .. '|' .. e.venue
local b = e.opponent .. '|' .. team .. '|' .. mirror_venue
local pair = (a < b) and (a .. '#' .. b) or (b .. '#' .. a)
if not reported[pair] and (count[a] or 0) ~= (count[b] or 0) then
reported[pair] = true
add_error(errors, 'the fixture between ' .. team .. ' and ' .. e.opponent
.. ' appears ' .. (count[a] or 0) .. ' time(s) in |schedule_' .. team .. '= but '
.. (count[b] or 0) .. ' time(s) in |schedule_' .. e.opponent .. '=')
end
elseif not e.null then
add_error(errors, '|schedule_' .. team .. '= names ' .. e.opponent
.. ', which has no |schedule_' .. e.opponent
.. '=; if it is an opponent from outside this table, add it to |external_teams=')
end
end
end
end
--------------------------------------------------------------------------------
-- Building a block table
--------------------------------------------------------------------------------
local function build_table(frame, Args, util, opts, block, schedules, notes, errors)
local root = mw.html.create('table')
:addClass('wikitable sports-res plainrowheaders')
:css('text-align', 'center')
if opts.font_size then root:css('font-size', opts.font_size) end
if opts.center then root:addClass('center-table') end
-- Keep rows unwrapped when requested.
if opts.table_nowrap then root:css('white-space', 'nowrap') end
if block.title then root:tag('caption'):wikitext(block.title) end
-- Optional legend row above the headers.
if opts.header_legend and opts.legend then
local lr = root:tag('tr')
lr:tag('th'):attr('colspan', tostring(opts.max_rounds + 1)):wikitext(opts.legend)
end
-- Header.
local h1 = root:tag('tr')
local team_cell = h1:tag('th'):attr('scope', 'col'):wikitext(opts.team_header)
-- Explicit width aligns team columns across blocks.
if opts.team_width then team_cell:css('width', opts.team_width) end
-- Span all active header rows.
local head_rows = 1 + (opts.span_header and 1 or 0) + (opts.round_dates and 1 or 0)
if head_rows > 1 then team_cell:attr('rowspan', tostring(head_rows)) end
local label_row = h1
if opts.span_header then
h1:tag('th'):attr('colspan', tostring(opts.max_rounds)):wikitext(opts.span_header)
label_row = root:tag('tr')
end
for n = 1, opts.max_rounds do
local c = label_row:tag('th'):attr('scope', 'col')
:wikitext(round_header(frame, Args, n))
if opts.col_width then c:css('width', opts.col_width) end
end
if opts.round_dates then
local h3 = root:tag('tr')
for n = 1, opts.max_rounds do
h3:tag('th'):attr('scope', 'col')
:wikitext(Args['round' .. n .. '_date'] or '')
end
end
for _, team in ipairs(block.teams) do
local bold = (opts.showteam == team)
local name = Args['name_' .. team] or team
local mode = opts.codes
if mode == 'inline' or mode == 'inline_bold' or mode == 'break' or mode == 'break_bold' then
local code = Args['short_' .. team] or team
-- Bold only the code, not its brackets.
if (mode == 'inline_bold' or mode == 'break_bold') and not bold then
code = "'''" .. code .. "'''"
end
local sep = (mode == 'break' or mode == 'break_bold') and '<br>' or ' '
name = name .. sep .. '<small>(' .. code .. ')</small>'
end
if notes.team[team] then name = name .. notes.team[team] end
if bold then name = "'''" .. name .. "'''" end
-- Skip the empty score row for byes-only schedules.
local entries_pre = schedules[team] or {}
local has_scores = false
for _, e in ipairs(entries_pre) do
if not e.null then has_scores = true break end
end
local span2 = has_scores and '2' or '1'
local opp_row = root:tag('tr')
local th = opp_row:tag('th'):attr('scope', 'row'):attr('rowspan', span2):wikitext(name)
if opts.team_align then th:css('text-align', opts.team_align) end
if opts.team_nowrap then th:css('white-space', 'nowrap') end
-- Apply row dividers to cells for browser compatibility.
if opts.row_divider then th:cssText(opts.row_divider) end
local score_row = has_scores and root:tag('tr') or nil
local entries = schedules[team] or {}
for _, e in ipairs(entries) do
if e.null then
local cell = opp_row:tag('td'):attr('rowspan', span2)
if (e.span or 1) > 1 then cell:attr('colspan', tostring(e.span)) end
if opts.row_divider then cell:cssText(opts.row_divider) end
-- Bye text may override |null_text=.
local txt = e.text or opts.null_text
if opts.solid_grey then
cell:css('background', '#BBB')
if e.text or opts.null_text_set then cell:wikitext(txt) end
else
cell:wikitext(txt)
end
else
-- Opponent.
local short
local code = Args['short_' .. e.opponent] or e.opponent
local oname = Args['name_' .. e.opponent]
if opts.opp_codes == 'unlinked' then
short = code
if opts.short_style ~= 'noflag' and oname then
local icon = util.split_icon(oname)
if icon ~= '' then short = icon .. short end
end
elseif opts.opp_codes == 'abbr' then
-- Use the full name in the abbreviation tooltip.
local shown = oname and util.clean_team_name(oname, e.opponent) or e.opponent
short = frame:expandTemplate{title = 'abbr', args = {code, shown}}
if opts.short_style ~= 'noflag' and oname then
local icon = util.split_icon(oname)
if icon ~= '' then short = icon .. short end
end
else
short = util.get_short_name(Args['short_' .. e.opponent], e.opponent,
oname, opts.short_style)
end
local venue_txt, at = '', ''
local ocell = opp_row:tag('td')
if opts.row_divider then ocell:cssText(opts.row_divider) end
if opts.venue_display == 'color' then
if e.venue == 'h' then
ocell:css('background', '#DDD')
elseif e.venue == 'n' then
ocell:css('background', '#FEDCBA')
end
elseif opts.venue_display == 'at' then
-- Prefix away games with @.
if e.venue == 'a' then at = '@ ' end
elseif opts.venue_display == 'abbr' then
venue_txt = ' <small>(' .. e.venue:upper() .. ')</small>'
end
local cell_txt = at .. short .. venue_txt
ocell:wikitext(bold and ("'''" .. cell_txt .. "'''") or cell_txt)
-- Score.
local raw, reversed, key = lookup(Args, 'match', team, e.opponent, e.venue, e.num, nil, opts.all_neutral)
local scell = score_row:tag('td')
local state = match_state(raw)
if state then
-- Unplayed matches use their state label.
local st = STATES[state]
scell:css('background', st.hex)
local shown = st.text
local mn = notes.match[key]
if mn then
if mn.used then
shown = shown .. mn.ref
else
mn.used = true
shown = shown .. mn.def
end
end
scell:wikitext(bold and ("'''" .. shown .. "'''") or shown)
elseif raw and raw ~= '' and raw ~= 'null' then
local plain, turned, blocked = score_forms(raw, util)
-- Evaluate the result from this team's view.
local own = reversed and turned or plain
local shown = own
if opts.home_first and e.venue ~= 'n' then
-- Scores are stored home first.
shown = plain
end
if reversed and blocked then
add_error(errors, '|' .. key .. '= contains markup, so its score was not '
.. 'turned round for the other team\'s row; give the value without tags, '
.. 'or write the two meetings out separately')
end
local extra = select(1, lookup(Args, 'result', team, e.opponent, e.venue, e.num, nil, opts.all_neutral)) or ''
if reversed then extra = invert_result(extra) end
if opts.style == 'FBR' then
-- Shootouts colour by the advancing team.
if extra == 'PKW' then
scell:cssText(util.get_score_background('1–0', 'level3', 'blue'))
elseif extra == 'PKL' then
scell:cssText(util.get_score_background('0–1', 'level3', 'blue'))
else
local lvl = (extra == 'OT' and 'level2') or (extra == 'PK' and 'level3') or ''
scell:cssText(util.get_score_background(own, lvl, 'blue'))
end
elseif opts.style == 'BSR' then
if extra == 'OTL' or extra == 'PKL' then
scell:cssText(util.get_score_background('0–1', 'level3', 'blue'))
elseif extra == 'OTW' or extra == 'PKW' then
scell:cssText(util.get_score_background('1–0', 'level3', 'blue'))
elseif extra == 'L' then
scell:cssText(util.get_score_background('0–1', '', 'blue'))
elseif extra == 'W' then
scell:cssText(util.get_score_background('1–0', '', 'blue'))
else
scell:cssText(util.get_score_background(own, extra == 'OT' and 'level3' or '', 'blue'))
end
elseif opts.colour then
local lvl = (extra == 'OT' and 'level2') or (extra == 'PK' and 'level3') or ''
if extra == 'W' then
scell:cssText(util.get_score_background('1–0', '', 'green'))
elseif extra == 'L' then
scell:cssText(util.get_score_background('0–1', '', 'green'))
elseif extra == 'OTW' or extra == 'PKW' then
scell:cssText(util.get_score_background('1–0', 'level3', 'green'))
elseif extra == 'OTL' or extra == 'PKL' then
scell:cssText(util.get_score_background('0–1', 'level3', 'green'))
else
scell:cssText(util.get_score_background(own, lvl, 'green'))
end
end
if opts.generate_links then
local first, second
if e.venue == 'n' then
-- Use parameter order for neutral-match anchors.
first = reversed and e.opponent or team
second = reversed and team or e.opponent
else
first = (e.venue == 'h') and team or e.opponent
second = (e.venue == 'h') and e.opponent or team
end
shown = util.generate_match_link(
util.clean_team_name(Args['name_' .. first], first),
util.clean_team_name(Args['name_' .. second], second),
shown)
end
local mn = notes.match[key]
if mn then
-- Define each match footnote once.
if mn.used then
shown = shown .. mn.ref
else
mn.used = true
shown = shown .. mn.def
end
end
scell:wikitext(bold and ("'''" .. shown .. "'''") or shown)
else
scell:wikitext('')
end
end
end
-- Pad shorter rows to a consistent width.
local pad = opts.max_rounds - row_width(entries)
if pad > 0 then
local padcell = opp_row:tag('td'):attr('rowspan', span2)
if pad > 1 then padcell:attr('colspan', tostring(pad)) end
if opts.row_divider then padcell:cssText(opts.row_divider) end
padcell:wikitext('')
end
end
-- Add the outside-opponent key below a single table.
if opts.external_key then
root:tag('tr'):tag('td')
:attr('colspan', tostring(opts.max_rounds + 1))
:wikitext(opts.external_key)
end
return tostring(root)
end
-- Wrap content in one horizontal scrollbar.
local function scroller(inner)
return '<div style="overflow:hidden"><div class="noresize overflowbugx" style="overflow:auto">'
.. inner .. '</div></div>'
end
--------------------------------------------------------------------------------
-- Round output via Module:Sports series
--------------------------------------------------------------------------------
-- Return the long form of a round heading.
local function round_label(frame, Args, n)
local override = Args['round' .. n .. '_header']
if override then return override end
local preset = ROUND_TYPES[string.lower(trim(Args['round_type']))]
local long = round_text(Args['round_name'] or (preset and preset[2]), n)
if long then return long end
local short = round_text(Args['round_abbreviation'] or (preset and preset[1]), n)
if short then return short end
return round_text(DEFAULT_ROUND_NAME, n)
end
-- Strip bold/italic markup; Module:Sports series handles highlighting.
local function series_name(Args, code, home, util)
local n = Args['name_' .. code] or code
n = n:gsub("'''+", ''):gsub("''", '')
if home then
-- Home names place the flag after the name.
local icon, rest = util.split_icon(n)
if icon ~= '' then
icon = icon:gsub('%s* %s*$', ''):gsub('%s+$', '')
n = rest .. ' ' .. icon
end
end
return n
end
-- Map only compatible parameters to Module:Sports series.
local SERIES_PASS = {'generate_links', 'link_headings', 'baselink', 'id', 'font_size', 'center_table',
'solid_cell', 'collapsed', 'small_text', 'bold_winner', 'color_winner',
'away_goals', 'note_group'}
local SERIES_MAP = {
series_matches_style = 'matches_style',
series_team1 = 'team1',
series_team2 = 'team2',
series_aggregate = 'aggregate',
table_nowrap = 'nowrap',
teamwidth = 'team_width',
match_col_width = 'score_width',
}
local function series_args(Args)
local out = {}
for _, k in ipairs(SERIES_PASS) do
if Args[k] ~= nil then out[k] = Args[k] end
end
for from, to in pairs(SERIES_MAP) do
if Args[from] ~= nil then out[to] = Args[from] end
end
-- Series mode fixes one leg per row and embeds flags in names.
out.legs = 'n'
out.flag = 'n'
-- This module builds the shared footer.
out.note_list = 'n'
out.legend = 'n'
return out
end
-- Build one series table for the given rounds.
local function build_series(frame, Args, util, opts, rounds, by_round, notes, heading, errors)
local p_series = require('Module:Sports series')
local args = series_args(Args)
if opts.any_neutral then
-- Neutral fixtures use generic headings.
args.h_a = nil
else
args.h_a = args.team1 and args.team2 and nil or 'y'
end
if heading then args.caption = heading end
if opts.split_columns then args.split_columns = opts.split_columns end
local i, row = 1, 0
for _, n in ipairs(rounds) do
local first = true
for _, e in ipairs(by_round[n] or {}) do
row = row + 1
if first and opts.headings then
args['heading' .. row] = round_label(frame, Args, n)
first = false
end
args[i] = series_name(Args, e.home, true, util)
local raw = select(1, lookup(Args, 'match', e.home, e.away,
e.neutral and 'n' or 'h', e.num or 1, nil, opts.all_neutral))
args[i + 1] = (raw and raw ~= '' and raw ~= 'null') and util.format_score(raw) or ''
args[i + 2] = series_name(Args, e.away, false, util)
local _, _, key = lookup(Args, 'match', e.home, e.away,
e.neutral and 'n' or 'h', e.num or 1, nil, opts.all_neutral)
if notes.match[key] then args['note_agg_' .. row] = Args[key .. '_note'] end
i = i + 3
end
end
if row == 0 then return '' end
return p_series._main(frame, args)
end
--------------------------------------------------------------------------------
-- Shared legend
--------------------------------------------------------------------------------
-- Valid |display_codes= values.
local CODE_MODES = {inline = true, inline_bold = true, ['break'] = true,
break_bold = true, legend = true, legend_unlinked = true}
local function code_mode(v)
v = string.lower(trim(v))
if v == '' then return nil end
if CODE_MODES[v] then return v end
if yesno(v) == false or v == 'none' then return nil end
return nil, v -- unrecognised; the caller reports it
end
local function has_neutral(schedules)
for _, entries in pairs(schedules) do
for _, e in ipairs(entries) do
if not e.null and e.venue == 'n' then return true end
end
end
return false
end
-- Result-cell colours matching get_score_background.
local WIN_HEX = {blue = '#BBF3FF', green = '#BBF3BB'}
local DRAW_HEX, LOSS_HEX = '#FFFFBB', '#FFBBBB'
local HOME_HEX, NEUTRAL_HEX = '#DDD', '#FEDCBA'
-- Build a standard legend entry.
local function legend_entry(opts, abbrev, meaning)
if opts.abbrev_after then
return meaning .. opts.divider .. abbrev
end
return abbrev .. opts.divider .. meaning
end
local function colour_entry(frame, opts, hex, name, meaning)
if opts.color_boxes then
-- Colour-box labels are capitalized.
return frame:expandTemplate{title = 'color box',
args = {hex, meaning:sub(1, 1):upper() .. meaning:sub(2)}}
end
return name .. opts.divider .. meaning
end
-- Build team-key entries for legends and outside opponents.
local function team_key_entries(Args, util, opts, codes)
local out = {}
for i, team in ipairs(codes) do
local name = Args['name_' .. team] or team
if opts.codes == 'legend_unlinked' then
name = util.clean_team_name(name, team)
elseif opts.short_style == 'noflag' then
local _, rest = util.split_icon(name)
name = rest
end
out[i] = legend_entry(opts, Args['short_' .. team] or team, name)
end
return out
end
-- Gather legend content for header and footer rendering.
local function legend_data(frame, Args, util, opts, teams, schedules)
local d = {venue = {}, colours = {}, teams = {}, venue_is_colour = false,
venue_is_sentence = false}
local word = opts.tie and 'game' or 'match'
local neutral = has_neutral(schedules)
if opts.venue_display == 'color' then
d.venue_is_colour = true
d.venue[#d.venue + 1] = colour_entry(frame, opts, HOME_HEX, 'Grey', 'home ' .. word)
d.venue[#d.venue + 1] = colour_entry(frame, opts,
'var(--background-color-neutral-subtle, #f8f9fa)',
'standard background', 'away ' .. word)
if neutral then
d.venue[#d.venue + 1] = colour_entry(frame, opts, NEUTRAL_HEX, 'orange',
'neutral ' .. word)
end
elseif opts.venue_display == 'at' then
d.venue_is_sentence = true
d.venue[#d.venue + 1] = 'Opponents shown after @ were played away'
elseif opts.venue_display == 'abbr' then
d.venue[#d.venue + 1] = legend_entry(opts, 'H', 'home')
d.venue[#d.venue + 1] = legend_entry(opts, 'A', 'away')
if neutral then d.venue[#d.venue + 1] = legend_entry(opts, 'N', 'neutral') end
end
if opts.coloured then
local blue = (opts.style == 'FBR' or opts.style == 'BSR')
local win = colour_entry(frame, opts, WIN_HEX[blue and 'blue' or 'green'],
blue and 'Blue' or 'Green', 'win')
local draw = colour_entry(frame, opts, DRAW_HEX, 'Yellow', opts.tie and 'tie' or 'draw')
local loss = colour_entry(frame, opts, LOSS_HEX, 'Red', 'loss')
d.colours[#d.colours + 1] = win
if opts.loss_first then
d.colours[#d.colours + 1] = loss
d.colours[#d.colours + 1] = draw
else
d.colours[#d.colours + 1] = draw
d.colours[#d.colours + 1] = loss
end
end
-- List cancelled and postponed states last.
for _, key in ipairs({'canc', 'ppd'}) do
if opts.states_used and opts.states_used[key] then
local st = STATES[key]
d.colours[#d.colours + 1] = colour_entry(frame, opts, st.hex, st.name,
st.meaning .. ' ' .. word)
end
end
if opts.codes == 'legend' or opts.codes == 'legend_unlinked' then
d.teams = team_key_entries(Args, util, opts, teams)
end
return d
end
-- Render the footer legend in its established format.
local function legend_footer(frame, Args, util, opts, teams, schedules)
local d = legend_data(frame, Args, util, opts, teams, schedules)
local parts = {}
if #d.venue > 0 then
local sep = ', '
if d.venue_is_colour then sep = '; ' elseif d.venue_is_sentence then sep = ' ' end
parts[#parts + 1] = table.concat(d.venue, sep)
end
if #d.colours > 0 then
parts[#parts + 1] = table.concat(d.colours, '; ')
end
local out = {}
if #parts > 0 then
out[#out + 1] = '<br />Legend: ' .. table.concat(parts, '. ') .. '.'
end
if #d.teams > 0 then
out[#out + 1] = '<br />Team key: ' .. table.concat(d.teams, '; ') .. '.'
end
return table.concat(out)
end
-- Render multi-item header legends as inline hlist entries.
local function inline_list(frame, items)
if #items == 1 then return items[1] end
local args = {class = 'inline'}
for i, v in ipairs(items) do
args[i] = v
end
return frame:expandTemplate{title = 'hlist', args = args}
end
-- Render the key for outside opponents.
local function external_key(frame, Args, util, opts, codes)
if #codes == 0 then return nil end
local entries = team_key_entries(Args, util, opts, codes)
local per = tonumber(Args['legend_teams_per_line']) or 4
if per < 1 then per = 1 end
local lines = {}
for i = 1, #entries, per do
local chunk = {}
for j = i, math.min(i + per - 1, #entries) do chunk[#chunk + 1] = entries[j] end
lines[#lines + 1] = inline_list(frame, chunk)
end
local label = Args['external_teams_label']
if label == nil then label = 'Other teams' end
label = trim(label)
if label == '' then return table.concat(lines, '<br>') end
if #lines == 1 then return label .. ': ' .. lines[1] end
return label .. ':<br>' .. table.concat(lines, '<br>')
end
-- Render venue, team-key, and colour entries in the header.
local function legend_header(frame, Args, util, opts, teams, schedules)
local d = legend_data(frame, Args, util, opts, teams, schedules)
local lines, kinds = {}, {}
if #d.venue > 0 and not d.venue_is_colour then
lines[#lines + 1] = inline_list(frame, d.venue)
kinds[#kinds + 1] = 'venue'
end
if #d.teams > 0 then
-- The row already says "Legend:".
local per = tonumber(Args['legend_teams_per_line']) or 4
if per < 1 then per = 1 end
for i = 1, #d.teams, per do
local chunk = {}
for j = i, math.min(i + per - 1, #d.teams) do chunk[#chunk + 1] = d.teams[j] end
lines[#lines + 1] = inline_list(frame, chunk)
kinds[#kinds + 1] = 'teams'
end
end
local cols = {}
if d.venue_is_colour then
for _, v in ipairs(d.venue) do cols[#cols + 1] = v end
end
for _, v in ipairs(d.colours) do cols[#cols + 1] = v end
if #cols > 0 then
lines[#lines + 1] = inline_list(frame, cols)
kinds[#kinds + 1] = 'colours'
end
if #lines == 0 then return nil end
if #lines == 1 and (kinds[1] == 'venue' or kinds[1] == 'colours') then
return 'Legend: ' .. lines[1]
end
return 'Legend:<br>' .. table.concat(lines, '<br>')
end
--------------------------------------------------------------------------------
-- Footer
--------------------------------------------------------------------------------
-- Series footer: one FBR legend and the note list.
local function build_series_footer(frame, Args, opts, notes_exist)
local f = {}
if opts.series_fbr then
f[#f + 1] = 'Legend: Blue = home team win; Yellow = draw; Red = away team win.'
end
if notes_exist then
f[#f + 1] = (#f > 0 and '<br>' or '') .. 'Notes:'
end
if #f == 0 then return '' end
-- Load these styles with the notes container.
local templatestyles = frame:extensionTag{name = 'templatestyles',
args = {src = 'Module:Sports results/styles.css'}}
local out = templatestyles .. '<div class="sports-results-notes">' .. table.concat(f) .. '</div>'
if notes_exist then
out = out .. frame:expandTemplate{title = 'notelist',
args = {group = Args['note_group'] or 'lower-alpha'}}
end
return out
end
-- Build one footer for all blocks.
local function build_footer(frame, Args, util, opts, teams, schedules, notes_exist)
local f = {}
local word = yesno(Args['use_tie'] or 'no') and 'game' or 'match'
local update = Args['update'] or ''
local source = Args['source'] or frame:expandTemplate{title = 'citation needed',
args = {reason = 'No source parameter defined', date = os.date('%B %Y')}}
if string.lower(update) == 'complete' then
-- No score direction needed.
elseif update == '' then
f[#f + 1] = 'Updated to ' .. word .. '(es) played on unknown.'
elseif string.lower(update) == 'future' then
f[#f + 1] = 'First ' .. word .. '(es) will be played: ' .. (Args['start_date'] or 'unknown') .. '.'
else
f[#f + 1] = 'Updated to ' .. word .. '(es) played on ' .. update .. '.'
end
f[#f + 1] = ' Source: ' .. source
-- Explain score direction.
local coloured = opts.coloured
local show_venue = (opts.venue_display ~= 'none')
if opts.home_first then
local txt = '<br />Scores are listed with the home team\'s score first'
if has_neutral(schedules) then
txt = txt .. '; neutral ' .. word .. 'es are listed from the perspective of the team in the "'
.. (Args['team_header'] or 'Team') .. '" column'
end
f[#f + 1] = txt .. '.'
else
f[#f + 1] = '<br />Scores' .. (coloured and ' and legend colours' or '')
.. ' are listed from the perspective of the team in the "'
.. (Args['team_header'] or 'Team') .. '" column.'
end
-- Add the legend unless it is in the header.
if not opts.header_legend then
f[#f + 1] = legend_footer(frame, Args, util, opts, teams, schedules)
end
-- Block layouts place the outside-opponent key in the footer.
if opts.footer_external then
f[#f + 1] = '<br />' .. opts.footer_external
end
if Args['a_note'] then
f[#f + 1] = '<br />For upcoming ' .. word .. 'es, an "a" indicates there is an article about the rivalry between the two participants.'
end
if Args['ot_note'] then
f[#f + 1] = '<br />' .. (yesno(Args['use_tie'] or 'no') and 'Games' or 'Matches')
.. ' with lighter background shading were decided after overtime.'
end
if Args['pk_note'] then
f[#f + 1] = '<br />' .. (yesno(Args['use_tie'] or 'no') and 'Games' or 'Matches')
.. ' with lighter background shading were decided by a penalty shoot-out.'
end
local templatestyles = frame:extensionTag{name = 'templatestyles',
args = {src = 'Module:Sports results/styles.css'}}
if notes_exist then
f[#f + 1] = '<br>Notes:'
return templatestyles .. '<div class="sports-results-notes">' .. table.concat(f) .. '</div>'
.. frame:expandTemplate{title = 'notelist', args = {group = 'lower-alpha'}}
end
return templatestyles .. '<div class="sports-results-notes">' .. table.concat(f) .. '</div>'
end
--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------
function pp._main(frame, Args, util, standalone)
local errors = {}
local format = string.lower(trim(Args['matches_format']))
local series_mode = (format == 'series' or format == 'series_split')
local series_split = (format == 'series_split')
local top_pos = tonumber(Args['highest_pos']) or tonumber(Args['highest_row']) or 1
local bottom_pos = tonumber(Args['lowest_pos']) or tonumber(Args['lowest_row']) or 0
if Args['legs'] then
add_error(errors, '|legs= has no meaning in the schedule format; repeat meetings are numbered instead')
end
do
local _, bad = code_mode(Args['display_codes'])
if bad then
add_error(errors, '|display_codes=' .. bad .. ' was not recognised; it is one of inline, '
.. 'inline_bold, break, break_bold, legend, legend_unlinked, or a negative value for none')
end
end
local opp = string.lower(trim(Args['opponent_inline_codes']))
if opp ~= '' and opp ~= 'linked' and opp ~= 'unlinked' and opp ~= 'abbr' then
add_error(errors, '|opponent_inline_codes=' .. opp
.. ' was not recognised; it is linked, unlinked or abbr')
end
if yesno(Args['header_legend'] or 'no') and using_blocks_early(Args) then
add_error(errors, '|header_legend= does nothing when the teams are split into blocks, '
.. 'because each block is a separate table; the legend stays in the footer')
end
if trim(Args['round_type']) ~= '' and not ROUND_TYPES[string.lower(trim(Args['round_type']))] then
local names = {}
for k in pairs(ROUND_TYPES) do names[#names + 1] = k end
table.sort(names)
add_error(errors, '|round_type=' .. trim(Args['round_type']) .. ' was not recognised; it is one of '
.. table.concat(names, ', ') .. ', or set |round_abbreviation= and |round_name= directly')
end
-- |schedule_TTT= takes precedence over round lists.
local has_schedule, has_rounds = false, false
local round_params = {}
for k in pairs(Args) do
if type(k) == 'string' then
if k:match('^schedule_.+$') then has_schedule = true end
local n = k:match('^round(%d+)_matches$')
if n then
has_rounds = true
round_params[#round_params + 1] = 'round' .. n .. '_matches'
end
end
end
table.sort(round_params)
if has_schedule and has_rounds then
add_error(errors, '|schedule_TTT= and |' .. table.concat(round_params, '=, |')
.. '= are both given; the round lists have been ignored')
end
-- Round output requires |roundN_matches= lists.
if series_mode and not has_rounds then
return '<div style="color:red">Error: |matches_format=' .. format
.. ' needs |roundN_matches=; it cannot be built from |schedule_TTT=</div>'
end
-- |all_neutral= permits bare codes and unordered match keys.
local all_neutral = yesno(Args['all_neutral'] or 'no') and true or false
-- Outside opponents have no schedule row but may have names and shorts.
local external, external_list = {}, {}
for _, c in ipairs(split_list(Args['external_teams'] or '')) do
if not external[c] then
external[c] = true
external_list[#external_list + 1] = c
end
end
for _, c in ipairs(external_list) do
if Args['schedule_' .. c] ~= nil then
add_error(errors, c .. ' is in |external_teams= but also has |schedule_' .. c
.. '=; a team is either a row of the table or an outside opponent, not both')
end
end
-- Warn when row-order parameters include outside opponents.
for _, p in ipairs({'default_order', 'team_order'}) do
for _, c in ipairs(split_list(Args[p] or '')) do
if external[c] then
add_error(errors, '|' .. p .. '= names ' .. c
.. ', which is in |external_teams= and has no row; it has been passed over')
end
end
end
do
local i = top_pos
while Args['team' .. i] ~= nil do
if external[trim(Args['team' .. i])] then
add_error(errors, '|team' .. i .. '=' .. trim(Args['team' .. i])
.. ' is in |external_teams= and has no row; it has been passed over')
end
i = i + 1
end
end
local teams, roster, schedules, max_rounds, by_round
if has_schedule or not has_rounds then
teams, roster = team_list(Args, util, top_pos, bottom_pos, nil, external)
schedules, max_rounds = {}, 0
for _, team in ipairs(teams) do
schedules[team] = read_schedule(Args, team, errors, all_neutral)
max_rounds = math.max(max_rounds, row_width(schedules[team]))
end
check_idle(teams, schedules, errors)
check_pairs(schedules, roster, errors, external)
else
-- Names validate round-list codes; outsiders remain valid for splitting.
local named, codes = false, {}
for k in pairs(Args) do
if type(k) == 'string' then
local c = k:match('^name_(.+)$')
if c and c ~= '' then
if not external[c] then named = true end
codes[c] = true
end
end
end
for _, c in ipairs(external_list) do codes[c] = true end
if not named then
-- With no names, infer codes from round lists.
for k, v in pairs(Args) do
if type(k) == 'string' and k:match('^round%d+_matches$') then
for _, item in ipairs(split_list(v)) do
local pair = item:match('^(.-):') or item
local i = pair:find('_', 1, true)
while i do
codes[trim(pair:sub(1, i - 1))] = true
codes[trim(pair:sub(i + 1))] = true
i = pair:find('_', i + 1, true)
end
end
end
end
end
local built
built, max_rounds, by_round = read_rounds(Args, codes, named, errors, all_neutral, external, series_mode)
local order = {}
for c in pairs(codes) do order[#order + 1] = c end
teams, roster = team_list(Args, util, top_pos, bottom_pos, order, external)
schedules = built or {}
for _, team in ipairs(teams) do
schedules[team] = schedules[team] or {}
end
end
if #teams == 0 then
add_error(errors, 'no teams were found: the schedule format needs |schedule_TTT= '
.. 'or |roundN_matches= parameters')
end
-- Notes are shared by both match appearances.
local notes_exist = false
local notes = {team = {}, match = {}}
local rand = tostring(math.random(1000, 9999))
for _, team in ipairs(teams) do
local tn = util.team_note(Args, team, standalone)
if tn then
notes_exist = true
notes.team[team] = frame:expandTemplate{title = 'efn',
args = {group = 'lower-alpha', tn}}
end
end
for _, team in ipairs(teams) do
for _, e in ipairs(schedules[team] or {}) do
if not e.null then
local _, _, key = lookup(Args, 'match', team, e.opponent, e.venue, e.num, errors, all_neutral)
local note = Args[key .. '_note']
if note and not notes.match[key] then
notes_exist = true
local id = '"sched_' .. key .. rand .. '"'
notes.match[key] = {
def = frame:expandTemplate{title = 'efn',
args = {group = 'lower-alpha', name = id, note}},
ref = frame:extensionTag{name = 'ref',
args = {group = 'lower-alpha', name = id}},
used = false,
}
end
end
end
end
--------------------------------------------------------------------------
-- Used legend entries
--------------------------------------------------------------------------
-- List only states and outside opponents actually used.
local states_used, external_seen = {}, {}
for _, team in ipairs(teams) do
for _, e in ipairs(schedules[team] or {}) do
if not e.null then
if external[e.opponent] then external_seen[e.opponent] = true end
local raw = select(1, lookup(Args, 'match', team, e.opponent, e.venue,
e.num, nil, all_neutral))
local st = match_state(raw)
if st then states_used[st] = true end
end
end
end
local external_used = {}
for _, c in ipairs(external_list) do
if external_seen[c] then external_used[#external_used + 1] = c end
end
-- Order outsiders by |default_order=, display text, then code.
do
local order, placed_t = {}, {}
local seen_ext = {}
for _, c in ipairs(external_used) do seen_ext[c] = true end
for _, c in ipairs(split_list(Args['default_order'] or '')) do
if seen_ext[c] and not placed_t[c] then
placed_t[c] = true
order[#order + 1] = c
end
end
local rest = {}
for _, c in ipairs(external_used) do
if not placed_t[c] then rest[#rest + 1] = c end
end
table.sort(rest, name_sorter(Args, util))
for _, c in ipairs(rest) do order[#order + 1] = c end
external_used = order
end
local style = Args['matches_style'] or ''
local opts = {
max_rounds = max_rounds,
team_header = Args['team_header'] or 'Team',
team_align = Args['team_align'],
team_nowrap = yesno(Args['team_nowrap'] or 'no') and true or false,
col_width = Args['match_col_width'],
font_size = Args['font_size'] and (Args['font_size']:match('^%d+$')
and (Args['font_size'] .. '%') or Args['font_size']) or nil,
center = yesno(Args['center_table'] or 'no') and true or false,
showteam = Args['showteam'],
codes = code_mode(Args['display_codes']),
opp_codes = string.lower(trim(Args['opponent_inline_codes'])),
short_style = Args['short_style'],
tie = yesno(Args['use_tie'] or 'no') and true or false,
loss_first = (Args['loss_before_draw'] or Args['loss_before_tie']) and true or false,
divider = (string.lower(trim(Args['legend_divider'])) == 'dash') and ' – ' or ' = ',
abbrev_after = yesno(Args['abbreviation_after'] or 'no') and true or false,
color_boxes = yesno(Args['color_boxes'] or 'no') and true or false,
all_neutral = all_neutral,
home_first = yesno(Args['home_first'] or 'no') and true or false,
solid_grey = Args['solid_cell'] and (Args['solid_cell']:lower() == 'grey'
or Args['solid_cell']:lower() == 'gray' or yesno(Args['solid_cell'])) or false,
style = (style == 'FBR' or style == 'BSR') and style or nil,
colour = (style == 'FBR_green') and true or false,
generate_links = yesno(Args['generate_links'] or 'no') and true or false,
span_header = round_span_header(Args),
round_dates = any_round_dates(Args),
null_text = Args['null_text'] or '—',
null_text_set = (Args['null_text'] ~= nil),
states_used = states_used,
table_nowrap = yesno(Args['table_nowrap'] or 'no') and true or false,
row_divider = yesno(Args['row_dividers'] or 'no') and 'border-top:2px solid gray;' or nil,
team_width = Args['teamwidth'] and (Args['teamwidth']:match('^%d+$')
and (Args['teamwidth'] .. 'px') or Args['teamwidth']) or nil,
}
-- Neutral fixtures override incompatible venue displays.
local vd = string.lower(trim(Args['venue_display']))
if vd == '' then vd = 'abbr' end
if vd ~= 'abbr' and vd ~= 'color' and vd ~= 'colour' and vd ~= 'at' then
add_error(errors, '|venue_display=' .. vd .. ' was not recognised; it is abbr, color or at')
vd = 'abbr'
end
if vd == 'colour' then vd = 'color' end
local every_neutral = all_neutral
if not every_neutral then
local any, all = false, true
for _, team in ipairs(teams) do
for _, e in ipairs(schedules[team] or {}) do
if not e.null then
any = true
if e.venue ~= 'n' then all = false end
end
end
end
every_neutral = any and all
end
if every_neutral then
vd = 'none'
elseif vd == 'at' and has_neutral(schedules) then
vd = 'abbr'
end
opts.venue_display = vd
opts.color_venue = (vd == 'color')
if Args['team_header_note'] then
notes_exist = true
opts.team_header = opts.team_header .. frame:expandTemplate{title = 'efn',
args = {group = 'lower-alpha', Args['team_header_note']}}
end
-- Track match values that no fixture displays.
local referenced = {}
local function mark(team, opponent, venue, num)
local _, _, key = lookup(Args, 'match', team, opponent, venue, num, nil, opts.all_neutral)
local forms = {key}
forms[#forms + 1] = key:gsub('^match(n?)_', 'match%%11_')
forms[#forms + 1] = key:gsub('^match(n?)1_', 'match%%1_')
if venue == 'n' then
-- Include both orders and plain all-neutral keys.
local n = (opts.all_neutral and {'', 'n'} or {'n'})
for _, nn in ipairs(n) do
local numtxt = (num or 1) > 1 and tostring(num) or ''
forms[#forms + 1] = 'match' .. nn .. numtxt .. '_' .. team .. '_' .. opponent
forms[#forms + 1] = 'match' .. nn .. numtxt .. '_' .. opponent .. '_' .. team
if (num or 1) == 1 then
forms[#forms + 1] = 'match' .. nn .. '1_' .. team .. '_' .. opponent
forms[#forms + 1] = 'match' .. nn .. '1_' .. opponent .. '_' .. team
end
end
end
for _, f in ipairs(forms) do referenced[f] = true end
end
for _, team in ipairs(teams) do
for _, e in ipairs(schedules[team] or {}) do
if not e.null then mark(team, e.opponent, e.venue, e.num) end
end
end
-- Round fixtures may display even when neither side has a row.
for _, fixtures in pairs(by_round or {}) do
for _, e in ipairs(fixtures) do
mark(e.home, e.away, e.neutral and 'n' or 'h', e.num or 1)
end
end
-- Sort orphan warnings for stable output.
local orphans = {}
for k in pairs(Args) do
if type(k) == 'string' and k:match('^matchn?%d*_.+_.+$') and not k:match('_note$')
and not referenced[k] then
orphans[#orphans + 1] = k
end
end
table.sort(orphans)
for _, k in ipairs(orphans) do
add_error(errors, '|' .. k .. '= is not shown anywhere; no team\'s fixtures refer to it')
end
if series_mode then
local style = Args['series_matches_style'] or ''
-- Headings are generated for every round; |series_split= also renders
-- each round as a separate side-by-side table.
local sopts = {
headings = true,
any_neutral = has_neutral(schedules),
series_fbr = (style == 'FBR'),
split_columns = series_split and (tonumber(Args['series_columns']) or 2) or nil,
}
local all = {}
for n = 1, (max_rounds or 0) do all[#all + 1] = n end
local body = build_series(frame, Args, util, sopts, all, by_round, notes, nil, errors)
local out = scroller(body)
out = out .. build_series_footer(frame, Args, sopts, notes_exist)
if #errors > 0 and frame:preprocess('{{REVISIONID}}') == '' then
for _, e in ipairs(errors) do
out = out .. '<div style="color:red">Error: ' .. e .. '</div>'
end
end
return out
end
local blocks, using_blocks = build_blocks(Args, teams, errors, external)
-- Header legends are used only for a single table.
opts.coloured = (opts.style ~= nil) or opts.colour
if yesno(Args['header_legend'] or 'no') and not using_blocks then
opts.header_legend = true
opts.legend = legend_header(frame, Args, util, opts, teams, schedules)
end
-- Put the outside-opponent key in the footer for block layouts.
local ext_key = external_key(frame, Args, util, opts, external_used)
if using_blocks then
opts.footer_external = ext_key
else
opts.external_key = ext_key
end
local parts = {}
for _, block in ipairs(blocks) do
parts[#parts + 1] = build_table(frame, Args, util, opts, block, schedules, notes, errors)
end
local body
if using_blocks and #parts > 1 then
local cols = tonumber(Args['block_columns']) or 2
if cols < 1 then cols = 1 end
-- Equal-width tracks expand as needed; the wrapper handles overflow.
local wrap = mw.html.create('div')
:css('display', 'grid')
:css('grid-template-columns', 'repeat(' .. cols .. ', minmax(max-content, 1fr))')
:css('gap', '1em')
:css('align-items', 'start')
for _, part in ipairs(parts) do
wrap:tag('div'):wikitext(part)
end
body = scroller(tostring(wrap))
else
local wrapped = {}
for i, part in ipairs(parts) do
wrapped[i] = scroller(part)
end
body = table.concat(wrapped, '\n')
end
body = body .. build_footer(frame, Args, util, opts, teams, schedules, notes_exist)
-- Rewrite match-report anchors for the transcluded template.
local baselink = Args['baselink'] or frame:getParent():getTitle()
if mw.title.getCurrentTitle().fullText == baselink then baselink = '' end
if baselink ~= '' and body:match('%[%[#[^%[%]]*%|') then
body = mw.ustring.gsub(body, '(%[%[)(#[^%[%]]*%|)', '%1' .. baselink .. '%2')
end
if #errors > 0 and frame:preprocess('{{REVISIONID}}') == '' then
for _, e in ipairs(errors) do
body = body .. '<div style="color:red">Error: ' .. e .. '</div>'
end
end
return body
end
return pp