From 69b85f0dd4e4726eb8bfbe9cefedee8ddccc5abe Mon Sep 17 00:00:00 2001 From: Peter Rotich <peter@osticket.com> Date: Sun, 18 Dec 2016 01:00:57 +0000 Subject: [PATCH] DateTime Address edge cases where timezone mixups happens on DateTimeField Allow datetime field to be timezone agnostic (not timezone aware) to display the timezone used to set the field. The timezone of the last user or agent that edited the field is used. --- include/class.format.php | 245 ++++++++++++++++++++++++++----------- include/class.forms.php | 226 +++++++++++++++++++++++++--------- include/class.i18n.php | 40 ++++++ include/class.misc.php | 35 +++--- include/class.timezone.php | 17 +-- 5 files changed, 402 insertions(+), 161 deletions(-) diff --git a/include/class.format.php b/include/class.format.php index 864bc6456..6058142d8 100644 --- a/include/class.format.php +++ b/include/class.format.php @@ -512,74 +512,128 @@ class Format { global $cfg; static $cache; - if (!$timestamp) - return ''; - - if ($fromDb) + if ($timestamp && $fromDb) $timestamp = Misc::db2gmtime($timestamp); - if (class_exists('IntlDateFormatter')) { - $locale = Internationalization::getCurrentLocale($user); - $key = "{$locale}:{$dayType}:{$timeType}:{$timezone}:{$format}"; - if (!isset($cache[$key])) { - // Setting up the IntlDateFormatter is pretty expensive, so - // cache it since there aren't many variations of the - // arguments passed to the constructor - $cache[$key] = $formatter = new IntlDateFormatter( - $locale, - $dayType, - $timeType, - $timezone, - IntlDateFormatter::GREGORIAN, - $format ?: null + // Make sure timestamp is valid for realz. + if (!$timestamp || !($datetime = DateTime::createFromFormat('U', $timestamp))) + return ''; + + // Set the desired timezone (caching since it will be mostly same + // for most date formatting. + if (isset($cache[$timezone])) + $tz = $cache[$timezone]; + else + $cache[$timezone] = $tz = new DateTimeZone($timezone ?: $cfg->getTimezone()); + $datetime->setTimezone($tz); + + // Formmating options + $options = array( + 'timezone' => $tz->getName(), + 'locale' => Internationalization::getCurrentLocale($user), + 'daytype' => $dayType, + 'timetype' => $timeType, + 'strftime' => $strftimeFallback, ); - if ($cfg->isForce24HourTime()) { - $format = str_replace(array('a', 'h'), array('', 'H'), - $formatter->getPattern()); - $formatter->setPattern($format); - } - } - else { - $formatter = $cache[$key]; - } - return $formatter->format($timestamp); - } - // Fallback using strftime - static $user_timezone; - if (!isset($user_timezone)) - $user_timezone = new DateTimeZone($cfg->getTimezone() ?: date_default_timezone_get()); - $format = self::getStrftimeFormat($format); - // Properly convert to user local time - if (!($time = DateTime::createFromFormat('U', $timestamp, new DateTimeZone('UTC')))) - return ''; + return self::IntDateFormat($datetime, $format, $options); - $offset = $user_timezone->getOffset($time); - $timestamp = $time->getTimestamp() + $offset; - return strftime($format ?: $strftimeFallback, $timestamp); } - function parseDate($date, $format=false) { + // IntDateFormat + // Format datetime to desired format in accorrding to desired locale + function IntDateFormat(DateTime $datetime, $format, $options=array()) { global $cfg; + if (!$datetime instanceof DateTime) + return ''; + + $format = $format ?: $cfg->getDateFormat(); + $timezone = $datetime->getTimeZone(); + // Use IntlDateFormatter if available if (class_exists('IntlDateFormatter')) { - $formatter = new IntlDateFormatter( - Internationalization::getCurrentLocale(), - null, - null, - null, - IntlDateFormatter::GREGORIAN, - $format ?: null - ); - if ($cfg->isForce24HourTime()) { + + if ($cfg && $cfg->isForce24HourTime()) $format = str_replace(array('a', 'h'), array('', 'H'), - $formatter->getPattern()); - $formatter->setPattern($format); - } - return $formatter->parse($date); + $format); + + $options += array( + 'pattern' => $format, + 'timezone' => $timezone->getName()); + + if ($fmt=Internationalization::getIntDateFormatter($options)) + return $fmt->format($datetime); + } + + // Fallback to using strftime which is not timezone aware + // Figure out timezone offset for given timestamp + $timestamp = $datetime->format('U'); + $time = DateTime::createFromFormat('U', $timestamp, new DateTimeZone('UTC')); + $timestamp += $timezone->getOffset($time); + // Change format to strftime format otherwise us a fallback format + $format = self::getStrftimeFormat($format) ?: $options['strftime'] + ?: '%x %X'; + return strftime($format, $timestamp); + } + + // Normalize ambiguous timezones + function timezone($tz, $default=false) { + + // Translate ambiguous 'GMT' timezone + if ($tz == 'GMT') + return 'Europe/London'; + + if (!$tz || !strcmp($tz, '+00:00')) + $tz = 'UTC'; + + if (is_numeric($tz)) + $tz = timezone_name_from_abbr('', $tz, false); + // Forbid timezone abbreviations like 'CDT' + elseif ($tz !== 'UTC' && strpos($tz, '/') === false) { + // Attempt to lookup based on the abbreviation + if (!($tz = timezone_name_from_abbr($tz))) + // Abbreviation doesn't point to anything valid + return $default; } - // Fallback using strtotime - return strtotime($date); + + // SYSTEM does not describe a time zone, ensure we have a valid zone + // by attempting to create an instance of DateTimeZone() + try { + $timezone = new DateTimeZone($tz); + return $timezone->getName(); + } catch(Exception $ex) { + return $default; + } + + return $tz; + } + + function parseDatetime($date, $locale=null, $format=false) { + global $cfg; + + if (!$date) + return null; + + // Timestamp format? + if (is_numeric($date)) + return DateTime::createFromFormat('U', $date); + + $datetime = null; + try { + $datetime = new DateTime($date); + $tz = $datetime->getTimezone()->getName(); + if ($tz && $tz[0] == '+' || $tz[0] == '-') + $tz = (int) $datetime->format('Z'); + $timezone = new DateTimeZone(Format::timezone($tz) ?: 'UTC'); + $datetime->setTimezone($timezone); + } catch (Exception $ex) { + // Fallback using strtotime + if (($time=strtotime($date))) + $datetime = DateTime::createFromFormat('U', $time); + + } + + return $datetime; } function time($timestamp, $fromDb=true, $format=false, $timezone=false, $user=false) { @@ -886,23 +940,45 @@ else { class FormattedLocalDate implements TemplateVariable { + var $date; var $timezone; + var $datetime; var $fromdb; + var $format; + + function __construct($date, $options=array()) { + + // Date to be formatted + $this->datetime = Format::parseDateTime($date); + $this->date = $this->datetime->getTimestamp(); + // Desired timezone + if (isset($options['timezone'])) + $this->timezone = $options['timezone']; + else + $this->timezone = false; + // User + if (isset($options['user'])) + $this->user = $options['user']; + else + $this->user = false; - function __construct($date, $timezone=false, $user=false, $fromdb=true) { - $this->date = $date; - $this->timezone = $timezone; - $this->user = $user; - $this->fromdb = $fromdb; + // DB date or nah? + if (isset($options['fromdb'])) + $this->fromdb = $options['fromdb']; + else + $this->fromdb = true; + // Desired format + if (isset($options['format']) && $options['format']) + $this->format = $options['format']; } - function asVar() { - return $this->getVar('long'); + function getDateTime() { + return $this->datetime; } - function __toString() { - return $this->asVar(); + function asVar() { + return $this->getVar($this->format ?: 'long'); } function getVar($what) { @@ -920,6 +996,10 @@ implements TemplateVariable { } } + function __toString() { + return $this->asVar() ?: ''; + } + static function getVarScope() { return array( 'full' => 'Expanded date, e.g. day, month dd, yyyy', @@ -938,7 +1018,28 @@ extends FormattedLocalDate { function __toString() { global $cfg; - return (string) new FormattedLocalDate($this->date, $cfg->getTimezone(), false, $this->fromdb); + + $timezone = new DatetimeZone($this->timezone ?: + $cfg->getTimezone()); + $options = array( + 'timezone' => $timezone->getName(), + 'fromdb' => $this->fromdb, + 'format' => $this->format + ); + + $val = (string) new FormattedLocalDate($this->date, $options); + if ($this->timezone && $this->format == 'long') { + try { + $this->datetime->setTimezone($timezone); + $val = sprintf('%s %s', + $val, $this->datetime->format('T')); + + } catch(Exception $ex) { + // ignore + } + } + + return $val; } function getVar($what, $context=null) { @@ -951,13 +1052,19 @@ extends FormattedLocalDate { case 'user': // Fetch $recipient from the context and find that user's time zone if ($context && ($recipient = $context->getObj('recipient'))) { - $tz = $recipient->getTimezone() ?: $cfg->getDefaultTimezone(); - return new FormattedLocalDate($this->date, $tz, $recipient); + $options = array( + 'timezone' => $recipient->getTimezone() ?: $cfg->getDefaultTimezone(), + 'user' => $recipient + ); + return new FormattedLocalDate($this->date, $options); } // Don't resolve the variable until correspondance is sent out return false; case 'system': - return new FormattedLocalDate($this->date, $cfg->getDefaultTimezone()); + return new FormattedLocalDate($this->date, array( + 'timezone' => $cfg->getDefaultTimezone() + ) + ); } } diff --git a/include/class.forms.php b/include/class.forms.php index fa5f2d882..bc3a21b14 100644 --- a/include/class.forms.php +++ b/include/class.forms.php @@ -1765,37 +1765,116 @@ class ChoiceField extends FormField { class DatetimeField extends FormField { static $widget = 'DatetimePickerWidget'; + var $min = null; + var $max = null; + + // Get php DatateTime object of the field - null if value is empty + function getDateTime($value=null) { + return Format::parseDateTime($value ?: $this->value); + } + + function getMinDateTime() { + + if (!isset($this->min)) { + $config = $this->getConfiguration(); + $this->min = $config['min'] + ? Format::parseDateTime($config['min']) : false; + } + + return $this->min; + } + + function getMaxDateTime() { + + if (!isset($this->max)) { + $config = $this->getConfiguration(); + $this->max = $config['max'] + ? Format::parseDateTime($config['max']) : false; + } + + return $this->max; + } + function to_database($value) { - // Store time in gmt time, unix epoch format - return $value ? date('Y-m-d H:i:s', $value) : $value; + // Store time in format given by Date Picker (DateTime::W3C) + return $value; } function to_php($value) { - if (!$value) - return $value; - else - return (int) strtotime($value); + + if (strtotime($value) <= 0) + return 0; + + return $value; } - function asVar($value, $id=false) { - if (!$value) return null; - return new FormattedDate((int) $value, 'UTC', false, false); + function display($value) { + global $cfg; + + if (!$value || !($datetime = Format::parseDatetime($value))) + return ''; + + $config = $this->getConfiguration(); + if ($config['gmt']) + return $this->format((int) $datetime->format('U')); + + $value = $this->format($datetime->format('U'), + $datetime->getTimezone()->getName()); + + // No need to show timezone + if (!$config['time']) + return $value; + + // Display is NOT timezone aware show entry's timezone. + return sprintf('%s (%s)', + $value, $datetime->format('T')); } - function asVarType() { - return 'FormattedDate'; + + function format($timestamp, $timezone=false) { + + if (!$timestamp || $timestamp <= 0) + return ''; + + $config = $this->getConfiguration(); + if ($config['time']) + $formatted = Format::datetime($timestamp, false, $timezone); + else + $formatted = Format::date($timestamp, false, false, $timezone); + + return $formatted; } function toString($value) { + + $timestamp = is_int($value) ? $value : (int) strtotime($value); + if ($timestamp <= 0) + return ''; + + return $this->format($timestamp); + } + + function asVar($value, $id=false) { global $cfg; - $config = $this->getConfiguration(); - // If GMT is set, convert to local time zone. Otherwise, leave - // unchanged (default TZ is UTC) + if (!$value) - return ''; - if ($config['time']) - return Format::datetime($value, false, !$config['gmt'] ? 'UTC' : false); + return null; + + $datetime = $this->getDateTime($value); + $config = $this->getConfiguration(); + if (!$config['gmt'] || !$config['time']) + $timezone = $datetime->getTimezone()->getName(); else - return Format::date($value, false, false, !$config['gmt'] ? 'UTC' : false); + $timezone = false; + + return new FormattedDate($value, array( + 'timezone' => $timezone, + 'format' => $config['time'] ? 'long' : 'short' + ) + ); + } + + function asVarType() { + return 'FormattedDate'; } function getConfigurationOptions() { @@ -1823,16 +1902,34 @@ class DatetimeField extends FormField { } function validateEntry($value) { + global $cfg; + $config = $this->getConfiguration(); parent::validateEntry($value); - if (!$value) return; - if ($config['min'] and $value < $config['min']) - $this->_errors[] = __('Selected date is earlier than permitted'); - elseif ($config['max'] and $value > $config['max']) - $this->_errors[] = __('Selected date is later than permitted'); - // strtotime returns -1 on error for PHP < 5.1.0 and false thereafter - elseif ($value === -1 or $value === false) + if (!$value || !($datetime = Format::parseDatetime($value))) + return; + + // Parse value to DateTime object + $val = Format::parseDatetime($value); + // Get configured min/max (if any) + $min = $this->getMinDatetime(); + $max = $this->getMaxDatetime(); + + if (!$val) { $this->_errors[] = __('Enter a valid date'); + } elseif ($min and $val < $min) { + $this->_errors[] = sprintf('%s (%s)', + __('Selected date is earlier than permitted'), + Format::date($min->getTimestamp(), false, false, + $min->getTimezone()->getName() ?: 'UTC') + ); + } elseif ($max and $val > $max) { + $this->_errors[] = sprintf('%s (%s)', + __('Selected date is later than permitted'), + Format::date($max->getTimestamp(), false, false, + $max->getTimezone()->getName() ?: 'UTC') + ); + } } // SearchableField interface ------------------------------ @@ -3591,33 +3688,43 @@ class DatetimePickerWidget extends Widget { $config = $this->field->getConfiguration(); if ($this->value) { - $this->value = is_int($this->value) ? $this->value : - strtotime($this->value); - - if ($config['gmt']) { - // Convert to GMT time - $tz = new DateTimeZone($cfg->getTimezone()); - $D = DateTime::createFromFormat('U', $this->value); - $this->value += $tz->getOffset($D); + + $timezone = null; + if (is_int($this->value)) + // Assuming UTC timezone. + $datetime = DateTime::createFromFormat('U', $this->value); + else { + $datetime = Format::parseDateTime($this->value); } - list($hr, $min) = explode(':', date('H:i', $this->value)); - $this->value = Format::date($this->value, false, false, 'UTC'); + + if ($config['time']) { + // Convert to user's timezone for update. + $timezone = new DateTimeZone($cfg->getTimezone()); + $datetime->setTimezone($timezone); + } + + $this->value = Format::date($datetime->getTimestamp(), false, + false, $timezone ? $timezone->getName() : 'UTC'); + } else { + $timezone = new DateTimeZone($cfg->getTimezone()); + $datetime = new DateTime('now'); + $datetime->setTimezone($timezone); } ?> <input type="text" name="<?php echo $this->name; ?>" id="<?php echo $this->id; ?>" style="display:inline-block;width:auto" - value="<?php echo Format::htmlchars($this->value); ?>" size="12" + value="<?php echo Format::htmlchars($this->value ?: ''); ?>" size="12" autocomplete="off" class="dp" /> <script type="text/javascript"> $(function() { $('input[name="<?php echo $this->name; ?>"]').datepicker({ <?php - if ($config['min']) - echo "minDate: new Date({$config['min']}000),"; - if ($config['max']) - echo "maxDate: new Date({$config['max']}000),"; + if ($dt=$this->field->getMinDateTime()) + echo sprintf("minDate: new Date(%s),\n", $dt->format('U')*1000); + if ($dt=$this->field->getMaxDateTime()) + echo sprintf("maxDate: new Date(%s),\n", $dt->format('U')*1000); elseif (!$config['future']) - echo "maxDate: new Date().getTime(),"; + echo "maxDate: new Date().getTime(),\n"; ?> numberOfMonths: 2, showButtonPanel: true, @@ -3628,37 +3735,38 @@ class DatetimePickerWidget extends Widget { }); </script> <?php - if ($config['time']) + if ($config['time']) { + list($hr, $min) = explode(':', $datetime ? + $datetime->format('H:i') : ''); // TODO: Add time picker -- requires time picker or selection with // Misc::timeDropdown echo ' ' . Misc::timeDropdown($hr, $min, $this->name . ':time'); + echo sprintf(' <span class="faded">(%s)</span>', + $datetime->format('T')); + } } /** * Function: getValue * Combines the datepicker date value and the time dropdown selected - * time value into a single date and time string value. + * time value into a single date and time string value in DateTime::W3C */ function getValue() { global $cfg; - $data = $this->field->getSource(); - $config = $this->field->getConfiguration(); - if ($datetime = parent::getValue()) { - $datetime = is_int($datetime) ? $datetime : - strtotime($datetime); - if ($datetime && isset($data[$this->name . ':time'])) { - list($hr, $min) = explode(':', $data[$this->name . ':time']); - $datetime += $hr * 3600 + $min * 60; - } - if ($datetime && $config['gmt']) { - // Convert to GMT time - $tz = new DateTimeZone($cfg->getTimezone()); - $D = DateTime::createFromFormat('U', $datetime); - $datetime -= $tz->getOffset($D); - } + if ($value = parent::getValue()) { + // Effective timezone for the selection + $tz = new DateTimeZone($cfg->getTimezone()); + // See if we have time + $data = $this->field->getSource(); + if ($value && isset($data[$this->name . ':time'])) + $value .=' '.$data[$this->name . ':time']; + + $dt = new DateTime($value, $tz); + $value = $dt->format('Y-m-d H:i:s T'); } - return $datetime; + + return $value; } } diff --git a/include/class.i18n.php b/include/class.i18n.php index ac6db0a33..49c4d8e5a 100644 --- a/include/class.i18n.php +++ b/include/class.i18n.php @@ -401,6 +401,46 @@ class Internationalization { return $locale; } + + // getIntDateFormatter($options) + // + // Setting up the IntlDateFormatter is pretty expensive, so cache it since + // there aren't many variations of the arguments passed to the constructor + static function getIntDateFormatter($options) { + static $cache = false; + global $cfg; + + // Set some defaults + $options['locale'] = $options['locale'] ?: self::getCurrentLocale(); + + // Generate signature key for options given + $k = md5(implode(':', array_filter( + array_intersect_key($options, + array_flip(array( + 'locale', + 'daytype', + 'timetype', + 'timezone', + 'pattern') + ))))); + + // We if we have it cached + if (isset($cache[$k]) && $cache[$k]) + return $cache[$k]; + + // Create formatter && cache + $cache[$k] = $formatter = new IntlDateFormatter( + $options['locale'], + $options['daytype'] ?: null, + $options['timetype'] ?: null, + $options['timezone'] ?: null, + $options['calendar'] ?: IntlDateFormatter::GREGORIAN, + $options['pattern'] ?: null + ); + + return $formatter; + } + static function rfc1766($what) { if (is_array($what)) return array_map(array(get_called_class(), 'rfc1766'), $what); diff --git a/include/class.misc.php b/include/class.misc.php index 9b706168d..b5ce88705 100644 --- a/include/class.misc.php +++ b/include/class.misc.php @@ -167,31 +167,32 @@ class Misc { function timeDropdown($hr=null, $min =null,$name='time') { global $cfg; - $hr =is_null($hr)?0:$hr; - $min =is_null($min)?0:$min; - //normalize; - if($hr>=24) - $hr=$hr%24; - elseif($hr<0) - $hr=0; - - if($min>=45) - $min=45; - elseif($min>=30) - $min=30; - elseif($min>=15) - $min=15; + if ($hr >= 24) + $hr = $hr%24; + elseif ($hr < 0) + $hr = 0; + elseif ($hr) + $hr = (int) $hr; + else // Default to 5pm + $hr = 17; + + if ($min >= 45) + $min = 45; + elseif ($min >= 30) + $min = 30; + elseif ($min >= 15) + $min = 15; else - $min=0; + $min = 0; $time = Misc::user2gmtime(mktime(0,0,0)); ob_start(); echo sprintf('<select name="%s" id="%s" style="display:inline-block;width:auto">',$name,$name); - echo '<option value="" selected>'.__('Time').'</option>'; + echo '<option value="" selected="selected">—'.__('Time').'—</option>'; for($i=23; $i>=0; $i--) { for ($minute=45; $minute>=0; $minute-=15) { - $sel=($hr==$i && $min==$minute)?'selected="selected"':''; + $sel=($hr===$i && $min===$minute) ? 'selected="selected"' : ''; $_minute=str_pad($minute, 2, '0',STR_PAD_LEFT); $_hour=str_pad($i, 2, '0',STR_PAD_LEFT); $disp = Format::time($time + ($i*3600 + $minute*60 + 1), false); diff --git a/include/class.timezone.php b/include/class.timezone.php index eb2afca67..1f24531f2 100644 --- a/include/class.timezone.php +++ b/include/class.timezone.php @@ -173,23 +173,8 @@ class DbTimezone { // timezone in PHP which honors BST (British Summer Time) return 'Europe/London'; } - // Forbid timezone abbreviations like 'CDT' - elseif ($TZ !== 'UTC' && strpos($TZ, '/') === false) { - // Attempt to lookup based on the abbreviation - if (!($TZ = timezone_name_from_abbr($TZ))) - // Abbreviation doesn't point to anything valid - return false; - } - // SYSTEM does not describe a time zone, ensure we have a valid zone - // by attempting to create an instance of DateTimeZone() - try { - new DateTimeZone($TZ); - return $TZ; - } - catch (Exception $ex) { - return false; - } + return Format::timezone($TZ); } function dst_dates($year) { -- GitLab