Mô đun:Article history

-------------------------------------------------------------------------------
-- Article history
--
-- This module allows editors to link to all the significant events in an
-- article's history, such as good article nominations and featured article
-- nominations. It also displays its current status, as well as other
-- information, such as the date it was featured on the main page.
-------------------------------------------------------------------------------

local CONFIG_PAGE = 'Mô đun:Article history/config'
local WRAPPER_TEMPLATE = 'Bản mẫu:Lịch sử bài viết'
local DEBUG_MODE = false -- If true, errors are not caught.

-- Load required modules.
require('strict')
local Category = require('Mô đun:Article history/Category')
local yesno = require('Mô đun:Yesno')
local lang = mw.language.getContentLanguage()

-------------------------------------------------------------------------------
-- Helper functions
-------------------------------------------------------------------------------

local function isPositiveInteger(num)
return type(num) 'number'
and math.floor(num) num
and num > 0
and num 0 then
return substituteParams(msg, ...)
else
return msg
end
end

function Message:raiseError(msg, help)
-- Raises an error with the specified message and help link. Execution
-- stops unless the error is caught. This is used for errors where
-- subsequent processing becomes impossible.
local errorText
if help then
errorText = self:message('error-message-help', msg, help)
else
errorText = self:message('error-message-nohelp', msg)
end
error(errorText, 0)
end

function Message:addWarning(msg, help)
-- Adds a warning to the object's warnings table. Execution continues as
-- normal. This is used for errors that should be fixed but that do not
-- prevent the module from outputting something useful.
self.warnings = self.warnings or {}
local warningText
if help then
warningText = self:message('warning-help', msg, help)
else
warningText = self:message('warning-nohelp', msg)
end
table.insert(self.warnings, warningText)
end

function Message:getWarnings()
return self.warnings or {}
end

-------------------------------------------------------------------------------
-- Row class
-- This class represents one row in the template.
-------------------------------------------------------------------------------

local Row = {}
Row.__index = Row
addMixin(Row, Message)

function Row.new(data)
local obj = setmetatable({}, Row)
obj.cfg = data.cfg
obj.currentTitle = data.currentTitle
obj.isSmall = data.isSmall
obj.makeData = data.makeData -- used by Row:getData
return obj
end

function Row:_cachedTry(cacheKey, errorCacheKey, func)
-- This method is for use in Row object methods that are called more than
-- once. The results of such methods should be cached to avoid unnecessary
-- processing. We also cache any errors found and abort if an error was
-- raised previously, otherwise error messages could be displayed multiple
-- times.
--
-- We use false as a key to cache nil results, so func cannot return false.
--
-- @param cacheKey The key to cache successful results with
-- @param errorCacheKey The key to cache errors with
-- @param func an anonymous function that returns the method result
if self[errorCacheKey] then
return nil
end
local ret = self[cacheKey]
if ret then
return ret
elseif ret == false then
return nil
end
local success
if DEBUG_MODE then
success = true
ret = func()
else
success, ret = pcall(func)
end
if success then
if ret then
self[cacheKey] = ret
return ret
else
self[cacheKey] = false
return nil
end
else
self[errorCacheKey] = true
-- We have already formatted the error message, so no need to format it
-- again.
error(ret, 0)
end
end

function Row:getData(articleHistoryObj)
return self:_cachedTry('_dataCache', '_isDataError', function ()
return self.makeData(articleHistoryObj)
end)
end

function Row:setIconValues(icon, caption, size, smallSize)
self.icon = icon
self.iconCaption = caption
self.iconSize = size
self.iconSmallSize = smallSize
end

function Row:getIcon(articleHistoryObj)
return maybeCallFunc(self.icon, articleHistoryObj, self)
end

function Row:getIconCaption(articleHistoryObj)
return maybeCallFunc(self.iconCaption, articleHistoryObj, self)
end

function Row:getIconSize()
if self.isSmall then
return self.iconSmallSize or self.cfg.defaultSmallIconSize or '15px'
else
return self.iconSize or self.cfg.defaultIconSize or '30px'
end
end

