Jump to content

Module:votes

From Wiktionary, the free dictionary

This module generates the vote count in WT:Votes/Active and {{votes}}.

Use the function status to get the vote count.

The Lua code below should be completely commented.


local export = {}

local find_templates = require("Module:template parser").find_templates
local insert = table.insert
local sort = table.sort
local split

-- This function returns the full vote page.
local function getFullPage (pageName)
	local pageObject = mw.title.new (pageName)
	if pageObject.isRedirect then
		error(pageName .. " is a redirect.")
	end
	return pageObject:getContent()
end

local function errorMsg(msg)
	return '<span class="maintenance-line" style="color: #FF0000;">' .. msg .. "</span>"
end

-- This function returns the portion of a vote page with the actual votes, without the vote description and the decision.
local function pageExcerpt (pageName)
	-- Get the text between "Enter '# {{support}} ~~~~' on next blank line",
	-- which appears in the first support section, and the "Decision" section,
	-- which appears below the votes.
	local page = getFullPage (pageName)
	
	if not page then
		error("The page '" .. pageName .. "' does not exist.")
	end
	
	return page:match ("Enter '# {{support}} ~~~~' on next blank line(.+)====? *Decision *====?")
		or error("Part of " .. pageName .. " with votes not found.")
end

function normalizeNamespace(namespace)
	return namespace:lower():gsub("_", " ")
end

function isUsernameIpV4(username)
	local parts = { username:match("^([0-9]+)%.([0-9]+)%.([0-9]+)%.([0-9]+)$") }
	if not parts[1] then return false end
	
	for i = 1, 4 do
		local part = parts[i]
		
		-- [0, 255]
		if tonumber(part) > 255 then return false end
	
		-- no leading zero
		if part:match("0[0-9]") then return false end
	end
	
	return true
end

