Jump to content

Module:Signpost poll

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Mr. Stradivarius (talk | contribs) at 02:14, 11 March 2015 (return the export table instead of the poll object). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

-- This module implments polls used in articles of the Signpost.

local CONFIG_MODULE = 'Module:Signpost poll/config'

local yesno = require('Module:Yesno')
local mStringCount = require('Module:String count')
local lang = mw.language.getContentLanguage()

local config = {

wrappers = 'Wikipedia:Wikipedia Signpost/Templates/Voter',

colors = {
	'#006699', -- Foundation blue
	'#339966', -- Foundation red
	'#990000', -- Foundation green

	-- From http://stackoverflow.com/questions/470690/how-to-automatically-generate-n-distinct-colors
	-- with some colors similar to Foundation colors removed.
	'#FFB300',
	'#803E75',
	'#FF6800',
	'#A6BDD7',
	'#CEA262',
	'#817066',
	'#F6768E',
	'#FF7A5C',
	'#53377A',
	'#FF8E00',
	'#B32851',
	'#F4C800',
	'#93AA00',
	'#593315',
	'#F13A13',
	'#232C16',
},

msg = {

-- The default vote preload text. This is preprocessed.
-- $1 is the option number.
['vote-default'] = 'Voting for option $1.',

-- The text that appears beside each option in the legend. This is preprocessed.
-- $1 is the option text,
-- $2 is the number of votes, and
-- $3 is the percentage of the total votes.
['legend-option-text'] = '$1 <small>($3%;&nbsp;$2&nbsp;{{PLURAL: $2 | vote | votes }})</small>',

-- This is preprocessed.
['not-enough-votes-warning'] = "Need '''$1''' more {{PLURAL: $1 | vote | votes }} to display results&mdash;" ..
	"if you haven't already, consider voting!",

['poll-closed-warning'] = 'Sorry; the poll is now closed.',

['preload-default'] = 'Wikipedia:Wikipedia Signpost/Templates/Voter/Vote preload',

['icon-default'] = 'WikipediaSignpostIcon.svg',

['overlay-default'] = 'Foundation Logo Transparent.svg',

['minimum-default'] = 10,

['header-text'] = "''Signpost'' poll",

['no-question-error'] = 'Please specify a question',

['no-votepage-error'] = 'Please specify a vote page',

['not-enough-options-error'] = 'Polls must have at least two options',

-- This is preprocessed.
-- $1 and $2 are the numbers of the options with duplicate vote text.
['duplicate-vote-text-error'] = 'duplicate vote text detected for options $1 and $2',

},

}

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

local function getUnixDate(date)
	date = lang:formatDate('U', date)
	return tonumber(date)
end

local function makeButton(url, display)
	return string.format(
		'<span class="plainlinks" style="margin: 2px">' ..
		'[%s <span class="mw-ui-button mw-ui-progressive" role="button" aria-disabled="false">%s</span>]' ..
		'</span>',
		url,
		display
	)
end

-------------------------------------------------------------------------------
-- Poll class
-------------------------------------------------------------------------------

local Poll = {}
Poll.__index = Poll

