From 6bd3cb3386cf5335e1d7abcd0d5e4832e4776410 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 29 Jan 2019 07:21:53 -0800 Subject: [PATCH] toDateTime() improvements for Date/Time fields --- CHANGELOG-v3.md | 3 + docs/date-time-fields.md | 53 +++++- src/helpers/DateTimeHelper.php | 308 ++++++++++++++++++++------------- 3 files changed, 244 insertions(+), 120 deletions(-) diff --git a/CHANGELOG-v3.md b/CHANGELOG-v3.md index d1ad49a994d..a4f43a99e69 100644 --- a/CHANGELOG-v3.md +++ b/CHANGELOG-v3.md @@ -7,6 +7,9 @@ ### Changed - `craft\web\Controller::requireAdmin()` now sends a 403 (Forbidden) response if the `allowAdminChanges` config setting has been set to `false`. ([#3728](https://github.com/craftcms/cms/issues/3728)) +- `craft\helpers\DateTimeHelper::toDateTime()` now supports passing an array with a `date` key set to the `YYYY-MM-DD` format, in addition to the current locale’s short date format. +- `craft\helpers\DateTimeHelper::toDateTime()` now supports passing an array with a `time` key set to the `HH:MM` format, in addition to the current locale’s short time format. +- `craft\helpers\DateTimeHelper::toDateTime()` now supports passing an array with a `datetime` key, which will be handled the same way strings passed to the method are handled (except that the `datetime` key can be paired with a `timezone` key). ### Fixed - Fixed an erroc that occurred when uing the `json_decode` filter. ([#3722](https://github.com/craftcms/cms/pull/3722)) diff --git a/docs/date-time-fields.md b/docs/date-time-fields.md index a28de98a980..9e2f723c38d 100644 --- a/docs/date-time-fields.md +++ b/docs/date-time-fields.md @@ -71,7 +71,7 @@ If you just the user to be able to select a date, use a `date` input: ```twig {% set currentValue = entry is defined and entry. - ? entry.|date('Y-m-d') + ? entry.|date('Y-m-d', timezone='UTC') : '' %} @@ -81,7 +81,7 @@ If you want the user to be able to select a time as well, use a `datetime-local` ```twig {% set currentValue = entry is defined and entry. - ? entry.|date('Y-m-d\\TH:i') + ? entry.|date('Y-m-d\\TH:i', timezone='UTC') : '' %} @@ -91,4 +91,51 @@ If you want the user to be able to select a time as well, use a `datetime-local` The [HTML5Forms.js](https://github.com/zoltan-dulac/html5Forms.js) polyfill can be used to implement `date` and `datetime-local` inputs [while we wait](https://caniuse.com/#feat=input-datetime) for better browser support. ::: -Note that Craft will assume the UTC time zone. +#### Customizing the Timezone + +By default, Craft will assume the date is posted in UTC. As of Craft 3.1.6 you you can post dates in a different timezone by changing the input name to `fields[][datetime]` and adding a hidden input named `fields[][timezone]`, set to a [valid PHP timezone](http://php.net/manual/en/timezones.php): + +```twig +{% set pt = 'America/Los_Angeles' %} +{% set currentValue = entry is defined and entry. + ? entry.|date('Y-m-d\\TH:i', timezone=pt) + : '' %} + + + +``` + +Or you can let users decide which timezone the date should be posted in: + +```twig +{% set currentValue = entry is defined and entry. + ? entry.|date('Y-m-d\\TH:i', timezone='UTC') + : '' %} + + + + +``` + +#### Posting the Date and Time Separately + +If you’d like to post the date and time as separate HTML inputs, give them the names `fields[][date]` and `fields[][time]`. + +The date input can either be set to the `YYYY-MM-DD` format, or the current locale’s short date format. + +The time input can either be set to the `HH:MM` format (24-hour), or the current locale’s short time format. + +::: tip +To find out what your current locale’s date and time formats are, add this to your template: + +```twig +Date format: {{ craft.app.locale.getDateFormat('short', 'php') }}
+Time format: {{ craft.app.locale.getTimeFormat('short', 'php') }} +``` + +Then refer to PHP’s [date()](http://php.net/manual/en/function.date.php) function docs to see what each of the format letters mean. +::: diff --git a/src/helpers/DateTimeHelper.php b/src/helpers/DateTimeHelper.php index 23703b25164..de6b1f4363b 100644 --- a/src/helpers/DateTimeHelper.php +++ b/src/helpers/DateTimeHelper.php @@ -78,17 +78,25 @@ class DateTimeHelper /** * Converts a value into a DateTime object. * - * Supports the following formats: - * - An array of the date and time in the current locale's short formats + * `$value` can be in the following formats: + * * - All W3C date and time formats (http://www.w3.org/TR/NOTE-datetime) * - MySQL DATE and DATETIME formats (http://dev.mysql.com/doc/refman/5.1/en/datetime.html) * - Relaxed versions of W3C and MySQL formats (single-digit months, days, and hours) * - Unix timestamps + * - An array with at least one of these keys defined: `datetime`, `date`, or `time`. Supported keys include: + * - `date` – a date string in `YYYY-MM-DD` format or the current locale’s short date format + * - `time` – a time string in `HH:MM` (24-hour) format or the current locale’s short time format + * - `datetime` – A timestamp in any of the non-array formats supported by this method + * - `timezone` – A [valid PHP timezone](http://php.net/manual/en/timezones.php). If set, this will override + * the assumed timezone per `$assumeSystemTimeZone`. * * @param mixed $value The value that should be converted to a DateTime object. - * @param bool $assumeSystemTimeZone Whether it should be assumed that the value was set in the system time zone if the timezone was not specified. If this is false, UTC will be assumed. (Defaults to false.) - * @param bool $setToSystemTimeZone Whether to set the resulting DateTime object to the system time zone. (Defaults to true.) + * @param bool $assumeSystemTimeZone Whether it should be assumed that the value was set in the system timezone if + * the timezone was not specified. If this is `false`, UTC will be assumed. + * @param bool $setToSystemTimeZone Whether to set the resulting DateTime object to the system timezone. * @return DateTime|false The DateTime object, or `false` if $object could not be converted to one + * @throws \Exception */ public static function toDateTime($value, bool $assumeSystemTimeZone = false, bool $setToSystemTimeZone = true) { @@ -98,129 +106,48 @@ public static function toDateTime($value, bool $assumeSystemTimeZone = false, bo $defaultTimeZone = ($assumeSystemTimeZone ? Craft::$app->getTimeZone() : 'UTC'); - // Was this a date/time-picker? - if (is_array($value) && (isset($value['date']) || isset($value['time']))) { - $dt = $value; - - if (empty($dt['date']) && empty($dt['time'])) { + if (is_array($value)) { + if (empty($value['datetime']) && empty($value['date']) && empty($value['time'])) { return false; } - $locale = Craft::$app->getLocale(); - + // Did they specify a timezone? if (!empty($value['timezone']) && ($normalizedTimeZone = static::normalizeTimeZone($value['timezone'])) !== false) { $timeZone = $normalizedTimeZone; } else { $timeZone = $defaultTimeZone; } - if (!empty($dt['date'])) { - $date = $dt['date']; - $format = $locale->getDateFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP); - - // Make sure it's a 4 digit year format. - $format = StringHelper::replace($format, 'y', 'Y'); - - // Valid separators are either '-', '.' or '/'. - if (StringHelper::contains($format, '.')) { - $separator = '.'; - } else if (StringHelper::contains($format, '-')) { - $separator = '-'; - } else { - $separator = '/'; - } - - // Ensure that the submitted date is using the locale’s separator - $date = StringHelper::replace($date, '-', $separator); - $date = StringHelper::replace($date, '.', $separator); - $date = StringHelper::replace($date, '/', $separator); - - // Check for a two-digit year as well - $altFormat = StringHelper::replace($format, 'Y', 'y'); - - if (DateTime::createFromFormat($altFormat, $date) !== false) { - $format = $altFormat; + // Did they specify a full timestamp ? + if (!empty($value['datetime'])) { + list($date, $format) = self::_parseDateTime($value['datetime'], $timeZone); + if ($format === false) { + return false; } } else { - // Default to the current date - $current = new DateTime('now', new DateTimeZone($timeZone)); - $format = 'n/j/Y'; - $date = $current->format($format); - } - - if (!empty($dt['time'])) { - $timePickerPhpFormat = $locale->getTimeFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP); - // Replace the localized "AM" and "PM" - if (preg_match('/(.*)(' . preg_quote($locale->getAMName(), '/') . '|' . preg_quote($locale->getPMName(), '/') . ')(.*)/u', $dt['time'], $matches)) { - $dt['time'] = $matches[1] . $matches[3]; - - if ($matches[2] == $locale->getAMName()) { - $dt['time'] .= 'AM'; - } else { - $dt['time'] .= 'PM'; - } + // Did they specify a date? + if (!empty($value['date'])) { + list($date, $format) = self::_parseDate($value['date']); + } else { + // Default to the current date + $format = 'Y-m-d'; + $date = (new DateTime('now', new DateTimeZone($timeZone)))->format($format); + } - $timePickerPhpFormat = str_replace('A', '', $timePickerPhpFormat) . 'A'; + // Did they specify a time? + if (!empty($value['time'])) { + list($time, $timeFormat) = self::_parseTime($value['time']); + $format .= ' ' . $timeFormat; + $date .= ' ' . $time; } - $date .= ' ' . $dt['time']; - $format .= ' ' . $timePickerPhpFormat; + // Add the timezone + $format .= ' e'; + $date .= ' ' . $timeZone; } - - // Add the timezone - $format .= ' e'; - $date .= ' ' . $timeZone; } else { - $date = trim((string)$value); - - if (preg_match('/^ - (?P\d{4}) # YYYY (four digit year) - (?: - -(?P\d\d?) # -M or -MM (1 or 2 digit month) - (?: - -(?P\d\d?) # -D or -DD (1 or 2 digit day) - (?: - [T\ ](?P\d\d?)\:(?P\d\d) # [T or space]hh:mm (1 or 2 digit hour and 2 digit minute) - (?: - \:(?P\d\d) # :ss (two digit second) - (?:\.\d+)? # .s (decimal fraction of a second -- not supported) - )? - (?:[ ]?(?P(AM|PM|am|pm))?)? # An optional space and AM or PM - (?PZ|(?P[+\-]\d\d\:?\d\d))? # Z or [+ or -]hh(:)ss (UTC or a timezone offset) - )? - )? - )?$/x', $date, $m)) { - $format = 'Y-m-d H:i:s'; - - $date = $m['year'] . - '-' . (!empty($m['mon']) ? sprintf('%02d', $m['mon']) : '01') . - '-' . (!empty($m['day']) ? sprintf('%02d', $m['day']) : '01') . - ' ' . (!empty($m['hour']) ? sprintf('%02d', $m['hour']) : '00') . - ':' . (!empty($m['min']) ? $m['min'] : '00') . - ':' . (!empty($m['sec']) ? $m['sec'] : '00'); - - if (!empty($m['ampm'])) { - $format .= ' A'; - $date .= ' ' . $m['ampm']; - } - - // Was a time zone specified? - if (!empty($m['tz'])) { - if (!empty($m['tzd'])) { - $format .= strpos($m['tzd'], ':') !== false ? 'P' : 'O'; - $date .= $m['tzd']; - } else { - // "Z" = UTC - $format .= 'e'; - $date .= 'UTC'; - } - } else { - $format .= 'e'; - $date .= $defaultTimeZone; - } - } else if (static::isValidTimeStamp((int)$date)) { - $format = 'U'; - } else { + list($date, $format) = self::_parseDateTime($value, $defaultTimeZone); + if ($format === false) { return false; } } @@ -235,24 +162,24 @@ public static function toDateTime($value, bool $assumeSystemTimeZone = false, bo } /** - * Normalizes a time zone string to a PHP time zone identifier. + * Normalizes a timezone string to a PHP timezone identifier. * * Supports the following formats: * - Time zone abbreviation (EST, MDT) * - Difference to Greenwich time (GMT) in hours, with/without a colon between the hours and minutes (+0200, -0200, +02:00, -02:00) - * - A PHP time zone identifier (UTC, GMT, Atlantic/Azores) + * - A PHP timezone identifier (UTC, GMT, Atlantic/Azores) * - * @param string $timeZone The time zone to be normalized - * @return string|false The PHP time zone identifier, or `false` if it could not be determined + * @param string $timeZone The timezone to be normalized + * @return string|false The PHP timezone identifier, or `false` if it could not be determined */ public static function normalizeTimeZone(string $timeZone) { - // Is it already a PHP time zone identifier? + // Is it already a PHP timezone identifier? if (in_array($timeZone, timezone_identifiers_list(), true)) { return $timeZone; } - // Is this a time zone abbreviation? + // Is this a timezone abbreviation? if (($timeZoneName = timezone_name_from_abbr($timeZone)) !== false) { return $timeZoneName; } @@ -656,6 +583,153 @@ public static function humanDurationFromInterval(DateInterval $dateInterval, boo // Private Methods // ========================================================================= + /** + * Normalizes and returns a date string along with the format it was set in. + * + * @param string $value + * @return array + */ + private static function _parseDate(string $value): array + { + $value = trim($value); + + // First see if it's in YYYY-MM-DD format + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return [$value, 'Y-m-d']; + } + + // Get the locale's short date format + $format = Craft::$app->getLocale()->getDateFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP); + + // Make sure it's a 4-digit year + $format = StringHelper::replace($format, 'y', 'Y'); + + // Valid separators are either '-', '.' or '/'. + if (StringHelper::contains($format, '.')) { + $separator = '.'; + } else if (StringHelper::contains($format, '-')) { + $separator = '-'; + } else { + $separator = '/'; + } + + // Ensure that the submitted date is using the locale’s separator + $date = strtr($value, '-./', str_repeat($separator, 3)); + + // Two-digit year? + $altFormat = StringHelper::replace($format, 'Y', 'y'); + if (DateTime::createFromFormat($altFormat, $date) !== false) { + return [$date, $altFormat]; + } + + return [$date, $format]; + } + + /** + * Normalizes and returns a time string along with the format it was set in + * + * @param string $value + * @return array + */ + private static function _parseTime(string $value): array + { + $value = trim($value); + + // First see if it's in HH:MM format + if (preg_match('/^\d{2}:\d{2}$/', $value)) { + return [$value, 'H:i']; + } + + // Get the locale's short time format + $locale = Craft::$app->getLocale(); + $format = $locale->getTimeFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP); + + // Replace the localized "AM" and "PM" + $am = $locale->getAMName(); + $pm = $locale->getPMName(); + + if (preg_match('/(.*)(' . preg_quote($am, '/') . '|' . preg_quote($pm, '/') . ')(.*)/iu', $value, $matches)) { + $value = $matches[1] . $matches[3]; + + if (mb_strtolower($matches[2]) === mb_strtolower($am)) { + $value .= 'AM'; + } else { + $value .= 'PM'; + } + + $format = str_replace('A', '', $format) . 'A'; + } + + return [$value, $format]; + } + + /** + * Normalizes and returns a date & time string along with the format it was set in. + * + * @param string $value + * @param string $defaultTimeZone + * @return array + */ + private static function _parseDateTime(string $value, string $defaultTimeZone): array + { + $value = trim($value); + + if (static::isValidTimeStamp($value)) { + return [$value, 'U']; + } + + if (!preg_match('/^ + (?P\d{4}) # YYYY (four digit year) + (?: + -(?P\d\d?) # -M or -MM (1 or 2 digit month) + (?: + -(?P\d\d?) # -D or -DD (1 or 2 digit day) + (?: + [T\ ](?P\d\d?)\:(?P\d\d) # [T or space]hh:mm (1 or 2 digit hour and 2 digit minute) + (?: + \:(?P\d\d) # :ss (two digit second) + (?:\.\d+)? # .s (decimal fraction of a second -- not supported) + )? + (?:[ ]?(?P(AM|PM|am|pm))?)? # An optional space and AM or PM + (?PZ|(?P[+\-]\d\d\:?\d\d))? # Z or [+ or -]hh(:)ss (UTC or a timezone offset) + )? + )? + )?$/x', $value, $m)) { + return [$value, false]; + } + + $format = 'Y-m-d H:i:s'; + + $date = $m['year'] . + '-' . (!empty($m['mon']) ? sprintf('%02d', $m['mon']) : '01') . + '-' . (!empty($m['day']) ? sprintf('%02d', $m['day']) : '01') . + ' ' . (!empty($m['hour']) ? sprintf('%02d', $m['hour']) : '00') . + ':' . (!empty($m['min']) ? $m['min'] : '00') . + ':' . (!empty($m['sec']) ? $m['sec'] : '00'); + + if (!empty($m['ampm'])) { + $format .= ' A'; + $date .= ' ' . $m['ampm']; + } + + // Did they specify a timezone? + if (!empty($m['tz'])) { + if (!empty($m['tzd'])) { + $format .= strpos($m['tzd'], ':') !== false ? 'P' : 'O'; + $date .= $m['tzd']; + } else { + // "Z" = UTC + $format .= 'e'; + $date .= 'UTC'; + } + } else { + $format .= 'e'; + $date .= $defaultTimeZone; + } + + return [$date, $format]; + } + /** * Returns translation pairs for [[translateDate()]]. *