-- 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.