diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef8e167717a..1fc8755f0cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - 5.x + - '5.12' - '*-internal' pull_request: permissions: diff --git a/CHANGELOG-5.12.md b/CHANGELOG-5.12.md new file mode 100644 index 00000000000..2d3fa020cfb --- /dev/null +++ b/CHANGELOG-5.12.md @@ -0,0 +1,17 @@ +# Release Notes for Craft CMS 5.12 (WIP) + +### Development + +- The `capitalize`, `lower`, `title`, and `upper` Twig filters now have `language` arguments, which default to the current application language. ([#19558](https://github.com/craftcms/cms/pull/19558)) + +### Extensibility + +- Added `craft\i18n\Locale::languageId()`. +- Added `craft\elements\db\NestedElementQueryTrait::mustHaveField()`. +- Added `craft\elements\db\NestedElementQueryTrait::mustHaveOwner()`. +- `craft\helpers\ElementHelper::normalizeSlug()` now has a `$language` argument, which defaults to the current application language. ([#19558](https://github.com/craftcms/cms/pull/19558)) +- `craft\helpers\StringHelper::toLowerCase()`, `::toTitleCase()`, and `::toUpperCase()` now have `$language` arguments, which default to the current application language. ([#19558](https://github.com/craftcms/cms/pull/19558)) + +### System + +- Fixed a bug where entry and address indexes weren’t showing any results if they had a “Field” condition rule set to “is empty”. diff --git a/src/elements/db/AddressQuery.php b/src/elements/db/AddressQuery.php index 95f5db2c2c5..93659caa8c5 100644 --- a/src/elements/db/AddressQuery.php +++ b/src/elements/db/AddressQuery.php @@ -899,7 +899,7 @@ protected function beforePrepare(): bool $this->normalizeNestedElementParams(); // Only join the elements_owners table if fieldId is specified - if (!empty($this->fieldId)) { + if (isset($this->fieldId)) { $this->applyNestedElementParams('addresses.fieldId', 'addresses.primaryOwnerId'); } elseif (isset($this->primaryOwnerId) || isset($this->ownerId)) { // User addresses don't get rows in the elements_owners table @@ -968,6 +968,11 @@ protected function beforePrepare(): bool return true; } + protected function mustHaveField(): bool + { + return false; + } + /** * @inheritdoc */ diff --git a/src/elements/db/EntryQuery.php b/src/elements/db/EntryQuery.php index 3e0f3d7da34..86d84c5260c 100644 --- a/src/elements/db/EntryQuery.php +++ b/src/elements/db/EntryQuery.php @@ -968,6 +968,16 @@ protected function beforePrepare(): bool return true; } + protected function mustHaveField(): bool + { + return false; + } + + protected function mustHaveOwner(): bool + { + return false; + } + /** * @inheritdoc */ diff --git a/src/elements/db/NestedElementQueryTrait.php b/src/elements/db/NestedElementQueryTrait.php index 305001f6f9e..2189efecedf 100644 --- a/src/elements/db/NestedElementQueryTrait.php +++ b/src/elements/db/NestedElementQueryTrait.php @@ -197,16 +197,49 @@ public function allowOwnerRevisions(?bool $value = true): static return $this; } + /** + * Returns whether the resulting elements will always have a field assigned to them. + * + * @since 5.12.0 + */ + protected function mustHaveField(): bool + { + return true; + } + + /** + * Returns whether the resulting elements will always have an owner assigned to them. + * + * @since 5.12.0 + */ + protected function mustHaveOwner(): bool + { + return true; + } + private function applyNestedElementParams(string $fieldIdColumn, string $primaryOwnerIdColumn): void { $this->normalizeNestedElementParams(); - if ($this->fieldId === false || $this->primaryOwnerId === false || $this->ownerId === false) { + $mustHaveField = $this->mustHaveField(); + $mustHaveOwner = $this->mustHaveOwner(); + + if ( + ($mustHaveField && $this->fieldId === false) || + ($mustHaveOwner && ($this->primaryOwnerId === false || $this->ownerId === false)) || + $this->fieldId === [] || + $this->primaryOwnerId === [] || + $this->ownerId === [] + ) { throw new QueryAbortedException(); } - if (!empty($this->fieldId) || !empty($this->ownerId) || !empty($this->primaryOwnerId)) { + if (isset($this->fieldId) || isset($this->ownerId) || isset($this->primaryOwnerId)) { // Join in the elements_owners table + $joinType = $mustHaveField || $this->fieldId || $this->ownerId || $this->primaryOwnerId + ? 'INNER JOIN' + : 'LEFT JOIN'; + $ownersCondition = [ 'and', '[[elements_owners.elementId]] = [[elements.id]]', @@ -218,15 +251,16 @@ private function applyNestedElementParams(string $fieldIdColumn, string $primary 'elements_owners.ownerId', 'elements_owners.sortOrder', ]) - ->innerJoin(['elements_owners' => Table::ELEMENTS_OWNERS], $ownersCondition); - $this->subQuery->innerJoin(['elements_owners' => Table::ELEMENTS_OWNERS], $ownersCondition); + ->join($joinType, ['elements_owners' => Table::ELEMENTS_OWNERS], $ownersCondition); + + $this->subQuery->join($joinType, ['elements_owners' => Table::ELEMENTS_OWNERS], $ownersCondition); - if ($this->fieldId) { - $this->subQuery->andWhere([$fieldIdColumn => $this->fieldId]); + if (isset($this->fieldId)) { + $this->subQuery->andWhere([$fieldIdColumn => $this->fieldId ?: null]); } - if ($this->primaryOwnerId) { - $this->subQuery->andWhere([$primaryOwnerIdColumn => $this->primaryOwnerId]); + if (isset($this->primaryOwnerId)) { + $this->subQuery->andWhere([$primaryOwnerIdColumn => $this->primaryOwnerId ?: null]); } // Ignore revision/draft blocks by default @@ -234,7 +268,8 @@ private function applyNestedElementParams(string $fieldIdColumn, string $primary $allowOwnerRevisions = $this->allowOwnerRevisions ?? ($this->id || $this->primaryOwnerId || $this->ownerId); if (!$allowOwnerDrafts || !$allowOwnerRevisions) { - $this->subQuery->innerJoin( + $this->subQuery->join( + $joinType, ['owners' => Table::ELEMENTS], $this->ownerId ? '[[owners.id]] = [[elements_owners.ownerId]]' : "[[owners.id]] = [[$primaryOwnerIdColumn]]" ); @@ -246,6 +281,10 @@ private function applyNestedElementParams(string $fieldIdColumn, string $primary if (!$allowOwnerRevisions) { $this->subQuery->andWhere(['owners.revisionId' => null]); } + + if ($this->ownerId === false) { + $this->subQuery->andWhere(['owners.id' => null]); + } } $this->defaultOrderBy = ['elements_owners.sortOrder' => SORT_ASC]; @@ -263,7 +302,7 @@ private function normalizeNestedElementParams(): void } /** - * Normalizes the fieldId param to an array of IDs or null + * Normalizes the fieldId param to an array of IDs, false, or null */ private function normalizeFieldId(): void { @@ -285,22 +324,29 @@ private function normalizeFieldId(): void } /** - * Normalizes the primaryOwnerId param to an array of IDs or null + * Normalizes the primaryOwnerId param to an array of IDs, false, or null * * @param mixed $value * @return int[]|null|false */ private function normalizeOwnerId(mixed $value): array|null|false { + if ($value === false) { + return false; + } + if (empty($value)) { - return null; + return is_array($value) ? [] : null; } + if (is_numeric($value)) { return [$value]; } + if (!is_array($value) || !ArrayHelper::isNumeric($value)) { return false; } + return $value; } diff --git a/src/helpers/ElementHelper.php b/src/helpers/ElementHelper.php index ee7091c1ac8..09435b219e4 100644 --- a/src/helpers/ElementHelper.php +++ b/src/helpers/ElementHelper.php @@ -108,17 +108,18 @@ public static function generateSlug(string $str, ?bool $ascii = null, ?string $l $slug = StringHelper::toAscii($slug, $language); } - return static::normalizeSlug($slug); + return static::normalizeSlug($slug, $language); } /** * Normalizes a slug. * * @param string $slug + * @param string|null $language The slug’s langauge * @return string * @since 3.5.0 */ - public static function normalizeSlug(string $slug): string + public static function normalizeSlug(string $slug, ?string $language = null): string { // Special case for the homepage if ($slug === Element::HOMEPAGE_URI) { @@ -134,7 +135,7 @@ public static function normalizeSlug(string $slug): string // Make it lowercase $generalConfig = Craft::$app->getConfig()->getGeneral(); if (!$generalConfig->allowUppercaseInSlug) { - $slug = mb_strtolower($slug); + $slug = StringHelper::toLowerCase($slug, $language); } // Get the "words". Split on anything that is not alphanumeric or allowed punctuation diff --git a/src/helpers/StringHelper.php b/src/helpers/StringHelper.php index 8fb013461ab..11be477b4ca 100644 --- a/src/helpers/StringHelper.php +++ b/src/helpers/StringHelper.php @@ -9,12 +9,14 @@ use BackedEnum; use Craft; +use craft\i18n\Locale; use HTMLPurifier_Config; use Illuminate\Support\Str; use IteratorAggregate; use LitEmoji\LitEmoji; use Normalizer; use Throwable; +use Transliterator; use voku\helper\ASCII; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -2313,11 +2315,12 @@ public static function toKebabCase(string $str, string $glue = '-', bool $lower * Converts all characters in the string to lowercase. An alias for PHP's mb_strtolower(). * * @param string $str The string to convert to lowercase. + * @param string|null $language The string’s langauge * @return string The lowercase string. */ - public static function toLowerCase(string $str): string + public static function toLowerCase(string $str, ?string $language = null): string { - return Str::lower($str); + return self::modifyCase($str, $language, 'Lower') ?? Str::lower($str); } /** @@ -2414,11 +2417,12 @@ public static function toTabs(string $str, int $tabLength = 4): string * Converts the first character of each word in the string to uppercase. * * @param string $str The string to convert case. + * @param string|null $language The string’s langauge * @return string The title-cased string. */ - public static function toTitleCase(string $str): string + public static function toTitleCase(string $str, ?string $language = null): string { - return Str::title($str); + return self::modifyCase($str, $language, 'Title') ?? Str::title($str); } /** @@ -2440,11 +2444,12 @@ public static function toTransliterate(string $str, bool $strict = false): strin * Converts all characters in the string to uppercase. An alias for PHP's mb_strtoupper(). * * @param string $str The string to convert to uppercase. + * @param string|null $language The string’s langauge * @return string The uppercase string. */ - public static function toUpperCase(string $str): string + public static function toUpperCase(string $str, ?string $language = null): string { - return Str::upper($str); + return self::modifyCase($str, $language, 'Upper') ?? Str::upper($str); } /** @@ -2803,4 +2808,19 @@ public static function invisibleCharsRegex(): string return sprintf('/%s/iu', implode('|', $invisibleCharCodes)); } + + private static function modifyCase(string $str, ?string $language, string $case): ?string + { + $language ??= Craft::$app->language; + $transliterator = Transliterator::create(sprintf('%s-%s', Locale::languageId($language), $case)); + + if (!$transliterator) { + return null; + } + + // Normalize NFD chars to NFC + $str = Normalizer::normalize($str, Normalizer::FORM_C); + + return $transliterator->transliterate($str); + } } diff --git a/src/i18n/Locale.php b/src/i18n/Locale.php index db9efd2b4fa..0af9cbdefa5 100644 --- a/src/i18n/Locale.php +++ b/src/i18n/Locale.php @@ -25,6 +25,19 @@ */ class Locale extends BaseObject { + /** + * Returns a locale’s language ID. + * + * @return string + * @since 5.12.0 + */ + public static function languageId(string $locale): string + { + $pos = strpos($locale, '-'); + $lang = $pos !== false ? substr($locale, 0, $pos) : $locale; + return strtolower($lang); + } + /** * @var int Positive prefix. */ @@ -296,11 +309,7 @@ public function __toString(): string #[AllowedInSandbox] public function getLanguageID(): string { - if (($pos = strpos($this->id, '-')) !== false) { - return substr($this->id, 0, $pos); - } - - return $this->id; + return static::languageId($this->id); } /** diff --git a/src/web/twig/Extension.php b/src/web/twig/Extension.php index bb32e26ee11..597dba81b80 100644 --- a/src/web/twig/Extension.php +++ b/src/web/twig/Extension.php @@ -230,6 +230,7 @@ public function getFilters(): array new TwigFilter('base64_encode', 'base64_encode'), new TwigFilter('boolean', 'boolval'), new TwigFilter('camel', [$this, 'camelFilter']), + new TwigFilter('capitalize', [$this, 'capitalizeFilter'], ['needs_charset' => true]), new TwigFilter('column', [$this, 'columnFilter'], ['needs_is_sandboxed' => true]), new TwigFilter('contains', [$this, 'containsFilter'], ['needs_is_sandboxed' => true]), new TwigFilter('currency', [$this, 'currencyFilter']), @@ -261,6 +262,7 @@ public function getFilters(): array new TwigFilter('length', [$this, 'lengthFilter'], ['needs_environment' => true]), new TwigFilter('lcfirst', [$this, 'lcfirstFilter']), new TwigFilter('literal', [$this, 'literalFilter']), + new TwigFilter('lower', [$this, 'lowerFilter']), new TwigFilter('map', [$this, 'mapFilter'], ['needs_environment' => true, 'needs_is_sandboxed' => true]), new TwigFilter('markdown', [$this, 'markdownFilter'], ['is_safe' => ['html']]), new TwigFilter('md', [$this, 'markdownFilter'], ['is_safe' => ['html']]), @@ -289,6 +291,7 @@ public function getFilters(): array new TwigFilter('string', 'strval'), new TwigFilter('time', [$this, 'timeFilter'], ['needs_environment' => true]), new TwigFilter('timestamp', [$this, 'timestampFilter']), + new TwigFilter('title', [$this, 'titleFilter']), new TwigFilter('translate', [$this, 'translateFilter']), new TwigFilter('truncate', [$this, 'truncateFilter']), new TwigFilter('t', [$this, 'translateFilter']), @@ -296,6 +299,7 @@ public function getFilters(): array new TwigFilter('ucwords', [$this, 'ucwordsFilter'], ['needs_environment' => true]), new TwigFilter('unique', 'array_unique'), new TwigFilter('unshift', [$this, 'unshiftFilter']), + new TwigFilter('upper', [$this, 'upperFilter']), new TwigFilter('values', 'array_values'), new TwigFilter('where', [$this, 'whereFilter'], ['needs_is_sandboxed' => true]), new TwigFilter('widont', [$this, 'widontFilter'], ['is_safe' => ['html']]), @@ -499,6 +503,18 @@ public function camelFilter(mixed $string): string return StringHelper::toCamelCase((string)$string); } + /** + * Capitalizes a string. + * + * @param string $charset + * @param string|null $string + * @param string|null $language + * @since 5.12.0 + */ + public function capitalizeFilter(string $charset, ?string $string, ?string $language = null): string + { + return StringHelper::toUpperCase(mb_substr($string ?? '', 0, 1, $charset), $language) . StringHelper::toLowerCase(mb_substr($string ?? '', 1, null, $charset), $language); + } /** * Throws a RuntimeError if the given name/key is a string containing a "." character and the environment is @@ -764,6 +780,19 @@ public function timestampFilter(mixed $value, ?string $format = null, bool $with } } + /** + * Title-cases a string + * + * @param string|null $string + * @param string|null $language + * @return string + * @since 5.12.0 + */ + public function titleFilter(?string $string, ?string $language = null): string + { + return StringHelper::toTitleCase($string ?? '', $language); + } + /** * This method will JSON encode a variable. We're overriding Twig's default implementation to set some stricter * encoding options on text/html/xml requests. @@ -982,6 +1011,19 @@ public function unshiftFilter(array $array): array return $array; } + /** + * Upper-cases a string + * + * @param string|null $string + * @param string|null $language + * @return string + * @since 5.12.0 + */ + public function upperFilter(?string $string, ?string $language = null): string + { + return StringHelper::toUpperCase($string ?? '', $language); + } + /** * Removes a class (or classes) from the given HTML tag. * @@ -1467,6 +1509,19 @@ public function literalFilter(mixed $value): string return Db::escapeParam((string)$value); } + /** + * Lower-cases a string + * + * @param string|null $string + * @param string|null $language + * @return string + * @since 5.12.0 + */ + public function lowerFilter(?string $string, ?string $language = null): string + { + return StringHelper::toLowerCase($string ?? '', $language); + } + /** * Parses text through Markdown. * diff --git a/tests/unit/helpers/ElementHelperTest.php b/tests/unit/helpers/ElementHelperTest.php index a99afaedf86..66214d9fbbd 100644 --- a/tests/unit/helpers/ElementHelperTest.php +++ b/tests/unit/helpers/ElementHelperTest.php @@ -69,6 +69,21 @@ public function testLowerRemoveFromCreateSlug(): void self::assertSame('word' . $general->slugWordSeparator . 'word', ElementHelper::normalizeSlug('word WORD')); } + /** + * @dataProvider normalizeSlugRespectsLanguageDataProvider + * @param string $expected + * @param string $slug + * @param string|null $language + */ + public function testNormalizeSlugRespectsLanguage(string $expected, string $slug, ?string $language): void + { + // The slug can only get lowercased if uppercase characters aren't allowed + $general = Craft::$app->getConfig()->getGeneral(); + $general->allowUppercaseInSlug = false; + + self::assertSame($expected, ElementHelper::normalizeSlug($slug, $language)); + } + /** * @dataProvider isTempSlugDataProvider * @param bool $expected @@ -219,6 +234,19 @@ public static function normalizeSlugDataProvider(): array ]; } + /** + * @return array + */ + public static function normalizeSlugRespectsLanguageDataProvider(): array + { + return [ + // Turkish lowercases a dotless "I" to a dotless "ı", not "i" + // (https://github.com/craftcms/cms/discussions/19555) + ['ıstanbul', 'Istanbul', 'tr'], + ['istanbul', 'Istanbul', null], + ]; + } + /** * @return array */ diff --git a/tests/unit/helpers/StringHelperTest.php b/tests/unit/helpers/StringHelperTest.php index 0672461a8f3..3888387825f 100644 --- a/tests/unit/helpers/StringHelperTest.php +++ b/tests/unit/helpers/StringHelperTest.php @@ -1354,10 +1354,11 @@ public function testToKebabCase(string $expected, string $string): void * @dataProvider toLowerCaseDataProvider * @param string $expected * @param string $string + * @param string|null $language */ - public function testToLowerCase(string $expected, string $string): void + public function testToLowerCase(string $expected, string $string, ?string $language = null): void { - $actual = StringHelper::toLowerCase($string); + $actual = StringHelper::toLowerCase($string, $language); self::assertSame($expected, $actual); } @@ -1423,10 +1424,11 @@ public function testToTabs(string $expected, string $string, int $tabLength = 4) * @dataProvider toTitleCaseDataProvider * @param string $expected * @param string $string + * @param string|null $language */ - public function testToTitleCase(string $expected, string $string): void + public function testToTitleCase(string $expected, string $string, ?string $language = null): void { - $actual = StringHelper::toTitleCase($string); + $actual = StringHelper::toTitleCase($string, $language); self::assertSame($expected, $actual); } @@ -1445,10 +1447,11 @@ public function testToTransliterate(string $expected, string $string): void * @dataProvider toUppercaseDataProvider * @param string $expected * @param string $string + * @param string|null $language */ - public function testToUppercase(string $expected, string $string): void + public function testToUppercase(string $expected, string $string, ?string $language = null): void { - $actual = StringHelper::toUpperCase($string); + $actual = StringHelper::toUpperCase($string, $language); self::assertSame($expected, $actual); } @@ -1657,6 +1660,10 @@ public static function toTitleCaseDataProvider(): array ['😘', '😘'], ['22 Alphan Numeric', '22 AlphaN Numeric'], ['!@#$% ^&*()', '!@#$% ^&*()'], + // Dutch "ij" is treated as a single letter, so title-casing it capitalizes both characters + // (https://github.com/craftcms/cms/discussions/19555) + ['IJsselmeer', 'ijsselmeer', 'nl'], + ['Ijsselmeer', 'ijsselmeer'], ]; } @@ -1676,6 +1683,10 @@ public static function toLowerCaseDataProvider(): array ['😘', '😘'], ['22 alphan numeric', '22 AlphaN Numeric'], ['!@#$% ^&*()', '!@#$% ^&*()'], + // Turkish lowercases a dotless "I" to a dotless "ı", not "i" + // (https://github.com/craftcms/cms/discussions/19555) + ['ıstanbul', 'Istanbul', 'tr'], + ['istanbul', 'Istanbul'], ]; } @@ -2249,6 +2260,14 @@ public static function toUppercaseDataProvider(): array ['😘', '😘'], ['22 ALPHAN NUMERIC', '22 AlphaN Numeric'], ['!@#$% ^&*()', '!@#$% ^&*()'], + // Turkish uppercases a dotted "i" to a dotted "İ", not "I" + // (https://github.com/craftcms/cms/discussions/19555) + ['İSTANBUL', 'istanbul', 'tr'], + ['ISTANBUL', 'istanbul'], + // Greek strips accents when uppercasing + // (https://github.com/craftcms/cms/discussions/19555) + ['ΑΝΘΡΩΠΟΣ', 'άνθρωπος', 'el'], + ['ΆΝΘΡΩΠΟΣ', 'άνθρωπος'], ]; } diff --git a/tests/unit/i18n/LocaleTest.php b/tests/unit/i18n/LocaleTest.php new file mode 100644 index 00000000000..da7e9ce684a --- /dev/null +++ b/tests/unit/i18n/LocaleTest.php @@ -0,0 +1,47 @@ + + * @since 5.12.0 + */ +class LocaleTest extends TestCase +{ + /** + * @param string $expected + * @param string $locale + * @dataProvider languageIdDataProvider + */ + public function testLanguageId(string $expected, string $locale): void + { + self::assertSame($expected, Locale::languageId($locale)); + } + + /** + * @return array[] + */ + public static function languageIdDataProvider(): array + { + return [ + ['en', 'en'], + ['en', 'EN'], + ['en', 'en-US'], + ['en', 'EN-US'], + ['zh', 'zh-Hans-CN'], + ['de', 'de-DE'], + ['', ''], + ['pt', 'pt-BR'], + ]; + } +} diff --git a/tests/unit/web/twig/ExtensionTest.php b/tests/unit/web/twig/ExtensionTest.php index 842c1597100..26769290f2c 100644 --- a/tests/unit/web/twig/ExtensionTest.php +++ b/tests/unit/web/twig/ExtensionTest.php @@ -372,6 +372,75 @@ public function testLcfirstFilter(): void ); } + /** + * `title`, `capitalize`, `upper`, and `lower` all accept an optional `language` argument + * that should be respected for language-specific casing rules + * (https://github.com/craftcms/cms/discussions/19555). + */ + public function testTitleFilter(): void + { + $this->testRenderResult( + 'Ijsselmeer', + '{{ "ijsselmeer"|title }}' + ); + + // Dutch title-cases the "ij" digraph as a single letter, capitalizing both characters + $this->testRenderResult( + 'IJsselmeer', + '{{ "ijsselmeer"|title("nl") }}' + ); + } + + /** + * @see testTitleFilter() + */ + public function testCapitalizeFilter(): void + { + $this->testRenderResult( + 'Istanbul', + '{{ "istanbul"|capitalize }}' + ); + + // Turkish uppercases a dotted "i" to a dotted "İ", not "I" + $this->testRenderResult( + 'İstanbul', + '{{ "istanbul"|capitalize("tr") }}' + ); + } + + /** + * @see testTitleFilter() + */ + public function testUpperFilter(): void + { + $this->testRenderResult( + 'ISTANBUL', + '{{ "istanbul"|upper }}' + ); + + $this->testRenderResult( + 'İSTANBUL', + '{{ "istanbul"|upper("tr") }}' + ); + } + + /** + * @see testTitleFilter() + */ + public function testLowerFilter(): void + { + $this->testRenderResult( + 'istanbul', + '{{ "Istanbul"|lower }}' + ); + + // Turkish lowercases a dotless "I" to a dotless "ı", not "i" + $this->testRenderResult( + 'ıstanbul', + '{{ "Istanbul"|lower("tr") }}' + ); + } + /** * */