function Row:renderIcon(articleHistoryObj)
local icon = self:getIcon(articleHistoryObj)
if not icon then
return nil
end
return renderImage(
icon,
self:getIconCaption(articleHistoryObj),
self:getIconSize()
)
end

function Row:setNoticeBarIconValues(icon, caption, size)
self.noticeBarIcon = icon
self.noticeBarIconCaption = caption
self.noticeBarIconSize = size
end

function Row:getNoticeBarIcon(articleHistoryObj)
local icon = maybeCallFunc(self.noticeBarIcon, articleHistoryObj, self)
if icon == true then
icon = self:getIcon(articleHistoryObj)
if not icon then
self:raiseError(
self:message('row-error-missing-icon'),
self:message('row-error-missing-icon-help')
)
end
end
return icon
end

function Row:getNoticeBarIconCaption(articleHistoryObj)
local caption = maybeCallFunc(
self.noticeBarIconCaption,
articleHistoryObj,
self
)
if not caption then
caption = self:getIconCaption(articleHistoryObj)
end
return caption
end

function Row:getNoticeBarIconSize()
return self.noticeBarIconSize or self.cfg.defaultNoticeBarIconSize or '15px'
end

function Row:exportNoticeBarIcon(articleHistoryObj)
local icon = self:getNoticeBarIcon(articleHistoryObj)
if not icon then
return nil
end
return renderImage(
icon,
self:getNoticeBarIconCaption(articleHistoryObj),
self:getNoticeBarIconSize()
)
end

function Row:setText(text)
self.text = text
end

function Row:getText(articleHistoryObj)
return maybeCallFunc(self.text, articleHistoryObj, self)
end

function Row:exportHtml(articleHistoryObj)
if self._html then
return self._html
end
local text = self:getText(articleHistoryObj)
if not text then
return nil
end
local html = mw.html.create('tr')
html
:tag('td')
:addClass('mbox-image')
:wikitext(self:renderIcon(articleHistoryObj))
:done()
:tag('td')
:addClass('mbox-text')
:wikitext(text)
self._html = html
return html
end

function Row:setCategories(val)
-- Set the categories from the object's config. val can be either an array
-- of strings or a function returning an array of category objects.
self.categories = val
end

function Row:getCategories(articleHistoryObj)
local ret = {}
if type(self.categories) == 'table' then
for _, cat in ipairs(self.categories) do
ret[ret + 1] = Category.new(cat)
end
elseif type(self.categories) == 'function' then
local t = self.categories(articleHistoryObj, self) or {}
for _, categoryObj in ipairs(t) do
ret[ret + 1] = categoryObj
end
end
return ret
end

-------------------------------------------------------------------------------
-- Status class
-- Status objects deal with possible current statuses of the article.
-------------------------------------------------------------------------------

local Status = setmetatable({}, Row)
Status.__index = Status

function Status.new(data)
local obj = Row.new(data)
setmetatable(obj, Status)

obj.id = data.id
obj.statusCfg = obj.cfg.statuses[obj.id]
obj.name = obj.statusCfg.name
obj:setIconValues(
obj.statusCfg.icon,
obj.statusCfg.iconCaption or obj.name,
data.iconSize
)
obj:setNoticeBarIconValues(
obj.statusCfg.noticeBarIcon,
obj.statusCfg.noticeBarIconCaption or obj.name,
obj.statusCfg.noticeBarIconSize
)
obj:setText(obj.statusCfg.text)
obj:setCategories(obj.statusCfg.categories)

return obj
end

function Status:getIconSize()
if self.isSmall then
return self.statusCfg.smallIconSize
or self.cfg.defaultSmallStatusIconSize
or '30px'
else
return self.iconSize
or self.statusCfg.iconSize
or self.cfg.defaultStatusIconSize
or '50px'
end
end

function Status:getText(articleHistoryObj)
local text = Row.getText(self, articleHistoryObj)
if text then
return substituteParams(
text,
self.currentTitle.subjectPageTitle.prefixedText,
self.currentTitle.text
)
end
end

-------------------------------------------------------------------------------
-- MultiStatus class
-- For when an article can have multiple distinct statuses, e.g. former
-- featured article status and good article status.
-------------------------------------------------------------------------------

