Module:Setlist

From Weezerpedia

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 />&#160;&#160;"' .. (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