getLocale()); if ($this->getHTML5()) { $attributes['min'] = $this->internalToFrontend($this->getMinDatetime()); $attributes['max'] = $this->internalToFrontend($this->getMaxDatetime()); } else { $attributes['type'] = 'text'; } return $attributes; } /** * @inheritDoc */ 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->internalToFrontend($this->getMinDatetime()), 'max' => $this->internalToFrontend($this->getMaxDatetime()) ]) ]); } /** * @inheritDoc */ public function Type() { return 'text datetime'; } /** * @return bool */ public function getHTML5() { return $this->html5; } /** * @param $bool * @return $this */ 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. * Assumes the value is in the defined {@link $timezone} (if one is set), * and adjusts for server timezone. * * @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 https://unicode-org.github.io/icu/userguide/format_parse/datetime/#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 https://unicode-org.github.io/icu/userguide/format_parse/datetime/#date-field-symbol-table * @param string $format * @return $this */ public function setDatetimeFormat($format) { $this->datetimeFormat = $format; return $this; } /** * Get a date formatter for the ISO 8601 format * * @param string $timezone Optional timezone identifier (defaults to server timezone) * @return IntlDateFormatter */ protected function getInternalFormatter($timezone = null) { if (!$timezone) { $timezone = date_default_timezone_get(); // Default to server timezone } $formatter = IntlDateFormatter::create( DBDate::ISO_LOCALE, IntlDateFormatter::MEDIUM, IntlDateFormatter::MEDIUM, $timezone ); $formatter->setLenient(false); // Note we omit timezone from this format, and we always assume server TZ $formatter->setPattern(DBDatetime::ISO_DATETIME); return $formatter; } /** * Assign value based on {@link $datetimeFormat}, which might be localised. * The value needs to be in the server timezone. * * 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 with "T" separator if (!$timestamp) { $fallbackFormatter = $this->getInternalFormatter(); $fallbackFormatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED); $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); } /** * 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 $datetime 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 Date in ISO 8601 format, in server timezone. */ public function getMinDatetime() { return $this->minDatetime; } /** * @param string $minDatetime A string in ISO 8601 format, in server timezone. * @return $this */ public function setMinDatetime($minDatetime) { $this->minDatetime = $this->tidyInternal($minDatetime); return $this; } /** * @return string Date in ISO 8601 format, in server timezone. */ public function getMaxDatetime() { return $this->maxDatetime; } /** * @param string $maxDatetime A string in ISO 8601 format, in server timezone. * @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( __CLASS__ . '.VALIDDATETIMEFORMAT', "Please enter a valid date and time format ({format})", ['format' => $this->getDatetimeFormat()] ) ); return false; } // Check min date (in server timezone) $min = $this->getMinDatetime(); if ($min) { $oops = strtotime($this->value ?? '') < strtotime($min ?? ''); if ($oops) { $validator->validationError( $this->name, _t( __CLASS__ . '.VALIDDATETIMEMINDATE', "Your date has to be newer or matching the minimum allowed date and time ({datetime})", [ 'datetime' => sprintf( '', $min, $this->internalToFrontend($min) ) ] ), ValidationResult::TYPE_ERROR, ValidationResult::CAST_HTML ); return false; } } // Check max date (in server timezone) $max = $this->getMaxDatetime(); if ($max) { $oops = strtotime($this->value ?? '') > strtotime($max ?? ''); if ($oops) { $validator->validationError( $this->name, _t( __CLASS__ . '.VALIDDATEMAXDATETIME', "Your date has to be older or matching the maximum allowed date and time ({datetime})", [ 'datetime' => sprintf( '', $max, $this->internalToFrontend($max) ) ] ), ValidationResult::TYPE_ERROR, ValidationResult::CAST_HTML ); 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"); } $this->timezone = $timezone; return $this; } }