Module:Date: Difference between revisions
formatted output and strftime
(enhanced version of Module:Age which will be replaced by this module as it provides generic date functions) |
(formatted output and strftime) |
||
Line 45: | Line 45: | ||
local function is_leap_year(year, calname) | local function is_leap_year(year, calname) | ||
-- Return true if year is a leap year. | -- Return true if year is a leap year. | ||
if calname == ' | if calname == 'Julian' then | ||
return year % 4 == 0 | return year % 4 == 0 | ||
end | end | ||
Line 66: | Line 66: | ||
-- http://www.tondering.dk/claus/cal/julperiod.php#formula | -- http://www.tondering.dk/claus/cal/julperiod.php#formula | ||
-- Testing shows this works for all dates from year -9999 to 9999! | -- Testing shows this works for all dates from year -9999 to 9999! | ||
-- JDN 0 is the 24-hour period starting at noon UTC on | -- JDN 0 is the 24-hour period starting at noon UTC on Monday | ||
-- 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 | ||
Line 76: | Line 76: | ||
local a = floor((14 - date.month)/12) | local a = floor((14 - date.month)/12) | ||
local y = date.year + 4800 - a | local y = date.year + 4800 - a | ||
if date.calname == ' | if date.calname == 'Julian' then | ||
offset = floor(y/4) - 32083 | offset = floor(y/4) - 32083 | ||
else | else | ||
Line 104: | Line 104: | ||
local jd = date.jd | 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 == ' | if calname == 'Julian' then | ||
limits = { -1931076.5, 5373557. | limits = { -1931076.5, 5373557.49999 } | ||
elseif calname == ' | elseif calname == 'Gregorian' then | ||
limits = { -1930999.5, 5373484. | limits = { -1930999.5, 5373484.49999 } | ||
else | else | ||
limits = { 1, 0 } -- impossible | limits = { 1, 0 } -- impossible | ||
end | end | ||
if not (limits[1] <= jd and jd < limits[2]) then | if not (limits[1] <= jd and jd <= limits[2]) then | ||
date.isvalid = false | date.isvalid = false | ||
return | return | ||
Line 138: | Line 138: | ||
end | end | ||
local b, c | local b, c | ||
if calname == ' | if calname == 'Julian' then | ||
b = 0 | b = 0 | ||
c = jdn + 32082 | c = jdn + 32082 | ||
Line 154: | Line 154: | ||
end | end | ||
local function | local function make_option_table(options) | ||
-- Return formatted string | -- If options is a string, return a table with its settings. | ||
-- Otherwise return options (it should already be a table). | |||
if type(options) == 'string' then | |||
-- Example: 'am:AM era:BC' | |||
local result = {} | |||
for item in options:gmatch('%S+') do | |||
local lhs, rhs = item:match('^(%w+):(.*)$') | |||
if lhs then | |||
result[lhs] = rhs | |||
end | |||
end | |||
return result | |||
end | |||
return options | |||
end | |||
local function strftime(date, format, options) | |||
-- Return date formatted as a string using codes similar to those | |||
-- in the C strftime library function. | |||
if not date.isvalid then | if not date.isvalid then | ||
return '(invalid)' | return '(invalid)' | ||
end | end | ||
local | local codes = { | ||
a = { field = 'dayabbr' }, | |||
A = { field = 'dayname' }, | |||
b = { field = 'monthabbr' }, | |||
B = { field = 'monthname' }, | |||
date.minute, | u = { fmt = '%d' , field = 'dowiso' }, | ||
w = { fmt = '%d' , field = 'dow' }, | |||
d = { fmt = '%02d', field = 'day' }, | |||
m = { fmt = '%02d', field = 'month' }, | |||
Y = { fmt = '%04d', field = 'year' }, | |||
H = { fmt = '%02d', field = 'hour' }, | |||
M = { fmt = '%02d', field = 'minute' }, | |||
) | S = { fmt = '%02d', field = 'second' }, | ||
j = { fmt = '%03d', field = 'doy' }, | |||
I = { fmt = '%02d', field = 'hour', special = 'hour12' }, | |||
p = { field = 'hour', special = 'am' }, | |||
X = { fmt = '%02d:%02d:%02d', field = 'hour', special = 'hms' }, | |||
['-d'] = { fmt = '%d', field = 'day' }, | |||
['-m'] = { fmt = '%d', field = 'month' }, | |||
['-Y'] = { fmt = '%d', field = 'year' }, | |||
['-j'] = { fmt = '%d', field = 'doy' }, | |||
} | |||
options = make_option_table(options or date.options) | |||
local amopt = options.am | |||
local eraopt = options.era | |||
local function replace_code(id) | |||
local code = codes[id] | |||
if code then | |||
local fmt = code.fmt | |||
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 == 'hms' then | |||
return string.format(fmt, value, date.minute, date.second) | |||
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 value < 12 and ap[1] or ap[2] | |||
end | |||
end | |||
if code.field == 'year' then | |||
if eraopt == 'BCMINUS' or eraopt == 'BCNEGATIVE' then | |||
local sign | |||
if value >= 0 then | |||
sign = '' | |||
else | |||
sign = eraopt == 'BCMINUS' and MINUS or '-' | |||
value = -value | |||
end | |||
return sign .. string.format(fmt, value) | |||
end | |||
if value <= 0 then | |||
value = 1 - value | |||
end | |||
end | |||
return fmt and string.format(fmt, value) or value | |||
end | end | ||
end | end | ||
local | local function replace_property(id) | ||
local result = date[id] | |||
if type(result) == 'string' then | |||
if id == 'era' and result ~= '' then | |||
-- Assume era follows a date. | |||
return ' ' .. result | |||
end | |||
return result | |||
end | |||
if type(result) == 'number' then | |||
return tostring(result) | |||
end | |||
if type(result) == 'boolean' then | |||
return result and '1' or '0' | |||
end | |||
-- This occurs, for example, if id is the name of a function. | |||
return nil | |||
end | end | ||
return | local PERCENT = '\127PERCENT\127' | ||
return (format | |||
:gsub('%%%%', PERCENT) | |||
:gsub('%%{(%w+)}', replace_property) | |||
:gsub('%%(-?%a)', replace_code) | |||
:gsub(PERCENT, '%%') | |||
) | ) | ||
end | end | ||
local function date_text(date, fmt, options) | |||
-- Return formatted string from given date. | |||
if not (type(date) == 'table' and date.isvalid) then | |||
return '(invalid)' | |||
end | |||
if type(fmt) ~= 'string' then | |||
fmt = '%Y-%m-%d' | |||
if date.hastime then | |||
if date.second > 0 then | |||
fmt = fmt .. ' %H:%M:%S' | |||
else | |||
fmt = fmt .. ' %H:%M' | |||
end | |||
end | |||
return strftime(date, fmt, options or { era = 'BCMINUS' }) | |||
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 month_number(text) | local function month_number(text) | ||
Line 242: | Line 388: | ||
return nil | return nil | ||
end | end | ||
local era_text = { | |||
-- options.era = { year<0 , year>0 } | |||
['BCMINUS'] = { MINUS , '' }, | |||
['BCNEGATIVE'] = { '-' , '' }, | |||
['BC'] = { 'BC' , '' }, | |||
['B.C.'] = { 'B.C.' , '' }, | |||
['BCE'] = { 'BCE' , '' }, | |||
['B.C.E.'] = { 'B.C.E.', '' }, | |||
['AD'] = { 'BC' , 'AD' }, | |||
['A.D.'] = { 'B.C.' , 'A.D.' }, | |||
['CE'] = { 'BCE' , 'CE' }, | |||
['C.E.'] = { 'B.C.E.', 'C.E.' }, | |||
} | |||
-- Metatable for some operations on dates. | -- Metatable for some operations on dates. | ||
-- For Lua 5.1, __lt does not work if the metatable is an anonymous table. | -- For Lua 5.1, __lt does not work if the metatable is an anonymous table. | ||
local Date -- forward declaration | |||
local datemt = { | local datemt = { | ||
__eq = function (lhs, rhs) | __eq = function (lhs, rhs) | ||
Line 263: | Line 424: | ||
__index = function (self, key) | __index = function (self, key) | ||
local value | local value | ||
if key == 'jd' or key == 'jdz' then | 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.jd + 1) % 7 -- day-of-week 0=Sun to 6=Sat | |||
elseif key == 'dowiso' then | |||
value = (self.jd % 7) + 1 -- ISO day-of-week 1=Mon to 7=Sun | |||
elseif key == 'doy' then | |||
local first = Date(self.year, 1, 1, self.calname).jd | |||
value = self.jd - first + 1 -- day-of-year 1 to 366 | |||
elseif key == 'era' then | |||
-- Era text from year and options. | |||
local eraopt = self.options.era | |||
local sign | |||
if self.year == 0 then | |||
sign = (eraopt == 'BCMINUS' or eraopt == 'BCNEGATIVE') and 2 or 1 | |||
else | |||
sign = self.year > 0 and 2 or 1 | |||
end | |||
value = era_text[eraopt][sign] | |||
elseif key == 'gsd' then | |||
-- 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. | |||
value = math.floor(self.jd - 1721424.5) | |||
elseif key == 'jd' or key == 'jdz' then | |||
local jd, jdz = julian_date(self) | local jd, jdz = julian_date(self) | ||
rawset(self, 'jd', jd) | rawset(self, 'jd', jd) | ||
rawset(self, 'jdz', jdz) | rawset(self, 'jdz', jdz) | ||
return key == 'jd' and jd or jdz | return key == 'jd' and jd or jdz | ||
elseif key == 'is_leap_year' then | elseif key == 'is_leap_year' then | ||
value = is_leap_year(self.year, self.calname) | value = is_leap_year(self.year, self.calname) | ||
elseif key == 'monthabbr' then | |||
value = month_info[self.month][1] | |||
elseif key == 'monthname' then | |||
value = month_info[self.month][2] | |||
end | end | ||
if value ~= nil then | if value ~= nil then | ||
Line 294: | Line 480: | ||
Date('04:30:59 1 April 1995', 'julian') | Date('04:30:59 1 April 1995', 'julian') | ||
]] | ]] | ||
function Date(...) -- for forward declaration above | |||
-- Return a table to hold a date assuming a uniform calendar always applies (proleptic). | -- Return a table to hold a date assuming a uniform calendar always applies (proleptic). | ||
-- If invalid, return an empty table which is regarded as invalid. | -- If invalid, return an empty table which is regarded as invalid. | ||
local calendars = { julian = ' | local calendars = { julian = 'Julian', gregorian = 'Gregorian' } | ||
local result = { | local result = { | ||
isvalid = false, | isvalid = false, -- false avoids __index lookup | ||
calname = ' | calname = 'Gregorian', -- default is Gregorian calendar | ||
hastime = false, | hastime = false, -- true if input sets a time | ||
hour = 0, | hour = 0, -- always set hour/minute/second so don't have to handle nil | ||
minute = 0, | minute = 0, | ||
second = 0, | second = 0, | ||
Line 308: | Line 494: | ||
return days_in_month(self.year, month, self.calname) | return days_in_month(self.year, month, self.calname) | ||
end, | end, | ||
-- 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.' | |||
options = { am = 'am', era = 'BC' }, | |||
text = date_text, | text = date_text, | ||
} | } | ||
Line 315: | Line 505: | ||
for _, v in ipairs({...}) do | for _, v in ipairs({...}) do | ||
v = strip_to_nil(v) | v = strip_to_nil(v) | ||
local vlower = type(v) == 'string' and v:lower() or nil | |||
if v == nil then | if v == nil then | ||
-- 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[ | elseif calendars[vlower] then | ||
result.calname = calendars[ | result.calname = calendars[vlower] | ||
else | else | ||
local num = tonumber(v) | local num = tonumber(v) | ||
Line 376: | Line 567: | ||
result.hastime = true | result.hastime = true | ||
end | end | ||
result.calname = ' | result.calname = 'Gregorian' -- ignore any given calendar name | ||
result.isvalid = true | result.isvalid = true | ||
elseif argtype == 'setdate' then | elseif argtype == 'setdate' then | ||
Line 418: | Line 609: | ||
-- TODO Replace with something using Julian dates? | -- TODO Replace with something using Julian dates? | ||
-- Who checks for isvalid()? | -- Who checks for isvalid()? | ||
-- Handle calname == ' | -- Handle calname == 'Julian' | ||
local calname = ' | local calname = 'Gregorian' -- TODO fix | ||
local isnegative | local isnegative | ||
if date2 < date1 then | if date2 < date1 then |