# Module:Track listing

local yesno = require('Module:Yesno')  
local checkType = require('libraryUtil').checkType  
local cfg = mw.loadData('Module:Track listing/configuration')  

---
-- Helper functions  
---

-- Add a mixin to a class.  
local function addMixin(class, mixin)  
for k, v in pairs(mixin) do  
if k ~= 'init' then  
class[k] = v  
end  
end  
end  

---
-- Validation mixin  
---

local Validation = {}  

function Validation.init(self)  
self.warnings = {}  
self.categories = {}  
end  

function Validation:addWarning(msg, category)  
table.insert(self.warnings, msg)  
table.insert(self.categories, category)  
end  

function Validation:addCategory(category)  
table.insert(self.categories, category)  
end  

function Validation:getWarnings  
return self.warnings  
end  

function Validation:getCategories  
return self.categories  
end  

-- Validate a track length. If a track length is invalid, a warning is added.  
-- A type error is raised if the length is not of type string or nil.  
function Validation:validateLength(length)  
checkType('validateLength', 1, length, 'string', true)  
if length == nil then  
-- Do nothing if no length specified  
return nil  
end  

local hours, minutes, seconds  

-- Try to match times like "1:23:45".  
hours, minutes, seconds = length:match('^(%d+):(%d%d):(%d%d)$')  
if hours and hours:sub(1, 1) == '0' then  
-- Disallow times like "0:12:34"  
self:addWarning(  
string.format(cfg.leading_0_in_hours, mw.text.nowiki(length)),  
cfg.input_error_category  
return nil  
end  

if not seconds then  
-- The previous attempt didn't match. Try to match times like "1:23".  
minutes, seconds = length:match('^(%d?%d):(%d%d)$')  
if minutes and minutes:find('^0%d$') then  
-- Special case to disallow lengths like "01:23". This check has to  
-- be here so that lengths like "1:01:23" are still allowed.  
self:addWarning(  
string.format(cfg.leading_0_in_minutes, mw.text.nowiki(length)),  
cfg.input_error_category  
return nil  
end  
end  

-- Add a warning and return if we did not find a match.  
if not seconds then  
self:addWarning(  
string.format(cfg.not_a_time, mw.text.nowiki(length)),  
cfg.input_error_category  
return nil  
end  

-- Check that the minutes are less than 60 if we have an hours field.  
if hours and tonumber(minutes) >= 60 then  
self:addWarning(  
string.format(cfg.more_than_60_minutes, mw.text.nowiki(length)),  
cfg.input_error_category  
return nil  
end  

-- Check that the seconds are less than 60  
if tonumber(seconds) >= 60 then  
self:addWarning(  
string.format(cfg.more_than_60_seconds, mw.text.nowiki(length)),  
cfg.input_error_category  
end  

return nil  
end  

---
-- Track class  
---

local Track = {}  
Track.__index = Track  
addMixin(Track, Validation)  

Track.fields = cfg.track_field_names  

Track.cellMethods = {  
number = 'makeNumberCell',  
title = 'makeTitleCell',  
writer = 'makeWriterCell',  
lyrics = 'makeLyricsCell',  
music = 'makeMusicCell',  
extra = 'makeExtraCell',  
length = 'makeLengthCell',  
}  

function Track.new(data)  
local self = setmetatable({}, Track)  
Validation.init(self)  
for field in pairs(Track.fields) do  
self[field] = data[field]  
end  
self.number = assert(tonumber(self.number))  
self:validateLength(self.length)  
return self  
end  

function Track:getLyricsCredit  
return self.lyrics  
end  

function Track:getMusicCredit  
return self.music  
end  

function Track:getWriterCredit  
return self.writer  
end  

function Track:getExtraField  
return self.extra  
end  

-- Note: called with single dot syntax  
function Track.makeSimpleCell(wikitext)  
return mw.html.create('td')  
:wikitext(wikitext or cfg.blank_cell)  
end  

function Track:makeNumberCell  
return mw.html.create('th')  
:attr('id', string.format(cfg.track_id, self.number))  
:attr('scope', 'row')  
:wikitext(string.format(cfg.number_terminated, self.number))  
end  

function Track:makeTitleCell  
local titleCell = mw.html.create('td')  
titleCell:wikitext(  
self.title and string.format(cfg.track_title, self.title) or cfg.untitled  
if self.note then  
titleCell:wikitext(string.format(cfg.note, self.note))  
end  
return titleCell  
end  

function Track:makeWriterCell  
return Track.makeSimpleCell(self.writer)  
end  

function Track:makeLyricsCell  
return Track.makeSimpleCell(self.lyrics)  
end  

function Track:makeMusicCell  
return Track.makeSimpleCell(self.music)  
end  

function Track:makeExtraCell  
return Track.makeSimpleCell(self.extra)  
end  

function Track:makeLengthCell  
return mw.html.create('td')  
:addClass('tracklist-length')  
:wikitext(self.length or cfg.blank_cell)  
end  

function Track:exportRow(columns)  
local columns = columns or {}  
local row = mw.html.create('tr')  
for i, column in ipairs(columns) do  
local method = Track.cellMethods[column]  
if method then  
row:node(self[method](self))  
end  
end  
return row  
end  

---
-- TrackListing class  
---

local TrackListing = {}  
TrackListing.__index = TrackListing  
addMixin(TrackListing, Validation)  
TrackListing.fields = cfg.track_listing_field_names  
TrackListing.deprecatedFields = cfg.deprecated_track_listing_field_names  

function TrackListing.new(data)  
local self = setmetatable({}, TrackListing)  
Validation.init(self)  

-- Check for deprecated arguments  
for deprecatedField in pairs(TrackListing.deprecatedFields) do  
if data[deprecatedField] then  
self:addCategory(cfg.deprecated_parameter_category)  
break  
end  
end  

-- Validate total length  
if data.total_length then  
self:validateLength(data.total_length)  
end  

-- Add properties  
for field in pairs(TrackListing.fields) do  
self[field] = data[field]  
end  

-- Evaluate boolean properties  
self.showCategories = yesno(self.category) ~= false  
self.category = nil  

-- Make track objects  
self.tracks = {}  
for i, trackData in ipairs(data.tracks or {}) do  
table.insert(self.tracks, Track.new(trackData))  
end  

-- Find which of the optional columns we have.  
-- We could just check every column for every track object, but that would  
-- be no fun^H^H^H^H^H^H inefficient, so we use four different strategies  
-- to try and check only as many columns and track objects as necessary.  
do  
local optionalColumns = {}  
local columnMethods = {  
lyrics = 'getLyricsCredit',  
music = 'getMusicCredit',  
writer = 'getWriterCredit',  
extra = 'getExtraField',  
}  
local doneWriterCheck = false  
for i, trackObj in ipairs(self.tracks) do  
for column, method in pairs(columnMethods) do  
if trackObj[method](trackObj) then  
optionalColumns[column] = true  
columnMethods[column] = nil  
end  
end  
if not doneWriterCheck and optionalColumns.writer then  
doneWriterCheck = true  
optionalColumns.lyrics = nil  
optionalColumns.music = nil  
columnMethods.lyrics = nil  
columnMethods.music = nil  
end  
if not next(columnMethods) then  
break  
end  
end  
self.optionalColumns = optionalColumns  
end  

return self  
end  

function TrackListing:makeIntro  
if self.all_writing then  
return string.format(cfg.tracks_written, self.all_writing)  
elseif self.all_lyrics and self.all_music then  
return mw.message.newRawMessage(  
cfg.lyrics_written_music_composed,  
self.all_lyrics,  
self.all_music  
):plain  
elseif self.all_lyrics then  
return string.format(cfg.lyrics_written, self.all_lyrics)  
elseif self.all_music then  
return string.format(cfg.music_composed, self.all_music)  
else  
return nil  
end  
end  

function TrackListing:renderTrackingCategories  
if not self.showCategories or mw.title.getCurrentTitle.namespace ~= 0 then  
return ''  
end  

local ret = ''  

local function addCategory(cat)  
ret = ret .. string.format('', cat)  
end  

for i, category in ipairs(self:getCategories) do  
addCategory(category)  
end  

for i, track in ipairs(self.tracks) do  
for j, category in ipairs(track:getCategories) do  
addCategory(category)  
end  
end  

return ret  
end  

function TrackListing:renderWarnings  
if not cfg.show_warnings then  
return ''  
end  

local ret = {}  

local function addWarning(msg)  
table.insert(ret, string.format(cfg.track_listing_error, msg))  
end  

for i, warning in ipairs(self:getWarnings) do  
addWarning(warning)  
end  

for i, track in ipairs(self.tracks) do  
for j, warning in ipairs(track:getWarnings) do  
addWarning(warning)  
end  
end  

return table.concat(ret, '  
')  
end  

function TrackListing:__tostring  
-- Root of the output  
local root = mw.html.create('div')  
:addClass('track-listing')  

local intro = self:makeIntro  
if intro then  
root:tag('p')  
:wikitext(intro)  
:done  
end  

-- Start of track listing table  
local tableRoot = mw.html.create('table')  
tableRoot  
:addClass('tracklist')  

-- Overall table width  
if self.width then  
tableRoot  
:css('width', self.width)  
end  

-- Header row  
if self.headline then  
tableRoot:tag('caption')  
:wikitext(self.headline or cfg.track_listing)  
end  

-- Headers  
local headerRow = tableRoot:tag('tr')  

---- Track number  
headerRow  
:tag('th')  
:addClass('tracklist-number-header')  
:attr('scope', 'col')  
:tag('abbr')  
:attr('title', cfg.number)  
:wikitext(cfg.number_abbr)  

-- Find columns to output  
local columns = {'number', 'title'}  
if self.optionalColumns.writer then  
columns[#columns + 1] = 'writer'  
else  
if self.optionalColumns.lyrics then  
columns[#columns + 1] = 'lyrics'  
end  
if self.optionalColumns.music then  
columns[#columns + 1] = 'music'  
end  
end  
if self.optionalColumns.extra then  
columns[#columns + 1] = 'extra'  
end  
columns[#columns + 1] = 'length'  

-- Find column width  
local nColumns = #columns  
local nOptionalColumns = nColumns - 3  

local titleColumnWidth = 100  
if nColumns >= 5 then  
titleColumnWidth = 40  
elseif nColumns >= 4 then  
titleColumnWidth = 60  
end  

local optionalColumnWidth = ((100 - titleColumnWidth) / nOptionalColumns) .. '%'  
titleColumnWidth = titleColumnWidth .. '%'  

---- Title column  
headerRow:tag('th')  
:attr('scope', 'col')  
:css('width', self.title_width or titleColumnWidth)  
:wikitext(cfg.title)  

---- Optional headers: writer, lyrics, music, and extra  
local function addOptionalHeader(field, headerText, width)  
if self.optionalColumns[field] then  
headerRow:tag('th')  
:attr('scope', 'col')  
:css('width', width or optionalColumnWidth)  
:wikitext(headerText)  
end  
end  
addOptionalHeader('writer', cfg.writer, self.writing_width)  
addOptionalHeader('lyrics', cfg.lyrics, self.lyrics_width)  
addOptionalHeader('music', cfg.music, self.music_width)  
addOptionalHeader(  
'extra',  
self.extra_column or cfg.extra,  
self.extra_width  

---- Track length  
headerRow:tag('th')  
:addClass('tracklist-length-header')  
:attr('scope', 'col')  
:wikitext(cfg.length)  

-- Tracks  
for i, track in ipairs(self.tracks) do  
tableRoot:node(track:exportRow(columns))  
end  

-- Total length  
if self.total_length then  
tableRoot  
:tag('tr')  
:addClass('tracklist-total-length')  
:tag('th')  
:attr('colspan', nColumns - 1)  
:attr('scope', 'row')  
:tag('span')  
:wikitext(cfg.total_length)  
:done  
:done  
:tag('td')  
:wikitext(self.total_length)  
end  

root:node(tableRoot)  
-- Warnings and tracking categories  
root:wikitext(self:renderWarnings)  
root:wikitext(self:renderTrackingCategories)  

return mw.getCurrentFrame:extensionTag{  
name = 'templatestyles', args = { src = 'Module:Track listing/styles.css' }  
} .. tostring(root)  
end  

---
-- Exports  
---

local p = {}  

function p._main(args)  
-- Process numerical args so that we can iterate through them.  
local data, tracks = {}, {}  
for k, v in pairs(args) do  
if type(k) == 'string' then  
local prefix, num = k:match('^(%D.-)(%d+)$')  
if prefix and Track.fields[prefix] and (num == '0' or num:sub(1, 1) ~= '0') then  
-- Allow numbers like 0, 1, 2 ..., but not 00, 01, 02...,  
-- 000, 001, 002... etc.  
num = tonumber(num)  
tracks[num] = tracks[num] or {}  
tracks[num][prefix] = v  
else  
data[k] = v  
end  
end  
end  
data.tracks = (function (t)  
-- Compress sparse array  
local ret = {}  
for num, trackData in pairs(t) do  
trackData.number = num  
table.insert(ret, trackData)  
end  
table.sort(ret, function (t1, t2)  
return t1.number < t2.number  
end)  
return ret  
end)(tracks)  

return tostring(TrackListing.new(data))  
end  

function p.main  
local args = require('Module:Arguments').getArgs(, {  
wrappers = 'Template:Track listing'  
})  
return p._main(args)  
end  

return p