Difference between revisions of "Module:Dts"

From Timelines
Jump to: navigation, search
(detect invalid numbers)
(make our own alternative to mw.language:formatDate)
Line 68: Line 68:
 
self.month = tonumber(args[2])
 
self.month = tonumber(args[2])
 
elseif type(args[2]) == 'string' then
 
elseif type(args[2]) == 'string' then
local lower = args[2]:lower()
+
self.month = self:parseMonthName(args[2])
self.month = Dts.monthSearch[lower]
 
if not self.month then
 
self.month = Dts.monthSearchAbbr[lower]
 
if self.month then
 
self.abbr = true
 
end
 
end
 
 
end
 
end
 
if not self.month then
 
if not self.month then
Line 108: Line 101:
 
elseif args[1] then
 
elseif args[1] then
 
-- args[1] is the entire date that we need to parse.
 
-- args[1] is the entire date that we need to parse.
args[1] = tostring(args[1])
+
self:parseDate(tostring(args[1]))
local date = Dts.formatDate('Y-m-d', args[1])
 
if date then
 
self.year, self.month, self.day = date:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$')
 
self.year = tonumber(self.year)
 
self.month = tonumber(self.month)
 
self.day = tonumber(self.day)
 
 
-- Try to detect whether the values have been normalised, e.g. the
 
-- user specified "February 2012" but formatDate added the day for
 
-- us, making 2012-02-01.
 
local function stringHasNumber(s, num)
 
num = tostring(num)
 
return s:find('%D0*' .. num .. '%D') or
 
s:find('^0*' .. num .. '%D') or
 
s:find('%D0*' .. num .. '$') or
 
s:find('^0*' .. num .. '$')
 
end
 
local currentYear = os.date('*t').year
 
if self.year == currentYear and not stringHasNumber(args[1], currentYear) then
 
self.year = nil
 
end
 
if self.month == 1 and
 
not args[1]:lower():find('jan') and
 
not stringHasNumber(args[1], 1)
 
then
 
self.month = nil
 
end
 
if self.day == 1 and not stringHasNumber(args[1], 1) then
 
self.day = nil
 
end
 
 
 
-- Detect abbreviated month names
 
if self.month then
 
local dateLower = args[1]:lower()
 
if not dateLower:find(Dts.months[self.month]:lower()) and
 
dateLower:find(Dts.monthsAbbr[self.month]:lower())
 
then
 
self.abbr = true
 
end
 
end
 
else
 
error(string.format(
 
"'%s' is an invalid date",
 
tostring(args[1])
 
), 3)
 
end
 
 
else
 
else
 
error('no date parameters detected', 3)
 
error('no date parameters detected', 3)
Line 192: Line 139:
 
end
 
end
  
function Dts.formatDate(format, timestamp)
+
-- Find the month number for a month name, and set the abbr flag as appropriate.
local success, ret = pcall(lang.formatDate, lang, format, timestamp)
+
function Dts:parseMonthName(s)
if success then
+
s = s:lower()
return ret
+
local month = Dts.monthSearch[s]
 +
if month then
 +
return month
 +
else
 +
month = Dts.monthSearchAbbr[s]
 +
if month then
 +
self.abbr = true
 +
return month
 +
end
 
end
 
end
 +
return nil
 
end
 
end
  
function Dts:setmonth(raw)
+
-- This method parses date strings. This is a poor man's alternative to
if not raw then
+
-- mw.language:formatDate, but it ends up being easier for us to parse the date
self.month = nil
+
-- here than to use mw.language:formatDate and then try to figure out after the
return false
+
-- 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
 +
), 5)
 
end
 
end
local numbermonth = tonumber(raw)
+
 
if numbermonth and numbermonth > 0 and numbermonth < 13 then
+
local function parseDayOrMonth(s)
self.month = numbermonth
+
if s:find('^%d%d?$') then
return true
+
return tonumber(s)
 +
end
 
end
 
end
for i, mon in pairs(self.monthsSearch) do
+
 
if string.find(string.lower(raw),mon) then
+
local function parseYear(s)
self.month=i
+
if #s == 4 then
if string.find(string.lower(raw),string.lower(self.months[i])) then
+
return tonumber(s)
self.abbr=false
 
