Module:Setlist
| This module uses TemplateStyles: |
| This module depends on the following other modules: |
This module implements {{setlist}}. Please see the template page for documentation.
local cfg = mw.loadData('Module:Setlist/configuration')
--------------------------------------------------------------------------------
-- Song aliases
--------------------------------------------------------------------------------
local SONG_ALIASES = {
["The Greatest Man That Ever Lived (Variations on a Shaker Hymn)"] =
"The Greatest Man That Ever Lived",
}
local function normalizeSongName(name)
if not name or name == "" then return name end
name = mw.text.trim(name)
return SONG_ALIASES[name] or name
end
-- Songs that should not be split on "/" for categories
local NO_MASHUP_SPLIT = {
["Kids/Poker Face"] = true,
}
--------------------------------------------------------------------------------
-- Song class
--------------------------------------------------------------------------------
local Song = {}
Song.__index = Song
Song.fields = cfg.song_field_names
Song.cellMethods = {
number = 'makeNumberCell',
song = 'makeSongCell',
placeholder = 'makePlaceholderCell',
}
function Song.new(data)
local self = setmetatable({}, Song)
for k, v in pairs(data) do
self[k] = v
end
self.number = assert(tonumber(self.number), 'Song number must be numeric')
return self
end
function Song.makeSimpleCell(wikitext)
return mw.html.create('td')
:wikitext(wikitext or cfg.blank_cell)
end
function Song:makeNumberCell(ordered)
local display = ordered and '•' or string.format(cfg.number_terminated, self.number)
return mw.html.create('th')
:attr('id', string.format(cfg.song_id, self.number))
:attr('scope', 'row')
:wikitext(display)
end
function Song:makeSongCell()
-- Enhanced to support medley parent rows and medley child lines.
local songCell = mw.html.create('td')
-- If this song entry has medley children, display the Medley: header (no quotes)
if self.medley and type(self.medley) == 'table' and #self.medley > 0 then
-- Plain "Medley:" text, not wrapped in cfg.song_title (which would add quotes)
songCell:wikitext('Medley:')
else
local songText = self.song and string.format(cfg.song_title, self.song) or cfg.untitled
songCell:wikitext(songText)
if self.note then
songCell:wikitext(string.format(cfg.note, self.note))
end
end
-- If medley children exist, render them under the same cell (indented)
if self.medley and type(self.medley) == 'table' and #self.medley > 0 then
-- Ensure medley lines are in numeric order if they include an 'order' field
table.sort(self.medley, function(a, b)
local ao, bo = tonumber(a.order) or 0, tonumber(b.order) or 0
return ao < bo
end)
for _, m in ipairs(self.medley) do
-- Use a non-breaking space indent and a <br /> to put each medley entry on its own line.
local line = '<br />  "' .. (m.song or cfg.untitled) .. '"'
if m.note then
line = line .. string.format(cfg.note, m.note)
end
songCell:wikitext(line)
end
end
return songCell
end
function Song:makePlaceholderCell()
return mw.html.create('td')
:addClass('setlist-placeholder')
:wikitext(cfg.blank_cell)
end
function Song:exportRow(columns, ordered)
columns = columns or {}
local row = mw.html.create('tr')
for _, column in ipairs(columns) do
local method = Song.cellMethods[column]
if method and self[method] then
if column == 'number' then
row:node(self[method](self, ordered))
else
row:node(self[method](self))
end
end
end
return row
end
--------------------------------------------------------------------------------
-- Setlist class
--------------------------------------------------------------------------------
local Setlist = {}
Setlist.__index = Setlist
Setlist.fields = cfg.setlist_field_names
function Setlist.new(data)
local self = setmetatable({}, Setlist)
for field in pairs(Setlist.fields) do
self[field] = data[field]
end
-- Handle the 'ordered' parameter: only accept "no" (case-insensitive)
if type(data.ordered) == 'string' and data.ordered:lower() == 'no' then
self.ordered = true
else
self.ordered = false
end
self.songs = {}
self.encoreSongs = {}
self.encoreTwoSongs = {}
for _, songData in ipairs(data.songs or {}) do
local songObj = Song.new(songData)
if songData.intermission then
table.insert(self.songs, songObj)
elseif songData.encoretwo then
table.insert(self.encoreTwoSongs, songObj)
elseif songData.encore then
table.insert(self.encoreSongs, songObj)
else
table.insert(self.songs, songObj)
end
end
self.customHeaders = data.customHeaders or {}
return self
end
function Setlist:addEncoreSection(songs, label, tableRoot, columns, songColumnWidth)
if #songs == 0 then return end
local header = tableRoot:tag('tr'):addClass('setlist-subheader')
header:tag('th')
:addClass('setlist-number-header')
:attr('scope', 'col')
:wikitext(cfg.blank_cell)
header:tag('th')
:attr('scope', 'col')
:css('width', songColumnWidth)
:wikitext("''" .. label .. "''")
header:tag('th')
:addClass('setlist-placeholder-header')
:attr('scope', 'col')
:wikitext(cfg.placeholder)
for _, song in ipairs(songs) do
tableRoot:node(song:exportRow(columns, self.ordered))
end
end
function Setlist:__tostring()
local root = mw.html.create('div'):addClass('setlist')
local tableRoot = mw.html.create('table'):addClass('setlist')
if self.width then
tableRoot:css('width', self.width)
end
if self.headline then
tableRoot:tag('caption'):wikitext(self.headline)
end
local headerRow = tableRoot:tag('tr')
headerRow:tag('th')
:addClass('setlist-number-header')
:attr('scope', 'col')
:wikitext(cfg.number_abbr)
local columns = {'number', 'song', 'placeholder'}
local songColumnWidth = self.song_width or '95%'
headerRow:tag('th')
:attr('scope', 'col')
:css('width', songColumnWidth)
:wikitext(cfg.song)
headerRow:tag('th')
:addClass('setlist-placeholder-header')
:attr('scope', 'col')
:wikitext(cfg.placeholder)
for _, song in ipairs(self.songs) do
local sectionTitle = self.customHeaders[song.number]
if sectionTitle then
local sectionRow = tableRoot:tag('tr'):addClass('setlist-subheader')
sectionRow:tag('th')
:addClass('setlist-number-header')
:attr('scope', 'col')
:wikitext(cfg.blank_cell)
sectionRow:tag('th')
:attr('scope', 'col')
:css('width', songColumnWidth)
:wikitext("''" .. sectionTitle .. "''")
sectionRow:tag('th')
:addClass('setlist-placeholder-header')
:attr('scope', 'col')
:wikitext(cfg.placeholder)
end
if song.intermission then
local row = tableRoot:tag('tr'):addClass('setlist-subheader'):addClass('setlist-intermission')
row:tag('th')
:addClass('setlist-number-header')
:attr('scope', 'col')
:wikitext(cfg.blank_cell)
row:tag('th')
:attr('scope', 'col')
:css('width', songColumnWidth)
:wikitext("''Intermission''" .. (song.intermission_text and (" <small>(" .. song.intermission_text .. ")</small>") or ""))
row:tag('th')
:addClass('setlist-placeholder-header')
:attr('scope', 'col')
:wikitext(cfg.placeholder)
else
tableRoot:node(song:exportRow(columns, self.ordered))
end
end
local encoreLabel = self.encore_header or cfg.encore_header or 'Encore'
local encoreTwoLabel = self.encore_two_header or (encoreLabel .. ' 2')
self:addEncoreSection(self.encoreSongs, encoreLabel, tableRoot, columns, songColumnWidth)
self:addEncoreSection(self.encoreTwoSongs, encoreTwoLabel, tableRoot, columns, songColumnWidth)
if self.source then
local sourceRow = tableRoot:tag('tr'):addClass('setlist-source')
sourceRow:tag('th')
:attr('colspan', 2)
:attr('scope', 'row')
:tag('span')
:wikitext(cfg.source or 'Source:')
sourceRow:tag('td'):wikitext(self.source)
end
root:node(tableRoot)
-- Category generation with mash-up support
local function extractSongName(wikitext)
if not wikitext or wikitext == '' then return nil end
local pageName = wikitext:match('%[%[([^|%]]+)') -- piped link
if pageName then return mw.text.trim(pageName) end
pageName = wikitext:match('%[%[([^%]]+)%]%]') -- simple link
if pageName then return mw.text.trim(pageName) end
return mw.text.trim(wikitext)
end
-- Strict extraction: artist = text before event type; year = first 4-digit sequence anywhere
local function extractArtistAndYear()
local title = mw.title.getCurrentTitle().text or ''
local events = { "concert", "web appearance", "radio appearance", "TV appearance" }
local artist
for _, event in ipairs(events) do
artist = title:match('^(.-)%s+' .. event)
if artist and artist ~= '' then
artist = mw.text.trim(artist)
break
end
end
if not artist or artist == '' then return nil, nil end
local year = title:match('(%d%d%d%d)')
if not year then return nil, nil end
return artist, year
end
local artistName, yearSortKey = extractArtistAndYear()
local categories = {}
local function addCategoryForMashup(songText)
if not songText or songText == '' then return end
-- Extract clean name first
local cleanName = songText:match('%[%[([^|%]]+)') or songText:match('%[%[([^%]]+)%]%]') or songText
cleanName = mw.text.trim(cleanName)
cleanName = cleanName:gsub('^"', ''):gsub('"$', '') -- remove quotes
cleanName = normalizeSongName(cleanName)
-- If it’s in the NO_MASHUP_SPLIT table, create only one category
if NO_MASHUP_SPLIT[cleanName] then
if artistName and yearSortKey then
local cat = string.format(
'[[Category:%s performances featuring "%s"|%s]]',
artistName, cleanName, yearSortKey
)
categories[cat] = true
end
return
end
-- Otherwise, split by "/" and create one category for each part
for part in songText:gmatch('[^/]+') do
local partName = part:match('%[%[([^|%]]+)') or part:match('%[%[([^%]]+)%]%]') or part
partName = mw.text.trim(partName)
partName = partName:gsub('^"', ''):gsub('"$', '')
partName = normalizeSongName(partName)
if partName and partName ~= '' and partName ~= cfg.untitled then
if artistName and yearSortKey then
local cat = string.format(
'[[Category:%s performances featuring "%s"|%s]]',
artistName, partName, yearSortKey
)
categories[cat] = true
end
end
end
end
-- Only attempt to create categories when both artist and year are present (strict mode)
if artistName and yearSortKey then
for _, song in ipairs(self.songs) do
addCategoryForMashup(song.song)
if song.medley and type(song.medley) == 'table' then
for _, m in ipairs(song.medley) do
addCategoryForMashup(m.song)
end
end
end
for _, song in ipairs(self.encoreSongs) do
addCategoryForMashup(song.song)
if song.medley and type(song.medley) == 'table' then
for _, m in ipairs(song.medley) do
addCategoryForMashup(m.song)
end
end
end
for _, song in ipairs(self.encoreTwoSongs) do
addCategoryForMashup(song.song)
if song.medley and type(song.medley) == 'table' then
for _, m in ipairs(song.medley) do
addCategoryForMashup(m.song)
end
end
end
end
local categoryWikitext = ''
for cat in pairs(categories) do
categoryWikitext = categoryWikitext .. cat
end
return mw.getCurrentFrame():extensionTag{
name = 'templatestyles',
args = { src = cfg.style_src or 'Module:Setlist/styles.css' }
} .. tostring(root) .. categoryWikitext
end
--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------
local p = {}
function p._main(args)
local data, songs, customHeaders = {}, {}, {}
local maxNum = 0
for k, v in pairs(args) do
if type(k) == 'string' then
local num = tonumber(k:match('(%d+)$'))
if num and v and v ~= '' then
if num > maxNum then maxNum = num end
songs[num] = songs[num] or {}
if k:match('^song%d+$') then
songs[num].song = v
elseif k:match('^note%d+$') then
songs[num].note = v
songs[num].encore = k:match('encore') ~= nil
elseif k:match('^encore%d+$') then
songs[num].song = v
songs[num].encore = true
elseif k:match('^encore_note%d+$') then
songs[num].note = v
songs[num].encore = true
elseif k:match('^encoretwo%d+$') then
songs[num].song = v
songs[num].encoretwo = true
elseif k:match('^encoretwo_note%d+$') then
songs[num].note = v
songs[num].encoretwo = true
elseif k:match('^section%d+$') then
customHeaders[num] = v
elseif k:match('^intermission%d+$') then
local key = num - 0.5
songs[key] = {intermission = true, intermission_text = v, number = key}
-- ===== UPDATED MEDLEY HANDLING =====
elseif k:match('^medley%d+[%p%d]*$') then
local base = tonumber(k:match('medley(%d+)'))
local sub = tonumber(k:match('medley%d+%.(%d+)'))
songs[base] = songs[base] or { medley = {} }
-- find existing entry
local found
for _, m in ipairs(songs[base].medley) do
if m.order == sub then
m.song = v
found = true
break
end
end
-- if not found, create a placeholder entry
if not found then
table.insert(songs[base].medley, {
order = sub,
song = v,
})
end
elseif k:match('^medley_note%d+[%p%d]*$') then
local base = tonumber(k:match('medley_note(%d+)'))
local sub = tonumber(k:match('medley_note%d+%.(%d+)'))
songs[base] = songs[base] or { medley = {} }
-- find existing entry
local found
for _, m in ipairs(songs[base].medley) do
if m.order == sub then
m.note = v
found = true
break
end
end
-- if not found, create placeholder (important!)
if not found then
table.insert(songs[base].medley, {
order = sub,
note = v,
})
end
else
data[k] = v
end
else
data[k] = v
end
end
end
data.songs = (function(t)
local ret = {}
for num, songData in pairs(t) do
songData.number = num
table.insert(ret, songData)
end
table.sort(ret, function(a, b) return a.number < b.number end)
return ret
end)(songs)
data.customHeaders = customHeaders
return tostring(Setlist.new(data))
end
function p.main(frame)
local args = require('Module:Arguments').getArgs(frame, {
wrappers = 'Template:Setlist 2'
})
return p._main(args)
end
return p