Module:Track listing: Difference between revisions
Appearance
	
	
Content deleted Content added
Johnrdorazio (talk | contribs) m 1 revision imported  | 
				Johnrdorazio (talk | contribs) m 1 revision imported  | 
				||
| (One intermediate revision by one other user not shown) | |||
| Line 1: | Line 1: | ||
-- This module implements [[Template:Track listing]]  | 
  |||
local yesno = require('Module:Yesno')  | 
  local yesno = require('Module:Yesno')  | 
||
local checkType = require('libraryUtil').checkType  | 
  local checkType = require('libraryUtil').checkType  | 
||
local cfg = mw.loadData('Module:Track listing/configuration')  | 
|||
local SHOW_WARNINGS = false  | 
  |||
local INPUT_ERROR_CATEGORY = 'Track listings with input errors'  | 
  |||
local COLLAPSED_PARAMETER_CATEGORY = 'Track listings that use the collapsed parameter '  | 
  |||
--------------------------------------------------------------------------------  | 
  --------------------------------------------------------------------------------  | 
||
| Line 64: | Line 59: | ||
	if hours and hours:sub(1, 1) == '0' then  | 
  	if hours and hours:sub(1, 1) == '0' then  | 
||
		-- Disallow times like "0:12:34"  | 
  		-- Disallow times like "0:12:34"  | 
||
		self:addWarning  | 
  		self:addWarning(  | 
||
			string.format(cfg.leading_0_in_hours, mw.text.nowiki(length)),  | 
|||
			"Invalid time '%s' (times in format 'h:mm:ss' cannot start with zero)",  | 
  |||
			cfg.input_error_category  | 
|||
			mw.text.nowiki(length)  | 
  |||
		)  | 
|||
		), INPUT_ERROR_CATEGORY)  | 
  |||
		return nil  | 
  		return nil  | 
||
	end  | 
  	end  | 
||
| Line 77: | Line 72: | ||
			-- Special case to disallow lengths like "01:23". This check has to  | 
  			-- Special case to disallow lengths like "01:23". This check has to  | 
||
			-- be here so that lengths like "1:01:23" are still allowed.  | 
  			-- be here so that lengths like "1:01:23" are still allowed.  | 
||
			self:addWarning  | 
  			self:addWarning(  | 
||
				string.format(cfg.leading_0_in_minutes, mw.text.nowiki(length)),  | 
|||
				"Invalid time '%s' (times in format 'mm:ss' cannot start with zero)",  | 
  |||
				cfg.input_error_category  | 
|||
				mw.text.nowiki(length)  | 
  |||
			)  | 
|||
			), INPUT_ERROR_CATEGORY)  | 
  |||
			return nil  | 
  			return nil  | 
||
		end  | 
  		end  | 
||
| Line 87: | Line 82: | ||
	-- Add a warning and return if we did not find a match.  | 
  	-- Add a warning and return if we did not find a match.  | 
||
	if not seconds then  | 
  	if not seconds then  | 
||
		self:addWarning  | 
  		self:addWarning(  | 
||
			string.format(cfg.not_a_time, mw.text.nowiki(length)),  | 
|||
			"Invalid time '%s' (times must be in a format of 'm:ss', 'mm:ss' or 'h:mm:ss')",  | 
  |||
			cfg.input_error_category  | 
|||
			mw.text.nowiki(length)  | 
  |||
		)  | 
|||
		), INPUT_ERROR_CATEGORY)  | 
  |||
		return nil  | 
  		return nil  | 
||
	end  | 
  	end  | 
||
| Line 96: | Line 91: | ||
	-- Check that the minutes are less than 60 if we have an hours field.  | 
  	-- Check that the minutes are less than 60 if we have an hours field.  | 
||
	if hours and tonumber(minutes) >= 60 then  | 
  	if hours and tonumber(minutes) >= 60 then  | 
||
		self:addWarning  | 
  		self:addWarning(  | 
||
			string.format(cfg.more_than_60_minutes, mw.text.nowiki(length)),  | 
|||
			"Invalid track length '%s' (if hours are specified, the number of minutes must be less than 60)",  | 
  |||
			cfg.input_error_category  | 
|||
			mw.text.nowiki(length)  | 
  |||
		)  | 
|||
		), INPUT_ERROR_CATEGORY)  | 
  |||
		return nil  | 
  		return nil  | 
||
	end  | 
  	end  | 
