Module:Date: Difference between revisions

Nothing to hide, but nothing to show you either.
Jump to navigation Jump to search
Content added Content deleted
(clean up and some aliases)
(major refactor with fixes; force Date to be read-only (error on write); list of dates in a month on a particular day of week)
Line 4: Line 4:
local MINUS = '−' -- Unicode U+2212 MINUS SIGN
local MINUS = '−' -- Unicode U+2212 MINUS SIGN


local Date, DateDiff, datemt -- forward declarations
local Date, DateDiff, diffmt -- forward declarations
local uniq = { 'unique identifier' }


local function is_date(t)
local function is_date(t)
-- The system used to make a date read-only means there is no unique
return type(t) == 'table' and getmetatable(t) == datemt
-- 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
end


Line 18: Line 29:
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
Line 93: Line 102:
local floor = math.floor
local floor = math.floor
local calname = date.calname
local calname = date.calname
local jd = date.jd
local limits -- min/max limits for date ranges −9999-01-01 to 9999-12-31
local limits -- min/max limits for date ranges −9999-01-01 to 9999-12-31
if calname == 'Julian' then
if calname == 'Julian' then
Line 100: Line 108:
limits = { -1930999.5, 5373484.49999 }
limits = { -1930999.5, 5373484.49999 }
else
else
return
limits = { 1, 0 } -- impossible
end
end
local jd = date.jd
if not (limits[1] <= jd and jd <= limits[2]) then
if not (type(jd) == 'number' and limits[1] <= jd and jd <= limits[2]) then
return
return
end
end
Line 150: Line 159:
return
return
end
end
local y = numbers.y or numbers[1]
local y = numbers.year or date.year
local m = numbers.m or numbers[2]
local m = numbers.month or date.month
local d = numbers.d or numbers[3]
local d = numbers.day or date.day
local H = numbers.H or numbers[4]
local H = numbers.hour
local M = numbers.M or numbers[5] or 0
local M = numbers.minute or date.minute or 0
local S = numbers.S or numbers[6] or 0
local S = numbers.second or date.second or 0
if not (y and m and d) then
if not (y and m and d) or not
(-9999 <= y and y <= 9999 and
return
1 <= m and m <= 12 and
end
if not (-9999 <= y and y <= 9999 and 1 <= m and m <= 12 and
1 <= d and d <= days_in_month(y, m, date.calname)) then
1 <= d and d <= days_in_month(y, m, date.calname)) then
return
return
end
end
Line 166: Line 174:
date.hastime = true
date.hastime = true
else
else
H = 0
H = date.hour or 0
end
end
if not (0 <= H and H <= 23 and
if not (0 <= H and H <= 23 and
Line 206: Line 214:
-- Example: 'am:AM era:BC'
-- Example: 'am:AM era:BC'
for item in options1:gmatch('%S+') do
for item in options1:gmatch('%S+') do
local lhs, rhs = item:match('^(%w+):(.+)$')
local lhs, rhs = item:match('^(%w+)[:=](.+)$')
if lhs then
if lhs then
result[lhs] = rhs
result[lhs] = rhs
Line 339: Line 347:
:gsub('%%%%', PERCENT)
:gsub('%%%%', PERCENT)
:gsub('(%s*)%%{(%w+)}', replace_property)
:gsub('(%s*)%%{(%w+)}', replace_property)
:gsub('(%s*)%%(-?)(%a)', replace_code)
:gsub('(%s*)%%(%-?)(%a)', replace_code)
:gsub(PERCENT, '%%')
:gsub(PERCENT, '%%')
)
)
Line 345: Line 353:


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 is_date(date) then
if not is_date(date) then
return 'Need a date (use "date:text()" with a colon).'
return 'Need a date (use "date:text()" with a colon).'
Line 411: Line 419:
}
}


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


