Module:Sandbox/Pbrks

From Wikipedia, the free encyclopedia
Jump to navigation Jump to search
---
--- Build Bracket (refactor skeleton)
--- Goal: separate data phases, minimize mutation, clarify responsibilities.
--- This is a FIRST PASS skeleton wired to p.main(frame).
--- We'll port functionality in phases while keeping the public API.
---

local p = {}

-- =====================
-- Localized stdlib
-- =====================
local str_gsub, str_match, str_find = string.gsub, string.match, string.find
local t_insert, t_sort, t_concat = table.insert, table.sort, table.concat
local m_max, m_min, m_ceil = math.max, math.min, math.ceil
local tonumber, tostring = tonumber, tostring
local pairs, ipairs, type = pairs, ipairs, type

local mw_html_create = mw and mw.html and mw.html.create

-- =====================
-- Small utilities (pure)
-- =====================
local function isempty(s) return s == nil or s == '' end
local function notempty(s) return s ~= nil and s ~= '' end
local function yesish(v)
  v = (tostring(v or ''):lower())
  return v == 'y' or v == 'yes' or v == 'true' or v == '1'
end
local function noish(v)
  v = (tostring(v or ''):lower())
  return v == 'n' or v == 'no' or v == 'false' or v == '0'
end
local function toChar(num) return string.char(string.byte('a') + num - 1) end

-- =====================
-- Types (documentation only)
-- =====================
-- Config = {
--   c:number, r:number|nil, minc:number,
--   autocol:boolean, autolegs:boolean,
--   seeds:boolean, forceseeds:boolean,
--   boldwinner_mode:'off'|'high'|'low', boldwinner_aggonly:boolean,
--   aggregate:boolean, aggregate_mode:'off'|'manual'|'sets'|'score',
--   colspacing:number, height:number, nowrap:boolean,
--   paramstyle:'indexed'|'numbered',
-- }
--
-- Grid[j][i] = Cell where Cell has a stable shape:
--   { ctype='header'|'team'|'text'|'line'|'group'|'blank',
--     index:number?, altindex:number?, headerindex:number?,
--     position:'top'|nil,
--     team:string?, seed:string?,
--     legs:number?,
--     score={ [1]=string?, [2]=string?, agg=string?, weight=table }?,
--     weight:'bold'|'normal'|nil,
--     pheader:string?, header:string?, shade:string?, shade_is_rd:boolean?,
--     group:string?, colspan:number?,
--     borderWidth:{number,number,number,number}? ,
--     border:'top'|'bottom'|'both'|nil,
--   }
--
-- Derived = {
--   rlegs = { [j]=number },
--   maxlegs = { [j]=number },
--   teamsPerMatch = { [j]=number },
--   headercount = { [j]=number },
--   hide = { [j] = { [headerindex]=true } },
--   byes = { [j] = { [headerindex]=true } },
--   matchgroup = { [j] = { [i]=groupId } },
--   pathCell, crossCell, skipPath, hascross, shift,
-- }

-- =====================
-- Phase 0. Arg access
-- =====================
local function makeArgReaders(frame)
  local fargs = frame.args or {}
  local pargs = (frame.getParent and frame:getParent().args) or {}
  local function bargs(k) return pargs[k] or fargs[k] end
  return fargs, pargs, bargs
end

-- =====================
-- Phase 1. Parse args → Config (pure)
-- =====================
local function parseConfig(fargs, pargs, bargs)
  local cfg = {
    r = tonumber(fargs.rows) or nil,
    c = tonumber(fargs.rounds) or 1,
    minc = tonumber(pargs.minround) or 1,

    autocol = yesish(fargs.autocol),
    colspacing = tonumber(fargs['col-spacing']) or 5,
    height = bargs('height') or 0,

    -- seeds
    forceseeds = yesish(pargs.seeds),
    seeds = not noish(pargs.seeds),

    -- boldwinner
    boldwinner_mode = 'off',
    boldwinner_aggonly = false,

    -- aggregate
    aggregate = false,
    aggregate_mode = 'off',

    autolegs = yesish(pargs.autolegs),
    paramstyle = (bargs('paramstyle') == 'numbered') and 'numbered' or 'indexed',
    nowrap = not noish(pargs.nowrap),

    deprecation_category = 'Pages using Build Bracket with deprecated parameters',
  }

  -- rounds override via maxround(s)
  local maxc = tonumber(pargs.maxrounds) or tonumber(pargs.maxround)
  if maxc then cfg.c = maxc end

  -- boldwinner legacy switch mapping
  do
    local bw = (bargs('boldwinner') or ''):lower()
    if bw == 'low' then cfg.boldwinner_mode = 'low'
    elseif bw == 'high' or yesish(bw) then cfg.boldwinner_mode = 'high'
    elseif bw == 'aggregate' or bw == 'agg' or bw == 'aggregate-high' or bw == 'agg-high' then
      cfg.boldwinner_mode = 'high'; cfg.boldwinner_aggonly = true
    elseif bw == 'aggregate-low' or bw == 'agg-low' then
      cfg.boldwinner_mode = 'low'; cfg.boldwinner_aggonly = true
    end
    if yesish(bargs('boldwinner-aggregate-only')) then cfg.boldwinner_aggonly = true end
  end

  -- aggregate mode
  do
    local aval = (bargs('aggregate') or ''):lower()
    if aval == 'sets' or aval == 'legs' then
      cfg.aggregate_mode = 'sets'; cfg.aggregate = true
    elseif aval == 'score' then
      cfg.aggregate_mode = 'score'; cfg.aggregate = true
    elseif yesish(aval) then
      cfg.aggregate_mode = 'manual'; cfg.aggregate = true
    else
      cfg.aggregate_mode = 'off'; cfg.aggregate = false
    end
  end

  return cfg
