Loading
  • 21 Aug, 2019

  • By, Wikipedia

Module:Rfx

This is a library for getting information about individual requests for adminship (RfA) and requests for bureaucratship (RfB) pages on the English Wikipedia. It is not meant to be used directly from wiki pages, but rather to be used by other Lua modules.

Creating new objects

First of all, the library must be loaded, like this:

local rfx = require( 'Module:Rfx' )

Once the library is loaded, you can make a new rfx object using rfx.new(). Caution - this function is expensive (see below).

rfx.new() is used like this:

local myRfx = rfx.new( pagename )

The pagename variable should be the name of a valid RfA or RfB page, for example:

local exampleRfa = rfx.new( 'Wikipedia:Requests for adminship/Example' )

If pagename is not specified, or the page is not a subpage of Wikipedia:Requests for adminship or Wikipedia:Requests for bureaucratship, then rfx.new will return nil.

Methods and properties

Once you have created a new rfx object, there are a number of methods and properties that you can use. They are all read-only.

Properties
  • type: the type of the rfx. This is either "rfa" or "rfb".
  • supports: the number of supports in the RfX. nil if the supports could not be processed.
  • opposes: the number of opposes in the RfX. nil if the opposes could not be processed.
  • neutrals: the number of neutrals in the RfX. nil if the neutrals could not be processed.
  • percent: the support percentage. Calculated by and rounded to the nearest integer. nil if it could not be processed.
  • endTime: the end time of the RfX. This is a string value taken from the RfX page. nil if it could not be found.
  • user: the username of the RfX candidate. nil if it could not be found.
Methods

Methods must be called with the colon syntax:

local titleObject = exampleRfa:getTitleObject()
  • getTitleObject(): gets the title object for the RfX page. See the reference manual for details on how to use title objects.
  • getSupportUsers(): gets an array containing the usernames that supported the RfX. If any usernames could not be processed, the text "Error parsing signature" is used instead, along with the text of the comment in question. N.b. this technique relies on the text of comment text being unique - if it is not unique then dupesExist() will treat the identical comments as duplicate votes. If the page content could not be parsed at all, this method returns nil.
  • getOpposeUsers(): gets an array containing the usernames that opposed the RfX. Functions similarly to getSupportUsers().
  • getNeutralUsers(): gets an array containing the usernames that were neutral at the RfX. Functions similarly to getSupportUsers().
  • dupesExist(): returns a boolean indicating whether there were any duplicate votes at the RfX. Returns nil if the vote tables couldn't be processed.
  • getSecondsLeft(): returns the number of seconds left before the RfX is due to close. Once it is due to close, shows zero. If the ending time cannot be found, returns nil.
  • getTimeLeft(): returns a string showing the time left before the RfX is due to close. The string is in the format "x days, y hours".
  • getReport(): returns a URI object for X!'s RfA Analysis tool at Wikimedia Labs, preloaded with the RfX page.
  • getStatus(): returns a string showing the current status of the RfX. This can be "successful", "unsuccessful", "open", or "pending closure". Returns nil if the status could not be determined.

You can compare rfx objects with the == operator. This will return true only if the two objects point to the same page. tostring( rfx ) will return prefixedTitle from the RfX page's title object (see the reference manual).

Expensive functions

This module makes use of the title:getContent method to fetch RfX page sources. This method will be called for each RfX page being looked up, so each use of rfx.new will count as an expensive function call. Please be aware that the library may fail for scripts which create many different RfX objects. (The current limit for the English Wikipedia is 500 expensive function calls per page.) Also, each RfX page that is looked up will count as a transclusion in Special:WhatLinksHere.

----------------------------------------------------------------------
--                          Module:Rfx                              --
-- This is a library for retrieving information about requests      --
-- for adminship and requests for bureaucratship on the English     --
-- Wikipedia. Please see the module documentation for instructions. --
----------------------------------------------------------------------

local libraryUtil = require('libraryUtil')
local lang = mw.getContentLanguage()
local textSplit = mw.text.split
local umatch = mw.ustring.match
local newTitle = mw.title.new

local rfx = {}

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

local function getTitleObject(title)
	local success, titleObject = pcall(newTitle, title)
	if success and titleObject then
		return titleObject
	else
		return nil
	end
end

