mirror of
https://github.com/TombEngine/TombEngine.git
synced 2025-04-29 00:07:58 +03:00

* Add portable ldoc compiler and build event * Remove broken ldoc compiler * Remove unneeded file, remove timestamp * Update documentation
675 lines
18 KiB
Lua
675 lines
18 KiB
Lua
--- Date and Date Format classes.
|
|
-- See @{05-dates.md|the Guide}.
|
|
--
|
|
-- NOTE: the date module is deprecated! see
|
|
-- https://github.com/lunarmodules/Penlight/issues/285
|
|
--
|
|
-- Dependencies: `pl.class`, `pl.stringx`, `pl.utils`
|
|
-- @classmod pl.Date
|
|
-- @pragma nostrip
|
|
|
|
local class = require 'pl.class'
|
|
local os_time, os_date = os.time, os.date
|
|
local stringx = require 'pl.stringx'
|
|
local utils = require 'pl.utils'
|
|
local assert_arg,assert_string = utils.assert_arg,utils.assert_string
|
|
|
|
|
|
utils.raise_deprecation {
|
|
source = "Penlight " .. utils._VERSION,
|
|
message = "the 'Date' module is deprecated, see https://github.com/lunarmodules/Penlight/issues/285",
|
|
version_removed = "2.0.0",
|
|
version_deprecated = "1.9.2",
|
|
}
|
|
|
|
|
|
local Date = class()
|
|
Date.Format = class()
|
|
|
|
--- Date constructor.
|
|
-- @param t this can be either
|
|
--
|
|
-- * `nil` or empty - use current date and time
|
|
-- * number - seconds since epoch (as returned by `os.time`). Resulting time is UTC
|
|
-- * `Date` - make a copy of this date
|
|
-- * table - table containing year, month, etc as for `os.time`. You may leave out year, month or day,
|
|
-- in which case current values will be used.
|
|
-- * year (will be followed by month, day etc)
|
|
--
|
|
-- @param ... true if Universal Coordinated Time, or two to five numbers: month,day,hour,min,sec
|
|
-- @function Date
|
|
function Date:_init(t,...)
|
|
local time
|
|
local nargs = select('#',...)
|
|
if nargs > 2 then
|
|
local extra = {...}
|
|
local year = t
|
|
t = {
|
|
year = year,
|
|
month = extra[1],
|
|
day = extra[2],
|
|
hour = extra[3],
|
|
min = extra[4],
|
|
sec = extra[5]
|
|
}
|
|
end
|
|
if nargs == 1 then
|
|
self.utc = select(1,...) == true
|
|
end
|
|
if t == nil or t == 'utc' then
|
|
time = os_time()
|
|
self.utc = t == 'utc'
|
|
elseif type(t) == 'number' then
|
|
time = t
|
|
if self.utc == nil then self.utc = true end
|
|
elseif type(t) == 'table' then
|
|
if getmetatable(t) == Date then -- copy ctor
|
|
time = t.time
|
|
self.utc = t.utc
|
|
else
|
|
if not (t.year and t.month) then
|
|
local lt = os_date('*t')
|
|
if not t.year and not t.month and not t.day then
|
|
t.year = lt.year
|
|
t.month = lt.month
|
|
t.day = lt.day
|
|
else
|
|
t.year = t.year or lt.year
|
|
t.month = t.month or (t.day and lt.month or 1)
|
|
t.day = t.day or 1
|
|
end
|
|
end
|
|
t.day = t.day or 1
|
|
time = os_time(t)
|
|
end
|
|
else
|
|
error("bad type for Date constructor: "..type(t),2)
|
|
end
|
|
self:set(time)
|
|
end
|
|
|
|
--- set the current time of this Date object.
|
|
-- @int t seconds since epoch
|
|
function Date:set(t)
|
|
self.time = t
|
|
if self.utc then
|
|
self.tab = os_date('!*t',t)
|
|
else
|
|
self.tab = os_date('*t',t)
|
|
end
|
|
end
|
|
|
|
--- get the time zone offset from UTC.
|
|
-- @int ts seconds ahead of UTC
|
|
function Date.tzone (ts)
|
|
if ts == nil then
|
|
ts = os_time()
|
|
elseif type(ts) == "table" then
|
|
if getmetatable(ts) == Date then
|
|
ts = ts.time
|
|
else
|
|
ts = Date(ts).time
|
|
end
|
|
end
|
|
local utc = os_date('!*t',ts)
|
|
local lcl = os_date('*t',ts)
|
|
lcl.isdst = false
|
|
return os.difftime(os_time(lcl), os_time(utc))
|
|
end
|
|
|
|
--- convert this date to UTC.
|
|
function Date:toUTC ()
|
|
local ndate = Date(self)
|
|
if not self.utc then
|
|
ndate.utc = true
|
|
ndate:set(ndate.time)
|
|
end
|
|
return ndate
|
|
end
|
|
|
|
--- convert this UTC date to local.
|
|
function Date:toLocal ()
|
|
local ndate = Date(self)
|
|
if self.utc then
|
|
ndate.utc = false
|
|
ndate:set(ndate.time)
|
|
--~ ndate:add { sec = Date.tzone(self) }
|
|
end
|
|
return ndate
|
|
end
|
|
|
|
--- set the year.
|
|
-- @int y Four-digit year
|
|
-- @class function
|
|
-- @name Date:year
|
|
|
|
--- set the month.
|
|
-- @int m month
|
|
-- @class function
|
|
-- @name Date:month
|
|
|
|
--- set the day.
|
|
-- @int d day
|
|
-- @class function
|
|
-- @name Date:day
|
|
|
|
--- set the hour.
|
|
-- @int h hour
|
|
-- @class function
|
|
-- @name Date:hour
|
|
|
|
--- set the minutes.
|
|
-- @int min minutes
|
|
-- @class function
|
|
-- @name Date:min
|
|
|
|
--- set the seconds.
|
|
-- @int sec seconds
|
|
-- @class function
|
|
-- @name Date:sec
|
|
|
|
--- set the day of year.
|
|
-- @class function
|
|
-- @int yday day of year
|
|
-- @name Date:yday
|
|
|
|
--- get the year.
|
|
-- @int y Four-digit year
|
|
-- @class function
|
|
-- @name Date:year
|
|
|
|
--- get the month.
|
|
-- @class function
|
|
-- @name Date:month
|
|
|
|
--- get the day.
|
|
-- @class function
|
|
-- @name Date:day
|
|
|
|
--- get the hour.
|
|
-- @class function
|
|
-- @name Date:hour
|
|
|
|
--- get the minutes.
|
|
-- @class function
|
|
-- @name Date:min
|
|
|
|
--- get the seconds.
|
|
-- @class function
|
|
-- @name Date:sec
|
|
|
|
--- get the day of year.
|
|
-- @class function
|
|
-- @name Date:yday
|
|
|
|
|
|
for _,c in ipairs{'year','month','day','hour','min','sec','yday'} do
|
|
Date[c] = function(self,val)
|
|
if val then
|
|
assert_arg(1,val,"number")
|
|
self.tab[c] = val
|
|
self:set(os_time(self.tab))
|
|
return self
|
|
else
|
|
return self.tab[c]
|
|
end
|
|
end
|
|
end
|
|
|
|
--- name of day of week.
|
|
-- @bool full abbreviated if true, full otherwise.
|
|
-- @ret string name
|
|
function Date:weekday_name(full)
|
|
return os_date(full and '%A' or '%a',self.time)
|
|
end
|
|
|
|
--- name of month.
|
|
-- @int full abbreviated if true, full otherwise.
|
|
-- @ret string name
|
|
function Date:month_name(full)
|
|
return os_date(full and '%B' or '%b',self.time)
|
|
end
|
|
|
|
--- is this day on a weekend?.
|
|
function Date:is_weekend()
|
|
return self.tab.wday == 1 or self.tab.wday == 7
|
|
end
|
|
|
|
--- add to a date object.
|
|
-- @param t a table containing one of the following keys and a value:
|
|
-- one of `year`,`month`,`day`,`hour`,`min`,`sec`
|
|
-- @return this date
|
|
function Date:add(t)
|
|
local old_dst = self.tab.isdst
|
|
local key,val = next(t)
|
|
self.tab[key] = self.tab[key] + val
|
|
self:set(os_time(self.tab))
|
|
if old_dst ~= self.tab.isdst then
|
|
self.tab.hour = self.tab.hour - (old_dst and 1 or -1)
|
|
self:set(os_time(self.tab))
|
|
end
|
|
return self
|
|
end
|
|
|
|
--- last day of the month.
|
|
-- @return int day
|
|
function Date:last_day()
|
|
local d = 28
|
|
local m = self.tab.month
|
|
while self.tab.month == m do
|
|
d = d + 1
|
|
self:add{day=1}
|
|
end
|
|
self:add{day=-1}
|
|
return self
|
|
end
|
|
|
|
--- difference between two Date objects.
|
|
-- @tparam Date other Date object
|
|
-- @treturn Date.Interval object
|
|
function Date:diff(other)
|
|
local dt = self.time - other.time
|
|
if dt < 0 then error("date difference is negative!",2) end
|
|
return Date.Interval(dt)
|
|
end
|
|
|
|
--- long numerical ISO data format version of this date.
|
|
function Date:__tostring()
|
|
local fmt = '%Y-%m-%dT%H:%M:%S'
|
|
if self.utc then
|
|
fmt = "!"..fmt
|
|
end
|
|
local t = os_date(fmt,self.time)
|
|
if self.utc then
|
|
return t .. 'Z'
|
|
else
|
|
local offs = self:tzone()
|
|
if offs == 0 then
|
|
return t .. 'Z'
|
|
end
|
|
local sign = offs > 0 and '+' or '-'
|
|
local h = math.ceil(offs/3600)
|
|
local m = (offs % 3600)/60
|
|
if m == 0 then
|
|
return t .. ('%s%02d'):format(sign,h)
|
|
else
|
|
return t .. ('%s%02d:%02d'):format(sign,h,m)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- equality between Date objects.
|
|
function Date:__eq(other)
|
|
return self.time == other.time
|
|
end
|
|
|
|
--- ordering between Date objects.
|
|
function Date:__lt(other)
|
|
return self.time < other.time
|
|
end
|
|
|
|
--- difference between Date objects.
|
|
-- @function Date:__sub
|
|
Date.__sub = Date.diff
|
|
|
|
--- add a date and an interval.
|
|
-- @param other either a `Date.Interval` object or a table such as
|
|
-- passed to `Date:add`
|
|
function Date:__add(other)
|
|
local nd = Date(self)
|
|
if Date.Interval:class_of(other) then
|
|
other = {sec=other.time}
|
|
end
|
|
nd:add(other)
|
|
return nd
|
|
end
|
|
|
|
Date.Interval = class(Date)
|
|
|
|
---- Date.Interval constructor
|
|
-- @int t an interval in seconds
|
|
-- @function Date.Interval
|
|
function Date.Interval:_init(t)
|
|
self:set(t)
|
|
end
|
|
|
|
function Date.Interval:set(t)
|
|
self.time = t
|
|
self.tab = os_date('!*t',self.time)
|
|
end
|
|
|
|
local function ess(n)
|
|
if n > 1 then return 's '
|
|
else return ' '
|
|
end
|
|
end
|
|
|
|
--- If it's an interval then the format is '2 hours 29 sec' etc.
|
|
function Date.Interval:__tostring()
|
|
local t, res = self.tab, ''
|
|
local y,m,d = t.year - 1970, t.month - 1, t.day - 1
|
|
if y > 0 then res = res .. y .. ' year'..ess(y) end
|
|
if m > 0 then res = res .. m .. ' month'..ess(m) end
|
|
if d > 0 then res = res .. d .. ' day'..ess(d) end
|
|
if y == 0 and m == 0 then
|
|
local h = t.hour
|
|
if h > 0 then res = res .. h .. ' hour'..ess(h) end
|
|
if t.min > 0 then res = res .. t.min .. ' min ' end
|
|
if t.sec > 0 then res = res .. t.sec .. ' sec ' end
|
|
end
|
|
if res == '' then res = 'zero' end
|
|
return res
|
|
end
|
|
|
|
------------ Date.Format class: parsing and renderinig dates ------------
|
|
|
|
-- short field names, explicit os.date names, and a mask for allowed field repeats
|
|
local formats = {
|
|
d = {'day',{true,true}},
|
|
y = {'year',{false,true,false,true}},
|
|
m = {'month',{true,true}},
|
|
H = {'hour',{true,true}},
|
|
M = {'min',{true,true}},
|
|
S = {'sec',{true,true}},
|
|
}
|
|
|
|
--- Date.Format constructor.
|
|
-- @string fmt. A string where the following fields are significant:
|
|
--
|
|
-- * d day (either d or dd)
|
|
-- * y year (either yy or yyy)
|
|
-- * m month (either m or mm)
|
|
-- * H hour (either H or HH)
|
|
-- * M minute (either M or MM)
|
|
-- * S second (either S or SS)
|
|
--
|
|
-- Alternatively, if fmt is nil then this returns a flexible date parser
|
|
-- that tries various date/time schemes in turn:
|
|
--
|
|
-- * [ISO 8601](http://en.wikipedia.org/wiki/ISO_8601), like `2010-05-10 12:35:23Z` or `2008-10-03T14:30+02`
|
|
-- * times like 15:30 or 8.05pm (assumed to be today's date)
|
|
-- * dates like 28/10/02 (European order!) or 5 Feb 2012
|
|
-- * month name like march or Mar (case-insensitive, first 3 letters); here the
|
|
-- day will be 1 and the year this current year
|
|
--
|
|
-- A date in format 3 can be optionally followed by a time in format 2.
|
|
-- Please see test-date.lua in the tests folder for more examples.
|
|
-- @usage df = Date.Format("yyyy-mm-dd HH:MM:SS")
|
|
-- @class function
|
|
-- @name Date.Format
|
|
function Date.Format:_init(fmt)
|
|
if not fmt then
|
|
self.fmt = '%Y-%m-%d %H:%M:%S'
|
|
self.outf = self.fmt
|
|
self.plain = true
|
|
return
|
|
end
|
|
local append = table.insert
|
|
local D,PLUS,OPENP,CLOSEP = '\001','\002','\003','\004'
|
|
local vars,used = {},{}
|
|
local patt,outf = {},{}
|
|
local i = 1
|
|
while i < #fmt do
|
|
local ch = fmt:sub(i,i)
|
|
local df = formats[ch]
|
|
if df then
|
|
if used[ch] then error("field appeared twice: "..ch,4) end
|
|
used[ch] = true
|
|
-- this field may be repeated
|
|
local _,inext = fmt:find(ch..'+',i+1)
|
|
local cnt = not _ and 1 or inext-i+1
|
|
if not df[2][cnt] then error("wrong number of fields: "..ch,4) end
|
|
-- single chars mean 'accept more than one digit'
|
|
local p = cnt==1 and (D..PLUS) or (D):rep(cnt)
|
|
append(patt,OPENP..p..CLOSEP)
|
|
append(vars,ch)
|
|
if ch == 'y' then
|
|
append(outf,cnt==2 and '%y' or '%Y')
|
|
else
|
|
append(outf,'%'..ch)
|
|
end
|
|
i = i + cnt
|
|
else
|
|
append(patt,ch)
|
|
append(outf,ch)
|
|
i = i + 1
|
|
end
|
|
end
|
|
-- escape any magic characters
|
|
fmt = utils.escape(table.concat(patt))
|
|
-- fmt = table.concat(patt):gsub('[%-%.%+%[%]%(%)%$%^%%%?%*]','%%%1')
|
|
-- replace markers with their magic equivalents
|
|
fmt = fmt:gsub(D,'%%d'):gsub(PLUS,'+'):gsub(OPENP,'('):gsub(CLOSEP,')')
|
|
self.fmt = fmt
|
|
self.outf = table.concat(outf)
|
|
self.vars = vars
|
|
end
|
|
|
|
local parse_date
|
|
|
|
--- parse a string into a Date object.
|
|
-- @string str a date string
|
|
-- @return date object
|
|
function Date.Format:parse(str)
|
|
assert_string(1,str)
|
|
if self.plain then
|
|
return parse_date(str,self.us)
|
|
end
|
|
local res = {str:match(self.fmt)}
|
|
if #res==0 then return nil, 'cannot parse '..str end
|
|
local tab = {}
|
|
for i,v in ipairs(self.vars) do
|
|
local name = formats[v][1] -- e.g. 'y' becomes 'year'
|
|
tab[name] = tonumber(res[i])
|
|
end
|
|
-- os.date() requires these fields; if not present, we assume
|
|
-- that the time set is for the current day.
|
|
if not (tab.year and tab.month and tab.day) then
|
|
local today = Date()
|
|
tab.year = tab.year or today:year()
|
|
tab.month = tab.month or today:month()
|
|
tab.day = tab.day or today:day()
|
|
end
|
|
local Y = tab.year
|
|
if Y < 100 then -- classic Y2K pivot
|
|
tab.year = Y + (Y < 35 and 2000 or 1999)
|
|
elseif not Y then
|
|
tab.year = 1970
|
|
end
|
|
return Date(tab)
|
|
end
|
|
|
|
--- convert a Date object into a string.
|
|
-- @param d a date object, or a time value as returned by @{os.time}
|
|
-- @return string
|
|
function Date.Format:tostring(d)
|
|
local tm
|
|
local fmt = self.outf
|
|
if type(d) == 'number' then
|
|
tm = d
|
|
else
|
|
tm = d.time
|
|
if d.utc then
|
|
fmt = '!'..fmt
|
|
end
|
|
end
|
|
return os_date(fmt,tm)
|
|
end
|
|
|
|
--- force US order in dates like 9/11/2001
|
|
function Date.Format:US_order(yesno)
|
|
self.us = yesno
|
|
end
|
|
|
|
--local months = {jan=1,feb=2,mar=3,apr=4,may=5,jun=6,jul=7,aug=8,sep=9,oct=10,nov=11,dec=12}
|
|
local months
|
|
local parse_date_unsafe
|
|
local function create_months()
|
|
local ld, day1 = parse_date_unsafe '2000-12-31', {day=1}
|
|
months = {}
|
|
for i = 1,12 do
|
|
ld = ld:last_day()
|
|
ld:add(day1)
|
|
local mon = ld:month_name():lower()
|
|
months [mon] = i
|
|
end
|
|
end
|
|
|
|
--[[
|
|
Allowed patterns:
|
|
- [day] [monthname] [year] [time]
|
|
- [day]/[month][/year] [time]
|
|
|
|
]]
|
|
|
|
local function looks_like_a_month(w)
|
|
return w:match '^%a+,*$' ~= nil
|
|
end
|
|
local is_number = stringx.isdigit
|
|
local function tonum(s,l1,l2,kind)
|
|
kind = kind or ''
|
|
local n = tonumber(s)
|
|
if not n then error(("%snot a number: '%s'"):format(kind,s)) end
|
|
if n < l1 or n > l2 then
|
|
error(("%s out of range: %s is not between %d and %d"):format(kind,s,l1,l2))
|
|
end
|
|
return n
|
|
end
|
|
|
|
local function parse_iso_end(p,ns,sec)
|
|
-- may be fractional part of seconds
|
|
local _,nfrac,secfrac = p:find('^%.%d+',ns+1)
|
|
if secfrac then
|
|
sec = sec .. secfrac
|
|
p = p:sub(nfrac+1)
|
|
else
|
|
p = p:sub(ns+1)
|
|
end
|
|
-- ISO 8601 dates may end in Z (for UTC) or [+-][isotime]
|
|
-- (we're working with the date as lower case, hence 'z')
|
|
if p:match 'z$' then -- we're UTC!
|
|
return sec, {h=0,m=0}
|
|
end
|
|
p = p:gsub(':','') -- turn 00:30 to 0030
|
|
local _,_,sign,offs = p:find('^([%+%-])(%d+)')
|
|
if not sign then return sec, nil end -- not UTC
|
|
|
|
if #offs == 2 then offs = offs .. '00' end -- 01 to 0100
|
|
local tz = { h = tonumber(offs:sub(1,2)), m = tonumber(offs:sub(3,4)) }
|
|
if sign == '-' then tz.h = -tz.h; tz.m = -tz.m end
|
|
return sec, tz
|
|
end
|
|
|
|
function parse_date_unsafe (s,US)
|
|
s = s:gsub('T',' ') -- ISO 8601
|
|
local parts = stringx.split(s:lower())
|
|
local i,p = 1,parts[1]
|
|
local function nextp() i = i + 1; p = parts[i] end
|
|
local year,min,hour,sec,apm
|
|
local tz
|
|
local _,nxt,day, month = p:find '^(%d+)/(%d+)'
|
|
if day then
|
|
-- swop for US case
|
|
if US then
|
|
day, month = month, day
|
|
end
|
|
_,_,year = p:find('^/(%d+)',nxt+1)
|
|
nextp()
|
|
else -- ISO
|
|
year,month,day = p:match('^(%d+)%-(%d+)%-(%d+)')
|
|
if year then
|
|
nextp()
|
|
end
|
|
end
|
|
if p and not year and is_number(p) then -- has to be date
|
|
if #p < 4 then
|
|
day = p
|
|
nextp()
|
|
else -- unless it looks like a 24-hour time
|
|
year = true
|
|
end
|
|
end
|
|
if p and looks_like_a_month(p) then -- date followed by month
|
|
p = p:sub(1,3)
|
|
if not months then
|
|
create_months()
|
|
end
|
|
local mon = months[p]
|
|
if mon then
|
|
month = mon
|
|
else error("not a month: " .. p) end
|
|
nextp()
|
|
end
|
|
if p and not year and is_number(p) then
|
|
year = p
|
|
nextp()
|
|
end
|
|
|
|
if p then -- time is hh:mm[:ss], hhmm[ss] or H.M[am|pm]
|
|
_,nxt,hour,min = p:find '^(%d+):(%d+)'
|
|
local ns
|
|
if nxt then -- are there seconds?
|
|
_,ns,sec = p:find ('^:(%d+)',nxt+1)
|
|
--if ns then
|
|
sec,tz = parse_iso_end(p,ns or nxt,sec)
|
|
--end
|
|
else -- might be h.m
|
|
_,ns,hour,min = p:find '^(%d+)%.(%d+)'
|
|
if ns then
|
|
apm = p:match '[ap]m$'
|
|
else -- or hhmm[ss]
|
|
local hourmin
|
|
_,nxt,hourmin = p:find ('^(%d+)')
|
|
if nxt then
|
|
hour = hourmin:sub(1,2)
|
|
min = hourmin:sub(3,4)
|
|
sec = hourmin:sub(5,6)
|
|
if #sec == 0 then sec = nil end
|
|
sec,tz = parse_iso_end(p,nxt,sec)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local today
|
|
if year == true then year = nil end
|
|
if not (year and month and day) then
|
|
today = Date()
|
|
end
|
|
day = day and tonum(day,1,31,'day') or (month and 1 or today:day())
|
|
month = month and tonum(month,1,12,'month') or today:month()
|
|
year = year and tonumber(year) or today:year()
|
|
if year < 100 then -- two-digit year pivot around year < 2035
|
|
year = year + (year < 35 and 2000 or 1900)
|
|
end
|
|
hour = hour and tonum(hour,0,apm and 12 or 24,'hour') or 12
|
|
if apm == 'pm' then
|
|
hour = hour + 12
|
|
end
|
|
min = min and tonum(min,0,59) or 0
|
|
sec = sec and tonum(sec,0,60) or 0 --60 used to indicate leap second
|
|
local res = Date {year = year, month = month, day = day, hour = hour, min = min, sec = sec}
|
|
if tz then -- ISO 8601 UTC time
|
|
local corrected = false
|
|
if tz.h ~= 0 then res:add {hour = -tz.h}; corrected = true end
|
|
if tz.m ~= 0 then res:add {min = -tz.m}; corrected = true end
|
|
res.utc = true
|
|
-- we're in UTC, so let's go local...
|
|
if corrected then
|
|
res = res:toLocal()
|
|
end-- we're UTC!
|
|
end
|
|
return res
|
|
end
|
|
|
|
function parse_date (s)
|
|
local ok, d = pcall(parse_date_unsafe,s)
|
|
if not ok then -- error
|
|
d = d:gsub('.-:%d+: ','')
|
|
return nil, d
|
|
else
|
|
return d
|
|
end
|
|
end
|
|
|
|
return Date
|
|
|