||
| Line 105: | Line 100: | ||
	-- Check that the seconds are less than 60  | 
  	-- Check that the seconds are less than 60  | 
||
	if tonumber(seconds) >= 60 then  | 
  	if tonumber(seconds) >= 60 then  | 
||
		self:addWarning  | 
  		self:addWarning(  | 
||
			string.format(cfg.more_than_60_seconds, mw.text.nowiki(length)),  | 
|||
			"Invalid track length '%s' (number of seconds must be less than 60)",  | 
  |||
			cfg.input_error_category  | 
|||
			mw.text.nowiki(length)  | 
  |||
		)  | 
|||
		), INPUT_ERROR_CATEGORY)  | 
  |||
	end  | 
  	end  | 
||
| Line 122: | Line 117: | ||
addMixin(Track, Validation)  | 
  addMixin(Track, Validation)  | 
||
Track.fields =   | 
  Track.fields = cfg.track_field_names  | 
||
	number = true,  | 
  |||
	title = true,  | 
  |||
	note = true,  | 
  |||
	length = true,  | 
  |||
	lyrics = true,  | 
  |||
	music = true,  | 
  |||
	writer = true,  | 
  |||
	extra = true,  | 
  |||
}  | 
  |||
Track.cellMethods = {  | 
  Track.cellMethods = {  | 
||
| Line 173: | Line 159: | ||
function Track.makeSimpleCell(wikitext)  | 
  function Track.makeSimpleCell(wikitext)  | 
||
	return mw.html.create('td')  | 
  	return mw.html.create('td')  | 
||
		:wikitext(wikitext or cfg.blank_cell)  | 
|||
		:css('vertical-align', 'top')  | 
  |||
		:wikitext(wikitext or ' ')  | 
  |||
end  | 
  end  | 
||
function Track:makeNumberCell()  | 
  function Track:makeNumberCell()  | 
||
	return mw.html.create('  | 
  	return mw.html.create('th')  | 
||
		:attr('id', string.format(cfg.track_id, self.number))  | 
|||
		:css('padding-right', '10px')  | 
  |||
		:  | 
  		:attr('scope', 'row')  | 
||
		:wikitext(string.format(cfg.number_terminated, self.number))  | 
|||
		:css('vertical-align', 'top')  | 
  |||
		:wikitext(self.number .. '.')  | 
  |||
end  | 
  end  | 
||
function Track:makeTitleCell()  | 
  function Track:makeTitleCell()  | 
||
	local titleCell = mw.html.create('td')  | 
  	local titleCell = mw.html.create('td')  | 
||
	titleCell  | 
  	titleCell:wikitext(  | 
||
		self.title and string.format(cfg.track_title, self.title) or cfg.untitled  | 
|||
		:css('vertical-align', 'top')  | 
  |||
	)  | 
|||
		:wikitext(self.title and string.format('"%s"', self.title) or 'Untitled')  | 
  |||
	if self.note then  | 
  	if self.note then  | 
||
		titleCell:wikitext(string.format(cfg.note, self.note))  | 
|||
		titleCell  | 
  |||
			:wikitext(' ')  | 
  |||
			:tag('span')  | 
  |||
				:wikitext(string.format('(%s)', self.note))  | 
  |||
	end  | 
  	end  | 
||
	return titleCell  | 
  	return titleCell  | 
||
| Line 217: | Line 198: | ||
function Track:makeLengthCell()  | 
  function Track:makeLengthCell()  | 
||
	return mw.html.create('td')  | 
  	return mw.html.create('td')  | 
||
		:  | 
  		:addClass('tracklist-length')  | 
||
		:wikitext(self.length or cfg.blank_cell)  | 
|||
		:css('text-align', 'right')  | 
  |||
		:css('vertical-align', 'top')  | 
  |||
		:wikitext(self.length or ' ')  | 
  |||
end  | 
  end  | 
||
function Track:exportRow(  | 
  function Track:exportRow(columns)  | 
||
	local columns = columns or {}  | 
|||
	local columns = options.columns or {}  | 
  |||
	local row = mw.html.create('tr')  | 
  	local row = mw.html.create('tr')  | 
||
	row:css('background-color', options.color or '#fff')  | 
  |||
	for i, column in ipairs(columns) do  | 
  	for i, column in ipairs(columns) do  | 
||
		local method = Track.cellMethods[column]  | 
  		local method = Track.cellMethods[column]  | 
||
| Line 244: | Line 221: | ||
TrackListing.__index = TrackListing  | 
  TrackListing.__index = TrackListing  | 
||
addMixin(TrackListing, Validation)  | 
  addMixin(TrackListing, Validation)  | 
||
TrackListing.fields = cfg.track_listing_field_names  | 
|||
TrackListing.  | 
  TrackListing.deprecatedFields = cfg.deprecated_track_listing_field_names  | 
||
	headline = true,  | 
  |||
	all_writing = true,  | 
  |||
	all_lyrics = true,  | 
  |||
	all_music = true,  | 
  |||
	extra_column = true,  | 
  |||
	total_length = true,  | 
  |||
	title_width = true,  | 
  |||
	writing_width = true,  | 
  |||
	lyrics_width = true,  | 
  |||
	music_width = true,  | 
  |||
	extra_width = true,  | 
  |||
	category = true,  | 
  |||
}  | 
  |||
TrackListing.deprecatedFields = {  | 
  |||
	writing_credits = true,  | 
  |||
	lyrics_credits = true,  | 
  |||
	music_credits = true,  | 
  |||
}  | 
  |||
function TrackListing.new(data)  | 
  function TrackListing.new(data)  | 
||
| Line 273: | Line 231: | ||
	for deprecatedField in pairs(TrackListing.deprecatedFields) do  | 
  	for deprecatedField in pairs(TrackListing.deprecatedFields) do  | 
||
		if data[deprecatedField] then  | 
  		if data[deprecatedField] then  | 
||
			self:addCategory(  | 
  			self:addCategory(cfg.deprecated_parameter_category)  | 
||
			break  | 
  			break  | 
||
		end  | 
  		end  | 
||
| Line 337: | Line 295: | ||
function TrackListing:makeIntro()  | 
  function TrackListing:makeIntro()  | 
||
	if self.all_writing then  | 
  	if self.all_writing then  | 
||
		return string.format(  | 
  		return string.format(cfg.tracks_written, self.all_writing)  | 
||
			'All tracks are written by %s.',  | 
  |||
			self.all_writing  | 
  |||
		)  | 
  |||
	elseif self.all_lyrics and self.all_music then  | 
  	elseif self.all_lyrics and self.all_music then  | 
||
		return   | 
  		return mw.message.newRawMessage(  | 
||
			cfg.lyrics_written_music_composed,  | 
|||
			'All lyrics are written by %s; all music is composed by %s.',  | 
  |||
			self.all_lyrics,  | 
  			self.all_lyrics,  | 
||
			self.all_music  | 
  			self.all_music  | 
||
		)  | 
  		):plain()  | 
||
	elseif self.all_lyrics then  | 
  	elseif self.all_lyrics then  | 
||
		return string.format(  | 
  		return string.format(cfg.lyrics_written, self.all_lyrics)  | 
||
			'All lyrics are written by %s.',  | 
  |||
			self.all_lyrics  | 
  |||
		)  | 
  |||
	elseif self.all_music then  | 
  	elseif self.all_music then  | 
||
		return string.format(  | 
  		return string.format(cfg.music_composed, self.all_music)  | 
||
			'All music is composed by %s.',  | 
  |||
			self.all_music  | 
  |||
		)  | 
  |||
	else  | 
  	else  | 
||
		return   | 
  		return nil  | 
||
	end  | 
  	end  | 
||
end  | 
  end  | 
||
| Line 387: | Line 336: | ||
function TrackListing:renderWarnings()  | 
  function TrackListing:renderWarnings()  | 
||
	if not   | 
  	if not cfg.show_warnings then  | 
||
		return ''  | 
  		return ''  | 
||
	end  | 
  	end  | 
||
| Line 394: | Line 343: | ||
	local function addWarning(msg)  | 
  	local function addWarning(msg)  | 
||
		table.insert(ret, string.format(  | 
  		table.insert(ret, string.format(cfg.track_listing_error, msg))  | 
||
			'<strong class="error">Track listing error: %s</strong>',  | 
  |||
			msg  | 
  |||
		))  | 
  |||
	end  | 
  	end  | 
||
| Line 414: | Line 360: | ||
function TrackListing:__tostring()  | 
  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')  | 
|||
	-- 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  | 
  	-- Find columns to output  | 
||
	local columns = {'number', 'title'}  | 
  	local columns = {'number', 'title'}  | 
||
| Line 430: | Line 410: | ||
	end  | 
  	end  | 
||
	columns[#columns + 1] = 'length'  | 
  	columns[#columns + 1] = 'length'  | 
||
	-- Find  | 
  	-- Find column width  | 
||
	local nColumns = #columns  | 
  	local nColumns = #columns  | 
||
	local nOptionalColumns = nColumns - 3  | 
  	local nOptionalColumns = nColumns - 3  | 
||
	local titleColumnWidth  | 
  |||
	local titleColumnWidth = 100  | 
|||
	if nColumns >= 5 then  | 
  	if nColumns >= 5 then  | 
||
		titleColumnWidth = 40  | 
  		titleColumnWidth = 40  | 
||
	elseif nColumns >= 4 then  | 
  	elseif nColumns >= 4 then  | 
||
		titleColumnWidth = 60  | 
  		titleColumnWidth = 60  | 
||
	else  | 
  |||
		titleColumnWidth = 100  | 
  |||
	end  | 
  	end  | 
||
	local optionalColumnWidth = (100 - titleColumnWidth) / nOptionalColumns  | 
  |||
	local optionalColumnWidth = ((100 - titleColumnWidth) / nOptionalColumns) .. '%'  | 
|||
	titleColumnWidth = titleColumnWidth .. '%'  | 
  	titleColumnWidth = titleColumnWidth .. '%'  | 
||
	optionalColumnWidth = optionalColumnWidth .. '%'  | 
  |||
	-- Root of the output  | 
  |||
	local root = mw.html.create()  | 
  |||
	-- Intro  | 
  |||
	root:node(self:makeIntro())  | 
  |||
	-- Start of track listing table  | 
  |||
	local tableRoot = root:tag('table')  | 
  |||
	tableRoot  | 
  |||
		:addClass('tracklist')  | 
  |||
		:css('display', 'block')  | 
  |||
		:css('border-spacing', '0px')  | 
  |||
	-- Header row  | 
  |||
	if self.headline then  | 
  |||
		tableRoot:tag('caption')  | 
  |||
			:addClass('tlheader mbox-text')  | 
  |||
			:attr('colspan', nColumns)  | 
  |||
			:css('text-align', 'left')  | 
  |||
			:css('background-color', '#fff')  | 
  |||
			:css('font-weight', '700')  | 
  |||
			:wikitext(self.headline or 'Track listing')  | 
  |||
	end  | 
  |||
	---- Title column  | 
|||
	-- Deprecated collapsed parameter  | 
  |||
	if self.collapsed then  | 
  |||
		self:addWarning("Deprecated collapsed parameter in use", COLLAPSED_PARAMETER_CATEGORY);  | 
  |||
	end  | 
  |||
	-- Headers  | 
  |||
	local headerRow = tableRoot:tag('tr')  | 
  |||
	---- Track number  | 
  |||
	headerRow  | 
  |||
		:tag('th')  | 
  |||
			:addClass('tlheader')  | 
  |||
			:attr('scope', 'col')  | 
  |||
			:css('width', '2em')  | 
  |||
			:css('padding-left', '10px')  | 
  |||
			:css('padding-right', '10px')  | 
  |||
			:css('text-align', 'right')  | 
  |||
			:css('background-color', '#eee')  | 
  |||
			:tag('abbr')  | 
  |||
				:attr('title', 'Number')  | 
  |||
				:wikitext('No.')  | 
  |||
	---- Title  | 
  |||
	headerRow:tag('th')  | 
  	headerRow:tag('th')  | 
||
		:addClass('tlheader')  | 
  |||
		:attr('scope', 'col')  | 
  		:attr('scope', 'col')  | 
||
		:css('width', self.title_width or titleColumnWidth)  | 
  		:css('width', self.title_width or titleColumnWidth)  | 
||
		:wikitext(cfg.title)  | 
|||
		:css('text-align', 'left')  | 
  |||
		:css('background-color', '#eee')  | 
  |||
		:wikitext('Title')  | 
  |||
	---- Optional headers: writer, lyrics, music, and extra  | 
  	---- Optional headers: writer, lyrics, music, and extra  | 
||
| Line 505: | Line 435: | ||
		if self.optionalColumns[field] then  | 
  		if self.optionalColumns[field] then  | 
||
			headerRow:tag('th')  | 
  			headerRow:tag('th')  | 
||
				:addClass('tlheader')  | 
  |||
				:attr('scope', 'col')  | 
  				:attr('scope', 'col')  | 
||
				:css('width', width or optionalColumnWidth)  | 
  				:css('width', width or optionalColumnWidth)  | 
||
				:css('text-align', 'left')  | 
  |||
				:css('background-color', '#eee')  | 
  |||
				:wikitext(headerText)  | 
  				:wikitext(headerText)  | 
||
		end  | 
  		end  | 
||
	end  | 
  	end  | 
||
	addOptionalHeader('writer',   | 
  	addOptionalHeader('writer', cfg.writer, self.writing_width)  | 
||
	addOptionalHeader('lyrics',   | 
  	addOptionalHeader('lyrics', cfg.lyrics, self.lyrics_width)  | 
||
	addOptionalHeader('music',   | 
  	addOptionalHeader('music', cfg.music, self.music_width)  | 
||
	addOptionalHeader(  | 
  	addOptionalHeader(  | 
||
		'extra',  | 
  		'extra',  | 
||
		self.extra_column or   | 
  		self.extra_column or cfg.extra,  | 
||
		self.extra_width  | 
  		self.extra_width  | 
||
	)  | 
  	)  | 
||
| Line 524: | Line 451: | ||
	---- Track length  | 
  	---- Track length  | 
||
	headerRow:tag('th')  | 
  	headerRow:tag('th')  | 
||
		:addClass('  | 
  		:addClass('tracklist-length-header')  | 
||
		:attr('scope', 'col')  | 
  		:attr('scope', 'col')  | 
||
		:wikitext(cfg.length)  | 
|||
		:css('width', '4em')  | 
  |||
		:css('padding-right', '10px')  | 
  |||
		:css('text-align', 'right')  | 
  |||
		:css('background-color', '#eee')  | 
  |||
		:wikitext('Length')  | 
  |||
	-- Tracks  | 
  	-- Tracks  | 
||
	for i, track in ipairs(self.tracks) do  | 
  	for i, track in ipairs(self.tracks) do  | 
||
		tableRoot:node(track:exportRow(  | 
  		tableRoot:node(track:exportRow(columns))  | 
||
			columns = columns,  | 
  |||
			color = i % 2 == 0 and '#f7f7f7' or '#fff'  | 
  |||
		}))  | 
  |||
	end  | 
  	end  | 
||
| Line 544: | Line 464: | ||
		tableRoot  | 
  		tableRoot  | 
||
			:tag('tr')  | 
  			:tag('tr')  | 
||
				:  | 
  				:addClass('tracklist-total-length')  | 
||
				:tag('th')  | 
|||
					:attr('colspan', nColumns - 1)  | 
  					:attr('colspan', nColumns - 1)  | 
||
					:  | 
  					:attr('scope', 'row')  | 
||
					:tag('span')  | 
  					:tag('span')  | 
||
						:  | 
  						:wikitext(cfg.total_length)  | 
||
						:css('float', 'right')  | 
  |||
						:css('padding-left', '10px')  | 
  |||
						:css('background-color', '#eee')  | 
  |||
						:css('margin-right', '2px')  | 
  |||
						:wikitext("'''Total length:'''")  | 
  |||
						:done()  | 
  						:done()  | 
||
					:done()  | 
  					:done()  | 
||
				:tag('td')  | 
  				:tag('td')  | 
||
					:  | 
  					:wikitext(self.total_length)  | 
||
					:css('text-align', 'right')  | 
  |||
					:css('background-color', '#eee')  | 
  |||
					:wikitext(string.format("'''%s'''", self.total_length))  | 
  |||
	end  | 
  	end  | 
||
	root:node(tableRoot)  | 
|||
	-- Warnings and tracking categories  | 
  	-- Warnings and tracking categories  | 
||
	root:wikitext(self:renderWarnings())  | 
  	root:wikitext(self:renderWarnings())  | 
||
	root:wikitext(self:renderTrackingCategories())  | 
  	root:wikitext(self:renderTrackingCategories())  | 
||
	return   | 
  	return mw.getCurrentFrame():extensionTag{  | 
||
		name = 'templatestyles', args = { src = 'Module:Track listing/styles.css' }  | 
|||
	} .. tostring(root)  | 
|||
end  | 
  end  | 
||
Latest revision as of 15:19, October 25, 2022
Documentation for this module may be created at Module:Track listing/doc
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('[[Category:%s]]', 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, '<br>')
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')
	
	-- 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(frame)
	local args = require('Module:Arguments').getArgs(frame, {
		wrappers = 'Template:Track listing'
	})
	return p._main(args)
end
return p