Files
FreshRSS/app/Models/Category.php
Jam Balaya f201061345 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>
2026-07-07 15:42:12 +02:00

347 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
class FreshRSS_Category extends Minz_Model {
use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;
/**
* Normal
*/
public const KIND_NORMAL = 0;
/**
* Category tracking a third-party Dynamic OPML
*/
public const KIND_DYNAMIC_OPML = 2;
private int $id = 0;
private int $kind = 0;
private string $name;
private int $nbFeeds = -1;
/** Number of unread articles in feeds with visibility FreshRSS_Feed::PRIORITY_FEED */
private int $nbNotRead = -1;
/** @var array<int,FreshRSS_Feed>|null where the key is the feed ID */
private ?array $feeds = null;
private bool|int $hasFeedsWithError = false;
private int $lastUpdate = 0;
private bool $error = false;
/**
* @param array<FreshRSS_Feed>|null $feeds
*/
public function __construct(string $name = '', int $id = 0, ?array $feeds = null) {
$this->_id($id);
$this->_name($name);
if ($feeds !== null) {
$this->_feeds($feeds);
$this->nbFeeds = 0;
$this->nbNotRead = 0;
foreach ($feeds as $feed) {
$feed->_category($this);
$this->nbFeeds++;
if ($feed->priority() > FreshRSS_Feed::PRIORITY_HIDDEN) {
$this->nbNotRead += $feed->nbNotRead();
$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
}
}
}
}
public function id(): int {
return $this->id;
}
public function kind(): int {
return $this->kind;
}
/** @return string HTML-encoded name of the category */
public function name(): string {
return $this->name;
}
public function lastUpdate(): int {
return $this->lastUpdate;
}
/**
* @param int|numeric-string $value
* 32-bit systems provide a string and will fail in year 2038
*/
public function _lastUpdate(int|string $value): void {
$this->lastUpdate = (int)$value;
}
public function inError(): bool {
return $this->error;
}
public function _error(bool|int $value): void {
$this->error = (bool)$value;
}
public function isDefault(): bool {
return $this->id == FreshRSS_CategoryDAO::DEFAULTCATEGORYID;
}
public function nbFeeds(): int {
if ($this->nbFeeds < 0) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$this->nbFeeds = $catDAO->countFeed($this->id());
}
return $this->nbFeeds;
}
/**
* @throws Minz_ConfigurationNamespaceException
* @throws Minz_PDOConnectionException
*/
public function nbNotRead(int $minPriority = FreshRSS_Feed::PRIORITY_FEED): int {
if ($this->nbNotRead > 0 && $minPriority === FreshRSS_Feed::PRIORITY_FEED) {
return $this->nbNotRead;
}
if ($this->feeds === null) {
$catDAO = FreshRSS_Factory::createCategoryDao();
$nb = $catDAO->countNotRead($this->id(), $minPriority);
if ($minPriority === FreshRSS_Feed::PRIORITY_FEED) {
$this->nbNotRead = $nb;
}
return $nb;
}
$nb = 0;
foreach ($this->feeds as $feed) {
if ($feed->priority() >= $minPriority) {
$nb += $feed->nbNotRead();
}
}
return $nb;
}
/** @return array<int,mixed> */
public function curlOptions(): array {
return []; // TODO (e.g., credentials for Dynamic OPML)
}
public function showUnreadCount(): bool {
return $this->attributeBoolean('show_unread_count') ??
(FreshRSS_Context::userConf()->show_unread_count === 'all');
}
/**
* @return array<int,FreshRSS_Feed> where the key is the feed ID
* @throws Minz_ConfigurationNamespaceException
* @throws Minz_PDOConnectionException
*/
public function feeds(): array {
if ($this->feeds === null) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$this->feeds = $feedDAO->listByCategory($this->id());
$this->nbFeeds = 0;
$this->nbNotRead = 0;
foreach ($this->feeds as $feed) {
$this->nbFeeds++;
if ($feed->priority() > FreshRSS_Feed::PRIORITY_HIDDEN) {
$this->nbNotRead += $feed->nbNotRead();
$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
}
}
$this->sortFeeds();
}
return $this->feeds ?? [];
}
public function hasFeedsWithError(): bool {
return (bool)($this->hasFeedsWithError);
}
public function _id(int $id): void {
$this->id = $id;
if ($id === FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
$this->name = _t('gen.short.default_category');
}
}
public function _kind(int $kind): void {
$this->kind = $kind;
}
public function _name(string $value): void {
if ($this->id !== FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
$this->name = mb_strcut(trim($value), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
}
}
/** @param array<FreshRSS_Feed>|FreshRSS_Feed $values */
public function _feeds(array|FreshRSS_Feed $values): void {
if (!is_array($values)) {
$values = [$values];
}
$this->feeds = array_values($values);
$this->sortFeeds();
}
public function defaultSort(): ?string {
return $this->attributeString('defaultSort');
}
public function defaultOrder(): ?string {
return $this->attributeString('defaultOrder');
}
/**
* To manually add feeds to this category (not committing to database).
*/
public function addFeed(FreshRSS_Feed $feed): void {
if ($this->feeds === null) {
$this->feeds = [];
}
$feed->_category($this);
if ($feed->id() === 0) {
// Feeds created on a dry run do not have an ID
$this->feeds[] = $feed;
} else {
$this->feeds[$feed->id()] = $feed;
}
$this->sortFeeds();
}
/**
* @throws FreshRSS_Context_Exception
*/
public function cacheFilename(string $url): string {
$simplePie = new FreshRSS_SimplePieCustom($this->attributes(), $this->curlOptions());
$filename = $simplePie->get_cache_filename($url);
return CACHE_PATH . '/' . $filename . '.opml.xml';
}
public function refreshDynamicOpml(): bool {
$url = $this->attributeString('opml_url');
if ($url == null) {
return false;
}
$ok = true;
$cachePath = $this->cacheFilename($url);
$opml = FreshRSS_http_Util::httpGet($url, $cachePath, 'opml', $this->attributes(), $this->curlOptions())['body'];
if ($opml == '') {
Minz_Log::warning('Error getting dynamic OPML for category ' . $this->id() . '! ' .
\SimplePie\Misc::url_remove_credentials($url));
$ok = false;
} else {
$dryRunCategory = new FreshRSS_Category();
$importService = new FreshRSS_Import_Service();
$importService->importOpml($opml, $dryRunCategory, true);
if ($importService->lastStatus()) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$limits = FreshRSS_Context::systemConf()->limits;
$maxFeeds = (int)($limits['max_feeds'] ?? 0);
$nbFeeds = $maxFeeds > 0 ? $feedDAO->count() : 0;
/** @var array<string,FreshRSS_Feed> */
$dryRunFeeds = [];
foreach ($dryRunCategory->feeds() as $dryRunFeed) {
$dryRunFeeds[$dryRunFeed->url()] = $dryRunFeed;
}
/** @var array<string,FreshRSS_Feed> */
$existingFeeds = [];
foreach ($this->feeds() as $existingFeed) {
$existingFeeds[$existingFeed->url()] = $existingFeed;
if (empty($dryRunFeeds[$existingFeed->url()])) {
// The feed does not exist in the new dynamic OPML, so mute (disable) that feed
$existingFeed->_mute(true);
$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
'ttl' => $existingFeed->ttl(true),
]) !== false);
}
}
foreach ($dryRunCategory->feeds() as $dryRunFeed) {
if (empty($existingFeeds[$dryRunFeed->url()])) {
// The feed does not exist in the current category, so add that feed
if ($maxFeeds > 0 && $nbFeeds >= $maxFeeds) {
// Respect the per-user maximum number of feeds
Minz_Log::warning(_t('feedback.sub.feed.over_max', $maxFeeds) .
' (dynamic OPML category ' . $this->id() . ')');
$ok = false;
break;
}
$dryRunFeed->_category($this);
if ($feedDAO->addFeedObject($dryRunFeed) === false) {
$ok = false;
} else {
$nbFeeds++;
}
$existingFeeds[$dryRunFeed->url()] = $dryRunFeed;
} else {
$existingFeed = $existingFeeds[$dryRunFeed->url()];
if ($existingFeed->mute()) {
// The feed already exists in the current category but was muted (disabled), so unmute (enable) again
$existingFeed->_mute(false);
$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
'ttl' => $existingFeed->ttl(true),
]) !== false);
}
}
}
} else {
$ok = false;
Minz_Log::warning('Error loading dynamic OPML for category ' . $this->id() . '! ' .
\SimplePie\Misc::url_remove_credentials($url));
}
}
$catDAO = FreshRSS_Factory::createCategoryDao();
if ($ok) {
$catDAO->updateLastUpdate($this->id());
} else {
$catDAO->updateLastError($this->id());
}
return (bool)$ok;
}
private function sortFeeds(): void {
if ($this->feeds === null) {
return;
}
uasort($this->feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b): int => FreshRSS_Context::localeCompare($a->name(), $b->name()));
}
/**
* Access cached feed
* @param array<FreshRSS_Category> $categories
*/
public static function findFeed(array $categories, int $feed_id): ?FreshRSS_Feed {
foreach ($categories as $category) {
foreach ($category->feeds() as $feed) {
if ($feed->id() === $feed_id) {
$feed->_category($category); // Should already be done; just to be safe
return $feed;
}
}
}
return null;
}
/**
* Access cached feeds
* @param array<FreshRSS_Category> $categories
* @return array<int,FreshRSS_Feed> where the key is the feed ID
*/
public static function findFeeds(array $categories): array {
$result = [];
foreach ($categories as $category) {
foreach ($category->feeds() as $feed) {
$result[$feed->id()] = $feed;
}
}
return $result;
}
/**
* @param array<FreshRSS_Category> $categories
*/
public static function countUnread(array $categories, int $minPriority = FreshRSS_Feed::PRIORITY_FEED): int {
$n = 0;
foreach ($categories as $category) {
$n += $category->nbNotRead($minPriority);
}
return $n;
}
}