function isUsernameIpV6(username)
	if not split then split = require("Module:string utilities").split end
	local parts = split(username, ":", true, true)
	if #parts < 2 or #parts > 8 then return false end
	
	-- cannot start with a single :
	if #parts[1] == 0 and #parts[2] > 0 then return false end
	
	local compressed = false
	for i = 1, #parts do
		local part = parts[i]
		if #part == 0 then -- ::
			-- trailing colon only allowed in trailing ::
			if compressed == (i < #parts) then
				-- can have only one ::
				return false
			-- do not record an empty section if it is at the beginning, since leading :: is valid
			elseif i > 1 then
				compressed = true
			end
		-- check for hex characters, 1-4
		elseif not part:match("^[0-9A-Fa-f]+$") or #part > 4 then
			return false
		end
	end
	
	return true
end

function isUsernameIp(username)
	-- iPv4
	return isUsernameIpV4(username) or isUsernameIpV6(username)
end

-- Processes all links that may have a namespace, stores each username in a link
-- to a "User" or "User talk" page, and returns the last username encountered,
-- or else nil.
-- Removes fragments (the part after #) and link text (the part after | if the
-- link is piped).
-- Consider the text:
-- # {{support}} per [[User:Example1]] --[[User:Example2|Example2]] ([[User talk:Example3#Fragment|talk]]) 8:00 pm, 12 September 2015, Saturday (4 years, 5 months, 3 days ago) (UTC−5)
-- It has three "User" or "User talk" links.
-- The username "Example3" from the last link, [[User talk:Example3#Fragment|talk]],
-- is returned.
-- Sometimes, a person has only the link to the user page or the talk page,
-- this makes sure their votes are counted too.
function getLastUsername(text)
	local username
	for potentialNamespace, potentialUser in text:gmatch("%[%[([%a_ ]+):([^|%[%]#|]+).-%]%]") do
		potentialNamespace = normalizeNamespace(potentialNamespace)
		if potentialNamespace == "user" or potentialNamespace == "user talk" then
			username = mw.text.trim(potentialUser)
		end
	end
	if not username then
		-- If {{unsigned}} is present, parse the user name from that, unless it is an IP.
		for template in find_templates(text) do
			if template:get_name() == "unsigned" then
				local args = template:get_arguments()
				if args[1] and not isUsernameIp(args[1]) then
					username = args[1]
				end
			end
		end
	end
	return username
end

-- This function returns a variable with a list of all people who voted in the page.
function countVotes (pageName)
	local votesText = pageExcerpt (pageName)
	
	-- Initializes new fields to 0.
	local votes = setmetatable({}, {
		__index = function(self, key)
			self[key] = 0
			return 0
		end
	})

	local support, oppose, abstain = {}, {}, {}
	local stances = {
		["support"] = support,
		["strong support"] = support,
		["weak support"] = support,
		["oppose"] = oppose,
		["strong oppose"] = oppose,
		["weak oppose"] = oppose,
		["abstain"] = abstain
	}
	

	-- Iterates over all lines that start with a "#" but not with "##", "#*" or
	-- "#:", which excludes discussion and crossed-out votes.
	-- Example of accepted line: # {{support}} --[[User:Example|Example]] ([[User talk:Example|talk]]) 9:16 pm, 25 November 2015, Wednesday (4 years, 2 months, 21 days ago) (UTC−6)
	for numberedLine in votesText:gmatch("%f[^\n%z]#([^#*:][^\n]*)") do
		local username = getLastUsername(numberedLine)

		if username then
			votes[username] = votes[username] + 1;

			-- Find the first instance of {{support}}, {{oppose}}, or
			-- {{abstain}} and add username to the count for that stance.
			for template in find_templates(numberedLine) do
				local votes = stances[template:get_name()]
				if votes then
					insert(votes, username)
					break
				end
			end
		end
	end

	return votes, stances
end

-- This function returns the start date of a vote, in the format of "Dec 9".
function startDate (pageName)
	local fullPage = getFullPage(pageName)

	local startDateFull = string.match (fullPage, "Vote start.-%(UTC%)")
	local startDateTable = mw.text.split (startDateFull, "Vote start.-: ")
	local languageObject = mw.language.new ("en")
	local ok, startDate = pcall(function() return languageObject:formatDate ("M j", startDateTable[2]) end)

	return ok and startDate or errorMsg("Unable to format date '" .. (startDateTable[2] or "nil") .. "'")
end

-- Returns the status of a vote. Either it is a premature vote, or it is the number of votes.
function export.status (frame)
	local fullPage = getFullPage(frame.args[1])

	local votes, stances = countVotes (frame.args[1])
	local count = select(2, string.gsub(fullPage, "==== Support", "==== Support"))
	local isMultipleVotes = count > 1
	local allVotes = 0
	local uniqueVoters = 0
	local uniqueVoterNames = {}

	for username, voteQuantity in pairs(votes) do 
		allVotes = allVotes + voteQuantity
		uniqueVoters = uniqueVoters + 1
		if isMultipleVotes then
			insert(uniqueVoterNames, username .. " (" .. voteQuantity .. ")")
		else
			insert(uniqueVoterNames, username)
		end
	end

	sort(uniqueVoterNames)
	
	local status = ""
	local p = ""

	if uniqueVoters == 1 then	 
		p = "person"
	else
		p = "people"
	end

	if isMultipleVotes then
		status = allVotes .. " (" .. uniqueVoters .. " " .. p .. ")"
	else
		status =
			"[[Image:Symbol support vote.svg|10px|link=]]" .. #stances.support
			.. " [[Image:Symbol oppose vote.svg|10px|link=]]" .. #stances.oppose
			.. " [[Image:Symbol abstain vote.svg|10px|link=]]" .. #stances.abstain
	end

	-- Checks for {{premature}}, which marks unstarted votes. If the current page is an unstarted vote, add the start date.
	if string.find (fullPage, "{{premature}}") then
		status = "starts: " .. startDate (frame.args[1])
	end

	-- If there are votes, then formats the end result with <span></span> and the title text with the list of voters.
	if allVotes > 0 then
		status = '<span title="' .. mw.text.listToText(uniqueVoterNames, ", ", ", ") .. '" style="border-bottom: dotted 1px black">' .. status .. "</span>"
	end

	return status
end

return export