ソースを参照

Support choosing the sort type when creating or editing a user query (#8954)

* Support choosing the sort type when creating or editing a user query

Changes
- User queries can now store a sort field (sort type), not only the sort
  direction. The create and edit forms expose a single combined dropdown
  (field + direction) that reuses the existing `index.menu.sort.*` options,
  matching the global reading-settings sort UI.
- The "Bookmark current query" action now also remembers the current sort
  field, where it previously kept only the order direction.
- Relabel the query sort control from "Sort by date" to "Sort by"; other
  languages are marked for re-translation (// TODO).
- Add a CHANGELOG entry and credit the contributor in CREDITS.

Implementation
- `FreshRSS_UserQuery`: add an optional `sort` property mirroring `order`
  (constructor parsing, `toArray()`, `getSort()` falling back to the user
  default sort, and a `hasParameters()` check).
- `configureController`: split the combined dropdown value into `sort` and
  `order`, validating the field against the implemented sort types
  (`id`, `c.name`, `date`, `f.name`, `length`, `link`, `title`, `rand`);
  separate `sort`/`order` request parameters are still accepted so the
  "bookmark current query" flow keeps working.
- Views render the combined dropdown and persist `sort` as a hidden field.
- `p/api/query.php`: back-fill `sort` from the saved query when the request
  does not provide one, mirroring the existing `order` handling.
- Add unit tests covering the sort round-trip and `toArray()` inclusion.

Compatibility
- No database or schema change: user queries are stored in the per-user PHP
  config array, which keeps arbitrary keys verbatim, so no migration is
  required.
- Upgrade: existing queries without `sort` fall back to the user default
  sort; behaviour is unchanged until a sort is explicitly chosen.
- Downgrade: older versions ignore the extra `sort` key and keep working;
  re-saving under an old version simply drops the key with no data loss.
- Shared query URLs without a `sort` parameter remain valid.

* Refresh README translation statistics

* i18n: fr

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
i @man 14 時間 前
親
コミット
4e8a258b01

+ 2 - 0
CHANGELOG.md

@@ -6,6 +6,8 @@ See also [the FreshRSS releases](https://github.com/FreshRSS/FreshRSS/releases).
 
 * Compatibility
 	* 💥 Require PHP 8.2+ (drop PHP 8.1) [#9470](https://github.com/FreshRSS/FreshRSS/pull/9470)
+* Features
+	* User queries can define their own sort criterion (field and direction) [#8954](https://github.com/FreshRSS/FreshRSS/pull/8954)
 * Bug fixes
 	* Fix HTTP Retry-After cache when proxy is used [#9508](https://github.com/FreshRSS/FreshRSS/pull/9508)
 	* Fix 32-bit systems provide a string for SQL BIGINT [#9486](https://github.com/FreshRSS/FreshRSS/pull/9486)

+ 3 - 3
README.fr.md

@@ -239,7 +239,7 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 | العربية (ar) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Far+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Azərbaycanca (az) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Faz+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Беларуская (be) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fbe+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
-| Čeština (cs) | ■■■■■■■■・・ 81% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
+| Čeština (cs) | ■■■■■■■■・・ 80% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Deutsch (de) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Ελληνικά (el) | ■■■■■■■・・・ 75% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | English (en) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
@@ -247,7 +247,7 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 | Español (es) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fes+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | فارسی (fa) | ■■■■■■■■■・ 98% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffa+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Suomi (fi) | ■■■■■■■■■・ 91% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffi+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
-| Français (fr) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffr+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
+| Français (fr) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffr+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | עברית (he) | ■■■■・・・・・・ 41% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Magyar (hu) | ■■■■■■■■■・ 98% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhu+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Bahasa Indonesia (id) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fid+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
@@ -258,7 +258,7 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 | Latviešu (lv) | ■■■■■■■■・・ 81% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Flv+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Nederlands (nl) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fnl+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Occitan (oc) | ■■■■■■■・・・ 73% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Foc+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
-| Polski (pl) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpl+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
+| Polski (pl) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpl+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Português (Brasil) (pt-BR) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-BR+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Português (Portugal) (pt-PT) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-PT+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Русский (ru) | ■■■■■■■■■・ 98% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fru+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |

+ 3 - 3
README.md

@@ -135,7 +135,7 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 | العربية (ar) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Far+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Azərbaycanca (az) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Faz+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Беларуская (be) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fbe+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
-| Čeština (cs) | ■■■■■■■■・・ 81% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
+| Čeština (cs) | ■■■■■■■■・・ 80% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Deutsch (de) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Ελληνικά (el) | ■■■■■■■・・・ 75% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | English (en) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
@@ -143,7 +143,7 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 | Español (es) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fes+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | فارسی (fa) | ■■■■■■■■■・ 98% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffa+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Suomi (fi) | ■■■■■■■■■・ 91% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffi+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
-| Français (fr) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffr+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
+| Français (fr) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffr+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | עברית (he) | ■■■■・・・・・・ 41% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Magyar (hu) | ■■■■■■■■■・ 98% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhu+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Bahasa Indonesia (id) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fid+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
@@ -154,7 +154,7 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 | Latviešu (lv) | ■■■■■■■■・・ 81% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Flv+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Nederlands (nl) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fnl+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Occitan (oc) | ■■■■■■■・・・ 73% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Foc+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
-| Polski (pl) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpl+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
+| Polski (pl) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpl+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Português (Brasil) (pt-BR) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-BR+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Português (Portugal) (pt-PT) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fpt-PT+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |
 | Русский (ru) | ■■■■■■■■■・ 98% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fru+%2F%28TODO%7CDIRTY%29%28+-+.%2A%29%3F%24%2F) |

+ 44 - 4
app/Controllers/configureController.php

@@ -429,7 +429,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/draggable.js?' . @filemtime(PUBLIC_PATH . '/scripts/draggable.js')));
 
 		if (Minz_Request::isPost()) {
-			/** @var array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
+			/** @var array<int,array{get?:string,name?:string,order?:string,sort?:string,search?:string,state?:int,url?:string,token?:string,
 			 * 		shareRss?:bool|numeric-string,shareOpml?:bool|numeric-string,description?:string,imageUrl?:string}> $params */
 			$params = Minz_Request::paramArray('queries');
 
@@ -512,9 +512,29 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 			if (!empty($params['get']) && is_string($params['get'])) {
 				$queryParams['get'] = $params['get'];
 			}
-			if (!empty($params['order']) && is_string($params['order'])) {
+			// Order direction may arrive on its own when bookmarking the current view.
+			if (is_string($params['order'] ?? null) && in_array($params['order'], ['ASC', 'DESC'], true)) {
 				$queryParams['order'] = $params['order'];
 			}
+			// The query form submits a combined sort value (e.g. 'date_desc' or 'rand');
+			// bookmarking the current view passes the sort field on its own instead.
+			$sorting = $params['sort'] ?? '';
+			if (is_string($sorting) && $sorting !== '') {
+				if ($sorting === 'rand') {
+					$sortField = 'rand';
+				} elseif (str_ends_with($sorting, '_asc')) {
+					$queryParams['order'] = 'ASC';
+					$sortField = substr($sorting, 0, -strlen('_asc'));
+				} elseif (str_ends_with($sorting, '_desc')) {
+					$queryParams['order'] = 'DESC';
+					$sortField = substr($sorting, 0, -strlen('_desc'));
+				} else {
+					$sortField = $sorting;
+				}
+				if (in_array($sortField, ['id', 'c.name', 'date', 'f.name', 'length', 'link', 'title', 'rand'], true)) {
+					$queryParams['sort'] = $sortField;
+				}
+			}
 			if (!empty($params['search']) && is_string($params['search'])) {
 				// Search must be as plain text to be XML-encoded or URL-encoded depending on the situation
 				$queryParams['search'] = htmlspecialchars_decode($params['search'], ENT_QUOTES);
@@ -608,7 +628,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 		$queries = FreshRSS_Context::userConf()->queries;
 		$id = empty($queries) ? 0 : (max(array_keys($queries)) + 1);
 
-		/** @var array{get?:string,name?:string,order?:string,search?:string,state?:int,shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string} $params */
+		/** @var array{get?:string,name?:string,order?:string,sort?:string,search?:string,state?:int,shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string} $params */
 		$params = Minz_Request::paramArray('query') ?: array_filter($_GET, 'is_string', ARRAY_FILTER_USE_KEY);
 		$name = ($params['name'] ?? '') ?: _t('conf.query.number', $id + 1);
 		$queryParams = [];
@@ -616,9 +636,29 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 		if (is_string($params['get'] ?? null)) {
 			$queryParams['get'] = $params['get'];
 		}
-		if (is_string($params['order'] ?? null)) {
+		// Order direction may arrive on its own when bookmarking the current view.
+		if (is_string($params['order'] ?? null) && in_array($params['order'], ['ASC', 'DESC'], true)) {
 			$queryParams['order'] = $params['order'];
 		}
+		// The query form submits a combined sort value (e.g. 'date_desc' or 'rand');
+		// bookmarking the current view passes the sort field on its own instead.
+		$sorting = $params['sort'] ?? '';
+		if (is_string($sorting) && $sorting !== '') {
+			if ($sorting === 'rand') {
+				$sortField = 'rand';
+			} elseif (str_ends_with($sorting, '_asc')) {
+				$queryParams['order'] = 'ASC';
+				$sortField = substr($sorting, 0, -strlen('_asc'));
+			} elseif (str_ends_with($sorting, '_desc')) {
+				$queryParams['order'] = 'DESC';
+				$sortField = substr($sorting, 0, -strlen('_desc'));
+			} else {
+				$sortField = $sorting;
+			}
+			if (in_array($sortField, ['id', 'c.name', 'date', 'f.name', 'length', 'link', 'title', 'rand'], true)) {
+				$queryParams['sort'] = $sortField;
+			}
+		}
 		if (is_string($params['search'] ?? null)) {
 			// Search must be as plain text to be XML-encoded or URL-encoded depending on the situation
 			$queryParams['search'] = htmlspecialchars_decode($params['search'], ENT_QUOTES);

+ 16 - 4
app/Models/UserQuery.php

@@ -15,6 +15,7 @@ class FreshRSS_UserQuery {
 	/** XML-encoded name */
 	private string $name = '';
 	private string $order = '';
+	private string $sort = '';
 	private readonly FreshRSS_BooleanSearch $search;
 	private int $state = 0;
 	private string $url = '';
@@ -45,7 +46,7 @@ class FreshRSS_UserQuery {
 	}
 
 	/**
-	 * @param array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
+	 * @param array{get?:string,name?:string,order?:string,sort?:string,search?:string,state?:int,url?:string,token?:string,
 	 * 	shareRss?:bool,shareOpml?:bool,includeUserLabels?:bool,excludeArticleTags?:bool,userLabelPrefix?:string,
 	 * 	publishLabelsInsteadOfTags?:bool,description?:string,imageUrl?:string} $query
 	 * @param array<FreshRSS_Category> $categories
@@ -72,6 +73,9 @@ class FreshRSS_UserQuery {
 		if (isset($query['order'])) {
 			$this->order = $query['order'];
 		}
+		if (isset($query['sort'])) {
+			$this->sort = $query['sort'];
+		}
 		if (empty($query['url'])) {
 			if (!empty($query)) {
 				$link = $query;
@@ -136,7 +140,7 @@ class FreshRSS_UserQuery {
 	/**
 	 * Convert the current object to an array.
 	 *
-	 * @return array{get?:string,name?:string,order?:string,search?:string,
+	 * @return array{get?:string,name?:string,order?:string,sort?:string,search?:string,
 	 * 	state?:int,url?:string,token?:string,shareRss?:bool,shareOpml?:bool,
 	 * 	includeUserLabels?:bool,excludeArticleTags?:bool,userLabelPrefix?:string,description?:string,imageUrl?:string}
 	 */
@@ -145,6 +149,7 @@ class FreshRSS_UserQuery {
 			'get' => $this->get,
 			'name' => $this->name,
 			'order' => $this->order,
+			'sort' => $this->sort,
 			'search' => $this->search->toString(expandUserQueries: false),
 			'state' => $this->state,
 			'url' => $this->url,
@@ -238,6 +243,9 @@ class FreshRSS_UserQuery {
 		if ($this->order !== '' && $this->order !== FreshRSS_Context::userConf()->sort_order) {
 			return true;
 		}
+		if ($this->sort !== '' && $this->sort !== FreshRSS_Context::userConf()->sort) {
+			return true;
+		}
 		return false;
 	}
 
@@ -268,6 +276,10 @@ class FreshRSS_UserQuery {
 		return $this->order ?: FreshRSS_Context::userConf()->sort_order;
 	}
 
+	public function getSort(): string {
+		return $this->sort ?: FreshRSS_Context::userConf()->sort;
+	}
+
 	public function getSearch(): FreshRSS_BooleanSearch {
 		return $this->search;
 	}
@@ -394,9 +406,9 @@ class FreshRSS_UserQuery {
 	/**
 	 * Remove queries where $get is appearing.
 	 * @param string $get the get attribute which should be removed.
-	 * @param array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
+	 * @param array<int,array{get?:string,name?:string,order?:string,sort?:string,search?:string,state?:int,url?:string,token?:string,
 	 * 	shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> $queries an array of queries.
-	 * @return array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,
+	 * @return array<int,array{get?:string,name?:string,order?:string,sort?:string,search?:string,state?:int,url?:string,token?:string,
 	 * 	shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> without queries where $get is appearing.
 	 */
 	public static function remove_query_by_get(string $get, array $queries): array {

+ 1 - 1
app/i18n/ar/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'العرض حسب الخلاصة',
 			'include_article_tags_label' => 'تضمين وسوم المقالات من الخلاصات',
 			'include_user_labels_label' => 'تضمين تسميات المستخدم، مع البادئة:',
-			'order' => 'الفرز حسب التاريخ',
+			'order' => 'Sort by',	// TODO
 			'search' => 'التعبير',
 			'shareOpml' => 'تفعيل المشاركة عبر OPML للتصنيفات والخلاصات المقابلة',
 			'shareRss' => 'تفعيل المشاركة عبر HTML &amp; RSS',

+ 1 - 1
app/i18n/az/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Lentə görə göstər',
 			'include_article_tags_label' => 'Lentlərdən gələn məqalə etiketlərini daxil et',
 			'include_user_labels_label' => 'İstifadəçi etiketlərini daxil et, prefiks ilə:',
-			'order' => 'Tarixə görə sırala',
+			'order' => 'Sort by',	// TODO
 			'search' => 'İfadə',
 			'shareOpml' => 'Uyğun kateqoriya və lentlərin OPML ilə paylaşılmasını aktivləşdir',
 			'shareRss' => 'HTML &amp; RSS ilə paylaşmanı aktivləşdir',

+ 1 - 1
app/i18n/cs/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Zobrazit podle kanálu',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Seřadit podle data',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Výraz',
 			'shareOpml' => 'Povolit sdílení příslušných kategorií a kanálů pomocí OPML',
 			'shareRss' => 'Povolit sdílení pomocí HTML &amp; RSS',

+ 1 - 1
app/i18n/de/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Nach Feed filtern',
 			'include_article_tags_label' => 'Artikel-Tags aus den Feeds berücksichtigen',
 			'include_user_labels_label' => 'Eigene Labels berücksichtigen, mit Präfix:',
-			'order' => 'Nach Datum sortieren',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Suchbegriff',
 			'shareOpml' => 'Teilen via OPML mit zugehörigen Kategorien und Feeds aktivieren',
 			'shareRss' => 'Teilen via HTML &amp; RSS aktivieren',

+ 1 - 1
app/i18n/el/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Προβολή ανά ροή',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Ταξινόμηση κατά ημερομηνία',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Έκφραση',
 			'shareOpml' => 'Ενεργοποίηση κοινοποίησης μέσω OPML των αντίστοιχων κατηγοριών και ροών',
 			'shareRss' => 'Ενεργοποίηση κοινοποίησης μέσω HTML &amp; RSS',

+ 1 - 1
app/i18n/en-US/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Display by feed',	// IGNORE
 			'include_article_tags_label' => 'Include article tags from feeds',	// IGNORE
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// IGNORE
-			'order' => 'Sort by date',	// IGNORE
+			'order' => 'Sort by',	// IGNORE
 			'search' => 'Expression',	// IGNORE
 			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// IGNORE
 			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// IGNORE

+ 1 - 1
app/i18n/en/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Display by feed',
 			'include_article_tags_label' => 'Include article tags from feeds',
 			'include_user_labels_label' => 'Include user labels, with prefix:',
-			'order' => 'Sort by date',
+			'order' => 'Sort by',
 			'search' => 'Expression',
 			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',
 			'shareRss' => 'Enable sharing by HTML &amp; RSS',

+ 1 - 1
app/i18n/es/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Mostrar por fuente',
 			'include_article_tags_label' => 'Incluir etiquetas de artículos de los feeds',
 			'include_user_labels_label' => 'Incluir etiquetas del usuario, con el prefijo:',
-			'order' => 'Ordenar por fecha',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Expresión',
 			'shareOpml' => 'Permitir que OPML comparta las categorías y fuentes correspondientes',
 			'shareRss' => 'Permite compartir por HTML &amp; RSS',

+ 1 - 1
app/i18n/fa/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => ' نمایش با فید',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => ' مرتب سازی بر اساس تاریخ',
+			'order' => 'Sort by',	// TODO
 			'search' => ' بیان',
 			'shareOpml' => 'فعال‌سازی اشتراک‌گذاری دسته‌ها و فیدهای مربوطه با OPML',
 			'shareRss' => 'اشتراک‌گذاری RSS',

+ 1 - 1
app/i18n/fi/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Näytä syötteen mukaan',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Lajittele päivämäärän mukaan',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Lauseke',
 			'shareOpml' => 'Jaa kyselyä vastaavat luokat ja syötteet OPML-muodossa',
 			'shareRss' => 'Jaa HTML &amp; RSS -muodossa',

+ 1 - 1
app/i18n/fr/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Afficher par flux',
 			'include_article_tags_label' => 'Inclure les tags des articles provenant des flux',
 			'include_user_labels_label' => 'Inclure les étiquettes utilisateur, avec le préfixe :',
-			'order' => 'Tri par date',
+			'order' => 'Trier par',
 			'search' => 'Expression',	// IGNORE
 			'shareOpml' => 'Active le partage par OPML des catégories et flux correspondants',
 			'shareRss' => 'Active le partage par HTML &amp; RSS',

+ 1 - 1
app/i18n/fr/gen.php

@@ -208,7 +208,7 @@ return array(
 		'it' => 'Italiano',	// IGNORE
 		'ja' => '日本語',	// IGNORE
 		'ko' => '한국어',	// IGNORE
-		'lt' => 'Lietuvių',	// TODO
+		'lt' => 'Lietuvių',	// IGNORE
 		'lv' => 'Latviešu',	// IGNORE
 		'nl' => 'Nederlands',	// IGNORE
 		'oc' => 'Occitan',	// IGNORE

+ 1 - 1
app/i18n/he/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Display by feed',	// TODO
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Sort by date',	// TODO
+			'order' => 'Sort by',	// TODO
 			'search' => 'Expression',	// TODO
 			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
 			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO

+ 1 - 1
app/i18n/hu/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Rendezés hírforrás szerint',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Rendezés dátum szerint',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Kifejezés',
 			'shareOpml' => 'Engedélyezze a megfelelő kategóriák és hírcsatornák OPML-alapú megosztását',
 			'shareRss' => 'Engedélyezze a HTML &amp; RSS megosztást',

+ 1 - 1
app/i18n/id/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Tampilkan berdasarkan umpan',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Urutkan berdasarkan tanggal',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Ekspresi Pencarian',
 			'shareOpml' => 'Aktifkan berbagi melalui OPML dari kategori dan umpan terkait',
 			'shareRss' => 'Aktifkan berbagi melalui HTML &amp; RSS',

+ 1 - 1
app/i18n/it/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Mostra per feed',
 			'include_article_tags_label' => 'Includi i tag degli articoli provenienti dai feed',
 			'include_user_labels_label' => 'Includi le etichette dell’utente, con prefisso:',
-			'order' => 'Ordina per data',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Espressione',
 			'shareOpml' => 'Abilita la condivisione di OPML di categorie e feed corrispondenti',
 			'shareRss' => 'Abilita la condivisione di HTML &amp; RSS',

+ 1 - 1
app/i18n/ja/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'フィードごとに表示する',
 			'include_article_tags_label' => 'フィードの記事タグを含める',
 			'include_user_labels_label' => 'プレフィックスを付けてユーザーラベルを含める:',
-			'order' => '日付ごとにソートする',
+			'order' => 'Sort by',	// TODO
 			'search' => '検索式',
 			'shareOpml' => 'カテゴリとフィードのOPMLによる共有を有効にする',
 			'shareRss' => 'HTMLとRSSによる共有を有効にする',

+ 1 - 1
app/i18n/ko/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => '피드별로 표시',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => '날짜순으로 정렬',
+			'order' => 'Sort by',	// TODO
 			'search' => '정규 표현식',
 			'shareOpml' => '해당 카테고리와 피드에 대한 OPML 공유 활성화',
 			'shareRss' => 'HTML 및 RSS 공유 활성화',

+ 1 - 1
app/i18n/lt/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Rodyti pagal kanalą',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Rikiuoti pagal datą',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Išraiška',
 			'shareOpml' => 'Įjungti atitinkamų kategorijų ir kanalų bendrinimą per OPML',
 			'shareRss' => 'Įjungti bendrinimą per HTML ir RSS',

+ 1 - 1
app/i18n/lv/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Rādīt pēc barotnes',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Kārtot pēc datuma',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Izteiksme',
 			'shareOpml' => 'Iespējot dalīšanos, izmantojot OPML atbilstošajām kategorijām un barotnēm',
 			'shareRss' => 'Iespējojiet koplietošanu ar HTML &amp; RSS',

+ 1 - 1
app/i18n/nl/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Weergeven op feed',
 			'include_article_tags_label' => 'Artikel-tags van feeds opnemen',
 			'include_user_labels_label' => 'Gebruikerslabels opnemen, met voorvoegsel:',
-			'order' => 'Sorteren op datum',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Expressie',
 			'shareOpml' => 'Via OPML delen van bijbehorende categorieën en feeds aanzetten',
 			'shareRss' => 'Via HTML &amp; RSS delen aanzetten',

+ 1 - 1
app/i18n/oc/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Afichatge per flux',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Triar per data',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Expression',	// IGNORE
 			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
 			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO

+ 1 - 1
app/i18n/pl/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Według kanału',
 			'include_article_tags_label' => 'Uwzględnij tagi wiadomości z kanałów',
 			'include_user_labels_label' => 'Uwzględnij etykiety użytkownika z prefiksem:',
-			'order' => 'Sortowanie wg daty',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Wyrażenie',
 			'shareOpml' => 'Włącz udostępnianie OPML-ów zawierających kategorie i kanały',
 			'shareRss' => 'Włącz udostępnianie przez HTML i RSS',

+ 1 - 1
app/i18n/pt-BR/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Exibir por feed',
 			'include_article_tags_label' => 'Incluir tags dos artigos dos feeds',
 			'include_user_labels_label' => 'Incluir rótulos do usuário, com prefixo:',
-			'order' => 'Ordenar por data',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Expressão',
 			'shareOpml' => 'Habilita o compartilhamento por OPML de categorias e feeds correspondentes',
 			'shareRss' => 'Habilita o compartilhamento por HTML &amp; RSS',

+ 1 - 1
app/i18n/pt-PT/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Mostrar por feed',
 			'include_article_tags_label' => 'Incluir tags de artigos dos feeds',
 			'include_user_labels_label' => 'Incluir rótulos de utilizador, com o prefixo:',
-			'order' => 'Ordenar por data',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Expressão',
 			'shareOpml' => 'Ativar a partilha por OPML de categorias e feeds correspondentes',
 			'shareRss' => 'Ativar a partilha por HTML e RSS',

+ 1 - 1
app/i18n/ru/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Отображение по ленте',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Сортировать по дате',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Выражение',
 			'shareOpml' => 'Включить общий доступ с помощью OPML к соответствующим категориям и лентам',
 			'shareRss' => 'Включить общий доступ с помощью HTML &amp; RSS',

+ 1 - 1
app/i18n/sk/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Zobraziť podľa kanála',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Zobraziť podľa dátumu',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Výraz',
 			'shareOpml' => 'Povoliť zdieľanie prostredníctvom OPML zodpovedajúdich kategórií a kanálov',
 			'shareRss' => 'Povoliť zdieľanie prostredníctvom HTML &amp; RSS',

+ 1 - 1
app/i18n/tr/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Beslemeye göre göster',
 			'include_article_tags_label' => 'Beslemelerden gelen makale etiketlerini dahil et',
 			'include_user_labels_label' => 'Kullanıcı etiketlerini şu ön ekle dahil et:',
-			'order' => 'Tarihe göre sırala',
+			'order' => 'Sort by',	// TODO
 			'search' => 'İfade',
 			'shareOpml' => 'İlgili kategori ve beslemelerin OPML ile paylaşımını etkinleştir',
 			'shareRss' => 'HTML ve RSS ile paylaşımı etkinleştir',

+ 1 - 1
app/i18n/uk/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => 'Показати стрічку',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => 'Впорядкувати за датою',
+			'order' => 'Sort by',	// TODO
 			'search' => 'Вираз',
 			'shareOpml' => 'Увімкнути OPML-поширення відповідних категорій і стрічок',
 			'shareRss' => 'Увімкнути поширення в форматі HTML і RSS',

+ 1 - 1
app/i18n/zh-CN/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => '按订阅源显示',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => '按日期排序',
+			'order' => 'Sort by',	// TODO
 			'search' => '表达式',
 			'shareOpml' => '启用相应类别和 feed 的 OPML 分享',
 			'shareRss' => '启用 HTML 和 RSS 分享',

+ 1 - 1
app/i18n/zh-TW/conf.php

@@ -203,7 +203,7 @@ return array(
 			'feeds' => '按訂閱源顯示',
 			'include_article_tags_label' => 'Include article tags from feeds',	// TODO
 			'include_user_labels_label' => 'Include user labels, with prefix:',	// TODO
-			'order' => '按日期排序',
+			'order' => 'Sort by',	// TODO
 			'search' => '表達式',
 			'shareOpml' => '啟用透過對應類別和訂閱源的 OPML 分享',
 			'shareRss' => '啟用透過 HTML 分享 &amp; RSS',

+ 22 - 11
app/views/configure/queries.phtml

@@ -117,12 +117,27 @@
 				</div>
 			</div>
 			<div class="form-group">
-				<label class="group-name" for="new_query_order"><?= _t('conf.query.filter.order') ?></label>
+				<label class="group-name" for="new_query_sort"><?= _t('conf.query.filter.order') ?></label>
 				<div class="group-controls">
-					<select name="query[order]" class="w50" id="new_query_order">
+					<select name="query[sort]" class="w50" id="new_query_sort">
 						<option value=""></option>
-						<option value="DESC" ><?= _t('conf.query.order_desc') ?></option>
-						<option value="ASC" ><?= _t('conf.query.order_asc') ?></option>
+						<option value="id_desc"><?= _t('index.menu.sort.id_desc') ?></option>
+						<option value="date_desc"><?= _t('index.menu.sort.date_desc') ?></option>
+						<option value="length_desc"><?= _t('index.menu.sort.length_desc') ?></option>
+						<option value="link_desc"><?= _t('index.menu.sort.link_desc') ?></option>
+						<option value="title_desc"><?= _t('index.menu.sort.title_desc') ?></option>
+						<option value="f.name_desc"><?= _t('index.menu.sort.f.name_desc') ?></option>
+						<option value="c.name_desc"><?= _t('index.menu.sort.c.name_desc') ?></option>
+						<option disabled="disabled">────────────────</option>
+						<option value="rand"><?= _t('index.menu.sort.rand') ?></option>
+						<option disabled="disabled">────────────────</option>
+						<option value="id_asc"><?= _t('index.menu.sort.id_asc') ?></option>
+						<option value="date_asc"><?= _t('index.menu.sort.date_asc') ?></option>
+						<option value="length_asc"><?= _t('index.menu.sort.length_asc') ?></option>
+						<option value="link_asc"><?= _t('index.menu.sort.link_asc') ?></option>
+						<option value="title_asc"><?= _t('index.menu.sort.title_asc') ?></option>
+						<option value="f.name_asc"><?= _t('index.menu.sort.f.name_asc') ?></option>
+						<option value="c.name_asc"><?= _t('index.menu.sort.c.name_asc') ?></option>
 					</select>
 				</div>
 			</div>
@@ -179,6 +194,7 @@
 							<input type="hidden" id="queries_<?= $key ?>_search" name="queries[<?= $key ?>][search]" value="<?= urlencode($query->getSearch()->toString(expandUserQueries: false)) ?>"/>
 							<input type="hidden" id="queries_<?= $key ?>_state" name="queries[<?= $key ?>][state]" value="<?= $query->getState() ?>"/>
 							<input type="hidden" id="queries_<?= $key ?>_order" name="queries[<?= $key ?>][order]" value="<?= $query->getOrder() ?>"/>
+							<input type="hidden" id="queries_<?= $key ?>_sort" name="queries[<?= $key ?>][sort]" value="<?= $query->getSort() ?>"/>
 							<input type="hidden" id="queries_<?= $key ?>_get" name="queries[<?= $key ?>][get]" value="<?= $query->getGet() ?>"/>
 						</div>
 						<?php if (!$query->hasParameters()) { ?>
@@ -203,13 +219,8 @@
 							<li class="item"><?= $stateTranslations[$query->getState()] ?? '' ?></li>
 							<?php } ?>
 
-							<?php if ($query->getOrder() !== '') { ?>
-							<li class="item"><?= match ($query->getOrder()) {
-								'ASC' => _t('conf.query.order_asc'),
-								'DESC' => _t('conf.query.order_desc'),
-								default => '',
-							} ?></li>
-							<?php } ?>
+							<?php $qSort = $query->getSort(); $sortCombined = $qSort === 'rand' ? 'rand' : $qSort . '_' . strtolower($query->getOrder()); ?>
+							<li class="item"><?= _t('index.menu.sort.' . $sortCombined) ?></li>
 
 							<?php if ($query->getGet() !== '') { ?>
 							<li class="item"><?= match ($query->getGetType()) {

+ 23 - 4
app/views/helpers/configure/query.phtml

@@ -161,13 +161,32 @@
 					</select>
 				</div>
 			</div>
+			<?php
+				$querySort = $this->query->getSort();
+				$querySortCombined = $querySort === 'rand' ? 'rand' : $querySort . '_' . strtolower($this->query->getOrder());
+			?>
 			<div class="form-group">
-				<label class="group-name" for=""><?= _t('conf.query.filter.order') ?></label>
+				<label class="group-name" for="query_sort"><?= _t('conf.query.filter.order') ?></label>
 				<div class="group-controls">
-					<select name="query[order]" class="w100" id="query_order">
+					<select name="query[sort]" class="w100" id="query_sort">
 						<option value=""></option>
-						<option value="DESC" <?= 'DESC' === $this->query->getOrder() ? 'selected="selected"' : '' ?>><?= _t('conf.query.order_desc') ?></option>
-						<option value="ASC" <?= 'ASC' === $this->query->getOrder() ? 'selected="selected"' : '' ?>><?= _t('conf.query.order_asc') ?></option>
+						<option value="id_desc" <?= $querySortCombined === 'id_desc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.id_desc') ?></option>
+						<option value="date_desc" <?= $querySortCombined === 'date_desc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.date_desc') ?></option>
+						<option value="length_desc" <?= $querySortCombined === 'length_desc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.length_desc') ?></option>
+						<option value="link_desc" <?= $querySortCombined === 'link_desc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.link_desc') ?></option>
+						<option value="title_desc" <?= $querySortCombined === 'title_desc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.title_desc') ?></option>
+						<option value="f.name_desc" <?= $querySortCombined === 'f.name_desc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.f.name_desc') ?></option>
+						<option value="c.name_desc" <?= $querySortCombined === 'c.name_desc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.c.name_desc') ?></option>
+						<option disabled="disabled">────────────────</option>
+						<option value="rand" <?= $querySortCombined === 'rand' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.rand') ?></option>
+						<option disabled="disabled">────────────────</option>
+						<option value="id_asc" <?= $querySortCombined === 'id_asc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.id_asc') ?></option>
+						<option value="date_asc" <?= $querySortCombined === 'date_asc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.date_asc') ?></option>
+						<option value="length_asc" <?= $querySortCombined === 'length_asc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.length_asc') ?></option>
+						<option value="link_asc" <?= $querySortCombined === 'link_asc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.link_asc') ?></option>
+						<option value="title_asc" <?= $querySortCombined === 'title_asc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.title_asc') ?></option>
+						<option value="f.name_asc" <?= $querySortCombined === 'f.name_asc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.f.name_asc') ?></option>
+						<option value="c.name_asc" <?= $querySortCombined === 'c.name_asc' ? 'selected="selected"' : '' ?>><?= _t('index.menu.sort.c.name_asc') ?></option>
 					</select>
 				</div>
 			</div>

+ 3 - 0
p/api/query.php

@@ -96,6 +96,9 @@ foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
 		if (Minz_Request::paramString('order', plaintext: true) === '') {
 			Minz_Request::_param('order', $query->getOrder());
 		}
+		if (Minz_Request::paramString('sort', plaintext: true) === '') {
+			Minz_Request::_param('sort', $query->getSort());
+		}
 		Minz_Request::_param('state', (string)$query->getState());
 
 		$search = $query->getSearch()->toString();

+ 13 - 0
tests/app/Models/UserQueryTest.php

@@ -88,6 +88,13 @@ class UserQueryTest extends TestCase {
 		self::assertSame($order, $user_query->getOrder());
 	}
 
+	public static function test__construct_whenSort_storesSort(): void {
+		$sort = 'title';
+		$query = ['sort' => $sort];
+		$user_query = new FreshRSS_UserQuery($query, [], []);
+		self::assertSame($sort, $user_query->getSort());
+	}
+
 	public static function test__construct_whenState_storesState(): void {
 		$state = FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_FAVORITE;
 		$query = ['state' => $state];
@@ -122,6 +129,12 @@ class UserQueryTest extends TestCase {
 		self::assertSame($query, $user_query->toArray());
 	}
 
+	public static function testToArray_whenSort_returnsSort(): void {
+		$query = ['sort' => 'date'];
+		$user_query = new FreshRSS_UserQuery($query, [], []);
+		self::assertSame('date', $user_query->toArray()['sort'] ?? null);
+	}
+
 	public static function testHasSearch_whenSearch_returnsTrue(): void {
 		$query = ['search' => 'some search'];
 		$user_query = new FreshRSS_UserQuery($query, [], []);