local MultiStatus = setmetatable({}, Row)
MultiStatus.__index = MultiStatus

function MultiStatus.new(data)
local obj = Row.new(data)
setmetatable(obj, MultiStatus)

obj.id = data.id
obj.statusCfg = obj.cfg.statuses[data.id]
obj.name = obj.statusCfg.name

-- Set child status objects
local function getChildStatusData(data, id, iconSize)
local ret = {}
for k, v in pairs(data) do
ret[k] = v
end
ret.id = id
ret.iconSize = iconSize
return ret
end
obj.statuses = {}
local defaultIconSize = obj.cfg.defaultSmallStatusIconSize or '30px'
for _, id in ipairs(obj.statusCfg.statuses) do
table.insert(obj.statuses, Status.new(getChildStatusData(
data,
id,
obj.cfg.statuses[id].iconMultiSize or defaultIconSize
)))
end

return obj
end

function MultiStatus:exportHtml(articleHistoryObj)
local ret = mw.html.create()
for _, obj in ipairs(self.statuses) do
ret:node(obj:exportHtml(articleHistoryObj))
end
return ret
end

function MultiStatus:getCategories(articleHistoryObj)
local ret = {}
for _, obj in ipairs(self.statuses) do
for _, categoryObj in ipairs(obj:getCategories(articleHistoryObj)) do
ret[ret + 1] = categoryObj
end
end
return ret
end

function MultiStatus:exportNoticeBarIcon()
local ret = {}
for _, obj in ipairs(self.statuses) do
ret[ret + 1] = obj:exportNoticeBarIcon()
end
return table.concat(ret)
end

function MultiStatus:getWarnings()
local ret = {}
for _, obj in ipairs(self.statuses) do
for _, msg in ipairs(obj:getWarnings()) do
ret[ret + 1] = msg
end
end
return ret
end

-------------------------------------------------------------------------------
-- Notice class
-- Notice objects contain notices about an article that aren't part of its
-- current status, e.g. the date an article was featured on the main page.
-------------------------------------------------------------------------------

local Notice = setmetatable({}, Row)
Notice.__index = Notice

function Notice.new(data)
local obj = Row.new(data)
setmetatable(obj, Notice)

obj:setIconValues(
data.icon,
data.iconCaption,
data.iconSize,
data.iconSmallSize
)
obj:setNoticeBarIconValues(
data.noticeBarIcon,
data.noticeBarIconCaption,
data.noticeBarIconSize
)
obj:setText(data.text)
obj:setCategories(data.categories)

return obj
end

-------------------------------------------------------------------------------
-- Action class
-- Action objects deal with a single action in the history of the article. We
-- use getter methods rather than properties for the name and result, etc., as
-- their processing needs to be delayed until after the status object has been
-- initialised. The status object needs to parse the action objects when it is
-- initialised, and the value of some names, etc., in the action objects depend
-- on the status object, so this is necessary to avoid errors/infinite loops.
-------------------------------------------------------------------------------

local Action = setmetatable({}, Row)
Action.__index = Action

function Action.new(data)
local obj = Row.new(data)
setmetatable(obj, Action)

obj.paramNum = data.paramNum

-- Set the ID
do
if not data.code then
obj:raiseError(
obj:message('action-error-no-code', obj:getParameter('code')),
obj:message('action-error-no-code-help')
)
end
local code = mw.ustring.upper(data.code)
obj.id = obj.cfg.actions[code] and obj.cfg.actions[code].id
if not obj.id then
obj:raiseError(
obj:message(
'action-error-invalid-code',
data.code,
obj:getParameter('code')
),
obj:message('action-error-invalid-code-help')
)
end
end

-- Add a shortcut for this action's config.
obj.actionCfg = obj.cfg.actions[obj.id]

-- Set the link
obj.link = data.link or obj.currentTitle.talkPageTitle.prefixedText