local function day_number(text)
-- A table to get the current date/time (UTC), but only if needed.
return name_to_number(text, {
local current = setmetatable({}, {
sun = 0, sunday = 0,
__index = function (self, key)
mon = 1, monday = 1,
local d = os.date('!*t')
tue = 2, tuesday = 2,
self.year = d.year
wed = 3, wednesday = 3,
self.month = d.month
thu = 4, thursday = 4,
self.day = d.day
fri = 5, friday = 5,
self.hour = d.hour
sat = 6, saturday = 6,
self.minute = d.min
})
self.second = d.sec
end
return rawget(self, key)

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,
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
return 'Need "list:text()" with a colon.'
end
end
local result = { join = _list_join }
})
for i, date in ipairs(list) do
result[i] = date:text(fmt)
end
return result
end

local function _make_list(date, spec)
-- Return a possibly empty numbered table of dates meeting the specification.
-- The spec should be a string like "Tue >=" meaning that the list will
-- hold dates for all Tuesdays on or after date, and in date's month.
if not is_date(date) then
return 'Need a date (use "date:list()" with a colon).'
end
local want_dow, op
local ops = {
['>='] = { before = false, include = true },
['>'] = { before = false, include = false },
['<='] = { before = true , include = true },
['<'] = { before = true , include = false },
}
if spec then
if type(spec) ~= 'string' then
return {}
end
for item in spec:gmatch('%S+') do
if ops[item] then
if op then
return {}
end
op = ops[item]
else
local dow = day_number(item)
if dow then
if want_dow then
-- LATER Could handle more than one day, but probably not needed.
return {}
end
want_dow = dow
else
return {}
end
end
end
end
local offset = want_dow and want_dow - date.dow or 0
op = op or ops['>=']
local first, last
if op.before then
if offset >= 0 and not (op.include and offset == 0) then
offset = offset - 7
end
last = date.day + offset
first = last % 7
if first == 0 then
first = 7
end
else
if offset < 0 or (not op.include and offset == 0) then
offset = offset + 7
end
first = date.day + offset
last = date.monthdays
end
local list = { text = _list_text }
local count = math.floor((last - first)/7) + 1
for i = 1, count do
list[i] = Date(date, {day = first})
first = first + 7
end
return list
end

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


