Module:Chapter

From Yugipedia
Jump to: navigation, search

local Chapter = {
	pageName = nil,
	seriesLink = nil,
	seriesName = nil,
	subseries = nil,
	arcLink = nil,
	arcName = nil,
	number = nil,
	subseriesNumber = nil,
	chapterWord = nil,
	isSpecial = false,
	name = nil,
	pageNumber = 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 chapter number in the subseries.)
local DUELIST_RANGE = { 60, 278 }
local MW_RANGE = { 279, 343 }

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

-- Capitalize the first letter in a string
-- Might move to more dedicated module
-- e.g. `ucfirst('abc')` returns "Abc"
-- @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 chapter object
-- @param args table (include either [`series` and `number`] OR [`page_name`] to look up the chapter)
-- @return Chapter
function Chapter:new(args)
	args = args or {}
	
	-- Create a new instance of a chapter object
	local c = mw.clone(Chapter)

	c.pageNumber = args['page']

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

		return c
	end

	-- Lookup information on the chapter
	local chapterData = self.lookup(args)

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

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

	-- Fill in the chapter data
	c.pageName    = chapterData.pageName
	c.name        = chapterData.name
	c.seriesName  = chapterData.series
	c.seriesLink  = seriesData.link or chapterData.series
	c.arcLink     = chapterData.arcLink or args.arc
	c.arcName     = chapterData.arcName or args.arc
	c.number      = chapterData.number
	c.isSpecial   = not chapterData.number

	if (chapterData.chapterWord) then
		-- If the chapter has its own specific chapter word, use that
		c.chapterWord = chapterData.chapterWord
	elseif (not c.isSpecial) then
		-- For non-special chapters use the series' chapter word
		-- If there is none, default to 'Chapter'
		c.chapterWord = seriesData.chapterWord or 'Chapter'
	end

	c:setSubseriesData()

	return c
end

