Module:Date: Difference between revisions

    From Nonbinary Wiki
    (formatted output and strftime)
    (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,
    }