Hopp til innhold

Modul:Date

Fra Wikipedia, den frie encyklopedi
Sideversjon per 7. mar. 2016 kl. 05:58 av Johnuniq (diskusjon | bidrag) (major refactor, mostly working now; add/subtract with a date; remove template handling which will be in Module:Age)
Moduldokumentasjon

This module provides date functions for use by other modules. Dates in the Gregorian calendar and the Julian calendar are supported, from 9999 BCE to 9999 CE. The calendars are proleptic—they are assumed to apply at all times with no irregularities.

A date, with an optional time, can be specified in a variety of formats, and can be converted for display using a variety of formats, for example, 1 April 2016 or April 1, 2016. The properties of a date include its Julian date and its Gregorian serial date, as well as the day-of-week and day-of-year.

Dates can be compared (for example, date1 <= date2), and can be used with add or subtract (for example, date + '3 months'). The difference between two dates can be determined with date1 - date2. These operations work with both Gregorian and Julian calendar dates, but date1 - date2 is nil if the two dates use different calendars.

The module provides the following items.

Export Description
_current Table with the current year, month, day, hour, minute, second.
_Date Function that returns a table for a specified date.
_days_in_month Function that returns the number of days in a month.

The following has examples of using the module:

Formatted output

A date can be formatted as text.

local Date = require('Module:Date')._Date
local text = Date(2016, 7, 1):text()          -- result is '1 July 2016'
local text = Date(2016, 7, 1):text('%-d %B')  -- result is '1 July'
local text = Date('1 July 2016'):text('mdy')  -- result is 'July 1, 2016'

The following simplified formatting codes are available.

Code Result
hm hour:minute, with "am" or "pm" or variant, if specified (14:30 or 2:30 pm or variant)
hms hour:minute:second (14:30:45)
ymd year-month-day (2016-07-01)
mdy month day, year (July 1, 2016)
dmy day month year (1 July 2016)

The following formatting codes (similar to strftime) are available.

Code Result
%a Day abbreviation: Mon, Tue, ...
%A Day name: Monday, Tuesday, ...
%u Day of week: 1 to 7 (Monday to Sunday)
%w Day of week: 0 to 6 (Sunday to Saturday)
%d Day of month zero-padded: 01 to 31
%b Month abbreviation: Jan to Dec
%B Month name: January to December
%m Month zero-padded: 01 to 12
%Y Year zero-padded: 0012, 0120, 1200
%H Hour 24-hour clock zero-padded: 00 to 23
%I Hour 12-hour clock zero-padded: 01 to 12
%p AM or PM or as in options
%M Minute zero-padded: 00 to 59
%S Second zero-padded: 00 to 59
%j Day of year zero-padded: 001 to 366
%-d Day of month: 1 to 31
%-m Month: 1 to 12
%-Y Year: 12, 120, 1200
%-H Hour: 0 to 23
%-M Minute: 0 to 59
%-S Second: 0 to 59
%-j Day of year: 1 to 366
%-I Hour: 1 to 12
%% %

In addition, %{property} (where property is any property of a date) can be used.

For example, Date('1 Feb 2015 14:30:45 A.D.') has the following properties.

Code Result
%{calendar} Gregorian
%{year} 2015
%{month} 2
%{day} 1
%{hour} 14
%{minute} 30
%{second} 45
%{dayabbr} Sun
%{dayname} Sunday
%{dayofweek} 0
%{dow} 0 (same as dayofweek)
%{dayofweekiso} 7
%{dowiso} 7 (same as dayofweekiso)
%{dayofyear} 32
%{era} A.D.
%{gsd} 735630 (numbers of days from 1 January 1 CE; the first is day 1)
%{juliandate} 2457055.1046875 (Julian day)
%{jd} 2457055.1046875 (same as juliandate)
%{isleapyear} false
%{monthdays} 28
%{monthabbr} Feb
%{monthname} February

