mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-02-02 09:31:06 -05:00
* 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>
452 lines
15 KiB
PHP
452 lines
15 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* This class handles main actions of FreshRSS.
|
|
*/
|
|
class FreshRSS_index_Controller extends FreshRSS_ActionController {
|
|
|
|
#[\Override]
|
|
public function firstAction(): void {
|
|
$this->view->html_url = Minz_Url::display(['c' => 'index', 'a' => 'index'], 'html', 'root');
|
|
}
|
|
|
|
/**
|
|
* This action only redirect on the default view mode (normal or global)
|
|
*/
|
|
public function indexAction(): void {
|
|
$preferred_output = FreshRSS_Context::userConf()->view_mode;
|
|
$viewMode = FreshRSS_ViewMode::getAllModes()[$preferred_output] ?? null;
|
|
|
|
// Fallback to 'normal' if the preferred mode was not found
|
|
if ($viewMode === null) {
|
|
Minz_Request::setBadNotification(_t('feedback.extensions.invalid_view_mode', $preferred_output));
|
|
$viewMode = FreshRSS_ViewMode::getAllModes()['normal'];
|
|
}
|
|
|
|
Minz_Request::forward([
|
|
'c' => $viewMode->controller(),
|
|
'a' => $viewMode->action(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return '.future'|'.today'|'.yesterday'|''
|
|
*/
|
|
private static function dayRelative(int $timestamp, bool $mayBeFuture): string {
|
|
static $today = null;
|
|
if (!is_int($today)) {
|
|
$today = strtotime('today') ?: 0;
|
|
}
|
|
if ($today <= 0) {
|
|
return '';
|
|
} elseif ($mayBeFuture && ($timestamp >= $today + 86400)) {
|
|
return '.future';
|
|
} elseif ($timestamp >= $today) {
|
|
return '.today';
|
|
} elseif ($timestamp >= $today - 86400) {
|
|
return '.yesterday';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Content for displaying a transition between entries when sorting by specific criteria.
|
|
*/
|
|
public static function transition(FreshRSS_Entry $entry): string {
|
|
return match (FreshRSS_Context::$sort) {
|
|
'id' => _t('index.feed.received' . self::dayRelative($entry->dateAdded(raw: true), mayBeFuture: false)) .
|
|
' — ' . timestamptodate($entry->dateAdded(raw: true), hour: false),
|
|
'date' => _t('index.feed.published' . self::dayRelative($entry->date(raw: true), mayBeFuture: true)) .
|
|
' — ' . timestamptodate($entry->date(raw: true), hour: false),
|
|
'lastUserModified' => _t('index.feed.userModified' . self::dayRelative($entry->lastUserModified(), mayBeFuture: false)) .
|
|
' — ' . timestamptodate($entry->lastUserModified(), hour: false),
|
|
'c.name' => $entry->feed()?->category()?->name() ?? '',
|
|
'f.name' => $entry->feed()?->name() ?? '',
|
|
default => '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Produce a hyperlink to the next transition of entries.
|
|
*/
|
|
public static function transitionLink(FreshRSS_Entry $entry, int $offset = 0): string {
|
|
if (in_array(FreshRSS_Context::$sort, ['c.name', 'f.name'], true)) {
|
|
return Minz_Url::display(Minz_Request::modifiedCurrentRequest([
|
|
'get' => match (FreshRSS_Context::$sort) {
|
|
'c.name' => 'c_' . ($entry->feed()?->category()?->id() ?? '0'),
|
|
'f.name' => 'f_' . ($entry->feed()?->id() ?? '0'),
|
|
},
|
|
]));
|
|
}
|
|
$operator = match (FreshRSS_Context::$sort) {
|
|
'id' => 'date',
|
|
'date' => 'pubdate',
|
|
'lastUserModified' => 'userdate',
|
|
default => throw new InvalidArgumentException('Unsupported sort criterion for transition: ' . FreshRSS_Context::$sort),
|
|
};
|
|
$offset = FreshRSS_Context::$order === 'ASC' ? $offset : -$offset;
|
|
$timestamp = match (FreshRSS_Context::$sort) {
|
|
'id' => $entry->dateAdded(raw: true),
|
|
'date' => $entry->date(raw: true),
|
|
'lastUserModified' => $entry->lastUserModified(),
|
|
default => throw new InvalidArgumentException('Unsupported sort criterion for transition: ' . FreshRSS_Context::$sort),
|
|
};
|
|
$searchString = $operator . ':' . ($offset < 0 ? '/' : '') . date('Y-m-d', $timestamp + ($offset * 86400)) . ($offset > 0 ? '/' : '');
|
|
return Minz_Url::display(Minz_Request::modifiedCurrentRequest([
|
|
'search' => FreshRSS_Context::$search->toString() === '' ? $searchString :
|
|
FreshRSS_Context::$search->enforce(new FreshRSS_Search($searchString))->toString(),
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* This action displays the normal view of FreshRSS.
|
|
*/
|
|
public function normalAction(): void {
|
|
$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
|
|
if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) {
|
|
Minz_Request::forward(['c' => 'auth', 'a' => 'login']);
|
|
return;
|
|
}
|
|
|
|
$id = Minz_Request::paramInt('id');
|
|
if ($id !== 0) {
|
|
$view = Minz_Request::paramString('a');
|
|
$url_redirect = ['c' => 'subscription', 'a' => 'feed', 'params' => ['id' => (string)$id, 'from' => $view]];
|
|
Minz_Request::forward($url_redirect, true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
FreshRSS_Context::updateUsingRequest(true);
|
|
} catch (FreshRSS_Context_Exception $e) {
|
|
Minz_Error::error(404);
|
|
}
|
|
|
|
$this->_csp([
|
|
'default-src' => "'self'",
|
|
'frame-src' => '*',
|
|
'img-src' => '* data: blob:',
|
|
'frame-ancestors' => FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'",
|
|
'media-src' => '*',
|
|
]);
|
|
|
|
$this->view->categories = FreshRSS_Context::categories();
|
|
|
|
$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
|
|
$title = FreshRSS_Context::$name;
|
|
$search = FreshRSS_Context::$search->toString(expandUserQueries: false);
|
|
if ($search !== '') {
|
|
$title = '“' . htmlspecialchars($search, ENT_COMPAT, 'UTF-8') . '”';
|
|
}
|
|
if (FreshRSS_Context::$get_unread > 0) {
|
|
$title = '(' . FreshRSS_Context::$get_unread . ') ' . $title;
|
|
}
|
|
FreshRSS_View::prependTitle($title . ' · ');
|
|
|
|
if (FreshRSS_Context::$id_max === '0') {
|
|
FreshRSS_Context::$id_max = uTimeString();
|
|
}
|
|
|
|
$this->view->callbackBeforeFeeds = static function (FreshRSS_View $view) {
|
|
$view->nbUnreadTags = 0;
|
|
if (Minz_Request::paramBoolean('ajax')) {
|
|
// Disable label counts for AJAX requests: faster and not needed
|
|
$view->tags = FreshRSS_Context::labels(precounts: false);
|
|
return;
|
|
}
|
|
$view->tags = FreshRSS_Context::labels(precounts: true);
|
|
foreach ($view->tags as $tag) {
|
|
$view->nbUnreadTags += $tag->nbUnread();
|
|
}
|
|
};
|
|
|
|
$this->view->callbackBeforeEntries = static function (FreshRSS_View $view) {
|
|
try {
|
|
// +1 to account for paging logic
|
|
$view->entries = FreshRSS_index_Controller::listEntriesByContext(FreshRSS_Context::$number + 1);
|
|
ob_start(); //Buffer "one entry at a time"
|
|
} catch (FreshRSS_EntriesGetter_Exception $e) {
|
|
Minz_Log::notice($e->getMessage());
|
|
Minz_Error::error(404);
|
|
}
|
|
};
|
|
|
|
$this->view->callbackBeforePagination = static function (?FreshRSS_View $view, int $nbEntries, FreshRSS_Entry $lastEntry) {
|
|
if ($nbEntries > FreshRSS_Context::$number) {
|
|
//We have enough entries: we discard the last one to use it for the next articles' page
|
|
ob_clean();
|
|
FreshRSS_Context::$continuation_id = $lastEntry->id();
|
|
} else {
|
|
FreshRSS_Context::$continuation_id = '0';
|
|
}
|
|
ob_end_flush();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* This action displays the reader view of FreshRSS.
|
|
*
|
|
* @todo: change this view into specific CSS rules?
|
|
*/
|
|
public function readerAction(): void {
|
|
$this->normalAction();
|
|
}
|
|
|
|
/**
|
|
* This action displays the global view of FreshRSS.
|
|
*/
|
|
public function globalAction(): void {
|
|
$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
|
|
if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) {
|
|
Minz_Request::forward(['c' => 'auth', 'a' => 'login']);
|
|
return;
|
|
}
|
|
|
|
FreshRSS_View::appendScript(Minz_Url::display('/scripts/extra.js?' . @filemtime(PUBLIC_PATH . '/scripts/extra.js')));
|
|
FreshRSS_View::appendScript(Minz_Url::display('/scripts/global_view.js?' . @filemtime(PUBLIC_PATH . '/scripts/global_view.js')));
|
|
|
|
try {
|
|
FreshRSS_Context::updateUsingRequest(true);
|
|
} catch (FreshRSS_Context_Exception) {
|
|
Minz_Error::error(404);
|
|
}
|
|
|
|
$this->view->categories = FreshRSS_Context::categories();
|
|
|
|
$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
|
|
$title = _t('index.feed.title_global');
|
|
if (FreshRSS_Context::$get_unread > 0) {
|
|
$title = '(' . FreshRSS_Context::$get_unread . ') ' . $title;
|
|
}
|
|
FreshRSS_View::prependTitle($title . ' · ');
|
|
|
|
$this->_csp([
|
|
'default-src' => "'self'",
|
|
'frame-src' => '*',
|
|
'img-src' => '* data: blob:',
|
|
'frame-ancestors' => FreshRSS_Context::systemConf()->attributeString('csp.frame-ancestors') ?? "'none'",
|
|
'media-src' => '*',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* This action displays the RSS feed of FreshRSS.
|
|
* @deprecated See user query RSS sharing instead
|
|
*/
|
|
public function rssAction(): void {
|
|
$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
|
|
|
|
// Check if user has access.
|
|
if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous && !Minz_Request::tokenIsOk()) {
|
|
Minz_Error::error(403, redirect: false);
|
|
}
|
|
|
|
try {
|
|
FreshRSS_Context::updateUsingRequest(false);
|
|
} catch (FreshRSS_Context_Exception $e) {
|
|
Minz_Error::error(404);
|
|
}
|
|
|
|
try {
|
|
$this->view->entries = FreshRSS_index_Controller::listEntriesByContext();
|
|
} catch (FreshRSS_EntriesGetter_Exception $e) {
|
|
Minz_Log::notice($e->getMessage());
|
|
Minz_Error::error(404);
|
|
}
|
|
|
|
$this->view->html_url = Minz_Url::display('', 'html', true);
|
|
$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
|
|
|
|
$queryString = $_SERVER['QUERY_STRING'] ?? '';
|
|
$this->view->rss_url = htmlspecialchars(
|
|
PUBLIC_TO_INDEX_PATH . '/' . ($queryString === '' || !is_string($queryString) ? '' : '?' . $queryString), ENT_COMPAT, 'UTF-8');
|
|
|
|
// No layout for RSS output.
|
|
$this->view->_layout(null);
|
|
header('Content-Type: application/rss+xml; charset=utf-8');
|
|
}
|
|
|
|
public function opmlAction(): void {
|
|
$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
|
|
|
|
// Check if user has access.
|
|
if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous && !Minz_Request::tokenIsOk()) {
|
|
Minz_Error::error(403, redirect: false);
|
|
}
|
|
|
|
try {
|
|
FreshRSS_Context::updateUsingRequest(false);
|
|
} catch (FreshRSS_Context_Exception) {
|
|
Minz_Error::error(404);
|
|
}
|
|
|
|
$get = FreshRSS_Context::currentGet(true);
|
|
$type = (string)$get[0];
|
|
$id = (int)$get[1];
|
|
|
|
$this->view->excludeMutedFeeds = $type !== 'f'; // Exclude muted feeds except when we focus on a feed
|
|
|
|
switch ($type) {
|
|
case 'a': // All PRIORITY_MAIN_STREAM
|
|
case 'A': // All except PRIORITY_HIDDEN
|
|
case 'Z': // All including PRIORITY_HIDDEN
|
|
$this->view->categories = FreshRSS_Context::categories();
|
|
break;
|
|
case 'c':
|
|
$cat = FreshRSS_Context::categories()[$id] ?? null;
|
|
if ($cat == null) {
|
|
Minz_Error::error(404);
|
|
return;
|
|
}
|
|
$this->view->categories = [$cat->id() => $cat];
|
|
break;
|
|
case 'f':
|
|
// We most likely already have the feed object in cache
|
|
$feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $id);
|
|
if ($feed === null) {
|
|
$feedDAO = FreshRSS_Factory::createFeedDao();
|
|
$feed = $feedDAO->searchById($id);
|
|
if ($feed == null) {
|
|
Minz_Error::error(404);
|
|
return;
|
|
}
|
|
}
|
|
$this->view->feeds = [$feed->id() => $feed];
|
|
break;
|
|
case 's':
|
|
case 't':
|
|
case 'T':
|
|
default:
|
|
Minz_Error::error(404);
|
|
return;
|
|
}
|
|
|
|
// No layout for OPML output.
|
|
$this->view->_layout(null);
|
|
header('Content-Type: application/xml; charset=utf-8');
|
|
}
|
|
|
|
/**
|
|
* This method returns a list of entries based on the Context object.
|
|
* @param int $postsPerPage override `FreshRSS_Context::$number`
|
|
* @return Traversable<FreshRSS_Entry>
|
|
* @throws FreshRSS_EntriesGetter_Exception
|
|
*/
|
|
public static function listEntriesByContext(?int $postsPerPage = null): Traversable {
|
|
$entryDAO = FreshRSS_Factory::createEntryDao();
|
|
|
|
$get = FreshRSS_Context::currentGet(true);
|
|
if (is_array($get)) {
|
|
$type = $get[0];
|
|
$id = (int)($get[1]);
|
|
} else {
|
|
$type = $get;
|
|
$id = 0;
|
|
}
|
|
|
|
$id_min = '0';
|
|
if (FreshRSS_Context::$sinceHours > 0) {
|
|
$id_min = (time() - (FreshRSS_Context::$sinceHours * 3600)) . '000000';
|
|
}
|
|
|
|
$continuation_values = [];
|
|
if (FreshRSS_Context::$continuation_id !== '0') {
|
|
if (in_array(FreshRSS_Context::$sort, ['c.name', 'date', 'f.name', 'link', 'title', 'lastUserModified', 'length'], true)) {
|
|
$pagingEntry = $entryDAO->searchById(FreshRSS_Context::$continuation_id);
|
|
|
|
if ($pagingEntry !== null && in_array(FreshRSS_Context::$sort, ['c.name', 'f.name'], true)) {
|
|
// We most likely already have the feed object in cache
|
|
$feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $pagingEntry->feedId());
|
|
if ($feed !== null) {
|
|
$pagingEntry->_feed($feed);
|
|
}
|
|
}
|
|
|
|
$continuation_values[] = $pagingEntry === null ? 0 : match (FreshRSS_Context::$sort) {
|
|
'c.name' => $pagingEntry->feed()?->categoryId() === FreshRSS_CategoryDAO::DEFAULTCATEGORYID ?
|
|
FreshRSS_CategoryDAO::DEFAULT_CATEGORY_NAME : $pagingEntry->feed()?->category()?->name() ?? '',
|
|
'date' => $pagingEntry->date(raw: true),
|
|
'f.name' => $pagingEntry->feed()?->name(raw: true) ?? '',
|
|
'link' => $pagingEntry->link(raw: true),
|
|
'title' => $pagingEntry->title(),
|
|
'lastUserModified' => $pagingEntry->lastUserModified(),
|
|
'length' => $pagingEntry->sqlContentLength() ?? 0,
|
|
};
|
|
if (FreshRSS_Context::$sort === 'c.name') {
|
|
// Internal secondary sort criterion for category name
|
|
$continuation_values[] = $pagingEntry?->feed()?->name(raw: true) ?? '';
|
|
}
|
|
if (in_array(FreshRSS_Context::$sort, ['c.name', 'f.name'], true)) {
|
|
// User secondary sort criterion
|
|
$continuation_values[] = $pagingEntry === null ? 0 : match (FreshRSS_Context::$secondary_sort) {
|
|
'id' => $pagingEntry->id(),
|
|
'date' => $pagingEntry->date(raw: true),
|
|
'link' => $pagingEntry->link(raw: true),
|
|
'title' => $pagingEntry->title(),
|
|
};
|
|
}
|
|
} elseif (FreshRSS_Context::$sort === 'rand') {
|
|
FreshRSS_Context::$continuation_id = '0';
|
|
}
|
|
}
|
|
|
|
foreach ($entryDAO->listWhere(
|
|
$type, $id, FreshRSS_Context::$state, FreshRSS_Context::$search,
|
|
id_min: $id_min, id_max: FreshRSS_Context::$id_max, sort: FreshRSS_Context::$sort, order: FreshRSS_Context::$order,
|
|
continuation_id: FreshRSS_Context::$continuation_id, continuation_values: $continuation_values,
|
|
limit: $postsPerPage ?? FreshRSS_Context::$number, offset: FreshRSS_Context::$offset,
|
|
secondary_sort: FreshRSS_Context::$secondary_sort, secondary_sort_order: FreshRSS_Context::$secondary_sort_order) as $entry) {
|
|
yield $entry;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This action displays the about page of FreshRSS.
|
|
*/
|
|
public function aboutAction(): void {
|
|
FreshRSS_View::prependTitle(_t('index.about.title') . ' · ');
|
|
}
|
|
|
|
/**
|
|
* This action displays the EULA/TOS (Terms of Service) page of FreshRSS.
|
|
* This page is enabled only if admin created a data/tos.html file.
|
|
* The content of the page is the content of data/tos.html.
|
|
* It returns 404 if there is no EULA/TOS.
|
|
*/
|
|
public function tosAction(): void {
|
|
$terms_of_service = file_get_contents(TOS_FILENAME);
|
|
if ($terms_of_service === false) {
|
|
Minz_Error::error(404);
|
|
return;
|
|
}
|
|
|
|
$this->view->terms_of_service = $terms_of_service;
|
|
$this->view->can_register = !FreshRSS_user_Controller::max_registrations_reached();
|
|
FreshRSS_View::prependTitle(_t('index.tos.title') . ' · ');
|
|
}
|
|
|
|
/**
|
|
* This action displays logs of FreshRSS for the current user.
|
|
*/
|
|
public function logsAction(): void {
|
|
if (!FreshRSS_Auth::hasAccess()) {
|
|
Minz_Error::error(403);
|
|
}
|
|
|
|
FreshRSS_View::prependTitle(_t('index.log.title') . ' · ');
|
|
|
|
if (Minz_Request::isPost()) {
|
|
FreshRSS_LogDAO::truncate();
|
|
}
|
|
|
|
$logs = FreshRSS_LogDAO::lines(); //TODO: ask only the necessary lines
|
|
|
|
//gestion pagination
|
|
$page = Minz_Request::paramInt('page') ?: 1;
|
|
$this->view->logsPaginator = new Minz_Paginator($logs);
|
|
$this->view->logsPaginator->_nbItemsPerPage(50);
|
|
$this->view->logsPaginator->_currentPage($page);
|
|
}
|
|
}
|