Module:Date: Difference between revisions

    From Nonbinary Wiki
    (tweak formatted output; extract_date to parse an input date string)
    (major refactor, mostly working now; add/subtract with a date; remove template handling which will be in Module:Age)
    Line 19: Line 19:


    local function strip_to_nil(text)
    local function strip_to_nil(text)
    -- Return nil if text is nil or is an empty string after trimming.
    -- If text is a string, return its trimmed content, or nil.
    -- If text is a non-blank string, return its content after trimming.
    -- Otherwise return text (convenient when Date fields are provided from
    -- Otherwise return text (convenient when accessed via another module).
    -- another module which is able to pass, for example, a number).
    if type(text) == 'string' then
    if type(text) == 'string' then
    local result = text:match("^%s*(.-)%s*$")
    text = text:match('(%S.-)%s*$')
    if result == '' then
    return nil
    end
    return result
    end
    if text == nil then
    return nil
    end
    end
    return text
    return text
    Line 63: Line 56:
    -- Return jd, jdz from a Julian or Gregorian calendar date where
    -- Return jd, jdz from a Julian or Gregorian calendar date where
    --  jd = Julian date and its fractional part is zero at noon
    --  jd = Julian date and its fractional part is zero at noon
    --  jdz = similar, but fractional part is zero at 00:00:00
    --  jdz = same, but assume time is 00:00:00 if no time given
    -- http://www.tondering.dk/claus/cal/julperiod.php#formula
    -- http://www.tondering.dk/claus/cal/julperiod.php#formula
    -- Testing shows this works for all dates from year -9999 to 9999!
    -- Testing shows this works for all dates from year -9999 to 9999!
    Line 69: Line 62:
    --    1 January 4713 BC  = (-4712, 1, 1)  Julian calendar
    --    1 January 4713 BC  = (-4712, 1, 1)  Julian calendar
    --  24 November 4714 BC = (-4713, 11, 24) Gregorian 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 floor = math.floor
    local offset
    local offset
    Line 82: Line 72:
    end
    end
    local m = date.month + 12*a - 3
    local m = date.month + 12*a - 3
    local date_part = date.day + floor((153*m + 2)/5) + 365*y + offset
    local jd = date.day + floor((153*m + 2)/5) + 365*y + offset
    local time_part, zbias
    if date.hastime then
    if date.hastime then
    time_part = (date.hour + (date.minute + date.second / 60) /60) / 24 - 0.5
    jd = jd + (date.hour + (date.minute + date.second / 60) /60) / 24 - 0.5
    zbias = 0
    return jd, jd
    else
    time_part = 0
    zbias = -0.5
    end
    end
    local jd = date_part + time_part
    return jd, jd - 0.5
    return jd, jd + zbias
    end
    end


    local function set_date_from_jd(date)
    local function set_date_from_jd(date)
    -- Set the fields of table date from its Julian date field.
    -- 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
    -- http://www.tondering.dk/claus/cal/julperiod.php#formula
    -- This handles the proleptic Julian and Gregorian calendars.
    -- This handles the proleptic Julian and Gregorian calendars.
    Line 112: Line 98:
    end
    end
    if not (limits[1] <= jd and jd <= limits[2]) then
    if not (limits[1] <= jd and jd <= limits[2]) then
    date.isvalid = false
    return
    return
    end
    end
    date.isvalid = true
    local jdn = floor(jd)
    local jdn = floor(jd)
    if date.hastime then
    if date.hastime then
    Line 152: Line 136:
    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)
    return true
    end
    end


    Line 189: Line 174:
    date.minute = M  -- 0 to 59
    date.minute = M  -- 0 to 59
    date.second = S  -- 0 to 59
    date.second = S  -- 0 to 59
    date.isvalid = true
    if type(options) == 'table' then
    if type(options) == 'table' then
    for _, k in ipairs({ 'am', 'era' }) do
    for _, k in ipairs({ 'am', 'era' }) do
    Line 200: Line 184:
    end
    end


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


    Line 220: Line 238:
    -- Return date formatted as a string using codes similar to those
    -- Return date formatted as a string using codes similar to those
    -- in the C strftime library function.
    -- in the C strftime library function.
    if not date.isvalid then
    local sformat = string.format
    return '(invalid)'
    end
    local shortcuts = {
    local shortcuts = {
    ['%c'] = '%-I:%M %p %-d %B %Y%{era}',  -- date and time: 2:30 pm 1 April 2016
    ['%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'] = '%-d %B %-Y %{era}',            -- date:          1 April 2016
    ['%X'] = '%-I:%M %p',                 -- time:          2:30 pm
    ['%X'] = '%-I:%M %p',                   -- time:          2:30 pm
    }
    }
    if shortcuts[format] then
    format = shortcuts[format]
    end
    local codes = {
    local codes = {
    a = { field = 'dayabbr' },
    a = { field = 'dayabbr' },
    Line 245: Line 264:
    p = { field = 'hour', special = 'am' },
    p = { field = 'hour', special = 'am' },
    }
    }
    options = make_option_table(options or date.options)
    options = make_option_table(options, date.options)
    local amopt = options.am
    local amopt = options.am
    local eraopt = options.era
    local eraopt = options.era
    local function replace_code(modifier, id)
    local function replace_code(spaces, modifier, id)
    local code = codes[id]
    local code = codes[id]
    if code then
    if code then
    Line 267: Line 286:
    ['A.M.'] = { 'A.M.', 'P.M.' },
    ['A.M.'] = { 'A.M.', 'P.M.' },
    })[amopt] or { 'am', 'pm' }
    })[amopt] or { 'am', 'pm' }
    return value < 12 and ap[1] or ap[2]
    return (spaces == '' and '' or '&nbsp;') .. (value < 12 and ap[1] or ap[2])
    end
    end
    end
    end
    if code.field == 'year' then
    if code.field == 'year' then
    if eraopt == 'BCMINUS' or eraopt == 'BCNEGATIVE' then
    local sign = (era_text[eraopt] or {}).sign
    local 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
    if value >= 0 then
    sign = ''
    sign = ''
    else
    else
    sign = eraopt == 'BCMINUS' and MINUS or '-'
    value = -value
    value = -value
    end
    end
    return sign .. string.format(fmt, value)
    end
    if value <= 0 then
    value = 1 - value
    end
    end
    return spaces .. sign .. sformat(fmt, value)
    end
    end
    return fmt and string.format(fmt, value) or value
    return spaces .. (fmt and sformat(fmt, value) or value)
    end
    end
    end
    end
    local function replace_property(id)
    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]
    local result = date[id]
    if type(result) == 'string' then
    if type(result) == 'string' then
    if id == 'era' and result ~= '' then
    return spaces .. result
    -- Assume era follows a date.
    return '&nbsp;' .. result
    end
    return result
    end
    end
    if type(result) == 'number' then
    if type(result) == 'number' then
    return tostring(result)
    return spaces .. tostring(result)
    end
    end
    if type(result) == 'boolean' then
    if type(result) == 'boolean' then
    return result and '1' or '0'
    return spaces .. (result and '1' or '0')
    end
    end
    -- 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'
    return (format
    return (format
    :gsub('%%%%', PERCENT)
    :gsub('%%%%', PERCENT)
    :gsub('%%{(%w+)}', replace_property)
    :gsub('(%s*)%%{(%w+)}', replace_property)
    :gsub('%%(-?)(%a)', replace_code)
    :gsub('(%s*)%%(-?)(%a)', replace_code)
    :gsub(PERCENT, '%%')
    :gsub(PERCENT, '%%')
    )
    )
    end
    end


    local function date_text(date, fmt, options)
    local function _date_text(date, fmt, options)
    -- Return formatted string from given date.
    -- Return formatted string from given date.
    if not (type(date) == 'table' and date.isvalid) then
    return '(invalid)'
    end
    if type(fmt) ~= 'string' then
    if type(fmt) ~= 'string' then
    fmt = '%Y-%m-%d'
    fmt = '%-d %B %-Y %{era}'
    if date.hastime then
    if date.hastime then
    if date.second > 0 then
    if date.second > 0 then
    fmt = fmt .. ' %H:%M:%S'
    fmt = '%H:%M:%S ' .. fmt
    else
    else
    fmt = fmt .. ' %H:%M'
    fmt = '%H:%M ' .. fmt
    end
    end
    end
    end
    return strftime(date, fmt, options or { era = 'BCMINUS' })
    return strftime(date, fmt, options)
    end
    end
    if fmt:find('%', 1, true) then
    if fmt:find('%', 1, true) then
    Line 345: Line 363:
    f = '%H:%M:%S'
    f = '%H:%M:%S'
    elseif item == 'ymd' then
    elseif item == 'ymd' then
    f = '%Y:%m:%d%{era}'
    f = '%Y-%m-%d %{era}'
    elseif item == 'mdy' then
    elseif item == 'mdy' then
    f = '%B %-d, %Y%{era}'
    f = '%B %-d, %-Y %{era}'
    elseif item == 'dmy' then
    elseif item == 'dmy' then
    f = '%-d %B %Y%{era}'
    f = '%-d %B %-Y %{era}'
    else
    else
    return '(invalid format)'
    return '(invalid format)'
    Line 418: Line 436:
    end
    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)
    local function extract_date(text)
    Line 459: Line 443:
    -- or return nothing if date is known to be invalid.
    -- or return nothing if date is known to be invalid.
    -- Caller determines if the values in n are valid.
    -- 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.
    -- 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 date, options = {}, {}
    local function extract_ymd(item)
    local function extract_ymd(item)
    Line 537: Line 525:
    end
    end
    for item in text:gsub(',', ' '):gmatch('%S+') do
    for item in text:gsub(',', ' '):gmatch('%S+') do
    -- Accept options in peculiar places; if duplicated, last wins.
    item_count = item_count + 1
    item_count = item_count + 1
    if era_text[item] then
    if era_text[item] then
    -- Era is accepted in peculiar places.
    if options.era then
    return
    end
    options.era = item
    options.era = item
    elseif ampm_options[item] then
    elseif ampm_options[item] then
    Line 574: Line 565:
    end
    end
    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
    end
    return date, options
    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
    end


    -- Metatable for some operations on dates.
    -- Metatable for some operations on dates.
    -- For Lua 5.1, __lt does not work if the metatable is an anonymous table.
    datemt = {  -- for forward declaration above
    local Date -- forward declaration
    __add = function (lhs, rhs)
    local datemt = {
    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)
    __eq = function (lhs, rhs)
    -- Return true if dates identify same date/time where, for example,
    -- Return true if dates identify same date/time where, for example,
    -- (-4712, 1, 1, 'Julian') == (-4713, 11, 24, 'Gregorian').
    -- Date(-4712, 1, 1, 'Julian') == Date(-4713, 11, 24, 'Gregorian') is true.
    return lhs.isvalid and rhs.isvalid and lhs.jdz == rhs.jdz
    -- This is only called if lhs and rhs have the same metatable.
    return lhs.jdz == rhs.jdz
    end,
    end,
    __lt = function (lhs, rhs)
    __lt = function (lhs, rhs)
    -- Return true if lhs < rhs.
    -- Return true if lhs < rhs, for example,
    if not lhs.isvalid then
    -- Date('1 Jan 2016') < Date('06:00 1 Jan 2016') is true.
    return true
    -- This is only called if lhs and rhs have the same metatable.
    end
    if not rhs.isvalid then
    return false
    end
    return lhs.jdz < rhs.jdz
    return lhs.jdz < rhs.jdz
    end,
    end,
    Line 611: Line 694:
    value = self.jd - first + 1  -- day-of-year 1 to 366
    value = self.jd - first + 1  -- day-of-year 1 to 366
    elseif key == 'era' then
    elseif key == 'era' then
    -- Era text from year and options.
    -- Era text (not a negative sign) from year and options.
    local eraopt = self.options.era
    value = get_era_for_year(self.options.era, self.year)
    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
    elseif key == 'gsd' then
    -- GSD = 1 from 00:00:00 to 23:59:59 on 1 January 1 AD Gregorian calendar,
    -- GSD = 1 from 00:00:00 to 23:59:59 on 1 January 1 AD Gregorian calendar,
    Line 642: Line 718:
    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:
    --[[ Examples of syntax to construct a date:
    Line 649: Line 729:
    Date('currentdate')
    Date('currentdate')
    Date('currentdatetime')
    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', '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')  using an era sets a flag to do the same for output
    Date('04:30:59 1 April 1995', 'julian')
    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
    function Date(...)  -- for forward declaration above
    -- Return a table to hold a date assuming a uniform calendar always applies (proleptic).
    -- Return a table holding a date assuming a uniform calendar always applies
    -- If invalid, return an empty table which is regarded as invalid.
    -- (proleptic Gregorian calendar or proleptic Julian calendar), or
    -- return nothing if date is invalid.
    local is_copy
    local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
    local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
    local result = {
    local result = {
    isvalid = false,  -- false avoids __index lookup
    calname = 'Gregorian',  -- default is Gregorian calendar
    calname = 'Gregorian',  -- default is Gregorian calendar
    hastime = false,  -- true if input sets a time
    hastime = false,  -- true if input sets a time
    Line 666: Line 748:
    minute = 0,
    minute = 0,
    second = 0,
    second = 0,
    month_days = function (self, month)
    month_days = _month_days,
    return days_in_month(self.year, month, self.calname)
    options = make_option_table(),
    end,
    text = _date_text,
    -- 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 argtype, datetext
    Line 684: Line 761:
    elseif calendars[vlower] then
    elseif calendars[vlower] then
    result.calname = calendars[vlower]
    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
    else
    local num = tonumber(v)
    local num = tonumber(v)
    Line 706: Line 798:
    end
    end
    elseif argtype then
    elseif argtype then
    return {}
    return
    elseif type(v) == 'string' then
    elseif type(v) == 'string' then
    if v == 'currentdate' or v == 'currentdatetime' or v == 'juliandate' then
    if v == 'currentdate' or v == 'currentdatetime' or v == 'juliandate' then
    Line 715: Line 807:
    end
    end
    else
    else
    return {}
    return
    end
    end
    end
    end
    Line 723: Line 815:
    set_date_from_numbers(result,
    set_date_from_numbers(result,
    extract_date(datetext))) then
    extract_date(datetext))) then
    return {}
    return
    end
    end
    elseif argtype == 'juliandate' then
    elseif argtype == 'juliandate' then
    if numbers.n == 1 then
    result.jd = numbers[1]
    result.jd = numbers[1]
    if not (numbers.n == 1 and set_date_from_jd(result)) then
    set_date_from_jd(result)
    return
    else
    return {}
    end
    end
    elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
    elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
    Line 743: Line 833:
    end
    end
    result.calname = 'Gregorian'  -- ignore any given calendar name
    result.calname = 'Gregorian'  -- ignore any given calendar name
    result.isvalid = true
    elseif argtype == 'setdate' then
    elseif argtype == 'setdate' then
    if not set_date_from_numbers(result, numbers) then
    if not set_date_from_numbers(result, numbers) then
    return {}
    return
    end
    end
    else
    elseif not is_copy then
    return {}
    return
    end
    end
    return setmetatable(result, datemt)
    return setmetatable(result, datemt)
    end
    end


    local function DateDiff(date1, date2)
    function DateDiff(date1, date2) -- for forward declaration above
    -- Return a table to with the difference between the two given dates.
    -- Return a table with the difference between the two dates (date1 - date2).
    -- Difference is negative if the second date is older than the first.
    -- The difference is negative if date2 is more recent than date1.
    -- TODO Replace with something using Julian dates?
    -- Return nothing if invalid.
    --      Who checks for isvalid()?
    if not (date1 and date2 and date1.calname == date2.calname) then
    --      Handle calname == 'Julian'
    return
    local calname = 'Gregorian'  -- TODO fix
    end
    local isnegative
    local isnegative
    if date2 < date1 then
    if date1 < date2 then
    isnegative = true
    isnegative = true
    date1, date2 = date2, date1
    date1, date2 = date2, date1
    end
    end
    -- It is known that date1 <= date2.
    -- It is known that date1 >= date2.
    local y1, m1 = date1.year, date1.month
    local y1, m1 = date1.year, date1.month
    local y2, m2 = date2.year, date2.month
    local y2, m2 = date2.year, date2.month
    local years, months, days = y2 - y1, m2 - m1, date2.day - date1.day
    local years, months, days = y1 - y2, m1 - m2, date1.day - date2.day
    if days < 0 then
    if days < 0 then
    days = days + days_in_month(y1, m1, calname)
    days = days + days_in_month(y2, m2, date2.calname)
    months = months - 1
    months = months - 1
    end
    end
    Line 804: Line 893:
    end,
    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
    end


    return {
    return {
    age_days = age_days,
    _current = current,
    age_ym = age_ym,
    _Date = Date,
    _Date = Date,
    days_in_month = days_in_month,
    _DateDiff = DateDiff,
    gsd = gsd_ymd,
    _days_in_month = days_in_month,
    JULIANDAY = ymd_to_jd,
    ymd_from_jd = ymd_from_jd,
    ymd_to_jd = ymd_to_jd,
    }
    }

    Revision as of 03:58, 7 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)
    	-- 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,
    }