# Module of TWiki Enterprise Collaboration Platform, http://TWiki.org/ # # Copyright (C) 2002 John Talintyre, john.talintyre@btinternet.com # Copyright (C) 2002-2018 Peter Thoeny, peter[at]thoeny.org # and TWiki Contributors. All Rights Reserved. TWiki Contributors # are listed in the AUTHORS file in the root of this distribution. # NOTE: Please extend that file, not this notice. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 3 # of the License, or (at your option) any later version. For # more details read LICENSE in the root of this distribution. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # # As per the GPL, removal of this notice is prohibited. =pod ---+ package TWiki::Time Time handling functions. =cut package TWiki::Time; use strict; require TWiki; use POSIX qw(tzset); # Constants use vars qw( @ISOMONTH @WEEKDAY @MONTHLENS %MON2NUM ); @ISOMONTH = ( 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ); # SMELL: does not account for leap years @MONTHLENS = ( 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ); @WEEKDAY = ( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun' ); %MON2NUM = ( jan => 0, feb => 1, mar => 2, apr => 3, may => 4, jun => 5, jul => 6, aug => 7, sep => 8, oct => 9, nov => 10, dec => 11 ); =pod ---++ StaticMethod parseTime( $szDate, $defaultLocal ) -> $iSecs Convert string date/time string to seconds since epoch (1970-01-01T00:00:00Z). * =$sDate= - date/time string Handles the following formats: Default TWiki format * 31 Dec 2001 - 23:59 TWiki format without time (defaults to 00:00) * 31 Dec 2001 Date seperated by '/', '.' or '-', time with '.' or ':' Date and time separated by ' ', '.' and/or '-' * 2001/12/31 23:59:59 * 2001.12.31.23.59.59 * 2001/12/31 23:59 * 2001.12.31.23.59 * 2001-12-31 23:59 * 2001-12-31 - 23:59 ISO format * 2001-12-31T23:59:59 ISO dates may have a timezone specifier, either Z or a signed difference in hh:mm format. For example: * 2001-12-31T23:59:59+01:00 * 2001-12-31T23:59Z The default timezone is Z, unless $defaultLocal is true in which case the local timezone will be assumed. If the date format was not recognised, will return 0. =cut sub parseTime { my( $date, $defaultLocal ) = @_; require Time::Local; # NOTE: This routine *will break* if input is not one of below formats! my $tzadj = 0; # Zulu if ($defaultLocal) { # Local time at midnight on the epoch gives us minus the # local difference. e.g. CST is GMT + 1, so midnight Jan 1 1970 CST # is -01:00Z ## $tzadj = -Time::Local::timelocal(0, 0, 0, 1, 0, 70); # Note: This does not work for GMT timezone itself, eg. under perl # v5.8.8 built for x86_64-linux-thread-multi., # Item7068: Proper handling of DST when in GMT timezone -- TWiki:Main/TomCrane my @now = localtime(time); $tzadj = Time::Local::timegm(@now) - Time::Local::timelocal(@now); } # try "31 Dec 2001 - 23:59" (TWiki date) # or "31 Dec 2001" if ($date =~ /(\d+)\s+([a-z]{3})\s+(\d+)(?:[-\s]+(\d+):(\d+))?/i) { my $year = $3; $year -= 1900 if( $year > 1900 ); return Time::Local::timegm( 0, $5||0, $4||0, $1, $MON2NUM{lc($2)}, $year ) - $tzadj; } # try "2001/12/31 23:59:59" or "2001.12.31.23.59.59" (RCS date) # or "2001-12-31 23:59:59" or "2001-12-31 - 23:59:59" if ($date =~ m!(\d+)[./\-](\d+)[./\-](\d+)[.\s\-]+(\d+)[.:](\d+)[.:](\d+)!) { my $year = $1; $year -= 1900 if( $year > 1900 ); return Time::Local::timegm( $6, $5, $4, $3, $2-1, $year ) - $tzadj; } # try "2001/12/31 23:59" or "2001.12.31.23.59" (RCS short date) # or "2001-12-31 23:59" or "2001-12-31 - 23:59" if ($date =~ m!(\d+)[./\-](\d+)[./\-](\d+)[.\s\-]+(\d+)[.:](\d+)!) { my $year = $1; $year -= 1900 if( $year > 1900 ); return Time::Local::timegm( 0, $5, $4, $3, $2-1, $year ) - $tzadj; } # ISO date if ($date =~ /(\d\d\d\d)(?:-(\d\d)(?:-(\d\d))?)?(?:T(\d\d)(?::(\d\d)(?::(\d\d(?:\.\d+)?))?)?)?(Z|[-+]\d\d(?::\d\d)?)?/ ) { my ($Y, $M, $D, $h, $m, $s, $tz) = ($1, $2||1, $3||1, $4||0, $5||0, $6||0, $7||''); $M--; $Y -= 1900 if( $Y > 1900 ); if ($tz eq 'Z') { $tzadj = 0; # Zulu } elsif ($tz =~ /([-+])(\d\d)(?::(\d\d))?/) { $tzadj = ($1||'').((($2 * 60) + ($3||0)) * 60); $tzadj -= 0; } return Time::Local::timegm( $s, $m, $h, $D, $M, $Y ) - $tzadj; } # give up, return start of epoch (01 Jan 1970 GMT) return 0; } =pod ---++ StaticMethod formatTime ($epochSeconds, $formatString, $outputTimeZone) -> $value * =$epochSeconds= epochSecs GMT * =$formatString= twiki time date format, default =$TWiki::cfg{DefaultDateFormat} - $hour:$min= * =$outputTimeZone= timezone to display, =gmtime= or =servertime=, default is whatever is set in =$TWiki::cfg{DisplayTimeValues}= =$formatString= supports: | $seconds | secs | | $minutes | mins | | $hours | hours | | $day | date | | $wday | weekday name | | $dow | day number (0 = Sunday) | | $week | week number | | $month | month name | | $mo | month number | | $year | 4-digit year | | $ye | 2-digit year | | $http | ful HTTP header format date/time | | $email | full email format date/time | | $rcs | full RCS format date/time | | $epoch | seconds since 1st January 1970 | =cut # previous known as TWiki::formatTime sub formatTime { my ($epochSeconds, $formatString, $outputTimeZone) = @_; my $value = $epochSeconds; # use default TWiki format "31 Dec 1999 - 23:59" unless specified $formatString ||= $TWiki::cfg{DefaultDateFormat} . ' - $hour:$min'; $outputTimeZone ||= $TWiki::cfg{DisplayTimeValues}; if( $formatString =~ /http|email/i ) { $outputTimeZone = 'gmtime'; } my( $sec, $min, $hour, $day, $mon, $year, $wday ); my $tz_str = 'GMT'; if( $outputTimeZone eq 'gmtime' ) { ( $sec, $min, $hour, $day, $mon, $year, $wday ) = gmtime( $epochSeconds ); } elsif ( $outputTimeZone eq 'servertime' ) { ( $sec, $min, $hour, $day, $mon, $year, $wday ) = localtime( $epochSeconds ); } else { my $tzSave; if ( $outputTimeZone ) { $tzSave = $ENV{TZ}; $ENV{TZ} = $outputTimeZone; # A workaround for item 7705: Ignore "tzset not available" eval { tzset(); } } ( $sec, $min, $hour, $day, $mon, $year, $wday ) = localtime( $epochSeconds ); if ( $outputTimeZone ) { if ( defined($tzSave) ) { $ENV{TZ} = $tzSave; } else { delete $ENV{TZ}; } # A workaround for item 7705: Ignore "tzset not available" eval { tzset(); } } if( $formatString =~ /(\$tz|iso)/i ) { # spcial case for local time zone offset calculation my( $tz_day, $tz_hour, $tz_min); my ( $gsec, $gmin, $ghour, $gday, $gmon, $gyear, $gwday ) = gmtime( $epochSeconds ); $tz_day = $wday - $gwday; $tz_hour = $hour - $ghour; $tz_min = $min - $gmin; $tz_day -= 7 if ($tz_day > 3); $tz_day += 7 if ($tz_day < -3); $tz_hour += (24 * $tz_day); $tz_min += (60 * $tz_hour); if ($tz_min < 0) { $tz_str = '-'; $tz_min *= -1; } else { $tz_str = '+'; } $tz_hour = $tz_min/60; $tz_min %= 60; $tz_str .= sprintf('%.2u',$tz_hour); $tz_str .= ':' if( $formatString =~ /iso/i ); $tz_str .= sprintf('%.2u',$tz_min); if (($tz_hour == 0) && ($tz_min == 0)) { $tz_str = 'GMT'; } } } #standard twiki date time formats if( $formatString =~ /rcs/i ) { # RCS format, example: "2001/12/31 23:59:59" $formatString = '$year/$mo/$day $hour:$min:$sec'; } elsif ( $formatString =~ /http|email/i ) { # HTTP and email header format, e.g. "Thu, 23 Jul 1998 07:21:56 EST" # RFC 822/2616/1123 $formatString = '$wday, $day $month $year $hour:$min:$sec $tz'; } elsif ( $formatString =~ /iso/i ) { # ISO Format, see spec at http://www.w3.org/TR/NOTE-datetime # "2002-12-31T19:30:12Z" or "2002-12-31T12:30:12-07:00" $formatString = '$year-$mo-$dayT$hour:$min:$sec$tz'; $tz_str = 'Z' if( $tz_str eq 'GMT' ); } $value = $formatString; $value =~ s/\$seco?n?d?s?/sprintf('%.2u',$sec)/gei; $value =~ s/\$minu?t?e?s?/sprintf('%.2u',$min)/gei; $value =~ s/\$hour?s?/sprintf('%.2u',$hour)/gei; $value =~ s/\$day/sprintf('%.2u',$day)/gei; $value =~ s/\$wday/$WEEKDAY[$wday]/gi; $value =~ s/\$dow/$wday/gi; $value =~ s/\$week/_weekNumber($day,$mon,$year,$wday)/egi; $value =~ s/\$mont?h?/$ISOMONTH[$mon]/gi; $value =~ s/\$mo/sprintf('%.2u',$mon+1)/gei; $value =~ s/\$year?/sprintf('%.4u',$year+1900)/gei; $value =~ s/\$ye/sprintf('%.2u',$year%100)/gei; $value =~ s/\$epoch/$epochSeconds/gi; $value =~ s/\$tz/$tz_str/geoi; return $value; } sub _weekNumber { my( $day, $mon, $year, $wday ) = @_; require Time::Local; # calculate the calendar week (ISO 8601) my $nextThursday = Time::Local::timegm(0, 0, 0, $day, $mon, $year) + (3 - ($wday + 6) % 7) * 24 * 60 * 60; # nearest thursday my $firstFourth = Time::Local::timegm(0, 0, 0, 4, 0, $year); # january, 4th return sprintf('%.0f', ($nextThursday - $firstFourth) / ( 7 * 86400 )) + 1; } =pod ---++ StaticMethod formatDelta( $s ) -> $string Format a time in seconds as a string. For example, "1 day, 3 hours, 2 minutes, 6 seconds" =cut sub formatDelta { my $secs = shift; my $language = shift; my $rem = $secs % (60 * 60 * 24); my $days = ($secs - $rem) / (60 * 60 * 24); $secs = $rem; $rem = $secs % (60 * 60); my $hours = ($secs - $rem) / (60 * 60); $secs = $rem; $rem = $secs % 60; my $mins = ($secs - $rem) / 60; $secs = $rem; my $str = ''; if ($language) { #format as in user's language if( $days ) { $str .= $language->maketext('[*,_1,day] ', $days); } if( $hours ) { $str .= $language->maketext('[*,_1,hour] ', $hours); } if( $mins ) { $str .= $language->maketext('[*,_1,minute] ', $mins); } if( $secs ) { $str .= $language->maketext('[*,_1,second] ', $secs); } } else { #original code, harcoded English (BAD) if( $days ) { $str .= $days . ' day' .( $days > 1 ? 's ' : ' ' ); } if( $hours ) { $str .= $hours . ' hour' .( $hours > 1 ? 's ' : ' ' ); } if( $mins ) { $str .= $mins . ' minute' .( $mins > 1 ? 's ' : ' ' ); } if( $secs ) { $str .= $secs . ' second' .( $secs > 1 ? 's ' : ' ' ); } } $str =~ s/\s+$//; return $str; } =pod ---++ StaticMethod parseInterval( $szInterval ) -> [$iSecs, $iSecs] Convert string representing a time interval to a pair of integers representing the amount of seconds since epoch for the start and end extremes of the time interval. * =$szInterval= - time interval string in yacc syntax, grammar and actions: interval ::= date { $$.start = fillStart($1); $$.end = fillEnd($1); } | date '/' date { $$.start = fillStart($1); $$.end = fillEnd($3); } | 'P' duration '/' date { $$.start = fillEnd($4)-$2; $$.end = fillEnd($4); } | date '/' 'P' duration { $$.start = fillStart($1); $$.end = fillStart($1)+$4; } ; an =interval= may be followed by a timezone specification string (this is not supported yet). =duration= has the form (regular expression): P()+ nameOfDuration may be one of: * y(year), m(month), w(week), d(day), h(hour), M(minute), S(second) =date= follows ISO8601 and must include hypens. (any amount of trailing elements may be omitted and will be filled in differently on the differents ends of the interval as to include the longest possible interval): * 2001-01-01T00:00:00 * 2001-12-31T23:59:59 timezone is optional. Default is local time. If the format is not recognised, will return empty interval [0,0]. TODO: timezone testing, especially on non valid strings =cut sub parseInterval{ my ($theInterval) = @_; my @lt = localtime(); my $today = sprintf('%04d-%02d-%02d',$lt[5]+1900, $lt[4]+1, $lt[3]); my $now = $today . sprintf('T%02d:%02d:%02d',$lt[2], $lt[1], $lt[0]); # replace $now and $today shortcuts $theInterval =~ s/\$today/$today/g; $theInterval =~ s/\$now/$now/g; # if $theDate does not contain a '/': force it to do so. $theInterval = $theInterval.'/'.$theInterval unless ($theInterval =~ /\// ); my @ends = split(/\//, $theInterval); # first translate dates into seconds from epoch, # in the second loop we will examine interval durations. foreach my $i (0,1) { # if not a period of time: next if ($ends[$i] =~ /^P/); # TODO assert(must include the year) if($i) { # fillEnd # if ending point, complete with parts from "-12-31T23:59:60" # if completing ending point, check last day of month # TODO: do we do leap years? if (length($ends[$i]) == 7){ my $month = substr($ends[$i],5); $ends[$i] .= $MONTHLENS[$month-1]; } $ends[$i] .= substr("0000-12-31T23:59:59",length($ends[$i])); } else { # fillStart # if starting point, complete with parts from "-01-01T00:00:00" $ends[$i] .= substr("0000-01-01T00:00:00",length($ends[$i])); } # convert the string into integer amount of seconds # from 1970-01-01T00:00:00.00 UTC $ends[$i] = parseTime($ends[$i], 1); } # now we're ready to translate interval durations... # ... we don't do P !!! my @oper = ("-","+"); # if any extreme was a time duration, examine it foreach my $i (0,1) { next unless ($ends[$i] =~ /^P/); # drop the 'P', substitute each letter with '*+', # where is the amount of seconds represented by # the unit. for example: w (week) becomes '*604800+'. $ends[$i] =~ s/^P//; $ends[$i] =~ s/y/\*31556925\+/gi; # tropical year $ends[$i] =~ s/m/\*2592000\+/g; # 1m = 30 days $ends[$i] =~ s/w/\*604800\+/gi; # 1w = 7 days $ends[$i] =~ s/d/\*86400\+/gi; $ends[$i] =~ s/h/\*3600\+/gi; $ends[$i] =~ s/M/\*60\+/g; # note: m != M $ends[$i] =~ s/S/\*1\+/gi; # possibly append '0' and evaluate numerically the string. $ends[$i] =~ s/\+$/+0/; $ends[$i] =~ s#[^-\d+*/]##g; my $duration = eval( $ends[$i] ); # the value computed, if it specifies the starting point # in time, must be subtracted from the previously # computed ending point. if it specifies the ending # point, it must be added to the previously computed # starting point. my $expr = "$ends[1-$i]$oper[$i]($ends[$i])"; $expr =~ s#[^-\d+*/()]##g; $ends[$i] = eval( $expr ); # SMELL: if the user specified both start and end as a # time duration, some kind of error must be reported. } return @ends; } 1;