Difference between revisions of "Module:Chapter"
(break up `normalizeArgs` into smaller functions, fix issue with blank unnamed params, dump debugging data to Lua log when there's a validation error) |
(`getPrev` and `getNext` to take arc into account) |
||
Line 487: | Line 487: | ||
-- If a numeric `next` is supplied, find the chapter based on that number. | -- If a numeric `next` is supplied, find the chapter based on that number. | ||
if (next and tonumber(next)) then | if (next and tonumber(next)) then | ||
− | return Chapter:new({ series = self.seriesName, number = tonumber(next) }) | + | return Chapter:new({ series = self.seriesName, arc = self.arcName, number = tonumber(next) }) |
end | end | ||
-- Find the next chapter, by adding 1 to the current number | -- Find the next chapter, by adding 1 to the current number | ||
if (self.number) then | if (self.number) then | ||
− | return Chapter:new({ series = self.seriesName, number = self.number + 1 }) | + | return Chapter:new({ series = self.seriesName, arc = self.arcName, number = self.number + 1 }) |
end | end | ||
Revision as of 18:45, 25 February 2024
Module:Chapter is used for looking up and formatting information on manga chapters.
It is implemented by Template:Chapter and Template:Images by chapter category.
Table data
The module returns a table, referred to as a Chapter
table in this documentation. It has the following attributes:
- pageName
- The chapter's page name on this site
- seriesLink
- The chapter's series' page name on this site
- seriesName
- The name of the chapter's series
- subseries
- The name of the chapter's subseries (Yu-Gi-Oh! Duelist or Yu-Gi-Oh! Millennium World for relevant chapters of the original Yu-Gi-Oh!)
- arcLink
- The page name of the chapter's arc, if applicable
- arcName
- The name of the chapter's arc, if applicable
- number
- The chapter's number in the series
- subseriesNumber
- The chapter's number in the subseries, if applicable
- chapterWord
- The word used to describe chapters in the series e.g. "Duel" for Yu-Gi-Oh!, "Rank" for Yu-Gi-Oh! ZEXAL, etc. Special chapters may have their own chapter word.
- isSpecial
- If the chapter is a special chapter (one that doesn't follow the standard numbering)
- name
- The English name of the chapter
Table methods
- Chapter:new(args)
- Look up chapter information for the supplied arguments and return them as an instance of the
Chapter
table. - Arguments may be the series name and chapter number e.g.
{ series = 'Yu-Gi-Oh! GX', number = 5 }
or the page name e.g.{ page = 'Summon the Dark Ruler!!' }
- Chapter:formatAsName()
- Format the instance of the Chapter table as its English name. The name will link to the chapter's page and be enclosed in quotes.
- Chapter:formatAsNumber()
- Format the instance of the Chapter table as its chapter number, if it has one. The number will link to the chapter's page.
- Chapter:formatAsRef()
- Format the instance of the Chapter table as its name in reference format.
- Chapter:getImageCategoryName()
- Get the name that should be used for the category containing images for the chapter
- Chapter:formatImageCategoryNavLink
- Format the instance of the Chapter tale as a link to its image category as the link should be formatted in previous/next links in such a category's navigation menu
- Chapter:getPrev(prev)
- Get the previous chapter in the series as an instance of the
Chapter
table - If the
prev
param is unused, this will automatically get the previous numbered chapter in the series. - If
prev
is a number, this will look up details for the chapter in the same series of that number. - If
prev
is used and not a number, this will look up details for the chapter whose page name matches the value. - Chapter:getNext(next)
- Get the next chapter in the series as an instance of the
Chapter
table - If the
next
param is unused, this will automatically get the next numbered chapter in the series. - If
next
is a number, this will look up details for the chapter in the same series of that number. - If
next
is used and not a number, this will look up details for the chapter whose page name matches the value.
Template methods
These methods are meant to be invoked by templates whose parameters will be passed into them.
- Chapter.chapter(frame)
- Render a link to a chapter, with various formatting options. See Template:Chapter for more details.
- Chapter.imageCategory(frame)
- Render the page content for a category for images from a given chapter. See Template:Images by chapter category for more details.
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