Editing Module:Date

Warning: You are not logged in. Your IP address will be publicly visible if you make any edits. Read the Privacy Policy to learn what information we collect about you and how we use it.

If you log in or create an account, your edits will be attributed to your username, along with other benefits.

The edit can be undone. Please check the comparison below to verify that this is what you want to do, and then publish the changes below to finish undoing the edit.

Latest revision Your text
Line 3: Line 3:


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 Date, DateDiff, diffmt  -- forward declarations
Line 35: Line 34:


local function strip_to_nil(text)
local function strip_to_nil(text)
-- If text is a string, return its trimmed content, or nil if empty.
-- If text is a string, return its trimmed content, or nil.
-- Otherwise return text (convenient when Date fields are provided from
-- Otherwise return text (convenient when Date fields are provided from
-- another module which may pass a string, a number, or another type).
-- another module which is able to pass, for example, a number).
if type(text) == 'string' then
if type(text) == 'string' then
text = text:match('(%S.-)%s*$')
text = text:match('(%S.-)%s*$')
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 58: Line 65:
end
end
return ({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 })[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 83: Line 76:
--    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
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.calendar == 'Julian' then
if date.calname == 'Julian' then
offset = floor(y/4) - 32083
offset = floor(y/4) - 32083
else
else
Line 94: Line 88:
local jd = date.day + floor((153*m + 2)/5) + 365*y + offset
local jd = date.day + floor((153*m + 2)/5) + 365*y + offset
if date.hastime then
if date.hastime then
jd = jd + hms(date) - 0.5
jd = jd + (date.hour + (date.minute + date.second / 60) /60) / 24 - 0.5
return jd, jd
return jd, jd
end
end
Line 106: Line 100:
-- 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 calname = date.calendar
local floor = math.floor
local low, high -- min/max limits for date ranges −9999-01-01 to 9999-12-31
local calname = date.calname
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
return
return
end
end
local jd = date.jd
local jd = date.jd
if not (type(jd) == 'number' and low <= jd and jd <= high) then
if not (type(jd) == 'number' and limits[1] <= jd and jd <= limits[2]) then
return
return
end
end
local jdn = floor(jd)
local jdn = floor(jd)
if date.hastime then
if date.hastime then
local time = jd - jdn -- 0 <= time < 1
local time = jd - jdn
if time >= 0.5 then   -- if at or after midnight of next day
local hour
if time >= 0.5 then
jdn = jdn + 1
jdn = jdn + 1
time = time - 0.5
time = time - 0.5
hour = 0
else
else
time = time + 0.5
hour = 12
end
end
date.hour, date.minute, date.second = h_m_s(time)
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
else
date.second = 0
date.second = 0
Line 150: Line 151:
date.year = 100*b + d - 4800 + floor(m/10)
date.year = 100*b + d - 4800 + floor(m/10)
return true
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
end


Line 207: Line 165:
local M = numbers.minute or date.minute or 0
local M = numbers.minute or date.minute or 0
local S = numbers.second or date.second or 0
local S = numbers.second or date.second or 0
local need_fix
if not (y and m and d) or not
if y and m and d then
(-9999 <= y and y <= 9999 and
date.partial = nil
if not (-9999 <= y and y <= 9999 and
1 <= m and m <= 12 and
1 <= m and m <= 12 and
1 <= d and d <= days_in_month(y, m, date.calendar)) then
1 <= d and d <= days_in_month(y, m, date.calname)) 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
return
end
end
if date.partial then
if H then
H = nil  -- ignore any time
date.hastime = true
M = nil
S = nil
else
else
if H then
H = date.hour or 0
-- 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
end
date.want_fix = nil
if not (0 <= H and H <= 23 and
if need_fix then
0 <= M and M <= 59 and
fix_numbers(numbers, y, m, d, H, M, S, date.partial, date.hastime, date.calendar)
0 <= S and S <= 59) then
return set_date_from_numbers(date, numbers, options)
return
end
end
date.year = y    -- -9999 to 9999 ('n BC' → year = 1 - n)
date.year = y    -- -9999 to 9999 ('n BC' → year = 1 - n)
date.month = m  -- 1 to 12 (may be nil if partial)
date.month = m  -- 1 to 12
date.day = d    -- 1 to 31 (* = nil if partial)
date.day = d    -- 1 to 31
date.hour = H    -- 0 to 59 (*)
date.hour = H    -- 0 to 59
date.minute = M  -- 0 to 59 (*)
date.minute = M  -- 0 to 59
date.second = S  -- 0 to 59 (*)
date.second = S  -- 0 to 59
if type(options) == 'table' then
if type(options) == 'table' then
for _, k in ipairs({ 'am', 'era', 'format' }) do
for _, k in ipairs({ 'am', 'era' }) do
if options[k] then
if options[k] then
date.options[k] = options[k]
date.options[k] = options[k]
Line 276: Line 200:
-- If options1 is a string, return a table with its settings, or
-- If options1 is a string, return a table with its settings, or
-- if it is a table, use its settings.
-- if it is a table, use its settings.
-- Missing options are set from table options2 or defaults.
-- Missing options are set from 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:
-- Valid option settings are:
-- am: 'am', 'a.m.', 'AM', 'A.M.'
-- 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.'
-- 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,
-- 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.
-- 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}.
-- BCMINUS displays a MINUS if year < 0 and the display format does not include %{era}.
-- BCNEGATIVE is similar but displays a hyphen.
-- BCNEGATIVE is similar but displays a hyphen.
local result = { bydefault = {} }
local result = {}
if type(options1) == 'table' then
if type(options1) == 'table' then
result.am = options1.am
result = options1
result.era = options1.era
elseif type(options1) == 'string' then
elseif type(options1) == 'string' then
-- Example: 'am:AM era:BC' or '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+)[:=](.+)$')
Line 303: Line 223:
local defaults = { am = 'am', era = 'BC' }
local defaults = { am = 'am', era = 'BC' }
for k, v in pairs(defaults) do
for k, v in pairs(defaults) do
if not result[k] then
result[k] = result[k] or options2[k] or v
if options2[k] then
result[k] = options2[k]
else
result[k] = v
result.bydefault[k] = true
end
end
end
end
return result
return result
end
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 = {
local era_text = {
Line 345: Line 245:


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


Line 373: Line 273:
M = { fmt = '%02d', fmt2 = '%d', field = 'minute' },
M = { fmt = '%02d', fmt2 = '%d', field = 'minute' },
S = { fmt = '%02d', fmt2 = '%d', field = 'second' },
S = { fmt = '%02d', fmt2 = '%d', field = 'second' },
j = { fmt = '%03d', fmt2 = '%d', field = 'dayofyear' },
j = { fmt = '%03d', fmt2 = '%d', field = 'doy' },
I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
p = { field = 'hour', special = 'am' },
p = { field = 'hour', special = 'am' },
Line 388: Line 288:
end
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 401: Line 298:
['AM'] = { 'AM', 'PM' },
['AM'] = { 'AM', 'PM' },
['A.M.'] = { 'A.M.', 'P.M.' },
['A.M.'] = { 'A.M.', 'P.M.' },
})[ampm_options[amopt]] or { 'am', 'pm' }
})[amopt] or { 'am', 'pm' }
return (spaces == '' and '' or '&nbsp;') .. (value < 12 and ap[1] or ap[2])
return (spaces == '' and '' or '&nbsp;') .. (value < 12 and ap[1] or ap[2])
end
end
Line 443: Line 340:
return  spaces .. (result and '1' or '0')
return  spaces .. (result and '1' or '0')
end
end
-- This occurs if id is an undefined field in a partial date, or is the name of a function.
-- This occurs, for example, if id is the name of a function.
return nil
return nil
end
end
Line 458: Line 355:
-- Return a formatted string representing the given date.
-- Return a formatted string representing the given date.
if not is_date(date) then
if not is_date(date) then
error('date:text: need a date (use "date:text()" with a colon)', 2)
return 'Need a date (use "date:text()" with a colon).'
end
end
if type(fmt) == 'string' and fmt:match('%S') then
if type(fmt) ~= 'string' then
if fmt:find('%', 1, true) then
fmt = '%-d %B %-Y %{era}'
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
fmt = (date.second > 0 and 'hms ' or 'hm ') .. fmt
if date.second > 0 then
fmt = '%H:%M:%S ' .. fmt
else
fmt = '%H:%M ' .. fmt
end
end
end
return strftime(date, fmt, options)
end
end
local function bad_format()
if fmt:find('%', 1, true) then
-- 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 = hm_fmt()
f = '%H:%M'
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}'
Line 511: Line 385:
f = '%-d %B %-Y %{era}'
f = '%-d %B %-Y %{era}'
else
else
return bad_format()
return '(invalid format)'
end
end
t:add(f)
t:add(f)
end
end
fmt = t:join(' ')
return strftime(date, t:join(' '), options)
if need_time then
fmt = hm_fmt() .. ' ' .. fmt
end
return strftime(date, fmt, options)
end
end