-- Set the result ID
do
local resultCode = data.resultCode
and mw.ustring.lower(data.resultCode)
or '_BLANK'
if obj.actionCfg.results[resultCode] then
obj.resultId = obj.actionCfg.results[resultCode].id
elseif resultCode == '_BLANK' then
obj:raiseError(
obj:message(
'action-error-blank-result',
obj.id,
obj:getParameter('resultCode')
),
obj:message('action-error-blank-result-help')
)
else
obj:raiseError(
obj:message(
'action-error-invalid-result',
data.resultCode,
obj.id,
obj:getParameter('resultCode')
),
obj:message('action-error-invalid-result-help')
)
end
end

-- Set the date
if data.date then
local success, date = pcall(
lang.formatDate,
lang,
obj:message('action-date-format'),
data.date
)
if success and date then
obj.date = date
else
obj:addWarning(
obj:message(
'action-warning-invalid-date',
data.date,
obj:getParameter('date')
),
obj:message('action-warning-invalid-date-help')
)
end
else
obj:addWarning(
obj:message(
'action-warning-no-date',
obj.paramNum,
obj:getParameter('date'),
obj:getParameter('code')
),
obj:message('action-warning-no-date-help')
)
end
obj.date = obj.date or obj:message('action-date-missing')

-- Set the oldid
obj.oldid = tonumber(data.oldid)
if data.oldid and (not obj.oldid or not isPositiveInteger(obj.oldid)) then
obj.oldid = nil
obj:addWarning(
obj:message(
'action-warning-invalid-oldid',
data.oldid,
obj:getParameter('oldid')
),
obj:message('action-warning-invalid-oldid-help')
)
end

-- Set the notice bar icon values
obj:setNoticeBarIconValues(
data.noticeBarIcon,
data.noticeBarIconCaption,
data.noticeBarIconSize
)

-- Set the categories
obj:setCategories(obj.actionCfg.categories)

return obj
end

function Action:getParameter(key)
-- Finds the original parameter name for the given key that was passed to
-- Action.new.
local prefix = self.cfg.actionParamPrefix
local suffix
for k, v in pairs(self.cfg.actionParamSuffixes) do
if v == key then
suffix = k
break
end
end
if not suffix then
error('invalid key "' .. tostring(key) .. '" passed to Action:getParameter', 2)
end
return prefix .. tostring(self.paramNum) .. suffix
end

function Action:getName(articleHistoryObj)
return maybeCallFunc(self.actionCfg.name, articleHistoryObj, self)
end

function Action:getResult(articleHistoryObj)
return maybeCallFunc(
self.actionCfg.results[self.resultId].text,
articleHistoryObj,
self
)
end

function Action:exportHtml(articleHistoryObj)
if self._html then
return self._html
end

local row = mw.html.create('tr')

-- Date cell
local dateCell = row:tag('td')
if self.oldid then
dateCell
:tag('span')
:addClass('plainlinks')
:wikitext(makeUrlLink(
self.currentTitle.subjectPageTitle:fullUrl{oldid = self.oldid},
self.date
))
else
dateCell:wikitext(self.date)
end

-- Process cell
row
:tag('td')
:wikitext(string.format(
"%s",
self.link,
self:getName(articleHistoryObj)
))

-- Result cell
row
:tag('td')
:wikitext(self:getResult(articleHistoryObj))

self._html = row
return row
end

-------------------------------------------------------------------------------
-- CollapsibleNotice class
-- This class makes notices that go in the collapsible part of the template,
-- underneath the list of actions.
-------------------------------------------------------------------------------

local CollapsibleNotice = setmetatable({}, Row)
CollapsibleNotice.__index = CollapsibleNotice

function CollapsibleNotice.new(data)
local obj = Row.new(data)
setmetatable(obj, CollapsibleNotice)

obj:setIconValues(
data.icon,
data.iconCaption,
data.iconSize,
data.iconSmallSize
)
obj:setNoticeBarIconValues(
data.noticeBarIcon,
data.noticeBarIconCaption,
data.noticeBarIconSize
)
obj:setText(data.text)
obj:setCollapsibleText(data.collapsibleText)
obj:setCategories(data.categories)

return obj
end

function CollapsibleNotice:setCollapsibleText(s)
self.collapsibleText = s
end

