Module:Article history

This is an old revision of this page, as edited by Mr. Stradivarius (talk | contribs) at 00:37, 23 October 2014 (use HTML objects to calculate the count of collapsible rows, rather than the number of action and collapsible notice objects, otherwise the count is incorrect when any errors are generated). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

-------------------------------------------------------------------------------
--                            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 = 'Module:Article history/config'
local WRAPPER_TEMPLATE = 'Template:Article history'

-- Load required modules.
require('Module:No globals')
local Category = require('Module:Article history/Category')
local yesno = require('Module: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 < math.huge
end

local function substituteParams(msg, ...)
	return mw.message.newRawMessage(msg, ...):plain()
end

local function makeUrlLink(url, display)
	return string.format('[%s %s]', url, display)
end

local function parseConfigVal(val, ...)
	if type(val) == 'function' then
		return val(...)
	else
		return val
	end
end

local function renderImage(image, caption, size)
	if caption then
		caption = '|' .. caption
	else
		caption = ''
	end
	return string.format('[[File:%s|%s%s]]', image, size, caption)
end

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

local Row = {}
Row.__index = Row

function Row.new(data)
	local obj = setmetatable({}, Row)
	obj.cfg = data.cfg
	obj.currentTitle = data.currentTitle
	obj.isSmall = data.isSmall
	obj.categories = {}
	return obj
end

function Row:setIcon(icon, caption, size)
	self.icon = icon
	self.caption = caption
	self.iconSize = size
end

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

function Row:exportHtml()
	local html = mw.html.create('tr')

	-- Render the icon link.
	local icon
	if self.icon then
		local iconSize
		if self.isSmall then
			iconSize = self.iconSmallSize or self.cfg.defaultSmallIconSize or '15px'
		else
			iconSize = self.iconSize or self.cfg.defaultIconSize or '30px'
		end
		icon = renderImage(self.icon, self.caption, iconSize)
	end

	-- Render the HTML
	html
		:tag('td')
			:addClass('mbox-image')
			:wikitext(icon)
			:done()
		:tag('td')
			:addClass('mbox-text')
			:wikitext(self.text)

	return html
end

function Row:addCategories(val, ...)
	-- Add categories to the object's categories table. val can be either an
	-- array of strings or a function returning an array of category objects.
	if type(val) == 'table' then
		for _, cat in ipairs(val) do
			table.insert(self.categories, Category.new(cat))
		end
	elseif type(val) == 'function' then
		for _, categoryObj in ipairs(val(...) or {}) do
			table.insert(self.categories, categoryObj)
		end
	end
end

function Row:exportCategories()
	return self.categories
end

function Row:setNoticeBarIcon(icon, caption, size)
	self.noticeBarIcon = icon
	self.noticeBarCaption = caption
	self.noticeBarIconSize = size
end

function Row:exportNoticeBarIcon()
	if not self.noticeBarIcon then
		return nil
	end
	local size = self.noticeBarIconSize or self.cfg.defaultNoticeBarIconSize or '15px'
	return renderImage(self.noticeBarIcon, size, self.noticeBarCaption)
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
	local statusCfg = obj.cfg.statuses[obj.id]
	obj.statusCfg = statusCfg
	
	-- Set the icon
	local iconSize
	if obj.isSmall then
		iconSize = statusCfg.smallIconSize or obj.cfg.defaultSmallStatusIconSize or '30px'
	else
		iconSize = statusCfg.iconSize or obj.cfg.defaultStatusIconSize or '50px'
	end
	obj:setIcon(statusCfg.icon, statusCfg.caption, iconSize)

	return obj
end

function Status:exportHtml(articleHistoryObj)
	self:setText(substituteParams(
		parseConfigVal(self.statusCfg.text, articleHistoryObj),
		self.currentTitle.subjectPageTitle.prefixedText,
		self.currentTitle.text
	))
	return Row.exportHtml(self)
end

function Status:exportCategories(articleHistoryObj)
	self:addCategories(self.statusCfg.categories, articleHistoryObj)
	return Row.exportCategories(self)
end

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

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

function DoubleStatus.new(data)
	local obj = Row.new(data)
	setmetatable(obj, DoubleStatus)
	return obj
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)
	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

