Module:Current events monthly archive

-- This module generates the monthly archives [[Portal:Current events]].
-- See a sample archive at [[Portal:Current events/September 2011]].

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

-- Return true if num is a positive integer; otherwise return false
local function isPositiveInteger(num)
	return num > 0 and num == math.floor(num)
end

-- Make an ordinal number from an integer.
local function makeOrdinalNumber(num)
	local suffix
	local rem100 = num % 100
	if rem100 == 11 or rem100 == 12 or rem100 == 13 then
		suffix = 'th'
	else
		local rem10 = num % 10
		if rem10 == 1 then
			suffix = 'st'
		elseif rem10 == 2 then
			suffix = 'nd'
		elseif rem10 == 3 then
			suffix = 'rd'
		else
			suffix = 'th'
		end
	end
	return tostring(num) .. suffix
end

-- Try to parse the year and month from the current title.
-- This template is usually used on pages with titles like
-- [[Portal:Current events/September 2011]], so our general approach will be to
-- pass the subpage name to lang:formatDate and see if we get something that's
-- not an error.
local function parseYearAndMonthFromCurrentTitle()
	local title = mw.title.getCurrentTitle()
	local lang = mw.language.getContentLanguage()
	-- Detect if we are on a sandbox page, and if so, use the base page.
	if title.subpageText:find('^[sS]andbox%d*$') then
		title = title.basePageTitle
	end
	-- Try to parse the date.
	local success, date = pcall(function ()
		-- lang:formatDate throws errors if it gets strange input,
		-- so use pcall to catch them, as random subpage names will
		-- usually not be well-formed dates.
		return lang:formatDate('Y-m', title.subpageText)
	end)
	if not success then
		-- We couldn't parse the date, so return nil.
		return nil, nil
	end
	-- Parse the year and month numbers from the date we got from
	-- lang:formatDate. If we can't parse them, then something has gone
	-- wrong with either lang:formatDate or our pattern.
	local year, month = date:match('^(%d%d%d%d)%-(%d%d)$')
	year = tonumber(year)
	month = tonumber(month)
	if not year or not month then
		error('Internal error in [[Module:Current events '
			.. 'monthly archive]]: couldn\'t match date '
			.. 'from lang:formatDate output'
		)
	end
	return year, month
end

--------------------------------------------------------------------------------
-- Date info
--------------------------------------------------------------------------------

