Module:Signpost poll

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

local CONFIG_MODULE = 'Module:Signpost poll/config'

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

--- -- Message method -- This method is available in every class, so it is defined separately. ---

local function message(self, 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

--- -- Option class ---

local Option = {} Option.__index = Option Option.message = message

function Option.new(t) local self = setmetatable({}, Option) self.cfg = t.cfg self.frame = t.frame self.nOption = t.nOption self.votePage = t.votePage self.preload = t.preload self.text = t.text self.voteText = t.voteText self.color = t.color return self end

function Option:getCount if self.count then return self.count else self.count = mw.getCurrentFrame:expandTemplate{title="String count",args={ page = self.votePage, search = self:getVoteText(n) }}		return self.count end end

function Option:setVoteTotal(n) self.total = n end

function Option:getVoteTotal return self.total or error('total number of votes has not been set') end

function Option:getPercentage if self.percentage then return self.percentage else self.percentage = self:getCount / self:getVoteTotal * 100 return self.percentage end end

function Option:getColor -- Get the default color for option n	if self.color then return self.color end local colors = self.cfg.colors local color = colors[self.nOption] if color then self.color = color else -- Loop to find the length of colors. We can't use the # operator as -- a metatable is set by mw.loadData. This is bad for polls with -- more options than there are colors in the config, as we would loop -- for every single option object. This will likely never be a problem -- in practice, however. local nColors = 0 for i in ipairs(colors) do			nColors = i		end -- colors[nColors] is necessary as Lua arrays are indexed starting at -- 1, and n % self.nColors might sometimes equal 0. self.color = colors[self.nOption % nColors] or colors[nColors] end return self.color end

function Option:getVoteText self.voteText = self.voteText or self:message(		'vote-default',		{self.nOption},		true	) return self.voteText end

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

function Option:renderButton local button = mw.html.create('span') :addClass('mw-ui-button mw-ui-progressive') :attr('role', 'button') :attr('aria-disabled', 'false') :wikitext(self.text) local wrapper = mw.html.create('span') :addClass('plainlinks') :css('margin', '0 4px') :wikitext(string.format( '[%s %s]', self:makeVoteURL, tostring(button) ))	return wrapper end

function Option:renderLegendRow local legend = mw.html.create('div') legend :css('margin', '4px') :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', self:getColor) :css('text-align', 'center') :wikitext(' ') :done :wikitext(' ') :wikitext(self:message('legend-option-text', { self.text, self:getCount, string.format('%.0f', self:getPercentage) }, true)) return legend end

--- -- Poll class ---

local Poll = {} Poll.__index = Poll Poll.message = message

function Poll.new(args, cfg, frame) local self = setmetatable({}, Poll) self.cfg = cfg or mw.loadData(CONFIG_MODULE) 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.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 preload = self:message('preload-page') local i = 1 while true do local key = 'option' .. tostring(i) local text = args[key] if not text then break end table.insert(self.options, Option.new{				nOption = i,				text = text,				voteText = args[key .. 'vote'],				color = args[key .. 'color'],				cfg = self.cfg,				frame = self.frame,				votePage = self.votePage,				preload = preload			}) 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 option in self:iterateOptions do			if votes[option:getVoteText] then error(self:message( 'duplicate-vote-text-error', {votes[option:getVoteText], option.nOption}, true ))			else votes[option:getVoteText] = option.nOption end end end

-- Prompt users to create the vote page if it doesn't exist. do local success, votePageContent = pcall(function 			return mw.title.new(self.votePage):getContent		end) if not success or not votePageContent then local createVotePageUrl = mw.uri.fullUrl(				self.votePage,				{					action = 'edit',					preload = self:message('vote-page-preload-default'),					['preloadparams[]'] = mw.title.getCurrentTitle.prefixedText,					summary = self:message('vote-page-create-summary'),					editintro = self:message('vote-page-create-editintro')				}			) error(self:message( 'votepage-nonexistent-error', {tostring(createVotePageUrl)} ), 0)		end end

-- Find total number of votes do local total = 0 for option in self:iterateOptions do			total = total + option:getCount end for option in self:iterateOptions do			option:setVoteTotal(total) end self.voteTotal = total end

return self end

-- Static methods

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

-- Normal methods

function Poll:iterateOptions local i = 0 local n = #self.options return function i = i + 1 if i <= n then return self.options[i] end end 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 0') :css('line-height', '120%') :wikitext(string.format( '',			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 overlayWidth = '253px' local vzn = mw.html.create('div') :css('height', '250px') :css('border-spacing', '0') :css('width', overlayWidth) :css('margin-left', 'auto') :css('margin-right', 'auto')

-- Overlay vzn :tag('div') :css('position', 'absolute') :css('z-index', '2') :css('padding', '0') :css('margin', '0') :wikitext(string.format( ' ',				self.overlay, overlayWidth ))

-- Option colors for option in self:iterateOptions do		vzn:tag('div') :css('background', option:getColor) :css('padding', '0') :css('margin', '0') :css('width', '250px') :css('height', string.format( '%.3f%%', -- Round to 3 decimal places and add a percent sign option:getPercentage ))			:wikitext(' ') end return vzn end

function Poll:renderLegend local legend = mw.html.create('div') :css('margin-top', '3px') :css('display', 'flex') :css('justify-content', 'center') local centered = legend:tag('div') for option in self:iterateOptions do		centered:node(option:renderLegendRow) end return legend 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 true end local wordCount = 0 for option in self:iterateOptions do		wordCount = wordCount + mw.ustring.len(option.text) end if nOptions == 3 then return wordCount >= 12 else return wordCount >= 15 end end

function Poll:renderButtons local hasBreaks = self:hasLineBreaks local buttons = mw.html.create('div') :css('margin-top', '5px') :css('display', 'flex') :css('justify-content', 'center') local centered = buttons:tag('div') if not hasBreaks then centered:css('text-align', 'center') end for option in self:iterateOptions do		local button if hasBreaks then button = centered:tag('div') :css('margin', '4px 0') else button = centered end button:node(option:renderButton) end return buttons end

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

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

function Poll:isOpen if self.expiry then return self.getUnixDate < self.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)

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

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

return tostring(root) end

--- -- Exports ---

local p = {}

function p._main(args, cfg, frame) 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