Module:Date: Difference between revisions

From Zoophilia Wiki
Jump to navigationJump to search
meta>Johnuniq
formatted output and strftime
meta>Johnuniq
tweak formatted output; extract_date to parse an input date string
Line 152: Line 152:
date.month = m + 3 - 12*floor(m/10)
date.month = m + 3 - 12*floor(m/10)
date.year = 100*b + d - 4800 + floor(m/10)
date.year = 100*b + d - 4800 + floor(m/10)
end
local function set_date_from_numbers(date, numbers, options)
-- Set the fields of table date from numeric values.
-- Return true if date is valid.
if type(numbers) ~= 'table' then
return
end
local y = numbers.y or numbers[1]
local m = numbers.m or numbers[2]
local d = numbers.d or numbers[3]
local H = numbers.H or numbers[4]
local M = numbers.M or numbers[5] or 0
local S = numbers.S or numbers[6] or 0
if not (y and m and d) then
return
end
if not (-9999 <= y and y <= 9999 and 1 <= m and m <= 12 and
1 <= d and d <= days_in_month(y, m, date.calname)) then
return
end
if H then
date.hastime = true
else
H = 0
end
if not (0 <= H and H <= 23 and
0 <= M and M <= 59 and
0 <= S and S <= 59) then
return
end
date.year = y    -- -9999 to 9999 ('n BC' → year = 1 - n)
date.month = m  -- 1 to 12
date.day = d    -- 1 to 31
date.hour = H    -- 0 to 59
date.minute = M  -- 0 to 59
date.second = S  -- 0 to 59
date.isvalid = true
if type(options) == 'table' then
for _, k in ipairs({ 'am', 'era' }) do
if options[k] then
date.options[k] = options[k]
end
end
end
return true
end
end


