From Wikipedia, the free encyclopedia

local yesno = require('Module:Yesno')

local lang = mw.language.getContentLanguage()

local N_YEAR_DIGITS = 12

local MAX_YEAR = 10^N_YEAR_DIGITS - 1



--------------------------------------------------------------------------------

-- Dts class

--------------------------------------------------------------------------------



local Dts = {}

Dts.__index = Dts



Dts.months = {

	"January",

	"February",

	"March",

	"April",

	"May",

	"June",

	"July",

	"August",

	"September",

	"October",

	"November",

	"December"

}



Dts.monthsAbbr = {

	"Jan",

	"Feb",

	"Mar",

	"Apr",

	"May",

	"Jun",

	"Jul",

	"Aug",

	"Sep",

	"Oct",

	"Nov",

	"Dec"

}



function Dts._makeMonthSearch(t)

	local ret = {}

	for i, month in ipairs(t) do

		retmonth:lower()] = i

	end

	return ret

end

Dts.monthSearch = Dts._makeMonthSearch(Dts.months)

Dts.monthSearchAbbr = Dts._makeMonthSearch(Dts.monthsAbbr)

Dts.monthSearchAbbr'sept' = 9 -- Allow "Sept" to match September



Dts.formats = {

	dmy = true,

	mdy = true,

	dm = true,

	md = true,

	my = true,

	y = true,

	m = true,

	d = true,

	hide = true

}



function Dts.new(args)

	local self = setmetatable({}, Dts)



	-- Parse date parameters.

	-- In this step we also record whether the date was in DMY or YMD format,

	-- and whether the month name was abbreviated.

	if args2 or args3 or args4 then

		self:parseDateParts(args1], args2], args3], args4])

	elseif args1 then

		self:parseDate(args1])

	end



	-- Raise an error on invalid values

	if self.year then

		if self.year == 0 then

			error('years cannot be zero', 0)

		elseif self.year < -MAX_YEAR then

			error(string.format(

				'years cannot be less than %s',

				lang:formatNum(-MAX_YEAR)

			), 0)

		elseif self.year > MAX_YEAR then

			error(string.format(

				'years cannot be greater than %s',

				lang:formatNum(MAX_YEAR)

			), 0)

		elseif math.floor(self.year) ~= self.year then

			error('years must be an integer', 0)

		end

	end

	if self.month and (

		self.month < 1

		or self.month > 12

		or math.floor(self.month) ~= self.month

	) then

		error('months must be an integer between 1 and 12', 0)

	end

	if self.day and (

		self.day < 1

		or self.day > 31

		or math.floor(self.day) ~= self.day

	) then

		error('days must be an integer between 1 and 31', 0)

	end



	-- Set month abbreviation behaviour, i.e. whether we are outputting

	-- "January" or "Jan".

	if args.abbr then

		self.isAbbreviated = args.abbr == 'on' or yesno(args.abbr) or false

	else

		self.isAbbreviated = self.isAbbreviated or false

	end



	-- Set the format string

	if args.format then

		self.format = args.format

	else

		self.format = self.format or 'mdy'

	end

	if not Dts.formatsself.format then

		error(string.format(

			"'%s' is not a valid format",

			tostring(self.format)

		), 0)

	end



	-- Set addkey. This adds a value at the end of the sort key, allowing users

	-- to manually distinguish between identical dates.

	if args.addkey then

		self.addkey = tonumber(args.addkey)

		if not self.addkey or

			self.addkey < 0 or

			self.addkey > 9999 or

			math.floor(self.addkey) ~= self.addkey

		then

			error("the 'addkey' parameter must be an integer between 0 and 9999", 0)

		end

	end



	-- Set whether the displayed date is allowed to wrap or not.

	self.isWrapping = args.nowrap == 'off' or yesno(args.nowrap) == false



	return self

end



function Dts:hasDate()

	return (self.year or self.month or self.day) ~= nil

end



-- Find the month number for a month name, and set the isAbbreviated flag as

-- appropriate.

function Dts:parseMonthName(s)

	s = s:lower()

	local month = Dts.monthSearchs

	if month then

		return month

	else

		month = Dts.monthSearchAbbrs

		if month then

			self.isAbbreviated = true

			return month

		end

	end

	return nil

end



-- Parses separate parameters for year, month, day, and era.

