Jump to content

Module:Date

Permanently protected module
From Wikipedia, the free encyclopedia

-- Date functions for use by other modules.-- I18N and time zones are not supported.localMINUS='−'-- Unicode U+2212 MINUS SIGNlocalfloor=math.floorlocalDate,DateDiff,diffmt-- forward declarationslocaluniq={'unique identifier'}localfunctionis_date(t)-- The system used to make a date read-only means there is no unique-- metatable that is conveniently accessible to check.returntype(t)=='table'andt._id==uniqendlocalfunctionis_diff(t)returntype(t)=='table'andgetmetatable(t)==diffmtendlocalfunction_list_join(list,sep)returntable.concat(list,sep)endlocalfunctioncollection()-- Return a table to hold items.return{n=0,add=function(self,item)self.n=self.n+1self[self.n]=itemend,join=_list_join,}endlocalfunctionstrip_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).iftype(text)=='string'thentext=text:match('(%S.-)%s*$')endreturntextendlocalfunctionis_leap_year(year,calname)-- Return true if year is a leap year.ifcalname=='Julian'thenreturnyear%4==0endreturn(year%4==0andyear%100~=0)oryear%400==0endlocalfunctiondays_in_month(year,month,calname)-- Return number of days (1..31) in given month (1..12).ifmonth==2andis_leap_year(year,calname)thenreturn29endreturn({31,28,31,30,31,30,31,31,30,31,30,31})[month]endlocalfunctionh_m_s(time)-- Return hour, minute, second extracted from fraction of a day.time=floor(time*24*3600+0.5)-- number of secondslocalsecond=time%60time=floor(time/60)returnfloor(time/60),time%60,secondendlocalfunctionhms(date)-- Return fraction of a day from date's time, where (0 <= fraction < 1)-- if the values are valid, but could be anything if outside range.return(date.hour+(date.minute+date.second/60)/60)/24endlocalfunctionjulian_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 = same, but assume time is 00:00:00 if no time given-- 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 calendarlocaloffsetlocala=floor((14-date.month)/12)localy=date.year+4800-aifdate.calendar=='Julian'thenoffset=floor(y/4)-32083elseoffset=floor(y/4)-floor(y/100)+floor(y/400)-32045endlocalm=date.month+12*a-3localjd=date.day+floor((153*m+2)/5)+365*y+offsetifdate.hastimethenjd=jd+hms(date)-0.5returnjd,jdendreturnjd,jd-0.5endlocalfunctionset_date_from_jd(date)-- Set the fields of table date from its Julian date field.-- Return true if date is valid.-- 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.localcalname=date.calendarlocallow,high-- min/max limits for date ranges −9999-01-01 to 9999-12-31ifcalname=='Gregorian'thenlow,high=-1930999.5,5373484.49999elseifcalname=='Julian'thenlow,high=-1931076.5,5373557.49999elsereturnendlocaljd=date.jdifnot(type(jd)=='number'andlow<=jdandjd<=high)thenreturnendlocaljdn=floor(jd)ifdate.hastimethenlocaltime=jd-jdn-- 0 <= time < 1iftime>=0.5then-- if at or after midnight of next dayjdn=jdn+1time=time-0.5elsetime=time+0.5enddate.hour,date.minute,date.second=h_m_s(time)elsedate.second=0date.minute=0date.hour=0endlocalb,cifcalname=='Julian'thenb=0c=jdn+32082else-- Gregorianlocala=jdn+32044b=floor((4*a+3)/146097)c=a-floor(146097*b/4)endlocald=floor((4*c+3)/1461)locale=c-floor(1461*d/4)localm=floor((5*e+2)/153)date.day=e-floor((153*m+2)/5)+1date.month=m+3-12*floor(m/10)date.year=100*b+d-4800+floor(m/10)returntrueendlocalfunctionfix_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.localdateifnot(1<=mandm<=12)thendate=Date(y,1,1)ifnotdatethenreturnenddate=date+((m-1)..'m')y,m=date.year,date.monthendlocaldays_hmsifnotpartialthenifhastimeandHandMandSthenifnot(0<=HandH<=23and0<=MandM<=59and0<=SandS<=59)thendays_hms=hms({hour=H,minute=M,second=S})endendifdays_hmsornot(1<=dandd<=days_in_month(y,m,calendar))thendate=dateorDate(y,m,1)ifnotdatethenreturnenddate=date+(d-1+(days_hmsor0))y,m,d=date.year,date.month,date.dayifdays_hmsthenH,M,S=date.hour,date.minute,date.secondendendendnumbers.year=ynumbers.month=mnumbers.day=difdays_hmsthen-- Don't set H unless it was valid because a valid H will set hastime.numbers.hour=Hnumbers.minute=Mnumbers.second=Sendendlocalfunctionset_date_from_numbers(date,numbers,options)-- Set the fields of table date from numeric values.-- Return true if date is valid.iftype(numbers)~='table'thenreturnendlocaly=numbers.yearordate.yearlocalm=numbers.monthordate.monthlocald=numbers.dayordate.daylocalH=numbers.hourlocalM=numbers.minuteordate.minuteor0localS=numbers.secondordate.secondor0localneed_fixifyandmanddthendate.partial=nilifnot(-9999<=yandy<=9999and1<=mandm<=12and1<=dandd<=days_in_month(y,m,date.calendar))thenifnotdate.want_fixthenreturnendneed_fix=trueendelseifyanddate.partialthenifdornot(-9999<=yandy<=9999)thenreturnendifmandnot(1<=mandm<=12)thenifnotdate.want_fixthenreturnendneed_fix=trueendelsereturnendifdate.partialthenH=nil-- ignore any timeM=nilS=nilelseifHthen-- It is not possible to set M or S without also setting H.date.hastime=trueelseH=0endifnot(0<=HandH<=23and0<=MandM<=59and0<=SandS<=59)thenifdate.want_fixthenneed_fix=trueelsereturnendendenddate.want_fix=nilifneed_fixthenfix_numbers(numbers,y,m,d,H,M,S,date.partial,date.hastime,date.calendar)returnset_date_from_numbers(date,numbers,options)enddate.year=y-- -9999 to 9999 ('n BC' → year = 1 - n)date.month=m-- 1 to 12 (may be nil if partial)date.day=d-- 1 to 31 (* = nil if partial)date.hour=H-- 0 to 59 (*)date.minute=M-- 0 to 59 (*)date.second=S-- 0 to 59 (*)iftype(options)=='table'thenfor_,kinipairs({'am','era','format'})doifoptions[k]thendate.options[k]=options[k]endendendreturntrueendlocalfunctionmake_option_table(options1,options2)-- If options1 is a string, return a table with its settings, or-- if it is a table, use its settings.-- Missing options are set from table options2 or defaults.-- If a default is used, a flag is set so caller knows the value was not intentionally set.-- Valid option settings are:-- am: 'am', 'a.m.', 'AM', 'A.M.'-- 'pm', 'p.m.', 'PM', 'P.M.' (each has same meaning as corresponding item above)-- era: 'BCMINUS', 'BCNEGATIVE', 'BC', 'B.C.', 'BCE', 'B.C.E.', 'AD', 'A.D.', 'CE', 'C.E.'-- Option am = 'am' does not mean the hour is AM; it means 'am' or 'pm' is used, depending on the hour,-- and am = 'pm' has the same meaning.-- Similarly, era = 'BC' means 'BC' is used if year <= 0.-- BCMINUS displays a MINUS if year < 0 and the display format does not include %{era}.-- BCNEGATIVE is similar but displays a hyphen.localresult={bydefault={}}iftype(options1)=='table'thenresult.am=options1.amresult.era=options1.eraelseiftype(options1)=='string'then-- Example: 'am:AM era:BC' or 'am=AM era=BC'.foriteminoptions1:gmatch('%S+')dolocallhs,rhs=item:match('^(%w+)[:=](.+)$')iflhsthenresult[lhs]=rhsendendendoptions2=type(options2)=='table'andoptions2or{}localdefaults={am='am',era='BC'}fork,vinpairs(defaults)doifnotresult[k]thenifoptions2[k]thenresult[k]=options2[k]elseresult[k]=vresult.bydefault[k]=trueendendendreturnresultendlocalampm_options={-- lhs = input text accepted as an am/pm option-- rhs = code used internally['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.',}localera_text={-- Text for displaying an era with a positive year (after adjusting-- by replacing year with 1 - year if date.year <= 0).-- options.era = { year<=0 , year>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.'},}localfunctionget_era_for_year(era,year)return(era_text[era]orera_text['BC'])[year>0and2or1]or''endlocalfunctionstrftime(date,format,options)-- Return date formatted as a string using codes similar to those-- in the C strftime library function.localsformat=string.formatlocalshortcuts={['%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}ifshortcuts[format]thenformat=shortcuts[format]endlocalcodes={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)localamopt=options.amlocaleraopt=options.eralocalfunctionreplace_code(spaces,modifier,id)localcode=codes[id]ifcodethenlocalfmt=code.fmtifmodifier=='-'andcode.fmt2thenfmt=code.fmt2endlocalvalue=date[code.field]ifnotvaluethenreturnnil-- an undefined field in a partial dateendlocalspecial=code.specialifspecialthenifspecial=='hour12'thenvalue=value%12value=value==0and12orvalueelseifspecial=='am'thenlocalap=({['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<12andap[1]orap[2])endendifcode.field=='year'thenlocalsign=(era_text[eraopt]or{}).signifnotsignorformat:find('%{era}',1,true)thensign=''ifvalue<=0thenvalue=1-valueendelseifvalue>=0thensign=''elsevalue=-valueendendreturnspaces..sign..sformat(fmt,value)endreturnspaces..(fmtandsformat(fmt,value)orvalue)endendlocalfunctionreplace_property(spaces,id)ifid=='era'then-- Special case so can use local era option.localresult=get_era_for_year(eraopt,date.year)ifresult==''thenreturn''endreturn(spaces==''and''or'&nbsp;')..resultendlocalresult=date[id]iftype(result)=='string'thenreturnspaces..resultendiftype(result)=='number'thenreturnspaces..tostring(result)endiftype(result)=='boolean'thenreturnspaces..(resultand'1'or'0')end-- This occurs if id is an undefined field in a partial date, or is the name of a function.returnnilendlocalPERCENT='\127PERCENT\127'return(format:gsub('%%%%',PERCENT):gsub('(%s*)%%{(%w+)}',replace_property):gsub('(%s*)%%(%-?)(%a)',replace_code):gsub(PERCENT,'%%'))endlocalfunction_date_text(date,fmt,options)-- Return a formatted string representing the given date.ifnotis_date(date)thenerror('date:text: need a date (use "date:text()" with a colon)',2)endiftype(fmt)=='string'andfmt:match('%S')theniffmt:find('%',1,true)thenreturnstrftime(date,fmt,options)endelseifdate.partialthenfmt=date.monthand'my'or'y'elsefmt='dmy'ifdate.hastimethenfmt=(date.second>0and'hms 'or'hm ')..fmtendendlocalfunctionbad_format()-- For consistency with other format processing, return given format-- (or cleaned format if original was not a string) if invalid.returnmw.text.nowiki(fmt)endifdate.partialthen-- Ignore days in standard formats like 'ymd'.iffmt=='ym'orfmt=='ymd'thenfmt=date.monthand'%Y-%m %{era}'or'%Y %{era}'elseiffmt=='my'orfmt=='dmy'orfmt=='mdy'thenfmt=date.monthand'%B %-Y %{era}'or'%-Y %{era}'elseiffmt=='y'thenfmt=date.monthand'%-Y %{era}'or'%-Y %{era}'elsereturnbad_format()endreturnstrftime(date,fmt,options)endlocalfunctionhm_fmt()localplain=make_option_table(options,date.options).bydefault.amreturnplainand'%H:%M'or'%-I:%M %p'endlocalneed_time=date.hastimelocalt=collection()foriteminfmt:gmatch('%S+')dolocalfifitem=='hm'thenf=hm_fmt()need_time=falseelseifitem=='hms'thenf='%H:%M:%S'need_time=falseelseifitem=='ymd'thenf='%Y-%m-%d %{era}'elseifitem=='mdy'thenf='%B %-d, %-Y %{era}'elseifitem=='dmy'thenf='%-d %B %-Y %{era}'elsereturnbad_format()endt:add(f)endfmt=t:join(' ')ifneed_timethenfmt=hm_fmt()..' '..fmtendreturnstrftime(date,fmt,options)endlocalday_info={-- 0=Sun to 6=Sat[0]={'Sun','Sunday'},{'Mon','Monday'},{'Tue','Tuesday'},{'Wed','Wednesday'},{'Thu','Thursday'},{'Fri','Friday'},{'Sat','Saturday'},}localmonth_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'},}localfunctionname_to_number(text,translate)iftype(text)=='string'thenreturntranslate[text:lower()]endendlocalfunctionday_number(text)returnname_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,})endlocalfunctionmonth_number(text)returnname_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,})endlocalfunction_list_text(list,fmt)-- Return a list of formatted strings from a list of dates.ifnottype(list)=='table'thenerror('date:list:text: need "list:text()" with a colon',2)endlocalresult={join=_list_join}fori,dateinipairs(list)doresult[i]=date:text(fmt)endreturnresultendlocalfunction_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 "<count> <day> <op>"-- where each item is optional and-- count = number of items wanted in list-- day = abbreviation or name such as Mon or Monday-- op = >, >=, <, <= (default is > 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 <'-- 5 is equivalent to '5' which is '5 >'ifnotis_date(date)thenerror('date:list: need a date (use "date:list()" with a colon)',2)endlocallist={text=_list_text}ifdate.partialthenreturnlistendlocalcount,offset,operationlocalops={['>=']={before=false,include=true},['>']={before=false,include=false},['<=']={before=true,include=true},['<']={before=true,include=false},}ifspectheniftype(spec)=='number'thencount=floor(spec+0.5)ifcount<0thencount=-countoperation=ops['<']endelseiftype(spec)=='string'thenlocalnum,day,op=spec:match('^%s*(%d*)%s*(%a*)%s*([<>=]*)%s*$')ifnotnumthenreturnlistendifnum~=''thencount=tonumber(num)endifday~=''thenlocaldow=day_number(day:gsub('[sS]$',''))-- accept plural daysifnotdowthenreturnlistendoffset=dow-date.dowendoperation=ops[op]elsereturnlistendendoffset=offsetor0operation=operationorops['>']localdatefrom,dayfirst,daylastifoperation.beforethenifoffset>0or(offset==0andnotoperation.include)thenoffset=offset-7endifcountthenifcount>1thenoffset=offset-7*(count-1)enddatefrom=date+offsetelsedaylast=date.day+offsetdayfirst=daylast%7ifdayfirst==0thendayfirst=7endendelseifoffset<0or(offset==0andnotoperation.include)thenoffset=offset+7endifcountthendatefrom=date+offsetelsedayfirst=date.day+offsetdaylast=date.monthdaysendendifnotcountthenifdaylast<dayfirstthenreturnlistendcount=floor((daylast-dayfirst)/7)+1datefrom=Date(date,{day=dayfirst})endfori=1,countdoifnotdatefromthenbreakend-- exceeds date limitslist[i]=datefromdatefrom=datefrom+7endreturnlistend-- A table to get the current date/time (UTC), but only if needed.localcurrent=setmetatable({},{__index=function(self,key)locald=os.date('!*t')self.year=d.yearself.month=d.monthself.day=d.dayself.hour=d.hourself.minute=d.minself.second=d.secreturnrawget(self,key)end})localfunctionextract_date(newdate,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 or format, if any-- or return nothing if date is known to be invalid.-- Caller determines if the values in n are valid.-- A year must be positive ('1' to '9999'); use 'BC' for BC.-- In a y-m-d string, the year must be four digits to avoid ambiguity-- ('0001' to '9999'). The only way to enter year <= 0 is by specifying-- the date as three numeric parameters like ymd Date(-1, 1, 1).-- Dates of form d/m/y, m/d/y, y/m/d are rejected as potentially ambiguous.localdate,options={},{}iftext:sub(-1)=='Z'then-- Extract date/time from a Wikidata timestamp.-- The year can be 1 to 16 digits but this module handles 1 to 4 digits only.-- Examples: '+2016-06-21T14:30:00Z', '-0000000180-00-00T00:00:00Z'.localsign,y,m,d,H,M,S=text:match('^([+%-])(%d+)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)Z$')ifsigntheny=tonumber(y)ifsign=='-'andy>0theny=-yendify<=0thenoptions.era='BCE'enddate.year=ym=tonumber(m)d=tonumber(d)H=tonumber(H)M=tonumber(M)S=tonumber(S)ifm==0thennewdate.partial=truereturndate,optionsenddate.month=mifd==0thennewdate.partial=truereturndate,optionsenddate.day=difH>0orM>0orS>0thendate.hour=Hdate.minute=Mdate.second=Sendreturndate,optionsendreturnendlocalfunctionextract_ymd(item)-- Called when no day or month has been set.localy,m,d=item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')ifythenifdate.yearthenreturnendifm:match('^%d%d?$')thenm=tonumber(m)elsem=month_number(m)endifmthendate.year=tonumber(y)date.month=mdate.day=tonumber(d)returntrueendendendlocalfunctionextract_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.localnumber,suffix=item:match('^(%d%d?%d?%d?)(.*)$')ifnumberthenlocaln=tonumber(number)if#number<=2andn<=31thensuffix=suffix:lower()ifsuffix==''orsuffix=='st'orsuffix=='nd'orsuffix=='rd'orsuffix=='th'thendate.day=nreturntrueendelseifsuffix==''andnewdate.partialandnotdate.yearthendate.year=nreturntrueendendendlocalfunctionextract_month(item)-- A month must be given as a name or abbreviation; a number could be ambiguous.localm=month_number(item)ifmthendate.month=mreturntrueendendlocalfunctionextract_time(item)localh,m,s=item:match('^(%d%d?):(%d%d)(:?%d*)$')ifdate.hourornoththenreturnendifs~=''thens=s:match('^:(%d%d)$')ifnotsthenreturnendenddate.hour=tonumber(h)date.minute=tonumber(m)date.second=tonumber(s)-- nil if empty stringreturntrueendlocalitem_count=0localindex_timelocalfunctionset_ampm(item)localH=date.hourifHandnotoptions.amandindex_time+1==item_countthenoptions.am=ampm_options[item]-- caller checked this is not nilifitem:match('^[Aa]')thenifnot(1<=HandH<=12)thenreturnendifH==12thendate.hour=0endelseifnot(1<=HandH<=23)thenreturnendifH<=11thendate.hour=H+12endendreturntrueendendforitemintext:gsub(',',' '):gsub('&nbsp;',' '):gmatch('%S+')doitem_count=item_count+1ifera_text[item]then-- Era is accepted in peculiar places.ifoptions.erathenreturnendoptions.era=itemelseifampm_options[item]thenifnotset_ampm(item)thenreturnendelseifitem:find(':',1,true)thenifnotextract_time(item)thenreturnendindex_time=item_countelseifdate.dayanddate.monththenifdate.yearthenreturn-- should be nothing more so item is invalidendifnotitem:match('^(%d%d?%d?%d?)$')thenreturnenddate.year=tonumber(item)elseifdate.daythenifnotextract_month(item)thenreturnendelseifdate.monththenifnotextract_day_or_year(item)thenreturnendelseifextract_month(item)thenoptions.format='mdy'elseifextract_ymd(item)thenoptions.format='ymd'elseifextract_day_or_year(item)thenifdate.daythenoptions.format='dmy'endelsereturnendendifnotdate.yearordate.year==0thenreturnendlocalera=era_text[options.era]iferaandera.isbcthendate.year=1-date.yearendreturndate,optionsendlocalfunctionautofill(date1,date2)-- Fill any missing month or day in each date using the-- corresponding component from the other date, if present,-- or with 1 if both dates are missing the month or day.-- This gives a good result for calculating the difference-- between two partial dates when no range is wanted.-- Return filled date1, date2 (two full dates).localfunctionfilled(a,b)-- Return date a filled, if necessary, with month and/or day from date b.-- The filled day is truncated to fit the number of days in the month.localfillmonth,filldayifnota.monththenfillmonth=b.monthor1endifnota.daythenfillday=b.dayor1endiffillmonthorfilldaythen-- need to create a new datea=Date(a,{month=fillmonth,day=math.min(filldayora.day,days_in_month(a.year,fillmonthora.month,a.calendar))})endreturnaendreturnfilled(date1,date2),filled(date2,date1)endlocalfunctiondate_add_sub(lhs,rhs,is_sub)-- Return a new date from calculating (lhs + rhs) or (lhs - rhs),-- or return nothing if invalid.-- The result is nil if the calculated date exceeds allowable limits.-- Caller ensures that lhs is a date; its properties are copied for the new date.iflhs.partialthen-- Adding to a partial is not supported.-- Can subtract a date or partial from a partial, but this is not called for that.returnendlocalfunctionis_prefix(text,word,minlen)localn=#textreturn(minlenor1)<=nandn<=#wordandtext==word:sub(1,n)endlocalfunctiondo_days(n)localforcetime,jdiffloor(n)==nthenjd=lhs.jdelseforcetime=notlhs.hastimejd=lhs.jdzendjd=jd+(is_suband-norn)ifforcetimethenjd=tostring(jd)ifnotjd:find('.',1,true)thenjd=jd..'.0'endendreturnDate(lhs,'juliandate',jd)endiftype(rhs)=='number'then-- Add/subtract days, including fractional days.returndo_days(rhs)endiftype(rhs)=='string'then-- rhs is a single component like '26m' or '26 months' (with optional sign).-- Fractions like '3.25d' are accepted for the units which are handled as days.localsign,numstr,id=rhs:match('^%s*([+-]?)([%d%.]+)%s*(%a+)$')ifsignthenifsign=='-'thenis_sub=not(is_subandtrueorfalse)endlocaly,m,dayslocalnum=tonumber(numstr)ifnotnumthenreturnendid=id:lower()ifis_prefix(id,'years')theny=numm=0elseifis_prefix(id,'months')theny=floor(num/12)m=num%12elseifis_prefix(id,'weeks')thendays=num*7elseifis_prefix(id,'days')thendays=numelseifis_prefix(id,'hours')thendays=num/24elseifis_prefix(id,'minutes',3)thendays=num/(24*60)elseifis_prefix(id,'seconds')thendays=num/(24*3600)elsereturnendifdaysthenreturndo_days(days)endifnumstr:find('.',1,true)thenreturnendifis_subtheny=-ym=-mendassert(-11<=mandm<=11)y=lhs.year+ym=lhs.month+mifm>12theny=y+1m=m-12elseifm<1theny=y-1m=m+12endlocald=math.min(lhs.day,days_in_month(y,m,lhs.calendar))returnDate(lhs,y,m,d)endendifis_diff(rhs)thenlocaldays=rhs.age_daysif(is_suborfalse)~=(rhs.isnegativeorfalse)thendays=-daysendreturnlhs+daysendendlocalfull_date_only={dayabbr=true,dayname=true,dow=true,dayofweek=true,dowiso=true,dayofweekiso=true,dayofyear=true,gsd=true,juliandate=true,jd=true,jdz=true,jdnoon=true,}-- Metatable for a date's calculated fields.localdatemt={__index=function(self,key)ifrawget(self,'partial')theniffull_date_only[key]thenreturnendifkey=='monthabbr'orkey=='monthdays'orkey=='monthname'thenifnotself.monththenreturnendendendlocalvalueifkey=='dayabbr'thenvalue=day_info[self.dow][1]elseifkey=='dayname'thenvalue=day_info[self.dow][2]elseifkey=='dow'thenvalue=(self.jdnoon+1)%7-- day-of-week 0=Sun to 6=Satelseifkey=='dayofweek'thenvalue=self.dowelseifkey=='dowiso'thenvalue=(self.jdnoon%7)+1-- ISO day-of-week 1=Mon to 7=Sunelseifkey=='dayofweekiso'thenvalue=self.dowisoelseifkey=='dayofyear'thenlocalfirst=Date(self.year,1,1,self.calendar).jdnoonvalue=self.jdnoon-first+1-- day-of-year 1 to 366elseifkey=='era'then-- Era text (never a negative sign) from year and options.value=get_era_for_year(self.options.era,self.year)elseifkey=='format'thenvalue=self.options.formator'dmy'elseifkey=='gsd'then-- GSD = 1 from 00:00:00 to 23:59:59 on 1 January 1 AD Gregorian calendar,-- which is from jd 1721425.5 to 1721426.49999.value=floor(self.jd-1721424.5)elseifkey=='juliandate'orkey=='jd'orkey=='jdz'thenlocaljd,jdz=julian_date(self)rawset(self,'juliandate',jd)rawset(self,'jd',jd)rawset(self,'jdz',jdz)returnkey=='jdz'andjdzorjdelseifkey=='jdnoon'then-- Julian date at noon (an integer) on the calendar day when jd occurs.value=floor(self.jd+0.5)elseifkey=='isleapyear'thenvalue=is_leap_year(self.year,self.calendar)elseifkey=='monthabbr'thenvalue=month_info[self.month][1]elseifkey=='monthdays'thenvalue=days_in_month(self.year,self.month,self.calendar)elseifkey=='monthname'thenvalue=month_info[self.month][2]endifvalue~=nilthenrawset(self,key,value)returnvalueendend,}-- Date operators.localfunctionmt_date_add(lhs,rhs)ifnotis_date(lhs)thenlhs,rhs=rhs,lhs-- put date on left (it must be a date for this to have been called)endreturndate_add_sub(lhs,rhs)endlocalfunctionmt_date_sub(lhs,rhs)ifis_date(lhs)thenifis_date(rhs)thenreturnDateDiff(lhs,rhs)endreturndate_add_sub(lhs,rhs,true)endendlocalfunctionmt_date_concat(lhs,rhs)returntostring(lhs)..tostring(rhs)endlocalfunctionmt_date_tostring(self)returnself:text()endlocalfunctionmt_date_eq(lhs,rhs)-- Return true if dates identify same date/time where, for example,-- Date(-4712, 1, 1, 'Julian') == Date(-4713, 11, 24, 'Gregorian') is true.-- This is called only if lhs and rhs have the same type and the same metamethod.iflhs.partialorrhs.partialthen-- One date is partial; the other is a partial or a full date.-- The months may both be nil, but must be the same.returnlhs.year==rhs.yearandlhs.month==rhs.monthandlhs.calendar==rhs.calendarendreturnlhs.jdz==rhs.jdzendlocalfunctionmt_date_lt(lhs,rhs)-- Return true if lhs < rhs, for example,-- Date('1 Jan 2016') < Date('06:00 1 Jan 2016') is true.-- This is called only if lhs and rhs have the same type and the same metamethod.iflhs.partialorrhs.partialthen-- One date is partial; the other is a partial or a full date.iflhs.calendar~=rhs.calendarthenreturnlhs.calendar=='Julian'endiflhs.partialthenlhs=lhs.partial.firstendifrhs.partialthenrhs=rhs.partial.firstendendreturnlhs.jdz<rhs.jdzend--[[ 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:SDate('currentdate')Date('currentdatetime')Date('1 April 1995', 'julian') parse date from textDate('1 April 1995 AD', 'julian') using an era sets a flag to do the same for outputDate('04:30:59 1 April 1995', 'julian')Date(date) copy of an existing dateDate(date, t) same, updated with y,m,d,H,M,S fields from table tDate(t) date with y,m,d,H,M,S fields from table t]]functionDate(...)-- for forward declaration above-- Return a table holding a date assuming a uniform calendar always applies-- (proleptic Gregorian calendar or proleptic Julian calendar), or-- return nothing if date is invalid.-- A partial date has a valid year, however its month may be nil, and-- its day and time fields are nil.-- Field partial is set to false (if a full date) or a table (if a partial date).localcalendars={julian='Julian',gregorian='Gregorian'}localnewdate={_id=uniq,calendar='Gregorian',-- default is Gregorian calendarhastime=false,-- true if input sets a timehour=0,-- always set hour/minute/second so don't have to handle nilminute=0,second=0,options={},list=_date_list,subtract=function(self,rhs,options)returnDateDiff(self,rhs,options)end,text=_date_text,}localargtype,datetext,is_copy,jd_number,tnumslocalnumindex=0localnumfields={'year','month','day','hour','minute','second'}localnumbers={}for_,vinipairs({...})dov=strip_to_nil(v)localvlower=type(v)=='string'andv:lower()ornilifv==nilthen-- Ignore empty arguments after stripping so modules can directly pass template parameters.elseifcalendars[vlower]thennewdate.calendar=calendars[vlower]elseifvlower=='partial'thennewdate.partial=trueelseifvlower=='fix'thennewdate.want_fix=trueelseifis_date(v)then-- Copy existing date (items can be overridden by other arguments).ifis_copyortnumsthenreturnendis_copy=truenewdate.calendar=v.calendarnewdate.partial=v.partialnewdate.hastime=v.hastimenewdate.options=v.optionsnewdate.year=v.yearnewdate.month=v.monthnewdate.day=v.daynewdate.hour=v.hournewdate.minute=v.minutenewdate.second=v.secondelseiftype(v)=='table'theniftnumsthenreturnendtnums={}localtfields={year=1,month=1,day=1,hour=2,minute=2,second=2}fortk,tvinpairs(v)doiftfields[tk]thentnums[tk]=tonumber(tv)endiftfields[tk]==2thennewdate.hastime=trueendendelselocalnum=tonumber(v)ifnotnumandargtype=='setdate'andnumindex==1thennum=month_number(v)endifnumthenifnotargtypethenargtype='setdate'endifargtype=='setdate'andnumindex<6thennumindex=numindex+1numbers[numfields[numindex]]=numelseifargtype=='juliandate'andnotjd_numberthenjd_number=numiftype(v)=='string'thenifv:find('.',1,true)thennewdate.hastime=trueendelseifnum~=floor(num)then-- The given value was a number. The time will be used-- if the fractional part is nonzero.newdate.hastime=trueendelsereturnendelseifargtypethenreturnelseiftype(v)=='string'thenifv=='currentdate'orv=='currentdatetime'orv=='juliandate'thenargtype=velseargtype='datetext'datetext=vendelsereturnendendendifargtype=='datetext'theniftnumsornotset_date_from_numbers(newdate,extract_date(newdate,datetext))thenreturnendelseifargtype=='juliandate'thennewdate.partial=nilnewdate.jd=jd_numberifnotset_date_from_jd(newdate)thenreturnendelseifargtype=='currentdate'orargtype=='currentdatetime'thennewdate.partial=nilnewdate.year=current.yearnewdate.month=current.monthnewdate.day=current.dayifargtype=='currentdatetime'thennewdate.hour=current.hournewdate.minute=current.minutenewdate.second=current.secondnewdate.hastime=trueendnewdate.calendar='Gregorian'-- ignore any given calendar nameelseifargtype=='setdate'theniftnumsornotset_date_from_numbers(newdate,numbers)thenreturnendelseifnot(is_copyortnums)thenreturnendiftnumsthennewdate.jd=nil-- force recalculation in case jd was set before changes from tnumsifnotset_date_from_numbers(newdate,tnums)thenreturnendendifnewdate.partialthenlocalyear=newdate.yearlocalmonth=newdate.monthlocalfirst=Date(year,monthor1,1,newdate.calendar)month=monthor12locallast=Date(year,month,days_in_month(year,month),newdate.calendar)newdate.partial={first=first,last=last}elsenewdate.partial=false-- avoid index lookupendsetmetatable(newdate,datemt)localreadonly={}localmt={__index=newdate,__newindex=function(t,k,v)error('date.'..tostring(k)..' is read-only',2)end,__add=mt_date_add,__sub=mt_date_sub,__concat=mt_date_concat,__tostring=mt_date_tostring,__eq=mt_date_eq,__lt=mt_date_lt,}returnsetmetatable(readonly,mt)endlocalfunction_diff_age(diff,code,options)-- Return a tuple of integer values from diff as specified by code, except that-- each integer may be a list of two integers for a diff with a partial date, or-- return nil if the code is not supported.-- If want round, the least significant unit is rounded to nearest whole unit.-- For a duration, an extra day is added.localwantround,wantduration,wantrangeiftype(options)=='table'thenwantround=options.roundwantduration=options.durationwantrange=options.rangeelsewantround=optionsendifnotis_diff(diff)thenlocalf=wantdurationand'duration'or'age'error(f..': need a date difference (use "diff:'..f..'()" with a colon)',2)endifdiff.partialthen-- Ignore wantround, wantduration.localfunctionchoose(v)iftype(v)=='table'thenifnotwantrangeorv[1]==v[2]then-- Example: Date('partial', 2005) - Date('partial', 2001) gives-- diff.years = { 3, 4 } to show the range of possible results.-- If do not want a range, choose the second value as more expected.returnv[2]endendreturnvendifcode=='ym'orcode=='ymd'thenifnotwantrangeanddiff.iszerothen-- This avoids an unexpected result such as-- Date('partial', 2001) - Date('partial', 2001)-- giving diff = { years = 0, months = { 0, 11 } }-- which would be reported as 0 years and 11 months.return0,0endreturnchoose(diff.partial.years),choose(diff.partial.months)endifcode=='y'thenreturnchoose(diff.partial.years)endifcode=='m'orcode=='w'orcode=='d'thenreturnchoose({diff.partial.mindiff:age(code),diff.partial.maxdiff:age(code)})endreturnnilendlocalextra_days=wantdurationand1or0ifcode=='wd'orcode=='w'orcode=='d'thenlocaloffset=wantroundand0.5or0localdays=diff.age_days+extra_daysifcode=='wd'orcode=='d'thendays=floor(days+offset)ifcode=='d'thenreturndaysendreturnfloor(days/7),days%7endreturnfloor(days/7+offset)endlocalH,M,S=diff.hours,diff.minutes,diff.secondsifcode=='dh'orcode=='dhm'orcode=='dhms'orcode=='h'orcode=='hm'orcode=='hms'orcode=='M'orcode=='s'thenlocaldays=floor(diff.age_days+extra_days)localinc_hourifwantroundthenifcode=='dh'orcode=='h'thenifM>=30theninc_hour=trueendelseifcode=='dhm'orcode=='hm'thenifS>=30thenM=M+1ifM>=60thenM=0inc_hour=trueendendelseifcode=='M'thenifS>=30thenM=M+1endelse-- Nothing needed because S is an integer.endifinc_hourthenH=H+1ifH>=24thenH=0days=days+1endendendifcode=='dh'orcode=='dhm'orcode=='dhms'thenifcode=='dh'thenreturndays,Helseifcode=='dhm'thenreturndays,H,Melsereturndays,H,M,Sendendlocalhours=days*24+Hifcode=='h'thenreturnhourselseifcode=='hm'thenreturnhours,Melseifcode=='M'orcode=='s'thenM=hours*60+Mifcode=='M'thenreturnMendreturnM*60+Sendreturnhours,M,Sendifwantroundthenlocalinc_hourifcode=='ymdh'orcode=='ymwdh'thenifM>=30theninc_hour=trueendelseifcode=='ymdhm'orcode=='ymwdhm'thenifS>=30thenM=M+1ifM>=60thenM=0inc_hour=trueendendelseifcode=='ymd'orcode=='ymwd'orcode=='yd'orcode=='md'thenifH>=12thenextra_days=extra_days+1endendifinc_hourthenH=H+1ifH>=24thenH=0extra_days=extra_days+1endendendlocaly,m,d=diff.years,diff.months,diff.daysifextra_days>0thend=d+extra_daysifd>28orcode=='yd'then-- Recalculate in case have passed a month.diff=diff.date1+extra_days-diff.date2y,m,d=diff.years,diff.months,diff.daysendendifcode=='ymd'thenreturny,m,delseifcode=='yd'thenify>0then-- It is known that diff.date1 > diff.date2.diff=diff.date1-(diff.date2+(y..'y'))endreturny,floor(diff.age_days)elseifcode=='md'thenreturny*12+m,delseifcode=='ym'orcode=='m'thenifwantroundthenifd>=16thenm=m+1ifm>=12thenm=0y=y+1endendendifcode=='ym'thenreturny,mendreturny*12+melseifcode=='ymw'thenlocalweeks=floor(d/7)ifwantroundthenlocaldays=d%7ifdays>3or(days==3andH>=12)thenweeks=weeks+1endendreturny,m,weekselseifcode=='ymwd'thenreturny,m,floor(d/7),d%7elseifcode=='ymdh'thenreturny,m,d,Helseifcode=='ymwdh'thenreturny,m,floor(d/7),d%7,Helseifcode=='ymdhm'thenreturny,m,d,H,Melseifcode=='ymwdhm'thenreturny,m,floor(d/7),d%7,H,Mendifcode=='y'thenifwantroundandm>=6theny=y+1endreturnyendreturnnilendlocalfunction_diff_duration(diff,code,options)iftype(options)~='table'thenoptions={round=options}endoptions.duration=truereturn_diff_age(diff,code,options)end-- Metatable for some operations on date differences.diffmt={-- for forward declaration above__concat=function(lhs,rhs)returntostring(lhs)..tostring(rhs)end,__tostring=function(self)returntostring(self.age_days)end,__index=function(self,key)localvalueifkey=='age_days'thenifrawget(self,'partial')thenlocalfunctionjdz(date)return(date.partialanddate.partial.firstordate).jdzendvalue=jdz(self.date1)-jdz(self.date2)elsevalue=self.date1.jdz-self.date2.jdzendendifvalue~=nilthenrawset(self,key,value)returnvalueendend,}functionDateDiff(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).ifnot(is_date(date1)andis_date(date2)anddate1.calendar==date2.calendar)thenreturnendlocalwantfilliftype(options)=='table'thenwantfill=options.fillendlocalisnegative=falselocaliszero=falseifdate1<date2thenisnegative=truedate1,date2=date2,date1elseifdate1==date2theniszero=trueend-- It is known that date1 >= date2 (period is from date2 to date1).ifdate1.partialordate2.partialthen-- Two partial dates might have timelines:---------------------A=================B--- date1 is from A to B inclusive--------C=======D-------------------------- 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-31ifwantfillthendate1,date2=autofill(date1,date2)elselocalfunctionzdiff(date1,date2)localdiff=date1-date2ifdiff.isnegativethenreturndate1-date1-- a valid diff in case we call its methodsendreturndiffendlocalfunctiongetdate(date,which)returndate.partialanddate.partial[which]ordateendlocalmaxdiff=zdiff(getdate(date1,'last'),getdate(date2,'first'))localmindiff=zdiff(getdate(date1,'first'),getdate(date2,'last'))localyears,monthsifmaxdiff.years==mindiff.yearsthenyears=maxdiff.yearsifmaxdiff.months==mindiff.monthsthenmonths=maxdiff.monthselsemonths={mindiff.months,maxdiff.months}endelseyears={mindiff.years,maxdiff.years}endreturnsetmetatable({date1=date1,date2=date2,partial={years=years,months=months,maxdiff=maxdiff,mindiff=mindiff,},isnegative=isnegative,iszero=iszero,age=_diff_age,duration=_diff_duration,},diffmt)endendlocaly1,m1=date1.year,date1.monthlocaly2,m2=date2.year,date2.monthlocalyears=y1-y2localmonths=m1-m2locald1=date1.day+hms(date1)locald2=date2.day+hms(date2)localdays,timeifd1>=d2thendays=d1-d2elsemonths=months-1-- Get days in previous month (before the "to" date) given December has 31 days.localdpm=m1>1anddays_in_month(y1,m1-1,date1.calendar)or31ifd2>=dpmthendays=d1-hms(date2)elsedays=dpm-d2+d1endendifmonths<0thenyears=years-1months=months+12enddays,time=math.modf(days)localH,M,S=h_m_s(time)returnsetmetatable({date1=date1,date2=date2,partial=false,-- avoid index lookupyears=years,months=months,days=days,hours=H,minutes=M,seconds=S,isnegative=isnegative,iszero=iszero,age=_diff_age,duration=_diff_duration,},diffmt)endreturn{_current=current,_Date=Date,_days_in_month=days_in_month,}
close