Difference between revisions of "Module:Protection banner"
		
		
		
		
		
		
		Jump to navigation
		Jump to search
		
				
		
		
		
		
		
		
		
		
	
imported>Mr. Stradivarius  (try fairly ugly hack for office protection)  | 
				m (1 revision imported)  | 
				||
| (205 intermediate revisions by 6 users not shown) | |||
| Line 2: | Line 2: | ||
-- {{pp-dispute}}, {{pp-vandalism}} and {{pp-sock}}.  | -- {{pp-dispute}}, {{pp-vandalism}} and {{pp-sock}}.  | ||
| − | local   | + | -- Initialise necessary modules.  | 
| − | + | require('Module:No globals')  | |
| − | + | local makeFileLink = require('Module:File link')._main  | |
| − | + | local effectiveProtectionLevel = require('Module:Effective protection level')._main  | |
| − | + | local effectiveProtectionExpiry = require('Module:Effective protection expiry')._main  | |
| − | + | local yesno = require('Module:Yesno')  | |
| − | + | ||
| − | + | -- Lazily initialise modules and objects we don't always need.  | |
| − | + | local getArgs, makeMessageBox, lang  | |
| − | + | ||
| − | + | -- Set constants.  | |
| − | + | local CONFIG_MODULE = 'Module:Protection banner/config'  | |
| − | + | ||
| − | + | --------------------------------------------------------------------------------  | |
| − | + | -- Helper functions  | |
| − | + | --------------------------------------------------------------------------------  | |
| − | + | ||
| − | + | local function makeCategoryLink(cat, sort)  | |
| − | + | 	if cat then  | |
| − | + | 		return string.format(  | |
| − | + | 			'[[%s:%s|%s]]',  | |
| − | + | 			mw.site.namespaces[14].name,  | |
| − | + | 			cat,  | |
| − | + | 			sort  | |
| − | + | 		)  | |
| − | + | 	end  | |
| − | + | end  | |
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | |||
| − | 	[  | ||
| − | |||
| − | |||
| − | local   | + | -- Validation function for the expiry and the protection date  | 
| − | + | local function validateDate(dateString, dateType)  | |
| − | + | 	if not lang then  | |
| − | + | 		lang = mw.language.getContentLanguage()  | |
| − | + | 	end  | |
| − | + | 	local success, result = pcall(lang.formatDate, lang, 'U', dateString)  | |
| − | + | 	if success then  | |
| − | + | 		result = tonumber(result)  | |
| − | + | 		if result then  | |
| − | + | 			return result  | |
| + | 		end  | ||
| + | 	end  | ||
| + | 	error(string.format(  | ||
| + | 		'invalid %s: %s',  | ||
| + | 		dateType,  | ||
| + | 		tostring(dateString)  | ||
| + | 	), 4)  | ||
| + | end  | ||
| − | local   | + | local function makeFullUrl(page, query, display)  | 
| − | + | 	return string.format(  | |
| − | + | 		'[%s %s]',  | |
| − | + | 		tostring(mw.uri.fullUrl(page, query)),  | |
| − | + | 		display  | |
| − | + | 	)  | |
| + | end  | ||
| − | local   | + | -- Given a directed graph formatted as node -> table of direct successors,  | 
| − | + | -- get a table of all nodes reachable from a given node (though always  | |
| − | + | -- including the given node).  | |
| − | + | local function getReachableNodes(graph, start)  | |
| − | + | 	local toWalk, retval = {[start] = true}, {}  | |
| − | + | 	while true do  | |
| − | + | 		-- Can't use pairs() since we're adding and removing things as we're iterating  | |
| − | + | 		local k = next(toWalk) -- This always gets the "first" key  | |
| − | + | 		if k == nil then  | |
| − | + | 			return retval  | |
| − | + | 		end  | |
| + | 		toWalk[k] = nil  | ||
| + | 		retval[k] = true  | ||
| + | 		for _,v in ipairs(graph[k]) do  | ||
| + | 			if not retval[v] then  | ||
| + | 				toWalk[v] = true  | ||
| + | 			end  | ||
| + | 		end  | ||
| + | 	end  | ||
| + | end  | ||
--------------------------------------------------------------------------------  | --------------------------------------------------------------------------------  | ||
| − | --   | + | -- Protection class  | 
--------------------------------------------------------------------------------  | --------------------------------------------------------------------------------  | ||
| − | local   | + | local Protection = {}  | 
| − | + | Protection.__index = Protection  | |
| − | + | Protection.supportedActions = {  | |
| + | 	edit = true,  | ||
| + | 	move = true,  | ||
| + | 	autoreview = true,  | ||
| + | 	upload = true  | ||
| + | }  | ||
| − | + | Protection.bannerConfigFields = {  | |
| − | + | 	'text',  | |
| − | + | 	'explanation',  | |
| − | + | 	'tooltip',  | |
| − | + | 	'alt',  | |
| − | + | 	'link',  | |
| − | + | 	'image'  | |
| − | + | }  | |
| − | + | function Protection.new(args, cfg, title)  | |
| − | + | 	local obj = {}  | |
| − | 	local   | + | 	obj._cfg = cfg  | 
| − | + | 	obj.title = title or mw.title.getCurrentTitle()  | |
| − | 	--   | + | 	-- Set action  | 
| − | + | 	if not args.action then  | |
| − | + | 		obj.action = 'edit'  | |
| − | + | 	elseif Protection.supportedActions[args.action] then  | |
| + | 		obj.action = args.action  | ||
	else  | 	else  | ||
| − | + | 		error(string.format(  | |
| + | 			'invalid action: %s',  | ||
| + | 			tostring(args.action)  | ||
| + | 		), 3)  | ||
| + | 	end  | ||
| + | |||
| + | 	-- Set level  | ||
| + | 	obj.level = args.demolevel or effectiveProtectionLevel(obj.action, obj.title)  | ||
| + | 	if not obj.level or (obj.action == 'move' and obj.level == 'autoconfirmed') then  | ||
| + | 		-- Users need to be autoconfirmed to move pages anyway, so treat  | ||
| + | 		-- semi-move-protected pages as unprotected.  | ||
| + | 		obj.level = '*'  | ||
| + | 	end  | ||
| + | |||
| + | 	-- Set expiry  | ||
| + | 	local effectiveExpiry = effectiveProtectionExpiry(obj.action, obj.title)  | ||
| + | 	if effectiveExpiry == 'infinity' then  | ||
| + | 		obj.expiry = 'indef'  | ||
| + | 	elseif effectiveExpiry ~= 'unknown' then  | ||
| + | 		obj.expiry = validateDate(effectiveExpiry, 'expiry date')  | ||
| + | 	end  | ||
| + | |||
| + | 	-- Set reason  | ||
| + | 	if args[1] then  | ||
| + | 		obj.reason = mw.ustring.lower(args[1])  | ||
| + | 		if obj.reason:find('|') then  | ||
| + | 			error('reasons cannot contain the pipe character ("|")', 3)  | ||
| + | 		end  | ||
	end  | 	end  | ||
| − | |||
| − | + | 	-- Set protection date  | |
| − | 	--   | + | 	if args.date then  | 
| − | 	if   | + | 		obj.protectionDate = validateDate(args.date, 'protection date')  | 
| − | |||
	end  | 	end  | ||
| − | 	local   | + | |
| − | + | 	-- Set banner config  | |
| − | + | 	do  | |
| − | + | 		obj.bannerConfig = {}  | |
| − | + | 		local configTables = {}  | |
| + | 		if cfg.banners[obj.action] then  | ||
| + | 			configTables[#configTables + 1] = cfg.banners[obj.action][obj.reason]  | ||
| + | 		end  | ||
| + | 		if cfg.defaultBanners[obj.action] then  | ||
| + | 			configTables[#configTables + 1] = cfg.defaultBanners[obj.action][obj.level]  | ||
| + | 			configTables[#configTables + 1] = cfg.defaultBanners[obj.action].default  | ||
| + | 		end  | ||
| + | 		configTables[#configTables + 1] = cfg.masterBanner  | ||
| + | 		for i, field in ipairs(Protection.bannerConfigFields) do  | ||
| + | 			for j, t in ipairs(configTables) do  | ||
| + | 				if t[field] then  | ||
| + | 					obj.bannerConfig[field] = t[field]  | ||
| + | 					break  | ||
| + | 				end  | ||
| + | 			end  | ||
| + | 		end  | ||
	end  | 	end  | ||
| − | 	return   | + | 	return setmetatable(obj, Protection)  | 
end  | end  | ||
| − | function   | + | function Protection:isProtected()  | 
| − | + | 	return self.level ~= '*'  | |
| − | + | end  | |
| − | + | ||
| − | + | function Protection:isTemporary()  | |
| − | + | 	return type(self.expiry) == 'number'  | |
| − | + | end  | |
| − | 	if not   | + | |
| − | + | function Protection:makeProtectionCategory()  | |
| − | + | 	local cfg = self._cfg  | |
| − | + | 	local title = self.title  | |
| + | |||
| + | 	-- Exit if the page is not protected.  | ||
| + | 	if not self:isProtected() then  | ||
| + | 		return ''  | ||
	end  | 	end  | ||
| − | 	--   | + | 	-- Get the expiry key fragment.  | 
| − | + | 	local expiryFragment  | |
| − | 	--   | + | 	if self.expiry == 'indef' then  | 
| − | 	local   | + | 		expiryFragment = self.expiry  | 
| − | + | 	elseif type(self.expiry) == 'number' then  | |
| − | + | 		expiryFragment = 'temp'  | |
| − | + | 	end  | |
| − | + | ||
| − | + | 	-- Get the namespace key fragment.  | |
| − | + | 	local namespaceFragment = cfg.categoryNamespaceKeys[title.namespace]  | |
| − | + | 	if not namespaceFragment and title.namespace % 2 == 1 then  | |
| − | + | 			namespaceFragment = 'talk'  | |
| − | 		{  | + | 	end  | 
| − | 		{  | + | |
| − | 		{  | + | 	-- Define the order that key fragments are tested in. This is done with an  | 
| − | 		{  | + | 	-- array of tables containing the value to be tested, along with its  | 
| − | 		{  | + | 	-- position in the cfg.protectionCategories table.  | 
| + | 	local order = {  | ||
| + | 		{val = expiryFragment,    keypos = 1},  | ||
| + | 		{val = namespaceFragment, keypos = 2},  | ||
| + | 		{val = self.reason,       keypos = 3},  | ||
| + | 		{val = self.level,        keypos = 4},  | ||
| + | 		{val = self.action,       keypos = 5}  | ||
	}  | 	}  | ||
| − | + | ||
| − | + | 	--[[  | |
| − | + | 	-- The old protection templates used an ad-hoc protection category system,  | |
| − | + | 	-- with some templates prioritising namespaces in their categories, and  | |
| − | + | 	-- others prioritising the protection reason. To emulate this in this module  | |
| − | + | 	-- we use the config table cfg.reasonsWithNamespacePriority to set the  | |
| − | + | 	-- reasons for which namespaces have priority over protection reason.  | |
| − | + | 	-- If we are dealing with one of those reasons, move the namespace table to  | |
| − | + | 	-- the end of the order table, i.e. give it highest priority. If not, the  | |
| − | + | 	-- reason should have highest priority, so move that to the end of the table  | |
| + | 	-- instead.  | ||
| + | 	--]]  | ||
| + | 	table.insert(order, table.remove(order, self.reason and cfg.reasonsWithNamespacePriority[self.reason] and 2 or 3))  | ||
| + | |||
| + | 	--[[  | ||
| + | 	-- Define the attempt order. Inactive subtables (subtables with nil "value"  | ||
| + | 	-- fields) are moved to the end, where they will later be given the key  | ||
| + | 	-- "all". This is to cut down on the number of table lookups in  | ||
| + | 	-- cfg.protectionCategories, which grows exponentially with the number of  | ||
| + | 	-- non-nil keys. We keep track of the number of active subtables with the  | ||
| + | 	-- noActive parameter.  | ||
| + | 	--]]  | ||
| + | 	local noActive, attemptOrder  | ||
| + | 	do  | ||
| + | 		local active, inactive = {}, {}  | ||
| + | 		for i, t in ipairs(order) do  | ||
| + | 			if t.val then  | ||
| + | 				active[#active + 1] = t  | ||
| + | 			else  | ||
| + | 				inactive[#inactive + 1] = t  | ||
| + | 			end  | ||
| + | 		end  | ||
| + | 		noActive = #active  | ||
| + | 		attemptOrder = active  | ||
| + | 		for i, t in ipairs(inactive) do  | ||
| + | 			attemptOrder[#attemptOrder + 1] = t  | ||
| + | 		end  | ||
	end  | 	end  | ||
| − | + | ||
| − | 	--   | + | 	--[[  | 
| − | 	for i,   | + | 	-- Check increasingly generic key combinations until we find a match. If a  | 
| + | 	-- specific category exists for the combination of key fragments we are  | ||
| + | 	-- given, that match will be found first. If not, we keep trying different  | ||
| + | 	-- key fragment combinations until we match using the key  | ||
| + | 	-- "all-all-all-all-all".  | ||
| + | 	--  | ||
| + | 	-- To generate the keys, we index the key subtables using a binary matrix  | ||
| + | 	-- with indexes i and j. j is only calculated up to the number of active  | ||
| + | 	-- subtables. For example, if there were three active subtables, the matrix  | ||
| + | 	-- would look like this, with 0 corresponding to the key fragment "all", and  | ||
| + | 	-- 1 corresponding to other key fragments.  | ||
| + | 	--   | ||
| + | 	--   j 1  2  3  | ||
| + | 	-- i    | ||
| + | 	-- 1   1  1  1  | ||
| + | 	-- 2   0  1  1  | ||
| + | 	-- 3   1  0  1  | ||
| + | 	-- 4   0  0  1  | ||
| + | 	-- 5   1  1  0  | ||
| + | 	-- 6   0  1  0  | ||
| + | 	-- 7   1  0  0  | ||
| + | 	-- 8   0  0  0  | ||
| + | 	--   | ||
| + | 	-- Values of j higher than the number of active subtables are set  | ||
| + | 	-- to the string "all".  | ||
| + | 	--  | ||
| + | 	-- A key for cfg.protectionCategories is constructed for each value of i.  | ||
| + | 	-- The position of the value in the key is determined by the keypos field in  | ||
| + | 	-- each subtable.  | ||
| + | 	--]]  | ||
| + | 	local cats = cfg.protectionCategories  | ||
| + | 	for i = 1, 2^noActive do  | ||
		local key = {}  | 		local key = {}  | ||
| − | 		for j,   | + | 		for j, t in ipairs(attemptOrder) do  | 
| − | 			if   | + | 			if j > noActive then  | 
| − | 				key[  | + | 				key[t.keypos] = 'all'  | 
			else  | 			else  | ||
| − | 				key[  | + | 				local quotient = i / 2 ^ (j - 1)  | 
| + | 				quotient = math.ceil(quotient)  | ||
| + | 				if quotient % 2 == 1 then  | ||
| + | 					key[t.keypos] = t.val  | ||
| + | 				else  | ||
| + | 					key[t.keypos] = 'all'  | ||
| + | 				end  | ||
			end  | 			end  | ||
		end  | 		end  | ||
| − | 		key = table.concat(key, '  | + | 		key = table.concat(key, '|')  | 
| − | |||
		local attempt = cats[key]  | 		local attempt = cats[key]  | ||
		if attempt then  | 		if attempt then  | ||
| − | 			return attempt  | + | 			return makeCategoryLink(attempt, title.text)  | 
| + | 		end  | ||
| + | 	end  | ||
| + | 	return ''  | ||
| + | end  | ||
| + | |||
| + | function Protection:isIncorrect()  | ||
| + | 	local expiry = self.expiry  | ||
| + | 	return not self:isProtected()  | ||
| + | 		or type(expiry) == 'number' and expiry < os.time()  | ||
| + | end  | ||
| + | |||
| + | function Protection:isTemplateProtectedNonTemplate()  | ||
| + | 	local action, namespace = self.action, self.title.namespace  | ||
| + | 	return self.level == 'templateeditor'  | ||
| + | 		and (  | ||
| + | 			(action ~= 'edit' and action ~= 'move')  | ||
| + | 			or (namespace ~= 10 and namespace ~= 828)  | ||
| + | 		)  | ||
| + | end  | ||
| + | |||
| + | function Protection:makeCategoryLinks()  | ||
| + | 	local msg = self._cfg.msg  | ||
| + | 	local ret = { self:makeProtectionCategory() }  | ||
| + | 	if self:isIncorrect() then  | ||
| + | 		ret[#ret + 1] = makeCategoryLink(  | ||
| + | 			msg['tracking-category-incorrect'],  | ||
| + | 			self.title.text  | ||
| + | 		)  | ||
| + | 	end  | ||
| + | 	if self:isTemplateProtectedNonTemplate() then  | ||
| + | 		ret[#ret + 1] = makeCategoryLink(  | ||
| + | 			msg['tracking-category-template'],  | ||
| + | 			self.title.text  | ||
| + | 		)  | ||
| + | 	end  | ||
| + | 	return table.concat(ret)  | ||
| + | end  | ||
| + | |||
| + | --------------------------------------------------------------------------------  | ||
| + | -- Blurb class  | ||
| + | --------------------------------------------------------------------------------  | ||
| + | |||
| + | local Blurb = {}  | ||
| + | Blurb.__index = Blurb  | ||
| + | |||
| + | Blurb.bannerTextFields = {  | ||
| + | 	text = true,  | ||
| + | 	explanation = true,  | ||
| + | 	tooltip = true,  | ||
| + | 	alt = true,  | ||
| + | 	link = true  | ||
| + | }  | ||
| + | |||
| + | function Blurb.new(protectionObj, args, cfg)  | ||
| + | 	return setmetatable({  | ||
| + | 		_cfg = cfg,  | ||
| + | 		_protectionObj = protectionObj,  | ||
| + | 		_args = args  | ||
| + | 	}, Blurb)  | ||
| + | end  | ||
| + | |||
| + | -- Private methods --  | ||
| + | |||
| + | function Blurb:_formatDate(num)  | ||
| + | 	-- Formats a Unix timestamp into dd Month, YYYY format.  | ||
| + | 	lang = lang or mw.language.getContentLanguage()  | ||
| + | 	local success, date = pcall(  | ||
| + | 		lang.formatDate,  | ||
| + | 		lang,  | ||
| + | 		self._cfg.msg['expiry-date-format'] or 'j F Y',  | ||
| + | 		'@' .. tostring(num)  | ||
| + | 	)  | ||
| + | 	if success then  | ||
| + | 		return date  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_getExpandedMessage(msgKey)  | ||
| + | 	return self:_substituteParameters(self._cfg.msg[msgKey])  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_substituteParameters(msg)  | ||
| + | 	if not self._params then  | ||
| + | 		local parameterFuncs = {}  | ||
| + | |||
| + | 		parameterFuncs.CURRENTVERSION     = self._makeCurrentVersionParameter  | ||
| + | 		parameterFuncs.EDITREQUEST        = self._makeEditRequestParameter  | ||
| + | 		parameterFuncs.EXPIRY             = self._makeExpiryParameter  | ||
| + | 		parameterFuncs.EXPLANATIONBLURB   = self._makeExplanationBlurbParameter  | ||
| + | 		parameterFuncs.IMAGELINK          = self._makeImageLinkParameter  | ||
| + | 		parameterFuncs.INTROBLURB         = self._makeIntroBlurbParameter  | ||
| + | 		parameterFuncs.INTROFRAGMENT      = self._makeIntroFragmentParameter  | ||
| + | 		parameterFuncs.PAGETYPE           = self._makePagetypeParameter  | ||
| + | 		parameterFuncs.PROTECTIONBLURB    = self._makeProtectionBlurbParameter  | ||
| + | 		parameterFuncs.PROTECTIONDATE     = self._makeProtectionDateParameter  | ||
| + | 		parameterFuncs.PROTECTIONLEVEL    = self._makeProtectionLevelParameter  | ||
| + | 		parameterFuncs.PROTECTIONLOG      = self._makeProtectionLogParameter  | ||
| + | 		parameterFuncs.TALKPAGE           = self._makeTalkPageParameter  | ||
| + | 		parameterFuncs.TOOLTIPBLURB       = self._makeTooltipBlurbParameter  | ||
| + | 		parameterFuncs.TOOLTIPFRAGMENT    = self._makeTooltipFragmentParameter  | ||
| + | 		parameterFuncs.VANDAL             = self._makeVandalTemplateParameter  | ||
| + | |||
| + | 		self._params = setmetatable({}, {  | ||
| + | 			__index = function (t, k)  | ||
| + | 				local param  | ||
| + | 				if parameterFuncs[k] then  | ||
| + | 					param = parameterFuncs[k](self)  | ||
| + | 				end  | ||
| + | 				param = param or ''  | ||
| + | 				t[k] = param  | ||
| + | 				return param  | ||
| + | 			end  | ||
| + | 		})  | ||
| + | 	end  | ||
| + | |||
| + | 	msg = msg:gsub('${(%u+)}', self._params)  | ||
| + | 	return msg  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeCurrentVersionParameter()  | ||
| + | 	-- A link to the page history or the move log, depending on the kind of  | ||
| + | 	-- protection.  | ||
| + | 	local pagename = self._protectionObj.title.prefixedText  | ||
| + | 	if self._protectionObj.action == 'move' then  | ||
| + | 		-- We need the move log link.  | ||
| + | 		return makeFullUrl(  | ||
| + | 			'Special:Log',  | ||
| + | 			{type = 'move', page = pagename},  | ||
| + | 			self:_getExpandedMessage('current-version-move-display')  | ||
| + | 		)  | ||
| + | 	else  | ||
| + | 		-- We need the history link.  | ||
| + | 		return makeFullUrl(  | ||
| + | 			pagename,  | ||
| + | 			{action = 'history'},  | ||
| + | 			self:_getExpandedMessage('current-version-edit-display')  | ||
| + | 		)  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeEditRequestParameter()  | ||
| + | 	local mEditRequest = require('Module:Submit an edit request')  | ||
| + | 	local action = self._protectionObj.action  | ||
| + | 	local level = self._protectionObj.level  | ||
| + | |||
| + | 	-- Get the edit request type.  | ||
| + | 	local requestType  | ||
| + | 	if action == 'edit' then  | ||
| + | 		if level == 'autoconfirmed' then  | ||
| + | 			requestType = 'semi'  | ||
| + | 		elseif level == 'extendedconfirmed' then  | ||
| + | 			requestType = 'extended'  | ||
| + | 		elseif level == 'templateeditor' then  | ||
| + | 			requestType = 'template'  | ||
		end  | 		end  | ||
	end  | 	end  | ||
| − | 	error('  | + | 	requestType = requestType or 'full'  | 
| − | end			  | + | |
| + | 	-- Get the display value.  | ||
| + | 	local display = self:_getExpandedMessage('edit-request-display')  | ||
| + | |||
| + | 	return mEditRequest._link{type = requestType, display = display}  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeExpiryParameter()  | ||
| + | 	local expiry = self._protectionObj.expiry  | ||
| + | 	if type(expiry) == 'number' then  | ||
| + | 		return self:_formatDate(expiry)  | ||
| + | 	else  | ||
| + | 		return expiry  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeExplanationBlurbParameter()  | ||
| + | 	-- Cover special cases first.  | ||
| + | 	if self._protectionObj.title.namespace == 8 then  | ||
| + | 		-- MediaWiki namespace  | ||
| + | 		return self:_getExpandedMessage('explanation-blurb-nounprotect')  | ||
| + | 	end  | ||
| + | |||
| + | 	-- Get explanation blurb table keys  | ||
| + | 	local action = self._protectionObj.action  | ||
| + | 	local level = self._protectionObj.level  | ||
| + | 	local talkKey = self._protectionObj.title.isTalkPage and 'talk' or 'subject'  | ||
| + | |||
| + | 	-- Find the message in the explanation blurb table and substitute any  | ||
| + | 	-- parameters.  | ||
| + | 	local explanations = self._cfg.explanationBlurbs  | ||
| + | 	local msg  | ||
| + | 	if explanations[action][level] and explanations[action][level][talkKey] then  | ||
| + | 		msg = explanations[action][level][talkKey]  | ||
| + | 	elseif explanations[action][level] and explanations[action][level].default then  | ||
| + | 		msg = explanations[action][level].default  | ||
| + | 	elseif explanations[action].default and explanations[action].default[talkKey] then  | ||
| + | 		msg = explanations[action].default[talkKey]  | ||
| + | 	elseif explanations[action].default and explanations[action].default.default then  | ||
| + | 		msg = explanations[action].default.default  | ||
| + | 	else  | ||
| + | 		error(string.format(  | ||
| + | 			'could not find explanation blurb for action "%s", level "%s" and talk key "%s"',  | ||
| + | 			action,  | ||
| + | 			level,  | ||
| + | 			talkKey  | ||
| + | 		), 8)  | ||
| + | 	end  | ||
| + | 	return self:_substituteParameters(msg)  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeImageLinkParameter()  | ||
| + | 	local imageLinks = self._cfg.imageLinks  | ||
| + | 	local action = self._protectionObj.action  | ||
| + | 	local level = self._protectionObj.level  | ||
| + | 	local msg  | ||
| + | 	if imageLinks[action][level] then  | ||
| + | 		msg = imageLinks[action][level]  | ||
| + | 	elseif imageLinks[action].default then  | ||
| + | 		msg = imageLinks[action].default  | ||
| + | 	else  | ||
| + | 		msg = imageLinks.edit.default  | ||
| + | 	end  | ||
| + | 	return self:_substituteParameters(msg)  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeIntroBlurbParameter()  | ||
| + | 	if self._protectionObj:isTemporary() then  | ||
| + | 		return self:_getExpandedMessage('intro-blurb-expiry')  | ||
| + | 	else  | ||
| + | 		return self:_getExpandedMessage('intro-blurb-noexpiry')  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeIntroFragmentParameter()  | ||
| + | 	if self._protectionObj:isTemporary() then  | ||
| + | 		return self:_getExpandedMessage('intro-fragment-expiry')  | ||
| + | 	else  | ||
| + | 		return self:_getExpandedMessage('intro-fragment-noexpiry')  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makePagetypeParameter()  | ||
| + | 	local pagetypes = self._cfg.pagetypes  | ||
| + | 	return pagetypes[self._protectionObj.title.namespace]  | ||
| + | 		or pagetypes.default  | ||
| + | 		or error('no default pagetype defined', 8)  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeProtectionBlurbParameter()  | ||
| + | 	local protectionBlurbs = self._cfg.protectionBlurbs  | ||
| + | 	local action = self._protectionObj.action  | ||
| + | 	local level = self._protectionObj.level  | ||
| + | 	local msg  | ||
| + | 	if protectionBlurbs[action][level] then  | ||
| + | 		msg = protectionBlurbs[action][level]  | ||
| + | 	elseif protectionBlurbs[action].default then  | ||
| + | 		msg = protectionBlurbs[action].default  | ||
| + | 	elseif protectionBlurbs.edit.default then  | ||
| + | 		msg = protectionBlurbs.edit.default  | ||
| + | 	else  | ||
| + | 		error('no protection blurb defined for protectionBlurbs.edit.default', 8)  | ||
| + | 	end  | ||
| + | 	return self:_substituteParameters(msg)  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeProtectionDateParameter()  | ||
| + | 	local protectionDate = self._protectionObj.protectionDate  | ||
| + | 	if type(protectionDate) == 'number' then  | ||
| + | 		return self:_formatDate(protectionDate)  | ||
| + | 	else  | ||
| + | 		return protectionDate  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeProtectionLevelParameter()  | ||
| + | 	local protectionLevels = self._cfg.protectionLevels  | ||
| + | 	local action = self._protectionObj.action  | ||
| + | 	local level = self._protectionObj.level  | ||
| + | 	local msg  | ||
| + | 	if protectionLevels[action][level] then  | ||
| + | 		msg = protectionLevels[action][level]  | ||
| + | 	elseif protectionLevels[action].default then  | ||
| + | 		msg = protectionLevels[action].default  | ||
| + | 	elseif protectionLevels.edit.default then  | ||
| + | 		msg = protectionLevels.edit.default  | ||
| + | 	else  | ||
| + | 		error('no protection level defined for protectionLevels.edit.default', 8)  | ||
| + | 	end  | ||
| + | 	return self:_substituteParameters(msg)  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeProtectionLogParameter()  | ||
| + | 	local pagename = self._protectionObj.title.prefixedText  | ||
| + | 	if self._protectionObj.action == 'autoreview' then  | ||
| + | 		-- We need the pending changes log.  | ||
| + | 		return makeFullUrl(  | ||
| + | 			'Special:Log',  | ||
| + | 			{type = 'stable', page = pagename},  | ||
| + | 			self:_getExpandedMessage('pc-log-display')  | ||
| + | 		)  | ||
| + | 	else  | ||
| + | 		-- We need the protection log.  | ||
| + | 		return makeFullUrl(  | ||
| + | 			'Special:Log',  | ||
| + | 			{type = 'protect', page = pagename},  | ||
| + | 			self:_getExpandedMessage('protection-log-display')  | ||
| + | 		)  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeTalkPageParameter()  | ||
| + | 	return string.format(  | ||
| + | 		'[[%s:%s#%s|%s]]',  | ||
| + | 		mw.site.namespaces[self._protectionObj.title.namespace].talk.name,  | ||
| + | 		self._protectionObj.title.text,  | ||
| + | 		self._args.section or 'top',  | ||
| + | 		self:_getExpandedMessage('talk-page-link-display')  | ||
| + | 	)  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeTooltipBlurbParameter()  | ||
| + | 	if self._protectionObj:isTemporary() then  | ||
| + | 		return self:_getExpandedMessage('tooltip-blurb-expiry')  | ||
| + | 	else  | ||
| + | 		return self:_getExpandedMessage('tooltip-blurb-noexpiry')  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeTooltipFragmentParameter()  | ||
| + | 	if self._protectionObj:isTemporary() then  | ||
| + | 		return self:_getExpandedMessage('tooltip-fragment-expiry')  | ||
| + | 	else  | ||
| + | 		return self:_getExpandedMessage('tooltip-fragment-noexpiry')  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | function Blurb:_makeVandalTemplateParameter()  | ||
| + | 	return require('Module:Vandal-m')._main{  | ||
| + | 		self._args.user or self._protectionObj.title.baseText  | ||
| + | 	}  | ||
| + | end  | ||
| + | |||
| + | -- Public methods --  | ||
| + | |||
| + | function Blurb:makeBannerText(key)  | ||
| + | 	-- Validate input.  | ||
| + | 	if not key or not Blurb.bannerTextFields[key] then  | ||
| + | 		error(string.format(  | ||
| + | 			'"%s" is not a valid banner config field',  | ||
| + | 			tostring(key)  | ||
| + | 		), 2)  | ||
| + | 	end  | ||
| + | |||
| + | 	-- Generate the text.  | ||
| + | 	local msg = self._protectionObj.bannerConfig[key]  | ||
| + | 	if type(msg) == 'string' then  | ||
| + | 		return self:_substituteParameters(msg)  | ||
| + | 	elseif type(msg) == 'function' then  | ||
| + | 		msg = msg(self._protectionObj, self._args)  | ||
| + | 		if type(msg) ~= 'string' then  | ||
| + | 			error(string.format(  | ||
| + | 				'bad output from banner config function with key "%s"'  | ||
| + | 					.. ' (expected string, got %s)',  | ||
| + | 				tostring(key),  | ||
| + | 				type(msg)  | ||
| + | 			), 4)  | ||
| + | 		end  | ||
| + | 		return self:_substituteParameters(msg)  | ||
| + | 	end  | ||
| + | end  | ||
| + | |||
| + | --------------------------------------------------------------------------------  | ||
| + | -- BannerTemplate class  | ||
| + | --------------------------------------------------------------------------------  | ||
| + | |||
| + | local BannerTemplate = {}  | ||
| + | BannerTemplate.__index = BannerTemplate  | ||
| + | |||
| + | function BannerTemplate.new(protectionObj, cfg)  | ||
| + | 	local obj = {}  | ||
| + | 	obj._cfg = cfg  | ||
| + | |||
| + | 	-- Set the image filename.  | ||
| + | 	local imageFilename = protectionObj.bannerConfig.image  | ||
| + | 	if imageFilename then  | ||
| + | 		obj._imageFilename = imageFilename  | ||
| + | 	else  | ||
| + | 		-- If an image filename isn't specified explicitly in the banner config,  | ||
| + | 		-- generate it from the protection status and the namespace.  | ||
| + | 		local action = protectionObj.action  | ||
| + | 		local level = protectionObj.level  | ||
| + | 		local namespace = protectionObj.title.namespace  | ||
| + | 		local reason = protectionObj.reason  | ||
| + | |||
| + | 		-- Deal with special cases first.  | ||
| + | 		if (  | ||
| + | 			namespace == 10  | ||
| + | 			or namespace == 828  | ||
| + | 			or reason and obj._cfg.indefImageReasons[reason]  | ||
| + | 			)  | ||
| + | 			and action == 'edit'  | ||
| + | 			and level == 'sysop'  | ||
| + | 			and not protectionObj:isTemporary()  | ||
| + | 		then  | ||
| + | 			-- Fully protected modules and templates get the special red "indef"  | ||
| + | 			-- padlock.  | ||
| + | 			obj._imageFilename = obj._cfg.msg['image-filename-indef']  | ||
| + | 		else  | ||
| + | 			-- Deal with regular protection types.  | ||
| + | 			local images = obj._cfg.images  | ||
| + | 			if images[action] then  | ||
| + | 				if images[action][level] then  | ||
| + | 					obj._imageFilename = images[action][level]  | ||
| + | 				elseif images[action].default then  | ||
| + | 					obj._imageFilename = images[action].default  | ||
| + | 				end  | ||
| + | 			end  | ||
| + | 		end  | ||
| + | 	end  | ||
| + | 	return setmetatable(obj, BannerTemplate)  | ||
| + | end  | ||
| + | |||
| + | function BannerTemplate:renderImage()  | ||
| + | 	local filename = self._imageFilename  | ||
| + | 		or self._cfg.msg['image-filename-default']  | ||
| + | 		or 'Transparent.gif'  | ||
| + | 	return makeFileLink{  | ||
| + | 		file = filename,  | ||
| + | 		size = (self.imageWidth or 20) .. 'px',  | ||
| + | 		alt = self._imageAlt,  | ||
| + | 		link = self._imageLink,  | ||
| + | 		caption = self.imageCaption  | ||
| + | 	}  | ||
| + | end  | ||
| + | |||
| + | --------------------------------------------------------------------------------  | ||
| + | -- Banner class  | ||
| + | --------------------------------------------------------------------------------  | ||
| + | |||
| + | local Banner = setmetatable({}, BannerTemplate)  | ||
| + | Banner.__index = Banner  | ||
| + | |||
| + | function Banner.new(protectionObj, blurbObj, cfg)  | ||
| + | 	local obj = BannerTemplate.new(protectionObj, cfg) -- This doesn't need the blurb.  | ||
| + | 	obj.imageWidth = 40  | ||
| + | 	obj.imageCaption = blurbObj:makeBannerText('alt') -- Large banners use the alt text for the tooltip.  | ||
| + | 	obj._reasonText = blurbObj:makeBannerText('text')  | ||
| + | 	obj._explanationText = blurbObj:makeBannerText('explanation')  | ||
| + | 	obj._page = protectionObj.title.prefixedText -- Only makes a difference in testing.  | ||
| + | 	return setmetatable(obj, Banner)  | ||
| + | end  | ||
| + | |||
| + | function Banner:__tostring()  | ||
| + | 	-- Renders the banner.  | ||
| + | 	makeMessageBox = makeMessageBox or require('Module:Message box').main  | ||
| + | 	local reasonText = self._reasonText or error('no reason text set', 2)  | ||
| + | 	local explanationText = self._explanationText  | ||
| + | 	local mbargs = {  | ||
| + | 		page = self._page,  | ||
| + | 		type = 'protection',  | ||
| + | 		image = self:renderImage(),  | ||
| + | 		text = string.format(  | ||
| + | 			"'''%s'''%s",  | ||
| + | 			reasonText,  | ||
| + | 			explanationText and '<br />' .. explanationText or ''  | ||
| + | 		)  | ||
| + | 	}  | ||
| + | 	return makeMessageBox('mbox', mbargs)  | ||
| + | end  | ||
| + | |||
| + | --------------------------------------------------------------------------------  | ||
| + | -- Padlock class  | ||
| + | --------------------------------------------------------------------------------  | ||
| + | |||
| + | local Padlock = setmetatable({}, BannerTemplate)  | ||
| + | Padlock.__index = Padlock  | ||
| + | |||
| + | function Padlock.new(protectionObj, blurbObj, cfg)  | ||
| + | 	local obj = BannerTemplate.new(protectionObj, cfg) -- This doesn't need the blurb.  | ||
| + | 	obj.imageWidth = 20  | ||
| + | 	obj.imageCaption = blurbObj:makeBannerText('tooltip')  | ||
| + | 	obj._imageAlt = blurbObj:makeBannerText('alt')  | ||
| + | 	obj._imageLink = blurbObj:makeBannerText('link')  | ||
| + | 	obj._indicatorName = cfg.padlockIndicatorNames[protectionObj.action]  | ||
| + | 		or cfg.padlockIndicatorNames.default  | ||
| + | 		or 'pp-default'  | ||
| + | 	return setmetatable(obj, Padlock)  | ||
| + | end  | ||
| + | |||
| + | function Padlock:__tostring()  | ||
| + | 	local frame = mw.getCurrentFrame()  | ||
| + | 	-- The nowiki tag helps prevent whitespace at the top of articles.  | ||
| + | 	return frame:extensionTag{name = 'nowiki'} .. frame:extensionTag{  | ||
| + | 		name = 'indicator',  | ||
| + | 		args = {name = self._indicatorName},  | ||
| + | 		content = self:renderImage()  | ||
| + | 	}  | ||
| + | end  | ||
| + | |||
| + | --------------------------------------------------------------------------------  | ||
| + | -- Exports  | ||
| + | --------------------------------------------------------------------------------  | ||
| + | |||
| + | local p = {}  | ||
| + | |||
| + | function p._exportClasses()  | ||
| + | 	-- This is used for testing purposes.  | ||
| + | 	return {  | ||
| + | 		Protection = Protection,  | ||
| + | 		Blurb = Blurb,  | ||
| + | 		BannerTemplate = BannerTemplate,  | ||
| + | 		Banner = Banner,  | ||
| + | 		Padlock = Padlock,  | ||
| + | 	}  | ||
| + | end  | ||
| + | |||
| + | function p._main(args, cfg, title)  | ||
| + | 	args = args or {}  | ||
| + | 	cfg = cfg or require(CONFIG_MODULE)  | ||
| + | |||
| + | 	local protectionObj = Protection.new(args, cfg, title)  | ||
| + | |||
| + | 	local ret = {}  | ||
| + | |||
| + | 	-- If a page's edit protection is equally or more restrictive than its  | ||
| + | 	-- protection from some other action, then don't bother displaying anything  | ||
| + | 	-- for the other action (except categories).  | ||
| + | 	if protectionObj.action == 'edit' or  | ||
| + | 		args.demolevel or  | ||
| + | 		not getReachableNodes(  | ||
| + | 			cfg.hierarchy,  | ||
| + | 			protectionObj.level  | ||
| + | 		)[effectiveProtectionLevel('edit', protectionObj.title)]  | ||
| + | 	then  | ||
| + | 		-- Initialise the blurb object  | ||
| + | 		local blurbObj = Blurb.new(protectionObj, args, cfg)  | ||
| + | |||
| + | 		-- Render the banner  | ||
| + | 		if protectionObj:isProtected() then  | ||
| + | 			ret[#ret + 1] = tostring(  | ||
| + | 				(yesno(args.small) and Padlock or Banner)  | ||
| + | 				.new(protectionObj, blurbObj, cfg)  | ||
| + | 			)  | ||
| + | 		end  | ||
| + | 	end  | ||
| + | |||
| + | 	-- Render the categories  | ||
| + | 	if yesno(args.category) ~= false then  | ||
| + | 		ret[#ret + 1] = protectionObj:makeCategoryLinks()  | ||
| + | 	end  | ||
| + | |||
| + | 	return table.concat(ret)	  | ||
| + | end  | ||
| + | |||
| + | function p.main(frame, cfg)  | ||
| + | 	cfg = cfg or require(CONFIG_MODULE)  | ||
| + | |||
| + | 	-- Find default args, if any.  | ||
| + | 	local parent = frame.getParent and frame:getParent()  | ||
| + | 	local defaultArgs = parent and cfg.wrappers[parent:getTitle():gsub('/sandbox$', '')]  | ||
| + | |||
| + | 	-- Find user args, and use the parent frame if we are being called from a  | ||
| + | 	-- wrapper template.  | ||
| + | 	getArgs = getArgs or require('Module:Arguments').getArgs  | ||
| + | 	local userArgs = getArgs(frame, {  | ||
| + | 		parentOnly = defaultArgs,  | ||
| + | 		frameOnly = not defaultArgs  | ||
| + | 	})  | ||
| + | |||
| + | 	-- Build the args table. User-specified args overwrite default args.  | ||
| + | 	local args = {}  | ||
| + | 	for k, v in pairs(defaultArgs or {}) do  | ||
| + | 		args[k] = v  | ||
| + | 	end  | ||
| + | 	for k, v in pairs(userArgs) do  | ||
| + | 		args[k] = v  | ||
| + | 	end  | ||
| + | 	return p._main(args, cfg)  | ||
| + | end  | ||
return p  | return p  | ||
Latest revision as of 14:34, 9 March 2020
Documentation for this module may be created at Module:Protection banner/doc
-- This module implements {{pp-meta}} and its daughter templates such as
-- {{pp-dispute}}, {{pp-vandalism}} and {{pp-sock}}.
-- Initialise necessary modules.
require('Module:No globals')
local makeFileLink = require('Module:File link')._main
local effectiveProtectionLevel = require('Module:Effective protection level')._main
local effectiveProtectionExpiry = require('Module:Effective protection expiry')._main
local yesno = require('Module:Yesno')
-- Lazily initialise modules and objects we don't always need.
local getArgs, makeMessageBox, lang
-- Set constants.
local CONFIG_MODULE = 'Module:Protection banner/config'
--------------------------------------------------------------------------------
-- Helper functions
--------------------------------------------------------------------------------
local function makeCategoryLink(cat, sort)
	if cat then
		return string.format(
			'[[%s:%s|%s]]',
			mw.site.namespaces[14].name,
			cat,
			sort
		)
	end
end
-- Validation function for the expiry and the protection date
local function validateDate(dateString, dateType)
	if not lang then
		lang = mw.language.getContentLanguage()
	end
	local success, result = pcall(lang.formatDate, lang, 'U', dateString)
	if success then
		result = tonumber(result)
		if result then
			return result
		end
	end
	error(string.format(
		'invalid %s: %s',
		dateType,
		tostring(dateString)
	), 4)
end
local function makeFullUrl(page, query, display)
	return string.format(
		'[%s %s]',
		tostring(mw.uri.fullUrl(page, query)),
		display
	)
end
-- Given a directed graph formatted as node -> table of direct successors,
-- get a table of all nodes reachable from a given node (though always
-- including the given node).
local function getReachableNodes(graph, start)
	local toWalk, retval = {[start] = true}, {}
	while true do
		-- Can't use pairs() since we're adding and removing things as we're iterating
		local k = next(toWalk) -- This always gets the "first" key
		if k == nil then
			return retval
		end
		toWalk[k] = nil
		retval[k] = true
		for _,v in ipairs(graph[k]) do
			if not retval[v] then
				toWalk[v] = true
			end
		end
	end
end
--------------------------------------------------------------------------------
-- Protection class
--------------------------------------------------------------------------------
local Protection = {}
Protection.__index = Protection
Protection.supportedActions = {
	edit = true,
	move = true,
	autoreview = true,
	upload = true
}
Protection.bannerConfigFields = {
	'text',
	'explanation',
	'tooltip',
	'alt',
	'link',
	'image'
}
function Protection.new(args, cfg, title)
	local obj = {}
	obj._cfg = cfg
	obj.title = title or mw.title.getCurrentTitle()
	-- Set action
	if not args.action then
		obj.action = 'edit'
	elseif Protection.supportedActions[args.action] then
		obj.action = args.action
	else
		error(string.format(
			'invalid action: %s',
			tostring(args.action)
		), 3)
	end
	-- Set level
	obj.level = args.demolevel or effectiveProtectionLevel(obj.action, obj.title)
	if not obj.level or (obj.action == 'move' and obj.level == 'autoconfirmed') then
		-- Users need to be autoconfirmed to move pages anyway, so treat
		-- semi-move-protected pages as unprotected.
		obj.level = '*'
	end
	-- Set expiry
	local effectiveExpiry = effectiveProtectionExpiry(obj.action, obj.title)
	if effectiveExpiry == 'infinity' then
		obj.expiry = 'indef'
	elseif effectiveExpiry ~= 'unknown' then
		obj.expiry = validateDate(effectiveExpiry, 'expiry date')
	end
	-- Set reason
	if args[1] then
		obj.reason = mw.ustring.lower(args[1])
		if obj.reason:find('|') then
			error('reasons cannot contain the pipe character ("|")', 3)
		end
	end
	-- Set protection date
	if args.date then
		obj.protectionDate = validateDate(args.date, 'protection date')
	end
	
	-- Set banner config
	do
		obj.bannerConfig = {}
		local configTables = {}
		if cfg.banners[obj.action] then
			configTables[#configTables + 1] = cfg.banners[obj.action][obj.reason]
		end
		if cfg.defaultBanners[obj.action] then
			configTables[#configTables + 1] = cfg.defaultBanners[obj.action][obj.level]
			configTables[#configTables + 1] = cfg.defaultBanners[obj.action].default
		end
		configTables[#configTables + 1] = cfg.masterBanner
		for i, field in ipairs(Protection.bannerConfigFields) do
			for j, t in ipairs(configTables) do
				if t[field] then
					obj.bannerConfig[field] = t[field]
					break
				end
			end
		end
	end
	return setmetatable(obj, Protection)
end
function Protection:isProtected()
	return self.level ~= '*'
end
function Protection:isTemporary()
	return type(self.expiry) == 'number'
end
function Protection:makeProtectionCategory()
	local cfg = self._cfg
	local title = self.title
	
	-- Exit if the page is not protected.
	if not self:isProtected() then
		return ''
	end
	
	-- Get the expiry key fragment.
	local expiryFragment
	if self.expiry == 'indef' then
		expiryFragment = self.expiry
	elseif type(self.expiry) == 'number' then
		expiryFragment = 'temp'
	end
	-- Get the namespace key fragment.
	local namespaceFragment = cfg.categoryNamespaceKeys[title.namespace]
	if not namespaceFragment and title.namespace % 2 == 1 then
			namespaceFragment = 'talk'
	end
 
	-- Define the order that key fragments are tested in. This is done with an
	-- array of tables containing the value to be tested, along with its
	-- position in the cfg.protectionCategories table.
	local order = {
		{val = expiryFragment,    keypos = 1},
		{val = namespaceFragment, keypos = 2},
		{val = self.reason,       keypos = 3},
		{val = self.level,        keypos = 4},
		{val = self.action,       keypos = 5}
	}
	--[[
	-- The old protection templates used an ad-hoc protection category system,
	-- with some templates prioritising namespaces in their categories, and
	-- others prioritising the protection reason. To emulate this in this module
	-- we use the config table cfg.reasonsWithNamespacePriority to set the
	-- reasons for which namespaces have priority over protection reason.
	-- If we are dealing with one of those reasons, move the namespace table to
	-- the end of the order table, i.e. give it highest priority. If not, the
	-- reason should have highest priority, so move that to the end of the table
	-- instead.
	--]]
	table.insert(order, table.remove(order, self.reason and cfg.reasonsWithNamespacePriority[self.reason] and 2 or 3))
 
	--[[
	-- Define the attempt order. Inactive subtables (subtables with nil "value"
	-- fields) are moved to the end, where they will later be given the key
	-- "all". This is to cut down on the number of table lookups in
	-- cfg.protectionCategories, which grows exponentially with the number of
	-- non-nil keys. We keep track of the number of active subtables with the
	-- noActive parameter.
	--]]
	local noActive, attemptOrder
	do
		local active, inactive = {}, {}
		for i, t in ipairs(order) do
			if t.val then
				active[#active + 1] = t
			else
				inactive[#inactive + 1] = t
			end
		end
		noActive = #active
		attemptOrder = active
		for i, t in ipairs(inactive) do
			attemptOrder[#attemptOrder + 1] = t
		end
	end
 
	--[[
	-- Check increasingly generic key combinations until we find a match. If a
	-- specific category exists for the combination of key fragments we are
	-- given, that match will be found first. If not, we keep trying different
	-- key fragment combinations until we match using the key
	-- "all-all-all-all-all".
	--
	-- To generate the keys, we index the key subtables using a binary matrix
	-- with indexes i and j. j is only calculated up to the number of active
	-- subtables. For example, if there were three active subtables, the matrix
	-- would look like this, with 0 corresponding to the key fragment "all", and
	-- 1 corresponding to other key fragments.
	-- 
	--   j 1  2  3
	-- i  
	-- 1   1  1  1
	-- 2   0  1  1
	-- 3   1  0  1
	-- 4   0  0  1
	-- 5   1  1  0
	-- 6   0  1  0
	-- 7   1  0  0
	-- 8   0  0  0
	-- 
	-- Values of j higher than the number of active subtables are set
	-- to the string "all".
	--
	-- A key for cfg.protectionCategories is constructed for each value of i.
	-- The position of the value in the key is determined by the keypos field in
	-- each subtable.
	--]]
	local cats = cfg.protectionCategories
	for i = 1, 2^noActive do
		local key = {}
		for j, t in ipairs(attemptOrder) do
			if j > noActive then
				key[t.keypos] = 'all'
			else
				local quotient = i / 2 ^ (j - 1)
				quotient = math.ceil(quotient)
				if quotient % 2 == 1 then
					key[t.keypos] = t.val
				else
					key[t.keypos] = 'all'
				end
			end
		end
		key = table.concat(key, '|')
		local attempt = cats[key]
		if attempt then
			return makeCategoryLink(attempt, title.text)
		end
	end
	return ''
end
function Protection:isIncorrect()
	local expiry = self.expiry
	return not self:isProtected()
		or type(expiry) == 'number' and expiry < os.time()
end
function Protection:isTemplateProtectedNonTemplate()
	local action, namespace = self.action, self.title.namespace
	return self.level == 'templateeditor'
		and (
			(action ~= 'edit' and action ~= 'move')
			or (namespace ~= 10 and namespace ~= 828)
		)
end
function Protection:makeCategoryLinks()
	local msg = self._cfg.msg
	local ret = { self:makeProtectionCategory() }
	if self:isIncorrect() then
		ret[#ret + 1] = makeCategoryLink(
			msg['tracking-category-incorrect'],
			self.title.text
		)
	end
	if self:isTemplateProtectedNonTemplate() then
		ret[#ret + 1] = makeCategoryLink(
			msg['tracking-category-template'],
			self.title.text
		)
	end
	return table.concat(ret)
end
--------------------------------------------------------------------------------
-- Blurb class
--------------------------------------------------------------------------------
local Blurb = {}
Blurb.__index = Blurb
Blurb.bannerTextFields = {
	text = true,
	explanation = true,
	tooltip = true,
	alt = true,
	link = true
}
function Blurb.new(protectionObj, args, cfg)
	return setmetatable({
		_cfg = cfg,
		_protectionObj = protectionObj,
		_args = args
	}, Blurb)
end
-- Private methods --
function Blurb:_formatDate(num)
	-- Formats a Unix timestamp into dd Month, YYYY format.
	lang = lang or mw.language.getContentLanguage()
	local success, date = pcall(
		lang.formatDate,
		lang,
		self._cfg.msg['expiry-date-format'] or 'j F Y',
		'@' .. tostring(num)
	)
	if success then
		return date
	end
end
function Blurb:_getExpandedMessage(msgKey)
	return self:_substituteParameters(self._cfg.msg[msgKey])
end
function Blurb:_substituteParameters(msg)
	if not self._params then
		local parameterFuncs = {}
		parameterFuncs.CURRENTVERSION     = self._makeCurrentVersionParameter
		parameterFuncs.EDITREQUEST        = self._makeEditRequestParameter
		parameterFuncs.EXPIRY             = self._makeExpiryParameter
		parameterFuncs.EXPLANATIONBLURB   = self._makeExplanationBlurbParameter
		parameterFuncs.IMAGELINK          = self._makeImageLinkParameter
		parameterFuncs.INTROBLURB         = self._makeIntroBlurbParameter
		parameterFuncs.INTROFRAGMENT      = self._makeIntroFragmentParameter
		parameterFuncs.PAGETYPE           = self._makePagetypeParameter
		parameterFuncs.PROTECTIONBLURB    = self._makeProtectionBlurbParameter
		parameterFuncs.PROTECTIONDATE     = self._makeProtectionDateParameter
		parameterFuncs.PROTECTIONLEVEL    = self._makeProtectionLevelParameter
		parameterFuncs.PROTECTIONLOG      = self._makeProtectionLogParameter
		parameterFuncs.TALKPAGE           = self._makeTalkPageParameter
		parameterFuncs.TOOLTIPBLURB       = self._makeTooltipBlurbParameter
		parameterFuncs.TOOLTIPFRAGMENT    = self._makeTooltipFragmentParameter
		parameterFuncs.VANDAL             = self._makeVandalTemplateParameter
		
		self._params = setmetatable({}, {
			__index = function (t, k)
				local param
				if parameterFuncs[k] then
					param = parameterFuncs[k](self)
				end
				param = param or ''
				t[k] = param
				return param
			end
		})
	end
	
	msg = msg:gsub('${(%u+)}', self._params)
	return msg
end
function Blurb:_makeCurrentVersionParameter()
	-- A link to the page history or the move log, depending on the kind of
	-- protection.
	local pagename = self._protectionObj.title.prefixedText
	if self._protectionObj.action == 'move' then
		-- We need the move log link.
		return makeFullUrl(
			'Special:Log',
			{type = 'move', page = pagename},
			self:_getExpandedMessage('current-version-move-display')
		)
	else
		-- We need the history link.
		return makeFullUrl(
			pagename,
			{action = 'history'},
			self:_getExpandedMessage('current-version-edit-display')
		)
	end
end
function Blurb:_makeEditRequestParameter()
	local mEditRequest = require('Module:Submit an edit request')
	local action = self._protectionObj.action
	local level = self._protectionObj.level
	
	-- Get the edit request type.
	local requestType
	if action == 'edit' then
		if level == 'autoconfirmed' then
			requestType = 'semi'
		elseif level == 'extendedconfirmed' then
			requestType = 'extended'
		elseif level == 'templateeditor' then
			requestType = 'template'
		end
	end
	requestType = requestType or 'full'
	
	-- Get the display value.
	local display = self:_getExpandedMessage('edit-request-display')
	return mEditRequest._link{type = requestType, display = display}
end
function Blurb:_makeExpiryParameter()
	local expiry = self._protectionObj.expiry
	if type(expiry) == 'number' then
		return self:_formatDate(expiry)
	else
		return expiry
	end
end
function Blurb:_makeExplanationBlurbParameter()
	-- Cover special cases first.
	if self._protectionObj.title.namespace == 8 then
		-- MediaWiki namespace
		return self:_getExpandedMessage('explanation-blurb-nounprotect')
	end
	-- Get explanation blurb table keys
	local action = self._protectionObj.action
	local level = self._protectionObj.level
	local talkKey = self._protectionObj.title.isTalkPage and 'talk' or 'subject'
	-- Find the message in the explanation blurb table and substitute any
	-- parameters.
	local explanations = self._cfg.explanationBlurbs
	local msg
	if explanations[action][level] and explanations[action][level][talkKey] then
		msg = explanations[action][level][talkKey]
	elseif explanations[action][level] and explanations[action][level].default then
		msg = explanations[action][level].default
	elseif explanations[action].default and explanations[action].default[talkKey] then
		msg = explanations[action].default[talkKey]
	elseif explanations[action].default and explanations[action].default.default then
		msg = explanations[action].default.default
	else
		error(string.format(
			'could not find explanation blurb for action "%s", level "%s" and talk key "%s"',
			action,
			level,
			talkKey
		), 8)
	end
	return self:_substituteParameters(msg)
end
function Blurb:_makeImageLinkParameter()
	local imageLinks = self._cfg.imageLinks
	local action = self._protectionObj.action
	local level = self._protectionObj.level
	local msg
	if imageLinks[action][level] then
		msg = imageLinks[action][level]
	elseif imageLinks[action].default then
		msg = imageLinks[action].default
	else
		msg = imageLinks.edit.default
	end
	return self:_substituteParameters(msg)
end
function Blurb:_makeIntroBlurbParameter()
	if self._protectionObj:isTemporary() then
		return self:_getExpandedMessage('intro-blurb-expiry')
	else
		return self:_getExpandedMessage('intro-blurb-noexpiry')
	end
end
function Blurb:_makeIntroFragmentParameter()
	if self._protectionObj:isTemporary() then
		return self:_getExpandedMessage('intro-fragment-expiry')
	else
		return self:_getExpandedMessage('intro-fragment-noexpiry')
	end
end
function Blurb:_makePagetypeParameter()
	local pagetypes = self._cfg.pagetypes
	return pagetypes[self._protectionObj.title.namespace]
		or pagetypes.default
		or error('no default pagetype defined', 8)
end
function Blurb:_makeProtectionBlurbParameter()
	local protectionBlurbs = self._cfg.protectionBlurbs
	local action = self._protectionObj.action
	local level = self._protectionObj.level
	local msg
	if protectionBlurbs[action][level] then
		msg = protectionBlurbs[action][level]
	elseif protectionBlurbs[action].default then
		msg = protectionBlurbs[action].default
	elseif protectionBlurbs.edit.default then
		msg = protectionBlurbs.edit.default
	else
		error('no protection blurb defined for protectionBlurbs.edit.default', 8)
	end
	return self:_substituteParameters(msg)
end
function Blurb:_makeProtectionDateParameter()
	local protectionDate = self._protectionObj.protectionDate
	if type(protectionDate) == 'number' then
		return self:_formatDate(protectionDate)
	else
		return protectionDate
	end
end
function Blurb:_makeProtectionLevelParameter()
	local protectionLevels = self._cfg.protectionLevels
	local action = self._protectionObj.action
	local level = self._protectionObj.level
	local msg
	if protectionLevels[action][level] then
		msg = protectionLevels[action][level]
	elseif protectionLevels[action].default then
		msg = protectionLevels[action].default
	elseif protectionLevels.edit.default then
		msg = protectionLevels.edit.default
	else
		error('no protection level defined for protectionLevels.edit.default', 8)
	end
	return self:_substituteParameters(msg)
end
function Blurb:_makeProtectionLogParameter()
	local pagename = self._protectionObj.title.prefixedText
	if self._protectionObj.action == 'autoreview' then
		-- We need the pending changes log.
		return makeFullUrl(
			'Special:Log',
			{type = 'stable', page = pagename},
			self:_getExpandedMessage('pc-log-display')
		)
	else
		-- We need the protection log.
		return makeFullUrl(
			'Special:Log',
			{type = 'protect', page = pagename},
			self:_getExpandedMessage('protection-log-display')
		)
	end
end
function Blurb:_makeTalkPageParameter()
	return string.format(
		'[[%s:%s#%s|%s]]',
		mw.site.namespaces[self._protectionObj.title.namespace].talk.name,
		self._protectionObj.title.text,
		self._args.section or 'top',
		self:_getExpandedMessage('talk-page-link-display')
	)
end
function Blurb:_makeTooltipBlurbParameter()
	if self._protectionObj:isTemporary() then
		return self:_getExpandedMessage('tooltip-blurb-expiry')
	else
		return self:_getExpandedMessage('tooltip-blurb-noexpiry')
	end
end
function Blurb:_makeTooltipFragmentParameter()
	if self._protectionObj:isTemporary() then
		return self:_getExpandedMessage('tooltip-fragment-expiry')
	else
		return self:_getExpandedMessage('tooltip-fragment-noexpiry')
	end
end
function Blurb:_makeVandalTemplateParameter()
	return require('Module:Vandal-m')._main{
		self._args.user or self._protectionObj.title.baseText
	}
end
-- Public methods --
function Blurb:makeBannerText(key)
	-- Validate input.
	if not key or not Blurb.bannerTextFields[key] then
		error(string.format(
			'"%s" is not a valid banner config field',
			tostring(key)
		), 2)
	end
	-- Generate the text.
	local msg = self._protectionObj.bannerConfig[key]
	if type(msg) == 'string' then
		return self:_substituteParameters(msg)
	elseif type(msg) == 'function' then
		msg = msg(self._protectionObj, self._args)
		if type(msg) ~= 'string' then
			error(string.format(
				'bad output from banner config function with key "%s"'
					.. ' (expected string, got %s)',
				tostring(key),
				type(msg)
			), 4)
		end
		return self:_substituteParameters(msg)
	end
end
--------------------------------------------------------------------------------
-- BannerTemplate class
--------------------------------------------------------------------------------
local BannerTemplate = {}
BannerTemplate.__index = BannerTemplate
function BannerTemplate.new(protectionObj, cfg)
	local obj = {}
	obj._cfg = cfg
	-- Set the image filename.
	local imageFilename = protectionObj.bannerConfig.image
	if imageFilename then
		obj._imageFilename = imageFilename
	else
		-- If an image filename isn't specified explicitly in the banner config,
		-- generate it from the protection status and the namespace.
		local action = protectionObj.action
		local level = protectionObj.level
		local namespace = protectionObj.title.namespace
		local reason = protectionObj.reason
		
		-- Deal with special cases first.
		if (
			namespace == 10
			or namespace == 828
			or reason and obj._cfg.indefImageReasons[reason]
			)
			and action == 'edit'
			and level == 'sysop'
			and not protectionObj:isTemporary()
		then
			-- Fully protected modules and templates get the special red "indef"
			-- padlock.
			obj._imageFilename = obj._cfg.msg['image-filename-indef']
		else
			-- Deal with regular protection types.
			local images = obj._cfg.images
			if images[action] then
				if images[action][level] then
					obj._imageFilename = images[action][level]
				elseif images[action].default then
					obj._imageFilename = images[action].default
				end
			end
		end
	end
	return setmetatable(obj, BannerTemplate)
end
function BannerTemplate:renderImage()
	local filename = self._imageFilename
		or self._cfg.msg['image-filename-default']
		or 'Transparent.gif'
	return makeFileLink{
		file = filename,
		size = (self.imageWidth or 20) .. 'px',
		alt = self._imageAlt,
		link = self._imageLink,
		caption = self.imageCaption
	}
end
--------------------------------------------------------------------------------
-- Banner class
--------------------------------------------------------------------------------
local Banner = setmetatable({}, BannerTemplate)
Banner.__index = Banner
function Banner.new(protectionObj, blurbObj, cfg)
	local obj = BannerTemplate.new(protectionObj, cfg) -- This doesn't need the blurb.
	obj.imageWidth = 40
	obj.imageCaption = blurbObj:makeBannerText('alt') -- Large banners use the alt text for the tooltip.
	obj._reasonText = blurbObj:makeBannerText('text')
	obj._explanationText = blurbObj:makeBannerText('explanation')
	obj._page = protectionObj.title.prefixedText -- Only makes a difference in testing.
	return setmetatable(obj, Banner)
end
function Banner:__tostring()
	-- Renders the banner.
	makeMessageBox = makeMessageBox or require('Module:Message box').main
	local reasonText = self._reasonText or error('no reason text set', 2)
	local explanationText = self._explanationText
	local mbargs = {
		page = self._page,
		type = 'protection',
		image = self:renderImage(),
		text = string.format(
			"'''%s'''%s",
			reasonText,
			explanationText and '<br />' .. explanationText or ''
		)
	}
	return makeMessageBox('mbox', mbargs)
end
--------------------------------------------------------------------------------
-- Padlock class
--------------------------------------------------------------------------------
local Padlock = setmetatable({}, BannerTemplate)
Padlock.__index = Padlock
function Padlock.new(protectionObj, blurbObj, cfg)
	local obj = BannerTemplate.new(protectionObj, cfg) -- This doesn't need the blurb.
	obj.imageWidth = 20
	obj.imageCaption = blurbObj:makeBannerText('tooltip')
	obj._imageAlt = blurbObj:makeBannerText('alt')
	obj._imageLink = blurbObj:makeBannerText('link')
	obj._indicatorName = cfg.padlockIndicatorNames[protectionObj.action]
		or cfg.padlockIndicatorNames.default
		or 'pp-default'
	return setmetatable(obj, Padlock)
end
function Padlock:__tostring()
	local frame = mw.getCurrentFrame()
	-- The nowiki tag helps prevent whitespace at the top of articles.
	return frame:extensionTag{name = 'nowiki'} .. frame:extensionTag{
		name = 'indicator',
		args = {name = self._indicatorName},
		content = self:renderImage()
	}
end
--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------
local p = {}
function p._exportClasses()
	-- This is used for testing purposes.
	return {
		Protection = Protection,
		Blurb = Blurb,
		BannerTemplate = BannerTemplate,
		Banner = Banner,
		Padlock = Padlock,
	}
end
function p._main(args, cfg, title)
	args = args or {}
	cfg = cfg or require(CONFIG_MODULE)
	local protectionObj = Protection.new(args, cfg, title)
	local ret = {}
	-- If a page's edit protection is equally or more restrictive than its
	-- protection from some other action, then don't bother displaying anything
	-- for the other action (except categories).
	if protectionObj.action == 'edit' or
		args.demolevel or
		not getReachableNodes(
			cfg.hierarchy,
			protectionObj.level
		)[effectiveProtectionLevel('edit', protectionObj.title)]
	then
		-- Initialise the blurb object
		local blurbObj = Blurb.new(protectionObj, args, cfg)
	
		-- Render the banner
		if protectionObj:isProtected() then
			ret[#ret + 1] = tostring(
				(yesno(args.small) and Padlock or Banner)
				.new(protectionObj, blurbObj, cfg)
			)
		end
	end
	-- Render the categories
	if yesno(args.category) ~= false then
		ret[#ret + 1] = protectionObj:makeCategoryLinks()
	end
	
	return table.concat(ret)	
end
function p.main(frame, cfg)
	cfg = cfg or require(CONFIG_MODULE)
	-- Find default args, if any.
	local parent = frame.getParent and frame:getParent()
	local defaultArgs = parent and cfg.wrappers[parent:getTitle():gsub('/sandbox$', '')]
	-- Find user args, and use the parent frame if we are being called from a
	-- wrapper template.
	getArgs = getArgs or require('Module:Arguments').getArgs
	local userArgs = getArgs(frame, {
		parentOnly = defaultArgs,
		frameOnly = not defaultArgs
	})
	-- Build the args table. User-specified args overwrite default args.
	local args = {}
	for k, v in pairs(defaultArgs or {}) do
		args[k] = v
	end
	for k, v in pairs(userArgs) do
		args[k] = v
	end
	return p._main(args, cfg)
end
return p