Jump to content

Module:Synchbot

From Meta, a Wikimedia project coordination wiki
Module documentation

This module is used to format Synchbot requests. The following functions are defined.

Request

[edit]

Arguments

[edit]

Renders a Synchbot request log which summarises the actions performed in response to this request. The following arguments are defined:

all requests
parameter usage
user The name of the user whose user pages to edit. This will be used by the bot to skip wikis where the user isn't registered.
action The action to perform on each page; one of delete, replace/prepend/append (for edit), or set (for preferences).
skip wikis An arbitrary list of wikis to skip.
status The status of the request; one of queued (default), done, not done, or on hold.
'edit' requests
title The title of the page to edit on each wiki (including namespace).
text The text to write to the page.
skip existing Whether to skip wikis where the page already exists ('yes' or blank).
'set' preferences requests
set:* The preferences to set as key:value pairs, like set:language = en.

Example (edit pages)

[edit]
{{#invoke:synchbot|request
  |user          = Pathoschild
  |action        = replace
  |title         = User:Pathoschild/common.js
  |text          = mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:Pathoschild/global.js&action=raw&ctype=text/javascript');
  |skip wikis    = 
  |skip existing = 
  |status        = done
}}
request done:
Pathoschild (global account · recent activity · user pages)
  • go to User:Pathoschild/common.js on every wiki
  • and replace the text with
    mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:Pathoschild/global.js&action=raw&ctype=text/javascript');
request_user = 'Pathoschild',
request_titles = ['User:Pathoschild/common.js'],
request_action = lambda bot: bot.save(u"""mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:Pathoschild/global.js&action=raw&ctype=text/javascript');"""),
skip_existing = False,
skip_new = False,
skip_unregistered = True,
skip_wikis = [],
only_wikis = [],
delete_summary = None,
edit_summary = None,

Example (set preferences)

[edit]
{{#invoke:synchbot|request
  |user          = Pathoschild
  |action        = set
  |set:language  = en
  |set:skin      = vector
  |skip wikis    = 
  |status        = done
}}
request done:
Pathoschild (global account · recent activity · user pages)
  • go to every wiki
  • and set these preferences:
    • Set language to en
    • Set skin to vector
request_user = 'Pathoschild',
request_titles = ['None'],
request_action = lambda bot: None,
skip_existing = False,
skip_new = False,
skip_unregistered = True,
skip_wikis = [],
only_wikis = [],
delete_summary = None,
edit_summary = None,

Log

[edit]

Arguments

[edit]

Renders a Synchbot request log which summarises the actions performed in response to this request. The following arguments are defined:

parameter usage
log The logged actions to output, as a multiline sequence of comma-separated values. Each line should consist of three fields: the time (like "00:53"), page link (like "[[s:User:Pathoschild|en.wikisource.org]]"), and log message (like "created (+116)"). Each message will be formatted by matching the log message for common patterns.
indent (optional) The number of times to indent the output, corresponding to the indentation level of a threaded wiki discussion.
counts:* (optional) Adds to the page counts used to build the summary bar graph. This is used to show an accurate summary even though some pages aren't listed (like page-doesn't-exist entries for a deletion request). Valid count keys: created, updated, deleted, missing (for missing user), skipped.

Example (no arguments)

[edit]
{{#invoke:synchbot|log|
     00:53,[[w:User:Pathoschild|en.wikipedia.org]],created (+116).
     00:53,[[s:User:Pathoschild|en.wikisource.org]],deleted.
     00:55,[[wikt:User:Pathoschild|en.wiktionary.org]],updated (+116).
}}
The following log shows what the bot did on each wiki. You can click the columns to sort the log.
summary:
timewikilogged action
00:53en.wikipedia.orgcreated (+116).
00:53en.wikisource.orgdeleted.
00:55en.wiktionary.orgupdated (+116).

Example (with arguments)