function CollapsibleNotice:getCollapsibleText(articleHistoryObj)
return maybeCallFunc(self.collapsibleText, articleHistoryObj, self)
end

function CollapsibleNotice:getIconSize()
if self.isSmall then
return self.iconSmallSize
or self.cfg.defaultSmallCollapsibleNoticeIconSize
or '15px'
else
return self.iconSize
or self.cfg.defaultCollapsibleNoticeIconSize
or '20px'
end
end

function CollapsibleNotice:exportHtml(articleHistoryObj, isInCollapsibleTable)
local cacheKey = isInCollapsibleTable
and '_htmlCacheCollapsible'
or '_htmlCacheDefault'
return self:_cachedTry(cacheKey, '_isHtmlError', function ()
local text = self:getText(articleHistoryObj)
if not text then
return nil
end

local function maybeMakeCollapsibleTable(cell, text, collapsibleText)
-- If collapsible text is specified, makes a collapsible table
-- inside the cell with two rows, a header row with one cell and a
-- collapsed row with one cell. These are filled with text and
-- collapsedText, respectively. If no collapsible text is
-- specified, the text is added to the cell as-is.
if collapsibleText then
cell
:tag('div')
:addClass('mw-collapsible mw-collapsed')
:tag('div')
:wikitext(text)
:done()
:tag('div')
:addClass('mw-collapsible-content')
:css('border', '1px silver solid')
:wikitext(collapsibleText)
else
cell:wikitext(text)
end
end

local html = mw.html.create('tr')
local icon = self:renderIcon(articleHistoryObj)
local collapsibleText = self:getCollapsibleText(articleHistoryObj)
if isInCollapsibleTable then
local textCell = html:tag('td')
:attr('colspan', 3)
:css('width', '100%')
local rowText
if icon then
rowText = icon .. ' ' .. text
else
rowText = text
end
maybeMakeCollapsibleTable(textCell, rowText, collapsibleText)
else
local textCell = html
:tag('td')
:addClass('mbox-image')
:wikitext(icon)
:done()
:tag('td')
:addClass('mbox-text')
maybeMakeCollapsibleTable(textCell, text, collapsibleText)
end

return html
end)
end

-------------------------------------------------------------------------------
-- ArticleHistory class
-- This class represents the whole template.
-------------------------------------------------------------------------------

local ArticleHistory = {}
ArticleHistory.__index = ArticleHistory
addMixin(ArticleHistory, Message)

function ArticleHistory.new(args, cfg, currentTitle)
local obj = setmetatable({}, ArticleHistory)

-- Set input
obj.args = args or {}
obj.currentTitle = currentTitle or mw.title.getCurrentTitle()

-- Set isSmall
obj.isSmall = yesno(obj.args.small) or false

-- Define object structure.
obj._errors = {}
obj._allObjectsCache = {}

-- Format the config
local function substituteAliases(t, ret)
-- This function substitutes strings found in an "aliases" subtable
-- as keys in the parent table. It works recursively, so "aliases"
-- subtables can be placed at any level. It assumes that tables will
-- not be nested recursively, which should be true in the case of our
-- config file.
ret = ret or {}
for k, v in pairs(t) do
if k ~= 'aliases' then
if type(v) == 'table' then
local newRet = {}
ret[k] = newRet
if v.aliases then
for _, alias in ipairs(v.aliases) do
ret[alias] = newRet
end
end
substituteAliases(v, newRet)
else
ret[k] = v
end
end
end
return ret
end
obj.cfg = substituteAliases(cfg or require(CONFIG_PAGE))