Line 577: Line 447:
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,
Line 587: Line 457:
-- Return a list of formatted strings from a list of dates.
-- Return a list of formatted strings from a list of dates.
if not type(list) == 'table' then
if not type(list) == 'table' then
error('date:list:text: need "list:text()" with a colon', 2)
return 'Need "list:text()" with a colon.'
end
end
local result = { join = _list_join }
local result = { join = _list_join }
Line 596: Line 466:
end
end


local function _date_list(date, spec)
local function _make_list(date, spec)
-- Return a possibly empty numbered table of dates meeting the specification.
-- 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 like "Tue >=" meaning that the list will
-- The spec should be a string of form "<count> <day> <op>"
-- hold dates for all Tuesdays on or after date, and in date's month.
-- 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
if not is_date(date) then
error('date:list: need a date (use "date:list()" with a colon)', 2)
return 'Need a date (use "date:list()" with a colon).'
end
local list = { text = _list_text }
if date.partial then
return list
end
end
local count, offset, operation
local want_dow, op
local ops = {
local ops = {
['>='] = { before = false, include = true  },
['>='] = { before = false, include = true  },
Line 624: Line 481:
}
}
if spec then
if spec then
if type(spec) == 'number' then
if type(spec) ~= 'string' then
count = floor(spec + 0.5)
return {}
if count < 0 then
end
count = -count
for item in spec:gmatch('%S+') do
operation = ops['<']
if ops[item] then
end
if op then
elseif type(spec) == 'string' then
return {}
local num, day, op = spec:match('^%s*(%d*)%s*(%a*)%s*([<>=]*)%s*$')
end
if not num then
op = ops[item]
return list
else
end
local dow = day_number(item)
if num ~= '' then
if dow then
count = tonumber(num)
if want_dow then
end
-- LATER Could handle more than one day, but probably not needed.
if day ~= '' then
return {}
local dow = day_number(day:gsub('[sS]$', '')) -- accept plural days
end
if not dow then
want_dow = dow
return list
else
return {}
end
end
offset = dow - date.dow
end
end
operation = ops[op]
else
return list
end
end
end
end
offset = offset or 0
local offset = want_dow and want_dow - date.dow or 0
operation = operation or ops['>']
op = op or ops['>=']
local datefrom, dayfirst, daylast
local first, last
if operation.before then
if op.before then
if offset > 0 or (offset == 0 and not operation.include) then
if offset >= 0 and not (op.include and offset == 0) then
offset = offset - 7
offset = offset - 7
end
end
if count then
last = date.day + offset
if count > 1 then
first = last % 7
offset = offset - 7*(count - 1)
if first == 0 then
end
first = 7
datefrom = date + offset
else
daylast = date.day + offset
dayfirst = daylast % 7
if dayfirst == 0 then
dayfirst = 7
end
end
end
else
else
if offset < 0 or (offset == 0 and not operation.include) then
if offset < 0 or (not op.include and offset == 0) then
offset = offset + 7
offset = offset + 7
end
end
if count then
first = date.day + offset
datefrom = date + offset
last = date.monthdays
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
end
local list = { text = _list_text }
local count = math.floor((last - first)/7) + 1
for i = 1, count do
for i = 1, count do
if not datefrom then break end  -- exceeds date limits
list[i] = Date(date, {day = first})
list[i] = datefrom
first = first + 7
datefrom = datefrom + 7
end
end
return list
return list
Line 696: Line 533:


