Files
FreshRSS/app/Controllers/subscriptionController.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

472 lines
18 KiB
PHP

<?php
declare(strict_types=1);
/**
* Controller to handle subscription actions.
*/
class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
/**
* This action is called before every other action in that class. It is
* the common boilerplate for every action. It is triggered by the
* underlying framework.
*/
#[\Override]
public function firstAction(): void {
if (!FreshRSS_Auth::hasAccess()) {
Minz_Error::error(403);
}
$catDAO = FreshRSS_Factory::createCategoryDao();
$catDAO->checkDefault();
$this->view->categories = $catDAO->listSortedCategories(prePopulateFeeds: false, details: true);
$signalError = false;
foreach ($this->view->categories as $cat) {
$feeds = $cat->feeds();
foreach ($feeds as $feed) {
if ($feed->inError()) {
$signalError = true;
}
}
if ($signalError) {
break;
}
}
$this->view->signalError = $signalError;
}
/**
* This action handles the main subscription page
*
* It displays categories and associated feeds.
*/
public function indexAction(): void {
FreshRSS_View::appendScript(Minz_Url::display('/scripts/category.js?' . @filemtime(PUBLIC_PATH . '/scripts/category.js')));
FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
FreshRSS_View::prependTitle(_t('sub.title') . ' · ');
$this->_csp([
'default-src' => "'self'",
'frame-ancestors' => FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'",
'img-src' => "'self' data: blob:",
]);
$this->view->onlyFeedsWithError = Minz_Request::paramBoolean('error');
$id = Minz_Request::paramInt('id');
$this->view->displaySlider = false;
if ($id !== 0) {
$type = Minz_Request::paramString('type');
$this->view->displaySlider = true;
switch ($type) {
case 'category':
$categoryDAO = FreshRSS_Factory::createCategoryDao();
$this->view->category = $categoryDAO->searchById($id);
break;
default:
$feedDAO = FreshRSS_Factory::createFeedDao();
$this->view->feed = $feedDAO->searchById($id) ?? FreshRSS_Feed::default();
break;
}
}
}
/**
* This action handles the feed configuration page.
*
* It displays the feed configuration page.
* If this action is reached through a POST request, it stores all new
* configuration values then sends a notification to the user.
*
* The options available on the page are:
* - name
* - description
* - website URL
* - feed URL
* - category id (default: default category id)
* - CSS path to article on website
* - favicon
* - display in main stream (default: 0)
* - HTTP authentication
* - number of article to retain (default: -2)
* - refresh frequency (default: 0)
* Default values are empty strings unless specified.
*/
public function feedAction(): void {
if (Minz_Request::paramBoolean('ajax')) {
$this->view->_layout(null);
} else {
FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
}
$id = Minz_Request::paramInt('id');
if ($id === 0) {
Minz_Error::error(400);
return;
}
$feedDAO = FreshRSS_Factory::createFeedDao();
$feed = $feedDAO->searchById($id);
if ($feed === null) {
Minz_Error::error(404);
return;
}
$this->view->feed = $feed;
FreshRSS_View::prependTitle($feed->name() . ' · ' . _t('sub.title.feed_management') . ' · ');
$this->_csp([
'default-src' => "'self'",
'frame-ancestors' => FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'",
'img-src' => "'self' data: blob:",
]);
if (Minz_Request::isPost()) {
$unicityCriteria = Minz_Request::paramString('unicityCriteria');
if (in_array($unicityCriteria, ['id', '', null], strict: true)) {
$unicityCriteria = null;
}
if ($unicityCriteria === null && $feed->attributeBoolean('hasBadGuids')) { // Legacy
$unicityCriteria = 'link';
}
$feed->_attribute('hasBadGuids', null); // Remove legacy
$feed->_attribute('unicityCriteria', $unicityCriteria);
$feed->_attribute('unicityCriteriaForced', Minz_Request::paramBoolean('unicityCriteriaForced') ? true : null);
$user = Minz_Request::paramString('http_user_feed' . $id);
$pass = Minz_Request::paramString('http_pass_feed' . $id);
$httpAuth = '';
if ($user !== '' && $pass !== '') { //TODO: Sanitize
$httpAuth = $user . ':' . $pass;
}
$feed->_ttl(Minz_Request::paramInt('ttl') ?: FreshRSS_Feed::TTL_DEFAULT);
$feed->_mute(Minz_Request::paramBoolean('mute'));
$feed->_attribute('read_upon_gone', Minz_Request::paramTernary('read_upon_gone'));
$feed->_attribute('mark_updated_article_unread', Minz_Request::paramTernary('mark_updated_article_unread'));
$feed->_attribute('read_upon_reception', Minz_Request::paramTernary('read_upon_reception'));
$feed->_attribute('clear_cache', Minz_Request::paramTernary('clear_cache'));
$keep_max_n_unread = Minz_Request::paramTernary('keep_max_n_unread') === true ? Minz_Request::paramInt('keep_max_n_unread') : null;
$feed->_attribute('keep_max_n_unread', $keep_max_n_unread >= 0 ? $keep_max_n_unread : null);
$read_when_same_title_in_feed = Minz_Request::paramString('read_when_same_title_in_feed');
if ($read_when_same_title_in_feed === '') {
$read_when_same_title_in_feed = null;
} else {
$read_when_same_title_in_feed = (int)$read_when_same_title_in_feed;
if ($read_when_same_title_in_feed <= 0) {
$read_when_same_title_in_feed = false;
}
}
$feed->_attribute('read_when_same_title_in_feed', $read_when_same_title_in_feed);
$cookie = Minz_Request::paramString('curl_params_cookie', plaintext: true);
$cookie_file = Minz_Request::paramBoolean('curl_params_cookiefile');
$max_redirs = Minz_Request::paramInt('curl_params_redirects');
$useragent = Minz_Request::paramString('curl_params_useragent', plaintext: true);
$proxy_address = Minz_Request::paramString('curl_params', plaintext: true);
$proxy_type = Minz_Request::paramIntNull('proxy_type');
$request_method = Minz_Request::paramString('curl_method', plaintext: true);
$request_fields = Minz_Request::paramString('curl_fields', plaintext: true);
$headers = Minz_Request::paramTextToArray('http_headers', plaintext: true);
$opts = [];
if ($proxy_type !== null) {
$opts[CURLOPT_PROXYTYPE] = $proxy_type;
}
if ($proxy_address !== '') {
$opts[CURLOPT_PROXY] = $proxy_address;
}
if ($cookie !== '') {
$opts[CURLOPT_COOKIE] = $cookie;
}
if ($cookie_file) {
// Pass empty cookie file name to enable the libcurl cookie engine
// without reading any existing cookie data.
$opts[CURLOPT_COOKIEFILE] = '';
}
if ($max_redirs != 0) {
$opts[CURLOPT_MAXREDIRS] = $max_redirs;
$opts[CURLOPT_FOLLOWLOCATION] = 1;
}
if ($useragent !== '') {
$opts[CURLOPT_USERAGENT] = $useragent;
}
if ($request_method === 'POST') {
$opts[CURLOPT_POST] = true;
if ($request_fields !== '') {
$opts[CURLOPT_POSTFIELDS] = $request_fields;
if (json_decode($request_fields, true) !== null) {
$opts[CURLOPT_HTTPHEADER] = ['Content-Type: application/json'];
}
}
}
$headers = array_filter($headers, fn(string $header): bool => trim($header) !== '');
if (!empty($headers)) {
$opts[CURLOPT_HTTPHEADER] = array_merge($headers, $opts[CURLOPT_HTTPHEADER] ?? []);
$opts[CURLOPT_HTTPHEADER] = array_unique($opts[CURLOPT_HTTPHEADER]);
}
$feed->_attribute('curl_params', empty($opts) ? null : $opts);
$feed->_attribute('content_action', Minz_Request::paramString('content_action', true) ?: 'replace');
$feed->_attribute('ssl_verify', Minz_Request::paramTernary('ssl_verify'));
$timeout = Minz_Request::paramInt('timeout');
$feed->_attribute('timeout', $timeout > 0 ? $timeout : null);
if (Minz_Request::paramBoolean('use_default_purge_options')) {
$feed->_attribute('archiving', null);
} else {
if (Minz_Request::paramBoolean('enable_keep_max')) {
$keepMax = Minz_Request::paramInt('keep_max') ?: FreshRSS_Feed::ARCHIVING_RETENTION_COUNT_LIMIT;
} else {
$keepMax = false;
}
if (Minz_Request::paramBoolean('enable_keep_period')) {
$keepPeriod = FreshRSS_Feed::ARCHIVING_RETENTION_PERIOD;
if (is_numeric(Minz_Request::paramString('keep_period_count')) && preg_match('/^PT?1[YMWDH]$/', Minz_Request::paramString('keep_period_unit'))) {
$keepPeriod = str_replace('1', Minz_Request::paramString('keep_period_count'), Minz_Request::paramString('keep_period_unit'));
}
} else {
$keepPeriod = false;
}
$feed->_attribute('archiving', [
'keep_period' => $keepPeriod,
'keep_max' => $keepMax,
'keep_min' => Minz_Request::paramInt('keep_min'),
'keep_favourites' => Minz_Request::paramBoolean('keep_favourites'),
'keep_labels' => Minz_Request::paramBoolean('keep_labels'),
'keep_unreads' => Minz_Request::paramBoolean('keep_unreads'),
]);
}
$feed->_filtersAction('read', Minz_Request::paramTextToArray('filteractions_read', plaintext: true));
$feed->_kind(Minz_Request::paramInt('feed_kind') ?: FreshRSS_Feed::KIND_RSS);
if ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH || $feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) {
$xPathSettings = [];
if (Minz_Request::paramString('xPathItem') != '')
$xPathSettings['item'] = Minz_Request::paramString('xPathItem', true);
if (Minz_Request::paramString('xPathItemTitle') != '')
$xPathSettings['itemTitle'] = Minz_Request::paramString('xPathItemTitle', true);
if (Minz_Request::paramString('xPathItemContent') != '')
$xPathSettings['itemContent'] = Minz_Request::paramString('xPathItemContent', true);
if (Minz_Request::paramString('xPathItemUri') != '')
$xPathSettings['itemUri'] = Minz_Request::paramString('xPathItemUri', true);
if (Minz_Request::paramString('xPathItemAuthor') != '')
$xPathSettings['itemAuthor'] = Minz_Request::paramString('xPathItemAuthor', true);
if (Minz_Request::paramString('xPathItemTimestamp') != '')
$xPathSettings['itemTimestamp'] = Minz_Request::paramString('xPathItemTimestamp', true);
if (Minz_Request::paramString('xPathItemTimeFormat') != '')
$xPathSettings['itemTimeFormat'] = Minz_Request::paramString('xPathItemTimeFormat', true);
if (Minz_Request::paramString('xPathItemThumbnail') != '')
$xPathSettings['itemThumbnail'] = Minz_Request::paramString('xPathItemThumbnail', true);
if (Minz_Request::paramString('xPathItemCategories') != '')
$xPathSettings['itemCategories'] = Minz_Request::paramString('xPathItemCategories', true);
if (Minz_Request::paramString('xPathItemUid') != '')
$xPathSettings['itemUid'] = Minz_Request::paramString('xPathItemUid', true);
if (!empty($xPathSettings))
$feed->_attribute('xpath', $xPathSettings);
} elseif ($feed->kind() === FreshRSS_Feed::KIND_JSON_DOTNOTATION || $feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH_JSON_DOTNOTATION) {
$jsonSettings = [];
if (Minz_Request::paramString('jsonFeedTitle') !== '') {
$jsonSettings['feedTitle'] = Minz_Request::paramString('jsonFeedTitle', true);
}
if (Minz_Request::paramString('jsonItem') !== '') {
$jsonSettings['item'] = Minz_Request::paramString('jsonItem', true);
}
if (Minz_Request::paramString('jsonItemTitle') !== '') {
$jsonSettings['itemTitle'] = Minz_Request::paramString('jsonItemTitle', true);
}
if (Minz_Request::paramString('jsonItemContent') !== '') {
$jsonSettings['itemContent'] = Minz_Request::paramString('jsonItemContent', true);
}
if (Minz_Request::paramString('jsonItemUri') !== '') {
$jsonSettings['itemUri'] = Minz_Request::paramString('jsonItemUri', true);
}
if (Minz_Request::paramString('jsonItemAuthor') !== '') {
$jsonSettings['itemAuthor'] = Minz_Request::paramString('jsonItemAuthor', true);
}
if (Minz_Request::paramString('jsonItemTimestamp') !== '') {
$jsonSettings['itemTimestamp'] = Minz_Request::paramString('jsonItemTimestamp', true);
}
if (Minz_Request::paramString('jsonItemTimeFormat') !== '') {
$jsonSettings['itemTimeFormat'] = Minz_Request::paramString('jsonItemTimeFormat', true);
}
if (Minz_Request::paramString('jsonItemThumbnail') !== '') {
$jsonSettings['itemThumbnail'] = Minz_Request::paramString('jsonItemThumbnail', true);
}
if (Minz_Request::paramString('jsonItemCategories') !== '') {
$jsonSettings['itemCategories'] = Minz_Request::paramString('jsonItemCategories', true);
}
if (Minz_Request::paramString('jsonItemUid') !== '') {
$jsonSettings['itemUid'] = Minz_Request::paramString('jsonItemUid', true);
}
if (!empty($jsonSettings)) {
$feed->_attribute('json_dotnotation', $jsonSettings);
}
if (Minz_Request::paramString('xPathToJson', plaintext: true) !== '') {
$feed->_attribute('xPathToJson', Minz_Request::paramString('xPathToJson', plaintext: true));
}
}
$conditions = Minz_Request::paramTextToArray('path_entries_conditions', plaintext: true);
$conditions = array_filter($conditions, fn(string $condition): bool => trim($condition) !== '');
$feed->_attribute('path_entries_conditions', empty($conditions) ? null : $conditions);
$feed->_attribute('path_entries_filter', Minz_Request::paramString('path_entries_filter', true));
// @phpstan-ignore offsetAccess.nonOffsetAccessible
$favicon_path = isset($_FILES['newFavicon']) ? $_FILES['newFavicon']['tmp_name'] : '';
// @phpstan-ignore offsetAccess.nonOffsetAccessible
$favicon_size = isset($_FILES['newFavicon']) ? $_FILES['newFavicon']['size'] : 0;
$favicon_uploaded = $favicon_path !== '';
$resetFavicon = Minz_Request::paramBoolean('resetFavicon');
if ($resetFavicon) {
$feed->resetCustomFavicon();
}
$defaultSortOrder = Minz_Request::paramString('defaultSortOrder', plaintext: true);
if (str_ends_with($defaultSortOrder, '_asc')) {
$feed->_attribute('defaultOrder', 'ASC');
$defaultSortOrder = substr($defaultSortOrder, 0, -strlen('_asc'));
} elseif (str_ends_with($defaultSortOrder, '_desc')) {
$feed->_attribute('defaultOrder', 'DESC');
$defaultSortOrder = substr($defaultSortOrder, 0, -strlen('_desc'));
} else {
$feed->_attribute('defaultOrder');
}
if (in_array($defaultSortOrder, ['id', 'date', 'link', 'title', 'length', 'rand'], true)) {
$feed->_attribute('defaultSort', $defaultSortOrder);
} else {
$feed->_attribute('defaultSort');
}
$values = [
'name' => Minz_Request::paramString('name'),
'kind' => $feed->kind(),
'description' => FreshRSS_SimplePieCustom::sanitizeHTML(Minz_Request::paramString('description', true)),
'website' => FreshRSS_http_Util::checkUrl(Minz_Request::paramString('website')) ?: '',
'url' => FreshRSS_http_Util::checkUrl(Minz_Request::paramString('url')) ?: '',
'category' => Minz_Request::paramInt('category'),
'pathEntries' => Minz_Request::paramString('path_entries'),
'priority' => Minz_Request::paramTernary('priority') === null ? FreshRSS_Feed::PRIORITY_MAIN_STREAM : Minz_Request::paramInt('priority'),
'httpAuth' => $httpAuth,
'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
];
invalidateHttpCache();
$from = Minz_Request::paramString('from');
switch ($from) {
case 'stats':
$url_redirect = ['c' => 'stats', 'a' => 'idle', 'params' => ['id' => $id, 'from' => 'stats']];
break;
case 'normal':
case 'reader':
$get = Minz_Request::paramString('get');
if ($get !== '') {
$url_redirect = ['c' => 'index', 'a' => $from, 'params' => ['get' => $get]];
} else {
$url_redirect = ['c' => 'index', 'a' => $from];
}
break;
default:
$url_redirect = ['c' => 'subscription', 'params' => ['id' => $id]];
}
if ($favicon_uploaded && !$resetFavicon) {
$max_size = FreshRSS_Context::systemConf()->limits['max_favicon_upload_size'];
if ($favicon_size > $max_size) {
Minz_Request::bad(_t('feedback.sub.feed.favicon.too_large', format_bytes($max_size)), $url_redirect);
return;
}
try {
$feed->setCustomFavicon(tmpPath: is_string($favicon_path) ? $favicon_path : '', values: $values);
} catch (FreshRSS_UnsupportedImageFormat_Exception $_) {
Minz_Request::bad(_t('feedback.sub.feed.favicon.unsupported_format'), $url_redirect);
return;
} catch (FreshRSS_Feed_Exception $_) {
Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
return;
}
Minz_Request::good(
_t('feedback.sub.feed.updated'),
$url_redirect,
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
);
} elseif ($values['url'] != '' && $feedDAO->updateFeed($id, $values) !== false) {
$feed->_categoryId($values['category']);
// update url and website values for faviconPrepare
$feed->_url($values['url'], false);
$feed->_website($values['website'], false);
$feed->faviconPrepare();
Minz_Request::good(
_t('feedback.sub.feed.updated'),
$url_redirect,
showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
);
} else {
if ($values['url'] == '') {
Minz_Log::warning('Invalid feed URL!');
}
Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
}
}
}
public function viewFilterAction(): void {
$id = Minz_Request::paramInt('id');
if ($id === 0) {
Minz_Error::error(400);
return;
}
$filteractions = Minz_Request::paramTextToArray('filteractions_read', plaintext: true);
$filteractions = array_map(fn(string $action): string => trim($action), $filteractions);
$filteractions = array_filter($filteractions, fn(string $action): bool => $action !== '');
$actionsSearch = new FreshRSS_BooleanSearch('', operator: 'AND');
foreach ($filteractions as $action) {
$actionSearch = new FreshRSS_BooleanSearch($action, operator: 'OR');
if ($actionSearch->toString() === '') {
continue;
}
$actionsSearch->add($actionSearch);
}
$search = new FreshRSS_BooleanSearch('');
$search->add(new FreshRSS_Search("f:$id"));
$search->add($actionsSearch);
Minz_Request::forward([
'c' => 'index',
'a' => 'index',
'params' => [
'search' => $search->toString(),
],
], redirect: true);
}
/**
* This action displays the bookmarklet page.
*/
public function bookmarkletAction(): void {
FreshRSS_View::prependTitle(_t('sub.title.subscription_tools') . ' . ');
}
/**
* This action displays the page to add a new feed
*/
public function addAction(): void {
FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
FreshRSS_View::prependTitle(_t('sub.title.add') . ' . ');
}
}