Difference between revisions of "Module:Dts"

From Timelines
Jump to: navigation, search
(actually, remove dtsa altogether, as the template has been deleted)
(allow years up to plus/minus one trillion, and add a tracking category for deprecated parameters)
 
(47 intermediate revisions by the same user not shown)
Line 1: Line 1:
local getArgs = require('Module:Arguments').getArgs
+
local yesno = require('Module:Yesno')
 +
local lang = mw.language.getContentLanguage()
 +
local N_YEAR_DIGITS = 12
 +
local MAX_YEAR = 10^N_YEAR_DIGITS - 1
  
local p = {}
+
--------------------------------------------------------------------------------
 +
-- 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
 +
ret[month: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 args[2] or args[3] or args[4] then
 +
self:parseDateParts(args[1], args[2], args[3], args[4])
 +
elseif args[1] then
 +
self:parseDate(args[1])
 +
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.formats[self.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
 +
 
 +
-- Check for deprecated parameters.
 +
if args.link then
 +
self.hasDeprecatedParameters = true
 +
end
  
p.monthsSearch = { "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec" }
+
return self
p.monthsAbr = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }
+
end
p.months = { "January", "February", "March", "April", "May", "June", "July", "August", "Septembre", "October", "November", "December" }
 
  
function p.dts(frame)
+
function Dts:hasDate()
local args = getArgs(frame, {removeBlanks = false})
+
return (self.year or self.month or self.day) ~= nil
return p:_dts(args)
 
 
end
 
end
  
function p:_setmonth(raw)
+
-- Find the month number for a month name, and set the isAbbreviated flag as
if not raw then
+
-- appropriate.
self._month = nil
+
function Dts:parseMonthName(s)
return false
+
s = s:lower()
 +
local month = Dts.monthSearch[s]
 +
if month then
 +
return month
 +
else
 +
month = Dts.monthSearchAbbr[s]
 +
if month then
 +
self.isAbbreviated = true
 +
return month
 +
end
 
end
 
end
local numbermonth = tonumber(raw)
+
return nil
if numbermonth and numbermonth > 0 and numbermonth < 13 then
+
end
self._month = numbermonth
+
 
return true
+
-- 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
 
end
for i, mon in pairs(self.monthsSearch) do
+
if month then
if string.find(string.lower(raw),mon) then
+
if tonumber(month) then
self._month=i
+
self.month = tonumber(month)
if string.find(string.lower(raw),string.lower(self.months[i])) then
+
elseif type(month) == 'string' then
self._abbr=false
+
self.month = self:parseMonthName(month)
else
+
end
self._abbr=true
+
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
 
end
return true
+
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
 
end
return false
 
 
end
 
end
  
function p:_annonval(val, dayfirst)
+
-- This method parses date strings. This is a poor man's alternative to
local numberval
+
-- mw.language:formatDate, but it ends up being easier for us to parse the date
if val then
+
-- here than to use mw.language:formatDate and then try to figure out after the
numberval = tonumber(mw.text.trim(val,"%s%t,"))
+
-- 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
 
end
if (not val) or (type(val)=="table") or (mw.text.trim(val)=="") then
+
 
numberval = 0
+
-- Split the string using non-word characters as boundaries.
 +
date = tostring(date)
 +
local parts = mw.text.split(date, '%W+')
 +
local nParts = #parts
 +
if parts[1] == '' or parts[nParts] == '' 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
 
end
if not numberval then
+
 
if mw.text.trim(string.lower(val)) == "bc" then
+
if nParts == 1 then
if (not self._year) then
+
-- This can be either a month name or a year.
self._year = self._day
+
self.month = self:parseMonthName(parts[1])
self._day = nil
+
if not self.month then
 +
self.year = parseYear(parts[1])
 +
if not self.year then
 +
dateError()
 
end
 
end
if self._year then
+
end
self._year = 0 - self._year
+
elseif nParts == 2 then
 +
-- This can be any of the following formats:
 +
-- DD Month
 +
-- Month DD
 +
-- Month YYYY
 +
-- YYYY-MM
 +
self.month = self:parseMonthName(parts[1])
 +
if self.month then
 +
-- This is either Month DD or Month YYYY.
 +
self.year = parseYear(parts[2])
 +
if not self.year then
 +
-- This is Month DD.
 +
self.format = 'mdy'
 +
self.day = parseDayOrMonth(parts[2])
 +
if not self.day then
 +
dateError()
 +
end
 
end
 
end
 
else
 
else
if self:_setmonth(val,dayfirst) and dayfirst and self._year and (not self._day) and (self._year > 0) and (self._year<31) then
+
self.month = self:parseMonthName(parts[2])
self._day = self._year
+
if self.month then
self._year = nil
+
-- This is DD Month.
self._fmt = "dmy"
+
self.format = 'dmy'
 +
self.day = parseDayOrMonth(parts[1])
 +
if not self.day then
 +
dateError()
 +
end
 +
else
 +
-- This is YYYY-MM.
 +
self.year = parseYear(parts[1])
 +
self.month = parseDayOrMonth(parts[2])
 +
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(parts[1])
 +
if self.month then
 +
-- This is Month DD, YYYY.
 +
self.format = 'mdy'
 +
self.day = parseDayOrMonth(parts[2])
 +
self.year = parseYear(parts[3])
 +
if not self.day or not self.year then
 +
dateError()
 +
end
 +
else
 +
self.day = parseDayOrMonth(parts[1])
 +
if self.day then
 +
self.month = self:parseMonthName(parts[2])
 +
if self.month then
 +
-- This is DD Month YYYY.
 +
self.format = 'dmy'
 +
self.year = parseYear(parts[3])
 +
if not self.year then
 +
dateError()
 +
end
 +
else
 +
-- This is DD-MM-YYYY.
 +
self.format = 'dmy'
 +
self.month = parseDayOrMonth(parts[2])
 +
self.year = parseYear(parts[3])
 +
if not self.month or not self.year then
 +
dateError()
 +
end
 +
end
 +
else
 +
-- This is YYYY-MM-DD
 +
self.year = parseYear(parts[1])
 +
self.month = parseDayOrMonth(parts[2])
 +
self.day = parseDayOrMonth(parts[3])
 +
if not self.year or not self.month or not self.day then
 +
dateError()
 +
end
 
end
 
end
 
end
 
end
return
 
 
end
 
end
if self._month and (not self._day) and (numberval < 32) and (numberval > 0) then
+
end
self._day = numberval
+
 
return
+
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 {{dts}} transclusions should sort last.
 +
year = MAX_YEAR
 +
month = 99
 +
day = 99
 
end
 
end
if self._year and (not self._month) then
+
return string.format(
self._month = numberval
+
'%0' .. nYearDigits .. 'd-%02d-%02d-%04d',
return
+
year, month, day, self.addkey or 0
 +
)
 +
end
 +
 
 +
function Dts:getMonthName()
 +
if not self.month then
 +
return ''
 
end
 
end
if (not self._year) then
+
if self.isAbbreviated then
self._year = numberval
+
return self.monthsAbbr[self.month]
return
+
else
 +
return self.months[self.month]
 
end
 
end
 
end
 
end
  
function p:_dateformat(args)
+
function Dts:makeDisplay()
self._fmt = "mdy"
+
if self.format == 'hide' then
self._abbr = false --default
+
return ''
if args[1] and (not args[2]) then
+
end
for _, val in pairs(mw.text.split(args[1],"[%s/-]")) do
+
local hasYear = self.year and self.format:find('y')
self:_annonval(val, true)
+
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
 
end
else
+
elseif hasDay and hasMonth then
for key, val in pairs(args) do
+
ret[#ret + 1] = self.day
if tonumber(key) then
+
ret[#ret + 1] = ' '
self:_annonval(val)
+
ret[#ret + 1] = self:getMonthName()
end
+
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
 
end
end
+
local displayYear = math.abs(self.year)
if args.format then
+
if displayYear > 9999 then
self._fmt = args.format
+
displayYear = lang:formatNum(displayYear)
end
 
if args.abbr then
 
if args.abbr == "on" then
 
self._abbr=true
 
 
else
 
else
self._abbr=false
+
displayYear = tostring(displayYear)
 +
end
 +
ret[#ret + 1] = displayYear
 +
if self.year < 0 then
 +
ret[#ret + 1] = '&nbsp;BC'
 
end
 
end
 
end
 
end
if (self._year==0) then --not valid. placeholder for no-year
+
return table.concat(ret)
self._year = nil
 
end
 
 
end
 
end
  
function p:_monthName()
+
function Dts:renderTrackingCategories()
if (not self._month) or (self._month < 0) or (self._month > 12) then
+
if self.hasDeprecatedParameters then
return ""
+
return '[[Category:Dts templates with deprecated parameters]]'
end
 
if self._abbr then
 
return self.monthsAbr[self._month]
 
 
else
 
else
return self.months[self._month]
+
return ''
 
end
 
end
 
end
 
end
  
function p:_sortkey()
+
function Dts:__tostring()
 
local root = mw.html.create()
 
local root = mw.html.create()
  
-- Sort span
+
-- Sort key
local sortSpan = root:tag('span')
+
root:tag('span')
sortSpan
 
 
:addClass('sortkey')
 
:addClass('sortkey')
 
:css('display', 'none')
 
:css('display', 'none')
 
:css('speak', 'none')
 
:css('speak', 'none')
    local current = os.date("*t")
+
:wikitext(self:makeSortKey())
local year = self._year or current.year
+
year = year > 0 and year or -10000 - year
+
-- Display
sortSpan:wikitext(string.format(
+
if self:hasDate() then
"%05d-%02d-%02d-%02d%02d",
+
if self.isWrapping then
year,
+
root:wikitext(self:makeDisplay())
self._month or 1,
+
else
self._day or 1,
+
root:tag('span')
0,
+
:css('white-space', 'nowrap')
0
+
:wikitext(self:makeDisplay())
))
+
end
    -- retval = retval .. '</span><span style="white-space:nowrap;">'
+
end
    return root
+
 
 +
-- Tracking categories
 +
root:wikitext(self:renderTrackingCategories())
 +
 
 +
return tostring(root)
 
end
 
end
  
function p:_dts(args)
+
--------------------------------------------------------------------------------
self:_dateformat(args)
+
-- Exports
local root = self:_sortkey()
+
--------------------------------------------------------------------------------
local displaySpan = root:tag('span')
+
 
displaySpan:css('white-space', 'nowrap')
+
local p = {}
if self._day then
+
 
    if self._fmt == "mdy" then
+
function p._exportClasses()
displaySpan:wikitext(self:_monthName())
+
return {
displaySpan:wikitext(' ')
+
Dts = Dts
displaySpan:wikitext(self._day)
+
}
if self._year then
+
end
displaySpan:wikitext(',')
+
 
end
+
function p._main(args)
else
+
local success, ret = pcall(function ()
displaySpan:wikitext(self._day)
+
local dts = Dts.new(args)
displaySpan:wikitext(' ')
+
return tostring(dts)
displaySpan:wikitext(self:_monthName())
+
end)
end
+
if success then
 +
return ret
 
else
 
else
if self._month then
+
ret = string.format(
displaySpan:wikitext(self:_monthName())
+
'<strong class="error">Error in [[Template:Dts]]: %s</strong>',
end
+
ret
end
+
)
if self._year then
+
if mw.title.getCurrentTitle().namespace == 0 then
if self._month then
+
-- Only categorise in the main namespace
displaySpan:wikitext(' ')
+
ret = ret .. '[[Category:Dts templates with errors]]'
end
 
displaySpan:wikitext(math.abs(self._year))
 
if self._year < 0 then
 
displaySpan:wikitext(' BC')
 
 
end
 
end
 +
return ret
 
end
 
end
 +
end
  
    return tostring(root)
+
function p.main(frame)
 +
local args = require('Module:Arguments').getArgs(frame, {
 +
wrappers = 'Template:Dts',
 +
})
 +
return p._main(args)
 
end
 
end
  
 
return p
 
return p

Latest revision as of 20:18, 5 July 2015

Documentation for this module may be created at Module:Dts/doc

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
		ret[month: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 args[2] or args[3] or args[4] then
		self:parseDateParts(args[1], args[2], args[3], args[4])
	elseif args[1] then
		self:parseDate(args[1])
	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.formats[self.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

	-- Check for deprecated parameters.
	if args.link then
		self.hasDeprecatedParameters = true
	end

	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.monthSearch[s]
	if month then
		return month
	else
		month = Dts.monthSearchAbbr[s]
		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 parts[1] == '' or parts[nParts] == '' 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(parts[1])
		if not self.month then
			self.year = parseYear(parts[1])
			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(parts[1])
		if self.month then
			-- This is either Month DD or Month YYYY.
			self.year = parseYear(parts[2])
			if not self.year then
				-- This is Month DD.
				self.format = 'mdy'
				self.day = parseDayOrMonth(parts[2])
				if not self.day then
					dateError()
				end
			end
		else
			self.month = self:parseMonthName(parts[2])
			if self.month then
				-- This is DD Month.
				self.format = 'dmy'
				self.day = parseDayOrMonth(parts[1])
				if not self.day then
					dateError()
				end
			else
				-- This is YYYY-MM.
				self.year = parseYear(parts[1])
				self.month = parseDayOrMonth(parts[2])
				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(parts[1])
		if self.month then
			-- This is Month DD, YYYY.
			self.format = 'mdy'
			self.day = parseDayOrMonth(parts[2])
			self.year = parseYear(parts[3])
			if not self.day or not self.year then
				dateError()
			end
		else
			self.day = parseDayOrMonth(parts[1])
			if self.day then
				self.month = self:parseMonthName(parts[2])
				if self.month then
					-- This is DD Month YYYY.
					self.format = 'dmy'
					self.year = parseYear(parts[3])
					if not self.year then
						dateError()
					end
				else
					-- This is DD-MM-YYYY.
					self.format = 'dmy'
					self.month = parseDayOrMonth(parts[2])
					self.year = parseYear(parts[3])
					if not self.month or not self.year then
						dateError()
					end
				end
			else
				-- This is YYYY-MM-DD
				self.year = parseYear(parts[1])
				self.month = parseDayOrMonth(parts[2])
				self.day = parseDayOrMonth(parts[3])
				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 {{dts}} 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.monthsAbbr[self.month]
	else
		return self.months[self.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:renderTrackingCategories()
	if self.hasDeprecatedParameters then
		return '[[Category:Dts templates with deprecated parameters]]'
	else
		return ''
	end
end

function Dts:__tostring()
	local root = mw.html.create()

	-- Sort key
	root:tag('span')
		:addClass('sortkey')
		:css('display', 'none')
		:css('speak', 'none')
		:wikitext(self:makeSortKey())
	
	-- Display
	if self:hasDate() then
		if self.isWrapping then
			root:wikitext(self:makeDisplay())
		else
			root:tag('span')
				:css('white-space', 'nowrap')
				:wikitext(self:makeDisplay())
		end
	end

	-- Tracking categories
	root:wikitext(self:renderTrackingCategories())

	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:Dts]]: %s</strong>',
			ret
		)
		if mw.title.getCurrentTitle().namespace == 0 then
			-- Only categorise in the main namespace
			ret = ret .. '[[Category:Dts templates with errors]]'
		end
		return ret
	end
end

function p.main(frame)
	local args = require('Module:Arguments').getArgs(frame, {
		wrappers = 'Template:Dts',
	})
	return p._main(args)
end

return p