Some shortcuts are available. Given date = Date('1 Feb 2015 14:30'), the following results would occur.

Code Description Example result Equivalent format
date:text('%c') date and time 2:30 pm 1 February 2015 %-I:%M %p %-d %B %-Y %{era}
date:text('%x') date 1 February 2015 %-d %B %-Y %{era}
date:text('%X') time 2:30 pm %-I:%M %p

Julian date

The following has an example of converting a Julian date to a date, then obtaining information about the date.

-- Code                                                -- Result
Date = require('Module:Date')._Date
date = Date('juliandate', 320)
number = date.gsd                                      -- -1721105
number = date.jd                                       -- 320
text = date.dayname                                    -- Saturday
text = date:text()                                     -- 9 October 4713&nbsp;BC
text = date:text('%Y-%m-%d')                           -- 4713-10-09
text = date:text('%{era} %Y-%m-%d')                    -- BC 4713-10-09
text = date:text('%Y-%m-%d %{era}')                    -- 4713-10-09&nbsp;BC
text = date:text('%Y-%m-%d %{era}', 'era=B.C.E.')      -- 4713-10-09&nbsp;B.C.E.
text = date:text('%Y-%m-%d', 'era=BCNEGATIVE')         -- -4712-10-09
text = date:text('%Y-%m-%d', 'era=BCMINUS')            -- −4712-10-09 (uses Unicode MINUS SIGN U+2212)
text = Date('juliandate',320):text('%{gsd} %{jd}')     -- -1721105 320
text = Date('Oct 9, 4713 B.C.E.'):text('%{gsd} %{jd}') -- -1721105 320
text = Date(-4712,10,9):text('%{gsd} %{jd}')           -- -1721105 320

Date differences

The difference between two dates can be determined with date1 - date2. The result is valid if both dates use the Gregorian calendar or if both dates use the Julian calendar, otherwise the result is nil. An age and duration can be calculated from a date difference.

For example:

-- Code                                      -- Result
Date = require('Module:Date')._Date
date1 = Date('21 Mar 2015')
date2 = Date('4 Dec 1999')
diff = date1 - date2
d = diff.age_days                            -- 5586
y, m, d = diff.years, diff.months, diff.days -- 15, 3, 17 (15 years + 3 months + 17 days)
y, m, d = diff:age('ymd')                    -- 15, 3, 17
y, m, w, d = diff:age('ymwd')                -- 15, 3, 2, 3 (15 years + 3 months + 2 weeks + 3 days)
y, m, w, d = diff:duration('ymwd')           -- 15, 3, 2, 4
d = diff:duration('d')                       -- 5587 (a duration includes the final day)

A date difference holds the original dates except they are swapped so diff.date1 >= diff.date2 (diff.date1 is the more recent date). This is shown in the following.

date1 = Date('21 Mar 2015')
date2 = Date('4 Dec 1999')
diff = date1 - date2
neg = diff.isnegative                        -- false
text = diff.date1:text()                     -- 21 March 2015
text = diff.date2:text()                     -- 4 December 1999
diff = date2 - date1
neg = diff.isnegative                        -- true (dates have been swapped)
text = diff.date1:text()                     -- 21 March 2015
text = diff.date2:text()                     -- 4 December 1999

A date difference also holds a time difference:

date1 = Date('8 Mar 2016 0:30:45')
date2 = Date('19 Jan 2014 22:55')
diff = date1 - date2
y, m, d = diff.years, diff.months, diff.days      -- 2, 1, 17
H, M, S = diff.hours, diff.minutes, diff.seconds  -- 1, 35, 45

A date difference can be added to a date, or subtracted from a date.

date1 = Date('8 Mar 2016 0:30:45')
date2 = Date('19 Jan 2014 22:55')
diff = date1 - date2
date3 = date2 + diff
date4 = date1 - diff
text = date3:text('ymd hms')        -- 2016-03-08 00:30:45
text = date4:text('ymd hms')        -- 2014-01-19 22:55:00
equal = (date1 == date3)            -- true
equal = (date2 == date4)            -- true