function Dts:parseDateParts(year, month, day, bc)

	if year then

		self.year = tonumber(year)

		if not self.year then

			error(string.format(

				"'%s' is not a valid year",

				tostring(year)

			), 0)

		end

	end

	if month then

		if tonumber(month) then

			self.month = tonumber(month)

		elseif type(month) == 'string' then

			self.month = self:parseMonthName(month)

		end

		if not self.month then

			error(string.format(

				"'%s' is not a valid month",

				tostring(month)

			), 0)

		end

	end

	if day then

		self.day = tonumber(day)

		if not self.day then

			error(string.format(

				"'%s' is not a valid day",

				tostring(day)

			), 0)

		end

	end

	if bc then

		local bcLower = type(bc) == 'string' and bc:lower()

		if bcLower == 'bc' or bcLower == 'bce' then

			if self.year and self.year > 0 then

				self.year = -self.year

			end

		elseif bcLower ~= 'ad' and bcLower ~= 'ce' then

			error(string.format(

				"'%s' is not a valid era code (expected 'BC', 'BCE', 'AD' or 'CE')",

				tostring(bc)

			), 0)

		end

	end

end



-- This method parses date strings. This is a poor man's alternative to

-- mw.language:formatDate, but it ends up being easier for us to parse the date

-- here than to use mw.language:formatDate and then try to figure out after the

-- fact whether the month was abbreviated and whether we were DMY or MDY.

function Dts:parseDate(date)

	-- Generic error message.

	local function dateError()

		error(string.format(

			"'%s' is an invalid date",

			date

		), 0)

	end



	local function parseDayOrMonth(s)

		if s:find('^%d%d?$') then

			return tonumber(s)

		end

	end



	local function parseYear(s)

		if s:find('^%d%d%d%d?$') then

			return tonumber(s)

		end

	end



	-- Deal with year-only dates first, as they can have hyphens in, and later

	-- we need to split the string by all non-word characters, including

	-- hyphens. Also, we don't need to restrict years to 3 or 4 digits, as on

	-- their own they can't be confused as a day or a month number.

	self.year = tonumber(date)

	if self.year then

		return

	end



	-- Split the string using non-word characters as boundaries.

	date = tostring(date)

	local parts = mw.text.split(date, '%W+')

	local nParts = #parts

	if parts1 == '' or partsnParts == '' or nParts > 3 then

		-- We are parsing a maximum of three elements, so raise an error if we

		-- have more. If the first or last elements were blank, then the start

		-- or end of the string was a non-word character, which we will also

		-- treat as an error.

		dateError()

	elseif nParts < 1 then

	 	-- If we have less than one element, then something has gone horribly

	 	-- wrong.

		error(string.format(

			"an unknown error occurred while parsing the date '%s'",

			date

		), 0)

	end



	if nParts == 1 then

		-- This can be either a month name or a year.

		self.month = self:parseMonthName(parts1])

		if not self.month then

			self.year = parseYear(parts1])

			if not self.year then

				dateError()

			end

		end

	elseif nParts == 2 then

		-- This can be any of the following formats:

		-- DD Month

		-- Month DD

		-- Month YYYY

		-- YYYY-MM

		self.month = self:parseMonthName(parts1])

		if self.month then

			-- This is either Month DD or Month YYYY.

			self.year = parseYear(parts2])

			if not self.year then

				-- This is Month DD.

				self.format = 'mdy'

				self.day = parseDayOrMonth(parts2])

				if not self.day then

					dateError()

				end

			end

		else

			self.month = self:parseMonthName(parts2])

			if self.month then

				-- This is DD Month.

				self.format = 'dmy'

				self.day = parseDayOrMonth(parts1])

				if not self.day then

					dateError()

				end

			else

				-- This is YYYY-MM.

				self.year = parseYear(parts1])

				self.month = parseDayOrMonth(parts2])

				if not self.year or not self.month then

					dateError()

				end

			end

		end

	elseif nParts == 3 then

		-- This can be any of the following formats:

		-- DD Month YYYY

		-- Month DD, YYYY

		-- YYYY-MM-DD

		-- DD-MM-YYYY

		self.month = self:parseMonthName(parts1])

		if self.month then

			-- This is Month DD, YYYY.

			self.format = 'mdy'

			self.day = parseDayOrMonth(parts2])

			self.year = parseYear(parts3])

			if not self.day or not self.year then

				dateError()

			end

		else

			self.day = parseDayOrMonth(parts1])

			if self.day then

				self.month = self:parseMonthName(parts2])

				if self.month then

					-- This is DD Month YYYY.

					self.format = 'dmy'

					self.year = parseYear(parts3])

					if not self.year then

						dateError()

					end

				else

					-- This is DD-MM-YYYY.

					self.format = 'dmy'

					self.month = parseDayOrMonth(parts2])

					self.year = parseYear(parts3])

					if not self.month or not self.year then

						dateError()

					end

				end

			else

				-- This is YYYY-MM-DD

				self.year = parseYear(parts1])

				self.month = parseDayOrMonth(parts2])

				self.day = parseDayOrMonth(parts3])

				if not self.year or not self.month or not self.day then

					dateError()

				end

			end

		end

	end

