From 02ae2e7ed0dbbaf2f5ec46e405543a6106b356f8 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Wed, 13 Jun 2018 13:31:04 +1200 Subject: [PATCH 1/4] BUG Fix internal date formatting inheriting default locale Fixes #8097 --- src/Forms/DateField.php | 15 +++---- src/Forms/DatetimeField.php | 7 ++-- src/ORM/FieldType/DBDate.php | 68 ++++++++++++++++++++++++++------ src/ORM/FieldType/DBDatetime.php | 32 +++++++++++++++ 4 files changed, 100 insertions(+), 22 deletions(-) diff --git a/src/Forms/DateField.php b/src/Forms/DateField.php index 5b2ea0474..a9c72e033 100644 --- a/src/Forms/DateField.php +++ b/src/Forms/DateField.php @@ -173,9 +173,9 @@ class DateField extends TextField */ public function getDateFormat() { + // Browsers expect ISO 8601 dates, localisation is handled on the client if ($this->getHTML5()) { - // Browsers expect ISO 8601 dates, localisation is handled on the client - $this->setDateFormat(DBDate::ISO_DATE); + return DBDate::ISO_DATE; } if ($this->dateFormat) { @@ -220,9 +220,7 @@ class DateField extends TextField ); } - - - if ($this->getHTML5() && $this->locale) { + if ($this->getHTML5() && $this->locale && $this->locale !== DBDate::ISO_LOCALE) { throw new \LogicException( 'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setLocale()' ); @@ -254,9 +252,8 @@ class DateField extends TextField */ protected function getInternalFormatter() { - $locale = i18n::config()->uninherited('default_locale'); $formatter = IntlDateFormatter::create( - i18n::config()->uninherited('default_locale'), + DBDate::ISO_LOCALE, IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE ); @@ -448,6 +445,10 @@ class DateField extends TextField */ public function getLocale() { + // Use iso locale for html5 + if ($this->getHTML5()) { + return DBDate::ISO_LOCALE; + } return $this->locale ?: i18n::get_locale(); } diff --git a/src/Forms/DatetimeField.php b/src/Forms/DatetimeField.php index 16c94db8b..03c4bf6c0 100644 --- a/src/Forms/DatetimeField.php +++ b/src/Forms/DatetimeField.php @@ -5,6 +5,7 @@ namespace SilverStripe\Forms; use IntlDateFormatter; use InvalidArgumentException; use SilverStripe\i18n\i18n; +use SilverStripe\ORM\FieldType\DBDate; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\ValidationResult; @@ -297,7 +298,7 @@ class DatetimeField extends TextField } $formatter = IntlDateFormatter::create( - i18n::config()->uninherited('default_locale'), + DBDate::ISO_LOCALE, IntlDateFormatter::MEDIUM, IntlDateFormatter::MEDIUM, $timezone @@ -337,10 +338,10 @@ class DatetimeField extends TextField $internalFormatter = $this->getInternalFormatter(); $timestamp = $internalFormatter->parse($value); - // Retry without "T" separator + // Retry with "T" separator if (!$timestamp) { $fallbackFormatter = $this->getInternalFormatter(); - $fallbackFormatter->setPattern(DBDatetime::ISO_DATETIME); + $fallbackFormatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED); $timestamp = $fallbackFormatter->parse($value); } diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index 98cf2f06f..9b8489b70 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -34,6 +34,12 @@ class DBDate extends DBField */ const ISO_DATE = 'y-MM-dd'; + /** + * Fixed locale to use for ISO date formatting. This is necessary to prevent + * locale-specific numeric localisation breaking internal date strings. + */ + const ISO_LOCALE = 'en_NZ'; + public function setValue($value, $record = null, $markChanged = true) { $value = $this->parseDate($value); @@ -77,8 +83,7 @@ class DBDate extends DBField } // Format as iso8601 - $formatter = $this->getFormatter(); - $formatter->setPattern($this->getISOFormat()); + $formatter = $this->getInternalFormatter(); return $formatter->format($source); } @@ -203,7 +208,43 @@ class DBDate extends DBField */ public function getFormatter($dateLength = IntlDateFormatter::MEDIUM, $timeLength = IntlDateFormatter::NONE) { - return new IntlDateFormatter(i18n::get_locale(), $dateLength, $timeLength); + return $this->getCustomFormatter(null, $dateLength, $timeLength); + } + + /** + * Return formatter in a given locale. Useful if localising in a format other than the current locale. + * + * @param string|null $locale The current locale, or null to use default + * @param string|null $pattern Custom pattern to use for this, if required + * @param int $dateLength + * @param int $timeLength + * @return IntlDateFormatter + */ + public function getCustomFormatter( + $locale = null, + $pattern = null, + $dateLength = IntlDateFormatter::MEDIUM, + $timeLength = IntlDateFormatter::NONE + ) { + $locale = $locale ?: i18n::get_locale(); + $formatter = IntlDateFormatter::create($locale, $dateLength, $timeLength); + if ($pattern) { + $formatter->setPattern($pattern); + } + return $formatter; + } + + /** + * Formatter used internally + * + * @internal + * @return IntlDateFormatter + */ + protected function getInternalFormatter() + { + $formatter = $this->getCustomFormatter(DBDate::ISO_LOCALE, DBDate::ISO_DATE); + $formatter->setLenient(false); + return $formatter; } /** @@ -271,7 +312,8 @@ class DBDate extends DBField // Get user format $format = $member->getDateFormat(); - return $this->Format($format); + $formatter = $this->getCustomFormatter($format, $member->getLocale()); + return $formatter->format($this->getTimestamp()); } /** @@ -319,7 +361,9 @@ class DBDate extends DBField */ public function Rfc2822() { - return $this->Format('y-MM-dd HH:mm:ss'); + $formatter = $this->getInternalFormatter(); + $formatter->setPattern('y-MM-dd HH:mm:ss'); + return $formatter->format($this->getTimestamp()); } /** @@ -420,7 +464,7 @@ class DBDate extends DBField ); case "minutes": - $span = round($ago/60); + $span = round($ago / 60); return _t( __CLASS__ . '.MINUTES_SHORT_PLURALS', '{count} min|{count} mins', @@ -428,7 +472,7 @@ class DBDate extends DBField ); case "hours": - $span = round($ago/3600); + $span = round($ago / 3600); return _t( __CLASS__ . '.HOURS_SHORT_PLURALS', '{count} hour|{count} hours', @@ -436,7 +480,7 @@ class DBDate extends DBField ); case "days": - $span = round($ago/86400); + $span = round($ago / 86400); return _t( __CLASS__ . '.DAYS_SHORT_PLURALS', '{count} day|{count} days', @@ -444,7 +488,7 @@ class DBDate extends DBField ); case "months": - $span = round($ago/86400/30); + $span = round($ago / 86400 / 30); return _t( __CLASS__ . '.MONTHS_SHORT_PLURALS', '{count} month|{count} months', @@ -452,7 +496,7 @@ class DBDate extends DBField ); case "years": - $span = round($ago/86400/365); + $span = round($ago / 86400 / 365); return _t( __CLASS__ . '.YEARS_SHORT_PLURALS', '{count} year|{count} years', @@ -466,8 +510,8 @@ class DBDate extends DBField public function requireField() { - $parts=array('datatype'=>'date', 'arrayValue'=>$this->arrayValue); - $values=array('type'=>'date', 'parts'=>$parts); + $parts = array('datatype' => 'date', 'arrayValue' => $this->arrayValue); + $values = array('type' => 'date', 'parts' => $parts); DB::require_field($this->tableName, $this->name, $values); } diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 78a5cff7f..7a058bf6f 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -228,6 +228,38 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider return new IntlDateFormatter(i18n::get_locale(), $dateLength, $timeLength); } + + /** + * Return formatter in a given locale. Useful if localising in a format other than the current locale. + * + * @param string|null $locale The current locale, or null to use default + * @param string|null $pattern Custom pattern to use for this, if required + * @param int $dateLength + * @param int $timeLength + * @return IntlDateFormatter + */ + public function getCustomFormatter( + $locale = null, + $pattern = null, + $dateLength = IntlDateFormatter::MEDIUM, + $timeLength = IntlDateFormatter::MEDIUM + ) { + return parent::getCustomFormatter($locale, $pattern, $dateLength, $timeLength); + } + + /** + * Formatter used internally + * + * @internal + * @return IntlDateFormatter + */ + protected function getInternalFormatter() + { + $formatter = $this->getCustomFormatter(DBDate::ISO_LOCALE, DBDatetime::ISO_DATETIME); + $formatter->setLenient(false); + return $formatter; + } + /** * Get standard ISO date format string * From 310a259c5fdf3f77ced6415741ede1c31e64e1a2 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 14 Jun 2018 17:28:16 +1200 Subject: [PATCH 2/4] Add locale to Format Fix up some regressions --- src/ORM/FieldType/DBDate.php | 18 ++++++++---------- src/ORM/FieldType/DBDatetime.php | 9 +++++---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index 9b8489b70..920eb905f 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -38,7 +38,7 @@ class DBDate extends DBField * Fixed locale to use for ISO date formatting. This is necessary to prevent * locale-specific numeric localisation breaking internal date strings. */ - const ISO_LOCALE = 'en_NZ'; + const ISO_LOCALE = 'en_US'; public function setValue($value, $record = null, $markChanged = true) { @@ -208,7 +208,7 @@ class DBDate extends DBField */ public function getFormatter($dateLength = IntlDateFormatter::MEDIUM, $timeLength = IntlDateFormatter::NONE) { - return $this->getCustomFormatter(null, $dateLength, $timeLength); + return $this->getCustomFormatter(null, null, $dateLength, $timeLength); } /** @@ -262,9 +262,10 @@ class DBDate extends DBField * for the day of the month ("1st", "2nd", "3rd" etc) * * @param string $format Format code string. See http://userguide.icu-project.org/formatparse/datetime + * @param string $locale Custom locale to use * @return string The date in the requested format */ - public function Format($format) + public function Format($format, $locale = null) { if (!$this->value) { return null; @@ -275,9 +276,8 @@ class DBDate extends DBField $format = str_replace('{o}', "'{$this->DayOfMonth(true)}'", $format); } - $formatter = $this->getFormatter(); - $formatter->setPattern($format); - return $formatter->format($this->getTimestamp()); + $formatter = $this->getCustomFormatter($locale, $format); + return $formatter->Format($this->getTimestamp()); } /** @@ -311,9 +311,7 @@ class DBDate extends DBField } // Get user format - $format = $member->getDateFormat(); - $formatter = $this->getCustomFormatter($format, $member->getLocale()); - return $formatter->format($this->getTimestamp()); + return $this->Format($member->getDateFormat(), $member->getLocale()); } /** @@ -549,7 +547,7 @@ class DBDate extends DBField */ public function URLDate() { - return rawurlencode($this->Format(self::ISO_DATE)); + return rawurlencode($this->Format(self::ISO_DATE, self::ISO_LOCALE)); } public function scaffoldFormField($title = null, $params = null) diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 7a058bf6f..e56d99265 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -111,7 +111,7 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider $timeFormat = $member->getTimeFormat(); // Get user format - return $this->Format($dateFormat . ' ' . $timeFormat); + return $this->Format($dateFormat . ' ' . $timeFormat, $member->getLocale()); } public function requireField() @@ -135,16 +135,17 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider */ public function URLDatetime() { - return rawurlencode($this->Format(self::ISO_DATETIME)); + return rawurlencode($this->Format(self::ISO_DATETIME, self::ISO_LOCALE)); } public function scaffoldFormField($title = null, $params = null) { $field = DatetimeField::create($this->name, $title); $dateTimeFormat = $field->getDatetimeFormat(); + $locale = $field->getLocale(); // Set date formatting hints and example - $date = static::now()->Format($dateTimeFormat); + $date = static::now()->Format($dateTimeFormat, $locale); $field ->setDescription(_t( 'SilverStripe\\Forms\\FormField.EXAMPLE', @@ -225,7 +226,7 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider */ public function getFormatter($dateLength = IntlDateFormatter::MEDIUM, $timeLength = IntlDateFormatter::MEDIUM) { - return new IntlDateFormatter(i18n::get_locale(), $dateLength, $timeLength); + return parent::getFormatter($dateLength, $timeLength); } From c414388220614118d132083697a54c5d81e8fccc Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 14 Jun 2018 18:14:12 +1200 Subject: [PATCH 3/4] FIX DatetimeFieldTest --- tests/php/Forms/DatetimeFieldTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/php/Forms/DatetimeFieldTest.php b/tests/php/Forms/DatetimeFieldTest.php index dd8587b49..d3ca4db24 100644 --- a/tests/php/Forms/DatetimeFieldTest.php +++ b/tests/php/Forms/DatetimeFieldTest.php @@ -109,11 +109,11 @@ class DatetimeFieldTest extends SapphireTest { $f = new DatetimeField('Datetime', 'Datetime'); $f->setValue('2003-03-29 23:59:38'); - $this->assertEquals($f->dataValue(), '2003-03-29 23:59:38', 'Accepts ISO'); + $this->assertEquals('2003-03-29 23:59:38', $f->dataValue(), 'Accepts ISO'); $f = new DatetimeField('Datetime', 'Datetime'); $f->setValue('2003-03-29T23:59:38'); - $this->assertNull($f->dataValue(), 'Rejects normalised ISO'); + $this->assertEquals('2003-03-29 23:59:38', $f->dataValue(), 'Accepts normalised ISO'); } public function testSubmittedValue() @@ -152,7 +152,7 @@ class DatetimeFieldTest extends SapphireTest $this->assertTrue($f->validate(new RequiredFields())); $f = new DatetimeField('Datetime', 'Datetime', '2003-03-29T23:59:38'); - $this->assertFalse($f->validate(new RequiredFields()), 'Normalised ISO'); + $this->assertTrue($f->validate(new RequiredFields()), 'Normalised ISO'); $f = new DatetimeField('Datetime', 'Datetime', '2003-03-29'); $this->assertFalse($f->validate(new RequiredFields()), 'Leaving out time'); From b636587945443b08d363b0f38440245746ba5e1d Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Fri, 15 Jun 2018 11:04:12 +1200 Subject: [PATCH 4/4] Respect semver and add tests --- src/ORM/FieldType/DBDate.php | 11 +++++++++-- src/ORM/FieldType/DBDatetime.php | 7 ++++--- tests/php/ORM/DBDatetimeTest.php | 20 ++++++++++++++++++-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index 920eb905f..13716b7eb 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -37,6 +37,8 @@ class DBDate extends DBField /** * Fixed locale to use for ISO date formatting. This is necessary to prevent * locale-specific numeric localisation breaking internal date strings. + * + * @internal (remove internal in 4.2) */ const ISO_LOCALE = 'en_US'; @@ -214,6 +216,8 @@ class DBDate extends DBField /** * Return formatter in a given locale. Useful if localising in a format other than the current locale. * + * @internal (Remove internal in 4.2) + * * @param string|null $locale The current locale, or null to use default * @param string|null $pattern Custom pattern to use for this, if required * @param int $dateLength @@ -262,11 +266,14 @@ class DBDate extends DBField * for the day of the month ("1st", "2nd", "3rd" etc) * * @param string $format Format code string. See http://userguide.icu-project.org/formatparse/datetime - * @param string $locale Custom locale to use + * @param string $locale Custom locale to use (add to signature in 5.0) * @return string The date in the requested format */ - public function Format($format, $locale = null) + public function Format($format) { + // Note: soft-arg uses func_get_args() to respect semver. Add to signature in 5.0 + $locale = func_num_args() > 1 ? func_get_arg(1) : null; + if (!$this->value) { return null; } diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index e56d99265..dd9c97607 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -2,15 +2,14 @@ namespace SilverStripe\ORM\FieldType; +use Exception; use IntlDateFormatter; +use InvalidArgumentException; use SilverStripe\Forms\DatetimeField; -use SilverStripe\i18n\i18n; use SilverStripe\ORM\DB; use SilverStripe\Security\Member; use SilverStripe\Security\Security; use SilverStripe\View\TemplateGlobalProvider; -use Exception; -use InvalidArgumentException; /** * Represents a date-time field. @@ -233,6 +232,8 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider /** * Return formatter in a given locale. Useful if localising in a format other than the current locale. * + * @internal (Remove internal in 4.2) + * * @param string|null $locale The current locale, or null to use default * @param string|null $pattern Custom pattern to use for this, if required * @param int $dateLength diff --git a/tests/php/ORM/DBDatetimeTest.php b/tests/php/ORM/DBDatetimeTest.php index a824ecdae..fd0bd91db 100644 --- a/tests/php/ORM/DBDatetimeTest.php +++ b/tests/php/ORM/DBDatetimeTest.php @@ -2,10 +2,9 @@ namespace SilverStripe\ORM\Tests; +use SilverStripe\Dev\SapphireTest; use SilverStripe\i18n\i18n; use SilverStripe\ORM\FieldType\DBDatetime; -use SilverStripe\Dev\SapphireTest; -use SilverStripe\Security\Member; /** * Tests for {@link Datetime} class. @@ -70,6 +69,23 @@ class DBDatetimeTest extends SapphireTest $this->assertEquals('10 Oct 3000 15 32 24', $date->Format('d MMM y H m s')); } + /** + * Coverage for dates using hindi-numerals + */ + public function testHindiNumerals() + { + // Parent locale is english; Can be localised to arabic + $date = DBDatetime::create_field('Datetime', '1600-10-10 15:32:24'); + $this->assertEquals('10 Oct 1600 15 32 24', $date->Format('d MMM y H m s')); + $this->assertEquals('١٠ أكتوبر ١٦٠٠ ١٥ ٣٢ ٢٤', $date->Format('d MMM y H m s', 'ar')); + + // Parent locale is arabic; Datavalue uses ISO date + i18n::set_locale('ar'); + $date = DBDatetime::create_field('Datetime', '1600-10-10 15:32:24'); + $this->assertEquals('١٠ أكتوبر ١٦٠٠ ١٥ ٣٢ ٢٤', $date->Format('d MMM y H m s')); + $this->assertEquals('1600-10-10 15:32:24', $date->getValue()); + } + public function testNice() { $date = DBDatetime::create_field('Datetime', '2001-12-31 22:10:59');