diff --git a/admin/tool/uploadcourse/tests/course_test.php b/admin/tool/uploadcourse/tests/course_test.php
index 3da6389681f..e5fc520504e 100644
--- a/admin/tool/uploadcourse/tests/course_test.php
+++ b/admin/tool/uploadcourse/tests/course_test.php
@@ -1504,7 +1504,7 @@ class course_test extends \advanced_testcase {
// Create our custom field.
$category = $this->get_customfield_generator()->create_category();
$this->create_custom_field($category, 'date', 'mydate',
- ['mindate' => strtotime('2020-04-01'), 'maxdate' => '2020-04-30']);
+ ['mindate' => strtotime('2020-04-01'), 'maxdate' => strtotime('2020-04-30')]);
$mode = tool_uploadcourse_processor::MODE_UPDATE_ONLY;
$updatemode = tool_uploadcourse_processor::UPDATE_ALL_WITH_DATA_ONLY;
diff --git a/calendar/tests/calendartype_test.php b/calendar/tests/calendartype_test.php
index 428aebbd9b2..5319b356905 100644
--- a/calendar/tests/calendartype_test.php
+++ b/calendar/tests/calendartype_test.php
@@ -195,6 +195,12 @@ class calendartype_test extends \advanced_testcase {
$this->assertEquals($calendar->timestamp_to_date_string($this->user->timecreated, '', 99, true, true),
userdate($this->user->timecreated));
+ // Test the userdate function with a timezone.
+ $this->assertEquals(
+ $calendar->timestamp_to_date_string($this->user->timecreated, '', 'Australia/Sydney', true, true),
+ userdate($this->user->timecreated, timezone: 'Australia/Sydney'),
+ );
+
// Test the calendar/lib.php functions.
$this->assertEquals($calendar->get_weekdays(), calendar_get_days());
$this->assertEquals($calendar->get_starting_weekday(), calendar_get_starting_weekday());
diff --git a/calendar/type/gregorian/classes/structure.php b/calendar/type/gregorian/classes/structure.php
index a01e84b1c1d..7680a874afd 100644
--- a/calendar/type/gregorian/classes/structure.php
+++ b/calendar/type/gregorian/classes/structure.php
@@ -276,13 +276,6 @@ class structure extends type_base {
/**
* Returns a formatted string that represents a date in user time.
*
- * Returns a formatted string that represents a date in user time
- * WARNING: note that the format is for strftime(), not date().
- * Because of a bug in most Windows time libraries, we can't use
- * the nicer %e, so we have to use %d which has leading zeroes.
- * A lot of the fuss in the function is just getting rid of these leading
- * zeroes as efficiently as possible.
- *
* If parameter fixday = true (default), then take off leading
* zero from %d, else maintain it.
*
@@ -303,43 +296,62 @@ class structure extends type_base {
$format = get_string('strftimedaydatetime', 'langconfig');
}
- if (!empty($CFG->nofixday)) { // Config.php can force %d not to be fixed.
- $fixday = false;
- } else if ($fixday) {
- $formatnoday = str_replace('%d', 'DD', $format);
- $fixday = ($formatnoday != $format);
- $format = $formatnoday;
+ // Note: This historical logic was about fixing 12-hour time to remove
+ // unnecessary leading zero was required because on Windows, PHP strftime
+ // function did not support the correct 'hour without leading zero' parameter (%l).
+ // This is no longer required because we use IntlDateFormatter.
+ // Unfortunately though the original implementation was done incorrectly.
+ // The documentation for strftime notes that for the "%l" and "%e" specifiers where
+ // no leading zero is used, a space is used instead.
+ // As a result we switch to the new format specifiers "%l" and "%e", wrap them in placeholders
+ // and then remove the spaces.
+
+ if (empty($CFG->nofixday) && $fixday) {
+ // Config.php can force %d not to be fixed, but only if the format did not specify it.
+ $format = str_replace(
+ '%d',
+ 'DDHH%eHHDD',
+ $format,
+ );
}
- // Note: This logic about fixing 12-hour time to remove unnecessary leading
- // zero is required because on Windows, PHP strftime function does not
- // support the correct 'hour without leading zero' parameter (%l).
- if (!empty($CFG->nofixhour)) {
- // Config.php can force %I not to be fixed.
- $fixhour = false;
- } else if ($fixhour) {
- $formatnohour = str_replace('%I', 'HH', $format);
- $fixhour = ($formatnohour != $format);
- $format = $formatnohour;
+ if (empty($CFG->nofixhour) && $fixhour) {
+ $format = str_replace(
+ '%I',
+ 'DDHH%lHHDD',
+ $format,
+ );
}
- $time = (int)$time; // Moodle allows rubbish in input...
- $datestring = date_format_string($time, $format, $timezone);
+ if (is_string($time) && !is_numeric($time)) {
+ debugging(
+ "Invalid time passed to timestamp_to_date_string: '{$time}'",
+ DEBUG_DEVELOPER,
+ );
+ $time = 0;
+ }
+
+ if ($time === null || $time === '') {
+ $time = 0;
+ }
+
+ $time = new \DateTime("@{$time}", new \DateTimeZone(date_default_timezone_get()));
date_default_timezone_set(\core_date::get_user_timezone($timezone));
- if ($fixday) {
- $daystring = ltrim(str_replace(array(' 0', ' '), '', date(' d', $time)));
- $datestring = str_replace('DD', $daystring, $datestring);
- }
- if ($fixhour) {
- $hourstring = ltrim(str_replace(array(' 0', ' '), '', date(' h', $time)));
- $datestring = str_replace('HH', $hourstring, $datestring);
- }
+ $formattedtime = \core_date::strftime(
+ $format,
+ $time,
+ get_string('locale', 'langconfig'),
+ );
\core_date::set_default_server_timezone();
- return $datestring;
+ // Use a simple regex to remove the placeholders and any leading spaces to match the historically
+ // generated format.
+ $formattedtime = preg_replace('/DDHH ?(\d{1,2})HHDD/', '$1', $formattedtime);
+
+ return $formattedtime;
}
/**
diff --git a/calendar/type/gregorian/tests/structure_test.php b/calendar/type/gregorian/tests/structure_test.php
new file mode 100644
index 00000000000..dc917ad8a1e
--- /dev/null
+++ b/calendar/type/gregorian/tests/structure_test.php
@@ -0,0 +1,193 @@
+.
+
+namespace calendartype_gregorian;
+
+/**
+ * Tests for Gregorian calendar type
+ *
+ * @package calendartype_gregorian
+ * @category test
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \calendartype_gregorian\structure
+ */
+final class structure_test extends \advanced_testcase {
+ public function tearDown(): void {
+ parent::tearDown();
+
+ get_string_manager(true);
+ }
+
+ /**
+ * Test the timestamp_to_date_string method with different input values.
+ *
+ * @dataProvider timestamp_to_date_string_provider
+ * @param string $locale
+ * @param int $timestamp
+ * @param string $format
+ * @param string $timezone
+ * @param bool $fixday
+ * @param bool $fixhour
+ * @param string $expected
+ */
+ public function test_timestamp_to_date_string(
+ string $locale,
+ int $timestamp,
+ string $format,
+ string $timezone,
+ bool $fixday,
+ bool $fixhour,
+ string $expected,
+ ): void {
+ $this->resetAfterTest();
+
+ $stringmanager = $this->get_mocked_string_manager();
+ $stringmanager->mock_string('locale', 'langconfig', $locale);
+
+ $structure = new structure();
+ $this->assertEquals(
+ $expected,
+ $structure->timestamp_to_date_string(
+ $timestamp,
+ $format,
+ $timezone,
+ $fixday,
+ $fixhour,
+ ),
+ );
+ }
+
+ /**
+ * Data provider for timestamp_to_date_string tests.
+ *
+ * @return array
+ */
+ public static function timestamp_to_date_string_provider(): array {
+ return [
+ 'English with UTC timezone' => [
+ 'en',
+ 0,
+ '%Y-%m-%d %H:%M:%S',
+ 'UTC',
+ false,
+ false,
+ '1970-01-01 00:00:00',
+ ],
+ 'English with London timezone' => [
+ 'en',
+ 1728487003,
+ "%d %B %Y",
+ 'Europe/London',
+ false,
+ false,
+ "09 October 2024",
+ ],
+ 'English with Sydney (+11) timezone' => [
+ 'en',
+ 1728487003,
+ "%d %B %Y",
+ 'Australia/Sydney',
+ false,
+ false,
+ "10 October 2024",
+ ],
+ 'Russian with Sydney (+11) timezone' => [
+ 'ru',
+ 1728487003,
+ "%d %B %Y %H:%M:%S",
+ 'Australia/Sydney',
+ false,
+ false,
+ '10 октября 2024 02:16:43',
+ ],
+ 'Russian %B %Y (Genitive) with Sydney (+11) timezone' => [
+ 'ru',
+ 1728487003,
+ "%B %Y",
+ 'Australia/Sydney',
+ false,
+ false,
+ "октябрь 2024",
+ ],
+ 'Russian %d %B %Y (Nominative) with London timezone' => [
+ 'ru',
+ 1728487003,
+ "%d %B %Y",
+ 'Europe/London',
+ false,
+ false,
+ "09 октября 2024",
+ ],
+ 'Russian %d %B %Y (Nominative) with London timezone fixing leading zero' => [
+ 'ru',
+ 1728487003,
+ "%d %B %Y",
+ 'Europe/London',
+ true,
+ false,
+ "9 октября 2024",
+ ],
+ 'Russian %e %B %Y (Nominative) with London timezone' => [
+ 'ru',
+ 1728487003,
+ "%e %B %Y",
+ 'Europe/London',
+ false,
+ false,
+ " 9 октября 2024",
+ ],
+ 'Time %I without fixing leading zero' => [
+ 'ru',
+ 1728487003,
+ "%I:%M:%S",
+ 'Australia/Sydney',
+ false,
+ false
+ ,
+ "02:16:43",
+ ],
+ 'Time %I fixing leading zero' => [
+ 'ru',
+ 1728487003,
+ "%I:%M:%S",
+ 'Australia/Sydney',
+ false,
+ true
+ ,
+ "2:16:43",
+ ],
+ 'Time %l without fixing leading zero' => [
+ 'ru',
+ 1728487003,
+ "%l:%M:%S",
+ 'Australia/Sydney',
+ false,
+ false,
+ " 2:16:43",
+ ],
+ 'Time %l fixing leading zero' => [
+ 'ru',
+ 1728487003,
+ "%l:%M:%S",
+ 'Australia/Sydney',
+ false,
+ true,
+ " 2:16:43",
+ ],
+ ];
+ }
+}
diff --git a/lib/classes/date.php b/lib/classes/date.php
index 5338f023492..453faa133ae 100644
--- a/lib/classes/date.php
+++ b/lib/classes/date.php
@@ -741,13 +741,14 @@ class core_date {
$intl_formats = [
'%a' => 'EEE', // An abbreviated textual representation of the day Sun through Sat
'%A' => 'EEEE', // A full textual representation of the day Sunday through Saturday
- '%b' => 'MMM', // Abbreviated month name, based on the locale Jan through Dec
- '%B' => 'MMMM', // Full month name, based on the locale January through December
- '%h' => 'MMM', // Abbreviated month name, based on the locale (an alias of %b) Jan through Dec
];
- $intl_formatter = function (DateTimeInterface $timestamp, string $format) use ($intl_formats, $locale) {
-
+ $originalformat = $format;
+ $intl_formatter = function (DateTimeInterface $timestamp, string $format) use (
+ $intl_formats,
+ $locale,
+ $originalformat,
+ ) {
// Map IANA timezone DB names (used by PHP) to those used internally by the "intl" extension. The extension uses its
// own data based on ICU timezones, which may not necessarily be in-sync with IANA depending on the version installed
// on the local system. See: https://unicode-org.github.io/icu/userguide/datetime/timezone/#updating-the-time-zone-data
@@ -789,6 +790,19 @@ class core_date {
$time_type = IntlDateFormatter::MEDIUM;
break;
+ case "%B":
+ case "%b":
+ case "%h":
+ // Check for any day (%d, or %e) in the string.
+ if (preg_match('/%[de]/', $originalformat)) {
+ // The day is present so use the standard format.
+ $pattern = $format === '%B' ? 'MMMM' : 'MMM';
+ } else {
+ // The day is not present so use the stand-alone format.
+ $pattern = $format === '%B' ? 'LLLL' : 'LLL';
+ }
+ break;
+
default:
$pattern = $intl_formats[$format];
}
diff --git a/lib/phpunit/classes/advanced_testcase.php b/lib/phpunit/classes/advanced_testcase.php
index aa7512f36df..f7a1a357885 100644
--- a/lib/phpunit/classes/advanced_testcase.php
+++ b/lib/phpunit/classes/advanced_testcase.php
@@ -912,4 +912,18 @@ abstract class advanced_testcase extends base_testcase {
'handlerstack' => $handlerstack,
];
}
+
+ /**
+ * Get a copy of the mocked string manager.
+ *
+ * @return \core\tests\mocking_string_manager
+ */
+ protected function get_mocked_string_manager(): \core\tests\mocking_string_manager {
+ global $CFG;
+
+ $this->resetAfterTest();
+ $CFG->config_php_settings['customstringmanager'] = \core\tests\mocking_string_manager::class;
+
+ return get_string_manager(true);
+ }
}
diff --git a/lib/tests/classes/mocking_string_manager.php b/lib/tests/classes/mocking_string_manager.php
new file mode 100644
index 00000000000..750cd65a3bd
--- /dev/null
+++ b/lib/tests/classes/mocking_string_manager.php
@@ -0,0 +1,54 @@
+.
+
+namespace core\tests;
+
+/**
+ * A string manager which supports mocking individual strings.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mocking_string_manager extends \core_string_manager_standard {
+ /** @var array The list of strings */
+ private $strings = [];
+
+ #[\Override]
+ public function get_string($identifier, $component = '', $a = null, $lang = null) {
+ if (isset($this->strings["{$component}/{$identifier}"])) {
+ return $this->strings["{$component}/{$identifier}"];
+ }
+
+ return parent::get_string($identifier, $component, $a, $lang);
+ }
+
+ /**
+ * Mock a string.
+ *
+ * @param string $identifier
+ * @param string $component
+ * @param string $value
+ * @return void
+ */
+ public function mock_string(
+ string $identifier,
+ string $component,
+ string $value,
+ ): void {
+ $this->strings["{$component}/{$identifier}"] = $value;
+ }
+}
diff --git a/lib/tests/date_test.php b/lib/tests/date_test.php
index cc1fbb5193d..fad7dc4bf69 100644
--- a/lib/tests/date_test.php
+++ b/lib/tests/date_test.php
@@ -627,6 +627,36 @@ class date_test extends advanced_testcase {
"%c",
"20 February 2024 at 1:09 pm",
],
+ 'Month Year only' => [
+ "1708405742",
+ "%B %Y",
+ "February 2024",
+ ],
+ 'Abbreviated Month Year only' => [
+ "1708405742",
+ "%b %Y",
+ "Feb 2024",
+ ],
+ 'DD Month Year' => [
+ "1708405742",
+ "%d %B %Y",
+ "20 February 2024",
+ ],
+ 'D Month Year' => [
+ "1708405742",
+ "%e %B %Y",
+ "20 February 2024",
+ ],
+ 'Abbreviated DD Month Year' => [
+ "1708405742",
+ "%d %b %Y",
+ "20 Feb 2024",
+ ],
+ 'Abbreviated D Month Year' => [
+ "1708405742",
+ "%e %b %Y",
+ "20 Feb 2024",
+ ],
'numeric_c' => [
1708405742,
"%c",
@@ -666,4 +696,70 @@ class date_test extends advanced_testcase {
public function test_strftime(mixed $input, string $format, string $expected): void {
$this->assertEqualsIgnoringWhitespace($expected, core_date::strftime($format, $input));
}
+
+ /**
+ * Data provider for ::test_strftime_locale.
+ *
+ * @return array[]
+ */
+ public static function get_strftime_locale_provider(): array {
+ return [
+ 'Month Year only' => [
+ "1728487000",
+ 'ru_RU.UTF-8',
+ "%B %Y",
+ "октябрь 2024",
+ ],
+ 'DD Month Year' => [
+ "1728487000",
+ 'ru_RU.UTF-8',
+ "%d %B %Y",
+ "09 октября 2024",
+ ],
+ 'D Month Year' => [
+ "1728487000",
+ 'ru_RU.UTF-8',
+ "%e %B %Y",
+ " 9 октября 2024",
+ ],
+ 'Abbreviated Month Year only' => [
+ "1728487000",
+ 'ru_RU.UTF-8',
+ "%b %Y",
+ "окт. 2024",
+ ],
+ 'Abbreviated DD Month Year' => [
+ "1728487000",
+ 'ru_RU.UTF-8',
+ "%d %b %Y",
+ "09 окт. 2024",
+ ],
+ 'Abbreviated D Month Year' => [
+ "1728487000",
+ 'ru_RU.UTF-8',
+ "%e %b %Y",
+ " 9 окт. 2024",
+ ],
+ ];
+ }
+
+ /**
+ * Test \core_date::strftime function with alternate languages.
+ *
+ * @dataProvider get_strftime_locale_provider
+ * @param mixed $input Input passed to strftime
+ * @param string $locale The locale
+ * @param string $format The date format to pass to strftime, falls back to '%c' if null
+ * @param string $expected The output generated by strftime
+ */
+ public function test_strftime_locale(
+ mixed $input,
+ string $locale,
+ string $format,
+ string $expected,
+ ): void {
+ $this->assertEqualsIgnoringWhitespace(
+ $expected,
+ core_date::strftime($format, $input, $locale));
+ }
}
diff --git a/lib/xapi/tests/privacy/provider_test.php b/lib/xapi/tests/privacy/provider_test.php
index ab6b48c1488..73e79b81de4 100644
--- a/lib/xapi/tests/privacy/provider_test.php
+++ b/lib/xapi/tests/privacy/provider_test.php
@@ -157,16 +157,17 @@ class provider_test extends provider_testcase {
$info = (object) reset($result);
// Ensure the correct data has been returned.
$this->assertNotEmpty($info->statedata);
- $this->assertNotEmpty(transform::datetime($info->timecreated));
- $this->assertNotEmpty(transform::datetime($info->timemodified));
+
+ $this->assertNotEmpty($info->timecreated);
+ $this->assertNotEmpty($info->timemodified);
// Get the states info for user2 in the system context.
$result = provider::get_xapi_states_for_user($user2->id, 'fake_component', $systemcontext->id);
$info = (object) reset($result);
// Ensure the correct data has been returned.
$this->assertNotEmpty($info->statedata);
- $this->assertNotEmpty(transform::datetime($info->timecreated));
- $this->assertNotEmpty(transform::datetime($info->timemodified));
+ $this->assertNotEmpty($info->timecreated);
+ $this->assertNotEmpty($info->timemodified);
// Get the states info for user3 in the system context (it should be empty).
$info = provider::get_xapi_states_for_user($user3->id, 'fake_component', $systemcontext->id);