Procházet zdrojové kódy

Feeds tree: sort feed names with locale-aware collation (#8985)

## Problem

The feed **tree** (left sidebar) and the **feed-title view** (main body) order feeds differently for names with accents or symbols. As reported in #8300, a Spanish feed starting with `Ú` appears **last** in the tree, after every ASCII-named feed.

The tree is sorted in PHP by `FreshRSS_Category::sortFeeds()` with `strnatcasecmp()`, which compares raw UTF-8 **bytes** — so any name starting with a non-ASCII character (`0xC3…` for `Á`, `Ñ`, `Ú`, …) sorts after `A–Z`. The main view is sorted by the database according to its collation, which orders accented letters near their base letter — hence the mismatch (as @Alkarex noted in the issue).

Closes #8300.

## Change

`sortFeeds()` now compares with a `Collator` (`ext-intl`, already a hard requirement) for the user's language, falling back to the previous `strnatcasecmp()` if a collator cannot be created:

```php
$collator = \Collator::create(FreshRSS_Context::hasUserConf() ? FreshRSS_Context::userConf()->language : '');
```

Sorting a Spanish set, before / after:

```
old: banana, Manzana, nube, Zorro, Ábaco, Ñandú, Úlcera, árbol
new: Ábaco, árbol, banana, Manzana, nube, Ñandú, Úlcera, Zorro
```

This makes the tree order locale-aware and much closer to the main view. Exact parity with the main view still depends on the database collation (which is admin/DB-specific and cannot be reproduced generically in PHP), but the "accented names sort last" problem is gone.

* Feeds tree: sort feed names with locale-aware collation

The feed tree sorted names byte-wise with strnatcasecmp(), which places
names starting with a non-ASCII character (e.g. an accented capital such
as "U-acute") after every ASCII name. Use a Collator for the user's
language so accented and non-ASCII names sort near their base letter,
closer to the database collation used on the main feed-title view.

For https://github.com/FreshRSS/FreshRSS/issues/8300

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Guard Collator with class_exists() so intl stays optional

The intl extension is only a recommended (not required) dependency, but
sortFeeds() called \Collator::create() unconditionally when a user language
was set. On installs without intl the \Collator class does not exist, so the
call raised a fatal "Class \"Collator\" not found" — effectively promoting
intl from recommended to required.

Guard the call with class_exists('Collator'): when intl is absent, fall back
to the previous byte-wise strnatcasecmp() sort. Locale-aware ordering stays a
progressive enhancement and the listed requirements are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Shorten the sortFeeds() collation comment per review

Trim the rationale comment to a concise four lines as suggested in review,
keeping the locale-aware/fallback and class_exists() reasoning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Stylistic preference

* Factorise localeCompare

* Minor code preference

* Preserve natural locale sort behaviour

* Avoid repeated locale lookup in comparator

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Jam Balaya před 9 hodinami
rodič
revize
f201061345

+ 1 - 1
app/Models/Category.php

@@ -299,7 +299,7 @@ class FreshRSS_Category extends Minz_Model {
 		if ($this->feeds === null) {
 			return;
 		}
-		uasort($this->feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b) => strnatcasecmp($a->name(), $b->name()));
+		uasort($this->feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b): int => FreshRSS_Context::localeCompare($a->name(), $b->name()));
 	}
 
 	/**

+ 1 - 1
app/Models/CategoryDAO.php

@@ -310,7 +310,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 			$aPosition = $a->attributeInt('position');
 			$bPosition = $b->attributeInt('position');
 			if ($aPosition === $bPosition) {
-				return strnatcasecmp($a->name(), $b->name());
+				return FreshRSS_Context::localeCompare($a->name(), $b->name());
 			} elseif (null === $aPosition) {
 				return 1;
 			} elseif (null === $bPosition) {

+ 25 - 0
app/Models/Context.php

@@ -667,4 +667,29 @@ final class FreshRSS_Context {
 		$timezone = ini_get('date.timezone');
 		return $timezone != false ? $timezone : 'UTC';
 	}
+
+	/**
+	 * Sort locale-aware with Collator if available
+	 */
+	public static function localeCompare(string $a, string $b): int {
+		static $collator = null;
+
+		if ($collator === null) {
+			$language = FreshRSS_Context::hasUserConf() ? FreshRSS_Context::userConf()->language : '';
+			if ($language === '' || !class_exists(\Collator::class)) {
+				$collator = false;
+			} else {
+				$collator = \Collator::create($language) ?? false;
+			}
+			if ($collator instanceof \Collator) {
+				$collator->setAttribute(\Collator::NUMERIC_COLLATION, \Collator::ON);
+			}
+		}
+
+		if (!($collator instanceof \Collator)) {
+			return strnatcasecmp($a, $b);
+		}
+		$result = $collator->compare($a, $b);
+		return $result === false ? strnatcasecmp($a, $b) : $result;
+	}
 }

+ 1 - 1
app/Models/FeedDAO.php

@@ -537,7 +537,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		/** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
 		 * 	pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
 		$feeds = self::daoToFeeds($res);
-		uasort($feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b) => strnatcasecmp($a->name(), $b->name()));
+		uasort($feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b) => FreshRSS_Context::localeCompare($a->name(), $b->name()));
 		return $feeds;
 	}
 

+ 1 - 1
app/Models/Share.php

@@ -55,7 +55,7 @@ class FreshRSS_Share {
 			self::register($share_options);
 		}
 
-		uasort(self::$list_sharing, static fn(FreshRSS_Share $a, FreshRSS_Share $b) => strcasecmp($a->name() ?? '', $b->name() ?? ''));
+		uasort(self::$list_sharing, static fn(FreshRSS_Share $a, FreshRSS_Share $b) => FreshRSS_Context::localeCompare($a->name() ?? '', $b->name() ?? ''));
 	}
 
 	/**