-- A table to get the current date/time (UTC), but only if needed.
-- A table to get the current date/time (UTC), but only if needed.
local current = setmetatable({}, {
-- A local test can set the global variable to produce fixed results.
local current = setmetatable(
set_current_for_test or {}, {
__index = function (self, key)
__index = function (self, key)
local d = os.date('!*t')
local d = os.date('!*t')
Line 708: Line 547:
end })
end })


local function extract_date(newdate, text)
local function extract_date(text)
-- Parse the date/time in text and return n, o where
-- Parse the date/time in text and return n, o where
--  n = table of numbers with date/time fields
--  n = table of numbers with date/time fields
--  o = table of options for AM/PM or AD/BC or format, if any
--  o = table of options for AM/PM or AD/BC, if any
-- or return nothing if date is known to be invalid.
-- or return nothing if date is known to be invalid.
-- Caller determines if the values in n are valid.
-- Caller determines if the values in n are valid.
Line 718: Line 557:
-- ('0001' to '9999'). The only way to enter year <= 0 is by specifying
-- ('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).
-- 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.
-- Dates of form d/m/y, m/d/y, y/m/d are rejected as ambiguous.
local date, options = {}, {}
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)
local function extract_ymd(item)
-- Called when no day or month has been set.
local ystr, mstr, dstr = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
local y, m, d = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
if ystr then
if y then
local m
if date.year then
if mstr:match('^%d%d?$') then
return
m = tonumber(mstr)
end
if m:match('^%d%d?$') then
m = tonumber(m)
else
else
m = month_number(m)
m = month_number(mstr)
end
end
if m then
if m then
date.year = tonumber(y)
date.year = tonumber(ystr)
date.month = m
date.month = m
date.day = tonumber(d)
date.day = tonumber(dstr)
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
return true
end
end
Line 797: Line 577:
end
end
local function extract_month(item)
local function extract_month(item)
-- A month must be given as a name or abbreviation; a number could be ambiguous.
-- A month must be given as a name or abbreviation; a number would be ambiguous.
local m = month_number(item)
local m = month_number(item)
if m then
if m then
Line 820: Line 600:
return true
return true
end
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 item_count = 0
local index_time
local index_time
Line 825: Line 615:
local H = date.hour
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] -- caller checked this is not nil
options.am = ampm_options[item]
if item:match('^[Aa]') then
if item:match('^[Aa]') then
if not (1 <= H and H <= 12) then
if not (1 <= H and H <= 12) then
Line 844: Line 634:
end
end
end
end
for item in text:gsub(',', ' '):gsub('&nbsp;', ' '):gmatch('%S+') do
for item in text:gsub(',', ' '):gmatch('%S+') do
item_count = item_count + 1
item_count = item_count + 1
if era_text[item] then
if era_text[item] then
Line 874: Line 664:
end
end
elseif date.month then
elseif date.month then
if not extract_day_or_year(item) then
if not item:match('^(%d%d?)$') then
return
return
end
end
elseif extract_month(item) then
date.day = tonumber(item)
options.format = 'mdy'
elseif not extract_ymd(item) then
elseif extract_ymd(item) then
if item:match('^(%d%d?)$') then
options.format = 'ymd'
date.day = tonumber(item)
elseif extract_day_or_year(item) then
elseif not extract_month(item) then
if date.day then
return
options.format = 'dmy'
end
end
else
return
end
end
end
end
Line 897: Line 684:
end
end
return date, options
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
end