local function parseVoteBoundaries(section)
	-- Returns an array containing the raw wikitext of RfX votes in a given section.
	section = section:match('^.-\n#(.*)$') -- Strip non-votes from the start.
	if not section then
		return {}
	end
	section = section:match('^(.-)\n[^#]') or section -- Discard subsequent numbered lists.
	local comments = textSplit(section, '\n#')
	local votes = {}
	for i, comment in ipairs(comments) do
		if comment:find('^[^#*;:].*%S') then
			votes[#votes + 1] = comment
		end
	end
	return votes
end

local function parseVote(vote)
	-- parses a username from an RfX vote.
	local userStart, userMatch = vote:match('^(.*)%[%[[%s_]*:?[%s_]*[uU][sS][eE][rR][%s_]*:[%s_]*(.-)[%s_]*%]%].-$')
	local talkStart, talkMatch = vote:match('^(.*)%[%[[%s_]*:?[%s_]*[uU][sS][eE][rR][%s_]+[tT][aA][lL][kK][%s_]*:[%s_]*(.-)[%s_]*%]%].-$')
	local contribStart, contribMatch = vote:match('^(.*)%[%[[%s_]*:?[%s_]*[sS][pP][eE][cC][iI][aA][lL][%s_]*:[%s_]*[cC][oO][nN][tT][rR][iI][bB][uU][tT][iI][oO][nN][sS]/[%s_]*(.-)[%s_]*%]%].-$')
	local username
	if userStart and talkStart then
		if #userStart > #talkStart then
			username = userMatch
		else
			username = talkMatch
		end
	elseif userStart then
		username = userMatch
	elseif talkStart then
		username = talkMatch
	elseif contribStart then
		username = contribMatch
	else
		return string.format( "'''Error parsing signature''': ''%s''", vote )
	end
	username = username:match('^[^|/#]*')
	return username
end

local function parseVoters(votes)
	local voters = {}
	for i, vote in ipairs(votes) do
		voters[#voters + 1] = parseVote(vote)
	end
	return voters
end

local function dupesExist(...)
	local exists = {}
	local tables = {...}
	for i, usernames in ipairs(tables) do
		for j, username in ipairs(usernames) do
			username = lang:ucfirst(username)
			if exists[username] then
				return true
			else
				exists[username] = true
			end
		end
	end
	return false
end

local function hasCategory(category, catList)
	for _, c in ipairs(catList) do
		if c == category then
			return true
		end
	end
	
	return false
end

------------------------------------------
--   Define the constructor function    --
------------------------------------------

function rfx.new(title)
	local obj = {}
	local data = {}
	local checkSelf = libraryUtil.makeCheckSelfFunction( 'Module:Rfx', 'rfx', obj, 'rfx object' )
	
	-- Get the title object and check to see whether we are a subpage of WP:RFA or WP:RFB.
	title = getTitleObject(title)
	if not title then
		return nil
	end
	
	function data:getTitleObject()
		checkSelf(self, 'getTitleObject')
		return title
	end
	
	if title.namespace == 4 then
		local rootText = title.rootText
		if rootText == 'Requests for adminship' then
			data.type = 'rfa'
		elseif rootText == 'Requests for bureaucratship' then
			data.type = 'rfb'
		else
			return nil
		end
	else
		return nil
	end

	-- Get the page content and divide it into sections.
	local pageText = title:getContent()
	if not pageText then
		return nil
	end
	local introText, supportText, opposeText, neutralText = umatch(
		pageText,
		'^(.-)\n====[^=\n][^\n]-====.-'
		.. '\n=====%s*[sS]upport%s*=====(.-)'
		.. '\n=====%s*[oO]ppose%s*=====(.-)'
		.. '\n=====%s*[nN]eutral%s*=====(.-)$'
	)
	if not introText then
		introText, supportText, opposeText, neutralText = umatch(
			pageText,
			"^(.-\n'''[^\n]-%(%d+/%d+/%d+%)[^\n]-''')\n.-"
			.. "\n'''Support'''(.-)\n'''Oppose'''(.-)\n'''Neutral'''(.-)"
		)
	end
	
	-- Switch to reconfirmation request for adminship if in that category
	local categories = title.categories
	if hasCategory('Reconfirmation requests for adminship', categories) then
		data.type = 'rrfa'
	end

	-- Get vote counts.
	local supportVotes, opposeVotes, neutralVotes
	if supportText and opposeText and neutralText then
		supportVotes = parseVoteBoundaries(supportText)
		opposeVotes = parseVoteBoundaries(opposeText)
		neutralVotes = parseVoteBoundaries(neutralText)
	end
	local supports, opposes, neutrals
	if supportVotes and opposeVotes and neutralVotes then
		supports = #supportVotes
		data.supports = supports
		opposes = #opposeVotes
		data.opposes = opposes
		neutrals = #neutralVotes
		data.neutrals = neutrals
	end

	-- Voter methods and dupe check.

	function data:getSupportUsers()
		checkSelf(self, 'getSupportUsers')
		if supportVotes then
			return parseVoters(supportVotes)
		else
			return nil
		end
	end

	function data:getOpposeUsers()
		checkSelf(self, 'getOpposeUsers')
		if opposeVotes then
			return parseVoters(opposeVotes)
		else
			return nil
		end
	end

	function data:getNeutralUsers()
		checkSelf(self, 'getNeutralUsers')
		if neutralVotes then
			return parseVoters(neutralVotes)
		else
			return nil
		end
	end

	function data:dupesExist()
		checkSelf(self, 'dupesExist')
		local supportUsers = self:getSupportUsers()
		local opposeUsers = self:getOpposeUsers()
		local neutralUsers = self:getNeutralUsers()
		if not (supportUsers and opposeUsers and neutralUsers) then
			return nil
		end
		return dupesExist(supportUsers, opposeUsers, neutralUsers)
	end

	if supports and opposes then
		local total = supports + opposes
		if total <= 0 then
			data.percent = 0
		else
			data.percent = math.floor((supports / total * 100) + 0.5)
		end
	end
	if introText then
		data.endTime = umatch(introText, '(%d%d:%d%d, %d+ %w+ %d+) %(UTC%)')
		data.user = umatch(introText, '===%s*%[%[[_%s]*[wW]ikipedia[_%s]*:[_%s]*[rR]equests[_ ]for[_ ]%w+/.-|[_%s]*(.-)[_%s]*%]%][_%s]*===')
		if not data.user then
			data.user = umatch(introText, '===%s*([^\n]-)%s*===')
		end
	end
	
	-- Methods for seconds left and time left.
	
	function data:getSecondsLeft()
		checkSelf(self, 'getSecondsLeft')
		local endTime = self.endTime
		if not endTime then
			return nil
		end
		local now = tonumber(lang:formatDate("U"))
		local success, endTimeU = pcall(lang.formatDate, lang, 'U', endTime)
		if not success then
			return nil
		end
		endTimeU = tonumber(endTimeU)
		if not endTimeU then
			return nil
		end
		local secondsLeft = endTimeU - now
		if secondsLeft <= 0 then
			return 0
		else
			return secondsLeft
		end
	end

	function data:getTimeLeft()
		checkSelf(self, 'getTimeLeft')
		local secondsLeft = self:getSecondsLeft()
		if not secondsLeft then
			return nil
		end
		return mw.ustring.gsub(lang:formatDuration(secondsLeft, {'days', 'hours'}), ' and', ',')
	end
	
	function data:getReport()
		-- Gets the URI object for Vote History tool
		checkSelf(self, 'getReport')
		return mw.uri.new('https://apersonbot.toolforge.org/vote-history?page=' .. mw.uri.encode(title.prefixedText))
	end
	
	function data:getStatus()
		-- Gets the current status of the RfX. Returns either "successful", "unsuccessful",
		-- "open", or "pending closure". Returns nil if the status could not be found.
		checkSelf( self, 'getStatus' )
		local rfxType = data.type
		if rfxType == 'rfa' or rfxType == 'rrfa' then
			if hasCategory('Successful requests for adminship', categories) then
				return 'successful'
			elseif hasCategory('Unsuccessful requests for adminship', categories) then
				return 'unsuccessful'
			end
		elseif rfxType == 'rfb' then
			if hasCategory('Successful requests for bureaucratship', categories) then
				return 'successful'
			elseif hasCategory('Unsuccessful requests for bureaucratship', categories) then
				return 'unsuccessful'
			end
		end
		local secondsLeft = self:getSecondsLeft()
		if secondsLeft and secondsLeft > 0 then
			return 'open'
		elseif secondsLeft and secondsLeft <= 0 then
			return 'pending closure'
		else
			return nil
		end
	end
	
	-- Specify which fields are read-only, and prepare the metatable.
	local readOnlyFields = {
		getTitleObject = true,
		['type'] = true,
		getSupportUsers = true,
		getOpposeUsers = true,
		getNeutralUsers = true,
		supports = true,
		opposes = true,
		neutrals = true,
		endTime = true,
		percent = true,
		user = true,
		dupesExist = true,
		getSecondsLeft = true,
		getTimeLeft = true,
		getReport = true,
		getStatus = true
	}
	
	local function pairsfunc( t, k )
		local v
		repeat
			k = next( readOnlyFields, k )
			if k == nil then
				return nil
			end
			v = t[k]
		until v ~= nil
		return k, v
	end

	return setmetatable( obj, {
		__pairs = function ( t )
			return pairsfunc, t, nil
		end,
		__index = data,
		__newindex = function( t, key, value )
			if readOnlyFields[ key ] then
				error( 'index "' .. key .. '" is read-only', 2 )
			else
				rawset( t, key, value )
			end
		end,
		__tostring = function( t )
			return t:getTitleObject().prefixedText
		end
	} )
end

return rfx