[edit]
{{#invoke:synchbot|log
  |indent = 3
  |log    = 
     00:53,[[w:User:Pathoschild|en.wikipedia.org]],created (+116).
     00:53,[[s:User:Pathoschild|en.wikisource.org]],deleted.
     00:55,[[wikt:User:Pathoschild|en.wiktionary.org]],updated (+116).
}}
The following log shows what the bot did on each wiki. You can click the columns to sort the log.
summary:
timewikilogged action
00:53en.wikipedia.orgcreated (+116).
00:53en.wikisource.orgdeleted.
00:55en.wiktionary.orgupdated (+116).

local p = {}
local inner = {}

--##########
--## Public functions
--##########
--- Render a Synchbot request box which summarises the request.
-- @param frame The arguments passed to the script. See docs on inner.renderRequest.
-- @test p.request({ args = { user = "Pathoschild", action = "replace", title = "User:Pathoschild/Woop.js", text = "test content", ["skip wikis"] = "enwiki, frwiki", ["skip existing"] = "no" } })
-- @test p.request({ args = { user = "Pathoschild", action = "set", title = nil, text = nil, ["skip wikis"] = "enwiki, frwiki", ["skip existing"] = "no", ["set:language"] = "en" } })
function p.request(frame)
	-- pack preference settings
	local settings = {}
	for key,value in pairs(frame.args) do
		local keyParts = mw.text.split(key, ':', true)
		if(#keyParts == 2 and keyParts[1] == 'set') then
			settings[keyParts[2]] = value
		end
	end

	-- render
	return inner.renderRequest(frame.args["user"], frame.args["action"], frame.args["title"], frame.args["text"], frame.args["skip wikis"], frame.args["skip existing"], settings, frame.args["status"], frame.args["flags"])
end

--- Render a Synchbot request log which summarises the actions performed in response to this request.
-- @param frame The arguments passed to the script. See docs on inner.renderLog.
-- @test p.log({ args = { ["count:skipped"] = "945", [1] = "00:53,[[w:User:Pathoschild|en.wikipedia.org]],created (+116).\n00:53,[[s:User:Pathoschild|en.wikisource.org]],deleted.\n00:55,[[wikt:User:Pathoschild|en.wiktionary.org]],updated (+116).\n00:56,[[wikidata:User:Pathoschild|wikidata.org]],skipped (user is not registered here)." } })
function p.log(frame)
	-- pack counts
	local counts = {}
	for key,value in pairs(frame.args) do
		local keyParts = mw.text.split(key, ':', true)
		if(#keyParts == 2 and keyParts[1] == 'count') then
			counts[keyParts[2]] = value
		end
	end
	
	-- render
	return inner.renderLog(inner.trimInput(frame.args[1] or frame.args["log"]), inner.trimInput(frame.args["indent"]), counts)
end

-- Render a global CSS/JS migration notice. See [[User:Pathoschild/2014–2015 global script migration]].
function p.migrationNotice(frame)
	-- analyse user
	local user = mw.title.getCurrentTitle().baseText
	local hasGlobalCss = mw.title.new('User:' .. user .. '/global.css').exists
	local hasGlobalJs = mw.title.new('User:' .. user .. '/global.js').exists
	
	-- render migration notice
	local html = {}
	local write = inner.write
	write(html, 'Hello ' .. user .. '. You have ')
	if hasGlobalJs and hasGlobalCss then
		write(html, 'global scripts and styles in <code>[[User:' .. user .. '/global.js]]</code> and <code>[[User:' .. user .. '/global.css]]</code>, which you import using [[toollabs:meta/userpages/' .. user .. '#css,js,user,subpages|your local CSS/JS pages]]. ')
	elseif hasGlobalJs then
		write(html, 'global scripts in <code>[[User:' .. user .. '/global.js]]</code>, which you import using [[toollabs:meta/userpages/' .. user .. '#js,user,subpages|your local JS pages]]. ')
	else
		write(html, 'global styles in <code>[[User:' .. user .. '/global.css]]</code>, which you import using [[toollabs:meta/userpages/' .. user .. '#css,user,subpages|your local CSS pages]]. ')
	end
	write(html, 'Since August 2014, your <code>global.js</code> and <code>global.css</code> pages are [[global user pages|loaded automatically on all wikis]]. ')
	if hasGlobalJs then
		write(html, 'Since you already import them yourself, you may experience script errors or tools being added twice. ')
	end
	write(html, 'Do you want me to fix this by removing the imports from your local pages using [[Synchbot]] (without changing any other content)?')
	return inner.flush(html)
end

-- Render a global CSS/JS migration request for [[Synchbot]]. See [[User:Pathoschild/2014–2015 global script migration]].
-- @test p.migrationRequest({ args = { ['user'] = 'Pathoschild', ['oldid'] = '12530499', ['diffid'] = '12577547' } })
function p.migrationRequest(frame)
	-- analyse user
	local user = frame.args['user']
	local oldid = frame.args['oldid']
	local diffid = frame.args['diffid']
	local hasGlobalCss = mw.title.new('User:' .. user .. '/global.css').exists
	local hasGlobalJs = mw.title.new('User:' .. user .. '/global.js').exists
	
	-- render migration notice
	local html = {}
	local write = inner.write
	write(html, '===[[user:' .. user .. '|]]===\n')
	write(html, '{{#invoke:synchbot|request\n')
	write(html, ' |user          = ' .. user .. '\n')
	write(html, ' |action        = delete\n')
	if hasGlobalCss and hasGlobalJs then
		write(html, ' |title         = User:' .. user .. '/*.css, User:' .. user .. '/*.js\n')
	elseif hasGlobalCss then
		write(html, ' |title         = User:' .. user .. '/*.css\n')
	else
		write(html, ' |title         = User:' .. user .. '/*.js\n')
	end
	write(html, ' |text          = \n')
	write(html, ' |skip wikis    = \n')
	write(html, ' |skip existing = no\n')
	write(html, ' |status        = <!-- don\'t change this line -->\n')
	write(html, '}}\n')
	write(html, 'Remove manual ')
	if hasGlobalCss and hasGlobalJs then
		write(html, '<code>[[User:' .. user .. '/global.css|global.css]]</code> and <code>[[User:' .. user .. '/global.js|global.js]]</code>')
	elseif hasGlobalCss then
		write(html, '<code>[[User:' .. user .. '/global.css|global.css]]</code>')
	else
		write(html, '<code>[[User:' .. user .. '/global.js|global.js]]</code>')
	end
	write(html, ' imports since [[global user pages|they\'re now automatic]], and delete local pages that don\'t contain anything else. Discussed at [[Special:Diff/' .. oldid .. '/' .. diffid .. '#Global CSS/JS migration|User talk:' .. user .. '#Global CSS/JS migration]]. ~~~')

	return inner.flush(html)
end

-- Render a notice like "this request was imported from X" for Synchbot archives imported from another Synchbot service.
function p.importedArchive(frame)
	local link = frame.args[1]
	return '<small style="color:gray;">This archived request was imported from ' .. link .. '.</small>'
end

-- Render a user entry for [[User:Pathoschild/2014 global script migration]].
function p.migrationStatus(frame)
	-- read arguments
	local user = inner.trimInput(frame.args['user'])
	local affected = ({['y'] = true, ['n'] = false})[inner.trimInput(frame.args['affected'])]
	local contacted = ({['y'] = true, ['n'] = false})[inner.trimInput(frame.args['contacted'])]
	local migrated = ({['y'] = true, ['n'] = false})[inner.trimInput(frame.args['migrated'])]
	local notes = inner.trimInput(frame.args['notes'])
	
	-- render	
	return inner.renderMigrationStatus(user, affected, contacted, migrated, notes)
end

--##########
--## Protected functions
--##########
-- Render a Synchbot request box which summarises the request.
-- @param user The name of the user whose user pages to edit. This will be used by the bot to skip wikis where the user isn't registered.
-- @param action The action to perform on each page; one of 'delete', 'replace', 'prepend', 'append', or 'custom'.
-- @param title The title of the page to edit on each wiki (including namespace).
-- @param text The text to write to the page.
-- @param skipWikis An arbitrary list of wikis to skip.
-- @param skipExistingWikis Whether to skip wikis where the page already exists.
-- @param settings A table of user preferences to set (if any).
-- @param status The status of the request; one of 'queued' (or ''), 'done', 'not done', or 'on hold'.
-- @param flags A string containing arbitrary flags used to control the generated settings.
-- @test p.renderRequest('Pathoschild', 'replace', 'User:Pathoschild/Woop.js', 'test content', 'enwiki, frwiki', 'no')
-- @test p.renderRequest('Pathoschild', 'set', nil, nil, 'enwiki, frwiki', 'no', { ['language'] = 'en' })
function inner.renderRequest(user, action, title, text, skipWikis, skipExistingWikis, settings, status, flags)
	-- parse input
	user = inner.trimInput(user)
	action = inner.trimInput(action)
	title = inner.trimInput(title)
	skipWikis = inner.trimInput(skipWikis)
	skipExistingWikis = inner.trimInput(skipExistingWikis)
	status = inner.trimInput(status)
	settings = settings or {}

	-- normalize
	if skipExistingWikis == 'no' then
		skipExistingWikis = false
	end

	-- derive config
	status = ({['declined'] = 'not done'})[status] or status or 'queued'
	local statusColor = ({['done'] = 'CFC', ['not done'] = 'FCC', ['withdrawn'] = 'FCC', ['on hold'] = 'FFC'})[status] or 'F2F2F2'
	local renderedSkipList = nil
	if skipWikis or skipExistingWikis then
		if skipWikis and skipExistingWikis then
			renderedSkipList = 'existing pages and ' .. skipWikis
		elseif skipExistingWikis then
			renderedSkipList = 'existing pages'
		else
			renderedSkipList = skipWikis
		end
	end

	-- render container & status
	local html = {}
	local write = inner.write
	write(html, '<div style="width:10em; padding:0.2em; border:1px #CCC solid; -moz-border-radius:0 2em 0 0; -webkit-border-radius:0 2em 0 0; border-radius:0 2em 0 0; border-bottom:0; background:#%s;">request %s: </div>', statusColor, status)
	write(html, '<div style="padding:0.5em; border:1px solid #CCC; -moz-border-radius:0 1em 1em 1em; -webkit-border-radius:0 1em 1em 1em; border-radius:0 1em 1em 1em;">')

	-- render user details
	if user then
		local encodedUser = mw.uri.encode(user)
		write(html, '[[user:%s|%s]] <small class="plainlinks">([https://meta.toolforge.org/stalktoy/%s global account] · [https://meta.toolforge.org/crossactivity/%s recent activity] · [https://meta.toolforge.org/userpages/%s user pages])</small>', user, user, encodedUser, encodedUser, encodedUser)
	else
		write(html, '<span color="red">no user specified</span>')
	end
	write(html, '<ul>')

	-- render title
	write(html, '<li>go to ')
	if title ~= nil then
		write(html, '<code>%s</code> on ', title)
	end
	write(html, 'every wiki')
	if renderedSkipList then
		write(html, '<small> (except on %s)</small>', renderedSkipList)
	end
	write(html, '</li>')

	-- render action
	write(html, '<li>')
	if not(({['delete'] = 1, ['replace'] = 1, ['prepend'] = 1, ['append'] = 1, ['set'] = 1, ['custom'] = 1})[action]) then
		write(html, 'and... <span color="red">uh-oh! The action "%s" is invalid. It should be delete, replace, prepend, append, or set.</span>', action)
	else
		if action == 'delete' then
			write(html, ' and delete the page.')
		elseif action == 'set' then
			write(html, ' and set these preferences:<ul>')
			for key,value in pairs(settings) do
				write(html, '<li>Set <code>' .. key .. '</code> to ' .. value .. '</li>')
			end
			write(html, '</ul>')
		elseif action == 'custom' then
			write(html, ' and follow the instructions below.')
		else
			if action == 'replace' then
				write(html, ' and replace the text with ')
			else
				write(html, ' and %s this text: ', action)
			end
			write(html, '<div style="max-height:25em; margin-left:2em; padding-left:0.5em; overflow:auto; border-left:3px solid #CCC; font-size:0.9em; color:#666;">%s</div>', text)
		end
	end
	write(html, '</ul>')
	write(html, '</div>')
	
	-- render console info
	inner.renderRequestSettings(html, user, action, title, text, skipWikis, skipExistingWikis, flags)

	return inner.flush(html)
end

--- Render the python settings matching the request.
-- @param html The table to which to write output.
-- @param user The name of the user whose user pages to edit. This will be used by the bot to skip wikis where the user isn't registered.
-- @param action The action to perform on each page; one of 'delete', 'replace', 'prepend', or 'append'.
-- @param title The title of the page to edit on each wiki (including namespace).
-- @param text The text to write to the page.
-- @param skipWikis An arbitrary list of wikis to skip.
-- @param skipExistingWikis Whether to skip wikis where the page already exists.
-- @param status The status of the request; one of 'queued' (or ''), 'done', 'not done', or 'on hold'.
-- @param flags A string containing arbitrary flags used to control the generated settings.
-- @test p.renderRequestSettings({}, 'Pathoschild', 'overwrite', 'User:Pathoschild/Woop.js', 'test content')
-- @test p.renderRequestSettings({}, 'Pathoschild', 'delete', 'User:Pathoschild/*.js', '', 'metawiki', false, 'globalcssjscleanup')
function inner.renderRequestSettings(html, user, action, title, text, skipWikis, skipExistingWikis, flags)
	-- generate basic settings
	user = user or ''
	local renderedSkipList = skipWikis and ('\'' .. table.concat(mw.text.split(skipWikis, ' *, *'), '\', \'') .. '\'') or ''
	local isGlobalCssJsCleanup = flags ~= nil and string.find(flags, 'globalcssjscleanup')

	-- generate summary
	local deleteSummary = 'None'
	local editSummary = 'None'
	if isGlobalCssJsCleanup then
		local summary = mw.text.nowiki(mw.ustring.format('no longer needed with [[m:global user pages|global user pages]] ([[m:Synchbot|requested by %s]])', user))
		deleteSummary = mw.ustring.format('\'%s\'', summary)
		editSummary = mw.ustring.format('\'removed import %s\'', summary)
	end

	-- generate text & action
	if text ~= nil and string.find(text, string.char(127)) then
		text = '...' -- don't handle text which contains strip markers
	end
	local script = 'None'
	if isGlobalCssJsCleanup then
		script = 'library().remove_global_imports(bot)'
	elseif action == 'replace' then
		script = 'bot.save(u"""' .. mw.text.nowiki(text) .. '""")'
	elseif action == 'prepend' then
		script = 'bot.save(u"""' .. mw.text.nowiki(text) .. '"""' .. ' + "\\n\\n" + bot.text())'
	elseif action == 'append' then
		script = 'bot.save(bot.text() + "\\n\\n" + ' .. 'u"""' .. mw.text.nowiki(text) .. '""")'
	elseif action == 'delete' then
		script = 'bot.delete()'
	end

	-- render
	inner.write(html, '<pre style="display:none;">')
	inner.write(html, '\nrequest_user = \'%s\',\nrequest_titles = [\'%s\'],\nrequest_action = lambda bot: %s,\nskip_existing = %s,\nskip_new = %s,\nskip_unregistered = %s,\nskip_wikis = [%s],\nonly_wikis = [],\ndelete_summary = %s,\nedit_summary = %s,\n',
		user,
		title or 'None',
		script,
		skipExistingWikis and 'True' or 'False',
		action == 'delete' and 'True' or 'False', -- skip uncreated pages when deleting
		action == 'delete' and 'False' or 'True', -- don't skip unregistered wikis when deleting
		renderedSkipList,
		deleteSummary,
		editSummary
	)
	inner.write(html, '</pre>')
end

--- Render a Synchbot request log which summarises the actions performed in response to this request.
-- @param log The logged actions to output, as a multiline sequence of comma-separated values. Each line should consist of three fields: the time (like "00:53"), page link (like "[[s:User:Pathoschild|en.wikisource.org]]"), and log message (like "created (+116)"). Each message will be formatted by matching the log message for common patterns.
-- @param indent (optional) The number of times to indent the output, corresponding to the indentation level of a threaded wiki discussion.
-- @param counts (optional) Counts to add to the bar chart for entries not shown in the log.
-- @test p.renderLog("00:53,[[w:User:Pathoschild|en.wikipedia.org]],created (+116).\n00:53,[[s:User:Pathoschild|en.wikisource.org]],deleted.\n00:55,[[wikt:User:Pathoschild|en.wiktionary.org]],updated (+116).\n00:56,[[wikidata:User:Pathoschild|wikidata.org]],skipped (user is not registered here).", 1)
function inner.renderLog(log, indent, counts)
	-- derive config
	indent = indent or 1
	html = {}

	-- render header
	local write = inner.write
	write(html, '<div style="max-width:50em; max-height:25em; overflow:auto; margin-left:%dem; padding:0.5em; border:1px solid #AAA; border-width:1px 0; font-size:0.85em;">The following log shows what the bot did on each wiki. You can click the columns to sort the log.<br />', indent * 2 + 1)

	-- render bar chart summary
	inner.renderLogChart(html, log, counts)

	-- render log
	write(html, '<table class="sortable"><tr><th>time</th><th>wiki</th><th>logged action</th></tr>')
	log = string.gsub(log, '([^\n,]+),([^\n,]+),([^\n]+)', function(logTime, pageLink, message)
		-- determine color
		if string.find(message, 'created', 1, true) then
			color = '#CFC'
		elseif string.find(message, 'updated', 1, true) then
			color = '#FDC'
		elseif string.find(message, 'deleted', 1, true) or string.find(message, 'marked for deletion', 1, true) then
			color = '#FCC'
		else
			color = '#CCC'
		end

		-- render entry
		return mw.ustring.format('<tr style="background:%s;"><td>%s</td><td>%s</td><td>%s</td></tr>', color, logTime, pageLink, message)
	end)
	write(html, log)
	write(html, '</table></div>')

	-- render
	return inner.flush(html)
end

--- Render a visualisation which summarises the logged actions.
-- @param html The table to which to write the chart.
-- @param log The logged actions to output, as a multiline sequence of comma-separated values. Each line should consist of three fields: the time (like "00:53"), page link (like "[[s:User:Pathoschild|en.wikisource.org]]"), and log message (like "created (+116)"). Each message will be formatted by matching the log message for common patterns.
-- @param counts (optional) Counts to add to the bar chart for entries not shown in the log.
function inner.renderLogChart(html, log, counts)
	-- count log actions
	local created = inner.countMatches(log, 'created')
	local updated = inner.countMatches(log, 'updated')
	local deleted = inner.countMatches(log, 'deleted') + inner.countMatches(log, 'marked for deletion')
	local missing = inner.countMatches(log, 'user is not registered here') + inner.countMatches(log, 'user is detached here') + inner.countMatches(log, 'user is unregistered here')
	local skipped = inner.countMatches(log, 'skip') - missing
	
	-- add manual counts
	created = created + (counts['created'] or 0)
	updated = updated + (counts['updated'] or 0)
	deleted = deleted + (counts['deleted'] or 0)
	missing = missing + (counts['missing'] or 0)
	skipped = skipped + (counts['skipped'] or 0)

	-- format chart
	if created + updated + deleted + missing + skipped > 0 then
		local BarChart = require('Module:Bar')
		local bars = BarChart.renderFromLua({
			{value = created,     color = 'green',        title = created .. ' pages created'},
			{value = updated,     color = 'orange',       title = updated .. ' pages updated'},
			{value = deleted,     color = 'red',          title = deleted .. ' pages deleted'},
			{value = skipped,     color = '#CCC',         title = skipped .. ' pages skipped'},
			{value = missing,     color = 'transparent',  title = missing .. ' pages skipped on wikis where the user isn\'t registered', css='border:1px solid #CCC'},
			nil,
			'30em'
		});

		-- render log table
		inner.write(html, '<table><tr><td>summary: </td><td style="width:30em;">%s</td></tr></table>', tostring(bars))
	end
end

-- Render a user entry for [[User:Pathoschild/2014 global script migration]].
-- @param user The name of the user to migrate.
-- @param affected Whether the user needs to be migrated.
-- @param contacted Whether the user has been offered a migration.
-- @param migrated Whether the user has been migrated.
-- @param notes Ad-hoc notes about the migration.
-- @test p.renderMigrationStatus('Pathoschild', false, nill, nil, nil)
-- @test p.renderMigrationStatus('Pathoschild', true, nill, nil, nil)
-- @test p.renderMigrationStatus('Pathoschild', true, true, nil, 'no response to offer yet.')
-- @test p.renderMigrationStatus('Pathoschild', true, true, true, '[[synchbot|migrated]]')
-- @test p.renderMigrationStatus('Pathoschild', true, true, false, 'declined migration')
function inner.renderMigrationStatus(user, affected, contacted, migrated, notes)
	-- choose row style
	local rowStyle = ''
	if migrated or affected == false then
		rowStyle = 'background:#CFC;'
	elseif contacted and migrated == nil then
		rowStyle = 'background:#CCC; color:#666;'
	elseif contacted == false or (contacted and migrated == false) then
		rowStyle = 'background:#FCC;'
	end

	-- choose cell styles
	local affectedStyle = affected and 'background:#CFC;' or ''
	local contactedStyle = contacted and 'background:#CFC;' or ''
	local affectedIcons = {[true] = '✓', [false] = '∅'}
	local icons = {[true] = '✓', [false] = '✖'}

	-- render
	local html = {}
	local write = inner.write
	write(html, '|- style="' .. rowStyle .. '"\n')
	write(html, '| \'\'\'[[user:' .. user .. '|' .. user .. ']]\'\'\' (<small>[[User talk:' .. user .. '|talk]] • [[Special:Contributions/' .. user .. '|contribs]]</small>)\n')
	write(html, '| <small class="plainlinks">[[toollabs:meta/userpages/' .. user .. '#css,js,user,talk,subpages|pages]] • [[toollabs:meta/crossactivity/' .. user .. '|activity]] • [[toollabs:meta/stalktoy/' .. user .. '|account]]</small>\n')
	write(html, '| [[user:' .. user .. '/global.css|css]] • [[user:' .. user .. '/global.js|js]]\n')
	write(html, '|style="' .. affectedStyle .. '"| ' .. (affectedIcons[affected] or '') .. '\n')
	write(html, '|style="' .. contactedStyle .. '"| ' .. (icons[contacted] or '') .. '\n')
	write(html, '| ' .. (icons[migrated] or '') .. '\n')
	write(html, '| <small>' .. (notes or '') .. '</small>\n')
	
	return inner.flush(html)
end

--- Write an output message to a cache table for eventual concatenation.
-- @param seq The table to which to save the message.
-- @param message The message to save.
-- @param ... The format arguments to apply to the message, if any.
function inner.write(seq, message, ...)
	if select('#', ...) == 0 then
		table.insert(seq, message)
	else
		table.insert(seq, mw.ustring.format(message, ...))
	end
end

--- Concatenate an output cache table into an output-ready string.
-- @param seq The table with messages to output.
function inner.flush(seq)
	return table.concat(seq)
end

--- Count the number of times a substring occurs in a string.
-- @param str The string to search.
-- @param substr The substring whose occurences to count.
function inner.countMatches(str, substr)
	local _, count = string.gsub(str, substr, substr)
	return count
end

--- Normalize a user-input string by trimming outer whitespace, and replacing it with nil if it's empty.
-- @param str The input string to normalize.
function inner.trimInput(str)
	-- trim
	if str then
		str = mw.text.trim(str)
	end

	-- return input or nil
	if not str or str == '' then
		return nil
	end
	return str
end

return p