From 67f7d1f4c756a5a8fba46bca760bd66c5a796e71 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Fri, 23 Aug 2019 00:56:21 +0000 Subject: [PATCH] Date/Time: Rewrite and simplify `date_i18n()` using `wp_timezone()` to address multiple issues with certain date formats and timezones, while preserving some extra handling for legacy use cases. Improve unit test coverage. Props Rarst, remcotolsma, raubvogel. Fixes #25768. git-svn-id: https://develop.svn.wordpress.org/trunk@45882 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/functions.php | 155 ++++++++++++++++---------- tests/phpunit/tests/date/dateI18n.php | 68 ++++++++++- 2 files changed, 158 insertions(+), 65 deletions(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 3b61fcd5ff..3415f3f854 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -129,6 +129,11 @@ function wp_timezone() { * take over the format for the date. If it isn't, then the date format string * will be used instead. * + * Note that due to the way WP typically generates a sum of timestamp and offset + * with `strtotime()`, it implies offset added at a _current_ time, not at the time + * the timestamp represents. Storing such timestamps or calculating them differently + * will lead to invalid output. + * * @since 0.71 * * @global WP_Locale $wp_locale WordPress date and time locale object. @@ -143,6 +148,7 @@ function wp_timezone() { */ function date_i18n( $dateformatstring, $timestamp_with_offset = false, $gmt = false ) { global $wp_locale; + $i = $timestamp_with_offset; if ( ! is_numeric( $i ) ) { @@ -154,78 +160,72 @@ function date_i18n( $dateformatstring, $timestamp_with_offset = false, $gmt = fa * See https://core.trac.wordpress.org/ticket/9396 */ $req_format = $dateformatstring; + $new_format = ''; - $dateformatstring = preg_replace( '/(?month ) ) && ( ! empty( $wp_locale->weekday ) ) ) { - $datemonth = $wp_locale->get_month( gmdate( 'm', $i ) ); - $datemonth_abbrev = $wp_locale->get_month_abbrev( $datemonth ); - $dateweekday = $wp_locale->get_weekday( gmdate( 'w', $i ) ); - $dateweekday_abbrev = $wp_locale->get_weekday_abbrev( $dateweekday ); - $datemeridiem = $wp_locale->get_meridiem( gmdate( 'a', $i ) ); - $datemeridiem_capital = $wp_locale->get_meridiem( gmdate( 'A', $i ) ); - $dateformatstring = ' ' . $dateformatstring; - $dateformatstring = preg_replace( '/([^\\\])D/', "\\1" . backslashit( $dateweekday_abbrev ), $dateformatstring ); - $dateformatstring = preg_replace( '/([^\\\])F/', "\\1" . backslashit( $datemonth ), $dateformatstring ); - $dateformatstring = preg_replace( '/([^\\\])l/', "\\1" . backslashit( $dateweekday ), $dateformatstring ); - $dateformatstring = preg_replace( '/([^\\\])M/', "\\1" . backslashit( $datemonth_abbrev ), $dateformatstring ); - $dateformatstring = preg_replace( '/([^\\\])a/', "\\1" . backslashit( $datemeridiem ), $dateformatstring ); - $dateformatstring = preg_replace( '/([^\\\])A/', "\\1" . backslashit( $datemeridiem_capital ), $dateformatstring ); + /* + * Timestamp with offset is typically produced by a UTC `strtotime()` call on an input without timezone. + * This is the best attempt to reverse that operation into a local time to use. + */ + $local_time = gmdate( 'Y-m-d H:i:s', $i ); + $gmt_mode = $gmt && ( false === $timestamp_with_offset ); + $timezone = $gmt_mode ? new DateTimeZone( 'UTC' ) : wp_timezone(); + $datetime = date_create( $local_time, $timezone ); - $dateformatstring = substr( $dateformatstring, 1, strlen( $dateformatstring ) - 1 ); + /* + * This is a legacy implementation quirk that the returned timestamp is also with offset. + * Ideally this function should never be used to produce a timestamp. + */ + $timestamp_mode = ( 'U' === $dateformatstring ); + + if ( $timestamp_mode ) { + $new_format = $i; } - $timezone_formats = array( 'P', 'I', 'O', 'T', 'Z', 'e' ); - $timezone_formats_re = implode( '|', $timezone_formats ); - if ( preg_match( "/$timezone_formats_re/", $dateformatstring ) ) { - $timezone_string = get_option( 'timezone_string' ); - if ( false === $timestamp_with_offset && $gmt ) { - $timezone_string = 'UTC'; - } - if ( $timezone_string ) { - $timezone_object = timezone_open( $timezone_string ); - $date_object = date_create( null, $timezone_object ); - foreach ( $timezone_formats as $timezone_format ) { - if ( false !== strpos( $dateformatstring, $timezone_format ) ) { - $formatted = date_format( $date_object, $timezone_format ); - $dateformatstring = ' ' . $dateformatstring; - $dateformatstring = preg_replace( "/([^\\\])$timezone_format/", "\\1" . backslashit( $formatted ), $dateformatstring ); - $dateformatstring = substr( $dateformatstring, 1, strlen( $dateformatstring ) - 1 ); - } - } - } else { - $offset = get_option( 'gmt_offset' ); - foreach ( $timezone_formats as $timezone_format ) { - if ( 'I' === $timezone_format ) { - continue; - } - if ( false !== strpos( $dateformatstring, $timezone_format ) ) { - if ( 'Z' === $timezone_format ) { - $formatted = (string) ( $offset * HOUR_IN_SECONDS ); - } else { - $prefix = ''; - $hours = (int) $offset; - $separator = ''; - $minutes = abs( ( $offset - $hours ) * 60 ); + if ( ! $timestamp_mode && ! empty( $wp_locale->month ) && ! empty( $wp_locale->weekday ) ) { + $month = $wp_locale->get_month( $datetime->format( 'm' ) ); + $weekday = $wp_locale->get_weekday( $datetime->format( 'w' ) ); - if ( 'T' === $timezone_format ) { - $prefix = 'GMT'; - } elseif ( 'e' === $timezone_format || 'P' === $timezone_format ) { - $separator = ':'; - } + $format_length = strlen( $dateformatstring ); - $formatted = sprintf( '%s%+03d%s%02d', $prefix, $hours, $separator, $minutes ); + for ( $i = 0; $i < $format_length; $i ++ ) { + switch ( $dateformatstring[ $i ] ) { + case 'D': + $new_format .= backslashit( $wp_locale->get_weekday_abbrev( $weekday ) ); + break; + case 'F': + $new_format .= backslashit( $month ); + break; + case 'l': + $new_format .= backslashit( $weekday ); + break; + case 'M': + $new_format .= backslashit( $wp_locale->get_month_abbrev( $month ) ); + break; + case 'a': + $new_format .= backslashit( $wp_locale->get_meridiem( $datetime->format( 'a' ) ) ); + break; + case 'A': + $new_format .= backslashit( $wp_locale->get_meridiem( $datetime->format( 'A' ) ) ); + break; + case '\\': + $new_format .= $dateformatstring[ $i ]; + + // If character follows a slash, we add it without translating. + if ( $i < $format_length ) { + $new_format .= $dateformatstring[ ++$i ]; } - - $dateformatstring = ' ' . $dateformatstring; - $dateformatstring = preg_replace( "/([^\\\])$timezone_format/", "\\1" . backslashit( $formatted ), $dateformatstring ); - $dateformatstring = substr( $dateformatstring, 1 ); - } + break; + default: + $new_format .= $dateformatstring[ $i ]; + break; } } } - $j = gmdate( $dateformatstring, $i ); + + $j = $datetime->format( $new_format ); /** * Filters the date formatted based on the locale. @@ -239,6 +239,7 @@ function date_i18n( $dateformatstring, $timestamp_with_offset = false, $gmt = fa * not provided. Default false. */ $j = apply_filters( 'date_i18n', $j, $req_format, $i, $gmt ); + return $j; } @@ -1555,6 +1556,40 @@ function do_robots() { echo apply_filters( 'robots_txt', $output, $public ); } +/** + * Display the robots.txt file content. + * + * The echo content should be with usage of the permalinks or for creating the + * robots.txt file. + * + * @since 5.3.0 + */ +function do_favicon() { + /** + * Fires when serving the favicon.ico file. + * + * @since 5.3.0 + */ + do_action( 'do_faviconico' ); + + wp_safe_redirect( esc_url( get_site_icon_url( 32, admin_url( 'images/w-logo-blue.png' ) ) ) ); + +} + +/** + * Don't load all of WordPress when handling a favicon.ico request. + * + * Instead, send the headers for a zero-length favicon and bail. + * + * @since 3.0.0 + */ +function wp_favicon_request() { + if ( '/favicon.ico' == $_SERVER['REQUEST_URI'] ) { + header( 'Content-Type: image/vnd.microsoft.icon' ); + exit; + } +} + /** * Determines whether WordPress is already installed. * diff --git a/tests/phpunit/tests/date/dateI18n.php b/tests/phpunit/tests/date/dateI18n.php index 3305856913..178f09181a 100644 --- a/tests/phpunit/tests/date/dateI18n.php +++ b/tests/phpunit/tests/date/dateI18n.php @@ -17,10 +17,6 @@ class Tests_Date_I18n extends WP_UnitTestCase { $this->assertEquals( strtotime( gmdate( DATE_RFC3339 ) ), strtotime( date_i18n( DATE_RFC3339, false, true ) ), 'The dates should be equal', 2 ); } - public function test_custom_timestamp_ignores_gmt_setting() { - $this->assertEquals( '2012-12-01 00:00:00', date_i18n( 'Y-m-d H:i:s', strtotime( '2012-12-01 00:00:00' ) ) ); - } - public function test_custom_timezone_setting() { update_option( 'timezone_string', 'America/Regina' ); @@ -85,8 +81,9 @@ class Tests_Date_I18n extends WP_UnitTestCase { } /** - * @dataProvider data_formats * @ticket 20973 + * + * @dataProvider data_formats */ public function test_date_i18n_handles_shorthand_formats( $short, $full ) { update_option( 'timezone_string', 'America/Regina' ); @@ -107,4 +104,65 @@ class Tests_Date_I18n extends WP_UnitTestCase { ), ); } + + /** + * @ticket 25768 + */ + public function test_should_return_wp_timestamp() { + update_option( 'timezone_string', 'Europe/Kiev' ); + + $datetime = new DateTimeImmutable( 'now', wp_timezone() ); + $timestamp = $datetime->getTimestamp(); + $wp_timestamp = $timestamp + $datetime->getOffset(); + + $this->assertEquals( $wp_timestamp, date_i18n( 'U' ), 2 ); + $this->assertEquals( $timestamp, date_i18n( 'U', false, true ), 2 ); + $this->assertEquals( $wp_timestamp, date_i18n( 'U', $wp_timestamp ) ); + } + + /** + * @ticket 43530 + */ + public function test_swatch_internet_time_with_wp_timestamp() { + update_option( 'timezone_string', 'America/Regina' ); + + $this->assertEquals( gmdate( 'B' ), date_i18n( 'B' ) ); + } + + /** + * @ticket 25768 + */ + public function test_should_handle_escaped_formats() { + $format = 'D | \D | \\D | \\\D | \\\\D | \\\\\D | \\\\\\D'; + + $this->assertEquals( gmdate( $format ), date_i18n( $format ) ); + } + + /** + * @ticket 25768 + * + * @dataProvider dst_times + * + * @param string $time Time to test in Y-m-d H:i:s format. + * @param string $timezone PHP timezone string to use. + */ + public function test_should_handle_dst( $time, $timezone ) { + update_option( 'timezone_string', $timezone ); + + $timezone = new DateTimeZone( $timezone ); + $datetime = new DateTime( $time, $timezone ); + $wp_timestamp = strtotime( $time ); + $format = 'I ' . DATE_RFC3339; + + $this->assertEquals( $datetime->format( $format ), date_i18n( $format, $wp_timestamp ) ); + } + + public function dst_times() { + return array( + 'Before DST start' => array( '2019-03-31 02:59:00', 'Europe/Kiev' ), + 'After DST start' => array( '2019-03-31 04:01:00', 'Europe/Kiev' ), + 'Before DST end' => array( '2019-10-27 02:59:00', 'Europe/Kiev' ), + 'After DST end' => array( '2019-10-27 04:01:00', 'Europe/Kiev' ), + ); + } }