-- Properties to implement:
-- * id
-- * oldid
-- * paramNum
-- * cfg
-- * actionCfg
-- * currentTitle

function Action.new(data)
	-- Valid data table keys:
	-- code, result, link, paramNum, cfg, currentTitle
	local obj = Row.new(data)
	setmetatable(obj, Action)

	obj.paramNum = data.paramNum

	-- Set the ID
	obj.id = obj.cfg.actions[data.code] and obj.cfg.actions[data.code].id
	if not obj.id then
		-- @TODO: Error
	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
	if obj.actionCfg.results[data.resultCode] then
		obj.resultId = obj.actionCfg.results[data.resultCode].id
	else
		-- @TODO: Error
	end

	-- Set the date
	if data.date then
		local success, date = pcall(
			lang.formatDate,
			lang,
			obj.cfg.msg['action-date-format'],
			data.date
		)
		if success and date then
			obj.date = date
		else
			-- @TODO: Error
		end
	else
		-- @TODO: Error
	end

	-- Set the oldid
	obj.oldid = tonumber(data.oldid)
	if obj.oldid and not isPositiveInteger(obj.oldid) then
		-- @TODO: Error
	end

	return obj
end

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

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

function Action:exportHtml(articleHistoryObj)
	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|%s]]'''",
				self.link,
				self:getName(articleHistoryObj)
			))
	
	-- Result cell
	row
		:tag('td')
			:wikitext(self:getResult(articleHistoryObj))

	return row
end

function Action:exportCategories(articleHistoryObj)
	local categories = self.actionCfg.categories
	if type(categories) == 'table' then
		local ret = {}
		for _, cat in ipairs(categories) do
			ret[#ret + 1] = Category.new(cat)
		end
		return ret
	elseif type(categories) == 'function' then
		return categories(self, articleHistoryObj)
	end
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)
	return obj
end

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

local ArticleHistory = {}
ArticleHistory.__index = ArticleHistory

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 the error table.
	obj.errors = {}
	
	-- 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))
	
	return obj
end

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

function ArticleHistory:message(key, ...)
	local msg = self.cfg.msg[key]
	if select('#', ...) > 0 then
		return substituteParams(msg, ...)
	else
		return msg
	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 ~= nil then
		return self.actions
	end

	-- Filter the arguments for actions.
	local actionParams = {}
	local pattern = '^' .. self.cfg.actionParamPrefix .. '([1-9][0-9]*)(.-)$'
	local suffixes = self.cfg.actionParamSuffixes
	for k, v in pairs(self.args) do
		if type(k) == 'string' then
			local num, suffix = k:match(pattern)
			if num and suffix and suffixes[suffix] then
				num = tonumber(num)
				local t = actionParams[num] or {}
				t[suffixes[suffix]] = v
				t.paramNum = num
				actionParams[num] = t
			end
		end
	end
	
	-- Sort the action parameters.
	local actionParamsSorted = {}
	for num, t in pairs(actionParams) do
		table.insert(actionParamsSorted, t)
	end
	table.sort(actionParamsSorted, function (t1, t2)
		return t1.paramNum < t2.paramNum
	end)
	
	-- Create the action objects.
	local actions = {}
	for _, t in ipairs(actionParamsSorted) do
		t.cfg = self.cfg
		t.currentTitle = self.currentTitle
		local actionObj = self:try(Action.new, t)
		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
	code = mw.ustring.upper(code)
	if statuses[code] then
		return statuses[code].id
	else
		-- @TODO: Error
	end
end

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

	-- Make a new status object.
	local statusObjData = {
		id = statusId,
		currentTitle = self.currentTitle,
		cfg = self.cfg,
		isSmall = self.isSmall
	}
	local statusObj = self:try(Status.new, statusObjData) or false
	self.statusObj = statusObj
	return statusObj
end

function ArticleHistory:getStatusId()
	local statusObj = self:getStatusObj()
	return statusObj and statusObj.id
end

function ArticleHistory:getNoticeObjects()
	-- Returns an array of notice objects to be output.
	local ret = {}
	return ret
end

function ArticleHistory:getCollapsibleNoticeObjects()
	-- Returns an array of collapsible notice objects to be output.
	local ret = {}
	return ret
end

function ArticleHistory:renderBox()
	local root = mw.html.create('table')
	root:addClass('tmbox tmbox-notice')
	if self.isSmall then
		root:addClass('mbox-small')
	else
		root:css('width', '80%')
	end
	
	-- Status
	local statusObj = self:getStatusObj()
	if statusObj then
		root:node(self:try(statusObj.exportHtml, statusObj, self))
	end

	-- Notices
	local notices = self:getNoticeObjects()
	for _, noticeObj in ipairs(notices) do
		root:node(self:try(noticeObj.exportHtml, noticeObj, self))
	end

	-- Get action objects and the collapsible notice objects, and generate their
	-- HTML objects. Use the HTML objects to calculate the number of collapsible
	-- rows. (We can't use the count of action and collapsible notice objects
	-- themselves, as it is inaccurate if they generate any errors.)
	local actionHtmlObjects, collapsibleNoticeHtmlObjects = {}, {}
	local actions = self:getActionObjects()
	local collapsibleNotices = self:getCollapsibleNoticeObjects()
	for _, obj in ipairs(actions or {}) do
		table.insert(
			actionHtmlObjects,
			self:try(obj.exportHtml, obj, self)
		)
	end
	for _, obj in ipairs(collapsibleNotices or {}) do
		table.insert(
			collapsibleNoticeHtmlObjects,
			self:try(obj.exportHtml, obj, self)
		)
	end
	local noCollapsibleRows = #actionHtmlObjects + #collapsibleNoticeHtmlObjects

	-- Collapsible table for actions and collapsible notices
	if noCollapsibleRows > 0 then
		-- Find out if we are collapsed or not.
		local isCollapsed
		if self.cfg.uncollapsedRows == 'all' then
			isCollapsed = false
		else
			isCollapsed = noCollapsibleRows > (tonumber(self.cfg.uncollapsedRows) or 3)
		end

		-- Collapsible table base
		local collapsibleTable = root
			:tag('tr')
				:tag('td')
					:attr('colspan', 2)
					:css('width', '100%')
					:tag('table')
						:addClass('AH-milestones')
						:addClass(isCollapsed and 'collapsible collapsed' or nil)
						:css('width', '100%')
						:css('background', 'transparent')
						:css('font-size', '90%')

		-- Header row
		local ctHeader = collapsibleTable
			:tag('tr')
				:tag('th')
					:attr('colspan', 3)
					:css('font-size', '110%')
		local noticeBarIcons = {}
		for i, t in ipairs{notices, collapsibleNotices, actions} do
			for j, obj in ipairs(t) do
				table.insert(noticeBarIcons, obj:exportNoticeBarIcon())
			end
		end
		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
		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
		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'))

		-- Actions and collapsible notices
		for i, t in ipairs{actionHtmlObjects, collapsibleNoticeHtmlObjects} do
			for j, htmlObj in ipairs(t) do
				collapsibleTable:node(htmlObj)
			end
		end
	end

	-- Error row
	if #self.errors > 0 then
		local errorList = root
			:tag('tr')
				:tag('td')
					:attr('colspan', 2)
					:addClass('mbox-text')
					:tag('ul')
						:addClass('error')
						:css('font-weight', 'bold')
		for _, msg in ipairs(self.errors) do
			errorList:tag('li'):wikitext(msg)
		end
	end

	return tostring(root)
end

function ArticleHistory:renderCategories()
	local ret = {}
	
	-- Status object categories
	local statusObj = self:getStatusObj()
	if statusObj then
		local categories = self:try(statusObj.exportCategories, statusObj, self)
		for i, categoryObj in ipairs(categories or {}) do
			ret[#ret + 1] = tostring(categoryObj)
		end
	end

	-- Action object categories
	for i, actionObj in ipairs(self:getActionObjects() or {}) do
		local categories = self:try(actionObj.exportCategories, actionObj, self)
		for j, categoryObj in ipairs(categories or {}) do
			ret[#ret + 1] = tostring(categoryObj)
		end
	end

	-- Error category
	if #self.errors > 0 then
		ret[#ret + 1] = tostring(Category.new(self:message('error-category')))
	end

	return table.concat(ret)
end

function ArticleHistory:__tostring()
	return self:renderBox() .. self:renderCategories()
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
	})
	return p._main(args)
end

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

return p