Files
FreshRSS/app/Models/Category.php
PeterVavercak ee7eb67f3c Implement sort order per feed (#8234)
* added local feed sorting

Addresses https://github.com/FreshRSS/FreshRSS/issues/4761

- Added number of sorted feeds and associative array for feed sorting option in Context.
- Number of sorted feeds and local sorting option by its index saved into Minz Request Parameters.
- Number of sorted feeds and local sorting options deleted when choosing another Option Of Global Sorting.
- Added option of allowing sorting by feed in configuration.
- Added variable for allowing local sorting in userConf.
- Added function to get feeds by current get in context.
- Added menu button for all individual feed sorting.
- New database options for individual feed sorting in EntryDAO.
- Considered choosing new entries based on chosen load limit.
- Local sorting parameter saved into continuation value in Index Controller.

How to test the feature manually:

1. At the bottom of Reading Configuration menu turn on individual sorting option menu 
2. Choose Sorting by feed option
3. Choose feed at next sorting menu and choose sorting option for that feed

* added feed sorting option

* added sort feeds display

* added template for sort feed name

* added title to feed sorting button

* added comments

* added local sorting option

* Added Docs

* css reset

* added getter and seter for local sort

* added getter and seter for local sort

* allowed sorting per feed

* allowed sorting per feed

* added sorting option for category

* deleted changes from NetryDAO

* add setting up sorting for category

* docs reset

* i18 reset

* updated i18 for category

* added i18 for categories

* added i18 for category

* added setting sorting for feeds and category

* removing userConf.allow-local-sort

* removing userConf.allow-local-sort

* removing white space

* added credits

* removed feeds_by_get

* removed whitespace

* changed escaping for values

* added escaping to user set values

* added in_array

* added secondary sort and order

* added secondary sort and order

* fixed readme

* removed whitespace change

* reseted i18n

* added translations

* added feed setting translations

* fixed i18n

* fixed i18n

* changes in sort order per feed

* changes in sort order per feed

* added secondary sort order

* primary sort

* changed to preferred sort order

* i18n

* Revert wrong whitespace changes

* Re-order new options

* added blank option

* fixed escaping

* fixed default sort in feed

* fixed default sort recovery

* siplyfied option

* added rand option

* Revert unrelated change

* Minor plaintext

* Whitespace and formatting fixes

* Avoid unneeded SQL requests and processing

* Improve syntax

* Improve logic

* Reuse existing translations as much as possible

* i18n

* Remove some options that make little sense

* Separators

* Fix old transation key

* Add help messages

* Progress on secondary sort

* raw name

* Pass parameters. Add TODO

* Progress

* Minor ordering

* Fix parenthesis

---------

Co-authored-by: root <root@LAPTOP-C8TCHHPN.localdomain>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
2026-02-01 13:12:47 +01:00

324 lines
8.9 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)
}
/**
* @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();
/** @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
$dryRunFeed->_category($this);
$ok &= ($feedDAO->addFeedObject($dryRunFeed) !== false);
$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();
$catDAO->updateLastUpdate($this->id(), !$ok);
return (bool)$ok;
}
private function sortFeeds(): void {
if ($this->feeds === null) {
return;
}
uasort($this->feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b) => strnatcasecmp($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;
}
}