Module:Date: Difference between revisions
date:list() now accepts a count; DateDiff methods age and duration; cleaner member names; remember if time entered with an am/pm option for default output
(rework date differences for more consistent years/months/days; differences include hours/minutes/seconds; can add 'date + diff'; tweaks) |
(date:list() now accepts a count; DateDiff methods age and duration; cleaner member names; remember if time entered with an am/pm option for default output) |
||
Line 85: | Line 85: | ||
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. | if date.calendar == 'Julian' then | ||
offset = floor(y/4) - 32083 | offset = floor(y/4) - 32083 | ||
else | else | ||
Line 105: | Line 105: | ||
-- 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. | local calname = date.calendar | ||
local low, high -- min/max limits for date ranges −9999-01-01 to 9999-12-31 | local low, high -- min/max limits for date ranges −9999-01-01 to 9999-12-31 | ||
if calname == 'Gregorian' then | if calname == 'Gregorian' then | ||
Line 166: | Line 166: | ||
(-9999 <= y and y <= 9999 and | (-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. | 1 <= d and d <= days_in_month(y, m, date.calendar)) then | ||
return | return | ||
end | end | ||
Line 199: | Line 199: | ||
-- 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 options2 or defaults. | -- 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: | -- Valid option settings are: | ||
-- am: 'am', 'a.m.', 'AM', 'A.M.' | -- am: 'am', 'a.m.', 'AM', 'A.M.' | ||
Line 209: | Line 210: | ||
-- 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 = {} | local result = { bydefault = {} } | ||
if type(options1) == 'table' then | if type(options1) == 'table' then | ||
result = options1 | result.am = options1.am | ||
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' or 'am=AM era=BC'. | ||
Line 224: | Line 226: | ||
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 | ||
result[k] | if not result[k] then | ||
if options2[k] then | |||
result[k] = options2[k] | |||
else | |||
result[k] = v | |||
result.bydefault[k] = true | |||
end | |||
end | |||
end | end | ||
return result | return result | ||
Line 287: | Line 296: | ||
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 = ' | j = { fmt = '%03d', fmt2 = '%d', field = 'dayofyear' }, | ||
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 372: | Line 381: | ||
end | end | ||
if type(fmt) ~= 'string' then | if type(fmt) ~= 'string' then | ||
fmt = ' | fmt = 'dmy' | ||
if date.hastime then | if date.hastime then | ||
fmt = (date.second > 0 and 'hms ' or 'hm ') .. fmt | |||
end | end | ||
elseif fmt:find('%', 1, true) then | |||
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 | 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 = | f = hm_fmt() | ||
need_time = false | |||
elseif item == 'hms' then | elseif item == 'hms' then | ||
f = '%H:%M:%S' | f = '%H:%M:%S' | ||
need_time = false | |||
elseif item == 'ymd' then | elseif item == 'ymd' then | ||
f = '%Y-%m-%d %{era}' | f = '%Y-%m-%d %{era}' | ||
Line 403: | Line 413: | ||
t:add(f) | t:add(f) | ||
end | end | ||
fmt = t:join(' ') | |||
if need_time then | |||
fmt = hm_fmt() .. ' ' .. fmt | |||
end | |||
return strftime(date, fmt, options) | |||
end | end | ||
Line 480: | Line 494: | ||
end | end | ||
local function | local function _date_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. | ||
-- The spec should be a string | -- Dates in the list are in ascending order (oldest date first). | ||
-- | -- The spec should be a string of form "<count> <day> <op>" | ||
-- where each item is optional and | |||
-- count = number of items wanted in list | |||
-- day = abbreviation or name such as Mon or Monday | |||
-- op = >, >=, <, <= (default is > meaning after date) | |||
-- If no count is given, the list is for the specified days in date's month. | |||
-- The default day is date's day. | |||
-- The spec can also be a positive or negative number: | |||
-- -5 is equivalent to '5 <' | |||
-- 5 is equivalent to '5' which is '5 >' | |||
local list = { text = _list_text } | |||
if not is_date(date) then | if not is_date(date) then | ||
return 'Need a date (use "date:list()" with a colon).' | return 'Need a date (use "date:list()" with a colon).' | ||
end | end | ||
local | local count, offset, operation | ||
local ops = { | local ops = { | ||
['>='] = { before = false, include = true }, | ['>='] = { before = false, include = true }, | ||
Line 495: | Line 519: | ||
} | } | ||
if spec then | if spec then | ||
if type(spec) | if type(spec) == 'number' then | ||
count = floor(spec + 0.5) | |||
if count < 0 then | |||
count = -count | |||
if | operation = ops['<'] | ||
end | |||
elseif type(spec) == 'string' then | |||
local num, day, op = spec:match('^%s*(%d*)%s*(%a*)%s*([<>=]*)%s*$') | |||
if not num then | |||
return list | |||
local dow = day_number( | end | ||
if dow then | if num ~= '' then | ||
count = tonumber(num) | |||
end | |||
if day ~= '' then | |||
local dow = day_number(day:gsub('[sS]$', '')) -- accept plural days | |||
if not dow then | |||
return list | |||
return | |||
end | end | ||
offset = dow - date.dow | |||
end | end | ||
operation = ops[op] | |||
else | |||
return list | |||
end | end | ||
end | end | ||
offset = offset or 0 | |||
operation = operation or ops['>'] | |||
local | local datefrom, dayfirst, daylast | ||
if | if operation.before then | ||
if offset >= 0 and not | if offset > 0 or (offset == 0 and not operation.include) then | ||
offset = offset - 7 | offset = offset - 7 | ||
end | end | ||
if count then | |||
if count > 1 then | |||
offset = offset - 7*(count - 1) | |||
end | |||
datefrom = date + offset | |||
else | |||
daylast = date.day + offset | |||
dayfirst = daylast % 7 | |||
if dayfirst == 0 then | |||
dayfirst = 7 | |||
end | |||
end | end | ||
else | else | ||
if offset < 0 or ( | if offset < 0 or (offset == 0 and not operation.include) then | ||
offset = offset + 7 | offset = offset + 7 | ||
end | end | ||
if count then | |||
datefrom = date + offset | |||
else | |||
dayfirst = date.day + offset | |||
daylast = date.monthdays | |||
end | |||
end | |||
if not count then | |||
if daylast < dayfirst then | |||
return list | |||
end | |||
count = floor((daylast - dayfirst)/7) + 1 | |||
datefrom = Date(date, {day = dayfirst}) | |||
end | end | ||
for i = 1, count do | for i = 1, count do | ||
list[i] = | if not datefrom then break end -- exceeds date limits | ||
list[i] = datefrom | |||
datefrom = datefrom + 7 | |||
end | end | ||
return list | return list | ||
Line 760: | Line 804: | ||
m = m + 12 | m = m + 12 | ||
end | end | ||
local d = math.min(lhs.day, days_in_month(y, m, lhs. | local d = math.min(lhs.day, days_in_month(y, m, lhs.calendar)) | ||
return Date(lhs, y, m, d) | return Date(lhs, y, m, d) | ||
end | end | ||
end | end | ||
if is_diff(rhs) then | if is_diff(rhs) then | ||
local days = rhs. | local days = rhs.age_days | ||
if (is_sub or false) ~= (rhs.isnegative or false) then | if (is_sub or false) ~= (rhs.isnegative or false) then | ||
days = -days | days = -days | ||
Line 789: | Line 833: | ||
elseif key == 'dayofweekiso' then | elseif key == 'dayofweekiso' then | ||
value = self.dowiso | value = self.dowiso | ||
elseif key == ' | elseif key == 'dayofyear' then | ||
local first = Date(self.year, 1, 1, self. | local first = Date(self.year, 1, 1, self.calendar).jdnoon | ||
value = self.jdnoon - first + 1 -- day-of-year 1 to 366 | 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. | ||
Line 811: | Line 853: | ||
value = floor(self.jd + 0.5) | value = floor(self.jd + 0.5) | ||
elseif key == 'isleapyear' then | elseif key == 'isleapyear' then | ||
value = is_leap_year(self.year, self. | value = is_leap_year(self.year, self.calendar) | ||
elseif key == 'monthabbr' then | elseif key == 'monthabbr' then | ||
value = month_info[self.month][1] | value = month_info[self.month][1] | ||
elseif key == 'monthdays' then | elseif key == 'monthdays' then | ||
value = days_in_month(self.year, self.month, self. | value = days_in_month(self.year, self.month, self.calendar) | ||
elseif key == 'monthname' then | elseif key == 'monthname' then | ||
value = month_info[self.month][2] | value = month_info[self.month][2] | ||
Line 885: | Line 927: | ||
local newdate = { | local newdate = { | ||
_id = uniq, | _id = uniq, | ||
calendar = 'Gregorian', -- default is Gregorian calendar | |||
hastime = false, -- true if input sets a time | hastime = false, -- true if input sets a time | ||
hour = 0, -- always set hour/minute/second so don't have to handle nil | hour = 0, -- always set hour/minute/second so don't have to handle nil | ||
minute = 0, | minute = 0, | ||
second = 0, | second = 0, | ||
options = | options = {}, | ||
list = | list = _date_list, | ||
text = _date_text, | text = _date_text, | ||
} | } | ||
Line 904: | Line 946: | ||
-- 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. | newdate.calendar = 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). | ||
Line 911: | Line 953: | ||
end | end | ||
is_copy = true | is_copy = true | ||
newdate. | newdate.calendar = v.calendar | ||
newdate.hastime = v.hastime | newdate.hastime = v.hastime | ||
newdate.options = v.options | newdate.options = v.options | ||
Line 993: | Line 1,035: | ||
newdate.hastime = true | newdate.hastime = true | ||
end | end | ||
newdate. | newdate.calendar = '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,020: | Line 1,062: | ||
} | } | ||
return setmetatable(readonly, mt) | return setmetatable(readonly, mt) | ||
end | |||
local function _diff_age(diff, code, options) | |||
-- Return a tuple of values from diff as specified by code. | |||
-- If options == 'duration', an extra day is added. | |||
if not is_diff(diff) then | |||
local f = options == 'duration' and 'duration' or 'age' | |||
return 'Need a date difference (use "diff:' .. f .. '()" with a colon).' | |||
end | |||
local extra_day = options == 'duration' and 1 or 0 | |||
if code == 'wd' or code == 'w' or code == 'd' then | |||
local d = diff.age_days + extra_day | |||
if code == 'd' then | |||
return d | |||
end | |||
local w = floor(d / 7) | |||
if code == 'w' then | |||
return w | |||
end | |||
return w, d % 7 | |||
end | |||
local y, m, d = diff.years, diff.months, diff.days | |||
if extra_day > 0 then | |||
d = d + extra_day | |||
local to_date = diff.date1 | |||
if d > days_in_month(to_date.year, to_date.month, to_date.calendar) then | |||
d = 1 | |||
m = m + 1 | |||
if m > 12 then | |||
m = 1 | |||
y = y + 1 | |||
end | |||
end | |||
end | |||
if code == 'ymd' then | |||
return y, m, d | |||
end | |||
if code == 'ym' then | |||
return y, m | |||
end | |||
if code == 'm' then | |||
return y * 12 + m | |||
end | |||
if code == 'ymwd' then | |||
return y, m, floor(d / 7), d % 7 | |||
end | |||
return y -- default: assume code == 'y'; ignore invalid codes | |||
end | |||
local function _diff_duration(diff, code) | |||
return _diff_age(diff, code, 'duration') | |||
end | end | ||
Line 1,028: | Line 1,121: | ||
end, | end, | ||
__tostring = function (self) | __tostring = function (self) | ||
return tostring(self. | return tostring(self.age_days) | ||
end, | end, | ||
__index = function (self, key) | __index = function (self, key) | ||
local value | local value | ||
if key == ' | if key == 'age_days' then | ||
value = self.date1.jdz - self.date2.jdz | value = self.date1.jdz - self.date2.jdz | ||
end | end | ||
Line 1,055: | Line 1,148: | ||
-- d = Date(2015,3,3) - Date(2015,1,31) | -- d = Date(2015,3,3) - Date(2015,1,31) | ||
-- gives d.years, d.months, d.days = 0, 1, 3 (excluding date1). | -- gives d.years, d.months, d.days = 0, 1, 3 (excluding date1). | ||
if not (is_date(date1) and is_date(date2) and date1. | if not (is_date(date1) and is_date(date2) and date1.calendar == date2.calendar) then | ||
return | return | ||
end | end | ||
Line 1,076: | Line 1,169: | ||
months = months - 1 | months = months - 1 | ||
-- Get days in previous month (before the "to" date) given December has 31 days. | -- 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. | local dpm = m1 > 1 and days_in_month(y1, m1 - 1, date1.calendar) or 31 | ||
if d2 >= dpm then | if d2 >= dpm then | ||
days = d1 | days = d1 | ||
Line 1,099: | Line 1,192: | ||
seconds = S, | seconds = S, | ||
isnegative = isnegative, | isnegative = isnegative, | ||
age = _diff_age, | |||
duration = _diff_duration, | |||
}, diffmt) | }, diffmt) | ||
end | end |