end

-- =====================
-- Phase 2. Build initial grid (headers/teams/lines/text) → Grid
--   NOTE: This mirrors the original getCells(), but returns a new grid and a
--   small Derived stub. We keep arg names compatible (colX-headers, etc.).
-- =====================
local function splitCSVInts(s)
  if isempty(s) then return {} end
  local out = {}
  s = s:gsub('%s+', '')
  for n in s:gmatch('[^,]+') do
    local v = tonumber(n)
    if v then t_insert(out, v) end
  end
  return out
end

local function buildInitialGrid(cfg, fargs, pargs, bargs)
  local grid = {}
  local derived = {
    teamsPerMatch = {},
    shift = {},
    maxtpm = 1,
  }

  local DEFAULT_TPM = 2

  -- detect explicit headers
  local hasHeaders = false
  for j = cfg.minc, cfg.c do
    if not isempty(fargs['col'..j..'-headers']) then hasHeaders = true end
  end

  -- pass 1: get tpm per round and track maxtpm
  for j = cfg.minc, cfg.c do
    local tpm = tonumber(fargs['RD'..j..'-teams-per-match'])
      or tonumber(fargs['col'..j..'-teams-per-match'])
      or tonumber(fargs['teams-per-match'])
      or DEFAULT_TPM
    derived.teamsPerMatch[j] = tpm
    if tpm > (derived.maxtpm or 1) then derived.maxtpm = tpm end
  end

  -- pass 2: populate skeleton cells
  local maxrow = 1
  for j = cfg.minc, cfg.c do
    grid[j] = {}
    derived.shift[j] = tonumber(bargs('RD'..j..'-shift')) or tonumber(bargs('shift')) or 0

    local headers = splitCSVInts(fargs['col'..j..'-headers'] or '')
    local matches = splitCSVInts(fargs['col'..j..'-matches'] or '')
    local lines   = splitCSVInts(fargs['col'..j..'-lines']   or '')
    local texts   = splitCSVInts(fargs['col'..j..'-text']    or '')

    -- Auto-insert a header if no explicit headers passed anywhere and noheaders flag not set.
    if not hasHeaders and (fargs['noheaders'] ~= 'y' and fargs['noheaders'] ~= 'yes') then
      t_insert(headers, 1)
    end

    local function mark(i, cell)
      grid[j][i] = cell
    end

    local function populateHeader(pos)
      local i = 2 * (pos + (derived.shift[j] or 0)) - 1
      mark(i,   { ctype='header', index=#headers, position='top' })
      mark(i+1, { ctype='blank' })
      maxrow = m_max(maxrow, i+1)
    end

    local function populateTeamGroup(n)
      local start = 2 * (n + (derived.shift[j] or 0)) - 1
      -- ensure a text row before first team block
      if grid[j][start-1] == nil and grid[j][start-2] == nil then
        mark(start-2, { ctype='text', index = n })
        mark(start-1, { ctype='blank' })
      end
      -- top team
      mark(start,     { ctype='team', index = derived.teamsPerMatch[j]*n - (derived.teamsPerMatch[j]-1), position='top' })
      mark(start + 1, { ctype='blank' })
      -- remaining teams in match
      for m=2, derived.teamsPerMatch[j] do
        local idx = derived.teamsPerMatch[j]*n - (derived.teamsPerMatch[j]-m)
        local row = start + 2*(m-1)
        mark(row,     { ctype='team', index=idx })
        mark(row + 1, { ctype='blank' })
      end
      maxrow = m_max(maxrow, start + 2*derived.teamsPerMatch[j]-1)
    end

    local function populateLine(pos)
      local i = 2 * (pos + (derived.shift[j] or 0)) - 1
      mark(i,   { ctype='line', border='bottom' })
      mark(i+1, { ctype='blank' })
      mark(i+2, { ctype='line', border='top' })
      mark(i+3, { ctype='blank' })
      maxrow = m_max(maxrow, i+3)
    end

    local function populateText(pos, textindex)
      local i = 2 * (pos + (derived.shift[j] or 0)) - 1
      mark(i,   { ctype='text', index=textindex })
      mark(i+1, { ctype='blank' })
      maxrow = m_max(maxrow, i+1)
    end

    -- Sorted application keeps row math simple
    table.sort(headers); table.sort(matches); table.sort(lines); table.sort(texts)

    local textindexCounter = 0
    for _,pos in ipairs(headers) do populateHeader(pos) end
    for n,_ in ipairs(matches) do populateTeamGroup(n) end
    for _,pos in ipairs(lines)   do populateLine(pos) end
    for k,_ in ipairs(texts)     do textindexCounter = textindexCounter + 1; populateText(texts[k], #matches + textindexCounter) end
  end

  -- finalize row count
  if cfg.r == nil then
    local mr = 1
    for j = cfg.minc, cfg.c do
      for i, _ in pairs(grid[j]) do if i > mr then mr = i end end
    end
    cfg.r = mr
  end

  return grid, derived
end

-- =====================
-- Phase 3+. Placeholders to port next
-- =====================
local function computeAltIndices(grid, cfg, fargs, pargs, bargs)
  -- TODO: port getAltIndices() but return: headercount[j], and set .altindex/.headerindex on cells (pure-ISH)
  local headercount = {}
  for j = cfg.minc, cfg.c do
    headercount[j] = 0
    local teamindex, textindex, groupindex = 1, 1, 1
    local row = grid[j]
    if row and row[1] == nil then headercount[j] = headercount[j] + 1 end
    for i=1, cfg.r do
      local e = row and row[i]
      if e then
        if e.ctype == 'header' then e.altindex = headercount[j]; teamindex, textindex = 1, 1; headercount[j] = headercount[j] + 1
        elseif e.ctype == 'team' then e.altindex = teamindex; teamindex = teamindex + 1
        elseif e.ctype == 'text' then e.altindex = textindex; textindex = textindex + 1
        elseif e.ctype == 'group' then e.altindex = groupindex; groupindex = groupindex + 1 end
        e.headerindex = headercount[j]
      end
    end
  end
  return headercount
end

local function computeHideAndByes(grid, cfg, bargs, headercount)
  -- Lua 5.1-safe (no goto). Port of getHide/getByes and a basic empty-round hide pass.
  local hide, byes = {}, {}
  for j = cfg.minc, cfg.c do
    hide[j], byes[j] = {}, {}
    for k=1,(headercount[j] or 0) do
      -- hide flags
      local h = bargs('RD'..j..toChar(k)..'-hide')
      hide[j][k] = (h == 'y' or h == 'yes') and true or false

      -- byes: global, RDj, RDja (last writer wins, matching original precedence)
      local v = false
      local globalByes = bargs('byes')
      if yesish(globalByes) then v = true
      elseif tonumber(globalByes) and j <= tonumber(globalByes) then v = true end
      local rd = bargs('RD'..j..'-byes'); if yesish(rd) then v = true elseif noish(rd) then v = false end
      local rda = bargs('RD'..j..toChar(k)..'-byes'); if yesish(rda) then v = true end
      byes[j][k] = v
    end
  end

  -- helper
  local function isBlankEntry(j,i)
    local col = grid[j]; local e = col and col[i]
    if not e then return true end
    if e.ctype == 'team' then return (e.team == nil or e.team == '') and (e.text == nil or e.text == '') end
    if e.ctype == 'text' then return (e.text == nil or e.text == '') end
    return false
  end

  -- hide empty-header rounds (no goto; nest conditions)
  for j = cfg.minc, cfg.c do
    for i=1, cfg.r do
      local e = grid[j] and grid[j][i]
      if e and e.ctype == 'header' then
        local hidx = e.headerindex
        if hidx then
          local row = i+1; local any=false
          while row <= cfg.r do
            local rce = grid[j][row]
            if rce and rce.ctype == 'header' then break end
            if rce and rce.ctype ~= 'blank' and not isBlankEntry(j,row) then any=true; break end
            row = row + 1
          end
          if not any then hide[j][hidx] = hide[j][hidx] or true end
        end
      end
    end
  end
  return hide, byes
end

-- Assign parameters (seed/team/score/header/text/group/line text)
local function assignParams(grid, cfg, fargs, pargs, bargs, derived)
  -- TODO: port paramNames() logic; for now, minimal pass to keep structure.
  return grid -- unchanged placeholder
end

-- Aggregates & bolding (pure)
local function computeAggregatesAndBold(grid, cfg, derived)
  -- TODO: port computeAggregate() and boldWinner() in a pure style.
end

-- Paths (pure data) and post-processing merges
local function computePaths(grid, cfg, fargs, pargs, bargs, derived)
  -- TODO: port getPaths() and connect logic but return pathCell/crossCell/skipPath/hascross; do not mutate grid here.
  derived.pathCell, derived.crossCell, derived.skipPath, derived.hascross = {}, {}, {}, {}
  return derived
end

local function updateMaxLegs(grid, cfg, derived)
  -- TODO: port updateMaxLegs() against current grid entries
  derived.rlegs, derived.maxlegs = derived.rlegs or {}, derived.maxlegs or {}
  for j = cfg.minc, cfg.c do
    derived.rlegs[j] = derived.rlegs[j] or 1
    derived.maxlegs[j] = derived.maxlegs[j] or derived.rlegs[j]
  end
end

-- =====================
-- Rendering (impure, view-only, no surprises)
-- =====================
local COLORS = {
  cell_bg_light = 'var(--background-color-neutral-subtle,#f8f9fa)',
  cell_bg_dark  = 'var(--background-color-neutral,#eaecf0)',
  text_color    = 'var(--color-base,#202122)',
  path_line_color = 'gray',
  cell_border   = 'var(--border-color-base,#a2a9b1)'
}

local function cellBorder(b) return (b[1]..'px '..b[2]..'px '..b[3]..'px '..b[4]..'px') end

local function renderTable(grid, cfg, derived)
  local frame = mw.getCurrentFrame()
  local div = mw_html_create('div'):css('overflow','auto')
  div:wikitext(frame:extensionTag('templatestyles','',{ src='Module:Build bracket/styles.css' }))
  if cfg.height ~= 0 then div:css('height', cfg.height) end

  local tbl = mw_html_create('table'):addClass('brk')
  if cfg.nowrap then tbl:addClass('brk-nw') end

  -- invisible header row for col widths (placeholder; widths will depend on derived.maxlegs)
  tbl:tag('tr'):css('visibility','collapse'):tag('td'):css('width','1px')

  -- TODO: set width cells using derived.maxlegs/aggregate/seeds just like original

  for i=1, cfg.r do
    local row = tbl:tag('tr')
    row:tag('td'):css('height','11px')
    for j=cfg.minc, cfg.c do
      local e = grid[j] and grid[j][i]
      local td = row:tag('td')
      if e then
        td:wikitext(e.ctype or ''):css('font-size','smaller'):css('color','#666')
      else
        td:wikitext('')
      end
      -- TODO: replace with real insertors once phases are fully ported
    end
  end

  div:wikitext(tostring(tbl))
  return tostring(div)
end

-- =====================
-- Deprecation cats (kept simple for now)
-- =====================
local function emitDeprecationCats()
  local title = mw.title.getCurrentTitle()
  if not title or title.namespace ~= 0 then return '' end
  return ''
end

-- =====================
-- MAIN
-- =====================
function p.main(frame)
  -- Styles once
  frame:extensionTag('templatestyles','',{ src='Module:Build bracket/styles.css' })

  -- Phase 0/1
  local fargs, pargs, bargs = makeArgReaders(frame)
  local cfg = parseConfig(fargs, pargs, bargs)

  -- Phase 2
  local grid, derived = buildInitialGrid(cfg, fargs, pargs, bargs)

  -- Phase 3+
  derived.headercount = computeAltIndices(grid, cfg, fargs, pargs, bargs)
  derived.hide, derived.byes = computeHideAndByes(grid, cfg, bargs, derived.headercount)
  grid = assignParams(grid, cfg, fargs, pargs, bargs, derived)
  computeAggregatesAndBold(grid, cfg, derived)
  computePaths(grid, cfg, fargs, pargs, bargs, derived)
  updateMaxLegs(grid, cfg, derived)

  -- Output
  local out = renderTable(grid, cfg, derived)
  return tostring(out) .. emitDeprecationCats()
end

return p