function Poll.new(args, cfg, frame)
	local self = setmetatable({}, Poll)
	self.cfg = cfg or config
	self.frame = frame or mw.getCurrentFrame()

	-- Set required fields
	self.question = assert(args.question, self:message('no-question-error'))
	self.votePage = assert(args.votepage, self:message('no-votepage-error'))

	-- Set optional fields
	self.preload = args.preload or self:message('preload-default')
	self.headerText = args.header or self:message('header-text')
	self.icon = args.icon or self:message('icon-default')
	self.overlay = args.overlay or self:message('overlay-default')
	self.minimum = tonumber(args.minimum) or self:message('minimum-default')
	self.expiry = args.expiry
	self.lineBreak = args['break']

	-- Set options
	self.options = {}
	do
		local i = 1
		while true do
			local key = 'option' .. tostring(i)
			local option = args[key]
			if not option then
				break
			end
			table.insert(self.options, {
				option = option,
				vote = args[key .. 'vote'] or self:message(
					'vote-default',
					{i},
					true
				),
				color = args[key .. 'color'] or self:getDefaultColor(i),
			})
			i = i + 1
		end
		if #self.options < 2 then
			error(self:message('not-enough-options-error'))
		end
	end

	-- Check for duplicate vote text
	do
		local votes = {}
		for i, option in ipairs(self.options) do
			if votes[option.vote] then
				error(self:message(
					'duplicate-vote-text-error',
					{votes[option.vote], i},
					true
				))
			else
				votes[option.vote] = i
			end
		end
	end

	-- Count votes
	for i, option in ipairs(self.options) do
		self.options[i].count = self:countVote(i)
	end

	-- Find total number of votes
	do
		local total = 0
		for i, option in ipairs(self.options) do
			total = total + option.count
		end
		self.voteTotal = total
	end

	-- Calculate percentages
	for i, option in ipairs(self.options) do
		self.options[i].percentage = option.count / self.voteTotal * 100
	end

	return self
end

function Poll:message(key, params, isPreprocessed)
	local msg = self.cfg.msg[key]
	if params and #params > 0 then
		msg = mw.message.newRawMessage(msg, params):plain()
	end
	if isPreprocessed then
		msg = self.frame:preprocess(msg)
	end
	return msg
end

function Poll:getVoteText(n)
	return self.options[n].vote
end

function Poll:countVote(n)
	return mStringCount._count{
		page = self.votePage,
		search = self:getVoteText(n)
	}
end

