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 selferrorCacheKey then
		return nil
	end
	local ret = selfcacheKey
	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
			selfcacheKey = ret
			return ret
		else
			selfcacheKey = false
			return nil
		end
	else
		selferrorCacheKey = 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.statusesobj.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.statusesdata.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
			retk = 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.statusesid.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.actionscode and obj.cfg.actionscode.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.actionsobj.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.resultsresultCode then
			obj.resultId = obj.actionCfg.resultsresultCode.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.resultsself.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 = {}
					retk = newRet
					if v.aliases then
						for _, alias in ipairs(v.aliases) do
							retalias = newRet
						end
					end
					substituteAliases(v, newRet)
				else
					retk = 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-90-9*)(.*)$')
				if prefix then
					num = tonumber(num)
					prefixArgsprefix = prefixArgsprefix or {}
					prefixArgsprefixnum = prefixArgsprefixnum or {}
					prefixArgsprefixnumsuffix = v
					prefixArgsprefixnum1 = num
				end
			end
		end
		-- Remove the gaps
		local prefixArrays = {}
		for prefix, prefixTable in pairs(prefixArgs) do
			prefixArraysprefix = {}
			local numKeys = {}
			for num in pairs(prefixTable) do
				numKeys#numKeys + 1 = num
			end
			table.sort(numKeys)
			for _, num in ipairs(numKeys) do
				table.insert(prefixArraysprefix, prefixTablenum)
			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.prefixArgsself.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 = suffixesk
			if newK then
				objArgsnewK = v
			end
		end
		objArgs.paramNum = t1
		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 statusescodeUpper then
		return statusescodeUpper.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.argsself.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.namespacesself.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