The age and duration methods of a date difference accept a code that identifies the components that should be returned. An extra day is included for the duration method because it includes the final day.

Code Returned values
'ymwd' years, months, weeks, days
'ymd' years, months, days
'ym' years, months
'y' years
'm' months
'wd' weeks, days
'w' weeks
'd' days

-- 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)
	-- If text is a string, return its trimmed content, or nil.
	-- Otherwise return text (convenient when Date fields are provided from
	-- another module which is able to pass, for example, a number).
	if type(text) == 'string' then
		text = text:match('(%S.-)%s*$')
	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 = same, but assume time is 00:00:00 if no time given
	-- 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
	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 jd = date.day + floor((153*m + 2)/5) + 365*y + offset
	if date.hastime then
		jd = jd + (date.hour + (date.minute + date.second / 60) /60) / 24 - 0.5
		return jd, jd
	end
	return jd, jd - 0.5
end

local function set_date_from_jd(date)
	-- Set the fields of table date from its Julian date field.
	-- Return true if date is valid.
	-- 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
		return
	end
	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)
	return true
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
	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(options1, options2)
	-- If options1 is a string, return a table with its settings, or
	-- if it is a table, use its settings.
	-- Missing options are set from options2 or defaults.
	-- 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.'
	-- Option am = 'am' does not mean the hour is AM; it means 'am' or 'pm' is used, depending on the hour.
	-- Similarly, era = 'BC' means 'BC' is used if year < 0.
	-- BCMINUS displays a MINUS if year < 0 and the display format does not include %{era}.
	-- BCNEGATIVE is similar but displays a hyphen.
	local result = {}
	if type(options1) == 'table' then
		result = options1
	elseif type(options1) == 'string' then
		-- Example: 'am:AM era:BC'
		for item in options1:gmatch('%S+') do
			local lhs, rhs = item:match('^(%w+):(.+)$')
			if lhs then
				result[lhs] = rhs
			end
		end
	end
	options2 = type(options2) == 'table' and options2 or {}
	local defaults = { am = 'am', era = 'BC' }
	for k, v in pairs(defaults) do
		result[k] = result[k] or options2[k] or v
	end
	return result
end

local era_text = {
	-- Text for displaying an era with a positive year (after adjusting
	-- by replacing year with 1 - year if date.year <= 0).
	-- options.era = { year<=0 , year>0 }
	['BCMINUS']    = { 'BC'    , ''    , isbc = true, sign = MINUS },
	['BCNEGATIVE'] = { 'BC'    , ''    , isbc = true, sign = '-'   },
	['BC']         = { 'BC'    , ''    , isbc = true },
	['B.C.']       = { 'B.C.'  , ''    , isbc = true },
	['BCE']        = { 'BCE'   , ''    , isbc = true },
	['B.C.E.']     = { 'B.C.E.', ''    , isbc = true },
	['AD']         = { 'BC'    , 'AD'   },
	['A.D.']       = { 'B.C.'  , 'A.D.' },
	['CE']         = { 'BCE'   , 'CE'   },
	['C.E.']       = { 'B.C.E.', 'C.E.' },
}

local function get_era_for_year(era, year)
	return (era_text[era or 'BC'] or {})[year > 0 and 2 or 1] or ''
end