function Poll:getDefaultColor(n)
	-- Get the default color for option n
	local colors = self.cfg.colors
	-- colors[#colors] is necessary as Lua arrays are indexed starting at 1,
	-- and n % #colors might sometimes equal 0.
	return colors[n] or colors[n % #colors] or colors[#colors]
end

function Poll:renderHeader()
	local headerDiv = mw.html.create('div')
	headerDiv
		:css('border-top', '1px solid #CCC')
		:css('font-family', 'Georgia, Palatino, Palatino Linotype, Times, Times New Roman, serif')
		:css('color', '#333')
		:css('padding', '5px 0px')
		:css('line-height', '120%')
		:wikitext(string.format(
			'[[File:%s|right|30px|link=]]',
			self.icon
		))
		:tag('span')
			:css('text-transform', 'uppercase')
			:css('color', '#999')
			:css('font-size', '105%')
			:css('font-weight', 'bold')
			:wikitext(self.headerText)
	return headerDiv
end

function Poll:renderQuestion()
	local question = mw.html.create('div')
		:css('margin-top', '10px')
		:css('margin-bottom', '10px')
		:css('line-height', '100%')
		:css('font-size', '95%')
		:wikitext(self.question)
	return question
end

function Poll:renderVisualization()
	local vzn = mw.html.create('table')

	-- Overlay row
	vzn
		:css('height', '250px')
		:css('border-spacing', '0px')
		:tag('tr')
			:tag('td')
				:css('position', 'absolute')
				:css('z-index', '2')
				:css('padding', '0px')
				:css('margin', '0px')
				:wikitext(string.format(
					'[[File:%s|253px|link=]] &nbsp;',
					self.overlay
				))

	-- Option color rows
	for i, option in ipairs(self.options) do
		vzn:tag('tr'):tag('td')
			:css('background', option.color)
			:css('padding', '0px')
			:css('margin', '0px')
			:css('width', '250px')
			:css('height', string.format(
				'%.3f%%', -- Round to 3 decimal places and add a percent sign
				option.percentage
			))
			:wikitext('&nbsp;')
	end
	
	return vzn
end

function Poll:renderLegend()
	local legend = mw.html.create('table')
	for i, option in ipairs(self.options) do
		legend:tag('tr'):tag('td')
			:tag('span')
				:css('display', 'inline-block')
				:css('width', '1.5em')
				:css('height', '1.5em')
				:css('margin', '1px 0')
				:css('border', '1px solid black')
				:css('background-color', option.color)
				:css('text-align', 'center')
				:wikitext('&nbsp;')
				:done()
			:wikitext('&nbsp;')
			:wikitext(self:message('legend-option-text', {
				option.option,
				option.count,
				string.format('%.0f', option.percentage)
			}, true))
	end
	return legend
end

function Poll:makeVoteURL(n)
	local url = mw.uri.fullUrl(
		self.votePage,
		{
			action = 'edit',
			section = 'new',
			nosummary = 'true',
			preload = self.preload,
			['preloadparams[]'] = self:getVoteText(n)
		}
	)
	return tostring(url)
end

function Poll:hasLineBreaks()
	-- Try to auto-detect whether we should have line breaks
	if self.lineBreak then
		return yesno(self.lineBreak) or true
	end
	local nOptions = #self.options
	if nOptions > 3 then
		return false
	end
	local wordCount = 0
	for i, option in ipairs(self.options) do
		wordCount = wordCount + mw.ustring.len(option.option)
	end
	if nOptions == 3 then
		return wordCount >= 12
	else
		return wordCount >= 15
	end
end

function Poll:renderButtons()
	local hasBreaks = self:hasLineBreaks()
	local buttonRoot = mw.html.create('div')
		:css('margin-top', '5px')
	if not hasBreaks then
		buttonRoot:addClass('center')
	end
	for i, option in ipairs(self.options) do
		buttonRoot:wikitext(makeButton(
			self:makeVoteURL(i),
			option.option
		))
		if hasBreaks then
			buttonRoot:tag('div'):css('clear', 'both')
		end			
	end
	return buttonRoot
end

function Poll:renderWarning(s)
	local warning = mw.html.create('div')
		:css('color', 'red')
		:css('line-height', '90%')
		:css('width', '85%')
		:css('font-size', '85%')
		:css('text-align', 'center')
		:css('margin-top', '5px')
		:wikitext(s)
	return warning
end

function Poll:hasMinimumVoteCount()
	return self.voteTotal >= self.minimum
end

function Poll:isOpen()
	if self.expiry then
		return getUnixDate() < getUnixDate(self.expiry)
	else
		return true
	end
end

function Poll:__tostring()
	local root = mw.html.create('div')
		:css('width', '270px')
		:css('float', 'right')
		:css('clear', 'right')
		:css('background', 'none')
		:css('margin-bottom', '10px')
		:css('margin-left', '10px')
		:addClass('signpost-sidebar')

	root:node(self:renderHeader())
	root:node(self:renderQuestion())

	local centered = root:tag('div')
		:css('margin-left', 'auto')
		:css('margin-right', 'auto')

	-- Visualization and legend
	if self:hasMinimumVoteCount() then
		centered:node(self:renderVisualization())
		centered:node(self:renderLegend())
	else
		centered:node(self:renderWarning(self:message(
			'not-enough-votes-warning',
			{self.minimum - self.voteTotal},
			true
		)))
	end

	-- Buttons
	if self:isOpen() then
		centered:node(self:renderButtons())
	else
		centered:node(self:renderWarning(self:message('poll-closed-warning')))
	end

	return tostring(root)
end

-------------------------------------------------------------------------------
-- Exports
-------------------------------------------------------------------------------

local p = {}

function p._main(args, cfg, frame)
	cfg = cfg or mw.loadData(CONFIG_MODULE)
	return tostring(Poll.new(args, cfg, frame))
end

function p.main(frame, cfg)
	cfg = cfg or mw.loadData(CONFIG_MODULE)
	local args = require('Module:Arguments').getArgs(frame, {
		wrappers = cfg.wrappers
	})
	return p._main(args, cfg, frame)
end

return p