Module:Date: Difference between revisions

    From Nonbinary Wiki
    (formatted output and strftime)
    m (3 revisions imported from wikipedia:Module:Date: see Topic:Vtixlm0q28eo6jtf)
     
    (24 intermediate revisions by 4 users not shown)
    Line 1: Line 1:
    -- Date functions for implementing templates and for use by other modules.
    -- Date functions for use by other modules.
    -- I18N and time zones are not supported.
    -- I18N and time zones are not supported.


    local MINUS = '−'  -- Unicode U+2212 MINUS SIGN
    local MINUS = '−'  -- Unicode U+2212 MINUS SIGN
    local floor = math.floor
    local Date, DateDiff, diffmt  -- forward declarations
    local uniq = { 'unique identifier' }
    local function is_date(t)
    -- The system used to make a date read-only means there is no unique
    -- metatable that is conveniently accessible to check.
    return type(t) == 'table' and t._id == uniq
    end
    local function is_diff(t)
    return type(t) == 'table' and getmetatable(t) == diffmt
    end
    local function _list_join(list, sep)
    return table.concat(list, sep)
    end


    local function collection()
    local function collection()
    Line 12: Line 30:
    self[self.n] = item
    self[self.n] = item
    end,
    end,
    join = function (self, sep)
    join = _list_join,
    return table.concat(self, sep)
    end,
    }
    }
    end
    end


    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 empty.
    -- 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 may pass a string, a number, or another type).
    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
    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
    end


    Line 53: Line 54:
    local function days_in_month(year, month, calname)
    local function days_in_month(year, month, calname)
    -- Return number of days (1..31) in given month (1..12).
    -- 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
    if month == 2 and is_leap_year(year, calname) then
    return 29
    return 29
    end
    end
    return month_days[month]
    return ({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 })[month]
    end
     
    local function h_m_s(time)
    -- Return hour, minute, second extracted from fraction of a day.
    time = floor(time * 24 * 3600 + 0.5)  -- number of seconds
    local second = time % 60
    time = floor(time / 60)
    return floor(time / 60), time % 60, second
    end
     
    local function hms(date)
    -- Return fraction of a day from date's time, where (0 <= fraction < 1)
    -- if the values are valid, but could be anything if outside range.
    return (date.hour + (date.minute + date.second / 60) / 60) / 24
    end
    end


    Line 63: Line 77:
    -- 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 83:
    --    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 offset
    local offset
    local a = floor((14 - date.month)/12)
    local a = floor((14 - date.month)/12)
    local y = date.year + 4800 - a
    local y = date.year + 4800 - a
    if date.calname == 'Julian' then
    if date.calendar == 'Julian' then
    offset = floor(y/4) - 32083
    offset = floor(y/4) - 32083
    else
    else
    Line 82: Line 92:
    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 + hms(date) - 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.
    -- Negative Julian dates are not defined but they work.
    -- Negative Julian dates are not defined but they work.
    local floor = math.floor
    local calname = date.calendar
    local calname = date.calname
    local low, high -- min/max limits for date ranges −9999-01-01 to 9999-12-31
    local jd = date.jd
    if calname == 'Gregorian' then
    local limits -- min/max limits for date ranges −9999-01-01 to 9999-12-31
    low, high = -1930999.5, 5373484.49999
    if calname == 'Julian' then
    elseif calname == 'Julian' then
    limits = { -1931076.5, 5373557.49999 }
    low, high = -1931076.5, 5373557.49999
    elseif calname == 'Gregorian' then
    limits = { -1930999.5, 5373484.49999 }
    else
    else
    limits = { 1, 0 }  -- impossible
    return
    end
    end
    if not (limits[1] <= jd and jd <= limits[2]) then
    local jd = date.jd
    date.isvalid = false
    if not (type(jd) == 'number' and low <= jd and jd <= high) then
    return
    return
    end
    end
    date.isvalid = true
    local jdn = floor(jd)
    local jdn = floor(jd)
    if date.hastime then
    if date.hastime then
    local time = jd - jdn
    local time = jd - jdn -- 0 <= time < 1
    local hour
    if time >= 0.5 then   -- if at or after midnight of next day
    if time >= 0.5 then
    jdn = jdn + 1
    jdn = jdn + 1
    time = time - 0.5
    time = time - 0.5
    hour = 0
    else
    else
    hour = 12
    time = time + 0.5
    end
    end
    time = floor(time * 24 * 3600 + 0.5)  -- number of seconds after hour
    date.hour, date.minute, date.second = h_m_s(time)
    date.second = time % 60
    time = floor(time / 60)
    date.minute = time % 60
    date.hour = hour + floor(time / 60)
    else
    else
    date.second = 0
    date.second = 0
    Line 152: Line 149:
    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


    local function make_option_table(options)
    local function fix_numbers(numbers, y, m, d, H, M, S, partial, hastime, calendar)
    -- If options is a string, return a table with its settings.
    -- Put the result of normalizing the given values in table numbers.
    -- Otherwise return options (it should already be a table).
    -- The result will have valid m, d values if y is valid; caller checks y.
    if type(options) == 'string' then
    -- The logic of PHP mktime is followed where m or d can be zero to mean
    -- Example: 'am:AM era:BC'
    -- the previous unit, and -1 is the one before that, etc.
    local result = {}
    -- Positive values carry forward.
    for item in options:gmatch('%S+') do
    local date
    local lhs, rhs = item:match('^(%w+):(.*)$')
    if not (1 <= m and m <= 12) then
    date = Date(y, 1, 1)
    if not date then return end
    date = date + ((m - 1) .. 'm')
    y, m = date.year, date.month
    end
    local days_hms
    if not partial then
    if hastime and H and M and S then
    if not (0 <= H and H <= 23 and
    0 <= M and M <= 59 and
    0 <= S and S <= 59) then
    days_hms = hms({ hour = H, minute = M, second = S })
    end
    end
    if days_hms or not (1 <= d and d <= days_in_month(y, m, calendar)) then
    date = date or Date(y, m, 1)
    if not date then return end
    date = date + (d - 1 + (days_hms or 0))
    y, m, d = date.year, date.month, date.day
    if days_hms then
    H, M, S = date.hour, date.minute, date.second
    end
    end
    end
    numbers.year = y
    numbers.month = m
    numbers.day = d
    if days_hms then
    -- Don't set H unless it was valid because a valid H will set hastime.
    numbers.hour = H
    numbers.minute = M
    numbers.second = S
    end
    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.year  or date.year
    local m = numbers.month  or date.month
    local d = numbers.day    or date.day
    local H = numbers.hour
    local M = numbers.minute or date.minute or 0
    local S = numbers.second or date.second or 0
    local need_fix
    if y and m and d then
    date.partial = nil
    if not (-9999 <= y and y <= 9999 and
    1 <= m and m <= 12 and
    1 <= d and d <= days_in_month(y, m, date.calendar)) then
    if not date.want_fix then
    return
    end
    need_fix = true
    end
    elseif y and date.partial then
    if d or not (-9999 <= y and y <= 9999) then
    return
    end
    if m and not (1 <= m and m <= 12) then
    if not date.want_fix then
    return
    end
    need_fix = true
    end
    else
    return
    end
    if date.partial then
    H = nil  -- ignore any time
    M = nil
    S = nil
    else
    if H then
    -- It is not possible to set M or S without also setting H.
    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
    if date.want_fix then
    need_fix = true
    else
    return
    end
    end
    end
    date.want_fix = nil
    if need_fix then
    fix_numbers(numbers, y, m, d, H, M, S, date.partial, date.hastime, date.calendar)
    return set_date_from_numbers(date, numbers, options)
    end
    date.year = y    -- -9999 to 9999 ('n BC' → year = 1 - n)
    date.month = m  -- 1 to 12 (may be nil if partial)
    date.day = d    -- 1 to 31 (* = nil if partial)
    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', 'format' }) 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 table options2 or defaults.
    -- If a default is used, a flag is set so caller knows the value was not intentionally set.
    -- Valid option settings are:
    -- am: 'am', 'a.m.', 'AM', 'A.M.'
    --    'pm', 'p.m.', 'PM', 'P.M.' (each has same meaning as corresponding item above)
    -- 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,
    --    and am = 'pm' has the same meaning.
    -- 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 = { bydefault = {} }
    if type(options1) == 'table' then
    result.am = options1.am
    result.era = options1.era
    elseif type(options1) == 'string' then
    -- Example: 'am:AM era:BC' or 'am=AM era=BC'.
    for item in options1:gmatch('%S+') do
    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
    if not result[k] then
    if options2[k] then
    result[k] = options2[k]
    else
    result[k] = v
    result.bydefault[k] = true
    end
    end
    end
    return result
    end
     
    local ampm_options = {
    -- lhs = input text accepted as an am/pm option
    -- rhs = code used internally
    ['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 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 era_text['BC'])[year > 0 and 2 or 1] or ''
    end
    end


    Line 174: Line 351:
    -- 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)'
    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
    end
    local codes = {
    local codes = {
    Line 184: Line 367:
    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 = 'dayofyear' },
    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, 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(spaces, 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]
    if not value then
    return nil  -- an undefined field in a partial date
    end
    local special = code.special
    local special = code.special
    if special then
    if special then
    Line 212: Line 396:
    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 219: Line 401:
    ['AM'] = { 'AM', 'PM' },
    ['AM'] = { 'AM', 'PM' },
    ['A.M.'] = { 'A.M.', 'P.M.' },
    ['A.M.'] = { 'A.M.', 'P.M.' },
    })[amopt] or { 'am', 'pm' }
    })[ampm_options[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 if id is an undefined field in a partial date, or is the name of a function.
    return nil
    return nil
    end
    end
    Line 262: Line 449:
    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 a formatted string representing the given date.
    if not (type(date) == 'table' and date.isvalid) then
    if not is_date(date) then
    return '(invalid)'
    error('date:text: need a date (use "date:text()" with a colon)', 2)
    end
    end
    if type(fmt) ~= 'string' then
    if type(fmt) == 'string' and fmt:match('%S') then
    fmt = '%Y-%m-%d'
    if fmt:find('%', 1, true) then
    return strftime(date, fmt, options)
    end
    elseif date.partial then
    fmt = date.month and 'my' or 'y'
    else
    fmt = 'dmy'
    if date.hastime then
    if date.hastime then
    if date.second > 0 then
    fmt = (date.second > 0 and 'hms ' or 'hm ') .. fmt
    fmt = fmt .. ' %H:%M:%S'
    else
    fmt = fmt .. ' %H:%M'
    end
    end
    end
    return strftime(date, fmt, options or { era = 'BCMINUS' })
    end
    end
    if fmt:find('%', 1, true) then
    local function bad_format()
    -- For consistency with other format processing, return given format
    -- (or cleaned format if original was not a string) if invalid.
    return mw.text.nowiki(fmt)
    end
    if date.partial then
    -- Ignore days in standard formats like 'ymd'.
    if fmt == 'ym' or fmt == 'ymd' then
    fmt = date.month and '%Y-%m %{era}' or '%Y %{era}'
    elseif fmt == 'my' or fmt == 'dmy' or fmt == 'mdy' then
    fmt = date.month and '%B %-Y %{era}' or '%-Y %{era}'
    elseif fmt == 'y' then
    fmt = date.month and '%-Y %{era}' or '%-Y %{era}'
    else
    return bad_format()
    end
    return strftime(date, fmt, options)
    return strftime(date, fmt, options)
    end
    end
    local function hm_fmt()
    local plain = make_option_table(options, date.options).bydefault.am
    return plain and '%H:%M' or '%-I:%M %p'
    end
    local need_time = date.hastime
    local t = collection()
    local t = collection()
    for item in fmt:gmatch('%S+') do
    for item in fmt:gmatch('%S+') do
    local f
    local f
    if item == 'hm' then
    if item == 'hm' then
    f = '%H:%M'
    f = hm_fmt()
    need_time = false
    elseif item == 'hms' then
    elseif item == 'hms' then
    f = '%H:%M:%S'
    f = '%H:%M:%S'
    need_time = false
    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 bad_format()
    end
    end
    t:add(f)
    t:add(f)
    end
    end
    return strftime(date, t:join(' '), options)
    fmt = t:join(' ')
    if need_time then
    fmt = hm_fmt() .. ' ' .. fmt
    end
    return strftime(date, fmt, options)
    end
    end


    Line 334: Line 548:
    { 'Dec', 'December' },
    { 'Dec', 'December' },
    }
    }
    local function name_to_number(text, translate)
    if type(text) == 'string' then
    return translate[text:lower()]
    end
    end
    local function day_number(text)
    return name_to_number(text, {
    sun = 0, sunday = 0,
    mon = 1, monday = 1,
    tue = 2, tuesday = 2,
    wed = 3, wednesday = 3,
    thu = 4, thursday = 4,
    fri = 5, friday = 5,
    sat = 6, saturday = 6,
    })
    end


    local function month_number(text)
    local function month_number(text)
    if type(text) == 'string' then
    return name_to_number(text, {
    local month_names = {
    jan = 1, january = 1,
    jan = 1, january = 1,
    feb = 2, february = 2,
    feb = 2, february = 2,
    mar = 3, march = 3,
    mar = 3, march = 3,
    apr = 4, april = 4,
    apr = 4, april = 4,
    may = 5,
    may = 5,
    jun = 6, june = 6,
    jun = 6, june = 6,
    jul = 7, july = 7,
    jul = 7, july = 7,
    aug = 8, august = 8,
    aug = 8, august = 8,
    sep = 9, september = 9, sept = 9,
    sep = 9, september = 9,
    oct = 10, october = 10,
    oct = 10, october = 10,
    nov = 11, november = 11,
    nov = 11, november = 11,
    dec = 12, december = 12,
    dec = 12, december = 12
    })
    }
    end
    return month_names[text:lower()]
     
    local function _list_text(list, fmt)
    -- Return a list of formatted strings from a list of dates.
    if not type(list) == 'table' then
    error('date:list:text: need "list:text()" with a colon', 2)
    end
    local result = { join = _list_join }
    for i, date in ipairs(list) do
    result[i] = date:text(fmt)
    end
    return result
    end
     
    local function _date_list(date, spec)
    -- Return a possibly empty numbered table of dates meeting the specification.
    -- Dates in the list are in ascending order (oldest date first).
    -- The spec should be a string of form "<count> <day> <op>"
    -- where each item is optional and
    --  count = number of items wanted in list
    --  day = abbreviation or name such as Mon or Monday
    --  op = >, >=, <, <= (default is > meaning after date)
    -- If no count is given, the list is for the specified days in date's month.
    -- The default day is date's day.
    -- The spec can also be a positive or negative number:
    --  -5 is equivalent to '5 <'
    --  5  is equivalent to '5' which is '5 >'
    if not is_date(date) then
    error('date:list: need a date (use "date:list()" with a colon)', 2)
    end
    local list = { text = _list_text }
    if date.partial then
    return list
    end
    local count, offset, operation
    local ops = {
    ['>='] = { before = false, include = true  },
    ['>']  = { before = false, include = false },
    ['<='] = { before = true , include = true  },
    ['<']  = { before = true , include = false },
    }
    if spec then
    if type(spec) == 'number' then
    count = floor(spec + 0.5)
    if count < 0 then
    count = -count
    operation = ops['<']
    end
    elseif type(spec) == 'string' then
    local num, day, op = spec:match('^%s*(%d*)%s*(%a*)%s*([<>=]*)%s*$')
    if not num then
    return list
    end
    if num ~= '' then
    count = tonumber(num)
    end
    if day ~= '' then
    local dow = day_number(day:gsub('[sS]$', '')) -- accept plural days
    if not dow then
    return list
    end
    offset = dow - date.dow
    end
    operation = ops[op]
    else
    return list
    end
    end
    offset = offset or 0
    operation = operation or ops['>']
    local datefrom, dayfirst, daylast
    if operation.before then
    if offset > 0 or (offset == 0 and not operation.include) then
    offset = offset - 7
    end
    if count then
    if count > 1 then
    offset = offset - 7*(count - 1)
    end
    datefrom = date + offset
    else
    daylast = date.day + offset
    dayfirst = daylast % 7
    if dayfirst == 0 then
    dayfirst = 7
    end
    end
    else
    if offset < 0 or (offset == 0 and not operation.include) then
    offset = offset + 7
    end
    if count then
    datefrom = date + offset
    else
    dayfirst = date.day + offset
    daylast = date.monthdays
    end
    end
    if not count then
    if daylast < dayfirst then
    return list
    end
    count = floor((daylast - dayfirst)/7) + 1
    datefrom = Date(date, {day = dayfirst})
    end
    for i = 1, count do
    if not datefrom then break end  -- exceeds date limits
    list[i] = datefrom
    datefrom = datefrom + 7
    end
    end
    return list
    end
    end


    -- A table to get the current year/month/day (UTC), but only if needed.
    -- A table to get the current date/time (UTC), but only if needed.
    local current = setmetatable({}, {
    local current = setmetatable({}, {
    __index = function (self, key)
    __index = function (self, key)
    local d = os.date('!*t')
    local d = os.date('!*t')
    self.year = d.year
    self.year = d.year
    self.month = d.month
    self.month = d.month
    self.day = d.day
    self.day = d.day
    self.hour = d.hour
    self.hour = d.hour
    self.minute = d.min
    self.minute = d.min
    self.second = d.sec
    self.second = d.sec
    return rawget(self, key)
    return rawget(self, key)
    end })
     
    local function extract_date(newdate, 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 or format, 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 potentially ambiguous.
    local date, options = {}, {}
    if text:sub(-1) == 'Z' then
    -- Extract date/time from a Wikidata timestamp.
    -- The year can be 1 to 16 digits but this module handles 1 to 4 digits only.
    -- Examples: '+2016-06-21T14:30:00Z', '-0000000180-00-00T00:00:00Z'.
    local sign, y, m, d, H, M, S = text:match('^([+%-])(%d+)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)Z$')
    if sign then
    y = tonumber(y)
    if sign == '-' and y > 0 then
    y = -y
    end
    if y <= 0 then
    options.era = 'BCE'
    end
    date.year = y
    m = tonumber(m)
    d = tonumber(d)
    H = tonumber(H)
    M = tonumber(M)
    S = tonumber(S)
    if m == 0 then
    newdate.partial = true
    return date, options
    end
    date.month = m
    if d == 0 then
    newdate.partial = true
    return date, options
    end
    date.day = d
    if H > 0 or M > 0 or S > 0 then
    date.hour = H
    date.minute = M
    date.second = S
    end
    return date, options
    end
    return
    end
    end
    })
    local function extract_ymd(item)
    -- Called when no day or month has been set.
    local y, m, d = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
    if y then
    if date.year then
    return
    end
    if m:match('^%d%d?$') then
    m = tonumber(m)
    else
    m = month_number(m)
    end
    if m then
    date.year = tonumber(y)
    date.month = m
    date.day = tonumber(d)
    return true
    end
    end
    end
    local function extract_day_or_year(item)
    -- Called when a day would be valid, or
    -- when a year would be valid if no year has been set and partial is set.
    local number, suffix = item:match('^(%d%d?%d?%d?)(.*)$')
    if number then
    local n = tonumber(number)
    if #number <= 2 and n <= 31 then
    suffix = suffix:lower()
    if suffix == '' or suffix == 'st' or suffix == 'nd' or suffix == 'rd' or suffix == 'th' then
    date.day = n
    return true
    end
    elseif suffix == '' and newdate.partial and not date.year then
    date.year = n
    return true
    end
    end
    end
    local function extract_month(item)
    -- A month must be given as a name or abbreviation; a number could be ambiguous.
    local m = month_number(item)
    if m then
    date.month = m
    return true
    end
    end
    local function extract_time(item)
    local h, m, s = item:match('^(%d%d?):(%d%d)(:?%d*)$')
    if date.hour or not h then
    return
    end
    if s ~= '' then
    s = s:match('^:(%d%d)$')
    if not s then
    return
    end
    end
    date.hour = tonumber(h)
    date.minute = tonumber(m)
    date.second = tonumber(s)  -- nil if empty string
    return true
    end
    local item_count = 0
    local index_time
    local function set_ampm(item)
    local H = date.hour
    if H and not options.am and index_time + 1 == item_count then
    options.am = ampm_options[item]  -- caller checked this is not nil
    if item:match('^[Aa]') then
    if not (1 <= H and H <= 12) then
    return
    end
    if H == 12 then
    date.hour = 0
    end
    else
    if not (1 <= H and H <= 23) then
    return
    end
    if H <= 11 then
    date.hour = H + 12
    end
    end
    return true
    end
    end
    for item in text:gsub(',', ' '):gsub('&nbsp;', ' '):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.day and date.month then
    if date.year then
    return  -- should be nothing more so item is invalid
    end
    if not item:match('^(%d%d?%d?%d?)$') then
    return
    end
    date.year = tonumber(item)
    elseif date.day then
    if not extract_month(item) then
    return
    end
    elseif date.month then
    if not extract_day_or_year(item) then
    return
    end
    elseif extract_month(item) then
    options.format = 'mdy'
    elseif extract_ymd(item) then
    options.format = 'ymd'
    elseif extract_day_or_year(item) then
    if date.day then
    options.format = 'dmy'
    end
    else
    return
    end
    end
    if not date.year or date.year == 0 then
    return
    end
    local era = era_text[options.era]
    if era and era.isbc then
    date.year = 1 - date.year
    end
    return date, options
    end
     
    local function autofill(date1, date2)
    -- Fill any missing month or day in each date using the
    -- corresponding component from the other date, if present,
    -- or with 1 if both dates are missing the month or day.
    -- This gives a good result for calculating the difference
    -- between two partial dates when no range is wanted.
    -- Return filled date1, date2 (two full dates).
    local function filled(a, b)
    -- Return date a filled, if necessary, with month and/or day from date b.
    -- The filled day is truncated to fit the number of days in the month.
    local fillmonth, fillday
    if not a.month then
    fillmonth = b.month or 1
    end
    if not a.day then
    fillday = b.day or 1
    end
    if fillmonth or fillday then  -- need to create a new date
    a = Date(a, {
    month = fillmonth,
    day = math.min(fillday or a.day, days_in_month(a.year, fillmonth or a.month, a.calendar))
    })
    end
    return a
    end
    return filled(date1, date2), filled(date2, date1)
    end


    local function date_component(named, positional, component)
    local function date_add_sub(lhs, rhs, is_sub)
    -- Return the first of the two arguments (named like {{example|year=2001}}
    -- Return a new date from calculating (lhs + rhs) or (lhs - rhs),
    -- or positional like {{example|2001}}) that is not nil and is not empty.
    -- or return nothing if invalid.
    -- If both are nil, return the current date component, if specified.
    -- The result is nil if the calculated date exceeds allowable limits.
    -- This translates empty arguments passed to the template to nil, and
    -- Caller ensures that lhs is a date; its properties are copied for the new date.
    -- optionally replaces a nil argument with a value from the current date.
    if lhs.partial then
    named = strip_to_nil(named)
    -- Adding to a partial is not supported.
    if named then
    -- Can subtract a date or partial from a partial, but this is not called for that.
    return named
    return
    end
    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)
    local forcetime, jd
    if floor(n) == n then
    jd = lhs.jd
    else
    forcetime = not lhs.hastime
    jd = lhs.jdz
    end
    jd = jd + (is_sub and -n or n)
    if forcetime then
    jd = tostring(jd)
    if not jd:find('.', 1, true) then
    jd = jd .. '.0'
    end
    end
    return Date(lhs, 'juliandate', jd)
    end
    end
    positional = strip_to_nil(positional)
    if type(rhs) == 'number' then
    if positional then
    -- Add/subtract days, including fractional days.
    return positional
    return do_days(rhs)
    end
    if type(rhs) == 'string' then
    -- rhs is a single component like '26m' or '26 months' (with optional sign).
    -- Fractions like '3.25d' are accepted for the units which are handled as days.
    local sign, numstr, id = rhs:match('^%s*([+-]?)([%d%.]+)%s*(%a+)$')
    if sign then
    if sign == '-' then
    is_sub = not (is_sub and true or false)
    end
    local y, m, days
    local num = tonumber(numstr)
    if not num then
    return
    end
    id = id:lower()
    if is_prefix(id, 'years') then
    y = num
    m = 0
    elseif is_prefix(id, 'months') then
    y = floor(num / 12)
    m = num % 12
    elseif is_prefix(id, 'weeks') then
    days = num * 7
    elseif is_prefix(id, 'days') then
    days = num
    elseif is_prefix(id, 'hours') then
    days = num / 24
    elseif is_prefix(id, 'minutes', 3) then
    days = num / (24 * 60)
    elseif is_prefix(id, 'seconds') then
    days = num / (24 * 3600)
    else
    return
    end
    if days then
    return do_days(days)
    end
    if numstr:find('.', 1, true) then
    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.calendar))
    return Date(lhs, y, m, d)
    end
    end
    end
    if component then
    if is_diff(rhs) then
    return current[component]
    local days = rhs.age_days
    if (is_sub or false) ~= (rhs.isnegative or false) then
    days = -days
    end
    return lhs + days
    end
    end
    return nil
    end
    end


    local era_text = {
    local full_date_only = {
    -- options.era = { year<0  , year>0 }
    dayabbr = true,
    ['BCMINUS']    = { MINUS  , ''    },
    dayname = true,
    ['BCNEGATIVE'] = { '-'    , ''    },
    dow = true,
    ['BC']        = { 'BC'    , ''    },
    dayofweek = true,
    ['B.C.']      = { 'B.C.'  , ''    },
    dowiso = true,
    ['BCE']        = { 'BCE'  , ''    },
    dayofweekiso = true,
    ['B.C.E.']    = { 'B.C.E.', ''    },
    dayofyear = true,
    ['AD']        = { 'BC'    , 'AD'  },
    gsd = true,
    ['A.D.']      = { 'B.C.'  , 'A.D.' },
    juliandate = true,
    ['CE']        = { 'BCE'  , 'CE'  },
    jd = true,
    ['C.E.']      = { 'B.C.E.', 'C.E.' },
    jdz = true,
    jdnoon = true,
    }
    }


    -- Metatable for some operations on dates.
    -- Metatable for a date's calculated fields.
    -- For Lua 5.1, __lt does not work if the metatable is an anonymous table.
    local Date  -- forward declaration
    local datemt = {
    local datemt = {
    __eq = function (lhs, rhs)
    __index = function (self, key)
    -- Return true if dates identify same date/time where, for example,
    if rawget(self, 'partial') then
    -- (-4712, 1, 1, 'Julian') == (-4713, 11, 24, 'Gregorian').
    if full_date_only[key] then return end
    return lhs.isvalid and rhs.isvalid and lhs.jdz == rhs.jdz
    if key == 'monthabbr' or key == 'monthdays' or key == 'monthname' then
    end,
    if not self.month then return end
    __lt = function (lhs, rhs)
    end
    -- Return true if lhs < rhs.
    if not lhs.isvalid then
    return true
    end
    end
    if not rhs.isvalid then
    return false
    end
    return lhs.jdz < rhs.jdz
    end,
    __index = function (self, key)
    local value
    local value
    if key == 'dayabbr' then
    if key == 'dayabbr' then
    Line 429: Line 1,058:
    value = day_info[self.dow][2]
    value = day_info[self.dow][2]
    elseif key == 'dow' then
    elseif key == 'dow' then
    value = (self.jd + 1) % 7  -- day-of-week 0=Sun to 6=Sat
    value = (self.jdnoon + 1) % 7  -- day-of-week 0=Sun to 6=Sat
    elseif key == 'dayofweek' then
    value = self.dow
    elseif key == 'dowiso' then
    elseif key == 'dowiso' then
    value = (self.jd % 7) + 1  -- ISO day-of-week 1=Mon to 7=Sun
    value = (self.jdnoon % 7) + 1  -- ISO day-of-week 1=Mon to 7=Sun
    elseif key == 'doy' then
    elseif key == 'dayofweekiso' then
    local first = Date(self.year, 1, 1, self.calname).jd
    value = self.dowiso
    value = self.jd - first + 1  -- day-of-year 1 to 366
    elseif key == 'dayofyear' then
    local first = Date(self.year, 1, 1, self.calendar).jdnoon
    value = self.jdnoon - first + 1  -- day-of-year 1 to 366
    elseif key == 'era' then
    elseif key == 'era' then
    -- Era text from year and options.
    -- Era text (never a negative sign) from year and options.
    local eraopt = self.options.era
    value = get_era_for_year(self.options.era, self.year)
    local sign
    elseif key == 'format' then
    if self.year == 0 then
    value = self.options.format or 'dmy'
    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,
    -- which is JDN = 1721426, and is from jd 1721425.5 to 1721426.49999.
    -- which is from jd 1721425.5 to 1721426.49999.
    value = math.floor(self.jd - 1721424.5)
    value = floor(self.jd - 1721424.5)
    elseif key == 'jd' or key == 'jdz' then
    elseif key == 'juliandate' or key == 'jd' or key == 'jdz' then
    local jd, jdz = julian_date(self)
    local jd, jdz = julian_date(self)
    rawset(self, 'juliandate', jd)
    rawset(self, 'jd', jd)
    rawset(self, 'jd', jd)
    rawset(self, 'jdz', jdz)
    rawset(self, 'jdz', jdz)
    return key == 'jd' and jd or jdz
    return key == 'jdz' and jdz or jd
    elseif key == 'is_leap_year' then
    elseif key == 'jdnoon' then
    value = is_leap_year(self.year, self.calname)
    -- Julian date at noon (an integer) on the calendar day when jd occurs.
    value = floor(self.jd + 0.5)
    elseif key == 'isleapyear' then
    value = is_leap_year(self.year, self.calendar)
    elseif key == 'monthabbr' then
    elseif key == 'monthabbr' then
    value = month_info[self.month][1]
    value = month_info[self.month][1]
    elseif key == 'monthdays' then
    value = days_in_month(self.year, self.month, self.calendar)
    elseif key == 'monthname' then
    elseif key == 'monthname' then
    value = month_info[self.month][2]
    value = month_info[self.month][2]
    Line 467: Line 1,101:
    end,
    end,
    }
    }
    -- Date operators.
    local function mt_date_add(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
    local function mt_date_sub(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
    local function mt_date_concat(lhs, rhs)
    return tostring(lhs) .. tostring(rhs)
    end
    local function mt_date_tostring(self)
    return self:text()
    end
    local function mt_date_eq(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 called only if lhs and rhs have the same type and the same metamethod.
    if lhs.partial or rhs.partial then
    -- One date is partial; the other is a partial or a full date.
    -- The months may both be nil, but must be the same.
    return lhs.year == rhs.year and lhs.month == rhs.month and lhs.calendar == rhs.calendar
    end
    return lhs.jdz == rhs.jdz
    end
    local function mt_date_lt(lhs, rhs)
    -- Return true if lhs < rhs, for example,
    -- Date('1 Jan 2016') < Date('06:00 1 Jan 2016') is true.
    -- This is called only if lhs and rhs have the same type and the same metamethod.
    if lhs.partial or rhs.partial then
    -- One date is partial; the other is a partial or a full date.
    if lhs.calendar ~= rhs.calendar then
    return lhs.calendar == 'Julian'
    end
    if lhs.partial then
    lhs = lhs.partial.first
    end
    if rhs.partial then
    rhs = rhs.partial.first
    end
    end
    return lhs.jdz < rhs.jdz
    end


    --[[ 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
    Date('currentdate')
    Date('currentdate')
    Date('currentdatetime')
    Date('currentdatetime')
    LATER: Following are not yet implemented:
    Date('1 April 1995', 'julian')     parse date from text
    Date('currentdate', H, M, S)     current date with given time
    Date('1 April 1995 AD', 'julian')  using an era sets a flag to do the same for output
    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')
    Date('04:30:59 1 April 1995', 'julian')
    Date(date)                          copy of an existing date
    Date(date, t)                      same, updated with y,m,d,H,M,S fields from table t
    Date(t)                      date with y,m,d,H,M,S fields from table t
    ]]
    ]]
    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.
    -- A partial date has a valid year, however its month may be nil, and
    -- its day and time fields are nil.
    -- Field partial is set to false (if a full date) or a table (if a partial date).
    local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
    local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
    local result = {
    local newdate = {
    isvalid = false, -- false avoids __index lookup
    _id = uniq,
    calname = 'Gregorian',  -- default is Gregorian calendar
    calendar = 'Gregorian',  -- default is Gregorian calendar
    hastime = false,  -- true if input sets a time
    hastime = false,  -- true if input sets a time
    hour = 0,  -- always set hour/minute/second so don't have to handle nil
    hour = 0,  -- always set hour/minute/second so don't have to handle nil
    minute = 0,
    minute = 0,
    second = 0,
    second = 0,
    month_days = function (self, month)
    options = {},
    return days_in_month(self.year, month, self.calname)
    list = _date_list,
    subtract = function (self, rhs, options)
    return DateDiff(self, rhs, options)
    end,
    end,
    -- Valid option settings are:
    text = _date_text,
    -- 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 numbers = collection()
    local argtype, datetext, is_copy, jd_number, tnums
    local datetext
    local numindex = 0
    local argtype
    local numfields = { 'year', 'month', 'day', 'hour', 'minute', 'second' }
    local numbers = {}
    for _, v in ipairs({...}) do
    for _, v in ipairs({...}) do
    v = strip_to_nil(v)
    v = strip_to_nil(v)
    Line 509: Line 1,203:
    -- Ignore empty arguments after stripping so modules can directly pass template parameters.
    -- Ignore empty arguments after stripping so modules can directly pass template parameters.
    elseif calendars[vlower] then
    elseif calendars[vlower] then
    result.calname = calendars[vlower]
    newdate.calendar = calendars[vlower]
    elseif vlower == 'partial' then
    newdate.partial = true
    elseif vlower == 'fix' then
    newdate.want_fix = true
    elseif is_date(v) then
    -- Copy existing date (items can be overridden by other arguments).
    if is_copy or tnums then
    return
    end
    is_copy = true
    newdate.calendar = v.calendar
    newdate.partial = v.partial
    newdate.hastime = v.hastime
    newdate.options = v.options
    newdate.year = v.year
    newdate.month = v.month
    newdate.day = v.day
    newdate.hour = v.hour
    newdate.minute = v.minute
    newdate.second = v.second
    elseif type(v) == 'table' then
    if tnums then
    return
    end
    tnums = {}
    local tfields = { year=1, month=1, day=1, hour=2, minute=2, second=2 }
    for tk, tv in pairs(v) do
    if tfields[tk] then
    tnums[tk] = tonumber(tv)
    end
    if tfields[tk] == 2 then
    newdate.hastime = true
    end
    end
    else
    else
    local num = tonumber(v)
    local num = tonumber(v)
    if not num and argtype == 'setdate' and numbers.n == 1 then
    if not num and argtype == 'setdate' and numindex == 1 then
    num = month_number(v)
    num = month_number(v)
    end
    end
    Line 519: Line 1,247:
    argtype = 'setdate'
    argtype = 'setdate'
    end
    end
    numbers:add(num)
    if argtype == 'setdate' and numindex < 6 then
    if argtype == 'juliandate' then
    numindex = numindex + 1
    numbers[numfields[numindex]] = num
    elseif argtype == 'juliandate' and not jd_number then
    jd_number = num
    if type(v) == 'string' then
    if type(v) == 'string' then
    if v:find('.', 1, true) then
    if v:find('.', 1, true) then
    result.hastime = true
    newdate.hastime = true
    end
    end
    elseif num ~= math.floor(num) then
    elseif num ~= floor(num) then
    -- The given value was a number. The time will be used
    -- The given value was a number. The time will be used
    -- if the fractional part is nonzero.
    -- if the fractional part is nonzero.
    result.hastime = true
    newdate.hastime = true
    end
    end
    else
    return
    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 541: Line 1,274:
    end
    end
    else
    else
    return {}
    return
    end
    end
    end
    end
    end
    end
    if argtype == 'datetext' then
    if argtype == 'datetext' then
    if numbers.n > 0 then
    if tnums or not set_date_from_numbers(newdate, extract_date(newdate, 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
    newdate.partial = nil
    result.jd = numbers[1]
    newdate.jd = jd_number
    set_date_from_jd(result)
    if not set_date_from_jd(newdate) then
    else
    return
    return {}
    end
    end
    elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
    elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
    result.year = current.year
    newdate.partial = nil
    result.month = current.month
    newdate.year = current.year
    result.day = current.day
    newdate.month = current.month
    newdate.day = current.day
    if argtype == 'currentdatetime' then
    if argtype == 'currentdatetime' then
    result.hour = current.hour
    newdate.hour = current.hour
    result.minute = current.minute
    newdate.minute = current.minute
    result.second = current.second
    newdate.second = current.second
    result.hastime = true
    newdate.hastime = true
    end
    end
    result.calname = 'Gregorian'  -- ignore any given calendar name
    newdate.calendar = 'Gregorian'  -- ignore any given calendar name
    result.isvalid = true
    elseif argtype == 'setdate' then
    elseif argtype == 'setdate' then
    if not (3 <= numbers.n and numbers.n <= 6) then
    if tnums or not set_date_from_numbers(newdate, numbers) then
    return {}
    return
    end
    end
    local y, m, d = numbers[1], numbers[2], numbers[3]
    elseif not (is_copy or tnums) then
    if not (-9999 <= y and y <= 9999 and 1 <= m and m <= 12 and
    return
    1 <= d and d <= days_in_month(y, m, result.calname)) then
    end
    return {}
    if tnums then
    newdate.jd = nil  -- force recalculation in case jd was set before changes from tnums
    if not set_date_from_numbers(newdate, tnums) then
    return
    end
    end
    local H = numbers[4]
    end
    if H then
    if newdate.partial then
    result.hastime = true
    local year = newdate.year
    else
    local month = newdate.month
    H = 0
    local first = Date(year, month or 1, 1, newdate.calendar)
    end
    month = month or 12
    local M = numbers[5] or 0
    local last = Date(year, month, days_in_month(year, month), newdate.calendar)
    local S = numbers[6] or 0
    newdate.partial = { first = first, last = last }
    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 {}
    newdate.partial = false  -- avoid index lookup
    end
    end
    return setmetatable(result, datemt)
    setmetatable(newdate, datemt)
    local readonly = {}
    local mt = {
    __index = newdate,
    __newindex = function(t, k, v) error('date.' .. tostring(k) .. ' is read-only', 2) end,
    __add = mt_date_add,
    __sub = mt_date_sub,
    __concat = mt_date_concat,
    __tostring = mt_date_tostring,
    __eq = mt_date_eq,
    __lt = mt_date_lt,
    }
    return setmetatable(readonly, mt)
    end
    end


    local function DateDiff(date1, date2)
    local function _diff_age(diff, code, options)
    -- Return a table to with the difference between the two given dates.
    -- Return a tuple of integer values from diff as specified by code, except that
    -- Difference is negative if the second date is older than the first.
    -- each integer may be a list of two integers for a diff with a partial date, or
    -- TODO Replace with something using Julian dates?
    -- return nil if the code is not supported.
    --     Who checks for isvalid()?
    -- If want round, the least significant unit is rounded to nearest whole unit.
    --      Handle calname == 'Julian'
    -- For a duration, an extra day is added.
    local calname = 'Gregorian'  -- TODO fix
    local wantround, wantduration, wantrange
    local isnegative
    if type(options) == 'table' then
    if date2 < date1 then
    wantround = options.round
    isnegative = true
    wantduration = options.duration
    date1, date2 = date2, date1
    wantrange = options.range
    else
    wantround = options
    end
    if not is_diff(diff) then
    local f = wantduration and 'duration' or 'age'
    error(f .. ': need a date difference (use "diff:' .. f .. '()" with a colon)', 2)
    end
    end
    -- It is known that date1 <= date2.
    if diff.partial then
    local y1, m1 = date1.year, date1.month
    -- Ignore wantround, wantduration.
    local y2, m2 = date2.year, date2.month
    local function choose(v)
    local years, months, days = y2 - y1, m2 - m1, date2.day - date1.day
    if type(v) == 'table' then
    if days < 0 then
    if not wantrange or v[1] == v[2] then
    days = days + days_in_month(y1, m1, calname)
    -- Example: Date('partial', 2005) - Date('partial', 2001) gives
    months = months - 1
    -- diff.years = { 3, 4 } to show the range of possible results.
    -- If do not want a range, choose the second value as more expected.
    return v[2]
    end
    end
    return v
    end
    if code == 'ym' or code == 'ymd' then
    if not wantrange and diff.iszero then
    -- This avoids an unexpected result such as
    -- Date('partial', 2001) - Date('partial', 2001)
    -- giving diff = { years = 0, months = { 0, 11 } }
    -- which would be reported as 0 years and 11 months.
    return 0, 0
    end
    return choose(diff.partial.years), choose(diff.partial.months)
    end
    if code == 'y' then
    return choose(diff.partial.years)
    end
    if code == 'm' or code == 'w' or code == 'd' then
    return choose({ diff.partial.mindiff:age(code), diff.partial.maxdiff:age(code) })
    end
    return nil
    end
    end
    if months < 0 then
    local extra_days = wantduration and 1 or 0
    months = months + 12
    if code == 'wd' or code == 'w' or code == 'd' then
    years = years - 1
    local offset = wantround and 0.5 or 0
    local days = diff.age_days + extra_days
    if code == 'wd' or code == 'd' then
    days = floor(days + offset)
    if code == 'd' then
    return days
    end
    return floor(days/7), days % 7
    end
    return floor(days/7 + offset)
    end
    end
    return {
    local H, M, S = diff.hours, diff.minutes, diff.seconds
    years = years,
    if code == 'dh' or code == 'dhm' or code == 'dhms' or code == 'h' or code == 'hm' or code == 'hms' or code == 'M' or code == 's' then
    months = months,
    local days = floor(diff.age_days + extra_days)
    days = days,
    local inc_hour
    isnegative = isnegative,
    if wantround then
    age_ym = function (self)
    if code == 'dh' or code == 'h' then
    -- Return text specifying difference in years, months.
    if M >= 30 then
    local sign = self.isnegative and MINUS or ''
    inc_hour = true
    local mtext = number_name(self.months, 'month')
    end
    local result
    elseif code == 'dhm' or code == 'hm' then
    if self.years > 0 then
    if S >= 30 then
    local ytext = number_name(self.years, 'year')
    M = M + 1
    if self.months == 0 then
    if M >= 60 then
    result = ytext
    M = 0
    else
    inc_hour = true
    result = ytext .. ',&nbsp;' .. mtext
    end
    end
    elseif code == 'M' then
    if S >= 30 then
    M = M + 1
    end
    else
    -- Nothing needed because S is an integer.
    end
    if inc_hour then
    H = H + 1
    if H >= 24 then
    H = 0
    days = days + 1
    end
    end
    end
    end
    if code == 'dh' or code == 'dhm' or code == 'dhms' then
    if code == 'dh' then
    return days, H
    elseif code == 'dhm' then
    return days, H, M
    else
    else
    if self.months == 0 then
    return days, H, M, S
    sign = ''
    end
    end
    local hours = days * 24 + H
    if code == 'h' then
    return hours
    elseif code == 'hm' then
    return hours, M
    elseif code == 'M' or code == 's' then
    M = hours * 60 + M
    if code == 'M' then
    return M
    end
    return M * 60 + S
    end
    return hours, M, S
    end
    if wantround then
    local inc_hour
    if code == 'ymdh' or code == 'ymwdh' then
    if M >= 30 then
    inc_hour = true
    end
    elseif code == 'ymdhm' or code == 'ymwdhm' then
    if S >= 30 then
    M = M + 1
    if M >= 60 then
    M = 0
    inc_hour = true
    end
    end
    elseif code == 'ymd' or code == 'ymwd' or code == 'yd' or code == 'md' then
    if H >= 12 then
    extra_days = extra_days + 1
    end
    end
    if inc_hour then
    H = H + 1
    if H >= 24 then
    H = 0
    extra_days = extra_days + 1
    end
    end
    end
    local y, m, d = diff.years, diff.months, diff.days
    if extra_days > 0 then
    d = d + extra_days
    if d > 28 or code == 'yd' then
    -- Recalculate in case have passed a month.
    diff = diff.date1 + extra_days - diff.date2
    y, m, d = diff.years, diff.months, diff.days
    end
    end
    if code == 'ymd' then
    return y, m, d
    elseif code == 'yd' then
    if y > 0 then
    -- It is known that diff.date1 > diff.date2.
    diff = diff.date1 - (diff.date2 + (y .. 'y'))
    end
    return y, floor(diff.age_days)
    elseif code == 'md' then
    return y * 12 + m, d
    elseif code == 'ym' or code == 'm' then
    if wantround then
    if d >= 16 then
    m = m + 1
    if m >= 12 then
    m = 0
    y = y + 1
    end
    end
    result = mtext
    end
    end
    return sign .. result
    end
    end,
    if code == 'ym' then
    }
    return y, m
    end
    return y * 12 + m
    elseif code == 'ymw' then
    local weeks = floor(d/7)
    if wantround then
    local days = d % 7
    if days > 3 or (days == 3 and H >= 12) then
    weeks = weeks + 1
    end
    end
    return y, m, weeks
    elseif code == 'ymwd' then
    return y, m, floor(d/7), d % 7
    elseif code == 'ymdh' then
    return y, m, d, H
    elseif code == 'ymwdh' then
    return y, m, floor(d/7), d % 7, H
    elseif code == 'ymdhm' then
    return y, m, d, H, M
    elseif code == 'ymwdhm' then
    return y, m, floor(d/7), d % 7, H, M
    end
    if code == 'y' then
    if wantround and m >= 6 then
    y = y + 1
    end
    return y
    end
    return nil
    end
    end


    local function message(msg, nocat)
    local function _diff_duration(diff, code, options)
    -- Return formatted message text for an error.
    if type(options) ~= 'table' then
    -- Can append "#FormattingError" to URL of a page with a problem to find it.
    options = { round = options }
    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
    end
    return anchor ..
    options.duration = true
    '<strong class="error">Error: ' ..
    return _diff_age(diff, code, options)
    msg ..
    '</strong>' ..
    category .. '\n'
    end
    end


    local function age_days(frame)
    -- Metatable for some operations on date differences.
    -- Return age in days between two given dates, or
    diffmt = { -- for forward declaration above
    -- between given date and current date.
    __concat = function (lhs, rhs)
    -- This code implements the logic in [[Template:Age in days]].
    return tostring(lhs) .. tostring(rhs)
    -- Like {{Age in days}}, a missing argument is replaced from the current
    end,
    -- date, so can get a bizarre mixture of specified/current y/m/d.
    __tostring = function (self)
    local args = frame:getParent().args
    return tostring(self.age_days)
    local date1 = Date(
    end,
    date_component(args.year1 , args[1], 'year' ),
    __index = function (self, key)
    date_component(args.month1, args[2], 'month'),
    local value
    date_component(args.day1  , args[3], 'day'  )
    if key == 'age_days' then
    )
    if rawget(self, 'partial') then
    local date2 = Date(
    local function jdz(date)
    date_component(args.year2 , args[4], 'year' ),
    return (date.partial and date.partial.first or date).jdz
    date_component(args.month2, args[5], 'month'),
    end
    date_component(args.day2  , args[6], 'day' )
    value = jdz(self.date1) - jdz(self.date2)
    )
    else
    if not (date1.isvalid and date2.isvalid) then
    value = self.date1.jdz - self.date2.jdz
    return message('Need valid year, month, day')
    end
    end
    end
    local sign = ''
    if value ~= nil then
    local result = date2.jd - date1.jd
    rawset(self, key, value)
    if result < 0 then
    return value
    sign = MINUS
    end
    result = -result
    end,
    end
    }
    return sign .. tostring(result)
    end


    local function age_ym(frame)
    function DateDiff(date1, date2, options) -- for forward declaration above
    -- Return age in years and months between two given dates, or
    -- Return a table with the difference between two dates (date1 - date2).
    -- between given date and current date.
    -- The difference is negative if date1 is older than date2.
    local args = frame:getParent().args
    -- Return nothing if invalid.
    local fields = {}
    -- If d = date1 - date2 then
    for i = 1, 6 do
    --    date1 = date2 + d
    fields[i] = strip_to_nil(args[i])
    -- If date1 >= date2 and the dates have no H:M:S time specified then
    --    date1 = date2 + (d.years..'y') + (d.months..'m') + d.days
    -- where the larger time units are added first.
    -- The result of Date(2015,1,x) + '1m' is Date(2015,2,28) for
    -- x = 28, 29, 30, 31. That means, for example,
    --    d = Date(2015,3,3) - Date(2015,1,31)
    -- gives d.years, d.months, d.days = 0, 1, 3 (excluding date1).
    if not (is_date(date1) and is_date(date2) and date1.calendar == date2.calendar) then
    return
    end
    end
    local date1, date2
    local wantfill
    if fields[1] and fields[2] and fields[3] then
    if type(options) == 'table' then
    date1 = Date(fields[1], fields[2], fields[3])
    wantfill = options.fill
    end
    end
    if not (date1 and date1.isvalid) then
    local isnegative = false
    return message('Need valid year, month, day')
    local iszero = false
    if date1 < date2 then
    isnegative = true
    date1, date2 = date2, date1
    elseif date1 == date2 then
    iszero = true
    end
    end
    if fields[4] and fields[5] and fields[6] then
    -- It is known that date1 >= date2 (period is from date2 to date1).
    date2 = Date(fields[4], fields[5], fields[6])
    if date1.partial or date2.partial then
    if not date2.isvalid then
    -- Two partial dates might have timelines:
    return message('Second date should be year, month, day')
    ---------------------A=================B--- date1 is from A to B inclusive
    --------C=======D-------------------------- date2 is from C to D inclusive
    -- date1 > date2 iff A > C (date1.partial.first > date2.partial.first)
    -- The periods can overlap ('April 2001' - '2001'):
    -------------A===B------------------------- A=2001-04-01  B=2001-04-30
    --------C=====================D------------ C=2001-01-01  D=2001-12-31
    if wantfill then
    date1, date2 = autofill(date1, date2)
    else
    local function zdiff(date1, date2)
    local diff = date1 - date2
    if diff.isnegative then
    return date1 - date1  -- a valid diff in case we call its methods
    end
    return diff
    end
    local function getdate(date, which)
    return date.partial and date.partial[which] or date
    end
    local maxdiff = zdiff(getdate(date1, 'last'), getdate(date2, 'first'))
    local mindiff = zdiff(getdate(date1, 'first'), getdate(date2, 'last'))
    local years, months
    if maxdiff.years == mindiff.years then
    years = maxdiff.years
    if maxdiff.months == mindiff.months then
    months = maxdiff.months
    else
    months = { mindiff.months, maxdiff.months }
    end
    else
    years = { mindiff.years, maxdiff.years }
    end
    return setmetatable({
    date1 = date1,
    date2 = date2,
    partial = {
    years = years,
    months = months,
    maxdiff = maxdiff,
    mindiff = mindiff,
    },
    isnegative = isnegative,
    iszero = iszero,
    age = _diff_age,
    duration = _diff_duration,
    }, diffmt)
    end
    end
    end
    local y1, m1 = date1.year, date1.month
    local y2, m2 = date2.year, date2.month
    local years = y1 - y2
    local months = m1 - m2
    local d1 = date1.day + hms(date1)
    local d2 = date2.day + hms(date2)
    local days, time
    if d1 >= d2 then
    days = d1 - d2
    else
    else
    date2 = Date('currentdate')
    months = months - 1
    -- Get days in previous month (before the "to" date) given December has 31 days.
    local dpm = m1 > 1 and days_in_month(y1, m1 - 1, date1.calendar) or 31
    if d2 >= dpm then
    days = d1 - hms(date2)
    else
    days = dpm - d2 + d1
    end
    end
    end
    return DateDiff(date1, date2):age_ym()
    if months < 0 then
    end
    years = years - 1
     
    months = months + 12
    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
    end
    return message('Need valid year, month, day')
    days, time = math.modf(days)
    end
    local H, M, S = h_m_s(time)
     
    return setmetatable({
    local function ymd_from_jd(frame)
    date1 = date1,
    -- Return formatted date from a Julian date.
    date2 = date2,
    -- The result is y-m-d or y-m-d H:M:S if input includes a fraction.
    partial = false, -- avoid index lookup
    -- The word 'Julian' is accepted for the Julian calendar.
    years = years,
    local args = frame:getParent().args
    months = months,
    local date = Date('juliandate', args[1], args[2])
    days = days,
    if date.isvalid then
    hours = H,
    return date:text()
    minutes = M,
    end
    seconds = S,
    return message('Need valid Julian date number')
    isnegative = isnegative,
    end
    iszero = iszero,
     
    age = _diff_age,
    local function ymd_to_jd(frame)
    duration = _diff_duration,
    -- Return Julian date (a number) from a date (y-m-d), or datetime (y-m-d H:M:S),
    }, diffmt)
    -- 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,
    _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,
    }
    }

    Latest revision as of 11:44, 21 May 2021

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

    -- Date functions for use by other modules.
    -- I18N and time zones are not supported.
    
    local MINUS = '−'  -- Unicode U+2212 MINUS SIGN
    local floor = math.floor
    
    local Date, DateDiff, diffmt  -- forward declarations
    local uniq = { 'unique identifier' }
    
    local function is_date(t)
    	-- The system used to make a date read-only means there is no unique
    	-- metatable that is conveniently accessible to check.
    	return type(t) == 'table' and t._id == uniq
    end
    
    local function is_diff(t)
    	return type(t) == 'table' and getmetatable(t) == diffmt
    end
    
    local function _list_join(list, sep)
    	return table.concat(list, sep)
    end
    
    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 = _list_join,
    	}
    end
    
    local function strip_to_nil(text)
    	-- If text is a string, return its trimmed content, or nil if empty.
    	-- Otherwise return text (convenient when Date fields are provided from
    	-- another module which may pass a string, a number, or another type).
    	if type(text) == 'string' then
    		text = text:match('(%S.-)%s*$')
    	end
    	return text
    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).
    	if month == 2 and is_leap_year(year, calname) then
    		return 29
    	end
    	return ({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 })[month]
    end
    
    local function h_m_s(time)
    	-- Return hour, minute, second extracted from fraction of a day.
    	time = floor(time * 24 * 3600 + 0.5)  -- number of seconds
    	local second = time % 60
    	time = floor(time / 60)
    	return floor(time / 60), time % 60, second
    end
    
    local function hms(date)
    	-- Return fraction of a day from date's time, where (0 <= fraction < 1)
    	-- if the values are valid, but could be anything if outside range.
    	return (date.hour + (date.minute + date.second / 60) / 60) / 24
    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 offset
    	local a = floor((14 - date.month)/12)
    	local y = date.year + 4800 - a
    	if date.calendar == '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 + hms(date) - 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 calname = date.calendar
    	local low, high  -- min/max limits for date ranges −9999-01-01 to 9999-12-31
    	if calname == 'Gregorian' then
    		low, high = -1930999.5, 5373484.49999
    	elseif calname == 'Julian' then
    		low, high = -1931076.5, 5373557.49999
    	else
    		return
    	end
    	local jd = date.jd
    	if not (type(jd) == 'number' and low <= jd and jd <= high) then
    		return
    	end
    	local jdn = floor(jd)
    	if date.hastime then
    		local time = jd - jdn  -- 0 <= time < 1
    		if time >= 0.5 then    -- if at or after midnight of next day
    			jdn = jdn + 1
    			time = time - 0.5
    		else
    			time = time + 0.5
    		end
    		date.hour, date.minute, date.second = h_m_s(time)
    	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 fix_numbers(numbers, y, m, d, H, M, S, partial, hastime, calendar)
    	-- Put the result of normalizing the given values in table numbers.
    	-- The result will have valid m, d values if y is valid; caller checks y.
    	-- The logic of PHP mktime is followed where m or d can be zero to mean
    	-- the previous unit, and -1 is the one before that, etc.
    	-- Positive values carry forward.
    	local date
    	if not (1 <= m and m <= 12) then
    		date = Date(y, 1, 1)
    		if not date then return end
    		date = date + ((m - 1) .. 'm')
    		y, m = date.year, date.month
    	end
    	local days_hms
    	if not partial then
    		if hastime and H and M and S then
    			if not (0 <= H and H <= 23 and
    					0 <= M and M <= 59 and
    					0 <= S and S <= 59) then
    				days_hms = hms({ hour = H, minute = M, second = S })
    			end
    		end
    		if days_hms or not (1 <= d and d <= days_in_month(y, m, calendar)) then
    			date = date or Date(y, m, 1)
    			if not date then return end
    			date = date + (d - 1 + (days_hms or 0))
    			y, m, d = date.year, date.month, date.day
    			if days_hms then
    				H, M, S = date.hour, date.minute, date.second
    			end
    		end
    	end
    	numbers.year = y
    	numbers.month = m
    	numbers.day = d
    	if days_hms then
    		-- Don't set H unless it was valid because a valid H will set hastime.
    		numbers.hour = H
    		numbers.minute = M
    		numbers.second = S
    	end
    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.year   or date.year
    	local m = numbers.month  or date.month
    	local d = numbers.day    or date.day
    	local H = numbers.hour
    	local M = numbers.minute or date.minute or 0
    	local S = numbers.second or date.second or 0
    	local need_fix
    	if y and m and d then
    		date.partial = nil
    		if not (-9999 <= y and y <= 9999 and
    			1 <= m and m <= 12 and
    			1 <= d and d <= days_in_month(y, m, date.calendar)) then
    				if not date.want_fix then
    					return
    				end
    				need_fix = true
    		end
    	elseif y and date.partial then
    		if d or not (-9999 <= y and y <= 9999) then
    			return
    		end
    		if m and not (1 <= m and m <= 12) then
    			if not date.want_fix then
    				return
    			end
    			need_fix = true
    		end
    	else
    		return
    	end
    	if date.partial then
    		H = nil  -- ignore any time
    		M = nil
    		S = nil
    	else
    		if H then
    			-- It is not possible to set M or S without also setting H.
    			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
    			if date.want_fix then
    				need_fix = true
    			else
    				return
    			end
    		end
    	end
    	date.want_fix = nil
    	if need_fix then
    		fix_numbers(numbers, y, m, d, H, M, S, date.partial, date.hastime, date.calendar)
    		return set_date_from_numbers(date, numbers, options)
    	end
    	date.year = y    -- -9999 to 9999 ('n BC' → year = 1 - n)
    	date.month = m   -- 1 to 12 (may be nil if partial)
    	date.day = d     -- 1 to 31 (* = nil if partial)
    	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', 'format' }) 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 table options2 or defaults.
    	-- If a default is used, a flag is set so caller knows the value was not intentionally set.
    	-- Valid option settings are:
    	-- am: 'am', 'a.m.', 'AM', 'A.M.'
    	--     'pm', 'p.m.', 'PM', 'P.M.' (each has same meaning as corresponding item above)
    	-- 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,
    	--    and am = 'pm' has the same meaning.
    	-- 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 = { bydefault = {} }
    	if type(options1) == 'table' then
    		result.am = options1.am
    		result.era = options1.era
    	elseif type(options1) == 'string' then
    		-- Example: 'am:AM era:BC' or '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
    		if not result[k] then
    			if options2[k] then
    				result[k] = options2[k]
    			else
    				result[k] = v
    				result.bydefault[k] = true
    			end
    		end
    	end
    	return result
    end
    
    local ampm_options = {
    	-- lhs = input text accepted as an am/pm option
    	-- rhs = code used internally
    	['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 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 era_text['BC'])[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 = 'dayofyear' },
    		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]
    			if not value then
    				return nil  -- an undefined field in a partial date
    			end
    			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.' },
    					})[ampm_options[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 if id is an undefined field in a partial date, or 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 a formatted string representing the given date.
    	if not is_date(date) then
    		error('date:text: need a date (use "date:text()" with a colon)', 2)
    	end
    	if type(fmt) == 'string' and fmt:match('%S') then
    		if fmt:find('%', 1, true) then
    			return strftime(date, fmt, options)
    		end
    	elseif date.partial then
    		fmt = date.month and 'my' or 'y'
    	else
    		fmt = 'dmy'
    		if date.hastime then
    			fmt = (date.second > 0 and 'hms ' or 'hm ') .. fmt
    		end
    	end
    	local function bad_format()
    		-- For consistency with other format processing, return given format
    		-- (or cleaned format if original was not a string) if invalid.
    		return mw.text.nowiki(fmt)
    	end
    	if date.partial then
    		-- Ignore days in standard formats like 'ymd'.
    		if fmt == 'ym' or fmt == 'ymd' then
    			fmt = date.month and '%Y-%m %{era}' or '%Y %{era}'
    		elseif fmt == 'my' or fmt == 'dmy' or fmt == 'mdy' then
    			fmt = date.month and '%B %-Y %{era}' or '%-Y %{era}'
    		elseif fmt == 'y' then
    			fmt = date.month and '%-Y %{era}' or '%-Y %{era}'
    		else
    			return bad_format()
    		end
    		return strftime(date, fmt, options)
    	end
    	local function hm_fmt()
    		local plain = make_option_table(options, date.options).bydefault.am
    		return plain and '%H:%M' or '%-I:%M %p'
    	end
    	local need_time = date.hastime
    	local t = collection()
    	for item in fmt:gmatch('%S+') do
    		local f
    		if item == 'hm' then
    			f = hm_fmt()
    			need_time = false
    		elseif item == 'hms' then
    			f = '%H:%M:%S'
    			need_time = false
    		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 bad_format()
    		end
    		t:add(f)
    	end
    	fmt = t:join(' ')
    	if need_time then
    		fmt = hm_fmt() .. ' ' .. fmt
    	end
    	return strftime(date, fmt, 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 name_to_number(text, translate)
    	if type(text) == 'string' then
    		return translate[text:lower()]
    	end
    end
    
    local function day_number(text)
    	return name_to_number(text, {
    		sun = 0, sunday = 0,
    		mon = 1, monday = 1,
    		tue = 2, tuesday = 2,
    		wed = 3, wednesday = 3,
    		thu = 4, thursday = 4,
    		fri = 5, friday = 5,
    		sat = 6, saturday = 6,
    	})
    end
    
    local function month_number(text)
    	return name_to_number(text, {
    		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, sept = 9,
    		oct = 10, october = 10,
    		nov = 11, november = 11,
    		dec = 12, december = 12,
    	})
    end
    
    local function _list_text(list, fmt)
    	-- Return a list of formatted strings from a list of dates.
    	if not type(list) == 'table' then
    		error('date:list:text: need "list:text()" with a colon', 2)
    	end
    	local result = { join = _list_join }
    	for i, date in ipairs(list) do
    		result[i] = date:text(fmt)
    	end
    	return result
    end
    
    local function _date_list(date, spec)
    	-- Return a possibly empty numbered table of dates meeting the specification.
    	-- Dates in the list are in ascending order (oldest date first).
    	-- The spec should be a string of form "<count> <day> <op>"
    	-- where each item is optional and
    	--   count = number of items wanted in list
    	--   day = abbreviation or name such as Mon or Monday
    	--   op = >, >=, <, <= (default is > meaning after date)
    	-- If no count is given, the list is for the specified days in date's month.
    	-- The default day is date's day.
    	-- The spec can also be a positive or negative number:
    	--   -5 is equivalent to '5 <'
    	--   5  is equivalent to '5' which is '5 >'
    	if not is_date(date) then
    		error('date:list: need a date (use "date:list()" with a colon)', 2)
    	end
    	local list = { text = _list_text }
    	if date.partial then
    		return list
    	end
    	local count, offset, operation
    	local ops = {
    		['>='] = { before = false, include = true  },
    		['>']  = { before = false, include = false },
    		['<='] = { before = true , include = true  },
    		['<']  = { before = true , include = false },
    	}
    	if spec then
    		if type(spec) == 'number' then
    			count = floor(spec + 0.5)
    			if count < 0 then
    				count = -count
    				operation = ops['<']
    			end
    		elseif type(spec) == 'string' then
    			local num, day, op = spec:match('^%s*(%d*)%s*(%a*)%s*([<>=]*)%s*$')
    			if not num then
    				return list
    			end
    			if num ~= '' then
    				count = tonumber(num)
    			end
    			if day ~= '' then
    				local dow = day_number(day:gsub('[sS]$', ''))  -- accept plural days
    				if not dow then
    					return list
    				end
    				offset = dow - date.dow
    			end
    			operation = ops[op]
    		else
    			return list
    		end
    	end
    	offset = offset or 0
    	operation = operation or ops['>']
    	local datefrom, dayfirst, daylast
    	if operation.before then
    		if offset > 0 or (offset == 0 and not operation.include) then
    			offset = offset - 7
    		end
    		if count then
    			if count > 1 then
    				offset = offset - 7*(count - 1)
    			end
    			datefrom = date + offset
    		else
    			daylast = date.day + offset
    			dayfirst = daylast % 7
    			if dayfirst == 0 then
    				dayfirst = 7
    			end
    		end
    	else
    		if offset < 0 or (offset == 0 and not operation.include) then
    			offset = offset + 7
    		end
    		if count then
    			datefrom = date + offset
    		else
    			dayfirst = date.day + offset
    			daylast = date.monthdays
    		end
    	end
    	if not count then
    		if daylast < dayfirst then
    			return list
    		end
    		count = floor((daylast - dayfirst)/7) + 1
    		datefrom = Date(date, {day = dayfirst})
    	end
    	for i = 1, count do
    		if not datefrom then break end  -- exceeds date limits
    		list[i] = datefrom
    		datefrom = datefrom + 7
    	end
    	return list
    end
    
    -- A table to get the current date/time (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(newdate, 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 or format, 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 potentially ambiguous.
    	local date, options = {}, {}
    	if text:sub(-1) == 'Z' then
    		-- Extract date/time from a Wikidata timestamp.
    		-- The year can be 1 to 16 digits but this module handles 1 to 4 digits only.
    		-- Examples: '+2016-06-21T14:30:00Z', '-0000000180-00-00T00:00:00Z'.
    		local sign, y, m, d, H, M, S = text:match('^([+%-])(%d+)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)Z$')
    		if sign then
    			y = tonumber(y)
    			if sign == '-' and y > 0 then
    				y = -y
    			end
    			if y <= 0 then
    				options.era = 'BCE'
    			end
    			date.year = y
    			m = tonumber(m)
    			d = tonumber(d)
    			H = tonumber(H)
    			M = tonumber(M)
    			S = tonumber(S)
    			if m == 0 then
    				newdate.partial = true
    				return date, options
    			end
    			date.month = m
    			if d == 0 then
    				newdate.partial = true
    				return date, options
    			end
    			date.day = d
    			if H > 0 or M > 0 or S > 0 then
    				date.hour = H
    				date.minute = M
    				date.second = S
    			end
    			return date, options
    		end
    		return
    	end
    	local function extract_ymd(item)
    		-- Called when no day or month has been set.
    		local y, m, d = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
    		if y then
    			if date.year then
    				return
    			end
    			if m:match('^%d%d?$') then
    				m = tonumber(m)
    			else
    				m = month_number(m)
    			end
    			if m then
    				date.year = tonumber(y)
    				date.month = m
    				date.day = tonumber(d)
    				return true
    			end
    		end
    	end
    	local function extract_day_or_year(item)
    		-- Called when a day would be valid, or
    		-- when a year would be valid if no year has been set and partial is set.
    		local number, suffix = item:match('^(%d%d?%d?%d?)(.*)$')
    		if number then
    			local n = tonumber(number)
    			if #number <= 2 and n <= 31 then
    				suffix = suffix:lower()
    				if suffix == '' or suffix == 'st' or suffix == 'nd' or suffix == 'rd' or suffix == 'th' then
    					date.day = n
    					return true
    				end
    			elseif suffix == '' and newdate.partial and not date.year then
    				date.year = n
    				return true
    			end
    		end
    	end
    	local function extract_month(item)
    		-- A month must be given as a name or abbreviation; a number could be ambiguous.
    		local m = month_number(item)
    		if m then
    			date.month = m
    			return true
    		end
    	end
    	local function extract_time(item)
    		local h, m, s = item:match('^(%d%d?):(%d%d)(:?%d*)$')
    		if date.hour or not h then
    			return
    		end
    		if s ~= '' then
    			s = s:match('^:(%d%d)$')
    			if not s then
    				return
    			end
    		end
    		date.hour = tonumber(h)
    		date.minute = tonumber(m)
    		date.second = tonumber(s)  -- nil if empty string
    		return true
    	end
    	local item_count = 0
    	local index_time
    	local function set_ampm(item)
    		local H = date.hour
    		if H and not options.am and index_time + 1 == item_count then
    			options.am = ampm_options[item]  -- caller checked this is not nil
    			if item:match('^[Aa]') then
    				if not (1 <= H and H <= 12) then
    					return
    				end
    				if H == 12 then
    					date.hour = 0
    				end
    			else
    				if not (1 <= H and H <= 23) then
    					return
    				end
    				if H <= 11 then
    					date.hour = H + 12
    				end
    			end
    			return true
    		end
    	end
    	for item in text:gsub(',', ' '):gsub('&nbsp;', ' '):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.day and date.month then
    			if date.year then
    				return  -- should be nothing more so item is invalid
    			end
    			if not item:match('^(%d%d?%d?%d?)$') then
    				return
    			end
    			date.year = tonumber(item)
    		elseif date.day then
    			if not extract_month(item) then
    				return
    			end
    		elseif date.month then
    			if not extract_day_or_year(item) then
    				return
    			end
    		elseif extract_month(item) then
    			options.format = 'mdy'
    		elseif extract_ymd(item) then
    			options.format = 'ymd'
    		elseif extract_day_or_year(item) then
    			if date.day then
    				options.format = 'dmy'
    			end
    		else
    			return
    		end
    	end
    	if not date.year or date.year == 0 then
    		return
    	end
    	local era = era_text[options.era]
    	if era and era.isbc then
    		date.year = 1 - date.year
    	end
    	return date, options
    end
    
    local function autofill(date1, date2)
    	-- Fill any missing month or day in each date using the
    	-- corresponding component from the other date, if present,
    	-- or with 1 if both dates are missing the month or day.
    	-- This gives a good result for calculating the difference
    	-- between two partial dates when no range is wanted.
    	-- Return filled date1, date2 (two full dates).
    	local function filled(a, b)
    		-- Return date a filled, if necessary, with month and/or day from date b.
    		-- The filled day is truncated to fit the number of days in the month.
    		local fillmonth, fillday
    		if not a.month then
    			fillmonth = b.month or 1
    		end
    		if not a.day then
    			fillday = b.day or 1
    		end
    		if fillmonth or fillday then  -- need to create a new date
    			a = Date(a, {
    				month = fillmonth,
    				day = math.min(fillday or a.day, days_in_month(a.year, fillmonth or a.month, a.calendar))
    			})
    		end
    		return a
    	end
    	return filled(date1, date2), filled(date2, date1)
    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.
    	-- The result is nil if the calculated date exceeds allowable limits.
    	-- Caller ensures that lhs is a date; its properties are copied for the new date.
    	if lhs.partial then
    		-- Adding to a partial is not supported.
    		-- Can subtract a date or partial from a partial, but this is not called for that.
    		return
    	end
    	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)
    		local forcetime, jd
    		if floor(n) == n then
    			jd = lhs.jd
    		else
    			forcetime = not lhs.hastime
    			jd = lhs.jdz
    		end
    		jd = jd + (is_sub and -n or n)
    		if forcetime then
    			jd = tostring(jd)
    			if not jd:find('.', 1, true) then
    				jd = jd .. '.0'
    			end
    		end
    		return Date(lhs, 'juliandate', jd)
    	end
    	if type(rhs) == 'number' then
    		-- Add/subtract days, including fractional days.
    		return do_days(rhs)
    	end
    	if type(rhs) == 'string' then
    		-- rhs is a single component like '26m' or '26 months' (with optional sign).
    		-- Fractions like '3.25d' are accepted for the units which are handled as days.
    		local sign, numstr, id = rhs:match('^%s*([+-]?)([%d%.]+)%s*(%a+)$')
    		if sign then
    			if sign == '-' then
    				is_sub = not (is_sub and true or false)
    			end
    			local y, m, days
    			local num = tonumber(numstr)
    			if not num then
    				return
    			end
    			id = id:lower()
    			if is_prefix(id, 'years') then
    				y = num
    				m = 0
    			elseif is_prefix(id, 'months') then
    				y = floor(num / 12)
    				m = num % 12
    			elseif is_prefix(id, 'weeks') then
    				days = num * 7
    			elseif is_prefix(id, 'days') then
    				days = num
    			elseif is_prefix(id, 'hours') then
    				days = num / 24
    			elseif is_prefix(id, 'minutes', 3) then
    				days = num / (24 * 60)
    			elseif is_prefix(id, 'seconds') then
    				days = num / (24 * 3600)
    			else
    				return
    			end
    			if days then
    				return do_days(days)
    			end
    			if numstr:find('.', 1, true) then
    				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.calendar))
    			return Date(lhs, y, m, d)
    		end
    	end
    	if is_diff(rhs) then
    		local days = rhs.age_days
    		if (is_sub or false) ~= (rhs.isnegative or false) then
    			days = -days
    		end
    		return lhs + days
    	end
    end
    
    local full_date_only = {
    	dayabbr = true,
    	dayname = true,
    	dow = true,
    	dayofweek = true,
    	dowiso = true,
    	dayofweekiso = true,
    	dayofyear = true,
    	gsd = true,
    	juliandate = true,
    	jd = true,
    	jdz = true,
    	jdnoon = true,
    }
    
    -- Metatable for a date's calculated fields.
    local datemt = {
    	__index = function (self, key)
    		if rawget(self, 'partial') then
    			if full_date_only[key] then return end
    			if key == 'monthabbr' or key == 'monthdays' or key == 'monthname' then
    				if not self.month then return end
    			end
    		end
    		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.jdnoon + 1) % 7  -- day-of-week 0=Sun to 6=Sat
    		elseif key == 'dayofweek' then
    			value = self.dow
    		elseif key == 'dowiso' then
    			value = (self.jdnoon % 7) + 1  -- ISO day-of-week 1=Mon to 7=Sun
    		elseif key == 'dayofweekiso' then
    			value = self.dowiso
    		elseif key == 'dayofyear' then
    			local first = Date(self.year, 1, 1, self.calendar).jdnoon
    			value = self.jdnoon - first + 1  -- day-of-year 1 to 366
    		elseif key == 'era' then
    			-- Era text (never a negative sign) from year and options.
    			value = get_era_for_year(self.options.era, self.year)
    		elseif key == 'format' then
    			value = self.options.format or 'dmy'
    		elseif key == 'gsd' then
    			-- GSD = 1 from 00:00:00 to 23:59:59 on 1 January 1 AD Gregorian calendar,
    			-- which is from jd 1721425.5 to 1721426.49999.
    			value = floor(self.jd - 1721424.5)
    		elseif key == 'juliandate' or key == 'jd' or key == 'jdz' then
    			local jd, jdz = julian_date(self)
    			rawset(self, 'juliandate', jd)
    			rawset(self, 'jd', jd)
    			rawset(self, 'jdz', jdz)
    			return key == 'jdz' and jdz or jd
    		elseif key == 'jdnoon' then
    			-- Julian date at noon (an integer) on the calendar day when jd occurs.
    			value = floor(self.jd + 0.5)
    		elseif key == 'isleapyear' then
    			value = is_leap_year(self.year, self.calendar)
    		elseif key == 'monthabbr' then
    			value = month_info[self.month][1]
    		elseif key == 'monthdays' then
    			value = days_in_month(self.year, self.month, self.calendar)
    		elseif key == 'monthname' then
    			value = month_info[self.month][2]
    		end
    		if value ~= nil then
    			rawset(self, key, value)
    			return value
    		end
    	end,
    }
    
    -- Date operators.
    local function mt_date_add(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
    
    local function mt_date_sub(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
    
    local function mt_date_concat(lhs, rhs)
    	return tostring(lhs) .. tostring(rhs)
    end
    
    local function mt_date_tostring(self)
    	return self:text()
    end
    
    local function mt_date_eq(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 called only if lhs and rhs have the same type and the same metamethod.
    	if lhs.partial or rhs.partial then
    		-- One date is partial; the other is a partial or a full date.
    		-- The months may both be nil, but must be the same.
    		return lhs.year == rhs.year and lhs.month == rhs.month and lhs.calendar == rhs.calendar
    	end
    	return lhs.jdz == rhs.jdz
    end
    
    local function mt_date_lt(lhs, rhs)
    	-- Return true if lhs < rhs, for example,
    	-- Date('1 Jan 2016') < Date('06:00 1 Jan 2016') is true.
    	-- This is called only if lhs and rhs have the same type and the same metamethod.
    	if lhs.partial or rhs.partial then
    		-- One date is partial; the other is a partial or a full date.
    		if lhs.calendar ~= rhs.calendar then
    			return lhs.calendar == 'Julian'
    		end
    		if lhs.partial then
    			lhs = lhs.partial.first
    		end
    		if rhs.partial then
    			rhs = rhs.partial.first
    		end
    	end
    	return lhs.jdz < rhs.jdz
    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
    Date(date, t)                       same, updated with y,m,d,H,M,S fields from table t
    Date(t)                       		date with y,m,d,H,M,S fields from table t
    ]]
    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.
    	-- A partial date has a valid year, however its month may be nil, and
    	-- its day and time fields are nil.
    	-- Field partial is set to false (if a full date) or a table (if a partial date).
    	local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
    	local newdate = {
    		_id = uniq,
    		calendar = '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,
    		options = {},
    		list = _date_list,
    		subtract = function (self, rhs, options)
    			return DateDiff(self, rhs, options)
    		end,
    		text = _date_text,
    	}
    	local argtype, datetext, is_copy, jd_number, tnums
    	local numindex = 0
    	local numfields = { 'year', 'month', 'day', 'hour', 'minute', 'second' }
    	local numbers = {}
    	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
    			newdate.calendar = calendars[vlower]
    		elseif vlower == 'partial' then
    			newdate.partial = true
    		elseif vlower == 'fix' then
    			newdate.want_fix = true
    		elseif is_date(v) then
    			-- Copy existing date (items can be overridden by other arguments).
    			if is_copy or tnums then
    				return
    			end
    			is_copy = true
    			newdate.calendar = v.calendar
    			newdate.partial = v.partial
    			newdate.hastime = v.hastime
    			newdate.options = v.options
    			newdate.year = v.year
    			newdate.month = v.month
    			newdate.day = v.day
    			newdate.hour = v.hour
    			newdate.minute = v.minute
    			newdate.second = v.second
    		elseif type(v) == 'table' then
    			if tnums then
    				return
    			end
    			tnums = {}
    			local tfields = { year=1, month=1, day=1, hour=2, minute=2, second=2 }
    			for tk, tv in pairs(v) do
    				if tfields[tk] then
    					tnums[tk] = tonumber(tv)
    				end
    				if tfields[tk] == 2 then
    					newdate.hastime = true
    				end
    			end
    		else
    			local num = tonumber(v)
    			if not num and argtype == 'setdate' and numindex == 1 then
    				num = month_number(v)
    			end
    			if num then
    				if not argtype then
    					argtype = 'setdate'
    				end
    				if argtype == 'setdate' and numindex < 6 then
    					numindex = numindex + 1
    					numbers[numfields[numindex]] = num
    				elseif argtype == 'juliandate' and not jd_number then
    					jd_number = num
    					if type(v) == 'string' then
    						if v:find('.', 1, true) then
    							newdate.hastime = true
    						end
    					elseif num ~= floor(num) then
    						-- The given value was a number. The time will be used
    						-- if the fractional part is nonzero.
    						newdate.hastime = true
    					end
    				else
    					return
    				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 tnums or not set_date_from_numbers(newdate, extract_date(newdate, datetext)) then
    			return
    		end
    	elseif argtype == 'juliandate' then
    		newdate.partial = nil
    		newdate.jd = jd_number
    		if not set_date_from_jd(newdate) then
    			return
    		end
    	elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
    		newdate.partial = nil
    		newdate.year = current.year
    		newdate.month = current.month
    		newdate.day = current.day
    		if argtype == 'currentdatetime' then
    			newdate.hour = current.hour
    			newdate.minute = current.minute
    			newdate.second = current.second
    			newdate.hastime = true
    		end
    		newdate.calendar = 'Gregorian'  -- ignore any given calendar name
    	elseif argtype == 'setdate' then
    		if tnums or not set_date_from_numbers(newdate, numbers) then
    			return
    		end
    	elseif not (is_copy or tnums) then
    		return
    	end
    	if tnums then
    		newdate.jd = nil  -- force recalculation in case jd was set before changes from tnums
    		if not set_date_from_numbers(newdate, tnums) then
    			return
    		end
    	end
    	if newdate.partial then
    		local year = newdate.year
    		local month = newdate.month
    		local first = Date(year, month or 1, 1, newdate.calendar)
    		month = month or 12
    		local last = Date(year, month, days_in_month(year, month), newdate.calendar)
    		newdate.partial = { first = first, last = last }
    	else
    		newdate.partial = false  -- avoid index lookup
    	end
    	setmetatable(newdate, datemt)
    	local readonly = {}
    	local mt = {
    		__index = newdate,
    		__newindex = function(t, k, v) error('date.' .. tostring(k) .. ' is read-only', 2) end,
    		__add = mt_date_add,
    		__sub = mt_date_sub,
    		__concat = mt_date_concat,
    		__tostring = mt_date_tostring,
    		__eq = mt_date_eq,
    		__lt = mt_date_lt,
    	}
    	return setmetatable(readonly, mt)
    end
    
    local function _diff_age(diff, code, options)
    	-- Return a tuple of integer values from diff as specified by code, except that
    	-- each integer may be a list of two integers for a diff with a partial date, or
    	-- return nil if the code is not supported.
    	-- If want round, the least significant unit is rounded to nearest whole unit.
    	-- For a duration, an extra day is added.
    	local wantround, wantduration, wantrange
    	if type(options) == 'table' then
    		wantround = options.round
    		wantduration = options.duration
    		wantrange = options.range
    	else
    		wantround = options
    	end
    	if not is_diff(diff) then
    		local f = wantduration and 'duration' or 'age'
    		error(f .. ': need a date difference (use "diff:' .. f .. '()" with a colon)', 2)
    	end
    	if diff.partial then
    		-- Ignore wantround, wantduration.
    		local function choose(v)
    			if type(v) == 'table' then
    				if not wantrange or v[1] == v[2] then
    					-- Example: Date('partial', 2005) - Date('partial', 2001) gives
    					-- diff.years = { 3, 4 } to show the range of possible results.
    					-- If do not want a range, choose the second value as more expected.
    					return v[2]
    				end
    			end
    			return v
    		end
    		if code == 'ym' or code == 'ymd' then
    			if not wantrange and diff.iszero then
    				-- This avoids an unexpected result such as
    				-- Date('partial', 2001) - Date('partial', 2001)
    				-- giving diff = { years = 0, months = { 0, 11 } }
    				-- which would be reported as 0 years and 11 months.
    				return 0, 0
    			end
    			return choose(diff.partial.years), choose(diff.partial.months)
    		end
    		if code == 'y' then
    			return choose(diff.partial.years)
    		end
    		if code == 'm' or code == 'w' or code == 'd' then
    			return choose({ diff.partial.mindiff:age(code), diff.partial.maxdiff:age(code) })
    		end
    		return nil
    	end
    	local extra_days = wantduration and 1 or 0
    	if code == 'wd' or code == 'w' or code == 'd' then
    		local offset = wantround and 0.5 or 0
    		local days = diff.age_days + extra_days
    		if code == 'wd' or code == 'd' then
    			days = floor(days + offset)
    			if code == 'd' then
    				return days
    			end
    			return floor(days/7), days % 7
    		end
    		return floor(days/7 + offset)
    	end
    	local H, M, S = diff.hours, diff.minutes, diff.seconds
    	if code == 'dh' or code == 'dhm' or code == 'dhms' or code == 'h' or code == 'hm' or code == 'hms' or code == 'M' or code == 's' then
    		local days = floor(diff.age_days + extra_days)
    		local inc_hour
    		if wantround then
    			if code == 'dh' or code == 'h' then
    				if M >= 30 then
    					inc_hour = true
    				end
    			elseif code == 'dhm' or code == 'hm' then
    				if S >= 30 then
    					M = M + 1
    					if M >= 60 then
    						M = 0
    						inc_hour = true
    					end
    				end
    			elseif code == 'M' then
    				if S >= 30 then
    					M = M + 1
    				end
    			else
    				-- Nothing needed because S is an integer.
    			end
    			if inc_hour then
    				H = H + 1
    				if H >= 24 then
    					H = 0
    					days = days + 1
    				end
    			end
    		end
    		if code == 'dh' or code == 'dhm' or code == 'dhms' then
    			if code == 'dh' then
    				return days, H
    			elseif code == 'dhm' then
    				return days, H, M
    			else
    				return days, H, M, S
    			end
    		end
    		local hours = days * 24 + H
    		if code == 'h' then
    			return hours
    		elseif code == 'hm' then
    			return hours, M
    		elseif code == 'M' or code == 's' then
    			M = hours * 60 + M
    			if code == 'M' then
    				return M
    			end
    			return M * 60 + S
    		end
    		return hours, M, S
    	end
    	if wantround then
    		local inc_hour
    		if code == 'ymdh' or code == 'ymwdh' then
    			if M >= 30 then
    				inc_hour = true
    			end
    		elseif code == 'ymdhm' or code == 'ymwdhm' then
    			if S >= 30 then
    				M = M + 1
    				if M >= 60 then
    					M = 0
    					inc_hour = true
    				end
    			end
    		elseif code == 'ymd' or code == 'ymwd' or code == 'yd' or code == 'md' then
    			if H >= 12 then
    				extra_days = extra_days + 1
    			end
    		end
    		if inc_hour then
    			H = H + 1
    			if H >= 24 then
    				H = 0
    				extra_days = extra_days + 1
    			end
    		end
    	end
    	local y, m, d = diff.years, diff.months, diff.days
    	if extra_days > 0 then
    		d = d + extra_days
    		if d > 28 or code == 'yd' then
    			-- Recalculate in case have passed a month.
    			diff = diff.date1 + extra_days - diff.date2
    			y, m, d = diff.years, diff.months, diff.days
    		end
    	end
    	if code == 'ymd' then
    		return y, m, d
    	elseif code == 'yd' then
    		if y > 0 then
    			-- It is known that diff.date1 > diff.date2.
    			diff = diff.date1 - (diff.date2 + (y .. 'y'))
    		end
    		return y, floor(diff.age_days)
    	elseif code == 'md' then
    		return y * 12 + m, d
    	elseif code == 'ym' or code == 'm' then
    		if wantround then
    			if d >= 16 then
    				m = m + 1
    				if m >= 12 then
    					m = 0
    					y = y + 1
    				end
    			end
    		end
    		if code == 'ym' then
    			return y, m
    		end
    		return y * 12 + m
    	elseif code == 'ymw' then
    		local weeks = floor(d/7)
    		if wantround then
    			local days = d % 7
    			if days > 3 or (days == 3 and H >= 12) then
    				weeks = weeks + 1
    			end
    		end
    		return y, m, weeks
    	elseif code == 'ymwd' then
    		return y, m, floor(d/7), d % 7
    	elseif code == 'ymdh' then
    		return y, m, d, H
    	elseif code == 'ymwdh' then
    		return y, m, floor(d/7), d % 7, H
    	elseif code == 'ymdhm' then
    		return y, m, d, H, M
    	elseif code == 'ymwdhm' then
    		return y, m, floor(d/7), d % 7, H, M
    	end
    	if code == 'y' then
    		if wantround and m >= 6 then
    			y = y + 1
    		end
    		return y
    	end
    	return nil
    end
    
    local function _diff_duration(diff, code, options)
    	if type(options) ~= 'table' then
    		options = { round = options }
    	end
    	options.duration = true
    	return _diff_age(diff, code, options)
    end
    
    -- Metatable for some operations on date differences.
    diffmt = {  -- for forward declaration above
    	__concat = function (lhs, rhs)
    		return tostring(lhs) .. tostring(rhs)
    	end,
    	__tostring = function (self)
    		return tostring(self.age_days)
    	end,
    	__index = function (self, key)
    		local value
    		if key == 'age_days' then
    			if rawget(self, 'partial') then
    				local function jdz(date)
    					return (date.partial and date.partial.first or date).jdz
    				end
    				value = jdz(self.date1) - jdz(self.date2)
    			else
    				value = self.date1.jdz - self.date2.jdz
    			end
    		end
    		if value ~= nil then
    			rawset(self, key, value)
    			return value
    		end
    	end,
    }
    
    function DateDiff(date1, date2, options)  -- for forward declaration above
    	-- Return a table with the difference between two dates (date1 - date2).
    	-- The difference is negative if date1 is older than date2.
    	-- Return nothing if invalid.
    	-- If d = date1 - date2 then
    	--     date1 = date2 + d
    	-- If date1 >= date2 and the dates have no H:M:S time specified then
    	--     date1 = date2 + (d.years..'y') + (d.months..'m') + d.days
    	-- where the larger time units are added first.
    	-- The result of Date(2015,1,x) + '1m' is Date(2015,2,28) for
    	-- x = 28, 29, 30, 31. That means, for example,
    	--     d = Date(2015,3,3) - Date(2015,1,31)
    	-- gives d.years, d.months, d.days = 0, 1, 3 (excluding date1).
    	if not (is_date(date1) and is_date(date2) and date1.calendar == date2.calendar) then
    		return
    	end
    	local wantfill
    	if type(options) == 'table' then
    		wantfill = options.fill
    	end
    	local isnegative = false
    	local iszero = false
    	if date1 < date2 then
    		isnegative = true
    		date1, date2 = date2, date1
    	elseif date1 == date2 then
    		iszero = true
    	end
    	-- It is known that date1 >= date2 (period is from date2 to date1).
    	if date1.partial or date2.partial then
    		-- Two partial dates might have timelines:
    		---------------------A=================B--- date1 is from A to B inclusive
    		--------C=======D-------------------------- date2 is from C to D inclusive
    		-- date1 > date2 iff A > C (date1.partial.first > date2.partial.first)
    		-- The periods can overlap ('April 2001' - '2001'):
    		-------------A===B------------------------- A=2001-04-01  B=2001-04-30
    		--------C=====================D------------ C=2001-01-01  D=2001-12-31
    		if wantfill then
    			date1, date2 = autofill(date1, date2)
    		else
    			local function zdiff(date1, date2)
    				local diff = date1 - date2
    				if diff.isnegative then
    					return date1 - date1  -- a valid diff in case we call its methods
    				end
    				return diff
    			end
    			local function getdate(date, which)
    				return date.partial and date.partial[which] or date
    			end
    			local maxdiff = zdiff(getdate(date1, 'last'), getdate(date2, 'first'))
    			local mindiff = zdiff(getdate(date1, 'first'), getdate(date2, 'last'))
    			local years, months
    			if maxdiff.years == mindiff.years then
    				years = maxdiff.years
    				if maxdiff.months == mindiff.months then
    					months = maxdiff.months
    				else
    					months = { mindiff.months, maxdiff.months }
    				end
    			else
    				years = { mindiff.years, maxdiff.years }
    			end
    			return setmetatable({
    				date1 = date1,
    				date2 = date2,
    				partial = {
    					years = years,
    					months = months,
    					maxdiff = maxdiff,
    					mindiff = mindiff,
    				},
    				isnegative = isnegative,
    				iszero = iszero,
    				age = _diff_age,
    				duration = _diff_duration,
    			}, diffmt)
    		end
    	end
    	local y1, m1 = date1.year, date1.month
    	local y2, m2 = date2.year, date2.month
    	local years = y1 - y2
    	local months = m1 - m2
    	local d1 = date1.day + hms(date1)
    	local d2 = date2.day + hms(date2)
    	local days, time
    	if d1 >= d2 then
    		days = d1 - d2
    	else
    		months = months - 1
    		-- Get days in previous month (before the "to" date) given December has 31 days.
    		local dpm = m1 > 1 and days_in_month(y1, m1 - 1, date1.calendar) or 31
    		if d2 >= dpm then
    			days = d1 - hms(date2)
    		else
    			days = dpm - d2 + d1
    		end
    	end
    	if months < 0 then
    		years = years - 1
    		months = months + 12
    	end
    	days, time = math.modf(days)
    	local H, M, S = h_m_s(time)
    	return setmetatable({
    		date1 = date1,
    		date2 = date2,
    		partial = false,  -- avoid index lookup
    		years = years,
    		months = months,
    		days = days,
    		hours = H,
    		minutes = M,
    		seconds = S,
    		isnegative = isnegative,
    		iszero = iszero,
    		age = _diff_age,
    		duration = _diff_duration,
    	}, diffmt)
    end
    
    return {
    	_current = current,
    	_Date = Date,
    	_days_in_month = days_in_month,
    }