Module:Mods overview table

From Terraria Mods Wiki
Jump to navigation Jump to search
Lua.svg Documentation The documentation below is transcluded from Module:Mods overview table/doc. (edit | history)

This module is used to assemble the large table on Terraria Mods Wiki:Overview. The criteria for deletion as well as the limit for the "last edit" data fetching can be adjusted at the top of the source code.

The module is invoked as follows: {{#invoke:Mods overview table|go}}.


---Criteria any of which a mod must meet in order not to be marked as pending for deletion.
local deletionCriteria = {
	---Minimum number of pages pertaining to the mod.
	minPages = 15,
	---Maximum number of days since the latest edit to any of the mod's pages.
	lasteditMaxDays = 14
}

---Retrieving the data about the latest edit of a mod is a very expensive operation
---that causes an error if performed too many times. Therefore, the number of mods
---for which the "lastedit data" can be retrieved each day must be limited. This
---variable specifies that limit.
local modsToCheckForLasteditedPerDay = 75

---First index of the mods for which lastedit data can be retrieved. Needs to be
---computed via `computeStartIndex()`.
---@type integer
local startIndexForLasteditData

---Default content language of the wiki (i.e. `$wgLanguageCode`, not the value of the
---`uselang` URL parameter, and not the user's language preference setting).
local contentLanguage = mw.getContentLanguage()

---A cached version of the current frame, the interface to the parser.
local currentFrame

---The current date (in UTC) as a string, e.g. "20220131".
local todayStr = os.date('!%Y%m%d')

---The current time in seconds since the epoch.
local now = os.time()

---List of all mod main pages. Needs to be filled via `fillMainpages()`.
---@type string[]
local mainpages

---Cached expansion of `{{yes}}` / `{{no}}`.
---@type string
local yes, no


---Return `x` rounded to the nearest integer.
---@param x number
---@return integer
local function round(x)
	return math.floor(x + 0.5)
end


---Split the `str` on each `div` in it and return the result as a table.
---This is much much faster then `mw.text.split`.
---Credit: http://richard.warburton.it.
---@param div string
---@param str string
---@return table|boolean
local function explode(div,str)
	if (div=='') then return false end
	local pos,arr = 0,{}
	-- for each divider found
	for st,sp in function() return string.find(str,div,pos,true) end do
		arr[#arr + 1] = string.sub(str,pos,st-1) -- Attach chars left of current divider
		pos = sp + 1 -- Jump past current divider
	end
	arr[#arr + 1] = string.sub(str,pos) -- Attach chars right of last divider
	return arr
end


---Call `{{note}}`.
---@param text string Note text
---@param paren? boolean Whether to add parentheses
---@param small? boolean Whether to use small text
local function note(text, paren, small)
	return currentFrame:expandTemplate{
		title = 'note',
		args = {
			text,
			paren = paren and 'y' or nil,
			small = small and 'y' or nil
		}
	}
end


---Call `{{duration}}`.
---@param seconds integer Number of seconds to format
---@return string
local function duration(seconds)
	return currentFrame:expandTemplate{
		title = 'duration' ,
		args = { seconds, 'weeks' }
	}
end


---Return the duration between now and the timestamp described by `seconds`.
---Round down the duration to days and format it.
---@param seconds integer
---@return string
local function daysDiff(seconds)
	local secondsDiff = math.abs(now - seconds)
	-- round the seconds down to days
	secondsDiff = math.floor(secondsDiff / (3600*24)) * (3600*24)
	local durationStr = 'less than 1 day'
	if secondsDiff > 0 then
		durationStr = duration(secondsDiff)
	end
	return durationStr
end


---Perform a query with Extension:DynamicPageList (DPL).
---@param ... table Named parameters for the query
---@return string
local function dpl(...)
	local dplArgs = ...
	-- callParserFunction requires a first unnamed parameter
	table.insert(dplArgs, 1, '')
	return currentFrame:callParserFunction{
		name = '#dpl',
		args = dplArgs
	}
end


---Convert a timestamp from a DPL query result to an integer of seconds.
---@param timestampFromDpl string
---@return integer
local function dplTimestampToSeconds(timestampFromDpl)
	if type(timestampFromDpl) ~= 'string' or timestampFromDpl == '' then
		timestampFromDpl = '1970-01-01 00:00:00'
	end
	local year, month, day, hour, min, sec = string.match(
		timestampFromDpl,
		'^(%d%d%d%d)%-(%d%d)%-(%d%d) (%d%d):(%d%d):(%d%d)$'
	)
	return os.time{
		year = year, month = month, day = day,
		hour = hour, min = min, sec = sec
	}
end


---Check whether the given page exists.
---@param title string Page title with namespace
---@return boolean
local function pageExists(title)
	return '1' ~= dpl{
		title = title,
		redirects = 'include',
		mode = 'userformat',
		noresultsheader = '1'
	}
end


---Fetch all mod main pages and put them in the global `mainpages` list.
local function fillMainpages()
	local mainpagesString = currentFrame:expandTemplate{ title = 'modlist', args = { sep = '<br/>' } }
	local mainpagesTable = explode('<br/>', mainpagesString)
	---@cast mainpagesTable string[]
	mainpages = mainpagesTable
end


---Compute the `startIndexForLasteditData` variable. Requires that `mainpages`
---is filled.
local function computeStartIndex()
	local yearday = os.date('!*t').yday
	-- yearday is the current day of the year, e.g. 32 if today is 01 February.
	-- using this ensures that we advance the startIndex by exactly
	-- `modsToCheckForLasteditedPerDay` mods each day. for example, if #mainpages=12
	-- and modsToCheckForLasteditedPerDay=5, then startIndexForLasteditData would be
	--  5 on 01 January,
	-- 10 on 02 January,
	--  3 on 03 January,
	--  8 on 04 January,
	--  1 on 05 January, etc.
	-- the startIndex is increased by 5 each day and wraps around when it exceeds 12.
	startIndexForLasteditData = (yearday * modsToCheckForLasteditedPerDay) % #mainpages
end


---@class Mod
---@field name string Mod name
---@field index integer Mod index in the list of all mods
---@field daysUntilLasteditDataAvailable integer Number of days to wait until the data about the mod's last edit is available
local Mod = {}


---Create a new `Mod` instance.
---@param index integer Index of the mod in the list of all mods
---@param name string Name of the mod
---@return Mod
function Mod:new(index, name)
	local newModInstance = {}
	setmetatable(newModInstance, { __index = self })
	newModInstance:_init(index, name)
	return newModInstance
end


---Initialize the new `Mod` instance's attributes.
function Mod:_init(index, name)
	self.name = name or ''
	self.index = index or 0
	self._pagesnumber = -1
	self._mainpageCreated = -1
	self.daysUntilLasteditDataAvailable = -1
	self._lastedit = { time = 0, revision = -1 }
	self._pendingDeletionNow = nil
	self._pendingDeletionSoon = nil
end


---Return the number of pages in the mod.
---@return integer
function Mod:getPagesnumber()
	if self._pagesnumber == -1 then
		-- number of subpages of the mod main page
		local pagesnumber = tonumber(dpl{
			namespace = '',
			titlematch = self.name .. '/%',
			mode = 'userformat',
			resultsheader = '%TOTALPAGES%',
			noresultsheader = '0'
		})
		-- add the mod main page
		self._pagesnumber = 1 + pagesnumber
	end
	return self._pagesnumber
end


---Return the creation date of the mod's main page, in seconds.
---@return integer
function Mod:getMainpageCreated()
	if self._mainpageCreated == -1 then
		local timestampString = dpl{
			title = self.name,
			ordermethod = 'firstedit',
			addeditdate = 'true',
			format = ',%DATE%,,'
		}
		self._mainpageCreated = dplTimestampToSeconds(timestampString)
	end
	return self._mainpageCreated
end


---Return whether lastedit data is available for the mod, and
---compute `daysUntilLasteditDataAvailable` in the process.
---@return boolean
function Mod:isLasteditDataAvailable()
	if self.daysUntilLasteditDataAvailable == -1 then
		-- the startIndex must be subtracted from the mod's index, but that would
		-- yield negative numbers in some cases, so "shift" all indices
		local modIndexShifted = self.index + #mainpages - startIndexForLasteditData
		self.daysUntilLasteditDataAvailable = math.floor(
			(modIndexShifted % #mainpages) / modsToCheckForLasteditedPerDay
		)
		-- example: #mainpages=12, modsToCheckForLasteditedPerDay=5, startIndexForLasteditData=10.
		-- we expect the mods with indices 10, 11, 12, 1, 2 to have lastedit data available today.
		-- we expect the mods with indices 3, 4, 5, 6, 7 to have the data available in 1 day.
		-- we expect the mods with indices 8, 9 to have the data available in 2 days.
		-- check for self.index=12:
		--    modIndexShifted==14,
		--    (modIndexShifted % #mainpages) / modsToCheckForLasteditedPerDay==0.4.
		-- check for self.index=7:
		--    modIndexShifted==9,
		--    (modIndexShifted % #mainpages) / modsToCheckForLasteditedPerDay==1.8.
		-- check for self.index=8:
		--    modIndexShifted==10,
		--    (modIndexShifted % #mainpages) / modsToCheckForLasteditedPerDay==2.
	end
	return self.daysUntilLasteditDataAvailable == 0
end


---Return timestamp in seconds and revision ID of the last edit to any of the mod's pages.
---@return { time: integer, revision: integer }
function Mod:getLastedit()
	if self._lastedit.time == 0 and self._lastedit.revision == -1 then
		local dplResult = dpl{
			titlematch = self.name .. '¦' .. self.name .. '/%',
			allrevisionsbefore = todayStr,
			count = 1,
			format = ',%DATE%§%REVISION%,,'
		}
		if dplResult ~= '' and dplResult ~= '§' then
			local exploded = explode('§', dplResult)
			self._lastedit.time = dplTimestampToSeconds(exploded[1])
			self._lastedit.revision = exploded[2]
		end
	end
	return self._lastedit
end


---Check whether the mod is currently pending for deletion.
---@return boolean
function Mod:isPendingDeletionNow()
	if self._pendingDeletionNow == nil then
		if self:getPagesnumber() >= deletionCriteria.minPages or not self:isLasteditDataAvailable() then
			-- mod has at least the minimum number of pages, so it's not pending deletion
			-- or lastedit data is unavailable, so it's unknown if it's pending deletion
			self._pendingDeletionNow = false
		else
			-- mod has fewer than the minimum number of pages and the lastedit data is available, so:
			-- if the last edit was more than the maximum number of days ago, then it's pending deletion
			self._pendingDeletionNow = now - self:getLastedit().time > (3600*24*deletionCriteria.lasteditMaxDays)
		end
	end
	return self._pendingDeletionNow
end


---Check whether the mod will be pending for deletion soon (unless it is edited before then).
---@return boolean
function Mod:isPendingDeletionSoon()
	if self._pendingDeletionSoon == nil then
		if self:getPagesnumber() >= deletionCriteria.minPages or not self:isLasteditDataAvailable() then
			-- mod has at least the minimum number of pages, so it's not pending deletion
			-- or lastedit data is unavailable, so it's unknown if it's pending deletion
			self._pendingDeletionSoon = false
		else
			-- mod has fewer than the minimum number of pages and the lastedit data is available, so:
			-- if the mod is not already pending for deletion, then it will soon
			self._pendingDeletionSoon = not self:isPendingDeletionNow()
		end
	end
	return self._pendingDeletionSoon
end


---Fill the `tableRow` with HTML, taking information from the `mod`.
---@param tableRow any `<tr>` object that will be filled
---@param mod Mod Mod object for which to display information in this row
local function makeTableRow(tableRow, mod)
	local td

	-- column: "Mod name"
	tableRow:tag('td')
		:wikitext('[[' .. mod.name .. ']]')

	-- column: "Pages"
	tableRow:tag('td')
		:wikitext(
			'[[Special:PrefixIndex/' .. mod.name .. '|' ..
			contentLanguage:formatNum(mod:getPagesnumber()) .. ']]'
		)

	-- column: "Created"
	td = tableRow:tag('td')
		:attr('data-sort-value', mod:getMainpageCreated())
		:wikitext(contentLanguage:formatDate('d M Y', '@' .. mod:getMainpageCreated()))
	td:tag('div')
		:wikitext(note(daysDiff(mod:getMainpageCreated()) .. ' ago', true))

	-- column: "Last edited"
	td = tableRow:tag('td')
	if mod:isLasteditDataAvailable() then
		td:attr('data-sort-value', mod:getLastedit().time)
			:wikitext(
				'[[Special:Diff/' .. mod:getLastedit().revision .. '|' ..
				contentLanguage:formatDate('d M Y', '@' .. mod:getLastedit().time) .. ']]'
			)
		td:tag('div')
			:wikitext(note(daysDiff(mod:getLastedit().time) .. ' ago', true))
	else
		local noteText = 'Currently unknown,<br/>check back in ' .. mod.daysUntilLasteditDataAvailable .. ' day'
		if mod.daysUntilLasteditDataAvailable ~= 1 then
			noteText = noteText .. 's'
		end
		td:attr('data-sort-value', now + mod.daysUntilLasteditDataAvailable * (3600*24))
			:tag('div')
				:wikitext(note("<p style='line-height:1.2'>''" .. noteText .. ".''</p>", false, true))
	end

	-- column: "Issues"
	td = tableRow:tag('td')
	local issueStrings = {}
	if mod:isPendingDeletionNow() then
		table.insert(issueStrings, note('Wiki is pending deletion.'))
	end
	if mod:isPendingDeletionSoon() then
		local timeUntilDeletion = daysDiff(mod:getLastedit().time + (3600*24*deletionCriteria.lasteditMaxDays))
		local noteText = 'Wiki will be pending deletion if not worked on within ' .. timeUntilDeletion .. '.'
		table.insert(issueStrings, note(noteText))
	end
	if not pageExists('File:Logo (' .. mod.name .. ').png') then
		table.insert(issueStrings, "Can't find [[:File:Logo (" .. mod.name .. ').png]]!')
	end
	if not pageExists('Category:' .. mod.name) then
		table.insert(issueStrings, "Can't find [[:Category:" .. mod.name .. ']]!')
	end
	if #issueStrings > 0 then
		td:wikitext(table.concat(issueStrings, '<hr class="incell-border"/>'))
	else
		td:wikitext("''None''")
	end

	-- column: "OK"
	tableRow:tag('td')
		:wikitext(mod:isPendingDeletionNow() and no or yes)
end


---Create a `<caption>` tag and put it in the `ovTable`.
---@param ovTable any `<table>` object
---@param mods Mod[] List of mods
local function makeTableCaption(ovTable, mods)
	local caption = ovTable:tag('caption')
	caption:tag('big')
		:wikitext('Information about all mods')

	local purge = currentFrame:expandTemplate{ title = 'purge' }
	caption:tag('div')
		:wikitext(note(purge .. ' to refresh', true, true))

	local pagesNumbersSum = 0
	for _, mod in ipairs(mods) do
		pagesNumbersSum = pagesNumbersSum + mod:getPagesnumber()
	end
	local dotlist = currentFrame:expandTemplate{ title = 'dotlist', args = {
		'Total number of mods: ' .. contentLanguage:formatNum(#mods),
		'Total number of mod pages: ' .. contentLanguage:formatNum(pagesNumbersSum),
		'Avg. number of pages: ' .. contentLanguage:formatNum(round(pagesNumbersSum / #mods)),
		space = 'xl'
	} }
	caption:tag('small')
		:wikitext(dotlist)
end


---Create a `<tr>` tag with header cells and put it in the `ovTable`.
---@param ovTable any `<table>` object
local function makeTableHeader(ovTable)
	local tr = ovTable:tag('tr')
	tr:tag('th'):wikitext('Mod name')
	tr:tag('th'):wikitext('Pages')
	tr:tag('th'):wikitext('Created')
	tr:tag('th'):wikitext('Last edited')
	tr:tag('th'):wikitext('Issues')
	local refTag = currentFrame:extensionTag('ref',
		'Does the wiki have at least ' .. deletionCriteria.minPages .. ' pages' ..
		' or was worked on in ' .. deletionCriteria.lasteditMaxDays .. ' days?'
	)
	tr:tag('th'):wikitext('OK' .. refTag)
end


-----------------------------------------------------------------
---main return object
local p = {}

p.go = function(frame)
	currentFrame = frame

	-- cache the expansions of these templates so that they don't
	-- have to be expanded over and over again
	yes = currentFrame:expandTemplate{ title = 'yes' }
	no = currentFrame:expandTemplate{ title = 'no' }

	fillMainpages()
	computeStartIndex()

	---List of all `Mod` objects
	---@type Mod[]
	local mods = {}

	-- create a `Mod` object for each mod mainpage
	for i, mainpageName in ipairs(mainpages) do
		mods[i] = Mod:new(i, mainpageName)
	end

	---Table HTML element
	local ovTable = mw.html.create('table')
		:addClass('terraria')
		:addClass('sortable')
		:addClass('lined')

	makeTableCaption(ovTable, mods)
	makeTableHeader(ovTable)

	for _, mod in ipairs(mods) do
		local tr = ovTable:tag('tr')
		makeTableRow(tr, mod)
	end

	return ovTable

end

return p