Line 177: Line 223:
return '(invalid)'
return '(invalid)'
end
end
local shortcuts = {
['%c'] = '%-I:%M %p %-d %B %Y%{era}',  -- date and time: 2:30 pm 1 April 2016
['%x'] = '%-d %B %Y%{era}',            -- date:          1 April 2016
['%X'] = '%-I:%M %p',                  -- time:          2:30 pm
}
local codes = {
local codes = {
a = { field = 'dayabbr' },
a = { field = 'dayabbr' },
Line 184: Line 235:
u = { fmt = '%d'  , field = 'dowiso' },
u = { fmt = '%d'  , field = 'dowiso' },
w = { fmt = '%d'  , field = 'dow' },
w = { fmt = '%d'  , field = 'dow' },
d = { fmt = '%02d', field = 'day' },
d = { fmt = '%02d', fmt2 = '%d', field = 'day' },
m = { fmt = '%02d', field = 'month' },
m = { fmt = '%02d', fmt2 = '%d', field = 'month' },
Y = { fmt = '%04d', field = 'year' },
Y = { fmt = '%04d', fmt2 = '%d', field = 'year' },
H = { fmt = '%02d', field = 'hour' },
H = { fmt = '%02d', fmt2 = '%d', field = 'hour' },
M = { fmt = '%02d', field = 'minute' },
M = { fmt = '%02d', fmt2 = '%d', field = 'minute' },
S = { fmt = '%02d', field = 'second' },
S = { fmt = '%02d', fmt2 = '%d', field = 'second' },
j = { fmt = '%03d', field = 'doy' },
j = { fmt = '%03d', fmt2 = '%d', field = 'doy' },
I = { fmt = '%02d', field = 'hour', special = 'hour12' },
I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
p = { field = 'hour', special = 'am' },
p = { field = 'hour', special = 'am' },
X = { fmt = '%02d:%02d:%02d', field = 'hour', special = 'hms' },
['-d'] = { fmt = '%d', field = 'day' },
['-m'] = { fmt = '%d', field = 'month' },
['-Y'] = { fmt = '%d', field = 'year' },
['-j'] = { fmt = '%d', field = 'doy' },
}
}
options = make_option_table(options or date.options)
options = make_option_table(options or date.options)
local amopt = options.am
local amopt = options.am
local eraopt = options.era
local eraopt = options.era
local function replace_code(id)
local function replace_code(modifier, id)
local code = codes[id]
local code = codes[id]
if code then
if code then
local fmt = code.fmt
local fmt = code.fmt
if modifier == '-' and code.fmt2 then
fmt = code.fmt2
end
local value = date[code.field]
local value = date[code.field]
local special = code.special
local special = code.special
Line 212: Line 261:
value = value % 12
value = value % 12
value = value == 0 and 12 or value
value = value == 0 and 12 or value
elseif special == 'hms' then
return string.format(fmt, value, date.minute, date.second)
elseif special == 'am' then
elseif special == 'am' then
local ap = ({
local ap = ({
Line 258: Line 305:
-- This occurs, for example, if id is the name of a function.
-- This occurs, for example, if id is the name of a function.
return nil
return nil
end
if shortcuts[format] then
format = shortcuts[format]
end
end
local PERCENT = '\127PERCENT\127'
local PERCENT = '\127PERCENT\127'
Line 263: Line 313:
:gsub('%%%%', PERCENT)
:gsub('%%%%', PERCENT)
:gsub('%%{(%w+)}', replace_property)
:gsub('%%{(%w+)}', replace_property)
:gsub('%%(-?%a)', replace_code)
:gsub('%%(-?)(%a)', replace_code)
:gsub(PERCENT, '%%')
:gsub(PERCENT, '%%')
)
)
Line 402: Line 452:
['C.E.']      = { 'B.C.E.', 'C.E.' },
['C.E.']      = { 'B.C.E.', 'C.E.' },
}
}
local function extract_date(text)
-- Parse the date/time in text and return n, o where
--  n = table of numbers with date/time fields
--  o = table of options for AM/PM or AD/BC, if any
-- or return nothing if date is known to be invalid.
-- Caller determines if the values in n are valid.
-- Dates of form d/m/y, m/d/y, y/m/d are rejected as ambiguous and undesirable.
local date, options = {}, {}
local function extract_ymd(item)
local ystr, mstr, dstr = item:match('^(%d%d%d%d)-(%w+)-(%d%d?)$')
if ystr then
local m
if mstr:match('^%d%d?$') then
m = tonumber(mstr)
else
m = month_number(mstr)
end
if m then
date.y = tonumber(ystr)
date.m = m
date.d = tonumber(dstr)
return true
end
end
end
local function extract_month(item)
-- A month must be given as a name or abbreviation; a number would be ambiguous.
local m = month_number(item)
if m then
date.m = m
return true
end
end
local function extract_time(item)
local h, m, s = item:match('^(%d%d?):(%d%d)(:?%d*)$')
if date.H or not h then
return
end
if s ~= '' then
s = s:match('^:(%d%d)$')
if not s then
return
end
end
date.H = tonumber(h)
date.M = tonumber(m)
date.S = tonumber(s)  -- nil if empty string
return true
end
local ampm_options = {
['am']  = 'am',
['AM']  = 'AM',
['a.m.'] = 'a.m.',
['A.M.'] = 'A.M.',
['pm']  = 'am',  -- same as am
['PM']  = 'AM',
['p.m.'] = 'a.m.',
['P.M.'] = 'A.M.',
}
local item_count = 0
local index_time
local function set_ampm(item)
local H = date.H
if H and not options.am and index_time + 1 == item_count then
options.am = ampm_options[item]
if item:match('^[Aa]') then
if not (1 <= H and H <= 12) then
return
end
if H == 12 then
date.H = 0
end
else
if not (1 <= H and H <= 23) then
return
end
if H <= 11 then
date.H = H + 12
end
end
return true
end
end
for item in text:gsub(',', ' '):gmatch('%S+') do
-- Accept options in peculiar places; if duplicated, last wins.
item_count = item_count + 1
if era_text[item] then
options.era = item
elseif ampm_options[item] then
if not set_ampm(item) then
return
end
elseif item:find(':', 1, true) then
if not extract_time(item) then
return
end
index_time = item_count
elseif date.d and date.m then
if date.y then
return  -- should be nothing more so item is invalid
end
if not item:match('^(%d%d?%d?%d?)$') then
return
end
date.y = tonumber(item)
elseif date.d then
if not extract_month(item) then
return
end
elseif date.m then
if not item:match('^(%d%d?)$') then
return
end
date.d = tonumber(item)
elseif not extract_ymd(item) then
if item:match('^(%d%d?)$') then
date.d = tonumber(item)
elseif not extract_month(item) then
return
end
end
end
return date, options
end


-- Metatable for some operations on dates.
-- Metatable for some operations on dates.
Line 469: Line 644:


--[[ Examples of syntax to construct a date:
--[[ Examples of syntax to construct a date:
Date(y, m, d, 'julian') default calendar is 'gregorian'
Date(y, m, d, 'julian')             default calendar is 'gregorian'
Date(y, m, d, H, M, S, 'julian')
Date(y, m, d, H, M, S, 'julian')
Date('juliandate', jd, 'julian')    if jd contains "." text output includes H:M:S
Date('juliandate', jd, 'julian')    if jd contains "." text output includes H:M:S
Line 475: Line 650:
Date('currentdatetime')
Date('currentdatetime')
LATER: Following are not yet implemented:
LATER: Following are not yet implemented:
Date('currentdate', H, M, S)     current date with given time
Date('currentdate', H, M, S)       current date with given time
Date('1 April 1995', 'julian')     parse date from text
Date('1 April 1995', 'julian')     parse date from text
Date('1 April 1995 AD', 'julian')  AD, CE, BC, BCE (using one of these sets a flag to do same for output)
Date('1 April 1995 AD', 'julian')  AD, CE, BC, BCE (using one of these sets a flag to do same for output)
Date('04:30:59 1 April 1995', 'julian')
Date('04:30:59 1 April 1995', 'julian')
Line 500: Line 675:
text = date_text,
text = date_text,
}
}
local argtype, datetext
local numbers = collection()
local numbers = collection()
local datetext
local argtype
for _, v in ipairs({...}) do
for _, v in ipairs({...}) do
v = strip_to_nil(v)
v = strip_to_nil(v)
Line 546: Line 720:
end
end
if argtype == 'datetext' then
if argtype == 'datetext' then
if numbers.n > 0 then
if not (numbers.n == 0 and
set_date_from_numbers(result,
extract_date(datetext))) then
return {}
return {}
end
end
-- TODO Parse datetext to extract y,m,d,H,M,S.
elseif argtype == 'juliandate' then
elseif argtype == 'juliandate' then
if numbers.n == 1 then
if numbers.n == 1 then
Line 570: Line 745:
result.isvalid = true
result.isvalid = true
elseif argtype == 'setdate' then
elseif argtype == 'setdate' then
if not (3 <= numbers.n and numbers.n <= 6) then
if not set_date_from_numbers(result, numbers) then
return {}
return {}
end
end
local y, m, d = numbers[1], numbers[2], numbers[3]
if not (-9999 <= y and y <= 9999 and 1 <= m and m <= 12 and
1 <= d and d <= days_in_month(y, m, result.calname)) then
return {}
end
local H = numbers[4]
if H then
result.hastime = true
else
H = 0
end
local M = numbers[5] or 0
local S = numbers[6] or 0
if not (0 <= H and H <= 23 and
0 <= M and M <= 59 and
0 <= S and S <= 59) then
return {}
end
result.year = y    -- -9999 to 9999; '1 BC' → year = 0; 'n BC' → year = 1 - n
result.month = m  -- 1 to 12
result.day = d    -- 1 to 31
result.hour = H    -- 0 to 59
result.minute = M  -- 0 to 59
result.second = S  -- 0 to 59
result.isvalid = true
else
else
return {}
return {}

Revision as of 04:56, 2 March 2016

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

-- Date functions for implementing templates and for use by other modules.
-- I18N and time zones are not supported.

local MINUS = '−'  -- Unicode U+2212 MINUS SIGN

local function collection()
	-- Return a table to hold items.
	return {
		n = 0,
		add = function (self, item)
			self.n = self.n + 1
			self[self.n] = item
		end,
		join = function (self, sep)
			return table.concat(self, sep)
		end,
	}
end

local function strip_to_nil(text)
	-- Return nil if text is nil or is an empty string after trimming.
	-- If text is a non-blank string, return its content after trimming.
	-- Otherwise return text (convenient when accessed via another module).
	if type(text) == 'string' then
		local result = text:match("^%s*(.-)%s*$")
		if result == '' then
			return nil
		end
		return result
	end
	if text == nil then
		return nil
	end
	return text
end

local function number_name(number, singular, plural, sep)
	-- Return the given number, converted to a string, with the
	-- separator (default space) and singular or plural name appended.
	plural = plural or (singular .. 's')
	sep = sep or ' '
	return tostring(number) .. sep .. ((number == 1) and singular or plural)
end

local function is_leap_year(year, calname)
	-- Return true if year is a leap year.
	if calname == 'Julian' then
		return year % 4 == 0
	end
	return (year % 4 == 0 and year % 100 ~= 0) or year % 400 == 0
end

local function days_in_month(year, month, calname)
	-- Return number of days (1..31) in given month (1..12).
	local month_days = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
	if month == 2 and is_leap_year(year, calname) then
		return 29
	end
	return month_days[month]
end

local function julian_date(date)
	-- Return jd, jdz from a Julian or Gregorian calendar date where
	--   jd = Julian date and its fractional part is zero at noon
	--   jdz = similar, but fractional part is zero at 00:00:00
	-- http://www.tondering.dk/claus/cal/julperiod.php#formula
	-- Testing shows this works for all dates from year -9999 to 9999!
	-- JDN 0 is the 24-hour period starting at noon UTC on Monday
	--    1 January 4713 BC  = (-4712, 1, 1)   Julian calendar
	--   24 November 4714 BC = (-4713, 11, 24) Gregorian calendar
	if not date.isvalid then
		return 0, 0  -- always return numbers to simplify usage
	end
	local floor = math.floor
	local offset
	local a = floor((14 - date.month)/12)
	local y = date.year + 4800 - a
	if date.calname == 'Julian' then
		offset = floor(y/4) - 32083
	else
		offset = floor(y/4) - floor(y/100) + floor(y/400) - 32045
	end
	local m = date.month + 12*a - 3
	local date_part = date.day + floor((153*m + 2)/5) + 365*y + offset
	local time_part, zbias
	if date.hastime then
		time_part = (date.hour + (date.minute + date.second / 60) /60) / 24 - 0.5
		zbias = 0
	else
		time_part = 0
		zbias = -0.5
	end
	local jd = date_part + time_part
	return jd, jd + zbias
end

local function set_date_from_jd(date)
	-- Set the fields of table date from its Julian date field.
	-- http://www.tondering.dk/claus/cal/julperiod.php#formula
	-- This handles the proleptic Julian and Gregorian calendars.
	-- Negative Julian dates are not defined but they work.
	local floor = math.floor
	local calname = date.calname
	local jd = date.jd
	local limits  -- min/max limits for date ranges −9999-01-01 to 9999-12-31
	if calname == 'Julian' then
		limits = { -1931076.5, 5373557.49999 }
	elseif calname == 'Gregorian' then
		limits = { -1930999.5, 5373484.49999 }
	else
		limits = { 1, 0 }  -- impossible
	end
	if not (limits[1] <= jd and jd <= limits[2]) then
		date.isvalid = false
		return
	end
	date.isvalid = true
	local jdn = floor(jd)
	if date.hastime then
		local time = jd - jdn
		local hour
		if time >= 0.5 then
			jdn = jdn + 1
			time = time - 0.5
			hour = 0
		else
			hour = 12
		end
		time = floor(time * 24 * 3600 + 0.5)  -- number of seconds after hour
		date.second = time % 60
		time = floor(time / 60)
		date.minute = time % 60
		date.hour = hour + floor(time / 60)
	else
		date.second = 0
		date.minute = 0
		date.hour = 0
	end
	local b, c
	if calname == 'Julian' then
		b = 0
		c = jdn + 32082
	else  -- Gregorian
		local a = jdn + 32044
		b = floor((4*a + 3)/146097)
		c = a - floor(146097*b/4)
	end
	local d = floor((4*c + 3)/1461)
	local e = c - floor(1461*d/4)
	local m = floor((5*e + 2)/153)
	date.day = e - floor((153*m + 2)/5) + 1
	date.month = m + 3 - 12*floor(m/10)
	date.year = 100*b + d - 4800 + floor(m/10)
end

local function set_date_from_numbers(date, numbers, options)
	-- Set the fields of table date from numeric values.
	-- Return true if date is valid.
	if type(numbers) ~= 'table' then
		return
	end
	local y = numbers.y or numbers[1]
	local m = numbers.m or numbers[2]
	local d = numbers.d or numbers[3]
	local H = numbers.H or numbers[4]
	local M = numbers.M or numbers[5] or 0
	local S = numbers.S or numbers[6] or 0
	if not (y and m and d) then
		return
	end
	if not (-9999 <= y and y <= 9999 and 1 <= m and m <= 12 and
				1 <= d and d <= days_in_month(y, m, date.calname)) then
		return
	end
	if H then
		date.hastime = true
	else
		H = 0
	end
	if not (0 <= H and H <= 23 and
			0 <= M and M <= 59 and
			0 <= S and S <= 59) then
		return
	end
	date.year = y    -- -9999 to 9999 ('n BC' → year = 1 - n)
	date.month = m   -- 1 to 12
	date.day = d     -- 1 to 31
	date.hour = H    -- 0 to 59
	date.minute = M  -- 0 to 59
	date.second = S  -- 0 to 59
	date.isvalid = true
	if type(options) == 'table' then
		for _, k in ipairs({ 'am', 'era' }) do
			if options[k] then
				date.options[k] = options[k]
			end
		end
	end
	return true
end

local function make_option_table(options)
	-- If options is a string, return a table with its settings.
	-- Otherwise return options (it should already be a table).
	if type(options) == 'string' then
		-- Example: 'am:AM era:BC'
		local result = {}
		for item in options:gmatch('%S+') do
			local lhs, rhs = item:match('^(%w+):(.*)$')
			if lhs then
				result[lhs] = rhs
			end
		end
		return result
	end
	return options
end

local function strftime(date, format, options)
	-- Return date formatted as a string using codes similar to those
	-- in the C strftime library function.
	if not date.isvalid then
		return '(invalid)'
	end
	local shortcuts = {
		['%c'] = '%-I:%M %p %-d %B %Y%{era}',  -- date and time: 2:30 pm 1 April 2016
		['%x'] = '%-d %B %Y%{era}',            -- date:          1 April 2016
		['%X'] = '%-I:%M %p',                  -- time:          2:30 pm
	}
	local codes = {
		a = { field = 'dayabbr' },
		A = { field = 'dayname' },
		b = { field = 'monthabbr' },
		B = { field = 'monthname' },
		u = { fmt = '%d'  , field = 'dowiso' },
		w = { fmt = '%d'  , field = 'dow' },
		d = { fmt = '%02d', fmt2 = '%d', field = 'day' },
		m = { fmt = '%02d', fmt2 = '%d', field = 'month' },
		Y = { fmt = '%04d', fmt2 = '%d', field = 'year' },
		H = { fmt = '%02d', fmt2 = '%d', field = 'hour' },
		M = { fmt = '%02d', fmt2 = '%d', field = 'minute' },
		S = { fmt = '%02d', fmt2 = '%d', field = 'second' },
		j = { fmt = '%03d', fmt2 = '%d', field = 'doy' },
		I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
		p = { field = 'hour', special = 'am' },
	}
	options = make_option_table(options or date.options)
	local amopt = options.am
	local eraopt = options.era
	local function replace_code(modifier, id)
		local code = codes[id]
		if code then
			local fmt = code.fmt
			if modifier == '-' and code.fmt2 then
				fmt = code.fmt2
			end
			local value = date[code.field]
			local special = code.special
			if special then
				if special == 'hour12' then
					value = value % 12
					value = value == 0 and 12 or value
				elseif special == 'am' then
					local ap = ({
						['a.m.'] = { 'a.m.', 'p.m.' },
						['AM'] = { 'AM', 'PM' },
						['A.M.'] = { 'A.M.', 'P.M.' },
					})[amopt] or { 'am', 'pm' }
					return value < 12 and ap[1] or ap[2]
				end
			end
			if code.field == 'year' then
				if eraopt == 'BCMINUS' or eraopt == 'BCNEGATIVE' then
					local sign
					if value >= 0 then
						sign = ''
					else
						sign = eraopt == 'BCMINUS' and MINUS or '-'
						value = -value
					end
					return sign .. string.format(fmt, value)
				end
				if value <= 0 then
					value = 1 - value
				end
			end
			return fmt and string.format(fmt, value) or value
		end
	end
	local function replace_property(id)
		local result = date[id]
		if type(result) == 'string' then
			if id == 'era' and result ~= '' then
				-- Assume era follows a date.
				return '&nbsp;' .. result
			end
			return result
		end
		if type(result) == 'number' then
			return tostring(result)
		end
		if type(result) == 'boolean' then
			return result and '1' or '0'
		end
		-- This occurs, for example, if id is the name of a function.
		return nil
	end
	if shortcuts[format] then
		format = shortcuts[format]
	end
	local PERCENT = '\127PERCENT\127'
	return (format
		:gsub('%%%%', PERCENT)
		:gsub('%%{(%w+)}', replace_property)
		:gsub('%%(-?)(%a)', replace_code)
		:gsub(PERCENT, '%%')
	)
end

local function date_text(date, fmt, options)
	-- Return formatted string from given date.
	if not (type(date) == 'table' and date.isvalid) then
		return '(invalid)'
	end
	if type(fmt) ~= 'string' then
		fmt = '%Y-%m-%d'
		if date.hastime then
			if date.second > 0 then
				fmt = fmt .. ' %H:%M:%S'
			else
				fmt = fmt .. ' %H:%M'
			end
		end
		return strftime(date, fmt, options or { era = 'BCMINUS' })
	end
	if fmt:find('%', 1, true) then
		return strftime(date, fmt, options)
	end
	local t = collection()
	for item in fmt:gmatch('%S+') do
		local f
		if item == 'hm' then
			f = '%H:%M'
		elseif item == 'hms' then
			f = '%H:%M:%S'
		elseif item == 'ymd' then
			f = '%Y:%m:%d%{era}'
		elseif item == 'mdy' then
			f = '%B %-d, %Y%{era}'
		elseif item == 'dmy' then
			f = '%-d %B %Y%{era}'
		else
			return '(invalid format)'
		end
		t:add(f)
	end
	return strftime(date, t:join(' '), options)
end

local day_info = {
	-- 0=Sun to 6=Sat
	[0] = { 'Sun', 'Sunday' },
	{ 'Mon', 'Monday' },
	{ 'Tue', 'Tuesday' },
	{ 'Wed', 'Wednesday' },
	{ 'Thu', 'Thursday' },
	{ 'Fri', 'Friday' },
	{ 'Sat', 'Saturday' },
}

local month_info = {
	-- 1=Jan to 12=Dec
	{ 'Jan', 'January' },
	{ 'Feb', 'February' },
	{ 'Mar', 'March' },
	{ 'Apr', 'April' },
	{ 'May', 'May' },
	{ 'Jun', 'June' },
	{ 'Jul', 'July' },
	{ 'Aug', 'August' },
	{ 'Sep', 'September' },
	{ 'Oct', 'October' },
	{ 'Nov', 'November' },
	{ 'Dec', 'December' },
}

local function month_number(text)
	if type(text) == 'string' then
		local month_names = {
			jan = 1, january = 1,
			feb = 2, february = 2,
			mar = 3, march = 3,
			apr = 4, april = 4,
			may = 5,
			jun = 6, june = 6,
			jul = 7, july = 7,
			aug = 8, august = 8,
			sep = 9, september = 9,
			oct = 10, october = 10,
			nov = 11, november = 11,
			dec = 12, december = 12
		}
		return month_names[text:lower()]
	end
end

-- A table to get the current year/month/day (UTC), but only if needed.
local current = setmetatable({}, {
		__index = function (self, key)
			local d = os.date('!*t')
			self.year = d.year
			self.month = d.month
			self.day = d.day
			self.hour = d.hour
			self.minute = d.min
			self.second = d.sec
			return rawget(self, key)
	end
})

local function date_component(named, positional, component)
	-- Return the first of the two arguments (named like {{example|year=2001}}
	-- or positional like {{example|2001}}) that is not nil and is not empty.
	-- If both are nil, return the current date component, if specified.
	-- This translates empty arguments passed to the template to nil, and
	-- optionally replaces a nil argument with a value from the current date.
	named = strip_to_nil(named)
	if named then
		return named
	end
	positional = strip_to_nil(positional)
	if positional then
		return positional
	end
	if component then
		return current[component]
	end
	return nil
end

local era_text = {
	-- options.era = { year<0  , year>0 }
	['BCMINUS']    = { MINUS   , ''     },
	['BCNEGATIVE'] = { '-'     , ''     },
	['BC']         = { 'BC'    , ''     },
	['B.C.']       = { 'B.C.'  , ''     },
	['BCE']        = { 'BCE'   , ''     },
	['B.C.E.']     = { 'B.C.E.', ''     },
	['AD']         = { 'BC'    , 'AD'   },
	['A.D.']       = { 'B.C.'  , 'A.D.' },
	['CE']         = { 'BCE'   , 'CE'   },
	['C.E.']       = { 'B.C.E.', 'C.E.' },
}

local function extract_date(text)
	-- Parse the date/time in text and return n, o where
	--   n = table of numbers with date/time fields
	--   o = table of options for AM/PM or AD/BC, if any
	-- or return nothing if date is known to be invalid.
	-- Caller determines if the values in n are valid.
	-- Dates of form d/m/y, m/d/y, y/m/d are rejected as ambiguous and undesirable.
	local date, options = {}, {}
	local function extract_ymd(item)
		local ystr, mstr, dstr = item:match('^(%d%d%d%d)-(%w+)-(%d%d?)$')
		if ystr then
			local m
			if mstr:match('^%d%d?$') then
				m = tonumber(mstr)
			else
				m = month_number(mstr)
			end
			if m then
				date.y = tonumber(ystr)
				date.m = m
				date.d = tonumber(dstr)
				return true
			end
		end
	end
	local function extract_month(item)
		-- A month must be given as a name or abbreviation; a number would be ambiguous.
		local m = month_number(item)
		if m then
			date.m = m
			return true
		end
	end
	local function extract_time(item)
		local h, m, s = item:match('^(%d%d?):(%d%d)(:?%d*)$')
		if date.H or not h then
			return
		end
		if s ~= '' then
			s = s:match('^:(%d%d)$')
			if not s then
				return
			end
		end
		date.H = tonumber(h)
		date.M = tonumber(m)
		date.S = tonumber(s)  -- nil if empty string
		return true
	end
	local ampm_options = {
		['am']   = 'am',
		['AM']   = 'AM',
		['a.m.'] = 'a.m.',
		['A.M.'] = 'A.M.',
		['pm']   = 'am',  -- same as am
		['PM']   = 'AM',
		['p.m.'] = 'a.m.',
		['P.M.'] = 'A.M.',
	}
	local item_count = 0
	local index_time
	local function set_ampm(item)
		local H = date.H
		if H and not options.am and index_time + 1 == item_count then
			options.am = ampm_options[item]
			if item:match('^[Aa]') then
				if not (1 <= H and H <= 12) then
					return
				end
				if H == 12 then
					date.H = 0
				end
			else
				if not (1 <= H and H <= 23) then
					return
				end
				if H <= 11 then
					date.H = H + 12
				end
			end
			return true
		end
	end
	for item in text:gsub(',', ' '):gmatch('%S+') do
		-- Accept options in peculiar places; if duplicated, last wins.
		item_count = item_count + 1
		if era_text[item] then
			options.era = item
		elseif ampm_options[item] then
			if not set_ampm(item) then
				return
			end
		elseif item:find(':', 1, true) then
			if not extract_time(item) then
				return
			end
			index_time = item_count
		elseif date.d and date.m then
			if date.y then
				return  -- should be nothing more so item is invalid
			end
			if not item:match('^(%d%d?%d?%d?)$') then
				return
			end
			date.y = tonumber(item)
		elseif date.d then
			if not extract_month(item) then
				return
			end
		elseif date.m then
			if not item:match('^(%d%d?)$') then
				return
			end
			date.d = tonumber(item)
		elseif not extract_ymd(item) then
			if item:match('^(%d%d?)$') then
				date.d = tonumber(item)
			elseif not extract_month(item) then
				return
			end
		end
	end
	return date, options
end

-- Metatable for some operations on dates.
-- For Lua 5.1, __lt does not work if the metatable is an anonymous table.
local Date  -- forward declaration
local datemt = {
	__eq = function (lhs, rhs)
		-- Return true if dates identify same date/time where, for example,
		-- (-4712, 1, 1, 'Julian') == (-4713, 11, 24, 'Gregorian').
		return lhs.isvalid and rhs.isvalid and lhs.jdz == rhs.jdz
	end,
	__lt = function (lhs, rhs)
		-- Return true if lhs < rhs.
		if not lhs.isvalid then
			return true
		end
		if not rhs.isvalid then
			return false
		end
		return lhs.jdz < rhs.jdz
	end,
	__index = function (self, key)
		local value
		if key == 'dayabbr' then
			value = day_info[self.dow][1]
		elseif key == 'dayname' then
			value = day_info[self.dow][2]
		elseif key == 'dow' then
			value = (self.jd + 1) % 7  -- day-of-week 0=Sun to 6=Sat
		elseif key == 'dowiso' then
			value = (self.jd % 7) + 1  -- ISO day-of-week 1=Mon to 7=Sun
		elseif key == 'doy' then
			local first = Date(self.year, 1, 1, self.calname).jd
			value = self.jd - first + 1  -- day-of-year 1 to 366
		elseif key == 'era' then
			-- Era text from year and options.
			local eraopt = self.options.era
			local sign
			if self.year == 0 then
				sign = (eraopt == 'BCMINUS' or eraopt == 'BCNEGATIVE') and 2 or 1
			else
				sign = self.year > 0 and 2 or 1
			end
			value = era_text[eraopt][sign]
		elseif key == 'gsd' then
			-- GSD = 1 from 00:00:00 to 23:59:59 on 1 January 1 AD Gregorian calendar,
			-- which is JDN = 1721426, and is from jd 1721425.5 to 1721426.49999.
			value = math.floor(self.jd - 1721424.5)
		elseif key == 'jd' or key == 'jdz' then
			local jd, jdz = julian_date(self)
			rawset(self, 'jd', jd)
			rawset(self, 'jdz', jdz)
			return key == 'jd' and jd or jdz
		elseif key == 'is_leap_year' then
			value = is_leap_year(self.year, self.calname)
		elseif key == 'monthabbr' then
			value = month_info[self.month][1]
		elseif key == 'monthname' then
			value = month_info[self.month][2]
		end
		if value ~= nil then
			rawset(self, key, value)
			return value
		end
	end,
}

--[[ Examples of syntax to construct a date:
Date(y, m, d, 'julian')             default calendar is 'gregorian'
Date(y, m, d, H, M, S, 'julian')
Date('juliandate', jd, 'julian')    if jd contains "." text output includes H:M:S
Date('currentdate')
Date('currentdatetime')
LATER: Following are not yet implemented:
Date('currentdate', H, M, S)        current date with given time
Date('1 April 1995', 'julian')      parse date from text
Date('1 April 1995 AD', 'julian')   AD, CE, BC, BCE (using one of these sets a flag to do same for output)
Date('04:30:59 1 April 1995', 'julian')
]]
function Date(...)  -- for forward declaration above
	-- Return a table to hold a date assuming a uniform calendar always applies (proleptic).
	-- If invalid, return an empty table which is regarded as invalid.
	local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
	local result = {
		isvalid = false,  -- false avoids __index lookup
		calname = 'Gregorian',  -- default is Gregorian calendar
		hastime = false,  -- true if input sets a time
		hour = 0,  -- always set hour/minute/second so don't have to handle nil
		minute = 0,
		second = 0,
		month_days = function (self, month)
			return days_in_month(self.year, month, self.calname)
		end,
		-- Valid option settings are:
		-- am: 'am', 'a.m.', 'AM', 'A.M.'
		-- era: 'BCMINUS', 'BCNEGATIVE', 'BC', 'B.C.', 'BCE', 'B.C.E.', 'AD', 'A.D.', 'CE', 'C.E.'
		options = { am = 'am', era = 'BC' },
		text = date_text,
	}
	local argtype, datetext
	local numbers = collection()
	for _, v in ipairs({...}) do
		v = strip_to_nil(v)
		local vlower = type(v) == 'string' and v:lower() or nil
		if v == nil then
			-- Ignore empty arguments after stripping so modules can directly pass template parameters.
		elseif calendars[vlower] then
			result.calname = calendars[vlower]
		else
			local num = tonumber(v)
			if not num and argtype == 'setdate' and numbers.n == 1 then
				num = month_number(v)
			end
			if num then
				if not argtype then
					argtype = 'setdate'
				end
				numbers:add(num)
				if argtype == 'juliandate' then
					if type(v) == 'string' then
						if v:find('.', 1, true) then
							result.hastime = true
						end
					elseif num ~= math.floor(num) then
						-- The given value was a number. The time will be used
						-- if the fractional part is nonzero.
						result.hastime = true
					end
				end
			elseif argtype then
				return {}
			elseif type(v) == 'string' then
				if v == 'currentdate' or v == 'currentdatetime' or v == 'juliandate' then
					argtype = v
				else
					argtype = 'datetext'
					datetext = v
				end
			else
				return {}
			end
		end
	end
	if argtype == 'datetext' then
		if not (numbers.n == 0 and
				set_date_from_numbers(result,
					extract_date(datetext))) then
			return {}
		end
	elseif argtype == 'juliandate' then
		if numbers.n == 1 then
			result.jd = numbers[1]
			set_date_from_jd(result)
		else
			return {}
		end
	elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
		result.year = current.year
		result.month = current.month
		result.day = current.day
		if argtype == 'currentdatetime' then
			result.hour = current.hour
			result.minute = current.minute
			result.second = current.second
			result.hastime = true
		end
		result.calname = 'Gregorian'  -- ignore any given calendar name
		result.isvalid = true
	elseif argtype == 'setdate' then
		if not set_date_from_numbers(result, numbers) then
			return {}
		end
	else
		return {}
	end
	return setmetatable(result, datemt)
end

local function DateDiff(date1, date2)
	-- Return a table to with the difference between the two given dates.
	-- Difference is negative if the second date is older than the first.
	-- TODO Replace with something using Julian dates?
	--      Who checks for isvalid()?
	--      Handle calname == 'Julian'
	local calname = 'Gregorian'  -- TODO fix
	local isnegative
	if date2 < date1 then
		isnegative = true
		date1, date2 = date2, date1
	end
	-- It is known that date1 <= date2.
	local y1, m1 = date1.year, date1.month
	local y2, m2 = date2.year, date2.month
	local years, months, days = y2 - y1, m2 - m1, date2.day - date1.day
	if days < 0 then
		days = days + days_in_month(y1, m1, calname)
		months = months - 1
	end
	if months < 0 then
		months = months + 12
		years = years - 1
	end
	return {
		years = years,
		months = months,
		days = days,
		isnegative = isnegative,
		age_ym = function (self)
			-- Return text specifying difference in years, months.
			local sign = self.isnegative and MINUS or ''
			local mtext = number_name(self.months, 'month')
			local result
			if self.years > 0 then
				local ytext = number_name(self.years, 'year')
				if self.months == 0 then
					result = ytext
				else
					result = ytext .. ',&nbsp;' .. mtext
				end
			else
				if self.months == 0 then
					sign = ''
				end
				result = mtext
			end
			return sign .. result
		end,
	}
end

local function message(msg, nocat)
	-- Return formatted message text for an error.
	-- Can append "#FormattingError" to URL of a page with a problem to find it.
	local anchor = '<span id="FormattingError" />'
	local category
	if not nocat and mw.title.getCurrentTitle():inNamespaces(0, 10) then
		-- Category only in namespaces: 0=article, 10=template.
		category = '[[Category:Age error]]'
	else
		category = ''
	end
	return anchor ..
		'<strong class="error">Error: ' ..
		msg ..
		'</strong>' ..
		category .. '\n'
end

local function age_days(frame)
	-- Return age in days between two given dates, or
	-- between given date and current date.
	-- This code implements the logic in [[Template:Age in days]].
	-- Like {{Age in days}}, a missing argument is replaced from the current
	-- date, so can get a bizarre mixture of specified/current y/m/d.
	local args = frame:getParent().args
	local date1 = Date(
		date_component(args.year1 , args[1], 'year' ),
		date_component(args.month1, args[2], 'month'),
		date_component(args.day1  , args[3], 'day'  )
	)
	local date2 = Date(
		date_component(args.year2 , args[4], 'year' ),
		date_component(args.month2, args[5], 'month'),
		date_component(args.day2  , args[6], 'day'  )
	)
	if not (date1.isvalid and date2.isvalid) then
		return message('Need valid year, month, day')
	end
	local sign = ''
	local result = date2.jd - date1.jd
	if result < 0 then
		sign = MINUS
		result = -result
	end
	return sign .. tostring(result)
end

local function age_ym(frame)
	-- Return age in years and months between two given dates, or
	-- between given date and current date.
	local args = frame:getParent().args
	local fields = {}
	for i = 1, 6 do
		fields[i] = strip_to_nil(args[i])
	end
	local date1, date2
	if fields[1] and fields[2] and fields[3] then
		date1 = Date(fields[1], fields[2], fields[3])
	end
	if not (date1 and date1.isvalid) then
		return message('Need valid year, month, day')
	end
	if fields[4] and fields[5] and fields[6] then
		date2 = Date(fields[4], fields[5], fields[6])
		if not date2.isvalid then
			return message('Second date should be year, month, day')
		end
	else
		date2 = Date('currentdate')
	end
	return DateDiff(date1, date2):age_ym()
end

local function gsd_ymd(frame)
	-- Return Gregorian serial date of the given date, or the current date.
	-- Like {{Gregorian serial date}}, a missing argument is replaced from the
	-- current date, so can get a bizarre mixture of specified/current y/m/d.
	-- This also accepts positional arguments, although the original template does not.
	-- The returned value is negative for dates before 1 January 1 AD despite
	-- the fact that GSD is not defined for earlier dates.
	local args = frame:getParent().args
	local date = Date(
		date_component(args.year , args[1], 'year' ),
		date_component(args.month, args[2], 'month'),
		date_component(args.day  , args[3], 'day'  )
	)
	if date.isvalid then
		return tostring(date.gsd)
	end
	return message('Need valid year, month, day')
end

local function ymd_from_jd(frame)
	-- Return formatted date from a Julian date.
	-- The result is y-m-d or y-m-d H:M:S if input includes a fraction.
	-- The word 'Julian' is accepted for the Julian calendar.
	local args = frame:getParent().args
	local date = Date('juliandate', args[1], args[2])
	if date.isvalid then
		return date:text()
	end
	return message('Need valid Julian date number')
end

local function ymd_to_jd(frame)
	-- Return Julian date (a number) from a date (y-m-d), or datetime (y-m-d H:M:S),
	-- or the current date ('currentdate') or current datetime ('currentdatetime').
	-- The word 'Julian' is accepted for the Julian calendar.
	local args = frame:getParent().args
	local date = Date(args[1], args[2], args[3], args[4], args[5], args[6], args[7])
	if date.isvalid then
		return tostring(date.jd)
	end
	return message('Need valid year/month/day or "currentdate"')
end

return {
	age_days = age_days,
	age_ym = age_ym,
	_Date = Date,
	days_in_month = days_in_month,
	gsd = gsd_ymd,
	JULIANDAY = ymd_to_jd,
	ymd_from_jd = ymd_from_jd,
	ymd_to_jd = ymd_to_jd,
}