Line 932: Line 691:
-- The result is nil if the calculated date exceeds allowable limits.
-- 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.
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 function is_prefix(text, word, minlen)
local n = #text
local n = #text
Line 943: Line 697:
local function do_days(n)
local function do_days(n)
local forcetime, jd
local forcetime, jd
if floor(n) == n then
if math.floor(n) == n then
jd = lhs.jd
jd = lhs.jd
else
else
Line 963: Line 717:
end
end
if type(rhs) == 'string' then
if type(rhs) == 'string' then
-- rhs is a single component like '26m' or '26 months' (with optional sign).
-- rhs is a single component like '26m' or '26 months' (unsigned integer only).
-- Fractions like '3.25d' are accepted for the units which are handled as days.
local num, id = rhs:match('^%s*(%d+)%s*(%a+)$')
local sign, numstr, id = rhs:match('^%s*([+-]?)([%d%.]+)%s*(%a+)$')
if num then
if sign then
local y, m
if sign == '-' then
num = tonumber(num)
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()
id = id:lower()
if is_prefix(id, 'years') then
if is_prefix(id, 'years') then
Line 980: Line 727:
m = 0
m = 0
elseif is_prefix(id, 'months') then
elseif is_prefix(id, 'months') then
y = floor(num / 12)
y = math.floor(num / 12)
m = num % 12
m = num % 12
elseif is_prefix(id, 'weeks') then
elseif is_prefix(id, 'weeks') then
days = num * 7
return do_days(num * 7)
elseif is_prefix(id, 'days') then
elseif is_prefix(id, 'days') then
days = num
return do_days(num)
elseif is_prefix(id, 'hours') then
elseif is_prefix(id, 'hours') then
days = num / 24
return do_days(num / 24)
elseif is_prefix(id, 'minutes', 3) then
elseif is_prefix(id, 'minutes', 3) then
days = num / (24 * 60)
return do_days(num / (24 * 60))
elseif is_prefix(id, 'seconds') then
elseif is_prefix(id, 'seconds') then
days = num / (24 * 3600)
return do_days(num / (24 * 3600))
else
else
return
end
if days then
return do_days(days)
end
if numstr:find('.', 1, true) then
return
return
end
end
Line 1,015: Line 756:
m = m + 12
m = m + 12
end
end
local d = math.min(lhs.day, days_in_month(y, m, lhs.calendar))
local d = math.min(lhs.day, days_in_month(y, m, lhs.calname))
return Date(lhs, y, m, d)
return Date(lhs, y, m, d)
end
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
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.
-- Metatable for a date's calculated fields.
local datemt = {
local datemt = {
__index = function (self, key)
__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
local value
if key == 'dayabbr' then
if key == 'dayabbr' then
Line 1,065: Line 778:
elseif key == 'dayofweekiso' then
elseif key == 'dayofweekiso' then
value = self.dowiso
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
elseif key == 'dayofyear' then
local first = Date(self.year, 1, 1, self.calendar).jdnoon
value = self.doy
value = self.jdnoon - first + 1  -- day-of-year 1 to 366
elseif key == 'era' then
elseif key == 'era' then
-- Era text (never a negative sign) from year and options.
-- Era text (never a negative sign) from year and options.
value = get_era_for_year(self.options.era, self.year)
value = get_era_for_year(self.options.era, self.year)
elseif key == 'format' then
value = self.options.format or 'dmy'
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 from jd 1721425.5 to 1721426.49999.
-- which is from jd 1721425.5 to 1721426.49999.
value = 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
local jd, jdz = julian_date(self)
local jd, jdz = julian_date(self)
Line 1,085: Line 798:
elseif key == 'jdnoon' then
elseif key == 'jdnoon' then
-- Julian date at noon (an integer) on the calendar day when jd occurs.
-- Julian date at noon (an integer) on the calendar day when jd occurs.
value = floor(self.jd + 0.5)
value = math.floor(self.jd + 0.5)
elseif key == 'isleapyear' then
elseif key == 'isleapyear' then
value = is_leap_year(self.year, self.calendar)
value = is_leap_year(self.year, self.calname)
elseif key == 'monthabbr' then
elseif key == 'monthabbr' then
value = month_info[self.month][1]
value = month_info[self.month][1]
elseif key == 'monthdays' then
elseif key == 'monthdays' then
value = days_in_month(self.year, self.month, self.calendar)
value = days_in_month(self.year, self.month, self.calname)
elseif key == 'monthname' then
elseif key == 'monthname' then
value = month_info[self.month][2]
value = month_info[self.month][2]
Line 1,130: Line 843:
-- Return true if dates identify same date/time where, for example,
-- Return true if dates identify same date/time where, for example,
-- Date(-4712, 1, 1, 'Julian') == Date(-4713, 11, 24, 'Gregorian') is true.
-- 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.
-- This is only called if lhs and rhs have the same metatable.
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
return lhs.jdz == rhs.jdz
end
end
Line 1,142: Line 850:
-- Return true if lhs < rhs, for example,
-- Return true if lhs < rhs, for example,
-- Date('1 Jan 2016') < Date('06:00 1 Jan 2016') is true.
-- 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.
-- This is only called if lhs and rhs have the same metatable.
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
return lhs.jdz < rhs.jdz
end
end
Line 1,175: 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.
-- 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 newdate = {
local newdate = {
_id = uniq,
_id = uniq,
calendar = '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
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,
options = {},
options = make_option_table(),
list = _date_list,
list = _make_list,
subtract = function (self, rhs, options)
return DateDiff(self, rhs, options)
end,
text = _date_text,
text = _date_text,
}
}
Line 1,203: 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
newdate.calendar = calendars[vlower]
newdate.calname = calendars[vlower]
elseif vlower == 'partial' then
newdate.partial = true
elseif vlower == 'fix' then
newdate.want_fix = true
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).
Line 1,214: Line 900:
end
end
is_copy = true
is_copy = true
newdate.calendar = v.calendar
newdate.calname = v.calname
newdate.partial = v.partial
newdate.hastime = v.hastime
newdate.hastime = v.hastime
newdate.options = v.options
newdate.options = v.options
Line 1,256: Line 941:
newdate.hastime = true
newdate.hastime = true
end
end
elseif num ~= 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.
Line 1,279: Line 964:
end
end
if argtype == 'datetext' then
if argtype == 'datetext' then
if tnums or not set_date_from_numbers(newdate, extract_date(newdate, datetext)) then
if tnums or not set_date_from_numbers(newdate, extract_date(datetext)) then
return
return
end
end
elseif argtype == 'juliandate' then
elseif argtype == 'juliandate' then
newdate.partial = nil
newdate.jd = jd_number
newdate.jd = jd_number
if not set_date_from_jd(newdate) then
if not set_date_from_jd(newdate) then
Line 1,289: Line 973:
end
end
elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
newdate.partial = nil
newdate.year = current.year
newdate.year = current.year
newdate.month = current.month
newdate.month = current.month
Line 1,299: Line 982:
newdate.hastime = true
newdate.hastime = true
end
end
newdate.calendar = 'Gregorian'  -- ignore any given calendar name
newdate.calname = 'Gregorian'  -- ignore any given calendar name
elseif argtype == 'setdate' then
elseif argtype == 'setdate' then
if tnums or not set_date_from_numbers(newdate, numbers) then
if tnums or not set_date_from_numbers(newdate, numbers) then
Line 1,312: Line 995:
return
return
end
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
end
setmetatable(newdate, datemt)
setmetatable(newdate, datemt)
Line 1,327: Line 1,000:
local mt = {
local mt = {
__index = newdate,
__index = newdate,
__newindex = function(t, k, v) error('date.' .. tostring(k) .. ' is read-only', 2) end,
__newindex = function(t, k, v) error('Date.' .. tostring(k) .. ' is read-only', 2) end,
__add = mt_date_add,
__add = mt_date_add,
__sub = mt_date_sub,
__sub = mt_date_sub,
Line 1,338: Line 1,011:
end
end


local function _diff_age(diff, code, options)
local function _age_ym(diff)
-- Return a tuple of integer values from diff as specified by code, except that
-- Return text specifying date difference in years, months.
-- each integer may be a list of two integers for a diff with a partial date, or
local sign = diff.isnegative and MINUS or ''
-- return nil if the code is not supported.
local mtext = number_name(diff.months, 'month')
-- If want round, the least significant unit is rounded to nearest whole unit.
local result
-- For a duration, an extra day is added.
if diff.years > 0 then
local wantround, wantduration, wantrange
local ytext = number_name(diff.years, 'year')
if type(options) == 'table' then
if diff.months == 0 then
wantround = options.round
result = ytext
wantduration = options.duration
else
wantrange = options.range
result = ytext .. ',&nbsp;' .. mtext
end
else
else
wantround = options
if diff.months == 0 then
end
sign = ''
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
end
return y
result = mtext
end
return nil
end
 
local function _diff_duration(diff, code, options)
if type(options) ~= 'table' then
options = { round = options }
end
end
options.duration = true
return sign .. result
return _diff_age(diff, code, options)
end
end


Line 1,558: Line 1,038:
end,
end,
__tostring = function (self)
__tostring = function (self)
return tostring(self.age_days)
return tostring(self.daystotal)
end,
end,
__index = function (self, key)
__index = function (self, key)
local value
local value
if key == 'age_days' then
if key == 'age_ym' then
if rawget(self, 'partial') then
value = _age_ym(self)
local function jdz(date)
elseif key == 'daystotal' then
return (date.partial and date.partial.first or date).jdz
value = self.date1.jdz - self.date2.jdz
end
value = jdz(self.date1) - jdz(self.date2)
else
value = self.date1.jdz - self.date2.jdz
end
end
end
if value ~= nil then
if value ~= nil then
Line 1,579: Line 1,054:
}
}


function DateDiff(date1, date2, options)  -- for forward declaration above
function DateDiff(date1, date2)  -- for forward declaration above
-- Return a table with the difference between two dates (date1 - date2).
-- Return a table with the difference between the two dates (date1 - date2).
-- The difference is negative if date1 is older than date2.
-- The difference is negative if date1 is older than date2.
-- Return nothing if invalid.
-- Return nothing if invalid.
-- If d = date1 - date2 then
if not (is_date(date1) and is_date(date2) and date1.calname == date2.calname) 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
return
end
local wantfill
if type(options) == 'table' then
wantfill = options.fill
end
end
local isnegative = false
local isnegative = false
local iszero = false
if date1 < date2 then
if date1 < date2 then
isnegative = true
isnegative = true
date1, date2 = date2, date1
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
end
-- It is known that date1 >= date2.
local y1, m1 = date1.year, date1.month
local y1, m1 = date1.year, date1.month
local y2, m2 = date2.year, date2.month
local y2, m2 = date2.year, date2.month
local years = y1 - y2
local years, months, days = y1 - y2, m1 - m2, date1.day - date2.day
local months = m1 - m2
if days < 0 then
local d1 = date1.day + hms(date1)
days = days + days_in_month(y2, m2, date2.calname)
local d2 = date2.day + hms(date2)
local days, time
if d1 >= d2 then
days = d1 - d2
else
months = months - 1
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
if months < 0 then
if months < 0 then
months = months + 12
years = years - 1
years = years - 1
months = months + 12
end
end
days, time = math.modf(days)
local H, M, S = h_m_s(time)
return setmetatable({
return setmetatable({
date1 = date1,
date1 = date1,
date2 = date2,
date2 = date2,
partial = false,  -- avoid index lookup
years = years,
years = years,
months = months,
months = months,
days = days,
days = days,
hours = H,
minutes = M,
seconds = S,
isnegative = isnegative,
isnegative = isnegative,
iszero = iszero,
age = _diff_age,
duration = _diff_duration,
}, diffmt)
}, diffmt)
end
end
Please note that all contributions to Nonbinary Wiki are considered to be released under the Creative Commons Attribution-ShareAlike (see Nonbinary Wiki:Copyrights for details). If you do not want your writing to be edited mercilessly and redistributed at will, then do not submit it here.
You are also promising us that you wrote this yourself, or copied it from a public domain or similar free resource. Do not submit copyrighted work without permission!
Cancel Editing help (opens in new window)

Template used on this page: