Module:Volume

From Yugipedia
Jump to: navigation, search
local Volume = {
	pageName = nil,
	seriesLink = nil,
	seriesName = nil,
	subseries = nil,
	edition = 'tankobon',
	arcLink = nil,
	arcName = nil,
	number = nil,
	subseriesNumber = nil,
	name = nil,
}

-- Check if Semantic MediaWiki is enabled/operational
-- (The module should have some fallbacks prepared if it isn't.)
local smwEnabled = mw.smw and true or false

-- Chapters in the overall numbering for the original Yu-Gi-Oh! series that
-- are considered to be Yu-Gi-Oh! Duelist and Yu-Gi-Oh! Millennium World
-- (Each table contains the first and last volume number in the subseries.)
local DUELIST_RANGE = { 8, 31 }
local MW_RANGE = { 32, 38 }

local seriesData = {
	['Yu-Gi-Oh!']                = { link = 'Yu-Gi-Oh! (manga)' },
	['Yu-Gi-Oh! R']              = { link = 'Yu-Gi-Oh! R' },
	['Yu-Gi-Oh! GX']             = { link = 'Yu-Gi-Oh! GX (manga)' },
	['Yu-Gi-Oh! 5D\'s']          = { link = 'Yu-Gi-Oh! 5D\'s (manga)' },
	['Yu-Gi-Oh! ZEXAL']          = { link = 'Yu-Gi-Oh! ZEXAL (manga)' },
	['Yu-Gi-Oh! D Team ZEXAL']   = { link = 'Yu-Gi-Oh! D-Team ZEXAL' },
	['Yu-Gi-Oh! ARC-V']          = { link = 'Yu-Gi-Oh! ARC-V (manga)' },
	['Yu-Gi-Oh! ARC-V The Strongest Duelist Yuya!!']        = { link = 'Yu-Gi-Oh! ARC-V The Strongest Duelist Yuya!!' },
	['Yu-Gi-Oh! SEVENS Luke! Explosive Supremacy Legend!!'] = { link = 'Yu-Gi-Oh! SEVENS Luke! Explosive Supremacy Legend!!' },
	['Yu-Gi-Oh! Rush Duel LP']   = { link = 'Yu-Gi-Oh! Rush Duel LP' },
	['Yu-Gi-Oh! OCG Structures'] = { link = 'Yu-Gi-Oh! OCG Structures' },
	['Nururin Charisma! GO! GO! Gokibore!!'] = { link = 'Nururin Charisma! GO! GO! Gokibore!!' },
	['Yu-Gi-Oh! GO RUSH!!']      = { link = 'Yu-Gi-Oh! GO RUSH!! (manga)' },
	['Yu-Gi-Oh! OCG Stories']    = { link = 'Yu-Gi-Oh! OCG Stories', perArcNumbering = true },
}

-- Lowercase the first letter in a string
-- e.g. `lcfirst('ABC')` returns "aBC"
-- Might move to more dedicated module
-- @param text string
-- @return string
local function lcfirst(text)
	return text:sub(1, 1):lower() .. text:sub(2)
end

-- Capitalize the first letter in a string
-- e.g. `ucfirst('abc')` returns "Abc"
-- Might move to more dedicated module
-- @param text string
-- @return string
local function ucfirst(text)
	return text:sub(1, 1):upper() .. text:sub(2)
end

-- Create a new instance of a volume object
-- @param args table include `series` and `number` and if applicable, `arc`
-- @return Volume
function Volume:new(args)
	args = args or {}
	
	-- Create a new instance of a volume object
	local v = mw.clone(Volume)

	-- If SMW is down, fail somewhat gracefully
	if (not smwEnabled) then
		v.pageName    = v.predictPageName(args)
		v.seriesName  = args['series']
		v.seriesLink  = seriesData[args['series']]
			and seriesData[args['series']].link
			or args['series']
		v.arcLink     = args['arc'] .. ' (arc)'
		v.arcName     = args['arc']
		v.number      = args['number']

		return v
	end

	-- Lookup information on the volume
	local volumeData = self.lookup(args)

	-- If nothing was found, return the empty object
	if (not volumeData.pageName) then
		return v
	end

	-- Get data on the series
	local seriesData = seriesData[volumeData.series] or {}

	-- Fill in the volume data
	v.pageName    = volumeData.pageName
	v.name        = volumeData.name
	v.seriesLink  = seriesData.link or volumeData.series
	v.seriesName  = volumeData.series
	v.edition     = volumeData.edition
	v.arcLink     = volumeData.arcLink or args.arc
	v.arcName     = volumeData.arcName or args.arc
	v.number      = volumeData.number

	v:setSubseriesData()

	return v
end

-- Look up data for a volume
-- @param args table include 'series', 'number' and, if necessary, 'arc' to look up the volume
-- @return table
function Volume.lookup(args)
	local query = ''

	if (args['series']) then
		query = query .. '[[Volume series::' .. args['series'] .. ']]'
	end

	query = query .. '[[Volume format::' .. ucfirst(args['edition']) .. ']]'

	if (args['arc']) then
		query = query .. '[[Arc.English name::' .. args['arc'] .. ']]'
	end

	if (args['number']) then
		query = query .. '[[Volume number::' .. args['number'] .. ']]'
	end

	local result = mw.smw.ask{
		query,
		'?                 = pageName#',
		'?Volume series    = series',
		'?Volume format    = edition',
		'?Arc#             = arcLink',
		'?Arc.English name = arcName',
		'?Volume number    = number',
		'?English name     = name',
		'?Release date#    = releaseDate',
		'?Release date     = releaseDateFormatted',
	}

	-- Get the first result. If nothing is found, use an empty table.
	result = result and result[1] or {}

	if (result.edition == 'Bunkoban' or result.edition == 'Tankobon') then
		result.edition = lcfirst(result.edition)
	end

	return result
end

-- Fill in the Yu-Gi-Oh! Duelist or Yu-Gi-Oh! Millennium World information
-- for Yu-Gi-Oh! volumes, if applicable
function Volume:setSubseriesData()
	-- Exit early if the series isn't Yu-Gi-Oh!
	if (self.seriesName ~= 'Yu-Gi-Oh!' or self.edition ~= 'tankobon') then
		return
	end

	-- If the volume is in the Duelist range
	-- Set the subseries to "Yu-Gi-Oh! Duelist" and calculate its number
	if (self.number and self.number >= DUELIST_RANGE[1] and self.number <= DUELIST_RANGE[2]) then
		self.subseries = 'Yu-Gi-Oh! Duelist'
		self.subseriesNumber = self.number - DUELIST_RANGE[1] + 1
	end

	-- If the volume is in the Millennium World range
	-- Set the subseries to "Yu-Gi-Oh! Millennium World" and calculate its number
	if (self.number and self.number >= MW_RANGE[1] and self.number <= MW_RANGE[2]) then
		self.subseries = 'Yu-Gi-Oh! Millennium World'
		self.subseriesNumber = self.number - MW_RANGE[1] + 1
	end
end

-- Remove HTML tags from a string
-- e.g. '<code>X</code>' -> 'X'
-- @param text string
-- @return string
local function stripHtmlTags(text)
	return text:gsub('%b<>', '')
end

local function zeroPad(number)
	return ('%03d'):format(number)
end

local function renderErrors(templateName, errors, data)
	-- Join the array into a string.
	-- Strip out tags which won't work inside the `title` attribute
	local errorsString = stripHtmlTags(table.concat(errors, ' '))

	local message = mw.html.create('span')
	message
		:css('color', '#d33')
		:node('Error rendering <code>{{' .. templateName .. '}}</code> ')
		:tag('abbr')
		:attr('title', errorsString)
		:node('🛈')
		
	local output = tostring(message)

	if (mw.title.getCurrentTitle().nsText ~= 'Template') then
 		output = output ..'[[Category:Pages with validation errors]]'
	end

	-- Put errors and other debugging data in the Lua log
	-- To access, preview this module on a page with an error
	-- then expand "Parser profiling data:" -> "Lua logs"
	mw.logObject(errors, 'errors')
	mw.logObject(data, 'data')
	mw.log('\n\n')

	return output
end

-- Trim whitespace from each string in a table
-- And remove elements that only contain whitespace
-- 
-- @param table args
-- @return table
local function cleanWhitespace(args)
	-- New table for the updated params
	-- Trying to update the existing one causes unexpected behaviour when trying
	-- to remove a blank value.
	local cleaned = {}

	-- Go through old table to populate the new table
	for param, value in pairs(args) do
		-- Clean whitespace if it's a string
		if (type(value) == 'string') then
			value = mw.text.trim(value)
		end

		-- Only add to new table if not blank
		if (value and value ~= '') then
			cleaned[param] = value
		end
	end

	return cleaned
end

-- Convert unnamed args to named args, unless superseded by a named arg
-- @param table args
-- @return table
local function unnamedToNamedArgs(args)
	-- If the second argument is defined and is not numeric
	if (not tonumber(args[2])) then
		args['series'] = args['series'] or args[1]
		args['arc']    = args['arc']    or args[2]
		args['number'] = args['number'] or args[3]
		args['format'] = args['format'] or args[4]
	-- Otherwise
	else
		args['series'] = args['series'] or args[1]
		args['number'] = args['number'] or args[2]
		args['format'] = args['format'] or args[3]
	end

	return args
end

-- Split a series from a format
-- e.g. { series = 'Yu-Gi-Oh! (bunkoban)' } -> { series = 'Yu-Gi-Oh!', edition = 'bunkoban' }
-- e.g. { series = 'Yu-Gi-Oh! (3-in)' } -> { series = 'Yu-Gi-Oh!', edition = '3-in-1 edition' }
-- @param table args
-- @return table
local function splitSeriesAndEdition(args)
	-- Pattern for text followed by more text in paretheses
	local pattern = '(.*) %((.*)%)'
	
	local series = args['series'];

	-- Default value
	args['edition'] = args['edition'] or 'tankobon'

	-- Exit early if series doesn't match the pattern
	if (type(series) ~= 'string' or not series:match(pattern)) then
		return args
	end

	-- Set the series and edition equal to the first and second parts of the pattern
	args['edition'] = series:gsub(pattern, '%2')
	args['series' ] = series:gsub(pattern, '%1')

	-- Convert shortened format name to full name
	local editionsNormalized = {
		['2-in-1'] = '2-in-1 edition',
		['3-in-1'] = '3-in-1 edition',
		['remix']  = 'Shueisha Jump Remix',
		['sjr']    = 'Shueisha Jump Remix',
	}
	args['edition'] = editionsNormalized[args['edition']] or args['edition'];

	return args
end

-- Convert a shortened series name to a full name
-- e.g. "GX" -> "Yu-Gi-Oh! GX"
-- @param string series
-- @return string
local function toFullSeriesName(series)
	-- If the series does not exist unless a 'Yu-Gi-Oh!' prefix is added, add the prefix
	if (series and not seriesData[series] and seriesData['Yu-Gi-Oh! ' .. series]) then
		series = 'Yu-Gi-Oh! ' .. series
	end

	return series
end

-- If the args used 'Yu-Gi-Oh! Duelist' / 'Yu-Gi-Oh! Millennium World' as the series
-- Convert the series to 'Yu-Gi-Oh!' and update the number
-- @param table args
-- @return table
local function convertSubseriesArgs(args)
	-- If the supplied series is 'Yu-Gi-Oh! Duelist',
	-- change to 'Yu-Gi-Oh!' and use overall series numbering
	if (args.series and (args.series == 'Yu-Gi-Oh! Duelist' or args.series == 'Duelist')) then
		args.series = 'Yu-Gi-Oh!'
		-- Get the overall number by adding the offset to the Duelist number.
		args.number = args.number and (args.number + DUELIST_RANGE[1] - 1) or nil
	end

	-- If the supplied series is 'Yu-Gi-Oh! Millennium World',
	-- change to 'Yu-Gi-Oh!' and use overall series numbering
	if (args.series and (args.series == 'Yu-Gi-Oh! Millennium World' or args.series == 'Millennium World')) then
		args.series = 'Yu-Gi-Oh!'
		-- Get the overall number by adding the offset to the Millennium World number.
		args.number = args.number and (args.number + MW_RANGE[1] - 1) or nil
	end

	return args
end

-- Normalize input
-- @param table args
-- @return args
function normalizeArgs(args)
	-- Perform the various modifications to the input params
	args        = cleanWhitespace(args)
	args        = unnamedToNamedArgs(args)
	args        = splitSeriesAndEdition(args)
	args.series = toFullSeriesName(args.series)
	args        = convertSubseriesArgs(args)

	return args
end

-- Check if the input is okay
-- @param args table - The input from the template parameters
-- @return table - An array of error messages
function validateArgs(args)
	args = args or {}

	local errors = {}

	if (not args['series'] or not args['number']) then
		table.insert(errors, 'Both a series and number must be specified.')
	end

	if (args['number'] and tonumber(args['number']) == nil) then
		table.insert(errors, 'Number (<code>' .. args['number'] .. '</code>) must be numeric.')
	end

	if (args['format'] and args['format'] ~= 'ref' and args['format'] ~= 'number') then
		table.insert(errors, 'Format (<code>' .. args['format'] .. '</code>) was not recognized.')
	end

	local hasPerArcNumbering = seriesData[args['series']] and seriesData[args['series']].perArcNumbering
	if (hasPerArcNumbering and not args['arc']) then
		table.insert(errors, 'An arc must be specified if the series is ' .. args['series'] .. '.')
	end

	return errors
end

-- Check if the series resets numbering each arc
-- @return boolean
function Volume:hasPerArcNumbering()
	return seriesData[self.seriesName] and seriesData[self.seriesName].perArcNumbering
end

-- Render the volume as its name, linked and quoted
-- @return string
function Volume:formatAsName()
	return '"[[' .. self.pageName .. '|' .. (self.name or self.pageName) .. ']]"'
end

-- Render the volume as its number, linked
-- @return string
function Volume:formatAsNumber()
	-- Get the number zero-padded
	-- Or the page name if there is no number
	local numberText = self.number and zeroPad(self.number) or self.pageName

	-- If there is a subseries, put an abbreviation and its number in parentheses.
	if (self.subseries and self.subseriesNumber) then
		local abbreviations = {
			['Yu-Gi-Oh! Duelist'] = 'D',
			['Yu-Gi-Oh! Millennium World'] = 'MW'
		}

		numberText = numberText .. ' (<i>' .. abbreviations[self.subseries] .. '</i> ' .. zeroPad(self.subseriesNumber) .. ')'
	end

	return '[[' .. self.pageName .. '|' .. numberText .. ']]'
end

-- Render the volume in reference format
-- @return string
function Volume:formatAsRef()
	local args = args or {}
	local output = ''

	if (self.seriesName) then
		output = output .. '<i>' .. self.seriesName .. '</i>'
	end

	if (self.edition and self.edition ~= 'tankobon') then
		output = output .. ' (' .. self.edition .. ')'
	end

	if (self:hasPerArcNumbering() and self.arcName) then
		output = output .. ' ' .. self.arcName .. ' arc'
	end

	-- If the volume doesn't have a name, link the word "volume" and the number
	if (self.name) then
		output = output .. ' volume ' .. self.number
	else
		output = output .. ' [[' .. self.pageName .. '|volume ' .. self.number .. ']]'
	end

	if self.subseries then
		-- Put the subseries name and number in parentheses.
		-- Remove the word "Yu-Gi-Oh!" from the subseries name.
		output = output .. ' (<i>' .. (self.subseries:gsub('Yu%-Gi%-Oh! ', '')) .. '</i>'
		output = output .. ' volume ' .. self.subseriesNumber .. ')'
	end

	-- Only include the colon and formatted name if the volume has a name
	if (self.name) then
		output = output .. ': ' .. self:formatAsName()
	end

	return mw.text.trim(output)
end

-- Predict what the page name should be
-- (Needed if Semantic MediaWiki is down.)
-- @param args table - the normalized template parameters
-- @return string
function Volume.predictPageName(args)
	-- Can't guess the page name without the series or number
	if (not args['series'] or not args['number']) then
		return nil
	end

	local seriesData = seriesData[args['series']] or {}

	local pageName = args['series']

	if (args['arc'] and seriesData and seriesData.perArcNumbering) then
		pageName = pageName .. ' ' .. args['arc'] .. ' Arc'
	end

	pageName = pageName .. ' -  Volume ' .. zeroPad(args['number'])

	return pageName
end

-- Function callable by templates for getting a volume, formatted in various ways
-- @param frame
-- @return string
function Volume.volume(frame)
	-- Get the template parameters and format them
	local args = normalizeArgs(frame:getParent().args)

	-- Show error if there is a problem with the input.
	local errors = validateArgs(args)
	if (#errors > 0) then
		return renderErrors('volume', errors, args)
	end

	-- Find the volume data
	local v = Volume:new(args)

	-- If a page was not found for the valume
	if (not v.pageName) then
		-- Fallback to predicting the page name if configured to do so
		if (args['not_found_fallback']) then
			return '[[' .. v.predictPageName(args) .. ']]'
		end

		-- Otherwise show an error.
		return renderErrors('volume', { 'volume not found' }, args)
	end

	-- Return the output
	if (args['format'] and args['format'] == 'ref')    then return v:formatAsRef()    end
	if (args['format'] and args['format'] == 'number') then return v:formatAsNumber() end

	return v:formatAsName()
end

return Volume