Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Penlight/lua/pl/Date.lua
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
675 lines (615 sloc)
18.1 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- 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 | |