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 for automatic punctuation for parameters:

-- all_writing, all_lyrics, all_music

-- Punctuation defaults to '.' unless ':' or '.' already exist

-- Automatic punctuation limited to parameter values not containing parser tags

-- i.e. Text, , etc.

--------------------------------------------------------------------------------

function fullstop(a)

if a ~= mw.text.killMarkers(mw.getCurrentFrame():preprocess(a)) or

a:sub(-1,-1) == ":" or

a:sub(-1,-1) == "." then

return a

else

return a .. '.'

end

end

function TrackListing:makeIntro()

if self.all_writing then

return string.format(cfg.tracks_written, fullstop(self.all_writing))

elseif self.all_lyrics and self.all_music then

return mw.message.newRawMessage(

cfg.lyrics_written_music_composed,

self.all_lyrics,

fullstop(self.all_music)

):plain()

elseif self.all_lyrics then

return string.format(cfg.lyrics_written, fullstop(self.all_lyrics))

elseif self.all_music then

return string.format(cfg.music_composed, fullstop(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, '
')

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(frame)

local args = require('Module:Arguments').getArgs(frame, {

wrappers = 'Template:Track listing'

})

return p._main(args)

end

return p