Mô đun:Date

-- Date functions for use by other modules.
-- I18N and time zones are not supported.

local MINUS = '−' -- Unicode U+2212 MINUS SIGN
local floor = math.floor

local Date, DateDiff, diffmt -- forward declarations
local uniq = { 'unique identifier' }

local function is_date(t)
-- The system used to make a date read-only means there is no unique
-- metatable that is conveniently accessible to check.
return type(t) 'table' and t._id uniq
end

local function is_diff(t)
return type(t) 'table' and getmetatable(t) diffmt
end

local function _list_join(list, sep)
return table.concat(list, sep)
end

local function collection()
-- Return a table to hold items.
return {
n = 0,
add = function (self, item)
self.n = self.n + 1
self[self.n] = item
end,
join = _list_join,
}
end

local function strip_to_nil(text)
-- If text is a string, return its trimmed content, or nil if empty.
-- Otherwise return text (convenient when Date fields are provided from
-- another module which may pass a string, a number, or another type).
if type(text) == 'string' then
text = text:match('(%S.-)%s$')
end
return text
end

local function is_leap_year(year, calname)
-- Return true if year is a leap year.
if calname 'Julian' then
return year % 4 0
end
return (year % 4 == 0 and year % 100 ~= 0) or year % 400 0
end

local function days_in_month(year, month, calname)
-- Return number of days (1..31) in given month (1..12).
if month 2 and is_leap_year(year, calname) then
return 29
end
return ({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 })[month]
end

local function h_m_s(time)
-- Return hour, minute, second extracted from fraction of a day.
time = floor(time 24 3600 + 0.5) -- number of seconds
local second = time % 60
time = floor(time / 60)
return floor(time / 60), time % 60, second
end

local function hms(date)
-- Return fraction of a day from date's time, where (0 = 0.5 then -- if at or after midnight of next day
jdn = jdn + 1
time = time - 0.5
else
time = time + 0.5
end
date.hour, date.minute, date.second = h_m_s(time)
else
date.second = 0
date.minute = 0
date.hour = 0
end
local b, c
if calname == 'Julian' then
b = 0
c = jdn + 32082
else -- Gregorian
local a = jdn + 32044
b = floor((4a + 3)/146097)
c = a - floor(146097b/4)
end
local d = floor((4c + 3)/1461)
local e = c - floor(1461d/4)
local m = floor((5e + 2)/153)
date.day = e - floor((153m + 2)/5) + 1
date.month = m + 3 - 12floor(m/10)
date.year = 100b + d - 4800 + floor(m/10)
return true
end

local function fix_numbers(numbers, y, m, d, H, M, S, partial, hastime, calendar)
-- Put the result of normalizing the given values in table numbers.
-- The result will have valid m, d values if y is valid; caller checks y.
-- The logic of PHP mktime is followed where m or d can be zero to mean
-- the previous unit, and -1 is the one before that, etc.
-- Positive values carry forward.
local date
if not (1 0 }
['BCMINUS'] = { 'BC' , , isbc = true, sign = MINUS },
['BCNEGATIVE'] = { 'BC' , , isbc = true, sign = '-' },
['BC'] = { 'BC' , , isbc = true },
['B.C.'] = { 'B.C.' , , isbc = true },
['BCE'] = { 'BCE' , , isbc = true },
['B.C.E.'] = { 'B.C.E.', , isbc = true },
['AD'] = { 'BC' , 'AD' },
['A.D.'] = { 'B.C.' , 'A.D.' },
['CE'] = { 'BCE' , 'CE' },
['C.E.'] = { 'B.C.E.', 'C.E.' },
}

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