end



function Dts:makeSortKey()

	local year, month, day

	local nYearDigits = N_YEAR_DIGITS

	if self:hasDate() then

		year = self.year or os.date("*t").year

		if year < 0 then

			year = -MAX_YEAR - 1 - year

			nYearDigits = nYearDigits + 1 -- For the minus sign

		end

		month = self.month or 1

		day = self.day or 1

	else

		-- Blank transclusions should sort last.

		year = MAX_YEAR

		month = 99

		day = 99

	end

	return string.format(

		'%0' .. nYearDigits .. 'd-%02d-%02d-%04d',

		year, month, day, self.addkey or 0

	)

end



function Dts:getMonthName()

	if not self.month then

		return ''

	end

	if self.isAbbreviated then

		return self.monthsAbbrself.month

	else

		return self.monthsself.month

	end

end



function Dts:makeDisplay()

	if self.format == 'hide' then

		return ''

	end

	local hasYear = self.year and self.format:find('y')

	local hasMonth = self.month and self.format:find('m')

	local hasDay = self.day and self.format:find('d')

	local isMonthFirst = self.format:find('md')

	local ret = {}

	if hasDay and hasMonth and isMonthFirst then

		ret#ret + 1 = self:getMonthName()

		ret#ret + 1 = ' '

		ret#ret + 1 = self.day

		if hasYear then

			ret#ret + 1 = ','

		end

	elseif hasDay and hasMonth then

		ret#ret + 1 = self.day

		ret#ret + 1 = ' '

		ret#ret + 1 = self:getMonthName()

	elseif hasDay then

		ret#ret + 1 = self.day

	elseif hasMonth then

		ret#ret + 1 = self:getMonthName()

	end

	if hasYear then

		if hasDay or hasMonth then

			ret#ret + 1 = ' '

		end

		local displayYear = math.abs(self.year)

		if displayYear > 9999 then

			displayYear = lang:formatNum(displayYear)

		else

			displayYear = tostring(displayYear)

		end

		ret#ret + 1 = displayYear

		if self.year < 0 then

			ret#ret + 1 = '&nbsp;BC'

		end

	end

	return table.concat(ret)

end



function Dts:__tostring()

	local root = mw.html.create()

	local span = root:tag('span')

		:attr('data-sort-value', self:makeSortKey())



	-- Display

	if self:hasDate() and self.format ~= 'hide' then

		span:wikitext(self:makeDisplay())

		if not self.isWrapping then

			span:css('white-space', 'nowrap')

		end

	end



	return tostring(root)

end



--------------------------------------------------------------------------------

-- Exports

--------------------------------------------------------------------------------



local p = {}



function p._exportClasses()

	return {

		Dts = Dts

	}

end



function p._main(args)

	local success, ret = pcall(function ()

		local dts = Dts.new(args)

		return tostring(dts)

	end)

	if success then

		return ret

	else

		ret = string.format(

			'<strong class="error">Error in [[Template:Date table sorting]]: %s</strong>',

			ret

		)

		if mw.title.getCurrentTitle().namespace == 0 then

			-- Only categorise in the main namespace

			ret = ret .. '[[Category:Date table sorting templates with errors]]'

		end

		return ret

	end

end



function p.main(frame)

	local args = require('Module:Arguments').getArgs(frame, {

		wrappers = 'Template:Date table sorting',

	})

	return p._main(args)

end



return p