local function extract_date(text)
local function extract_date(text)
Line 458: Line 560:
local date, options = {}, {}
local date, options = {}, {}
local function extract_ymd(item)
local function extract_ymd(item)
local ystr, mstr, dstr = item:match('^(%d%d%d%d)-(%w+)-(%d%d?)$')
local ystr, mstr, dstr = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
if ystr then
if ystr then
local m
local m
Line 467: Line 569:
end
end
if m then
if m then
date.y = tonumber(ystr)
date.year = tonumber(ystr)
date.m = m
date.month = m
date.d = tonumber(dstr)
date.day = tonumber(dstr)
return true
return true
end
end
Line 478: Line 580:
local m = month_number(item)
local m = month_number(item)
if m then
if m then
date.m = m
date.month = m
return true
return true
end
end
Line 484: Line 586:
local function extract_time(item)
local function extract_time(item)
local h, m, s = item:match('^(%d%d?):(%d%d)(:?%d*)$')
local h, m, s = item:match('^(%d%d?):(%d%d)(:?%d*)$')
if date.H or not h then
if date.hour or not h then
return
return
end
end
Line 493: Line 595:
end
end
end
end
date.H = tonumber(h)
date.hour = tonumber(h)
date.M = tonumber(m)
date.minute = tonumber(m)
date.S = tonumber(s) -- nil if empty string
date.second = tonumber(s) -- nil if empty string
return true
return true
end
end
Line 511: Line 613:
local index_time
local index_time
local function set_ampm(item)
local function set_ampm(item)
local H = date.H
local H = date.hour
if H and not options.am and index_time + 1 == item_count then
if H and not options.am and index_time + 1 == item_count then
options.am = ampm_options[item]
options.am = ampm_options[item]
Line 519: Line 621:
end
end
if H == 12 then
if H == 12 then
date.H = 0
date.hour = 0
end
end
else
else
Line 526: Line 628:
end
end
if H <= 11 then
if H <= 11 then
date.H = H + 12
date.hour = H + 12
end
end
end
end
Line 549: Line 651:
end
end
index_time = item_count
index_time = item_count
elseif date.d and date.m then
elseif date.day and date.month then
if date.y then
if date.year then
return -- should be nothing more so item is invalid
return -- should be nothing more so item is invalid
end
end
Line 556: Line 658:
return
return
end
end
date.y = tonumber(item)
date.year = tonumber(item)
elseif date.d then
elseif date.day then
if not extract_month(item) then
if not extract_month(item) then
return
return
end
end
elseif date.m then
elseif date.month then
if not item:match('^(%d%d?)$') then
if not item:match('^(%d%d?)$') then
return
return
end
end
date.d = tonumber(item)
date.day = tonumber(item)
elseif not extract_ymd(item) then
elseif not extract_ymd(item) then
if item:match('^(%d%d?)$') then
if item:match('^(%d%d?)$') then
date.d = tonumber(item)
date.day = tonumber(item)
elseif not extract_month(item) then
elseif not extract_month(item) then
return
return
Line 574: Line 676:
end
end
end
end
if not date.y or date.y == 0 then
if not date.year or date.year == 0 then
return
return
end
end
local era = era_text[options.era]
local era = era_text[options.era]
if era and era.isbc then
if era and era.isbc then
date.y = 1 - date.y
date.year = 1 - date.year
end
end
return date, options
return date, options
Line 587: Line 689:
-- Return a new date from calculating (lhs + rhs) or (lhs - rhs),
-- Return a new date from calculating (lhs + rhs) or (lhs - rhs),
-- or return nothing if invalid.
-- 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.
-- Caller ensures that lhs is a date; its properties are copied for the new date.
local function is_prefix(text, word, minlen)
local function is_prefix(text, word, minlen)
Line 593: Line 696:
end
end
local function do_days(n)
local function do_days(n)
local forcetime, jd
if is_sub then
n = -n
if math.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
end
return Date(lhs, 'juliandate', lhs.jd + n)
return Date(lhs, 'juliandate', jd)
end
end
if type(rhs) == 'number' then
if type(rhs) == 'number' then
-- Add days, including fractional days.
-- Add/subtract days, including fractional days.
return do_days(rhs)
return do_days(rhs)
end
end
Line 648: Line 762:
end
end


