diff --git a/core/lib/Drupal/Component/Datetime/DateTimePlus.php b/core/lib/Drupal/Component/Datetime/DateTimePlus.php index 2134ffb..0386faf 100644 --- a/core/lib/Drupal/Component/Datetime/DateTimePlus.php +++ b/core/lib/Drupal/Component/Datetime/DateTimePlus.php @@ -30,6 +30,18 @@ * The parent class would convert that value to '2010-11-30' and report * a warning but not an error. This extension treats that as an error. * + * This class adds a test for the creation of a date using a string + * that includes a timezone offset or abbreviation because anything + * other than a complete timezone name results in ambiguity and + * inaccuracy since there are many timezones that use the same + * abbreviation and offset. Without a complete timezone name we + * can't modify or compare the date correctly. In addition, a + * date created this way has a timezone name that can't be used + * to create a valid timezone object. We can't guess the real + * timezone from those values, and we can't tell what was intended + * if that offset does not match the timezone that was input. + * @see http://derickrethans.nl/gmt-being-tricky.html + * * As with the base class, a date object may be created even if it has * errors. It has an errors array attached to it that explains what the * errors are. This is less disruptive than allowing datetime exceptions @@ -127,7 +139,7 @@ class DateTimePlus extends \DateTime { * date will be created using the createFromFormat() method. * Defaults to NULL. * @see http://us3.php.net/manual/en/datetime.createfromformat.php - * @params array $settings + * @param array $settings * - boolean $validate_format * The format used in createFromFormat() allows slightly different * values than format(). If we use an input format that works in @@ -197,6 +209,9 @@ public function __construct($time = 'now', $timezone = NULL, $format = NULL, $se $this->constructFallback(); } + // Final validity check. + $this->checkValidity(); + // Clean up the error messages. $this->getErrors(); $this->errors = array_unique($this->errors); @@ -447,6 +462,63 @@ public function constructFallback() { } /** + * Final validation tests + * + * If a date was created from a string with a timezone offset or + * timezone abbreviation, like +10:00 or 'EST', the input timezone + * will be ignored and the value created may be incorrect and won't + * be modified correctly across daylight savings time. It also + * creates a date that has a timezone name (+10:00, or 'EST'), + * that is invalid input for creating another timezone object. + * This can only happen if a timezone offset or abbreviation is + * appended to the input time string because it's impossible to + * create a timezone object from these values. + * + * We first check to see if the provided abbreviation or offset + * seems to match the provided timezone name. If so, the timezone + * name is updated. If not, an error is thrown. + */ + public function checkValidity() { + + // If we already have errors, these tests might not work. + if (!empty($this->errors)) { + return; + } + + // See if the created timezone is a timezone abbreviation. + $created_timezone = $this->getTimezone()->getName(); + if (strlen($created_timezone) <= 4 && $created_timezone != 'UTC') { + $match = FALSE; + $abbr = strtolower($created_timezone); + $timezone_abbreviations = \DateTimeZone::listAbbreviations(); + foreach ($timezone_abbreviations[$abbr] as $abbreviation) { + if ($abbreviation['timezone_id'] == $this->input_timezone_adjusted->getName()) { + $match = TRUE; + break; + } + } + if ($match) { + $this->setTimezone($this->input_timezone_adjusted); + } + else { + $this->errors[] = 'The input time includes a timezone abbreviation that is inconsistent with the timezone name.'; + } + } + + // Otherwise see if the created timezone is an offset. + elseif (!preg_match('/[a-zA-Z_]/', $created_timezone)) { + $string_offset = $this->getOffset(); + $comp = new \DateTime($this->format('Y-m-d H:i:s'), $this->input_timezone_adjusted); + if ($this->getOffset() == $comp->getOffset()) { + $this->setTimezone($this->input_timezone_adjusted); + } + else { + $this->errors[] = 'The input time includes a timezone offset that is inconsistent with the timezone name.'; + } + } + } + + /** * Examine getLastErrors() and see what errors to report. * * We're interested in two kinds of errors: anything that DateTime @@ -634,7 +706,7 @@ function canUseIntl() { * @param string $format * A format string using either PHP's date() or the * IntlDateFormatter() format. - * @params array $settings + * @param array $settings * - string $format_string_type * Which pattern is used by the format string. When using the * Intl formatter, the format string must use the Intl pattern, diff --git a/core/lib/Drupal/Core/Datetime/DrupalDateTime.php b/core/lib/Drupal/Core/Datetime/DrupalDateTime.php index 9378406..d917c3e 100644 --- a/core/lib/Drupal/Core/Datetime/DrupalDateTime.php +++ b/core/lib/Drupal/Core/Datetime/DrupalDateTime.php @@ -36,7 +36,7 @@ class DrupalDateTime extends DateTimePlus { * date will be created using the createFromFormat() method. * Defaults to NULL. * @see http://us3.php.net/manual/en/datetime.createfromformat.php - * @params array $settings + * @param array $settings * - boolean $validate_format * The format used in createFromFormat() allows slightly different * values than format(). If we use an input format that works in @@ -75,6 +75,18 @@ public function __construct($time = 'now', $timezone = NULL, $format = NULL, $se } /** + * Override basic component timezone handling to use Drupal's + * knowledge of the preferred user timezone. + */ + public function prepareTimezone($timezone) { + $user_timezone = drupal_get_user_timezone(); + if (empty($timezone) && !empty($user_timezone)) { + $timezone = $user_timezone; + } + parent::prepareTimezone($timezone); + } + + /** * Format the date for display. * * Use the IntlDateFormatter to display the format, if available. @@ -86,7 +98,7 @@ public function __construct($time = 'now', $timezone = NULL, $format = NULL, $se * @param string $format * A format string using either date() or IntlDateFormatter() * format. - * @params array $settings + * @param array $settings * - string $format_string_type * Which pattern is used by the format string. When using the * Intl formatter, the format string must use the Intl pattern, diff --git a/core/modules/system/lib/Drupal/system/Tests/Datetime/DateTimePlusTest.php b/core/modules/system/lib/Drupal/system/Tests/Datetime/DateTimePlusTest.php index b268914..4eba36d 100644 --- a/core/modules/system/lib/Drupal/system/Tests/Datetime/DateTimePlusTest.php +++ b/core/modules/system/lib/Drupal/system/Tests/Datetime/DateTimePlusTest.php @@ -73,6 +73,23 @@ public function testDateStrings() { $value = $date->format('c'); $expected = '2009-06-07T00:00:00-05:00'; $this->assertEqual($expected, $value, "Test new DateTimePlus($input, $timezone): should be $expected, found $value."); + + // Create date object from date string. + $input = '2009-03-07 10:30'; + $timezone = 'Australia/Canberra'; + $date = new DateTimePlus($input, $timezone); + $value = $date->format('c'); + $expected = '2009-03-07T10:30:00+11:00'; + $this->assertEqual($expected, $value, "Test new DateTimePlus($input, $timezone): should be $expected, found $value."); + + // Same during daylight savings time. + $input = '2009-06-07 10:30'; + $timezone = 'Australia/Canberra'; + $date = new DateTimePlus($input, $timezone); + $value = $date->format('c'); + $expected = '2009-06-07T10:30:00+10:00'; + $this->assertEqual($expected, $value, "Test new DateTimePlus($input, $timezone): should be $expected, found $value."); + } /** @@ -96,6 +113,23 @@ function testDateArrays() { $expected = '2010-02-28T10:00:00-06:00'; $this->assertEqual($expected, $value, "Test new DateTimePlus(array('year' => 2010, 'month' => 2, 'day' => 28, 'hour' => 10), $timezone): should be $expected, found $value."); + // Create date object from date array, date only. + $input = array('year' => 2010, 'month' => 2, 'day' => 28); + $timezone = 'Europe/Berlin'; + $date = new DateTimePlus($input, $timezone); + $value = $date->format('c'); + $expected = '2010-02-28T00:00:00+01:00'; + $this->assertEqual($expected, $value, "Test new DateTimePlus(array('year' => 2010, 'month' => 2, 'day' => 28), $timezone): should be $expected, found $value."); + + // Create date object from date array with hour. + $input = array('year' => 2010, 'month' => 2, 'day' => 28, 'hour' => 10); + $timezone = 'Europe/Berlin'; + $date = new DateTimePlus($input, $timezone); + $value = $date->format('c'); + $expected = '2010-02-28T10:00:00+01:00'; + $this->assertEqual($expected, $value, "Test new DateTimePlus(array('year' => 2010, 'month' => 2, 'day' => 28, 'hour' => 10), $timezone): should be $expected, found $value."); + + } /** @@ -226,6 +260,23 @@ function testTimezoneConversion() { $value = $date->getOffset(); $this->assertEqual($expected, $value, "The current offset should be $expected, found $value."); + // Convert the local time to UTC using string input. + $input = '1969-12-31 16:00:00'; + $timezone = 'Europe/Warsaw'; + $date = new DateTimePlus($input, $timezone); + $offset = $date->getOffset(); + $value = $date->format('c'); + $expected = '1969-12-31T16:00:00+01:00'; + $this->assertEqual($expected, $value, "Test new DateTimePlus('$input', '$timezone'): should be $expected, found $value."); + + $expected = 'Europe/Warsaw'; + $value = $date->getTimeZone()->getName(); + $this->assertEqual($expected, $value, "The current timezone should be $expected, found $value."); + $expected = '+3600'; + $value = $date->getOffset(); + $this->assertEqual($expected, $value, "The current offset should be $expected, found $value."); + + } /** @@ -277,7 +328,7 @@ function testDateFormat() { function testInvalidDates() { // Test for invalid month names when we are using a short version - // of the month + // of the month. $input = '23 abc 2012'; $timezone = NULL; $format = 'd M Y'; @@ -340,6 +391,42 @@ function testInvalidDates() { } /** + * Test that DrupalDateTime can detect the right timezone to use. + * When specified or not. + */ + public function testDateTimezone() { + global $user; + + $date_string = '2007-01-31 21:00:00'; + + // Detect the system timezone. + $system_timezone = date_default_timezone_get(); + + // Create a date object with an unspecified timezone, which should + // end up using the system timezone. + $date = new DateTimePlus($date_string); + $timezone = $date->getTimezone()->getName(); + $this->assertTrue($timezone == $system_timezone, 'DateTimePlus uses the system timezone when there is no site timezone.'); + + // Create a date object with a specified timezone name. + $date = new DateTimePlus($date_string, 'America/Yellowknife'); + $timezone = $date->getTimezone()->getName(); + $this->assertTrue($timezone == 'America/Yellowknife', 'DateTimePlus uses the specified timezone if provided.'); + + // Create a date object with a timezone object. + $date = new DateTimePlus($date_string, new \DateTimeZone('Australia/Canberra')); + $timezone = $date->getTimezone()->getName(); + $this->assertTrue($timezone == 'Australia/Canberra', 'DateTimePlus uses the specified timezone if provided.'); + + // Create a date object with another date object. + $new_date = new DateTimePlus('now', 'Pacific/Midway'); + $date = new DateTimePlus($new_date); + $timezone = $date->getTimezone()->getName(); + $this->assertTrue($timezone == 'Pacific/Midway', 'DateTimePlus uses the specified timezone if provided.'); + + } + + /** * Tear down after tests. */ public function tearDown() { diff --git a/core/modules/system/lib/Drupal/system/Tests/Datetime/DrupalDateTimeTest.php b/core/modules/system/lib/Drupal/system/Tests/Datetime/DrupalDateTimeTest.php new file mode 100644 index 0000000..ea10896 --- /dev/null +++ b/core/modules/system/lib/Drupal/system/Tests/Datetime/DrupalDateTimeTest.php @@ -0,0 +1,110 @@ + 'DrupalDateTime', + 'description' => 'Test DrupalDateTime functionality.', + 'group' => 'Datetime', + ); + } + + /** + * Set up required modules. + */ + public static $modules = array(); + + /** + * Test setup. + */ + public function setUp() { + parent::setUp(); + + } + + /** + * Test that DrupalDateTime can detect the right timezone to use. + * Test with a variety of less commonly used timezone names to + * help ensure that the system timezone will be different than the + * stated timezones. + */ + public function testDateTimezone() { + global $user; + + $date_string = '2007-01-31 21:00:00'; + + // Make sure no site timezone has been set. + variable_set('date_default_timezone', NULL); + variable_set('configurable_timezones', 0); + + // Detect the system timezone. + $system_timezone = date_default_timezone_get(); + + // Create a date object with an unspecified timezone, which should + // end up using the system timezone. + $date = new DrupalDateTime($date_string); + $timezone = $date->getTimezone()->getName(); + $this->assertTrue($timezone == $system_timezone, 'DrupalDateTime uses the system timezone when there is no site timezone.'); + + // Create a date object with a specified timezone. + $date = new DrupalDateTime($date_string, 'America/Yellowknife'); + $timezone = $date->getTimezone()->getName(); + $this->assertTrue($timezone == 'America/Yellowknife', 'DrupalDateTime uses the specified timezone if provided.'); + + // Set a site timezone. + variable_set('date_default_timezone', 'Europe/Warsaw'); + + // Create a date object with an unspecified timezone, which should + // end up using the site timezone. + $date = new DrupalDateTime($date_string); + $timezone = $date->getTimezone()->getName(); + $this->assertTrue($timezone == 'Europe/Warsaw', 'DrupalDateTime uses the site timezone if provided.'); + + // Create user. + variable_set('configurable_timezones', 1); + $test_user = $this->drupalCreateUser(array()); + $this->drupalLogin($test_user); + + // Set up the user with a different timezone than the site. + $edit = array('mail' => $test_user->mail, 'timezone' => 'Asia/Manila'); + $this->drupalPost('user/' . $test_user->uid . '/edit', $edit, t('Save')); + + // Disable session saving as we are about to modify the global $user. + drupal_save_session(FALSE); + // Save the original user and then replace it with the test user. + $real_user = $user; + $user = user_load($test_user->uid, TRUE); + + // Simulate a Drupal bootstrap with the logged-in user. + date_default_timezone_set(drupal_get_user_timezone()); + + // Create a date object with an unspecified timezone, which should + // end up using the user timezone. + + $date = new DrupalDateTime($date_string); + $timezone = $date->getTimezone()->getName(); + $this->assertTrue($timezone == 'Asia/Manila', 'DrupalDateTime uses the user timezone, if configurable timezones are used and it is set.'); + + // Restore the original user, and enable session saving. + $user = $real_user; + // Restore default time zone. + date_default_timezone_set(drupal_get_user_timezone()); + drupal_save_session(TRUE); + + + } +}