getLocale()); if ($this->getHTML5()) { $attributes['type'] = 'datetime-local'; $attributes['min'] = $this->getMinDatetime(); $attributes['max'] = $this->getMaxDatetime(); } return $attributes; } public function getSchemaDataDefaults() { $defaults = parent::getSchemaDataDefaults(); return array_merge($defaults, [ 'lang' => i18n::convert_rfc1766($this->getLocale()), 'data' => array_merge($defaults['data'], [ 'html5' => $this->getHTML5(), 'min' => $this->getMinDate(), 'max' => $this->getMaxDate() ]) ]); } public function Type() { return 'text datetime'; } public function getHTML5() { return $this->html5; } public function setHTML5($bool) { $this->html5 = $bool; return $this; } /** * Assign value posted from form submission, based on {@link $datetimeFormat}. * When $html5=true, this needs to be normalised ISO format (with "T" separator). * * @param mixed $value * @param mixed $data * @return $this */ public function setSubmittedValue($value, $data = null) { // Save raw value for later validation $this->rawValue = $value; // Null case if (!$value) { $this->value = null; return $this; } // Parse from submitted value $this->value = $this->frontendToInternal($value); return $this; } /** * Convert frontend date to the internal representation (ISO 8601). * The frontend date is also in ISO 8601 when $html5=true. * * @param string $datetime * @return string The formatted date, or null if not a valid date */ public function frontendToInternal($datetime) { if (!$datetime) { return null; } $fromFormatter = $this->getFrontendFormatter(); $toFormatter = $this->getInternalFormatter(); // Try to parse time with seconds $timestamp = $fromFormatter->parse($datetime); // Try to parse time without seconds, since that's a valid HTML5 submission format // See https://html.spec.whatwg.org/multipage/infrastructure.html#times if ($timestamp === false && $this->getHTML5()) { $fromFormatter->setPattern(str_replace(':ss', '', $fromFormatter->getPattern())); $timestamp = $fromFormatter->parse($datetime); } if ($timestamp === false) { return null; } return $toFormatter->format($timestamp) ?: null; } /** * Get date formatter with the standard locale / date format * * @throws \LogicException * @return IntlDateFormatter */ protected function getFrontendFormatter() { if ($this->getHTML5() && $this->datetimeFormat && $this->datetimeFormat !== DBDatetime::ISO_DATETIME_NORMALISED) { throw new \LogicException( 'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setDatetimeFormat()' ); } if ($this->getHTML5() && $this->dateLength) { throw new \LogicException( 'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setDateLength()' ); } if ($this->getHTML5() && $this->locale) { throw new \LogicException( 'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setLocale()' ); } $formatter = IntlDateFormatter::create( $this->getLocale(), $this->getDateLength(), $this->getTimeLength(), $this->getTimezone() ); if ($this->getHTML5()) { // Browsers expect ISO 8601 dates, localisation is handled on the client. // Add 'T' date and time separator to create W3C compliant format $formatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED); } elseif ($this->datetimeFormat) { // Don't invoke getDatetimeFormat() directly to avoid infinite loop $ok = $formatter->setPattern($this->datetimeFormat); if (!$ok) { throw new InvalidArgumentException("Invalid date format {$this->datetimeFormat}"); } } return $formatter; } /** * Get date format in CLDR standard format * * This can be set explicitly. If not, this will be generated from the current locale * with the current date length. * * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table */ public function getDatetimeFormat() { if ($this->datetimeFormat) { return $this->datetimeFormat; } // Get from locale return $this->getFrontendFormatter()->getPattern(); } /** * Set date format in CLDR standard format. * Only applicable with {@link setHTML5(false)}. * * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table * @param string $format * @return $this */ public function setDatetimeFormat($format) { $this->datetimeFormat = $format; return $this; } /** * Get formatter for converting to the target timezone, if timezone is set * Can return null if no timezone set * * @return IntlDateFormatter|null */ protected function getTimezoneFormatter() { $timezone = $this->getTimezone(); if (!$timezone) { return null; } // Build new formatter with the altered timezone $formatter = clone $this->getInternalFormatter(); $formatter->setTimeZone($timezone); // ISO8601 date with a standard "T" separator (W3C standard). // Note we omit timezone from this format, and we assume server TZ always. $formatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED); return $formatter; } /** * Get a date formatter for the ISO 8601 format * * @return IntlDateFormatter */ protected function getInternalFormatter() { $formatter = IntlDateFormatter::create( i18n::config()->uninherited('default_locale'), IntlDateFormatter::MEDIUM, IntlDateFormatter::MEDIUM, date_default_timezone_get() // Default to server timezone ); $formatter->setLenient(false); // Note we omit timezone from this format, and we always assume server TZ if ($this->getHTML5()) { // ISO8601 date with a standard "T" separator (W3C standard). $formatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED); } else { // ISO8601 date with a whitespace separator $formatter->setPattern(DBDatetime::ISO_DATETIME); } return $formatter; } /** * Assign value based on {@link $datetimeFormat}, which might be localised. * * When $html5=true, assign value from ISO 8601 normalised string (with a "T" separator). * Falls back to an ISO 8601 string (with a whitespace separator). * * @param mixed $value * @param mixed $data * @return $this */ public function setValue($value, $data = null) { // Save raw value for later validation $this->rawValue = $value; // Empty value if (empty($value)) { $this->value = null; return $this; } // Validate iso 8601 date // If invalid, assign for later validation failure $internalFormatter = $this->getInternalFormatter(); $timestamp = $internalFormatter->parse($value); // Retry without "T" separator if (!$timestamp) { $fallbackFormatter = $this->getInternalFormatter(); $fallbackFormatter->setPattern(DBDatetime::ISO_DATETIME); $timestamp = $fallbackFormatter->parse($value); } if ($timestamp === false) { return $this; } // Cleanup date $value = $internalFormatter->format($timestamp); // Save value $this->value = $value; return $this; } /** * Returns the frontend representation of the field value, * according to the defined {@link dateFormat}. * With $html5=true, this will be in ISO 8601 format. * * @return string */ public function Value() { return $this->internalToFrontend($this->value); } /** * Returns the field value in the internal representation (ISO 8601), * suitable for insertion into the data object. * * @return string */ public function dataValue() { return $this->internalToDataValue($this->value); } /** * Convert an ISO 8601 localised datetime into a saveable representation. * * @param string $datetime * @return string The formatted date and time, or null if not a valid date and time */ public function internalToDataValue($datetime) { $fromFormatter = $this->getInternalFormatter(); $toFormatter = $this->getFrontendFormatter(); // Set default timezone (avoid shifting data values into user timezone) $toFormatter->setTimezone(date_default_timezone_get()); // Send to data object without "T" separator $toFormatter->setPattern(DBDatetime::ISO_DATETIME); $timestamp = $fromFormatter->parse($datetime); if ($timestamp === false) { return null; } return $toFormatter->format($timestamp) ?: null; } /** * Convert the internal date representation (ISO 8601) to a format used by the frontend, * as defined by {@link $dateFormat}. With $html5=true, the frontend date will also be * in ISO 8601. * * @param string $datetime * @return string The formatted date and time, or null if not a valid date and time */ public function internalToFrontend($datetime) { $datetime = $this->tidyInternal($datetime); if (!$datetime) { return null; } $fromFormatter = $this->getInternalFormatter(); $toFormatter = $this->getFrontendFormatter(); $timestamp = $fromFormatter->parse($datetime); if ($timestamp === false) { return null; } return $toFormatter->format($timestamp) ?: null; } /** * Tidy up the internal date representation (ISO 8601), * and fall back to strtotime() if there's parsing errors. * * @param string $date Date in ISO 8601 or approximate form * @return string ISO 8601 date, or null if not valid */ public function tidyInternal($datetime) { if (!$datetime) { return null; } // Re-run through formatter to tidy up (e.g. remove time component) $formatter = $this->getInternalFormatter(); $timestamp = $formatter->parse($datetime); if ($timestamp === false) { // Fallback to strtotime $timestamp = strtotime($datetime, DBDatetime::now()->getTimestamp()); if ($timestamp === false) { return null; } } return $formatter->format($timestamp); } /** * Get length of the date format to use. One of: * * - IntlDateFormatter::SHORT * - IntlDateFormatter::MEDIUM * - IntlDateFormatter::LONG * - IntlDateFormatter::FULL * * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants * @return int */ public function getDateLength() { if ($this->dateLength) { return $this->dateLength; } return IntlDateFormatter::MEDIUM; } /** * Get length of the date format to use. * Only applicable with {@link setHTML5(false)}. * * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants * * @param int $length * @return $this */ public function setDateLength($length) { $this->dateLength = $length; return $this; } /** * Get length of the date format to use. One of: * * - IntlDateFormatter::SHORT * - IntlDateFormatter::MEDIUM * - IntlDateFormatter::LONG * - IntlDateFormatter::FULL * * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants * @return int */ public function getTimeLength() { if ($this->timeLength) { return $this->timeLength; } return IntlDateFormatter::MEDIUM; } /** * Get length of the date format to use. * Only applicable with {@link setHTML5(false)}. * * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants * * @param int $length * @return $this */ public function setTimeLength($length) { $this->timeLength = $length; return $this; } public function setDisabled($bool) { parent::setDisabled($bool); return $this; } public function setReadonly($bool) { parent::setReadonly($bool); return $this; } /** * Set default locale for this field. If omitted will default to the current locale. * * @param string $locale * @return $this */ public function setLocale($locale) { $this->locale = $locale; return $this; } /** * Get locale for this field * * @return string */ public function getLocale() { return $this->locale ?: i18n::get_locale(); } /** * @return string */ public function getMinDatetime() { return $this->minDatetime; } /** * @param string $minDatetime A string in ISO 8601 format * @return $this */ public function setMinDatetime($minDatetime) { $this->minDatetime = $this->tidyInternal($minDatetime); return $this; } /** * @return string */ public function getMaxDatetime() { return $this->maxDatetime; } /** * @param string $maxDatetime * @return $this */ public function setMaxDatetime($maxDatetime) { $this->maxDatetime = $this->tidyInternal($maxDatetime); return $this; } /** * @param Validator $validator * @return bool */ public function validate($validator) { // Don't validate empty fields if (empty($this->rawValue)) { return true; } // We submitted a value, but it couldn't be parsed if (empty($this->value)) { $validator->validationError( $this->name, _t( 'DatetimeField.VALIDDATETIMEFORMAT', "Please enter a valid date and time format ({format})", ['format' => $this->getDatetimeFormat()] ) ); return false; } // Check min date $min = $this->getMinDatetime(); if ($min) { $oops = strtotime($this->value) < strtotime($min); if ($oops) { $validator->validationError( $this->name, _t( 'DatetimeField.VALIDDATETIMEMINDATE', "Your date has to be newer or matching the minimum allowed date and time ({datetime})", ['datetime' => $this->internalToFrontend($min)] ) ); return false; } } // Check max date $max = $this->getMaxDatetime(); if ($max) { $oops = strtotime($this->value) > strtotime($max); if ($oops) { $validator->validationError( $this->name, _t( 'DatetimeField.VALIDDATEMAXDATETIME', "Your date has to be older or matching the maximum allowed date and time ({datetime})", ['datetime' => $this->internalToFrontend($max)] ) ); return false; } } return true; } public function performReadonlyTransformation() { $field = clone $this; $field->setReadonly(true); return $field; } /** * @return string */ public function getTimezone() { return $this->timezone; } /** * @param string $timezone * @return $this */ public function setTimezone($timezone) { if ($this->value && $timezone !== $this->timezone) { throw new \BadMethodCallException("Can't change timezone after setting a value"); } // Note: DateField has no timezone option, and TimeField::setTimezone // should be ignored $this->timezone = $timezone; return $this; } }