local function strftime(date, format, options)
-- Return date formatted as a string using codes similar to those
-- in the C strftime library function.
local sformat = string.format
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
}
if shortcuts[format] then
format = shortcuts[format]
end
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 = 'dayofyear' },
I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
p = { field = 'hour', special = 'am' },
}
options = make_option_table(options, date.options)
local amopt = options.am
local eraopt = options.era
local function replace_code(spaces, 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]
if not value then
return nil -- an undefined field in a partial date
end
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.' },
})[ampm_options[amopt]] or { 'am', 'pm' }
return (spaces == and or '&nbsp;') .. (value = 0 then
sign =
else
value = -value
end
end
return spaces .. sign .. sformat(fmt, value)
end
return spaces .. (fmt and sformat(fmt, value) or value)
end
end
local function replace_property(spaces, id)
if id == 'era' then
-- Special case so can use local era option.
local result = get_era_for_year(eraopt, date.year)
if result then
return
end
return (spaces and or '&nbsp;') .. result
end
local result = date[id]
if type(result) 'string' then
return spaces .. result
end
if type(result) 'number' then
return spaces .. tostring(result)
end
if type(result) == 'boolean' then
return spaces .. (result and '1' or '0')
end
-- This occurs if id is an undefined field in a partial date, or is the name of a function.
return nil
end
local PERCENT = '\127PERCENT\127'
return (format
:gsub('%%%%', PERCENT)
:gsub('(%s)%%{(%w+)}', replace_property)
:gsub('(%s)%%(%-?)(%a)', replace_code)
:gsub(PERCENT, '%%')
)
end

local function _date_text(date, fmt, options)
-- Return a formatted string representing the given date.
if not is_date(date) then
error('date:text: need a date (use "date:text()" with a colon)', 2)
end
if type(fmt) == 'string' and fmt:match('%S') then
if fmt:find('%', 1, true) then
return strftime(date, fmt, options)
end
elseif date.partial then
fmt = date.month and 'my' or 'y'
else
fmt = 'dmy'
if date.hastime then
fmt = (date.second > 0 and 'hms ' or 'hm ') .. fmt
end
end
local function bad_format()
-- 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)
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()
for item in fmt:gmatch('%S+') do
local f
if item == 'hm' then
f = hm_fmt()
need_time = false
elseif item == 'hms' then
f = '%H:%M:%S'
need_time = false
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 bad_format()
end
t:add(f)
end
fmt = t:join(' ')
if need_time then
fmt = hm_fmt() .. ' ' .. fmt
end
return strftime(date, fmt, 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 name_to_number(text, translate)
if type(text) == 'string' then
return translate[text:lower()]
end
end

local function day_number(text)
return name_to_number(text, {
sun = 0, sunday = 0,
mon = 1, monday = 1,
tue = 2, tuesday = 2,
wed = 3, wednesday = 3,
thu = 4, thursday = 4,
fri = 5, friday = 5,
sat = 6, saturday = 6,
})
end

local function month_number(text)
return name_to_number(text, {
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, sept = 9,
oct = 10, october = 10,
nov = 11, november = 11,
dec = 12, december = 12,
})
end

local function _list_text(list, fmt)
-- Return a list of formatted strings from a list of dates.
if not type(list) == 'table' then
error('date:list:text: need "list:text()" with a colon', 2)
end
local result = { join = _list_join }
for i, date in ipairs(list) do
result[i] = date:text(fmt)
end
return result
end

local function _date_list(date, spec)
-- 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 of form " "
-- where each item is optional and
-- count = number of items wanted in list
-- day = abbreviation or name such as Mon or Monday
-- op = >, >=, 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 '
if not is_date(date) then
error('date:list: need a date (use "date:list()" with a colon)', 2)
end
local list = { text = _list_text }
if date.partial then
return list
end
local count, offset, operation
local ops = {
['>='] = { before = false, include = true },
['>'] = { before = false, include = false },
['=])%s$')
if not num then
return list
end
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
end
offset = dow - date.dow
end
operation = ops[op]
else
return list
end
end
offset = offset or 0
operation = operation or ops['>']
local datefrom, dayfirst, daylast
if operation.before then
if offset > 0 or (offset == 0 and not operation.include) then
offset = offset - 7
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
else
if offset 0 then
y = -y
end
if y 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)
-- Called when no day or month has been set.
local y, m, d = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
if y then
if date.year then
return
end
if m:match('^%d%d?$') then
m = tonumber(m)
else
m = month_number(m)
end
if m then
date.year = tonumber(y)
date.month = m
date.day = tonumber(d)
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 12 then
y = y + 1
m = m - 12
elseif 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
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
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
return y
end
return nil
end

local function _diff_duration(diff, code, options)
if type(options) ~= 'table' then
options = { round = options }
end
options.duration = true
return _diff_age(diff, code, options)
end

-- Metatable for some operations on date differences.
diffmt = { -- for forward declaration above
__concat = function (lhs, rhs)
return tostring(lhs) .. tostring(rhs)
end,
__tostring = function (self)
return tostring(self.age_days)
end,
__index = function (self, key)
local value
if key == 'age_days' then
if rawget(self, 'partial') then
local function jdz(date)
return (date.partial and date.partial.first or date).jdz
end
value = jdz(self.date1) - jdz(self.date2)
else
value = self.date1.jdz - self.date2.jdz
end
end
if value ~= nil then
rawset(self, key, value)
return value
end
end,
}

function DateDiff(date1, date2, options) -- for forward declaration above
-- Return a table with the difference between two dates (date1 - date2).
-- The difference is negative if date1 is older than date2.
-- Return nothing if invalid.
-- If d = date1 - date2 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
end
local wantfill
if type(options) 'table' then
wantfill = options.fill
end
local isnegative = false
local iszero = false
if date1 = date2 (period is from date2 to date1).
if date1.partial or date2.partial then
-- Two partial dates might have timelines:
---------------------AB--- date1 is from A to B inclusive
--------CD-------------------------- 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
local y1, m1 = date1.year, date1.month
local y2, m2 = date2.year, date2.month
local years = y1 - y2
local months = m1 - m2
local d1 = date1.day + hms(date1)
local d2 = date2.day + hms(date2)
local days, time
if d1 >= d2 then
days = d1 - d2
else
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
if months < 0 then
years = years - 1
months = months + 12
end
days, time = math.modf(days)
local H, M, S = h_m_s(time)
return setmetatable({
date1 = date1,
date2 = date2,
partial = false, -- avoid index lookup
years = years,
months = months,
days = days,
hours = H,
minutes = M,
seconds = S,
isnegative = isnegative,
iszero = iszero,
age = _diff_age,
duration = _diff_duration,
}, diffmt)
end

return {
_current = current,
_Date = Date,
_days_in_month = days_in_month,
}