else
 
self.abbr=true
 
end
 
return true
 
 
end
 
end
 
end
 
end
return false
 
end
 
  
function Dts:annonval(val, dayfirst)
+
-- Deal with things that can have hyphens in first, as later we need to
local numberval
+
-- split the string by all non-word characters, including hyphens.
if val then
+
if tonumber(date) then
numberval = tonumber(mw.text.trim(val,"%s%t,"))
+
self.year = tonumber(date)
 +
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.
 +
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
 +
), 4)
 
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
 +
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
 +
-- This is DD Month.
 +
self.format = 'dmy'
 +
self.day = parseDayOrMonth(parts[1])
 +
self.month = self:parseMonthName(parts[2])
 +
if not self.day or not self.month then
 +
dateError()
 +
end
 +
end
 +
elseif nParts == 3 then
 +
-- This can be any of the following formats:
 +
-- DD Month YYYY
 +
-- Month DD, YYYY
 +
-- YYYY-MM-DD
 +
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
 
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.day = parseDayOrMonth(parts[1])
self.day = self.year
+
if self.day then
self.year = nil
+
-- This is DD Month YYYY.
self.format = "dmy"
+
self.format = 'dmy'
 +
self.month = self:parseMonthName(parts[2])
 +
self.year = parseYear(parts[3])
 +
if not self.month or not self.year then
 +
dateError()
 +
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
 
if self.month and (not self.day) and (numberval < 32) and (numberval > 0) then
 
self.day = numberval
 
return
 
end
 
if self.year and (not self.month) then
 
self.month = numberval
 
return
 
end
 
if (not self.year) then
 
self.year = numberval
 
return
 
end
 
end
 
 
function Dts:monthName()
 
if (not self.month) or (self.month < 0) or (self.month > 12) then
 
return ""
 
end
 
if self.abbr then
 
return self.monthsAbr[self.month]
 
else
 
return self.months[self.month]
 
 
end
 
end
 
end
 
end
Line 285: Line 288:
 
0
 
0
 
)
 
)
 +
end
 +
 +
function Dts:getMonthName()
 +
if not self.month then
 +
return ''
 +
end
 +
if self.abbr then
 +
return self.monthsAbr[self.month]
 +
else
 +
return self.months[self.month]
 +
end
 
end
 
end
  
Line 291: Line 305:
 
if self.day then
 
if self.day then
 
if self.format == "mdy" then
 