local function strftime(date, format, options)
	-- Return date formatted as a string using codes similar to those
	-- in the C strftime library function.
	local sformat = string.format
	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
	}
	if shortcuts[format] then
		format = shortcuts[format]
	end
	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, date.options)
	local amopt = options.am
	local eraopt = options.era
	local function replace_code(spaces, 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 (spaces == '' and '' or '&nbsp;') .. (value < 12 and ap[1] or ap[2])
				end
			end
			if code.field == 'year' then
				local sign = (era_text[eraopt] or {}).sign
				if not sign or format:find('%{era}', 1, true) then
					sign = ''
					if value <= 0 then
						value = 1 - value
					end
				else
					if value >= 0 then
						sign = ''
					else
						value = -value
					end
				end
				return spaces .. sign .. sformat(fmt, value)
			end
			return spaces .. (fmt and sformat(fmt, value) or value)
		end
	end
	local function replace_property(spaces, id)
		if id == 'era' then
			-- Special case so can use local era option.
			local result = get_era_for_year(eraopt, date.year)
			if result == '' then
				return ''
			end
			return (spaces == '' and '' or '&nbsp;') .. result
		end
		local result = date[id]
		if type(result) == 'string' then
			return spaces .. result
		end
		if type(result) == 'number' then
			return  spaces .. tostring(result)
		end
		if type(result) == 'boolean' then
			return  spaces .. (result and '1' or '0')
		end
		-- This occurs, for example, if id is the name of a function.
		return nil
	end
	local PERCENT = '\127PERCENT\127'
	return (format
		:gsub('%%%%', PERCENT)
		:gsub('(%s*)%%{(%w+)}', replace_property)
		:gsub('(%s*)%%(-?)(%a)', replace_code)
		:gsub(PERCENT, '%%')
	)
end

local function _date_text(date, fmt, options)
	-- Return formatted string from given date.
	if type(fmt) ~= 'string' then
		fmt = '%-d %B %-Y %{era}'
		if date.hastime then
			if date.second > 0 then
				fmt = '%H:%M:%S ' .. fmt
			else
				fmt = '%H:%M ' .. fmt
			end
		end
		return strftime(date, fmt, options)
	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 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.
	-- A year must be positive ('1' to '9999'); use 'BC' for BC.
	-- In a y-m-d string, the year must be four digits to avoid ambiguity
	-- ('0001' to '9999'). The only way to enter year <= 0 is by specifying
	-- the date as three numeric parameters like ymd Date(-1, 1, 1).
	-- Dates of form d/m/y, m/d/y, y/m/d are rejected as ambiguous.
	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
		item_count = item_count + 1
		if era_text[item] then
			-- Era is accepted in peculiar places.
			if options.era then
				return
			end
			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
	if not date.y or date.y == 0 then
		return
	end
	local era = era_text[options.era]
	if era and era.isbc then
		date.y = 1 - date.y
	end
	return date, options
end

local Date, DateDiff, datemt  -- forward declarations

local function is_date(t)
	return type(t) == 'table' and getmetatable(t) == datemt
end

local function date_add_sub(lhs, rhs, is_sub)
	-- Return a new date from calculating (lhs + rhs) or (lhs - rhs),
	-- or return nothing if invalid.
	-- Caller ensures that lhs is a date; its properties are copied for the new date.
	local function is_prefix(text, word, minlen)
		local n = #text
		return (minlen or 1) <= n and n <= #word and text == word:sub(1, n)
	end
	local function do_days(n)
		if is_sub then
			n = -n
		end
		return Date(lhs, 'juliandate', lhs.jd + n)
	end
	if type(rhs) == 'number' then
		-- Add days, including fractional days.
		return do_days(rhs)
	end
	if type(rhs) == 'string' then
		-- rhs is a single component like '26m' or '26 months' (unsigned integer only).
		local num, id = rhs:match('^%s*(%d+)%s*(%a+)$')
		if num then
			local y, m
			num = tonumber(num)
			id = id:lower()
			if is_prefix(id, 'years') then
				y = num
				m = 0
			elseif is_prefix(id, 'months') then
				y = math.floor(num / 12)
				m = num % 12
			elseif is_prefix(id, 'weeks') then
				return do_days(num * 7)
			elseif is_prefix(id, 'days') then
				return do_days(num)
			elseif is_prefix(id, 'hours') then
				return do_days(num / 24)
			elseif is_prefix(id, 'minutes', 3) then
				return do_days(num / (24 * 60))
			elseif is_prefix(id, 'seconds') then
				return do_days(num / (24 * 3600))
			else
				return
			end
			if is_sub then
				y = -y
				m = -m
			end
			assert(-11 <= m and m <= 11)
			y = lhs.year + y
			m = lhs.month + m
			if m > 12 then
				y = y + 1
				m = m - 12
			elseif m < 1 then
				y = y - 1
				m = m + 12
			end
			local d = math.min(lhs.day, days_in_month(y, m, lhs.calname))
			return Date(lhs, y, m, d)
		end
	end
end

-- Metatable for some operations on dates.
datemt = {  -- for forward declaration above
	__add = function (lhs, rhs)
		if not is_date(lhs) then
			lhs, rhs = rhs, lhs  -- put date on left (it must be a date for this to have been called)
		end
		return date_add_sub(lhs, rhs)
	end,
	__sub = function (lhs, rhs)
		if is_date(lhs) then
			if is_date(rhs) then
				return DateDiff(lhs, rhs)
			end
			return date_add_sub(lhs, rhs, true)
		end
	end,
	__concat = function (lhs, rhs)
		return tostring(lhs) .. tostring(rhs)
	end,
	__tostring = function (self)
		return self:text()
	end,
	__eq = function (lhs, rhs)
		-- Return true if dates identify same date/time where, for example,
		-- Date(-4712, 1, 1, 'Julian') == Date(-4713, 11, 24, 'Gregorian') is true.
		-- This is only called if lhs and rhs have the same metatable.
		return lhs.jdz == rhs.jdz
	end,
	__lt = function (lhs, rhs)
		-- Return true if lhs < rhs, for example,
		-- Date('1 Jan 2016') < Date('06:00 1 Jan 2016') is true.
		-- This is only called if lhs and rhs have the same metatable.
		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 (not a negative sign) from year and options.
			value = get_era_for_year(self.options.era, self.year)
		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,
}

local function _month_days(date, month)
	return days_in_month(date.year, month, date.calname)
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')
Date('1 April 1995', 'julian')      parse date from text
Date('1 April 1995 AD', 'julian')   using an era sets a flag to do the same for output
Date('04:30:59 1 April 1995', 'julian')
Date(date)                          copy of an existing date
LATER: Following is not yet implemented:
Date('currentdate', H, M, S)        current date with given time
]]
function Date(...)  -- for forward declaration above
	-- Return a table holding a date assuming a uniform calendar always applies
	-- (proleptic Gregorian calendar or proleptic Julian calendar), or
	-- return nothing if date is invalid.
	local is_copy
	local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
	local result = {
		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 = _month_days,
		options = make_option_table(),
		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]
		elseif is_date(v) then
			-- Copy existing date (items can be overridden by other arguments).
			if is_copy then
				return
			end
			is_copy = true
			result.calname = v.calname
			result.hastime = v.hastime
			result.options = v.options
			result.year = v.year
			result.month = v.month
			result.day = v.day
			result.hour = v.hour
			result.minute = v.minute
			result.second = v.second
		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
		result.jd = numbers[1]
		if not (numbers.n == 1 and set_date_from_jd(result)) then
			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
	elseif argtype == 'setdate' then
		if not set_date_from_numbers(result, numbers) then
			return
		end
	elseif not is_copy then
		return
	end
	return setmetatable(result, datemt)
end

function DateDiff(date1, date2)  -- for forward declaration above
	-- Return a table with the difference between the two dates (date1 - date2).
	-- The difference is negative if date2 is more recent than date1.
	-- Return nothing if invalid.
	if not (date1 and date2 and date1.calname == date2.calname) then
		return
	end
	local isnegative
	if date1 < date2 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 = y1 - y2, m1 - m2, date1.day - date2.day
	if days < 0 then
		days = days + days_in_month(y2, m2, date2.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

return {
	_current = current,
	_Date = Date,
	_DateDiff = DateDiff,
	_days_in_month = days_in_month,
}