-- Metatable for some operations on dates.
-- Metatable for a date's calculated fields.
local datemt = {
datemt = { -- for forward declaration above
__add = function (lhs, rhs)
if not is_date(lhs) then
lhs, rhs = rhs, lhs -- put date on left (it must be a date for this to have been called)
end
return date_add_sub(lhs, rhs)
end,
__sub = function (lhs, rhs)
if is_date(lhs) then
if is_date(rhs) then
return DateDiff(lhs, rhs)
end
return date_add_sub(lhs, rhs, true)
end
end,
__concat = function (lhs, rhs)
return tostring(lhs) .. tostring(rhs)
end,
__tostring = function (self)
return self:text()
end,
__eq = function (lhs, rhs)
-- Return true if dates identify same date/time where, for example,
-- Date(-4712, 1, 1, 'Julian') == Date(-4713, 11, 24, 'Gregorian') is true.
-- This is only called if lhs and rhs have the same metatable.
return lhs.jdz == rhs.jdz
end,
__lt = function (lhs, rhs)
-- Return true if lhs < rhs, for example,
-- Date('1 Jan 2016') < Date('06:00 1 Jan 2016') is true.
-- This is only called if lhs and rhs have the same metatable.
return lhs.jdz < rhs.jdz
end,
__index = function (self, key)
__index = function (self, key)
local value
local value
Line 689: Line 771:
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
elseif key == 'dayofweek' then
value = self.dow
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 == 'dayofweekiso' then
elseif key == 'dayofweekiso' then
value = self.dowiso
value = self.dowiso
elseif key == 'doy' then
elseif key == 'doy' then
local first = Date(self.year, 1, 1, self.calname).jd
local first = Date(self.year, 1, 1, self.calname).jdnoon
value = self.jd - first + 1 -- day-of-year 1 to 366
value = self.jdnoon - first + 1 -- day-of-year 1 to 366
elseif key == 'dayofyear' then
elseif key == 'dayofyear' then
value = self.doy
value = self.doy
Line 706: Line 788:
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 = math.floor(self.jd - 1721424.5)
elseif key == 'juliandate' or key == 'jd' or key == 'jdz' then
elseif key == 'juliandate' or key == 'jd' or key == 'jdz' then
Line 714: Line 796:
rawset(self, 'jdz', jdz)
rawset(self, 'jdz', jdz)
return key == 'jdz' and jdz or jd
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 = math.floor(self.jd + 0.5)
elseif key == 'isleapyear' then
elseif key == 'isleapyear' then
value = is_leap_year(self.year, self.calname)
value = is_leap_year(self.year, self.calname)
Line 729: Line 814:
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 only called if lhs and rhs have the same metatable.
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 only called if lhs and rhs have the same metatable.
return lhs.jdz < rhs.jdz
end


--[[ Examples of syntax to construct a date:
--[[ Examples of syntax to construct a date:
Line 740: Line 864:
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) copy of an existing date
Date(date, t) same, updated with y,m,d,H,M,S fields from table t
LATER: Following is not yet implemented:
Date('currentdate', H, M, S) current date with given time
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
Line 747: Line 871:
-- (proleptic Gregorian calendar or proleptic Julian calendar), or
-- (proleptic Gregorian calendar or proleptic Julian calendar), or
-- return nothing if date is invalid.
-- return nothing if date is invalid.
local is_copy
local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
local result = {
local newdate = {
_id = uniq,
calname = 'Gregorian', -- default is Gregorian calendar
calname = 'Gregorian', -- default is Gregorian calendar
hastime = false, -- true if input sets a time
hastime = false, -- true if input sets a time
Line 756: Line 880:
second = 0,
second = 0,
options = make_option_table(),
options = make_option_table(),
list = _make_list,
text = _date_text,
text = _date_text,
}
}
local argtype, datetext
local argtype, datetext, is_copy, jd_number, tnums
local numbers = collection()
local numindex = 0
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 766: Line 893:
-- 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.calname = calendars[vlower]
elseif is_date(v) then
elseif is_date(v) then
-- Copy existing date (items can be overridden by other arguments).
-- Copy existing date (items can be overridden by other arguments).
if is_copy then
if is_copy or tnums then
return
return
end
end
is_copy = true
is_copy = true
result.calname = v.calname
newdate.calname = v.calname
result.hastime = v.hastime
newdate.hastime = v.hastime
result.options = v.options
newdate.options = v.options
result.year = v.year
newdate.year = v.year
result.month = v.month
newdate.month = v.month
result.day = v.day
newdate.day = v.day
result.hour = v.hour
newdate.hour = v.hour
result.minute = v.minute
newdate.minute = v.minute
result.second = v.second
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 791: Line 932:
argtype = 'setdate'
argtype = 'setdate'
end
end
if argtype == 'setdate' and numindex < 6 then
numbers:add(num)
numindex = numindex + 1
if argtype == 'juliandate' then
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 ~= math.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
Line 818: Line 964:
end
end
if argtype == 'datetext' then
if argtype == 'datetext' then
if tnums or not set_date_from_numbers(newdate, extract_date(datetext)) then
if not (numbers.n == 0 and
set_date_from_numbers(result,
extract_date(datetext))) then
return
return
end
end
elseif argtype == 'juliandate' then
elseif argtype == 'juliandate' then
result.jd = numbers[1]
newdate.jd = jd_number
if not (numbers.n == 1 and set_date_from_jd(result)) then
if not set_date_from_jd(newdate) then
return
return
end
end
elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
result.year = current.year
newdate.year = current.year
result.month = current.month
newdate.month = current.month
result.day = current.day
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.calname = 'Gregorian' -- ignore any given calendar name
elseif argtype == 'setdate' then
elseif argtype == 'setdate' then
if not set_date_from_numbers(result, numbers) then
if tnums or not set_date_from_numbers(newdate, numbers) then
return
return
end
end
elseif not is_copy then
elseif not (is_copy or tnums) then
return
return
end
end
if tnums then
return setmetatable(result, datemt)
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
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 _age_ym(diff)
-- Return text specifying date difference in years, months.
local sign = diff.isnegative and MINUS or ''
local mtext = number_name(diff.months, 'month')
local result
if diff.years > 0 then
local ytext = number_name(diff.years, 'year')
if diff.months == 0 then
result = ytext
else
result = ytext .. ',&nbsp;' .. mtext
end
else
if diff.months == 0 then
sign = ''
end
result = mtext
end
return sign .. result
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.daystotal)
end,
__index = function (self, key)
local value
if key == 'age_ym' then
value = _age_ym(self)
elseif key == 'daystotal' then
value = self.date1.jdz - self.date2.jdz
end
if value ~= nil then
rawset(self, key, value)
return value
end
end,
}


function DateDiff(date1, date2) -- for forward declaration above
function DateDiff(date1, date2) -- for forward declaration above
-- Return a table with the difference between the two dates (date1 - date2).
-- Return a table with the difference between the two dates (date1 - date2).
-- The difference is negative if date2 is more recent than date1.
-- The difference is negative if date1 is older than date2.
-- Return nothing if invalid.
-- Return nothing if invalid.
if not (is_date(date1) and is_date(date2) and date1.calname == date2.calname) then
if not (is_date(date1) and is_date(date2) and date1.calname == date2.calname) then
return
return
end
end
local isnegative
local isnegative = false
if date1 < date2 then
if date1 < date2 then
isnegative = true
isnegative = true
Line 873: Line 1,078:
years = years - 1
years = years - 1
end
end
return {
return setmetatable({
date1 = date1,
date2 = date2,
years = years,
years = years,
months = months,
months = months,
days = days,
days = days,
isnegative = isnegative,
isnegative = isnegative,
}, diffmt)
age_ym = function (self)
-- Return text specifying difference in years, months.
local sign = self.isnegative and MINUS or ''
local mtext = number_name(self.months, 'month')
local result
if self.years > 0 then
local ytext = number_name(self.years, 'year')
if self.months == 0 then
result = ytext
else
result = ytext .. ',&nbsp;' .. mtext
end
else
if self.months == 0 then
sign = ''
end
result = mtext
end
return sign .. result
end,
}
end
end



Revision as of 10:31, 20 March 2016

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 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.
	-- Otherwise return text (convenient when Date fields are provided from
	-- another module which is able to pass, for example, a number).
	if type(text) == 'string' then
		text = text:match('(%S.-)%s*$')
	end
	return text
end

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

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

local function days_in_month(year, month, calname)
	-- Return number of days (1..31) in given month (1..12).
	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 julian_date(date)
	-- Return jd, jdz from a Julian or Gregorian calendar date where
	--   jd = Julian date and its fractional part is zero at noon
	--   jdz = same, but assume time is 00:00:00 if no time given
	-- http://www.tondering.dk/claus/cal/julperiod.php#formula
	-- Testing shows this works for all dates from year -9999 to 9999!
	-- JDN 0 is the 24-hour period starting at noon UTC on Monday
	--    1 January 4713 BC  = (-4712, 1, 1)   Julian calendar
	--   24 November 4714 BC = (-4713, 11, 24) Gregorian calendar
	local floor = math.floor
	local offset
	local a = floor((14 - date.month)/12)
	local y = date.year + 4800 - a
	if date.calname == 'Julian' then
		offset = floor(y/4) - 32083
	else
		offset = floor(y/4) - floor(y/100) + floor(y/400) - 32045
	end
	local m = date.month + 12*a - 3
	local jd = date.day + floor((153*m + 2)/5) + 365*y + offset
	if date.hastime then
		jd = jd + (date.hour + (date.minute + date.second / 60) /60) / 24 - 0.5
		return jd, jd
	end
	return jd, jd - 0.5
end

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

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

local function make_option_table(options1, options2)
	-- If options1 is a string, return a table with its settings, or
	-- if it is a table, use its settings.
	-- Missing options are set from options2 or defaults.
	-- Valid option settings are:
	-- am: 'am', 'a.m.', 'AM', 'A.M.'
	-- era: 'BCMINUS', 'BCNEGATIVE', 'BC', 'B.C.', 'BCE', 'B.C.E.', 'AD', 'A.D.', 'CE', 'C.E.'
	-- Option am = 'am' does not mean the hour is AM; it means 'am' or 'pm' is used, depending on the hour.
	-- Similarly, era = 'BC' means 'BC' is used if year < 0.
	-- BCMINUS displays a MINUS if year < 0 and the display format does not include %{era}.
	-- BCNEGATIVE is similar but displays a hyphen.
	local result = {}
	if type(options1) == 'table' then
		result = options1
	elseif type(options1) == 'string' then
		-- Example: 'am:AM era:BC'
		for item in options1:gmatch('%S+') do
			local lhs, rhs = item:match('^(%w+)[:=](.+)$')
			if lhs then
				result[lhs] = rhs
			end
		end
	end
	options2 = type(options2) == 'table' and options2 or {}
	local defaults = { am = 'am', era = 'BC' }
	for k, v in pairs(defaults) do
		result[k] = result[k] or options2[k] or v
	end
	return result
end

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

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

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

local function _date_text(date, fmt, options)
	-- Return a formatted string representing the given date.
	if not is_date(date) then
		return 'Need a date (use "date:text()" with a colon).'
	end
	if type(fmt) ~= 'string' then
		fmt = '%-d %B %-Y %{era}'
		if date.hastime then
			if date.second > 0 then
				fmt = '%H:%M:%S ' .. fmt
			else
				fmt = '%H:%M ' .. fmt
			end
		end
		return strftime(date, fmt, options)
	end
	if fmt:find('%', 1, true) then
		return strftime(date, fmt, options)
	end
	local t = collection()
	for item in fmt:gmatch('%S+') do
		local f
		if item == 'hm' then
			f = '%H:%M'
		elseif item == 'hms' then
			f = '%H:%M:%S'
		elseif item == 'ymd' then
			f = '%Y-%m-%d %{era}'
		elseif item == 'mdy' then
			f = '%B %-d, %-Y %{era}'
		elseif item == 'dmy' then
			f = '%-d %B %-Y %{era}'
		else
			return '(invalid format)'
		end
		t:add(f)
	end
	return strftime(date, t:join(' '), options)
end

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

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

local function 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,
		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
		return 'Need "list:text()" with a colon.'
	end
	local result = { join = _list_join }
	for i, date in ipairs(list) do
		result[i] = date:text(fmt)
	end
	return result
end

local function _make_list(date, spec)
	-- Return a possibly empty numbered table of dates meeting the specification.
	-- The spec should be a string like "Tue >=" meaning that the list will
	-- hold dates for all Tuesdays on or after date, and in date's month.
	if not is_date(date) then
		return 'Need a date (use "date:list()" with a colon).'
	end
	local want_dow, op
	local ops = {
		['>='] = { before = false, include = true  },
		['>']  = { before = false, include = false },
		['<='] = { before = true , include = true  },
		['<']  = { before = true , include = false },
	}
	if spec then
		if type(spec) ~= 'string' then
			return {}
		end
		for item in spec:gmatch('%S+') do
			if ops[item] then
				if op then
					return {}
				end
				op = ops[item]
			else
				local dow = day_number(item)
				if dow then
					if want_dow then
						-- LATER Could handle more than one day, but probably not needed.
						return {}
					end
					want_dow = dow
				else
					return {}
				end
			end
		end
	end
	local offset = want_dow and want_dow - date.dow or 0
	op = op or ops['>=']
	local first, last
	if op.before then
		if offset >= 0 and not (op.include and offset == 0) then
			offset = offset - 7
		end
		last = date.day + offset
		first = last % 7
		if first == 0 then
			first = 7
		end
	else
		if offset < 0 or (not op.include and offset == 0) then
			offset = offset + 7
		end
		first = date.day + offset
		last = date.monthdays
	end
	local list = { text = _list_text }
	local count = math.floor((last - first)/7) + 1
	for i = 1, count do
		list[i] = Date(date, {day = first})
		first = first + 7
	end
	return list
end

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

local function extract_date(text)
	-- Parse the date/time in text and return n, o where
	--   n = table of numbers with date/time fields
	--   o = table of options for AM/PM or AD/BC, if any
	-- or return nothing if date is known to be invalid.
	-- Caller determines if the values in n are valid.
	-- A year must be positive ('1' to '9999'); use 'BC' for BC.
	-- In a y-m-d string, the year must be four digits to avoid ambiguity
	-- ('0001' to '9999'). The only way to enter year <= 0 is by specifying
	-- the date as three numeric parameters like ymd Date(-1, 1, 1).
	-- Dates of form d/m/y, m/d/y, y/m/d are rejected as ambiguous.
	local date, options = {}, {}
	local function extract_ymd(item)
		local ystr, mstr, dstr = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
		if ystr then
			local m
			if mstr:match('^%d%d?$') then
				m = tonumber(mstr)
			else
				m = month_number(mstr)
			end
			if m then
				date.year = tonumber(ystr)
				date.month = m
				date.day = tonumber(dstr)
				return true
			end
		end
	end
	local function extract_month(item)
		-- A month must be given as a name or abbreviation; a number would be ambiguous.
		local m = month_number(item)
		if m then
			date.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 ampm_options = {
		['am']   = 'am',
		['AM']   = 'AM',
		['a.m.'] = 'a.m.',
		['A.M.'] = 'A.M.',
		['pm']   = 'am',  -- same as am
		['PM']   = 'AM',
		['p.m.'] = 'a.m.',
		['P.M.'] = 'A.M.',
	}
	local item_count = 0
	local index_time
	local function set_ampm(item)
		local H = date.hour
		if H and not options.am and index_time + 1 == item_count then
			options.am = ampm_options[item]
			if item:match('^[Aa]') then
				if not (1 <= H and H <= 12) then
					return
				end
				if H == 12 then
					date.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(',', ' '):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 item:match('^(%d%d?)$') then
				return
			end
			date.day = tonumber(item)
		elseif not extract_ymd(item) then
			if item:match('^(%d%d?)$') then
				date.day = tonumber(item)
			elseif not extract_month(item) then
				return
			end
		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 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.
	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 math.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' (unsigned integer only).
		local num, id = rhs:match('^%s*(%d+)%s*(%a+)$')
		if num then
			local y, m
			num = tonumber(num)
			id = id:lower()
			if is_prefix(id, 'years') then
				y = num
				m = 0
			elseif is_prefix(id, 'months') then
				y = math.floor(num / 12)
				m = num % 12
			elseif is_prefix(id, 'weeks') then
				return do_days(num * 7)
			elseif is_prefix(id, 'days') then
				return do_days(num)
			elseif is_prefix(id, 'hours') then
				return do_days(num / 24)
			elseif is_prefix(id, 'minutes', 3) then
				return do_days(num / (24 * 60))
			elseif is_prefix(id, 'seconds') then
				return do_days(num / (24 * 3600))
			else
				return
			end
			if is_sub then
				y = -y
				m = -m
			end
			assert(-11 <= m and m <= 11)
			y = lhs.year + y
			m = lhs.month + m
			if m > 12 then
				y = y + 1
				m = m - 12
			elseif m < 1 then
				y = y - 1
				m = m + 12
			end
			local d = math.min(lhs.day, days_in_month(y, m, lhs.calname))
			return Date(lhs, y, m, d)
		end
	end
end

-- Metatable for a date's calculated fields.
local datemt = {
	__index = function (self, key)
		local value
		if key == 'dayabbr' then
			value = day_info[self.dow][1]
		elseif key == 'dayname' then
			value = day_info[self.dow][2]
		elseif key == 'dow' then
			value = (self.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 == 'doy' then
			local first = Date(self.year, 1, 1, self.calname).jdnoon
			value = self.jdnoon - first + 1  -- day-of-year 1 to 366
		elseif key == 'dayofyear' then
			value = self.doy
		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 == '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 = math.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 = math.floor(self.jd + 0.5)
		elseif key == 'isleapyear' then
			value = is_leap_year(self.year, self.calname)
		elseif key == 'monthabbr' then
			value = month_info[self.month][1]
		elseif key == 'monthdays' then
			value = days_in_month(self.year, self.month, self.calname)
		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 only called if lhs and rhs have the same metatable.
	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 only called if lhs and rhs have the same metatable.
	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.
	local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
	local newdate = {
		_id = uniq,
		calname = 'Gregorian',  -- default is Gregorian calendar
		hastime = false,  -- true if input sets a time
		hour = 0,  -- always set hour/minute/second so don't have to handle nil
		minute = 0,
		second = 0,
		options = make_option_table(),
		list = _make_list,
		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.calname = calendars[vlower]
		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.calname = v.calname
			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 ~= math.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(datetext)) then
			return
		end
	elseif argtype == 'juliandate' then
		newdate.jd = jd_number
		if not set_date_from_jd(newdate) then
			return
		end
	elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
		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.calname = '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
	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 _age_ym(diff)
	-- Return text specifying date difference in years, months.
	local sign = diff.isnegative and MINUS or ''
	local mtext = number_name(diff.months, 'month')
	local result
	if diff.years > 0 then
		local ytext = number_name(diff.years, 'year')
		if diff.months == 0 then
			result = ytext
		else
			result = ytext .. ',&nbsp;' .. mtext
		end
	else
		if diff.months == 0 then
			sign = ''
		end
		result = mtext
	end
	return sign .. result
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.daystotal)
	end,
	__index = function (self, key)
		local value
		if key == 'age_ym' then
			value = _age_ym(self)
		elseif key == 'daystotal' then
			value = self.date1.jdz - self.date2.jdz
		end
		if value ~= nil then
			rawset(self, key, value)
			return value
		end
	end,
}

function DateDiff(date1, date2)  -- for forward declaration above
	-- Return a table with the difference between the two dates (date1 - date2).
	-- The difference is negative if date1 is older than date2.
	-- Return nothing if invalid.
	if not (is_date(date1) and is_date(date2) and date1.calname == date2.calname) then
		return
	end
	local isnegative = false
	if date1 < date2 then
		isnegative = true
		date1, date2 = date2, date1
	end
	-- It is known that date1 >= date2.
	local y1, m1 = date1.year, date1.month
	local y2, m2 = date2.year, date2.month
	local years, months, days = y1 - y2, m1 - m2, date1.day - date2.day
	if days < 0 then
		days = days + days_in_month(y2, m2, date2.calname)
		months = months - 1
	end
	if months < 0 then
		months = months + 12
		years = years - 1
	end
	return setmetatable({
		date1 = date1,
		date2 = date2,
		years = years,
		months = months,
		days = days,
		isnegative = isnegative,
	}, diffmt)
end

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