diff options
author | chai <chaifix@163.com> | 2021-10-30 11:32:16 +0800 |
---|---|---|
committer | chai <chaifix@163.com> | 2021-10-30 11:32:16 +0800 |
commit | 42ec7286b2d36a9ba22925f816a17cb1cc2aa5ce (patch) | |
tree | 24bc7009457a8d7500f264e89946dc20d069294f /Data/Libraries/Penlight/lua/pl/Date.lua | |
parent | 164885fd98d48703bd771f802d79557b7db97431 (diff) |
+ Penlight
Diffstat (limited to 'Data/Libraries/Penlight/lua/pl/Date.lua')
-rw-r--r-- | Data/Libraries/Penlight/lua/pl/Date.lua | 675 |
1 files changed, 675 insertions, 0 deletions
diff --git a/Data/Libraries/Penlight/lua/pl/Date.lua b/Data/Libraries/Penlight/lua/pl/Date.lua new file mode 100644 index 0000000..039dbaf --- /dev/null +++ b/Data/Libraries/Penlight/lua/pl/Date.lua @@ -0,0 +1,675 @@ +--- 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 + |