--
-- Get a table of the arguments sorted by prefix and number. Non-string
-- keys and keys that don't contain a number are ignored. (This means that
-- positional parameters are ignored, as they are numbers, not strings.)
-- The parameter numbers are stored in the first positional parameter of
-- the subtables, and any gaps are removed so that the tables can be
-- iterated over with ipairs.
--
-- For example, these arguments:
-- {a1x = 'eggs', a1y = 'spam', a2x = 'chips', b1z = 'beans', b3x = 'bacon'}
-- would translate into this prefixArgs table.
-- {
-- a = {
-- {1, x = 'eggs', y = 'spam'},
-- {2, x = 'chips'}
-- },
-- b = {
-- {1, z = 'beans'},
-- {3, x = 'bacon'}
-- }
-- }
--
do
local prefixArgs = {}
for k, v in pairs(obj.args) do
if type(k) == 'string' then
local prefix, num, suffix = k:match('^(.-)([1-9][0-9])(.)$')
if prefix then
num = tonumber(num)
prefixArgs[prefix] = prefixArgs[prefix] or {}
prefixArgs[prefix][num] = prefixArgs[prefix][num] or {}
prefixArgs[prefix][num][suffix] = v
prefixArgs[prefix][num][1] = num
end
end
end
-- Remove the gaps
local prefixArrays = {}
for prefix, prefixTable in pairs(prefixArgs) do
prefixArrays[prefix] = {}
local numKeys = {}
for num in pairs(prefixTable) do
numKeys[numKeys + 1] = num
end
table.sort(numKeys)
for _, num in ipairs(numKeys) do
table.insert(prefixArrays[prefix], prefixTable[num])
end
end
obj.prefixArgs = prefixArrays
end

return obj
end

function ArticleHistory:try(func, ...)
if DEBUG_MODE then
local val = func(...)
return val
else
local success, val = pcall(func, ...)
if success then
return val
else
table.insert(self._errors, val)
return nil
end
end
end

function ArticleHistory:getActionObjects()
-- Gets an array of action objects for the parameters specified by the
-- user. We memoise this so that the parameters only have to be processed
-- once.
if self.actions then
return self.actions
end

-- Get the action args, and exit if they don't exist.
local actionArgs = self.prefixArgs[self.cfg.actionParamPrefix]
if not actionArgs then
self.actions = {}
return self.actions
end

-- Make the objects.
local actions = {}
local suffixes = self.cfg.actionParamSuffixes
for _, t in ipairs(actionArgs) do
local objArgs = {}
for k, v in pairs(t) do
local newK = suffixes[k]
if newK then
objArgs[newK] = v
end
end
objArgs.paramNum = t[1]
objArgs.cfg = self.cfg
objArgs.currentTitle = self.currentTitle
local actionObj = self:try(Action.new, objArgs)
table.insert(actions, actionObj)
end
self.actions = actions
return actions
end

function ArticleHistory:getStatusIdForCode(code)
-- Gets a status ID given a status code. If no code is specified, returns
-- nil, and if the code is invalid, raises an error.
if not code then
return nil
end
local statuses = self.cfg.statuses
local codeUpper = mw.ustring.upper(code)
if statuses[codeUpper] then
return statuses[codeUpper].id
else
self:addWarning(
self:message('articlehistory-warning-invalid-status', code),
self:message('articlehistory-warning-invalid-status-help')
)
return nil
end
end

function ArticleHistory:getStatusObj()
-- Get the status object for the current status.
if self.statusObj == false then
return nil
elseif self.statusObj ~= nil then
return self.statusObj
end
local statusId
if self.cfg.getStatusIdFunction then
statusId = self:try(self.cfg.getStatusIdFunction, self)
else
statusId = self:try(
self.getStatusIdForCode, self,
self.args[self.cfg.currentStatusParam]
)
end
if not statusId then
self.statusObj = false
return nil
end

-- Check that some actions were specified, and if not add a warning.
local actions = self:getActionObjects()
if actions (tonumber(self.cfg.uncollapsedRows) or 3)
end

-- If we are not collapsed, re-render the collapsible notices in the
-- non-collapsed version.
if not isCollapsed then
collapsibleNoticeHtmlObjects = {}
for _, obj in ipairs(collapsibleNotices) do
table.insert(
collapsibleNoticeHtmlObjects,
self:try(obj.exportHtml, obj, self, false)
)
end
end

-- Collapsible table for actions and collapsible notices. Collapsible
-- notices are only included in the table if it is collapsed. Action rows
-- are always included.
local collapsibleTable
if isCollapsed or nActionRows > 0 then
-- Collapsible table base
collapsibleTable = tableRoot
:tag('tr')
:tag('td')
:attr('colspan', 2)
:css('width', '100%')
:tag('table')
:addClass('AH-milestones')
:addClass(isCollapsed and 'mw-collapsible mw-collapsed' or nil)
:css('width', '100%')
:css('background', 'transparent')
:css('font-size', '90%')

