Modul:Date
This module provides date functions for use by other modules. Dates in the Gregorian calendar and the Julian calendar are supported, from 9999 BCE to 9999 CE. The calendars are proleptic—they are assumed to apply at all times with no irregularities.
A date, with an optional time, can be specified in a variety of formats, and can be converted for display using a variety of formats, for example, 1 April 2016 or April 1, 2016. The properties of a date include its Julian date and its Gregorian serial date, as well as the day-of-week and day-of-year.
Dates can be compared (for example, date1 <= date2
), and can be used with add or subtract (for example, date + '3 months'
). The difference between two dates can be determined with date1 - date2
. These operations work with both Gregorian and Julian calendar dates, but date1 - date2
is nil if the two dates use different calendars.
The module provides the following items.
Export | Description |
---|---|
_current |
Table with the current year, month, day, hour, minute, second. |
_Date |
Function that returns a table for a specified date. |
_days_in_month |
Function that returns the number of days in a month. |
The following has examples of using the module:
- Module:Date/example • Demonstration showing how Module:Date may be used.
- Module talk:Date/example • Output from the demonstration.
Formatted output
A date can be formatted as text.
local Date = require('Module:Date')._Date
local text = Date(2016, 7, 1):text() -- result is '1 July 2016'
local text = Date(2016, 7, 1):text('%-d %B') -- result is '1 July'
local text = Date('1 July 2016'):text('mdy') -- result is 'July 1, 2016'
The following simplified formatting codes are available.
Code | Result |
---|---|
hm | hour:minute, with "am" or "pm" or variant, if specified (14:30 or 2:30 pm or variant) |
hms | hour:minute:second (14:30:45) |
ymd | year-month-day (2016-07-01) |
mdy | month day, year (July 1, 2016) |
dmy | day month year (1 July 2016) |
The following formatting codes (similar to strftime) are available.
Code | Result |
---|---|
%a | Day abbreviation: Mon, Tue, ... |
%A | Day name: Monday, Tuesday, ... |
%u | Day of week: 1 to 7 (Monday to Sunday) |
%w | Day of week: 0 to 6 (Sunday to Saturday) |
%d | Day of month zero-padded: 01 to 31 |
%b | Month abbreviation: Jan to Dec |
%B | Month name: January to December |
%m | Month zero-padded: 01 to 12 |
%Y | Year zero-padded: 0012, 0120, 1200 |
%H | Hour 24-hour clock zero-padded: 00 to 23 |
%I | Hour 12-hour clock zero-padded: 01 to 12 |
%p | AM or PM or as in options |
%M | Minute zero-padded: 00 to 59 |
%S | Second zero-padded: 00 to 59 |
%j | Day of year zero-padded: 001 to 366 |
%-d | Day of month: 1 to 31 |
%-m | Month: 1 to 12 |
%-Y | Year: 12, 120, 1200 |
%-H | Hour: 0 to 23 |
%-M | Minute: 0 to 59 |
%-S | Second: 0 to 59 |
%-j | Day of year: 1 to 366 |
%-I | Hour: 1 to 12 |
%% | % |
In addition, %{property}
(where property
is any property of a date) can be used.
For example, Date('1 Feb 2015 14:30:45 A.D.')
has the following properties.
Code | Result |
---|---|
%{calendar} | Gregorian |
%{year} | 2015 |
%{month} | 2 |
%{day} | 1 |
%{hour} | 14 |
%{minute} | 30 |
%{second} | 45 |
%{dayabbr} | Sun |
%{dayname} | Sunday |
%{dayofweek} | 0 |
%{dow} | 0 (same as dayofweek) |
%{dayofweekiso} | 7 |
%{dowiso} | 7 (same as dayofweekiso) |
%{dayofyear} | 32 |
%{era} | A.D. |
%{gsd} | 735630 (numbers of days from 1 January 1 CE; the first is day 1) |
%{juliandate} | 2457055.1046875 (Julian day) |
%{jd} | 2457055.1046875 (same as juliandate) |
%{isleapyear} | false |
%{monthdays} | 28 |
%{monthabbr} | Feb |
%{monthname} | February |
Some shortcuts are available. Given date = Date('1 Feb 2015 14:30')
, the following results would occur.
Code | Description | Example result | Equivalent format |
---|---|---|---|
date:text('%c') | date and time | 2:30 pm 1 February 2015 | %-I:%M %p %-d %B %-Y %{era} |
date:text('%x') | date | 1 February 2015 | %-d %B %-Y %{era} |
date:text('%X') | time | 2:30 pm | %-I:%M %p |
Julian date
The following has an example of converting a Julian date to a date, then obtaining information about the date.
-- Code -- Result
Date = require('Module:Date')._Date
date = Date('juliandate', 320)
number = date.gsd -- -1721105
number = date.jd -- 320
text = date.dayname -- Saturday
text = date:text() -- 9 October 4713 BC
text = date:text('%Y-%m-%d') -- 4713-10-09
text = date:text('%{era} %Y-%m-%d') -- BC 4713-10-09
text = date:text('%Y-%m-%d %{era}') -- 4713-10-09 BC
text = date:text('%Y-%m-%d %{era}', 'era=B.C.E.') -- 4713-10-09 B.C.E.
text = date:text('%Y-%m-%d', 'era=BCNEGATIVE') -- -4712-10-09
text = date:text('%Y-%m-%d', 'era=BCMINUS') -- −4712-10-09 (uses Unicode MINUS SIGN U+2212)
text = Date('juliandate',320):text('%{gsd} %{jd}') -- -1721105 320
text = Date('Oct 9, 4713 B.C.E.'):text('%{gsd} %{jd}') -- -1721105 320
text = Date(-4712,10,9):text('%{gsd} %{jd}') -- -1721105 320
Date differences
The difference between two dates can be determined with date1 - date2
. The result is valid if both dates use the Gregorian calendar or if both dates use the Julian calendar, otherwise the result is nil. An age and duration can be calculated from a date difference.
For example:
-- Code -- Result
Date = require('Module:Date')._Date
date1 = Date('21 Mar 2015')
date2 = Date('4 Dec 1999')
diff = date1 - date2
d = diff.age_days -- 5586
y, m, d = diff.years, diff.months, diff.days -- 15, 3, 17 (15 years + 3 months + 17 days)
y, m, d = diff:age('ymd') -- 15, 3, 17
y, m, w, d = diff:age('ymwd') -- 15, 3, 2, 3 (15 years + 3 months + 2 weeks + 3 days)
y, m, w, d = diff:duration('ymwd') -- 15, 3, 2, 4
d = diff:duration('d') -- 5587 (a duration includes the final day)
A date difference holds the original dates except they are swapped so diff.date1 >= diff.date2
(diff.date1
is the more recent date). This is shown in the following.
date1 = Date('21 Mar 2015')
date2 = Date('4 Dec 1999')
diff = date1 - date2
neg = diff.isnegative -- false
text = diff.date1:text() -- 21 March 2015
text = diff.date2:text() -- 4 December 1999
diff = date2 - date1
neg = diff.isnegative -- true (dates have been swapped)
text = diff.date1:text() -- 21 March 2015
text = diff.date2:text() -- 4 December 1999
A date difference also holds a time difference:
date1 = Date('8 Mar 2016 0:30:45')
date2 = Date('19 Jan 2014 22:55')
diff = date1 - date2
y, m, d = diff.years, diff.months, diff.days -- 2, 1, 17
H, M, S = diff.hours, diff.minutes, diff.seconds -- 1, 35, 45
A date difference can be added to a date, or subtracted from a date.
date1 = Date('8 Mar 2016 0:30:45')
date2 = Date('19 Jan 2014 22:55')
diff = date1 - date2
date3 = date2 + diff
date4 = date1 - diff
text = date3:text('ymd hms') -- 2016-03-08 00:30:45
text = date4:text('ymd hms') -- 2014-01-19 22:55:00
equal = (date1 == date3) -- true
equal = (date2 == date4) -- true
The age and duration methods of a date difference accept a code that identifies the components that should be returned. An extra day is included for the duration method because it includes the final day.
Code | Returned values |
---|---|
'ymwd' |
years, months, weeks, days |
'ymd' |
years, months, days |
'ym' |
years, months |
'y' |
years |
'm' |
months |
'wd' |
weeks, days |
'w' |
weeks |
'd' |
days |
-- Date functions for implementing templates and for use by other modules.
-- I18N and time zones are not supported.
local MINUS = '−' -- Unicode U+2212 MINUS SIGN
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 = function (self, sep)
return table.concat(self, sep)
end,
}
end
local function strip_to_nil(text)
-- Return nil if text is nil or is an empty string after trimming.
-- If text is a non-blank string, return its content after trimming.
-- Otherwise return text (convenient when accessed via another module).
if type(text) == 'string' then
local result = text:match("^%s*(.-)%s*$")
if result == '' then
return nil
end
return result
end
if text == nil then
return nil
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).
local month_days = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
if month == 2 and is_leap_year(year, calname) then
return 29
end
return month_days[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 = similar, but fractional part is zero at 00:00:00
-- 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
if not date.isvalid then
return 0, 0 -- always return numbers to simplify usage
end
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 date_part = date.day + floor((153*m + 2)/5) + 365*y + offset
local time_part, zbias
if date.hastime then
time_part = (date.hour + (date.minute + date.second / 60) /60) / 24 - 0.5
zbias = 0
else
time_part = 0
zbias = -0.5
end
local jd = date_part + time_part
return jd, jd + zbias
end
local function set_date_from_jd(date)
-- Set the fields of table date from its Julian date field.
-- 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 jd = date.jd
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
limits = { 1, 0 } -- impossible
end
if not (limits[1] <= jd and jd <= limits[2]) then
date.isvalid = false
return
end
date.isvalid = true
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)
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.y or numbers[1]
local m = numbers.m or numbers[2]
local d = numbers.d or numbers[3]
local H = numbers.H or numbers[4]
local M = numbers.M or numbers[5] or 0
local S = numbers.S or numbers[6] or 0
if not (y and m and d) then
return
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
return
end
if H then
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
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
date.isvalid = true
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(options)
-- 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
return '(invalid)'
end
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
}
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 or date.options)
local amopt = options.am
local eraopt = options.era
local function replace_code(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 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
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
if shortcuts[format] then
format = shortcuts[format]
end
local PERCENT = '\127PERCENT\127'
return (format
:gsub('%%%%', PERCENT)
:gsub('%%{(%w+)}', replace_property)
:gsub('%%(-?)(%a)', replace_code)
:gsub(PERCENT, '%%')
)
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)
if type(text) == 'string' then
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
-- A table to get the current year/month/day (UTC), but only if needed.
local current = setmetatable({}, {
__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 date_component(named, positional, component)
-- Return the first of the two arguments (named like {{example|year=2001}}
-- or positional like {{example|2001}}) that is not nil and is not empty.
-- If both are nil, return the current date component, if specified.
-- This translates empty arguments passed to the template to nil, and
-- optionally replaces a nil argument with a value from the current date.
named = strip_to_nil(named)
if named then
return named
end
positional = strip_to_nil(positional)
if positional then
return positional
end
if component then
return current[component]
end
return nil
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.' },
}
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.
-- Dates of form d/m/y, m/d/y, y/m/d are rejected as ambiguous and undesirable.
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.y = tonumber(ystr)
date.m = m
date.d = 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.m = m
return true
end
end
local function extract_time(item)
local h, m, s = item:match('^(%d%d?):(%d%d)(:?%d*)$')
if date.H or not h then
return
end
if s ~= '' then
s = s:match('^:(%d%d)$')
if not s then
return
end
end
date.H = tonumber(h)
date.M = tonumber(m)
date.S = 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.H
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.H = 0
end
else
if not (1 <= H and H <= 23) then
return
end
if H <= 11 then
date.H = H + 12
end
end
return true
end
end
for item in text:gsub(',', ' '):gmatch('%S+') do
-- Accept options in peculiar places; if duplicated, last wins.
item_count = item_count + 1
if era_text[item] then
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.d and date.m then
if date.y then
return -- should be nothing more so item is invalid
end
if not item:match('^(%d%d?%d?%d?)$') then
return
end
date.y = tonumber(item)
elseif date.d then
if not extract_month(item) then
return
end
elseif date.m then
if not item:match('^(%d%d?)$') then
return
end
date.d = tonumber(item)
elseif not extract_ymd(item) then
if item:match('^(%d%d?)$') then
date.d = tonumber(item)
elseif not extract_month(item) then
return
end
end
end
return date, options
end
-- Metatable for some operations on dates.
-- For Lua 5.1, __lt does not work if the metatable is an anonymous table.
local Date -- forward declaration
local datemt = {
__eq = function (lhs, rhs)
-- Return true if dates identify same date/time where, for example,
-- (-4712, 1, 1, 'Julian') == (-4713, 11, 24, 'Gregorian').
return lhs.isvalid and rhs.isvalid and lhs.jdz == rhs.jdz
end,
__lt = function (lhs, rhs)
-- Return true if lhs < rhs.
if not lhs.isvalid then
return true
end
if not rhs.isvalid then
return false
end
return lhs.jdz < rhs.jdz
end,
__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.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)
rawset(self, 'jd', jd)
rawset(self, 'jdz', jdz)
return key == 'jd' and jd or jdz
elseif key == 'is_leap_year' then
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
if value ~= nil then
rawset(self, key, value)
return value
end
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')
LATER: Following are not yet implemented:
Date('currentdate', H, M, S) current date with given time
Date('1 April 1995', 'julian') parse date from text
Date('1 April 1995 AD', 'julian') AD, CE, BC, BCE (using one of these sets a flag to do same for output)
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).
-- If invalid, return an empty table which is regarded as invalid.
local calendars = { julian = 'Julian', gregorian = 'Gregorian' }
local result = {
isvalid = false, -- false avoids __index lookup
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,
month_days = function (self, month)
return days_in_month(self.year, month, self.calname)
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,
}
local argtype, datetext
local numbers = collection()
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
result.calname = calendars[vlower]
else
local num = tonumber(v)
if not num and argtype == 'setdate' and numbers.n == 1 then
num = month_number(v)
end
if num then
if not argtype then
argtype = 'setdate'
end
numbers:add(num)
if argtype == 'juliandate' then
if type(v) == 'string' then
if v:find('.', 1, true) then
result.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.
result.hastime = true
end
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 not (numbers.n == 0 and
set_date_from_numbers(result,
extract_date(datetext))) then
return {}
end
elseif argtype == 'juliandate' then
if numbers.n == 1 then
result.jd = numbers[1]
set_date_from_jd(result)
else
return {}
end
elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
result.year = current.year
result.month = current.month
result.day = current.day
if argtype == 'currentdatetime' then
result.hour = current.hour
result.minute = current.minute
result.second = current.second
result.hastime = true
end
result.calname = 'Gregorian' -- ignore any given calendar name
result.isvalid = true
elseif argtype == 'setdate' then
if not set_date_from_numbers(result, numbers) then
return {}
end
else
return {}
end
return setmetatable(result, datemt)
end
local function DateDiff(date1, date2)
-- Return a table to with the difference between the two given dates.
-- Difference is negative if the second date is older than the first.
-- TODO Replace with something using Julian dates?
-- Who checks for isvalid()?
-- Handle calname == 'Julian'
local calname = 'Gregorian' -- TODO fix
local isnegative
if date2 < date1 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 = y2 - y1, m2 - m1, date2.day - date1.day
if days < 0 then
days = days + days_in_month(y1, m1, calname)
months = months - 1
end
if months < 0 then
months = months + 12
years = years - 1
end
return {
years = years,
months = months,
days = days,
isnegative = isnegative,
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 .. ', ' .. mtext
end
else
if self.months == 0 then
sign = ''
end
result = mtext
end
return sign .. result
end,
}
end
local function message(msg, nocat)
-- Return formatted message text for an error.
-- Can append "#FormattingError" to URL of a page with a problem to find it.
local anchor = '<span id="FormattingError" />'
local category
if not nocat and mw.title.getCurrentTitle():inNamespaces(0, 10) then
-- Category only in namespaces: 0=article, 10=template.
category = '[[Category:Age error]]'
else
category = ''
end
return anchor ..
'<strong class="error">Error: ' ..
msg ..
'</strong>' ..
category .. '\n'
end
local function age_days(frame)
-- Return age in days between two given dates, or
-- between given date and current date.
-- This code implements the logic in [[Template:Age in days]].
-- Like {{Age in days}}, a missing argument is replaced from the current
-- date, so can get a bizarre mixture of specified/current y/m/d.
local args = frame:getParent().args
local date1 = Date(
date_component(args.year1 , args[1], 'year' ),
date_component(args.month1, args[2], 'month'),
date_component(args.day1 , args[3], 'day' )
)
local date2 = Date(
date_component(args.year2 , args[4], 'year' ),
date_component(args.month2, args[5], 'month'),
date_component(args.day2 , args[6], 'day' )
)
if not (date1.isvalid and date2.isvalid) then
return message('Need valid year, month, day')
end
local sign = ''
local result = date2.jd - date1.jd
if result < 0 then
sign = MINUS
result = -result
end
return sign .. tostring(result)
end
local function age_ym(frame)
-- Return age in years and months between two given dates, or
-- between given date and current date.
local args = frame:getParent().args
local fields = {}
for i = 1, 6 do
fields[i] = strip_to_nil(args[i])
end
local date1, date2
if fields[1] and fields[2] and fields[3] then
date1 = Date(fields[1], fields[2], fields[3])
end
if not (date1 and date1.isvalid) then
return message('Need valid year, month, day')
end
if fields[4] and fields[5] and fields[6] then
date2 = Date(fields[4], fields[5], fields[6])
if not date2.isvalid then
return message('Second date should be year, month, day')
end
else
date2 = Date('currentdate')
end
return DateDiff(date1, date2):age_ym()
end
local function gsd_ymd(frame)
-- Return Gregorian serial date of the given date, or the current date.
-- Like {{Gregorian serial date}}, a missing argument is replaced from the
-- current date, so can get a bizarre mixture of specified/current y/m/d.
-- This also accepts positional arguments, although the original template does not.
-- The returned value is negative for dates before 1 January 1 AD despite
-- the fact that GSD is not defined for earlier dates.
local args = frame:getParent().args
local date = Date(
date_component(args.year , args[1], 'year' ),
date_component(args.month, args[2], 'month'),
date_component(args.day , args[3], 'day' )
)
if date.isvalid then
return tostring(date.gsd)
end
return message('Need valid year, month, day')
end
local function ymd_from_jd(frame)
-- Return formatted date from a Julian date.
-- The result is y-m-d or y-m-d H:M:S if input includes a fraction.
-- The word 'Julian' is accepted for the Julian calendar.
local args = frame:getParent().args
local date = Date('juliandate', args[1], args[2])
if date.isvalid then
return date:text()
end
return message('Need valid Julian date number')
end
local function ymd_to_jd(frame)
-- Return Julian date (a number) from a date (y-m-d), or datetime (y-m-d H:M:S),
-- or the current date ('currentdate') or current datetime ('currentdatetime').
-- The word 'Julian' is accepted for the Julian calendar.
local args = frame:getParent().args
local date = Date(args[1], args[2], args[3], args[4], args[5], args[6], args[7])
if date.isvalid then
return tostring(date.jd)
end
return message('Need valid year/month/day or "currentdate"')
end
return {
age_days = age_days,
age_ym = age_ym,
_Date = Date,
days_in_month = days_in_month,
gsd = gsd_ymd,
JULIANDAY = ymd_to_jd,
ymd_from_jd = ymd_from_jd,
ymd_to_jd = ymd_to_jd,
}