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