if nCollapsibleRows > 1 then
-- Header row
local ctHeader = collapsibleTable
:tag('tr')
:tag('th')
:attr('colspan', 3)
:css('font-size', '110%')

-- Notice bar
if isCollapsed then
local noticeBarIcons = self:getNoticeBarIcons()
if noticeBarIcons > 0 then
local noticeBar = ctHeader:tag('span'):css('float', 'left')
for _, icon in ipairs(noticeBarIcons) do
noticeBar:wikitext(icon)
end
ctHeader:wikitext(' ')
end
end

-- Header text
if mw.site.namespaces[self.currentTitle.namespace].subject.id == 0 then
ctHeader:wikitext(self:message('milestones-header'))
else
ctHeader:wikitext(self:message(
'milestones-header-other-ns',
self.currentTitle.subjectNsText
))
end

-- Subheadings
if nActionRows > 0 then
collapsibleTable
:tag('tr')
:css('text-align', 'left')
:tag('th')
:wikitext(self:message('milestones-date-header'))
:done()
:tag('th')
:wikitext(self:message('milestones-process-header'))
:done()
:tag('th')
:wikitext(self:message('milestones-result-header'))
end
end

-- Actions
for _, htmlObj in ipairs(actionHtmlObjects) do
collapsibleTable:node(htmlObj)
end
end

-- Collapsible notices and current status
-- These are only included in the collapsible table if it is collapsed.
-- Otherwise, they are added afterwards, so that they align with the
-- notices.
do
local tableNode, statusColspan
if isCollapsed then
tableNode = collapsibleTable
statusColspan = 3
else
tableNode = tableRoot
statusColspan = 2
end

-- Collapsible notices
for _, obj in ipairs(collapsibleNotices) do
tableNode:node(self:try(obj.exportHtml, obj, self, isCollapsed))
end

-- Current status
if statusObj and nActionRows > 1 then
tableNode
:tag('tr')
:tag('td')
:attr('colspan', statusColspan)
:wikitext(self:message('status-blurb', statusObj.name))
end
end

-- Get the categories. We have to do this before the error row, so that
-- category errors display.
local categories = self:renderCategories()

-- Error row and error category
local errors = self:getErrorMessages()
local errorCategory
if errors > 0 then
local errorList = tableRoot
:tag('tr')
:tag('td')
:attr('colspan', 2)
:addClass('mbox-text')
:tag('ul')
:addClass('error')
:css('font-weight', 'bold')
for _, msg in ipairs(errors) do
errorList:tag('li'):wikitext(msg)
end
if self:categoriesAreActive() then
errorCategory = tostring(Category.new(self:message(
'error-category'
)))
end

-- If there are no errors and no active objects, then exit. We can't make
-- this check earlier as we don't know where the errors may be until we
-- have finished rendering the banner.
elseif self:getAllObjects() < 1 then
return
end

-- Add the categories
root:wikitext(categories)
root:wikitext(errorCategory)

return tostring(root)
end

-------------------------------------------------------------------------------
-- Exports
-- These functions are called from Lua and from wikitext.
-------------------------------------------------------------------------------

local p = {}

function p._main(args, cfg, currentTitle)
local articleHistoryObj = ArticleHistory.new(args, cfg, currentTitle)
return tostring(articleHistoryObj)
end

function p.main(frame)
local args = require('Module:Arguments').getArgs(frame, {
wrappers = WRAPPER_TEMPLATE
})
if frame:getTitle():find('sandbox', 1, true) then
CONFIG_PAGE = CONFIG_PAGE .. '/sandbox'
end
return p._main(args)
end

function p._exportClasses()
return {
Message = Message,
Row = Row,
Status = Status,
MultiStatus = MultiStatus,
Notice = Notice,
Action = Action,
CollapsibleNotice = CollapsibleNotice,
ArticleHistory = ArticleHistory
}
end

return p