mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-03-30 04:01:33 -04:00
Add option to sort results by received date (existing, default), publication date, title, URL (link), random. fix https://github.com/FreshRSS/FreshRSS/issues/1771 fix https://github.com/FreshRSS/FreshRSS/issues/2083 fix https://github.com/FreshRSS/FreshRSS/issues/2119 fix https://github.com/FreshRSS/FreshRSS/issues/2596 fix https://github.com/FreshRSS/FreshRSS/issues/3204 fix https://github.com/FreshRSS/FreshRSS/issues/4405 fix https://github.com/FreshRSS/FreshRSS/issues/5529 fix https://github.com/FreshRSS/FreshRSS/issues/5864 fix https://github.com/FreshRSS/Extensions/issues/161 URL parameters: * `&sort=id` (current behaviour, sorting according to newest received articles) * `&sort=date` (publication date, which is not indicative of how new an article is) * `&sort=title` * `&sort=link` * `&sort=rand` (random order - which disables infinite scrolling, at least for now) combined with `&order=ASC` or `&order=DESC`  ## Implementation notes The sorting criteria by *received date* (id), which is the default, and which was the only one before this PR, is the one that has the best sorting characteristics: * *uniqueness*: no entries have the exact same received date * *monotonicity*: new entries always have a higher received date * *performance*: this field is efficiently indexed in database for fast usage, including for paging (indexing could also be done to other fields, but with lower effective performance) In contrary, sorting criteria such as by *publication date*, by *title*, or by *link* are neither unique nor monotonic. In particular, multiple articles may share the same *publication date*, and we may receive articles with a *publication date* far in the future, and then later some new articles with a *publication date* far in the past. To understand why sorting by *publication date* is problematic, it helps to think about sorting by *title* or by *link*, as sorting by *title* and by *publication date* share more or less the same characteristics. ### Problem 1: new articles New articles may be received in the background after what is shown on screen, and before the next user action such as *mark all as read*. Due to the lack of *monotonicity* when sorting by e.g. *publication date* or *title*, users risk marking as read a batch of articles containing some fresh articles without seeing them. Mitigation: A parameter `idMax` tracks the maximum ID related to a batch of actions such as *mark all as read* to exclude articles received after those that are displayed. ### Problem 2: paging / pagination When navigating articles, only a few articles are displayed, and a new "page" of articles needs to be received from the database when scrolling down or when clicking the button to show more articles. When sorting by e.g. *publication date* or *title*, it is not trivial to show the next page without re-showing some of the same articles, and without skipping any. Indeed, views are often with additional criteria such as showing only unread articles, and users may mark some articles as read while viewing them, hereby removing some articles from the previous pages. And like for *Problem 1*, new articles may have been received in the background. Consequently, it is not possible to use `OFFSET` to implement pagination (so the patches suggested by a few users were wrong due to that, in particular). Mitigation: `idMax` is also used (just like for *Problem 1*) and a *Keyset Pagination* approach is used, combining an unstable sorting criterion such as *publication date* or *title*, together with *id* to ensure stable sorting. (So, 2 sorting criteria + 1 filter criteria) See e.g. https://www.alwaysdeveloping.net/dailydrop/2022/07/01-keyset-pagination/ ### Problem 3: performance Sorting by anything else than *received date* (id) is doomed to be slow(er) due to the combination of 3 criteria (see *Problem 2*). An `OFFSET` approach (which is not possible anyway as explained) would be even slower. Furthermore, we have no SQL index at the moment, but they would not necessarily help much due to the multiple sorting criteria needed and involving some `OR` logic which is difficult to optimise for databases. The nicest syntax would be using tuples and corresponding indexes, but that is poorly supported by MySQL https://bugs.mysql.com/bug.php?id=104128 Mitigation: a compatibility SQL syntax is used to implement *Keyset Pagination* ### Problem 4: user confusion Several users have shown that they do not fully understand the difference between *received date* and *publication date*, and particularly not the pitfalls of *publication date*. Mitigation: the menus to mark-as-read *before 1 day* and *before 1 week* are disabled when sorting by anything else than *received date*. Likewise, the separation headers *Today* and *Yesterday* and *Before yesterday* are only shown when sorting by *received date*. Again here, to better understand why, it helps to think about sorting by *title* or by *link*, as sorting by *title* and by *publication date* share more or less the same characteristics. * [ ] We should write a Q&A and/or documentation about the problems associated to *sorting by publication date*: risks of not noticing new publication, of inadvertently marking them as read, of having some articles with a date in the future hanging at the top of the views (vice versa when sorting in ascending order), performance, etc. ### Problem 5: APIs Sorting by anything else than *received date* breaks the guarantees needed for a successful synchronisation via API. Mitigation: sorting by *received date* is ensured for all API calls.
608 lines
20 KiB
PHP
608 lines
20 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* The context object handles the current configuration file and different
|
|
* useful functions associated to the current view state.
|
|
*/
|
|
final class FreshRSS_Context {
|
|
|
|
/** @var array<int,FreshRSS_Category> where the key is the category ID */
|
|
private static array $categories = [];
|
|
/** @var array<int,FreshRSS_Tag> where the key is the label ID */
|
|
private static array $tags = [];
|
|
public static string $name = '';
|
|
public static string $description = '';
|
|
public static int $total_unread = 0;
|
|
public static int $total_important_unread = 0;
|
|
|
|
/** @var array{all:int,read:int,unread:int} */
|
|
public static array $total_starred = [
|
|
'all' => 0,
|
|
'read' => 0,
|
|
'unread' => 0,
|
|
];
|
|
|
|
public static int $get_unread = 0;
|
|
|
|
/** @var array{all:bool,A:bool,starred:bool,important:bool,feed:int|false,category:int|false,tag:int|false,tags:bool,Z:bool} */
|
|
public static array $current_get = [
|
|
'all' => false,
|
|
'A' => false,
|
|
'starred' => false,
|
|
'important' => false,
|
|
'feed' => false,
|
|
'category' => false,
|
|
'tag' => false,
|
|
'tags' => false,
|
|
'Z' => false,
|
|
];
|
|
|
|
public static string $next_get = 'a';
|
|
public static int $state = 0;
|
|
/** @var 'ASC'|'DESC' */
|
|
public static string $order = 'DESC';
|
|
/** @var 'id'|'date'|'link'|'title'|'rand' */
|
|
public static string $sort = 'id';
|
|
public static int $number = 0;
|
|
public static int $offset = 0;
|
|
public static FreshRSS_BooleanSearch $search;
|
|
/** @var numeric-string */
|
|
public static string $continuation_id = '0';
|
|
/** @var numeric-string */
|
|
public static string $id_max = '0';
|
|
public static int $sinceHours = 0;
|
|
public static bool $isCli = false;
|
|
|
|
/**
|
|
* @deprecated Will be made `private`; use `FreshRSS_Context::systemConf()` instead.
|
|
* @internal
|
|
*/
|
|
public static ?FreshRSS_SystemConfiguration $system_conf = null;
|
|
/**
|
|
* @deprecated Will be made `private`; use `FreshRSS_Context::userConf()` instead.
|
|
* @internal
|
|
*/
|
|
public static ?FreshRSS_UserConfiguration $user_conf = null;
|
|
|
|
/**
|
|
* Initialize the context for the global system.
|
|
*/
|
|
public static function initSystem(bool $reload = false): void {
|
|
if ($reload || FreshRSS_Context::$system_conf === null) {
|
|
//TODO: Keep in session what we need instead of always reloading from disk
|
|
FreshRSS_Context::$system_conf = FreshRSS_SystemConfiguration::init(DATA_PATH . '/config.php', FRESHRSS_PATH . '/config.default.php');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws FreshRSS_Context_Exception
|
|
*/
|
|
public static function &systemConf(): FreshRSS_SystemConfiguration {
|
|
if (FreshRSS_Context::$system_conf === null) {
|
|
throw new FreshRSS_Context_Exception('System configuration not initialised!');
|
|
}
|
|
return FreshRSS_Context::$system_conf;
|
|
}
|
|
|
|
public static function hasSystemConf(): bool {
|
|
return FreshRSS_Context::$system_conf !== null;
|
|
}
|
|
|
|
/**
|
|
* Initialize the context for the current user.
|
|
*/
|
|
public static function initUser(string $username = '', bool $userMustExist = true): void {
|
|
FreshRSS_Context::$user_conf = null;
|
|
if (!isset($_SESSION)) {
|
|
Minz_Session::init('FreshRSS');
|
|
}
|
|
|
|
Minz_Session::lock();
|
|
if ($username == '') {
|
|
$username = Minz_User::name() ?? '';
|
|
}
|
|
if (($username === Minz_User::INTERNAL_USER || FreshRSS_user_Controller::checkUsername($username)) &&
|
|
(!$userMustExist || FreshRSS_user_Controller::userExists($username))) {
|
|
try {
|
|
//TODO: Keep in session what we need instead of always reloading from disk
|
|
FreshRSS_Context::$user_conf = FreshRSS_UserConfiguration::init(
|
|
USERS_PATH . '/' . $username . '/config.php',
|
|
FRESHRSS_PATH . '/config-user.default.php');
|
|
|
|
Minz_User::change($username);
|
|
} catch (Exception $ex) {
|
|
Minz_Log::warning($ex->getMessage(), USERS_PATH . '/_/' . LOG_FILENAME);
|
|
}
|
|
}
|
|
if (FreshRSS_Context::$user_conf == null) {
|
|
Minz_Session::_params([
|
|
'loginOk' => false,
|
|
Minz_User::CURRENT_USER => false,
|
|
]);
|
|
}
|
|
Minz_Session::unlock();
|
|
|
|
if (FreshRSS_Context::$user_conf == null) {
|
|
return;
|
|
}
|
|
|
|
FreshRSS_Context::$search = new FreshRSS_BooleanSearch('');
|
|
|
|
//Legacy
|
|
$oldEntries = FreshRSS_Context::$user_conf->param('old_entries', 0);
|
|
$oldEntries = is_numeric($oldEntries) ? (int)$oldEntries : 0;
|
|
$keepMin = FreshRSS_Context::$user_conf->param('keep_history_default', -5);
|
|
$keepMin = is_numeric($keepMin) ? (int)$keepMin : -5;
|
|
if ($oldEntries > 0 || $keepMin > -5) { //Freshrss < 1.15
|
|
$archiving = FreshRSS_Context::$user_conf->archiving;
|
|
$archiving['keep_max'] = false;
|
|
if ($oldEntries > 0) {
|
|
$archiving['keep_period'] = 'P' . $oldEntries . 'M';
|
|
}
|
|
if ($keepMin > 0) {
|
|
$archiving['keep_min'] = $keepMin;
|
|
} elseif ($keepMin == -1) { //Infinite
|
|
$archiving['keep_period'] = false;
|
|
$archiving['keep_min'] = false;
|
|
}
|
|
FreshRSS_Context::$user_conf->archiving = $archiving;
|
|
}
|
|
|
|
//Legacy < 1.16.1
|
|
if (!in_array(FreshRSS_Context::$user_conf->display_categories, [ 'active', 'remember', 'all', 'none' ], true)) {
|
|
FreshRSS_Context::$user_conf->display_categories = FreshRSS_Context::$user_conf->display_categories === true ? 'all' : 'active';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws FreshRSS_Context_Exception
|
|
*/
|
|
public static function &userConf(): FreshRSS_UserConfiguration {
|
|
if (FreshRSS_Context::$user_conf === null) {
|
|
throw new FreshRSS_Context_Exception('User configuration not initialised!');
|
|
}
|
|
return FreshRSS_Context::$user_conf;
|
|
}
|
|
|
|
public static function hasUserConf(): bool {
|
|
return FreshRSS_Context::$user_conf !== null;
|
|
}
|
|
|
|
public static function clearUserConf(): void {
|
|
FreshRSS_Context::$user_conf = null;
|
|
}
|
|
|
|
/** @return array<int,FreshRSS_Category> where the key is the category ID */
|
|
public static function categories(): array {
|
|
if (empty(self::$categories)) {
|
|
$catDAO = FreshRSS_Factory::createCategoryDao();
|
|
self::$categories = $catDAO->listSortedCategories(true, false);
|
|
}
|
|
return self::$categories;
|
|
}
|
|
|
|
/** @return array<int,FreshRSS_Feed> where the key is the feed ID */
|
|
public static function feeds(): array {
|
|
return FreshRSS_Category::findFeeds(self::categories());
|
|
}
|
|
|
|
/** @return array<int,FreshRSS_Tag> where the key is the label ID */
|
|
public static function labels(bool $precounts = false): array {
|
|
if (empty(self::$tags) || $precounts) {
|
|
$tagDAO = FreshRSS_Factory::createTagDao();
|
|
self::$tags = $tagDAO->listTags($precounts) ?: [];
|
|
}
|
|
return self::$tags;
|
|
}
|
|
|
|
/**
|
|
* This action updates the Context object by using request parameters.
|
|
*
|
|
* HTTP GET request parameters are:
|
|
* - state (default: conf->default_view)
|
|
* - search (default: empty string)
|
|
* - order (default: conf->sort_order)
|
|
* - nb (default: conf->posts_per_page)
|
|
* - next (default: empty string)
|
|
* - hours (default: 0)
|
|
* @throws FreshRSS_Context_Exception
|
|
* @throws Minz_ConfigurationNamespaceException
|
|
* @throws Minz_PDOConnectionException
|
|
*/
|
|
public static function updateUsingRequest(bool $computeStatistics): void {
|
|
if ($computeStatistics && self::$total_unread === 0) {
|
|
// Update number of read / unread variables.
|
|
$entryDAO = FreshRSS_Factory::createEntryDao();
|
|
self::$total_starred = $entryDAO->countUnreadReadFavorites();
|
|
self::$total_unread = FreshRSS_Category::countUnread(self::categories(), FreshRSS_Feed::PRIORITY_MAIN_STREAM);
|
|
self::$total_important_unread = FreshRSS_Category::countUnread(self::categories(), FreshRSS_Feed::PRIORITY_IMPORTANT);
|
|
}
|
|
|
|
self::_get(Minz_Request::paramString('get') ?: 'a');
|
|
|
|
self::$state = Minz_Request::paramInt('state') ?: FreshRSS_Context::userConf()->default_state;
|
|
$state_forced_by_user = Minz_Request::paramString('state', true) !== '';
|
|
if (!$state_forced_by_user) {
|
|
if (FreshRSS_Context::userConf()->show_fav_unread && (self::isCurrentGet('s') || self::isCurrentGet('T') || self::isTag())) {
|
|
self::$state = FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_READ;
|
|
} elseif (FreshRSS_Context::userConf()->default_view === 'all') {
|
|
self::$state = FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_READ;
|
|
} elseif (FreshRSS_Context::userConf()->default_view === 'unread_or_favorite') {
|
|
self::$state = FreshRSS_Entry::STATE_OR_NOT_READ | FreshRSS_Entry::STATE_OR_FAVORITE;
|
|
} elseif (FreshRSS_Context::userConf()->default_view === 'adaptive' && self::$get_unread <= 0) {
|
|
self::$state = FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_READ;
|
|
}
|
|
}
|
|
|
|
self::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'));
|
|
$order = Minz_Request::paramString('order', true) ?: FreshRSS_Context::userConf()->sort_order;
|
|
self::$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'DESC';
|
|
$sort = Minz_Request::paramString('sort', true) ?: FreshRSS_Context::userConf()->sort;
|
|
self::$sort = in_array($sort, ['id', 'date', 'link', 'title', 'rand'], true) ? $sort : 'id';
|
|
self::$number = Minz_Request::paramInt('nb') ?: FreshRSS_Context::userConf()->posts_per_page;
|
|
if (self::$number > FreshRSS_Context::userConf()->max_posts_per_rss) {
|
|
self::$number = max(
|
|
FreshRSS_Context::userConf()->max_posts_per_rss,
|
|
FreshRSS_Context::userConf()->posts_per_page);
|
|
}
|
|
self::$offset = Minz_Request::paramInt('offset');
|
|
$id_max = Minz_Request::paramString('idMax', true);
|
|
self::$id_max = ctype_digit($id_max) ? $id_max : '0';
|
|
$continuation_id = Minz_Request::paramString('cid', true);
|
|
self::$continuation_id = ctype_digit($continuation_id) ? $continuation_id : '0';
|
|
self::$sinceHours = Minz_Request::paramInt('hours');
|
|
}
|
|
|
|
/**
|
|
* Returns if the current state includes $state parameter.
|
|
*/
|
|
public static function isStateEnabled(int $state): int {
|
|
return self::$state & $state;
|
|
}
|
|
|
|
/**
|
|
* Returns the current state with or without $state parameter.
|
|
*/
|
|
public static function getRevertState(int $state): int {
|
|
if (self::$state & $state) {
|
|
return self::$state & ~$state;
|
|
}
|
|
return self::$state | $state;
|
|
}
|
|
|
|
/**
|
|
* Return the current get as a string or an array.
|
|
*
|
|
* If $array is true, the first item of the returned value is 'f' or 'c' or 't' and the second is the id.
|
|
* @phpstan-return ($asArray is true ? array{'a'|'A'|'c'|'f'|'i'|'s'|'t'|'T'|'Z',bool|int} : string)
|
|
* @return string|array{string,bool|int}
|
|
*/
|
|
public static function currentGet(bool $asArray = false): string|array {
|
|
if (self::$current_get['all']) {
|
|
return $asArray ? ['a', true] : 'a';
|
|
} elseif (self::$current_get['A']) {
|
|
return $asArray ? ['A', true] : 'A';
|
|
} elseif (self::$current_get['important']) {
|
|
return $asArray ? ['i', true] : 'i';
|
|
} elseif (self::$current_get['starred']) {
|
|
return $asArray ? ['s', true] : 's';
|
|
} elseif (self::$current_get['feed']) {
|
|
if ($asArray) {
|
|
return ['f', self::$current_get['feed']];
|
|
} else {
|
|
return 'f_' . self::$current_get['feed'];
|
|
}
|
|
} elseif (self::$current_get['category']) {
|
|
if ($asArray) {
|
|
return ['c', self::$current_get['category']];
|
|
} else {
|
|
return 'c_' . self::$current_get['category'];
|
|
}
|
|
} elseif (self::$current_get['tag']) {
|
|
if ($asArray) {
|
|
return ['t', self::$current_get['tag']];
|
|
} else {
|
|
return 't_' . self::$current_get['tag'];
|
|
}
|
|
} elseif (self::$current_get['tags']) {
|
|
return $asArray ? ['T', true] : 'T';
|
|
} elseif (self::$current_get['Z']) {
|
|
return $asArray ? ['Z', true] : 'Z';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* @return bool true if the current request targets all feeds (main view), false otherwise.
|
|
*/
|
|
public static function isAll(): bool {
|
|
return self::$current_get['all'] != false;
|
|
}
|
|
|
|
public static function isAllAndCategories(): bool {
|
|
return self::$current_get['A'] != false;
|
|
}
|
|
|
|
public static function isAllAndArchived(): bool {
|
|
return self::$current_get['Z'] != false;
|
|
}
|
|
|
|
/**
|
|
* @return bool true if the current request targets important feeds, false otherwise.
|
|
*/
|
|
public static function isImportant(): bool {
|
|
return self::$current_get['important'] != false;
|
|
}
|
|
|
|
/**
|
|
* @return bool true if the current request targets a category, false otherwise.
|
|
*/
|
|
public static function isCategory(): bool {
|
|
return self::$current_get['category'] != false;
|
|
}
|
|
|
|
/**
|
|
* @return bool true if the current request targets a feed (and not a category or all articles), false otherwise.
|
|
*/
|
|
public static function isFeed(): bool {
|
|
return self::$current_get['feed'] != false;
|
|
}
|
|
|
|
/**
|
|
* @return bool true if the current request targets a tag (though not all tags), false otherwise.
|
|
*/
|
|
public static function isTag(): bool {
|
|
return self::$current_get['tag'] != false;
|
|
}
|
|
|
|
/**
|
|
* @return bool whether $get parameter corresponds to the $current_get attribute.
|
|
*/
|
|
public static function isCurrentGet(string $get): bool {
|
|
$type = substr($get, 0, 1);
|
|
$id = substr($get, 2);
|
|
|
|
return match ($type) {
|
|
'a' => self::$current_get['all'],
|
|
'A' => self::$current_get['A'],
|
|
'i' => self::$current_get['important'],
|
|
's' => self::$current_get['starred'],
|
|
'f' => self::$current_get['feed'] == $id,
|
|
'c' => self::$current_get['category'] == $id,
|
|
't' => self::$current_get['tag'] == $id,
|
|
'T' => self::$current_get['tags'] || self::$current_get['tag'],
|
|
'Z' => self::$current_get['Z'],
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set the current $get attribute.
|
|
*
|
|
* Valid $get parameter are:
|
|
* - a
|
|
* - s
|
|
* - f_<feed id>
|
|
* - c_<category id>
|
|
* - t_<tag id>
|
|
*
|
|
* $name and $get_unread attributes are also updated as $next_get
|
|
* Raise an exception if id or $get is invalid.
|
|
* @throws FreshRSS_Context_Exception
|
|
* @throws Minz_ConfigurationNamespaceException
|
|
* @throws Minz_PDOConnectionException
|
|
*/
|
|
public static function _get(string $get): void {
|
|
$type = $get[0];
|
|
$id = (int)substr($get, 2);
|
|
|
|
if (empty(self::$categories)) {
|
|
$catDAO = FreshRSS_Factory::createCategoryDao();
|
|
$details = $type === 'f'; // Load additional feed details in the case of feed view
|
|
self::$categories = $catDAO->listCategories(true, $details);
|
|
}
|
|
|
|
switch ($type) {
|
|
case 'a': // All PRIORITY_MAIN_STREAM
|
|
self::$current_get['all'] = true;
|
|
self::$description = FreshRSS_Context::systemConf()->meta_description;
|
|
self::$get_unread = self::$total_unread;
|
|
break;
|
|
case 'A': // All except PRIORITY_ARCHIVED
|
|
self::$current_get['A'] = true;
|
|
self::$description = FreshRSS_Context::systemConf()->meta_description;
|
|
self::$get_unread = self::$total_unread;
|
|
break;
|
|
case 'Z': // All including PRIORITY_ARCHIVED
|
|
self::$current_get['Z'] = true;
|
|
self::$name = _t('index.feed.title');
|
|
self::$description = FreshRSS_Context::systemConf()->meta_description;
|
|
self::$get_unread = self::$total_unread;
|
|
break;
|
|
case 'i': // Priority important feeds
|
|
self::$current_get['important'] = true;
|
|
self::$name = _t('index.menu.important');
|
|
self::$description = FreshRSS_Context::systemConf()->meta_description;
|
|
self::$get_unread = self::$total_unread;
|
|
break;
|
|
case 's':
|
|
self::$current_get['starred'] = true;
|
|
self::$name = _t('index.feed.title_fav');
|
|
self::$description = FreshRSS_Context::systemConf()->meta_description;
|
|
self::$get_unread = self::$total_starred['unread'];
|
|
// Update state if favorite is not yet enabled.
|
|
self::$state = self::$state | FreshRSS_Entry::STATE_FAVORITE;
|
|
break;
|
|
case 'f':
|
|
// We try to find the corresponding feed. When allowing robots, always retrieve the full feed including description
|
|
$feed = FreshRSS_Context::systemConf()->allow_robots ? null : FreshRSS_Category::findFeed(self::$categories, $id);
|
|
if ($feed === null) {
|
|
throw new FreshRSS_Context_Exception('Invalid feed: ' . $id);
|
|
}
|
|
self::$current_get['feed'] = $id;
|
|
self::$current_get['category'] = $feed->categoryId();
|
|
self::$name = $feed->name();
|
|
self::$description = $feed->description();
|
|
self::$get_unread = $feed->nbNotRead();
|
|
break;
|
|
case 'c':
|
|
// We try to find the corresponding category.
|
|
self::$current_get['category'] = $id;
|
|
$cat = null;
|
|
foreach (self::$categories as $category) {
|
|
if ($category->id() === $id) {
|
|
$cat = $category;
|
|
break;
|
|
}
|
|
}
|
|
if ($cat === null) {
|
|
throw new FreshRSS_Context_Exception('Invalid category: ' . $id);
|
|
}
|
|
self::$name = $cat->name();
|
|
self::$get_unread = $cat->nbNotRead();
|
|
break;
|
|
case 't':
|
|
// We try to find the corresponding tag.
|
|
self::$current_get['tag'] = $id;
|
|
$tag = null;
|
|
foreach (self::$tags as $t) {
|
|
if ($t->id() === $id) {
|
|
$tag = $t;
|
|
break;
|
|
}
|
|
}
|
|
if ($tag === null) {
|
|
$tagDAO = FreshRSS_Factory::createTagDao();
|
|
$tag = $tagDAO->searchById($id);
|
|
if ($tag === null) {
|
|
throw new FreshRSS_Context_Exception('Invalid tag: ' . $id);
|
|
}
|
|
}
|
|
self::$name = $tag->name();
|
|
self::$get_unread = $tag->nbUnread();
|
|
break;
|
|
case 'T':
|
|
$tagDAO = FreshRSS_Factory::createTagDao();
|
|
self::$current_get['tags'] = true;
|
|
self::$name = _t('index.menu.tags');
|
|
self::$get_unread = $tagDAO->countNotRead();
|
|
break;
|
|
default:
|
|
throw new FreshRSS_Context_Exception('Invalid getter: ' . $get);
|
|
}
|
|
|
|
self::_nextGet();
|
|
}
|
|
|
|
/**
|
|
* Set the value of $next_get attribute.
|
|
*/
|
|
private static function _nextGet(): void {
|
|
$get = self::currentGet();
|
|
// By default, $next_get == $get
|
|
self::$next_get = $get;
|
|
|
|
if (empty(self::$categories)) {
|
|
$catDAO = FreshRSS_Factory::createCategoryDao();
|
|
self::$categories = $catDAO->listCategories(true);
|
|
}
|
|
|
|
if (FreshRSS_Context::userConf()->onread_jump_next && strlen($get) > 2) {
|
|
$another_unread_id = '';
|
|
$found_current_get = false;
|
|
switch ($get[0]) {
|
|
case 'f':
|
|
// We search the next unread feed with the following priorities: next in same category, or previous in same category, or next, or previous.
|
|
foreach (self::$categories as $cat) {
|
|
$sameCat = false;
|
|
foreach ($cat->feeds() as $feed) {
|
|
if ($found_current_get) {
|
|
if ($feed->nbNotRead() > 0) {
|
|
$another_unread_id = $feed->id();
|
|
break 2;
|
|
}
|
|
} elseif ($feed->id() == self::$current_get['feed']) {
|
|
$found_current_get = true;
|
|
} elseif ($feed->nbNotRead() > 0) {
|
|
$another_unread_id = $feed->id();
|
|
$sameCat = true;
|
|
}
|
|
}
|
|
if ($found_current_get && $sameCat) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If there is no more unread feed, show main stream
|
|
self::$next_get = $another_unread_id == '' ? 'a' : 'f_' . $another_unread_id;
|
|
break;
|
|
case 'c':
|
|
// We search the next category with at least one unread article.
|
|
foreach (self::$categories as $cat) {
|
|
if ($cat->id() == self::$current_get['category']) {
|
|
// Here is our current category! Next one could be our
|
|
// champion if it has unread articles.
|
|
$found_current_get = true;
|
|
continue;
|
|
}
|
|
|
|
if ($cat->nbNotRead() > 0) {
|
|
$another_unread_id = $cat->id();
|
|
if ($found_current_get) {
|
|
// Unread articles and the current category has
|
|
// already been found? Leave the loop!
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there is no more unread category, show main stream
|
|
self::$next_get = $another_unread_id == '' ? 'a' : 'c_' . $another_unread_id;
|
|
break;
|
|
case 't':
|
|
// We can't know what the next unread tag is because entries can be in multiple tags
|
|
// so marking all entries in a tag can indirectly mark all entries in multiple tags.
|
|
// Default is to return to the current tag, so mark it as next_get = 'a' instead when
|
|
// userconf -> onread_jump_next so the readAction knows to jump to the next unread
|
|
// tag.
|
|
self::$next_get = 'a';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if the auto remove is available in the current context.
|
|
* This feature is available if:
|
|
* - it is activated in the configuration
|
|
* - the "read" state is not enable
|
|
* - the "unread" state is enable
|
|
*/
|
|
public static function isAutoRemoveAvailable(): bool {
|
|
return FreshRSS_Context::userConf()->auto_remove_article && !self::isStateEnabled(FreshRSS_Entry::STATE_READ) &&
|
|
(self::isStateEnabled(FreshRSS_Entry::STATE_NOT_READ) || self::isStateEnabled(FreshRSS_Entry::STATE_OR_NOT_READ));
|
|
}
|
|
|
|
/**
|
|
* Determine if the "sticky post" option is enabled. It can be enable
|
|
* by the user when it is selected in the configuration page or by the
|
|
* application when the context allows to auto-remove articles when they
|
|
* are read.
|
|
*/
|
|
public static function isStickyPostEnabled(): bool {
|
|
if (FreshRSS_Context::userConf()->sticky_post) {
|
|
return true;
|
|
}
|
|
if (self::isAutoRemoveAvailable()) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public static function defaultTimeZone(): string {
|
|
$timezone = ini_get('date.timezone');
|
|
return $timezone != false ? $timezone : 'UTC';
|
|
}
|
|
}
|