diff --git a/core/modules/locale/locale.api.php b/core/modules/locale/locale.api.php index 7e7be105bc3..6ae4f4117a0 100644 --- a/core/modules/locale/locale.api.php +++ b/core/modules/locale/locale.api.php @@ -120,6 +120,9 @@ function hook_locale_translation_projects_alter(&$projects) { 'info' => [ 'interface translation server pattern' => 'http://example.com/files/translations/%core/%project/%project-%version.%language.po', ], + // An optional key to change the order in which translation files are + // processed. By default, the projects are sorted alphabetically by key. + 'weight' => 1, ]; } diff --git a/core/modules/locale/src/LocaleProjectStorage.php b/core/modules/locale/src/LocaleProjectStorage.php index 8d34d0887b6..cd441118610 100644 --- a/core/modules/locale/src/LocaleProjectStorage.php +++ b/core/modules/locale/src/LocaleProjectStorage.php @@ -28,7 +28,14 @@ class LocaleProjectStorage implements LocaleProjectStorageInterface { * * @var bool */ - protected static $all = FALSE; + protected bool $all = FALSE; + + /** + * Sorted status flag. + * + * @var bool + */ + protected bool $sorted = FALSE; /** * Constructs a State object. @@ -98,6 +105,7 @@ class LocaleProjectStorage implements LocaleProjectStorageInterface { $this->cache[$key] = $value; } $this->keyValueStore->setMultiple($data); + $this->sorted = FALSE; } /** @@ -122,7 +130,7 @@ class LocaleProjectStorage implements LocaleProjectStorageInterface { */ public function resetCache() { $this->cache = []; - static::$all = FALSE; + $this->sorted = $this->all = FALSE; } /** @@ -159,11 +167,26 @@ class LocaleProjectStorage implements LocaleProjectStorageInterface { * {@inheritdoc} */ public function getAll() { - if (!static::$all) { + if (!$this->all) { $this->cache = $this->keyValueStore->getAll(); - static::$all = TRUE; + $this->all = TRUE; } - return $this->cache; + if (!$this->sorted) { + // Work around PHP 8.3.0 - 8.3.3 bug by assigning $this->cache to a local + // variable, see https://github.com/php/php-src/pull/13285. + $cache = $this->cache; + uksort($this->cache, function ($a, $b) use ($cache) { + // Sort by weight, if available, and then by key. This allows locale + // projects to set a weight, if required, and keeps the order consistent + // regardless of whether the list is built from code or retrieve from + // the database. + $sort = (int) ($cache[$a]['weight'] ?? 0) <=> (int) ($cache[$b]['weight'] ?? 0); + return $sort ?: strcmp($a, $b); + }); + $this->sorted = TRUE; + } + // Remove any NULL values as these are not valid projects. + return array_filter($this->cache, fn ($value) => $value !== NULL); } } diff --git a/core/modules/locale/tests/src/Unit/LocaleProjectStorageTest.php b/core/modules/locale/tests/src/Unit/LocaleProjectStorageTest.php new file mode 100644 index 00000000000..1355a41d9be --- /dev/null +++ b/core/modules/locale/tests/src/Unit/LocaleProjectStorageTest.php @@ -0,0 +1,101 @@ +keyValueMemoryFactory = new KeyValueMemoryFactory(); + $this->projectStorage = new LocaleProjectStorage($this->keyValueMemoryFactory); + } + + /** + * Tests that projects are sorted by weight and key. + */ + public function testSorting(): void { + // There are no projects. + $this->assertSame([], $this->projectStorage->getAll()); + + // Add project 'b'. + $this->projectStorage->set('b', ['name' => 'b']); + $this->assertSame(['b'], array_keys($this->projectStorage->getAll())); + + // Add project 'c' and confirm alphabetical order. + $this->projectStorage->set('c', ['name' => 'c']); + $this->assertSame(['b', 'c'], array_keys($this->projectStorage->getAll())); + + // Add project 'a' and confirm 'a' is first. + $this->projectStorage->set('a', ['name' => 'a']); + $this->assertSame(['a', 'b', 'c'], array_keys($this->projectStorage->getAll())); + + // Add project 'd' with a negative weight and confirm 'd' is first. + $this->projectStorage->set('d', ['name' => 'd', 'weight' => -1]); + $this->assertSame(['d', 'a', 'b', 'c'], array_keys($this->projectStorage->getAll())); + + // Add project 'aa' with a positive weight and confirm 'aa' is last. + $this->projectStorage->set('aa', ['name' => 'aa', 'weight' => 1]); + $this->assertSame(['d', 'a', 'b', 'c', 'aa'], array_keys($this->projectStorage->getAll())); + + // Delete project 'a'. + $this->projectStorage->delete('a'); + $this->assertSame(['d', 'b', 'c', 'aa'], array_keys($this->projectStorage->getAll())); + + // Add project 'e' with a lower negative weight than 'd' and confirm 'e' is + // first. + $this->projectStorage->set('e', ['name' => 'e', 'weight' => -5]); + $this->assertSame(['e', 'd', 'b', 'c', 'aa'], array_keys($this->projectStorage->getAll())); + + // Pretend there is a container rebuild by generating a new + // LocaleProjectStorage object with the same data. + $this->projectStorage = new LocaleProjectStorage($this->keyValueMemoryFactory); + $this->projectStorage->set('z', ['name' => 'z']); + $this->assertSame(['e', 'd', 'b', 'c', 'z', 'aa'], array_keys($this->projectStorage->getAll())); + + // Now delete all projects. + $this->projectStorage->deleteAll(); + $this->assertSame([], $this->projectStorage->getAll()); + + // Add project 'z' before project 'a' and confirm 'a' is first. + $this->projectStorage->set('z', ['name' => 'z']); + $this->projectStorage->set('a', ['name' => 'a']); + $this->assertSame(['a', 'z'], array_keys($this->projectStorage->getAll())); + } + + /** + * Tests deleted projects are not included in the count. + */ + public function testDelete(): void { + $this->projectStorage->set('b', ['name' => 'b']); + $this->assertSame(['name' => 'b'], $this->projectStorage->get('b')); + $this->assertSame(1, $this->projectStorage->countProjects()); + $this->projectStorage->delete('b'); + $this->assertNull($this->projectStorage->get('b')); + $this->assertSame(0, $this->projectStorage->countProjects()); + } + +}