if self.format == "mdy" then
ret[#ret + 1] = self:monthName()
+
ret[#ret + 1] = self:getMonthName()
 
ret[#ret + 1] = ' '
 
ret[#ret + 1] = ' '
 
ret[#ret + 1] = self.day
 
ret[#ret + 1] = self.day
Line 300: Line 314:
 
ret[#ret + 1] = self.day
 
ret[#ret + 1] = self.day
 
ret[#ret + 1] = ' '
 
ret[#ret + 1] = ' '
ret[#ret + 1] = self:monthName()
+
ret[#ret + 1] = self:getMonthName()
 
end
 
end
 
elseif self.month then
 
elseif self.month then
ret[#ret + 1] = self:monthName()
+
ret[#ret + 1] = self:getMonthName()
 
end
 
end
 
if self.year then
 
if self.year then

Revision as of 23:58, 1 July 2015

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

local lang = mw.language.getContentLanguage()

--------------------------------------------------------------------------------
-- 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)

function Dts.new(args)
	local self = setmetatable({}, Dts)
	self.format = args.format or "mdy"
	self.abbr = false -- Default

	if args[2] or args[3] or args[4] then
		-- YMD parameters are specified individually.
		if args[1] then
			self.year = tonumber(args[1])
			if not self.year then
				error(string.format(
					"'%s' is not a valid year",
					tostring(args[1])
				), 3)
			end
		end
		if args[2] then
			if tonumber(args[2]) then
				self.month = tonumber(args[2])
			elseif type(args[2]) == 'string' then
				self.month = self:parseMonthName(args[2])
			end
			if not self.month then
				error(string.format(
					"'%s' is not a valid month",
					tostring(args[2])
				), 3)
			end
		end
		if args[3] then
			self.day = tonumber(args[3])
			if not self.day then
				error(string.format(
					"'%s' is not a valid day",
					tostring(args[3])
				), 3)
			end
		end
		if args[4] then
			local bc = type(args[4]) == 'string' and args[4]:lower()
			if bc == 'bc' or bc == 'bce' then
				if self.year and self.year > 0 then
					self.year = -self.year
				end
			elseif bc ~= 'ad' or bc ~= 'ce' then
				error(string.format(
					"'%s' is not a valid era code (expected 'BC', 'BCE', 'AD' or 'CE')",
					tostring(args[4])
				), 3)
			end
		end
	elseif args[1] then
		-- args[1] is the entire date that we need to parse.
		self:parseDate(tostring(args[1]))
	else
		error('no date parameters detected', 3)
	end

	-- Whether to output abbreviated month names
	if args.abbr then
		self.abbr = args.abbr == 'on'
	else
		self.abbr = self.abbr or false
	end

	-- Raise an error on invalid values
	if self.year then
		if self.year == 0 then
			error('years cannot be zero', 3)
		elseif math.floor(self.year) ~= self.year then
			error('years must be an integer', 3)
		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', 3)
	end
	if self.day and (
		self.day < 1
		or self.day > 31
		or math.floor(self.month) ~= self.month
	) then
		error('months must be an integer between 1 and 31', 3)
	end

	return self
end

-- Find the month number for a month name, and set the abbr 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.abbr = true
			return month
		end
	end
	return nil
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
		), 5)
	end

	local function parseDayOrMonth(s)
		if s:find('^%d%d?$') then
			return tonumber(s)
		end
	end

	local function parseYear(s)
		if #s == 4 then
			return tonumber(s)
		end
	end

	-- Deal with things that can have hyphens in first, as later we need to
	-- split the string by all non-word characters, including hyphens.
	if tonumber(date) then
		self.year = tonumber(date)
		return
	end

	-- Split the string using non-word characters as boundaries.
	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
		), 4)
	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
		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
			-- This is DD Month.
			self.format = 'dmy'
			self.day = parseDayOrMonth(parts[1])
			self.month = self:parseMonthName(parts[2])
			if not self.day or not self.month then
				dateError()
			end
		end
	elseif nParts == 3 then
		-- This can be any of the following formats:
		-- DD Month YYYY
		-- Month DD, YYYY
		-- YYYY-MM-DD
		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
				-- This is DD Month YYYY.
				self.format = 'dmy'
				self.month = self:parseMonthName(parts[2])
				self.year = parseYear(parts[3])
				if not self.month or not self.year then
					dateError()
				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 = self.year or os.date("*t").year
	year = year > 0 and year or -10000 - year
	return string.format(
		"%05d-%02d-%02d-%02d%02d",
		year,
		self.month or 1,
		self.day or 1,
		0,
		0
	)
end

function Dts:getMonthName()
	if not self.month then
		return ''
	end
	if self.abbr then
		return self.monthsAbr[self.month]
	else
		return self.months[self.month]
	end
end

function Dts:makeDisplay()
	local ret = {}
	if self.day then
		if self.format == "mdy" then
			ret[#ret + 1] = self:getMonthName()
			ret[#ret + 1] = ' '
			ret[#ret + 1] = self.day
			if self.year then
				ret[#ret + 1] = ','
			end
		else
			ret[#ret + 1] = self.day
			ret[#ret + 1] = ' '
			ret[#ret + 1] = self:getMonthName()
		end
	elseif self.month then
		ret[#ret + 1] = self:getMonthName()
	end
	if self.year then
		if self.month then
			ret[#ret + 1] = ' '
		end
		ret[#ret + 1] = math.abs(self.year)
		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()
	root
		:tag('span')
			:addClass('sortkey')
			:css('display', 'none')
			:css('speak', 'none')
			:wikitext(self:makeSortKey())
			:done()
		:tag('span')
			:css('white-space', 'nowrap')
			:wikitext(self:makeDisplay())
	return tostring(root)
end

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

local p = {}

function p._main(args)
	local dts = Dts.new(args)
	return tostring(dts)
end

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

return p