-- Get a table of information about the date for the monthly archive.
local function getDateInfo(year, month)
	local lang = mw.language.getContentLanguage()
	local dateFuncs = {}
	local dateInfo = setmetatable({}, {
		__index = function (t, key)
			-- Memoize values so we only have to calculate them once.
			if dateFuncs[key] then
				local val = dateFuncs[key]()
				t[key] = val
				return val
			end
		end
	})

	function dateFuncs.currentYear()
		-- The current year (number)
		return tonumber(os.date('%Y'))
	end

	function dateFuncs.currentMonthNumber()
		-- The current month (number)
		return tonumber(os.date('%m'))
	end

	function dateFuncs.year()
		-- The year (number)
		return tonumber(year) or dateInfo.currentYear
	end

	function dateFuncs.monthNumber()
		-- The month (number)
		return tonumber(month) or dateInfo.currentMonthNumber
	end

	function dateFuncs.monthNumberZeroPadded()
		-- The month, zero-padded to two digits (string)
		return string.format('%02d', dateInfo.monthNumber)
	end

	function dateFuncs.date()
		-- The date in YYYY-MM-DD format (string)
		return string.format(
			'%04d-%02d-01',
			dateInfo.year, dateInfo.monthNumber
		)
	end

	function dateFuncs.monthName()
		-- The month name, e.g. "September" (string)
		return lang:formatDate('F', dateInfo.date)
	end

	function dateFuncs.monthOrdinal()
		-- The ordinal month as an English word (string)
		local ordinals = {
			"first",   "second",   "third",
			"fourth",  "fifth",    "sixth",
			"seventh", "eighth",   "ninth",
			"tenth",   "eleventh", "twelfth and final",
		}
		return ordinals[dateInfo.monthNumber]
	end

	function dateFuncs.beVerb()
		-- If the month is the current month or a month in the future, then this
		-- is the string "is"; otherwise, "was" (string)
		if dateInfo.year > dateInfo.currentYear
			or (
				dateInfo.year == dateInfo.currentYear
				and dateInfo.monthNumber >= dateInfo.currentMonthNumber
			)
		then
			return 'is'
		else
			return 'was'
		end
	end

	function dateFuncs.leapDesc()
		-- The year's leap year status; either "common", "leap" or
		-- "century leap" (string)
		local isLeapYear = tonumber(lang:formatDate('L', dateInfo.date)) == 1
		if isLeapYear and dateInfo.year % 400 == 0 then
			return 'century leap'
		elseif isLeapYear then
			return 'leap'
		else
			return 'common'
		end
	end

	function dateFuncs.decadeNote()
		-- If the month is the first or last of a decade, century, or
		-- millennium, a note to that effect; otherwise the empty string
		-- (string)
		local function getMillennium(year)
			return math.floor((year - 1) / 1000) + 1 -- Fenceposts
		end

		local function getCentury(year)
			return math.floor((year - 1) / 100) + 1 -- Fenceposts
		end

		local year = dateInfo.year
		local month = dateInfo.monthNumber
		local firstOrLast = month == 12 and "last" or "first"

		if year % 1000 == 0 and month == 12
			or year % 1000 == 1 and month == 1
		then
			local millennium = makeOrdinalNumber(getMillennium(year))
			local century = makeOrdinalNumber(getCentury(year))
			return string.format(
				--Millenniums always overlap centuries.
				"It %s the %s month of the [[%s millennium]] and the [[%s century]].",
				dateInfo.beVerb, firstOrLast, millennium, century
			)
		elseif year % 100 == 0 and month == 12
			or year % 100 == 1 and month == 1
		then
			local century = makeOrdinalNumber(getCentury(year))
			return string.format(
				"It %s the %s month of the [[%s century]].",
				dateInfo.beVerb, firstOrLast, century
			)
		elseif year % 10 == 9 and month == 12
			or year % 10 == 0 and month == 1
		then
			local decadeNumber = math.floor(dateInfo.year / 10) * 10
			return string.format(
				"It %s the %s month of the [[%ds]] decade.",
				dateInfo.beVerb, firstOrLast, decadeNumber
			)
		end

		return ''
	end

	function dateFuncs.moonNote()
		-- If the month had no full moon, a note to that effect; otherwise the
		-- empty string (string)
		if dateInfo.monthNumber == 2 then
			-- https://www.quora.com/When-was-the-last-time-the-entire-month-of-February-passed-without-a-Full-Moon/answer/Alan-Marble
			local year = dateInfo.year
			if year == 1961
				or year == 1999
				or year == 2018
				or year == 2037
				or year == 2067
				or year == 2094
			then
				return 'This month had no full moon.'
			end
		end

		return ''
	end

	function dateFuncs.firstDayOfMonth()
		-- Weekday of the first day of the month, e.g. "Tuesday" (string)
		return lang:formatDate('l', dateInfo.date)
	end

	function dateFuncs.lastDayOfMonth()
		-- Weekday of the last day of the month, e.g. "Thursday" (string)
		return lang:formatDate('l', dateInfo.date .. ' +1 month -1 day')
	end

	function dateFuncs.daysInMonth()
		-- Number of days in the month (number)
		return tonumber(lang:formatDate(
			'j',
			dateInfo.date .. ' +1 month -1 day')
		)
	end

	function dateFuncs.mainContent()
		-- The rendered content of all the current events portal pages for the
		-- month (string)
		local ret = {}
		local frame = mw.getCurrentFrame()
		local year = dateInfo.year
		local monthName = dateInfo.monthName
		for date = 1, 31 do
			local portalTitle = mw.title.new(string.format(
				'Portal:Current events/%d %s %d',
				year, monthName, date
			))
			if portalTitle.exists then
				table.insert(
					ret,
					frame:expandTemplate{title = portalTitle.prefixedText}
				)
			end
		end
		return table.concat(ret, '\n')
	end

	return dateInfo
end

--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------

local p = {}

function p.main(frame)
	-- Get the arguments
	local args = require('Module:Arguments').getArgs(frame, {
		wrappers = 'Template:Current events monthly archive',
	})
	local year = tonumber(args.year)
	local month = tonumber(args.month)

	-- Validate the arguments
	if year and not isPositiveInteger(year) then
		error('invalid year argument (must be a positive integer)', 2)
	end
	if month then
		if not isPositiveInteger(month) then
			error('invalid month argument (must be a positive integer)', 2)
		elseif month > 12 then
			error('invalid month argument (must be 12 or less)', 2)
		end
	end

	-- If we weren't passed a month or a year, try to get them from the
	-- page title.
	if not year and not month then
		year, month = parseYearAndMonthFromCurrentTitle()
	end

	-- Convert the dateInfo table values into arguments to pass to the current
	-- events monthly archive display template
	local dateInfo = getDateInfo(year, month)
	local displayArgs = {}
	displayArgs['year']                     = dateInfo.year
	displayArgs['month-name']               = dateInfo.monthName
	displayArgs['month-number']             = dateInfo.monthNumber
	displayArgs['month-number-zero-padded'] = dateInfo.monthNumberZeroPadded
	displayArgs['be-verb']                  = dateInfo.beVerb
	displayArgs['month-ordinal']            = dateInfo.monthOrdinal
	displayArgs['leap-desc']                = dateInfo.leapDesc
	displayArgs['moon-note']                = dateInfo.moonNote
	displayArgs['decade-note']              = dateInfo.decadeNote
	displayArgs['first-day-of-month']       = dateInfo.firstDayOfMonth
	displayArgs['last-day-of-month']        = dateInfo.lastDayOfMonth
	displayArgs['days-in-month']            = dateInfo.daysInMonth
	displayArgs['main-content']             = dateInfo.mainContent

	-- Expand the display template with the arguments from dateInfo, and return
	-- it
	return frame:expandTemplate{
		title = 'Current events monthly archive/display',
		args = displayArgs,
	}
end

-- Export getDateInfo so that we can use it in unit tests.
p.getDateInfo = getDateInfo

return p