-- Look up data for a chapter
-- @param args table (include either [`series` and `number`] OR [`page`] to look up the chapter)
-- @return table
function Chapter.lookup(args)
	local query = ''

	if (args['page_name']) then
		query = query .. '[[' .. args['page_name'] .. ']]'
	end

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

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

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

	local result = mw.smw.ask{
		query,
		'?                 = pageName#',
		'?Chapter series   = series',
		'?Arc#             = arcLink',
		'?Arc.English name = arcName',
		'?Chapter number   = number',
		'?Chapter word#    = chapterWord',
		'?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 the page name was passed into the template, make sure it gets used
	-- So if it's a redirect, it gets tracked in Special:WhatLinksHere
	if (args['page_name']) then
		result.pageName = args['page_name']
	end
	
	return result
end

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

	-- If the chapter 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 chapter 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

-- 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 unnamed param is 'ref'...
	if (args[2] == 'ref') then
		args['page_name'] = args['page_name'] or args[1]
		args['format']    = args['format']    or args[2]
	-- If the second argument is defined and is not numeric
	elseif (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

-- 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.series = toFullSeriesName(args.series)
	args        = convertSubseriesArgs(args)

	-- If the format is 'numbers', change it to 'number' (singular)
	-- @todo: track and update pages using 'numbers' and drop support for it.
	if (args.format == 'numbers') then
		args.format = 'number'
	end

	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['page_name'] and (not args['series'] or not args['number'])) then
		table.insert(errors, 'Either a page name or 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 Chapter:hasPerArcNumbering()
	return seriesData[self.seriesName] and seriesData[self.seriesName].perArcNumbering
end

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

-- Render the chapter as its number, linked
-- @return string
function Chapter: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 chapter in reference format
-- @return string
function Chapter:formatAsRef()
	local args = args or {}
	local output = ''

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

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

	if (self.chapterWord) then
		output = output .. ' ' .. self.chapterWord
	end

	if self.number then
		output = output .. ' ' .. 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 .. self.chapterWord .. ' ' .. self.subseriesNumber .. ')'
	end

	if self.chapterWord then
		output = output .. ':'
	end

	output = output .. ' ' .. self:formatAsName()

	if (self.pageNumber) then
		output = output .. '; page ' .. self.pageNumber
	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 Chapter.predictPageName(args)
	-- If the page name is supplied, use that
	if (args['page_name']) then
		return args['page_name']
	end

	-- If the series and number are specified, use them to form a page name
	if (args['series'] and args['number']) then
		local seriesData = seriesData[args['series']] or {}

		-- Get a string in the format '{$series} - {$chapterWord} {$number}'
		-- Where `$number` is zero-padded to three digits.
		return args['series'] .. ' - '
			.. (seriesData.chapterWord or 'Chapter') .. ' '
			.. zeroPad(args['number'])
	end

	return nil
end

-- Get the name of the category containing images for this chapter
-- @return string
function Chapter:getImageCategoryName()
	local output = ''

	local showArcName = self:hasPerArcNumbering() and self.arcName

	if (self.seriesName)  then output = output .. self.seriesName end
	if (showArcName)      then output = output .. ' ' .. self.arcName .. ' arc' end
	if (self.chapterWord) then output = output .. ' ' .. self.chapterWord end
	if (self.number)      then output = output .. ' ' .. self.number end

	output = output .. ' images'

	return output
end

-- Get the link to the image category as it is formatted in a navigation menu
-- @return string
function Chapter:formatImageCategoryNavLink()
	local output = ''

	-- Add the chapter word and number if applicable to the output.
	if (self.chapterWord) then output = output .. ucfirst(self.chapterWord) end
	if (self.number)      then output = output .. ' ' .. self.number end

	-- Add a colon if there is anything in the output so far.
	output = output and (output .. ': ')

	-- Add the chapter name, linked to the category to the output.
	output = output .. '"[[:Category:' .. self:getImageCategoryName() .. '|' .. self.name .. ']]"'

	return output
end

-- Get the previous chapter
-- @param prev {string|number|nil}
--     The page name or chapter number of the previous chapter
--     Leave blank to automatically find the previous chapter
-- @return Chapter
function Chapter:getPrev(prev)
	-- If the first chapter, there is no previous one
	if self.number == 1 then return {} end

	-- If a non-numeric `prev` is supplied, find the chapter based on that page name.
	if (prev and not tonumber(prev)) then
		return Chapter:new({ page = prev })
	end

	-- If a numeric `prev` is supplied, find the chapter based on that number.
	if (prev and tonumber(prev)) then
		return Chapter:new({ series = self.seriesName, number = tonumber(prev) })
	end

	-- Automatically find the previous chapter, by subtracting 1 from the current number
	if (self.number) then
		return Chapter:new({ series = self.seriesName, number = self.number - 1 })
	end

	return nil
end

-- Get the next chapter
-- @param next {string|number|nil}
--     The page name or chapter number of the next chapter
--     Leave blank to automatically find the next chapter
-- @return Chapter
function Chapter:getNext(next)
	-- If a non-numeric `next` is supplied, find the chapter based on that page name.
	if (next and not tonumber(next)) then
		return Chapter:new({ page = next })
	end

	-- If a numeric `next` is supplied, find the chapter based on that number.
	if (next and tonumber(next)) then
		return Chapter:new({ series = self.seriesName, arc = self.arcName, number = tonumber(next) })
	end

	-- Find the next chapter, by adding 1 to the current number
	if (self.number) then
		return Chapter:new({ series = self.seriesName, arc = self.arcName, number = self.number + 1 })
	end

	return nil
end

-- Function callable by templates for getting a chapter, formatted in various ways
-- @param frame
-- @return string
function Chapter.chapter(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('chapter', errors, args)
	end

	-- Find the chapter data
	local c = Chapter:new(args)

	-- Show error if the chapter was not found.
	if (not c.pageName) then
		return renderErrors('chapter', { 'chapter not found' }, args)
	end

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

	return c:formatAsName()
end

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

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

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

-- Function callable by templates for getting a chapter and rendering the
-- wikitext for an images category for the chapter
--
-- @param frame
-- @return string
function Chapter.imageCategory(frame)
	-- Get the template parameters and normarize them
	local args = normalizeArgs(frame:getParent().args)

	-- Look up the current, previous and next chapters
	local curr = Chapter:new(args)

	if (not curr or not curr.pageName) then
		return '<div style="color: #d33;">Could not find chapter</div>' ..
			'[[Category:Pages with validation errors]]'
	end

	local prev = curr:getPrev(args['prev'])
	local next = curr:getNext(args['next'])

	-- Intro text
	local output = 'This category is for images from ' .. curr:formatAsRef() .. '.'

	-- Navigation
	output = output .. '<div class="toccolours" style="clear: both; display: flex; margin-bottom: .5em;">'
	if (prev and prev.pageName) then
		local prevText = prev:formatImageCategoryNavLink()
		output = output .. '<div style="flex: 1; text-align: left;">←' .. prevText .. '</div>'
	end
	if (next and next.pageName) then
		local nextText = next:formatImageCategoryNavLink()
		output = output .. '<div style="flex: 1; text-align: right;">' .. nextText .. '→</div>'
	end
	output = output .. '</div>'

	-- Category
	output = output .. '[[Category:' .. curr.seriesName .. ' images by chapter|' .. (curr.number or curr.chapterWord) ..  ']]'

	return output
end

return Chapter